Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |