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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjW75aYB8UyVRZo6fCr
This commit is contained in:
Claude
2026-10-29 13:18:07 +00:00
parent 0e63670f7a
commit 0f36445830
16 changed files with 162 additions and 84 deletions
+1 -1
View File
@@ -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.
+16 -4
View File
@@ -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;
}
}
+12 -4
View File
@@ -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"]);
});
+3 -4
View File
@@ -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 {
+13
View File
@@ -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]
+10
View File
@@ -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"
-1
View File
@@ -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",
+3 -3
View File
@@ -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"]
+32 -11
View File
@@ -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}
+4 -4
View File
@@ -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 = []
+29 -43
View File
@@ -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"}}
@@ -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"}, {})
+2 -2
View File
@@ -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
+3 -4
View File
@@ -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 = []
@@ -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"