""" Core Renderer — orchestrates the pipeline described in the PRD: render() -> download() -> timeline() -> transition() -> subtitle() -> audio() -> encode() -> cleanup() Each stage is its own module (downloader, timeline, compiler, subtitle, audio, ffmpeg_utils). This file only sequences them and writes progress to status.json so it stays readable and testable in isolation. """ import os import shutil from app.core.downloader import download_asset from app.core.timeline import build_timeline from app.core.compiler import compile_filter_graph from app.core.subtitle import build_subtitle_filter from app.core.audio import build_audio_filter from app.core.ffmpeg_utils import probe_duration, run_ffmpeg from app.core.templates import load_template from app.core.jobs import job_dir, write_status from app.core.logger import job_log OUTPUT_DIR = "outputs" def run_render_job(job_id: str, request: dict): """ Entry point invoked from a background task. `request` is the raw RenderRequest.dict(). Any exception here is caught by the caller and written to status.json as status=failed. """ write_status(job_id, status="downloading", progress="fetching assets") tmpl = load_template(request.get("template", "default")) # 1. download() — resolve every remote URL to a local, cached path resolved_clips = [] for item in request["timeline"]: local_path = download_asset(job_id, item["url"]) duration = item.get("duration") if item["type"] == "video" and duration is None: duration = probe_duration(local_path) elif item["type"] == "image" and duration is None: duration = tmpl.get("default_image_duration", 6) resolved_clips.append({ "id": item["id"], "type": item["type"], "local_path": local_path, "duration": duration, "animation": item.get("animation") or tmpl.get("default_animation", "none"), "transition_in": item.get("transition_in") or tmpl.get("default_transition", "none"), "transition_duration": item.get("transition_duration", tmpl.get("transition_duration", 0.5)), }) voice_path = download_asset(job_id, request["voice"]["url"]) if request.get("voice") else None bgm_path = download_asset(job_id, request["bgm"]["url"]) if request.get("bgm") else None bgm_volume = (request.get("bgm") or {}).get("volume", tmpl.get("bgm_volume", 0.15)) subtitle_path = download_asset(job_id, request["subtitle"]["url"]) if request.get("subtitle") else None subtitle_style = (request.get("subtitle") or {}).get("style", tmpl.get("subtitle_style", "default")) job_log(job_id, "assets resolved", stage="download") # 2. timeline() — build the abstract Scene/Layer/Clip model write_status(job_id, status="processing", progress="building timeline") timeline = build_timeline( resolved_clips, voice_path=voice_path, bgm_path=bgm_path, bgm_volume=bgm_volume, subtitle_path=subtitle_path, subtitle_style=subtitle_style, ) job_log(job_id, f"{len(timeline.clips)} clips, {timeline.total_duration:.1f}s total", stage="timeline") # 3. transition() + video filter graph — compiled in one step since # transitions are per-clip wrappers inside the same filter_complex write_status(job_id, status="processing", progress="compiling filter graph") graph = compile_filter_graph(timeline) filter_lines = [graph["filter_complex"]] video_label = graph["video_out_label"] input_args = list(graph["input_args"]) # 4. subtitle() — burn in on top of the compiled video stream if subtitle_path: sub_filter = build_subtitle_filter(subtitle_path, subtitle_style) filter_lines.append(f"[{video_label}]{sub_filter}[vsub]") video_label = "vsub" job_log(job_id, "subtitle stage done" if subtitle_path else "no subtitle", stage="subtitle") # 5. audio() — voice + bgm mixdown with ducking audio_input_offset = len(input_args) # ffmpeg input index tracking n_video_inputs = sum(1 for c in resolved_clips) # one -i per clip audio_idx = n_video_inputs audio_map_labels = {} if voice_path: input_args += ["-i", voice_path] audio_map_labels["voice"] = audio_idx audio_idx += 1 if bgm_path: input_args += ["-i", bgm_path] audio_map_labels["bgm"] = audio_idx audio_idx += 1 audio_build = build_audio_filter(bool(voice_path), bool(bgm_path), bgm_volume) audio_label = None if audio_build["audio_out_label"]: # remap generic [voice]/[bgm] labels in audio.py output to actual input indices relabeled = [] for line in audio_build["filter_lines"]: line = line.replace("[voice]", f"[{audio_map_labels.get('voice')}:a]") line = line.replace("[bgm]", f"[{audio_map_labels.get('bgm')}:a]") relabeled.append(line) filter_lines.extend(relabeled) audio_label = audio_build["audio_out_label"] job_log(job_id, "audio stage done", stage="audio") # 6. encode() write_status(job_id, status="encoding", progress="running ffmpeg") out_dir = os.path.join(OUTPUT_DIR) os.makedirs(out_dir, exist_ok=True) out_path = os.path.join(out_dir, f"{job_id}.mp4") log_path = os.path.join(job_dir(job_id), "ffmpeg.log") ffmpeg_args = input_args + [ "-filter_complex", ";".join(filter_lines), "-map", f"[{video_label}]", ] if audio_label: ffmpeg_args += ["-map", f"[{audio_label}]"] ffmpeg_args += [ "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-r", "30", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", out_path, ] run_ffmpeg(ffmpeg_args, log_path=log_path) job_log(job_id, f"encoded -> {out_path}", stage="encode") # thumbnail thumb_path = None thumb_spec = request.get("thumbnail") if thumb_spec: thumb_out = os.path.join(out_dir, f"{job_id}.jpg") if thumb_spec.get("url"): shutil.copy(download_asset(job_id, thumb_spec["url"]), thumb_out) elif thumb_spec.get("auto_extract"): at = thumb_spec.get("at_second", 1.0) run_ffmpeg(["-ss", str(at), "-i", out_path, "-frames:v", "1", thumb_out]) thumb_path = thumb_out # 7. cleanup() — drop per-job downloads (cache/ is untouched, that's # the whole point of it being separate from downloads/{job_id}) write_status(job_id, status="completed", progress="done", video=os.path.basename(out_path), thumbnail=os.path.basename(thumb_path) if thumb_path else None) job_log(job_id, "job complete", stage="cleanup") shutil.rmtree(os.path.join("downloads", job_id), ignore_errors=True)