screen-demo: add synthetic-terminal mode via Remotion TerminalScene

Make synthetic screen recording a first-class, discoverable capability
alongside real OS capture. For CLI / terminal / install-flow demos where
commands and output are predictable, author a `terminal_scene` cut instead
of driving a real screen recorder — deterministic, privacy-safe, pixel-
perfect, and frame-accurate to narration cues.

Components:
- TerminalScene.tsx: window chrome, char-by-char typing, blinking cursor,
  scrolling output, non-blocking floating pills, spring-based reveals
- ProviderChip.tsx: rotating badge overlay that cycles through provider
  names (used in AI-generated-motion scenes)
- BackgroundVideoLayer + source_in_seconds + backgroundVideo props on
  every scene type — supports video-behind-component composition

Discovery chain (six layers, so the next agent finds this without reading
source code):

1. pipeline_defs/screen-demo.yaml — bump to 2.1, declare production_modes
   (real_capture, synthetic_terminal) with required_tools, scene_type, and
   agent_skills pointers
2. skills/pipelines/screen-demo/idea-director.md — mode-selection table
   at brief time; brief.metadata.production_mode contract
3. skills/pipelines/screen-demo/asset-director.md — reads production_mode
   and branches asset production (capture+overlays vs steps+narration+
   pacing check)
4. .agents/skills/synthetic-screen-recording/SKILL.md — Layer 3 skill with
   step kinds (cmd/out/pause/pill), pacing rule, and the frozen-terminal
   failure mode captured from the showcase v3 retune
5. AGENT_GUIDE.md — TerminalScene added to Remotion routing; links
   SCENE_TYPES.md as the authoritative cut-type registry
6. remotion-composer/SCENE_TYPES.md — new cheat sheet of every cut.type
   and overlay.type with required fields, plus a "how to add a new scene
   type" section (candidates: ChatTranscript, EditorScene, PrReview,
   SlackThread, TicketBoard)

Guardrail:
- lib/verify_scene_pacing.py — reusable trace() and assert_alignment()
  helpers that mimic the TerminalScene frame math exactly. Fail loudly
  before render if steps burn through too fast or leave the scene frozen.
