Stop only reaches jobs that check for it; a failure landing with Stop counts as a stop. Hint for imported workflows corrected.
213 lines
9.2 KiB
Python
213 lines
9.2 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(stoppable=True)
|
|
asyncio.create_task(jobs.run(job, upscale.run, media, model, body.get("rtx_options") or "{}", error=(RuntimeError, ValueError, files.ClipError), stoppable=True))
|
|
return web.json_response({"job_id": job})
|
|
|
|
@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}
|