Merge branch 'calesthio:main' into main

This commit is contained in:
Hanzo Dev
2026-06-03 13:03:57 -07:00
committed by GitHub
68 changed files with 5714 additions and 158 deletions
@@ -0,0 +1,47 @@
---
name: canvas-procedural-animation
description: Use p5.js/canvas for local procedural character effects: particles, weather, squash/stretch, walk cycles, and environmental motion.
license: MIT
---
# Canvas Procedural Animation
Use this skill when p5.js or Canvas is used for character-supporting motion:
rain, snow, leaves, feathers, ambient particles, squash/stretch, or procedural
walk cycles.
## Proven Pattern
p5.js runs setup once and redraws continuously through `draw()`. Keep animation
state deterministic from time/frame values when rendering previews.
```js
function setup() {
createCanvas(1920, 1080);
}
function draw() {
const t = millis() / 1000;
clear();
drawCharacter(width / 2, height / 2 + sin(t * 8) * 8);
}
```
## Use For
- Particle/weather overlays.
- Environmental motion.
- Simple procedural bodies.
- Effects that do not need individually authored SVG parts.
## Avoid For
- Complex facial acting where SVG/layered rig parts are easier to inspect.
- Final renders that need exact frame determinism unless the runtime exposes
frame-index control.
## Sources
- p5.js `setup()` reference: https://p5js.org/reference/p5/setup/
- p5.js `draw()` reference: https://p5js.org/reference/p5/draw/
- p5.js animation examples: https://p5js.org/examples/
@@ -0,0 +1,43 @@
---
name: character-animation-qa
description: Review local character animation with schema checks, Playwright browser previews, frame sampling, and FFmpeg/ffprobe final output checks.
license: MIT
---
# Character Animation QA
Use this skill before presenting a character-animation preview or final render.
## Review Layers
1. Schema validation: character design, rig plan, pose library, action timeline.
2. Static asset checks: referenced parts and backgrounds exist.
3. Browser preview: load the preview, capture screenshots, collect console errors.
4. Motion check: compare sampled frames for non-trivial differences.
5. Final MP4 check: ffprobe metadata, duration, resolution, audio, frame samples.
6. Agent visual review: inspect sampled frames for detached limbs, bad layers,
off-frame characters, unreadable expressions, broken text.
## Playwright Pattern
```ts
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
await page.goto(previewUrl, { waitUntil: "networkidle" });
await page.screenshot({ path: "preview.png" });
```
## Pass/Revise/Fail
- `pass`: technical checks pass, acting is readable.
- `revise`: fixable rig/timeline issue.
- `fail`: missing assets, blank render, runtime failure, or wrong runtime.
## Sources
- Playwright screenshots:
https://playwright.dev/docs/screenshots
- Playwright page navigation:
https://playwright.dev/docs/api/class-page#page-goto
- FFmpeg/ffprobe should be used for final media probing:
https://ffmpeg.org/ffprobe.html
+53
View File
@@ -0,0 +1,53 @@
---
name: character-rigging
description: Build data-driven 2D character rigs for local animation: parts, pivots, layers, constraints, views, and reusable rig packages.
license: MIT
---
# Character Rigging
Use this skill when building OpenMontage `rig_plan` artifacts or renderer input
for local 2D character animation.
## Proven Patterns
- Keep runtime code generic; make each character a data package.
- Split characters into independently transformable parts.
- Define pivots in the same coordinate space as the artwork.
- Store constraints on moving parts to prevent impossible rotations.
- Keep layer order explicit; do not rely on SVG source order after generation.
- Start with one view and add views only when the shot list requires them.
## Rig Package
```json
{
"character_id": "mouse",
"rig_type": "svg_rig",
"parts": [
{ "id": "body", "kind": "torso", "layer": 10 },
{ "id": "head", "kind": "head", "layer": 30, "parent": "body" },
{ "id": "arm_right", "kind": "limb", "layer": 40, "parent": "body" }
],
"joints": {
"head": { "pivot": [320, 180], "rotation": [-20, 20] },
"arm_right": { "pivot": [390, 310], "rotation": [-70, 95] }
}
}
```
## Quality Checklist
- Every moving part has a pivot.
- Every child part has a parent where hierarchy matters.
- Mouth shapes are separate assets or separate path groups.
- Eyes and pupils are separate when gaze needs to change.
- Props are separate if the character touches or carries them.
## Sources
- SVG transform-origin behavior is browser-defined and can be sensitive to
coordinate space; prefer explicit SVG-coordinate pivots when using GSAP
`svgOrigin`: https://gsap.com/docs/v3/GSAP/CorePlugins/CSS/
- Remotion animations must be frame-driven and deterministic via current frame:
https://www.remotion.dev/docs/use-current-frame
+97
View File
@@ -0,0 +1,97 @@
---
name: doubao-tts
description: Generate Mandarin and multilingual narration with Volcengine Doubao Speech 2.0. Use when creating Chinese voiceovers, when the user prefers Doubao/Volcengine/火山引擎/豆包 TTS, or when narration needs character-level timestamp metadata for subtitles.
---
# Doubao TTS
Requires `DOUBAO_SPEECH_API_KEY` in `.env`.
Set `DOUBAO_SPEECH_VOICE_TYPE` for the default voice, or pass `voice_id` to the tool.
## Current API
Use the new-console API key flow:
```text
X-Api-Key: ${DOUBAO_SPEECH_API_KEY}
X-Api-Resource-Id: seed-tts-2.0
```
Do not use `X-Api-App-Id` and `X-Api-Access-Key` with a new-console API Key. If the API returns `load grant: requested grant not found`, the key type or auth header is probably wrong.
For long-form video narration, prefer the async endpoint:
```text
POST https://openspeech.bytedance.com/api/v3/tts/submit
POST https://openspeech.bytedance.com/api/v3/tts/query
```
This returns `audio_url` plus `sentences[].words[]` timing metadata that can be used to build subtitles.
## OpenMontage Usage
Generate with the TTS selector:
```python
from tools.audio.tts_selector import TTSSelector
result = TTSSelector().execute({
"preferred_provider": "doubao",
"text": "如果 AI 真的会改变未来,普通人到底该怎么参与?",
"voice_id": "zh_female_vv_uranus_bigtts",
"output_path": "projects/my-video/assets/audio/narration.mp3",
"speech_rate": 0,
"enable_timestamp": True,
})
```
Or call the provider directly:
```python
from tools.audio.doubao_tts import DoubaoTTS
result = DoubaoTTS().execute({
"text": "短样本试听文本。",
"voice_id": "zh_female_vv_uranus_bigtts",
"output_path": "projects/my-video/assets/audio/doubao_sample.mp3",
})
```
The provider writes:
- `output_path`: downloaded audio file
- `metadata_path`: full query response JSON, defaulting to `<output_path>.json`
## Recommended Workflow
1. Generate a 10-15 second sample before a full paid narration.
2. Ask the user to approve voice naturalness, accent, and speed.
3. Generate the full narration only after approval.
4. Keep the query JSON. It is the source of truth for subtitle timing.
5. Build captions from `sentences[].words[]`, not from estimated text length.
6. Group captions by Chinese semantic phrases before applying timestamps. Do not split only by fixed character count; it can break phrases like "在不押单个公司的情况下" or "可能会被慢慢稀释" and hurt comprehension.
7. Let the video duration follow the approved voice rhythm unless the user explicitly asks to match a prior runtime.
## Parameters
- `voice_id`: Doubao `speaker` / voice type. Defaults to `DOUBAO_SPEECH_VOICE_TYPE`.
- `resource_id`: use `seed-tts-2.0` for Doubao Speech 2.0 voices.
- `speech_rate`: `0` is normal, `100` is 2x, `-50` is 0.5x.
- `sample_rate`: default `24000`.
- `enable_timestamp`: default `true`.
- `return_usage`: default `true`, requests usage metadata when available.
Do not pass `additions.explicit_language` by default. Some endpoint/key combinations reject `zh-cn` with `unsupported additions explicit language zh-cn`.
For calm Mandarin explainers, start with `speech_rate: 0`. If the result is too long for the approved format, make a short comparison sample with `speech_rate: 25` or `50` before regenerating the full narration. Do not speed up only to match a previous provider's duration if the user prefers Doubao's natural pace.
## Troubleshooting
- `load grant: requested grant not found`: wrong key type or wrong auth header. Use `X-Api-Key` for new-console API Keys.
- `speaker permission denied`: voice id is wrong or not authorized for the selected resource.
- `quota exceeded`: quota, lifetime characters, or concurrency exceeded.
- Missing timestamps: verify `enable_timestamp: true`, keep the query JSON, and confirm the selected endpoint returned `sentences`.
## Safety
Never print or write the API key to logs, metadata, patches, or project artifacts. `.env.example` should contain only empty variable names.
@@ -0,0 +1,58 @@
---
name: pose-library-design
description: Design reusable 2D character pose libraries, action cycles, and expression states for data-driven animation.
license: MIT
---
# Pose Library Design
Use this skill when producing `pose_library` artifacts.
## Pose Categories
- Neutral: idle, breathe, listening.
- Attention: look_up, look_down, look_left, look_right.
- Emotion: happy, sad, surprised, worried, determined.
- Action: reach, point, hold, jump, flap, walk_contact, walk_passing.
- Mouth: closed, small_o, wide_open, smile, frown, phoneme-ish shapes.
## Acting Pattern
Use timed pose sequences:
```text
anticipation -> action -> hold -> settle
```
Do not continuously animate every part. Holds make the acting readable.
## Pose Data Pattern
```json
{
"pose": "surprised",
"parts": {
"head": { "rotation": -6, "y": -4 },
"pupil_left": { "x": 4, "y": -6 },
"mouth": "small_o"
},
"hold_frames": 18,
"transition": "back.out"
}
```
## Quality Checklist
- Required emotions have poses.
- Required actions have poses or cycles.
- Reused cycles have contact and passing poses.
- Poses name only changed parts; defaults come from the rig.
## Sources
- GSAP timeline sequencing for readable multi-step poses:
https://gsap.com/docs/v3/GSAP/Timeline/
- Remotion interpolation for frame-based transitions:
https://www.remotion.dev/docs/interpolate
- Remotion spring for natural motion:
https://www.remotion.dev/docs/spring
+89 -6
View File
@@ -95,7 +95,72 @@ seedance.execute({
})
```
## Prompt structure
## Prompt structure — The Higgsfield Methodology (canonical as of 2026)
**CRITICAL: Open every prompt with a shot-structure declaration.** Seedance rewards prompts that declare format upfront before any creative description. This is the single biggest quality lever.
### Opener templates (copy one verbatim, then extend)
**For action/combat/multi-shot (highest-performing format):**
```
Montage, multi-shot Hollywood action, don't use one camera angle or single cut, cinematic lighting, photorealistic, 35mm film quality, ARRI ALEXA aesthetic, heavy film grain, sharp but imperfect focus, motion blur on fast actions, halation on highlights, soft highlight rolloff, wide-angle lens with strong distortion, subtle chromatic aberration near frame edges, no 3D, no cartoon, no VFX aesthetic.
```
**For single-POV continuous shots (orbs, walkthrough):**
```
Single continuous shot, first-person POV perspective, the camera IS [his/her] eyes, hyper-chaotic handheld motion, completely unstabilized, violent raw human movement, constant micro-jitters, aggressive head swings, abrupt jerks, frequent over-rotation, no smoothness at all, no cuts, no zoom, 35mm film, photorealistic.
```
**For locked-POV reaction scenes:**
```
One continuous shot, POV [setting] perspective, no cuts, no zoom, natural head movement, photorealistic, 35mm film grain.
```
### Body structure (after the opener)
1. **Environment/location** — sensory detail (wet asphalt, sodium lamps, neon bleed, rain particulates, volumetric haze)
2. **Character block** — with reference tags and identity-lock language (see Reference-to-video below)
3. **Enemy/secondary character block** — same detail level
4. **Beat-by-beat choreography** with TEMPORAL MARKERS: `03s: … 36s: … 610s: …`
5. **VFX inline in brackets:** `[VFX: branching white-blue electric arcs pulsing along forearms, sparks jumping between fingers]`
6. **Slow-motion markers:** write `RAMPS TO SLOW MOTION` before the impact beat, `SNAPS BACK TO REAL TIME` on resume
7. **Sound design block:** either `no music, only raw SFX` or explicit SFX sequence. Music language stays textural.
### Combat vocabulary (proven to hit)
- `snaps forward`, `lunges`, `sprints`, `weaves`, `chambers`, `drives`, `pivots`, `redirects`, `ducks`, `slips`
- `explodes outward`, `devastating`, `raw force`, `kinetic`, `overload`, `compresses`, `erupts`, `fractures`, `ripples`
- Avoid soft verbs: `attacks`, `hits`, `fights` — these read generic and Seedance underdelivers on them
### Camera behavior — state what it IS and ISN'T doing
Seedance misfires when camera intent is ambiguous. Always explicitly negate what you don't want:
- `no cuts` (for continuous POV)
- `no zoom` (prevents unnatural perspective punch-ins)
- `no stabilization` (when you want chaotic handheld)
- `no smoothness at all`
- `no 3D, no cartoon, no VFX aesthetic` — counter-intuitive but forces photoreal skin/texture/lighting even when the scene has heavy VFX elements
### Realism enforcement phrase
When the brief has VFX but you want photoreal skin/textures (not plastic Marvel-cartoon look), include:
```
no 3D, no cartoon, no VFX aesthetic — photorealistic textures, real skin pores, authentic fabric detail, grounded in reality
```
### Format priority (Higgsfield empirical ordering)
| Format | Best for | Pattern |
|---|---|---|
| **Transformation** | calm → threat → transformation → aftermath | 6 numbered shots × 2.5s each @ 15s total |
| **Orbs** | single continuous POV | 1 shot × 15s, hyper-chaotic handheld |
| **Fights** | combat choreography | Beat-by-beat, clear power mismatch, RAMPS/SNAPS |
| **POV** | locked reaction | Continuous, "no cuts no zoom" mantra |
| **Animation** | stylized 3D | `@image` keyframe + timed segments |
The **2.5-second-per-shot rhythm** appears optimal for multi-shot generations.
## Legacy 8-part template (use only for single simple shots, not action)
Seedance 2.0 is unusually literal about camera language, multi-shot cuts, and quoted dialogue. Use this 8-part template:
@@ -138,15 +203,33 @@ Do **not** request complex multi-instrument scores — keep music language textu
### Reference-to-video
When you have character / product / wardrobe references, use the reference-to-video endpoint and name each asset in the prompt:
When you have character / product / wardrobe references, use the reference-to-video endpoint. Seedance 2.0 honors an explicit bracket tagging syntax:
```
Reference 1: hero character (Aang) — bald, blue arrow tattoo, orange robes.
Reference 2: environment plate — snowy Air Temple courtyard at dawn.
Shot 1: Aang (from reference 1) walks across the courtyard (reference 2),
wind lifting his robes. Low-angle tracking shot, slow push-in.
[reference_image: hero_portrait.png]
[identity_lock]
The same character — bald, blue arrow tattoo, orange robes — consistent across all shots, no drift or deformation. Do not alter clothing category or primary color.
Shot 1 (wide, slow push-in): hero walks across the snowy Air Temple courtyard, wind lifting robes.
Shot 2 (medium close-up): hero turns toward camera, staff in hand.
Shot 3 (extreme close-up, rack focus): hero's eyes open, wind whipping.
```
**Identity-anchor phrases that measurably reduce face drift** (stack them — redundancy helps):
- `the same character`
- `consistent across different scenes / all shots`
- `maintain exact appearance from reference image`
- `no deformation, no drift, no face morph`
- `Do not alter clothing category or primary color`
**Single-reference workflow (common in practice):** When you only have one photo:
- Use a clear, front-facing portrait with neutral lighting and minimal motion blur; avoid occluded faces (e.g., phones, sunglasses, heavy shadow).
- Reuse the SAME reference image across all shots — do not generate new refs per shot.
- Put all shots in ONE prompt under a single `[identity_lock]` block so the model treats them as a coherent sequence.
- If wardrobe is changing by design (e.g., civilian → costume), describe the costume verbatim on every shot it appears and add `Do not alter clothing category or primary color` to lock it once generated.
**Anti-drift fallback:** If face morphs across frames on first render, drop to a shorter duration (5-6s instead of 10s), tighten the identity-lock language, and if you have multiple reference images, cull to the 3 most consistent ones rather than flooding with 9.
## Parameter guidance
| Parameter | Guidance |
@@ -0,0 +1,56 @@
---
name: svg-character-animation
description: Animate SVG character rigs with GSAP, CSS transforms, Remotion frame control, and HyperFrames-compatible browser previews.
license: MIT
---
# SVG Character Animation
Use this skill when animating character rigs made from SVG parts.
## Runtime Rules
- Animate transforms (`x`, `y`, `scale`, `rotation`) rather than layout.
- Use timelines for multi-part acting beats.
- For SVG elements, use stable pivots (`svgOrigin` or correctly scoped
transform origins).
- In Remotion, do not let GSAP advance with `requestAnimationFrame`; drive a
paused timeline from the current frame.
## Browser Pattern
```js
gsap.set("#arm_right", { svgOrigin: "390 310" });
const tl = gsap.timeline({ defaults: { ease: "power2.inOut" } });
tl.to("#head", { rotation: -8, duration: 0.2 })
.to("#arm_right", { rotation: 35, duration: 0.4 }, "<");
```
## Remotion Pattern
```tsx
const frame = useCurrentFrame();
const progress = frame / durationInFrames;
timeline.progress(progress);
```
## HyperFrames Pattern
Use HTML/SVG/GSAP components with deterministic timelines and validate via the
HyperFrames CLI before final render.
## Quality Checklist
- Parts stay connected at pivots during motion.
- Blinks, gaze, and mouth shapes are separate enough to read.
- Pose holds are long enough to communicate emotion.
- Frame sampling shows meaningful deltas, not frozen animation.
## Sources
- GSAP core transform properties and SVG handling:
https://gsap.com/docs/v3/GSAP/CorePlugins/CSS/
- GSAP timelines and sequencing:
https://gsap.com/docs/v3/GSAP/Timeline/
- Remotion `useCurrentFrame`:
https://www.remotion.dev/docs/use-current-frame
+2
View File
@@ -13,6 +13,8 @@ GOOGLE_API_KEY= # Google Imagen images, Google Cloud TTS (700+ voic
ELEVENLABS_API_KEY= # TTS narration, music generation, sound effects
OPENAI_API_KEY= # OpenAI TTS fallback and DALL-E image generation
XAI_API_KEY= # Grok image generation/editing and Grok video generation
DOUBAO_SPEECH_API_KEY= # Volcengine Doubao Speech TTS (new console API Key)
DOUBAO_SPEECH_VOICE_TYPE= # Default Doubao speaker/voice type, e.g. zh_female_vv_uranus_bigtts
# Piper local voices do not require env vars; install `piper-tts` via pip
# --- Music ---
+4 -1
View File
@@ -230,6 +230,7 @@ If the folder has tracks, the proposal and asset stages should present them as o
| `podcast-repurpose` | Podcast highlights and derivatives | beta |
| `cinematic` | Trailer, teaser, and mood-led edits | production |
| `animation` | Motion-graphics and animation-first videos | production |
| `character-animation` | Local rigged cartoon characters and reusable character acting | beta |
| `hybrid` | Source footage plus support visuals | production |
| `avatar-spokesperson` | Presenter-led avatar or lip-sync videos | production |
| `localization-dub` | Subtitle, dub, and translated variants | beta |
@@ -361,7 +362,7 @@ print('HyperFrames note:', info.get('hyperframes_note'))
|--------|----------|----------|
| **FFmpeg** | Video-only cuts, concat, trim, subtitle burn | `ffmpeg` binary (always available) |
| **Remotion** | React-based composition: still images → animated video, text cards, stat cards, charts, callouts, comparisons, transitions with spring physics, word-level caption burn, TalkingHead avatar | Node.js (`npx`) + `remotion-composer/` + `node_modules` |
| **HyperFrames** | HTML/CSS/GSAP composition: kinetic typography, product promos, launch reels, website-to-video, registry-block-driven scenes | Node.js ≥ 22 + FFmpeg + `npx` (consumed via `npx @hyperframes/cli`) |
| **HyperFrames** | HTML/CSS/GSAP composition: kinetic typography, product promos, launch reels, website-to-video, registry-block-driven scenes, SVG character rigs | Node.js ≥ 22 + FFmpeg + `npx` (consumed via `npx hyperframes`) |
`render_runtime` is **locked at proposal** (`proposal_packet.production_plan.render_runtime`) and **carried through edit_decisions unchanged**. `video_compose` routes based on this field; silent runtime swaps are forbidden. If the chosen runtime becomes unavailable at compose time, surface a structured blocker per "Escalate Blockers Explicitly" above. See `skills/core/hyperframes.md` for the Remotion-vs-HyperFrames decision matrix.
@@ -448,6 +449,7 @@ Key capability families to look for in the output:
- **audio_processing** — Mixing, enhancement (FFmpeg-based, always local).
- **analysis** — Transcription, scene detection, frame sampling.
- **avatar** — Talking head and lip sync generation.
- **character_animation** — Local character specs, SVG rigs, pose libraries, action timelines, previews, and QA.
- **enhancement** — Upscale, background removal, face enhance, color grading.
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.
@@ -637,6 +639,7 @@ The `.agents/skills/` directory is large. When you're not coming in through a to
|---|---|
| **Composition runtime** | `remotion`, `remotion-best-practices`, `synthetic-screen-recording` (fake terminal/UI demos via Remotion TerminalScene) |
| **Animation knowledge (generic)** | `gsap-core`, `gsap-timeline`, `gsap-plugins` (SplitText / MorphSVG / DrawSVG / MotionPath / Flip / CustomEase), `gsap-utils`, `gsap-react`, `gsap-performance`, `gsap-scrolltrigger`, `gsap-frameworks`, `framer-motion` (Disney 12 principles), `lottie-bodymovin` (Lottie export) |
| **Character animation** | `character-rigging`, `svg-character-animation`, `pose-library-design`, `canvas-procedural-animation`, `character-animation-qa` |
| **Image generation** | `bfl-api`, `flux-best-practices` |
| **Video generation** | `seedance-2-0` (preferred premium default — cinematic, trailer, multi-shot, synced audio, lip-sync), `ai-video-gen`, `ltx2` |
| **Audio** | `elevenlabs`, `music`, `sound-effects`, `acestep`, `text-to-speech`, `setup-api-key` |
+2 -1
View File
@@ -72,6 +72,7 @@ Each tool's `agent_skills[]` field bridges Layer 1 → Layer 3. See `skills/INDE
| `tools/video/video_stitch.py` | Multi-clip assembly (stitch, spatial, validate, preview) |
| `tools/video/video_compose.py` | Runtime-aware composition orchestrator — routes to Remotion / HyperFrames / FFmpeg based on `edit_decisions.render_runtime` |
| `tools/video/hyperframes_compose.py` | HyperFrames runtime — workspace materialization, `hyperframes lint`/`validate`/`render`, FFmpeg floor check |
| `tools/character/character_animation.py` | Local character-animation tools — character specs, SVG rig plans, pose libraries, action timelines, HyperFrames packages, and QA reports |
| `lib/hyperframes_style_bridge.py` | Playbook → CSS custom properties + `DESIGN.md` bridge for HyperFrames workspaces |
| `remotion-composer/src/components/` | 8 Remotion components (TextCard, StatCard, ProgressBar, CalloutBox, ComparisonCard + charts/) |
| `.agents/skills/hyperframes*/` | Vendored HyperFrames Layer 3 skills (authoring contract, CLI, registry, website-to-video) |
@@ -90,6 +91,7 @@ Each tool's `agent_skills[]` field bridges Layer 1 → Layer 3. See `skills/INDE
| `podcast-repurpose` | `pipeline_defs/podcast-repurpose.yaml` | Podcast repurposing |
| `cinematic` | `pipeline_defs/cinematic.yaml` | Cinematic edit |
| `animation` | `pipeline_defs/animation.yaml` | Animation-first |
| `character-animation` | `pipeline_defs/character-animation.yaml` | Local rigged character animation |
| `hybrid` | `pipeline_defs/hybrid.yaml` | Source-plus-support hybrid |
| `avatar-spokesperson` | `pipeline_defs/avatar-spokesperson.yaml` | Avatar presenter |
| `localization-dub` | `pipeline_defs/localization-dub.yaml` | Localization and dubbing |
@@ -115,4 +117,3 @@ Each tool's `agent_skills[]` field bridges Layer 1 → Layer 3. See `skills/INDE
6. Let discovery happen through `tools/tool_registry.py`; do not depend on ad hoc imports
7. Add a JSON schema in `schemas/tools/` if the tool has complex I/O
8. Add tests only after the runtime path is correct
+7 -6
View File
@@ -205,15 +205,16 @@ You don't need paid API keys to make real videos. Out of the box, `make setup` g
| **Open footage** | Archive.org + NASA + Wikimedia Commons | Free/open archival footage, educational media, and documentary texture |
| **Extra stock** | Pexels + Unsplash + Pixabay | Free stock footage/images (developer keys are free to get) |
| **Composition (React)** | Remotion | React-based rendering — spring-animated image scenes, text cards, stat cards, charts, TikTok-style word-level captions, TalkingHead |
| **Composition (HTML/GSAP)** | HyperFrames | HTML/CSS/GSAP rendering — kinetic typography, product promos, launch reels, registry blocks, website-to-video |
| **Composition (HTML/GSAP)** | HyperFrames | HTML/CSS/GSAP rendering — kinetic typography, product promos, launch reels, registry blocks, website-to-video, rigged SVG character animation |
| **Post-production** | FFmpeg | Encoding, subtitle burn-in, audio mixing, color grading |
| **Subtitles** | Built-in | Auto-generated captions with word-level timing |
OpenMontage picks between Remotion and HyperFrames at proposal time (locked as `render_runtime`). Remotion is the default for data-driven explainers and anything using the existing React scene stack; HyperFrames is the default for motion-graphics-heavy briefs that express naturally as HTML + GSAP. See `skills/core/hyperframes.md` for the full decision matrix.
OpenMontage picks between Remotion and HyperFrames at proposal time (locked as `render_runtime`). Remotion is the default for data-driven explainers and anything using the existing React scene stack; HyperFrames is the default for motion-graphics-heavy briefs that express naturally as HTML + GSAP, including the `character-animation` pipeline's SVG/GSAP rig output. See `skills/core/hyperframes.md` for the full decision matrix.
**Two free-ish paths:**
- **Image-based video:** Piper narrates your script, images provide the visuals, and Remotion animates them into a polished edit.
- **Local character animation:** SVG rigs, pose libraries, GSAP timelines, and HyperFrames render cartoon character acting to `projects/<project-name>/renders/final.mp4`.
- **Real-footage video:** the documentary montage pipeline builds a CLIP-searchable corpus from Archive.org, NASA, Wikimedia Commons, and optional free-key sources like Pexels and Unsplash, then cuts together actual motion footage into a finished video.
If you want the second one, prompt for a **documentary montage**, **tone poem**, or **stock-footage collage**, and explicitly say **use real footage only**.
@@ -374,8 +375,8 @@ OpenMontage/
│ ├── avatar/ # Talking head, lip sync
│ └── subtitle/ # SRT/VTT generation
├── pipeline_defs/ # 11 YAML pipeline manifests (the agent's playbook)
├── skills/ # 124 Markdown skill files (the agent's knowledge)
├── pipeline_defs/ # YAML pipeline manifests (the agent's playbook)
├── skills/ # Markdown skill files (the agent's knowledge)
│ ├── pipelines/ # Per-pipeline stage director skills
│ ├── creative/ # Creative technique skills
│ ├── core/ # Core tool skills
@@ -393,7 +394,7 @@ OpenMontage/
```
Layer 1: tools/ + pipeline_defs/ "What exists" — executable capabilities + orchestration
Layer 2: skills/ "How to use it" — OpenMontage conventions and quality bars
Layer 3: .agents/skills/ "How it works" — 47 external technology knowledge packs
Layer 3: .agents/skills/ "How it works" — external technology knowledge packs
```
Each tool declares which Layer 3 skills it relies on. The agent reads Layer 1 to know what's available, Layer 2 to know how OpenMontage wants it used, and Layer 3 for deep technical knowledge when needed.
@@ -509,7 +510,7 @@ Each tool declares which Layer 3 skills it relies on. The agent reads Layer 1 to
| Engine | Type | What It Does |
|--------|------|-------------|
| **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, audio with fade curves, and the TalkingHead avatar composition. **When no video generation providers are configured, the agent generates still images and Remotion turns them into fully animated video.** |
| **HyperFrames** | Local (Node.js ≥ 22) | HTML/CSS/GSAP programmatic video — kinetic typography, product promos, launch reels, custom motion graphics, registry blocks (data charts, grain overlays, shader transitions), website-to-video workflows. Consumed via `npx @hyperframes/cli`; no monorepo checkout needed. |
| **HyperFrames** | Local (Node.js ≥ 22) | HTML/CSS/GSAP programmatic video — kinetic typography, product promos, launch reels, custom motion graphics, registry blocks (data charts, grain overlays, shader transitions), website-to-video workflows, and rigged SVG character animation. Consumed via `npx hyperframes`; no monorepo checkout needed. |
| **FFmpeg** | Local | Core video assembly, encoding, subtitle burn, audio muxing, color grading |
Runtime is chosen at proposal (`render_runtime`) and locked through `edit_decisions`. Silent swaps between runtimes are a governance violation — see `skills/core/hyperframes.md`.
+14 -7
View File
@@ -53,7 +53,7 @@ OpenMontage/
│ ├── subtitle/ # SRT/VTT generation from timestamps
│ └── video/ # 13 video gen providers, composition, stitching, trimming
├── pipeline_defs/ # 11 YAML pipeline manifests
├── pipeline_defs/ # YAML pipeline manifests
├── schemas/ # JSON Schema definitions for validation
│ ├── artifacts/ # 11 artifact schemas (brief → publish_log)
│ ├── checkpoints/ # Checkpoint state schema
@@ -65,9 +65,9 @@ OpenMontage/
│ ├── core/ # FFmpeg, Remotion, WhisperX, color grading skills
│ ├── creative/ # Video editing, enhancement, data viz, prompt engineering
│ ├── meta/ # reviewer, checkpoint-protocol, skill-creator
│ └── pipelines/ # Per-pipeline stage-director skills (10 pipelines)
│ └── pipelines/ # Per-pipeline stage-director skills
├── .agents/skills/ # Layer 3: 47 external technology skills (FFmpeg, ElevenLabs, FLUX, etc.)
├── .agents/skills/ # Layer 3: external technology skills (FFmpeg, HyperFrames, GSAP, etc.)
├── styles/ # Visual style playbooks (YAML) + loader
├── remotion-composer/ # Node.js/React — Remotion video composition renderer
├── tests/ # Contract tests, QA integration tests, eval harness
@@ -200,13 +200,14 @@ stages:
# ... through publish
```
### Available Pipelines (11)
### Available Pipelines
| Pipeline | Category | Description |
|----------|----------|-------------|
| `animated-explainer` | generated | AI-produced explainer with research, narration, visuals, music |
| `animation` | animation | Motion graphics, kinetic typography |
| `avatar-spokesperson` | talking_head | Avatar-driven presenter videos |
| `character-animation` | animation | Local rigged cartoon characters with SVG rigs, pose libraries, GSAP timelines, and HyperFrames rendering |
| `cinematic` | cinematic | Trailer, teaser, mood-driven edits |
| `clip-factory` | custom | Batch short-form clips from long source |
| `hybrid` | hybrid | Source footage + AI-generated support visuals |
@@ -218,7 +219,7 @@ stages:
### Standard Stage Progression
All production pipelines follow a canonical 8-stage flow:
Most production pipelines follow a canonical 8-stage flow:
```
research → proposal → script → scene_plan → assets → edit → compose → publish
@@ -231,6 +232,11 @@ Each stage:
4. Has **review_focus** criteria and **success_criteria**
5. Can require **human approval** before proceeding
Specialized pipelines may insert domain-specific stages. For example,
`character-animation` adds `character_design` and `rig_plan` before
`scene_plan`, then emits a HyperFrames workspace and final deliverable at
`projects/<project-name>/renders/final.mp4`.
---
## Checkpoint System
@@ -437,11 +443,12 @@ A standalone Node.js/React subproject in `remotion-composer/` using [Remotion](h
### HyperFrames (HTML/CSS/GSAP)
Consumed via `npx @hyperframes/cli` (no monorepo checkout needed). Runtime floor: Node.js ≥ 22, FFmpeg, `npx`.
Consumed via `npx hyperframes` (no monorepo checkout needed). Runtime floor: Node.js ≥ 22, FFmpeg, `npx`.
- Handles kinetic typography, product promos, launch reels, website-to-video, registry blocks
- Handles kinetic typography, product promos, launch reels, website-to-video, registry blocks, and SVG/GSAP character rigs
- Driver: `tools/video/hyperframes_compose.py` materializes a workspace under `projects/<name>/hyperframes/`, then runs `lint → validate → render`
- Layer 3 skills vendored at `.agents/skills/hyperframes*/`; Layer 2 guide at `skills/core/hyperframes.md`
- The `character-animation` pipeline uses HyperFrames as the production render package. Browser previews are QA/debug artifacts only, not the render path.
### FFmpeg (fallback / simple cuts)
+81
View File
@@ -39,6 +39,8 @@ GOOGLE_API_KEY= # Google TTS + Google Imagen
ELEVENLABS_API_KEY= # TTS, music, sound effects (10K chars/month free)
OPENAI_API_KEY= # OpenAI TTS + DALL-E 3 images
XAI_API_KEY= # xAI Grok image generation/editing + Grok video generation
DOUBAO_SPEECH_API_KEY= # Volcengine Doubao Speech TTS (strong Mandarin narration)
DOUBAO_SPEECH_VOICE_TYPE= # Default Doubao speaker/voice type
# MULTI-MODEL GATEWAY (one key, 6+ tools)
FAL_KEY= # FLUX, Recraft, Kling, Veo, MiniMax video
@@ -159,6 +161,52 @@ No subscription — pure pay-as-you-go, no minimum spend.
---
### Doubao Speech — Mandarin TTS
> **Strong Mandarin narration.** Volcengine Doubao Speech is a good choice for Chinese explainer voiceovers and long-form narration that needs subtitle timing metadata.
**Tools unlocked:** `doubao_tts`
**Env vars:** `DOUBAO_SPEECH_API_KEY`, `DOUBAO_SPEECH_VOICE_TYPE`
#### Setup
1. Open the Volcengine Doubao Speech console and enable Speech Synthesis 2.0.
2. Create a new-console API Key.
3. Choose a Speech 2.0 voice type, for example `zh_female_vv_uranus_bigtts`.
4. Add to `.env`:
```bash
DOUBAO_SPEECH_API_KEY=your-api-key
DOUBAO_SPEECH_VOICE_TYPE=zh_female_vv_uranus_bigtts
```
#### API Notes
OpenMontage uses the new-console API key flow:
```text
X-Api-Key: ${DOUBAO_SPEECH_API_KEY}
X-Api-Resource-Id: seed-tts-2.0
```
Do not pass a new-console API Key as `X-Api-App-Id` or `X-Api-Access-Key`. That mismatch can produce `load grant: requested grant not found`.
#### What It Is Best For
- Natural Mandarin narration for Chinese-language explainers
- Async long-form narration via `/api/v3/tts/submit` and `/api/v3/tts/query`
- Character-level timing metadata for subtitle alignment
- Calm educational pacing where the video duration can follow the approved voice rhythm
#### Pacing
Start with `speech_rate: 0` for natural Mandarin delivery. If the approved format needs a tighter runtime, compare short samples at `speech_rate: 25` or `50` before generating the full narration. Do not force Doubao to match another provider's duration unless the user explicitly wants that tradeoff.
#### Pricing
Doubao Speech 2.0 is billed by character package or usage in Volcengine. OpenMontage estimates cost from text length and prefers provider-returned usage metadata when available.
---
### Google — TTS + Imagen (Shared Key)
> **One key, two tools.** Google Cloud TTS has 700+ voices in 50+ languages — the strongest localization option. Imagen 4 generates high-quality images.
@@ -503,6 +551,39 @@ If Remotion is not installed, compositions fall back to FFmpeg Ken Burns pan-and
---
### HyperFrames - HTML/CSS/GSAP Video Composition
> **GSAP-native local rendering.** HyperFrames is the preferred runtime for motion-graphics-heavy HTML compositions and the `character-animation` pipeline's rigged SVG character acting.
**Tool:** `hyperframes_compose` directly, or `video_compose` with `edit_decisions.render_runtime="hyperframes"`
**Runtime:** CPU (Node.js >= 22, FFmpeg, and `npx` required)
**Env var:** None
#### Setup
```bash
node --version
ffmpeg -version
npx --yes hyperframes doctor
```
The CLI is consumed as `npx hyperframes`. Do not use `npx @hyperframes/cli`; that package name is not the OpenMontage runtime path.
#### What HyperFrames Renders
| Use case | What it produces |
|----------|------------------|
| **Kinetic typography** | HTML/CSS text animation driven by GSAP timelines |
| **Product / launch videos** | Structured HTML scenes, registry blocks, and transitions |
| **Website-to-video** | Browser-captured site compositions with HyperFrames validation |
| **Character animation** | SVG character rigs, pose/action timelines, and GSAP acting beats rendered to `renders/final.mp4` |
HyperFrames workspaces live under `projects/<project-name>/hyperframes/`. Final videos still follow the normal OpenMontage convention: `projects/<project-name>/renders/final.mp4`.
**Cost:** Free. Always local.
---
### Piper TTS — Offline Text-to-Speech
> **Completely free, fully offline TTS.** No network required. Good quality for drafts and budget-constrained projects.
+4 -4
View File
@@ -44,7 +44,7 @@ def _probe_video(path: Path, tool_registry: Any) -> dict[str, Any]:
# Technical probe via audio_probe or ffprobe
try:
audio_probe = tool_registry.get_tool("audio_probe")
audio_probe = tool_registry.get("audio_probe")
if audio_probe:
probe_result = audio_probe.execute({"input_path": str(path)})
if probe_result.success:
@@ -84,7 +84,7 @@ def _probe_video(path: Path, tool_registry: Any) -> dict[str, Any]:
# Sample frames
try:
frame_sampler = tool_registry.get_tool("frame_sampler")
frame_sampler = tool_registry.get("frame_sampler")
if frame_sampler:
duration = result["technical_probe"].get("duration_seconds", 0)
timestamps = _sample_timestamps(duration, count=4)
@@ -122,7 +122,7 @@ def _probe_audio(path: Path, tool_registry: Any) -> dict[str, Any]:
result: dict[str, Any] = {"technical_probe": {}, "quality_risks": []}
try:
audio_probe = tool_registry.get_tool("audio_probe")
audio_probe = tool_registry.get("audio_probe")
if audio_probe:
probe_result = audio_probe.execute({"input_path": str(path)})
if probe_result.success:
@@ -195,7 +195,7 @@ def _transcribe_if_available(
return None
try:
transcriber = tool_registry.get_tool("transcriber")
transcriber = tool_registry.get("transcriber")
if transcriber and transcriber.get_status().value == "available":
result = transcriber.execute({"input_path": str(path)})
if result.success:
+302
View File
@@ -0,0 +1,302 @@
name: character-animation
version: "0.1"
description: >
Character animation pipeline for local, reusable cartoon characters. It turns
a script and scene plan into character specs, rig plans, pose libraries, action
timelines, and browser-rendered SVG/Canvas/Remotion/HyperFrames animation.
The pipeline is designed for deterministic local motion, not remote video-gen
replacement.
category: animation
stability: beta
default_checkpoint_policy: guided
reference_input:
supported: true
analysis_depth: standard
analysis_tools:
- video_analyzer
- transcript_fetcher
- video_downloader
- scene_detect
- frame_sampler
extensions:
custom_scripts: true
custom_playbooks: true
custom_skills: true
custom_tools: false
required_skills:
- pipelines/character-animation/executive-producer
- pipelines/character-animation/research-director
- pipelines/character-animation/proposal-director
- pipelines/character-animation/script-director
- pipelines/character-animation/character-design-director
- pipelines/character-animation/rig-plan-director
- pipelines/character-animation/scene-director
- pipelines/character-animation/asset-director
- pipelines/character-animation/edit-director
- pipelines/character-animation/compose-director
- pipelines/character-animation/publish-director
- meta/reviewer
- meta/checkpoint-protocol
- meta/animation-runtime-selector
orchestration:
mode: executive-producer
skill: pipelines/character-animation/executive-producer
budget_default_usd: 2.00
max_revisions_per_stage: 3
max_send_backs: 3
max_wall_time_minutes: 20
compatible_playbooks:
recommended:
- flat-motion-graphics
also_works:
- clean-professional
- minimalist-diagram
custom_allowed: true
stages:
- name: research
skill: pipelines/character-animation/research-director
produces:
- research_brief
tools_available: []
checkpoint_required: false
human_approval_default: false
review_focus:
- Reference style and character-animation technique are researched when a reference exists
- At least 3 comparable character-animation examples or techniques are summarized
- Feasibility separates rigged local animation from frame-by-frame traditional animation
success_criteria:
- Schema-valid research_brief
- Technique notes identify reusable rigs, pose libraries, and effects needs
- name: proposal
skill: pipelines/character-animation/proposal-director
required_artifacts_in:
- research_brief
produces:
- proposal_packet
- decision_log
tools_available: []
checkpoint_required: true
human_approval_default: true
review_focus:
- Concepts are differentiated and not a carbon copy of any reference
- Character count, action complexity, and reuse strategy are explicit
- Render runtime selection presents Remotion and HyperFrames when both are available
- Motion requirement is preserved; FFmpeg-only fallback is not proposed for character acting
- Sample-first plan is included before full production
success_criteria:
- Schema-valid proposal_packet
- Selected concept includes character_count, rig_complexity, reuse_strategy, and render_runtime
- User approval is recorded before character assets are generated
sub_stages:
- name: sample
description: "10-15 second character-animation proof before full production"
condition: "approved_concept_exists"
human_approval_default: true
tools_available:
- character_spec_generator
- svg_rig_builder
- pose_library_builder
- action_timeline_compiler
- character_rig_renderer
- character_animation_reviewer
- video_compose
review_focus:
- Sample proves character style, rig integrity, pose readability, and motion timing
- name: script
skill: pipelines/character-animation/script-director
required_artifacts_in:
- proposal_packet
optional_artifacts_in:
- research_brief
- video_analysis_brief
produces:
- script
tools_available:
- transcriber
checkpoint_required: true
human_approval_default: true
review_focus:
- Script is written as action beats, not only narration
- Dialogue/narration architecture is locked
- Every emotional turn can be expressed with poses and actions
success_criteria:
- Schema-valid script
- Character beats and audio architecture are explicit
- name: character_design
skill: pipelines/character-animation/character-design-director
required_artifacts_in:
- script
- proposal_packet
produces:
- character_design
tools_available:
- character_spec_generator
- image_selector
checkpoint_required: true
human_approval_default: true
review_focus:
- Each character has a distinct role, silhouette, emotional range, and action list
- Character count is realistic for local rigging
- Style anchors are reusable across scenes
success_criteria:
- Schema-valid character_design
- Every main character has required emotions, actions, and views
- name: rig_plan
skill: pipelines/character-animation/rig-plan-director
required_artifacts_in:
- character_design
produces:
- rig_plan
- pose_library
tools_available:
- svg_rig_builder
- pose_library_builder
checkpoint_required: true
human_approval_default: false
review_focus:
- Parts, pivots, layers, constraints, views, and required poses are complete
- Rig plan avoids per-character code paths; character differences are data
- Known risky motions are surfaced before asset generation
success_criteria:
- Schema-valid rig_plan
- Schema-valid pose_library
- Every required action has at least one pose or action strategy
- name: scene_plan
skill: pipelines/character-animation/scene-director
required_artifacts_in:
- script
- character_design
- rig_plan
- pose_library
optional_artifacts_in:
- proposal_packet
produces:
- scene_plan
tools_available: []
checkpoint_required: true
human_approval_default: true
review_focus:
- Scenes use character_scene or animation types with timed actions
- Each scene has characters, actions, camera/framing, background, and effects
- Scene complexity fits the approved sample and budget
success_criteria:
- Schema-valid scene_plan
- Every scene maps to rigged characters and required assets
- name: assets
skill: pipelines/character-animation/asset-director
required_artifacts_in:
- character_design
- rig_plan
- pose_library
- scene_plan
optional_artifacts_in:
- script
- proposal_packet
produces:
- asset_manifest
tools_available:
- image_selector
- tts_selector
- music_gen
- character_rig_renderer
checkpoint_required: true
human_approval_default: false
review_focus:
- Character parts, backgrounds, props, audio, and effects are linked to scenes
- Layer 3 skills are read for every generation or animation-runtime tool
- Asset provenance and prompts are recorded
- Missing rig assets are blocked, not hidden
success_criteria:
- Schema-valid asset_manifest
- All referenced asset files exist
- Character assets are organized under projects/<name>/assets/characters
- name: edit
skill: pipelines/character-animation/edit-director
required_artifacts_in:
- scene_plan
- asset_manifest
- pose_library
optional_artifacts_in:
- script
- rig_plan
produces:
- edit_decisions
- action_timeline
tools_available:
- action_timeline_compiler
checkpoint_required: true
human_approval_default: false
review_focus:
- Action timeline preserves acting beats and emotional readability
- Pose transitions are timed with holds, anticipation, and follow-through
- render_runtime is carried from proposal unchanged
success_criteria:
- Schema-valid edit_decisions
- Schema-valid action_timeline
- Every scene has timed actions
- name: compose
skill: pipelines/character-animation/compose-director
required_artifacts_in:
- edit_decisions
- action_timeline
- asset_manifest
optional_artifacts_in:
- rig_plan
- pose_library
- proposal_packet
produces:
- render_report
- final_review
- character_qa_report
tools_available:
- character_rig_renderer
- character_animation_reviewer
- video_compose
- audio_mixer
checkpoint_required: true
human_approval_default: false
review_focus:
- Runtime chosen in proposal is the runtime actually used
- Browser preview passes Playwright/screenshot checks where available
- Final MP4 passes ffprobe, frame sampling, character QA, and visual self-review
- No silent downgrade to still-image motion
success_criteria:
- Schema-valid render_report
- Schema-valid final_review
- Schema-valid character_qa_report
- Output exists and passes technical validation
- name: publish
skill: pipelines/character-animation/publish-director
required_artifacts_in:
- render_report
- final_review
- character_qa_report
optional_artifacts_in:
- proposal_packet
- script
produces:
- publish_log
tools_available: []
checkpoint_required: true
human_approval_default: true
review_focus:
- Metadata describes the character-animation style honestly
- Thumbnail/poster frame features the main character and emotional hook
- Any limitations are surfaced in delivery notes
success_criteria:
- Schema-valid publish_log
+1
View File
@@ -24,6 +24,7 @@ When you add a new component, append it here and in `src/components/index.ts`.
| `progress_bar` | `ProgressBar` | `progress` | `progressLabel`, `progressColor`, `progressSegments` | Animated progress |
| `anime_scene` | `AnimeScene` | `images` (list) | `particles`, `lightingFrom`, `lightingTo`, `vignette` | Still-image anime scene with particles + camera motion |
| **`terminal_scene`** | **`TerminalScene`** | **`steps`** (list of cmd/out/pause/pill) | **`terminalTitle`, `prompt`, `accentColor`** | **Synthetic terminal animation — NO real capture needed. See [`.agents/skills/synthetic-screen-recording/SKILL.md`](../.agents/skills/synthetic-screen-recording/SKILL.md)** |
| **`screenshot_scene`** | **`ScreenshotScene`** | **`backgroundImage`** (path in `public/`), **`screenshotSteps`** (list of overlays) | **`screenshotSize` (natural px w/h), `cursorStartAt`, `accentColor`** | **Approach-1 synthetic UI — drop any screenshot, animate scripted overlays on top (cursor, click_pulse, type_into, bubble_append, typing_dots, highlight_box, callout_balloon). Viewer-indistinguishable from a real recording for 1530s focused demos. Coordinates are normalized (01) against the contain-fit rect. See [`.agents/skills/synthetic-ui-recording/SKILL.md`](../.agents/skills/synthetic-ui-recording/SKILL.md) (planned).** |
---
+168 -35
View File
@@ -174,6 +174,10 @@ const TitleCard: React.FC<{
titleFontSize: number;
titleWidth: number;
signalLineCount: number;
backgroundSrc?: string;
backgroundTrimBeforeSeconds?: number;
backgroundTrimAfterSeconds?: number;
variant?: "plate" | "overlay";
}> = ({
text,
accent,
@@ -181,85 +185,210 @@ const TitleCard: React.FC<{
titleFontSize,
titleWidth,
signalLineCount,
backgroundSrc,
backgroundTrimBeforeSeconds,
backgroundTrimAfterSeconds,
variant = "plate",
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const reveal = spring({
const container = spring({
fps,
frame,
config: { damping: 18, stiffness: 90 },
config: { damping: 22, stiffness: 80 },
});
const exit = interpolate(
frame,
[durationInFrames - 12, durationInFrames],
[durationInFrames - 14, durationInFrames],
[1, 0],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const y = interpolate(reveal, [0, 1], [18, 0]);
const letterSpacing = interpolate(reveal, [0, 1], [0.3, 0.18]);
// Split by newlines first (each line rendered in its own block),
// then word-stagger inside each line. This preserves intentional
// \n separators (e.g. "TITLE 1\nTITLE 2") that the old whitespace
// regex was collapsing into a single space.
const lines = text.split(/\r?\n/);
const staggerFrames = 3;
const wordFadeFrames = 14;
const lineGrow = interpolate(frame, [0, 22], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const lineExit = exit;
const flareOpacity =
0.18 + Math.max(0, Math.sin(frame * 0.08)) * 0.14 * intensity;
0.22 + Math.max(0, Math.sin(frame * 0.09)) * 0.18 * intensity;
const bgScale = interpolate(frame, [0, durationInFrames], [1.04, 1.1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const bgTrimBefore =
backgroundTrimBeforeSeconds !== undefined
? Math.round(backgroundTrimBeforeSeconds * fps)
: undefined;
const bgTrimAfter =
backgroundTrimAfterSeconds !== undefined
? Math.round(backgroundTrimAfterSeconds * fps)
: undefined;
const plateBg =
variant === "overlay"
? "transparent"
: "radial-gradient(ellipse at 50% 50%, rgba(8,14,22,0.78) 0%, rgba(2,4,8,0.92) 58%, rgba(0,0,0,1) 100%)";
return (
<AbsoluteFill
style={{
background:
"radial-gradient(circle at 50% 42%, rgba(16,28,40,0.9) 0%, rgba(3,5,8,1) 58%, rgba(0,0,0,1) 100%)",
background: "#000",
justifyContent: "center",
alignItems: "center",
}}
>
{backgroundSrc ? (
<>
<AbsoluteFill style={{ transform: `scale(${bgScale})`, opacity: 0.62 }}>
<OffthreadVideo
muted
src={resolveAsset(backgroundSrc)}
trimBefore={bgTrimBefore}
trimAfter={bgTrimAfter}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
filter: "contrast(1.08) saturate(0.55) brightness(0.55) blur(4px)",
}}
/>
</AbsoluteFill>
<AbsoluteFill
style={{
background:
"linear-gradient(180deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.35) 40%, rgba(0,0,0,0.35) 60%, rgba(0,0,0,0.78) 100%)",
}}
/>
</>
) : null}
<AbsoluteFill
style={{
background: plateBg,
}}
/>
<SignalTexture
accent={accent}
intensity={intensity}
intensity={intensity * 0.7}
lineCount={signalLineCount}
/>
{/* Top accent line — grows from center outwards */}
<div
style={{
position: "absolute",
width: 880,
height: 2,
width: 1100 * lineGrow,
height: 1,
background: accent,
boxShadow: `0 0 28px ${accent}`,
opacity: flareOpacity,
transform: "translateY(-126px)",
boxShadow: `0 0 24px ${accent}`,
opacity: flareOpacity * lineExit,
transform: "translateY(-118px)",
}}
/>
<div
style={{
position: "absolute",
width: 880,
height: 2,
width: 1100 * lineGrow,
height: 1,
background: accent,
boxShadow: `0 0 28px ${accent}`,
opacity: flareOpacity * 0.7,
transform: "translateY(126px)",
boxShadow: `0 0 24px ${accent}`,
opacity: flareOpacity * 0.75 * lineExit,
transform: "translateY(118px)",
}}
/>
{/* Word-stagger text reveal */}
<div
style={{
opacity: reveal * exit,
transform: `translateY(${y}px)`,
fontFamily,
fontWeight: 700,
fontSize: titleFontSize,
lineHeight: 1.06,
letterSpacing: `${letterSpacing}em`,
textAlign: "center",
color: "#f3f6fa",
textTransform: "uppercase",
opacity: exit,
width: titleWidth,
textShadow: "0 0 22px rgba(255,255,255,0.08)",
textAlign: "center",
fontFamily,
fontWeight: 500,
fontSize: titleFontSize,
lineHeight: 1.12,
letterSpacing: "0.16em",
color: "#f6f4ee",
textTransform: "uppercase",
textShadow: "0 0 34px rgba(255,255,255,0.10), 0 0 2px rgba(0,0,0,0.8)",
}}
>
{text}
{(() => {
let wordCounter = 0;
return lines.map((line, lineIdx) => {
const tokens = line.split(/(\s+)/).filter((w) => w.length > 0);
return (
<div key={lineIdx} style={{ display: "block" }}>
{tokens.map((w, ti) => {
if (/^\s+$/.test(w)) {
return <span key={ti}>&nbsp;</span>;
}
const startFrame = wordCounter * staggerFrames;
wordCounter += 1;
const wordOpacity = interpolate(
frame,
[startFrame, startFrame + wordFadeFrames],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const blur = interpolate(
frame,
[startFrame, startFrame + wordFadeFrames],
[6, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const ty = interpolate(
frame,
[startFrame, startFrame + wordFadeFrames],
[14, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
return (
<span
key={ti}
style={{
display: "inline-block",
opacity: wordOpacity,
filter: `blur(${blur}px)`,
transform: `translateY(${ty}px)`,
}}
>
{w}
</span>
);
})}
</div>
);
});
})()}
</div>
{/* Subtle accent dot centered under text */}
<div
style={{
position: "absolute",
width: 6,
height: 6,
borderRadius: "50%",
background: accent,
boxShadow: `0 0 18px ${accent}`,
opacity: 0.55 * container * lineExit,
transform: "translateY(172px)",
}}
/>
</AbsoluteFill>
);
};
@@ -384,6 +513,10 @@ export const CinematicRenderer: React.FC<CinematicRendererProps> = ({
titleFontSize={titleFontSize}
titleWidth={titleWidth}
signalLineCount={signalLineCount}
backgroundSrc={scene.backgroundSrc}
backgroundTrimBeforeSeconds={scene.backgroundTrimBeforeSeconds}
backgroundTrimAfterSeconds={scene.backgroundTrimAfterSeconds}
variant={scene.variant}
/>
)}
</Sequence>
+608
View File
@@ -0,0 +1,608 @@
import {
AbsoluteFill,
Img,
OffthreadVideo,
Sequence,
interpolate,
random,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import React from "react";
import { loadFont as loadPlayfair } from "@remotion/google-fonts/PlayfairDisplay";
const { fontFamily: playfairFamily } = loadPlayfair("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});
const { fontFamily: playfairItalic } = loadPlayfair("italic", {
weights: ["400", "700"],
subsets: ["latin"],
});
function resolveAsset(src: string): string {
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) return src;
const clean = src.replace(/^file:\/\/\/?/, "");
if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) {
return `file:///${clean.replace(/\\/g, "/")}`;
}
return staticFile(clean);
}
export type CollageTransition =
| "pop"
| "slide-zoom"
| "spin"
| "shutter"
| "glitch"
| "swoop";
export interface CollageClip {
src: string;
kind: "image" | "video";
inSeconds: number;
outSeconds: number;
x: number; // 0..1 center
y: number; // 0..1 center
widthPct: number; // 0..1 of frame width
aspect?: number; // width/height, default 3/4 (portrait card)
rotation: number; // degrees
sourceInSeconds?: number;
transition?: CollageTransition;
hero?: boolean; // adds extra polish + flash boost
seed?: number;
}
export interface CollageBurstProps {
backgroundSrc: string;
backgroundInSeconds?: number;
curtainStartSeconds: number;
curtainEndSeconds: number;
clips: CollageClip[];
}
// ----------------------------------------------------------------------------
// Opening text — elegant serif card that lives in the pre-reveal black, then
// fades out as the curtain opens.
// ----------------------------------------------------------------------------
const OpeningText: React.FC<{
lineOne: string;
lineTwo: string;
fadeInStart: number;
fadeInEnd: number;
fadeOutStart: number;
fadeOutEnd: number;
}> = ({ lineOne, lineTwo, fadeInStart, fadeInEnd, fadeOutStart, fadeOutEnd }) => {
const frame = useCurrentFrame();
if (frame < fadeInStart || frame > fadeOutEnd) return null;
const fadeIn = interpolate(frame, [fadeInStart, fadeInEnd], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fadeOut = interpolate(frame, [fadeOutStart, fadeOutEnd], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = fadeIn * fadeOut;
// Slight rise on entry, slight drift on exit
const yIn = interpolate(fadeIn, [0, 1], [18, 0]);
const yOut = interpolate(fadeOut, [0, 1], [-10, 0]);
const y = yIn + yOut;
// Line draws in from center outward
const lineProgress = interpolate(frame, [fadeInStart, fadeInEnd + 8], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const cream = "#F5E7C5";
const gold = "rgba(255, 214, 150, 0.9)";
return (
<AbsoluteFill
style={{
pointerEvents: "none",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
paddingTop: "8%",
opacity,
}}
>
<div
style={{
transform: `translateY(${y}px)`,
textAlign: "center",
filter: `drop-shadow(0 0 22px rgba(255, 200, 120, 0.3))`,
}}
>
{/* Decorative rule */}
<div
style={{
width: 160 * lineProgress,
height: 1.5,
background: `linear-gradient(90deg, rgba(245,231,197,0) 0%, ${gold} 50%, rgba(245,231,197,0) 100%)`,
margin: "0 auto 28px",
}}
/>
<div
style={{
fontFamily: playfairFamily,
fontWeight: 400,
fontSize: 46,
color: cream,
letterSpacing: "0.01em",
lineHeight: 1.35,
textShadow: "0 2px 18px rgba(0,0,0,0.7)",
}}
>
{lineOne}
</div>
<div
style={{
fontFamily: playfairItalic,
fontStyle: "italic",
fontWeight: 400,
fontSize: 78,
color: cream,
letterSpacing: "0.005em",
lineHeight: 1.15,
marginTop: 14,
textShadow: "0 2px 22px rgba(0,0,0,0.75)",
}}
>
{lineTwo}
</div>
{/* Small jewel divider */}
<div
style={{
marginTop: 30,
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 14,
opacity: lineProgress,
}}
>
<div
style={{
width: 60,
height: 1,
background: `linear-gradient(90deg, rgba(245,231,197,0) 0%, ${gold} 100%)`,
}}
/>
<div
style={{
width: 6,
height: 6,
borderRadius: 999,
background: gold,
boxShadow: `0 0 14px ${gold}`,
}}
/>
<div
style={{
width: 60,
height: 1,
background: `linear-gradient(90deg, ${gold} 0%, rgba(245,231,197,0) 100%)`,
}}
/>
</div>
</div>
</AbsoluteFill>
);
};
// ----------------------------------------------------------------------------
// White flash — brief burst when a card lands. Rendered at composition level.
// ----------------------------------------------------------------------------
const CardFlash: React.FC<{ atFrame: number; strength?: number }> = ({
atFrame,
strength = 0.4,
}) => {
const frame = useCurrentFrame();
if (frame < atFrame - 1 || frame > atFrame + 6) return null;
const t = (frame - atFrame) / 6;
const opacity = Math.max(0, (1 - t) * strength);
return (
<AbsoluteFill
style={{
backgroundColor: "white",
opacity,
pointerEvents: "none",
mixBlendMode: "screen",
}}
/>
);
};
// ----------------------------------------------------------------------------
// CollageCard — picks an entry transition by `clip.transition` and plays video
// starting at its in-time via <Sequence>.
// ----------------------------------------------------------------------------
const CollageCard: React.FC<{ clip: CollageClip; frameW: number; frameH: number }> = ({
clip,
frameW,
frameH,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const inFrame = clip.inSeconds * fps;
const outFrame = clip.outSeconds * fps;
if (frame < inFrame - 2 || frame > outFrame + 8) return null;
const rel = frame - inFrame;
const relOut = frame - outFrame;
const seed = clip.seed ?? 0;
const transition = clip.transition ?? "pop";
// Common spring value 0→1 over ~18f for entrance
const entry = spring({
frame: rel,
fps,
config: { damping: 10, stiffness: 130, mass: 0.85 },
durationInFrames: 20,
});
const exit = spring({
frame: relOut,
fps,
config: { damping: 14, stiffness: 200, mass: 0.8 },
durationInFrames: 12,
});
const isExiting = frame >= outFrame;
// Base transform values derived from transition
let scale = 1;
let rot = clip.rotation;
let offX = 0;
let offY = 0;
let opacity = 1;
let clipPathCss: string | undefined;
let blur = 0;
if (!isExiting) {
const e = entry;
const inv = 1 - e;
switch (transition) {
case "pop":
scale = e;
opacity = e;
break;
case "slide-zoom": {
const dir = (seed % 4); // 0=left, 1=right, 2=top, 3=bottom
const slide = frameW * 0.6 * inv;
if (dir === 0) offX = -slide;
else if (dir === 1) offX = slide;
else if (dir === 2) offY = -slide;
else offY = slide;
scale = 0.7 + 0.3 * e;
opacity = e;
blur = inv * 8;
break;
}
case "spin": {
scale = e;
rot = clip.rotation + (1 - e) * (seed % 2 === 0 ? 540 : -540);
opacity = e;
break;
}
case "shutter": {
scale = 0.92 + 0.08 * e;
opacity = e > 0.1 ? 1 : 0;
const pct = Math.round(inv * 50);
clipPathCss = `inset(${pct}% 0 ${pct}% 0 round 14px)`;
break;
}
case "glitch": {
scale = 0.85 + 0.15 * e;
opacity = e;
// High-frequency jitter first ~8f then settles
const jitterAmt = Math.max(0, 1 - rel / 8);
offX = (random(`gx${seed}-${Math.floor(rel / 1)}`) - 0.5) * 40 * jitterAmt;
offY = (random(`gy${seed}-${Math.floor(rel / 1)}`) - 0.5) * 40 * jitterAmt;
rot = clip.rotation + (random(`gr${seed}-${Math.floor(rel / 2)}`) - 0.5) * 16 * jitterAmt;
break;
}
case "swoop": {
// arc in from a corner
const fromX = (seed % 2 === 0 ? -1 : 1) * frameW * 0.55;
const fromY = -frameH * 0.3;
offX = fromX * inv;
offY = fromY * inv;
scale = 0.6 + 0.4 * e;
rot = clip.rotation + inv * (seed % 2 === 0 ? -35 : 35);
opacity = e;
break;
}
}
} else {
// Exit: quick shrink + fade
const o = exit;
scale = 1 - 0.7 * o;
opacity = 1 - o;
rot = clip.rotation + o * (seed % 2 === 0 ? -8 : 8);
}
// Gentle idle breathing while held
const idle = Math.sin((rel - 20) / 14 + seed) * 0.012;
const held = !isExiting && rel > 20;
const idleScale = held ? 1 + idle : 1;
const aspect = clip.aspect ?? 3 / 4; // default portrait
const w = frameW * clip.widthPct;
const h = w / aspect;
const cx = clip.x * frameW;
const cy = clip.y * frameH;
// Source offset for videos — Sequence starts at inFrame so OffthreadVideo
// naturally begins playback on card entry.
const startFromFrames = Math.round((clip.sourceInSeconds ?? 0) * fps);
const borderWidth = clip.hero ? 10 : 8;
const glowColor = clip.hero ? "rgba(255, 210, 140, 0.5)" : "rgba(255,255,255,0.05)";
const cardContent =
clip.kind === "image" ? (
<Img
src={resolveAsset(clip.src)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<Sequence from={inFrame} layout="none">
<OffthreadVideo
src={resolveAsset(clip.src)}
startFrom={startFromFrames}
muted
playbackRate={1}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
</Sequence>
);
return (
<div
style={{
position: "absolute",
left: cx - w / 2,
top: cy - h / 2,
width: w,
height: h,
transform: `translate(${offX}px, ${offY}px) rotate(${rot}deg) scale(${scale * idleScale})`,
transformOrigin: "center center",
opacity,
filter: blur > 0 ? `blur(${blur}px)` : undefined,
willChange: "transform",
}}
>
<div
style={{
position: "absolute",
inset: 0,
borderRadius: 16,
overflow: "hidden",
boxShadow: clip.hero
? "0 40px 100px rgba(0,0,0,0.7), 0 0 90px rgba(255,200,120,0.55), 0 8px 18px rgba(0,0,0,0.55)"
: "0 24px 60px rgba(0,0,0,0.6), 0 8px 18px rgba(0,0,0,0.4)",
border: `${borderWidth}px solid #FAFAF5`,
backgroundColor: "#FAFAF5",
clipPath: clipPathCss,
}}
>
{cardContent}
{/* Subtle inner vignette on hero cards for depth */}
{clip.hero && (
<AbsoluteFill
style={{
pointerEvents: "none",
background:
"radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,0.4) 100%)",
}}
/>
)}
{/* Warm film edge glow for hero */}
{clip.hero && (
<AbsoluteFill
style={{
pointerEvents: "none",
boxShadow: `inset 0 0 60px ${glowColor}`,
}}
/>
)}
</div>
</div>
);
};
export const CollageBurst: React.FC<CollageBurstProps> = ({
backgroundSrc,
backgroundInSeconds = 0,
curtainStartSeconds,
curtainEndSeconds,
clips,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const curtainStart = curtainStartSeconds * fps;
const curtainEnd = curtainEndSeconds * fps;
const t = interpolate(frame, [curtainStart, curtainEnd], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const panelShift = ease * (width / 2 + 40);
const seamOpacity = frame < curtainStart ? 0.65 + 0.35 * Math.sin(frame / 3) : 0;
const seamGlow = frame < curtainStart ? 0.6 + 0.4 * Math.sin(frame / 4) : 0;
const bgStartFrame = Math.max(0, curtainStart - 6);
const bgVisible = frame >= bgStartFrame;
// Slow cinematic zoom
const bgZoom = interpolate(frame, [curtainStart, curtainStart + 900], [1.05, 1.18], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Screen shake intensity — spikes briefly around each clip entry
let shakeX = 0;
let shakeY = 0;
for (const c of clips) {
const entryFrame = c.inSeconds * fps;
const dist = frame - entryFrame;
if (dist >= 0 && dist < 6) {
const falloff = 1 - dist / 6;
const amp = (c.hero ? 12 : 5) * falloff;
shakeX += (random(`sx-${entryFrame}-${dist}`) - 0.5) * amp;
shakeY += (random(`sy-${entryFrame}-${dist}`) - 0.5) * amp;
}
}
return (
<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
{/* ---------- Hazy muted background ---------- */}
{bgVisible && (
<AbsoluteFill
style={{
transform: `scale(${bgZoom})`,
filter: "saturate(0.35) brightness(0.55) contrast(0.95) blur(6px)",
opacity: 0.55,
}}
>
<OffthreadVideo
src={resolveAsset(backgroundSrc)}
startFrom={Math.round(backgroundInSeconds * fps)}
muted
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
</AbsoluteFill>
)}
{/* Dirty-gold / grey atmospheric wash layered over bg */}
{bgVisible && (
<AbsoluteFill
style={{
pointerEvents: "none",
background:
"radial-gradient(ellipse at 50% 40%, rgba(180,150,110,0.18) 0%, rgba(30,28,24,0.75) 70%, rgba(5,5,5,0.95) 100%)",
mixBlendMode: "multiply",
}}
/>
)}
{/* Grainy noise via CSS — subtle film texture */}
{bgVisible && (
<AbsoluteFill
style={{
pointerEvents: "none",
opacity: 0.22,
background:
"repeating-conic-gradient(rgba(255,255,255,0.04) 0deg 1deg, rgba(0,0,0,0) 1deg 2deg)",
mixBlendMode: "overlay",
}}
/>
)}
{/* ---------- Curtain ---------- */}
<AbsoluteFill>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "50%",
height: "100%",
backgroundColor: "#050505",
transform: `translateX(${-panelShift}px)`,
boxShadow: "inset -30px 0 60px rgba(0,0,0,0.9)",
}}
/>
<div
style={{
position: "absolute",
top: 0,
right: 0,
width: "50%",
height: "100%",
backgroundColor: "#050505",
transform: `translateX(${panelShift}px)`,
boxShadow: "inset 30px 0 60px rgba(0,0,0,0.9)",
}}
/>
<div
style={{
position: "absolute",
top: 0,
left: "50%",
width: 8,
height: "100%",
transform: "translateX(-50%)",
background:
"linear-gradient(180deg, rgba(255,220,160,0) 0%, rgba(255,220,160,1) 50%, rgba(255,220,160,0) 100%)",
opacity: Math.max(0, seamOpacity - ease * 1.2),
filter: `blur(${2 + 6 * seamGlow}px)`,
boxShadow: `0 0 ${50 + 80 * seamGlow}px rgba(255, 200, 120, 0.95)`,
}}
/>
</AbsoluteFill>
{/* ---------- Opening text (pre-reveal, on black) ---------- */}
<OpeningText
lineOne="When you realize your daughters"
lineTwo="deserve these lines"
fadeInStart={Math.round(0.3 * fps)}
fadeInEnd={Math.round(0.9 * fps)}
fadeOutStart={Math.round(1.8 * fps)}
fadeOutEnd={Math.round(2.6 * fps)}
/>
{/* ---------- Foreground shake wrapper ---------- */}
<AbsoluteFill style={{ transform: `translate(${shakeX}px, ${shakeY}px)` }}>
{clips.map((clip, i) => (
<CollageCard key={i} clip={clip} frameW={width} frameH={height} />
))}
</AbsoluteFill>
{/* ---------- Flash bursts layered on each clip entry ---------- */}
{clips.map((c, i) => (
<CardFlash
key={`f${i}`}
atFrame={c.inSeconds * fps}
strength={c.hero ? 0.55 : 0.22}
/>
))}
{/* Frame top/bottom vignette for focus */}
<AbsoluteFill
style={{
background:
"linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0) 14%, rgba(0,0,0,0) 86%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};
+17
View File
@@ -43,6 +43,8 @@ import { AnimeScene } from "./components/AnimeScene";
import type { CameraMotion } from "./components/AnimeScene";
import { TerminalScene } from "./components/TerminalScene";
import type { TerminalStep } from "./components/TerminalScene";
import { ScreenshotScene } from "./components/ScreenshotScene";
import type { ScreenshotStep } from "./components/ScreenshotScene";
import { ProviderChip } from "./components/ProviderChip";
import type { ParticleType } from "./components/ParticleOverlay";
import { resolveTheme, type ThemeConfig, DEFAULT_THEME } from "./Root";
@@ -262,6 +264,10 @@ interface Cut {
steps?: TerminalStep[];
terminalTitle?: string;
prompt?: string;
// Screenshot scene props (type: "screenshot_scene")
screenshotSteps?: ScreenshotStep[];
screenshotSize?: { width: number; height: number };
cursorStartAt?: [number, number];
}
interface Overlay {
@@ -602,6 +608,17 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme
/>
);
}
if (cut.type === "screenshot_scene" && cut.backgroundImage && cut.screenshotSteps) {
return (
<ScreenshotScene
backgroundImage={cut.backgroundImage}
backgroundSize={cut.screenshotSize}
steps={cut.screenshotSteps as ScreenshotStep[]}
accentColor={accent}
cursorStartAt={cut.cursorStartAt}
/>
);
}
// --- Chart types — use theme.chartColors as default palette ---
if (cut.type === "bar_chart" && cut.chartData) {
+173
View File
@@ -0,0 +1,173 @@
import {
AbsoluteFill,
Audio,
OffthreadVideo,
interpolate,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import React from "react";
import { loadFont as loadPlayfair } from "@remotion/google-fonts/PlayfairDisplay";
const { fontFamily: playfairItalic } = loadPlayfair("italic", {
weights: ["400", "700"],
subsets: ["latin"],
});
function resolveAsset(src: string): string {
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) return src;
const clean = src.replace(/^file:\/\/\/?/, "");
if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) {
return `file:///${clean.replace(/\\/g, "/")}`;
}
return staticFile(clean);
}
export interface Lyric {
text: string;
inSeconds: number;
outSeconds: number;
}
export interface LyricOverlayProps {
videoSrc: string;
lyrics: Lyric[];
bottomY?: number; // 0..1, vertical center of subtitle band
}
const LyricLine: React.FC<{ lyric: Lyric; bottomY: number }> = ({ lyric, bottomY }) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const inFrame = lyric.inSeconds * fps;
const outFrame = lyric.outSeconds * fps;
if (frame < inFrame - 1 || frame > outFrame + 8) return null;
const fadeInDur = 6;
const fadeOutDur = 8;
const fadeIn = interpolate(frame, [inFrame, inFrame + fadeInDur], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fadeOut = interpolate(frame, [outFrame, outFrame + fadeOutDur], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = fadeIn * fadeOut;
const yRise = interpolate(fadeIn, [0, 1], [10, 0]);
const cream = "#F5E7C5";
const gold = "rgba(255, 214, 150, 0.85)";
// Line draws in from center outward beneath the text
const lineProgress = interpolate(frame, [inFrame, inFrame + fadeInDur + 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
pointerEvents: "none",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-end",
paddingBottom: height * (1 - bottomY),
opacity,
}}
>
{/* Subtle dark backdrop behind text for readability */}
<div
style={{
position: "absolute",
bottom: height * (1 - bottomY) - 80,
left: 0,
right: 0,
height: 240,
background:
"linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 55%, rgba(0,0,0,0.75) 100%)",
opacity,
}}
/>
<div
style={{
transform: `translateY(${yRise}px)`,
textAlign: "center",
padding: "0 60px",
filter: "drop-shadow(0 0 18px rgba(255, 200, 120, 0.22))",
position: "relative",
zIndex: 2,
}}
>
<div
style={{
fontFamily: playfairItalic,
fontStyle: "italic",
fontWeight: 400,
fontSize: 54,
lineHeight: 1.15,
color: cream,
letterSpacing: "0.01em",
textShadow: "0 2px 18px rgba(0,0,0,0.85), 0 0 22px rgba(0,0,0,0.5)",
}}
>
{lyric.text}
</div>
{/* Gold underline */}
<div
style={{
marginTop: 18,
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 90 * lineProgress,
height: 1.2,
background: `linear-gradient(90deg, rgba(245,231,197,0) 0%, ${gold} 100%)`,
}}
/>
<div
style={{
width: 5,
height: 5,
borderRadius: 999,
background: gold,
opacity: lineProgress,
boxShadow: `0 0 10px ${gold}`,
}}
/>
<div
style={{
width: 90 * lineProgress,
height: 1.2,
background: `linear-gradient(90deg, ${gold} 0%, rgba(245,231,197,0) 100%)`,
}}
/>
</div>
</div>
</AbsoluteFill>
);
};
export const LyricOverlay: React.FC<LyricOverlayProps> = ({
videoSrc,
lyrics,
bottomY = 0.88,
}) => {
const { durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
<OffthreadVideo src={resolveAsset(videoSrc)} />
{lyrics.map((l, i) => (
<LyricLine key={i} lyric={l} bottomY={bottomY} />
))}
</AbsoluteFill>
);
};
+30
View File
@@ -14,6 +14,8 @@ import { EndTag, EndTagProps } from "./components/EndTag";
import { HeroTitle } from "./components/HeroTitle";
import { ProductReveal, ProductRevealProps } from "./components/ProductReveal";
import { CaptionOverlay, WordCaption } from "./components/CaptionOverlay";
import { CollageBurst, CollageBurstProps } from "./CollageBurst";
import { LyricOverlay, LyricOverlayProps } from "./LyricOverlay";
// ---------------------------------------------------------------------------
// Theme System — prevents every video from looking like dark fintech
@@ -266,6 +268,34 @@ export const Root: React.FC = () => {
backgroundColor: "rgba(15, 23, 42, 0.75)",
}}
/>
<Composition
id="CollageBurst"
component={CollageBurst}
durationInFrames={30 * 30}
fps={30}
width={1080}
height={1920}
defaultProps={{
backgroundSrc: "",
backgroundInSeconds: 0,
curtainStartSeconds: 1.5,
curtainEndSeconds: 3.0,
clips: [],
} as CollageBurstProps}
/>
<Composition
id="LyricOverlay"
component={LyricOverlay}
durationInFrames={30 * 28}
fps={30}
width={1080}
height={1920}
defaultProps={{
videoSrc: "",
lyrics: [],
bottomY: 0.88,
} as LyricOverlayProps}
/>
<Composition
id="EndTag"
component={EndTag}
+4
View File
@@ -22,6 +22,10 @@ export interface CinematicTitleScene extends CinematicBaseScene {
text: string;
accent?: string;
intensity?: number;
backgroundSrc?: string;
backgroundTrimBeforeSeconds?: number;
backgroundTrimAfterSeconds?: number;
variant?: "plate" | "overlay";
}
export type CinematicScene = CinematicVideoScene | CinematicTitleScene;
@@ -0,0 +1,668 @@
import {
AbsoluteFill,
Img,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
/**
* ScreenshotScene approach-1 synthetic UI demo.
*
* Takes any screenshot as a frozen backdrop and animates scripted overlays
* (cursor, click pulses, typing, chat bubbles, highlight rings, callouts)
* on top at normalized coordinates. Viewer-indistinguishable from a real
* screen recording for ~15-30s focused demos.
*
* Coordinate system: everything is 0-1 normalized against the rendered
* backdrop rectangle (not the raw canvas), so overlays track the image
* correctly regardless of letterboxing.
*
* See .agents/skills/synthetic-ui-recording/SKILL.md for authoring guidance.
*/
// ---------- Types ----------
export type Region = { x: number; y: number; w: number; h: number }; // all 0-1
export type Point = [number, number]; // [x, y], 0-1 normalized
export type ScreenshotStep =
| { kind: "cursor_move"; to: Point; durationSeconds?: number }
| { kind: "click_pulse"; at?: Point; durationSeconds?: number; color?: string }
| {
kind: "type_into";
region: Region;
text: string;
typeSpeed?: number; // seconds per char
fontSize?: number; // in normalized-height units; default 0.022
color?: string;
}
| {
kind: "bubble_append";
region: Region; // where the bubble lands (its bounding box)
text: string;
role?: "user" | "assistant";
durationSeconds?: number;
stream?: boolean; // if true, text reveals word-by-word over the duration
fontSize?: number;
}
| {
kind: "typing_dots";
at: Point;
durationSeconds?: number;
color?: string;
}
| {
kind: "highlight_box";
region: Region;
durationSeconds?: number;
color?: string;
pulses?: number;
}
| {
kind: "callout_balloon";
anchor: Point; // the element being pointed at
text: string;
position?: "top" | "bottom" | "left" | "right"; // where balloon sits relative to anchor
durationSeconds?: number;
color?: string;
}
| { kind: "pause"; seconds: number };
interface ScreenshotSceneProps {
backgroundImage: string;
/** Natural pixel size of the image used to compute the contain-fit
* rectangle so overlays land on correct pixels. Defaults to 16:9. */
backgroundSize?: { width: number; height: number };
steps: ScreenshotStep[];
accentColor?: string;
/** Starting cursor position. Default: top-right area. */
cursorStartAt?: Point;
}
// ---------- Helpers ----------
function resolveAsset(src: string): string {
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) {
return src;
}
const clean = src.replace(/^file:\/\/\/?/, "");
if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) {
return `file:///${clean.replace(/\\/g, "/")}`;
}
return staticFile(clean);
}
/** Compute the rendered bounding box of the backdrop inside a canvas,
* using object-fit: contain semantics. Returns pixel offsets/sizes. */
function containRect(
imgW: number,
imgH: number,
cvW: number,
cvH: number
): { x: number; y: number; w: number; h: number } {
const imgAspect = imgW / imgH;
const cvAspect = cvW / cvH;
if (imgAspect > cvAspect) {
// image is wider → fit width, letterbox top/bottom
const w = cvW;
const h = cvW / imgAspect;
return { x: 0, y: (cvH - h) / 2, w, h };
} else {
// image is taller → fit height, letterbox left/right
const h = cvH;
const w = cvH * imgAspect;
return { x: (cvW - w) / 2, y: 0, w, h };
}
}
// ---------- Timing walk — assign frame windows to each step ----------
interface TimedStep {
step: ScreenshotStep;
startFrame: number;
endFrame: number;
/** cursor position at start of this step (inclusive) */
cursorBefore: Point;
/** cursor position at end of this step */
cursorAfter: Point;
}
function walkTimeline(
steps: ScreenshotStep[],
fps: number,
cursorStart: Point
): { timed: TimedStep[]; totalFrames: number } {
const timed: TimedStep[] = [];
let cursor = cursorStart;
let frameCursor = 0;
for (const step of steps) {
let duration = 0;
const before = cursor;
let after = cursor;
let blocks = true; // whether step advances timeline cursor
switch (step.kind) {
case "cursor_move":
duration = (step.durationSeconds ?? 0.9) * fps;
after = step.to;
break;
case "click_pulse":
duration = (step.durationSeconds ?? 0.45) * fps;
if (step.at) after = step.at;
break;
case "type_into": {
const speed = step.typeSpeed ?? 0.04;
duration = step.text.length * speed * fps + 0.25 * fps;
break;
}
case "bubble_append":
duration = (step.durationSeconds ?? 0.9) * fps;
break;
case "typing_dots":
duration = (step.durationSeconds ?? 1.2) * fps;
break;
case "highlight_box":
duration = (step.durationSeconds ?? 1.5) * fps;
blocks = false; // non-blocking — subsequent steps can overlap
break;
case "callout_balloon":
duration = (step.durationSeconds ?? 2.2) * fps;
blocks = false;
break;
case "pause":
duration = step.seconds * fps;
break;
}
timed.push({
step,
startFrame: Math.round(frameCursor),
endFrame: Math.round(frameCursor + duration),
cursorBefore: before,
cursorAfter: after,
});
cursor = after;
if (blocks) frameCursor += duration;
}
const totalFrames = Math.max(
...timed.map((t) => t.endFrame),
Math.round(frameCursor)
);
return { timed, totalFrames };
}
// ---------- SVG cursor ----------
const CursorArrow: React.FC<{ size?: number }> = ({ size = 28 }) => (
<svg width={size} height={size * 1.2} viewBox="0 0 16 20" style={{ display: "block" }}>
<path
d="M2 2 L2 16 L6 12 L8.5 17 L10.5 16 L8 11 L13 11 Z"
fill="#FFFFFF"
stroke="#111"
strokeWidth={1.2}
strokeLinejoin="round"
/>
</svg>
);
// ---------- Main component ----------
export const ScreenshotScene: React.FC<ScreenshotSceneProps> = ({
backgroundImage,
backgroundSize,
steps,
accentColor = "#F59E0B",
cursorStartAt = [0.95, 0.05],
}) => {
const frame = useCurrentFrame();
const { fps, width: cvW, height: cvH } = useVideoConfig();
const imgW = backgroundSize?.width ?? 1920;
const imgH = backgroundSize?.height ?? 1080;
const rect = containRect(imgW, imgH, cvW, cvH);
// Convert normalized (0-1) backdrop coord to absolute canvas pixels
const abs = (p: Point): { x: number; y: number } => ({
x: rect.x + p[0] * rect.w,
y: rect.y + p[1] * rect.h,
});
const absRect = (r: Region) => ({
left: rect.x + r.x * rect.w,
top: rect.y + r.y * rect.h,
width: r.w * rect.w,
height: r.h * rect.h,
});
// Walk timeline once
const { timed } = walkTimeline(steps, fps, cursorStartAt);
// --- Cursor position at current frame ---
// Find the active cursor_move or the completed-most-recent one.
let cursorPos = cursorStartAt;
for (const t of timed) {
if (frame >= t.endFrame) {
cursorPos = t.cursorAfter;
} else if (frame >= t.startFrame && t.step.kind === "cursor_move") {
const p = interpolate(frame, [t.startFrame, t.endFrame], [0, 1], {
extrapolateRight: "clamp",
});
// Ease-out so cursor decelerates as it arrives
const eased = 1 - Math.pow(1 - p, 3);
cursorPos = [
t.cursorBefore[0] + (t.cursorAfter[0] - t.cursorBefore[0]) * eased,
t.cursorBefore[1] + (t.cursorAfter[1] - t.cursorBefore[1]) * eased,
];
break;
} else if (frame < t.startFrame) {
cursorPos = t.cursorBefore;
break;
}
}
const cursorAbs = abs(cursorPos);
return (
<AbsoluteFill style={{ background: "#000" }}>
{/* Backdrop */}
<Img
src={resolveAsset(backgroundImage)}
style={{
position: "absolute",
left: rect.x,
top: rect.y,
width: rect.w,
height: rect.h,
objectFit: "fill",
}}
/>
{/* Overlays render in order so later steps paint on top.
Sticky kinds (type_into, bubble_append) persist once they appear;
transient kinds fade out after their duration. */}
{timed.map((t, i) => {
const kind = t.step.kind;
const sticky = kind === "type_into" || kind === "bubble_append";
const active = sticky
? frame >= t.startFrame
: frame >= t.startFrame && frame <= t.endFrame + fps * 0.4;
if (!active) return null;
return (
<OverlayForStep
key={i}
timed={t}
frame={frame}
fps={fps}
rect={rect}
abs={abs}
absRect={absRect}
accentColor={accentColor}
/>
);
})}
{/* Cursor — always on top */}
<div
style={{
position: "absolute",
left: cursorAbs.x - 4,
top: cursorAbs.y - 2,
pointerEvents: "none",
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.4))",
}}
>
<CursorArrow size={Math.round(rect.w * 0.018)} />
</div>
</AbsoluteFill>
);
};
// ---------- Per-step overlay renderers ----------
interface OverlayProps {
timed: TimedStep;
frame: number;
fps: number;
rect: { x: number; y: number; w: number; h: number };
abs: (p: Point) => { x: number; y: number };
absRect: (r: Region) => { left: number; top: number; width: number; height: number };
accentColor: string;
}
const OverlayForStep: React.FC<OverlayProps> = ({
timed,
frame,
fps,
rect,
abs,
absRect,
accentColor,
}) => {
const { step, startFrame, endFrame } = timed;
const localFrame = frame - startFrame;
if (step.kind === "click_pulse") {
const at = step.at ?? timed.cursorBefore;
const p = abs(at);
const progress = interpolate(localFrame, [0, endFrame - startFrame], [0, 1], {
extrapolateRight: "clamp",
});
const size = interpolate(progress, [0, 1], [10, 80]);
const alpha = interpolate(progress, [0, 1], [0.85, 0]);
const color = step.color ?? accentColor;
return (
<div
style={{
position: "absolute",
left: p.x - size / 2,
top: p.y - size / 2,
width: size,
height: size,
borderRadius: "50%",
border: `3px solid ${color}`,
opacity: alpha,
pointerEvents: "none",
}}
/>
);
}
if (step.kind === "type_into") {
const r = absRect(step.region);
const speed = step.typeSpeed ?? 0.04;
const totalChars = step.text.length;
const typeFrames = totalChars * speed * fps;
const revealed = Math.min(
totalChars,
Math.floor(interpolate(localFrame, [0, typeFrames], [0, totalChars], { extrapolateRight: "clamp" }))
);
const typed = step.text.slice(0, revealed);
const fontPx = Math.round(rect.h * (step.fontSize ?? 0.024));
const blink = Math.floor(frame / (fps * 0.5)) % 2 === 0;
return (
<div
style={{
position: "absolute",
left: r.left,
top: r.top,
width: r.width,
height: r.height,
display: "flex",
alignItems: "center",
paddingLeft: Math.round(rect.w * 0.012),
fontFamily: "Inter, -apple-system, sans-serif",
fontSize: fontPx,
color: step.color ?? "#E5E7EB",
pointerEvents: "none",
whiteSpace: "nowrap",
overflow: "hidden",
}}
>
<span>{typed}</span>
{blink && (
<span
style={{
display: "inline-block",
width: 2,
height: fontPx * 0.95,
background: step.color ?? "#E5E7EB",
marginLeft: 2,
}}
/>
)}
</div>
);
}
if (step.kind === "bubble_append") {
const r = absRect(step.region);
const springIn = spring({
frame: localFrame,
fps,
config: { damping: 16, stiffness: 140 },
durationInFrames: Math.ceil(fps * 0.5),
});
const isUser = step.role === "user";
const fontPx = Math.round(rect.h * (step.fontSize ?? 0.021));
// Streaming text reveal (word-by-word)
let displayText = step.text;
if (step.stream) {
const words = step.text.split(/(\s+)/); // keep whitespace
const totalRevealFrames = Math.max(1, endFrame - startFrame - fps * 0.3);
const wordCount = words.filter((w) => w.trim()).length;
const revealedWords = Math.floor(
interpolate(localFrame, [fps * 0.3, fps * 0.3 + totalRevealFrames], [0, wordCount], {
extrapolateRight: "clamp",
extrapolateLeft: "clamp",
})
);
let count = 0;
const pieces: string[] = [];
for (const w of words) {
if (w.trim()) {
if (count < revealedWords) {
pieces.push(w);
count++;
} else {
break;
}
} else {
pieces.push(w);
}
}
displayText = pieces.join("");
}
const bg = isUser ? "#2D3748" : "#1F2937";
const border = isUser ? "#4A5568" : "#374151";
return (
<div
style={{
position: "absolute",
left: r.left,
top: r.top,
width: r.width,
minHeight: r.height,
background: bg,
border: `1px solid ${border}`,
borderRadius: Math.round(rect.w * 0.008),
padding: `${Math.round(rect.h * 0.015)}px ${Math.round(rect.w * 0.012)}px`,
fontFamily: "Inter, -apple-system, sans-serif",
fontSize: fontPx,
color: "#F1F5F9",
lineHeight: 1.5,
opacity: springIn,
transform: `translateY(${(1 - springIn) * 20}px)`,
boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
whiteSpace: "pre-wrap",
pointerEvents: "none",
overflow: "hidden",
}}
>
{displayText}
</div>
);
}
if (step.kind === "typing_dots") {
const p = abs(step.at);
const dotSize = Math.round(rect.h * 0.01);
const dots = [0, 1, 2].map((i) => {
const phase = (frame / (fps * 0.35) - i * 0.3) % 2;
const alpha = phase < 1 ? 0.3 + phase * 0.7 : 1 - (phase - 1) * 0.7;
return alpha;
});
const color = step.color ?? accentColor;
return (
<div
style={{
position: "absolute",
left: p.x,
top: p.y,
display: "flex",
gap: dotSize * 0.7,
pointerEvents: "none",
}}
>
{dots.map((a, i) => (
<div
key={i}
style={{
width: dotSize,
height: dotSize,
borderRadius: "50%",
background: color,
opacity: Math.max(0.3, a),
}}
/>
))}
</div>
);
}
if (step.kind === "highlight_box") {
const r = absRect(step.region);
const dur = endFrame - startFrame;
const pulses = step.pulses ?? 2;
const color = step.color ?? accentColor;
// Pulsing ring: oscillate opacity + scale
const wave = Math.sin((localFrame / dur) * pulses * Math.PI * 2) * 0.5 + 0.5;
const alpha = 0.4 + wave * 0.5;
const glow = 10 + wave * 18;
return (
<div
style={{
position: "absolute",
left: r.left - 6,
top: r.top - 6,
width: r.width + 12,
height: r.height + 12,
border: `3px solid ${color}`,
borderRadius: Math.round(rect.w * 0.006),
boxShadow: `0 0 ${glow}px ${color}`,
opacity: alpha,
pointerEvents: "none",
}}
/>
);
}
if (step.kind === "callout_balloon") {
const a = abs(step.anchor);
const pos = step.position ?? "top";
const springIn = spring({
frame: localFrame,
fps,
config: { damping: 14, stiffness: 160 },
durationInFrames: Math.ceil(fps * 0.4),
});
const dur = endFrame - startFrame;
const fadeOut = interpolate(
localFrame,
[dur - fps * 0.4, dur],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const alpha = Math.min(springIn, fadeOut);
const color = step.color ?? accentColor;
const fontPx = Math.round(rect.h * 0.024);
const maxW = rect.w * 0.28;
// Balloon offset from anchor
const offset = rect.h * 0.06;
let bx = a.x;
let by = a.y;
let tailStyle: React.CSSProperties = {};
if (pos === "top") {
by = a.y - offset - fontPx * 2.5;
bx = a.x - maxW / 2;
tailStyle = {
position: "absolute",
bottom: -10,
left: "50%",
transform: "translateX(-50%)",
width: 0,
height: 0,
borderLeft: "10px solid transparent",
borderRight: "10px solid transparent",
borderTop: `12px solid ${color}`,
};
} else if (pos === "bottom") {
by = a.y + offset;
bx = a.x - maxW / 2;
tailStyle = {
position: "absolute",
top: -10,
left: "50%",
transform: "translateX(-50%)",
width: 0,
height: 0,
borderLeft: "10px solid transparent",
borderRight: "10px solid transparent",
borderBottom: `12px solid ${color}`,
};
} else if (pos === "left") {
bx = a.x - offset - maxW;
by = a.y - fontPx;
tailStyle = {
position: "absolute",
right: -10,
top: "50%",
transform: "translateY(-50%)",
width: 0,
height: 0,
borderTop: "10px solid transparent",
borderBottom: "10px solid transparent",
borderLeft: `12px solid ${color}`,
};
} else {
bx = a.x + offset;
by = a.y - fontPx;
tailStyle = {
position: "absolute",
left: -10,
top: "50%",
transform: "translateY(-50%)",
width: 0,
height: 0,
borderTop: "10px solid transparent",
borderBottom: "10px solid transparent",
borderRight: `12px solid ${color}`,
};
}
return (
<div
style={{
position: "absolute",
left: Math.max(rect.x + 8, Math.min(rect.x + rect.w - maxW - 8, bx)),
top: by,
width: maxW,
background: color,
color: "#0B0F1A",
fontFamily: "Inter, -apple-system, sans-serif",
fontWeight: 600,
fontSize: fontPx,
lineHeight: 1.35,
padding: `${Math.round(fontPx * 0.6)}px ${Math.round(fontPx * 0.9)}px`,
borderRadius: Math.round(rect.w * 0.008),
opacity: alpha,
transform: `scale(${interpolate(springIn, [0, 1], [0.9, 1])})`,
boxShadow: "0 10px 30px rgba(0,0,0,0.35)",
pointerEvents: "none",
}}
>
{step.text}
<div style={tailStyle} />
</div>
);
}
return null;
};
@@ -11,7 +11,9 @@ export { HeroTitle } from "./HeroTitle";
export { ParticleOverlay } from "./ParticleOverlay";
export { AnimeScene } from "./AnimeScene";
export { TerminalScene } from "./TerminalScene";
export { ScreenshotScene } from "./ScreenshotScene";
export { ProviderChip } from "./ProviderChip";
export type { ParticleType } from "./ParticleOverlay";
export type { CameraMotion, AnimeSceneProps } from "./AnimeScene";
export type { TerminalStep } from "./TerminalScene";
export type { ScreenshotStep, Region, Point } from "./ScreenshotScene";
+5
View File
@@ -15,7 +15,11 @@ ARTIFACT_NAMES = [
"proposal_packet",
"brief",
"script",
"character_design",
"rig_plan",
"pose_library",
"scene_plan",
"action_timeline",
"asset_manifest",
"edit_decisions",
"render_report",
@@ -25,6 +29,7 @@ ARTIFACT_NAMES = [
"decision_log",
"source_media_review",
"final_review",
"character_qa_report",
"video_analysis_brief",
]
@@ -0,0 +1,50 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "openmontage/artifacts/action_timeline",
"title": "Action Timeline",
"description": "Timed character actions and poses compiled for rendering.",
"type": "object",
"required": ["version", "scenes"],
"properties": {
"version": { "type": "string", "const": "1.0" },
"fps": { "type": "number", "minimum": 1, "default": 30 },
"scenes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["scene_id", "start_seconds", "end_seconds", "actions"],
"properties": {
"scene_id": { "type": "string" },
"start_seconds": { "type": "number", "minimum": 0 },
"end_seconds": { "type": "number", "minimum": 0 },
"camera": { "type": "object" },
"background": { "type": "string" },
"effects": { "type": "array", "items": { "type": "string" } },
"actions": {
"type": "array",
"items": {
"type": "object",
"required": ["at_seconds", "character_id", "action"],
"properties": {
"at_seconds": { "type": "number", "minimum": 0 },
"duration_seconds": { "type": "number", "minimum": 0 },
"character_id": { "type": "string" },
"action": { "type": "string" },
"pose": { "type": "string" },
"emotion": { "type": "string" },
"target": { "type": "string" },
"easing": { "type": "string" },
"notes": { "type": "string" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"metadata": { "type": "object" }
},
"additionalProperties": false
}
@@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "openmontage/artifacts/character_design",
"title": "Character Design",
"description": "Character definitions for local rigged animation.",
"type": "object",
"required": ["version", "characters"],
"properties": {
"version": { "type": "string", "const": "1.0" },
"style": {
"type": "object",
"properties": {
"visual_style": { "type": "string" },
"palette": { "type": "array", "items": { "type": "string" } },
"line_style": { "type": "string" },
"texture": { "type": "string" }
},
"additionalProperties": false
},
"characters": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "role", "body_type", "style", "required_emotions", "required_actions"],
"properties": {
"id": { "type": "string" },
"display_name": { "type": "string" },
"role": { "type": "string" },
"body_type": { "type": "string" },
"style": { "type": "string" },
"silhouette_notes": { "type": "string" },
"required_emotions": { "type": "array", "items": { "type": "string" } },
"required_actions": { "type": "array", "items": { "type": "string" } },
"required_views": { "type": "array", "items": { "type": "string" } },
"props": { "type": "array", "items": { "type": "string" } },
"constraints": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
}
},
"metadata": { "type": "object" }
},
"additionalProperties": false
}
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "openmontage/artifacts/character_qa_report",
"title": "Character QA Report",
"description": "Structured review of character rig integrity, motion, and render readiness.",
"type": "object",
"required": ["version", "status", "checks"],
"properties": {
"version": { "type": "string", "const": "1.0" },
"status": { "type": "string", "enum": ["pass", "revise", "fail"] },
"preview_path": { "type": "string" },
"checks": {
"type": "object",
"properties": {
"schema_valid": { "type": "boolean" },
"assets_exist": { "type": "boolean" },
"pivots_defined": { "type": "boolean" },
"poses_defined": { "type": "boolean" },
"actions_timed": { "type": "boolean" },
"motion_detected": { "type": "boolean" },
"browser_preview_checked": { "type": "boolean" },
"frame_samples_checked": { "type": "boolean" }
},
"additionalProperties": false
},
"issues": { "type": "array", "items": { "type": "string" } },
"recommended_action": {
"type": "string",
"enum": ["present_to_user", "fix_rig", "fix_assets", "fix_timeline", "block"]
},
"metadata": { "type": "object" }
},
"additionalProperties": false
}
@@ -0,0 +1,41 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "openmontage/artifacts/pose_library",
"title": "Pose Library",
"description": "Named reusable poses, expressions, and transition hints for character rigs.",
"type": "object",
"required": ["version", "characters"],
"properties": {
"version": { "type": "string", "const": "1.0" },
"characters": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["character_id", "poses"],
"properties": {
"character_id": { "type": "string" },
"poses": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"description": { "type": "string" },
"parts": { "type": "object" },
"expression": { "type": "string" },
"hold_frames": { "type": "integer", "minimum": 0 },
"transition": { "type": "string" }
},
"additionalProperties": true
}
},
"mouth_shapes": { "type": "object" },
"action_cycles": { "type": "object" }
},
"additionalProperties": false
}
},
"metadata": { "type": "object" }
},
"additionalProperties": false
}
+59
View File
@@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "openmontage/artifacts/rig_plan",
"title": "Rig Plan",
"description": "Rig parts, pivots, layers, constraints, and views for character animation.",
"type": "object",
"required": ["version", "characters"],
"properties": {
"version": { "type": "string", "const": "1.0" },
"characters": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["character_id", "parts", "joints", "layers", "required_poses"],
"properties": {
"character_id": { "type": "string" },
"rig_type": { "type": "string", "enum": ["svg_rig", "canvas_procedural", "lottie", "hybrid"], "default": "svg_rig" },
"parts": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "kind", "layer"],
"properties": {
"id": { "type": "string" },
"kind": { "type": "string" },
"layer": { "type": "integer" },
"asset_path": { "type": "string" },
"parent": { "type": "string" }
},
"additionalProperties": false
}
},
"joints": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["pivot"],
"properties": {
"pivot": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"rotation": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"scale": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
},
"additionalProperties": false
}
},
"layers": { "type": "array", "items": { "type": "string" } },
"views": { "type": "array", "items": { "type": "string" } },
"required_poses": { "type": "array", "items": { "type": "string" } },
"required_actions": { "type": "array", "items": { "type": "string" } },
"risks": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
}
},
"metadata": { "type": "object" }
},
"additionalProperties": false
}
+22 -1
View File
@@ -17,7 +17,7 @@
"id": { "type": "string" },
"type": {
"type": "string",
"enum": ["talking_head", "broll", "animation", "diagram", "text_card", "transition", "generated", "screen_recording"]
"enum": ["talking_head", "broll", "animation", "character_scene", "diagram", "text_card", "transition", "generated", "screen_recording"]
},
"description": { "type": "string" },
"start_seconds": { "type": "number", "minimum": 0 },
@@ -68,6 +68,27 @@
"default": false,
"description": "True if this is the visual peak of the video — deserves extra attention"
},
"character_actions": {
"type": "array",
"description": "Character-specific acting beats for rigged character animation scenes",
"items": {
"type": "object",
"required": ["character_id", "action_sequence"],
"properties": {
"character_id": { "type": "string" },
"emotion": { "type": "string" },
"action_sequence": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
},
"dialogue": { "type": "string" },
"target": { "type": "string" },
"notes": { "type": "string" }
},
"additionalProperties": false
}
},
"texture_keywords": {
"type": "array",
"items": { "type": "string" },
+3 -1
View File
@@ -58,6 +58,7 @@ Key capability families to look for in the output:
| `audio_processing` | — | FFmpeg-based local tools |
| `enhancement` | — | Mixed providers |
| `analysis` | — | Mixed providers |
| `character_animation` | — | Local character specs, SVG rigs, pose libraries, action timelines, previews, and QA |
| `graphics` | — | Local rendering tools |
| `music_generation` | — | Single-provider |
| `subtitle` | — | Pure Python |
@@ -92,7 +93,7 @@ Key capability families to look for in the output:
| Enhancement Strategy | `creative/enhancement-strategy.md` | Overlay placement and density | `ffmpeg` |
| Data Visualization | `creative/data-visualization.md` | Chart type selection, animation, label placement | `d3-viz`, `remotion-best-practices` |
| Video Stitching | `creative/video-stitching.md` | Multi-clip assembly, AI clip chaining, spatial composition | `ffmpeg`, `video_toolkit` |
| Video Gen Prompting | `creative/video-gen-prompting.md` | Universal video generation prompt vocabulary | `ai-video-gen`, `ltx2`, `create-video` |
| Video Gen Prompting | `creative/video-gen-prompting.md` | Universal video generation prompt vocabulary; **canonical 5-aspect spec** (Subject / Motion / Scene / Spatial / Camera); ~200 cinematography primitives | `ai-video-gen`, `ltx2`, `create-video` |
| ↳ Seedance Prompting | `creative/prompting/seedance-prompting.md` | **Preferred premium default.** Seedance 2.0 8-component structure, multi-shot, lip-sync, reference-to-video | `seedance-2-0`, `ai-video-gen` |
| ↳ Grok Prompting | `creative/prompting/grok-prompting.md` | Grok image/video prompting, edit flows, reference-image video | `grok-media` |
| ↳ Sora Prompting | `creative/prompting/sora-prompting.md` | Sora 2 structured template, advanced fields | `ai-video-gen` |
@@ -127,6 +128,7 @@ Pipeline type skills provide production guidance for specific video formats, ind
| Long-Form | `creative/long-form.md` | YouTube 10+ min â€" chapters, retention, end screens |
| Screen Recording | `creative/screen-recording.md` | Code walkthroughs, tutorials, software demos |
| Animation Pipeline | `creative/animation-pipeline.md` | Motion graphics, easing, transitions, composition |
| Character Animation Pipeline | `pipelines/character-animation/` | Rigged local cartoon characters, pose libraries, action timelines, SVG/Canvas/Remotion/HyperFrames rendering |
| Cinematic | `creative/cinematic.md` | Letterbox, film pacing, layered audio, color grading |
## Pipeline Stage Director Skills
+60 -38
View File
@@ -288,54 +288,76 @@ Same pattern as Remotion.
Things the upstream docs don't warn about but will cost you a 60-minute
render to discover. Fix at author time, not at render time.
### Video elements need explicit size, in BOTH HTML attrs and `!important` CSS
### Full-frame background video: source-resolution trap (NOT a framework bug)
HyperFrames runtime applies inline `style="width:...px; height:...px"` to
every `<video>` element based on the video's **intrinsic** dimensions.
That inline style beats any class-selector CSS you write, so even a
`video.bg-video { width: 100%; height: 100% }` rule gets silently ignored
and the video renders at whatever size the decoded stream reports —
usually a small centered box inside an otherwise-black clip.
Symptom: background video renders as a small centered box with black
around it, even though you've told HyperFrames the clip is 1920×1080.
Six renders of debugging revealed this is **almost always a source
quality issue, not a HyperFrames framework bug.** Fix the input, not
the CSS.
The bug is invisible in some scenarios:
- Low `filter: brightness(0.25-0.35)` makes the small video box look
mostly black.
- Fullscreen typography layered on top hides the problem.
- `object-fit: cover` on a class selector does NOT fix it because the
inline width/height wins first.
Root cause (observed on Pexels + Pixabay stock): many free stock
clips ship at 640×360 or 960×540 even when you ask for the "large"
size tier. If your pre-transform does
`scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:...`
you'll produce a 1920×1080 file whose visible content is a centered
640×360 rectangle with black padding. HyperFrames plays this clip
faithfully — the small-video-in-black-frame you see is a correctly
rendered letterboxed input.
It becomes obvious the moment you go footage-forward (brightness > 0.5,
lower-third typography) — the small centered video box appears against
a black frame that was supposed to be full-bleed b-roll.
Diagnostic first: before blaming CSS, run
`ffprobe -v error -select_streams v -show_entries stream=width,height`
on the source clip and on your pre-transformed clip. If the pre-
transform already shows a 640×360 active region inside 1920×1080
padding, the problem is upstream.
**Correct pattern** for a full-frame background video:
Fix the pre-transform to scale-to-COVER and crop, not scale-to-FIT
and pad:
```
-vf "scale=1920:1080:force_original_aspect_ratio=increase,\
crop=1920:1080,unsharp=3:3:0.6"
```
This upscales the small clip to at least cover 1920×1080, crops the
overflow, and sharpens (unsharp) to offset perceived softness. The
output is full-bleed and HyperFrames will render it edge-to-edge.
The canonical wrapper-div pattern (`patterns.md`) is still the right
HTML shape for backgrounds:
```html
<video
class="clip bg-video"
data-start="0" data-duration="8" data-track-index="0"
src="assets/hero.mp4"
muted playsinline preload="metadata"
width="1920" height="1080"
></video>
<div style="position:absolute;top:0;left:0;width:1920px;height:1080px;overflow:hidden;">
<video
data-start="0" data-duration="60" data-track-index="0"
src="..." muted playsinline
style="width:100%;height:100%;object-fit:cover;"
></video>
</div>
```
```css
video.bg-video {
position: absolute !important; inset: 0 !important;
width: 100% !important; height: 100% !important;
object-fit: cover !important;
/* filter optional */
}
```
Use it because `overflow: hidden` on the wrapper crops gracefully when
the video's aspect differs slightly from 16:9, and because
`object-fit: cover` on the inner `<video>` handles the (rare) case
where source aspect isn't 16:9. It is **not** a workaround for a
framework layout bug — the framework will size `<video class="clip">`
correctly too if the source file has content filling the full frame.
Both the explicit `width="1920" height="1080"` HTML attrs AND the
`!important` CSS are required. Either one alone isn't enough.
Apply visual treatments (`filter`, `border-radius`, etc.) on the
wrapper or on a scoped selector like `.bg-slot video { filter: ... }`.
Alternative: wrap the `<video>` in a `<div class="clip">` and put
`data-start`/`data-duration` on the wrapper. Let the video fill its div
via `position: absolute; inset: 0` without the framework's inline-style
interference. Slightly more DOM but more robust.
Invisible-vs-obvious failure mode: this trap hides when you stack
dim filters (`brightness(0.250.35)`) or full-bleed typography on top —
the letterbox reads as "moody darkness." It becomes obvious the moment
you go footage-forward (brightness > 0.5, lower-third typography). If
you're planning footage-forward from the start, probe your source
resolutions in the asset stage.
When you truly can't source HD clips, switch pipelines: use FFmpeg
hybrid-compositing (scale-to-cover + crop + unsharp in an external
b-roll reel, then overlay HyperFrames typography via chromakey). That's
the pattern `projects/quantum-willow-multiverse/` settled on after six
HyperFrames renders with 640×360 Pexels sources.
### Always preview-scrub footage-forward scenes before render
+12 -7
View File
@@ -70,14 +70,18 @@ Fallback: AI-generated image of server racks
### Query Templates by Scene Type
| Scene Type | Query Template | Example |
Add a **POV keyword** to every query. Stock libraries (Pexels, Pixabay, Storyblocks, Artgrid) explicitly index POV terms — drone, aerial, OTS (over-the-shoulder), macro, top-down, dashcam, FPV, handheld, locked-off — and adding the POV often unlocks better matches than refining the subject. The CMU/Harvard CHAI taxonomy treats POV as a first-class Scene aspect for the same reason: it changes which library shelf you're searching.
| Scene Type | Query Template | Example with POV |
|-----------|---------------|---------|
| Establishing | `[place] [time of day]` | "tokyo skyline night" |
| Activity | `[person] [action]` | "scientist microscope" |
| Object | `[object] [style]` | "circuit board closeup" |
| Nature | `[element] [quality]` | "ocean waves aerial" |
| Abstract motion | `[movement] [style]` | "light trails timelapse" |
| Workplace | `[setting] [activity]` | "modern office meeting" |
| Establishing | `[place] [time of day] [POV]` | "tokyo skyline night drone" |
| Activity | `[person] [action] [POV]` | "scientist microscope OTS" |
| Object | `[object] [style] [POV]` | "circuit board macro top-down" |
| Nature | `[element] [quality] [POV]` | "ocean waves aerial drone" |
| Abstract motion | `[movement] [style] [POV]` | "light trails timelapse locked-off" |
| Workplace | `[setting] [activity] [POV]` | "modern office meeting handheld" |
If the scene description doesn't already imply a POV, ask the script/scene director — don't default to "no POV." A wrong-POV match (handheld when the scene needs drone) is harder to fix than a wrong color grade.
## Evaluating Stock Footage Quality
@@ -89,6 +93,7 @@ When the stock tool returns results, evaluate before using:
- **Style compatibility:** Doesn't clash with the playbook's visual style
- **No watermarks:** Pexels/Pixabay are license-free, but verify
- **Composition:** Subject is well-framed, not cut off awkwardly
- **POV match:** Does the footage's actual POV (drone, OTS, macro, handheld, locked-off, etc.) match what the scene needs? A wrong POV — e.g., handheld when the scene wants drone — is **more costly to fix than a wrong color grade**. Reject and re-query rather than try to crop your way out of it.
### Video Criteria (all image criteria plus)
- **Duration:** At least as long as the scene needs (can trim, can't extend)
+16
View File
@@ -3,6 +3,8 @@
> Sources: No Film School editorial guides, StudioBinder filmmaking resources, Film Riot
> production tutorials, CinematographyDB shot databases, Walter Murch "In the Blink of an Eye"
> For the universal cinematography vocabulary (camera, lens, motion, focus primitives, plus the 5-aspect Subject / Subject Motion / Scene / Spatial Framing / Camera spec), see `skills/creative/video-gen-prompting.md`. This file layers cinematic-specific conventions on top of those primitives — it does not redefine them.
## Quick Reference Card
```
@@ -16,6 +18,19 @@ MUSIC: 60-90 BPM, orchestral or ambient, dynamic (not loop-based)
TARGET LUFS: -14 LUFS integrated, -24 LUFS for quiet moments
```
## Replace Mood Adjectives with Visual Causes
> **"Cinematic" and "epic" don't constrain pixels.** The cinematic look comes from concrete choices: aspect ratio, lens, lighting key, color grade, shot duration, and audio layer count. State those — the rest is decoration.
>
> The CMU/Harvard CHAI study showed subjective phrasing varies wildly across annotators and model interpretations, which means a beat tagged "moody" routes to a different visual every render. Replace the adjective with the lighting + grade + shot-duration combination that produces moodiness. See `skills/creative/storytelling.md` "Anti-Subjective Rule" for the script-side equivalent.
>
> | Mood adjective | Cinematic translation |
> |---|---|
> | "epic" | 2.39:1 letterbox, 24fps, 8s+ shot duration, orchestral score with crescendo |
> | "moody" | `moody_dark` grade at 0.6, key light at 1/8 fill, 6s+ contemplative shots, ambient bed at -28dB |
> | "intimate" | 1.85:1, 40-50mm equivalent, shallow DoF, 2 audio layers (dialogue + room tone), no music under dialogue |
> | "cinematic" | (this word is banned — pick one of the above or describe the actual choices) |
## Aspect Ratios
| Ratio | Resolution (in 1080p frame) | Feel | When to Use |
@@ -113,6 +128,7 @@ Add a subtle ambient layer to fill silence and create depth:
- Highlights should be slightly rolled off (never pure white)
- Skin tones must stay on the vectorscope skin tone line
- Consistency across all clips — one LUT/profile for the entire video
- **If a beat is described as "moody," rewrite as the lighting + grade + shot-duration combination that produces moodiness.** Don't pass mood adjectives to the asset/edit stages.
## Applying to OpenMontage
+24 -5
View File
@@ -3,6 +3,8 @@
> Source: [Tencent Prompt Handbook](https://github.com/Tencent-Hunyuan/HunyuanVideo-1.5/blob/main/assets/HunyuanVideo_1_5_Prompt_Handbook_EN.md)
> For universal vocabulary, see: `skills/creative/video-gen-prompting.md`
**Word count:** Hunyuan 1.5 reads well at 80200 words; doesn't reward 400-word essays.
## HunyuanVideo Prompt Formula
### Text-to-Video
@@ -34,15 +36,28 @@ Describe lighting with multiple dimensions:
| Movement | Type | HunyuanVideo Prompt |
|----------|------|-------------------|
| Crane / Pedestal | Vertical | "camera rises vertically" |
| Truck / Tracking | Horizontal | "camera tracks left alongside subject" |
| Dolly In | Push | "camera pushes forward toward subject" |
| Dolly Out | Pull | "camera pulls back from subject" |
| Pan | Rotation | "camera pans right across the scene" |
| Crane / Pedestal | Translation (vertical) | "camera rises vertically" |
| Truck / Tracking | Translation (horizontal) | "camera tracks left alongside subject" |
| Dolly In | Translation (push) | "camera pushes forward toward subject" |
| Dolly Out | Translation (pull) | "camera pulls back from subject" |
| Pan | Rotation (yaw) | "camera pans right across the scene" |
| Tilt | Rotation (pitch) | "camera tilts upward to follow the rocket" |
| Roll | Rotation (Z-axis / Dutch) | "camera rolls clockwise into a Dutch tilt" |
| Orbit | Circular | "camera orbits around subject" |
| Follow | Lock-on | "camera follows subject from behind" |
| Zoom | Lens-only (focal length) | "camera slowly zooms in on the figure" |
| Rack focus | Lens-only (focal plane, snap) | "rack focus from the foreground bottle to the figure in the background" |
| Pull focus | Lens-only (focal plane, gradual) | "camera shifts focus from foreground X to background Y" |
| Static | Fixed | "static camera, no movement" |
### Focal-plane labels at start AND end of dynamic-DoF shots
Hunyuan benefits when both endpoints of focus-changing shots are stated. State where focus starts AND where it lands — don't leave one implicit.
Example: "shallow DoF; focus on the foreground bottle at start; focus pulls to the figure in the background by end."
Without both endpoints, Hunyuan often defaults to deep focus or holds on the wrong plane.
### Style Keywords
**Photorealistic / Cinematic**:
@@ -65,6 +80,10 @@ When using image-to-video, the input image defines appearance. Your prompt shoul
**Bad I2V prompt**: "A beautiful woman in a red dress standing in a forest" — this repeats what the image already shows.
### Order motions temporally
Describe motion in temporal order; if multiple movements occur, separate them ("first the camera pans right, then tilts upward"). Hunyuan executes motion in the order it appears in the prompt — bundling two movements into one clause causes one of them to be dropped or blended.
## Example (T2V)
```
+6 -1
View File
@@ -11,9 +11,13 @@ LTX-2 uses a clean, focused prompt structure:
2. **Set the scene** — lighting, color palette, textures, atmosphere
3. **Describe the action** — natural sequence flowing from beginning to end
4. **Define the character(s)** — physical cues (age, hair, clothes), not abstract labels
5. **Camera movement(s)** — specify how and when; describe what appears AFTER the movement
5. **Camera movement(s)** — specify how and when; describe what appears AFTER the movement. (LTX honors the translation/rotation/lens distinction: `dolly``zoom`, `pan``truck`. Pick the right family — translation moves the rig, rotation pivots it, lens-only changes focal length or focal plane without moving the camera.)
6. **Describe the audio** — ambient sound, music, speech, or singing
### Strict-Static-Shot rule
If you write "static camera," the shot must have NO movement, NO focus change, NO zoom. LTX takes "static" literally — adding any motion verb later in the prompt will either be ignored or will produce a glitch where the camera contradicts itself. Pick one: static, OR a single named movement.
## LTX-Specific Tips
### Post-Movement Description
@@ -51,6 +55,7 @@ LTX organizes styles into three families:
| Overloaded scenes | Many characters/actions reduces coherence |
| Conflicting lighting descriptions | Pick one setup, commit to it |
| Starting complex | Build up: simple prompt first, add layers |
| Prompts over ~80 words | LTX-2 degrades past that. Pick the most important 56 elements. |
## LTX Technical Notes
@@ -44,6 +44,8 @@ Seedance is unusually literal about camera language, multi-shot cuts, and quoted
## Multi-shot pattern
> **Repeat identity verbatim across every shot.** "the same character" / pronouns / "Aang again" do not work. Repeat the 36 disambiguating visual attributes verbatim in every shot block. Seedance treats each shot as if you said it cold.
Seedance honors explicit shot lists:
```
@@ -63,6 +65,22 @@ Style: anamorphic lens, teal-orange cinematic grade, 35mm film grain.
Audio: rising orchestral swell with low taiko pulse, wind, distant wingbeats.
```
### Subject transition primitives in multi-shot
Seedance handles four distinct ways a subject can enter or exit a shot. Naming the primitive explicitly helps the model build the right transition between shots.
- **Subject revealing** (by camera move OR subject move) — the subject becomes visible mid-shot.
Example: `Shot 2 (slow truck right): empty corridor at first; the camera trucks right to reveal Aang — bald, blue arrow tattoo, orange robes — pressed flat against the wall.`
- **Subject disappearing** — the subject leaves frame, by motion or occlusion.
Example: `Shot 4 (static wide): Aang — bald, blue arrow tattoo, orange robes — sprints into the temple doorway and is swallowed by shadow; camera holds on the empty threshold.`
- **Subject switching** (rack focus / camera move) — focus or framing transfers from one subject to another.
Example: `Shot 5 (close-up, rack focus): rack focus from Aang's glowing arrow tattoo in foreground to Sokka — dark hair, blue tunic, boomerang on back — emerging from the mist behind.`
- **Complex alternating focus** — focus oscillates between two subjects within one shot.
Example: `Shot 7 (medium two-shot, alternating rack focus): focus on Aang — bald, blue arrow tattoo, orange robes — as he speaks, then pulls to Katara — long brown hair, blue water-tribe parka — as she answers, then back to Aang on the final beat.`
## Lip-sync pattern
```
@@ -84,6 +102,7 @@ Sokka, half a step behind, replies: "Then we fight."
| `generate_audio` | Keep `true` — sync audio is the moat. Strip in compose if unused. |
| `model_variant` | `standard` for hero + multi-shot + camera-heavy. `fast` for b-roll, previews, latency-capped jobs. |
| `seed` | Lock once a shot composition reads; iterate variants with the same seed. |
| `prompt length` | 200400 words for hero shots; 80150 for inserts. Seedance is one of the few models that rewards long, structured 5-aspect prompts. |
## Iteration strategy
@@ -3,6 +3,8 @@
> Source: [OpenAI Sora 2 Cookbook](https://developers.openai.com/cookbook/examples/sora/sora2_prompting_guide)
> For universal vocabulary, see: `skills/creative/video-gen-prompting.md`
**Word count:** Sora 2 plateaus around 100250 words. Past 250, additional detail rarely improves output.
## Sora-Specific Prompt Template
Sora responds best to a structured format with prose + cinematography block + action beats:
@@ -40,6 +42,9 @@ Sora uniquely responds to these production-level details that most models ignore
| **Wardrobe** | "navy coat, sleeves rolled, suspenders loose" |
| **Finishing** | "fine-grain overlay, mild halation, gate weave, soft vignette" |
| **Shutter** | "180° shutter angle" |
| **Playback speed** | "speed ramp from 1x to 0.25x mid-shot", "stop-motion staccato", "time-reversed exhale" |
| **Lens distortion** | "fisheye barrel distortion at the edges", "subtle barrel curvature on straight lines" |
| **Focus mode** | "rack focus from foreground bottle to background figure", "deep focus, FG to BG sharp" |
## What Sora Does Differently
+18 -2
View File
@@ -3,6 +3,8 @@
> Source: [Vertex AI Video Gen Prompt Guide](https://cloud.google.com/vertex-ai/generative-ai/docs/video/video-gen-prompt-guide)
> For universal vocabulary, see: `skills/creative/video-gen-prompting.md`
**Word count:** VEO 3.1 sweet spot is 100250 words; longer prompts stop helping.
## VEO-Specific 14-Component Structure
VEO responds to the most comprehensive prompt structure of any model:
@@ -29,16 +31,30 @@ VEO responds to the most comprehensive prompt structure of any model:
- **Negative prompts**: Explicitly supported — "no text overlays, no watermarks, no lens flare"
- **Editing vocabulary**: Understands "match cut", "jump cut", "montage", "split diopter" as prompt terms.
### Camera vocabulary VEO honors literally
VEO 3.1 distinguishes the three camera-motion families and treats their tokens as separate primitives. Mixing them up (e.g. asking for a "zoom" when you mean a "dolly") will produce the wrong move.
- **Translation (rig physically moves):** `dolly` (in/out along the lens axis), `truck` (left/right laterally), `pedestal` (up/down vertically)
- **Rotation (rig stays put, camera rotates):** `pan` (yaw, left/right), `tilt` (pitch, up/down), `roll` (Dutch / Z-axis)
- **Lens-only (rig and body don't move):** `zoom` (focal length change), `rack focus` / `pull focus` / `focus tracking` (focal-plane change)
dolly ≠ zoom; pan ≠ truck. VEO follows whichever token leads.
## VEO Lens Effects (Unique)
VEO specifically responds to optical effects most models ignore:
| Effect | Prompt Language |
|--------|----------------|
| **Rack focus** | "rack focus from foreground flower to background figure" |
| **Rack focus** | "rack focus from foreground flower to background figure" (snap shift) |
| **Pull focus** | "slow pull focus from the candle in the foreground to the doorway behind" (gradual, slower than rack) |
| **Focus tracking** | "focus tracks the runner as she crosses frame; background stays soft" (focus follows a moving subject) |
| **Dolly zoom (vertigo)** | "vertigo effect as character realizes the truth" |
| **Fisheye** | "fisheye lens distortion, skatepark POV" |
| **Lens flare** | "anamorphic lens flare from setting sun" |
| **Anamorphic lens flare** | "anamorphic lens flare streaking horizontally from setting sun" |
These three focus modes (rack, pull, tracking) are different — VEO 3.1 honors the distinction per the paper.
## VEO Art Movement References
+40
View File
@@ -57,6 +57,32 @@ For a **3-minute explainer video** (scale proportionally for other lengths):
| 3 min | 3-5 | 8s | 22s | 100s | 30s | 15s |
| 5 min | 5-8 | 10s | 30s | 180s | 50s | 20s |
## Anti-Subjective Rule
> Hooks, beats, and section descriptions in OpenMontage scripts must describe the **visual cause** of the emotion, not the emotion itself. The CMU/Harvard CHAI study showed that subjective phrasing varies wildly across annotators and across model interpretations — so it does not constrain pixels and it doesn't reliably guide downstream generation tools.
>
> | Avoid | Use instead |
> |---|---|
> | "epic reveal" | "wide aerial pull-back; subject silhouetted against rising sun" |
> | "inspiring moment" | "low angle on the subject's face; light catches the edge of a tear" |
> | "moody atmosphere" | "low-key key light, lifted shadows by 2 stops, fog volumetrics" |
> | "powerful music swell" | "music drops out at 0:42, holds 1.5s of silence, returns with low taiko at half tempo" |
>
> The rule applies to script narration AND to the metadata fields scene-director consumes. For the universal vocabulary that names these visual primitives, see `skills/creative/video-gen-prompting.md`.
## Subject Transitions in the Script
When a script beat introduces a new subject, kills one off, or hands focus from one subject to another, **name the transition explicitly** so the scene-director doesn't have to infer it. The CMU/Harvard taxonomy uses four labels:
| Label | What it means |
|---|---|
| **revealing** | A new subject enters frame or is uncovered (door opens, camera pans to find them, fog clears). |
| **disappearing** | An existing subject leaves frame or is removed (walks out, fades, eclipsed). |
| **switching** | Focus jumps from subject A to subject B (cut, rack focus, camera whip). |
| **complex-alternating** | Multiple subjects trade focus repeatedly within a beat (debate cross-cutting, ensemble action). |
Add 1-2 sentences in the beat describing the mechanism (cut, pan, reveal-by-light, etc.). This propagates into the scene_plan as a transition primitive.
## Hook Types
| Type | Pattern | Best For |
@@ -114,6 +140,20 @@ Don't explain the answer. **Reconstruct the reasoning path** so the viewer feels
**Progressive Revelation:** Never show the full picture at once. Build visuals layer by layer.
Each layer arrives exactly when the narration references it.
## Camera Intent Per Beat
When writing a beat, attach one line of camera intent so the scene-director doesn't have to invent it from a blank slate. Use the universal vocabulary in `skills/creative/video-gen-prompting.md` (Subject / Subject Motion / Scene / Spatial Framing / Camera). One line is enough — the scene-director will expand it.
Example beat:
```
[0:30] Concept 1 — atoms aren't tiny planets
Narration: "We grew up imagining electrons as tiny planets orbiting the nucleus..."
Camera intent: medium shot of stylized atom; slow rotation; deep focus.
```
The camera-intent line is consumed verbatim by the scene-director's 5-aspect spec — keep it concrete, no mood adjectives.
## Pacing Rules
| Rule | Value | Source |
+151 -27
View File
@@ -24,18 +24,50 @@ For model-specific tips, see the linked guides below.
| **Kling 2.6** | [Kling Prompt Guide](https://fal.ai/learn/devs/kling-2-6-pro-prompt-guide) | 4-part structure. Supports `++emphasis++` syntax for key elements. |
| **Wan 2.1 / CogVideoX** | Use this generic guide | No official prompt guide. Standard cinematographic vocabulary works well. |
## Order Matters
When listing multiple subjects or events:
- **Temporal order** when events unfold over time ("First X enters, then Y reacts").
- **Prominence order** when temporal isn't relevant — humans before objects, largest/most-centered first, then secondary subjects.
## Self-Contained Prompt
> Write the prompt so that someone who has never seen the intended video could picture the subjects, scene, motion, and camera work from your text alone. If a reader could not picture it, a generation model will not render it.
## Universal Prompt Formula
All video generation models respond to this structure. Include what's relevant, omit what's not.
Prior work (CMU/Harvard, "Building a Precise Video Language with Human-AI Oversight") shows VLMs reliably describe subject + scene but fail on motion, spatial, and camera. **Forcing prompts to fill all five slots is the highest-leverage change.**
The OpenMontage canonical 5-aspect skeleton:
```
[Shot type/framing] + [Camera movement] + [Subject description] +
[Action/motion in beats] + [Setting/environment] + [Lighting] +
[Style/aesthetic] + [Audio/atmosphere]
[Subject] type + key visual attributes + how to disambiguate when multiple
[Subject Motion] actions in temporal order; subject↔object and subject↔subject interactions; group action
[Scene] overlays (separately!) + POV + setting + time of day + scene dynamics
[Spatial] shot size + position-in-frame + depth (FG/MG/BG) + camera-height-relative
— and how those CHANGE during the clip
[Camera] playback speed → lens distortion → height → angle → focus/DoF → steadiness → movement
```
**Shorter prompts = more creative freedom. Longer prompts = more control.**
### Prompt Length by Model
Empirical sweet spots from the paper's Section 6 findings — different models reward different prompt densities:
| Model | Sweet Spot | Notes |
|---|---|---|
| Seedance 2.0 | 200400 words for hero shots, 80150 for inserts | Reward long, structured 5-aspect prompts |
| Wan 2.2 | 200400 words | Fine-tuned on long captions |
| Sora 2 / VEO 3.1 | 100250 words | Plateau past ~250 |
| LTX-2 | ≤ 80 words | Degrades past that, keep tight |
| Runway Gen-4 | ≤ 60 words | "Focus on motion, not appearance" |
### Overlays Are Not Scene Depth
> Overlays (titles, HUD, subtitles, watermarks, framing graphics) are NOT part of the scene's foreground/midground/background depth axis. List them separately with content and placement. Never say "overlay in the foreground."
---
## Camera Shot Types
@@ -58,23 +90,56 @@ All video generation models respond to this structure. Include what's relevant,
## Camera Movements
| Movement | What It Does | Best For |
|----------|-------------|----------|
| **Static / fixed** | No movement | Dialogue, contemplation, stability |
| **Pan** (left/right) | Rotates horizontally | Revealing a scene, following action |
| **Tilt** (up/down) | Rotates vertically | Revealing height, slow reveal |
| **Dolly in / out** | Physically moves toward/away | Building tension, emphasis |
| **Truck** (left/right) | Moves sideways | Parallels subject movement |
| **Pedestal** (up/down) | Moves vertically | Smooth elevation changes |
| **Crane shot** | Sweeping vertical arcs | Epic reveals, transitions |
| **Tracking / follow** | Follows subject | Action sequences, walk-and-talk |
| **Arc shot** | Circles around subject | Dramatic emphasis, 360° reveal |
| **Zoom** (in/out) | Lens focal length change | Quick emphasis (cheaper than dolly) |
| **Whip pan** | Extremely fast pan (blurs) | Transitions, energy, surprise |
| **Handheld / shaky cam** | Unstable, human feel | Documentary, urgency, realism |
| **Aerial / drone** | High altitude, smooth | Landscapes, establishing shots |
| **Slow push-in** | Gradual forward movement | Building intimacy or tension |
| **Dolly zoom (vertigo)** | Dolly one way, zoom opposite | Disorientation, revelation |
The paper shows current models confuse translation, rotation, and lens-only changes — group your prompts so the model can't conflate them:
| Group | Primitives | Rule |
|---|---|---|
| **Translation** (camera physically moves) | dolly in/out, truck left/right, pedestal up/down | "dolly forward toward subject" |
| **Rotation** (camera pivots in place) | pan left/right, tilt up/down, roll CW/CCW | "pan right across the room" |
| **Lens-only** (no camera move) | zoom in/out, rack focus, pull focus, focus tracking | "zoom in" ≠ "dolly in" |
| **Hybrid / signature** | dolly zoom (vertigo), arc/orbit, crane, whip pan, tracking/follow, handheld | "vertigo" only at moments of revelation |
| **Stillness states** | static (NO movement at all — strict), micro-shake, locked-off | "static" requires zero movement, focus change, or zoom |
> **dolly ≠ zoom.** dolly is camera translation; zoom is focal-length change. Models follow whichever token dominates. **pan ≠ truck.** pan rotates, truck translates laterally.
> **Static shot is strict.** A static shot has zero movement, zero focus change, zero zoom. If any of those occur, do NOT write "static camera" — pick the right movement primitive.
## Camera Height (relative to ground)
| Primitive | Example |
|---|---|
| Aerial-level | "drone-altitude wide of the city" |
| Overhead-level | "rooftop height looking across the street" |
| Eye-level | "framed at eye level" |
| Hip-level | "hip-height tracking shot" |
| Ground-level | "low to the ground, ankle height" |
| Water-level | "skimming the water surface" |
| Underwater | "submerged below the surface" |
## Camera Angle (relative to subject)
| Primitive | Definition |
|---|---|
| **Bird's-eye** | strict top-down. Not the same as aerial. |
| High angle | looking down on subject |
| Level angle | camera and subject at same height |
| Low angle | looking up at subject |
| Worm's-eye | looking straight up |
| **Dutch angle (fixed)** | tilted horizon held steady |
| **Dutch angle (rolling)** | horizon tilt changes during shot |
> **bird's-eye = strict top-down. aerial = altitude.** A drone shot at 45° looking down is a high angle from aerial height, NOT bird's-eye.
## Point of View (POV)
| POV | Example |
|---|---|
| First-person | "the camera follows the character's viewpoint as they walk" |
| Drone | "aerial drone footage of city skyline" |
| Over-the-shoulder | "OTS framing of the laptop screen" |
| Top-down oblique | "top-down view of the chess board, tilted slightly" |
| Dashcam | "vehicle dashcam framing of the road" |
| Objective / Neutral | (default — use when no specific POV) |
## Lighting Vocabulary
@@ -100,14 +165,51 @@ All video generation models respond to this structure. Include what's relevant,
| Effect | Result |
|--------|--------|
| **Shallow depth of field** | Subject sharp, background bokeh |
| **Deep focus** | Everything sharp, foreground to background |
| **Wide-angle lens** (24-35mm) | Broader view, exaggerated perspective |
| **Telephoto** (85mm+) | Compressed perspective, subject isolation |
| **Anamorphic** | Stretched aspect, signature lens flares |
| **Lens flare** | Streaks from bright light hitting lens |
| **Rack focus** | Shift focus between subjects in-shot |
| **Fisheye** | Ultra-wide, barrel distortion |
### Lens Distortion
The paper distinguishes two primitives that models honor as separate effects — they are NOT interchangeable:
| Primitive | Effect |
|---|---|
| **Fisheye** | extreme curvature, edges bent strongly outward |
| **Barrel** | mild distortion, straight lines bow slightly outward |
### Focus / Depth of Field
| Primitive | Definition |
|---|---|
| Deep focus | everything sharp, FG to BG |
| Shallow DoF | subject sharp, background bokeh |
| Extremely shallow DoF | razor-thin focal plane |
| Rack focus | shifts focus between two subjects mid-shot |
| Pull focus | gradual focus shift (slower than rack) |
| Focus tracking | focus follows a moving subject |
When DoF changes during a shot, label start AND end focal plane (FG/MG/BG/out-of-focus).
## Subject Transitions
When subjects enter, leave, or hand off focus, name the transition explicitly:
| Primitive | When |
|---|---|
| **Subject revealing** | a new subject enters frame (by subject movement OR camera movement) |
| **Subject disappearing** | a subject exits frame |
| **Subject switching** | focus shifts from one subject to another (often via rack focus or camera move) |
| **Complex alternating** | subjects alternate focus multiple times |
Always name the cause: "by subject movement" or "by camera movement". This unlocks reveal-style camerawork in multi-shot prompts.
## Identity Anchoring for Multi-Shot Prompts
> Models lose character identity across cuts unless you re-state it. In every shot of a multi-shot prompt, repeat the same 36 disambiguating visual attributes for each named subject verbatim. Pronouns and "the same character" do not work.
>
> Example: "Aang — bald, blue arrow tattoo on forehead, orange-and-yellow robes — plants his staff. … Aang — bald, blue arrow tattoo on forehead, orange-and-yellow robes — turns to camera."
## Style & Aesthetic References
@@ -137,10 +239,23 @@ All video generation models respond to this structure. Include what's relevant,
## Temporal Effects
### Playback Speed
The paper defines six explicit playback-speed primitives. Use the right one — they're not synonymous:
| Primitive | Definition |
|---|---|
| Time-lapse | events significantly faster than real time (clouds racing) |
| Fast-motion | slightly faster than real (1x3x) |
| Slow-motion | slower than real |
| Stop-motion | frame-by-frame discrete movements |
| Speed-ramp | mix of fast and slow within the same shot |
| Time-reversed | plays in reverse |
### Other Temporal Devices
| Effect | Use |
|--------|-----|
| **Slow motion** | Emphasis, beauty, impact |
| **Time-lapse** | Passage of time, processes |
| **Freeze-frame** | Dramatic pause |
| **Rapid cuts** | Energy, urgency |
| **Continuous / long take** | Immersion, tension |
@@ -160,6 +275,15 @@ Put dialogue in quotation marks: `Character says: "Hello world."`
## What to Avoid
> **Replace emotional adjectives with the visual cause of the emotion.**
> - "sad character" → "tears on cheek, shoulders slumped, staring at empty chair"
> - "cinematic mood" → "low-key Rembrandt key + 35mm anamorphic + crushed shadows, lifted-by-2-stops shadow detail"
> - "epic" → "low-angle, 24mm wide, sun directly behind subject, lens flare on the rim"
>
> "Inspiring," "powerful," "moody," "epic" do not constrain pixels.
> **Static shot is strict.** A static shot has zero movement, zero focus change, zero zoom. If any of those occur, do NOT write "static camera" — pick the right movement primitive.
| Don't | Why | Do Instead |
|-------|-----|-----------|
| "Beautiful scene" | Too vague, no visual info | "Wet cobblestone street, warm streetlamp glow reflecting in puddles" |
+16 -3
View File
@@ -6,6 +6,18 @@ After completing any pipeline stage's work — before checkpointing. You are the
Every stage gets reviewed. No exceptions. The review quality determines whether the final video is worth watching.
## Critique Quality (CHAI Rules)
> Findings ≠ critiques. A finding identifies a problem; a critique tells the next stage how to fix it. The CMU/Harvard CHAI study ("Building a Precise Video Language with Human-AI Oversight", arXiv 2604.21718v2) showed that critique quality, measured on three axes, directly governs downstream output quality. Apply all three to every reviewer pass.
>
> **Accurate.** Every finding must reference a concrete artifact field, line number, or visible asset frame. Forbid hallucinated criticism — if you cannot point to where the problem is, you are guessing.
>
> **Complete.** A reviewer pass that catches one mistake while missing a second is worse than scoring "needs another pass" and continuing. If you find one critical issue, scan for the rest of the same class before returning. Pattern-match: where else in this artifact could the same mistake be hiding?
>
> **Constructive.** Every "critical" finding MUST propose a concrete fix, not just identify the problem. "Caption is wrong" → "Caption says 'man on the right'; the man is on the left of the frame. Replace with 'the man on the left of the frame.'" If you cannot propose a fix, label the finding as "investigation" not "critical."
>
> Removing any of these three properties measurably hurts pipeline output. The reviewer is the choke point — be rigorous.
## Protocol
### Step 1: Load Review Context
@@ -27,9 +39,10 @@ First, the non-negotiable check:
For each `review_focus` item from the manifest:
1. Evaluate the artifact against this specific criterion
2. Assign a severity:
- **critical** — Must fix before proceeding. The artifact is broken, incomplete, or dangerously wrong.
- **suggestion** — Should fix. Improves quality significantly but doesn't block progress.
- **nitpick** — Could fix. Minor polish that's nice-to-have.
- **critical** — Must fix before proceeding. The artifact is broken, incomplete, or dangerously wrong. **Per CHAI rules, every critical finding MUST carry a `proposed_fix` (concrete replacement text, exact field value, or specific corrective action). A critical finding without a proposed fix is downgraded to `investigation`.**
- **suggestion** — Should fix. Improves quality significantly but doesn't block progress. **Suggestions MUST carry a `proposed_change` describing how to improve.**
- **nitpick** — Could fix. Minor polish that's nice-to-have. May stand alone without a proposed change.
- **investigation** — A real concern but you cannot pinpoint the fix. Surface it for the next round; do not block on it.
3. Write a specific, actionable finding (not vague)
**Good finding:** "Section 3 narration is 180 words for a 10-second window — that's 1080 wpm, impossible to speak. Cut to 25 words."
+28 -1
View File
@@ -38,7 +38,8 @@ video_analyzer.execute({
```
Read the resulting VideoAnalysisBrief. Before proceeding, present a summary to the
user. This is NOT a raw dump. It's a conversational interpretation:
user. This is NOT a raw dump. It's a conversational interpretation, and it MUST be
structured by the 5 aspects so downstream stages can lift fields directly:
```
"I've watched the video. Here's what I see:
@@ -48,12 +49,22 @@ user. This is NOT a raw dump. It's a conversational interpretation:
**Structure:** [X scenes over Y seconds, pacing style]
**Motion:** [N of M scenes are motion clips / animated stills / static images.
This video uses [AI-generated video clips / still images with pan-zoom / a mix].]
**5-aspect breakdown (per shot or per shot-group):**
- Subject: [type, count, attributes; subject transitions across shots: revealing / disappearing / switching / complex-alternating; or N/A]
- Subject Motion: [actions in temporal order; interactions; or N/A]
- Scene: [overlays (text/graphics) listed separately; POV (drone/OTS/macro/etc.); setting; time of day; dynamics]
- Spatial Framing: [shot size; subject position; depth; height-relative; how it changes]
- Camera: [playback speed; lens; height; angle; focus/DoF; steadiness; movement]
**What makes it work:** [2-3 specific things — the hook technique, the pacing,
the visual transitions, the narration style]
Now let me check what I can do with your current setup..."
```
The 5-aspect block above is the **canonical form** that `proposal-director`, `script-director`, and `scene-director` will read. Do not collapse it back into prose — keep the labels.
**Motion classification is critical.** The VideoAnalysisBrief now includes per-scene
`motion_type` ("motion_clip", "animated_still", "static_image") and `flow_variance`.
Use this to determine the production approach:
@@ -81,6 +92,22 @@ Update the brief's `content_analysis`, `style_profile`, and `replication_guidanc
fields with your visual observations. This is where the analysis becomes truly
comprehensive — the tools provide structure; your vision provides understanding.
### 5-Aspect Structured Output (MANDATORY)
The analyst's report MUST break down the reference video into the **five aspects** from the CMU/Harvard CHAI study (also the canonical structure used in `skills/creative/video-gen-prompting.md`). A narrative-only summary is no longer sufficient — downstream stages (proposal, script, scene-director) ingest the 5-aspect form directly without re-parsing prose.
**Decision-tree captioning policy.** For each detected shot, walk all five aspects in order:
> - **Subject:** type, attributes (count, age, role, costume, distinguishing features), multiple-subject disambiguation, transitions across shots (revealing / disappearing / switching / complex-alternating).
> - **Subject Motion:** actions in temporal order; group/interaction patterns (parallel, sequential, reactive); locomotion vs gesture vs facial.
> - **Scene:** **overlays separately** (text, lower thirds, graphics, watermark — call these out as their own layer, do not merge into setting) + POV (drone, aerial, OTS, macro, top-down, dashcam, FPV, handheld, locked-off) + setting + time of day + dynamics (weather, particles, crowd movement).
> - **Spatial Framing:** shot size (ECU/CU/MS/WS/EWS), subject position in frame, depth (foreground/midground/background usage), height-relative (above/at/below subject) — and how each of these **changes** across the shot if the camera or subject moves.
> - **Camera:** playback speed (real-time / slow-mo / time-lapse), lens distortion (anamorphic, fish-eye, tilt-shift), height (ground / eye / overhead), angle (high / low / Dutch), focus / DoF (rack focus, deep focus, shallow), steadiness (locked / handheld / gimbal), movement (push / pull / pan / tilt / dolly / truck / crane / orbit).
>
> **Mark any aspect explicitly as N/A** if it doesn't apply (e.g., "Subject: N/A — pure scenery shot," or "Scene overlays: N/A — no graphics"). **Silent omission is the most common analyst failure** and produces ambiguous downstream prompts.
See `skills/creative/video-gen-prompting.md` for primitive definitions and the canonical vocabulary used at every aspect.
### Step 2: Capability Audit
Run standard preflight:
+17 -1
View File
@@ -85,9 +85,25 @@ Recommended metadata keys:
- `tool_path_map`
- `reusable_motifs`
### 5. Quality Gate
### 5. 5-Aspect Scene-Plan Checklist
> Every scene must specify all five aspects, BUT the load shifts with the scene's `animation_mode`. Manim and other diagrammatic/programmatic scenes care most about **Subject** and **Spatial Framing** — Camera and Subject Motion in the cinematographic sense often map to N/A or to abstract equivalents. AI-video / `image_animation` / `anime_scene` scenes care about all five and behave like cinematic shots. Marking an aspect as N/A is allowed but must be explicit per scene; silent omission is forbidden.
>
> 1. **Subject** — type + key visual attributes; for Manim, the equation/object/graph being foregrounded; for `anime_scene`, the character or environment in focus.
> 2. **Subject Motion** — for Manim, the order of `Create`/`Transform`/`FadeIn` and what each animation conveys; for AI-video, the actions and interactions in temporal order.
> 3. **Scene** — overlays (separately!) + POV + setting + time of day + scene dynamics. For Manim, "setting" is the canvas background + axis style; for `anime_scene`, the environment + lighting gradient.
> 4. **Spatial Framing** — shot size + position-in-frame + depth (FG/MG/BG) + camera-height-relative; and how those CHANGE. Manim cares about layout grid + element positions; AI-video cares about full cinematographic framing.
> 5. **Camera** — playback speed → lens distortion → height → angle → focus/DoF → steadiness → movement. For Manim and pure motion-graphics, default to N/A unless using a virtual camera move (`MoveCamera`, `self.frame`). For `anime_scene` and AI-video, specify fully.
>
> Tie this back to `animation_mode` in scene metadata: a Manim scene that lists Camera fully is over-specified; an AI-video scene that omits Camera is under-specified. See `skills/creative/video-gen-prompting.md` for the primitive vocabulary.
> **Overlays callout.** Overlays (titles, subtitles, HUD, watermarks, framing graphics, lower-thirds, `hero_title`, `section_title`, `provider_chip`) are NOT part of the scene's foreground/midground/background depth axis. List them separately in scene metadata (`overlays: [...]`) with content and placement. Never describe an overlay as "in the foreground" — that confuses both downstream tools and any video-understanding model that re-analyzes the output.
### 6. Quality Gate
- every scene has a clear timing intent,
- the 5-aspect checklist is satisfied for the scene's `animation_mode` (with explicit N/A where appropriate),
- overlays live under `overlays:`, never inside the framing description,
- the transition system is limited and meaningful,
- the tool path is explicit,
- the sequence feels like one designed system.
@@ -0,0 +1,57 @@
# Asset Director - Character Animation Pipeline
## Goal
Produce `asset_manifest` with character parts, backgrounds, props, audio, music,
and preview artifacts.
## Layer 3 Gate
Before authoring or generating animation assets, read the relevant Layer 3 skills:
- `character-rigging`
- `svg-character-animation`
- `pose-library-design`
- `canvas-procedural-animation` when p5/canvas effects are used
- `character-animation-qa` before review
- `gsap-core`, `gsap-timeline`, and `gsap-react` for GSAP/Remotion work
- `remotion` and `remotion-best-practices` for Remotion render work
- `hyperframes` and `hyperframes-cli` for HyperFrames work
Before image/TTS/music generation, read the tool's `agent_skills` from the
registry.
## Asset Organization
Write character assets under:
```text
projects/<project-name>/assets/characters/<character-id>/
```
Use subfolders:
```text
parts/
poses/
previews/
```
Generated backgrounds go under:
```text
projects/<project-name>/assets/backgrounds/
```
## Process
1. Produce or source only the parts required by `rig_plan`.
2. Keep each moving part separate.
3. Preserve transparent backgrounds for parts.
4. Record prompts, seeds, providers, and model names.
5. Build a small preview before full asset expansion.
## Quality Bar
All parts referenced by `rig_plan` must exist before compose. Missing parts are a
blocker unless the action timeline removes the action requiring them.
@@ -0,0 +1,32 @@
# Character Design Director - Character Animation Pipeline
## Goal
Produce `character_design`: a small cast with clear silhouettes, roles,
emotions, actions, and style anchors.
## Process
1. List every character with `id`, role, body type, and style.
2. Identify the minimum emotional range needed by the story.
3. Identify the minimum action list needed by the story.
4. Decide required views: front, 3/4, side, back. Keep MVPs to one or two views.
5. Note props attached to characters, such as scarf, feather, bag, glasses.
## Constraints
- One or two characters is the MVP sweet spot.
- Animal characters need species-specific parts and action cycles.
- More views multiply asset and pose requirements.
- Do not invent more poses than the approved duration can use.
## Tool Use
Use `character_spec_generator` for structured drafts. Use `image_selector` only
after the visual style and character sheet requirements are explicit. Before
using image generation, read the tool's Layer 3 skills from the registry.
## Quality Bar
A character design is ready only when an animator or tool can infer what parts,
expressions, and actions must exist.
@@ -0,0 +1,49 @@
# Compose Director - Character Animation Pipeline
## Goal
Render the approved character animation and prove it was reviewed.
## Runtime Routing
First read `edit_decisions.render_runtime`. It must match the runtime locked in
proposal unless a `render_runtime_selection` decision explicitly changed it.
- `remotion`: stage assets into `remotion-composer/public`, build composition
JSON, render via `video_compose`.
- `hyperframes`: materialize a HyperFrames workspace and let `video_compose`
delegate to `hyperframes_compose`. `hyperframes lint` and `validate` must pass.
- `ffmpeg`: only for post-processing or simple video assembly; not enough for
character acting by itself.
## Review Workflow
1. Run `character_rig_renderer` to produce or refresh the HyperFrames package.
The browser preview is a QA/debug artifact only, not the render path.
2. Verify the renderer emitted a HyperFrames `workspace_path`, composition HTML,
`asset_manifest`, and `edit_decisions.render_runtime: "hyperframes"` handoff.
3. Run `character_animation_reviewer` against rig, poses, timeline, and preview.
4. Render final video through `video_compose` using the renderer handoff or the
approved Remotion/HyperFrames package. The deliverable path is
`projects/<project-name>/renders/final.mp4`, matching the standard
OpenMontage project convention.
5. Run standard `final_review`: ffprobe, frame sampling, visual spotcheck, audio
spotcheck, promise preservation.
## Browser QA
When Playwright is available:
- open the preview,
- capture opening/middle/end frames,
- check for console errors,
- verify characters are visible,
- compare frame deltas to ensure motion exists.
When Playwright is unavailable, use static artifact checks and FFmpeg frame
sampling, and report the reduced confidence.
## Quality Bar
Do not present the output as complete when `character_qa_report.status` is
`revise` or `fail`.
@@ -0,0 +1,33 @@
# Edit Director - Character Animation Pipeline
## Goal
Produce `edit_decisions` and `action_timeline`.
## Process
1. Carry `render_runtime` forward from the approved proposal.
2. Convert scene beats into timed character actions.
3. Add anticipation, hold, action, and follow-through where appropriate.
4. Align mouth/gesture beats to dialogue or music.
5. Keep action density readable.
## Timing Pattern
Most acting beats need:
```text
anticipation -> action -> hold/reaction -> settle
```
Do not animate everything continuously. Holds are part of acting.
## Tool Use
Use `action_timeline_compiler` for a first pass, then revise the timeline if the
acting or rhythm is weak.
## Quality Bar
Every scene has timed actions. Every action maps to a pose, action cycle, or
procedural effect that the renderer can understand.
@@ -0,0 +1,48 @@
# Executive Producer - Character Animation Pipeline
## When To Use
Use this pipeline when the requested deliverable depends on reusable animated
characters: cartoon shorts, mascot explainers, music-led character scenes,
dialogue between simple characters, or reference-inspired local animation.
Do not use this pipeline for one-off motion graphics with no acting. Route those
to `animation`. Do not use it for avatar presenter lip-sync. Route that to
`avatar-spokesperson`.
## Contract
The pipeline produces local, deterministic character animation. It does not
silently substitute still-image motion for acting. If the character motion cannot
be built with the available rigs, assets, or runtime, surface a blocker.
## Stage Order
1. `research` - understand reference, technique, and feasibility.
2. `proposal` - present concepts, runtime options, cost, music plan, sample plan.
3. `script` - write action-friendly beats and dialogue/narration.
4. `character_design` - define characters, silhouettes, emotions, actions.
5. `rig_plan` - define parts, pivots, layers, constraints, poses.
6. `scene_plan` - map story beats to character scenes.
7. `assets` - produce or source character parts, backgrounds, props, audio.
8. `edit` - compile timed action timeline.
9. `compose` - render through the approved runtime and run QA.
10. `publish` - package the final output.
## Governance Rules
- Run registry preflight before proposal.
- If both Remotion and HyperFrames are available, present both before locking
`render_runtime`.
- Produce a 10-15 second sample before full asset generation.
- Character differences belong in rig data, not one-off code paths.
- Every generated or runtime-authored asset must list Layer 3 skills read.
- Use `character_animation_reviewer` plus final `final_review` before delivery.
## Send-Back Triggers
- `character_design` lacks required actions or emotional range.
- `rig_plan` lacks pivots for moving parts.
- `pose_library` has no readable acting poses.
- `action_timeline` has actions that cannot be rendered by the rig.
- Compose used a runtime not approved in proposal.
@@ -0,0 +1,58 @@
# Proposal Director - Character Animation Pipeline
## Goal
Present character-animation concepts that are honest about local rigged motion,
reuse, cost, and runtime choice.
## Required Proposal Elements
Each option must include:
- characters and roles,
- visual style,
- action complexity,
- rig reuse strategy,
- sample plan,
- audio architecture,
- music plan,
- render runtime options,
- cost estimate,
- honest limitation note.
## Runtime Selection
Read `skills/meta/animation-runtime-selector.md` before recommending a runtime.
When both Remotion and HyperFrames are available:
- Remotion: best when the final composition needs deterministic React-rendered
video, captions, audio, scene JSON, and final MP4 governance.
- HyperFrames: best when the character scene is HTML/SVG/GSAP-heavy and benefits
from web-native authoring, lint, validate, and registry blocks.
- FFmpeg: post-processing only. Do not pick FFmpeg as the primary runtime for
character acting.
Wait for user approval before locking `render_runtime`.
## Sample-First Rule
Before full production, propose a 10-15 second sample containing:
- one main character,
- one expression change,
- one body action,
- one camera/background treatment,
- one audio/music cue if relevant.
Do not batch-generate all assets until this sample is approved.
## Cost Honesty
Local rigging is cheap at render time but expensive in authoring complexity.
Report the difference:
- asset generation cost,
- TTS/music cost,
- local render cost,
- manual complexity risk.
@@ -0,0 +1,26 @@
# Publish Director - Character Animation Pipeline
## Goal
Package the final character-animation deliverable with honest metadata and a
strong character-forward thumbnail concept.
## Requirements
- Mention the actual visual treatment: local rigged character animation,
procedural effects, Remotion/HyperFrames render, or mixed.
- Pick a poster frame where the main character's emotion is readable.
- If the output is a sample, label it as a sample.
- If the final is inspired by a reference, describe the inspiration without
claiming duplication.
## Output
Produce `publish_log` with:
- final video path,
- thumbnail/poster-frame notes,
- title ideas,
- description,
- platform-specific export notes,
- limitations or follow-up recommendations.
@@ -0,0 +1,45 @@
# Research Director - Character Animation Pipeline
## Goal
Ground the character-animation plan in real references and current technique.
For reference videos, start from `video_analysis_brief`: content, pacing, motion
classification, keyframes, color, and production complexity.
## Process
1. Identify what the reference actually uses:
- rigged local animation,
- frame-by-frame traditional animation,
- video generation,
- still-image motion,
- mixed techniques.
2. Research 3-5 relevant examples or techniques.
3. Separate what the pipeline can reproduce locally from what requires manual
illustration, video generation, or a larger asset library.
4. Record reusable animation primitives:
- walk cycle,
- blink,
- head turn,
- reach,
- wing flap,
- squash/stretch,
- camera pan/parallax,
- particles/weather.
## Output Guidance
The `research_brief` should include:
- `character_animation_fit`: high/medium/low,
- `reference_motion_type`,
- `required_character_actions`,
- `rig_complexity`,
- `manual_asset_risks`,
- `local_runtime_candidates`.
## Quality Bar
Be explicit when a reference is hand-drawn or frame-by-frame. The user can still
choose an inspired local rigged style, but the proposal must not imply exact
traditional-animation quality from an automatic rig.
@@ -0,0 +1,40 @@
# Rig Plan Director - Character Animation Pipeline
## Goal
Produce `rig_plan` and `pose_library` from `character_design`.
## Process
1. Convert each character into rig parts:
- body,
- head,
- eyes/pupils,
- brows,
- mouth shapes,
- limbs/wings,
- tail/accessories,
- props.
2. Define pivots for every moving part.
3. Define layer order.
4. Define constraints so limbs do not rotate into impossible positions.
5. Define named poses for the approved scenes.
6. Define action cycles only when reused at least twice or central to the story.
## Runtime Pattern
Character differences are data. The renderer should not need one-off code for a
mouse versus a bird. A bird may have `wing_left`; a mouse may have `tail`, but
both feed the same pose interpolation and timeline compiler.
## Quality Checks
- Every moving part has a pivot.
- Every required action has poses or a procedural strategy.
- Every pose names the changed parts.
- Risky actions are called out, not hidden.
## Tool Use
Use `svg_rig_builder` to draft rig data and `pose_library_builder` to draft the
initial pose library. The agent may revise their output before checkpointing.
@@ -0,0 +1,37 @@
# Scene Director - Character Animation Pipeline
## Goal
Produce a `scene_plan` where each scene is feasible for rigged character
animation.
## Scene Planning Fields
For each scene, include:
- character IDs,
- emotional beat,
- action sequence,
- camera/framing,
- background,
- props,
- effects,
- required assets,
- transition notes.
Use `type: "character_scene"` for rigged character acting scenes. Store
character-specific detail in `character_actions`; do not put per-scene acting
data in arbitrary metadata because the shared `scene_plan` schema rejects
unknown per-scene fields.
## Complexity Budget
Prefer fewer, stronger shots:
- one establish,
- one action beat,
- one reaction beat,
- one resolution beat.
Avoid scenes that require many unique views or complex physical contact unless
the user approved that complexity.
@@ -0,0 +1,37 @@
# Script Director - Character Animation Pipeline
## Goal
Write scripts as performable animation beats, not just narration.
## Process
1. Lock audio architecture:
- music-only,
- narrator,
- character dialogue,
- narrator plus character sounds/dialogue.
2. Break the story into beats that can be acted with poses.
3. For each beat, state what changes visually:
- emotion,
- gaze,
- body pose,
- prop interaction,
- camera,
- environment.
## Writing Rules
- Prefer short visual beats with readable holds.
- Avoid action that needs many unique hand-drawn poses unless approved.
- Dialogue should be short enough for mouth-shape approximation.
- Silent/music-led scenes need stronger physical acting notes.
## Output Notes
In the `script` artifact metadata, include:
- `audio_architecture`,
- `character_beats`,
- `required_emotions`,
- `required_actions`.
@@ -101,6 +101,22 @@ Recommended metadata keys:
- `generated_support_assets`
- `rights_notes`
### Pre/Post Self-Review for Generation Prompts
> Before sending a prompt to any image or video generation tool, run a three-step self-review modeled on the CHAI oversight loop ("Building a Precise Video Language with Human-AI Oversight", arXiv 2604.21718v2). Cost is small (no extra tool calls); benefit is large (avoids wasted generations). For cinematic, this matters most for **hero-frame prompts** — one bad hero frame ruins the piece, and hero frames are the most expensive shots to regenerate.
>
> **Step 1 — Pre-caption pass.** Write the prompt the way you'd write it today. Do not over-edit; aim for a complete first draft.
>
> **Step 2 — Critique pass.** Score the draft against the 5-aspect checklist (Subject / Subject Motion / Scene / Spatial Framing / Camera). For each aspect:
> - Is it specified? If not, is the omission deliberate (e.g., "no subject — scenery shot") or accidental?
> - Are confusable terms disambiguated? (dolly vs zoom, pan vs truck, bird's-eye vs aerial, fisheye vs barrel, full shot vs close-up)
> - Are emotional adjectives ("epic", "moody", "cinematic") replaced with their visual causes (low-key lighting, slow push-in, anamorphic flare, deep shadows)?
> - For multi-shot prompts and identity-anchored hero frames: is identity anchored verbatim across shots?
>
> **Step 3 — Post-caption pass.** Rewrite filling the missing aspects, fixing confusable terms, and replacing subjective language. The post-caption is what gets sent to the generation tool.
>
> Log the (pre, critique, post) triplet in the asset metadata for traceability. This mirrors the CHAI workflow and creates a record the reviewer can audit.
### 5. Quality Gate
- source and support assets are clearly distinguished,
+17 -2
View File
@@ -49,11 +49,26 @@ Recommended metadata keys:
- `title_card_rules`
- `support_insert_rules`
### 5. Quality Gate
### 5. 5-Aspect Scene-Plan Checklist
> Every scene beat — and especially every hero frame — must specify all five aspects. Cinematic relies on a small number of memorable frames; vague hero-frame specs are the single most common failure mode and produce unpredictable model output. Marking an aspect as N/A is allowed but must be explicit (e.g., "no subject — establishing scenery shot"). Silent omission is forbidden.
>
> 1. **Subject** — type + key visual attributes; if multiple, how to disambiguate. For hero frames, identity must be anchored verbatim across shots.
> 2. **Subject Motion** — actions in temporal order; subject↔object / subject↔subject interactions.
> 3. **Scene** — overlays (separately!) + POV + setting + time of day + scene dynamics.
> 4. **Spatial Framing** — shot size + position-in-frame + depth (FG/MG/BG) + camera-height-relative; and how those CHANGE across the beat.
> 5. **Camera** — playback speed → lens distortion → height → angle → focus/DoF → steadiness → movement.
>
> See `skills/creative/video-gen-prompting.md` for the primitive vocabulary.
> **Overlays callout.** Overlays (titles, subtitles, HUD, watermarks, framing graphics, lower-thirds, name plates, end-tag cards) are NOT part of the scene's foreground/midground/background depth axis. List them separately in scene metadata (`overlays: [...]`) with content and placement. Never describe an overlay as "in the foreground" — that confuses both downstream tools and any video-understanding model that re-analyzes the output.
### 6. Quality Gate
- every beat has a scene treatment,
- hero frames are identifiable,
- hero frames are identifiable AND fully specified across all 5 aspects,
- support inserts are justified,
- overlays are recorded under `overlays:`, never inside the depth/framing description,
- the visual language stays consistent across the piece.
## Common Pitfalls
@@ -178,6 +178,22 @@ Assemble all generated assets into the manifest:
}
```
### Pre/Post Self-Review for Generation Prompts
> Before sending a prompt to any generation tool — `image_selector`, `diagram_gen`, `video_selector`, even `code_snippet` styling prompts — run a three-step self-review modeled on the CHAI oversight loop ("Building a Precise Video Language with Human-AI Oversight", arXiv 2604.21718v2). Cost is small (no extra tool calls); benefit is large (avoids wasted generations). This applies just as strongly to `diagram_gen` Mermaid prompts and `image_selector` illustration prompts as it does to video generation — bad explainer visuals fail in the same way: missing subject, missing framing, vague "make it look educational."
>
> **Step 1 — Pre-caption pass.** Write the prompt the way you'd write it today. Do not over-edit; aim for a complete first draft.
>
> **Step 2 — Critique pass.** Score the draft against the 5-aspect checklist (Subject / Subject Motion / Scene / Spatial Framing / Camera). For each aspect:
> - Is it specified? If not, is the omission deliberate (e.g., "Camera N/A — Remotion native scene", "no subject motion — static diagram") or accidental?
> - Are confusable terms disambiguated? (dolly vs zoom, pan vs truck, bird's-eye vs aerial, fisheye vs barrel, full shot vs close-up; for diagrams: flowchart vs sequence vs state diagram, top-down vs left-right)
> - Are emotional adjectives ("clean", "professional", "modern") replaced with their visual causes (sans-serif typography, generous whitespace, monochromatic palette with one accent)?
> - For multi-shot prompts: is identity anchored verbatim across shots? For `image_selector` prompts that recur (a character or world appearing in multiple scenes), are consistency anchors specified verbatim?
>
> **Step 3 — Post-caption pass.** Rewrite filling the missing aspects, fixing confusable terms, and replacing subjective language. The post-caption is what gets sent to the generation tool.
>
> Log the (pre, critique, post) triplet in the asset metadata for traceability. This mirrors the CHAI workflow and creates a record the reviewer can audit.
### Step 7: Verify All Assets
**Existence check:**
@@ -156,6 +156,20 @@ If the video includes narration, the script **must** be written to fit the video
- If narration exceeds video by >1s, either trim the script and regenerate, or extend the video's closing scene.
- Always run `composition_validator` before rendering to catch mismatches automatically.
### Step 4c: 5-Aspect Scene-Plan Checklist
> Every scene must specify all five aspects. For diagram, chart, and Remotion-native scenes, "Subject" can map to a foregrounded data element and "Camera" can be marked N/A — but only EXPLICITLY (e.g., `"camera": "N/A — Remotion native scene, no virtual camera"`). Silent omission is the most common failure mode and produces unpredictable model output, brittle prompts, and reviewer churn.
>
> 1. **Subject** — type + key visual attributes; if multiple, how to disambiguate. For diagram/chart scenes, this is the foregrounded data element (the node, the bar, the KPI being highlighted). For generated images, it's the person/object/concept being illustrated.
> 2. **Subject Motion** — actions in temporal order; for animated diagrams, the order in which nodes/edges/values appear or change.
> 3. **Scene** — overlays (separately!) + POV + setting + time of day + scene dynamics. For Remotion scenes, "setting" maps to background treatment + theme.
> 4. **Spatial Framing** — shot size + position-in-frame + depth (FG/MG/BG) + camera-height-relative; and how those CHANGE. For static Remotion scenes, document the layout grid + which element occupies the visual center.
> 5. **Camera** — playback speed → lens distortion → height → angle → focus/DoF → steadiness → movement. Mark N/A for native-Remotion scenes; specify fully for `generated`/`broll`/`image_animation` scenes.
>
> See `skills/creative/video-gen-prompting.md` for the primitive vocabulary.
> **Overlays callout.** Overlays (titles, subtitles, HUD, watermarks, framing graphics, lower-thirds, section_title bars, stat_reveal chips, hero_title overlays, provider chips) are NOT part of the scene's foreground/midground/background depth axis. List them separately in scene metadata (`overlays: [...]`) with content and placement. Never describe an overlay as "in the foreground" — that confuses both downstream tools and any video-understanding model that re-analyzes the output.
### Step 5: Validate Against Playbook
The style playbook constrains your visual choices:
@@ -0,0 +1,276 @@
"""Contract tests for the local character-animation pipeline."""
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from lib.pipeline_loader import get_required_tools, get_stage_order, load_pipeline
from schemas.artifacts import ARTIFACT_NAMES, validate_artifact
from tools.character.character_animation import (
ActionTimelineCompiler,
CharacterAnimationReviewer,
CharacterRigRenderer,
CharacterSpecGenerator,
PoseLibraryBuilder,
SvgRigBuilder,
)
from tools.tool_registry import registry
from tools.video.hyperframes_compose import HyperFramesCompose
from tools.video.video_compose import VideoCompose
def test_character_animation_manifest_contract():
manifest = load_pipeline("character-animation")
assert manifest["name"] == "character-animation"
assert get_stage_order(manifest) == [
"research",
"proposal",
"script",
"character_design",
"rig_plan",
"scene_plan",
"assets",
"edit",
"compose",
"publish",
]
assert {
"character_spec_generator",
"svg_rig_builder",
"pose_library_builder",
"action_timeline_compiler",
"character_rig_renderer",
"character_animation_reviewer",
}.issubset(set(get_required_tools(manifest)))
def test_character_artifacts_are_registered():
assert {
"character_design",
"rig_plan",
"pose_library",
"action_timeline",
"character_qa_report",
}.issubset(set(ARTIFACT_NAMES))
def test_character_tools_discover_in_registry():
registry.discover()
names = {tool.name for tool in registry.get_by_capability("character_animation")}
assert {
"character_spec_generator",
"svg_rig_builder",
"pose_library_builder",
"action_timeline_compiler",
"character_rig_renderer",
"character_animation_reviewer",
}.issubset(names)
def test_character_animation_smoke_flow(tmp_path):
character_result = CharacterSpecGenerator().execute(
{
"characters": [
{
"id": "mouse_lead",
"role": "curious lead",
"body_type": "mouse with tail",
"required_actions": ["idle", "gesture", "tail_swish"],
},
{
"id": "bird_friend",
"role": "expressive sidekick",
"body_type": "round bird",
"required_actions": ["idle", "wing_flap", "react"],
},
],
"output_path": str(tmp_path / "character_design.json"),
}
)
assert character_result.success
character_design = character_result.data["character_design"]
validate_artifact("character_design", character_design)
rig_result = SvgRigBuilder().execute(
{
"character_design": character_design,
"output_path": str(tmp_path / "rig_plan.json"),
}
)
assert rig_result.success
rig_plan = rig_result.data["rig_plan"]
validate_artifact("rig_plan", rig_plan)
pose_result = PoseLibraryBuilder().execute(
{"rig_plan": rig_plan, "output_path": str(tmp_path / "pose_library.json")}
)
assert pose_result.success
pose_library = pose_result.data["pose_library"]
validate_artifact("pose_library", pose_library)
scene_plan = {
"version": "1.0",
"scenes": [
{
"id": "scene-1",
"type": "character_scene",
"start_seconds": 0,
"end_seconds": 4,
"description": "The mouse discovers a glowing seed while the bird reacts.",
"hero_moment": True,
"character_actions": [
{
"character_id": "mouse_lead",
"emotion": "surprised",
"action_sequence": ["anticipate", "perform", "settle"],
},
{
"character_id": "bird_friend",
"emotion": "surprised",
"action_sequence": ["react", "follow", "settle"],
},
],
}
],
}
validate_artifact("scene_plan", scene_plan)
timeline_result = ActionTimelineCompiler().execute(
{
"scene_plan": scene_plan,
"character_ids": ["mouse_lead", "bird_friend"],
"output_path": str(tmp_path / "action_timeline.json"),
}
)
assert timeline_result.success
action_timeline = timeline_result.data["action_timeline"]
validate_artifact("action_timeline", action_timeline)
assert {action["character_id"] for action in action_timeline["scenes"][0]["actions"]} == {
"mouse_lead",
"bird_friend",
}
preview_path = tmp_path / "preview.html"
render_result = CharacterRigRenderer().execute(
{
"rig_plan": rig_plan,
"pose_library": pose_library,
"action_timeline": action_timeline,
"output_path": str(preview_path),
}
)
assert render_result.success
assert preview_path.exists()
preview_html = preview_path.read_text(encoding="utf-8")
assert "character_mouse-lead" in preview_html
assert "character_bird-friend" in preview_html
qa_result = CharacterAnimationReviewer().execute(
{
"rig_plan": rig_plan,
"pose_library": pose_library,
"action_timeline": action_timeline,
"preview_path": str(preview_path),
"output_path": str(tmp_path / "character_qa_report.json"),
}
)
assert qa_result.success
qa_report = qa_result.data["character_qa_report"]
validate_artifact("character_qa_report", qa_report)
assert qa_report["status"] == "pass"
assert qa_report["checks"]["schema_valid"] is True
def test_character_style_is_normalized_for_schema(tmp_path):
result = CharacterSpecGenerator().execute(
{
"characters": [{"id": "style_test", "role": "lead", "body_type": "round"}],
"style": {
"name": "flat-motion-graphics",
"palette": ["#ff8f68", "#75b8ff"],
"unexpected": "should not leak into artifact",
},
"output_path": str(tmp_path / "character_design.json"),
}
)
assert result.success
character_design = result.data["character_design"]
validate_artifact("character_design", character_design)
assert character_design["style"] == {
"visual_style": "flat-motion-graphics",
"palette": ["#ff8f68", "#75b8ff"],
}
def test_character_renderer_can_handoff_to_video_compose(tmp_path):
hyperframes = HyperFramesCompose()
runtime = hyperframes._runtime_check()
if not runtime["runtime_available"]:
pytest.skip("HyperFrames runtime is required for character render handoff")
character_design = CharacterSpecGenerator().execute(
{"characters": [{"id": "mouse_lead", "role": "lead", "body_type": "mouse with tail"}]}
).data["character_design"]
rig_plan = SvgRigBuilder().execute({"character_design": character_design}).data["rig_plan"]
pose_library = PoseLibraryBuilder().execute({"rig_plan": rig_plan}).data["pose_library"]
scene_plan = {
"version": "1.0",
"scenes": [
{
"id": "scene-1",
"type": "character_scene",
"description": "Mouse reacts to a tiny surprise.",
"start_seconds": 0,
"end_seconds": 1,
"character_actions": [
{
"character_id": "mouse_lead",
"emotion": "surprised",
"action_sequence": ["anticipate", "perform", "settle"],
}
],
}
],
}
validate_artifact("scene_plan", scene_plan)
action_timeline = ActionTimelineCompiler().execute(
{"scene_plan": scene_plan, "character_ids": ["mouse_lead"]}
).data["action_timeline"]
render_result = CharacterRigRenderer().execute(
{
"rig_plan": rig_plan,
"pose_library": pose_library,
"action_timeline": action_timeline,
"output_path": str(tmp_path / "preview.html"),
"workspace_path": str(tmp_path / "hyperframes"),
}
)
assert render_result.success
validate_artifact("asset_manifest", render_result.data["asset_manifest"])
validate_artifact("edit_decisions", render_result.data["edit_decisions"])
assert render_result.data["edit_decisions"]["render_runtime"] == "hyperframes"
assert Path(render_result.data["composition_path"]).exists()
output_path = tmp_path / "renders" / "final.mp4"
compose_result = VideoCompose().execute(
{
"operation": "render",
"asset_manifest": render_result.data["asset_manifest"],
"edit_decisions": render_result.data["edit_decisions"],
"workspace_path": render_result.data["hyperframes_workspace"],
"output_path": str(output_path),
"skip_contrast": True,
"quality": "draft",
"fps": 24,
}
)
assert compose_result.success, compose_result.error
assert output_path.exists()
+165
View File
@@ -627,6 +627,171 @@ def test_decision_log_accepts_render_runtime_selection_with_both_options():
jsonschema.validate(log, schema)
def test_transcript_comparison_catches_literal_punctuation_leak(tmp_path):
"""Regression: Chirp3-HD (and some other TTS engines) read literal `...`
as the word 'dot' in audio output. This failure is invisible to
volume-based audio spotchecks but ships audio that literally says
'dot dot dot' twelve times. The transcript_comparison check catches
this automatically before the video is marked pass."""
import json
# Real-world example: user's script had `...` everywhere for dramatic
# pause, Chirp read them all as "dot", transcript contains "dot dot dot"
# phrases the script never had.
script_text = (
"A computer just did in five minutes what would take every machine on Earth, "
"running since the Big Bang, ten septillion years to finish. "
"We may have gotten help from parallel universes."
)
transcript_data = {
"word_timestamps": [
{"word": "A", "start": 0.0, "end": 0.1},
{"word": "computer", "start": 0.1, "end": 0.5},
{"word": "just", "start": 0.5, "end": 0.7},
{"word": "did", "start": 0.7, "end": 0.9},
{"word": "in", "start": 0.9, "end": 1.0},
{"word": "five", "start": 1.0, "end": 1.3},
{"word": "minutes", "start": 1.3, "end": 1.7},
{"word": "dot", "start": 1.7, "end": 1.9}, # leak!
{"word": "dot", "start": 1.9, "end": 2.1}, # leak!
{"word": "dot", "start": 2.1, "end": 2.3}, # leak!
{"word": "what", "start": 2.5, "end": 2.8},
{"word": "would", "start": 2.8, "end": 3.0},
{"word": "take", "start": 3.0, "end": 3.3},
{"word": "every", "start": 3.3, "end": 3.6},
{"word": "machine", "start": 3.6, "end": 4.0},
{"word": "on", "start": 4.0, "end": 4.2},
{"word": "Earth", "start": 4.2, "end": 4.6},
{"word": "running", "start": 4.7, "end": 5.1},
{"word": "since", "start": 5.1, "end": 5.4},
{"word": "the", "start": 5.4, "end": 5.5},
{"word": "Big", "start": 5.5, "end": 5.8},
{"word": "Bang", "start": 5.8, "end": 6.2},
{"word": "ten", "start": 6.2, "end": 6.5},
{"word": "septillion", "start": 6.5, "end": 7.3},
{"word": "years", "start": 7.3, "end": 7.7},
{"word": "to", "start": 7.7, "end": 7.9},
{"word": "finish", "start": 7.9, "end": 8.3},
{"word": "dot", "start": 8.3, "end": 8.5}, # another leak
{"word": "We", "start": 9.0, "end": 9.2},
{"word": "may", "start": 9.2, "end": 9.4},
{"word": "have", "start": 9.4, "end": 9.6},
{"word": "gotten", "start": 9.6, "end": 9.9},
{"word": "help", "start": 9.9, "end": 10.3},
{"word": "from", "start": 10.3, "end": 10.5},
{"word": "parallel", "start": 10.5, "end": 11.0},
{"word": "universes", "start": 11.0, "end": 11.7},
]
}
transcript_path = tmp_path / "transcript.json"
transcript_path.write_text(json.dumps(transcript_data), encoding="utf-8")
result = VideoCompose._compare_transcript_to_script(transcript_path, script_text)
# Must catch the punctuation leak
assert result["spurious_punctuation_words"], (
"transcript_comparison failed to detect the 'dot' leak from literal ... punctuation."
)
leak_counts = {
entry["word"]: entry["count"]
for entry in result["spurious_punctuation_words"]
}
assert leak_counts.get("dot") == 4, f"Expected 4 'dot' leaks, got {leak_counts}"
# Must produce a CRITICAL-severity issue message
issue_text = " ".join(result["issues"]).lower()
assert "tts punctuation leak" in issue_text
assert "not in the script" in issue_text
# Must NOT mark the transcript as matching
assert result["transcript_matches_script"] is False
def test_transcript_comparison_passes_clean_audio(tmp_path):
"""Clean audio with no punctuation leaks must NOT trigger a false
positive."""
import json
script_text = "The quick brown fox jumps over the lazy dog."
transcript_data = {
"word_timestamps": [
{"word": "The", "start": 0.0, "end": 0.1},
{"word": "quick", "start": 0.1, "end": 0.4},
{"word": "brown", "start": 0.4, "end": 0.7},
{"word": "fox", "start": 0.7, "end": 1.0},
{"word": "jumps", "start": 1.0, "end": 1.3},
{"word": "over", "start": 1.3, "end": 1.6},
{"word": "the", "start": 1.6, "end": 1.7},
{"word": "lazy", "start": 1.7, "end": 2.0},
{"word": "dog", "start": 2.0, "end": 2.3},
]
}
transcript_path = tmp_path / "transcript.json"
transcript_path.write_text(json.dumps(transcript_data), encoding="utf-8")
result = VideoCompose._compare_transcript_to_script(transcript_path, script_text)
assert result["spurious_punctuation_words"] == []
assert result["transcript_matches_script"] is True
assert result["word_accuracy"] >= 0.9
# issues may still have informational content but no CRITICAL TTS leak
assert not any("tts punctuation leak" in i.lower() for i in result["issues"])
def test_transcript_comparison_graceful_when_inputs_missing(tmp_path):
"""When transcript or script is unavailable, the check should NOT
crash it should record the skip in issues so the silence is visible."""
# No transcript
result = VideoCompose._compare_transcript_to_script(None, "some script text")
assert any("not provided" in i for i in result["issues"])
# No script
dummy = tmp_path / "t.json"
dummy.write_text('{"word_timestamps": []}', encoding="utf-8")
result = VideoCompose._compare_transcript_to_script(dummy, "")
assert any("not provided" in i for i in result["issues"])
# Transcript file missing
result = VideoCompose._compare_transcript_to_script(tmp_path / "nonexistent.json", "script")
assert any("not provided" in i for i in result["issues"])
def test_run_final_review_includes_transcript_comparison_section(tmp_path):
"""Regression: the `transcript_comparison` section must ALWAYS appear in
the final_review output even when the caller doesn't provide a
transcript. A missing section = silent governance failure."""
import subprocess
# Build a minimal MP4 so _run_final_review can probe it.
mp4 = tmp_path / "out.mp4"
subprocess.run(
[
"ffmpeg", "-y",
"-f", "lavfi", "-i", "color=c=#000000:s=320x240:d=2",
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest", str(mp4),
],
capture_output=True, check=True, timeout=30,
)
review = VideoCompose()._run_final_review(
mp4,
edit_decisions={
"version": "1.0",
"renderer_family": "animation-first",
"render_runtime": "hyperframes",
"cuts": [{"id": "c1", "source": "x", "in_seconds": 0, "out_seconds": 2}],
},
)
assert "transcript_comparison" in review["checks"], (
"final_review must always include a transcript_comparison section. "
"When the caller doesn't provide a transcript, the section should "
"still appear with a 'skipped' issue entry — not be omitted."
)
tc = review["checks"]["transcript_comparison"]
assert any("not provided" in i for i in tc["issues"])
def test_hyperframes_root_composition_has_data_start_and_duration(tmp_path):
"""Regression: the generated root composition was missing data-start
and data-duration, violating the HyperFrames contract (SKILL.md table)."""
+420
View File
@@ -0,0 +1,420 @@
"""Doubao Speech text-to-speech provider tool."""
from __future__ import annotations
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any
from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
RetryPolicy,
ToolResult,
ToolRuntime,
ToolStability,
ToolStatus,
ToolTier,
)
class DoubaoTTS(BaseTool):
name = "doubao_tts"
version = "0.1.0"
tier = ToolTier.VOICE
capability = "tts"
provider = "doubao"
stability = ToolStability.EXPERIMENTAL
execution_mode = ExecutionMode.ASYNC
determinism = Determinism.STOCHASTIC
runtime = ToolRuntime.API
dependencies = []
install_instructions = (
"Set DOUBAO_SPEECH_API_KEY to a Volcengine Doubao Speech API Key.\n"
"Optional: set DOUBAO_SPEECH_VOICE_TYPE to the default speaker voice.\n"
"Use the new console API key flow; do not pass app id/access token as the API key."
)
fallback = "google_tts"
fallback_tools = ["google_tts", "elevenlabs_tts", "openai_tts", "piper_tts"]
agent_skills = ["doubao-tts", "text-to-speech"]
capabilities = [
"text_to_speech",
"voice_selection",
"multilingual",
"timestamp_alignment",
]
supports = {
"voice_cloning": False,
"multilingual": True,
"offline": False,
"native_audio": True,
"timestamps": True,
"long_text_async": True,
}
best_for = [
"natural Mandarin narration",
"Chinese explainer voiceovers with character-level timestamps",
"long-form narration that needs subtitle alignment",
]
not_good_for = [
"fully offline production",
"voice clone matching",
"real-time interactive speech playback",
]
input_schema = {
"type": "object",
"required": ["text"],
"properties": {
"text": {"type": "string", "description": "Text to convert to speech"},
"voice_id": {
"type": "string",
"description": "Doubao speaker/voice_type. Defaults to DOUBAO_SPEECH_VOICE_TYPE.",
},
"resource_id": {
"type": "string",
"default": "seed-tts-2.0",
"description": "Volcengine resource id. Use seed-tts-2.0 for Doubao Speech 2.0 voices.",
},
"format": {
"type": "string",
"default": "mp3",
"enum": ["mp3", "ogg_opus", "pcm"],
},
"sample_rate": {
"type": "integer",
"default": 24000,
"enum": [8000, 16000, 22050, 24000, 32000, 44100, 48000],
},
"speech_rate": {
"type": "integer",
"default": 0,
"minimum": -50,
"maximum": 100,
"description": "Doubao speech rate. 0=normal, 100=2x, -50=0.5x.",
},
"enable_timestamp": {
"type": "boolean",
"default": True,
"description": "Return sentence/word timing metadata when supported by the selected endpoint.",
},
"disable_markdown_filter": {
"type": "boolean",
"default": False,
"description": "Pass through Doubao markdown filtering behavior. Defaults to API-safe false.",
},
"return_usage": {
"type": "boolean",
"default": True,
"description": "Request usage token data from Volcengine when available.",
},
"output_path": {"type": "string"},
"metadata_path": {
"type": "string",
"description": "Where to save the full query JSON. Defaults next to output_path.",
},
"poll_interval_seconds": {
"type": "number",
"default": 2.0,
"minimum": 0.5,
},
"timeout_seconds": {
"type": "integer",
"default": 300,
"minimum": 30,
},
},
}
output_schema = {
"type": "object",
"properties": {
"output": {"type": "string"},
"metadata_path": {"type": "string"},
"task_id": {"type": "string"},
"audio_duration_seconds": {"type": ["number", "null"]},
"sentences": {"type": "array"},
"usage": {"type": ["object", "null"]},
},
}
artifact_schema = {
"type": "array",
"items": {"type": "string"},
}
resource_profile = ResourceProfile(
cpu_cores=1, ram_mb=256, vram_mb=0, disk_mb=50, network_required=True
)
retry_policy = RetryPolicy(
max_retries=2,
backoff_seconds=2.0,
retryable_errors=["timeout", "rate_limit", "quota exceeded for types: concurrency"],
)
idempotency_key_fields = ["text", "voice_id", "resource_id", "speech_rate", "sample_rate"]
side_effects = [
"writes audio file to output_path",
"writes Doubao query metadata JSON next to output_path",
"calls Volcengine Doubao Speech API",
]
user_visible_verification = [
"Listen to generated audio for Mandarin naturalness and pacing",
"Check timestamp JSON before building subtitles",
]
quality_score = 0.88
latency_p50_seconds = 8.0
SUBMIT_URL = "https://openspeech.bytedance.com/api/v3/tts/submit"
QUERY_URL = "https://openspeech.bytedance.com/api/v3/tts/query"
DEFAULT_RESOURCE_ID = "seed-tts-2.0"
DEFAULT_VOICE_ENV = "DOUBAO_SPEECH_VOICE_TYPE"
def get_status(self) -> ToolStatus:
if os.environ.get("DOUBAO_SPEECH_API_KEY"):
return ToolStatus.AVAILABLE
return ToolStatus.UNAVAILABLE
def estimate_cost(self, inputs: dict[str, Any]) -> float:
# Volcengine bills Doubao Speech 2.0 by characters. Keep this conservative
# and prefer provider-returned usage when available.
return round(len(inputs.get("text", "")) * 0.000015, 4)
def execute(self, inputs: dict[str, Any]) -> ToolResult:
api_key = os.environ.get("DOUBAO_SPEECH_API_KEY")
if not api_key:
return ToolResult(success=False, error="No Doubao Speech API key. " + self.install_instructions)
voice_id = inputs.get("voice_id") or os.environ.get(self.DEFAULT_VOICE_ENV)
if not voice_id:
return ToolResult(
success=False,
error=(
"No Doubao voice_id provided. Pass voice_id or set "
f"{self.DEFAULT_VOICE_ENV} in the environment."
),
)
start = time.time()
try:
result = self._generate(inputs, api_key=api_key, voice_id=voice_id)
except Exception as exc:
return ToolResult(success=False, error=f"Doubao TTS failed: {self._safe_error(exc)}")
result.duration_seconds = round(time.time() - start, 2)
if not result.cost_usd:
result.cost_usd = self.estimate_cost(inputs)
return result
def _generate(self, inputs: dict[str, Any], *, api_key: str, voice_id: str) -> ToolResult:
import requests
text = inputs["text"]
fmt = inputs.get("format", "mp3")
resource_id = inputs.get("resource_id", self.DEFAULT_RESOURCE_ID)
output_path = Path(inputs.get("output_path", f"doubao_tts.{self._extension_for_format(fmt)}"))
metadata_path = Path(
inputs.get("metadata_path") or output_path.with_suffix(output_path.suffix + ".json")
)
output_path.parent.mkdir(parents=True, exist_ok=True)
metadata_path.parent.mkdir(parents=True, exist_ok=True)
req_id = str(uuid.uuid4())
headers = self._headers(
api_key=api_key,
resource_id=resource_id,
request_id=req_id,
return_usage=bool(inputs.get("return_usage", True)),
)
body = self._submit_body(inputs, voice_id=voice_id, request_id=req_id)
submit_response = requests.post(self.SUBMIT_URL, headers=headers, json=body, timeout=(10, 60))
submit_data = self._json_or_raise(submit_response)
self._raise_for_doubao_error(submit_response.status_code, submit_data)
task_id = submit_data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError("Doubao submit succeeded but did not return data.task_id")
query_data = self._poll_query(
requests_module=requests,
api_key=api_key,
resource_id=resource_id,
task_id=task_id,
return_usage=bool(inputs.get("return_usage", True)),
poll_interval=float(inputs.get("poll_interval_seconds", 2.0)),
timeout_seconds=int(inputs.get("timeout_seconds", 300)),
)
data = query_data.get("data", {})
audio_url = data.get("audio_url")
if not audio_url:
raise RuntimeError("Doubao task completed but did not return data.audio_url")
audio_response = requests.get(audio_url, timeout=(10, 120))
audio_response.raise_for_status()
output_path.write_bytes(audio_response.content)
metadata_path.write_text(json.dumps(query_data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
audio_duration = self._audio_duration(output_path)
usage = data.get("usage")
cost = self._cost_from_usage(usage) or self.estimate_cost(inputs)
return ToolResult(
success=True,
data={
"provider": self.provider,
"model": resource_id,
"resource_id": resource_id,
"voice_id": voice_id,
"format": fmt,
"sample_rate": inputs.get("sample_rate", 24000),
"speech_rate": inputs.get("speech_rate", 0),
"text_length": len(text),
"task_id": task_id,
"task_status": data.get("task_status"),
"req_text_length": data.get("req_text_length"),
"synthesize_text_length": data.get("synthesize_text_length"),
"audio_duration_seconds": round(audio_duration, 2) if audio_duration else None,
"output": str(output_path),
"metadata_path": str(metadata_path),
"sentences": data.get("sentences", []),
"usage": usage,
"url_expire_time": data.get("url_expire_time"),
},
artifacts=[str(output_path), str(metadata_path)],
cost_usd=cost,
model=resource_id,
)
def _headers(
self,
*,
api_key: str,
resource_id: str,
request_id: str,
return_usage: bool,
) -> dict[str, str]:
headers = {
"X-Api-Key": api_key,
"X-Api-Resource-Id": resource_id,
"X-Api-Request-Id": request_id,
"Content-Type": "application/json",
}
if return_usage:
headers["X-Control-Require-Usage-Tokens-Return"] = "true"
return headers
def _submit_body(self, inputs: dict[str, Any], *, voice_id: str, request_id: str) -> dict[str, Any]:
audio_params = {
"format": inputs.get("format", "mp3"),
"sample_rate": inputs.get("sample_rate", 24000),
"speech_rate": inputs.get("speech_rate", 0),
"enable_timestamp": bool(inputs.get("enable_timestamp", True)),
}
additions = {
"disable_markdown_filter": bool(inputs.get("disable_markdown_filter", False)),
}
return {
"user": {"uid": inputs.get("user_id", "openmontage")},
"unique_id": request_id,
"req_params": {
"text": inputs["text"],
"speaker": voice_id,
"audio_params": audio_params,
"additions": json.dumps(additions, ensure_ascii=False),
},
}
def _poll_query(
self,
*,
requests_module: Any,
api_key: str,
resource_id: str,
task_id: str,
return_usage: bool,
poll_interval: float,
timeout_seconds: int,
) -> dict[str, Any]:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
time.sleep(poll_interval)
headers = self._headers(
api_key=api_key,
resource_id=resource_id,
request_id=str(uuid.uuid4()),
return_usage=return_usage,
)
response = requests_module.post(self.QUERY_URL, headers=headers, json={"task_id": task_id}, timeout=(10, 60))
query_data = self._json_or_raise(response)
self._raise_for_doubao_error(response.status_code, query_data)
status = query_data.get("data", {}).get("task_status")
if status == 2:
return query_data
if status == 3:
raise RuntimeError(f"Doubao task failed: {query_data.get('message', 'unknown error')}")
raise TimeoutError(f"Doubao task did not finish within {timeout_seconds} seconds")
@staticmethod
def _json_or_raise(response: Any) -> dict[str, Any]:
try:
return response.json()
except ValueError as exc:
raise RuntimeError(f"Non-JSON response from Doubao API: HTTP {response.status_code}") from exc
def _raise_for_doubao_error(self, http_status: int, payload: dict[str, Any]) -> None:
code = payload.get("code")
if http_status < 400 and code == 20000000:
return
message = payload.get("message", "unknown error")
hint = self._diagnostic_hint(message)
raise RuntimeError(f"HTTP {http_status}, code {code}: {message}{hint}")
@staticmethod
def _diagnostic_hint(message: str) -> str:
lowered = message.lower()
if "load grant" in lowered or "requested grant not found" in lowered:
return " (check DOUBAO_SPEECH_API_KEY and use the new-console X-Api-Key flow)"
if "speaker permission denied" in lowered or "access denied" in lowered:
return " (check voice_id/DOUBAO_SPEECH_VOICE_TYPE and voice authorization)"
if "quota exceeded" in lowered:
return " (check quota, concurrency, or remaining character package)"
if "unsupported additions explicit language" in lowered:
return " (do not pass additions.explicit_language for this endpoint)"
return ""
@staticmethod
def _safe_error(exc: Exception) -> str:
# Avoid ever echoing request headers or secrets in user-visible errors.
return str(exc).replace(os.environ.get("DOUBAO_SPEECH_API_KEY", ""), "[redacted]")
@staticmethod
def _extension_for_format(fmt: str) -> str:
if fmt == "ogg_opus":
return "ogg"
if fmt == "pcm":
return "pcm"
return "mp3"
@staticmethod
def _audio_duration(path: Path) -> float | None:
try:
from tools.analysis.audio_probe import probe_duration
return probe_duration(path)
except Exception:
return None
@staticmethod
def _cost_from_usage(usage: Any) -> float | None:
if not isinstance(usage, dict):
return None
text_words = usage.get("text_words")
if not isinstance(text_words, (int, float)):
return None
return round(float(text_words) * 0.000015, 4)
+2
View File
@@ -0,0 +1,2 @@
"""Character animation tools."""
+896
View File
@@ -0,0 +1,896 @@
"""Local character-animation contract tools.
These tools provide deterministic artifact generation and validation for the
character-animation pipeline. They intentionally keep creative orchestration in
skills and manifests; Python only creates structured artifacts and lightweight
preview/review outputs.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any
from schemas.artifacts import validate_artifact
from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
ToolResult,
ToolStability,
ToolTier,
)
def _write_json(path: str | None, data: dict[str, Any]) -> list[str]:
if not path:
return []
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
return [str(out)]
def _slug(value: str) -> str:
chars = [c.lower() if c.isalnum() else "-" for c in value.strip()]
return "-".join("".join(chars).split("-")).strip("-") or "character"
def _character_color(index: int) -> tuple[str, str]:
palettes = [
("#ff8f68", "#ffd39f"),
("#75b8ff", "#ffe7a3"),
("#8fd17f", "#f7c8ff"),
("#f2c94c", "#fce6c9"),
]
return palettes[index % len(palettes)]
def _normalize_style(style: Any) -> dict[str, Any]:
if not isinstance(style, dict):
return {}
normalized: dict[str, Any] = {}
visual_style = style.get("visual_style") or style.get("name") or style.get("style")
if visual_style:
normalized["visual_style"] = str(visual_style)
palette = style.get("palette")
if isinstance(palette, list):
normalized["palette"] = [str(color) for color in palette]
for key in ["line_style", "texture"]:
if style.get(key):
normalized[key] = str(style[key])
return normalized
def _render_preview_mp4(preview_path: Path, video_path: Path, duration_seconds: float, fps: int) -> None:
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg is required to render preview MP4")
try:
from playwright.sync_api import sync_playwright
except Exception as exc: # pragma: no cover - dependency-specific branch
raise RuntimeError("Playwright is required to render preview MP4") from exc
frame_dir = video_path.parent / f"{video_path.stem}_frames"
frame_dir.mkdir(parents=True, exist_ok=True)
frame_count = max(2, int(duration_seconds * fps))
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1280, "height": 720})
page.goto(preview_path.resolve().as_uri(), wait_until="networkidle")
for frame in range(frame_count):
if frame:
page.wait_for_timeout(int(1000 / fps))
page.screenshot(path=str(frame_dir / f"frame_{frame:04d}.png"))
browser.close()
cmd = [
"ffmpeg",
"-y",
"-framerate",
str(fps),
"-i",
str(frame_dir / "frame_%04d.png"),
"-r",
str(fps),
"-pix_fmt",
"yuv420p",
str(video_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "ffmpeg failed to render preview MP4")
class CharacterSpecGenerator(BaseTool):
name = "character_spec_generator"
version = "0.1.0"
tier = ToolTier.CORE
capability = "character_animation"
provider = "openmontage"
stability = ToolStability.BETA
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
resource_profile = ResourceProfile(cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=10)
agent_skills = ["character-rigging", "pose-library-design"]
capabilities = ["draft_character_design", "normalize_character_specs"]
best_for = ["Converting approved concepts into structured character_design artifacts"]
not_good_for = ["Generating artwork pixels or finished animation"]
input_schema = {
"type": "object",
"properties": {
"characters": {"type": "array"},
"brief": {"type": "string"},
"style": {"type": "object"},
"output_path": {"type": "string"},
},
}
output_schema = {"type": "object", "properties": {"character_design": {"type": "object"}}}
artifact_schema = {"artifact": "character_design"}
side_effects = ["optionally writes character_design JSON to output_path"]
user_visible_verification = ["Review character count, action list, and emotional range"]
def execute(self, inputs: dict[str, Any]) -> ToolResult:
start = time.time()
raw_characters = inputs.get("characters") or [
{
"id": "main_character",
"role": "lead character",
"body_type": "simple rounded cartoon character",
"style": "local rigged cartoon",
"required_emotions": ["neutral", "curious", "happy", "surprised"],
"required_actions": ["idle", "blink", "look", "gesture"],
}
]
characters: list[dict[str, Any]] = []
for raw in raw_characters:
name = raw.get("id") or raw.get("name") or raw.get("display_name") or "character"
characters.append(
{
"id": _slug(str(name)),
"display_name": raw.get("display_name", str(name).replace("_", " ").title()),
"role": raw.get("role", "supporting character"),
"body_type": raw.get("body_type", "simple cartoon body"),
"style": raw.get("style", inputs.get("style", {}).get("visual_style", "cartoon")),
"silhouette_notes": raw.get("silhouette_notes", ""),
"required_emotions": raw.get("required_emotions", ["neutral", "happy", "surprised"]),
"required_actions": raw.get("required_actions", ["idle", "blink", "look"]),
"required_views": raw.get("required_views", ["front", "side"]),
"props": raw.get("props", []),
"constraints": raw.get("constraints", []),
}
)
artifact = {
"version": "1.0",
"style": _normalize_style(inputs.get("style", {})),
"characters": characters,
"metadata": {
"source": "character_spec_generator",
"brief": inputs.get("brief", ""),
},
}
artifacts = _write_json(inputs.get("output_path"), artifact)
return ToolResult(
success=True,
data={"character_design": artifact},
artifacts=artifacts,
duration_seconds=round(time.time() - start, 2),
)
class SvgRigBuilder(BaseTool):
name = "svg_rig_builder"
version = "0.1.0"
tier = ToolTier.CORE
capability = "character_animation"
provider = "openmontage"
stability = ToolStability.BETA
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
resource_profile = ResourceProfile(cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=10)
agent_skills = ["character-rigging", "svg-character-animation", "gsap-core", "gsap-timeline"]
capabilities = ["draft_svg_rig_plan", "define_parts_pivots_layers"]
input_schema = {
"type": "object",
"required": ["character_design"],
"properties": {
"character_design": {"type": "object"},
"output_path": {"type": "string"},
},
}
artifact_schema = {"artifact": "rig_plan"}
side_effects = ["optionally writes rig_plan JSON to output_path"]
user_visible_verification = ["Check pivots and layers before asset generation"]
def execute(self, inputs: dict[str, Any]) -> ToolResult:
start = time.time()
design = inputs["character_design"]
rig_characters: list[dict[str, Any]] = []
for character in design.get("characters", []):
cid = character["id"]
actions = character.get("required_actions", [])
base_parts = [
("body", "torso", 20, None, [320, 380]),
("head", "head", 40, "body", [320, 220]),
("eye_left", "eye", 50, "head", [288, 210]),
("eye_right", "eye", 50, "head", [352, 210]),
("pupil_left", "pupil", 51, "eye_left", [288, 210]),
("pupil_right", "pupil", 51, "eye_right", [352, 210]),
("mouth", "mouth", 52, "head", [320, 260]),
("arm_left", "limb", 35, "body", [260, 330]),
("arm_right", "limb", 35, "body", [380, 330]),
("leg_left", "limb", 10, "body", [285, 470]),
("leg_right", "limb", 10, "body", [355, 470]),
]
if "tail" in character.get("body_type", "").lower() or "mouse" in cid:
base_parts.append(("tail", "tail", 5, "body", [245, 425]))
if any("wing" in a for a in actions) or "bird" in cid:
base_parts.extend(
[
("wing_left", "wing", 30, "body", [275, 330]),
("wing_right", "wing", 30, "body", [365, 330]),
]
)
parts = [
{
"id": part_id,
"kind": kind,
"layer": layer,
**({"parent": parent} if parent else {}),
}
for part_id, kind, layer, parent, _ in base_parts
]
joints = {
part_id: {
"pivot": pivot,
"rotation": [-35, 35] if kind in {"head", "tail"} else [-75, 95],
"scale": [0.8, 1.2],
}
for part_id, kind, _, _, pivot in base_parts
}
required_poses = sorted(
set(["idle", "blink", "look_left", "look_right", "surprised"] + actions)
)
rig_characters.append(
{
"character_id": cid,
"rig_type": "svg_rig",
"parts": parts,
"joints": joints,
"layers": [p["id"] for p in sorted(parts, key=lambda p: p["layer"])],
"views": character.get("required_views", ["front", "side"]),
"required_poses": required_poses,
"required_actions": actions,
"risks": [
"Generated pivots are first-pass estimates; review with preview frames.",
],
}
)
artifact = {"version": "1.0", "characters": rig_characters, "metadata": {"source": self.name}}
artifacts = _write_json(inputs.get("output_path"), artifact)
return ToolResult(
success=True,
data={"rig_plan": artifact},
artifacts=artifacts,
duration_seconds=round(time.time() - start, 2),
)
class PoseLibraryBuilder(BaseTool):
name = "pose_library_builder"
version = "0.1.0"
tier = ToolTier.CORE
capability = "character_animation"
provider = "openmontage"
stability = ToolStability.BETA
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
resource_profile = ResourceProfile(cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=10)
agent_skills = ["pose-library-design", "character-rigging", "svg-character-animation"]
capabilities = ["draft_pose_library", "draft_action_cycles"]
input_schema = {
"type": "object",
"required": ["rig_plan"],
"properties": {"rig_plan": {"type": "object"}, "output_path": {"type": "string"}},
}
artifact_schema = {"artifact": "pose_library"}
side_effects = ["optionally writes pose_library JSON to output_path"]
def execute(self, inputs: dict[str, Any]) -> ToolResult:
start = time.time()
characters = []
for rig in inputs["rig_plan"].get("characters", []):
cid = rig["character_id"]
poses = {
"idle": {"description": "Neutral readable hold", "parts": {}, "hold_frames": 24},
"blink": {
"description": "Quick eye close/open",
"parts": {"eye_left": {"scaleY": 0.08}, "eye_right": {"scaleY": 0.08}},
"hold_frames": 3,
"transition": "power1.inOut",
},
"look_left": {
"description": "Gaze shifts left",
"parts": {"pupil_left": {"x": -6}, "pupil_right": {"x": -6}},
"hold_frames": 18,
},
"look_right": {
"description": "Gaze shifts right",
"parts": {"pupil_left": {"x": 6}, "pupil_right": {"x": 6}},
"hold_frames": 18,
},
"surprised": {
"description": "Head lifts, eyes widen, mouth opens",
"parts": {"head": {"y": -4, "rotation": -4}, "mouth": {"shape": "small_o"}},
"expression": "surprised",
"hold_frames": 24,
"transition": "back.out",
},
}
for action in rig.get("required_actions", []):
poses.setdefault(
action,
{
"description": f"First-pass pose for {action}",
"parts": {},
"hold_frames": 18,
"transition": "power2.inOut",
},
)
characters.append(
{
"character_id": cid,
"poses": poses,
"mouth_shapes": {
"closed": {"description": "Neutral closed mouth"},
"small_o": {"description": "Small open mouth for surprise or vowel"},
"wide": {"description": "Wide open mouth"},
"smile": {"description": "Smile shape"},
},
"action_cycles": {
"walk": ["walk_contact", "walk_passing"],
"breathe": ["idle"],
},
}
)
artifact = {"version": "1.0", "characters": characters, "metadata": {"source": self.name}}
artifacts = _write_json(inputs.get("output_path"), artifact)
return ToolResult(
success=True,
data={"pose_library": artifact},
artifacts=artifacts,
duration_seconds=round(time.time() - start, 2),
)
class ActionTimelineCompiler(BaseTool):
name = "action_timeline_compiler"
version = "0.1.0"
tier = ToolTier.CORE
capability = "character_animation"
provider = "openmontage"
stability = ToolStability.BETA
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
resource_profile = ResourceProfile(cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=10)
agent_skills = ["pose-library-design", "svg-character-animation", "gsap-timeline"]
capabilities = ["compile_scene_actions", "draft_action_timeline"]
input_schema = {
"type": "object",
"required": ["scene_plan"],
"properties": {
"scene_plan": {"type": "object"},
"character_ids": {"type": "array", "items": {"type": "string"}},
"fps": {"type": "number"},
"output_path": {"type": "string"},
},
}
artifact_schema = {"artifact": "action_timeline"}
side_effects = ["optionally writes action_timeline JSON to output_path"]
def execute(self, inputs: dict[str, Any]) -> ToolResult:
start = time.time()
character_ids = inputs.get("character_ids") or ["main_character"]
scenes = []
for scene in inputs["scene_plan"].get("scenes", []):
start_s = scene.get("start_seconds", 0)
end_s = scene.get("end_seconds", start_s + 3)
duration = max(0.1, end_s - start_s)
actions = []
for index, character_id in enumerate(character_ids):
offset = min(duration * 0.08 * index, duration * 0.2)
is_primary = index == 0
actions.extend(
[
{
"at_seconds": start_s + offset,
"duration_seconds": min(0.5, duration / 4),
"character_id": character_id,
"action": "anticipate" if is_primary else "react",
"pose": "idle",
"easing": "power2.out",
},
{
"at_seconds": start_s + duration * 0.25 + offset,
"duration_seconds": duration * 0.35,
"character_id": character_id,
"action": "perform" if is_primary else "follow",
"pose": (
"surprised"
if scene.get("hero_moment") or not is_primary
else "look_right"
),
"easing": "back.out",
"notes": scene.get("description", ""),
},
{
"at_seconds": start_s + duration * 0.7 + offset,
"duration_seconds": duration * 0.25,
"character_id": character_id,
"action": "settle",
"pose": "idle",
"easing": "power2.inOut",
},
]
)
scenes.append(
{
"scene_id": scene["id"],
"start_seconds": start_s,
"end_seconds": end_s,
"camera": {"framing": scene.get("framing", "medium")},
"background": scene.get("description", ""),
"effects": [],
"actions": actions,
}
)
artifact = {
"version": "1.0",
"fps": inputs.get("fps", 30),
"scenes": scenes,
"metadata": {"source": self.name},
}
artifacts = _write_json(inputs.get("output_path"), artifact)
return ToolResult(
success=True,
data={"action_timeline": artifact},
artifacts=artifacts,
duration_seconds=round(time.time() - start, 2),
)
class CharacterRigRenderer(BaseTool):
name = "character_rig_renderer"
version = "0.1.0"
tier = ToolTier.CORE
capability = "character_animation"
provider = "openmontage"
stability = ToolStability.BETA
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
resource_profile = ResourceProfile(cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=50)
agent_skills = [
"character-rigging",
"svg-character-animation",
"canvas-procedural-animation",
"gsap-core",
"gsap-timeline",
"remotion-best-practices",
"hyperframes",
]
capabilities = ["write_browser_preview", "prepare_character_render_package"]
input_schema = {
"type": "object",
"required": ["action_timeline"],
"properties": {
"action_timeline": {"type": "object"},
"rig_plan": {"type": "object"},
"pose_library": {"type": "object"},
"output_path": {"type": "string"},
"workspace_path": {"type": "string"},
"video_output_path": {"type": "string"},
"render_video": {"type": "boolean", "default": False},
"duration_seconds": {"type": "number", "minimum": 0.1, "default": 3},
"fps": {"type": "integer", "minimum": 1, "default": 12},
},
}
output_schema = {
"type": "object",
"properties": {
"preview_path": {"type": "string"},
"hyperframes_workspace": {"type": "string"},
"composition_path": {"type": "string"},
"video_path": {"type": "string"},
"asset_manifest": {"type": "object"},
"edit_decisions": {"type": "object"},
},
}
side_effects = [
"writes a lightweight HTML preview to output_path",
"writes a HyperFrames workspace/package",
"optionally writes preview MP4",
]
user_visible_verification = ["Open preview and check character visibility and motion"]
def execute(self, inputs: dict[str, Any]) -> ToolResult:
start = time.time()
output_path = Path(inputs.get("output_path", "projects/character-preview/preview.html"))
output_path.parent.mkdir(parents=True, exist_ok=True)
timeline_json = json.dumps(inputs["action_timeline"])
rig_characters = (inputs.get("rig_plan") or {}).get("characters", [])
if not rig_characters:
seen_ids = {
action.get("character_id")
for scene in inputs["action_timeline"].get("scenes", [])
for action in scene.get("actions", [])
if action.get("character_id")
}
rig_characters = [{"character_id": cid} for cid in sorted(seen_ids)] or [
{"character_id": "main_character"}
]
count = len(rig_characters)
spacing = 620 / max(count, 1)
character_svgs = []
for index, character in enumerate(rig_characters):
cid = _slug(character.get("character_id", f"character-{index + 1}"))
x = 110 + spacing * index if count > 1 else 320
scale = 0.82 if count > 1 else 1
body_fill, head_fill = _character_color(index)
character_svgs.append(
f"""
<g class=\"character\" id=\"character_{cid}\" data-character=\"{cid}\" transform=\"translate({x - 320:.1f} 0) scale({scale})\">
<ellipse class=\"shadow\" cx=\"320\" cy=\"560\" rx=\"120\" ry=\"22\" fill=\"rgba(0,0,0,.18)\" />
<ellipse class=\"body outline\" cx=\"320\" cy=\"400\" rx=\"80\" ry=\"120\" fill=\"{body_fill}\" />
<circle class=\"head outline\" cx=\"320\" cy=\"230\" r=\"90\" fill=\"{head_fill}\" />
<ellipse class=\"eye eye-left outline\" cx=\"285\" cy=\"215\" rx=\"18\" ry=\"26\" fill=\"white\" />
<ellipse class=\"eye eye-right outline\" cx=\"355\" cy=\"215\" rx=\"18\" ry=\"26\" fill=\"white\" />
<circle class=\"pupil pupil-left\" cx=\"289\" cy=\"218\" r=\"8\" fill=\"#202632\" />
<circle class=\"pupil pupil-right\" cx=\"359\" cy=\"218\" r=\"8\" fill=\"#202632\" />
<path class=\"mouth outline\" d=\"M285 275 Q320 305 355 275\" fill=\"none\" />
<path class=\"arm arm-left outline\" d=\"M255 360 C210 380 190 420 180 455\" fill=\"none\" />
<path class=\"arm arm-right outline\" d=\"M385 360 C440 330 465 290 475 240\" fill=\"none\" />
</g>"""
)
html = f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Character Animation Preview</title>
<script src=\"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js\"></script>
<style>
body {{ margin: 0; overflow: hidden; background: #9bd7ff; font-family: system-ui, sans-serif; }}
#stage {{ width: 100vw; height: 100vh; display: grid; place-items: center; background: linear-gradient(#9bd7ff 0 65%, #75c878 65%); }}
svg {{ width: min(82vw, 720px); overflow: visible; }}
.outline {{ stroke: #202632; stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }}
#note {{ position: fixed; left: 16px; bottom: 16px; background: white; border: 2px solid #202632; padding: 10px 12px; border-radius: 8px; }}
</style>
</head>
<body>
<div id=\"stage\">
<svg viewBox=\"0 0 640 640\" role=\"img\" aria-label=\"Character preview\">
{''.join(character_svgs)}
</svg>
</div>
<div id=\"note\">Local character preview. Characters: <span id=\"characters\"></span> · Scenes: <span id=\"count\"></span></div>
<script>
window.__ACTION_TIMELINE__ = {timeline_json};
document.querySelector('#count').textContent = window.__ACTION_TIMELINE__.scenes.length;
const characters = gsap.utils.toArray('.character');
document.querySelector('#characters').textContent = characters.map((node) => node.dataset.character).join(', ');
characters.forEach((node, index) => {{
const q = gsap.utils.selector(node);
gsap.set(q('.head'), {{ svgOrigin: '320 320' }});
gsap.set(q('.arm-right'), {{ svgOrigin: '385 360' }});
gsap.set(q('.arm-left'), {{ svgOrigin: '255 360' }});
gsap.timeline({{ repeat: -1, defaults: {{ ease: 'power2.inOut' }}, delay: index * 0.12 }})
.to(node, {{ y: -16, duration: 0.45 }})
.to(node, {{ y: 0, duration: 0.45 }});
gsap.timeline({{ repeat: -1, repeatDelay: 0.5, delay: index * 0.18 }})
.to(q('.head'), {{ rotation: index % 2 ? 8 : -8, duration: 0.35 }})
.to(q('.pupil'), {{ x: index % 2 ? -8 : 8, y: -3, duration: 0.2 }}, '<')
.to(q('.arm-right'), {{ rotation: index % 2 ? -22 : 28, duration: 0.35 }}, '<')
.to(q('.eye'), {{ scaleY: 0.08, transformOrigin: 'center', duration: 0.08 }})
.to(q('.eye'), {{ scaleY: 1, duration: 0.1 }})
.to(q('.head'), {{ rotation: index % 2 ? -6 : 6, duration: 0.35 }})
.to(q('.pupil'), {{ x: index % 2 ? 6 : -6, y: 3, duration: 0.2 }}, '<')
.to(q('.arm-right'), {{ rotation: index % 2 ? 8 : -8, duration: 0.35 }}, '<');
}});
</script>
</body>
</html>
"""
output_path.write_text(html, encoding="utf-8")
total_duration = max(
[
float(scene.get("end_seconds", 0) or 0)
for scene in inputs["action_timeline"].get("scenes", [])
]
or [float(inputs.get("duration_seconds", 3))]
)
workspace_path = Path(
inputs.get("workspace_path")
or output_path.parent / "hyperframes"
)
composition_dir = workspace_path / "compositions"
composition_dir.mkdir(parents=True, exist_ok=True)
(workspace_path / "assets").mkdir(parents=True, exist_ok=True)
(workspace_path / "hyperframes.json").write_text(
json.dumps(
{
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
"paths": {
"blocks": "compositions",
"components": "compositions/components",
"assets": "assets",
},
},
indent=2,
),
encoding="utf-8",
)
(workspace_path / "DESIGN.md").write_text(
"# DESIGN\n\n"
"Generated for OpenMontage character animation.\n\n"
"- Background: `#9bd7ff` sky and `#75c878` ground\n"
"- Foreground: `#202632` ink outlines\n"
"- Accent: saturated cartoon body colors\n"
"- Motion: GSAP pose holds, squash/bounce, gaze, blink, and arm arcs\n",
encoding="utf-8",
)
finite_bounce_repeats = max(0, int(total_duration / 0.9) - 1)
finite_acting_repeats = max(0, int(total_duration / 2.1) - 1)
composition_html = f"""<template id=\"character-scene-template\">
<div data-composition-id=\"character-scene\" data-start=\"0\" data-duration=\"{total_duration:.3f}\" data-width=\"1280\" data-height=\"720\">
<style>
[data-composition-id=\"character-scene\"] {{ position: relative; width: 1280px; height: 720px; overflow: hidden; background: linear-gradient(#9bd7ff 0 65%, #75c878 65%); }}
[data-composition-id=\"character-scene\"] svg {{ width: 920px; position: absolute; left: 180px; top: 42px; overflow: visible; }}
[data-composition-id=\"character-scene\"] .outline {{ stroke: #202632; stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }}
</style>
<svg viewBox=\"0 0 640 640\" role=\"img\" aria-label=\"Character animation scene\">
{''.join(character_svgs)}
</svg>
<script src=\"https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js\"></script>
<script>
window.__timelines = window.__timelines || {{}};
const tl = gsap.timeline({{ paused: true }});
const characters = gsap.utils.toArray('[data-composition-id=\"character-scene\"] .character');
characters.forEach((node, index) => {{
const q = gsap.utils.selector(node);
tl.set(q('.head'), {{ svgOrigin: '320 320' }}, 0);
tl.set(q('.arm-right'), {{ svgOrigin: '385 360' }}, 0);
tl.set(q('.arm-left'), {{ svgOrigin: '255 360' }}, 0);
tl.from(node, {{ y: 26, scale: 0.94, opacity: 0, duration: 0.45, ease: 'back.out(1.8)' }}, 0.15 + index * 0.12);
tl.to(node, {{ y: -16, duration: 0.45, repeat: {finite_bounce_repeats}, yoyo: true, ease: 'power2.inOut' }}, 0.7 + index * 0.08);
tl.to(q('.head'), {{ rotation: index % 2 ? 8 : -8, duration: 0.35, repeat: {finite_acting_repeats}, yoyo: true, ease: 'sine.inOut' }}, 0.55 + index * 0.16);
tl.to(q('.pupil'), {{ x: index % 2 ? -8 : 8, y: -3, duration: 0.2, repeat: {finite_acting_repeats}, yoyo: true, ease: 'power2.inOut' }}, 0.6 + index * 0.16);
tl.to(q('.arm-right'), {{ rotation: index % 2 ? -22 : 28, duration: 0.35, repeat: {finite_acting_repeats}, yoyo: true, ease: 'back.inOut(1.4)' }}, 0.65 + index * 0.16);
tl.to(q('.eye'), {{ scaleY: 0.08, transformOrigin: 'center', duration: 0.08, repeat: {finite_acting_repeats}, repeatDelay: 1.4, yoyo: true, ease: 'power1.inOut' }}, 1.1 + index * 0.12);
}});
window.__timelines['character-scene'] = tl;
</script>
</div>
</template>
"""
composition_path = composition_dir / "character-scene.html"
composition_path.write_text(composition_html, encoding="utf-8")
asset_id = "character_scene_hyperframes"
asset_manifest = {
"version": "1.0",
"assets": [
{
"id": asset_id,
"type": "animation",
"path": str(composition_path),
"source_tool": self.name,
"scene_id": "character_preview",
"duration_seconds": total_duration,
"format": "html",
"generation_summary": "HyperFrames SVG/GSAP character composition package.",
}
],
"total_cost_usd": 0,
"metadata": {"source": self.name, "workspace_path": str(workspace_path)},
}
edit_decisions = {
"version": "1.0",
"render_runtime": "hyperframes",
"renderer_family": "animation-first",
"cuts": [
{
"id": "character-scene",
"source": asset_id,
"in_seconds": 0,
"out_seconds": total_duration,
"reason": "HyperFrames SVG/GSAP character scene generated by character_rig_renderer.",
}
],
"metadata": {
"proposal_render_runtime": "hyperframes",
"title": "Character Animation",
},
}
data: dict[str, Any] = {
"preview_path": str(output_path),
"render_package": "hyperframes_workspace",
"hyperframes_workspace": str(workspace_path),
"composition_path": str(composition_path),
"asset_manifest": asset_manifest,
"edit_decisions": edit_decisions,
}
artifacts = [str(output_path), str(workspace_path / "hyperframes.json"), str(composition_path)]
render_video = bool(inputs.get("render_video") or inputs.get("video_output_path"))
if render_video:
video_path = Path(
inputs.get("video_output_path")
or output_path.with_suffix(".mp4")
)
video_path.parent.mkdir(parents=True, exist_ok=True)
duration_seconds = float(inputs.get("duration_seconds", 3))
fps = int(inputs.get("fps", 12))
_render_preview_mp4(output_path, video_path, duration_seconds, fps)
video_asset_id = f"{output_path.stem}_preview_video"
video_asset_manifest = {
"version": "1.0",
"assets": [
{
"id": video_asset_id,
"type": "video",
"path": str(video_path),
"source_tool": self.name,
"scene_id": "character_preview",
"duration_seconds": duration_seconds,
"format": "mp4",
"generation_summary": "Rendered from local SVG/GSAP character preview via Playwright frame capture and ffmpeg.",
}
],
"total_cost_usd": 0,
"metadata": {"source": self.name, "preview_path": str(output_path)},
}
video_edit_decisions = {
"version": "1.0",
"render_runtime": "ffmpeg",
"renderer_family": "animation-first",
"cuts": [
{
"id": "character-preview-cut",
"source": video_asset_id,
"in_seconds": 0,
"out_seconds": duration_seconds,
"reason": "Local rendered character preview for video_compose handoff.",
}
],
"metadata": {
"proposal_render_runtime": "ffmpeg",
"character_preview_path": str(output_path),
},
}
data.update(
{
"video_path": str(video_path),
"video_asset_manifest": video_asset_manifest,
"video_edit_decisions": video_edit_decisions,
}
)
artifacts.append(str(video_path))
return ToolResult(
success=True,
data=data,
artifacts=artifacts,
duration_seconds=round(time.time() - start, 2),
)
class CharacterAnimationReviewer(BaseTool):
name = "character_animation_reviewer"
version = "0.1.0"
tier = ToolTier.ANALYZE
capability = "character_animation"
provider = "openmontage"
stability = ToolStability.BETA
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
resource_profile = ResourceProfile(cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=10)
agent_skills = ["character-animation-qa"]
capabilities = ["review_character_artifacts", "draft_character_qa_report"]
input_schema = {
"type": "object",
"properties": {
"rig_plan": {"type": "object"},
"pose_library": {"type": "object"},
"action_timeline": {"type": "object"},
"preview_path": {"type": "string"},
"review_level": {"type": "string", "enum": ["static", "browser", "final"], "default": "static"},
"browser_preview_checked": {"type": "boolean", "default": False},
"frame_samples_checked": {"type": "boolean", "default": False},
"output_path": {"type": "string"},
},
}
artifact_schema = {"artifact": "character_qa_report"}
side_effects = ["optionally writes character_qa_report JSON to output_path"]
def execute(self, inputs: dict[str, Any]) -> ToolResult:
start = time.time()
issues: list[str] = []
rig = inputs.get("rig_plan") or {}
poses = inputs.get("pose_library") or {}
timeline = inputs.get("action_timeline") or {}
preview_path = inputs.get("preview_path")
review_level = inputs.get("review_level", "static")
browser_preview_checked = bool(inputs.get("browser_preview_checked", False))
frame_samples_checked = bool(inputs.get("frame_samples_checked", False))
assets_exist = True
if not preview_path:
assets_exist = False
issues.append("Preview path is required for character animation QA.")
elif not Path(preview_path).exists():
assets_exist = False
issues.append(f"Preview path does not exist: {preview_path}")
pivots_defined = all(
bool(character.get("joints"))
for character in rig.get("characters", [])
) if rig else False
poses_defined = all(
bool(character.get("poses"))
for character in poses.get("characters", [])
) if poses else False
actions_timed = all(
bool(scene.get("actions"))
for scene in timeline.get("scenes", [])
) if timeline else False
if not pivots_defined:
issues.append("Rig plan is missing joints/pivots for one or more characters.")
if not poses_defined:
issues.append("Pose library is missing poses for one or more characters.")
if not actions_timed:
issues.append("Action timeline has scenes without timed actions.")
schema_valid = True
for artifact_name, artifact in [
("rig_plan", rig),
("pose_library", poses),
("action_timeline", timeline),
]:
if not artifact:
continue
try:
validate_artifact(artifact_name, artifact)
except Exception as exc:
schema_valid = False
issues.append(f"{artifact_name} schema validation failed: {exc}")
if review_level in {"browser", "final"} and not browser_preview_checked:
issues.append("Browser preview check is required for browser/final QA.")
if review_level == "final" and not frame_samples_checked:
issues.append("Frame sample check is required for final QA.")
status = "pass" if not issues else "revise"
report = {
"version": "1.0",
"status": status,
"preview_path": preview_path or "",
"checks": {
"schema_valid": schema_valid,
"assets_exist": assets_exist,
"pivots_defined": pivots_defined,
"poses_defined": poses_defined,
"actions_timed": actions_timed,
"motion_detected": actions_timed,
"browser_preview_checked": browser_preview_checked,
"frame_samples_checked": frame_samples_checked,
},
"issues": issues,
"recommended_action": "present_to_user" if status == "pass" else "fix_rig",
"metadata": {
"source": self.name,
"confidence": "static artifact review; run Playwright/FFmpeg checks in compose for final output",
},
}
artifacts = _write_json(inputs.get("output_path"), report)
return ToolResult(
success=True,
data={"character_qa_report": report},
artifacts=artifacts,
duration_seconds=round(time.time() - start, 2),
)
+18
View File
@@ -1089,6 +1089,19 @@ class HyperFramesCompose(BaseTool):
# Unknown cut shape — render a placeholder text card so the render
# still succeeds; lint/validate will surface the issue.
if ext in {".html", ".htm"} and src_path:
rel = self._rel_from_workspace(str(src_path))
composition_id = Path(rel).stem
html = (
f'<div id="{cut_id}" class="clip composition-clip" '
f'data-composition-id="{self._escape_attr(composition_id)}" '
f'data-composition-src="{self._escape_attr(rel)}" '
f'data-start="{self._f(in_s)}" data-duration="{self._f(duration)}" '
f'data-width="{width}" data-height="{height}" '
f'data-track-index="1"></div>'
)
return html, None
placeholder = self._escape_text(text or cut.get("reason") or f"Scene {index + 1}")
html = (
f'<div id="{cut_id}" class="clip text-card" '
@@ -1182,5 +1195,10 @@ class HyperFramesCompose(BaseTool):
# If it's already a relative path starting with assets/, keep as-is.
if not p.is_absolute():
return str(p).replace("\\", "/")
parts = p.parts
for anchor in ("assets", "compositions"):
if anchor in parts:
index = len(parts) - 1 - list(reversed(parts)).index(anchor)
return "/".join(parts[index:])
# Otherwise emit just the basename under assets/.
return f"assets/{p.name}"
+5 -5
View File
@@ -216,7 +216,7 @@ class SeedanceVideo(BaseTool):
payload["image_url"] = inputs["image_url"]
elif inputs.get("image_path"):
from tools.video._shared import upload_image_fal
payload["image_url"] = upload_image_to_fal(inputs["image_path"])
payload["image_url"] = upload_image_fal(inputs["image_path"])
if inputs.get("end_image_url"):
payload["end_image_url"] = inputs["end_image_url"]
@@ -224,7 +224,7 @@ class SeedanceVideo(BaseTool):
ref_image_urls = list(inputs.get("reference_image_urls") or [])
for local_path in inputs.get("reference_image_paths") or []:
from tools.video._shared import upload_image_fal
ref_image_urls.append(upload_image_to_fal(local_path))
ref_image_urls.append(upload_image_fal(local_path))
# Seedance 2.0 reference-to-video ceilings: 9 images + 3 video + 3 audio.
if len(ref_image_urls) > 9:
return ToolResult(
@@ -257,7 +257,7 @@ class SeedanceVideo(BaseTool):
try:
submit_resp = requests.post(
f"https://queue.fal.run/fal-ai/{model_path}",
f"https://queue.fal.run/{model_path}",
headers=headers,
json=payload,
timeout=30,
@@ -305,7 +305,7 @@ class SeedanceVideo(BaseTool):
success=True,
data={
"provider": "seedance",
"model": f"fal-ai/{model_path}",
"model": model_path,
"prompt": inputs["prompt"],
"operation": operation,
"variant": variant,
@@ -321,5 +321,5 @@ class SeedanceVideo(BaseTool):
artifacts=[str(output_path)],
cost_usd=self.estimate_cost(inputs),
duration_seconds=round(time.time() - start, 2),
model=f"fal-ai/{model_path}",
model=model_path,
)
+206 -4
View File
@@ -105,6 +105,32 @@ class VideoCompose(BaseTool):
"edit_decisions.metadata.proposal_render_runtime."
),
},
"narration_transcript_path": {
"type": "string",
"description": (
"Path to a word-level transcript JSON (from `transcriber` "
"tool output). Optional but STRONGLY recommended: when "
"combined with script_path/script_text, final_review "
"runs transcript_comparison and catches TTS failures "
"like 'Chirp3-HD reads ... as the word dot'. Without "
"it, content-level audio bugs ship silently."
),
},
"script_path": {
"type": "string",
"description": (
"Path to the source narration script (plain text). "
"Used by transcript_comparison to diff against the "
"transcribed audio. Provide this OR script_text."
),
},
"script_text": {
"type": "string",
"description": (
"Inline source narration script. Used by "
"transcript_comparison when a file path is unavailable."
),
},
"subtitle_path": {"type": "string"},
"subtitle_style": {
"type": "object",
@@ -1043,7 +1069,13 @@ class VideoCompose(BaseTool):
# --- Post-render: mandatory final self-review ---
if render_result.success and output_path.exists():
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
output_path,
edit_decisions,
inputs.get("proposal_packet"),
narration_transcript_path=inputs.get("narration_transcript_path"),
script_text=inputs.get("script_text") or self._read_text_file(
inputs.get("script_path")
),
)
# Attach final_review to the ToolResult data so the compose-director
@@ -1160,7 +1192,13 @@ class VideoCompose(BaseTool):
# Post-render: mandatory final self-review (identical contract to the Remotion path).
if output_path.exists():
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
output_path,
edit_decisions,
inputs.get("proposal_packet"),
narration_transcript_path=inputs.get("narration_transcript_path"),
script_text=inputs.get("script_text") or self._read_text_file(
inputs.get("script_path")
),
)
if render_result.data is None:
render_result.data = {}
@@ -1214,7 +1252,13 @@ class VideoCompose(BaseTool):
if render_result.success and output_path.exists():
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
output_path,
edit_decisions,
inputs.get("proposal_packet"),
narration_transcript_path=inputs.get("narration_transcript_path"),
script_text=inputs.get("script_text") or self._read_text_file(
inputs.get("script_path")
),
)
if render_result.data is None:
render_result.data = {}
@@ -1353,11 +1397,156 @@ class VideoCompose(BaseTool):
# Final self-review — mandatory post-render inspection
# ------------------------------------------------------------------
# Punctuation/SSML-leak words that should NEVER appear in rendered audio.
# When a TTS engine reads a literal "..." as the word "dot", or a "—" as
# "hyphen", those leak into the transcript. Catching these in the final
# review is the difference between catching a bad voice render in-tool
# vs. shipping a video that says "dot dot dot" twelve times. CRITICAL.
_TTS_PUNCTUATION_LEAK_WORDS = {
"dot", "dots", "ellipsis", "period", "periods",
"comma", "commas", "semicolon", "colon",
"dash", "hyphen", "emdash", "endash",
"parenthesis", "bracket", "brace",
"asterisk", "slash", "backslash",
"exclamation", "question mark",
}
@staticmethod
def _read_text_file(path: str | Path | None) -> str | None:
"""Read a small text file if given a path; None-safe and exception-safe."""
if not path:
return None
try:
return Path(path).read_text(encoding="utf-8")
except Exception:
return None
@classmethod
def _tokenize(cls, text: str) -> list[str]:
"""Split text into comparable word tokens (lowercased, punctuation
stripped, numeric-word-aware). Empty tokens dropped."""
import re
# Preserve hyphenated words as single tokens ("many-worlds" -> "many-worlds").
# Drop everything except letters, digits, hyphens, apostrophes.
cleaned = re.sub(r"[^A-Za-z0-9\-' ]+", " ", text.lower())
return [t for t in cleaned.split() if t and t != "-"]
@classmethod
def _compare_transcript_to_script(
cls,
transcript_path: Path,
script_text: str,
) -> dict[str, Any]:
"""Compare a word-level transcript against the source script.
Purpose: catch TTS failures that look fine on audio-volume/duration
checks but produce garbage content. The canonical example is
Chirp3-HD reading ellipses ("...") literally as the word "dot" our
volume check says "narration present, not clipped" and the video
ships. This check diffs the actual transcribed audio against what
was supposed to be said, and flags:
- Spurious punctuation-leak words ("dot", "comma", "hyphen", etc.)
that appear in audio but not script CRITICAL
- Overall word-accuracy ratio against script SUGGESTION if < 0.9
Returns the transcript_comparison section of final_review, or a
placeholder with an issue describing why the check couldn't run
(missing transcript, missing script) so the review never goes
silently quiet on this contract.
"""
result: dict[str, Any] = {
"transcript_matches_script": False,
"word_accuracy": None,
"script_word_count": 0,
"transcript_word_count": 0,
"spurious_punctuation_words": [],
"issues": [],
}
if not transcript_path or not Path(transcript_path).is_file():
result["issues"].append(
"transcript_comparison skipped: narration_transcript not provided"
)
return result
if not script_text:
result["issues"].append(
"transcript_comparison skipped: script_text not provided"
)
return result
try:
transcript_data = json.loads(Path(transcript_path).read_text(encoding="utf-8"))
except Exception as e:
result["issues"].append(f"transcript_comparison could not parse transcript: {e}")
return result
transcript_words = [
w.get("word", "").strip() for w in transcript_data.get("word_timestamps", [])
]
transcript_tokens = cls._tokenize(" ".join(transcript_words))
script_tokens = cls._tokenize(script_text)
result["script_word_count"] = len(script_tokens)
result["transcript_word_count"] = len(transcript_tokens)
if not script_tokens or not transcript_tokens:
result["issues"].append(
f"transcript_comparison: empty token set "
f"(script={len(script_tokens)}, transcript={len(transcript_tokens)})"
)
return result
# --- Punctuation-leak detection (TTS reading literal punctuation) ---
script_set = set(script_tokens)
leak_occurrences: dict[str, int] = {}
for token in transcript_tokens:
if token in cls._TTS_PUNCTUATION_LEAK_WORDS and token not in script_set:
leak_occurrences[token] = leak_occurrences.get(token, 0) + 1
if leak_occurrences:
formatted = ", ".join(
f"{w!r}×{n}" for w, n in sorted(leak_occurrences.items(), key=lambda x: -x[1])
)
result["spurious_punctuation_words"] = [
{"word": w, "count": n} for w, n in leak_occurrences.items()
]
result["issues"].append(
f"TTS punctuation leak: transcript contains {formatted}"
f"these words are NOT in the script, which means the voice "
f"engine is reading literal punctuation aloud. Rewrite the "
f"script to eliminate the corresponding characters (ellipses, "
f"em-dashes, etc.) and regenerate narration."
)
# --- Word accuracy via set overlap (cheap & ordering-insensitive) ---
# We don't penalize small word-order differences or minor TTS
# hallucinations; we just want to know "did 90%+ of the script's
# content make it into the audio." Using set overlap on the script
# side is robust to transcription noise.
matched = sum(1 for t in script_tokens if t in set(transcript_tokens))
accuracy = matched / max(1, len(script_tokens))
result["word_accuracy"] = round(accuracy, 3)
result["transcript_matches_script"] = accuracy >= 0.9 and not leak_occurrences
if accuracy < 0.9:
result["issues"].append(
f"Low transcript-to-script match: only {accuracy:.0%} of script "
f"words appear in the transcribed audio ({matched}/"
f"{len(script_tokens)}). Narration may be truncated, mispronounced, "
f"or the wrong script was used."
)
return result
def _run_final_review(
self,
output_path: Path,
edit_decisions: dict[str, Any] | None = None,
proposal_packet: dict[str, Any] | None = None,
narration_transcript_path: str | Path | None = None,
script_text: str | None = None,
) -> dict[str, Any]:
"""Run post-render self-review and produce a final_review artifact.
@@ -1706,12 +1895,24 @@ class VideoCompose(BaseTool):
issues.extend(subtitle_check.get("issues", []))
# --- 6. Determine overall status ---
# --- 6. Transcript-vs-script comparison ---
# Catches content-level TTS failures (the classic "Chirp reads `...`
# as the word 'dot'" trap) that volume-based audio checks miss.
# Only runs when caller provides both the transcript and script; when
# skipped, issues list records that so the silence is visible.
transcript_comparison = self._compare_transcript_to_script(
Path(narration_transcript_path) if narration_transcript_path else None,
script_text,
)
issues.extend(transcript_comparison.get("issues", []))
# --- 7. Determine overall status ---
critical_issues = [
i for i in issues
if any(kw in i.lower() for kw in [
"silent downgrade", "delivery promise violation",
"effectively silent", "ffprobe failed", "suspiciously short",
"tts punctuation leak", # reading literal punctuation aloud
])
]
@@ -1739,6 +1940,7 @@ class VideoCompose(BaseTool):
"audio_spotcheck": audio_spotcheck,
"promise_preservation": promise_preservation,
"subtitle_check": subtitle_check,
"transcript_comparison": transcript_comparison,
},
"issues_found": issues,
"recommended_action": recommended_action,