docmontage: make music + end-tag mandatory, add Remotion EndTag component

User override during the audit runs made music and an end-tag MANDATORY
defaults for documentary-montage (narration stays optional). This commit
codifies those defaults into the pipeline manifest + director skills and
ships the Remotion end-tag component that the compose stage now
concatenates after the FFmpeg body.

Changes:

- pipeline_defs/documentary-montage.yaml: idea/edit/compose stages now
  require music_plan and end_tag_plan to be present; opt-out requires
  an explicit user note recorded in metadata.

- skills/pipelines/documentary-montage/idea-director.md: rewrote sections
  4 (music MANDATORY), 5 (end-tag MANDATORY with shape
  {text,palette,duration_seconds,render_engine:remotion,component:EndTag}),
  6 (narration OPTIONAL), 7 (updated brief JSON shape), 8 (quality gate).
  Common Pitfalls grew two entries covering silent-contract drift and
  end-tag omission.

- skills/pipelines/documentary-montage/compose-director.md: section 4
  now stops on music-contract violation; new section 4b documents the
  two-engine flow (FFmpeg body -> npx remotion render EndTag -> ffmpeg
  concat). Quality gate checks render_report.metadata.music_mixed and
  end_tag_rendered.

- remotion-composer/src/components/EndTag.tsx (new): bold uppercase
  typographic end-card with animated 0->100% underline draw-in and a
  single left-to-right shimmer sweep. Supports cool_offwhite_on_black
  and warm_ivory_on_black palettes. Renders at 1920x1080 @ 30fps.

- remotion-composer/src/Root.tsx: register EndTag as a first-class
  composition (durationInFrames=165, 5.5s hold) so it can be rendered
  standalone via the Remotion CLI with --props overrides.
This commit is contained in:
calesthio
2026-04-11 00:42:53 -07:00
parent de94d4dba3
commit b80b7a0a61
5 changed files with 351 additions and 18 deletions

View File

@@ -54,11 +54,14 @@ stages:
- Thematic question is ONE sentence
- Tone register is ONE value from the fixed list
- Duration and shape are concrete
- Music plan is resolved or explicitly silent
- Music plan is present (MANDATORY — silent only if user explicitly opted out)
- End-tag plan is present (MANDATORY — one philosophical closing line, rendered as Remotion end-card, unless user explicitly opted out)
- Narration plan is present (narration itself is OPTIONAL — absence is fine if music + visuals + end-tag carry the register)
success_criteria:
- Schema-valid brief artifact
- thematic_question present in metadata
- music_plan present in metadata
- music_plan present in metadata (source may be `none` ONLY with explicit user opt-out note)
- end_tag_plan present in metadata (text, palette, duration — may be `null` ONLY with explicit user opt-out note)
- name: scene_plan
skill: pipelines/documentary-montage/scene-director
@@ -125,8 +128,9 @@ stages:
- Hero slots hold longest; mid-sequence cutaways shortest
- No two adjacent cuts share subject AND scale
- Transition vocabulary is at most 4 distinct values
- Music config is present OR brief explicitly says no music
- total_duration_seconds matches sum of cut durations
- Music config is present (MANDATORY — silent only if brief.metadata.music_plan.source=none with explicit opt-out note)
- End-tag cut is present at the tail (MANDATORY — rendered separately via Remotion, concatenated after body, unless brief.metadata.end_tag_plan is null with explicit opt-out note)
- total_duration_seconds matches sum of cut durations INCLUDING the end-tag hold
- Every cut has a reason
success_criteria:
- Schema-valid edit_decisions artifact
@@ -156,12 +160,15 @@ stages:
checkpoint_required: true
human_approval_default: false
review_focus:
- Output duration matches planned within 1s
- Output duration matches planned within 1s (body + end-tag inclusive)
- Resolution matches target_platform canvas
- Uniform LUT applied across the timeline
- Music present iff brief planned for it
- First and last frames verified
- Music is mixed in (MANDATORY — silent output only with explicit user opt-out recorded in brief)
- End-tag MP4 rendered via Remotion and concatenated at the tail (MANDATORY — absence only with explicit opt-out)
- First and last frames verified (last frame must be the end-tag card unless opted out)
- No silent fallback from a motion-led promise
success_criteria:
- Schema-valid render_report artifact
- Output file exists and passes ffprobe validation
- render_report.end_tag_rendered = true (or explicit opt-out)
- render_report.music_mixed = true (or explicit opt-out)

View File

