From d42bcd269ffbfdd48fa003d0a02d06999fa673c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 07:09:45 +0000 Subject: [PATCH] Render: playing at the clips' real frame rate is an option (Settings), off by default The final video keeps the project's frame rate unless 'Keep the clips' own frame rate' is switched on. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tw9fjXAUznBpkWSZm9vtPL --- app/ui/features/render/index.js | 6 +++++- modules/output/render/__init__.py | 5 +++-- modules/output/render/stitch.py | 9 +++++---- modules/output/render/tests/test_jobs_routes.py | 13 +++++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/ui/features/render/index.js b/app/ui/features/render/index.js index 1576d82c..a56babf3 100644 --- a/app/ui/features/render/index.js +++ b/app/ui/features/render/index.js @@ -22,7 +22,7 @@ export default { busy = true; draw(); try { await p.flush(); - const state = await runJob(`${RENDER}/projects/${open.id}/render`, { clips }); + const state = await runJob(`${RENDER}/projects/${open.id}/render`, { clips, real_fps: p.pref("renderRealFps", false) === true }); if (state.state !== "done") return tell(state.detail || "The render failed."); const { media } = await call("POST", `${RENDER}/import-clip`, { clip: state.media }); if (app.say) app.say("media"); // the bin shows it now @@ -34,6 +34,10 @@ export default { if (node) node.replaceWith(next); else host.append(next); node = next; } + app.editorSettings.push(() => c.region.stack({ gap: "md", children: [ + c.label.section({ text: "Render" }), + c.toggle.default({ label: "Keep the clips' own frame rate", hint: "Off: the final video plays at the project's frame rate. On: it plays at the fastest rate the clips really have, for example after Interpolate, so no frames are dropped.", + checked: p.pref("renderRealFps", false) === true, onChange: (v) => p.setPref("renderRealFps", v) })] })); draw(); const offered = offer(app, { id: "render-final", label: "Render", icon: "⧉", run: () => { if (!busy && p.project) render(); } }); const off = app.on(() => { if (Boolean(p.project) === node.disabled) draw(); }); diff --git a/modules/output/render/__init__.py b/modules/output/render/__init__.py index fd51c16a..ca28da41 100644 --- a/modules/output/render/__init__.py +++ b/modules/output/render/__init__.py @@ -117,12 +117,13 @@ def routes(table, base, web): @table.post(base + "/projects/{pid}/render") async def _render(req): proj = project_of(req) - clips = (await body_of(req)).get("clips") + 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, error=stitch.RenderError)) + 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}") diff --git a/modules/output/render/stitch.py b/modules/output/render/stitch.py index e7b8624a..289386d8 100644 --- a/modules/output/render/stitch.py +++ b/modules/output/render/stitch.py @@ -277,8 +277,9 @@ def _rate(text) -> float: 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.""" +def render(project, clips: list[dict], real_fps: bool = False) -> dict: + """Run the final render: -> {"media": {...temp file...}, "clips": n}. Blocking: call it off the event loop. + `real_fps`: play at the fastest rate the clip files really run at, not the project's frame rate.""" clips = [c for c in clips if _clip_spec_ok(c)] if isinstance(clips, list) else [] blank = not clips if blank and not has_graphics(project): @@ -294,8 +295,8 @@ 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 + real = [r for r in (_rate(files.frame_rate(p)) for p in paths) if r > 0] if real_fps else [] + if real: # what the files really run at (interpolated renders are faster than the project's setting); only when asked for; the fastest wins so none is thinned fps = max(real) for c in clips: c["fps"] = fps diff --git a/modules/output/render/tests/test_jobs_routes.py b/modules/output/render/tests/test_jobs_routes.py index b827a7c0..ef3f8e08 100644 --- a/modules/output/render/tests/test_jobs_routes.py +++ b/modules/output/render/tests/test_jobs_routes.py @@ -127,11 +127,12 @@ def test_finished_jobs_are_forgotten_past_the_cap(monkeypatch): @needs_ffmpeg -def test_render_keeps_the_real_frame_rate_of_interpolated_clips(comfy, server): +def test_render_keeps_the_real_frame_rate_of_interpolated_clips_only_when_asked(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" + for body, expected in (({}, "24/1"), ({"real_fps": True}, "48/1")): # the setting says 24, the file runs at 48 + status, queued = call(server, "POST", f"{BASE}/projects/{proj.id}/render", {"clips": [clip("fast.mp4", fps=24)], **body}) + 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"])) == expected