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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjVnGVrbKC3XMjBh7Te
This commit is contained in:
Claude
2026-11-02 14:37:47 +00:00
parent 8a99ee2686
commit cebfc72cee
5 changed files with 40 additions and 6 deletions
+1 -1
View File
@@ -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(); }
+4 -4
View File
@@ -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}.` });
});
+3 -1
View File
@@ -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:
+14
View File
@@ -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"))
@@ -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"