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