Spaces:
Sleeping
Sleeping
File size: 6,847 Bytes
9459cd0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | """
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)
|