From 386338c92b0d20120176e1929616c25683746d3e Mon Sep 17 00:00:00 2001 From: calesthio Date: Thu, 23 Apr 2026 23:30:23 -0700 Subject: [PATCH] feat(remotion): upgrade cinematic TitleCard + add new scene components - CinematicRenderer.tsx / cinematic/types.ts: TitleCard now supports ghosted video background, word-by-word stagger reveal with blur->sharp transition, proper newline handling, and growing accent lines. Title scenes accept backgroundSrc/backgroundTrimBeforeSeconds/ backgroundTrimAfterSeconds plus a plate|overlay variant. - Add CollageBurst, LyricOverlay, ScreenshotScene components. - Explainer, Root, components/index.ts wiring updates. - SCENE_TYPES.md reflects the new components. - seedance-2-0 skill + seedance_video.py: small fixes. --- .agents/skills/seedance-2-0/SKILL.md | 95 ++- remotion-composer/SCENE_TYPES.md | 1 + remotion-composer/src/CinematicRenderer.tsx | 203 +++++- remotion-composer/src/CollageBurst.tsx | 608 ++++++++++++++++ remotion-composer/src/Explainer.tsx | 17 + remotion-composer/src/LyricOverlay.tsx | 173 +++++ remotion-composer/src/Root.tsx | 30 + remotion-composer/src/cinematic/types.ts | 4 + .../src/components/ScreenshotScene.tsx | 668 ++++++++++++++++++ remotion-composer/src/components/index.ts | 2 + tools/video/seedance_video.py | 6 +- 11 files changed, 1763 insertions(+), 44 deletions(-) create mode 100644 remotion-composer/src/CollageBurst.tsx create mode 100644 remotion-composer/src/LyricOverlay.tsx create mode 100644 remotion-composer/src/components/ScreenshotScene.tsx diff --git a/.agents/skills/seedance-2-0/SKILL.md b/.agents/skills/seedance-2-0/SKILL.md index a5ec54ee..3b162059 100644 --- a/.agents/skills/seedance-2-0/SKILL.md +++ b/.agents/skills/seedance-2-0/SKILL.md @@ -95,7 +95,72 @@ seedance.execute({ }) ``` -## Prompt structure +## Prompt structure — The Higgsfield Methodology (canonical as of 2026) + +**CRITICAL: Open every prompt with a shot-structure declaration.** Seedance rewards prompts that declare format upfront before any creative description. This is the single biggest quality lever. + +### Opener templates (copy one verbatim, then extend) + +**For action/combat/multi-shot (highest-performing format):** +``` +Montage, multi-shot Hollywood action, don't use one camera angle or single cut, cinematic lighting, photorealistic, 35mm film quality, ARRI ALEXA aesthetic, heavy film grain, sharp but imperfect focus, motion blur on fast actions, halation on highlights, soft highlight rolloff, wide-angle lens with strong distortion, subtle chromatic aberration near frame edges, no 3D, no cartoon, no VFX aesthetic. +``` + +**For single-POV continuous shots (orbs, walkthrough):** +``` +Single continuous shot, first-person POV perspective, the camera IS [his/her] eyes, hyper-chaotic handheld motion, completely unstabilized, violent raw human movement, constant micro-jitters, aggressive head swings, abrupt jerks, frequent over-rotation, no smoothness at all, no cuts, no zoom, 35mm film, photorealistic. +``` + +**For locked-POV reaction scenes:** +``` +One continuous shot, POV [setting] perspective, no cuts, no zoom, natural head movement, photorealistic, 35mm film grain. +``` + +### Body structure (after the opener) + +1. **Environment/location** — sensory detail (wet asphalt, sodium lamps, neon bleed, rain particulates, volumetric haze) +2. **Character block** — with reference tags and identity-lock language (see Reference-to-video below) +3. **Enemy/secondary character block** — same detail level +4. **Beat-by-beat choreography** with TEMPORAL MARKERS: `0–3s: … 3–6s: … 6–10s: …` +5. **VFX inline in brackets:** `[VFX: branching white-blue electric arcs pulsing along forearms, sparks jumping between fingers]` +6. **Slow-motion markers:** write `RAMPS TO SLOW MOTION` before the impact beat, `SNAPS BACK TO REAL TIME` on resume +7. **Sound design block:** either `no music, only raw SFX` or explicit SFX sequence. Music language stays textural. + +### Combat vocabulary (proven to hit) + +- `snaps forward`, `lunges`, `sprints`, `weaves`, `chambers`, `drives`, `pivots`, `redirects`, `ducks`, `slips` +- `explodes outward`, `devastating`, `raw force`, `kinetic`, `overload`, `compresses`, `erupts`, `fractures`, `ripples` +- Avoid soft verbs: `attacks`, `hits`, `fights` — these read generic and Seedance underdelivers on them + +### Camera behavior — state what it IS and ISN'T doing + +Seedance misfires when camera intent is ambiguous. Always explicitly negate what you don't want: +- `no cuts` (for continuous POV) +- `no zoom` (prevents unnatural perspective punch-ins) +- `no stabilization` (when you want chaotic handheld) +- `no smoothness at all` +- `no 3D, no cartoon, no VFX aesthetic` — counter-intuitive but forces photoreal skin/texture/lighting even when the scene has heavy VFX elements + +### Realism enforcement phrase + +When the brief has VFX but you want photoreal skin/textures (not plastic Marvel-cartoon look), include: +``` +no 3D, no cartoon, no VFX aesthetic — photorealistic textures, real skin pores, authentic fabric detail, grounded in reality +``` + +### Format priority (Higgsfield empirical ordering) + +| Format | Best for | Pattern | +|---|---|---| +| **Transformation** | calm → threat → transformation → aftermath | 6 numbered shots × 2.5s each @ 15s total | +| **Orbs** | single continuous POV | 1 shot × 15s, hyper-chaotic handheld | +| **Fights** | combat choreography | Beat-by-beat, clear power mismatch, RAMPS/SNAPS | +| **POV** | locked reaction | Continuous, "no cuts no zoom" mantra | +| **Animation** | stylized 3D | `@image` keyframe + timed segments | + +The **2.5-second-per-shot rhythm** appears optimal for multi-shot generations. + +## Legacy 8-part template (use only for single simple shots, not action) Seedance 2.0 is unusually literal about camera language, multi-shot cuts, and quoted dialogue. Use this 8-part template: @@ -138,15 +203,33 @@ Do **not** request complex multi-instrument scores — keep music language textu ### Reference-to-video -When you have character / product / wardrobe references, use the reference-to-video endpoint and name each asset in the prompt: +When you have character / product / wardrobe references, use the reference-to-video endpoint. Seedance 2.0 honors an explicit bracket tagging syntax: ``` -Reference 1: hero character (Aang) — bald, blue arrow tattoo, orange robes. -Reference 2: environment plate — snowy Air Temple courtyard at dawn. -Shot 1: Aang (from reference 1) walks across the courtyard (reference 2), -wind lifting his robes. Low-angle tracking shot, slow push-in. +[reference_image: hero_portrait.png] +[identity_lock] +The same character — bald, blue arrow tattoo, orange robes — consistent across all shots, no drift or deformation. Do not alter clothing category or primary color. + +Shot 1 (wide, slow push-in): hero walks across the snowy Air Temple courtyard, wind lifting robes. +Shot 2 (medium close-up): hero turns toward camera, staff in hand. +Shot 3 (extreme close-up, rack focus): hero's eyes open, wind whipping. ``` +**Identity-anchor phrases that measurably reduce face drift** (stack them — redundancy helps): +- `the same character` +- `consistent across different scenes / all shots` +- `maintain exact appearance from reference image` +- `no deformation, no drift, no face morph` +- `Do not alter clothing category or primary color` + +**Single-reference workflow (common in practice):** When you only have one photo: +- Use a clear, front-facing portrait with neutral lighting and minimal motion blur; avoid occluded faces (e.g., phones, sunglasses, heavy shadow). +- Reuse the SAME reference image across all shots — do not generate new refs per shot. +- Put all shots in ONE prompt under a single `[identity_lock]` block so the model treats them as a coherent sequence. +- If wardrobe is changing by design (e.g., civilian → costume), describe the costume verbatim on every shot it appears and add `Do not alter clothing category or primary color` to lock it once generated. + +**Anti-drift fallback:** If face morphs across frames on first render, drop to a shorter duration (5-6s instead of 10s), tighten the identity-lock language, and if you have multiple reference images, cull to the 3 most consistent ones rather than flooding with 9. + ## Parameter guidance | Parameter | Guidance | diff --git a/remotion-composer/SCENE_TYPES.md b/remotion-composer/SCENE_TYPES.md index 51b35639..539e3261 100644 --- a/remotion-composer/SCENE_TYPES.md +++ b/remotion-composer/SCENE_TYPES.md @@ -24,6 +24,7 @@ When you add a new component, append it here and in `src/components/index.ts`. | `progress_bar` | `ProgressBar` | `progress` | `progressLabel`, `progressColor`, `progressSegments` | Animated progress | | `anime_scene` | `AnimeScene` | `images` (list) | `particles`, `lightingFrom`, `lightingTo`, `vignette` | Still-image anime scene with particles + camera motion | | **`terminal_scene`** | **`TerminalScene`** | **`steps`** (list of cmd/out/pause/pill) | **`terminalTitle`, `prompt`, `accentColor`** | **Synthetic terminal animation — NO real capture needed. See [`.agents/skills/synthetic-screen-recording/SKILL.md`](../.agents/skills/synthetic-screen-recording/SKILL.md)** | +| **`screenshot_scene`** | **`ScreenshotScene`** | **`backgroundImage`** (path in `public/`), **`screenshotSteps`** (list of overlays) | **`screenshotSize` (natural px w/h), `cursorStartAt`, `accentColor`** | **Approach-1 synthetic UI — drop any screenshot, animate scripted overlays on top (cursor, click_pulse, type_into, bubble_append, typing_dots, highlight_box, callout_balloon). Viewer-indistinguishable from a real recording for 15–30s focused demos. Coordinates are normalized (0–1) against the contain-fit rect. See [`.agents/skills/synthetic-ui-recording/SKILL.md`](../.agents/skills/synthetic-ui-recording/SKILL.md) (planned).** | --- diff --git a/remotion-composer/src/CinematicRenderer.tsx b/remotion-composer/src/CinematicRenderer.tsx index e0219949..b7c81e25 100644 --- a/remotion-composer/src/CinematicRenderer.tsx +++ b/remotion-composer/src/CinematicRenderer.tsx @@ -174,6 +174,10 @@ const TitleCard: React.FC<{ titleFontSize: number; titleWidth: number; signalLineCount: number; + backgroundSrc?: string; + backgroundTrimBeforeSeconds?: number; + backgroundTrimAfterSeconds?: number; + variant?: "plate" | "overlay"; }> = ({ text, accent, @@ -181,85 +185,210 @@ const TitleCard: React.FC<{ titleFontSize, titleWidth, signalLineCount, + backgroundSrc, + backgroundTrimBeforeSeconds, + backgroundTrimAfterSeconds, + variant = "plate", }) => { const frame = useCurrentFrame(); const { fps, durationInFrames } = useVideoConfig(); - const reveal = spring({ + const container = spring({ fps, frame, - config: { damping: 18, stiffness: 90 }, + config: { damping: 22, stiffness: 80 }, }); const exit = interpolate( frame, - [durationInFrames - 12, durationInFrames], + [durationInFrames - 14, durationInFrames], [1, 0], - { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }, + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, ); - const y = interpolate(reveal, [0, 1], [18, 0]); - const letterSpacing = interpolate(reveal, [0, 1], [0.3, 0.18]); + // Split by newlines first (each line rendered in its own block), + // then word-stagger inside each line. This preserves intentional + // \n separators (e.g. "TITLE 1\nTITLE 2") that the old whitespace + // regex was collapsing into a single space. + const lines = text.split(/\r?\n/); + const staggerFrames = 3; + const wordFadeFrames = 14; + + const lineGrow = interpolate(frame, [0, 22], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const lineExit = exit; const flareOpacity = - 0.18 + Math.max(0, Math.sin(frame * 0.08)) * 0.14 * intensity; + 0.22 + Math.max(0, Math.sin(frame * 0.09)) * 0.18 * intensity; + + const bgScale = interpolate(frame, [0, durationInFrames], [1.04, 1.1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const bgTrimBefore = + backgroundTrimBeforeSeconds !== undefined + ? Math.round(backgroundTrimBeforeSeconds * fps) + : undefined; + const bgTrimAfter = + backgroundTrimAfterSeconds !== undefined + ? Math.round(backgroundTrimAfterSeconds * fps) + : undefined; + + const plateBg = + variant === "overlay" + ? "transparent" + : "radial-gradient(ellipse at 50% 50%, rgba(8,14,22,0.78) 0%, rgba(2,4,8,0.92) 58%, rgba(0,0,0,1) 100%)"; return ( + {backgroundSrc ? ( + <> + + + + + + ) : null} + + + + + {/* Top accent line — grows from center outwards */}
+ + {/* Word-stagger text reveal */}
- {text} + {(() => { + let wordCounter = 0; + return lines.map((line, lineIdx) => { + const tokens = line.split(/(\s+)/).filter((w) => w.length > 0); + return ( +
+ {tokens.map((w, ti) => { + if (/^\s+$/.test(w)) { + return  ; + } + const startFrame = wordCounter * staggerFrames; + wordCounter += 1; + const wordOpacity = interpolate( + frame, + [startFrame, startFrame + wordFadeFrames], + [0, 1], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + const blur = interpolate( + frame, + [startFrame, startFrame + wordFadeFrames], + [6, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + const ty = interpolate( + frame, + [startFrame, startFrame + wordFadeFrames], + [14, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + return ( + + {w} + + ); + })} +
+ ); + }); + })()}
+ + {/* Subtle accent dot centered under text */} +
); }; @@ -384,6 +513,10 @@ export const CinematicRenderer: React.FC = ({ titleFontSize={titleFontSize} titleWidth={titleWidth} signalLineCount={signalLineCount} + backgroundSrc={scene.backgroundSrc} + backgroundTrimBeforeSeconds={scene.backgroundTrimBeforeSeconds} + backgroundTrimAfterSeconds={scene.backgroundTrimAfterSeconds} + variant={scene.variant} /> )} diff --git a/remotion-composer/src/CollageBurst.tsx b/remotion-composer/src/CollageBurst.tsx new file mode 100644 index 00000000..3bba2132 --- /dev/null +++ b/remotion-composer/src/CollageBurst.tsx @@ -0,0 +1,608 @@ +import { + AbsoluteFill, + Img, + OffthreadVideo, + Sequence, + interpolate, + random, + spring, + staticFile, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import React from "react"; +import { loadFont as loadPlayfair } from "@remotion/google-fonts/PlayfairDisplay"; + +const { fontFamily: playfairFamily } = loadPlayfair("normal", { + weights: ["400", "700"], + subsets: ["latin"], +}); +const { fontFamily: playfairItalic } = loadPlayfair("italic", { + weights: ["400", "700"], + subsets: ["latin"], +}); + +function resolveAsset(src: string): string { + if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) return src; + const clean = src.replace(/^file:\/\/\/?/, ""); + if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) { + return `file:///${clean.replace(/\\/g, "/")}`; + } + return staticFile(clean); +} + +export type CollageTransition = + | "pop" + | "slide-zoom" + | "spin" + | "shutter" + | "glitch" + | "swoop"; + +export interface CollageClip { + src: string; + kind: "image" | "video"; + inSeconds: number; + outSeconds: number; + x: number; // 0..1 center + y: number; // 0..1 center + widthPct: number; // 0..1 of frame width + aspect?: number; // width/height, default 3/4 (portrait card) + rotation: number; // degrees + sourceInSeconds?: number; + transition?: CollageTransition; + hero?: boolean; // adds extra polish + flash boost + seed?: number; +} + +export interface CollageBurstProps { + backgroundSrc: string; + backgroundInSeconds?: number; + curtainStartSeconds: number; + curtainEndSeconds: number; + clips: CollageClip[]; +} + +// ---------------------------------------------------------------------------- +// Opening text — elegant serif card that lives in the pre-reveal black, then +// fades out as the curtain opens. +// ---------------------------------------------------------------------------- +const OpeningText: React.FC<{ + lineOne: string; + lineTwo: string; + fadeInStart: number; + fadeInEnd: number; + fadeOutStart: number; + fadeOutEnd: number; +}> = ({ lineOne, lineTwo, fadeInStart, fadeInEnd, fadeOutStart, fadeOutEnd }) => { + const frame = useCurrentFrame(); + if (frame < fadeInStart || frame > fadeOutEnd) return null; + + const fadeIn = interpolate(frame, [fadeInStart, fadeInEnd], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const fadeOut = interpolate(frame, [fadeOutStart, fadeOutEnd], [1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const opacity = fadeIn * fadeOut; + + // Slight rise on entry, slight drift on exit + const yIn = interpolate(fadeIn, [0, 1], [18, 0]); + const yOut = interpolate(fadeOut, [0, 1], [-10, 0]); + const y = yIn + yOut; + + // Line draws in from center outward + const lineProgress = interpolate(frame, [fadeInStart, fadeInEnd + 8], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const cream = "#F5E7C5"; + const gold = "rgba(255, 214, 150, 0.9)"; + + return ( + +
+ {/* Decorative rule */} +
+
+ {lineOne} +
+
+ {lineTwo} +
+ {/* Small jewel divider */} +
+
+
+
+
+
+ + ); +}; + +// ---------------------------------------------------------------------------- +// White flash — brief burst when a card lands. Rendered at composition level. +// ---------------------------------------------------------------------------- +const CardFlash: React.FC<{ atFrame: number; strength?: number }> = ({ + atFrame, + strength = 0.4, +}) => { + const frame = useCurrentFrame(); + if (frame < atFrame - 1 || frame > atFrame + 6) return null; + const t = (frame - atFrame) / 6; + const opacity = Math.max(0, (1 - t) * strength); + return ( + + ); +}; + +// ---------------------------------------------------------------------------- +// CollageCard — picks an entry transition by `clip.transition` and plays video +// starting at its in-time via . +// ---------------------------------------------------------------------------- +const CollageCard: React.FC<{ clip: CollageClip; frameW: number; frameH: number }> = ({ + clip, + frameW, + frameH, +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const inFrame = clip.inSeconds * fps; + const outFrame = clip.outSeconds * fps; + if (frame < inFrame - 2 || frame > outFrame + 8) return null; + + const rel = frame - inFrame; + const relOut = frame - outFrame; + const seed = clip.seed ?? 0; + const transition = clip.transition ?? "pop"; + + // Common spring value 0→1 over ~18f for entrance + const entry = spring({ + frame: rel, + fps, + config: { damping: 10, stiffness: 130, mass: 0.85 }, + durationInFrames: 20, + }); + + const exit = spring({ + frame: relOut, + fps, + config: { damping: 14, stiffness: 200, mass: 0.8 }, + durationInFrames: 12, + }); + + const isExiting = frame >= outFrame; + + // Base transform values derived from transition + let scale = 1; + let rot = clip.rotation; + let offX = 0; + let offY = 0; + let opacity = 1; + let clipPathCss: string | undefined; + let blur = 0; + + if (!isExiting) { + const e = entry; + const inv = 1 - e; + switch (transition) { + case "pop": + scale = e; + opacity = e; + break; + case "slide-zoom": { + const dir = (seed % 4); // 0=left, 1=right, 2=top, 3=bottom + const slide = frameW * 0.6 * inv; + if (dir === 0) offX = -slide; + else if (dir === 1) offX = slide; + else if (dir === 2) offY = -slide; + else offY = slide; + scale = 0.7 + 0.3 * e; + opacity = e; + blur = inv * 8; + break; + } + case "spin": { + scale = e; + rot = clip.rotation + (1 - e) * (seed % 2 === 0 ? 540 : -540); + opacity = e; + break; + } + case "shutter": { + scale = 0.92 + 0.08 * e; + opacity = e > 0.1 ? 1 : 0; + const pct = Math.round(inv * 50); + clipPathCss = `inset(${pct}% 0 ${pct}% 0 round 14px)`; + break; + } + case "glitch": { + scale = 0.85 + 0.15 * e; + opacity = e; + // High-frequency jitter first ~8f then settles + const jitterAmt = Math.max(0, 1 - rel / 8); + offX = (random(`gx${seed}-${Math.floor(rel / 1)}`) - 0.5) * 40 * jitterAmt; + offY = (random(`gy${seed}-${Math.floor(rel / 1)}`) - 0.5) * 40 * jitterAmt; + rot = clip.rotation + (random(`gr${seed}-${Math.floor(rel / 2)}`) - 0.5) * 16 * jitterAmt; + break; + } + case "swoop": { + // arc in from a corner + const fromX = (seed % 2 === 0 ? -1 : 1) * frameW * 0.55; + const fromY = -frameH * 0.3; + offX = fromX * inv; + offY = fromY * inv; + scale = 0.6 + 0.4 * e; + rot = clip.rotation + inv * (seed % 2 === 0 ? -35 : 35); + opacity = e; + break; + } + } + } else { + // Exit: quick shrink + fade + const o = exit; + scale = 1 - 0.7 * o; + opacity = 1 - o; + rot = clip.rotation + o * (seed % 2 === 0 ? -8 : 8); + } + + // Gentle idle breathing while held + const idle = Math.sin((rel - 20) / 14 + seed) * 0.012; + const held = !isExiting && rel > 20; + const idleScale = held ? 1 + idle : 1; + + const aspect = clip.aspect ?? 3 / 4; // default portrait + const w = frameW * clip.widthPct; + const h = w / aspect; + const cx = clip.x * frameW; + const cy = clip.y * frameH; + + // Source offset for videos — Sequence starts at inFrame so OffthreadVideo + // naturally begins playback on card entry. + const startFromFrames = Math.round((clip.sourceInSeconds ?? 0) * fps); + + const borderWidth = clip.hero ? 10 : 8; + const glowColor = clip.hero ? "rgba(255, 210, 140, 0.5)" : "rgba(255,255,255,0.05)"; + + const cardContent = + clip.kind === "image" ? ( + + ) : ( + + + + ); + + return ( +
0 ? `blur(${blur}px)` : undefined, + willChange: "transform", + }} + > +
+ {cardContent} + {/* Subtle inner vignette on hero cards for depth */} + {clip.hero && ( + + )} + {/* Warm film edge glow for hero */} + {clip.hero && ( + + )} +
+
+ ); +}; + +export const CollageBurst: React.FC = ({ + backgroundSrc, + backgroundInSeconds = 0, + curtainStartSeconds, + curtainEndSeconds, + clips, +}) => { + const frame = useCurrentFrame(); + const { fps, width, height } = useVideoConfig(); + + const curtainStart = curtainStartSeconds * fps; + const curtainEnd = curtainEndSeconds * fps; + + const t = interpolate(frame, [curtainStart, curtainEnd], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + const panelShift = ease * (width / 2 + 40); + + const seamOpacity = frame < curtainStart ? 0.65 + 0.35 * Math.sin(frame / 3) : 0; + const seamGlow = frame < curtainStart ? 0.6 + 0.4 * Math.sin(frame / 4) : 0; + + const bgStartFrame = Math.max(0, curtainStart - 6); + const bgVisible = frame >= bgStartFrame; + + // Slow cinematic zoom + const bgZoom = interpolate(frame, [curtainStart, curtainStart + 900], [1.05, 1.18], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + // Screen shake intensity — spikes briefly around each clip entry + let shakeX = 0; + let shakeY = 0; + for (const c of clips) { + const entryFrame = c.inSeconds * fps; + const dist = frame - entryFrame; + if (dist >= 0 && dist < 6) { + const falloff = 1 - dist / 6; + const amp = (c.hero ? 12 : 5) * falloff; + shakeX += (random(`sx-${entryFrame}-${dist}`) - 0.5) * amp; + shakeY += (random(`sy-${entryFrame}-${dist}`) - 0.5) * amp; + } + } + + return ( + + {/* ---------- Hazy muted background ---------- */} + {bgVisible && ( + + + + )} + + {/* Dirty-gold / grey atmospheric wash layered over bg */} + {bgVisible && ( + + )} + + {/* Grainy noise via CSS — subtle film texture */} + {bgVisible && ( + + )} + + {/* ---------- Curtain ---------- */} + +
+
+
+ + + {/* ---------- Opening text (pre-reveal, on black) ---------- */} + + + {/* ---------- Foreground shake wrapper ---------- */} + + {clips.map((clip, i) => ( + + ))} + + + {/* ---------- Flash bursts layered on each clip entry ---------- */} + {clips.map((c, i) => ( + + ))} + + {/* Frame top/bottom vignette for focus */} + + + ); +}; diff --git a/remotion-composer/src/Explainer.tsx b/remotion-composer/src/Explainer.tsx index d4e65327..fb55dde6 100644 --- a/remotion-composer/src/Explainer.tsx +++ b/remotion-composer/src/Explainer.tsx @@ -43,6 +43,8 @@ import { AnimeScene } from "./components/AnimeScene"; import type { CameraMotion } from "./components/AnimeScene"; import { TerminalScene } from "./components/TerminalScene"; import type { TerminalStep } from "./components/TerminalScene"; +import { ScreenshotScene } from "./components/ScreenshotScene"; +import type { ScreenshotStep } from "./components/ScreenshotScene"; import { ProviderChip } from "./components/ProviderChip"; import type { ParticleType } from "./components/ParticleOverlay"; import { resolveTheme, type ThemeConfig, DEFAULT_THEME } from "./Root"; @@ -262,6 +264,10 @@ interface Cut { steps?: TerminalStep[]; terminalTitle?: string; prompt?: string; + // Screenshot scene props (type: "screenshot_scene") + screenshotSteps?: ScreenshotStep[]; + screenshotSize?: { width: number; height: number }; + cursorStartAt?: [number, number]; } interface Overlay { @@ -602,6 +608,17 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme /> ); } + if (cut.type === "screenshot_scene" && cut.backgroundImage && cut.screenshotSteps) { + return ( + + ); + } // --- Chart types — use theme.chartColors as default palette --- if (cut.type === "bar_chart" && cut.chartData) { diff --git a/remotion-composer/src/LyricOverlay.tsx b/remotion-composer/src/LyricOverlay.tsx new file mode 100644 index 00000000..24605f57 --- /dev/null +++ b/remotion-composer/src/LyricOverlay.tsx @@ -0,0 +1,173 @@ +import { + AbsoluteFill, + Audio, + OffthreadVideo, + interpolate, + staticFile, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import React from "react"; +import { loadFont as loadPlayfair } from "@remotion/google-fonts/PlayfairDisplay"; + +const { fontFamily: playfairItalic } = loadPlayfair("italic", { + weights: ["400", "700"], + subsets: ["latin"], +}); + +function resolveAsset(src: string): string { + if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) return src; + const clean = src.replace(/^file:\/\/\/?/, ""); + if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) { + return `file:///${clean.replace(/\\/g, "/")}`; + } + return staticFile(clean); +} + +export interface Lyric { + text: string; + inSeconds: number; + outSeconds: number; +} + +export interface LyricOverlayProps { + videoSrc: string; + lyrics: Lyric[]; + bottomY?: number; // 0..1, vertical center of subtitle band +} + +const LyricLine: React.FC<{ lyric: Lyric; bottomY: number }> = ({ lyric, bottomY }) => { + const frame = useCurrentFrame(); + const { fps, width, height } = useVideoConfig(); + const inFrame = lyric.inSeconds * fps; + const outFrame = lyric.outSeconds * fps; + if (frame < inFrame - 1 || frame > outFrame + 8) return null; + + const fadeInDur = 6; + const fadeOutDur = 8; + + const fadeIn = interpolate(frame, [inFrame, inFrame + fadeInDur], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const fadeOut = interpolate(frame, [outFrame, outFrame + fadeOutDur], [1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const opacity = fadeIn * fadeOut; + + const yRise = interpolate(fadeIn, [0, 1], [10, 0]); + + const cream = "#F5E7C5"; + const gold = "rgba(255, 214, 150, 0.85)"; + + // Line draws in from center outward beneath the text + const lineProgress = interpolate(frame, [inFrame, inFrame + fadeInDur + 4], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + return ( + + {/* Subtle dark backdrop behind text for readability */} +
+
+
+ {lyric.text} +
+ {/* Gold underline */} +
+
+
+
+
+
+ + ); +}; + +export const LyricOverlay: React.FC = ({ + videoSrc, + lyrics, + bottomY = 0.88, +}) => { + const { durationInFrames } = useVideoConfig(); + return ( + + + {lyrics.map((l, i) => ( + + ))} + + ); +}; diff --git a/remotion-composer/src/Root.tsx b/remotion-composer/src/Root.tsx index 175e5dac..d837f142 100644 --- a/remotion-composer/src/Root.tsx +++ b/remotion-composer/src/Root.tsx @@ -14,6 +14,8 @@ import { EndTag, EndTagProps } from "./components/EndTag"; import { HeroTitle } from "./components/HeroTitle"; import { ProductReveal, ProductRevealProps } from "./components/ProductReveal"; import { CaptionOverlay, WordCaption } from "./components/CaptionOverlay"; +import { CollageBurst, CollageBurstProps } from "./CollageBurst"; +import { LyricOverlay, LyricOverlayProps } from "./LyricOverlay"; // --------------------------------------------------------------------------- // Theme System — prevents every video from looking like dark fintech @@ -266,6 +268,34 @@ export const Root: React.FC = () => { backgroundColor: "rgba(15, 23, 42, 0.75)", }} /> + + cvAspect) { + // image is wider → fit width, letterbox top/bottom + const w = cvW; + const h = cvW / imgAspect; + return { x: 0, y: (cvH - h) / 2, w, h }; + } else { + // image is taller → fit height, letterbox left/right + const h = cvH; + const w = cvH * imgAspect; + return { x: (cvW - w) / 2, y: 0, w, h }; + } +} + +// ---------- Timing walk — assign frame windows to each step ---------- + +interface TimedStep { + step: ScreenshotStep; + startFrame: number; + endFrame: number; + /** cursor position at start of this step (inclusive) */ + cursorBefore: Point; + /** cursor position at end of this step */ + cursorAfter: Point; +} + +function walkTimeline( + steps: ScreenshotStep[], + fps: number, + cursorStart: Point +): { timed: TimedStep[]; totalFrames: number } { + const timed: TimedStep[] = []; + let cursor = cursorStart; + let frameCursor = 0; + + for (const step of steps) { + let duration = 0; + const before = cursor; + let after = cursor; + let blocks = true; // whether step advances timeline cursor + + switch (step.kind) { + case "cursor_move": + duration = (step.durationSeconds ?? 0.9) * fps; + after = step.to; + break; + case "click_pulse": + duration = (step.durationSeconds ?? 0.45) * fps; + if (step.at) after = step.at; + break; + case "type_into": { + const speed = step.typeSpeed ?? 0.04; + duration = step.text.length * speed * fps + 0.25 * fps; + break; + } + case "bubble_append": + duration = (step.durationSeconds ?? 0.9) * fps; + break; + case "typing_dots": + duration = (step.durationSeconds ?? 1.2) * fps; + break; + case "highlight_box": + duration = (step.durationSeconds ?? 1.5) * fps; + blocks = false; // non-blocking — subsequent steps can overlap + break; + case "callout_balloon": + duration = (step.durationSeconds ?? 2.2) * fps; + blocks = false; + break; + case "pause": + duration = step.seconds * fps; + break; + } + + timed.push({ + step, + startFrame: Math.round(frameCursor), + endFrame: Math.round(frameCursor + duration), + cursorBefore: before, + cursorAfter: after, + }); + cursor = after; + if (blocks) frameCursor += duration; + } + + const totalFrames = Math.max( + ...timed.map((t) => t.endFrame), + Math.round(frameCursor) + ); + return { timed, totalFrames }; +} + +// ---------- SVG cursor ---------- + +const CursorArrow: React.FC<{ size?: number }> = ({ size = 28 }) => ( + + + +); + +// ---------- Main component ---------- + +export const ScreenshotScene: React.FC = ({ + backgroundImage, + backgroundSize, + steps, + accentColor = "#F59E0B", + cursorStartAt = [0.95, 0.05], +}) => { + const frame = useCurrentFrame(); + const { fps, width: cvW, height: cvH } = useVideoConfig(); + + const imgW = backgroundSize?.width ?? 1920; + const imgH = backgroundSize?.height ?? 1080; + const rect = containRect(imgW, imgH, cvW, cvH); + + // Convert normalized (0-1) backdrop coord to absolute canvas pixels + const abs = (p: Point): { x: number; y: number } => ({ + x: rect.x + p[0] * rect.w, + y: rect.y + p[1] * rect.h, + }); + const absRect = (r: Region) => ({ + left: rect.x + r.x * rect.w, + top: rect.y + r.y * rect.h, + width: r.w * rect.w, + height: r.h * rect.h, + }); + + // Walk timeline once + const { timed } = walkTimeline(steps, fps, cursorStartAt); + + // --- Cursor position at current frame --- + // Find the active cursor_move or the completed-most-recent one. + let cursorPos = cursorStartAt; + for (const t of timed) { + if (frame >= t.endFrame) { + cursorPos = t.cursorAfter; + } else if (frame >= t.startFrame && t.step.kind === "cursor_move") { + const p = interpolate(frame, [t.startFrame, t.endFrame], [0, 1], { + extrapolateRight: "clamp", + }); + // Ease-out so cursor decelerates as it arrives + const eased = 1 - Math.pow(1 - p, 3); + cursorPos = [ + t.cursorBefore[0] + (t.cursorAfter[0] - t.cursorBefore[0]) * eased, + t.cursorBefore[1] + (t.cursorAfter[1] - t.cursorBefore[1]) * eased, + ]; + break; + } else if (frame < t.startFrame) { + cursorPos = t.cursorBefore; + break; + } + } + + const cursorAbs = abs(cursorPos); + + return ( + + {/* Backdrop */} + + + {/* Overlays — render in order so later steps paint on top. + Sticky kinds (type_into, bubble_append) persist once they appear; + transient kinds fade out after their duration. */} + {timed.map((t, i) => { + const kind = t.step.kind; + const sticky = kind === "type_into" || kind === "bubble_append"; + const active = sticky + ? frame >= t.startFrame + : frame >= t.startFrame && frame <= t.endFrame + fps * 0.4; + if (!active) return null; + return ( + + ); + })} + + {/* Cursor — always on top */} +
+ +
+
+ ); +}; + +// ---------- Per-step overlay renderers ---------- + +interface OverlayProps { + timed: TimedStep; + frame: number; + fps: number; + rect: { x: number; y: number; w: number; h: number }; + abs: (p: Point) => { x: number; y: number }; + absRect: (r: Region) => { left: number; top: number; width: number; height: number }; + accentColor: string; +} + +const OverlayForStep: React.FC = ({ + timed, + frame, + fps, + rect, + abs, + absRect, + accentColor, +}) => { + const { step, startFrame, endFrame } = timed; + const localFrame = frame - startFrame; + + if (step.kind === "click_pulse") { + const at = step.at ?? timed.cursorBefore; + const p = abs(at); + const progress = interpolate(localFrame, [0, endFrame - startFrame], [0, 1], { + extrapolateRight: "clamp", + }); + const size = interpolate(progress, [0, 1], [10, 80]); + const alpha = interpolate(progress, [0, 1], [0.85, 0]); + const color = step.color ?? accentColor; + return ( +
+ ); + } + + if (step.kind === "type_into") { + const r = absRect(step.region); + const speed = step.typeSpeed ?? 0.04; + const totalChars = step.text.length; + const typeFrames = totalChars * speed * fps; + const revealed = Math.min( + totalChars, + Math.floor(interpolate(localFrame, [0, typeFrames], [0, totalChars], { extrapolateRight: "clamp" })) + ); + const typed = step.text.slice(0, revealed); + const fontPx = Math.round(rect.h * (step.fontSize ?? 0.024)); + const blink = Math.floor(frame / (fps * 0.5)) % 2 === 0; + return ( +
+ {typed} + {blink && ( + + )} +
+ ); + } + + if (step.kind === "bubble_append") { + const r = absRect(step.region); + const springIn = spring({ + frame: localFrame, + fps, + config: { damping: 16, stiffness: 140 }, + durationInFrames: Math.ceil(fps * 0.5), + }); + const isUser = step.role === "user"; + const fontPx = Math.round(rect.h * (step.fontSize ?? 0.021)); + + // Streaming text reveal (word-by-word) + let displayText = step.text; + if (step.stream) { + const words = step.text.split(/(\s+)/); // keep whitespace + const totalRevealFrames = Math.max(1, endFrame - startFrame - fps * 0.3); + const wordCount = words.filter((w) => w.trim()).length; + const revealedWords = Math.floor( + interpolate(localFrame, [fps * 0.3, fps * 0.3 + totalRevealFrames], [0, wordCount], { + extrapolateRight: "clamp", + extrapolateLeft: "clamp", + }) + ); + let count = 0; + const pieces: string[] = []; + for (const w of words) { + if (w.trim()) { + if (count < revealedWords) { + pieces.push(w); + count++; + } else { + break; + } + } else { + pieces.push(w); + } + } + displayText = pieces.join(""); + } + + const bg = isUser ? "#2D3748" : "#1F2937"; + const border = isUser ? "#4A5568" : "#374151"; + + return ( +
+ {displayText} +
+ ); + } + + if (step.kind === "typing_dots") { + const p = abs(step.at); + const dotSize = Math.round(rect.h * 0.01); + const dots = [0, 1, 2].map((i) => { + const phase = (frame / (fps * 0.35) - i * 0.3) % 2; + const alpha = phase < 1 ? 0.3 + phase * 0.7 : 1 - (phase - 1) * 0.7; + return alpha; + }); + const color = step.color ?? accentColor; + return ( +
+ {dots.map((a, i) => ( +
+ ))} +
+ ); + } + + if (step.kind === "highlight_box") { + const r = absRect(step.region); + const dur = endFrame - startFrame; + const pulses = step.pulses ?? 2; + const color = step.color ?? accentColor; + // Pulsing ring: oscillate opacity + scale + const wave = Math.sin((localFrame / dur) * pulses * Math.PI * 2) * 0.5 + 0.5; + const alpha = 0.4 + wave * 0.5; + const glow = 10 + wave * 18; + return ( +
+ ); + } + + if (step.kind === "callout_balloon") { + const a = abs(step.anchor); + const pos = step.position ?? "top"; + const springIn = spring({ + frame: localFrame, + fps, + config: { damping: 14, stiffness: 160 }, + durationInFrames: Math.ceil(fps * 0.4), + }); + const dur = endFrame - startFrame; + const fadeOut = interpolate( + localFrame, + [dur - fps * 0.4, dur], + [1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + const alpha = Math.min(springIn, fadeOut); + const color = step.color ?? accentColor; + const fontPx = Math.round(rect.h * 0.024); + const maxW = rect.w * 0.28; + + // Balloon offset from anchor + const offset = rect.h * 0.06; + let bx = a.x; + let by = a.y; + let tailStyle: React.CSSProperties = {}; + if (pos === "top") { + by = a.y - offset - fontPx * 2.5; + bx = a.x - maxW / 2; + tailStyle = { + position: "absolute", + bottom: -10, + left: "50%", + transform: "translateX(-50%)", + width: 0, + height: 0, + borderLeft: "10px solid transparent", + borderRight: "10px solid transparent", + borderTop: `12px solid ${color}`, + }; + } else if (pos === "bottom") { + by = a.y + offset; + bx = a.x - maxW / 2; + tailStyle = { + position: "absolute", + top: -10, + left: "50%", + transform: "translateX(-50%)", + width: 0, + height: 0, + borderLeft: "10px solid transparent", + borderRight: "10px solid transparent", + borderBottom: `12px solid ${color}`, + }; + } else if (pos === "left") { + bx = a.x - offset - maxW; + by = a.y - fontPx; + tailStyle = { + position: "absolute", + right: -10, + top: "50%", + transform: "translateY(-50%)", + width: 0, + height: 0, + borderTop: "10px solid transparent", + borderBottom: "10px solid transparent", + borderLeft: `12px solid ${color}`, + }; + } else { + bx = a.x + offset; + by = a.y - fontPx; + tailStyle = { + position: "absolute", + left: -10, + top: "50%", + transform: "translateY(-50%)", + width: 0, + height: 0, + borderTop: "10px solid transparent", + borderBottom: "10px solid transparent", + borderRight: `12px solid ${color}`, + }; + } + + return ( +
+ {step.text} +
+
+ ); + } + + return null; +}; diff --git a/remotion-composer/src/components/index.ts b/remotion-composer/src/components/index.ts index 86567a88..cc4976e8 100644 --- a/remotion-composer/src/components/index.ts +++ b/remotion-composer/src/components/index.ts @@ -11,7 +11,9 @@ export { HeroTitle } from "./HeroTitle"; export { ParticleOverlay } from "./ParticleOverlay"; export { AnimeScene } from "./AnimeScene"; export { TerminalScene } from "./TerminalScene"; +export { ScreenshotScene } from "./ScreenshotScene"; export { ProviderChip } from "./ProviderChip"; export type { ParticleType } from "./ParticleOverlay"; export type { CameraMotion, AnimeSceneProps } from "./AnimeScene"; export type { TerminalStep } from "./TerminalScene"; +export type { ScreenshotStep, Region, Point } from "./ScreenshotScene"; diff --git a/tools/video/seedance_video.py b/tools/video/seedance_video.py index a0135d40..4f4edb9e 100644 --- a/tools/video/seedance_video.py +++ b/tools/video/seedance_video.py @@ -257,7 +257,7 @@ class SeedanceVideo(BaseTool): try: submit_resp = requests.post( - f"https://queue.fal.run/fal-ai/{model_path}", + f"https://queue.fal.run/{model_path}", headers=headers, json=payload, timeout=30, @@ -305,7 +305,7 @@ class SeedanceVideo(BaseTool): success=True, data={ "provider": "seedance", - "model": f"fal-ai/{model_path}", + "model": model_path, "prompt": inputs["prompt"], "operation": operation, "variant": variant, @@ -321,5 +321,5 @@ class SeedanceVideo(BaseTool): artifacts=[str(output_path)], cost_usd=self.estimate_cost(inputs), duration_seconds=round(time.time() - start, 2), - model=f"fal-ai/{model_path}", + model=model_path, )