Upgrade Remotion composition engine with cinematic enhancements

- Upgrade Remotion packages to 4.0.441, add transitions/captions/fonts/media
- Add spring-animated image scenes, stat reveals, section titles, hero cards
- Add TikTok-style word-by-word captions with highlight effect
- Add Google Fonts (Space Grotesk) and dynamic duration via calculateMetadata
- Fix Remotion false-positive: check node_modules/ in _remotion_available()
- Add project directory convention, music library, tool naming docs
- Add music transparency to proposal stage, subtitle pipeline to compose stage
- Add README showcase montage and Windows npm troubleshooting note
This commit is contained in:
calesthio
2026-03-29 12:06:06 -07:00
parent b97b5ab5b4
commit 2c16c6e547
17 changed files with 3838 additions and 119 deletions
+10
View File
@@ -21,6 +21,12 @@ output/
pipeline/
tests/qa/output/
# Project workspaces (generated assets, renders — all regenerable)
projects/
# User music library (personal royalty-free tracks — not part of repo)
music_library/
# Environment
.env
.env.local
@@ -42,3 +48,7 @@ Thumbs.db
# Node (for remotion-composer/)
node_modules/
# Remotion build output and copied project assets (regenerable at render time)
remotion-composer/out/
remotion-composer/public/
+51
View File
@@ -46,6 +46,40 @@ Infrastructure files:
- `tools/cost_tracker.py` — budget governance
- `lib/pipeline_loader.py` — manifest loading and helpers
## Project Directory Convention
Every production run creates a project workspace under `projects/`. This directory is gitignored — all generated assets are regenerable.
```
projects/<project-name>/
├── artifacts/ # JSON artifacts from each stage (research_brief, script, scene_plan, etc.)
├── assets/
│ ├── images/ # Generated images (PNG)
│ ├── video/ # Generated video clips (MP4)
│ ├── audio/ # Narration segments + final mix (MP3/WAV)
│ ├── music/ # Background music track (MP3)
│ └── subtitles.srt # Generated subtitles
└── renders/
└── final.mp4 # Final rendered video (the deliverable)
```
**Naming convention**: Use kebab-case derived from the video title (e.g., `hidden-math-of-nature`, `how-music-rewires-brain`).
Create the project directory at pipeline initialization, before any stage runs. All tools and agents should write outputs to these paths — never to the repo root or ad-hoc locations.
## Music Library
Users can place royalty-free music tracks in `music_library/` (gitignored). The asset director will check this folder before falling back to API-based music generation.
```
music_library/
├── ambient_track.mp3
├── cinematic_epic.mp3
└── ...
```
If the folder has tracks, the proposal and asset stages should present them as options alongside generated music. See the proposal-director and asset-director skills for details.
## Available Pipelines
| Pipeline | Best For | Stability |
@@ -237,6 +271,23 @@ Key capability families to look for in the output:
Each tool in the registry declares `best_for`, `install_instructions`, `runtime` (LOCAL, API, LOCAL_GPU, HYBRID), and `status`. Read these fields — do not assume tool strengths from memory.
### Tool Class Naming Convention
All tool classes use **PascalCase without a "Tool" suffix**. When importing tools in Python:
| Module | Class Name | NOT |
|--------|-----------|-----|
| `tools.audio.music_gen` | `MusicGen` | ~~MusicGenTool~~ |
| `tools.video.video_compose` | `VideoCompose` | ~~VideoComposeTool~~ |
| `tools.audio.audio_mixer` | `AudioMixer` | ~~AudioMixerTool~~ |
| `tools.tts.elevenlabs_tts` | `ElevenLabsTTS` | ~~ElevenLabsTTSTool~~ |
| `tools.analysis.transcriber` | `Transcriber` | ~~TranscriberTool~~ |
| `tools.subtitle.subtitle_gen` | `SubtitleGen` | ~~SubtitleGenTool~~ |
When in doubt, check: `grep "^class " tools/<path>.py`
All tools call via `.execute(params_dict)` (returns `ToolResult` with `.success`, `.data`, `.error`), NOT `.run()`.
### Selector Pattern
Three selector tools abstract multi-provider capabilities. **Selectors auto-discover providers from the registry.** Adding a new provider tool automatically makes it available through the selector — no selector code changes needed.
+11 -1
View File
@@ -12,6 +12,14 @@ No video editing experience required. No complex UI to learn. Just describe your
Works with **Claude Code, Cursor, Copilot, Windsurf, Codex** — any AI coding assistant that can read files and run code.
<p align="center">
<img src="assets/showcase.jpg" alt="Nature's Source Code — an AI-produced explainer video" width="100%">
</p>
> **"Nature's Source Code"** — a cinematic explainer produced from a single prompt. The agent researched the topic across dozens of sources, wrote the script, generated every visual, composed narration and music, added word-by-word captions, and rendered the final video. Total human input: one sentence and a few approvals.
>
> OpenMontage produces **10+ minute videos** with minimal interaction — describe your vision, approve the creative direction, and get a finished production.
---
## Why OpenMontage?
@@ -112,7 +120,7 @@ Edit your own talking-head footage. Generate a fully animated explainer from scr
### Composition & Rendering
| Engine | Type | What It Does |
|--------|------|-------------|
| **Remotion** | Local (Node.js) | React-based programmatic video — animated text cards, stat cards, charts, spring physics transitions, image-to-video with motion. **When no video generation providers are configured, the agent generates still images and Remotion turns them into fully animated video.** |
| **Remotion** | Local (Node.js) | React-based programmatic video — spring-animated image scenes, stat reveals, section titles, hero cards, TikTok-style word-by-word captions, scene transitions (fade/slide/wipe/flip), Google Fonts, and audio with fade curves. **When no video generation providers are configured, the agent generates still images and Remotion turns them into fully animated video.** |
| **FFmpeg** | Local | Core video assembly, encoding, subtitle burn, audio muxing, color grading |
---
@@ -207,6 +215,8 @@ That's it. `make setup` installs Python dependencies, the Remotion composer, fre
> pip install piper-tts
> cp .env.example .env
> ```
>
> **Windows users:** If `npm install` fails with `ERR_INVALID_ARG_TYPE`, use `npx --yes npm install` instead.
### 3. Add API Keys (optional)
Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

+2942
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -8,11 +8,15 @@
"upgrade": "npx remotion upgrade"
},
"dependencies": {
"@remotion/cli": "4.0.0",
"@remotion/player": "4.0.0",
"@remotion/captions": "^4.0.441",
"@remotion/cli": "^4.0.441",
"@remotion/google-fonts": "^4.0.441",
"@remotion/media": "^4.0.441",
"@remotion/player": "^4.0.441",
"@remotion/transitions": "^4.0.441",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remotion": "4.0.0"
"remotion": "^4.0.441"
},
"devDependencies": {
"@types/react": "^18.2.0",
+208 -100
View File
@@ -6,13 +6,35 @@ import {
Sequence,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { loadFont } from "@remotion/google-fonts/SpaceGrotesk";
// Resolve asset path — use staticFile() for local paths, passthrough URLs
function resolveAsset(src: string): string {
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) {
return src;
}
// Strip any file:// prefix
const clean = src.replace(/^file:\/\/\/?/, "");
return staticFile(clean);
}
import { TextCard } from "./components/TextCard";
import { StatCard } from "./components/StatCard";
import { CalloutBox } from "./components/CalloutBox";
import { ComparisonCard } from "./components/ComparisonCard";
import { CaptionOverlay, WordCaption } from "./components/CaptionOverlay";
import { SectionTitle } from "./components/SectionTitle";
import { StatReveal } from "./components/StatReveal";
import { HeroTitle } from "./components/HeroTitle";
// Load Space Grotesk font for cinematic typography
const { fontFamily } = loadFont("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});
// ---------------------------------------------------------------------------
// Types — aligned with edit_decisions artifact schema
@@ -43,35 +65,42 @@ interface Cut {
transform?: {
animation?: string;
scale?: number;
position?: { x: number; y: number };
position?: string | { x: number; y: number };
};
}
interface Overlay {
type: "section_title" | "stat_reveal" | "hero_title";
in_seconds: number;
out_seconds: number;
text: string;
subtitle?: string;
accentColor?: string;
position?: string;
}
interface AudioLayer {
src: string;
volume?: number;
}
interface SfxCue {
src: string;
start_seconds: number;
volume?: number;
}
interface AudioConfig {
narration?: AudioLayer;
music?: AudioLayer;
sfx?: SfxCue[];
music?: AudioLayer & {
fadeInSeconds?: number;
fadeOutSeconds?: number;
};
}
interface ExplainerProps {
export interface ExplainerProps {
cuts: Cut[];
subtitles?: { enabled: boolean; src?: string };
overlays?: Overlay[];
captions?: WordCaption[];
audio?: AudioConfig;
}
// ---------------------------------------------------------------------------
// Image scene — spring / interpolate animations (replaces FFmpeg Ken Burns)
// Image Extensions
// ---------------------------------------------------------------------------
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"];
@@ -87,6 +116,24 @@ function isVideo(source: string): boolean {
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
// ---------------------------------------------------------------------------
// Cinematic vignette overlay
// ---------------------------------------------------------------------------
const Vignette: React.FC = () => (
<AbsoluteFill
style={{
background:
"radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.6) 100%)",
pointerEvents: "none",
}}
/>
);
// ---------------------------------------------------------------------------
// Enhanced Image Scene — spring physics, parallax, variety
// ---------------------------------------------------------------------------
const ImageScene: React.FC<{ src: string; animation?: string }> = ({
src,
animation,
@@ -94,102 +141,101 @@ const ImageScene: React.FC<{ src: string; animation?: string }> = ({
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Smooth fade-in on entrance
const fadeIn = spring({ frame, fps, config: { damping: 20 } });
// Smooth spring fade-in
const fadeIn = spring({ frame, fps, config: { damping: 18, stiffness: 80 } });
// Fade-out for crossfade effect
const fadeOutStart = durationInFrames - 8;
const fadeOut = interpolate(frame, [fadeOutStart, durationInFrames], [1, 0.3], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
let scale = 1;
let translateX = 0;
let translateY = 0;
const anim = animation || "zoom-in";
// Progress with easing — smoother than linear
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
if (anim === "zoom-in") {
scale = interpolate(frame, [0, durationInFrames], [1, 1.15], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
scale = 1 + progress * 0.18;
} else if (anim === "zoom-out") {
scale = interpolate(frame, [0, durationInFrames], [1.15, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
scale = 1.18 - progress * 0.18;
} else if (anim === "pan-left") {
translateX = interpolate(frame, [0, durationInFrames], [30, -30], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateX = interpolate(progress, [0, 1], [40, -40]);
scale = 1.15;
} else if (anim === "pan-right") {
translateX = interpolate(frame, [0, durationInFrames], [-30, 30], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateX = interpolate(progress, [0, 1], [-40, 40]);
scale = 1.15;
} else if (anim === "ken-burns") {
scale = interpolate(frame, [0, durationInFrames], [1, 1.2], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateX = interpolate(frame, [0, durationInFrames], [0, -20], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateY = interpolate(frame, [0, durationInFrames], [0, -10], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
} else if (anim === "ken-burns" || anim === "ken-burns-slow-zoom") {
// Cinematic Ken Burns: gentle zoom + diagonal drift
scale = 1 + progress * 0.22;
translateX = interpolate(progress, [0, 1], [0, -25]);
translateY = interpolate(progress, [0, 1], [0, -15]);
} else if (anim === "parallax") {
// Subtle parallax — foreground moves faster
translateY = interpolate(progress, [0, 1], [15, -15]);
scale = 1.1;
}
// "static" or "none" → no motion, just fade in
// "static" or "none" → just display
return (
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "#000" }}>
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "#0F172A" }}>
<Img
src={src}
src={resolveAsset(src)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
opacity: fadeIn,
opacity: fadeIn * fadeOut,
transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)`,
willChange: "transform, opacity",
}}
/>
<Vignette />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Video scene — OffthreadVideo for frame-accurate rendering
// Enhanced Video Scene
// ---------------------------------------------------------------------------
const VideoScene: React.FC<{ src: string; startFrom?: number }> = ({
src,
startFrom = 0,
}) => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill>
<OffthreadVideo
src={src}
startFrom={Math.round(startFrom * fps)}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Fade transition wrapper
// ---------------------------------------------------------------------------
const FadeIn: React.FC<{
children: React.ReactNode;
durationFrames?: number;
}> = ({ children, durationFrames = 10 }) => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, durationFrames], [0, 1], {
const { fps, durationInFrames } = useVideoConfig();
const fadeIn = spring({ frame, fps, config: { damping: 20 } });
const fadeOutStart = durationInFrames - 8;
const fadeOut = interpolate(frame, [fadeOutStart, durationInFrames], [1, 0.3], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
return (
<AbsoluteFill style={{ backgroundColor: "#0F172A" }}>
<OffthreadVideo
src={resolveAsset(src)}
startFrom={Math.round(startFrom * fps)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
opacity: fadeIn * fadeOut,
}}
muted
/>
<Vignette />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
@@ -227,7 +273,6 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
);
}
// Auto-detect from source file extension
const animation = cut.animation || cut.transform?.animation;
if (isImage(cut.source)) {
@@ -238,59 +283,122 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
return <VideoScene src={cut.source} startFrom={cut.in_seconds} />;
}
// Fallback: treat as image
return <ImageScene src={cut.source} animation={animation} />;
};
// ---------------------------------------------------------------------------
// Overlay renderer
// ---------------------------------------------------------------------------
const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => {
if (overlay.type === "section_title") {
return (
<SectionTitle
title={overlay.text}
subtitle={overlay.subtitle}
accentColor={overlay.accentColor}
position={(overlay.position as any) || "top-left"}
/>
);
}
if (overlay.type === "stat_reveal") {
return (
<StatReveal
stat={overlay.text}
label={overlay.subtitle}
accentColor={overlay.accentColor}
position={(overlay.position as any) || "bottom-right"}
/>
);
}
if (overlay.type === "hero_title") {
return <HeroTitle title={overlay.text} subtitle={overlay.subtitle} />;
}
return null;
};
// ---------------------------------------------------------------------------
// Main composition
// ---------------------------------------------------------------------------
export const Explainer: React.FC<ExplainerProps> = ({ cuts, audio }) => {
const { fps } = useVideoConfig();
export const Explainer: React.FC<ExplainerProps> = ({
cuts,
overlays,
captions,
audio,
}) => {
const { fps, durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
{/* Visual layers */}
<AbsoluteFill style={{ backgroundColor: "#0F172A", fontFamily }}>
{/* Layer 1: Visual scenes */}
{cuts.map((cut) => {
const from = Math.round(cut.in_seconds * fps);
const duration = Math.round(
(cut.out_seconds - cut.in_seconds) * fps
);
const scene = <SceneRenderer cut={cut} />;
const wrapped =
cut.transition_in === "fade" ? (
<FadeIn>{scene}</FadeIn>
) : (
scene
);
const duration = Math.round((cut.out_seconds - cut.in_seconds) * fps);
return (
<Sequence key={cut.id} from={from} durationInFrames={duration}>
<AbsoluteFill>{wrapped}</AbsoluteFill>
<SceneRenderer cut={cut} />
</Sequence>
);
})}
{/* Audio layers */}
{/* Layer 2: Overlays (section titles, stat reveals, hero titles) */}
{overlays?.map((overlay, i) => {
const from = Math.round(overlay.in_seconds * fps);
const duration = Math.round(
(overlay.out_seconds - overlay.in_seconds) * fps
);
return (
<Sequence key={`overlay-${i}`} from={from} durationInFrames={duration}>
<OverlayRenderer overlay={overlay} />
</Sequence>
);
})}
{/* Layer 3: Captions (word-by-word highlight) */}
{captions && captions.length > 0 && (
<CaptionOverlay
words={captions}
wordsPerPage={6}
fontSize={42}
highlightColor="#22D3EE"
backgroundColor="rgba(15, 23, 42, 0.7)"
/>
)}
{/* Layer 4: Audio — narration */}
{audio?.narration?.src && (
<Audio src={audio.narration.src} volume={audio.narration.volume ?? 1} />
<Audio src={resolveAsset(audio.narration.src)} volume={audio.narration.volume ?? 1} />
)}
{/* Layer 4: Audio — music with fade in/out */}
{audio?.music?.src && (
<Audio
src={audio.music.src}
volume={audio.music.volume ?? 0.06}
src={resolveAsset(audio.music.src)}
volume={(f) => {
const baseVol = audio.music!.volume ?? 0.1;
const fadeInDur = (audio.music!.fadeInSeconds ?? 2) * fps;
const fadeOutDur = (audio.music!.fadeOutSeconds ?? 3) * fps;
const totalFrames = durationInFrames;
// Fade in
const fadeIn = interpolate(f, [0, fadeInDur], [0, baseVol], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Fade out
const fadeOut = interpolate(
f,
[totalFrames - fadeOutDur, totalFrames],
[baseVol, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return Math.min(fadeIn, fadeOut);
}}
/>
)}
{audio?.sfx?.map((cue, i) => (
<Sequence
key={`sfx-${i}`}
from={Math.round(cue.start_seconds * fps)}
>
<Audio src={cue.src} volume={cue.volume ?? 0.5} />
</Sequence>
))}
</AbsoluteFill>
);
};
+18 -4
View File
@@ -1,5 +1,17 @@
import { Composition } from "remotion";
import { Explainer } from "./Explainer";
import { Composition, CalculateMetadataFunction } from "remotion";
import { Explainer, ExplainerProps } from "./Explainer";
const calculateMetadata: CalculateMetadataFunction<ExplainerProps> = async ({
props,
}) => {
const cuts = props.cuts || [];
if (cuts.length === 0) {
return { durationInFrames: 30 * 60 };
}
const lastEnd = Math.max(...cuts.map((c) => c.out_seconds || 0));
// Add 1 second padding for final fade
return { durationInFrames: Math.ceil((lastEnd + 1) * 30) };
};
export const Root: React.FC = () => {
return (
@@ -7,15 +19,17 @@ export const Root: React.FC = () => {
<Composition
id="Explainer"
component={Explainer}
durationInFrames={30 * 60} // default 60s at 30fps, overridden by props
durationInFrames={30 * 60}
fps={30}
width={1920}
height={1080}
defaultProps={{
cuts: [],
subtitles: { enabled: false },
overlays: [],
captions: [],
audio: {},
}}
calculateMetadata={calculateMetadata}
/>
</>
);
@@ -0,0 +1,157 @@
import {
AbsoluteFill,
Sequence,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
// Word-level caption for TikTok-style highlight display
export interface WordCaption {
word: string;
startMs: number;
endMs: number;
}
interface CaptionOverlayProps {
words: WordCaption[];
// How many words to show at once in a "page"
wordsPerPage?: number;
fontSize?: number;
color?: string;
highlightColor?: string;
backgroundColor?: string;
fontFamily?: string;
}
interface CaptionPage {
words: WordCaption[];
startMs: number;
endMs: number;
}
function buildPages(words: WordCaption[], wordsPerPage: number): CaptionPage[] {
const pages: CaptionPage[] = [];
for (let i = 0; i < words.length; i += wordsPerPage) {
const pageWords = words.slice(i, i + wordsPerPage);
if (pageWords.length === 0) continue;
pages.push({
words: pageWords,
startMs: pageWords[0].startMs,
endMs: pageWords[pageWords.length - 1].endMs,
});
}
return pages;
}
const PageRenderer: React.FC<{
page: CaptionPage;
fontSize: number;
color: string;
highlightColor: string;
backgroundColor: string;
fontFamily: string;
}> = ({ page, fontSize, color, highlightColor, backgroundColor, fontFamily }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentMs = page.startMs + (frame / fps) * 1000;
// Spring entrance
const entrance = spring({
frame,
fps,
config: { damping: 18, stiffness: 120 },
});
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 80,
}}
>
<div
style={{
opacity: entrance,
transform: `translateY(${interpolate(entrance, [0, 1], [20, 0])}px)`,
backgroundColor,
borderRadius: 12,
padding: "14px 28px",
maxWidth: "80%",
textAlign: "center",
}}
>
<span
style={{
fontSize,
fontWeight: 700,
fontFamily,
lineHeight: 1.4,
whiteSpace: "pre-wrap",
}}
>
{page.words.map((w, i) => {
const isActive = w.startMs <= currentMs && w.endMs > currentMs;
const isPast = w.endMs <= currentMs;
return (
<span
key={`${w.startMs}-${i}`}
style={{
color: isActive ? highlightColor : isPast ? color : `${color}99`,
transition: "none", // CSS transitions forbidden in Remotion
textShadow: isActive
? `0 0 20px ${highlightColor}66, 0 2px 4px rgba(0,0,0,0.5)`
: "0 2px 4px rgba(0,0,0,0.5)",
}}
>
{w.word}
</span>
);
})}
</span>
</div>
</AbsoluteFill>
);
};
export const CaptionOverlay: React.FC<CaptionOverlayProps> = ({
words,
wordsPerPage = 6,
fontSize = 42,
color = "#F8FAFC",
highlightColor = "#22D3EE",
backgroundColor = "rgba(15, 23, 42, 0.75)",
fontFamily = "Space Grotesk, Inter, system-ui, sans-serif",
}) => {
const { fps } = useVideoConfig();
const pages = buildPages(words, wordsPerPage);
return (
<AbsoluteFill>
{pages.map((page, i) => {
const fromFrame = Math.round((page.startMs / 1000) * fps);
const nextStart = pages[i + 1]?.startMs ?? page.endMs + 500;
const duration = Math.max(
1,
Math.round(((nextStart - page.startMs) / 1000) * fps)
);
return (
<Sequence key={i} from={fromFrame} durationInFrames={duration}>
<PageRenderer
page={page}
fontSize={fontSize}
color={color}
highlightColor={highlightColor}
backgroundColor={backgroundColor}
fontFamily={fontFamily}
/>
</Sequence>
);
})}
</AbsoluteFill>
);
};
@@ -0,0 +1,113 @@
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
interface HeroTitleProps {
title: string;
subtitle?: string;
}
export const HeroTitle: React.FC<HeroTitleProps> = ({ title, subtitle }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Staggered letter-by-letter spring
const titleChars = title.split("");
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
background:
"radial-gradient(ellipse at center, rgba(15,23,42,0.85) 0%, rgba(15,23,42,0.95) 100%)",
}}
>
<div style={{ textAlign: "center", maxWidth: "85%" }}>
{/* Main title with per-character spring */}
<div
style={{
fontSize: 72,
fontWeight: 800,
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
lineHeight: 1.2,
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
gap: 0,
}}
>
{titleChars.map((char, i) => {
const delay = i * 1.2;
const charSpring = spring({
frame: frame - delay,
fps,
config: { damping: 12, stiffness: 150 },
});
return (
<span
key={i}
style={{
display: "inline-block",
opacity: charSpring,
transform: `translateY(${interpolate(charSpring, [0, 1], [30, 0])}px)`,
color: i < 8 ? "#22D3EE" : "#F8FAFC", // Accent first word
whiteSpace: char === " " ? "pre" : undefined,
minWidth: char === " " ? "0.3em" : undefined,
}}
>
{char}
</span>
);
})}
</div>
{/* Subtitle */}
{subtitle && (
<div
style={{
marginTop: 20,
opacity: spring({
frame: frame - titleChars.length * 1.2 - 5,
fps,
config: { damping: 20 },
}),
fontSize: 28,
fontWeight: 400,
color: "#A78BFA",
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
{subtitle}
</div>
)}
{/* Animated underline */}
<div
style={{
margin: "24px auto 0",
height: 3,
backgroundColor: "#22D3EE",
borderRadius: 2,
width: interpolate(
spring({
frame: frame - 15,
fps,
config: { damping: 15, stiffness: 60 },
}),
[0, 1],
[0, 400]
),
}}
/>
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,101 @@
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
interface SectionTitleProps {
title: string;
subtitle?: string;
accentColor?: string;
position?: "top-left" | "bottom-left" | "center";
}
export const SectionTitle: React.FC<SectionTitleProps> = ({
title,
subtitle,
accentColor = "#22D3EE",
position = "top-left",
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Entrance spring
const slideIn = spring({
frame,
fps,
config: { damping: 15, stiffness: 80 },
});
// Exit fade
const exitStart = durationInFrames - 15;
const fadeOut = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = Math.min(slideIn, fadeOut);
const positionStyles: React.CSSProperties =
position === "center"
? { justifyContent: "center", alignItems: "center" }
: position === "bottom-left"
? { justifyContent: "flex-end", alignItems: "flex-start", padding: 60 }
: { justifyContent: "flex-start", alignItems: "flex-start", padding: 60 };
return (
<AbsoluteFill style={positionStyles}>
<div
style={{
opacity,
transform: `translateX(${interpolate(slideIn, [0, 1], [-40, 0])}px)`,
}}
>
{/* Accent bar */}
<div
style={{
width: interpolate(slideIn, [0, 1], [0, 60]),
height: 4,
backgroundColor: accentColor,
marginBottom: 12,
borderRadius: 2,
}}
/>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: "#F8FAFC",
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
letterSpacing: "0.05em",
textTransform: "uppercase",
textShadow: "0 2px 8px rgba(0,0,0,0.6)",
}}
>
{title}
</div>
{subtitle && (
<div
style={{
fontSize: 18,
fontWeight: 400,
color: accentColor,
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
marginTop: 4,
opacity: spring({
frame: frame - 8,
fps,
config: { damping: 20 },
}),
textShadow: "0 2px 8px rgba(0,0,0,0.6)",
}}
>
{subtitle}
</div>
)}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,99 @@
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
interface StatRevealProps {
stat: string;
label?: string;
accentColor?: string;
position?: "center" | "bottom-right" | "right";
}
export const StatReveal: React.FC<StatRevealProps> = ({
stat,
label,
accentColor = "#A78BFA",
position = "bottom-right",
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Bouncy entrance
const scale = spring({
frame,
fps,
config: { damping: 10, stiffness: 100, mass: 0.8 },
from: 0,
to: 1,
});
const glow = spring({
frame: frame - 5,
fps,
config: { damping: 20, stiffness: 60 },
});
// Exit
const exitStart = durationInFrames - 12;
const fadeOut = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = Math.min(spring({ frame, fps, config: { damping: 20 } }), fadeOut);
const positionStyles: React.CSSProperties =
position === "center"
? { justifyContent: "center", alignItems: "center" }
: position === "right"
? { justifyContent: "center", alignItems: "flex-end", paddingRight: 80 }
: { justifyContent: "flex-end", alignItems: "flex-end", padding: 80 };
return (
<AbsoluteFill style={positionStyles}>
<div
style={{
opacity,
transform: `scale(${scale})`,
textAlign: position === "center" ? "center" : "right",
}}
>
<div
style={{
fontSize: 96,
fontWeight: 800,
color: accentColor,
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
lineHeight: 1,
textShadow: `0 0 ${interpolate(glow, [0, 1], [0, 30])}px ${accentColor}66, 0 4px 12px rgba(0,0,0,0.5)`,
}}
>
{stat}
</div>
{label && (
<div
style={{
fontSize: 22,
fontWeight: 500,
color: "#F8FAFC",
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
marginTop: 8,
opacity: spring({
frame: frame - 10,
fps,
config: { damping: 20 },
}),
textShadow: "0 2px 8px rgba(0,0,0,0.6)",
}}
>
{label}
</div>
)}
</div>
</AbsoluteFill>
);
};
@@ -4,3 +4,7 @@ export { ProgressBar } from "./ProgressBar";
export { CalloutBox } from "./CalloutBox";
export { ComparisonCard } from "./ComparisonCard";
export { BarChart, LineChart, PieChart, KPIGrid } from "./charts";
export { CaptionOverlay } from "./CaptionOverlay";
export { SectionTitle } from "./SectionTitle";
export { StatReveal } from "./StatReveal";
export { HeroTitle } from "./HeroTitle";
+10 -5
View File
@@ -99,11 +99,16 @@ Process asset tasks grouped by tool for efficiency:
### Step 5: Generate Music
1. Read playbook's `audio.music_mood` and `audio.music_volume`
2. Generate or select a background track:
- **Primary**: `music_gen` (ElevenLabs Music) — custom, costs per generation
- **Fallback**: Stock music library (if available)
3. Duration should match total video duration (or be loopable)
4. Verify the audio file exists
2. Check the music decision from `proposal_packet.production_plan.music_source` (set by the Proposal Director)
3. Source the background track in this priority order:
- **User-selected library track**: If the proposal specified a track from `music_library/`, copy it to `projects/<project>/assets/music/background_music.mp3`
- **User music library (`music_library/`)**: If the folder exists and has tracks, pick the best match for the playbook's `audio.music_mood`. List candidates by filename and let the EP decide.
- **Music generation API**: Use `music_gen` (ElevenLabs) or `suno_music` if available. Check status via registry first — if the tool is unavailable or quota-exhausted, skip immediately (do NOT attempt and fail silently).
- **No music available**: Log this clearly in the asset manifest as `"music_status": "unavailable"` with the reason. Do NOT silently produce a video without music — the EP and user should know.
4. Duration should be at least as long as total video duration. If shorter, it can be looped by the compose stage.
5. Verify the audio file exists at `projects/<project>/assets/music/background_music.mp3`
**Critical:** If music generation fails or is unavailable, report it immediately in the asset manifest — do not defer the problem to the compose stage.
### Step 6: Build Asset Manifest
@@ -95,6 +95,56 @@ Call the `audio_mixer` tool to:
The video_compose tool will mux this with the video.
### Step 5b: Generate Subtitles (Mandatory)
Subtitles are mandatory for all explainer content. Generate them from the narration audio — do NOT skip this step.
1. **Transcribe** the full narration using the `transcriber` tool (whisperx):
```python
from tools.analysis.transcriber import Transcriber
result = Transcriber().execute({
'input_path': 'projects/<project>/assets/audio/narration_full.mp3',
'model_size': 'base',
'language': 'en',
'output_dir': 'projects/<project>/assets/audio'
})
# result.data contains segments with word-level timestamps
```
2. **Generate SRT** from the transcription using `subtitle_gen`:
```python
from tools.subtitle.subtitle_gen import SubtitleGen
result = SubtitleGen().execute({
'segments': transcription_data['segments'],
'format': 'srt',
'output_path': 'projects/<project>/assets/subtitles.srt',
'max_words_per_cue': 8,
'max_chars_per_line': 42
})
```
3. **Burn subtitles** into the video using `video_compose`:
```python
from tools.video.video_compose import VideoCompose
result = VideoCompose().execute({
'operation': 'burn_subtitles',
'input_path': 'projects/<project>/renders/output.mp4',
'output_path': 'projects/<project>/renders/final.mp4',
'subtitle_path': 'projects/<project>/assets/subtitles.srt',
'subtitle_style': {
'font': '<from playbook typography.headings.font or Arial>',
'font_size': 22,
'primary_color': '&HFFFFFF',
'outline_color': '&H000000',
'outline_width': 2,
'margin_v': 50,
'alignment': 2
}
})
```
**The final deliverable is the subtitled version**, not the pre-subtitle render.
### Step 6: Verify Output
**File verification:**
@@ -216,6 +216,43 @@ Also present **alternative production paths** — complete packages at different
| Budget | Local TTS + images | ~$0.05-0.15 | Robotic voice, image-only |
| Free | Local TTS + diagrams | $0.00 | Functional but minimal |
### Step 5b: Music Plan (Mandatory)
Music is a critical part of the video's feel. **Surface the music situation to the user at proposal time** — do not silently defer it to the asset stage where a failure becomes expensive.
**Check music availability in this order:**
1. **User music library (`music_library/`):** Check if this folder exists and contains tracks. If so, list available tracks with durations and let the user pick one.
2. **Music generation APIs:** Check which music tools are available via the registry (`registry.get_by_capability("music_generation")`). Report their status honestly.
3. **Stock music sources:** Note if stock music is available via any provider.
**Present to the user:**
```
MUSIC PLAN
├── Your music library: 3 tracks available
│ ├── cosmic_interstellar_space.mp3 (3:13) — ambient, cosmic
│ ├── cinematic_epic.mp3 (2:45) — dramatic, building
│ └── lofi_beat.mp3 (4:00) — chill, electronic
├── AI generation: music_gen (ElevenLabs) — UNAVAILABLE (plan limit)
└── Recommendation: Use "cosmic_interstellar_space.mp3" from your library
OR provide a different track before asset generation
Would you like to:
(a) Use a track from your library (which one?)
(b) Provide a different track (drop it in music_library/)
(c) Generate one via API (if available)
(d) Proceed without music
```
**If no music source is available:** Tell the user explicitly. Do NOT let this surface as a surprise at the asset stage. Offer the `music_library/` path so they can add a track before production starts.
**Rules:**
- Always check `music_library/` first — user-provided music is free and intentional
- Always report music API status (available, unavailable, quota remaining if checkable)
- Record the music decision in `proposal_packet.production_plan.music_source`
- If the user picks a library track, record its path for the asset director
### Step 6: Build the Cost Estimate
Itemize every paid operation:
+20 -6
View File
@@ -161,13 +161,19 @@ class VideoCompose(BaseTool):
]
def _remotion_available(self) -> bool:
"""Check if Remotion rendering is available (requires npx + composer project)."""
"""Check if Remotion rendering is available (requires npx + composer project + node_modules)."""
import shutil as _shutil
if not _shutil.which("npx"):
return False
composer_dir = Path(__file__).resolve().parent.parent.parent / "remotion-composer"
return composer_dir.exists() and (composer_dir / "package.json").exists()
if not composer_dir.exists() or not (composer_dir / "package.json").exists():
return False
# Check that node_modules are actually installed — without this,
# npx remotion render will fail even though the project exists.
if not (composer_dir / "node_modules").exists():
return False
return True
def get_info(self) -> dict[str, Any]:
"""Extend base get_info to surface Remotion sub-capability.
@@ -193,10 +199,18 @@ class VideoCompose(BaseTool):
"and motion-graphics pipelines."
)
else:
info["remotion_note"] = (
"Remotion is NOT available (needs Node.js/npx + remotion-composer). "
"Falling back to FFmpeg Ken Burns for image-based compositions."
)
composer_dir = Path(__file__).resolve().parent.parent.parent / "remotion-composer"
if composer_dir.exists() and (composer_dir / "package.json").exists() and not (composer_dir / "node_modules").exists():
info["remotion_note"] = (
"Remotion project exists but node_modules are NOT installed. "
"Run 'cd remotion-composer && npm install' to enable Remotion rendering. "
"Falling back to FFmpeg Ken Burns for image-based compositions."
)
else:
info["remotion_note"] = (
"Remotion is NOT available (needs Node.js/npx + remotion-composer). "
"Falling back to FFmpeg Ken Burns for image-based compositions."
)
return info
def execute(self, inputs: dict[str, Any]) -> ToolResult: