From fb5996234a2c96cfa796e5e38957438cb319cfc2 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Mon, 10 Aug 2026 10:05:59 +0400 Subject: [PATCH] Motion becomes a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every part of a clip moved according to numbers written into its own component: hormozi sprang with damping 12, subtle slid eight pixels over six frames, the name card faded over eight. Changing any of it meant editing a component, which is the same as saying it could not be changed. It is four values now — enter, exit, duration, feel — attached to a part and nothing else. Four, not a keyframe timeline: every move a short actually makes is in that list, and a timeline is a surface people open once. If something real cannot be said this way, that is the argument for keyframes, and not before. Each style ships the motion it already had, so this changes nothing on its own. Verified by hash: hormozi, karaoke, subtle and branded all render the same frame before and after, and an overridden motion renders a different one. Carried as one JSON value rather than a flag per property, because that is how it will arrive from a template. --- backend/cli.py | 10 ++ backend/services/clip_generator.py | 5 + remotion/render.mjs | 12 +++ remotion/src/CaptionedClip.tsx | 13 ++- remotion/src/Root.tsx | 5 + remotion/src/components/HormoziCaptions.tsx | 25 ++--- remotion/src/components/KaraokeCaptions.tsx | 2 + remotion/src/components/NameCard.tsx | 13 ++- remotion/src/components/SubtleCaptions.tsx | 25 ++--- remotion/src/motion.ts | 107 ++++++++++++++++++-- tests/test_clip_generator.py | 10 ++ 11 files changed, 183 insertions(+), 44 deletions(-) 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(