11 KiB
11 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 44-video-presentations | 03 | execute | 2 |
|
|
true |
|
|
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
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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) 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:
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):
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>;
}
Task 1: Extend useContentJob to surface fine-grained SSE progress
ui/src/hooks/useContentJob.ts
- ui/src/hooks/useContentJob.ts (full file — understand current SSE event handler)
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.
cd /opt/nexus && grep -c "data.progress" ui/src/hooks/useContentJob.ts
- 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
useContentJob reads data.progress from SSE events for fine-grained video render progress, falls back to coarse statusToProgress for other job types
Task 2: Create PresentationPanel and add Presentations tab to ContentStudio
ui/src/components/PresentationPanel.tsx, ui/src/pages/ContentStudio.tsx
- 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)
**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>
```
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
- 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
PresentationPanel renders prompt input, type selector, real-time progress bar, MP4 video player with download; ContentStudio has Presentations tab
- `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
<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>