Files
olivv-cs-ComfyUI-FunPack/modules/output/render/__init__.py
T

223 lines
9.8 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}.")
if model != rtx.NAME and upscale.comfy_busy():
return bad(upscale.BUSY)
job = jobs.new(stoppable=True)
task = asyncio.create_task(jobs.run(job, upscale.run, media, model, body.get("rtx_options") or "{}", error=(RuntimeError, ValueError, files.ClipError), stoppable=True, exclusive=True))
if model != rtx.NAME:
upscale.MODEL_JOBS[0] += 1
task.add_done_callback(lambda _t: upscale.MODEL_JOBS.__setitem__(0, max(0, upscale.MODEL_JOBS[0] - 1)))
return web.json_response({"job_id": job})
@table.get(base + "/upscale_busy")
async def _upscale_busy(_req):
"""Whether an upscale-model job is queued or running: Generate waits for it (they share ComfyUI's model memory)."""
return web.json_response({"busy": upscale.MODEL_JOBS[0] > 0})
@table.post(base + "/jobs/{job}/stop")
async def _stop_job(req):
"""Stop an Upscale job (the page's Stop button); only jobs that check for it can be stopped."""
return web.json_response({"stopped": jobs.stop(req.match_info["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}