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
+17
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}
/>
</>
);
};
+196
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>
);
};