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:
@@ -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(); }
|
||||
|
||||
@@ -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}.` });
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user