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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user