From cebfc72ceec9f7d3a80ef602545555993c9ad4de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:37:47 +0000 Subject: [PATCH] Render names are a hash, and Render keeps the clips' real frame rate Final render, selected export and media-bin saves no longer carry the project's name: the server names them with the same hash rule as saved scene clips. Render used the project's static FPS even when clips were interpolated; it now probes the files and uses the fastest real rate. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tw9fjVnGVrbKC3XMjBh7Te --- app/ui/features/render/index.js | 2 +- app/ui/features/render/selected.js | 8 ++++---- modules/output/render/__init__.py | 4 +++- modules/output/render/stitch.py | 14 ++++++++++++++ .../output/render/tests/test_jobs_routes.py | 18 ++++++++++++++++++ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/ui/features/render/index.js b/app/ui/features/render/index.js index 9f41a5ed..1576d82c 100644 --- a/app/ui/features/render/index.js +++ b/app/ui/features/render/index.js @@ -24,7 +24,7 @@ export default { await p.flush(); const state = await runJob(`${RENDER}/projects/${open.id}/render`, { clips }); if (state.state !== "done") return tell(state.detail || "The render failed."); - const { media } = await call("POST", `${RENDER}/import-clip`, { clip: state.media, name: `${open.name}.mp4` }); + const { media } = await call("POST", `${RENDER}/import-clip`, { clip: state.media }); if (app.say) app.say("media"); // the bin shows it now c.toast.good({ text: `Rendered: saved to the media bin as ${media.name}.` }); } catch (err) { tell(err.message); } finally { busy = false; draw(); } diff --git a/app/ui/features/render/selected.js b/app/ui/features/render/selected.js index 4621fa3f..822873a1 100644 --- a/app/ui/features/render/selected.js +++ b/app/ui/features/render/selected.js @@ -31,14 +31,14 @@ export default { try { await work(); } catch (err) { tell(err.message); } finally { busy = false; draw(); } }; const exportIt = run(async () => { - const name = p.project.name, media = await join(); // named for the project it was started in + const media = await join(); if (!media) return; - app.dom.download(viewUrl(media), `${name}.mp4`); + app.dom.download(viewUrl(media), ""); // empty: the browser takes the server's hash name, never the project's }); const toBin = run(async () => { - const name = p.project.name, media = await join(); + const media = await join(); if (!media) return; - const { media: entry } = await call("POST", `${RENDER}/import-clip`, { clip: media, name: `${name}.mp4` }); + const { media: entry } = await call("POST", `${RENDER}/import-clip`, { clip: media }); app.say("media"); c.toast.good({ text: `Saved to the media bin as ${entry.name}.` }); }); diff --git a/modules/output/render/__init__.py b/modules/output/render/__init__.py index 44409c37..fd51c16a 100644 --- a/modules/output/render/__init__.py +++ b/modules/output/render/__init__.py @@ -165,7 +165,9 @@ def routes(table, base, web): 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 "clip.mp4").strip() or "clip.mp4" + 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: diff --git a/modules/output/render/stitch.py b/modules/output/render/stitch.py index 45693371..e7b8624a 100644 --- a/modules/output/render/stitch.py +++ b/modules/output/render/stitch.py @@ -268,6 +268,15 @@ def export_canvas(project, clips: list[dict], paths: list[str], fallback: tuple[ return int(_f(clips[idx].get("w")) or fallback[0]), int(_f(clips[idx].get("h")) or fallback[1]) +def _rate(text) -> float: + """ffprobe's "48/1" -> 48.0; 0.0 when unreadable.""" + try: + num, _, den = str(text).partition("/") + return float(num) / float(den or 1) + except (ValueError, ZeroDivisionError): + return 0.0 + + def render(project, clips: list[dict]) -> dict: """Run the final render: -> {"media": {...temp file...}, "clips": n}. Blocking: call it off the event loop.""" clips = [c for c in clips if _clip_spec_ok(c)] if isinstance(clips, list) else [] @@ -285,6 +294,11 @@ def render(project, clips: list[dict]) -> dict: for c in clips: c["w"], c["h"] = cw, ch # build_filter reads the canvas off the clips fps = _f(clips[0].get("fps")) or fps + real = [r for r in (_rate(files.frame_rate(p)) for p in paths) if r > 0] + if real: # what the files really run at (interpolated renders are faster than the project's setting); the fastest wins so none is thinned + fps = max(real) + for c in clips: + c["fps"] = fps for i, (c, p) in enumerate(zip(clips, paths)): c["has_audio"] = files.has_audio(p) have, want = files.duration(p), _f(c.get("dur")) diff --git a/modules/output/render/tests/test_jobs_routes.py b/modules/output/render/tests/test_jobs_routes.py index 19a9c98e..b827a7c0 100644 --- a/modules/output/render/tests/test_jobs_routes.py +++ b/modules/output/render/tests/test_jobs_routes.py @@ -4,6 +4,8 @@ import http.client import json import time +import re + import pytest pytest.importorskip("aiohttp") @@ -11,6 +13,7 @@ pytest.importorskip("aiohttp") from core import media, projects # noqa: E402 from core.tests.test_routes_shortcuts import server # noqa: E402,F401 from core import jobs # noqa: E402 +from modules.output.render import files # noqa: E402 from modules.output.render.tests.clips import comfy, make_clip, needs_ffmpeg, probe # noqa: E402,F401 BASE = "/fun/api/m/render" @@ -101,6 +104,10 @@ def test_a_clip_imports_into_the_media_library_trimmed_and_named(comfy, server): assert probe(media.path_for(out["media"]["id"]))[0] == pytest.approx(1.0, abs=0.15) status, whole = call(server, "POST", f"{BASE}/import-clip", {"clip": {"filename": "a.mp4"}}) assert status == 200 and media.path_for(whole["media"]["id"]).read_bytes() == (comfy.output / "a.mp4").read_bytes() + assert re.fullmatch(r"[0-9a-f]{12}\.mp4", whole["media"]["name"]), "no name given: a hash like every saved render, not the project's name" + again = call(server, "POST", f"{BASE}/import-clip", {"clip": {"filename": "a.mp4"}})[1] + assert again["media"]["name"] == whole["media"]["name"] + assert call(server, "POST", f"{BASE}/import-clip", {"clip": {"filename": "a.mp4", "in": 1.0, "dur": 1.0}})[1]["media"]["name"] != whole["media"]["name"] assert call(server, "POST", f"{BASE}/import-clip", {"clip": {}})[0] == 400 status, body = call(server, "POST", f"{BASE}/import-clip", {"clip": {"filename": "gone.mp4"}}) assert status == 400 and "not on disk" in body["detail"] @@ -117,3 +124,14 @@ def test_finished_jobs_are_forgotten_past_the_cap(monkeypatch): monkeypatch.setattr(jobs, "MAX_JOBS", 3) ids = [jobs.new() for _ in range(5)] assert jobs.get(ids[0]) is None and jobs.get(ids[4]) is not None + + +@needs_ffmpeg +def test_render_keeps_the_real_frame_rate_of_interpolated_clips(comfy, server): + make_clip(comfy.output / "fast.mp4", 1.0, rate=48) + proj = projects.save(projects.Project(width=160, height=120)) + status, queued = call(server, "POST", f"{BASE}/projects/{proj.id}/render", {"clips": [clip("fast.mp4", fps=24)]}) # the setting says 24, the file runs at 48 + assert status == 200 + job = finish(server, f"{BASE}/projects/{proj.id}/render", queued["job_id"]) + assert job["state"] == "done" + assert files.frame_rate(str(comfy.temp / job["media"]["filename"])) == "48/1"