Video-Gen / app /core /renderer.py
jacky3102's picture
Upload 33 files
9459cd0 verified
Raw
History Blame Contribute Delete
6.85 kB
"""
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)