265 lines
11 KiB
Markdown
265 lines
11 KiB
Markdown
---
|
|
phase: 44-video-presentations
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["44-01"]
|
|
files_modified:
|
|
- ui/src/hooks/useContentJob.ts
|
|
- ui/src/components/PresentationPanel.tsx
|
|
- ui/src/pages/ContentStudio.tsx
|
|
autonomous: true
|
|
requirements:
|
|
- PRES-01
|
|
- PRES-02
|
|
- PRES-03
|
|
- PRES-04
|
|
|
|
must_haves:
|
|
truths:
|
|
- "User can enter a prompt and select pitch-deck or demo-video type to generate a presentation"
|
|
- "Progress bar updates in real-time during Remotion render via SSE content_job.progress events"
|
|
- "Completed presentation shows an MP4 video player and download button"
|
|
- "ContentStudio has a Presentations tab that renders PresentationPanel"
|
|
artifacts:
|
|
- path: "ui/src/components/PresentationPanel.tsx"
|
|
provides: "Presentation generation UI with prompt, type selector, progress, video player"
|
|
min_lines: 80
|
|
- path: "ui/src/hooks/useContentJob.ts"
|
|
provides: "Extended SSE handler that reads data.progress for fine-grained progress"
|
|
contains: "data.progress"
|
|
- path: "ui/src/pages/ContentStudio.tsx"
|
|
provides: "Presentations tab in Content Studio"
|
|
contains: "presentations"
|
|
key_links:
|
|
- from: "ui/src/components/PresentationPanel.tsx"
|
|
to: "ui/src/hooks/useContentJob.ts"
|
|
via: "useContentJob hook"
|
|
pattern: "useContentJob"
|
|
- from: "ui/src/pages/ContentStudio.tsx"
|
|
to: "ui/src/components/PresentationPanel.tsx"
|
|
via: "PresentationPanel import"
|
|
pattern: "PresentationPanel"
|
|
---
|
|
|
|
<objective>
|
|
Extend useContentJob to surface fine-grained progress, create the PresentationPanel UI component, and add a Presentations tab to ContentStudio.
|
|
|
|
Purpose: Complete the user-facing experience for generating presentations and videos with real-time progress feedback.
|
|
Output: PresentationPanel.tsx, updated useContentJob.ts, updated ContentStudio.tsx
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/44-video-presentations/44-RESEARCH.md
|
|
@.planning/phases/44-video-presentations/44-01-SUMMARY.md
|
|
@ui/src/hooks/useContentJob.ts
|
|
@ui/src/pages/ContentStudio.tsx
|
|
@ui/src/components/BrandKitPanel.tsx (panel pattern reference)
|
|
@ui/src/components/DocumentGeneratePanel.tsx (panel pattern reference)
|
|
|
|
<interfaces>
|
|
From ui/src/hooks/useContentJob.ts:
|
|
```typescript
|
|
type JobStatus = "idle" | "queued" | "running" | "done" | "failed";
|
|
interface ContentJobState {
|
|
jobId: string | null;
|
|
status: JobStatus;
|
|
progress: number;
|
|
resultAssetId: string | null;
|
|
errorMessage: string | null;
|
|
}
|
|
export function useContentJob(companyId: string | null): ContentJobState & { submit, reset };
|
|
```
|
|
|
|
From ui/src/api/contentJobs.ts:
|
|
```typescript
|
|
export async function submitContentJob(...): Promise<{ jobId: string }>;
|
|
export async function getContentJobAsset(companyId: string, assetId: string): Promise<string>;
|
|
```
|
|
|
|
From server/src/services/renderers/types.ts (Plan 01):
|
|
```typescript
|
|
export interface PresentationBundle {
|
|
type: "presentation-bundle";
|
|
presentationType: "pitch-deck" | "demo-video";
|
|
title: string;
|
|
slideCount: number;
|
|
durationInFrames: number;
|
|
fps: number;
|
|
mp4Base64: string;
|
|
inputProps: Record<string, unknown>;
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Extend useContentJob to surface fine-grained SSE progress</name>
|
|
<files>ui/src/hooks/useContentJob.ts</files>
|
|
<read_first>
|
|
- ui/src/hooks/useContentJob.ts (full file — understand current SSE event handler)
|
|
</read_first>
|
|
<action>
|
|
Modify the `es.addEventListener("status", ...)` handler in `useContentJob.ts` to check for a `progress` field in the SSE payload.
|
|
|
|
Update the `data` type assertion inside the status event handler to include `progress?: number`:
|
|
```typescript
|
|
const data = JSON.parse(e.data as string) as {
|
|
status?: string;
|
|
progress?: number;
|
|
resultAssetId?: string | null;
|
|
errorMessage?: string | null;
|
|
};
|
|
```
|
|
|
|
Change the progress calculation to prefer the fine-grained value when present:
|
|
```typescript
|
|
const progress =
|
|
typeof data.progress === "number"
|
|
? data.progress
|
|
: statusToProgress(status);
|
|
```
|
|
|
|
This is backward-compatible — non-video jobs that don't send `data.progress` still use the coarse `statusToProgress` mapping. Video jobs that send progress 0-100 via content_job.progress will update the progress bar in real-time.
|
|
|
|
No other changes to the hook. The existing `state.progress` field is already a number 0-100.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && grep -c "data.progress" ui/src/hooks/useContentJob.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- grep -q "progress?: number" ui/src/hooks/useContentJob.ts
|
|
- grep -q "typeof data.progress" ui/src/hooks/useContentJob.ts
|
|
- grep -q "statusToProgress" ui/src/hooks/useContentJob.ts
|
|
</acceptance_criteria>
|
|
<done>useContentJob reads data.progress from SSE events for fine-grained video render progress, falls back to coarse statusToProgress for other job types</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create PresentationPanel and add Presentations tab to ContentStudio</name>
|
|
<files>ui/src/components/PresentationPanel.tsx, ui/src/pages/ContentStudio.tsx</files>
|
|
<read_first>
|
|
- ui/src/components/BrandKitPanel.tsx (full — panel structure pattern)
|
|
- ui/src/components/DocumentGeneratePanel.tsx (first 80 lines — simpler panel pattern)
|
|
- ui/src/pages/ContentStudio.tsx (full — tab structure)
|
|
- ui/src/api/contentJobs.ts (getContentJobAsset function)
|
|
</read_first>
|
|
<action>
|
|
**ui/src/components/PresentationPanel.tsx:**
|
|
|
|
Create following the BrandKitPanel pattern. Props: `{ companyId: string }`.
|
|
|
|
State:
|
|
- `prompt` (string) — user's description
|
|
- `videoType` ("pitch-deck" | "demo-video") — defaults to "pitch-deck"
|
|
- `bundle` (PresentationBundle | null) — parsed result
|
|
|
|
Define `PresentationBundle` interface locally (same as server types but only the fields needed by UI):
|
|
```typescript
|
|
interface PresentationBundle {
|
|
type: "presentation-bundle";
|
|
presentationType: "pitch-deck" | "demo-video";
|
|
title: string;
|
|
slideCount: number;
|
|
durationInFrames: number;
|
|
fps: number;
|
|
mp4Base64: string;
|
|
inputProps: Record<string, unknown>;
|
|
}
|
|
```
|
|
|
|
Use `useContentJob(companyId)` hook. Submit with `job.submit("presentation", { prompt, videoType, title: prompt.slice(0, 60) })`.
|
|
|
|
When `job.status === "done"` and `job.resultAssetId` is set, fetch the asset via `getContentJobAsset(companyId, job.resultAssetId)`, fetch the URL, parse JSON as `PresentationBundle`, set to `bundle` state. Same pattern as BrandKitPanel.
|
|
|
|
Layout (using shadcn Card, Button, Textarea, Progress, Select components):
|
|
1. **Card header:** "Generate Presentation"
|
|
2. **Prompt textarea:** 5 rows, placeholder "Describe the presentation you want — topic, audience, key points..."
|
|
3. **Type selector:** Two radio buttons or a Select dropdown with options "Pitch Deck" (value "pitch-deck") and "Demo Video" (value "demo-video"). Import `Select, SelectContent, SelectItem, SelectTrigger, SelectValue` from `@/components/ui/select`.
|
|
4. **Generate button:** Disabled when no prompt or generating. Shows Loader2 spinner when generating.
|
|
5. **Progress bar:** Show `<Progress value={job.progress} />` when status is "queued" or "running". Display percentage text next to it: `{job.progress}%`. This is where the fine-grained SSE progress from PRES-04 is visible.
|
|
6. **Error display:** If `job.status === "failed"`, show error message in red text.
|
|
7. **Result section (when bundle is set):**
|
|
- Title and slide count info
|
|
- An HTML5 `<video>` element playing the MP4. Convert `bundle.mp4Base64` to a blob URL:
|
|
```typescript
|
|
const videoUrl = useMemo(() => {
|
|
if (!bundle?.mp4Base64) return null;
|
|
const bytes = Uint8Array.from(atob(bundle.mp4Base64), c => c.charCodeAt(0));
|
|
return URL.createObjectURL(new Blob([bytes], { type: "video/mp4" }));
|
|
}, [bundle?.mp4Base64]);
|
|
```
|
|
Render: `<video src={videoUrl} controls className="w-full rounded-lg aspect-video" />`
|
|
- Download button: creates an `<a>` tag with `href={videoUrl}` and `download={bundle.title}.mp4`
|
|
- "Generate Another" button that calls `job.reset()` and clears `bundle`/`prompt`
|
|
|
|
Clean up blob URL on unmount with `useEffect(() => () => { if (videoUrl) URL.revokeObjectURL(videoUrl) }, [videoUrl])`.
|
|
|
|
Import from: `useState`, `useMemo`, `useEffect` from react; `Loader2`, `Download`, `Video` from lucide-react; Card/Button/Textarea/Progress/Select from shadcn.
|
|
|
|
**ui/src/pages/ContentStudio.tsx:**
|
|
|
|
Add import: `import { PresentationPanel } from "../components/PresentationPanel";`
|
|
|
|
Add a new `TabsTrigger` with `value="presentations"` and text "Presentations" — place it after the "brand" trigger.
|
|
|
|
Add a new `TabsContent` with `value="presentations"`:
|
|
```tsx
|
|
<TabsContent value="presentations" className="mt-4">
|
|
{companyId ? (
|
|
<PresentationPanel companyId={companyId} />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
|
|
)}
|
|
</TabsContent>
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && grep -c "PresentationPanel" ui/src/components/PresentationPanel.tsx && grep -c "presentations" ui/src/pages/ContentStudio.tsx && grep -c "PresentationPanel" ui/src/pages/ContentStudio.tsx</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- grep -q "PresentationPanel" ui/src/components/PresentationPanel.tsx
|
|
- grep -q "useContentJob" ui/src/components/PresentationPanel.tsx
|
|
- grep -q "pitch-deck" ui/src/components/PresentationPanel.tsx
|
|
- grep -q "demo-video" ui/src/components/PresentationPanel.tsx
|
|
- grep -q "mp4Base64" ui/src/components/PresentationPanel.tsx
|
|
- grep -q "video/mp4" ui/src/components/PresentationPanel.tsx
|
|
- grep -q "job.progress" ui/src/components/PresentationPanel.tsx
|
|
- grep -q 'value="presentations"' ui/src/pages/ContentStudio.tsx
|
|
- grep -q "PresentationPanel" ui/src/pages/ContentStudio.tsx
|
|
</acceptance_criteria>
|
|
<done>PresentationPanel renders prompt input, type selector, real-time progress bar, MP4 video player with download; ContentStudio has Presentations tab</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `grep -q "data.progress" ui/src/hooks/useContentJob.ts` confirms fine-grained progress
|
|
- `grep -q "PresentationPanel" ui/src/components/PresentationPanel.tsx` confirms panel exists
|
|
- `grep -q 'value="presentations"' ui/src/pages/ContentStudio.tsx` confirms tab exists
|
|
- `grep -q "video/mp4" ui/src/components/PresentationPanel.tsx` confirms MP4 playback
|
|
- `pnpm --filter @paperclipai/ui exec -- npx tsc --noEmit 2>&1 | tail -3` passes
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- useContentJob reads data.progress for fine-grained SSE progress (PRES-04)
|
|
- PresentationPanel allows prompt + type selection for pitch-deck and demo-video (PRES-01, PRES-03)
|
|
- Progress bar shows real-time render percentage (PRES-04)
|
|
- Completed render shows MP4 video player with download (PRES-02)
|
|
- ContentStudio has 8 tabs including Presentations
|
|
- All changes follow existing codebase patterns (Card, useContentJob, getContentJobAsset)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/44-video-presentations/44-03-SUMMARY.md`
|
|
</output>
|