mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-05 15:20:40 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
196
remotion-composer/src/components/EndTag.tsx
Normal file
196
remotion-composer/src/components/EndTag.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user