@@ -10,6 +10,7 @@ import {
TitledVideo,
calculateTitledVideoMetadata,
} from "./TitledVideo";
import { EndTag, EndTagProps } from "./components/EndTag";
// ---------------------------------------------------------------------------
// Theme System — prevents every video from looking like dark fintech
@@ -203,6 +204,22 @@ export const Root: React.FC = () => {
}}
calculateMetadata={calculateTitledVideoMetadata}
/>
<Composition
id="EndTag"
component={EndTag}
// 5.5s at 30fps = 165 frames. Render CLI can override via --props.
durationInFrames={165}
fps={30}
width={1920}
height={1080}
defaultProps={{
text: "THE CITY KEEPS ITS OWN VIGIL.",
palette: "cool_offwhite_on_black",
fadeInSeconds: 0.6,
holdSeconds: 4.3,
fadeOutSeconds: 0.6,
} as EndTagProps}
/>
</>
);
};

View File

@@ -0,0 +1,196 @@
import {
AbsoluteFill,
interpolate,
Easing,
useCurrentFrame,
useVideoConfig,
} from "remotion";
export interface EndTagProps {
text: string;
palette?: "cool_offwhite_on_black" | "warm_ivory_on_black";
// Optional extra fade hold controls (all in seconds)
fadeInSeconds?: number;
holdSeconds?: number;
fadeOutSeconds?: number;
}
const PALETTES = {
cool_offwhite_on_black: {
background: "#000000",
text: "#F5F7FA",
underline: "#EAECEF",
shine: "rgba(255,255,255,0.95)",
},
warm_ivory_on_black: {
background: "#000000",
text: "#F5EBD5",
underline: "#E6D4A8",
shine: "rgba(255,238,200,0.95)",
},
} as const;
/**
* EndTag — a philosophical closing card for documentary-montage films.
*
* Renders a bold, letter-spaced, uppercase line of text on a black
* canvas with an animated underline that draws in, then receives a
* single left-to-right shimmer pass. Fades in, holds, fades out.
*
* Intended usage: rendered as a standalone Remotion composition, then
* concatenated after the FFmpeg-composed body of the montage. This
* sidesteps the scene-adapter gap in video_compose.render and keeps
* the two engines (FFmpeg for footage, Remotion for typography)
* cleanly separated.
*/
export const EndTag: React.FC<EndTagProps> = ({
text,
palette = "cool_offwhite_on_black",
fadeInSeconds = 0.6,
holdSeconds = 4.3,
fadeOutSeconds = 0.6,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pal = PALETTES[palette];
// Timing in frames
const fadeInFrames = Math.round(fadeInSeconds * fps);
const holdFrames = Math.round(holdSeconds * fps);
const fadeOutFrames = Math.round(fadeOutSeconds * fps);
// Full opacity envelope: fade in -> hold -> fade out
const fadeInEnd = fadeInFrames;
const fadeOutStart = fadeInEnd + holdFrames;
const fadeOutEnd = fadeOutStart + fadeOutFrames;
const opacity = interpolate(
frame,
[0, fadeInEnd, fadeOutStart, fadeOutEnd],
[0, 1, 1, 0],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.ease),
}
);
// Underline draws in AFTER the text has faded to full (0.2s lag),
// and takes 0.9s to reach full width
const underlineStartFrame = fadeInEnd + Math.round(0.2 * fps);
const underlineDrawFrames = Math.round(0.9 * fps);
const underlineWidthPct = interpolate(
frame,
[underlineStartFrame, underlineStartFrame + underlineDrawFrames],
[0, 100],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.ease),
}
);
// Shimmer pass: starts 0.3s after the underline finishes drawing,
// travels left-to-right across the underline for 1.2s, then parks.
const shimmerStartFrame =
underlineStartFrame + underlineDrawFrames + Math.round(0.3 * fps);
const shimmerTravelFrames = Math.round(1.2 * fps);
const shimmerPosPct = interpolate(
frame,
[shimmerStartFrame, shimmerStartFrame + shimmerTravelFrames],
[-40, 140],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.ease),
}
);
const shimmerVisible =
frame >= shimmerStartFrame &&
frame <= shimmerStartFrame + shimmerTravelFrames;
return (
<AbsoluteFill
style={{
backgroundColor: pal.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
opacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: "86%",
}}
>
{/* The tag line */}
<div
style={{
fontFamily:
"'Space Grotesk', 'Inter', 'Helvetica Neue', system-ui, sans-serif",
fontWeight: 900,
fontSize: 84,
letterSpacing: "0.12em",
lineHeight: 1.18,
color: pal.text,
textAlign: "center",
textTransform: "uppercase",
// soft drop-shadow so the letters read cleanly on black
// without looking neon
textShadow:
"0 2px 12px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6)",
}}
>
{text}
</div>
{/* The underline — draws in, then gets a single shimmer pass */}
<div
style={{
marginTop: 38,
position: "relative",
width: 820,
maxWidth: "100%",
height: 5,
}}
>
{/* Underline body (width interpolates from 0 -> 100%) */}
<div
style={{
position: "absolute",
left: "50%",
top: 0,
transform: "translateX(-50%)",
width: `${underlineWidthPct}%`,
height: 5,
backgroundColor: pal.underline,
borderRadius: 3,
boxShadow: `0 0 18px 0 ${pal.underline}33`,
}}
/>
{/* Shimmer highlight travelling across the underline */}
{shimmerVisible && (
<div
style={{
position: "absolute",
left: `${shimmerPosPct}%`,
top: -3,
width: 160,
height: 11,
transform: "translateX(-50%)",
background: `linear-gradient(90deg, transparent 0%, ${pal.shine} 50%, transparent 100%)`,
filter: "blur(2px)",
pointerEvents: "none",
}}
/>
)}
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -131,8 +131,60 @@ and L-cut sfx layers. Your job is to execute them faithfully:
- L-cut SFX layers = mix at 0.5-0.7 volume, under music.
- No narration unless explicitly present in `edit_decisions.audio.narration`.
**Music is MANDATORY.** If the edit has no music entry, check the brief:
- `brief.metadata.music_plan.source == "none"` with an `opt_out_reason`
the user explicitly opted out. Render silent and note it in
`render_report.warnings`.
- Anything else → STOP. This is a contract violation. Surface it to
the user before rendering. A silent render on a music-mandatory brief
is the loudest failure mode in this pipeline.
Do NOT add ambient noise "to fill the gap".
### 4b. Render The End-Tag Via Remotion, Concatenate After Body
The end-tag is rendered **separately** from the FFmpeg body and
concatenated at the tail. This is deliberate — it sidesteps the
`video_compose.render` scene-adapter mismatch and keeps the two
render engines (FFmpeg for footage, Remotion for typography) cleanly
separated.
Read `brief.metadata.end_tag_plan`:
```json
{
"text": "WE BUILT BOTH WITH THE SAME HANDS.",
"palette": "warm_ivory_on_black",
"duration_seconds": 5.5,
"render_engine": "remotion",
"component": "EndTag"
}
```
Execution path:
1. Compose the body via FFmpeg (cuts + LUT + music + silence window).
Save as `projects/<name>/renders/body.mp4`.
2. Render the end-tag via Remotion CLI with component-specific props:
`npx remotion render EndTag --props='{"text":"...", "palette":"...","durationInFrames":132}' projects/<name>/renders/end_tag.mp4`
(5.5s at 24fps = 132 frames). Canvas must match body canvas.
3. Concat body + end_tag with `ffmpeg -f concat -safe 0 -i list.txt -c copy final.mp4`
or, if encoders don't match, re-encode with the documentary spec.
**End-tag is MANDATORY.** The ONLY way to skip it is an explicit user
opt-out recorded as `end_tag_plan: null` with an `end_tag_opt_out_reason`.
If the brief has an end-tag plan but you skipped rendering it, that is
a contract violation. Stop and surface before finalizing.
Record in `render_report`:
- `end_tag_rendered: true | false`
- `end_tag_path: "projects/<name>/renders/end_tag.mp4"`
- `end_tag_text: "..."` (for audit trail)
If the brief says "no music" and the edit correctly has no music
entry, render silent. Do NOT add ambient noise "to fill the gap".
entry AND `music_plan.source == "none"` with an opt-out reason, render
silent. Do NOT add ambient noise "to fill the gap".
### 5. Render At Documentary Spec
@@ -208,14 +260,16 @@ Record verifications in `render_report.verification_notes`.
### 8. Quality Gate
- Output file exists and plays.
- Duration within ±1s of `brief.duration_seconds`.
- Duration within ±1s of `brief.duration_seconds` (body + end-tag inclusive).
- Resolution matches `target_platform` canvas.
- LUT was applied (or a warning logged).
- Music is present iff the brief planned for it.
- **Music is present** unless `brief.metadata.music_plan.source == "none"` with an explicit opt-out reason.
- **End-tag MP4 was rendered and concatenated** unless `brief.metadata.end_tag_plan` is null with an explicit opt-out reason. Last frame of final MP4 must be the end-tag card in that case.
- First and last frames verified.
- Silence window (if any) verified in the waveform.
- No narration unless brief-approved.
- `render_report.warnings` lists every substitution.
- `render_report.metadata.music_mixed = true` and `render_report.metadata.end_tag_rendered = true` (or explicit opt-out recorded).
## Common Pitfalls

View File

@@ -71,19 +71,60 @@ Shape options:
- **list/catalogue** — "everyone who..." structure, no arc, just
accumulation (good for reverent or elegiac)
### 4. Note Music Intent
### 4. Note Music Intent (MANDATORY)
Documentary montage is inseparable from its music bed. Decide now:
Documentary montage is inseparable from its music bed. **Music is MANDATORY
for this pipeline.** The ONLY way out is an explicit user opt-out (e.g.
"no music, I want it silent") — which MUST be recorded as
`music_plan.source = "none"` with a `music_plan.opt_out_reason` field.
Silent-by-design briefs that feel "pure" at the idea stage regularly look
like abandoned footage at compose time. Do not assume silence will earn
itself. If the user has not mentioned music, ASSUME THEY WANT IT and pick:
- user-provided track (put path in `music_plan.source_path`),
- music library pick (list what's in `music_library/`),
- generated (name the tool and prompt seed),
- or none (silence).
- generated (name the tool and prompt seed with register),
- explicit opt-out (`source: "none"` + `opt_out_reason`).
**Warn the user if no music source is available.** Do not silently
defer this — it becomes an expensive surprise at the asset stage.
### 5. Record The Brief
### 5. Note End-Tag Intent (MANDATORY)
Every documentary-montage film closes on a philosophical end-tag — one
short, abstract line that gives the whole thing meaning. It is rendered
as a Remotion end-card ("shining underlined tag" register — bold weight,
letter-spaced, animated underline) and concatenated after the last clip.
**End-tag is MANDATORY.** The ONLY way out is an explicit user opt-out
recorded as `end_tag_plan: null` with an `end_tag_opt_out_reason` field.
Propose the end-tag at the brief stage. Write 3 options and recommend one.
Expected shape:
```json
{
"end_tag_plan": {
"text": "WE BUILT BOTH WITH THE SAME HANDS.",
"palette": "warm_ivory_on_black",
"duration_seconds": 5.5,
"render_engine": "remotion",
"component": "EndTag"
}
}
```
Keep the copy to 3-9 words. It must be a thesis, not a summary.
### 6. Note Narration Intent (OPTIONAL)
Unlike music and end-tag, narration is OPTIONAL. Absence is fine if
visuals + music + end-tag carry the register. If narration IS used, name
the TTS provider and voice. Record `narration: "none"` explicitly if
there's no narration — don't leave the field missing.
### 7. Record The Brief
Minimum fields the brief must carry:
@@ -97,7 +138,18 @@ Minimum fields the brief must carry:
"sources_allowed": ["pexels", "archive_org", "nasa"],
"generated_clips_allowed": false,
"narration": "none",
"music_plan": { "source": "library", "path": "music_library/dawn_04.mp3" },
"music_plan": {
"source": "generated",
"provider": "elevenlabs",
"prompt_seed": "slow ambient drone in A minor, no percussion, 60s sustained swell, Max Richter register"
},
"end_tag_plan": {
"text": "THE CITY KEEPS ITS OWN VIGIL.",
"palette": "cool_offwhite_on_black",
"duration_seconds": 5.5,
"render_engine": "remotion",
"component": "EndTag"
},
"era_mix": "any",
"target_platform": "social_short"
}
@@ -107,12 +159,15 @@ Minimum fields the brief must carry:
Pexels, "vintage" biases toward Archive.org Prelinger, "any" leaves it
open for the scene director to decide per slot.
### 6. Quality Gate
### 8. Quality Gate
- Thematic question is ONE sentence.
- Tone is ONE register from the fixed list.
- Duration and shape are concrete numbers / enum values.
- Music source is named OR the brief explicitly says "no music".
- `music_plan` is present AND either names a real source OR has
`source: "none"` + `opt_out_reason` (explicit user decision).
- `end_tag_plan` is present AND either has a non-empty `text` OR is
`null` with `end_tag_opt_out_reason` (explicit user decision).
- Sources list is non-empty and at least one requested source is
`available` per `corpus_builder.source_provider_menu` surfaced in
preflight.
@@ -125,3 +180,7 @@ open for the scene director to decide per slot.
- Ignoring duration. A 45s piece with 50 cuts is nausea. A 3-minute
piece with 12 cuts is a slideshow.
- Forgetting to ask about music. The user usually has an opinion.
- Assuming silence will earn itself. It won't. Music is mandatory unless
the user explicitly says no.
- Skipping the end-tag because "the images speak for themselves". They
don't — the end-tag is the thesis. Propose one every time.