Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
font_family=config.get("font_family"),
crop_strategy=config.get("crop_strategy", "face"),
format=config.get("format", "vertical"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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)")
Expand Down
22 changes: 20 additions & 2 deletions backend/clip_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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)")
Expand All @@ -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"]
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 36 additions & 4 deletions backend/services/clip_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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]]:
"""
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1184,6 +1208,7 @@ def generate_clip(
cards=cards,
brand=brand,
font_family=font_family,
theme=theme,
captions=captions,
)

Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down Expand Up @@ -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),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

# Get file size
file_size = os.path.getsize(final_path)
Expand Down
4 changes: 4 additions & 0 deletions remotion/bundle-cache.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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");
Expand Down Expand Up @@ -148,6 +151,7 @@ export async function getCachedBundle({ onBundle } = {}) {
await bundle({
entryPoint: ENTRY_POINT,
outDir: staging,
publicDir: PUBLIC_DIR,
webpackOverride,
});
if (!ownsLock(lock)) {
Expand Down
35 changes: 35 additions & 0 deletions remotion/bundle-cache.test.mjs
Original file line number Diff line number Diff line change
@@ -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 = [];

Expand All @@ -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-"));
Expand Down
Loading
Loading