diff --git a/backend/cli.py b/backend/cli.py index df3692a..bee88a4 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -582,6 +582,12 @@ def cmd_process(args): "seconds": getattr(args, "name_card_seconds", None), "accent": getattr(args, "name_card_accent", None), } + if getattr(args, "motion", None): + try: + config["motion"] = json.loads(args.motion) + except (TypeError, ValueError): + print(" Warning: --motion is not valid JSON; using each style's own motion", + file=sys.stderr) if getattr(args, "bookend_fade", None) is not None: config["bookend_fade"] = args.bookend_fade if getattr(args, "thumbnails", None) is not None: @@ -1065,6 +1071,7 @@ def _transcribe_progress(pct, msg): outro_path=config.get("outro_path") or None, intro_path=config.get("intro_path") or None, name_card=config.get("name_card"), + motion=config.get("motion"), bookend_fade=config.get("bookend_fade", 0.0), keep_segments=clip.get("segments"), face_map=face_map, @@ -4008,6 +4015,9 @@ def main(): help="How long the lower third holds (default 3)") proc.add_argument("--name-card-accent", dest="name_card_accent", help="Underline colour on the lower third") + proc.add_argument("--motion", dest="motion", + help='How each part arrives and leaves, as JSON: ' + '{"captions":{"enter":"rise","exit":"fade","duration":5,"feel":"soft"}}') proc.add_argument("--bookend-fade", dest="bookend_fade", type=float, default=0.0, help="Seconds of crossfade into an intro or outro (default 0, a cut)") proc.add_argument("--no-outro", action="store_true", help="Do not append an outro (default for highlight profiles)") diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index 44f0e1c..1c5e2bc 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -466,6 +466,7 @@ def _render_with_remotion( time_offset: float = 0.0, logo_path: Optional[str] = None, name_card: Optional[dict] = None, + motion: Optional[dict] = None, keep_caption_overlay: bool = False, ) -> tuple[bool, Optional[str]]: """ @@ -599,6 +600,8 @@ def _render_with_remotion( cmd.extend(["--name-card-seconds", str(name_card["seconds"])]) if name_card.get("accent"): cmd.extend(["--name-card-accent", str(name_card["accent"])]) + if motion: + cmd.extend(["--motion", json.dumps(motion)]) if keep_caption_overlay: cmd.append("--keep-overlay") @@ -662,6 +665,7 @@ def generate_clip( outro_path: Optional[str] = None, intro_path: Optional[str] = None, name_card: Optional[dict] = None, + motion: Optional[dict] = None, bookend_fade: float = 0.0, clean_fillers: bool = True, keep_segments: list[dict] = None, @@ -922,6 +926,7 @@ def generate_clip( time_offset=caption_time_offset, logo_path=logo_path or None, name_card=name_card, + motion=motion, keep_caption_overlay=keep_caption_overlay, ) diff --git a/remotion/render.mjs b/remotion/render.mjs index 7e0fecf..5f14a1a 100644 --- a/remotion/render.mjs +++ b/remotion/render.mjs @@ -154,6 +154,17 @@ async function main() { } : null; + // How each part arrives and leaves. A JSON object rather than a flag per + // property: it is one value in a template, and it travels as one. + let motion = null; + if (opts.motion) { + try { + motion = JSON.parse(opts.motion); + } catch { + console.error("Ignoring --motion: not valid JSON"); + } + } + const inputProps = { videoSrc, words, @@ -161,6 +172,7 @@ async function main() { logoSrc, faceY, nameCard, + motion, durationInFrames, fps, }; diff --git a/remotion/src/CaptionedClip.tsx b/remotion/src/CaptionedClip.tsx index f99577d..0f35645 100644 --- a/remotion/src/CaptionedClip.tsx +++ b/remotion/src/CaptionedClip.tsx @@ -7,6 +7,8 @@ import { BrandedCaptions } from "./components/BrandedCaptions"; import { NameCard } from "./components/NameCard"; import type { NameCardProps } from "./components/NameCard"; import { Watermark } from "./components/Watermark"; +import { MOTION } from "./motion"; +import type { Motion } from "./motion"; import type { Word, CaptionStyle } from "./types"; export interface CaptionedClipProps { @@ -17,6 +19,8 @@ export interface CaptionedClipProps { faceY?: number | null; /** Who is speaking, shown for the first few seconds. */ nameCard?: NameCardProps | null; + /** Per-part overrides; each part falls back to its style's own motion. */ + motion?: { captions?: Partial; nameCard?: Partial } | null; } export const CaptionedClip: React.FC = ({ @@ -25,8 +29,13 @@ export const CaptionedClip: React.FC = ({ logoSrc, faceY, nameCard, + motion, }) => { const { height } = useVideoConfig(); + const captionMotion: Motion = { + ...(MOTION[style.name] ?? MOTION.subtle), ...(motion?.captions ?? {}), + }; + const cardMotion: Motion = { ...MOTION.nameCard, ...(motion?.nameCard ?? {}) }; const CaptionComponent = { hormozi: HormoziCaptions, karaoke: KaraokeCaptions, @@ -40,9 +49,9 @@ export const CaptionedClip: React.FC = ({ {style.name === "branded" ? ( ) : ( - + )} - {nameCard?.title && } + {nameCard?.title && } ); }; diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx index ded44ee..c3e210d 100644 --- a/remotion/src/Root.tsx +++ b/remotion/src/Root.tsx @@ -49,6 +49,10 @@ const inputProps = getInputProps() as { color?: string; accent?: string; } | null; + motion?: { + captions?: Record; + nameCard?: Record; + } | null; }; export const RemotionRoot: React.FC = () => { @@ -71,6 +75,7 @@ export const RemotionRoot: React.FC = () => { logoSrc: inputProps.logoSrc, faceY: inputProps.faceY ?? null, nameCard: inputProps.nameCard ?? null, + motion: inputProps.motion ?? null, }} /> = ({ words, style }) => { +export const HormoziCaptions: React.FC = ({ words, style, motion }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); const s = captionScale(height); @@ -29,21 +30,13 @@ export const HormoziCaptions: React.FC = ({ words, style }) => { if (!activeChunk) return null; - const entryFrame = Math.round(activeChunk.start * fps); - - const scale = spring({ - frame: frame - entryFrame, - fps, - config: { damping: 12, stiffness: 180, mass: 0.5 }, + const { opacity, scale } = motionAt({ + frame, fps, + start: activeChunk.start, + end: activeChunk.end, + motion: motion ?? MOTION.hormozi, }); - const opacity = interpolate( - frame - entryFrame, - [0, 3], - [0, 1], - { extrapolateRight: "clamp" } - ); - return (
= ({ background = "rgba(0,0,0,0.85)", color = "#FFFFFF", accent = "#2ED9C3", + motion, }) => { const frame = useCurrentFrame(); const { fps, height } = useVideoConfig(); @@ -38,8 +41,10 @@ export const NameCard: React.FC = ({ if (!title) return null; - const opacity = fadeInOut({ - frame, fps, start: 0, end: seconds, inFrames: 8, outFrames: 10, + const { opacity, shift } = motionAt({ + frame, fps, start: 0, end: seconds, + motion: motion ?? MOTION.nameCard, + scale: 12 * s, }); if (opacity <= 0) return null; @@ -56,7 +61,7 @@ export const NameCard: React.FC = ({ opacity, // Rises the last few pixels as it arrives, which reads as arriving // rather than appearing. - transform: `translateY(${(1 - opacity) * 12 * s}px)`, + transform: `translateY(${shift}px)`, }} >
= ({ words, style }) => { +export const SubtleCaptions: React.FC = ({ words, style, motion }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); const s = captionScale(height); @@ -31,19 +33,14 @@ export const SubtleCaptions: React.FC = ({ words, style }) => { if (!activeChunk) return null; - const entryFrame = Math.round(activeChunk.start * fps); - const opacity = fadeInOut({ - frame, fps, start: activeChunk.start, end: activeChunk.end, inFrames: 5, outFrames: 5, + const { opacity, shift: translateY } = motionAt({ + frame, fps, + start: activeChunk.start, + end: activeChunk.end, + motion: motion ?? MOTION.subtle, + scale: 8 * s, }); - // Slight upward slide on entry - const translateY = interpolate( - frame - entryFrame, - [0, 6], - [8 * s, 0], - { extrapolateRight: "clamp" } - ); - const [line1, line2] = splitIntoLines(activeChunk.words); const text1 = line1.map((w) => w.word).join(" "); const text2 = line2.map((w) => w.word).join(" "); diff --git a/remotion/src/motion.ts b/remotion/src/motion.ts index 8ea4c15..2e627d2 100644 --- a/remotion/src/motion.ts +++ b/remotion/src/motion.ts @@ -1,14 +1,55 @@ -import { interpolate } from "remotion"; +import { interpolate, spring } from "remotion"; + +/** + * How a part of a clip arrives and leaves. + * + * Every move a short actually makes is in this list, which is why it is a list + * and not a keyframe timeline: four values are editable in seconds, and a + * timeline is a surface people open once. If something real cannot be said + * here, that is the argument for keyframes, and not before. + * + * Each caption style ships the motion it already had, so a template that says + * nothing renders exactly what it rendered before. + */ +export type Motion = { + enter: "none" | "fade" | "rise" | "pop"; + exit: "none" | "fade" | "sink"; + /** Frames at the composition's fps, for the fading half of the move. */ + duration: number; + feel: "snap" | "soft" | "linear"; +}; + +/** Spring shapes, named for how they read rather than for their constants. */ +const FEEL: Record = { + snap: { damping: 12, stiffness: 180, mass: 0.5 }, + soft: { damping: 20, stiffness: 90, mass: 0.7 }, + linear: { damping: 200, stiffness: 100, mass: 1 }, +}; + +export const MOTION: Record = { + /** Word-by-word: springs up to size, no exit — the cut is the style. */ + hormozi: { enter: "pop", exit: "none", duration: 3, feel: "snap" }, + /** A line that is meant to be read, so it arrives and leaves quietly. */ + subtle: { enter: "rise", exit: "fade", duration: 5, feel: "soft" }, + /** Progressive highlight carries the movement; the block itself holds still. */ + karaoke: { enter: "none", exit: "none", duration: 0, feel: "linear" }, + /** The pill on the active word is the motion; the block holds still. */ + branded: { enter: "none", exit: "none", duration: 0, feel: "linear" }, + /** Names arrive, hold, and get out of the way. */ + nameCard: { enter: "rise", exit: "fade", duration: 8, feel: "soft" }, + /** Always there, so it neither arrives nor leaves. */ + watermark: { enter: "none", exit: "none", duration: 0, feel: "linear" }, +}; /** * Fade a thing in when it arrives and out before it leaves. * * Captions faded in and then cut, which reads as a flicker at every chunk - * boundary: the outgoing line vanishes on the same frame the incoming one - * starts at zero. Ramping the tail down inside the chunk's own window turns + * boundary: the outgoing line vanished on the same frame the incoming one + * started at zero. Ramping the tail down inside the part's own window turns * that into a crossfade without moving a single caption timing. * - * The out ramp is skipped on a chunk too short to hold full opacity, since a + * The out ramp is skipped on a window too short to hold full opacity, since a * line that fades in and straight back out never reads at all. */ export function fadeInOut({ @@ -25,10 +66,12 @@ export function fadeInOut({ const startFrame = Math.round(start * fps); const endFrame = Math.round(end * fps); - const rising = interpolate(frame - startFrame, [0, inFrames], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); + const rising = inFrames > 0 + ? interpolate(frame - startFrame, [0, inFrames], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }) + : 1; const held = endFrame - startFrame; if (!outFrames || held < inFrames + outFrames + 2) return rising; @@ -40,3 +83,51 @@ export function fadeInOut({ return Math.min(rising, falling); } + +/** + * One part's motion at one frame, as the two properties worth animating. + * + * `scale` is kept separate from `shift` so a caller can compose them in the + * order its layout needs; both are identity when the motion says none. + */ +export function motionAt({ + frame, fps, start, end, motion, scale: rise = 8, +}: { + frame: number; + fps: number; + start: number; + end: number; + motion: Motion; + /** How far a rise or a sink travels, already scaled to the canvas. */ + scale?: number; +}): { opacity: number; scale: number; shift: number } { + const startFrame = Math.round(start * fps); + const since = frame - startFrame; + + const opacity = fadeInOut({ + frame, fps, start, end, + inFrames: motion.enter === "none" ? 0 : motion.duration, + outFrames: motion.exit === "none" ? 0 : motion.duration, + }); + + const scale = motion.enter === "pop" + ? spring({ frame: since, fps, config: FEEL[motion.feel] }) + : 1; + + const entering = motion.enter === "rise" + ? interpolate(since, [0, motion.duration + 1], [rise, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }) + : 0; + + const endFrame = Math.round(end * fps); + const leaving = motion.exit === "sink" + ? interpolate(frame, [endFrame - motion.duration, endFrame], [0, rise], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }) + : 0; + + return { opacity, scale, shift: entering + leaving }; +} diff --git a/tests/test_clip_generator.py b/tests/test_clip_generator.py index b6ef7ee..1efbb35 100644 --- a/tests/test_clip_generator.py +++ b/tests/test_clip_generator.py @@ -1,3 +1,4 @@ +import json import os import shutil import subprocess @@ -105,6 +106,15 @@ def test_name_card_reaches_the_renderer(self): self.assertIn("Jamie Gull", argv) self.assertIn("--name-card-sub", argv) + def test_motion_reaches_the_renderer_as_one_value(self): + argv = self._render_args( + caption_style="subtle", + motion={"captions": {"enter": "pop", "exit": "sink", "duration": 8, "feel": "soft"}}, + ) + self.assertIn("--motion", argv) + payload = json.loads(argv[argv.index("--motion") + 1]) + self.assertEqual(payload["captions"]["enter"], "pop") + def test_remotion_runtime_failure_does_not_disable_future_clips(self): real_exists = os.path.exists fail_result = subprocess.CompletedProcess(