feat: Phase 44 — Video & Presentations (Remotion MP4, SSE progress)
This commit is contained in:
parent
e4165adefb
commit
d25d88d053
29 changed files with 4355 additions and 72 deletions
|
|
@ -53,10 +53,10 @@ Requirements for Content Generation milestone. Each maps to roadmap phases.
|
|||
|
||||
### Presentations & Video
|
||||
|
||||
- [ ] **PRES-01**: User can generate pitch deck presentations from a conversation
|
||||
- [ ] **PRES-02**: System renders presentations via Remotion to interactive web or MP4
|
||||
- [ ] **PRES-03**: User can generate demo and explainer videos from conversation content
|
||||
- [ ] **PRES-04**: System shows render progress via SSE during video generation
|
||||
- [x] **PRES-01**: User can generate pitch deck presentations from a conversation
|
||||
- [x] **PRES-02**: System renders presentations via Remotion to interactive web or MP4
|
||||
- [x] **PRES-03**: User can generate demo and explainer videos from conversation content
|
||||
- [x] **PRES-04**: System shows render progress via SSE during video generation
|
||||
|
||||
### Social Media Content
|
||||
|
||||
|
|
@ -175,10 +175,10 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||
| BRAND-04 | Phase 43 | Complete |
|
||||
| BRAND-05 | Phase 43 | Complete |
|
||||
| BRAND-06 | Phase 43 | Complete |
|
||||
| PRES-01 | Phase 44 | Pending |
|
||||
| PRES-02 | Phase 44 | Pending |
|
||||
| PRES-03 | Phase 44 | Pending |
|
||||
| PRES-04 | Phase 44 | Pending |
|
||||
| PRES-01 | Phase 44 | Complete |
|
||||
| PRES-02 | Phase 44 | Complete |
|
||||
| PRES-03 | Phase 44 | Complete |
|
||||
| PRES-04 | Phase 44 | Complete |
|
||||
| SKILL-01 | Phase 45 | Pending |
|
||||
| SKILL-02 | Phase 45 | Pending |
|
||||
| SKILL-03 | Phase 45 | Pending |
|
||||
|
|
|
|||
|
|
@ -129,7 +129,13 @@ Plans:
|
|||
3. An agent response delivered in full voice mode plays back automatically in the chat thread; the auto-play can be turned off in settings and stays off after a page reload
|
||||
4. The chat message for a voice interaction shows a voice badge and an expandable section revealing the full markdown response with code blocks intact
|
||||
5. Voice recording and VAD work correctly in Chrome and Firefox on the Mac Mini (COOP/COEP headers satisfy SharedArrayBuffer requirements)
|
||||
**Plans**: TBD
|
||||
**Plans**: 3 plans
|
||||
|
||||
Plans:
|
||||
- [x] 44-01-PLAN.md — Remotion workspace package, compositions, shared constants, types, job-runner wiring
|
||||
- [x] 44-02-PLAN.md — Presentation renderer with LLM slide generation, Remotion render, SSE progress
|
||||
- [x] 44-03-PLAN.md — PresentationPanel UI, useContentJob progress extension, ContentStudio tab
|
||||
**UI hint**: yes
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 38: Telegram Bridge
|
||||
|
|
@ -143,7 +149,13 @@ Plans:
|
|||
4. The Telegram bridge runs via long polling with no public HTTPS endpoint required — verified by running on the Mac Mini behind NAT
|
||||
5. The entire `telegram.ts` service file is under 500 lines
|
||||
6. The onboarding wizard includes a BotFather setup step that walks through creating a bot token and saves it to `nexus-settings.json` without manual file editing
|
||||
**Plans**: TBD
|
||||
**Plans**: 3 plans
|
||||
|
||||
Plans:
|
||||
- [x] 44-01-PLAN.md — Remotion workspace package, compositions, shared constants, types, job-runner wiring
|
||||
- [ ] 44-02-PLAN.md — Presentation renderer with LLM slide generation, Remotion render, SSE progress
|
||||
- [x] 44-03-PLAN.md — PresentationPanel UI, useContentJob progress extension, ContentStudio tab
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 39: Voice Polish
|
||||
**Goal**: Voice responses begin playing before synthesis is complete (sentence-buffered), a single response can be synthesized in multiple languages simultaneously, and new installs can detect STT/TTS hardware capability during onboarding and enable voice in one step
|
||||
|
|
@ -174,7 +186,7 @@ Plans:
|
|||
- [x] **Phase 41: Diagrams, Icons & Theme Engine** — Mermaid diagrams, SVG icon generation, OKLCH theme palette with WCAG AA and live preview (DIAG-01..05, ICON-01..03, THEME-01..07) (completed 2026-04-04)
|
||||
- [x] **Phase 42: Wallpapers, Social, Format Conversion & Voice** — LLM SVG + sharp wallpapers, social content, format conversion registry with AI fallback, Whisper web chat mic (WALL-01..04, SOCIAL-01..03, CONV-01..09, VOICE-01..03) (completed 2026-04-04)
|
||||
- [x] **Phase 43: Documents & Branding** — Playwright PDF reports and invoices, full brand identity kit with zip export (DOC-01..03, BRAND-01..06) (completed 2026-04-04)
|
||||
- [ ] **Phase 44: Video & Presentations** — Remotion workspace package, pitch decks and demo videos, SSE render progress (PRES-01..04)
|
||||
- [x] **Phase 44: Video & Presentations** — Remotion workspace package, pitch decks and demo videos, SSE render progress (PRES-01..04) (completed 2026-04-04)
|
||||
- [ ] **Phase 45: Content as Skills** — Markdown skill files for all content types, Creative skill group on generalist agent (SKILL-01..03)
|
||||
|
||||
## Phase Details
|
||||
|
|
@ -262,7 +274,13 @@ Plans:
|
|||
2. The Remotion bundle is compiled once at server startup and reused for all renders — submitting a second render request does not trigger a second webpack compilation
|
||||
3. A browser connected during a video render receives SSE progress events (percentage complete) throughout the render; the final event delivers the download URL
|
||||
4. Concurrent LLM inference and video rendering do not cause the server to become unresponsive — render concurrency is capped and serialized with LLM workloads
|
||||
**Plans**: TBD
|
||||
**Plans**: 3 plans
|
||||
|
||||
Plans:
|
||||
- [x] 44-01-PLAN.md — Remotion workspace package, compositions, shared constants, types, job-runner wiring
|
||||
- [ ] 44-02-PLAN.md — Presentation renderer with LLM slide generation, Remotion render, SSE progress
|
||||
- [ ] 44-03-PLAN.md — PresentationPanel UI, useContentJob progress extension, ContentStudio tab
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 45: Content as Skills
|
||||
**Goal**: Every content type built in Phases 41-44 is accessible to agents as an installable Markdown skill, and the generalist agent ships pre-loaded with the Creative skill group
|
||||
|
|
@ -368,5 +386,5 @@ All 52 v1.7 requirements are mapped to exactly one phase. No orphans.
|
|||
| 41. Diagrams, Icons & Theme Engine | v1.7 | 6/6 | Complete | 2026-04-04 |
|
||||
| 42. Wallpapers, Social, Format Conversion & Voice | v1.7 | 6/6 | Complete | 2026-04-04 |
|
||||
| 43. Documents & Branding | v1.7 | 3/3 | Complete | 2026-04-04 |
|
||||
| 44. Video & Presentations | v1.7 | 0/TBD | Not started | - |
|
||||
| 44. Video & Presentations | v1.7 | 3/3 | Complete | 2026-04-04 |
|
||||
| 45. Content as Skills | v1.7 | 0/TBD | Not started | - |
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||
milestone: v1.7
|
||||
milestone_name: Content Generation
|
||||
status: verifying
|
||||
stopped_at: Completed 43-03-PLAN.md — DocumentGeneratePanel, BrandKitPanel, BrandKitResult, ContentStudio 7 tabs
|
||||
last_updated: "2026-04-04T22:56:41.659Z"
|
||||
stopped_at: Completed 44-02-PLAN.md — presentation-renderer with LLM slide generation, Remotion render pipeline, SSE progress events, tsc compilation verified
|
||||
last_updated: "2026-04-04T23:36:43.205Z"
|
||||
last_activity: 2026-04-04
|
||||
progress:
|
||||
total_phases: 6
|
||||
completed_phases: 4
|
||||
total_plans: 17
|
||||
completed_plans: 17
|
||||
completed_phases: 5
|
||||
total_plans: 20
|
||||
completed_plans: 20
|
||||
percent: 0
|
||||
---
|
||||
|
||||
|
|
@ -21,11 +21,11 @@ progress:
|
|||
See: .planning/PROJECT.md (updated 2026-04-04)
|
||||
|
||||
**Core value:** A fresh onboard asks for ONE thing (root directory), auto-creates PM + Engineer agents, and drops you in the dashboard.
|
||||
**Current focus:** Phase 43 — documents-branding
|
||||
**Current focus:** Phase 44 — video-presentations
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 44
|
||||
Phase: 45
|
||||
Plan: Not started
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-04-04
|
||||
|
|
@ -91,6 +91,14 @@ Key constraints for v1.7:
|
|||
- [Phase 43]: Social images in brand-renderer use SVG templates (colored rect + embedded logo) rather than LLM-generated — fast, deterministic, always on-brand
|
||||
- [Phase 43-documents-branding]: BrandKitBundle type defined in BrandKitResult.tsx and imported by BrandKitPanel — type co-located with display component, avoids duplication
|
||||
- [Phase 43-documents-branding]: iframe sandbox=allow-same-origin for email signature and letterhead previews — prevents script execution while allowing inline CSS
|
||||
- [Phase 44-video-presentations]: Remotion workspace package uses CommonJS module resolution — rspack bundler requires CJS, no type:module
|
||||
- [Phase 44-video-presentations]: getBundlePath caches bundle path at module level — bundle() called once at startup, not per-render
|
||||
- [Phase 44-video-presentations]: compositions/index.ts is UI-safe sub-export — no @remotion/bundler or @remotion/renderer imports
|
||||
- [Phase 44-video-presentations]: renderContent extended with optional companyId/jobId params for presentation SSE progress events
|
||||
- [Phase 44-video-presentations]: data.progress check uses typeof guard — prevents 0 (falsy) from triggering statusToProgress fallback in useContentJob
|
||||
- [Phase 44-video-presentations]: mp4Base64 blob URL created in useMemo and revoked in useEffect return — correct lifecycle for React renders
|
||||
- [Phase 44-video-presentations]: Ambient module declaration in server/src/types/content-renderer.d.ts provides type safety for dynamic import without pulling JSX into server tsc context
|
||||
- [Phase 44-video-presentations]: content-renderer NOT added as workspace dep in server/package.json — symlink causes tsc to walk JSX source; ambient declaration is sufficient for type safety and runtime works via monorepo node_modules
|
||||
|
||||
### Pending Todos
|
||||
|
||||
|
|
@ -105,6 +113,6 @@ None yet.
|
|||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-04T22:56:11.026Z
|
||||
Stopped at: Completed 43-03-PLAN.md — DocumentGeneratePanel, BrandKitPanel, BrandKitResult, ContentStudio 7 tabs
|
||||
Last session: 2026-04-04T23:35:57.656Z
|
||||
Stopped at: Completed 44-02-PLAN.md — presentation-renderer with LLM slide generation, Remotion render pipeline, SSE progress events, tsc compilation verified
|
||||
Resume file: None
|
||||
|
|
|
|||
276
.planning/phases/44-video-presentations/44-01-PLAN.md
Normal file
276
.planning/phases/44-video-presentations/44-01-PLAN.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
---
|
||||
phase: 44-video-presentations
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- packages/content-renderer/package.json
|
||||
- packages/content-renderer/tsconfig.json
|
||||
- packages/content-renderer/src/index.ts
|
||||
- packages/content-renderer/src/Root.tsx
|
||||
- packages/content-renderer/src/compositions/index.ts
|
||||
- packages/content-renderer/src/compositions/PitchDeck.tsx
|
||||
- packages/content-renderer/src/compositions/DemoVideo.tsx
|
||||
- packages/content-renderer/src/components/SlideFrame.tsx
|
||||
- packages/content-renderer/src/components/TitleSlide.tsx
|
||||
- packages/shared/src/constants.ts
|
||||
- server/src/services/renderers/types.ts
|
||||
- server/src/services/content-job-runner.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PRES-01
|
||||
- PRES-02
|
||||
- PRES-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "packages/content-renderer is a valid pnpm workspace package with remotion deps installed"
|
||||
- "content_job.progress exists in LIVE_EVENT_TYPES and compiles"
|
||||
- "PresentationBundle type is exported from renderer types"
|
||||
- "content-job-runner dispatches presentation jobs to the new renderer"
|
||||
- "Remotion Root registers PitchDeck and DemoVideo compositions"
|
||||
artifacts:
|
||||
- path: "packages/content-renderer/package.json"
|
||||
provides: "Workspace package with remotion, @remotion/bundler, @remotion/renderer deps"
|
||||
contains: "@remotion/renderer"
|
||||
- path: "packages/content-renderer/src/Root.tsx"
|
||||
provides: "registerRoot entry for Remotion bundler"
|
||||
contains: "registerRoot"
|
||||
- path: "packages/content-renderer/src/compositions/PitchDeck.tsx"
|
||||
provides: "Pitch deck composition using Series"
|
||||
contains: "Series.Sequence"
|
||||
- path: "packages/content-renderer/src/compositions/DemoVideo.tsx"
|
||||
provides: "Demo video composition"
|
||||
contains: "Series.Sequence"
|
||||
- path: "packages/content-renderer/src/index.ts"
|
||||
provides: "getBundlePath and renderPresentationComposition exports"
|
||||
exports: ["getBundlePath", "renderPresentationComposition"]
|
||||
- path: "packages/shared/src/constants.ts"
|
||||
provides: "content_job.progress event type"
|
||||
contains: "content_job.progress"
|
||||
- path: "server/src/services/renderers/types.ts"
|
||||
provides: "PresentationBundle in ContentBundle union"
|
||||
contains: "PresentationBundle"
|
||||
- path: "server/src/services/content-job-runner.ts"
|
||||
provides: "presentation case in renderContent switch"
|
||||
contains: 'case "presentation"'
|
||||
key_links:
|
||||
- from: "packages/content-renderer/src/index.ts"
|
||||
to: "packages/content-renderer/src/Root.tsx"
|
||||
via: "bundle() entryPoint"
|
||||
pattern: "Root\\.tsx"
|
||||
- from: "server/src/services/content-job-runner.ts"
|
||||
to: "server/src/services/renderers/presentation-renderer.ts"
|
||||
via: "dynamic import"
|
||||
pattern: "presentation-renderer"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the Remotion workspace package (packages/content-renderer/) with compositions for pitch decks and demo videos, extend shared constants with content_job.progress event type, add PresentationBundle to renderer types, and wire content-job-runner to dispatch presentation jobs.
|
||||
|
||||
Purpose: Establish the isolated Remotion webpack context and all type contracts before the server renderer and UI can be built.
|
||||
Output: A compilable workspace package with Remotion compositions, updated shared constants, updated renderer types, updated job runner.
|
||||
</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
|
||||
@packages/shared/src/constants.ts
|
||||
@server/src/services/renderers/types.ts
|
||||
@server/src/services/content-job-runner.ts
|
||||
@server/src/services/renderers/diagram-renderer.ts (resolveBrowserPath pattern)
|
||||
|
||||
<interfaces>
|
||||
From packages/shared/src/constants.ts:
|
||||
```typescript
|
||||
export const LIVE_EVENT_TYPES = [
|
||||
"content_job.queued",
|
||||
"content_job.running",
|
||||
"content_job.done",
|
||||
"content_job.failed",
|
||||
] as const;
|
||||
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
||||
```
|
||||
|
||||
From server/src/services/renderers/types.ts:
|
||||
```typescript
|
||||
export interface RenderResult {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
export type ContentBundle = DiagramBundle | IconSetBundle | ... | BrandKitBundle;
|
||||
```
|
||||
|
||||
From server/src/services/content-job-runner.ts:
|
||||
```typescript
|
||||
export async function renderContent(jobType: string, input: Record<string, unknown>): Promise<RenderResult>
|
||||
```
|
||||
|
||||
From server/src/services/renderers/diagram-renderer.ts:
|
||||
```typescript
|
||||
export function resolveBrowserPath(): string // Resolves Playwright Chromium path
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create packages/content-renderer workspace package with Remotion compositions</name>
|
||||
<files>
|
||||
packages/content-renderer/package.json,
|
||||
packages/content-renderer/tsconfig.json,
|
||||
packages/content-renderer/src/index.ts,
|
||||
packages/content-renderer/src/Root.tsx,
|
||||
packages/content-renderer/src/compositions/index.ts,
|
||||
packages/content-renderer/src/compositions/PitchDeck.tsx,
|
||||
packages/content-renderer/src/compositions/DemoVideo.tsx,
|
||||
packages/content-renderer/src/components/SlideFrame.tsx,
|
||||
packages/content-renderer/src/components/TitleSlide.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
- packages/shared/package.json (package.json pattern for workspace packages)
|
||||
- server/src/services/renderers/diagram-renderer.ts (resolveBrowserPath export — line 90-107)
|
||||
- pnpm-workspace.yaml (confirm packages/* is included)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `packages/content-renderer/` as a NEW workspace package. This package isolates Remotion's webpack/rspack from the server's tsc/Vite context.
|
||||
|
||||
**package.json:** name `@paperclipai/content-renderer`, NO `"type": "module"` (omit the field entirely — Remotion's rspack bundler needs CommonJS resolution internally). Dependencies: `remotion@4.0.445`, `@remotion/bundler@4.0.445`, `@remotion/renderer@4.0.445`, `react`, `react-dom`. The `react` and `react-dom` should be peerDependencies listed as deps for the workspace to resolve. Add an `"exports"` field with `"."` pointing to `"./src/index.ts"` and `"./compositions"` pointing to `"./src/compositions/index.ts"` (the UI-safe sub-export that does NOT import bundler/renderer).
|
||||
|
||||
**tsconfig.json:** Extend `../../tsconfig.base.json`. Override `"jsx": "react-jsx"`, `"module": "CommonJS"`, `"moduleResolution": "Node"`, `"esModuleInterop": true`. rootDir `src`, outDir `dist`. Include `src`.
|
||||
|
||||
**src/Root.tsx:** Import `registerRoot` from `remotion`. Import `Composition` from `remotion`. Import `PitchDeck` from `./compositions/PitchDeck` and `DemoVideo` from `./compositions/DemoVideo`. Define `FRAMES_PER_SLIDE = 90` (3 seconds at 30fps). Register a `RemotionRoot` component containing two `<Composition>` elements:
|
||||
- id `"PitchDeck"`, component `PitchDeck`, 1920x1080, 30fps, `durationInFrames: FRAMES_PER_SLIDE * 10` (default), `defaultProps: { slides: [], companyName: "" }`
|
||||
- id `"DemoVideo"`, component `DemoVideo`, 1920x1080, 30fps, `durationInFrames: FRAMES_PER_SLIDE * 8` (default), `defaultProps: { slides: [], title: "" }`
|
||||
Call `registerRoot(RemotionRoot)` at module level.
|
||||
|
||||
**src/compositions/PitchDeck.tsx:** Export `Slide` interface: `{ title: string; body: string; accent: string }`. Export `PitchDeckProps`: `{ slides: Slide[]; companyName: string }`. Component renders an `<AbsoluteFill>` with dark background (#0f0f0f), wrapping a `<Series>`. Each slide is a `<Series.Sequence>` with `durationInFrames={90}` containing a `<SlideFrame>`. First slide should be a `<TitleSlide>` (if `companyName` is provided and slides.length > 0, render TitleSlide for index 0). Use `calculateMetadata` static function to compute total durationInFrames as `slides.length * 90` (exported on the component for Remotion to pick up).
|
||||
|
||||
**src/compositions/DemoVideo.tsx:** Export `DemoSlide` interface: `{ title: string; content: string; narration: string }`. Export `DemoVideoProps`: `{ slides: DemoSlide[]; title: string }`. Similar Series-based layout but with a different visual style — use a gradient background (#1a1a2e to #16213e), larger title text, and a narration text overlay at the bottom of each slide. Use `calculateMetadata` for dynamic duration.
|
||||
|
||||
**src/compositions/index.ts:** Re-export `PitchDeck`, `PitchDeckProps`, `Slide` from `./PitchDeck` and `DemoVideo`, `DemoVideoProps`, `DemoSlide` from `./DemoVideo`. This is the UI-safe entrypoint — does NOT import from `@remotion/bundler` or `@remotion/renderer`.
|
||||
|
||||
**src/components/SlideFrame.tsx:** Props: `{ slide: Slide; slideIndex: number; total: number }`. Renders a full-frame slide with the slide title in bold (white, 48px), body text below (light gray, 28px), a colored accent bar at the bottom using `slide.accent`, and a slide counter `slideIndex+1 / total` in the bottom-right corner. Use `useCurrentFrame` and `interpolate` from `remotion` for a fade-in animation on enter (first 15 frames).
|
||||
|
||||
**src/components/TitleSlide.tsx:** Props: `{ companyName: string; subtitle?: string }`. Renders a centered title card with company name in large text (72px, white), optional subtitle below. Use `useCurrentFrame` + `spring` from `remotion` for a scale-in animation.
|
||||
|
||||
**src/index.ts:** This is the server-side entrypoint.
|
||||
- Export `getBundlePath()`: Uses `bundle()` from `@remotion/bundler` with entryPoint pointing to `path.resolve(__dirname, "Root.tsx")`. Caches the result in a module-level `let cachedBundlePath: string | null = null`. Returns the cached path on subsequent calls.
|
||||
- Export `renderPresentationComposition(opts)`: Takes `{ serveUrl: string; input: Record<string, unknown>; onProgress: (progress: number) => void; browserExecutable?: string }`. Uses `selectComposition()` from `@remotion/renderer` to select `input.videoType === "demo" ? "DemoVideo" : "PitchDeck"`. Then calls `renderMedia()` with `codec: "h264"`, `concurrency: 1` (to avoid competing with LLM inference), the `onProgress` callback mapping `({ progress }) => onProgress(Math.round(progress * 100))`, and `browserExecutable` if provided. Returns a `{ buffer: Buffer; durationInFrames: number; fps: number; inputProps: Record<string, unknown> }` object.
|
||||
- Re-export composition types from `./compositions/index` for convenience.
|
||||
|
||||
After creating all files, run `cd /opt/nexus && pnpm install` to link the workspace package. Verify with `pnpm ls --filter @paperclipai/content-renderer`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && pnpm ls --filter @paperclipai/content-renderer remotion @remotion/bundler @remotion/renderer && node -e "const p = require('./packages/content-renderer/package.json'); if (!p.name.includes('content-renderer')) throw new Error('bad name')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "content-renderer" packages/content-renderer/package.json
|
||||
- grep -q "remotion" packages/content-renderer/package.json
|
||||
- grep -q "@remotion/bundler" packages/content-renderer/package.json
|
||||
- grep -q "@remotion/renderer" packages/content-renderer/package.json
|
||||
- grep -q "registerRoot" packages/content-renderer/src/Root.tsx
|
||||
- grep -q "Series.Sequence" packages/content-renderer/src/compositions/PitchDeck.tsx
|
||||
- grep -q "Series.Sequence" packages/content-renderer/src/compositions/DemoVideo.tsx
|
||||
- grep -q "getBundlePath" packages/content-renderer/src/index.ts
|
||||
- grep -q "renderPresentationComposition" packages/content-renderer/src/index.ts
|
||||
- grep -q "concurrency" packages/content-renderer/src/index.ts
|
||||
</acceptance_criteria>
|
||||
<done>packages/content-renderer/ is a linked workspace package with Remotion deps, Root.tsx registers PitchDeck and DemoVideo compositions, index.ts exports getBundlePath and renderPresentationComposition, compositions sub-export is UI-safe</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Extend shared constants, renderer types, and content-job-runner for presentations</name>
|
||||
<files>
|
||||
packages/shared/src/constants.ts,
|
||||
server/src/services/renderers/types.ts,
|
||||
server/src/services/content-job-runner.ts
|
||||
</files>
|
||||
<read_first>
|
||||
- packages/shared/src/constants.ts (full file — find exact LIVE_EVENT_TYPES array)
|
||||
- server/src/services/renderers/types.ts (full file — find ContentBundle union)
|
||||
- server/src/services/content-job-runner.ts (full file — find renderContent switch)
|
||||
</read_first>
|
||||
<action>
|
||||
**packages/shared/src/constants.ts:** Add `"content_job.progress"` to the `LIVE_EVENT_TYPES` array, placed between `"content_job.running"` and `"content_job.done"`. This unblocks TypeScript compilation for any code that publishes progress events (PRES-04).
|
||||
|
||||
**server/src/services/renderers/types.ts:** Add `PresentationBundle` interface:
|
||||
```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>;
|
||||
}
|
||||
```
|
||||
Add `PresentationBundle` to the `ContentBundle` union type.
|
||||
|
||||
**server/src/services/content-job-runner.ts:** Add a `"presentation"` case to the `renderContent()` switch statement:
|
||||
```typescript
|
||||
case "presentation": {
|
||||
const { renderPresentation } = await import("./renderers/presentation-renderer.js");
|
||||
return renderPresentation(input, job.companyId, job.id);
|
||||
}
|
||||
```
|
||||
IMPORTANT: The `renderPresentation` function signature differs from other renderers — it takes `(input, companyId, jobId)` because it needs companyId and jobId to publish progress SSE events. Update the `renderContent` function signature to accept `companyId` and `jobId` as optional parameters:
|
||||
```typescript
|
||||
export async function renderContent(
|
||||
jobType: string,
|
||||
input: Record<string, unknown>,
|
||||
companyId?: string,
|
||||
jobId?: string,
|
||||
): Promise<RenderResult>
|
||||
```
|
||||
Pass these through in the `"presentation"` case. Update the `runJob` call site to pass `job.companyId` and `job.id` to `renderContent`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && pnpm --filter @paperclipai/shared exec -- npx tsc --noEmit 2>&1 | head -5 && grep -c "content_job.progress" packages/shared/src/constants.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "content_job.progress" packages/shared/src/constants.ts
|
||||
- grep -q "PresentationBundle" server/src/services/renderers/types.ts
|
||||
- grep -q "presentation-bundle" server/src/services/renderers/types.ts
|
||||
- grep -q "mp4Base64" server/src/services/renderers/types.ts
|
||||
- grep -q 'case "presentation"' server/src/services/content-job-runner.ts
|
||||
- grep -q "presentation-renderer" server/src/services/content-job-runner.ts
|
||||
</acceptance_criteria>
|
||||
<done>content_job.progress is a valid LiveEventType, PresentationBundle is in the ContentBundle union, content-job-runner dispatches "presentation" jobs to presentation-renderer</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `pnpm ls --filter @paperclipai/content-renderer remotion` confirms Remotion deps installed
|
||||
- `grep -q "content_job.progress" packages/shared/src/constants.ts` confirms progress event type
|
||||
- `grep -q "PresentationBundle" server/src/services/renderers/types.ts` confirms bundle type
|
||||
- `grep -q 'case "presentation"' server/src/services/content-job-runner.ts` confirms runner wiring
|
||||
- `grep -q "registerRoot" packages/content-renderer/src/Root.tsx` confirms Remotion entry
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- packages/content-renderer/ is a linked workspace package with remotion, @remotion/bundler, @remotion/renderer
|
||||
- Root.tsx registers PitchDeck and DemoVideo compositions
|
||||
- compositions/index.ts is a UI-safe export (no bundler/renderer imports)
|
||||
- index.ts exports getBundlePath (cached) and renderPresentationComposition (concurrency:1)
|
||||
- content_job.progress is in LIVE_EVENT_TYPES
|
||||
- PresentationBundle type exists with mp4Base64, inputProps, presentationType
|
||||
- content-job-runner has "presentation" case pointing to presentation-renderer
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/44-video-presentations/44-01-SUMMARY.md`
|
||||
</output>
|
||||
158
.planning/phases/44-video-presentations/44-01-SUMMARY.md
Normal file
158
.planning/phases/44-video-presentations/44-01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
phase: 44-video-presentations
|
||||
plan: 01
|
||||
subsystem: content-renderer
|
||||
tags: [remotion, video, presentations, workspace-package, typescript, content-jobs]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 40-job-infrastructure
|
||||
provides: content_jobs table, renderContent switch pattern, contentJobRunner dispatch
|
||||
provides:
|
||||
- packages/content-renderer workspace package with remotion 4.0.445
|
||||
- PitchDeck and DemoVideo Remotion compositions (Series-based, calculateMetadata)
|
||||
- getBundlePath (cached bundle) and renderPresentationComposition (concurrency:1) exports
|
||||
- UI-safe compositions/index.ts sub-export (no bundler/renderer imports)
|
||||
- content_job.progress LiveEventType in shared constants
|
||||
- PresentationBundle interface in ContentBundle union
|
||||
- presentation case in renderContent dispatching to presentation-renderer stub
|
||||
affects:
|
||||
- 44-video-presentations/44-02 (server renderer uses getBundlePath, renderPresentationComposition)
|
||||
- 44-video-presentations/44-03 (UI consumes PresentationBundle, compositions sub-export)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added:
|
||||
- remotion@4.0.445
|
||||
- "@remotion/bundler@4.0.445"
|
||||
- "@remotion/renderer@4.0.445"
|
||||
patterns:
|
||||
- Remotion isolated in packages/content-renderer/ workspace package — webpack bundler must not enter Vite/tsc server context
|
||||
- getBundlePath caches bundle path at module level — called once at startup, reused per render
|
||||
- compositions/index.ts is UI-safe (no @remotion/bundler or @remotion/renderer imports)
|
||||
- tsconfig.json overrides module to CommonJS + moduleResolution Node — required for Remotion's rspack internal resolution
|
||||
- calculateMetadata static function on component for dynamic duration based on slide count
|
||||
- Stub renderer pattern: throws "not implemented" to satisfy tsc module resolution (plan 02 replaces)
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- packages/content-renderer/package.json
|
||||
- packages/content-renderer/tsconfig.json
|
||||
- packages/content-renderer/src/index.ts
|
||||
- packages/content-renderer/src/Root.tsx
|
||||
- packages/content-renderer/src/compositions/index.ts
|
||||
- packages/content-renderer/src/compositions/PitchDeck.tsx
|
||||
- packages/content-renderer/src/compositions/DemoVideo.tsx
|
||||
- packages/content-renderer/src/components/SlideFrame.tsx
|
||||
- packages/content-renderer/src/components/TitleSlide.tsx
|
||||
- server/src/services/renderers/presentation-renderer.ts
|
||||
modified:
|
||||
- packages/shared/src/constants.ts
|
||||
- server/src/services/renderers/types.ts
|
||||
- server/src/services/content-job-runner.ts
|
||||
|
||||
key-decisions:
|
||||
- "Remotion workspace package uses CommonJS module resolution (no type:module) — Remotion rspack bundler requires CommonJS"
|
||||
- "getBundlePath caches bundle path at module level — bundle() called once, not per-render"
|
||||
- "compositions/index.ts is UI-safe sub-export — no @remotion/bundler or @remotion/renderer imports"
|
||||
- "renderPresentationComposition uses concurrency:1 to avoid competing with LLM inference"
|
||||
- "renderMedia called with outputLocation:null to get Buffer return from RenderMediaResult"
|
||||
- "presentation-renderer.ts stub added to satisfy tsc module resolution — plan 02 implements real renderer"
|
||||
- "renderContent extended with optional companyId and jobId params for presentation SSE progress events"
|
||||
|
||||
patterns-established:
|
||||
- "Remotion isolated workspace package: webpack context never enters server tsc/Vite context"
|
||||
- "calculateMetadata on component exports enables dynamic durationInFrames from slide count"
|
||||
- "UI-safe sub-export: compositions/index.ts imports only from remotion (not bundler/renderer)"
|
||||
|
||||
requirements-completed: [PRES-01, PRES-02, PRES-04]
|
||||
|
||||
# Metrics
|
||||
duration: 3min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 44 Plan 01: Video Presentations — Remotion Workspace Foundation Summary
|
||||
|
||||
**Remotion 4.0.445 workspace package with PitchDeck/DemoVideo compositions, cached getBundlePath, concurrency:1 renderPresentationComposition, and presentation job wiring in content-job-runner**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~3 min
|
||||
- **Started:** 2026-04-04T23:20:41Z
|
||||
- **Completed:** 2026-04-04T23:24:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 13
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Created `packages/content-renderer/` as isolated pnpm workspace package with remotion 4.0.445, @remotion/bundler, @remotion/renderer
|
||||
- Root.tsx registers PitchDeck and DemoVideo compositions via registerRoot; tsconfig uses CommonJS resolution for Remotion rspack compatibility
|
||||
- Extended shared constants with `content_job.progress` LiveEventType, added `PresentationBundle` to ContentBundle union, wired `presentation` case in content-job-runner
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Create packages/content-renderer workspace package with Remotion compositions** - `ba7ac20d` (feat)
|
||||
2. **Task 2: Extend shared constants, renderer types, and content-job-runner for presentations** - `a0365020` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `packages/content-renderer/package.json` - Workspace package, remotion deps, CommonJS exports
|
||||
- `packages/content-renderer/tsconfig.json` - Extends base, overrides module/moduleResolution to CommonJS/Node
|
||||
- `packages/content-renderer/src/Root.tsx` - registerRoot, PitchDeck + DemoVideo Compositions
|
||||
- `packages/content-renderer/src/compositions/PitchDeck.tsx` - Slide/PitchDeckProps types, Series-based layout, calculateMetadata
|
||||
- `packages/content-renderer/src/compositions/DemoVideo.tsx` - DemoSlide/DemoVideoProps types, gradient layout with narration overlay, calculateMetadata
|
||||
- `packages/content-renderer/src/components/SlideFrame.tsx` - fade-in animation, accent bar, slide counter
|
||||
- `packages/content-renderer/src/components/TitleSlide.tsx` - spring scale-in, centered company name
|
||||
- `packages/content-renderer/src/compositions/index.ts` - UI-safe sub-export (no bundler/renderer)
|
||||
- `packages/content-renderer/src/index.ts` - getBundlePath (cached), renderPresentationComposition (concurrency:1, outputLocation:null for Buffer)
|
||||
- `packages/shared/src/constants.ts` - Added content_job.progress to LIVE_EVENT_TYPES
|
||||
- `server/src/services/renderers/types.ts` - Added PresentationBundle interface and to ContentBundle union
|
||||
- `server/src/services/content-job-runner.ts` - Added presentation case, optional companyId/jobId params, updated runJob call site
|
||||
- `server/src/services/renderers/presentation-renderer.ts` - Stub renderer for tsc module resolution (plan 02 implements)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `content-renderer` uses `"module": "CommonJS"` (no `"type": "module"` in package.json) — Remotion's rspack bundler requires CommonJS resolution internally
|
||||
- `getBundlePath` caches the bundle path at module level; `bundle()` is called once at startup and reused per render request
|
||||
- `compositions/index.ts` is strictly UI-safe — imports only from `remotion`, never from `@remotion/bundler` or `@remotion/renderer`
|
||||
- `renderMedia` called with `outputLocation: null` so `RenderMediaResult.buffer` is populated as `Buffer | null` (non-null on success)
|
||||
- `renderContent` signature extended with optional `companyId` and `jobId` to enable SSE progress events in the presentation renderer
|
||||
- Stub `presentation-renderer.ts` throws "not yet implemented" — satisfies tsc module resolution without breaking existing build
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing Critical] Added presentation-renderer.ts stub**
|
||||
- **Found during:** Task 2 (content-job-runner wiring)
|
||||
- **Issue:** content-job-runner dynamically imports `./renderers/presentation-renderer.js` — TypeScript module resolution requires the source file to exist at compile time
|
||||
- **Fix:** Created stub `presentation-renderer.ts` that exports `renderPresentation` throwing "not yet implemented" — identical pattern used for all renderer stubs in phases 41-43
|
||||
- **Files modified:** `server/src/services/renderers/presentation-renderer.ts`
|
||||
- **Verification:** Server tsc --noEmit passes with no errors
|
||||
- **Committed in:** `a0365020` (Task 2 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 missing critical — tsc module resolution)
|
||||
**Impact on plan:** Stub is required for correct TypeScript compilation. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None - plan executed as specified.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
- `server/src/services/renderers/presentation-renderer.ts` — `renderPresentation` throws "not yet implemented". Phase 44 plan 02 replaces with real Remotion render pipeline.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- packages/content-renderer is linked workspace package, all Remotion deps installed
|
||||
- Root.tsx, compositions, and index.ts are ready for plan 02 server renderer to use getBundlePath and renderPresentationComposition
|
||||
- PresentationBundle type is defined and in ContentBundle union — plan 03 UI can consume it
|
||||
- content_job.progress is a valid LiveEventType — plan 02 can publish SSE progress events
|
||||
- presentation case in content-job-runner dispatches to plan 02 renderer stub
|
||||
|
||||
---
|
||||
*Phase: 44-video-presentations*
|
||||
*Completed: 2026-04-04*
|
||||
249
.planning/phases/44-video-presentations/44-02-PLAN.md
Normal file
249
.planning/phases/44-video-presentations/44-02-PLAN.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
---
|
||||
phase: 44-video-presentations
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["44-01"]
|
||||
files_modified:
|
||||
- server/src/services/renderers/presentation-renderer.ts
|
||||
- server/src/services/content-job-runner.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PRES-01
|
||||
- PRES-02
|
||||
- PRES-03
|
||||
- PRES-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "LLM generates structured slide JSON from a user prompt for both pitch-deck and demo-video types"
|
||||
- "renderPresentation calls getBundlePath, selectComposition, and renderMedia to produce an MP4 buffer"
|
||||
- "onProgress callback publishes content_job.progress SSE events with 0-100 percentage"
|
||||
- "Remotion concurrency is capped at 1 to avoid starving LLM inference"
|
||||
- "browserExecutable reuses existing Playwright Chromium binary"
|
||||
- "Resulting MP4 buffer is wrapped in a PresentationBundle JSON with inputProps for Player replay"
|
||||
artifacts:
|
||||
- path: "server/src/services/renderers/presentation-renderer.ts"
|
||||
provides: "renderPresentation function — LLM prompt + Remotion render + SSE progress"
|
||||
exports: ["renderPresentation"]
|
||||
min_lines: 80
|
||||
key_links:
|
||||
- from: "server/src/services/renderers/presentation-renderer.ts"
|
||||
to: "packages/content-renderer/src/index.ts"
|
||||
via: "dynamic import @paperclipai/content-renderer"
|
||||
pattern: "content-renderer"
|
||||
- from: "server/src/services/renderers/presentation-renderer.ts"
|
||||
to: "server/src/services/live-events.ts"
|
||||
via: "publishLiveEvent content_job.progress"
|
||||
pattern: "content_job\\.progress"
|
||||
- from: "server/src/services/renderers/presentation-renderer.ts"
|
||||
to: "server/src/services/puter-inference.ts"
|
||||
via: "puterChatComplete for slide JSON generation"
|
||||
pattern: "puterChatComplete"
|
||||
- from: "server/src/services/renderers/presentation-renderer.ts"
|
||||
to: "server/src/services/renderers/diagram-renderer.ts"
|
||||
via: "resolveBrowserPath import"
|
||||
pattern: "resolveBrowserPath"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the presentation renderer that generates slide JSON via LLM and renders it to MP4 via Remotion, publishing SSE progress events throughout the render.
|
||||
|
||||
Purpose: This is the core rendering pipeline for PRES-01 through PRES-04 — it connects the LLM (slide content generation), Remotion (video rendering), and SSE (progress reporting).
|
||||
Output: server/src/services/renderers/presentation-renderer.ts
|
||||
</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
|
||||
@server/src/services/renderers/diagram-renderer.ts (resolveBrowserPath pattern)
|
||||
@server/src/services/renderers/brand-renderer.ts (LLM prompt pattern)
|
||||
@server/src/services/puter-inference.ts (puterChatComplete interface)
|
||||
@server/src/services/live-events.ts (publishLiveEvent interface)
|
||||
|
||||
<interfaces>
|
||||
From packages/content-renderer/src/index.ts (created in Plan 01):
|
||||
```typescript
|
||||
export async function getBundlePath(): Promise<string>;
|
||||
export async function renderPresentationComposition(opts: {
|
||||
serveUrl: string;
|
||||
input: Record<string, unknown>;
|
||||
onProgress: (progress: number) => void;
|
||||
browserExecutable?: string;
|
||||
}): Promise<{ buffer: Buffer; durationInFrames: number; fps: number; inputProps: Record<string, unknown> }>;
|
||||
```
|
||||
|
||||
From server/src/services/renderers/types.ts (updated in 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>;
|
||||
}
|
||||
export interface RenderResult {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/services/puter-inference.ts:
|
||||
```typescript
|
||||
export async function puterChatComplete(messages: ChatMessage[], model?: string): Promise<string>;
|
||||
```
|
||||
|
||||
From server/src/services/live-events.ts:
|
||||
```typescript
|
||||
export function publishLiveEvent(input: { companyId: string; type: LiveEventType; payload?: Record<string, unknown> }): LiveEvent;
|
||||
```
|
||||
|
||||
From server/src/services/renderers/diagram-renderer.ts:
|
||||
```typescript
|
||||
export function resolveBrowserPath(): string;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create presentation-renderer with LLM slide generation and Remotion render pipeline</name>
|
||||
<files>server/src/services/renderers/presentation-renderer.ts</files>
|
||||
<read_first>
|
||||
- server/src/services/renderers/brand-renderer.ts (full — LLM system prompt + JSON parse pattern)
|
||||
- server/src/services/renderers/wallpaper-renderer.ts (first 60 lines — simpler LLM prompt pattern)
|
||||
- server/src/services/renderers/diagram-renderer.ts (lines 90-107 — resolveBrowserPath)
|
||||
- server/src/services/puter-inference.ts (first 30 lines — puterChatComplete signature)
|
||||
- packages/content-renderer/src/compositions/PitchDeck.tsx (Slide interface)
|
||||
- packages/content-renderer/src/compositions/DemoVideo.tsx (DemoSlide interface)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `server/src/services/renderers/presentation-renderer.ts`.
|
||||
|
||||
**Exports:** `renderPresentation(input: Record<string, unknown>, companyId: string, jobId: string): Promise<RenderResult>`
|
||||
|
||||
**Flow:**
|
||||
1. Extract `input.prompt` (string), `input.videoType` ("pitch-deck" | "demo-video", default "pitch-deck"), `input.title` (optional string).
|
||||
2. Call `puterChatComplete` with a system prompt that instructs the LLM to generate slide content as a JSON array. Use two different system prompts depending on `videoType`:
|
||||
|
||||
For "pitch-deck": System prompt asks the LLM to generate a JSON array of `Slide` objects: `[{ "title": "...", "body": "...", "accent": "#hex" }]`. The prompt should request 6-10 slides covering: title slide, problem, solution, market, business model, traction, team, ask/closing. Each slide should have a distinct accent color.
|
||||
|
||||
For "demo-video": System prompt asks the LLM to generate a JSON array of `DemoSlide` objects: `[{ "title": "...", "content": "...", "narration": "..." }]`. Request 4-8 slides covering: intro, key features, demo walkthrough, conclusion. Each slide should have a narration script.
|
||||
|
||||
Use a helper `stripMarkdownFences(text: string): string` (define locally, same pattern as pdf-renderer) to clean LLM output before JSON.parse.
|
||||
|
||||
3. Parse LLM response as JSON. Wrap in try/catch — if parse fails, throw with a clear error including the raw response.
|
||||
|
||||
4. Build `inputProps` for Remotion: For pitch-deck: `{ slides, companyName: input.title || "Presentation" }`. For demo-video: `{ slides, title: input.title || "Demo" }`.
|
||||
|
||||
5. Call the content-renderer package via dynamic import:
|
||||
```typescript
|
||||
const { getBundlePath, renderPresentationComposition } = await import("@paperclipai/content-renderer");
|
||||
```
|
||||
|
||||
6. Resolve browser path: import `resolveBrowserPath` from `./diagram-renderer.js`. Wrap in try/catch — if Playwright Chromium not found, pass `undefined` (Remotion will auto-download).
|
||||
|
||||
7. Call `renderPresentationComposition({ serveUrl: await getBundlePath(), input: inputProps, onProgress, browserExecutable })` where `onProgress` publishes:
|
||||
```typescript
|
||||
publishLiveEvent({
|
||||
companyId,
|
||||
type: "content_job.progress",
|
||||
payload: { jobId, progress },
|
||||
});
|
||||
```
|
||||
|
||||
8. Build `PresentationBundle` from the result:
|
||||
```typescript
|
||||
const bundle: PresentationBundle = {
|
||||
type: "presentation-bundle",
|
||||
presentationType: videoType === "demo" ? "demo-video" : "pitch-deck",
|
||||
title: input.title as string || "Presentation",
|
||||
slideCount: slides.length,
|
||||
durationInFrames: result.durationInFrames,
|
||||
fps: result.fps,
|
||||
mp4Base64: result.buffer.toString("base64"),
|
||||
inputProps,
|
||||
};
|
||||
```
|
||||
|
||||
9. Return `RenderResult`: `{ filename: title.mp4, contentType: "application/json", buffer: Buffer.from(JSON.stringify(bundle)) }`. Use `application/json` as contentType because the bundle contains both the MP4 (base64) and metadata — same pattern as brand-renderer returns JSON bundles.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -c "renderPresentation" server/src/services/renderers/presentation-renderer.ts && grep -c "publishLiveEvent" server/src/services/renderers/presentation-renderer.ts && grep -c "content_job.progress" server/src/services/renderers/presentation-renderer.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "export async function renderPresentation" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "puterChatComplete" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "content_job.progress" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "publishLiveEvent" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "getBundlePath" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "renderPresentationComposition" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "resolveBrowserPath" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "concurrency" server/src/services/renderers/presentation-renderer.ts || grep -q "concurrency" packages/content-renderer/src/index.ts
|
||||
- grep -q "stripMarkdownFences" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "pitch-deck" server/src/services/renderers/presentation-renderer.ts
|
||||
- grep -q "demo-video" server/src/services/renderers/presentation-renderer.ts
|
||||
</acceptance_criteria>
|
||||
<done>presentation-renderer.ts generates slide JSON via LLM, renders via Remotion with concurrency:1, publishes SSE progress events, reuses Playwright browser, returns PresentationBundle with mp4Base64 and inputProps</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Verify server tsc compilation with presentation renderer wired</name>
|
||||
<files>server/src/services/content-job-runner.ts</files>
|
||||
<read_first>
|
||||
- server/src/services/content-job-runner.ts (verify the "presentation" case was added in Plan 01)
|
||||
</read_first>
|
||||
<action>
|
||||
Verify that the full server compiles with the new presentation renderer. Run `pnpm --filter @paperclipai/server exec -- npx tsc --noEmit`. If there are TypeScript errors:
|
||||
- If errors relate to the dynamic import of `@paperclipai/content-renderer`, ensure the workspace package is properly linked and its types are resolvable. May need to add `@paperclipai/content-renderer` as a dependency in `server/package.json` using `pnpm --filter @paperclipai/server add @paperclipai/content-renderer@workspace:*`.
|
||||
- If errors relate to `content_job.progress` not being in `LiveEventType`, verify Plan 01 Task 2 was completed (check `packages/shared/src/constants.ts`).
|
||||
- Fix any type mismatches between the renderer return type and `RenderResult`.
|
||||
|
||||
Also run `pnpm --filter @paperclipai/shared exec -- npx tsc --noEmit` to verify shared package compiles.
|
||||
|
||||
Do NOT attempt to run the actual Remotion render (that requires the full bundle pipeline) — just verify TypeScript compilation.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && pnpm --filter @paperclipai/shared exec -- npx tsc --noEmit 2>&1 | tail -3</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- pnpm --filter @paperclipai/shared exec -- npx tsc --noEmit exits 0 or shows no errors
|
||||
</acceptance_criteria>
|
||||
<done>Server and shared packages compile without TypeScript errors, presentation renderer is type-correct</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `grep -q "renderPresentation" server/src/services/renderers/presentation-renderer.ts` confirms renderer exists
|
||||
- `grep -q "content_job.progress" server/src/services/renderers/presentation-renderer.ts` confirms SSE progress
|
||||
- `grep -q "puterChatComplete" server/src/services/renderers/presentation-renderer.ts` confirms LLM integration
|
||||
- `pnpm --filter @paperclipai/shared exec -- npx tsc --noEmit` passes
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- presentation-renderer.ts exists with renderPresentation export
|
||||
- LLM generates slide JSON for both pitch-deck and demo-video types (PRES-01, PRES-03)
|
||||
- Remotion renderMedia is called with concurrency:1 (PRES-02)
|
||||
- onProgress publishes content_job.progress SSE events (PRES-04)
|
||||
- browserExecutable reuses Playwright Chromium
|
||||
- PresentationBundle includes mp4Base64 and inputProps for interactive Player replay
|
||||
- TypeScript compilation passes for server and shared packages
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/44-video-presentations/44-02-SUMMARY.md`
|
||||
</output>
|
||||
117
.planning/phases/44-video-presentations/44-02-SUMMARY.md
Normal file
117
.planning/phases/44-video-presentations/44-02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
---
|
||||
phase: 44-video-presentations
|
||||
plan: 02
|
||||
subsystem: content-renderer
|
||||
tags: [remotion, video, presentations, typescript, llm, sse, puter-inference]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 44-video-presentations/44-01
|
||||
provides: packages/content-renderer workspace package, getBundlePath, renderPresentationComposition, PresentationBundle type, presentation case in content-job-runner stub
|
||||
- phase: 40-job-infrastructure
|
||||
provides: content_jobs table, SSE live events, publishLiveEvent
|
||||
provides:
|
||||
- renderPresentation function — LLM slide JSON generation + Remotion MP4 render + SSE progress events
|
||||
- server/src/types/content-renderer.d.ts — ambient module declaration isolating Remotion JSX from server tsc
|
||||
affects:
|
||||
- 44-video-presentations/44-03 (UI PresentationPanel consumes PresentationBundle returned by this renderer)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Ambient module declaration (content-renderer.d.ts) isolates Remotion/React JSX from server tsc context — force-added with git add -f following express.d.ts precedent
|
||||
- Dynamic import pattern: await import("@paperclipai/content-renderer") at runtime keeps webpack/rspack out of server tsc compilation
|
||||
- stripMarkdownFences local helper (same pattern as pdf-renderer and brand-renderer) cleans LLM output before JSON.parse
|
||||
- try/catch around resolveBrowserPath — falls back to undefined so Remotion auto-downloads Chromium if needed
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- server/src/services/renderers/presentation-renderer.ts
|
||||
- server/src/types/content-renderer.d.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Ambient module declaration in server/src/types/content-renderer.d.ts provides type safety for dynamic import without pulling JSX composition files into server tsc context"
|
||||
- "content-renderer NOT added as workspace dep in server/package.json — symlink in node_modules causes tsc to walk JSX source files even with dynamic import; ambient declaration is sufficient"
|
||||
- "resolveBrowserPath wrapped in try/catch — falls back to undefined so Remotion auto-downloads Chromium if Playwright binary not found"
|
||||
- "videoType 'demo-video' or 'demo' both map to DemoVideo composition — content-renderer uses 'demo' internally, renderer accepts both"
|
||||
|
||||
patterns-established:
|
||||
- "Ambient module declaration pattern: server/src/types/*.d.ts for packages whose source includes JSX/webpack contexts (force-add to bypass gitignore)"
|
||||
|
||||
requirements-completed: [PRES-01, PRES-02, PRES-03, PRES-04]
|
||||
|
||||
# Metrics
|
||||
duration: 12min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 44 Plan 02: Video Presentations — Presentation Renderer Summary
|
||||
|
||||
**renderPresentation function with LLM pitch-deck/demo-video slide generation (puterChatComplete), Remotion MP4 rendering (concurrency:1 via content-renderer), and SSE content_job.progress events**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~12 min
|
||||
- **Started:** 2026-04-04T23:26:00Z
|
||||
- **Completed:** 2026-04-04T23:38:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Created `server/src/services/renderers/presentation-renderer.ts` — replaces stub from plan 01 with full implementation: LLM generates 6-10 pitch-deck slides or 4-8 demo-video slides as structured JSON, Remotion renders to MP4 Buffer via dynamic import of content-renderer, SSE progress events published via publishLiveEvent
|
||||
- Created `server/src/types/content-renderer.d.ts` — ambient module declaration providing type safety for the dynamic import without pulling React/JSX composition files into server tsc context (no jsx option in server tsconfig)
|
||||
- Both server and shared packages compile without TypeScript errors
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Create presentation-renderer with LLM slide generation and Remotion render pipeline** - `161ff9cf` (feat)
|
||||
2. **Task 2: Verify server tsc compilation with presentation renderer wired** - `28a8d63d` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `server/src/services/renderers/presentation-renderer.ts` — Full renderPresentation implementation: LLM slide JSON → Remotion MP4 → PresentationBundle JSON
|
||||
- `server/src/types/content-renderer.d.ts` — Ambient declaration for @paperclipai/content-renderer (force-added, bypasses gitignore)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Ambient module declaration instead of workspace dep: adding `@paperclipai/content-renderer` as a workspace dep caused TypeScript to resolve the package source (package.json exports `./src/index.ts`) and walk into JSX files, triggering `--jsx is not set` errors. The ambient declaration in `server/src/types/` provides correct types without the source resolution.
|
||||
- `resolveBrowserPath` wrapped in try/catch — if Playwright Chromium is not installed, `browserExecutable` is `undefined` and Remotion will auto-download Chromium (acceptable fallback for first-time setup).
|
||||
- Both `"demo-video"` and `"demo"` input values for `videoType` map to the DemoVideo composition — content-renderer internally uses `videoType === "demo"` to select the composition.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Ambient module declaration to resolve tsc JSX incompatibility**
|
||||
- **Found during:** Task 2 (TypeScript compilation verification)
|
||||
- **Issue:** Adding `@paperclipai/content-renderer` as workspace dependency caused tsc to resolve the package's source files (package.json maps exports to `./src/index.ts`), which includes React JSX compositions. Server tsconfig has no `jsx` option, resulting in `TS6142: Module './PitchDeck' was resolved to PitchDeck.tsx but --jsx is not set` errors. This blocked successful compilation.
|
||||
- **Fix:** Removed `@paperclipai/content-renderer` from server/package.json and created `server/src/types/content-renderer.d.ts` as an ambient module declaration. This provides type safety for the dynamic import without TypeScript resolving the JSX source files. Force-added with `git add -f` (file would otherwise be excluded by `server/src/**/*.d.ts` gitignore pattern — same approach used for `express.d.ts`).
|
||||
- **Files modified:** `server/src/types/content-renderer.d.ts` (created), `server/package.json` (reverted content-renderer dep)
|
||||
- **Verification:** `pnpm --filter @paperclipai/server exec -- npx tsc --noEmit` exits 0
|
||||
- **Committed in:** `28a8d63d` (Task 2 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking — tsc JSX incompatibility)
|
||||
**Impact on plan:** Required for correct TypeScript compilation. The ambient declaration pattern is the correct architectural solution for Remotion isolation. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Parallel agent had already executed plan 44-03 by the time Task 2 was being completed. The `content-renderer.d.ts` ambient declaration was committed as a new file — it did not conflict with plan 44-03 work (which touched UI files only).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — `renderPresentation` is fully implemented; no stubs remain.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- `renderPresentation` is fully wired: LLM → slides JSON → Remotion → MP4 → PresentationBundle
|
||||
- Plan 44-03 (UI) already executed in parallel — PresentationPanel consumes PresentationBundle via the content-job SSE pattern
|
||||
- TypeScript compilation passes for server and shared packages
|
||||
|
||||
---
|
||||
*Phase: 44-video-presentations*
|
||||
*Completed: 2026-04-04*
|
||||
265
.planning/phases/44-video-presentations/44-03-PLAN.md
Normal file
265
.planning/phases/44-video-presentations/44-03-PLAN.md
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
---
|
||||
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>
|
||||
116
.planning/phases/44-video-presentations/44-03-SUMMARY.md
Normal file
116
.planning/phases/44-video-presentations/44-03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
phase: 44-video-presentations
|
||||
plan: 03
|
||||
subsystem: ui
|
||||
tags: [remotion, video, presentations, react, sse, progress, content-studio]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 44-video-presentations/44-01
|
||||
provides: PresentationBundle interface, content_job.progress LiveEventType, presentation case in content-job-runner
|
||||
- phase: 44-video-presentations/44-02
|
||||
provides: Server-side presentation renderer emitting SSE progress events
|
||||
provides:
|
||||
- useContentJob extended to read fine-grained data.progress from SSE events
|
||||
- PresentationPanel component with prompt, type selector, real-time progress, MP4 player
|
||||
- ContentStudio Presentations tab wiring PresentationPanel
|
||||
affects:
|
||||
- Any future content studio panels using useContentJob (can use fine-grained progress)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fine-grained SSE progress: data.progress preferred over coarse statusToProgress fallback"
|
||||
- "mp4Base64 -> blob URL via useMemo with URL.revokeObjectURL cleanup in useEffect"
|
||||
- "Panel pattern: useContentJob + getContentJobAsset fetch on job.status === done"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ui/src/components/PresentationPanel.tsx
|
||||
modified:
|
||||
- ui/src/hooks/useContentJob.ts
|
||||
- ui/src/pages/ContentStudio.tsx
|
||||
|
||||
key-decisions:
|
||||
- "data.progress preferred over statusToProgress when typeof data.progress === 'number' — backward-compatible for all non-video job types"
|
||||
- "mp4Base64 blob URL created in useMemo and cleaned up via useEffect return — avoids memory leaks across rerenders"
|
||||
- "PresentationBundle interface defined locally in PresentationPanel — avoids cross-package import from server types"
|
||||
|
||||
patterns-established:
|
||||
- "Fine-grained SSE progress reading: check typeof data.progress === 'number' before falling back to coarse status mapping"
|
||||
- "Video blob URL lifecycle: useMemo for creation, useEffect cleanup for revocation"
|
||||
|
||||
requirements-completed: [PRES-01, PRES-02, PRES-03, PRES-04]
|
||||
|
||||
# Metrics
|
||||
duration: 5min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 44 Plan 03: Video Presentations — UI Panel Summary
|
||||
|
||||
**PresentationPanel with real-time SSE progress bar, MP4 video player + download, and Presentations tab in ContentStudio — backed by fine-grained data.progress in useContentJob**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~5 min
|
||||
- **Started:** 2026-04-04T23:28:00Z
|
||||
- **Completed:** 2026-04-04T23:33:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Extended `useContentJob` SSE handler to read `data.progress` (number 0-100) from status events, falling back to coarse `statusToProgress` for non-video job types
|
||||
- Created `PresentationPanel` (199 lines) with prompt textarea, pitch-deck/demo-video selector, real-time progress bar with percentage text, MP4 video player with blob URL lifecycle management, and download + generate-another actions
|
||||
- Added Presentations tab to ContentStudio as the 8th tab, wired to PresentationPanel
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Extend useContentJob to read fine-grained SSE progress** - `f088090a` (feat)
|
||||
2. **Task 2: Create PresentationPanel and add Presentations tab to ContentStudio** - `1fb26bfb` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `ui/src/hooks/useContentJob.ts` - Added `progress?: number` to SSE data type; prefer `data.progress` over coarse `statusToProgress`
|
||||
- `ui/src/components/PresentationPanel.tsx` - Full panel: prompt, type selector, progress bar, video player, blob URL lifecycle
|
||||
- `ui/src/pages/ContentStudio.tsx` - Import PresentationPanel; add Presentations TabsTrigger and TabsContent after Brand
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `data.progress` check uses `typeof data.progress === "number"` guard — prevents `0` (falsy) from triggering the fallback incorrectly
|
||||
- `mp4Base64` blob URL created in `useMemo([bundle?.mp4Base64])` and revoked in `useEffect` return — ensures revocation fires when URL changes or component unmounts
|
||||
- `PresentationBundle` interface defined locally in `PresentationPanel.tsx` — avoids importing server-side types into the UI package
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Pre-existing TypeScript errors in `ContentStudio.tsx` (themeJob.bundle reference) and `PersonalAssistant.tsx` (ToastTone type mismatch) exist independently of this plan's changes. No new errors were introduced by this plan. Logged as deferred items (out-of-scope per deviation rule boundary).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- All four PRES requirements (PRES-01 through PRES-04) are complete across plans 01-03
|
||||
- Phase 44 is complete: Remotion workspace (plan 01), server renderer (plan 02), UI panel (plan 03)
|
||||
- ContentStudio now has 8 tabs: Diagrams, Icons, Themes, Wallpapers, Social, Documents, Brand, Presentations
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ui/src/components/PresentationPanel.tsx
|
||||
- FOUND: ui/src/hooks/useContentJob.ts (modified)
|
||||
- FOUND: ui/src/pages/ContentStudio.tsx (modified)
|
||||
- FOUND: .planning/phases/44-video-presentations/44-03-SUMMARY.md
|
||||
- FOUND commit: f088090a (Task 1)
|
||||
- FOUND commit: 1fb26bfb (Task 2)
|
||||
- FOUND commit: 60e94e51 (metadata)
|
||||
|
||||
---
|
||||
*Phase: 44-video-presentations*
|
||||
*Completed: 2026-04-04*
|
||||
41
.planning/phases/44-video-presentations/44-CONTEXT.md
Normal file
41
.planning/phases/44-video-presentations/44-CONTEXT.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Phase 44: Video & Presentations - Context
|
||||
|
||||
**Gathered:** 2026-04-04
|
||||
**Status:** Ready for planning
|
||||
**Mode:** Auto-generated (discuss skipped via workflow.skip_discuss)
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Agents can produce pitch deck presentations and demo videos rendered by Remotion from a conversation, with SSE progress updates throughout the render — which may take several minutes on the M4
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Claude's Discretion
|
||||
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
|
||||
|
||||
</decisions>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
Codebase context will be gathered during plan-phase research.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
No specific requirements — discuss phase skipped. Refer to ROADMAP phase description and success criteria.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discuss phase skipped.
|
||||
|
||||
</deferred>
|
||||
600
.planning/phases/44-video-presentations/44-RESEARCH.md
Normal file
600
.planning/phases/44-video-presentations/44-RESEARCH.md
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
# Phase 44: Video & Presentations - Research
|
||||
|
||||
**Researched:** 2026-04-04
|
||||
**Domain:** Remotion server-side rendering, pitch deck composition, SSE render progress
|
||||
**Confidence:** HIGH
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
None — discuss phase was skipped. All implementation choices are at Claude's discretion.
|
||||
|
||||
### Claude's Discretion
|
||||
All implementation choices including package structure, composition layout, slide design patterns, render settings, and progress event granularity.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None specified.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| PRES-01 | User can generate pitch deck presentations from a conversation | LLM generates slide JSON; Remotion composition renders slides via `Series`; job infra from Phase 40 handles submission |
|
||||
| PRES-02 | System renders presentations via Remotion to interactive web or MP4 | `@remotion/player` for interactive web preview; `renderMedia()` with `codec: 'h264'` for MP4 export |
|
||||
| PRES-03 | User can generate demo and explainer videos from conversation content | Same renderer, different composition (e.g., `VideoPresentation` vs `PitchDeck`); LLM generates narration script and slide sequence |
|
||||
| PRES-04 | System shows render progress via SSE during video generation | `onProgress` callback in `renderMedia()` yields 0–1 progress; publish `content_job.progress` live event; extend shared constants and useContentJob hook |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 44 adds Remotion-powered video and presentation generation to the Content Studio. The core pattern follows all prior content phases: the UI submits a job via `POST /api/companies/:id/content-jobs`, the server renders asynchronously, and SSE events report status. Remotion adds two complications that don't exist in any prior renderer:
|
||||
|
||||
First, Remotion uses webpack (`@remotion/bundler` uses rspack internally) which must not enter the Vite/tsc server context. Per the locked architecture decision in STATE.md, Remotion must live in `packages/content-renderer/` as a dedicated workspace package. The server imports this package at runtime via dynamic import, keeping webpack out of the server's tsc compilation and Vite's bundling.
|
||||
|
||||
Second, Remotion renders can take several minutes. The current SSE infrastructure only emits lifecycle events (`queued`, `running`, `done`, `failed`). For PRES-04, we need to add a `content_job.progress` event type to `packages/shared/src/constants.ts` and wire `renderMedia()`'s `onProgress` callback to publish it. The `useContentJob` hook and SSE route already support arbitrary payload fields — we just need the new event type and a progress field in the payload.
|
||||
|
||||
**Primary recommendation:** Create `packages/content-renderer/` as a standalone Node ESM package containing the Remotion composition definitions and a `renderPresentation()` export. The server's `presentation-renderer.ts` calls this package's export. Bundle once at server startup and cache the path in a module-level variable.
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| remotion | 4.0.445 | Composition model, timing API (`useCurrentFrame`, `Series`, `Sequence`) | Core Remotion library; required by all Remotion packages |
|
||||
| @remotion/bundler | 4.0.445 | `bundle()` — compiles composition to a webpack/rspack bundle | Required for server-side rendering workflow |
|
||||
| @remotion/renderer | 4.0.445 | `renderMedia()`, `selectComposition()` — headless render to MP4/buffer | Required for server-side rendering workflow |
|
||||
| @remotion/player | 4.0.445 | React `<Player>` component for interactive web preview | Required for PRES-02 interactive web output |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| @remotion/compositor-linux-x64-gnu | 4.0.445 | Rust compositor binary for frame stitching/encoding on Linux x64 | Installed automatically as optional dependency of @remotion/renderer |
|
||||
| react + react-dom | (already in workspace) | Remotion compositions are React components | peerDependency of remotion; reuse workspace versions |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| packages/content-renderer/ workspace package | Inline in server/ | Workspace package prevents webpack from entering tsc/Vite context; inline import would corrupt tsc compilation |
|
||||
| @remotion/renderer (renderMedia) | Playwright headless screenshot-to-video | renderMedia has native frame-accurate encoding; screenshot-based approach lacks timing primitives |
|
||||
| Series + Sequence for slides | Custom timing logic | Series handles cumulative durationInFrames automatically; custom timing is error-prone |
|
||||
|
||||
**Installation (packages/content-renderer/):**
|
||||
```bash
|
||||
cd packages/content-renderer
|
||||
pnpm add remotion @remotion/bundler @remotion/renderer @remotion/player react react-dom
|
||||
```
|
||||
|
||||
**Installation (server/ — presenter renderer only needs the package's export):**
|
||||
```bash
|
||||
# No new server deps — server imports from packages/content-renderer via workspace
|
||||
```
|
||||
|
||||
**Installation (ui/ — for @remotion/player interactive preview):**
|
||||
```bash
|
||||
cd ui
|
||||
pnpm add @remotion/player
|
||||
```
|
||||
|
||||
**Version verification (confirmed 2026-04-04):**
|
||||
```bash
|
||||
npm view remotion version # 4.0.445
|
||||
npm view @remotion/renderer version # 4.0.445
|
||||
npm view @remotion/bundler version # 4.0.445
|
||||
npm view @remotion/player version # 4.0.445
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
packages/content-renderer/ # NEW: webpack-isolated Remotion workspace package
|
||||
├── package.json # type: "commonjs" (rspack/webpack requirement)
|
||||
├── src/
|
||||
│ ├── index.ts # Exports: renderPresentation(), BUNDLE_PATH singleton
|
||||
│ ├── Root.tsx # registerRoot() entry — registers all compositions
|
||||
│ ├── compositions/
|
||||
│ │ ├── PitchDeck.tsx # Pitch deck composition (Series of SlideFrame)
|
||||
│ │ └── DemoVideo.tsx # Demo/explainer video composition
|
||||
│ └── components/
|
||||
│ ├── SlideFrame.tsx # Individual slide with title, body, visual
|
||||
│ └── TitleSlide.tsx # Opening title slide variant
|
||||
│
|
||||
server/src/services/renderers/
|
||||
└── presentation-renderer.ts # NEW: calls packages/content-renderer renderPresentation()
|
||||
|
||||
server/src/services/
|
||||
└── content-job-runner.ts # EXTEND: add "presentation" case
|
||||
|
||||
ui/src/components/
|
||||
└── PresentationPanel.tsx # NEW: matches BrandKitPanel pattern
|
||||
ui/src/pages/
|
||||
└── ContentStudio.tsx # EXTEND: add "presentations" tab
|
||||
```
|
||||
|
||||
### Pattern 1: Bundle Once at Startup
|
||||
**What:** Call `bundle()` once when the server starts, store the returned path in a module-level variable, pass it to every `renderMedia()` call.
|
||||
**When to use:** Always — multiple renders share the same bundle. Re-bundling per render is an anti-pattern.
|
||||
**Example:**
|
||||
```typescript
|
||||
// packages/content-renderer/src/index.ts
|
||||
// Source: https://www.remotion.dev/docs/bundle
|
||||
import { bundle } from "@remotion/bundler";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let cachedBundlePath: string | null = null;
|
||||
|
||||
export async function getBundlePath(): Promise<string> {
|
||||
if (cachedBundlePath) return cachedBundlePath;
|
||||
cachedBundlePath = await bundle({
|
||||
entryPoint: path.resolve(__dirname, "Root.tsx"),
|
||||
// rspack: true // can enable for faster bundling
|
||||
});
|
||||
return cachedBundlePath;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Render with Progress SSE
|
||||
**What:** `renderMedia()` accepts an `onProgress` callback that fires with percentage (0–1). We call `publishLiveEvent` with `content_job.progress` from inside that callback.
|
||||
**When to use:** All video/MP4 renders. Progress is meaningful because renders can take several minutes.
|
||||
**Example:**
|
||||
```typescript
|
||||
// server/src/services/renderers/presentation-renderer.ts
|
||||
// Source: https://www.remotion.dev/docs/renderer/render-media
|
||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||
import { getBundlePath } from "@paperclipai/content-renderer";
|
||||
import { publishLiveEvent } from "../live-events.js";
|
||||
|
||||
export async function renderPresentation(
|
||||
input: Record<string, unknown>,
|
||||
companyId: string,
|
||||
jobId: string,
|
||||
): Promise<RenderResult> {
|
||||
const serveUrl = await getBundlePath();
|
||||
const compositionId = input.videoType === "demo" ? "DemoVideo" : "PitchDeck";
|
||||
|
||||
const composition = await selectComposition({
|
||||
serveUrl,
|
||||
id: compositionId,
|
||||
inputProps: input,
|
||||
});
|
||||
|
||||
const { buffer } = await renderMedia({
|
||||
composition,
|
||||
serveUrl,
|
||||
codec: "h264",
|
||||
inputProps: input,
|
||||
concurrency: 1, // cap concurrency — renders alongside LLM
|
||||
onProgress: ({ progress }) => {
|
||||
publishLiveEvent({
|
||||
companyId,
|
||||
type: "content_job.progress",
|
||||
payload: { jobId, progress: Math.round(progress * 100) },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ... build bundle and return RenderResult
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Composition with Series
|
||||
**What:** Each slide is a `Series.Sequence`; total `durationInFrames` is computed by summing slide durations.
|
||||
**When to use:** Any multi-slide sequence where timing must be sequential without manual `from` calculations.
|
||||
**Example:**
|
||||
```typescript
|
||||
// packages/content-renderer/src/compositions/PitchDeck.tsx
|
||||
// Source: https://www.remotion.dev/docs/series
|
||||
import { AbsoluteFill, Series } from "remotion";
|
||||
import { SlideFrame } from "../components/SlideFrame";
|
||||
|
||||
const FRAMES_PER_SLIDE = 90; // 3 seconds at 30fps
|
||||
|
||||
export interface Slide {
|
||||
title: string;
|
||||
body: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
interface PitchDeckProps {
|
||||
slides: Slide[];
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export const PitchDeck: React.FC<PitchDeckProps> = ({ slides, companyName }) => (
|
||||
<AbsoluteFill style={{ background: "#0f0f0f" }}>
|
||||
<Series>
|
||||
{slides.map((slide, i) => (
|
||||
<Series.Sequence key={i} durationInFrames={FRAMES_PER_SLIDE}>
|
||||
<SlideFrame slide={slide} slideIndex={i} total={slides.length} />
|
||||
</Series.Sequence>
|
||||
))}
|
||||
</Series>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 4: Root Registration
|
||||
**What:** `registerRoot()` must be called in the entry point file that `bundle()` points to.
|
||||
**When to use:** Exactly once, in the entry file.
|
||||
**Example:**
|
||||
```typescript
|
||||
// packages/content-renderer/src/Root.tsx
|
||||
import { Composition } from "remotion";
|
||||
import { registerRoot } from "remotion";
|
||||
import { PitchDeck } from "./compositions/PitchDeck";
|
||||
import { DemoVideo } from "./compositions/DemoVideo";
|
||||
|
||||
const FRAMES_PER_SLIDE = 90;
|
||||
|
||||
export const RemotionRoot: React.FC = () => (
|
||||
<>
|
||||
<Composition
|
||||
id="PitchDeck"
|
||||
component={PitchDeck}
|
||||
width={1920}
|
||||
height={1080}
|
||||
fps={30}
|
||||
durationInFrames={FRAMES_PER_SLIDE * 8} // default; overridden by calculateMetadata
|
||||
defaultProps={{ slides: [], companyName: "" }}
|
||||
/>
|
||||
<Composition
|
||||
id="DemoVideo"
|
||||
component={DemoVideo}
|
||||
width={1920}
|
||||
height={1080}
|
||||
fps={30}
|
||||
durationInFrames={FRAMES_PER_SLIDE * 6}
|
||||
defaultProps={{ slides: [], title: "" }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
```
|
||||
|
||||
### Pattern 5: Interactive Web Preview with @remotion/player
|
||||
**What:** `<Player>` renders the same React composition interactively in the browser without encoding.
|
||||
**When to use:** The UI "interactive web" output mode for PRES-02.
|
||||
**Example:**
|
||||
```typescript
|
||||
// ui/src/components/PresentationPanel.tsx (result section)
|
||||
import { Player } from "@remotion/player";
|
||||
import { PitchDeck } from "@paperclipai/content-renderer";
|
||||
|
||||
<Player
|
||||
component={PitchDeck}
|
||||
inputProps={bundle.inputProps}
|
||||
durationInFrames={bundle.durationInFrames}
|
||||
fps={30}
|
||||
compositionWidth={1920}
|
||||
compositionHeight={1080}
|
||||
style={{ width: "100%", aspectRatio: "16/9" }}
|
||||
controls
|
||||
/>
|
||||
```
|
||||
|
||||
**NOTE:** This requires `PitchDeck` to be importable from `content-renderer` by the Vite build. The composition components (no `bundle()` / `renderMedia()` — those are server-only) can be re-exported from the package with a UI-safe entrypoint that does NOT import `@remotion/bundler` or `@remotion/renderer`.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Calling `bundle()` per render:** Triggers a full webpack compilation each time (~30s+). Bundle once at startup and cache the path.
|
||||
- **Importing `@remotion/bundler` or `@remotion/renderer` in the server's tsc entry:** These packages pull in webpack. Add to `packages/content-renderer/` only; server imports via `@paperclipai/content-renderer` workspace reference.
|
||||
- **Importing webpack-containing code in Vite context:** `@remotion/bundler` will break Vite if imported by any UI module. Composition components should be split into a UI-safe sub-export that only imports from `remotion` (not `@remotion/bundler`).
|
||||
- **Setting `concurrency` too high:** Video rendering and LLM inference compete for CPU. Use `concurrency: 1` or `"50%"` max per the success criteria SC4.
|
||||
- **Omitting `content_job.progress` from shared/constants.ts:** TypeScript will reject the event type unless it's added to the `LIVE_EVENT_TYPES` array.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Frame timing for multi-slide decks | Custom timing calculator | `Series` component | Series handles cumulative `from` calculation; manual calculation has off-by-one errors |
|
||||
| Video encoding (H.264, WebM) | ffmpeg subprocess with raw frames | `renderMedia()` | Remotion handles frame rendering + encoding pipeline; direct ffmpeg needs frame extraction first |
|
||||
| Webpack bundling of React compositions | Custom webpack config | `bundle()` | Remotion's bundler has composition-specific optimizations and correct env setup |
|
||||
| Progress percentage calculation | Polling DB status | `renderMedia()` onProgress callback | onProgress fires per-frame with accurate float; polling is coarse |
|
||||
| Browser management for render | Manual Puppeteer setup | Remotion auto-manages via `openBrowser()` / auto-download | Remotion downloads its own compatible Chromium (or reuses via browserExecutable); mismatched versions cause render failures |
|
||||
|
||||
**Key insight:** Remotion is a complete render pipeline. The only custom code needed is React composition components (what each slide looks like) and the LLM prompt that generates the `inputProps` JSON.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Webpack/Rspack in tsc/Vite Context
|
||||
**What goes wrong:** `@remotion/bundler` imports webpack which breaks tsc strict mode and crashes Vite's dev server.
|
||||
**Why it happens:** pnpm workspace hoisting can make `@remotion/bundler` visible to the server package's tsc even if it's only listed as a dep of `content-renderer`.
|
||||
**How to avoid:** Keep `@remotion/bundler` and `@remotion/renderer` ONLY in `packages/content-renderer/package.json`. Import from `@paperclipai/content-renderer` using a dynamic import in `presentation-renderer.ts`. Add `@remotion/bundler` to server tsconfig `exclude` if needed.
|
||||
**Warning signs:** `tsc` errors referencing webpack types; Vite startup errors about `require` in webpack modules.
|
||||
|
||||
### Pitfall 2: Bundle Path Not Persisted Across Hot Reloads
|
||||
**What goes wrong:** In dev mode (`tsx watch`), module-level variables reset on each hot reload, triggering a new `bundle()` call every time a file changes — O(30s) per save.
|
||||
**Why it happens:** `tsx` re-executes module code on watch. A plain module-level `let cachedBundlePath` is cleared.
|
||||
**How to avoid:** Write the bundle path to a temp file on first run and re-read it on subsequent startups. Or accept the re-bundle in dev (not blocking, just slow).
|
||||
**Warning signs:** Server logs showing "Bundling..." on every file save.
|
||||
|
||||
### Pitfall 3: Missing `content_job.progress` in Shared Constants
|
||||
**What goes wrong:** `publishLiveEvent` TypeScript call fails to compile because `"content_job.progress"` is not in the `LiveEventType` union.
|
||||
**Why it happens:** `packages/shared/src/constants.ts` defines the exhaustive list. New event types must be added there.
|
||||
**How to avoid:** Add `"content_job.progress"` to `LIVE_EVENT_TYPES` array in shared/constants.ts as the FIRST change in Wave 1.
|
||||
**Warning signs:** TypeScript error `Argument of type '"content_job.progress"' is not assignable to parameter of type 'LiveEventType'`.
|
||||
|
||||
### Pitfall 4: useContentJob Progress Not Surfaced to UI
|
||||
**What goes wrong:** `content_job.progress` events arrive via SSE but the `useContentJob` hook ignores the `progress` field in the payload (it reads `data.status` but not `data.progress`).
|
||||
**Why it happens:** Current `statusToProgress()` maps lifecycle stages to coarse values (5/50/100). Fine-grained progress from Remotion needs explicit handling.
|
||||
**How to avoid:** Extend the `status` SSE event handler in `useContentJob` to check `data.progress` (number 0–100) and update the `progress` state field directly when `data.status === "running"`.
|
||||
**Warning signs:** Progress bar stays stuck at 50% throughout the entire render despite SSE events arriving.
|
||||
|
||||
### Pitfall 5: @remotion/player Import Pulls in Renderer in Vite
|
||||
**What goes wrong:** Importing `PitchDeck` composition component from `@paperclipai/content-renderer` in the UI inadvertently imports code that depends on `@remotion/bundler`.
|
||||
**Why it happens:** If `packages/content-renderer/src/index.ts` re-exports everything including `getBundlePath()` and `renderPresentation()`, Vite will try to bundle those imports and fail on webpack/Node internals.
|
||||
**How to avoid:** Use a separate UI-safe sub-export in content-renderer package: `"./compositions"` exports only React components; `"."` (default) exports `renderPresentation` and `getBundlePath`. The UI imports from `@paperclipai/content-renderer/compositions`; the server imports from `@paperclipai/content-renderer`.
|
||||
**Warning signs:** Vite build errors about `process.env`, `__dirname`, `require`, or webpack-related modules.
|
||||
|
||||
### Pitfall 6: Chromium Binary Conflict
|
||||
**What goes wrong:** Remotion auto-downloads its own Chrome Headless Shell while Playwright's Chromium is already at `~/.cache/ms-playwright`. Disk space wasted, possible version conflict.
|
||||
**Why it happens:** Remotion's default behavior is to auto-download a compatible browser.
|
||||
**How to avoid:** Pass `browserExecutable` pointing to the existing Playwright Chromium binary at `~/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome`. This is the same path that `resolveBrowserPath()` in `diagram-renderer.ts` already resolves.
|
||||
**Warning signs:** Remotion downloading ~300MB "chrome-headless-shell" at first render.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from official sources and codebase:
|
||||
|
||||
### Startup Bundle Initialization (server/src/index.ts)
|
||||
```typescript
|
||||
// Source: https://www.remotion.dev/docs/bundle (bundle once pattern)
|
||||
// Add near server startup sequence, after DB init
|
||||
|
||||
let remotionBundlePath: string | null = null;
|
||||
|
||||
async function initRemotionBundle(): Promise<void> {
|
||||
const { getBundlePath } = await import("@paperclipai/content-renderer");
|
||||
remotionBundlePath = await getBundlePath();
|
||||
logger.info({ path: remotionBundlePath }, "Remotion bundle ready");
|
||||
}
|
||||
|
||||
// Call during startup (non-blocking, fire and forget or await):
|
||||
void initRemotionBundle().catch((err) => {
|
||||
logger.warn({ err }, "Remotion bundle init failed — video rendering unavailable");
|
||||
});
|
||||
```
|
||||
|
||||
### Presentation Renderer (server/src/services/renderers/presentation-renderer.ts)
|
||||
```typescript
|
||||
// Source: https://www.remotion.dev/docs/renderer/render-media
|
||||
import type { RenderResult } from "./types.js";
|
||||
import { publishLiveEvent } from "../live-events.js";
|
||||
import { puterChatComplete } from "../puter-inference.js";
|
||||
|
||||
export async function renderPresentation(
|
||||
input: Record<string, unknown>,
|
||||
companyId: string,
|
||||
jobId: string,
|
||||
): Promise<RenderResult> {
|
||||
const { getBundlePath, renderPresentationComposition } = await import(
|
||||
"@paperclipai/content-renderer"
|
||||
);
|
||||
const result = await renderPresentationComposition({
|
||||
serveUrl: await getBundlePath(),
|
||||
input,
|
||||
onProgress: (progress: number) => {
|
||||
publishLiveEvent({
|
||||
companyId,
|
||||
type: "content_job.progress",
|
||||
payload: { jobId, progress },
|
||||
});
|
||||
},
|
||||
browserExecutable: resolveBrowserPath(), // reuse Playwright Chromium
|
||||
});
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### content_job.progress Event in Shared Constants (packages/shared/src/constants.ts)
|
||||
```typescript
|
||||
// Source: codebase constants.ts line 324
|
||||
export const LIVE_EVENT_TYPES = [
|
||||
"heartbeat.run.queued",
|
||||
"heartbeat.run.status",
|
||||
"heartbeat.run.event",
|
||||
"heartbeat.run.log",
|
||||
"agent.status",
|
||||
"activity.logged",
|
||||
"plugin.ui.updated",
|
||||
"plugin.worker.crashed",
|
||||
"plugin.worker.restarted",
|
||||
"content_job.queued",
|
||||
"content_job.running",
|
||||
"content_job.progress", // ADD THIS
|
||||
"content_job.done",
|
||||
"content_job.failed",
|
||||
] as const;
|
||||
```
|
||||
|
||||
### useContentJob Progress Extension (ui/src/hooks/useContentJob.ts)
|
||||
```typescript
|
||||
// Source: codebase ui/src/hooks/useContentJob.ts
|
||||
// Extend the status event handler to capture fine-grained progress:
|
||||
es.addEventListener("status", (e: MessageEvent) => {
|
||||
const data = JSON.parse(e.data as string) as {
|
||||
status?: string;
|
||||
progress?: number; // NEW: Remotion render progress 0–100
|
||||
resultAssetId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
const status = (data.status ?? "queued") as JobStatus;
|
||||
const progress =
|
||||
typeof data.progress === "number"
|
||||
? data.progress // fine-grained from Remotion onProgress
|
||||
: statusToProgress(status); // coarse fallback for non-video jobs
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status,
|
||||
progress,
|
||||
resultAssetId: data.resultAssetId ?? prev.resultAssetId,
|
||||
errorMessage: data.errorMessage ?? prev.errorMessage,
|
||||
}));
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### PresentationBundle Type (server/src/services/renderers/types.ts)
|
||||
```typescript
|
||||
// Follow existing bundle type pattern in types.ts
|
||||
export interface PresentationBundle {
|
||||
type: "presentation-bundle";
|
||||
presentationType: "pitch-deck" | "demo-video";
|
||||
title: string;
|
||||
slideCount: number;
|
||||
durationInFrames: number;
|
||||
fps: number;
|
||||
mp4Base64: string; // H.264 video output
|
||||
inputProps: Record<string, unknown>; // for Player re-render in UI
|
||||
}
|
||||
```
|
||||
|
||||
### content-job-runner.ts Extension
|
||||
```typescript
|
||||
// Source: codebase server/src/services/content-job-runner.ts
|
||||
// Add "presentation" case to renderContent() switch:
|
||||
case "presentation": {
|
||||
const { renderPresentation } = await import("./renderers/presentation-renderer.js");
|
||||
return renderPresentation(input, job.companyId, job.id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Webpack bundler | rspack (default in @remotion/bundler 4.x) | Remotion 4.0 | ~3x faster bundle compilation; same API |
|
||||
| Manual Puppeteer browser management | `openBrowser()` + `browserExecutable` | Remotion 3.x+ | Reuse existing browser binary |
|
||||
| Lambda-only SSR | Full Node.js API (`bundle()` + `renderMedia()`) | Remotion 2.x+ | Self-hosted rendering without cloud dependency |
|
||||
| webpack 4 | webpack 5.105 (via @remotion/bundler) | Remotion 4.x | Faster, better tree-shaking |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `renderFrames()` + manual encoding: Replaced by the unified `renderMedia()` which handles both frame rendering and encoding in one call.
|
||||
- `getCompositions()` for single render: Use `selectComposition()` instead — it evaluates only the target composition and throws if not found.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **packages/content-renderer/ module system: ESM vs CJS**
|
||||
- What we know: `@remotion/bundler` uses webpack/rspack internally; the `bundle()` function itself runs in Node. The rest of the server uses ESM (`"type": "module"`).
|
||||
- What's unclear: Whether `@remotion/bundler` requires CommonJS module context or works from ESM. The rspack integration uses `require()` internally.
|
||||
- Recommendation: Start with `"type": "commonjs"` in packages/content-renderer/package.json and use `.cjs` extension or omit `"type": "module"`. Export via named CJS exports; server's dynamic import handles the interop.
|
||||
|
||||
2. **Browser reuse: Playwright vs Remotion auto-download**
|
||||
- What we know: Playwright Chromium is at `~/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome`. Remotion's `browserExecutable` parameter accepts an absolute path.
|
||||
- What's unclear: Whether the Playwright Chromium version (1217 = Chrome 127 area) is compatible with what Remotion 4.0.445 expects.
|
||||
- Recommendation: Pass `browserExecutable` pointing to the existing Playwright Chromium. If Remotion rejects it (version mismatch), fall back to letting Remotion auto-download — but add a startup log warning about the extra disk use.
|
||||
|
||||
3. **Interactive web preview: share composition code with UI**
|
||||
- What we know: `@remotion/player` works in Vite. The composition components themselves (just React + remotion primitives) can be imported by Vite safely.
|
||||
- What's unclear: Whether a sub-export path like `@paperclipai/content-renderer/compositions` is the right pattern, or whether we just duplicate the slide components in the UI package.
|
||||
- Recommendation: Use sub-exports in packages/content-renderer/package.json: `"./compositions": "./src/compositions/index.ts"`. This sub-export must NOT re-export anything from `@remotion/bundler` or `@remotion/renderer`.
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Node.js | renderMedia(), bundle() | Yes | v20.20.2 | — |
|
||||
| Playwright Chromium | Remotion browser (via browserExecutable) | Yes | ~1217 (Chrome 127) | Remotion auto-downloads its own |
|
||||
| ffmpeg | Video encoding (Remotion uses internally) | Yes (via ffmpeg-static) | bundled | — |
|
||||
| @remotion/compositor-linux-x64-gnu | Remotion Rust compositor (auto-installed) | Not yet — must pnpm add | 4.0.445 | No fallback — required for Linux render |
|
||||
| pnpm workspace | packages/content-renderer/ as workspace pkg | Yes | workspace configured | — |
|
||||
|
||||
**Missing dependencies with no fallback:**
|
||||
- `@remotion/compositor-linux-x64-gnu`: Installed automatically as an optional dep of `@remotion/renderer`. Must run `pnpm install` after adding `@remotion/renderer` to the workspace package.
|
||||
|
||||
**Missing dependencies with fallback:**
|
||||
- Playwright Chromium for Remotion: If version incompatibility, Remotion auto-downloads Chrome Headless Shell (~300MB).
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | vitest 3.0.5 |
|
||||
| Config file | server/vitest.config.ts |
|
||||
| Quick run command | `pnpm --filter @paperclipai/server test --run src/__tests__/presentation-renderer.test.ts` |
|
||||
| Full suite command | `pnpm --filter @paperclipai/server test --run` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| PRES-01 | LLM generates slide JSON, job submitted successfully | unit | `pnpm --filter @paperclipai/server test --run src/__tests__/presentation-renderer.test.ts` | ❌ Wave 0 |
|
||||
| PRES-02 | renderPresentation() returns mp4Base64 + inputProps for Player | unit | same | ❌ Wave 0 |
|
||||
| PRES-03 | DemoVideo composition renders with narration script input | unit | same | ❌ Wave 0 |
|
||||
| PRES-04 | onProgress callback fires; content_job.progress events published | unit | `pnpm --filter @paperclipai/server test --run src/__tests__/content-jobs-sse.test.ts` | ✅ extend |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `pnpm --filter @paperclipai/server test --run src/__tests__/presentation-renderer.test.ts`
|
||||
- **Per wave merge:** `pnpm --filter @paperclipai/server test --run`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `server/src/__tests__/presentation-renderer.test.ts` — covers PRES-01, PRES-02, PRES-03 (mock `@paperclipai/content-renderer` and `puter-inference`)
|
||||
- [ ] `packages/content-renderer/` — new workspace package with package.json, tsconfig.json, src/Root.tsx
|
||||
- [ ] Add `"content_job.progress"` to `packages/shared/src/constants.ts` — unblocks PRES-04 TypeScript
|
||||
|
||||
*(No new test framework needed — vitest already in server/ and all existing patterns apply)*
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- https://www.remotion.dev/docs/bundle — bundle() API, caching pattern
|
||||
- https://www.remotion.dev/docs/renderer/render-media — renderMedia() onProgress, concurrency, outputLocation
|
||||
- https://www.remotion.dev/docs/renderer/select-composition — selectComposition() API
|
||||
- https://www.remotion.dev/docs/series — Series + Series.Sequence for slide timing
|
||||
- https://www.remotion.dev/docs/composition — Composition component, registerRoot
|
||||
- https://www.remotion.dev/docs/renderer/open-browser — browserExecutable pattern, shared instance
|
||||
- `npm view @remotion/renderer optionalDependencies` — confirmed Rust compositor packages
|
||||
- Codebase: server/src/services/renderers/ — existing renderer patterns
|
||||
- Codebase: server/src/services/live-events.ts — EventEmitter SSE infrastructure
|
||||
- Codebase: packages/shared/src/constants.ts — LIVE_EVENT_TYPES (missing content_job.progress)
|
||||
- Codebase: packages/db/src/schema/content_jobs.ts — no schema changes needed
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- https://www.remotion.dev/docs/ssr-node — Node SSR workflow (bundle once + render per request)
|
||||
- https://www.remotion.dev/docs/sequence — Sequence timing model
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Module system choice (ESM vs CJS for content-renderer): Based on webpack's CommonJS requirements and existing workspace patterns. Not directly documented by Remotion.
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — npm view confirmed all package versions live
|
||||
- Architecture: HIGH — Remotion official docs + codebase pattern match is clear
|
||||
- Pitfalls: HIGH for webpack isolation (State.md pre-identifies this); MEDIUM for browser reuse (version compatibility unverified)
|
||||
- SSE progress: HIGH — LIVE_EVENT_TYPES gap confirmed in codebase, fix path is clear
|
||||
|
||||
**Research date:** 2026-04-04
|
||||
**Valid until:** 2026-05-04 (Remotion 4.x is stable; patch versions won't affect API)
|
||||
24
packages/content-renderer/package.json
Normal file
24
packages/content-renderer/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@paperclipai/content-renderer",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./compositions": "./src/compositions/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"remotion": "4.0.445",
|
||||
"@remotion/bundler": "4.0.445",
|
||||
"@remotion/renderer": "4.0.445",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
35
packages/content-renderer/src/Root.tsx
Normal file
35
packages/content-renderer/src/Root.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React from "react";
|
||||
import { Composition, registerRoot } from "remotion";
|
||||
import { PitchDeck } from "./compositions/PitchDeck";
|
||||
import { DemoVideo } from "./compositions/DemoVideo";
|
||||
|
||||
const FRAMES_PER_SLIDE = 90;
|
||||
|
||||
const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="PitchDeck"
|
||||
component={PitchDeck}
|
||||
width={1920}
|
||||
height={1080}
|
||||
fps={30}
|
||||
durationInFrames={FRAMES_PER_SLIDE * 10}
|
||||
defaultProps={{ slides: [], companyName: "" }}
|
||||
calculateMetadata={PitchDeck.calculateMetadata}
|
||||
/>
|
||||
<Composition
|
||||
id="DemoVideo"
|
||||
component={DemoVideo}
|
||||
width={1920}
|
||||
height={1080}
|
||||
fps={30}
|
||||
durationInFrames={FRAMES_PER_SLIDE * 8}
|
||||
defaultProps={{ slides: [], title: "" }}
|
||||
calculateMetadata={DemoVideo.calculateMetadata}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
69
packages/content-renderer/src/components/SlideFrame.tsx
Normal file
69
packages/content-renderer/src/components/SlideFrame.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React from "react";
|
||||
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
|
||||
import type { Slide } from "../compositions/PitchDeck";
|
||||
|
||||
interface SlideFrameProps {
|
||||
slide: Slide;
|
||||
slideIndex: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const SlideFrame: React.FC<SlideFrameProps> = ({ slide, slideIndex, total }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "#0f0f0f",
|
||||
opacity,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "60px 80px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
marginBottom: 32,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{slide.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "#d0d0d0",
|
||||
fontSize: 28,
|
||||
lineHeight: 1.7,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{slide.body}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 6,
|
||||
backgroundColor: slide.accent,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 24,
|
||||
right: 64,
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{slideIndex + 1} / {total}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
63
packages/content-renderer/src/components/TitleSlide.tsx
Normal file
63
packages/content-renderer/src/components/TitleSlide.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from "react";
|
||||
import { AbsoluteFill, useCurrentFrame, spring } from "remotion";
|
||||
|
||||
interface TitleSlideProps {
|
||||
companyName: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export const TitleSlide: React.FC<TitleSlideProps> = ({ companyName, subtitle }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps: 30,
|
||||
config: {
|
||||
damping: 12,
|
||||
stiffness: 100,
|
||||
mass: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "#0f0f0f",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 72,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{companyName}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div
|
||||
style={{
|
||||
color: "#a0a0a0",
|
||||
fontSize: 36,
|
||||
marginTop: 24,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
102
packages/content-renderer/src/compositions/DemoVideo.tsx
Normal file
102
packages/content-renderer/src/compositions/DemoVideo.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React from "react";
|
||||
import { AbsoluteFill, Series, useCurrentFrame, interpolate } from "remotion";
|
||||
import { SlideFrame } from "../components/SlideFrame";
|
||||
|
||||
export interface DemoSlide {
|
||||
title: string;
|
||||
content: string;
|
||||
narration: string;
|
||||
}
|
||||
|
||||
export interface DemoVideoProps {
|
||||
slides: DemoSlide[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
const FRAMES_PER_SLIDE = 90;
|
||||
|
||||
const DemoSlideContent: React.FC<{ slide: DemoSlide; slideIndex: number; total: number }> = ({
|
||||
slide,
|
||||
slideIndex,
|
||||
total,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)",
|
||||
opacity,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "60px 80px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 52,
|
||||
fontWeight: 700,
|
||||
marginBottom: 32,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{slide.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "#c0c0e0",
|
||||
fontSize: 32,
|
||||
lineHeight: 1.6,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{slide.content}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "#8080b0",
|
||||
fontSize: 22,
|
||||
fontStyle: "italic",
|
||||
marginTop: 24,
|
||||
paddingTop: 16,
|
||||
borderTop: "1px solid rgba(255,255,255,0.15)",
|
||||
}}
|
||||
>
|
||||
{slide.narration}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 32,
|
||||
right: 64,
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{slideIndex + 1} / {total}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const DemoVideo: React.FC<DemoVideoProps> & {
|
||||
calculateMetadata: (opts: { props: DemoVideoProps }) => { durationInFrames: number };
|
||||
} = ({ slides, title: _title }) => {
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)" }}>
|
||||
<Series>
|
||||
{slides.map((slide, i) => (
|
||||
<Series.Sequence key={i} durationInFrames={FRAMES_PER_SLIDE}>
|
||||
<DemoSlideContent slide={slide} slideIndex={i} total={slides.length} />
|
||||
</Series.Sequence>
|
||||
))}
|
||||
</Series>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
DemoVideo.calculateMetadata = ({ props }) => ({
|
||||
durationInFrames: Math.max(props.slides.length * FRAMES_PER_SLIDE, FRAMES_PER_SLIDE),
|
||||
});
|
||||
41
packages/content-renderer/src/compositions/PitchDeck.tsx
Normal file
41
packages/content-renderer/src/compositions/PitchDeck.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from "react";
|
||||
import { AbsoluteFill, Series } from "remotion";
|
||||
import { SlideFrame } from "../components/SlideFrame";
|
||||
import { TitleSlide } from "../components/TitleSlide";
|
||||
|
||||
export interface Slide {
|
||||
title: string;
|
||||
body: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export interface PitchDeckProps {
|
||||
slides: Slide[];
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
const FRAMES_PER_SLIDE = 90;
|
||||
|
||||
export const PitchDeck: React.FC<PitchDeckProps> & {
|
||||
calculateMetadata: (opts: { props: PitchDeckProps }) => { durationInFrames: number };
|
||||
} = ({ slides, companyName }) => {
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0f0f0f" }}>
|
||||
<Series>
|
||||
{slides.map((slide, i) => (
|
||||
<Series.Sequence key={i} durationInFrames={FRAMES_PER_SLIDE}>
|
||||
{i === 0 && companyName ? (
|
||||
<TitleSlide companyName={companyName} subtitle={slide.title} />
|
||||
) : (
|
||||
<SlideFrame slide={slide} slideIndex={i} total={slides.length} />
|
||||
)}
|
||||
</Series.Sequence>
|
||||
))}
|
||||
</Series>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
PitchDeck.calculateMetadata = ({ props }) => ({
|
||||
durationInFrames: Math.max(props.slides.length * FRAMES_PER_SLIDE, FRAMES_PER_SLIDE),
|
||||
});
|
||||
4
packages/content-renderer/src/compositions/index.ts
Normal file
4
packages/content-renderer/src/compositions/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { PitchDeck } from "./PitchDeck";
|
||||
export type { PitchDeckProps, Slide } from "./PitchDeck";
|
||||
export { DemoVideo } from "./DemoVideo";
|
||||
export type { DemoVideoProps, DemoSlide } from "./DemoVideo";
|
||||
69
packages/content-renderer/src/index.ts
Normal file
69
packages/content-renderer/src/index.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import path from "path";
|
||||
import { bundle } from "@remotion/bundler";
|
||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||
|
||||
export * from "./compositions/index";
|
||||
|
||||
let cachedBundlePath: string | null = null;
|
||||
|
||||
export async function getBundlePath(): Promise<string> {
|
||||
if (cachedBundlePath) {
|
||||
return cachedBundlePath;
|
||||
}
|
||||
const entryPoint = path.resolve(__dirname, "Root.tsx");
|
||||
cachedBundlePath = await bundle({ entryPoint });
|
||||
return cachedBundlePath;
|
||||
}
|
||||
|
||||
export interface RenderPresentationOptions {
|
||||
serveUrl: string;
|
||||
input: Record<string, unknown>;
|
||||
onProgress: (progress: number) => void;
|
||||
browserExecutable?: string;
|
||||
}
|
||||
|
||||
export interface RenderPresentationResult {
|
||||
buffer: Buffer;
|
||||
durationInFrames: number;
|
||||
fps: number;
|
||||
inputProps: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function renderPresentationComposition(
|
||||
opts: RenderPresentationOptions,
|
||||
): Promise<RenderPresentationResult> {
|
||||
const { serveUrl, input, onProgress, browserExecutable } = opts;
|
||||
|
||||
const compositionId = input["videoType"] === "demo" ? "DemoVideo" : "PitchDeck";
|
||||
|
||||
const composition = await selectComposition({
|
||||
serveUrl,
|
||||
id: compositionId,
|
||||
inputProps: input,
|
||||
...(browserExecutable ? { browserExecutable } : {}),
|
||||
});
|
||||
|
||||
const result = await renderMedia({
|
||||
composition,
|
||||
serveUrl,
|
||||
codec: "h264",
|
||||
concurrency: 1,
|
||||
inputProps: input,
|
||||
outputLocation: null,
|
||||
...(browserExecutable ? { browserExecutable } : {}),
|
||||
onProgress: ({ progress }) => {
|
||||
onProgress(Math.round(progress * 100));
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.buffer) {
|
||||
throw new Error("renderMedia returned null buffer");
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
durationInFrames: composition.durationInFrames,
|
||||
fps: composition.fps,
|
||||
inputProps: input,
|
||||
};
|
||||
}
|
||||
12
packages/content-renderer/tsconfig.json
Normal file
12
packages/content-renderer/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"esModuleInterop": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -333,6 +333,7 @@ export const LIVE_EVENT_TYPES = [
|
|||
"plugin.worker.restarted",
|
||||
"content_job.queued",
|
||||
"content_job.running",
|
||||
"content_job.progress",
|
||||
"content_job.done",
|
||||
"content_job.failed",
|
||||
] as const;
|
||||
|
|
|
|||
1628
pnpm-lock.yaml
generated
1628
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,8 @@ type ContentJob = typeof contentJobs.$inferSelect;
|
|||
export async function renderContent(
|
||||
jobType: string,
|
||||
input: Record<string, unknown>,
|
||||
companyId?: string,
|
||||
jobId?: string,
|
||||
): Promise<RenderResult> {
|
||||
switch (jobType) {
|
||||
case "diagram": {
|
||||
|
|
@ -46,6 +48,10 @@ export async function renderContent(
|
|||
const { renderBrandKit } = await import("./renderers/brand-renderer.js");
|
||||
return renderBrandKit(input);
|
||||
}
|
||||
case "presentation": {
|
||||
const { renderPresentation } = await import("./renderers/presentation-renderer.js");
|
||||
return renderPresentation(input, companyId, jobId);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown jobType: ${jobType}`);
|
||||
}
|
||||
|
|
@ -62,7 +68,7 @@ async function runJob(db: Db, storage: StorageService, job: ContentJob): Promise
|
|||
});
|
||||
|
||||
try {
|
||||
const result = await renderContent(job.jobType, job.input as Record<string, unknown>);
|
||||
const result = await renderContent(job.jobType, job.input as Record<string, unknown>, job.companyId, job.id);
|
||||
|
||||
if (result.buffer.byteLength > MAX_GENERATED_ASSET_BYTES) {
|
||||
throw new Error(
|
||||
|
|
|
|||
220
server/src/services/renderers/presentation-renderer.ts
Normal file
220
server/src/services/renderers/presentation-renderer.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { puterChatComplete } from "../puter-inference.js";
|
||||
import { publishLiveEvent } from "../live-events.js";
|
||||
import { resolveBrowserPath } from "./diagram-renderer.js";
|
||||
import type { RenderResult, PresentationBundle } from "./types.js";
|
||||
|
||||
// ─── Local helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function stripMarkdownFences(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ─── Slide types (mirrors compositions) ────────────────────────────────────────
|
||||
|
||||
interface Slide {
|
||||
title: string;
|
||||
body: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
interface DemoSlide {
|
||||
title: string;
|
||||
content: string;
|
||||
narration: string;
|
||||
}
|
||||
|
||||
// ─── LLM slide generation ───────────────────────────────────────────────────────
|
||||
|
||||
async function generatePitchDeckSlides(prompt: string): Promise<Slide[]> {
|
||||
const systemPrompt = [
|
||||
"You are a professional pitch deck designer.",
|
||||
"Generate slide content as a JSON array of slide objects.",
|
||||
"Respond with ONLY a JSON array — no markdown fences, no explanation.",
|
||||
"Required fields per slide:",
|
||||
' "title": slide heading (string)',
|
||||
' "body": slide body text (2-4 sentences, string)',
|
||||
' "accent": a distinct accent hex color for this slide (e.g. "#3B82F6", string)',
|
||||
"Generate 6-10 slides covering: title slide, problem, solution, market opportunity,",
|
||||
"business model, traction/milestones, team, ask/closing.",
|
||||
"Each slide should have a visually distinct accent color that fits a dark presentation theme.",
|
||||
"The first slide is the title/hero slide.",
|
||||
].join("\n");
|
||||
|
||||
const raw = await puterChatComplete([
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content: `Create a pitch deck for: ${prompt}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const cleaned = stripMarkdownFences(raw);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`generatePitchDeckSlides: failed to parse LLM response as JSON.\n` +
|
||||
`Raw response: ${raw}\nError: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
`generatePitchDeckSlides: expected JSON array, got ${typeof parsed}. Raw: ${raw}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (parsed as Partial<Slide>[]).map((s, i) => ({
|
||||
title: s.title ?? `Slide ${i + 1}`,
|
||||
body: s.body ?? "",
|
||||
accent: s.accent ?? "#3B82F6",
|
||||
}));
|
||||
}
|
||||
|
||||
async function generateDemoVideoSlides(prompt: string): Promise<DemoSlide[]> {
|
||||
const systemPrompt = [
|
||||
"You are a professional product demo video scriptwriter.",
|
||||
"Generate slide content as a JSON array of demo slide objects.",
|
||||
"Respond with ONLY a JSON array — no markdown fences, no explanation.",
|
||||
"Required fields per slide:",
|
||||
' "title": slide heading (string)',
|
||||
' "content": main content / key points for this slide (2-3 sentences, string)',
|
||||
' "narration": voiceover narration script for this slide (1-3 sentences, string)',
|
||||
"Generate 4-8 slides covering: intro/hook, key features (one per slide),",
|
||||
"demo walkthrough, call-to-action/conclusion.",
|
||||
"Each narration should flow naturally as a spoken script.",
|
||||
].join("\n");
|
||||
|
||||
const raw = await puterChatComplete([
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content: `Create a demo video for: ${prompt}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const cleaned = stripMarkdownFences(raw);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`generateDemoVideoSlides: failed to parse LLM response as JSON.\n` +
|
||||
`Raw response: ${raw}\nError: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
`generateDemoVideoSlides: expected JSON array, got ${typeof parsed}. Raw: ${raw}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (parsed as Partial<DemoSlide>[]).map((s, i) => ({
|
||||
title: s.title ?? `Slide ${i + 1}`,
|
||||
content: s.content ?? "",
|
||||
narration: s.narration ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Main exported renderer ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a presentation (pitch deck or demo video) to MP4 via Remotion.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Extract prompt, videoType, title from input
|
||||
* 2. Generate slide JSON via LLM (puterChatComplete)
|
||||
* 3. Build inputProps for Remotion
|
||||
* 4. Dynamic-import @paperclipai/content-renderer
|
||||
* 5. Resolve Playwright Chromium browser path
|
||||
* 6. Render via renderPresentationComposition with onProgress SSE events
|
||||
* 7. Wrap result in PresentationBundle and return as RenderResult
|
||||
*/
|
||||
export async function renderPresentation(
|
||||
input: Record<string, unknown>,
|
||||
companyId: string = "",
|
||||
jobId: string = "",
|
||||
): Promise<RenderResult> {
|
||||
const prompt =
|
||||
typeof input.prompt === "string" ? input.prompt : "Create a presentation";
|
||||
const videoType =
|
||||
input.videoType === "demo-video" || input.videoType === "demo"
|
||||
? "demo-video"
|
||||
: "pitch-deck";
|
||||
const title =
|
||||
typeof input.title === "string" && input.title.length > 0
|
||||
? input.title
|
||||
: "Presentation";
|
||||
|
||||
// Step 1: Generate slides via LLM
|
||||
let slides: Slide[] | DemoSlide[];
|
||||
if (videoType === "demo-video") {
|
||||
slides = await generateDemoVideoSlides(prompt);
|
||||
} else {
|
||||
slides = await generatePitchDeckSlides(prompt);
|
||||
}
|
||||
|
||||
// Step 2: Build inputProps for Remotion
|
||||
// Note: content-renderer uses videoType === "demo" to select DemoVideo composition
|
||||
const inputProps: Record<string, unknown> =
|
||||
videoType === "demo-video"
|
||||
? { slides, title, videoType: "demo" }
|
||||
: { slides, companyName: title, videoType: "pitch-deck" };
|
||||
|
||||
// Step 3: Dynamic import of content-renderer (keeps webpack/rspack out of server tsc context)
|
||||
const { getBundlePath, renderPresentationComposition } = await import(
|
||||
"@paperclipai/content-renderer"
|
||||
);
|
||||
|
||||
// Step 4: Resolve Playwright Chromium binary — fallback to undefined for Remotion auto-download
|
||||
let browserExecutable: string | undefined;
|
||||
try {
|
||||
browserExecutable = resolveBrowserPath();
|
||||
} catch {
|
||||
browserExecutable = undefined;
|
||||
}
|
||||
|
||||
// Step 5: Render via Remotion — onProgress publishes SSE content_job.progress events
|
||||
const serveUrl = await getBundlePath();
|
||||
|
||||
const result = await renderPresentationComposition({
|
||||
serveUrl,
|
||||
input: inputProps,
|
||||
browserExecutable,
|
||||
onProgress: (progress: number) => {
|
||||
if (companyId) {
|
||||
publishLiveEvent({
|
||||
companyId,
|
||||
type: "content_job.progress",
|
||||
payload: { jobId, progress },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Step 6: Assemble PresentationBundle
|
||||
const bundle: PresentationBundle = {
|
||||
type: "presentation-bundle",
|
||||
presentationType: videoType === "demo-video" ? "demo-video" : "pitch-deck",
|
||||
title,
|
||||
slideCount: slides.length,
|
||||
durationInFrames: result.durationInFrames,
|
||||
fps: result.fps,
|
||||
mp4Base64: result.buffer.toString("base64"),
|
||||
inputProps: result.inputProps,
|
||||
};
|
||||
|
||||
// Step 7: Return as JSON RenderResult — bundle contains both MP4 (base64) and metadata
|
||||
const safeFilename = title.replace(/[^a-z0-9_\-]/gi, "-").toLowerCase();
|
||||
return {
|
||||
filename: `${safeFilename}.json`,
|
||||
contentType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||||
};
|
||||
}
|
||||
|
|
@ -93,4 +93,15 @@ export interface BrandKitBundle {
|
|||
zipBase64: string;
|
||||
}
|
||||
|
||||
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle | WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle | PdfDocumentBundle | BrandKitBundle;
|
||||
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>;
|
||||
}
|
||||
|
||||
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle | WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle | PdfDocumentBundle | BrandKitBundle | PresentationBundle;
|
||||
|
|
|
|||
31
server/src/types/content-renderer.d.ts
vendored
Normal file
31
server/src/types/content-renderer.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Ambient module declaration for @paperclipai/content-renderer.
|
||||
*
|
||||
* The content-renderer package uses Remotion (React JSX + webpack rspack) which
|
||||
* must NOT be resolved into the server's tsc context (server tsconfig has no jsx
|
||||
* option and must not include the bundler/renderer webpack path).
|
||||
*
|
||||
* This declaration provides server-side type safety for the dynamic import without
|
||||
* pulling in the JSX composition files.
|
||||
*/
|
||||
declare module "@paperclipai/content-renderer" {
|
||||
export function getBundlePath(): Promise<string>;
|
||||
|
||||
export interface RenderPresentationOptions {
|
||||
serveUrl: string;
|
||||
input: Record<string, unknown>;
|
||||
onProgress: (progress: number) => void;
|
||||
browserExecutable?: string;
|
||||
}
|
||||
|
||||
export interface RenderPresentationResult {
|
||||
buffer: Buffer;
|
||||
durationInFrames: number;
|
||||
fps: number;
|
||||
inputProps: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function renderPresentationComposition(
|
||||
opts: RenderPresentationOptions,
|
||||
): Promise<RenderPresentationResult>;
|
||||
}
|
||||
199
ui/src/components/PresentationPanel.tsx
Normal file
199
ui/src/components/PresentationPanel.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Loader2, Download, Video } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useContentJob } from "@/hooks/useContentJob";
|
||||
import { getContentJobAsset } from "@/api/contentJobs";
|
||||
|
||||
interface PresentationBundle {
|
||||
type: "presentation-bundle";
|
||||
presentationType: "pitch-deck" | "demo-video";
|
||||
title: string;
|
||||
slideCount: number;
|
||||
durationInFrames: number;
|
||||
fps: number;
|
||||
mp4Base64: string;
|
||||
inputProps: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PresentationPanelProps {
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export function PresentationPanel({ companyId }: PresentationPanelProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [videoType, setVideoType] = useState<"pitch-deck" | "demo-video">("pitch-deck");
|
||||
const [bundle, setBundle] = useState<PresentationBundle | null>(null);
|
||||
const job = useContentJob(companyId);
|
||||
|
||||
const isGenerating = job.status === "queued" || job.status === "running";
|
||||
const isIdle = job.status === "idle";
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!prompt.trim() || isGenerating) return;
|
||||
setBundle(null);
|
||||
await job.submit("presentation", { prompt, videoType, title: prompt.slice(0, 60) });
|
||||
}
|
||||
|
||||
// Fetch asset when job completes
|
||||
if (job.status === "done" && job.resultAssetId && !bundle) {
|
||||
void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => {
|
||||
const res = await fetch(assetUrl);
|
||||
const text = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as PresentationBundle;
|
||||
setBundle(parsed);
|
||||
} catch {
|
||||
// ignore parse error — will show empty state
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (videoUrl) URL.revokeObjectURL(videoUrl);
|
||||
};
|
||||
}, [videoUrl]);
|
||||
|
||||
function handleGenerateAnother() {
|
||||
job.reset();
|
||||
setBundle(null);
|
||||
setPrompt("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold">Generate Presentation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="presentation-prompt" className="text-sm font-medium">
|
||||
Presentation description
|
||||
</label>
|
||||
<Textarea
|
||||
id="presentation-prompt"
|
||||
rows={5}
|
||||
placeholder="Describe the presentation you want — topic, audience, key points..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="video-type" className="text-sm font-medium">
|
||||
Presentation type
|
||||
</label>
|
||||
<Select
|
||||
value={videoType}
|
||||
onValueChange={(v) => setVideoType(v as "pitch-deck" | "demo-video")}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<SelectTrigger id="video-type" className="w-full">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pitch-deck">Pitch Deck</SelectItem>
|
||||
<SelectItem value="demo-video">Demo Video</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
|
||||
Generating presentation...
|
||||
</>
|
||||
) : (
|
||||
"Generate Presentation"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{(job.status === "queued" || job.status === "running") && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Progress
|
||||
value={job.progress}
|
||||
role="progressbar"
|
||||
aria-valuenow={job.progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Presentation render progress"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">{job.progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.status === "failed" && job.errorMessage && (
|
||||
<p className="text-sm text-destructive">
|
||||
Generation failed — {job.errorMessage}. Try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{bundle && videoUrl ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold">{bundle.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{bundle.slideCount} slides · {bundle.presentationType === "pitch-deck" ? "Pitch Deck" : "Demo Video"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
className="w-full rounded-lg aspect-video"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" className="flex-1">
|
||||
<a href={videoUrl} download={`${bundle.title}.mp4`}>
|
||||
<Download className="mr-2 size-4" aria-hidden="true" />
|
||||
Download MP4
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={handleGenerateAnother}
|
||||
>
|
||||
<Video className="mr-2 size-4" aria-hidden="true" />
|
||||
Generate Another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isIdle ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<p className="text-xl font-semibold">No presentation yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Describe your presentation — topic, audience, key points — to generate a pitch deck
|
||||
or demo video rendered as an MP4.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -70,11 +70,15 @@ export function useContentJob(companyId: string | null) {
|
|||
es.addEventListener("status", (e: MessageEvent) => {
|
||||
const data = JSON.parse(e.data as string) as {
|
||||
status?: string;
|
||||
progress?: number;
|
||||
resultAssetId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
const status = (data.status ?? "queued") as JobStatus;
|
||||
const progress = statusToProgress(status);
|
||||
const progress =
|
||||
typeof data.progress === "number"
|
||||
? data.progress
|
||||
: statusToProgress(status);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { WallpaperGeneratePanel } from "../components/WallpaperGeneratePanel";
|
|||
import { SocialPostPanel } from "../components/SocialPostPanel";
|
||||
import { DocumentGeneratePanel } from "../components/DocumentGeneratePanel";
|
||||
import { BrandKitPanel } from "../components/BrandKitPanel";
|
||||
import { PresentationPanel } from "../components/PresentationPanel";
|
||||
import { ThemeSeedInput } from "../components/ThemeSeedInput";
|
||||
import { ThemePaletteGrid } from "../components/ThemePaletteGrid";
|
||||
import { ThemePreviewPanel } from "../components/ThemePreviewPanel";
|
||||
|
|
@ -33,6 +34,7 @@ export function ContentStudio() {
|
|||
<TabsTrigger value="social">Social</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||
<TabsTrigger value="brand">Brand</TabsTrigger>
|
||||
<TabsTrigger value="presentations">Presentations</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="diagrams" className="mt-4">
|
||||
|
|
@ -118,6 +120,14 @@ export function ContentStudio() {
|
|||
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue