diff --git a/backend/cli.py b/backend/cli.py index 54c62f56..b6e09510 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -105,6 +105,25 @@ def _json_file_arg(raw: str | None, name: str): return None +def _json_object_arg(raw: str | None, name: str): + """A JSON object argument, or nothing. Malformed or non-object stops the run. + + Unlike `_json_file_arg`, this one refuses rather than warns: a style theme + silently ignored would render with the wrong look and nothing to say why. + """ + if not raw: + return None + try: + parsed = json.loads(raw) + except (ValueError, TypeError) as exc: + print(f"Error: {name} is not valid JSON ({exc})", file=sys.stderr) + sys.exit(1) + if not isinstance(parsed, dict): + print(f"Error: {name} must be a JSON object", file=sys.stderr) + sys.exit(1) + return parsed + + def _cached_face_map(video_path: str): """Face maps are keyed by video content, not by transcript, so an imported transcript can still borrow the map from an earlier run on the same file.""" @@ -538,6 +557,8 @@ def _pass_through(value): cmd += ["--cards", args.cards] if getattr(args, "brand", None): cmd += ["--brand", args.brand] + if getattr(args, "style", None): + cmd += ["--style", args.style] if getattr(args, "font_family", None): cmd += ["--font-family", args.font_family] if args.outro_title is not None: @@ -759,6 +780,8 @@ def cmd_process(args): except (ValueError, TypeError): print(" Warning: --brand is not valid JSON; using the default colours", file=sys.stderr) + if getattr(args, "style", None): + config["style"] = _json_object_arg(args.style, "--style") if getattr(args, "font_family", None): config["font_family"] = args.font_family if getattr(args, "no_outro", False): @@ -1312,6 +1335,7 @@ def _transcribe_progress(pct, msg): topic=config.get("topic"), progress=config.get("progress"), brand=config.get("brand"), + theme=config.get("style"), font_family=config.get("font_family"), crop_strategy=config.get("crop_strategy", "face"), format=config.get("format", "vertical"), @@ -1541,6 +1565,7 @@ def _transcribe_progress(pct, msg): topic=config.get("topic"), progress=config.get("progress"), brand=config.get("brand"), + theme=config.get("style"), font_family=config.get("font_family"), crop_strategy=config.get("crop_strategy", "face"), format=config.get("format", "vertical"), @@ -1881,6 +1906,7 @@ def _rerender_clip(r): face_map=face_map, allow_ass_fallback=config.get("allow_ass_fallback", False), use_ass_captions=config.get("use_ass_captions", False), + theme=config.get("style"), ) r["result"] = new_result ok = True @@ -2067,6 +2093,7 @@ def _rerender_clip(r): face_map=face_map, allow_ass_fallback=config.get("allow_ass_fallback", False), use_ass_captions=config.get("use_ass_captions", False), + theme=config.get("style"), ) rendered.append({"clip": f_clip, "result": new_result, "index": len(clips)}) clips.append(f_clip) @@ -2123,6 +2150,7 @@ def _rerender_clip(r): face_map=face_map, allow_ass_fallback=config.get("allow_ass_fallback", False), use_ass_captions=config.get("use_ass_captions", False), + theme=config.get("style"), ) rendered.append({"clip": nc, "result": new_result, "index": len(clips)}) clips.append(nc) @@ -4499,6 +4527,8 @@ def main(): help="Colour of the progress bar, e.g. '#3B9CFF'") proc.add_argument("--brand", help="Show colours as JSON: " '{"accent":"#4C9DF5","ink":"#FFFFFF","surface":"#0A0D14"}') + proc.add_argument("--style", help='Visual theme as JSON: ' + '{"pack":"collage","motion":"stop-motion"}') proc.add_argument("--font-family", dest="font_family", help="The show's own typeface, ahead of the built-in stack") proc.add_argument("--outro", help="Outro video (asset name or path)") @@ -4590,6 +4620,8 @@ def main(): studio.add_argument("--cards", help="On-screen cards as JSON, each with kind/start/end") studio.add_argument("--brand", help="Show colours as JSON: " '{"accent":"#4C9DF5","ink":"#FFFFFF","surface":"#0A0D14"}') + studio.add_argument("--style", help='Visual theme as JSON: ' + '{"pack":"collage","motion":"stop-motion"}') studio.add_argument("--font-family", dest="font_family", help="The show's own typeface, ahead of the built-in stack") studio.add_argument("--intro", help="Intro video (asset name or path)") diff --git a/backend/clip_studio.py b/backend/clip_studio.py index e0cad6db..02948df8 100644 --- a/backend/clip_studio.py +++ b/backend/clip_studio.py @@ -161,6 +161,19 @@ def _json_arg(raw, name): return None +def _json_object_arg(raw, name): + """A JSON object argument, or nothing. Malformed or non-object stops the run.""" + if not raw: + return None + try: + parsed = json.loads(raw) + except (ValueError, TypeError) as exc: + raise SystemExit(f"Error: {name} is not valid JSON ({exc})") + if not isinstance(parsed, dict): + raise SystemExit(f"Error: {name} must be a JSON object") + return parsed + + def _json_file(raw, name): """JSON given inline or named as a file, or nothing. @@ -235,7 +248,7 @@ def _face_map_for(video, crop, start, end): def _render_fragment(video, start, end, words, style, crop, title, out_dir, fmt="vertical", logo=None, name_card=None, motion=None, caption_position="auto", caption_scale=1.0, logo_position="top-left", logo_scale=1.0, - topic=None, progress=None, cards=None, brand=None, font_family=None, + topic=None, progress=None, cards=None, brand=None, theme=None, font_family=None, captions=True, face_map=None, crop_keyframes=None): """Render the fragment with face-crop + captions via the existing engine.""" from services.clip_generator import generate_clip @@ -249,7 +262,7 @@ def _render_fragment(video, start, end, words, style, crop, title, out_dir, fmt= face_map=face_map, crop_keyframes=crop_keyframes, transcript_words=words, title=title, output_dir=out_dir, logo_path=logo, name_card=name_card, motion=motion, - topic=topic, progress=progress, cards=cards, brand=brand, font_family=font_family, + topic=topic, progress=progress, cards=cards, brand=brand, theme=theme, font_family=font_family, captions=captions, clean_fillers=True, allow_ass_fallback=True, progress_callback=lambda p, m: print(f" {p}% {m}", flush=True), @@ -377,6 +390,8 @@ def main(): help="On-screen cards as JSON, each with kind/start/end") ap.add_argument("--brand", default=None, help='Show colours as JSON: {"accent":"#4C9DF5","ink":"#FFF","surface":"#000"}') + ap.add_argument("--style", default=None, + help='Visual theme as JSON: {"pack":"collage","motion":"stop-motion"}') ap.add_argument("--font-family", default=None, help="The show's own typeface, ahead of the built-in stack") ap.add_argument("--intro", default=None, help="Intro video (asset name or path)") @@ -397,6 +412,8 @@ def main(): help="Save the given handle/platforms/outro-title/accent/bg as the default brand and exit") args = ap.parse_args() + style_theme = _json_object_arg(args.style, "--style") + # Resolve brand fields: CLI flag > saved brand.json > BRAND_DEFAULTS brand = {**BRAND_DEFAULTS, **_load_brand()} handle = args.handle if args.handle is not None else brand["handle"] @@ -489,6 +506,7 @@ def media_path(value: str | None, kind: str) -> str | None: ), cards=_json_arg(args.cards, "--cards"), brand=_json_arg(args.brand, "--brand"), + theme=style_theme, font_family=args.font_family, captions=not args.no_captions, face_map=face_map, diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index 2901bc1b..314f390d 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -316,7 +316,24 @@ def _render_transition_autofix_passes( ) -def _auto_fix_transition_jumps(video_path: str, max_passes: int = 1) -> bool: +DESIGNED_CUT_TOLERANCE = 0.3 + + +def _designed_cuts(cards: Optional[list], offset: float = 0.0) -> list[float]: + times: list[float] = [] + for card in cards or []: + if not isinstance(card, dict): + continue + for key in ("start", "end"): + value = card.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + times.append(float(value) + offset) + return times + + +def _auto_fix_transition_jumps( + video_path: str, max_passes: int = 1, designed: Optional[list[float]] = None, +) -> bool: """ Bounded auto-fix for jumpy transitions. Never loops indefinitely. Returns True if at least one fix pass succeeded. @@ -329,7 +346,10 @@ def _auto_fix_transition_jumps(video_path: str, max_passes: int = 1) -> bool: for i in range(max_passes): duration = _get_media_duration(current) scene_threshold = 0.22 if i == 0 else 0.18 - cuts = _detect_scene_cuts(current, threshold=scene_threshold) + cuts = [ + t for t in _detect_scene_cuts(current, threshold=scene_threshold) + if all(abs(t - d) > DESIGNED_CUT_TOLERANCE for d in designed or []) + ] problematic = _select_problematic_scene_cuts(cuts, duration) if not problematic: break @@ -599,6 +619,7 @@ def _render_with_remotion( cards: Optional[list] = None, brand: Optional[dict] = None, font_family: Optional[str] = None, + theme: Optional[dict] = None, captions: bool = True, ) -> tuple[bool, Optional[str]]: """ @@ -766,6 +787,8 @@ def _render_with_remotion( cmd.extend(["--brand", json.dumps(brand)]) if font_family: cmd.extend(["--font-family", str(font_family)]) + if theme: + cmd.extend(["--theme", json.dumps(theme)]) if keep_caption_overlay: cmd.append("--keep-overlay") @@ -846,6 +869,7 @@ def generate_clip( cards: Optional[list] = None, brand: Optional[dict] = None, font_family: Optional[str] = None, + theme: Optional[dict] = None, captions: bool = True, progress_callback: Optional[Callable[[int, str], None]] = None, ) -> dict: @@ -1113,7 +1137,7 @@ def generate_clip( # screen at all", and a clip that had cards to draw ran the pass and # burned the default caption style over a file that had asked for none. wants_overlay = bool( - logo_path or topic or progress or cards + logo_path or topic or progress or cards or theme or (name_card and name_card.get("title")) ) if not captions and wants_overlay: @@ -1184,6 +1208,7 @@ def generate_clip( cards=cards, brand=brand, font_family=font_family, + theme=theme, captions=captions, ) @@ -1268,6 +1293,7 @@ def generate_clip( # concat_outro joins two clips head-to-tail, so passing the intro first # puts it in front of the clip. final_video_path = normalized_path + intro_offset = 0.0 if intro_path and os.path.exists(intro_path): if progress_callback: progress_callback(83, "Adding intro") @@ -1278,9 +1304,11 @@ def generate_clip( intro_scaled = os.path.join(work_dir, "intro_scaled.mp4") scale_to_frame(intro_path, intro_scaled, cw, ch) with_intro_path = os.path.join(work_dir, "with_intro.mp4") + clip_duration = _get_media_duration(final_video_path) concat_outro(intro_scaled, final_video_path, with_intro_path, crossfade_duration=bookend_fade) final_video_path = with_intro_path + intro_offset = max(0.0, _get_media_duration(with_intro_path) - clip_duration) if outro_path and os.path.exists(outro_path): if progress_callback: @@ -1318,7 +1346,11 @@ def generate_clip( if max_autofix_passes > 0: if progress_callback: progress_callback(97, "Quality gate: checking transitions...") - _auto_fix_transition_jumps(final_path, max_passes=max_autofix_passes) + _auto_fix_transition_jumps( + final_path, + max_passes=max_autofix_passes, + designed=_designed_cuts(cards, offset=intro_offset), + ) # Get file size file_size = os.path.getsize(final_path) diff --git a/remotion/bundle-cache.mjs b/remotion/bundle-cache.mjs index 017c7a33..e8b9dff6 100644 --- a/remotion/bundle-cache.mjs +++ b/remotion/bundle-cache.mjs @@ -20,6 +20,7 @@ const LOCK_DIR = `${CACHE_DIR}.lock`; const LOCK_STALE_MS = 5 * 60 * 1000; const LOCK_HEARTBEAT_MS = Math.floor(LOCK_STALE_MS / 3); const ENTRY_POINT = path.join(__dirname, "src", "index.ts"); +const PUBLIC_DIR = path.join(__dirname, "public"); /** * A bundle is a product of the compositions, the webpack config, and the @@ -39,7 +40,9 @@ function currentHash() { } walk(path.join(__dirname, "src")); + if (fs.existsSync(PUBLIC_DIR)) walk(PUBLIC_DIR); hash.update(fs.readFileSync(path.join(__dirname, "webpack-override.mjs"))); + hash.update(fs.readFileSync(fileURLToPath(import.meta.url))); hash.update(`remotion@${require("remotion/package.json").version}`); hash.update(`@remotion/bundler@${require("@remotion/bundler/package.json").version}`); return hash.digest("hex"); @@ -148,6 +151,7 @@ export async function getCachedBundle({ onBundle } = {}) { await bundle({ entryPoint: ENTRY_POINT, outDir: staging, + publicDir: PUBLIC_DIR, webpackOverride, }); if (!ownsLock(lock)) { diff --git a/remotion/bundle-cache.test.mjs b/remotion/bundle-cache.test.mjs index a81ef2e5..ec2ea39b 100644 --- a/remotion/bundle-cache.test.mjs +++ b/remotion/bundle-cache.test.mjs @@ -1,11 +1,14 @@ import fs from "fs"; import os from "os"; import path from "path"; +import { fileURLToPath } from "url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { bundle } from "@remotion/bundler"; vi.mock("@remotion/bundler", () => ({ bundle: vi.fn() })); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const savedCacheDir = process.env.PODCLI_CACHE_DIR; const temporaryDirectories = []; @@ -27,6 +30,38 @@ afterEach(() => { } }); +describe("bundle cache hash", () => { + it("rebuilds when a file is added under remotion/public", async () => { + const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "podcli-bundle-cache-")); + temporaryDirectories.push(cacheRoot); + process.env.PODCLI_CACHE_DIR = cacheRoot; + + vi.mocked(bundle).mockImplementation(async ({ outDir }) => { + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "index.html"), ""); + return outDir; + }); + + const { getCachedBundle } = await import("./bundle-cache.mjs"); + await getCachedBundle(); + expect(bundle).toHaveBeenCalledTimes(1); + + await getCachedBundle(); + expect(bundle).toHaveBeenCalledTimes(1); + + const publicDir = path.join(__dirname, "public"); + fs.mkdirSync(publicDir, { recursive: true }); + const marker = path.join(publicDir, `.bundle-cache-test-${process.pid}`); + fs.writeFileSync(marker, "new texture"); + try { + await getCachedBundle(); + expect(bundle).toHaveBeenCalledTimes(2); + } finally { + fs.rmSync(marker, { force: true }); + } + }); +}); + describe("bundle cache lock", () => { it("does not let a displaced owner release its successor's lock", async () => { const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "podcli-bundle-cache-")); diff --git a/remotion/package-lock.json b/remotion/package-lock.json index 3d660beb..0029e5d5 100644 --- a/remotion/package-lock.json +++ b/remotion/package-lock.json @@ -6,7 +6,11 @@ "": { "name": "podcli-remotion-bundle", "dependencies": { + "@fontsource/barlow-condensed": "5.3.0", + "@fontsource/courier-prime": "5.3.0", "@fontsource/dm-sans": "5.2.8", + "@fontsource/noto-sans-georgian": "5.3.0", + "@fontsource/playfair-display": "5.3.0", "@remotion/bundler": "4.0.488", "@remotion/renderer": "4.0.488", "react": "19.2.7", @@ -461,6 +465,24 @@ "node": ">=18" } }, + "node_modules/@fontsource/barlow-condensed": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/barlow-condensed/-/barlow-condensed-5.3.0.tgz", + "integrity": "sha512-RA7MmIx0v3eQ6Gcf74qLVLOwLB0GpPOzx+eU9iG/AmCnSJGcIWYeU8y6CEMGZ210MGwGg5CZhzLkkvyZdTTk1g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/courier-prime": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/courier-prime/-/courier-prime-5.3.0.tgz", + "integrity": "sha512-KIi1k4VG1Wv+/CGpb1AUpLkUuOAQA6zmvthqxOZGN0AV8BEgQgbK7qAtquv14zsBJAY1bb5BHIEbWVFLgENh0w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/dm-sans": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", @@ -470,6 +492,24 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/noto-sans-georgian": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/noto-sans-georgian/-/noto-sans-georgian-5.3.0.tgz", + "integrity": "sha512-+a+N1mvCugsggDb5eKPKcwDXQQtbaV+x9/Zo2/p2c9yAZeib+qD2ftbezbPAoLvBf4MP+F8BdDwHdrlnNRSdng==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/playfair-display": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/playfair-display/-/playfair-display-5.3.0.tgz", + "integrity": "sha512-uJSABalvdWc10fEPppQjQocamT21LEyRfzzWfRfqbkPz+sRv+kKpYDKyluBUskvJREkJTos9epj1WjQROGu99Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1038,7 +1078,6 @@ "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==", "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", @@ -1310,7 +1349,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1335,7 +1373,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -1407,7 +1444,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -1909,7 +1945,6 @@ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.50.8.tgz", "integrity": "sha512-LgykLyQzhdpo0V2yw3UXmOpj+b4JAGdpHBwsPE6kjSt8Za0d1VllD+FV7EGHBcdV4+oHUAo+yrqbVAWxNSDCPQ==", "license": "MPL-2.0", - "peer": true, "workspaces": [ ".", "packages/*" @@ -2082,7 +2117,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2184,7 +2218,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2194,7 +2227,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2207,7 +2239,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2557,7 +2588,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/remotion/package.json b/remotion/package.json index c1aade3e..52ca62a0 100644 --- a/remotion/package.json +++ b/remotion/package.json @@ -3,7 +3,11 @@ "private": true, "type": "module", "dependencies": { + "@fontsource/barlow-condensed": "5.3.0", + "@fontsource/courier-prime": "5.3.0", "@fontsource/dm-sans": "5.2.8", + "@fontsource/noto-sans-georgian": "5.3.0", + "@fontsource/playfair-display": "5.3.0", "@remotion/bundler": "4.0.488", "@remotion/renderer": "4.0.488", "react": "19.2.7", diff --git a/remotion/public/style/LICENSE.txt b/remotion/public/style/LICENSE.txt new file mode 100644 index 00000000..29a0c212 --- /dev/null +++ b/remotion/public/style/LICENSE.txt @@ -0,0 +1,9 @@ +These textures are CC0 (public domain), sourced from ambientCG.com and +reprocessed for use as podcli style-pack overlays. + +detail-paper.png - ambientCG Paper003, Paper002 +detail-kraft.png - ambientCG Paper004, Paper003 +tape-1.png, tape-2.png, tape-3.png - ambientCG Paper003 + +Source: https://ambientcg.com +License: CC0 1.0 Universal (https://creativecommons.org/publicdomain/zero/1.0/) diff --git a/remotion/public/style/detail-kraft.png b/remotion/public/style/detail-kraft.png new file mode 100644 index 00000000..e129b18b Binary files /dev/null and b/remotion/public/style/detail-kraft.png differ diff --git a/remotion/public/style/detail-paper.png b/remotion/public/style/detail-paper.png new file mode 100644 index 00000000..032403a8 Binary files /dev/null and b/remotion/public/style/detail-paper.png differ diff --git a/remotion/public/style/tape-1.png b/remotion/public/style/tape-1.png new file mode 100644 index 00000000..2631ac8f Binary files /dev/null and b/remotion/public/style/tape-1.png differ diff --git a/remotion/public/style/tape-2.png b/remotion/public/style/tape-2.png new file mode 100644 index 00000000..88fb3fd9 Binary files /dev/null and b/remotion/public/style/tape-2.png differ diff --git a/remotion/public/style/tape-3.png b/remotion/public/style/tape-3.png new file mode 100644 index 00000000..98d795f5 Binary files /dev/null and b/remotion/public/style/tape-3.png differ diff --git a/remotion/render.mjs b/remotion/render.mjs index 8ea9e287..7851e370 100644 --- a/remotion/render.mjs +++ b/remotion/render.mjs @@ -16,6 +16,7 @@ * [--progress] [--progress-color '#3B9CFF'] \ * [--cards '[{"kind":"stat","start":2,"end":6,"value":"70%"}]'] \ * [--brand '{"accent":"#4C9DF5","ink":"#FFFFFF","surface":"#0A0D14"}'] \ + * [--theme '{"pack":"collage","motion":"stop-motion"}'] \ * [--font-family "Inter"] \ * [--fps 30] * @@ -255,6 +256,7 @@ async function main() { const cards = json("cards"); const brand = json("brand"); + const theme = json("theme"); const topic = opts.topic ? { label: opts.topic, @@ -285,6 +287,7 @@ async function main() { progress, cards: Array.isArray(cards) ? cards : null, brand: brand && typeof brand === "object" ? brand : null, + theme: theme && typeof theme === "object" && !Array.isArray(theme) ? theme : null, fontFamily: opts["font-family"] || null, }; diff --git a/remotion/src/CaptionedClip.tsx b/remotion/src/CaptionedClip.tsx index b171647b..5ac36b56 100644 --- a/remotion/src/CaptionedClip.tsx +++ b/remotion/src/CaptionedClip.tsx @@ -21,6 +21,10 @@ import { brandCaptions, captionZone, LOGO_CAPTION_GAP, LOGO_HEIGHT, LOGO_INSET, safeFor, } from "./types"; import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "./types"; +import { resolveTheme } from "./style/theme"; +import { withDefaultTextures } from "./style/textures"; +import type { ThemeInput } from "./style/theme"; +import { StyledCaptions, StyledCards, StyledNameCard } from "./style/styled"; export interface CaptionedClipProps { videoSrc: string; @@ -49,6 +53,7 @@ export interface CaptionedClipProps { brand?: Brand | null; /** Per-part overrides; each part falls back to its style's own motion. */ motion?: { captions?: Partial; nameCard?: Partial } | null; + theme?: ThemeInput | null; } export const CaptionedClip: React.FC = ({ @@ -70,7 +75,9 @@ export const CaptionedClip: React.FC = ({ startFrom = 0, brand, motion, + theme: themeInput, }) => { + const theme = resolveTheme(withDefaultTextures(themeInput ?? null)); const { fps, height, width } = useVideoConfig(); const SAFE = safeFor(width, height); const frame = useCurrentFrame(); @@ -175,7 +182,10 @@ export const CaptionedClip: React.FC = ({ return ( {/* First, so everything below stays up while a card holds the frame. */} - {cards && cards.length > 0 && pastNameCard && ( + {theme && cards && cards.length > 0 && pastNameCard && ( + + )} + {!theme && cards && cards.length > 0 && pastNameCard && ( = ({ /> )} - {style.name === "branded" ? ( + {theme ? ( + + ) : style.name === "branded" ? ( @@ -196,7 +208,16 @@ export const CaptionedClip: React.FC = ({ )} - {nameCard?.title && ( + {theme && nameCard?.title && ( + + )} + {!theme && nameCard?.title && ( - new FontFace("DM Sans", `url(${source})`, { +const loadFace = (source: string, weight: string, family = "DM Sans", style = "normal") => + new FontFace(family, `url(${source})`, { weight, - style: "normal", + style, display: "swap", }) .load() @@ -26,7 +34,17 @@ const loadFace = (source: string, weight: string) => document.fonts.add(face); }); -Promise.all([loadFace(dmSans400 as string, "400"), loadFace(dmSans700 as string, "700")]) +Promise.all([ + loadFace(dmSans400 as string, "400"), + loadFace(dmSans700 as string, "700"), + loadFace(playfair700i as string, "700", "Playfair Display", "italic"), + loadFace(playfair900i as string, "900", "Playfair Display", "italic"), + loadFace(barlow600 as string, "600", "Barlow Condensed"), + loadFace(barlow700 as string, "700", "Barlow Condensed"), + loadFace(courier400 as string, "400", "Courier Prime"), + loadFace(georgian400 as string, "400", "Noto Sans Georgian"), + loadFace(georgian700 as string, "700", "Noto Sans Georgian"), +]) .then(() => continueRender(fontsReady)) .catch((err) => { console.warn("DM Sans failed to load, falling back:", err); @@ -82,6 +100,7 @@ const inputProps = getInputProps() as { brand?: Brand | null; /** The show's own face, ahead of the stack that covers what it cannot draw. */ fontFamily?: string | null; + theme?: ThemeInput | null; }; export const RemotionRoot: React.FC = () => { @@ -127,6 +146,7 @@ export const RemotionRoot: React.FC = () => { progress: inputProps.progress ?? null, cards: inputProps.cards ?? null, brand: inputProps.brand ?? null, + theme: inputProps.theme ?? null, }} /> { + let t = (seed * 2654435761) >>> 0; + return () => { + t = (t + 0x6d2b79f5) >>> 0; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; +}; + +export interface Sides { top?: boolean; right?: boolean; bottom?: boolean; left?: boolean } + +export const fibrous = (seed: number, wave: number, fiber: number, sides: Sides = {}, steps = 220) => { + const r = random(seed); + const { top = true, right = true, bottom = true, left = true } = sides; + const points: string[] = []; + const edge = (on: boolean, point: (t: number, d: number) => string) => { + if (!on) { + points.push(point(0, 0)); + return; + } + const p1 = r() * 6.28; + const p2 = r() * 6.28; + const f1 = 2 + r() * 2.5; + const f2 = 9 + r() * 6; + for (let i = 0; i < steps; i++) { + const t = (i / steps) * 100; + const u = i / steps; + const smooth = (Math.sin(u * f1 * 6.28 + p1) * 0.6 + Math.sin(u * f2 * 6.28 + p2) * 0.4 + 1) / 2; + const spike = r() < 0.05 ? fiber * 2.2 : 0; + points.push(point(t, 1 + wave * smooth + fiber * r() + spike)); + } + }; + edge(top, (t, d) => `${t.toFixed(2)}% ${d.toFixed(1)}px`); + edge(right, (t, d) => `calc(100% - ${d.toFixed(1)}px) ${t.toFixed(2)}%`); + edge(bottom, (t, d) => `${(100 - t).toFixed(2)}% calc(100% - ${d.toFixed(1)}px)`); + edge(left, (t, d) => `${d.toFixed(1)}px ${(100 - t).toFixed(2)}%`); + return `polygon(${points.join(",")})`; +}; + +const lighter = (hex: string, k: number) => + `color-mix(in oklab, ${hex}, #FFFFFF ${Math.round(k * 100)}%)`; + +export const Paper: React.FC<{ + theme: Theme; + seed: number; + color: string; + scale: number; + texture?: "paper" | "kraft" | "none"; + strength?: number; + sides?: Sides; + rimColor?: string; + shadow?: boolean; + style?: React.CSSProperties; + children?: React.ReactNode; +}> = ({ + theme, seed, color, scale: s, texture = "paper", strength, sides, rimColor, shadow = true, style, children, +}) => { + if (theme.surface === "none") return
{children}
; + const torn = theme.edge.kind === "torn"; + const rim = theme.edge.rim * s; + const outer = torn ? fibrous(seed, theme.edge.wave * s, theme.edge.fiber * 1.6 * s, sides) : undefined; + const inner = torn ? fibrous(seed + 5, theme.edge.wave * 0.6 * s, theme.edge.fiber * s, sides) : undefined; + const amount = strength ?? theme.texture.strength; + const src = texture === "none" || amount <= 0 ? undefined : theme.textures[texture]; + const inset = (on?: boolean) => (on === false ? 0 : rim * 0.55); + const layer: React.CSSProperties = { position: "absolute", inset: 0 }; + + return ( +
+ {shadow && theme.shadow > 0 && [1.5, 3.5, 6.5].map((dy, i) => ( +
+ ))} + {torn && ( +
+ )} +
+ {src && ( +
+ )} +
+
{children}
+
+ ); +}; + +export const Label: React.FC<{ + theme: Theme; + seed: number; + scale: number; + size: number; + color?: string; + ink?: string; + pad?: number; + style?: React.CSSProperties; + children: React.ReactNode; +}> = ({ theme, seed, scale: s, size: base, color, ink, pad = 0.28, style, children }) => { + const size = base * (theme.type.label.size ?? 1); + if (theme.surface === "none") { + return ( +
+ {children} +
+ ); + } + return ( + +
+ {children} +
+
+ ); +}; + +export const Tape: React.FC<{ + theme: Theme; + seed: number; + scale: number; + width: number; + style?: React.CSSProperties; +}> = ({ theme, seed, scale: s, width, style }) => { + const tapes = theme.textures.tape ?? []; + if (!theme.tape || !tapes.length) return null; + return ( + + ); +}; diff --git a/remotion/src/style/pages.tsx b/remotion/src/style/pages.tsx new file mode 100644 index 00000000..3967a88f --- /dev/null +++ b/remotion/src/style/pages.tsx @@ -0,0 +1,483 @@ +import React from "react"; +import { + AbsoluteFill, getRemotionEnvironment, Img, interpolate, OffthreadVideo, staticFile, useCurrentFrame, + useVideoConfig, Video, +} from "remotion"; +import type { Card } from "../cards"; +import { fontOf } from "./theme"; +import type { Role, Theme } from "./theme"; +import { Label, Paper, Tape } from "./materials"; +import { camera, progress, Reveal, RevealText, roleFrames } from "./reveal"; +import type { Shot } from "./reveal"; +import { Scene } from "../components/Scene"; + +type PageProps = { + card: Extract; + theme: Theme; + s: number; + f: number; + fps: number; +}; + +const planner = (theme: Theme, start = 2) => { + let cursor = start; + return (role: Role, text?: string) => { + const at = cursor; + const spoken = theme.motion.roles[role] === "type" || theme.motion.roles[role] === "scramble"; + cursor = at + (spoken ? roleFrames(theme, role, text) : 0) + Math.max(2, theme.motion.stagger); + return at; + }; +}; + +const Page: React.FC<{ + theme: Theme; + s: number; + f: number; + length: number; + shots: Shot[]; + punch: Shot | null; + children: React.ReactNode; +}> = ({ theme, s, f, length, shots, punch, children }) => { + const { width, height } = useVideoConfig(); + const lens = (shot: Shot) => ({ ...shot, x: shot.x * s, y: shot.y * s }); + const fade = theme.motion.exit === "fade" + ? interpolate(f, [length - 6, length], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) + : 1; + return ( + +
+ {theme.textures.paper && theme.texture.ground > 0 && ( +
+ )} + {children} +
+ + ); +}; + +const Circle: React.FC<{ theme: Theme; f: number; at: number; s: number; inset: number }> = ({ + theme, f, at, s, inset, +}) => { + const p = progress(theme.motion, f, at, 10); + if (p <= 0) return null; + const ring = (dx: string, dy: string, rot: number) => ( + + ); + return ( + + {ring("50%", "50%", -4)} + {theme.surface === "paper" && ring("50.5%", "49%", 3)} + + ); +}; + +const tilt = (theme: Theme, k: number) => ({ transform: `rotate(${theme.tilt * k}deg)` }); + +const titleSize = (text: string, base: number) => { + const length = Array.from(text).length; + if (length > 22) return base * 0.66; + if (length > 14) return base * 0.8; + return base; +}; + +const Bar: React.FC<{ theme: Theme; seed: number; s: number; color: string; height: number; children?: React.ReactNode }> = ({ + theme, seed, s, color, height, children, +}) => (theme.surface === "none" + ?
{children}
+ : ( + +
{children}
+
+ )); + +const Strip: React.FC<{ theme: Theme; seed: number; s: number; color?: string; size?: number; children: React.ReactNode }> = ({ + theme, seed, s, color, size = 48, children, +}) => ( + +
+ {children} +
+
+); + +const StatPage: React.FC & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const title = card.eyebrow ?? "By the numbers"; + const next = planner(theme); + const titleAt = next("title", title); + const stripAt = next("strip"); + const sheetAt = next("media"); + const figureAt = next("figure", card.value) + 4; + const circleAt = figureAt + 8; + return ( + + {theme.surface === "paper" && ( + + + + + )} +
+ + +
+ +
+
+
+ {card.caption && ( + + {card.caption} + + )} +
+ +
+ + +
+
+
+ ); +}; + +const HeadlinePage: React.FC & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const next = planner(theme); + const eyebrowAt = next("label"); + const leadAt = next("title", card.lead); + const markAt = leadAt + roleFrames(theme, "title", card.lead) + 2; + const subAt = next("strip") + 6; + const mark = Array.isArray(card.emphasis) ? card.emphasis[0] : card.emphasis; + return ( + + {theme.surface === "paper" && ( + + )} + + + + + +
+ +
+
+
+ {card.sub && ( + + {card.sub} + + )} +
+ ); +}; + +const EntityPage: React.FC & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const next = planner(theme, 0); + const mediaAt = next("media"); + const nameAt = next("figure", card.name) + 10; + const noteAt = next("strip") + 10; + const src = card.src && (theme.media === "halftone" ? theme.textures.halftone?.[card.src] ?? card.src : card.src); + return ( + + + +
+ {src && } +
+
+ + +
+ + + + {card.note && ( + + {card.note} + + )} +
+ ); +}; + +const BulletsPage: React.FC & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const title = card.eyebrow ?? "In short"; + const next = planner(theme); + const titleAt = next("title", title); + const items = card.items.slice(0, 3).map(() => next("item")); + const stocks = [theme.color.sheet, theme.color.stripAlt, theme.color.strip]; + const lefts = [50, 110, 70]; + return ( + + {theme.surface === "paper" && ( + + )} + + +
+ +
+
+
+ {card.items.slice(0, 3).map((item, i) => ( + + +
+ +
+
+
+ +
+
+ ))} +
+ ); +}; + +const QuotePage: React.FC & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const next = planner(theme); + const textAt = next("title", card.text); + const byAt = next("label") + 4; + return ( + + {theme.surface === "paper" && ( + + )} + + +
+
“
+
+ +
+
+
+ +
+ {card.attribution && ( + + + + )} +
+ ); +}; + +const Title: React.FC<{ theme: Theme; s: number; f: number; fps: number; at: number; text: string; seed: number }> = ({ + theme, s, f, fps, at, text, seed, +}) => ( + + +
+ +
+
+
+); + +const CompareRows: React.FC & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const title = card.eyebrow ?? "Side by side"; + const next = planner(theme); + const titleAt = next("title", title); + const rows = card.rows.slice(0, 5); + const ats = rows.map(() => next("item")); + const top = Math.max(...rows.map((r) => r.value), 1); + return ( + + + {rows.map((row, i) => ( + <Reveal key={`${row.label}-${i}`} theme={theme} role="item" f={f} at={ats[i]} fps={fps} + style={{ left: 70 * s, top: (480 + i * 170) * s, width: 940 * s }}> + <div style={{ ...fontOf(theme.type.label, 46 * s), color: theme.color.ink, marginBottom: 10 * s }}>{row.label}</div> + <div style={{ display: "flex", alignItems: "center", gap: 18 * s }}> + <div style={{ width: `${Math.max(6, (row.value / top) * 72)}%` }}> + <Bar theme={theme} seed={82 + i} s={s} height={56} + color={row.subject ? theme.color.accent : theme.surface === "none" ? theme.color.muted : theme.color.stripAlt} /> + </div> + <div style={{ ...fontOf(theme.type.label, 52 * s), fontWeight: 700, + color: row.subject && theme.surface === "none" ? theme.color.accent : theme.color.ink }}> + {row.display ?? String(row.value)} + </div> + </div> + </Reveal> + ))} + </Page> + ); +}; + +const ChangePage: React.FC<PageProps<"change"> & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const title = card.eyebrow ?? card.label ?? "What moved"; + const next = planner(theme); + const titleAt = next("title", title); + const fromAt = next("item"); + const arrowAt = fromAt + 6; + const toAt = next("figure", card.to.value) + 8; + const p = progress(theme.motion, f, arrowAt, 10); + const end = (value: string, note: string | undefined, at: number, role: "item" | "figure", top: number, seed: number, strong: boolean) => ( + <Reveal theme={theme} role={role} f={f} at={at} fps={fps} style={{ left: 0, right: 0, top: top * s, display: "flex", flexDirection: "column", alignItems: "center", gap: 14 * s }}> + <Label theme={theme} seed={seed} scale={s} size={(strong ? 200 : 120) * s} pad={0.16} + ink={strong ? theme.color.figure : undefined}> + <RevealText theme={theme} role={role} f={f} at={at} text={value} s={s} /> + </Label> + {note && <div style={{ ...fontOf(theme.type.label, 42 * s), color: theme.color.muted }}>{note}</div>} + </Reveal> + ); + return ( + <Page theme={theme} s={s} f={f} length={length} punch={{ at: toAt, zoom: 1.1, x: 540, y: 1000 }} + shots={[{ at: 0, zoom: 1.25, x: 540, y: 560 }, { at: arrowAt, zoom: 1.25, x: 540, y: 560 }, { at: toAt + 8, zoom: 1, x: 540, y: 960 }]}> + <Title theme={theme} s={s} f={f} fps={fps} at={titleAt} text={title} seed={91} /> + {end(card.from.value, card.from.note, fromAt, "item", 440, 92, false)} + {p > 0 && ( + <svg style={{ position: "absolute", left: 470 * s, top: 690 * s, width: 140 * s, height: 190 * s, overflow: "visible" }}> + <path d={`M ${70 * s} ${10 * s} L ${70 * s} ${170 * s} M ${30 * s} ${130 * s} L ${70 * s} ${172 * s} L ${110 * s} ${130 * s}`} + fill="none" stroke={theme.color.accent} strokeWidth={9 * s} strokeLinecap="round" strokeLinejoin="round" + pathLength={1} strokeDasharray="1" strokeDashoffset={1 - p} /> + </svg> + )} + {end(card.to.value, card.to.note, toAt, "figure", 920, 93, true)} + </Page> + ); +}; + +const SharePage: React.FC<PageProps<"share"> & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const share = Math.max(0, Math.min(1, card.value)); + const display = card.display ?? `${Math.round(share * 100)}%`; + const title = card.eyebrow ?? "How much"; + const next = planner(theme); + const titleAt = next("title", title); + const figureAt = next("figure", display) + 4; + const barAt = next("item"); + const fill = progress(theme.motion, f, barAt, Math.max(12, theme.motion.frames * 2)); + return ( + <Page theme={theme} s={s} f={f} length={length} punch={{ at: figureAt, zoom: 1.1, x: 540, y: 700 }} + shots={[{ at: 0, zoom: 1.3, x: 380, y: 290 }, { at: figureAt, zoom: 1.3, x: 380, y: 290 }, { at: figureAt + 12, zoom: 1, x: 540, y: 960 }]}> + <Title theme={theme} s={s} f={f} fps={fps} at={titleAt} text={title} seed={101} /> + <Reveal theme={theme} role="figure" f={f} at={figureAt} fps={fps} + style={{ left: 0, right: 0, top: 520 * s, display: "flex", justifyContent: "center", ...tilt(theme, 1.4) }}> + <Label theme={theme} seed={102} scale={s} size={260 * s} ink={theme.color.figure} pad={0.16}> + <RevealText theme={theme} role="figure" f={f} at={figureAt} text={display} s={s} /> + </Label> + </Reveal> + <Reveal theme={theme} role="item" f={f} at={barAt} fps={fps} style={{ left: 90 * s, top: 960 * s, width: 900 * s }}> + <Bar theme={theme} seed={103} s={s} height={70} + color={theme.surface === "none" ? `color-mix(in oklab, ${theme.color.muted} 45%, transparent)` : theme.color.stripAlt}> + <div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: `${share * fill * 100}%`, backgroundColor: theme.color.accent }} /> + </Bar> + {card.caption && ( + <div style={{ ...fontOf(theme.type.label, 46 * s), color: theme.color.ink, marginTop: 24 * s }}>{card.caption}</div> + )} + </Reveal> + </Page> + ); +}; + +const MediaPage: React.FC<PageProps<"image" | "video"> & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const src = card.src.startsWith("http") || card.src.startsWith("/") ? card.src : staticFile(card.src); + const shown = card.kind === "image" && theme.media === "halftone" ? theme.textures.halftone?.[card.src] ?? src : src; + const Frame = getRemotionEnvironment().isRendering ? OffthreadVideo : Video; + const next = planner(theme, 0); + const mediaAt = next("media"); + const captionAt = next("strip") + 6; + const bleed = theme.surface === "none"; + const fit: React.CSSProperties = { width: "100%", height: "100%", display: "block", objectFit: card.fit === "fit" ? "contain" : "cover" }; + const body = card.kind === "video" + ? <Frame src={src} muted startFrom={Math.max(0, Math.round((card.startAt ?? 0) * fps))} style={fit} /> + : <Img src={shown} style={fit} />; + return ( + <Page theme={theme} s={s} f={f} length={length} punch={null} + shots={[{ at: 0, zoom: 1.12, x: 540, y: 800 }, { at: 30, zoom: 1, x: 540, y: 960 }]}> + <Reveal theme={theme} role="media" f={f} at={mediaAt} fps={fps} + style={bleed ? { inset: 0 } : { left: 70 * s, top: 170 * s, width: 940 * s, height: 1180 * s, ...tilt(theme, 1.1) }}> + {!bleed && ( + <Paper theme={theme} seed={111} color="#E8E4D8" scale={s} strength={0.3} style={{ position: "absolute", inset: 0 }} /> + )} + <div style={{ position: "absolute", inset: bleed ? 0 : 22 * s }}>{body}</div> + <Tape theme={theme} seed={2} scale={s} width={250} style={{ left: -50 * s, top: -10 * s, transform: "rotate(-26deg)" }} /> + <Tape theme={theme} seed={3} scale={s} width={250} style={{ right: -50 * s, top: 0, transform: "rotate(24deg)" }} /> + </Reveal> + {card.caption && ( + <Reveal theme={theme} role="strip" f={f} at={captionAt} fps={fps} style={{ left: 110 * s, top: (bleed ? 170 : 1290) * s, ...tilt(theme, -0.8) }}> + <Strip theme={theme} seed={112} s={s} color={bleed ? theme.color.ground : undefined}>{card.caption}</Strip> + </Reveal> + )} + </Page> + ); +}; + +const ScenePage: React.FC<PageProps<"scene"> & { length: number }> = ({ card, theme, s, f, fps, length }) => { + const next = planner(theme, 0); + const sheetAt = next("media"); + const bodyAt = next("item"); + const surface = theme.surface === "none" ? theme.color.ground : theme.color.sheet; + return ( + <Page theme={theme} s={s} f={f} length={length} punch={null} + shots={[{ at: 0, zoom: 1.12, x: 540, y: 700 }, { at: bodyAt + 14, zoom: 1, x: 540, y: 960 }]}> + <Reveal theme={theme} role="media" f={f} at={sheetAt} fps={fps} + style={{ left: 40 * s, top: 170 * s, width: 1000 * s, height: 1180 * s, ...tilt(theme, -0.8) }}> + <Paper theme={theme} seed={121} color={surface} scale={s} style={{ width: "100%", height: "100%" }} /> + </Reveal> + <Reveal theme={theme} role="item" f={f} at={bodyAt} fps={fps} + style={{ left: 110 * s, top: 250 * s, width: 860 * s, ...tilt(theme, -0.8) }}> + <Scene blocks={card.blocks} layout={card.layout} gap={card.gap} scale={s} room={1020 * s} + brand={{ accent: theme.color.accent, ink: theme.color.ink, surface }} accent={theme.color.accent} /> + </Reveal> + </Page> + ); +}; + +export const TakeoverPage: React.FC<{ card: Card; theme: Theme; s: number; start: number; length: number }> = ({ + card, theme, s, start, length, +}) => { + const f = useCurrentFrame() - start; + const { fps } = useVideoConfig(); + const props = { theme, s, f, fps, length }; + if (card.kind === "stat") return <StatPage card={card} {...props} />; + if (card.kind === "headline") return <HeadlinePage card={card} {...props} />; + if (card.kind === "entity") return <EntityPage card={card} {...props} />; + if (card.kind === "bullets") return <BulletsPage card={card} {...props} />; + if (card.kind === "quote") return <QuotePage card={card} {...props} />; + if (card.kind === "compare") return <CompareRows card={card} {...props} />; + if (card.kind === "change") return <ChangePage card={card} {...props} />; + if (card.kind === "share") return <SharePage card={card} {...props} />; + if (card.kind === "image" || card.kind === "video") return <MediaPage card={card} {...props} />; + if (card.kind === "scene") return <ScenePage card={card} {...props} />; + return null; +}; diff --git a/remotion/src/style/reveal.test.ts b/remotion/src/style/reveal.test.ts new file mode 100644 index 00000000..4f1d9e85 --- /dev/null +++ b/remotion/src/style/reveal.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { entranceFrames, settle } from "./reveal"; +import { MOTIONS } from "./theme"; + +describe("settle", () => { + it("starts at rest and lands on the mark", () => { + for (const bounces of [0, 1, 2, 3]) { + expect(settle(0, bounces)).toBeCloseTo(0); + expect(settle(1, bounces)).toBeCloseTo(1); + } + }); + + it("never passes the mark without bounces", () => { + const peak = Math.max(...Array.from({ length: 101 }, (_, i) => settle(i / 100, 0))); + expect(peak).toBeLessThanOrEqual(1.0001); + }); + + it("swings past the mark once per bounce asked for", () => { + const crossings = (bounces: number) => { + let count = 0; + let above = false; + for (let i = 1; i < 200; i++) { + const now = settle(i / 200, bounces) > 1; + if (now !== above) count++; + above = now; + } + return count; + }; + expect(crossings(1)).toBe(1); + expect(crossings(2)).toBe(2); + }); +}); + +describe("entranceFrames", () => { + it("types a line word by word in poses", () => { + const motion = { ...MOTIONS["stop-motion"], textUnit: "word" as const }; + expect(entranceFrames(motion, "type", "Space is becoming a factory")).toBe(5 * 2); + }); + + it("types a line character by character in poses", () => { + const motion = MOTIONS["stop-motion"]; + expect(entranceFrames(motion, "type", "Cost")).toBe(4 * 2); + }); + + it("gives a pop one frame and every other entrance the motion length", () => { + const motion = MOTIONS.smooth; + expect(entranceFrames(motion, "pop")).toBe(1); + expect(entranceFrames(motion, "mask-circle")).toBe(motion.frames); + }); +}); diff --git a/remotion/src/style/reveal.tsx b/remotion/src/style/reveal.tsx new file mode 100644 index 00000000..cb6f12a3 --- /dev/null +++ b/remotion/src/style/reveal.tsx @@ -0,0 +1,248 @@ +import React from "react"; +import { Easing, interpolate, spring } from "remotion"; +import type { Entrance, Motion, Role, Side, Theme } from "./theme"; + +export const held = (motion: Motion, frame: number) => frame - (frame % Math.max(1, motion.holdEvery)); + +const easing = (motion: Motion) => + motion.ease === "in-out" ? Easing.inOut(Easing.cubic) : motion.ease === "linear" ? Easing.linear : Easing.out(Easing.cubic); + +export const progress = (motion: Motion, frame: number, at: number, frames = motion.frames) => + interpolate(held(motion, frame), [at, at + Math.max(1, frames)], [0, 1], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easing(motion), + }); + +export const wipe = (p: number, from: "left" | "bottom" = "left") => { + const hidden = `${(1 - p) * 100}%`; + return from === "bottom" ? `inset(${hidden} 0 0 0)` : `inset(-20% ${hidden} -20% -5%)`; +}; + +const TEXT_ENTRANCES = new Set<Entrance>(["type", "scramble"]); + +export const settle = (t: number, bounces: number) => { + const k = Math.min(1, Math.max(0, t)); + return 1 - (1 - k) ** 2 * Math.cos((Math.PI * (2 * bounces + 1) * k) / 2); +}; + +const units = (text: string, unit: Motion["textUnit"]) => + unit === "word" ? text.split(/(\s+)/).filter((part) => part.length > 0) : Array.from(text); + +const unitCount = (text: string, unit: Motion["textUnit"]) => + units(text, unit).filter((part) => part.trim().length > 0).length; + +export const entranceFrames = (motion: Motion, entrance: Entrance, text?: string) => { + if (entrance === "pop") return 1; + if (TEXT_ENTRANCES.has(entrance) && text) { + const perPose = motion.textUnit === "word" ? 1 : motion.charsPerPose; + return Math.ceil(unitCount(text, motion.textUnit) / perPose) * Math.max(1, motion.holdEvery); + } + return motion.frames; +}; + +const offset = (from: Side, distance: number) => { + if (from === "left") return `translateX(${-distance}px)`; + if (from === "right") return `translateX(${distance}px)`; + if (from === "top") return `translateY(${-distance}px)`; + return `translateY(${distance}px)`; +}; + +const mask = (entrance: Entrance, p: number) => { + if (entrance === "mask-circle") return `circle(${p * 75}% at 50% 50%)`; + if (entrance === "mask-split") return `inset(-10% ${(1 - p) * 50}% -10% ${(1 - p) * 50}%)`; + const x = p * 220 - 10; + return `polygon(-10% -10%, ${x}% -10%, ${x - 100}% 110%, -10% 110%)`; +}; + +export const roleFrames = (theme: Theme, role: Role, text?: string) => + entranceFrames(theme.motion, theme.motion.roles[role], text); + +const GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&"; + +export const Reveal: React.FC<{ + theme: Theme; + role: Role; + f: number; + at: number; + fps: number; + style?: React.CSSProperties; + children: React.ReactNode; +}> = ({ theme, role, f, at, fps, style, children }) => { + const { motion } = theme; + const entrance = motion.roles[role]; + const base: React.CSSProperties = { position: "absolute", ...style }; + if (f < at) return null; + if (entrance === "pop" || TEXT_ENTRANCES.has(entrance)) return <div style={base}>{children}</div>; + + if (entrance === "wipe" || entrance === "wipe-up") { + const p = progress(motion, f, at); + return <div style={{ ...base, clipPath: p < 1 ? wipe(p, entrance === "wipe-up" ? "bottom" : "left") : undefined }}>{children}</div>; + } + + if (entrance === "spring") { + const k = spring({ + frame: held(motion, f) - at, fps, + config: { damping: 10 + (1 - motion.overshoot) * 30, stiffness: 180, mass: 0.6 }, + durationInFrames: motion.frames * 2, + }); + const opacity = interpolate(held(motion, f) - at, [0, 2], [0, 1], { extrapolateRight: "clamp" }); + return ( + <div style={{ ...base, opacity, transform: `${base.transform ?? ""} scale(${0.82 + 0.18 * k})` }}>{children}</div> + ); + } + + const t = interpolate(held(motion, f), [at, at + Math.max(1, motion.frames)], [0, 1], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", + }); + const moved = motion.bounces > 0 ? settle(t, motion.bounces) : progress(motion, f, at); + const shown = interpolate(held(motion, f) - at, [0, 3], [0, 1], { extrapolateRight: "clamp" }); + + if (entrance === "slide") { + return ( + <div style={{ ...base, opacity: shown, transform: `${base.transform ?? ""} ${offset(motion.from, (1 - moved) * 140)}` }}> + {children} + </div> + ); + } + + if (entrance === "scale") { + return ( + <div style={{ ...base, opacity: shown, transform: `${base.transform ?? ""} scale(${0.6 + 0.4 * moved})` }}>{children}</div> + ); + } + + if (entrance === "blur") { + const p = progress(motion, f, at); + return <div style={{ ...base, opacity: p, filter: p < 1 ? `blur(${(1 - p) * 18}px)` : undefined }}>{children}</div>; + } + + if (entrance === "mask-circle" || entrance === "mask-diagonal" || entrance === "mask-split") { + const p = progress(motion, f, at); + return <div style={{ ...base, clipPath: p < 1 ? mask(entrance, p) : undefined }}>{children}</div>; + } + + const p = progress(motion, f, at); + const rise = entrance === "rise" ? offset("bottom", (1 - (motion.bounces > 0 ? moved : p)) * 40) : ""; + return <div style={{ ...base, opacity: p, transform: `${base.transform ?? ""} ${rise}` }}>{children}</div>; +}; + +export const RevealText: React.FC<{ + theme: Theme; + role: Role; + f: number; + at: number; + text: string; + mark?: string; + markAt?: number; + s: number; +}> = ({ theme, role, f, at, text, mark, markAt, s }) => { + const { motion } = theme; + const entrance = motion.roles[role]; + const typing = TEXT_ENTRANCES.has(entrance); + const pieces = units(text, motion.textUnit); + const perPose = motion.textUnit === "word" ? 1 : motion.charsPerPose; + const poses = Math.floor((held(motion, f) - at) / Math.max(1, motion.holdEvery)) + 1; + const total = pieces.filter((part) => part.trim().length > 0).length; + const count = typing ? Math.max(0, Math.min(total, poses * perPose)) : total; + const seed = Math.floor(held(motion, f) / Math.max(1, motion.holdEvery)); + const markStart = mark ? text.indexOf(mark) : -1; + const markEnd = markStart + (mark?.length ?? 0); + const markP = markStart >= 0 && markAt !== undefined ? progress(motion, f, markAt) : 0; + + let seen = 0; + let cursor = 0; + const spans: { text: string; from: number; visible: boolean; fresh: boolean }[] = pieces.map((piece) => { + const word = piece.trim().length > 0; + const index = word ? seen++ : seen - 1; + const from = cursor; + cursor += piece.length; + const settled = index < count; + const shown = entrance === "scramble" ? f >= at : settled || (!word && index < count); + const body = entrance === "scramble" && !settled && word + ? Array.from(piece).map((_, i) => GLYPHS[((from + i) * 7 + seed * 13) % GLYPHS.length]).join("") + : piece; + return { text: body, from, visible: shown, fresh: entrance === "type" && word && index === count - 1 && count < total }; + }); + + const render = (lo: number, hi: number) => spans + .filter((span) => span.from >= lo && span.from < hi) + .map((span) => ( + <span key={span.from} style={{ + opacity: span.visible ? 1 : 0, + filter: span.fresh ? `blur(${3 * s}px)` : undefined, + }}>{span.text}</span> + )); + + if (markStart < 0 || motion.textUnit === "word" && !spans.some((span) => span.from === markStart)) { + return <>{render(0, text.length)}</>; + } + if (theme.mark === "color") { + return ( + <> + {render(0, markStart)} + <span style={{ color: markP > 0 ? theme.color.accent : undefined }}>{render(markStart, markEnd)}</span> + {render(markEnd, text.length)} + </> + ); + } + return ( + <> + {render(0, markStart)} + <span style={{ position: "relative", display: "inline-block", zIndex: 0 }}> + <span style={{ + position: "absolute", left: -10 * s, right: -14 * s, top: "22%", bottom: "2%", + backgroundColor: theme.color.highlight, zIndex: -1, clipPath: wipe(markP), + transform: "rotate(-1deg)", + }} /> + {render(markStart, markEnd)} + </span> + {render(markEnd, text.length)} + </> + ); +}; + +export interface Shot { + at: number; + zoom: number; + x: number; + y: number; +} + +export const camera = ( + theme: Theme, f: number, shots: Shot[], punch: Shot | null, length: number, width: number, height: number, +) => { + const { motion } = theme; + const h = held(motion, f); + let zoom = 1; + let x = width / 2; + let y = height / 2; + if (motion.camera === "push") { + zoom = 1 + 0.045 * Math.min(1, h / Math.max(1, length)); + } else if (motion.camera === "punch" && punch) { + const k = interpolate(h, [punch.at, punch.at + 1, punch.at + 14], [0, 1, 0.35], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic), + }); + zoom = 1 + (punch.zoom - 1) * k; + x = width / 2 + (punch.x - width / 2) * k; + y = height / 2 + (punch.y - height / 2) * k; + } else if (motion.camera === "track" && shots.length) { + const next = shots.findIndex((shot) => shot.at > h); + const last = shots[shots.length - 1]; + if (next === -1) { + zoom = last.zoom * (1 + 0.02 * Math.min(1, (h - last.at) / 120)); + ({ x, y } = last); + } else if (next === 0) { + ({ zoom, x, y } = shots[0]); + } else { + const a = shots[next - 1]; + const b = shots[next]; + const k = interpolate(h, [a.at, b.at], [0, 1], { easing: Easing.inOut(Easing.cubic) }); + zoom = a.zoom + (b.zoom - a.zoom) * k; + x = a.x + (b.x - a.x) * k; + y = a.y + (b.y - a.y) * k; + } + } + if (zoom === 1) return "none"; + const tx = Math.min(0, Math.max(width - width * zoom, width / 2 - x * zoom)); + const ty = Math.min(0, Math.max(height - height * zoom, height / 2 - y * zoom)); + return `translate(${tx}px, ${ty}px) scale(${zoom})`; +}; diff --git a/remotion/src/style/styled.tsx b/remotion/src/style/styled.tsx new file mode 100644 index 00000000..22f0df9c --- /dev/null +++ b/remotion/src/style/styled.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { useCurrentFrame, useVideoConfig } from "remotion"; +import { captionScale } from "../types"; +import type { CaptionStyle, Word } from "../types"; +import { cardAt } from "../cards"; +import type { Card } from "../cards"; +import { activeChunkAt, buildChunks } from "../chunks"; +import { fontOf } from "./theme"; +import type { Theme } from "./theme"; +import { Label, Paper } from "./materials"; +import { progress, Reveal } from "./reveal"; +import { TakeoverPage } from "./pages"; + +export const StyledCards: React.FC<{ cards: Card[]; theme: Theme }> = ({ cards, theme }) => { + const frame = useCurrentFrame(); + const { fps, height } = useVideoConfig(); + const card = cardAt(cards.filter((c) => theme.layout[c.kind] === "takeover"), frame / fps); + if (!card) return null; + const start = Math.round(card.start * fps); + return ( + <TakeoverPage card={card} theme={theme} s={captionScale(height)} start={start} + length={Math.round(card.end * fps) - start} /> + ); +}; + +export const StyledNameCard: React.FC<{ + theme: Theme; + title: string; + subtitle?: string; + seconds: number; + bottom: number; +}> = ({ theme, title, subtitle, seconds, bottom }) => { + const frame = useCurrentFrame(); + const { fps, height } = useVideoConfig(); + const s = captionScale(height); + const end = Math.round(seconds * fps); + const out = progress(theme.motion, frame, end - 6, 6); + if (frame < 8 || out >= 1) return null; + return ( + <Reveal theme={theme} role="name" f={frame} at={8} fps={fps} style={{ + left: 60 * s, bottom, display: "flex", flexDirection: "column", + alignItems: "flex-start", gap: 6 * s, opacity: 1 - out, + textShadow: theme.surface === "none" ? `0 ${2 * s}px ${14 * s}px rgba(0,0,0,0.8)` : undefined, + }}> + <Label theme={theme} seed={71} scale={s} size={92 * s} ink={theme.surface === "none" ? theme.color.ink : undefined} + style={{ transform: `rotate(${-theme.tilt}deg)` }}> + {title} + </Label> + {subtitle && ( + <Paper theme={theme} seed={72} color={theme.color.stripAlt} scale={s} + style={{ marginLeft: theme.surface === "none" ? 0 : 30 * s, transform: `rotate(${theme.tilt * 0.6}deg)` }}> + <div style={{ ...fontOf(theme.type.label, 42 * s), color: theme.surface === "none" ? theme.color.accent : theme.color.ink, + padding: theme.surface === "none" ? 0 : `${12 * s}px ${26 * s}px` }}> + {subtitle} + </div> + </Paper> + )} + </Reveal> + ); +}; + +export const StyledCaptions: React.FC<{ + theme: Theme; + words: Word[]; + style: CaptionStyle; +}> = ({ theme, words, style }) => { + const frame = useCurrentFrame(); + const { fps, height, durationInFrames } = useVideoConfig(); + const s = captionScale(height); + const now = frame / fps; + const chunks = buildChunks(words, { + perChunk: style.wordsPerChunk, absorbTail: 1, clipEnd: durationInFrames / fps, + }); + const chunk = activeChunkAt(chunks, now); + if (!chunk) return null; + const seed = Math.round(chunk.start * 31); + const size = theme.type.caption * s; + const bare = theme.surface === "none"; + const at = Math.round(chunk.start * fps); + + return ( + <Reveal theme={theme} role="caption" f={frame} at={at} fps={fps} + style={{ left: 0, right: 0, bottom: style.marginBottom * s, display: "flex", justifyContent: "center" }}> + <Paper theme={theme} seed={seed} color={theme.color.sheet} scale={s} strength={0.3} + style={{ maxWidth: 880 * s, transform: `rotate(${((seed % 3) - 1) * theme.tilt * 0.4}deg)` }}> + <div style={{ ...fontOf(theme.type.label, size), fontWeight: 700, color: theme.color.ink, + padding: `${14 * s}px ${30 * s}px ${10 * s}px`, textAlign: "center", + textShadow: bare ? `0 ${2 * s}px ${12 * s}px rgba(0,0,0,0.85), 0 0 ${3 * s}px rgba(0,0,0,0.6)` : undefined }}> + {chunk.words.map((w, i) => { + const on = now >= w.start && now < w.end; + return ( + <React.Fragment key={i}> + {i > 0 ? " " : ""} + <span style={bare ? { color: on ? theme.color.accent : undefined } : { + backgroundColor: on ? theme.color.highlight : undefined, + boxShadow: on ? `0 0 0 ${6 * s}px ${theme.color.highlight}` : undefined, + }}>{w.word}</span> + </React.Fragment> + ); + })} + </div> + </Paper> + </Reveal> + ); +}; diff --git a/remotion/src/style/textures.test.ts b/remotion/src/style/textures.test.ts new file mode 100644 index 00000000..4db5a5e2 --- /dev/null +++ b/remotion/src/style/textures.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_PACK_TEXTURES, withDefaultTextures } from "./textures"; + +describe("withDefaultTextures", () => { + it("passes through a null theme", () => { + expect(withDefaultTextures(null)).toBeNull(); + }); + + it("fills the default pack textures when a theme names none", () => { + const theme = withDefaultTextures({ pack: "collage" }); + expect(theme?.textures).toEqual(DEFAULT_PACK_TEXTURES); + }); + + it("keeps a theme's own textures rather than overwriting them", () => { + const ownTextures = { paper: "custom/paper.png" }; + const theme = withDefaultTextures({ pack: "collage", textures: ownTextures }); + expect(theme?.textures).toBe(ownTextures); + }); + + it("resolves the baked textures under the style/ static folder", () => { + expect(DEFAULT_PACK_TEXTURES.paper).toBe("/style/detail-paper.png"); + expect(DEFAULT_PACK_TEXTURES.kraft).toBe("/style/detail-kraft.png"); + expect(DEFAULT_PACK_TEXTURES.tape).toEqual([ + "/style/tape-1.png", + "/style/tape-2.png", + "/style/tape-3.png", + ]); + }); +}); diff --git a/remotion/src/style/textures.ts b/remotion/src/style/textures.ts new file mode 100644 index 00000000..da99bbfd --- /dev/null +++ b/remotion/src/style/textures.ts @@ -0,0 +1,18 @@ +import { staticFile } from "remotion"; +import type { ThemeInput } from "./theme"; + +export const DEFAULT_PACK_TEXTURES: NonNullable<ThemeInput["textures"]> = { + paper: staticFile("style/detail-paper.png"), + kraft: staticFile("style/detail-kraft.png"), + tape: [ + staticFile("style/tape-1.png"), + staticFile("style/tape-2.png"), + staticFile("style/tape-3.png"), + ], +}; + +export const withDefaultTextures = (theme: ThemeInput | null): ThemeInput | null => { + if (!theme) return null; + if (theme.textures && Object.keys(theme.textures).length > 0) return theme; + return { ...theme, textures: DEFAULT_PACK_TEXTURES }; +}; diff --git a/remotion/src/style/theme.test.ts b/remotion/src/style/theme.test.ts new file mode 100644 index 00000000..7df975fc --- /dev/null +++ b/remotion/src/style/theme.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { MOTIONS, PACKS, resolveTheme } from "./theme"; + +describe("resolveTheme", () => { + it("returns null without a style", () => { + expect(resolveTheme(null)).toBeNull(); + }); + + it("starts from the pack and its own motion", () => { + const theme = resolveTheme({ pack: "editorial" }); + expect(theme?.color.accent).toBe(PACKS.editorial.color.accent); + expect(theme?.motion).toEqual(MOTIONS.smooth); + }); + + it("swaps the motion without touching the look", () => { + const theme = resolveTheme({ pack: "collage", motion: "kinetic" }); + expect(theme?.motion.camera).toBe("punch"); + expect(theme?.edge.kind).toBe("torn"); + }); + + it("applies overrides on top of the motion preset", () => { + const theme = resolveTheme({ pack: "clean", motion: "calm", overrides: { motion: { frames: 20 } } }); + expect(theme?.motion.frames).toBe(20); + expect(theme?.motion.roles.title).toBe("fade"); + }); + + it("refuses gradients and image urls in overrides", () => { + const theme = resolveTheme({ + pack: "clean", + overrides: { color: { ground: "linear-gradient(red, blue)", accent: "url(x.png)", ink: "#FFFFFF" } }, + }); + expect(theme?.color.ground).toBe(PACKS.clean.color.ground); + expect(theme?.color.accent).toBe(PACKS.clean.color.accent); + expect(theme?.color.ink).toBe("#FFFFFF"); + }); + + it("carries no gradient anywhere in the packs or presets", () => { + const text = JSON.stringify({ PACKS, MOTIONS }); + expect(text).not.toMatch(/gradient/i); + }); +}); diff --git a/remotion/src/style/theme.ts b/remotion/src/style/theme.ts new file mode 100644 index 00000000..bfc8dc60 --- /dev/null +++ b/remotion/src/style/theme.ts @@ -0,0 +1,265 @@ +import type React from "react"; + +export type EdgeKind = "straight" | "torn"; +export type Layout = "takeover"; +export type Role = "title" | "figure" | "item" | "media" | "label" | "strip" | "caption" | "name"; +export type Entrance = + | "type" | "scramble" | "wipe" | "wipe-up" | "pop" | "rise" | "fade" | "spring" + | "slide" | "scale" | "blur" | "mask-circle" | "mask-diagonal" | "mask-split"; +export type Side = "left" | "right" | "top" | "bottom"; +export type TextUnit = "char" | "word"; +export type Ease = "out" | "in-out" | "linear"; +export type CameraMove = "none" | "track" | "push" | "punch"; +export type Exit = "cut" | "fade"; + +export const ROLES: Role[] = ["title", "figure", "item", "media", "label", "strip", "caption", "name"]; +export const ENTRANCES: Entrance[] = [ + "type", "scramble", "wipe", "wipe-up", "pop", "rise", "fade", "spring", + "slide", "scale", "blur", "mask-circle", "mask-diagonal", "mask-split", +]; +export const SIDES: Side[] = ["left", "right", "top", "bottom"]; + +export interface Face { + family: string; + weight: number; + italic?: boolean; + tracking?: number; + lineHeight?: number; + size?: number; +} + +export interface Motion { + roles: Record<Role, Entrance>; + frames: number; + stagger: number; + holdEvery: number; + charsPerPose: number; + ease: Ease; + overshoot: number; + bounces: number; + from: Side; + textUnit: TextUnit; + camera: CameraMove; + exit: Exit; +} + +export interface Theme { + pack: string; + color: { + ground: string; + sheet: string; + strip: string; + stripAlt: string; + ink: string; + muted: string; + accent: string; + highlight: string; + label: string; + onLabel: string; + figure: string; + }; + type: { display: Face; label: Face; mono: Face; caption: number }; + edge: { kind: EdgeKind; wave: number; fiber: number; rim: number }; + surface: "paper" | "none"; + mark: "fill" | "color"; + shadow: number; + texture: { strength: number; ground: number }; + tape: boolean; + media: "halftone" | "photo"; + tilt: number; + motion: Motion; + layout: Record<string, Layout>; + textures: { paper?: string; kraft?: string; tape?: string[]; halftone?: Record<string, string> }; +} + +export type DeepPartial<T> = { + [K in keyof T]?: T[K] extends (infer U)[] ? U[] : T[K] extends object ? DeepPartial<T[K]> : T[K]; +}; + +const every = (entrance: Entrance, extra: Partial<Record<Role, Entrance>> = {}): Record<Role, Entrance> => ({ + title: entrance, figure: entrance, item: entrance, media: entrance, + label: entrance, strip: entrance, caption: "pop", name: entrance, ...extra, +}); + +export const MOTIONS = { + "stop-motion": { + roles: every("pop", { title: "type", item: "wipe", media: "wipe-up", strip: "wipe", name: "wipe" }), + frames: 8, stagger: 8, holdEvery: 2, charsPerPose: 1, ease: "out", overshoot: 0, bounces: 0, + from: "left", textUnit: "char", camera: "track", exit: "cut", + }, + smooth: { + roles: every("rise", { media: "fade" }), + frames: 12, stagger: 5, holdEvery: 1, charsPerPose: 2, ease: "out", overshoot: 0, bounces: 0, + from: "bottom", textUnit: "word", camera: "push", exit: "fade", + }, + kinetic: { + roles: every("spring", { label: "pop", strip: "wipe", name: "wipe" }), + frames: 7, stagger: 4, holdEvery: 1, charsPerPose: 3, ease: "out", overshoot: 0.35, bounces: 1, + from: "right", textUnit: "word", camera: "punch", exit: "cut", + }, + calm: { + roles: every("fade"), + frames: 15, stagger: 0, holdEvery: 1, charsPerPose: 2, ease: "in-out", overshoot: 0, bounces: 0, + from: "bottom", textUnit: "word", camera: "none", exit: "fade", + }, + digital: { + roles: every("scramble", { media: "pop", label: "pop", strip: "wipe", name: "pop" }), + frames: 10, stagger: 6, holdEvery: 2, charsPerPose: 2, ease: "linear", overshoot: 0, bounces: 0, + from: "left", textUnit: "char", camera: "none", exit: "cut", + }, +} satisfies Record<string, Motion>; + +export type MotionId = keyof typeof MOTIONS; + +export interface ThemeInput { + pack: PackId; + motion?: MotionId; + overrides?: DeepPartial<Theme>; + textures?: Theme["textures"]; +} + +const GEORGIAN = "'Noto Sans Georgian'"; + +const face = (family: string, weight: number, extra: Partial<Face> = {}): Face => ({ + family: `'${family}', ${GEORGIAN}, sans-serif`, + weight, + ...extra, +}); + +const TAKEOVER: Record<string, Layout> = { + stat: "takeover", headline: "takeover", entity: "takeover", bullets: "takeover", quote: "takeover", + compare: "takeover", change: "takeover", share: "takeover", image: "takeover", video: "takeover", scene: "takeover", +}; + +const collage: Theme = { + pack: "collage", + color: { + ground: "#D1CABC", + sheet: "#F1EBE2", + strip: "#C8BEA8", + stripAlt: "#DAD4BF", + ink: "#2A2723", + muted: "#6E675D", + accent: "#D4C159", + highlight: "#EADA82", + label: "#2D2925", + onLabel: "#EFE9E2", + figure: "#D4C159", + }, + type: { + display: face("Playfair Display", 900, { italic: true, tracking: -1, lineHeight: 1.02 }), + label: face("Barlow Condensed", 600, { tracking: 0.5, lineHeight: 1.05 }), + mono: face("Courier Prime", 400, { lineHeight: 1.45 }), + caption: 74, + }, + edge: { kind: "torn", wave: 5, fiber: 1.6, rim: 9 }, + surface: "paper", + mark: "fill", + shadow: 0.07, + texture: { strength: 0.55, ground: 0.95 }, + tape: true, + media: "halftone", + tilt: 1.6, + motion: MOTIONS["stop-motion"], + layout: TAKEOVER, + textures: {}, +}; + +const editorial: Theme = { + ...collage, + pack: "editorial", + color: { + ...collage.color, + ground: "#EDE8DF", + sheet: "#F7F4EE", + strip: "#E3DDD2", + stripAlt: "#EDE8DF", + ink: "#161514", + accent: "#B3261E", + highlight: "#F1D3CC", + label: "#B3261E", + onLabel: "#F7F4EE", + figure: "#F7F4EE", + }, + type: { + display: face("Playfair Display", 700, { italic: true, tracking: -1, lineHeight: 1.04 }), + label: face("Barlow Condensed", 600, { tracking: 1, lineHeight: 1.05 }), + mono: face("Courier Prime", 400, { lineHeight: 1.45 }), + caption: 72, + }, + edge: { kind: "straight", wave: 0, fiber: 0, rim: 0 }, + shadow: 0.05, + texture: { strength: 0.25, ground: 0.4 }, + tape: false, + tilt: 0, + motion: MOTIONS.smooth, +}; + +const clean: Theme = { + ...collage, + pack: "clean", + color: { + ground: "#08090C", + sheet: "#08090C", + strip: "#08090C", + stripAlt: "#08090C", + ink: "#F5F6F8", + muted: "#8B93A1", + accent: "#4C9DF5", + highlight: "#4C9DF5", + label: "#4C9DF5", + onLabel: "#08090C", + figure: "#4C9DF5", + }, + type: { + display: face("DM Sans", 700, { tracking: -2.5, lineHeight: 1.02 }), + label: face("DM Sans", 700, { tracking: 0, lineHeight: 1.1, size: 0.8 }), + mono: face("DM Sans", 400, { lineHeight: 1.5 }), + caption: 58, + }, + edge: { kind: "straight", wave: 0, fiber: 0, rim: 0 }, + surface: "none", + mark: "color", + shadow: 0, + texture: { strength: 0, ground: 0 }, + tape: false, + media: "photo", + tilt: 0, + motion: MOTIONS.smooth, +}; + +export const PACKS = { clean, collage, editorial } satisfies Record<string, Theme>; + +export type PackId = keyof typeof PACKS; + +const isObject = (v: unknown): v is Record<string, unknown> => + typeof v === "object" && v !== null && !Array.isArray(v); + +const FORBIDDEN = /gradient|url\(|image-set|filter/i; + +const deepMerge = <T>(base: T, patch: unknown): T => { + if (!isObject(base) || !isObject(patch)) return (patch ?? base) as T; + const out: Record<string, unknown> = { ...base }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined || (typeof value === "string" && FORBIDDEN.test(value))) continue; + out[key] = isObject(value) && isObject(out[key]) ? deepMerge(out[key], value) : value; + } + return out as T; +}; + +export const resolveTheme = (input?: ThemeInput | null): Theme | null => { + if (!input) return null; + const pack: Theme = PACKS[input.pack] ?? PACKS.collage; + const base = input.motion ? { ...pack, motion: MOTIONS[input.motion] ?? pack.motion } : pack; + const merged = deepMerge(base, input.overrides ?? {}); + return { ...merged, textures: { ...merged.textures, ...(input.textures ?? {}) } }; +}; + +export const fontOf = (f: Face, size: number): React.CSSProperties => ({ + fontFamily: f.family, + fontWeight: f.weight, + fontStyle: f.italic ? "italic" : "normal", + fontSize: size, + letterSpacing: (f.tracking ?? 0) * (size / 60), + lineHeight: f.lineHeight ?? 1.2, +}); diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 51429565..33503f8c 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -119,3 +119,23 @@ def test_horizontal_selection_uses_the_format_window(self): config = {**DEFAULT_PRESET, "format": "horizontal"} bounds = ClipBounds.of(config["format"], *cli_mod._explicit_clip_bounds(config)) self.assertEqual((bounds.dur_min, bounds.dur_max), (60, 300)) + + +class JsonObjectArgTests(unittest.TestCase): + """`--style` is validated up front: malformed input stops the run rather + than rendering with a theme nobody asked for.""" + + def test_missing_value_is_none(self): + self.assertIsNone(cli_mod._json_object_arg(None, "--style")) + + def test_a_valid_object_passes_through(self): + parsed = cli_mod._json_object_arg('{"pack": "collage"}', "--style") + self.assertEqual(parsed, {"pack": "collage"}) + + def test_malformed_json_exits(self): + with self.assertRaises(SystemExit): + cli_mod._json_object_arg("not json", "--style") + + def test_a_json_array_is_not_an_object(self): + with self.assertRaises(SystemExit): + cli_mod._json_object_arg("[1, 2]", "--style") diff --git a/tests/test_clip_generator.py b/tests/test_clip_generator.py index 627d04cf..1f212f8e 100644 --- a/tests/test_clip_generator.py +++ b/tests/test_clip_generator.py @@ -213,6 +213,20 @@ def test_motion_reaches_the_renderer_as_one_value(self): payload = json.loads(argv[argv.index("--motion") + 1]) self.assertEqual(payload["captions"]["enter"], "pop") + def test_theme_reaches_the_renderer_as_one_value(self): + argv = self._render_args( + caption_style="subtle", + theme={"pack": "collage", "motion": "stop-motion"}, + ) + self.assertIn("--theme", argv) + payload = json.loads(argv[argv.index("--theme") + 1]) + self.assertEqual(payload["pack"], "collage") + self.assertEqual(payload["motion"], "stop-motion") + + def test_no_theme_sends_no_theme_flag(self): + argv = self._render_args(caption_style="subtle") + self.assertNotIn("--theme", argv) + def test_layout_values_reach_remotion_unchanged(self): argv = self._render_args( caption_style="branded", caption_position="center", caption_font_scale=125, @@ -359,6 +373,27 @@ def test_auto_fix_transition_jumps_is_bounded_by_max_passes(self): self.assertEqual(replace_mock.call_count, 2) + def test_auto_fix_transition_jumps_leaves_designed_cuts_sharp(self): + with mock.patch.object(cg, "_get_media_duration", return_value=40.0), \ + mock.patch.object(cg, "_detect_scene_cuts", return_value=[10.0, 10.6, 38.8]), \ + mock.patch.object(cg, "_apply_local_transition_smoothing", return_value=True) as smooth_mock, \ + mock.patch.object(cg.os, "replace"): + fixed = cg._auto_fix_transition_jumps( + "/tmp/fake.mp4", max_passes=1, designed=[10.1, 10.5, 38.9], + ) + + self.assertFalse(fixed) + smooth_mock.assert_not_called() + + def test_designed_cuts_come_from_card_windows(self): + cards = [{"kind": "stat", "start": 4, "end": 9.5}, {"kind": "quote", "start": 12.0, "end": True}, "junk"] + self.assertEqual(cg._designed_cuts(cards), [4.0, 9.5, 12.0]) + self.assertEqual(cg._designed_cuts(None), []) + + def test_designed_cuts_shift_by_the_intro(self): + cards = [{"kind": "stat", "start": 4, "end": 9.5}] + self.assertEqual(cg._designed_cuts(cards, offset=2.5), [6.5, 12.0]) + class TransitionAutofixGatingTests(unittest.TestCase): def test_multi_segment_cut_can_jump(self): diff --git a/tests/test_studio_format.py b/tests/test_studio_format.py index ead0a187..03cc6993 100644 --- a/tests/test_studio_format.py +++ b/tests/test_studio_format.py @@ -85,6 +85,46 @@ def test_old_callers_keep_the_original_layout_defaults(self): self.assertEqual(cmd[cmd.index("--logo-scale") + 1], "1.0") +class StudioStyleTests(unittest.TestCase): + """`--style` travels to clip_studio.py the same way `--brand` does.""" + + def test_style_reaches_the_render_script(self): + cmd = _run_studio(_studio_args(style='{"pack": "collage"}')) + self.assertIn("--style", cmd) + self.assertEqual(cmd[cmd.index("--style") + 1], '{"pack": "collage"}') + + def test_no_style_sends_no_style_flag(self): + cmd = _run_studio(_studio_args()) + self.assertNotIn("--style", cmd) + + +class ClipStudioStyleValidationTests(unittest.TestCase): + """clip_studio.py validates --style itself: it is also run standalone.""" + + def test_missing_value_is_none(self): + import clip_studio + + self.assertIsNone(clip_studio._json_object_arg(None, "--style")) + + def test_a_valid_object_passes_through(self): + import clip_studio + + parsed = clip_studio._json_object_arg('{"pack": "collage"}', "--style") + self.assertEqual(parsed, {"pack": "collage"}) + + def test_malformed_json_exits(self): + import clip_studio + + with self.assertRaises(SystemExit): + clip_studio._json_object_arg("not json", "--style") + + def test_a_json_array_is_not_an_object(self): + import clip_studio + + with self.assertRaises(SystemExit): + clip_studio._json_object_arg("[1, 2]", "--style") + + class StudioCanvasTests(unittest.TestCase): """Each stage must be handed the canvas, and it must be the spec's."""