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)