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