Files
olivv-cs-ComfyUI-FunPack/modules/output/render/__init__.py
T
Claude d42bcd269f Render: playing at the clips' real frame rate is an option (Settings), off by default
The final video keeps the project's frame rate unless 'Keep the clips' own frame rate' is switched on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjXAUznBpkWSZm9vtPL
2026-11-04 07:09:45 +00:00

208 lines
8.9 KiB
Python

"""Render: playing, previewing, exporting and finishing the clips on the timeline.
Everything between "a clip was generated" and "a file you can keep": serving a render
so a browser can seek in it, the trimmed preview segment each timeline clip plays,
exporting a selection, and the final stitch with its seams, effects, audio and
overlays, plus the one node that upscales a finished file. This runs beside ComfyUI,
on the files it wrote.
Validated on a GPU rental; the ffmpeg parts are also tested against real ffmpeg.
"""
import asyncio
import os
import time
from ..._core import jobs, log, media, projects
from . import files, library, rtx, serving, stitch, upscale
try:
from aiohttp import web
except Exception: # noqa: BLE001 -- tests without aiohttp
web = None
ID = "render"
TITLE = "Render"
STAGE = "post"
CATEGORY = "post"
STATUS = "experimental"
NODES = [upscale.FunUIUpscaleVideo] if upscale.io is not None else []
def routes(table, base, web):
def project_of(req):
found = projects.get(req.match_info["pid"])
if found is None:
raise web.HTTPNotFound(reason="No such project.")
return found
async def body_of(req):
try:
body = await req.json()
except Exception: # noqa: BLE001
return {}
return body if isinstance(body, dict) else {}
def bad(detail, status=400):
return web.json_response({"detail": detail}, status=status)
@table.get(base + "/result")
async def _result(req):
return await serving.result(req)
@table.get(base + "/poster")
async def _poster(req):
return await serving.poster(req)
@table.get(base + "/projects/{pid}/preview-segment/{scene_id}")
async def _segment(req):
return await serving.segment(req, project_of(req))
@table.post(base + "/projects/{pid}/last-frame")
async def _last_frame(req):
proj = project_of(req)
body = await body_of(req)
scene_id = body.get("scene_id")
if not isinstance(scene_id, str) or not scene_id:
return bad("Send {scene_id}.")
render = body.get("render") if isinstance(body.get("render"), dict) and isinstance((body["render"].get("media") or None), dict) else None
window = serving.query_window({"dur": body.get("dur"), "src_in": body.get("src_in")})
at = body.get("at")
if at is not None and (isinstance(at, bool) or not isinstance(at, (int, float)) or not 0 <= at <= 1):
return bad("`at` is how far into the clip, 0 to 1.")
try:
return web.json_response(await asyncio.to_thread(serving.last_frame, proj, scene_id, render, window, bool(body.get("reverse")), at))
except KeyError as exc:
return bad(f"No render for this scene ({exc}).", 404)
except (files.ClipError, ValueError, OSError) as exc:
return bad(str(exc), 502)
@table.post(base + "/projects/{pid}/carry-log")
async def _carry_log(req):
"""The Log's record that a carried last frame went into a reference slot (the page knows the slot; the node does not)."""
proj = project_of(req)
body = await body_of(req)
scene_id, mid, ref = body.get("scene_id"), body.get("media_id"), body.get("reference")
if not isinstance(scene_id, str) or not media.is_id(mid) or isinstance(ref, bool) or not isinstance(ref, int) or ref < 1:
return bad("Send {scene_id, media_id, reference}.")
entry = media.get(mid) or {}
log.info("Continuity", f"last frame of the clip before scene {projects.safe_part(scene_id)[:40]} ({proj.id[:8]}) -> reference {ref}: {entry.get('name', mid)} [{mid}]")
return web.json_response({"ok": True})
@table.get(base + "/upscale_models")
async def _upscale_models(_req):
from ..._core import addons
return web.json_response({"models": upscale.models(), "rtx": {"name": rtx.NAME, "qualities": list(rtx.QUALITIES)} if addons.enabled("nvidia_vsr") else None,
"rtx_offered": any(a["id"] == "nvidia_vsr" for a in addons.listing())}) # offered = this machine could switch it on in Settings ▸ Modules
@table.post(base + "/upscale")
async def _upscale(req):
body = await body_of(req)
media, model = body.get("media"), body.get("model")
if not isinstance(media, dict) or not isinstance(media.get("filename"), str) or not media["filename"] or not isinstance(model, str) or not model:
return bad("Send {media: {filename, subfolder, type}, model, rtx_options}.")
job = jobs.new()
asyncio.create_task(jobs.run(job, upscale.run, media, model, body.get("rtx_options") or "{}", error=(RuntimeError, ValueError, files.ClipError)))
return web.json_response({"job_id": job})
@table.get(base + "/upscale/{job}")
async def _upscale_job(req):
job = jobs.get(req.match_info["job"])
return web.json_response(job) if job else bad("Upscale job not found (ComfyUI restarted?).", 404)
@table.get(base + "/library")
async def _library(_req):
return web.json_response(library.payload())
@table.post(base + "/projects/{pid}/render")
async def _render(req):
proj = project_of(req)
body = await body_of(req)
clips = body.get("clips")
clips = clips if isinstance(clips, list) else []
if not clips and not stitch.has_graphics(proj):
return bad("Nothing to render: add overlays, audio, or generated clips.")
job = jobs.new()
asyncio.create_task(jobs.run(job, stitch.render, proj, clips, body.get("real_fps") is True, error=stitch.RenderError))
return web.json_response({"job_id": job})
@table.get(base + "/projects/{pid}/render/{job}")
async def _render_status(req):
project_of(req)
job = jobs.get(req.match_info["job"])
return web.json_response(job) if job else bad("Render job not found.", 404)
@table.post(base + "/projects/{pid}/export-clip")
async def _export_clip(req):
project_of(req)
clip = (await body_of(req)).get("clip")
if not isinstance(clip, dict) or not (clip.get("filename") or clip.get("bin_media_ref")):
return bad("Missing clip filename.")
try:
out = await asyncio.to_thread(stitch.concat, [clip])
except stitch.RenderError as exc:
return bad(str(exc), 503)
return web.json_response({"media": out["media"]})
@table.post(base + "/projects/{pid}/export-clips")
async def _export_clips(req):
project_of(req)
clips = (await body_of(req)).get("clips")
if not isinstance(clips, list) or not clips:
return bad("Nothing to export.")
job = jobs.new()
asyncio.create_task(jobs.run(job, stitch.concat, clips, error=stitch.RenderError))
return web.json_response({"job_id": job})
@table.get(base + "/projects/{pid}/export-clips/{job}")
async def _export_status(req):
project_of(req)
job = jobs.get(req.match_info["job"])
return web.json_response(job) if job else bad("Export job not found.", 404)
@table.post(base + "/import-clip")
async def _import_clip(req):
body = await body_of(req)
clip = body.get("clip")
if not isinstance(clip, dict) or not (clip.get("bin_media_ref") or clip.get("filename")):
return bad("Missing clip source.")
name = str(body.get("name") or "").strip()
if not name: # no name given: a hash of which video it is, as every other saved render gets (never the project's name)
name = serving.saved_name("|".join(str(clip.get(k) or "") for k in ("bin_media_ref", "type", "subfolder", "filename", "in", "dur")), ".mp4")
if not name.lower().endswith((".mp4", ".webm", ".mov", ".mkv", ".avi")):
name += ".mp4"
try:
data = await asyncio.to_thread(_clip_bytes, clip)
entry = media.save_upload(name, data, limit=None) # our own render: a long one is over the upload limit
except (files.ClipError, stitch.RenderError) as exc:
return bad(str(exc), 400)
except ValueError as exc:
return bad(str(exc), 400)
return web.json_response({"media": entry})
def _clip_bytes(clip: dict) -> bytes:
"""The clip's bytes, trimmed when the spec asks for a window. Blocking."""
src = files.clip_path(clip)
inn, dur = clip.get("in"), clip.get("dur")
try:
needs_trim = (inn is not None and float(inn) > 0.001) or dur is not None
except (TypeError, ValueError):
needs_trim = True
if not needs_trim:
with open(src, "rb") as fh:
return fh.read()
out = files.temp_file(f"funui_import_{int(time.time() * 1000)}.mp4")
try:
files.trim(src, out, inn, dur)
with open(out, "rb") as fh:
return fh.read()
finally:
try:
os.remove(out)
except OSError:
pass
PROVIDES = {"routes": routes}