From b80b7a0a61ead27a1af0c3129c8b11cfea24d63d Mon Sep 17 00:00:00 2001 From: calesthio Date: Sat, 11 Apr 2026 00:42:53 -0700 Subject: [PATCH] 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. --- pipeline_defs/documentary-montage.yaml | 21 +- remotion-composer/src/Root.tsx | 17 ++ remotion-composer/src/components/EndTag.tsx | 196 ++++++++++++++++++ .../documentary-montage/compose-director.md | 60 +++++- .../documentary-montage/idea-director.md | 75 ++++++- 5 files changed, 351 insertions(+), 18 deletions(-) create mode 100644 remotion-composer/src/components/EndTag.tsx diff --git a/pipeline_defs/documentary-montage.yaml b/pipeline_defs/documentary-montage.yaml index 4102b81b..611edc18 100644 --- a/pipeline_defs/documentary-montage.yaml +++ b/pipeline_defs/documentary-montage.yaml @@ -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) diff --git a/remotion-composer/src/Root.tsx b/remotion-composer/src/Root.tsx index 2ce866f3..bbc163e6 100644 --- a/remotion-composer/src/Root.tsx +++ b/remotion-composer/src/Root.tsx @@ -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} /> + ); }; diff --git a/remotion-composer/src/components/EndTag.tsx b/remotion-composer/src/components/EndTag.tsx new file mode 100644 index 00000000..16447d56 --- /dev/null +++ b/remotion-composer/src/components/EndTag.tsx @@ -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 = ({ + 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 ( + +
+ {/* The tag line */} +
+ {text} +
+ + {/* The underline — draws in, then gets a single shimmer pass */} +
+ {/* Underline body (width interpolates from 0 -> 100%) */} +
+ + {/* Shimmer highlight travelling across the underline */} + {shimmerVisible && ( +
+ )} +
+
+ + ); +}; diff --git a/skills/pipelines/documentary-montage/compose-director.md b/skills/pipelines/documentary-montage/compose-director.md index fbe9af28..50a0da52 100644 --- a/skills/pipelines/documentary-montage/compose-director.md +++ b/skills/pipelines/documentary-montage/compose-director.md @@ -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//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//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//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 diff --git a/skills/pipelines/documentary-montage/idea-director.md b/skills/pipelines/documentary-montage/idea-director.md index 47a7031d..f854eb5e 100644 --- a/skills/pipelines/documentary-montage/idea-director.md +++ b/skills/pipelines/documentary-montage/idea-director.md @@ -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.