From 0f3644583027c2ed1abff07c99635ddb011925b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:18:07 +0000 Subject: [PATCH] Interpolate runs as a FunUI background job, not a ComfyUI prompt (an extra prompt without loaders made ComfyUI drop the workflow's models); the NVIDIA effect stays loaded between jobs Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tw9fjW75aYB8UyVRZo6fCr --- CHANGELOG.md | 2 +- app/ui/features/interpolate/job.js | 20 ++++-- app/ui/features/interpolate/job.test.js | 16 +++-- app/ui/shell/api.js | 7 +- {modules/output/render => core}/jobs.py | 0 core/nvidia.py | 13 ++++ core/tests/test_nvidia.py | 10 +++ core/tests/test_shipped_nodes.py | 1 - modules/_core.py | 6 +- modules/output/interpolate/__init__.py | 43 ++++++++--- modules/output/interpolate/framegen.py | 8 +-- modules/output/interpolate/nodes.py | 72 ++++++++----------- .../interpolate/tests/test_interpolate.py | 35 ++++++++- modules/output/render/__init__.py | 4 +- modules/output/render/rtx.py | 7 +- .../output/render/tests/test_jobs_routes.py | 2 +- 16 files changed, 162 insertions(+), 84 deletions(-) rename {modules/output/render => core}/jobs.py (100%) create mode 100644 core/tests/test_nvidia.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ac8c5bd2..9a439b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Interpolate** (module of its own, add-on **NVIDIA RTX Frame Generation**): adds in-between frames to a finished render with NVIDIA's RTX frame generation (2 to 4 times the frames; the length and the sound stay, 24 fps becomes 48 fps). Settings > Editor > Interpolate: Never, a button beside the rating, or always when a render finishes. Quality Fast, Balanced or Best, and 10-bit processing. Works beside Upscale in either order and never runs twice on the same file. Needs an RTX card and nvidia-vfx 0.2 or newer: switching the add-on on moves an older nvidia-vfx up (nothing else is touched), and Super Resolution never moves a newer one back. ComfyUI's own RIFE/FILM interpolation nodes are a different thing and are not used. Works on a rental GPU (a few seconds for a clip). +- **Interpolate** (module of its own, add-on **NVIDIA RTX Frame Generation**): adds in-between frames to a finished render with NVIDIA's RTX frame generation (2 to 4 times the frames; the length and the sound stay, 24 fps becomes 48 fps). Settings > Editor > Interpolate: Never, a button beside the rating, or always when a render finishes. Quality Fast, Balanced or Best, and 10-bit processing. It runs as a background job of FunUI, not as a ComfyUI prompt, so it does not make ComfyUI drop the workflow's loaded models (an extra prompt without loaders does exactly that, and the next generation reloads them); the NVIDIA model also stays loaded between presses. Works beside Upscale in either order and never runs twice on the same file. Needs an RTX card and nvidia-vfx 0.2 or newer: switching the add-on on moves an older nvidia-vfx up (nothing else is touched), and Super Resolution never moves a newer one back. ComfyUI's own RIFE/FILM interpolation nodes are a different thing and are not used. Works on a rental GPU (a few seconds for a clip). - **Project settings > Resolution is the** (H3, LTX image to video): what Width and Height mean once the scene picture sets the shape. Short side (the smaller number is the clip's shorter side), Long side (the larger is its longer side) or Pixel count (the old rule: about Width x Height pixels, which turned 1024x1024 into 1312x768 for a wide picture). New projects start on Short side; projects saved earlier keep Pixel count, so nothing changes size by itself. The H3 preset no longer needs the KJNodes ImageTransformKJ node for this (saved H3 pipelines are switched over on load). - **VAE Loader: Decode-only VAE (Optional)**: a second VAE used only for the final picture decode while the first still encodes (references, start pictures). For decoders without an encoder: MiniMax H3's Sol-H3-VAE (`sol_h3_vae.safetensors`, whole clip at once, about 2.5x faster decode, needs a ComfyUI newer than 0.43.1) and the X2 Detail VAE. - **H3 loader: MXFP8** (Blackwell GPUs): stores the attention and MLP weights of the middle blocks as MXFP8 (first 2 and last 3 blocks stay bf16) for about twice the matmul rate. Needs torch 2.10+ with CUDA 13; skipped with a note elsewhere. Untested on a GPU; listen to the sound first. diff --git a/app/ui/features/interpolate/job.js b/app/ui/features/interpolate/job.js index 5934c586..5f35f3ca 100644 --- a/app/ui/features/interpolate/job.js +++ b/app/ui/features/interpolate/job.js @@ -1,5 +1,4 @@ -// One interpolation job from queue to result. Pure of the page; the waiting and the swap are shared with Upscale (shell/renders.js). -import { poll } from "../../shell/renders.js"; +// One interpolation job from start to result. Pure of the page; the swap and the step ledger are shared with Upscale (shell/renders.js). export const RTX = "NVIDIA RTX Frame Generation"; // the same string as modules/output/interpolate/framegen.py NAME export const DEFAULTS = { mode: "MEDIUM", multiplier: 2, tenbit: false }; @@ -8,6 +7,19 @@ const MODE = { LOW: "Fast", MEDIUM: "Balanced", HIGH: "Best" }; export const modeLabel = (m) => MODE[m] || m; /** -> the interpolated media, or throws a sentence. `sleep` is injectable so a test need not wait. */ -export async function interpolate(api, media, opts, sleep) { - return poll(api, await api.queueInterpolate(media, opts), sleep); +export async function interpolate(api, media, opts, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) { + const { job_id: id } = await api.interpolate(media, opts); + let lost = 0; + for (;;) { + await sleep(1000); + let r; + try { r = await api.interpolateJob(id); } catch (err) { + if (err.status === 404) throw new Error("ComfyUI restarted while it was working"); + if (++lost < 30) continue; // a dropped reply is not a failed job + throw new Error("lost contact with ComfyUI"); + } + lost = 0; + if (r.state === "error") throw new Error(r.detail || "failed inside ComfyUI"); + if (r.state === "done") return r.media; + } } diff --git a/app/ui/features/interpolate/job.test.js b/app/ui/features/interpolate/job.test.js index eb123992..7c33064c 100644 --- a/app/ui/features/interpolate/job.test.js +++ b/app/ui/features/interpolate/job.test.js @@ -5,15 +5,23 @@ import { derived, swap, wentThrough } from "../../shell/renders.js"; const m = (f, sub = "") => ({ filename: f, subfolder: sub, type: "output" }); -test("the options reach the node, the job is waited for, and an error is thrown in words", async () => { - const seen = [], seq = [null, { videos: [m("fi.mp4", "funui_interpolated")] }]; - const api = { queueInterpolate: async (...a) => (seen.push(a), "id"), upscaleResult: async () => seq.shift() }; +test("the options reach the job, the job is waited for, and an error is thrown in words", async () => { + const seen = [], seq = [{ state: "running" }, { state: "done", media: m("fi.mp4", "funui_interpolated") }]; + const api = { interpolate: async (...a) => (seen.push(a), { job_id: "id" }), interpolateJob: async () => seq.shift() }; assert.equal((await interpolate(api, m("x.mp4"), { multiplier: 2 }, async () => {})).filename, "fi.mp4"); assert.deepEqual(seen[0][1], { multiplier: 2 }); - const bad = { queueInterpolate: async () => "id", upscaleResult: async () => ({ error: "no libnvidia-ngx" }) }; + const bad = { interpolate: async () => ({ job_id: "id" }), interpolateJob: async () => ({ state: "error", detail: "no libnvidia-ngx" }) }; await assert.rejects(interpolate(bad, m("x.mp4"), {}, async () => {}), /libnvidia-ngx/); }); +test("a restart is named, a dropped reply is retried", async () => { + const gone = { interpolate: async () => ({ job_id: "id" }), interpolateJob: async () => { throw Object.assign(new Error("x"), { status: 404 }); } }; + await assert.rejects(interpolate(gone, m("x.mp4"), {}, async () => {}), /restarted/); + let n = 0; + const flaky = { interpolate: async () => ({ job_id: "id" }), interpolateJob: async () => { if (++n < 5) throw new Error("net"); return { state: "done", media: m("u.mp4") }; } }; + assert.equal((await interpolate(flaky, m("x.mp4"), {}, async () => {})).filename, "u.mp4"); +}); + test("modes read as words", () => { assert.deepEqual(["LOW", "MEDIUM", "HIGH"].map(modeLabel), ["Fast", "Balanced", "Best"]); }); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 4e651f37..d43faecf 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -144,10 +144,9 @@ export const api = { return body.prompt_id; }, frameGeneration: () => call("GET", "/api/m/interpolate/frame_generation"), - /** Interpolation is an ordinary ComfyUI job too; read it back with upscaleResult. -> the prompt id. */ - queueInterpolate(media, opts) { - return this.queueGraph({ 1: { class_type: "FunUIInterpolateVideo", inputs: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output", frame_options: JSON.stringify(opts || {}) } } }); - }, + /** Interpolation is a background job of FunUI, not a ComfyUI prompt (that would make ComfyUI drop the workflow's loaded models). -> {job_id}. */ + interpolate: (media, options) => call("POST", "/api/m/interpolate/run", { media: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output" }, options }), + interpolateJob: (id) => call("GET", `/api/m/interpolate/jobs/${encodeURIComponent(id)}`), /** Whether ComfyUI's queue holds a run of this project (queued from any tab). Unreadable: false, the queue decides. */ async projectQueued(projectId) { try { diff --git a/modules/output/render/jobs.py b/core/jobs.py similarity index 100% rename from modules/output/render/jobs.py rename to core/jobs.py diff --git a/core/nvidia.py b/core/nvidia.py index d5cfd6e2..bc4294db 100644 --- a/core/nvidia.py +++ b/core/nvidia.py @@ -26,3 +26,16 @@ def ngx_hint(): except OSError: return (" This machine has no libnvidia-ngx.so.1 (NVIDIA's driver library that the RTX video effects need; some rented GPUs do not mount it, " "like libnvidia-encode). Use an upscale model instead, or rent a host that exposes the full NVIDIA driver (graphics/video capabilities).") + + +_held = {} + + +def held(kind, key, build): + """The loaded effect of this `kind` for these settings, kept between jobs: pressing the button again reuses it instead of loading NVIDIA's model again. + A different setting or size replaces it (only one of each kind is kept, so the card is never holding a pile of them).""" + got = _held.get(kind) + if got is None or got[0] != key: + _held.pop(kind, None) + _held[kind] = (key, build()) + return _held[kind][1] diff --git a/core/tests/test_nvidia.py b/core/tests/test_nvidia.py new file mode 100644 index 00000000..035226e9 --- /dev/null +++ b/core/tests/test_nvidia.py @@ -0,0 +1,10 @@ +from core import nvidia + + +def test_a_loaded_effect_is_kept_between_jobs_and_replaced_by_a_different_setting(monkeypatch): + monkeypatch.setattr(nvidia, "_held", {}) + built = [] + make = lambda tag: (lambda: built.append(tag) or tag) + assert nvidia.held("k", (1, 2), make("a")) == "a" and nvidia.held("k", (1, 2), make("b")) == "a" + assert nvidia.held("k", (1, 3), make("c")) == "c" and built == ["a", "c"] # one per kind: the old one is let go + assert nvidia.held("other", (1, 3), make("d")) == "d" and nvidia.held("k", (1, 3), make("e")) == "c" diff --git a/core/tests/test_shipped_nodes.py b/core/tests/test_shipped_nodes.py index 965f1be3..b5830d7a 100644 --- a/core/tests/test_shipped_nodes.py +++ b/core/tests/test_shipped_nodes.py @@ -71,7 +71,6 @@ FROZEN = { "source_id", "phase_scale", "id_strength", "arcface_mode"], "FunUIDecode": ["samples", "vae", "model", "audio_vae", "tile_size"], "FunUIUpscaleVideo": ["filename", "subfolder", "type", "upscale_model", "rtx_options"], - "FunUIInterpolateVideo": ["filename", "subfolder", "type", "frame_options"], "FunUITrainLora": [ "model", "clip", "vae", "audio_vae", "set_name", "name", "use", "steps", "learning_rate", "rank", "algorithm", "optimizer", "loss", "batch_size", "grad_accumulation", "resolution", "aspect_buckets", "frames", "fps", "start", diff --git a/modules/_core.py b/modules/_core.py index 286e2ec0..f89b22e3 100644 --- a/modules/_core.py +++ b/modules/_core.py @@ -11,8 +11,8 @@ leading underscore keeps it out of the module scan. """ try: # root on sys.path: tests, dev server - from core import addons, chain, config, contract, control, dit_hooks, graph, input_steer, log, media, patching, projects, rated_dial, registry, relations, run, schema, shortcuts, streams, sysinfo, temp_files, traits, vae_tiles, video_pipe, nvidia + from core import addons, chain, config, contract, control, dit_hooks, graph, input_steer, jobs, log, media, patching, projects, rated_dial, registry, relations, run, schema, shortcuts, streams, sysinfo, temp_files, traits, vae_tiles, video_pipe, nvidia except ImportError: # inside ComfyUI: a subpackage of the pack - from ..core import addons, chain, config, contract, control, dit_hooks, graph, input_steer, log, media, patching, projects, rated_dial, registry, relations, run, schema, shortcuts, streams, sysinfo, temp_files, traits, vae_tiles, video_pipe, nvidia + from ..core import addons, chain, config, contract, control, dit_hooks, graph, input_steer, jobs, log, media, patching, projects, rated_dial, registry, relations, run, schema, shortcuts, streams, sysinfo, temp_files, traits, vae_tiles, video_pipe, nvidia -__all__ = ["addons", "chain", "config", "contract", "control", "dit_hooks", "graph", "input_steer", "log", "media", "patching", "projects", "rated_dial", "registry", "relations", "run", "schema", "shortcuts", "streams", "sysinfo", "temp_files", "traits", "vae_tiles", "video_pipe", "nvidia"] +__all__ = ["addons", "chain", "config", "contract", "control", "dit_hooks", "graph", "input_steer", "jobs", "log", "media", "patching", "projects", "rated_dial", "registry", "relations", "run", "schema", "shortcuts", "streams", "sysinfo", "temp_files", "traits", "vae_tiles", "video_pipe", "nvidia"] diff --git a/modules/output/interpolate/__init__.py b/modules/output/interpolate/__init__.py index 398b8bda..2591c675 100644 --- a/modules/output/interpolate/__init__.py +++ b/modules/output/interpolate/__init__.py @@ -1,28 +1,27 @@ """Interpolate: more frames for a finished video, with NVIDIA RTX Frame Generation (the nvidia-vfx add-on). -The length and the sound stay, the frame rate goes up (x2 turns 24 fps into 48 fps). It runs -beside ComfyUI on the file a render wrote, like Upscale, and is a module of its own. - -Not yet run on the real SDK (no RTX card in the test machine); the streaming around it is tested against real ffmpeg. +The length and the sound stay, the frame rate goes up (x2 turns 24 fps into 48 fps). It runs beside ComfyUI on the file a +render wrote, like Upscale, and is a module of its own. Unlike Upscale it is a background job of the page and not a ComfyUI +prompt, so it never disturbs ComfyUI's cache of the workflow's loaded models. """ -from . import framegen -from . import nodes +import asyncio -try: - from aiohttp import web -except Exception: # noqa: BLE001 -- tests without aiohttp - web = None +from . import framegen, nodes +from ..._core import jobs ID = "interpolate" TITLE = "Interpolate" STAGE = "post" CATEGORY = "post" STATUS = "experimental" -NODES = [nodes.FunUIInterpolateVideo] if nodes.io is not None else [] +NODES = [] def routes(table, base, web): + def bad(detail, status=400): + return web.json_response({"detail": detail}, status=status) + @table.get(base + "/frame_generation") async def _frame_generation(_req): from ..._core import addons @@ -30,5 +29,27 @@ def routes(table, base, web): "rtx": {"name": framegen.NAME, "modes": list(framegen.MODES), "max_multiplier": framegen.MAX_MULTIPLIER} if addons.enabled("nvidia_framegen") else None, "rtx_offered": any(a["id"] == "nvidia_framegen" for a in addons.listing())}) # offered = this machine could switch it on in Settings ▸ Modules + @table.post(base + "/run") + async def _run(req): + try: + body = await req.json() + except Exception: # noqa: BLE001 + body = None + media = body.get("media") if isinstance(body, dict) else None + if not isinstance(media, dict) or not isinstance(media.get("filename"), str) or not media["filename"]: + return bad("Send {media: {filename, subfolder, type}, options}.") + try: + framegen.options((body or {}).get("options") or {}) # refuse a bad value now, by name, not after the job was queued + except ValueError as exc: + return bad(str(exc)) + job = jobs.new() + asyncio.create_task(jobs.run(job, nodes.interpolate, media, body.get("options") or {}, error=nodes.InterpolateError)) + return web.json_response({"job_id": job}) + + @table.get(base + "/jobs/{job}") + async def _job(req): + job = jobs.get(req.match_info["job"]) + return web.json_response(job) if job else bad("Interpolate job not found (ComfyUI restarted?).", 404) + PROVIDES = {"routes": routes} diff --git a/modules/output/interpolate/framegen.py b/modules/output/interpolate/framegen.py index f1f56b7f..a9ac3490 100644 --- a/modules/output/interpolate/framegen.py +++ b/modules/output/interpolate/framegen.py @@ -45,10 +45,10 @@ def make(o): raise RuntimeError("This nvidia-vfx has no frame generation: update it (needs 0.2 or newer).") import torch mult = o["multiplier"] - effects, last = {}, [None] # one effect per frame size; the previous frame, on the GPU + last = [None] # the previous frame, on the GPU def effect(h, w): - if (h, w) not in effects: + def build(): kw = {"image_encoding": VideoFrameGeneration.ImageEncoding.RGB10A2} if o["tenbit"] else {} e = VideoFrameGeneration(getattr(VideoFrameGeneration.Mode, o["mode"]), **kw) e.input_width, e.input_height, e.frame_multiplier = w, h, mult @@ -56,8 +56,8 @@ def make(o): e.load() except Exception as exc: # noqa: BLE001 -- NvVFXError: say what to do, not just the SDK code raise RuntimeError(f"{NAME} could not start ({exc}).{ngx_hint()}") from exc - effects[(h, w)] = e - return effects[(h, w)] + return e + return nvidia.held("framegen", (o["mode"], o["tenbit"], mult, h, w), build) def run(x): out = [] diff --git a/modules/output/interpolate/nodes.py b/modules/output/interpolate/nodes.py index 28664352..5f917aa7 100644 --- a/modules/output/interpolate/nodes.py +++ b/modules/output/interpolate/nodes.py @@ -1,55 +1,41 @@ -"""Post-render interpolation: a finished video gets in-between frames (NVIDIA RTX Frame Generation), played at the multiplied frame rate. +"""The interpolation itself: a finished file in the output folder in, a new one beside it out. -Same stream as the upscale (chunks through ffmpeg, the source's sound kept); only the engine and the frame rate differ. +It runs beside ComfyUI as a background job of the page, NOT as a ComfyUI prompt: a prompt with no loaders in it makes ComfyUI's +cache let go of the loaders (and the models) of the workflow that ran before, so the next generation would load them again. """ import os from . import framegen -from ..._core import video_pipe +from ..._core import log, video_pipe SUBFOLDER = "funui_interpolated" -try: - from comfy_api.latest import io -except Exception: # noqa: BLE001 -- tests without ComfyUI - io = None -if io is not None: - class FunUIInterpolateVideo(io.ComfyNode): - @classmethod - def define_schema(cls) -> io.Schema: - return io.Schema( - node_id="FunUIInterpolateVideo", - display_name="FunUI Interpolate Video", - category="FunUI/Output", - description="Add in-between frames to a finished video file with NVIDIA RTX Frame Generation; the length and sound stay, the frame rate goes up.", - inputs=[ - io.String.Input("filename", default=""), - io.String.Input("subfolder", default=""), - io.Combo.Input("type", options=["output", "temp"], default="output"), - io.String.Input("frame_options", default="{}", optional=True, - tooltip="NVIDIA RTX Frame Generation options as JSON: mode (LOW, MEDIUM, HIGH), multiplier (2 to 4), tenbit."), - ], - outputs=[], - is_output_node=True, - ) +class InterpolateError(Exception): + """Something the person can act on, in a sentence.""" - @classmethod - def execute(cls, filename, subfolder, type, frame_options="{}") -> io.NodeOutput: - import comfy.model_management as mm - import folder_paths - src = video_pipe.comfy_path(filename, subfolder, type) - if not src or not os.path.isfile(src): - raise RuntimeError(f"{filename} is not in the {type} folder (anymore).") - opts = framegen.options(frame_options) - fn = framegen.make(opts) - out_dir = os.path.join(folder_paths.get_output_directory(), SUBFOLDER) - os.makedirs(out_dir, exist_ok=True) - name = video_pipe.free_name(out_dir, filename, f"x{opts['multiplier']}") - frames = video_pipe.stream_file(src, os.path.join(out_dir, name), fn, mm.throw_exception_if_processing_interrupted, opts["multiplier"]) - from ..._core import log - log.info("FunUI Interpolate", f"{filename} x{opts['multiplier']} ({opts['mode']}): {frames} frames -> {SUBFOLDER}/{name}") - return io.NodeOutput(ui={"videos": [{"filename": name, "subfolder": SUBFOLDER, - "type": "output", "format": "video/h264-mp4"}]}) +def interpolate(media, options, interrupted=lambda: None) -> dict: + """Blocking. `media` is {filename, subfolder, type}. -> {"media": the new file}.""" + try: + import folder_paths + except ImportError as exc: + raise InterpolateError("ComfyUI is not here to say where the output folder is.") from exc + src = video_pipe.comfy_path(media.get("filename"), media.get("subfolder") or "", media.get("type") or "output") + if not src or not os.path.isfile(src): + raise InterpolateError(f"{media.get('filename')} is not in the {media.get('type') or 'output'} folder (anymore).") + try: + opts = framegen.options(options) + fn = framegen.make(opts) + except (ValueError, RuntimeError) as exc: + raise InterpolateError(str(exc)) from exc + out_dir = os.path.join(folder_paths.get_output_directory(), SUBFOLDER) + os.makedirs(out_dir, exist_ok=True) + name = video_pipe.free_name(out_dir, media["filename"], f"x{opts['multiplier']}") + try: + frames = video_pipe.stream_file(src, os.path.join(out_dir, name), fn, interrupted, opts["multiplier"]) + except video_pipe.ClipError as exc: + raise InterpolateError(str(exc)) from exc + log.info("FunUI Interpolate", f"{media['filename']} x{opts['multiplier']} ({opts['mode']}): {frames} frames -> {SUBFOLDER}/{name}") + return {"media": {"filename": name, "subfolder": SUBFOLDER, "type": "output"}} diff --git a/modules/output/interpolate/tests/test_interpolate.py b/modules/output/interpolate/tests/test_interpolate.py index ab8b6202..d3efc49f 100644 --- a/modules/output/interpolate/tests/test_interpolate.py +++ b/modules/output/interpolate/tests/test_interpolate.py @@ -53,17 +53,19 @@ def test_frame_generation_feeds_pairs_in_order_across_chunks(monkeypatch): """A stand-in nvvfx (CPU) records the calls: every pair once, in order, indexes 1..N-1, the seam between chunks included.""" import sys import types - calls = [] + calls, loads = [], [] class Effect: class Mode: LOW = 0; MEDIUM = 1; HIGH = 2 class ImageEncoding: RGB8 = 0; RGB10A2 = 1 def __init__(self, mode, **kw): self.mode = mode - def load(self): pass + def load(self): loads.append(1) def run(self, a, b, frame_index=1): calls.append((float(a.mean()), float(b.mean()), frame_index)) return types.SimpleNamespace(image=(a + b) / 2) + from core import nvidia + monkeypatch.setattr(nvidia, "_held", {}) monkeypatch.setitem(sys.modules, "nvvfx", types.SimpleNamespace(VideoFrameGeneration=Effect)) from core import addons monkeypatch.setattr(addons, "enabled", lambda _id: True) @@ -75,3 +77,32 @@ def test_frame_generation_feeds_pairs_in_order_across_chunks(monkeypatch): assert a.shape[0] == 4 and b.shape[0] == 6 # 1 + 3, then 3 + 3: the seam pair got its frames too assert [round(c[1], 2) for c in calls[::2]] == [0.3, 0.6, 0.9] and [c[2] for c in calls] == [1, 2] * 3 assert calls[2][0] == pytest.approx(0.3) and calls[2][1] == pytest.approx(0.6) # the seam pair was interpolated + again = framegen.make(framegen.options({"multiplier": 3})) # a second job: NVIDIA's model stays loaded + again(frames[:2]) + assert len(loads) == 1 + + +@needs_ffmpeg +def test_the_job_writes_a_doubled_file_beside_the_original(comfy, monkeypatch): + import sys + import types + from modules.output.interpolate import nodes + make_clip(str(comfy.output / "a.mp4"), seconds=1.0, size="64x48", rate=10, audio=True) + monkeypatch.setitem(sys.modules, "folder_paths", types.SimpleNamespace(get_output_directory=lambda: str(comfy.output))) + monkeypatch.setattr(nodes.video_pipe, "comfy_dir", lambda kind: str(comfy.output)) + last = [None] + + def between(x): + out = [] + for f in x: + if last[0] is not None: + out.append((last[0] + f) / 2) + out.append(f) + last[0] = f + return torch.stack(out) + monkeypatch.setattr(nodes.framegen, "make", lambda o: between) + got = nodes.interpolate({"filename": "a.mp4", "subfolder": "", "type": "output"}, {})["media"] + assert got == {"filename": "a_x2.mp4", "subfolder": "funui_interpolated", "type": "output"} + assert upscale.probe(str(comfy.output / "funui_interpolated" / "a_x2.mp4")) == (64, 48, "20/1", True) + with pytest.raises(nodes.InterpolateError, match="not in the output folder"): + nodes.interpolate({"filename": "../x.mp4"}, {}) diff --git a/modules/output/render/__init__.py b/modules/output/render/__init__.py index 4f20f2fa..247829fd 100644 --- a/modules/output/render/__init__.py +++ b/modules/output/render/__init__.py @@ -13,8 +13,8 @@ import asyncio import os import time -from ..._core import media, projects -from . import files, jobs, library, rtx, serving, stitch, upscale +from ..._core import jobs, media, projects +from . import files, library, rtx, serving, stitch, upscale try: from aiohttp import web diff --git a/modules/output/render/rtx.py b/modules/output/render/rtx.py index 9ecdd306..d15e3cdd 100644 --- a/modules/output/render/rtx.py +++ b/modules/output/render/rtx.py @@ -63,10 +63,9 @@ def make(o): quality = getattr(VideoSuperRes.QualityLevel, o["quality"], None) if quality is None: raise RuntimeError(f"This nvidia-vfx has no quality level {o['quality']}.") - effects = {} def effect(h, w): - if (h, w) not in effects: + def build(): kw = {"quality": quality} if o["strength"] < 1.0: kw["strength"] = o["strength"] @@ -78,8 +77,8 @@ def make(o): e.load() except Exception as exc: # noqa: BLE001 -- NvVFXError: say what to do, not just the SDK code raise RuntimeError(f"{NAME} could not start ({exc}).{ngx_hint()}") from exc - effects[(h, w)] = e - return effects[(h, w)] + return e + return nvidia.held("vsr", (o["quality"], o["strength"], o["tenbit"], h, w, out_size(w, h, o)), build) def run(x): out = [] diff --git a/modules/output/render/tests/test_jobs_routes.py b/modules/output/render/tests/test_jobs_routes.py index 6a28fe26..19a9c98e 100644 --- a/modules/output/render/tests/test_jobs_routes.py +++ b/modules/output/render/tests/test_jobs_routes.py @@ -10,7 +10,7 @@ pytest.importorskip("aiohttp") from core import media, projects # noqa: E402 from core.tests.test_routes_shortcuts import server # noqa: E402,F401 -from modules.output.render import jobs # noqa: E402 +from core import jobs # noqa: E402 from modules.output.render.tests.clips import comfy, make_clip, needs_ffmpeg, probe # noqa: E402,F401 BASE = "/fun/api/m/render"