This commit is contained in:
calesthio
2026-04-16 19:22:46 -07:00
parent 33ba37704e
commit a36ce99793
11 changed files with 918 additions and 26 deletions
+102 -19
View File
@@ -41,6 +41,9 @@ import { StatReveal } from "./components/StatReveal";
import { HeroTitle } from "./components/HeroTitle";
import { AnimeScene } from "./components/AnimeScene";
import type { CameraMotion } from "./components/AnimeScene";
import { TerminalScene } from "./components/TerminalScene";
import type { TerminalStep } from "./components/TerminalScene";
import { ProviderChip } from "./components/ProviderChip";
import type { ParticleType } from "./components/ParticleOverlay";
import { resolveTheme, type ThemeConfig, DEFAULT_THEME } from "./Root";
@@ -197,6 +200,9 @@ interface Cut {
subtitle?: string;
callout_type?: "info" | "warning" | "tip" | "quote";
title?: string;
// Video source trim — seek to this point in the source before playback.
// Defaults to 0 (play from beginning). Use this instead of in_seconds for source trimming.
source_in_seconds?: number;
// Comparison props
leftLabel?: string;
rightLabel?: string;
@@ -228,7 +234,9 @@ interface Cut {
// Styling overrides
backgroundColor?: string;
backgroundImage?: string; // AI-generated or stock image rendered behind the component
backgroundOverlay?: number; // Opacity of dark overlay on backgroundImage (0-1, default 0.55)
backgroundVideo?: string; // Video clip rendered behind the component (takes priority over backgroundImage)
backgroundVideoStart?: number; // Seek position in seconds for background video (default 0)
backgroundOverlay?: number; // Opacity of dark overlay on backgroundImage/backgroundVideo (0-1, default 0.55)
color?: string;
accentColor?: string;
fontSize?: number;
@@ -250,16 +258,24 @@ interface Cut {
vignette?: boolean;
lightingFrom?: string;
lightingTo?: string;
// Terminal scene props (type: "terminal_scene")
steps?: TerminalStep[];
terminalTitle?: string;
prompt?: string;
}
interface Overlay {
type: "section_title" | "stat_reveal" | "hero_title";
type: "section_title" | "stat_reveal" | "hero_title" | "provider_chip";
in_seconds: number;
out_seconds: number;
text: string;
text?: string;
subtitle?: string;
accentColor?: string;
position?: string;
// provider_chip
providers?: string[];
cycleSeconds?: number;
label?: string;
}
interface AudioLayer {
@@ -472,9 +488,54 @@ const BackgroundImageLayer: React.FC<{
);
};
// Background video layer — plays a looping video behind component content with dark overlay
const BackgroundVideoLayer: React.FC<{
src: string;
startFrom?: number;
overlayOpacity?: number;
children: React.ReactNode;
}> = ({ src, startFrom = 0, overlayOpacity = 0.55, children }) => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill style={{ overflow: "hidden" }}>
{/* Background video */}
<OffthreadVideo
src={resolveAsset(src)}
startFrom={Math.round(startFrom * fps)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
muted
/>
{/* Dark overlay for readability */}
<AbsoluteFill
style={{
background: `rgba(15, 23, 42, ${overlayOpacity})`,
}}
/>
{/* Component content on top */}
{children}
</AbsoluteFill>
);
};
const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme }) => {
// Wrap component with background image if specified
const maybeWrapWithBgImage = (element: React.ReactElement) => {
// Wrap component with background video or image if specified
const maybeWrapWithBg = (element: React.ReactElement) => {
if (cut.backgroundVideo) {
return (
<BackgroundVideoLayer
src={cut.backgroundVideo}
startFrom={cut.backgroundVideoStart ?? 0}
overlayOpacity={cut.backgroundOverlay ?? 0.55}
>
{element}
</BackgroundVideoLayer>
);
}
if (cut.backgroundImage) {
return (
<BackgroundImageLayer
@@ -491,24 +552,24 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
// Resolve the scene element based on cut type, then wrap with backgroundImage if set
// Use transparent bg so the animated gradient background shows through
// When no explicit backgroundColor on the cut, inherit from theme
const rawBg = cut.backgroundImage ? "transparent" : (cut.backgroundColor || theme.surfaceColor);
const rawBg = (cut.backgroundImage || cut.backgroundVideo) ? "transparent" : (cut.backgroundColor || theme.surfaceColor);
const bgColor = (rawBg === theme.backgroundColor || rawBg === "#0F172A" || rawBg === "#0f172a") ? "transparent" : rawBg;
const textColor = cut.color || theme.textColor;
const accent = cut.accentColor || theme.accentColor;
// Explicit component types — use theme-derived defaults for colors
if (cut.type === "text_card" && cut.text) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<TextCard text={cut.text} fontSize={cut.fontSize} color={textColor} backgroundColor={bgColor} />
);
}
if (cut.type === "stat_card" && cut.stat) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<StatCard stat={cut.stat} subtitle={cut.subtitle} accentColor={accent} backgroundColor={bgColor} />
);
}
if (cut.type === "callout" && cut.text) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<CalloutBox
text={cut.text} type={cut.callout_type} title={cut.title}
borderColor={accent} backgroundColor={cut.backgroundColor || theme.surfaceColor}
@@ -517,7 +578,7 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
);
}
if (cut.type === "comparison" && cut.leftLabel && cut.rightLabel && cut.leftValue && cut.rightValue) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<ComparisonCard
leftLabel={cut.leftLabel} rightLabel={cut.rightLabel}
leftValue={cut.leftValue} rightValue={cut.rightValue}
@@ -526,14 +587,25 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
);
}
if (cut.type === "hero_title" && cut.text) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<HeroTitle title={cut.text} subtitle={cut.heroSubtitle || cut.subtitle} />
);
}
if (cut.type === "terminal_scene" && cut.steps) {
return maybeWrapWithBg(
<TerminalScene
title={cut.terminalTitle || "Terminal"}
steps={cut.steps as TerminalStep[]}
prompt={cut.prompt}
accentColor={accent}
backgroundColor={bgColor || theme.backgroundColor}
/>
);
}
// --- Chart types — use theme.chartColors as default palette ---
if (cut.type === "bar_chart" && cut.chartData) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<BarChart
data={cut.chartData} title={cut.title} colors={cut.chartColors || theme.chartColors}
animationStyle={(cut.chartAnimation as any) || "grow-up"}
@@ -542,7 +614,7 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
);
}
if (cut.type === "line_chart" && cut.chartSeries) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<LineChart
series={cut.chartSeries} title={cut.title} colors={cut.chartColors || theme.chartColors}
animationStyle={(cut.chartAnimation as any) || "draw"}
@@ -552,7 +624,7 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
);
}
if (cut.type === "pie_chart" && cut.chartData) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<PieChart
data={cut.chartData} title={cut.title} colors={cut.chartColors || theme.chartColors}
animationStyle={(cut.chartAnimation as any) || "expand"}
@@ -562,7 +634,7 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
);
}
if (cut.type === "kpi_grid" && cut.chartData) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<KPIGrid
metrics={cut.chartData} title={cut.title} columns={cut.columns}
colors={cut.chartColors || theme.chartColors} animationStyle={(cut.chartAnimation as any) || "count-up"}
@@ -571,7 +643,7 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
);
}
if (cut.type === "progress_bar" && cut.progress !== undefined) {
return maybeWrapWithBgImage(
return maybeWrapWithBg(
<AbsoluteFill
style={{
background: bgColor || theme.surfaceColor,
@@ -620,16 +692,16 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
const animation = cut.animation || cut.transform?.animation;
if (cut.source && isImage(cut.source)) {
return <ImageScene src={cut.source} animation={animation} />;
return maybeWrapWithBg(<ImageScene src={cut.source} animation={animation} />);
}
if (cut.source && isVideo(cut.source)) {
return <VideoScene src={cut.source} startFrom={cut.in_seconds} />;
return maybeWrapWithBg(<VideoScene src={cut.source} startFrom={cut.source_in_seconds ?? 0} />);
}
// Final fallback — try as image if source exists, otherwise show text_card
if (cut.source) {
return <ImageScene src={cut.source} animation={animation} />;
return maybeWrapWithBg(<ImageScene src={cut.source} animation={animation} />);
}
// No source, no type — render as text card with cut id as fallback
@@ -664,6 +736,17 @@ const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => {
if (overlay.type === "hero_title") {
return <HeroTitle title={overlay.text} subtitle={overlay.subtitle} />;
}
if (overlay.type === "provider_chip" && overlay.providers) {
return (
<ProviderChip
providers={overlay.providers as string[]}
cycleSeconds={overlay.cycleSeconds}
position={(overlay.position as any) || "bottom-right"}
accentColor={overlay.accentColor}
label={overlay.label}
/>
);
}
return null;
};
@@ -0,0 +1,101 @@
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
/**
* ProviderChip — rotating pill of AI video provider names that cycle through.
* Positioned in a corner over background video.
*/
interface ProviderChipProps {
providers: string[];
cycleSeconds?: number;
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
accentColor?: string;
label?: string;
}
const POS_STYLES: Record<string, React.CSSProperties> = {
"top-left": { top: 48, left: 48 },
"top-right": { top: 48, right: 48 },
"bottom-left": { bottom: 96, left: 48 }, // avoid caption zone
"bottom-right": { bottom: 96, right: 48 },
};
export const ProviderChip: React.FC<ProviderChipProps> = ({
providers,
cycleSeconds = 2.5,
position = "bottom-right",
accentColor = "#22D3EE",
label = "generated with",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const cycleFrames = Math.max(1, Math.round(cycleSeconds * fps));
const idx = Math.floor(frame / cycleFrames) % providers.length;
const current = providers[idx];
const framesIntoCycle = frame % cycleFrames;
// Spring in on cycle start
const springIn = spring({
frame: framesIntoCycle,
fps,
config: { damping: 14, stiffness: 200 },
durationInFrames: Math.ceil(fps * 0.35),
});
// Fade out before cycle end
const fadeOut =
framesIntoCycle > cycleFrames - fps * 0.25
? interpolate(framesIntoCycle, [cycleFrames - fps * 0.25, cycleFrames], [1, 0], { extrapolateRight: "clamp" })
: 1;
const alpha = Math.min(springIn, fadeOut);
const translateY = interpolate(springIn, [0, 1], [12, 0]);
return (
<AbsoluteFill pointerEvents="none">
<div
style={{
position: "absolute",
...POS_STYLES[position],
display: "flex",
flexDirection: "column",
alignItems: position.includes("right") ? "flex-end" : "flex-start",
gap: 8,
opacity: alpha,
transform: `translateY(${translateY}px)`,
}}
>
<div
style={{
fontSize: 16,
color: "rgba(255,255,255,0.6)",
fontFamily: "Inter, sans-serif",
fontWeight: 500,
letterSpacing: 1.5,
textTransform: "uppercase",
}}
>
{label}
</div>
<div
style={{
padding: "14px 26px",
background: "rgba(11, 15, 26, 0.82)",
border: `2px solid ${accentColor}`,
borderRadius: 999,
color: accentColor,
fontFamily: "'Space Grotesk', Inter, sans-serif",
fontWeight: 700,
fontSize: 28,
letterSpacing: 0.3,
backdropFilter: "blur(8px)",
boxShadow: `0 8px 32px ${accentColor}30`,
}}
>
{current}
</div>
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,266 @@
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
/**
* TerminalScene — animated terminal with typed commands and scrolling output.
*
* Each "step" is either:
* { kind: "cmd", text: "git clone ...", typeSpeed?: number } — typed char-by-char with prompt
* { kind: "out", text: "cloning into 'OpenMontage'..." } — reveals instantly
* { kind: "pause", seconds: number } — silent dwell
* { kind: "pill", text: "Piper TTS installed", color?: string } — floating badge
*
* Steps execute in order at the specified durations. Terminal auto-scrolls when
* it fills up.
*/
export type TerminalStep =
| { kind: "cmd"; text: string; typeSpeed?: number; holdSeconds?: number }
| { kind: "out"; text: string; holdSeconds?: number }
| { kind: "pause"; seconds: number }
| { kind: "pill"; text: string; color?: string; durationSeconds?: number };
interface TerminalSceneProps {
title?: string;
steps: TerminalStep[];
prompt?: string;
accentColor?: string;
backgroundColor?: string;
}
interface RenderedLine {
text: string;
isCmd: boolean;
startFrame: number;
endFrame: number; // frame at which typing completes
}
interface RenderedPill {
text: string;
color: string;
startFrame: number;
endFrame: number;
}
export const TerminalScene: React.FC<TerminalSceneProps> = ({
title = "Terminal",
steps,
prompt = "$",
accentColor = "#22D3EE",
backgroundColor = "#0B0F1A",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Lay out timing in frames
const lines: RenderedLine[] = [];
const pills: RenderedPill[] = [];
let cursorFrame = 0;
for (const step of steps) {
if (step.kind === "cmd") {
const speed = step.typeSpeed ?? 0.035; // seconds per char
const typeFrames = Math.ceil(step.text.length * speed * fps);
const hold = Math.ceil((step.holdSeconds ?? 0.3) * fps);
lines.push({
text: step.text,
isCmd: true,
startFrame: cursorFrame,
endFrame: cursorFrame + typeFrames,
});
cursorFrame += typeFrames + hold;
} else if (step.kind === "out") {
const revealFrames = Math.max(2, Math.ceil(0.08 * fps));
const hold = Math.ceil((step.holdSeconds ?? 0.15) * fps);
lines.push({
text: step.text,
isCmd: false,
startFrame: cursorFrame,
endFrame: cursorFrame + revealFrames,
});
cursorFrame += revealFrames + hold;
} else if (step.kind === "pause") {
cursorFrame += Math.ceil(step.seconds * fps);
} else if (step.kind === "pill") {
const dur = Math.ceil((step.durationSeconds ?? 2.2) * fps);
pills.push({
text: step.text,
color: step.color ?? accentColor,
startFrame: cursorFrame,
endFrame: cursorFrame + dur,
});
// pill is non-blocking — don't advance cursor
}
}
// Only render lines that have started
const visibleLines = lines.filter(l => frame >= l.startFrame);
// Auto-scroll: keep last N lines in view
const MAX_VISIBLE = 18;
const scrollStart = Math.max(0, visibleLines.length - MAX_VISIBLE);
const renderedLines = visibleLines.slice(scrollStart);
// Cursor blinks on most recent command
const blinkPhase = Math.floor(frame / (fps * 0.55)) % 2 === 0;
// Terminal window frame fade-in
const windowOpacity = spring({ frame, fps, config: { damping: 25, stiffness: 100 } });
return (
<AbsoluteFill
style={{
background: backgroundColor,
justifyContent: "center",
alignItems: "center",
padding: "80px",
fontFamily: "'JetBrains Mono', 'Consolas', 'Monaco', monospace",
}}
>
<div
style={{
width: "85%",
maxWidth: 1600,
height: "80%",
opacity: windowOpacity,
transform: `scale(${interpolate(windowOpacity, [0, 1], [0.97, 1])})`,
borderRadius: 16,
overflow: "hidden",
boxShadow: "0 40px 120px rgba(0,0,0,0.6), 0 0 1px rgba(255,255,255,0.2) inset",
background: "#12151F",
position: "relative",
}}
>
{/* Title bar */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "14px 18px",
background: "#1A1F2E",
borderBottom: "1px solid rgba(255,255,255,0.05)",
}}
>
<div style={{ width: 12, height: 12, borderRadius: "50%", background: "#FF5F56" }} />
<div style={{ width: 12, height: 12, borderRadius: "50%", background: "#FFBD2E" }} />
<div style={{ width: 12, height: 12, borderRadius: "50%", background: "#27C93F" }} />
<div
style={{
flex: 1,
textAlign: "center",
color: "#8E8E93",
fontSize: 16,
fontFamily: "Inter, sans-serif",
}}
>
{title}
</div>
</div>
{/* Terminal body */}
<div
style={{
padding: "32px 40px",
fontSize: 26,
lineHeight: 1.55,
color: "#E5E7EB",
height: "calc(100% - 46px)",
overflow: "hidden",
}}
>
{renderedLines.map((line, idx) => {
if (line.isCmd) {
// Char-by-char typed command
const progress = interpolate(
frame,
[line.startFrame, line.endFrame],
[0, line.text.length],
{ extrapolateRight: "clamp" }
);
const typed = line.text.slice(0, Math.floor(progress));
const isLatest = idx === renderedLines.length - 1;
const isActive = frame <= line.endFrame + fps * 0.2;
return (
<div key={`${line.startFrame}-${idx}`} style={{ display: "flex", alignItems: "baseline" }}>
<span style={{ color: accentColor, marginRight: 12, fontWeight: 600 }}>{prompt}</span>
<span style={{ color: "#F1F5F9" }}>{typed}</span>
{isLatest && isActive && blinkPhase && (
<span
style={{
display: "inline-block",
width: 12,
height: 26,
background: "#F1F5F9",
marginLeft: 2,
transform: "translateY(4px)",
}}
/>
)}
</div>
);
} else {
// Instant-reveal output line with fade-in
const alpha = interpolate(
frame,
[line.startFrame, line.endFrame],
[0, 1],
{ extrapolateRight: "clamp" }
);
return (
<div
key={`${line.startFrame}-${idx}`}
style={{ color: "#9CA3AF", opacity: alpha, paddingLeft: 4 }}
>
{line.text}
</div>
);
}
})}
</div>
{/* Floating command pills */}
{pills
.filter(p => frame >= p.startFrame && frame <= p.endFrame)
.map((pill, idx) => {
const lifeProgress = (frame - pill.startFrame) / Math.max(1, pill.endFrame - pill.startFrame);
// spring in (0 → 1), hold, spring out (0.8 → 1.0)
const inAlpha = spring({
frame: frame - pill.startFrame,
fps,
config: { damping: 14, stiffness: 180 },
durationInFrames: Math.ceil(fps * 0.35),
});
const outAlpha =
lifeProgress > 0.82
? interpolate(lifeProgress, [0.82, 1], [1, 0], { extrapolateRight: "clamp" })
: 1;
const alpha = Math.min(inAlpha, outAlpha);
const translateY = interpolate(inAlpha, [0, 1], [14, 0]);
return (
<div
key={`${pill.startFrame}-${idx}`}
style={{
position: "absolute",
top: 28 + idx * 62,
right: 32,
padding: "12px 20px",
background: pill.color,
color: "#0B0F1A",
borderRadius: 999,
fontFamily: "Inter, sans-serif",
fontWeight: 700,
fontSize: 20,
letterSpacing: 0.2,
opacity: alpha,
transform: `translateY(${translateY}px)`,
boxShadow: `0 10px 30px ${pill.color}40`,
}}
>
{pill.text}
</div>
);
})}
</div>
</AbsoluteFill>
);
};
@@ -10,5 +10,8 @@ export { StatReveal } from "./StatReveal";
export { HeroTitle } from "./HeroTitle";
export { ParticleOverlay } from "./ParticleOverlay";
export { AnimeScene } from "./AnimeScene";
export { TerminalScene } from "./TerminalScene";
export { ProviderChip } from "./ProviderChip";
export type { ParticleType } from "./ParticleOverlay";
export type { CameraMotion, AnimeSceneProps } from "./AnimeScene";
export type { TerminalStep } from "./TerminalScene";