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:
@@ -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}> </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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user