Spaces:
Sleeping
Sleeping
Download shorts_engine/render.py from callernode/autovido: direct link, hf CLI and curl.
- Browser
- Download file 11.6 kB
-
https://huggingface.co/spaces/callernode/autovido/resolve/main/shorts_engine/render.py
- Command line
-
hf download hf://spaces/callernode/autovido/shorts_engine/render.py
-
curl -L -o render.py https://huggingface.co/spaces/callernode/autovido/resolve/main/shorts_engine/render.py
11.6 kB
| """Scene clips (Ken Burns) -> xfade chain -> subtitles + narration + ducked music + SFX -> final MP4.""" | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import random | |
| import threading | |
| import time | |
| import traceback | |
| from pathlib import Path | |
| from . import config | |
| from .storage import ServiceError, job_dir, media_duration, read_json, run, scene_file, write_json | |
| AUDIO_EXT = (".mp3", ".wav", ".ogg", ".m4a", ".flac") | |
| _render_slots = threading.Semaphore(config.RENDER_CONCURRENCY) | |
| MOTIONS = ["zoom_in", "pan_right", "zoom_out", "pan_up", "pan_left", "pan_down"] | |
| def motion_for(text: str, idx: int) -> str: | |
| t = (text or "").lower().replace("-", " ") | |
| table = [ | |
| ("zoom_out", ("zoom out", "pull back", "pull out", "dolly out", "reveal")), | |
| ("zoom_in", ("zoom in", "push in", "dolly in", "close up", "closeup")), | |
| ("pan_left", ("pan left", "left")), | |
| ("pan_right", ("pan right", "right", "track")), | |
| ("pan_up", ("tilt up", "pan up", "rise", "upward", "crane up")), | |
| ("pan_down", ("tilt down", "pan down", "descend", "downward")), | |
| ("static", ("static", "still", "locked")), | |
| ] | |
| for motion, keys in table: | |
| if any(k in t for k in keys): | |
| return motion | |
| return MOTIONS[idx % len(MOTIONS)] | |
| def _zoompan(motion: str, frames: int) -> str: | |
| z = config.KEN_BURNS_ZOOM | |
| n = max(1, frames - 1) | |
| cx, cy = "iw/2-(iw/zoom/2)", "ih/2-(ih/zoom/2)" | |
| if motion == "zoom_out": | |
| zz, x, y = f"{1 + z}-{z}*on/{n}", cx, cy | |
| elif motion == "pan_left": | |
| zz, x, y = f"{1 + z}", f"(iw-iw/zoom)*(1-on/{n})", cy | |
| elif motion == "pan_right": | |
| zz, x, y = f"{1 + z}", f"(iw-iw/zoom)*on/{n}", cy | |
| elif motion == "pan_up": | |
| zz, x, y = f"{1 + z}", cx, f"(ih-ih/zoom)*(1-on/{n})" | |
| elif motion == "pan_down": | |
| zz, x, y = f"{1 + z}", cx, f"(ih-ih/zoom)*on/{n}" | |
| elif motion == "static": | |
| zz, x, y = f"{1 + z * 0.25}+{z * 0.1}*on/{n}", cx, cy # near-static, avoids a frozen look | |
| else: # zoom_in | |
| zz, x, y = f"1+{z}*on/{n}", cx, cy | |
| return f"zoompan=z='{zz}':x='{x}':y='{y}':d={frames}:s={config.WIDTH}x{config.HEIGHT}:fps={config.FPS}" | |
| def render_scene_clip(image: Path, out: Path, seconds: float, motion: str) -> None: | |
| frames = max(2, int(round(seconds * config.FPS))) | |
| # upscale 2x before zoompan: removes the integer-pixel "jitter" zoompan has at native size | |
| w2, h2 = config.WIDTH * 2, config.HEIGHT * 2 | |
| vf = (f"scale={w2}:{h2}:force_original_aspect_ratio=increase:flags=lanczos,crop={w2}:{h2}," | |
| f"{_zoompan(motion, frames)},setsar=1,format=yuv420p") | |
| run(["ffmpeg", "-y", "-v", "error", "-i", str(image), "-vf", vf, "-frames:v", str(frames), | |
| "-r", str(config.FPS), "-c:v", "libx264", "-preset", config.X264_PRESET, "-crf", str(config.CRF), | |
| "-pix_fmt", "yuv420p", str(out)], timeout=600) | |
| def _pick(folder: Path, seed: str) -> Path | None: | |
| if not folder.is_dir(): | |
| return None | |
| files = sorted(p for p in folder.rglob("*") if p.suffix.lower() in AUDIO_EXT) | |
| if not files: | |
| return None | |
| return random.Random(seed).choice(files) | |
| def choose_music(job_id: str, mood: str | None, genre: str | None, explicit: str | None) -> Path | None: | |
| if explicit: | |
| p = Path(explicit) | |
| if not p.is_absolute(): | |
| p = config.AUDIO_LIBRARY / "music" / explicit | |
| return p if p.exists() else None | |
| mapping = json.loads((config.AUDIO_LIBRARY / "mood_map.json").read_text()) \ | |
| if (config.AUDIO_LIBRARY / "mood_map.json").exists() else {} | |
| candidates = [] | |
| for key in (mood, genre): | |
| if key: | |
| k = key.lower().strip() | |
| candidates += [mapping.get(k, k), k] | |
| candidates.append("default") | |
| for folder in candidates: | |
| found = _pick(config.AUDIO_LIBRARY / "music" / folder, job_id) | |
| if found: | |
| return found | |
| return None | |
| def choose_sfx(name: str | None, seed: str) -> Path | None: | |
| if not name or name.lower() in ("none", "null", ""): | |
| return None | |
| root = config.AUDIO_LIBRARY / "sfx" | |
| if not root.is_dir(): | |
| return None | |
| key = name.lower().replace(" ", "_") | |
| files = sorted(p for p in root.rglob("*") if p.suffix.lower() in AUDIO_EXT) | |
| matches = [p for p in files if key in p.stem.lower() or p.parent.name.lower() == key] | |
| if not matches: | |
| for token in key.split("_"): | |
| if len(token) > 3: | |
| matches = [p for p in files if token in p.stem.lower() or token == p.parent.name.lower()] | |
| if matches: | |
| break | |
| return random.Random(seed + key).choice(matches) if matches else None | |
| def _status(job_id: str, **data) -> None: | |
| path = job_dir(job_id) / "render_status.json" | |
| cur = json.loads(path.read_text()) if path.exists() else {} | |
| cur.update(data, updated_at=time.time()) | |
| path.write_text(json.dumps(cur, indent=2)) | |
| def get_status(job_id: str) -> dict: | |
| base = job_dir(job_id) | |
| path = base / "render_status.json" | |
| status = json.loads(path.read_text()) if path.exists() else {"status": "not_started"} | |
| status["job_id"] = job_id | |
| status["files"] = { | |
| "story": (base / "story.json").exists(), | |
| "scenes": (base / "scenes.json").exists(), | |
| "images": len(list((base / "images").glob("scene_*.png"))), | |
| "audio": len(list((base / "audio").glob("scene_*.wav"))), | |
| "narration": (base / "narration.wav").exists(), | |
| "subtitles": (base / "subtitles" / "subtitles.ass").exists(), | |
| "video": (base / "final" / "video.mp4").exists(), | |
| "thumbnail": (base / "final" / "thumbnail.jpg").exists(), | |
| } | |
| return status | |
| def render(job_id: str, opts: dict) -> dict: | |
| base = job_dir(job_id) | |
| scenes = read_json(job_id, "scenes")["scenes"] | |
| timing = read_json(job_id, "timing") | |
| by_num = {t["scene_number"]: t for t in timing["scenes"]} | |
| n = len(scenes) | |
| if n == 0 or n > config.MAX_SCENES: | |
| raise ServiceError(f"scene count {n} outside 1..{config.MAX_SCENES}", stage="scenes") | |
| tr = float(opts.get("transition_seconds", config.TRANSITION_SECONDS)) | |
| transition = opts.get("transition", config.TRANSITION) | |
| # 1) per-scene Ken Burns clips; each non-final clip is extended by `tr` so xfade overlaps keep sync | |
| clips, durations = [], [] | |
| for i, sc in enumerate(scenes): | |
| num = sc["scene_number"] | |
| img = scene_file(job_id, "image", num) | |
| if not img.exists(): | |
| raise ServiceError(f"image for scene {num} missing", 409, stage="visuals") | |
| d = by_num[num]["duration"] | |
| length = d + (tr if i < n - 1 else 0) | |
| out = scene_file(job_id, "clip", num) | |
| _status(job_id, status="rendering", step=f"scene {num}/{n}") | |
| render_scene_clip(img, out, length, motion_for(sc.get("camera_motion", ""), i)) | |
| clips.append(out) | |
| durations.append(d) | |
| # 2) video graph: xfade chain + subtitles | |
| inputs, vf = [], [] | |
| for c in clips: | |
| inputs += ["-i", str(c)] | |
| if n == 1: | |
| vf.append("[0:v]null[vx]") | |
| else: | |
| prev, offset = "[0:v]", 0.0 | |
| for i in range(1, n): | |
| offset += durations[i - 1] | |
| label = "[vx]" if i == n - 1 else f"[x{i}]" | |
| vf.append(f"{prev}[{i}:v]xfade=transition={transition}:duration={tr}:offset={offset:.3f}{label}") | |
| prev = label | |
| ass = base / "subtitles" / "subtitles.ass" | |
| if ass.exists() and opts.get("burn_subtitles", True): | |
| ass_escaped = str(ass).replace("\\", "/").replace(":", "\\:") | |
| vf.append(f"[vx]ass='{ass_escaped}':fontsdir='{config.FONTS_DIR}',format=yuv420p[vout]") | |
| else: | |
| vf.append("[vx]format=yuv420p[vout]") | |
| # 3) audio graph: narration (+ ducked music) (+ SFX at scene starts) -> loudnorm | |
| total = sum(durations) | |
| idx = n | |
| inputs += ["-i", str(base / "narration.wav")] | |
| nar = idx | |
| idx += 1 | |
| af = [f"[{nar}:a]aresample=48000,apad,atrim=0:{total:.3f},asplit=2[nar][key]"] | |
| mix = ["[nar]"] | |
| music = choose_music(job_id, opts.get("mood"), opts.get("genre"), opts.get("music_file")) | |
| if music: | |
| inputs += ["-stream_loop", "-1", "-i", str(music)] | |
| m = idx | |
| idx += 1 | |
| vol = float(opts.get("music_volume", config.MUSIC_VOLUME)) | |
| af.append(f"[{m}:a]aresample=48000,aformat=channel_layouts=mono,atrim=0:{total:.3f},volume={vol}," | |
| f"afade=t=in:d=0.8,afade=t=out:st={max(0, total - 1.5):.3f}:d=1.5[mus]") | |
| af.append("[mus][key]sidechaincompress=threshold=0.03:ratio=8:attack=15:release=350[duck]") | |
| mix.append("[duck]") | |
| else: | |
| af.append("[key]anullsink") | |
| sfx_used = [] | |
| for sc in scenes: | |
| sfx = choose_sfx(sc.get("sfx"), job_id) | |
| if not sfx: | |
| continue | |
| inputs += ["-i", str(sfx)] | |
| s = idx | |
| idx += 1 | |
| start_ms = int(by_num[sc["scene_number"]]["start"] * 1000) | |
| af.append(f"[{s}:a]aresample=48000,aformat=channel_layouts=mono,atrim=0:4,afade=t=out:st=3:d=1," | |
| f"volume={config.SFX_VOLUME},adelay={start_ms}|{start_ms}[s{s}]") | |
| mix.append(f"[s{s}]") | |
| sfx_used.append({"scene_number": sc["scene_number"], "file": sfx.name}) | |
| if len(mix) > 1: | |
| af.append(f"{''.join(mix)}amix=inputs={len(mix)}:duration=first:normalize=0[mixed]") | |
| else: | |
| af.append("[nar]anull[mixed]") | |
| af.append(f"[mixed]loudnorm=I={config.TARGET_LUFS}:TP=-1.5:LRA=11,aresample=48000[aout]") | |
| final = base / "final" / "video.mp4" | |
| tmp_final = base / "final" / "video.tmp.mp4" | |
| _status(job_id, status="rendering", step="final encode") | |
| run(["ffmpeg", "-y", "-v", "error", *inputs, | |
| "-filter_complex", ";".join(vf + af), "-map", "[vout]", "-map", "[aout]", | |
| "-t", f"{total:.3f}", "-r", str(config.FPS), | |
| "-c:v", "libx264", "-profile:v", "high", "-preset", config.X264_PRESET, "-crf", str(config.CRF), | |
| "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", | |
| "-movflags", "+faststart", str(tmp_final)], timeout=1800) | |
| tmp_final.replace(final) | |
| manifest = {"video_path": str(final), "duration": round(media_duration(final), 3), "scenes": n, | |
| "music": music.name if music else None, "sfx": sfx_used, "transition": transition, | |
| "size_bytes": final.stat().st_size, | |
| "sha256": hashlib.sha256(final.read_bytes()).hexdigest()} | |
| write_json(job_id, "render_manifest", manifest) | |
| return manifest | |
| def start_render(job_id: str, opts: dict) -> dict: | |
| job_dir(job_id) | |
| cur = get_status(job_id) | |
| if cur.get("status") == "rendering" and time.time() - cur.get("updated_at", 0) < 900: | |
| return {"job_id": job_id, "status": "rendering", "message": "render already in progress"} | |
| _status(job_id, status="queued", error=None, started_at=time.time()) | |
| def worker(): | |
| with _render_slots: | |
| try: | |
| _status(job_id, status="rendering", step="starting") | |
| manifest = render(job_id, opts) | |
| _status(job_id, status="completed", step="done", **manifest) | |
| except ServiceError as exc: | |
| _status(job_id, status="failed", error=exc.message, failed_stage=exc.stage or "render") | |
| except Exception as exc: # noqa: BLE001 | |
| _status(job_id, status="failed", error=f"{exc}\n{traceback.format_exc()[-1500:]}", | |
| failed_stage="render") | |
| threading.Thread(target=worker, daemon=True).start() | |
| return {"job_id": job_id, "status": "queued"} | |