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.
This commit is contained in:
calesthio
2026-04-23 23:30:23 -07:00
parent 578d77e336
commit 386338c92b
11 changed files with 1763 additions and 44 deletions
+1
View File
@@ -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 1530s focused demos. Coordinates are normalized (01) against the contain-fit rect. See [`.agents/skills/synthetic-ui-recording/SKILL.md`](../.agents/skills/synthetic-ui-recording/SKILL.md) (planned).** |
---
+168 -35
View File
@@ -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 (
<AbsoluteFill
style={{
background:
"radial-gradient(circle at 50% 42%, rgba(16,28,40,0.9) 0%, rgba(3,5,8,1) 58%, rgba(0,0,0,1) 100%)",
background: "#000",
justifyContent: "center",
alignItems: "center",
}}
>
{backgroundSrc ? (
<>
<AbsoluteFill style={{ transform: `scale(${bgScale})`, opacity: 0.62 }}>
<OffthreadVideo
muted
src={resolveAsset(backgroundSrc)}
trimBefore={bgTrimBefore}
trimAfter={bgTrimAfter}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
filter: "contrast(1.08) saturate(0.55) brightness(0.55) blur(4px)",
}}
/>
</AbsoluteFill>
<AbsoluteFill
style={{
background:
"linear-gradient(180deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.35) 40%, rgba(0,0,0,0.35) 60%, rgba(0,0,0,0.78) 100%)",
}}
/>
</>
) : null}
<AbsoluteFill
style={{
background: plateBg,
}}
/>
<SignalTexture
accent={accent}
intensity={intensity}
intensity={intensity * 0.7}
lineCount={signalLineCount}
/>
{/* Top accent line — grows from center outwards */}
<div
style={{
position: "absolute",
width: 880,
height: 2,
width: 1100 * lineGrow,
height: 1,
background: accent,
boxShadow: `0 0 28px ${accent}`,
opacity: flareOpacity,
transform: "translateY(-126px)",
boxShadow: `0 0 24px ${accent}`,
opacity: flareOpacity * lineExit,
transform: "translateY(-118px)",
}}
/>
<div
style={{
position: "absolute",
width: 880,
height: 2,
width: 1100 * lineGrow,
height: 1,
background: accent,
boxShadow: `0 0 28px ${accent}`,
opacity: flareOpacity * 0.7,
transform: "translateY(126px)",
boxShadow: `0 0 24px ${accent}`,
opacity: flareOpacity * 0.75 * lineExit,
transform: "translateY(118px)",
}}
/>
{/* Word-stagger text reveal */}
<div
style={{
opacity: reveal * exit,
transform: `translateY(${y}px)`,
fontFamily,
fontWeight: 700,
fontSize: titleFontSize,
lineHeight: 1.06,
letterSpacing: `${letterSpacing}em`,
textAlign: "center",
color: "#f3f6fa",
textTransform: "uppercase",
opacity: exit,
width: titleWidth,
textShadow: "0 0 22px rgba(255,255,255,0.08)",
textAlign: "center",
fontFamily,
fontWeight: 500,
fontSize: titleFontSize,
lineHeight: 1.12,
letterSpacing: "0.16em",
color: "#f6f4ee",
textTransform: "uppercase",
textShadow: "0 0 34px rgba(255,255,255,0.10), 0 0 2px rgba(0,0,0,0.8)",
}}
>
{text}
{(() => {
let wordCounter = 0;
return lines.map((line, lineIdx) => {
const tokens = line.split(/(\s+)/).filter((w) => w.length > 0);
return (
<div key={lineIdx} style={{ display: "block" }}>
{tokens.map((w, ti) => {
if (/^\s+$/.test(w)) {
return <span key={ti}>&nbsp;</span>;
}
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 (
<span
key={ti}
style={{
display: "inline-block",
opacity: wordOpacity,
filter: `blur(${blur}px)`,
transform: `translateY(${ty}px)`,
}}
>
{w}
</span>
);
})}
</div>
);
});
})()}
</div>
{/* Subtle accent dot centered under text */}
<div
style={{
position: "absolute",
width: 6,
height: 6,
borderRadius: "50%",
background: accent,
boxShadow: `0 0 18px ${accent}`,
opacity: 0.55 * container * lineExit,
transform: "translateY(172px)",
}}
/>
</AbsoluteFill>
);
};
@@ -384,6 +513,10 @@ export const CinematicRenderer: React.FC<CinematicRendererProps> = ({
titleFontSize={titleFontSize}
titleWidth={titleWidth}
signalLineCount={signalLineCount}
backgroundSrc={scene.backgroundSrc}
backgroundTrimBeforeSeconds={scene.backgroundTrimBeforeSeconds}
backgroundTrimAfterSeconds={scene.backgroundTrimAfterSeconds}
variant={scene.variant}
/>
)}
</Sequence>
+608
View File
@@ -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 (
<AbsoluteFill
style={{
pointerEvents: "none",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
paddingTop: "8%",
opacity,
}}
>
<div
style={{
transform: `translateY(${y}px)`,
textAlign: "center",
filter: `drop-shadow(0 0 22px rgba(255, 200, 120, 0.3))`,
}}
>
{/* Decorative rule */}
<div
style={{
width: 160 * lineProgress,
height: 1.5,
background: `linear-gradient(90deg, rgba(245,231,197,0) 0%, ${gold} 50%, rgba(245,231,197,0) 100%)`,
margin: "0 auto 28px",
}}
/>
<div
style={{
fontFamily: playfairFamily,
fontWeight: 400,
fontSize: 46,
color: cream,
letterSpacing: "0.01em",
lineHeight: 1.35,
textShadow: "0 2px 18px rgba(0,0,0,0.7)",
}}
>
{lineOne}
</div>
<div
style={{
fontFamily: playfairItalic,
fontStyle: "italic",
fontWeight: 400,
fontSize: 78,
color: cream,
letterSpacing: "0.005em",
lineHeight: 1.15,
marginTop: 14,
textShadow: "0 2px 22px rgba(0,0,0,0.75)",
}}
>
{lineTwo}
</div>
{/* Small jewel divider */}
<div
style={{
marginTop: 30,
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 14,
opacity: lineProgress,
}}
>
<div
style={{
width: 60,
height: 1,
background: `linear-gradient(90deg, rgba(245,231,197,0) 0%, ${gold} 100%)`,
}}
/>
<div
style={{
width: 6,
height: 6,
borderRadius: 999,
background: gold,
boxShadow: `0 0 14px ${gold}`,
}}
/>
<div
style={{
width: 60,
height: 1,
background: `linear-gradient(90deg, ${gold} 0%, rgba(245,231,197,0) 100%)`,
}}
/>
</div>
</div>
</AbsoluteFill>
);
};
// ----------------------------------------------------------------------------
// 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 (
<AbsoluteFill
style={{
backgroundColor: "white",
opacity,
pointerEvents: "none",
mixBlendMode: "screen",
}}
/>
);
};
// ----------------------------------------------------------------------------
// CollageCard — picks an entry transition by `clip.transition` and plays video
// starting at its in-time via <Sequence>.
// ----------------------------------------------------------------------------
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" ? (
<Img
src={resolveAsset(clip.src)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<Sequence from={inFrame} layout="none">
<OffthreadVideo
src={resolveAsset(clip.src)}
startFrom={startFromFrames}
muted
playbackRate={1}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
</Sequence>
);
return (
<div
style={{
position: "absolute",
left: cx - w / 2,
top: cy - h / 2,
width: w,
height: h,
transform: `translate(${offX}px, ${offY}px) rotate(${rot}deg) scale(${scale * idleScale})`,
transformOrigin: "center center",
opacity,
filter: blur > 0 ? `blur(${blur}px)` : undefined,
willChange: "transform",
}}
>
<div
style={{
position: "absolute",
inset: 0,
borderRadius: 16,
overflow: "hidden",
boxShadow: clip.hero
? "0 40px 100px rgba(0,0,0,0.7), 0 0 90px rgba(255,200,120,0.55), 0 8px 18px rgba(0,0,0,0.55)"
: "0 24px 60px rgba(0,0,0,0.6), 0 8px 18px rgba(0,0,0,0.4)",
border: `${borderWidth}px solid #FAFAF5`,
backgroundColor: "#FAFAF5",
clipPath: clipPathCss,
}}
>
{cardContent}
{/* Subtle inner vignette on hero cards for depth */}
{clip.hero && (
<AbsoluteFill
style={{
pointerEvents: "none",
background:
"radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,0.4) 100%)",
}}
/>
)}
{/* Warm film edge glow for hero */}
{clip.hero && (
<AbsoluteFill
style={{
pointerEvents: "none",
boxShadow: `inset 0 0 60px ${glowColor}`,
}}
/>
)}
</div>
</div>
);
};
export const CollageBurst: React.FC<CollageBurstProps> = ({
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 (
<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
{/* ---------- Hazy muted background ---------- */}
{bgVisible && (
<AbsoluteFill
style={{
transform: `scale(${bgZoom})`,
filter: "saturate(0.35) brightness(0.55) contrast(0.95) blur(6px)",
opacity: 0.55,
}}
>
<OffthreadVideo
src={resolveAsset(backgroundSrc)}
startFrom={Math.round(backgroundInSeconds * fps)}
muted
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
</AbsoluteFill>
)}
{/* Dirty-gold / grey atmospheric wash layered over bg */}
{bgVisible && (
<AbsoluteFill
style={{
pointerEvents: "none",
background:
"radial-gradient(ellipse at 50% 40%, rgba(180,150,110,0.18) 0%, rgba(30,28,24,0.75) 70%, rgba(5,5,5,0.95) 100%)",
mixBlendMode: "multiply",
}}
/>
)}
{/* Grainy noise via CSS — subtle film texture */}
{bgVisible && (
<AbsoluteFill
style={{
pointerEvents: "none",
opacity: 0.22,
background:
"repeating-conic-gradient(rgba(255,255,255,0.04) 0deg 1deg, rgba(0,0,0,0) 1deg 2deg)",
mixBlendMode: "overlay",
}}
/>
)}
{/* ---------- Curtain ---------- */}
<AbsoluteFill>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "50%",
height: "100%",
backgroundColor: "#050505",
transform: `translateX(${-panelShift}px)`,
boxShadow: "inset -30px 0 60px rgba(0,0,0,0.9)",
}}
/>
<div
style={{
position: "absolute",
top: 0,
right: 0,
width: "50%",
height: "100%",
backgroundColor: "#050505",
transform: `translateX(${panelShift}px)`,
boxShadow: "inset 30px 0 60px rgba(0,0,0,0.9)",
}}
/>
<div
style={{
position: "absolute",
top: 0,
left: "50%",
width: 8,
height: "100%",
transform: "translateX(-50%)",
background:
"linear-gradient(180deg, rgba(255,220,160,0) 0%, rgba(255,220,160,1) 50%, rgba(255,220,160,0) 100%)",
opacity: Math.max(0, seamOpacity - ease * 1.2),
filter: `blur(${2 + 6 * seamGlow}px)`,
boxShadow: `0 0 ${50 + 80 * seamGlow}px rgba(255, 200, 120, 0.95)`,
}}
/>
</AbsoluteFill>
{/* ---------- Opening text (pre-reveal, on black) ---------- */}
<OpeningText
lineOne="When you realize your daughters"
lineTwo="deserve these lines"
fadeInStart={Math.round(0.3 * fps)}
fadeInEnd={Math.round(0.9 * fps)}
fadeOutStart={Math.round(1.8 * fps)}
fadeOutEnd={Math.round(2.6 * fps)}
/>
{/* ---------- Foreground shake wrapper ---------- */}
<AbsoluteFill style={{ transform: `translate(${shakeX}px, ${shakeY}px)` }}>
{clips.map((clip, i) => (
<CollageCard key={i} clip={clip} frameW={width} frameH={height} />
))}
</AbsoluteFill>
{/* ---------- Flash bursts layered on each clip entry ---------- */}
{clips.map((c, i) => (
<CardFlash
key={`f${i}`}
atFrame={c.inSeconds * fps}
strength={c.hero ? 0.55 : 0.22}
/>
))}
{/* Frame top/bottom vignette for focus */}
<AbsoluteFill
style={{
background:
"linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0) 14%, rgba(0,0,0,0) 86%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};
+17
View File
@@ -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 (
<ScreenshotScene
backgroundImage={cut.backgroundImage}
backgroundSize={cut.screenshotSize}
steps={cut.screenshotSteps as ScreenshotStep[]}
accentColor={accent}
cursorStartAt={cut.cursorStartAt}
/>
);
}
// --- Chart types — use theme.chartColors as default palette ---
if (cut.type === "bar_chart" && cut.chartData) {
+173
View File
@@ -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 (
<AbsoluteFill
style={{
pointerEvents: "none",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-end",
paddingBottom: height * (1 - bottomY),
opacity,
}}
>
{/* Subtle dark backdrop behind text for readability */}
<div
style={{
position: "absolute",
bottom: height * (1 - bottomY) - 80,
left: 0,
right: 0,
height: 240,
background:
"linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 55%, rgba(0,0,0,0.75) 100%)",
opacity,
}}
/>
<div
style={{
transform: `translateY(${yRise}px)`,
textAlign: "center",
padding: "0 60px",
filter: "drop-shadow(0 0 18px rgba(255, 200, 120, 0.22))",
position: "relative",
zIndex: 2,
}}
>
<div
style={{
fontFamily: playfairItalic,
fontStyle: "italic",
fontWeight: 400,
fontSize: 54,
lineHeight: 1.15,
color: cream,
letterSpacing: "0.01em",
textShadow: "0 2px 18px rgba(0,0,0,0.85), 0 0 22px rgba(0,0,0,0.5)",
}}
>
{lyric.text}
</div>
{/* Gold underline */}
<div
style={{
marginTop: 18,
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 90 * lineProgress,
height: 1.2,
background: `linear-gradient(90deg, rgba(245,231,197,0) 0%, ${gold} 100%)`,
}}
/>
<div
style={{
width: 5,
height: 5,
borderRadius: 999,
background: gold,
opacity: lineProgress,
boxShadow: `0 0 10px ${gold}`,
}}
/>
<div
style={{
width: 90 * lineProgress,
height: 1.2,
background: `linear-gradient(90deg, ${gold} 0%, rgba(245,231,197,0) 100%)`,
}}
/>
</div>
</div>
</AbsoluteFill>
);
};
export const LyricOverlay: React.FC<LyricOverlayProps> = ({
videoSrc,
lyrics,
bottomY = 0.88,
}) => {
const { durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
<OffthreadVideo src={resolveAsset(videoSrc)} />
{lyrics.map((l, i) => (
<LyricLine key={i} lyric={l} bottomY={bottomY} />
))}
</AbsoluteFill>
);
};
+30
View File
@@ -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)",
}}
/>
<Composition
id="CollageBurst"
component={CollageBurst}
durationInFrames={30 * 30}
fps={30}
width={1080}
height={1920}
defaultProps={{
backgroundSrc: "",
backgroundInSeconds: 0,
curtainStartSeconds: 1.5,
curtainEndSeconds: 3.0,
clips: [],
} as CollageBurstProps}
/>
<Composition
id="LyricOverlay"
component={LyricOverlay}
durationInFrames={30 * 28}
fps={30}
width={1080}
height={1920}
defaultProps={{
videoSrc: "",
lyrics: [],
bottomY: 0.88,
} as LyricOverlayProps}
/>
<Composition
id="EndTag"
component={EndTag}
+4
View File
@@ -22,6 +22,10 @@ export interface CinematicTitleScene extends CinematicBaseScene {
text: string;
accent?: string;
intensity?: number;
backgroundSrc?: string;
backgroundTrimBeforeSeconds?: number;
backgroundTrimAfterSeconds?: number;
variant?: "plate" | "overlay";
}
export type CinematicScene = CinematicVideoScene | CinematicTitleScene;
@@ -0,0 +1,668 @@
import {
AbsoluteFill,
Img,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
/**
* ScreenshotScene — approach-1 synthetic UI demo.
*
* Takes any screenshot as a frozen backdrop and animates scripted overlays
* (cursor, click pulses, typing, chat bubbles, highlight rings, callouts)
* on top at normalized coordinates. Viewer-indistinguishable from a real
* screen recording for ~15-30s focused demos.
*
* Coordinate system: everything is 0-1 normalized against the rendered
* backdrop rectangle (not the raw canvas), so overlays track the image
* correctly regardless of letterboxing.
*
* See .agents/skills/synthetic-ui-recording/SKILL.md for authoring guidance.
*/
// ---------- Types ----------
export type Region = { x: number; y: number; w: number; h: number }; // all 0-1
export type Point = [number, number]; // [x, y], 0-1 normalized
export type ScreenshotStep =
| { kind: "cursor_move"; to: Point; durationSeconds?: number }
| { kind: "click_pulse"; at?: Point; durationSeconds?: number; color?: string }
| {
kind: "type_into";
region: Region;
text: string;
typeSpeed?: number; // seconds per char
fontSize?: number; // in normalized-height units; default 0.022
color?: string;
}
| {
kind: "bubble_append";
region: Region; // where the bubble lands (its bounding box)
text: string;
role?: "user" | "assistant";
durationSeconds?: number;
stream?: boolean; // if true, text reveals word-by-word over the duration
fontSize?: number;
}
| {
kind: "typing_dots";
at: Point;
durationSeconds?: number;
color?: string;
}
| {
kind: "highlight_box";
region: Region;
durationSeconds?: number;
color?: string;
pulses?: number;
}
| {
kind: "callout_balloon";
anchor: Point; // the element being pointed at
text: string;
position?: "top" | "bottom" | "left" | "right"; // where balloon sits relative to anchor
durationSeconds?: number;
color?: string;
}
| { kind: "pause"; seconds: number };
interface ScreenshotSceneProps {
backgroundImage: string;
/** Natural pixel size of the image — used to compute the contain-fit
* rectangle so overlays land on correct pixels. Defaults to 16:9. */
backgroundSize?: { width: number; height: number };
steps: ScreenshotStep[];
accentColor?: string;
/** Starting cursor position. Default: top-right area. */
cursorStartAt?: Point;
}
// ---------- Helpers ----------
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);
}
/** Compute the rendered bounding box of the backdrop inside a canvas,
* using object-fit: contain semantics. Returns pixel offsets/sizes. */
function containRect(
imgW: number,
imgH: number,
cvW: number,
cvH: number
): { x: number; y: number; w: number; h: number } {
const imgAspect = imgW / imgH;
const cvAspect = cvW / cvH;
if (imgAspect > 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 }) => (
<svg width={size} height={size * 1.2} viewBox="0 0 16 20" style={{ display: "block" }}>
<path
d="M2 2 L2 16 L6 12 L8.5 17 L10.5 16 L8 11 L13 11 Z"
fill="#FFFFFF"
stroke="#111"
strokeWidth={1.2}
strokeLinejoin="round"
/>
</svg>
);
// ---------- Main component ----------
export const ScreenshotScene: React.FC<ScreenshotSceneProps> = ({
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 (
<AbsoluteFill style={{ background: "#000" }}>
{/* Backdrop */}
<Img
src={resolveAsset(backgroundImage)}
style={{
position: "absolute",
left: rect.x,
top: rect.y,
width: rect.w,
height: rect.h,
objectFit: "fill",
}}
/>
{/* 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 (
<OverlayForStep
key={i}
timed={t}
frame={frame}
fps={fps}
rect={rect}
abs={abs}
absRect={absRect}
accentColor={accentColor}
/>
);
})}
{/* Cursor — always on top */}
<div
style={{
position: "absolute",
left: cursorAbs.x - 4,
top: cursorAbs.y - 2,
pointerEvents: "none",
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.4))",
}}
>
<CursorArrow size={Math.round(rect.w * 0.018)} />
</div>
</AbsoluteFill>
);
};
// ---------- 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<OverlayProps> = ({
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 (
<div
style={{
position: "absolute",
left: p.x - size / 2,
top: p.y - size / 2,
width: size,
height: size,
borderRadius: "50%",
border: `3px solid ${color}`,
opacity: alpha,
pointerEvents: "none",
}}
/>
);
}
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 (
<div
style={{
position: "absolute",
left: r.left,
top: r.top,
width: r.width,
height: r.height,
display: "flex",
alignItems: "center",
paddingLeft: Math.round(rect.w * 0.012),
fontFamily: "Inter, -apple-system, sans-serif",
fontSize: fontPx,
color: step.color ?? "#E5E7EB",
pointerEvents: "none",
whiteSpace: "nowrap",
overflow: "hidden",
}}
>
<span>{typed}</span>
{blink && (
<span
style={{
display: "inline-block",
width: 2,
height: fontPx * 0.95,
background: step.color ?? "#E5E7EB",
marginLeft: 2,
}}
/>
)}
</div>
);
}
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 (
<div
style={{
position: "absolute",
left: r.left,
top: r.top,
width: r.width,
minHeight: r.height,
background: bg,
border: `1px solid ${border}`,
borderRadius: Math.round(rect.w * 0.008),
padding: `${Math.round(rect.h * 0.015)}px ${Math.round(rect.w * 0.012)}px`,
fontFamily: "Inter, -apple-system, sans-serif",
fontSize: fontPx,
color: "#F1F5F9",
lineHeight: 1.5,
opacity: springIn,
transform: `translateY(${(1 - springIn) * 20}px)`,
boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
whiteSpace: "pre-wrap",
pointerEvents: "none",
overflow: "hidden",
}}
>
{displayText}
</div>
);
}
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 (
<div
style={{
position: "absolute",
left: p.x,
top: p.y,
display: "flex",
gap: dotSize * 0.7,
pointerEvents: "none",
}}
>
{dots.map((a, i) => (
<div
key={i}
style={{
width: dotSize,
height: dotSize,
borderRadius: "50%",
background: color,
opacity: Math.max(0.3, a),
}}
/>
))}
</div>
);
}
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 (
<div
style={{
position: "absolute",
left: r.left - 6,
top: r.top - 6,
width: r.width + 12,
height: r.height + 12,
border: `3px solid ${color}`,
borderRadius: Math.round(rect.w * 0.006),
boxShadow: `0 0 ${glow}px ${color}`,
opacity: alpha,
pointerEvents: "none",
}}
/>
);
}
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 (
<div
style={{
position: "absolute",
left: Math.max(rect.x + 8, Math.min(rect.x + rect.w - maxW - 8, bx)),
top: by,
width: maxW,
background: color,
color: "#0B0F1A",
fontFamily: "Inter, -apple-system, sans-serif",
fontWeight: 600,
fontSize: fontPx,
lineHeight: 1.35,
padding: `${Math.round(fontPx * 0.6)}px ${Math.round(fontPx * 0.9)}px`,
borderRadius: Math.round(rect.w * 0.008),
opacity: alpha,
transform: `scale(${interpolate(springIn, [0, 1], [0.9, 1])})`,
boxShadow: "0 10px 30px rgba(0,0,0,0.35)",
pointerEvents: "none",
}}
>
{step.text}
<div style={tailStyle} />
</div>
);
}
return null;
};
@@ -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";