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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjXAUznBpkWSZm9vtPL
This commit is contained in:
Claude
2026-11-04 07:09:45 +00:00
parent fc53758f6f
commit d42bcd269f
4 changed files with 20 additions and 13 deletions
+5 -1
View File
@@ -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(); });
+3 -2
View File
@@ -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}")
+5 -4
View File
@@ -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
@@ -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