feat: final render is made at a clip's own resolution (first clip, a chosen clip, or the project's), never forced to the project size
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
7c4f353df2
commit
a799846df0
@@ -585,6 +585,17 @@
|
||||
row2.append(numberField("Height", p.height != null ? p.height : 512, (v) => S.patchProjectQuiet({ height: v }), "pj-h"));
|
||||
body.append(row2);
|
||||
|
||||
// The final render takes its size from a clip's own resolution, so a project Width/Height never
|
||||
// rescales the material (and never adds black bars).
|
||||
const sizeFrom = el("select"); sizeFrom.dataset.k = "pj-export-size";
|
||||
const rendered = S.orderedTimelineScenes().filter((sc) => !sc.excluded && S.get().sceneRenders[sc.id]);
|
||||
const opts = [["", "The first clip's own size"], ["project", `The project's size (${p.width || 768}×${p.height || 512})`]]
|
||||
.concat(rendered.map((sc, i) => [sc.id, `Clip ${i + 1}'s own size`]));
|
||||
if (p.export_size_from && !opts.some(([v]) => v === p.export_size_from)) opts.push([p.export_size_from, "A clip that is no longer here (the first clip is used)"]);
|
||||
opts.forEach(([v, lbl]) => { const o = new Option(lbl, v); if ((p.export_size_from || "") === v) o.selected = true; sizeFrom.append(o); });
|
||||
sizeFrom.onchange = () => S.patchProject({ export_size_from: sizeFrom.value });
|
||||
body.append(field("Final render size", sizeFrom));
|
||||
|
||||
// Sets what a new scene starts from and whether a missing anchor is worth mentioning.
|
||||
// Images stay wireable in either mode.
|
||||
const mode = el("select"); mode.dataset.k = "pj-genmode";
|
||||
|
||||
+1
-1
@@ -5061,7 +5061,7 @@
|
||||
get, set, subscribe, notify, init,
|
||||
scheduleSaveFromHistory, notifyHistoryState,
|
||||
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
|
||||
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, suspendSave, resumeSave, selectScene, addScene, removeScene, removeSelectedScenes, removeFromPlan, restoreToPlan, dismissGhost, moveScene, moveSceneTo, moveTimelineClip, scene,
|
||||
orderedTimelineScenes, patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, suspendSave, resumeSave, selectScene, addScene, removeScene, removeSelectedScenes, removeFromPlan, restoreToPlan, dismissGhost, moveScene, moveSceneTo, moveTimelineClip, scene,
|
||||
addVideoClip, addImageClip, convertToVideo, convertToScene, isVideoClip, isGenerativeScene,
|
||||
genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds,
|
||||
renderPromptForScene, renderPromptMismatch, renderAnchorMismatch, renderMediaLabel, renderIsStale,
|
||||
|
||||
@@ -412,6 +412,9 @@ class Project:
|
||||
refinement_key: str = "default"
|
||||
#: Audio: keep each clip's own sound, plus lanes mixed over the montage.
|
||||
keep_original_audio: bool = True
|
||||
#: Which clip's own resolution the final render takes: "" = the first clip on the timeline,
|
||||
#: "project" = the project's Width x Height, otherwise a scene id.
|
||||
export_size_from: str = ""
|
||||
audio_tracks: list = field(default_factory=list)
|
||||
overlay_lanes: list = field(default_factory=list)
|
||||
overlay_tracks: list = field(default_factory=list)
|
||||
@@ -472,6 +475,7 @@ class Project:
|
||||
references=[r for r in _blob(d.get("references"), list) if isinstance(r, str) and r][:256],
|
||||
refinement_key=_str(d.get("refinement_key"), "default", 64) or "default",
|
||||
keep_original_audio=_bool(d.get("keep_original_audio"), True),
|
||||
export_size_from=_str(d.get("export_size_from"), "", 128),
|
||||
audio_tracks=_dicts(d.get("audio_tracks")),
|
||||
overlay_lanes=_dicts(d.get("overlay_lanes")),
|
||||
overlay_tracks=_dicts(d.get("overlay_tracks")),
|
||||
|
||||
@@ -182,6 +182,21 @@ def duration(path: str) -> float | None:
|
||||
return 0.0 if proc.returncode == 0 else None
|
||||
|
||||
|
||||
def dimensions(path: str) -> tuple[int, int] | None:
|
||||
"""(width, height) of the first video stream, or None."""
|
||||
probe = shutil.which("ffprobe")
|
||||
if not probe:
|
||||
return None
|
||||
proc = subprocess.run([probe, "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||
"stream=width,height", "-of", "csv=p=0:s=x", path],
|
||||
capture_output=True, text=True)
|
||||
try:
|
||||
w, h = proc.stdout.strip().split("x")[:2]
|
||||
return (int(w), int(h)) if int(w) > 0 and int(h) > 0 else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def frame_rate(path: str) -> str | None:
|
||||
"""The first video stream's rate as ffprobe spells it ("25/1"), or None."""
|
||||
probe = shutil.which("ffprobe")
|
||||
|
||||
@@ -224,6 +224,20 @@ def _clip_spec_ok(c) -> bool:
|
||||
return isinstance(c, dict) and (c.get("bin_media_ref") or c.get("filename"))
|
||||
|
||||
|
||||
def export_canvas(project, clips: list[dict], paths: list[str], fallback: tuple[int, int]) -> tuple[int, int]:
|
||||
"""The size the final render is made at: the real resolution of the clip the person chose
|
||||
(default: the first), never the project's Width x Height unless asked for -- a project
|
||||
setting must not rescale the material."""
|
||||
choice = getattr(project, "export_size_from", "") or ""
|
||||
if choice == "project":
|
||||
return fallback
|
||||
idx = next((i for i, c in enumerate(clips) if choice and c.get("scene_id") == choice), 0)
|
||||
got = files.dimensions(paths[idx])
|
||||
if got:
|
||||
return got
|
||||
return int(_f(clips[idx].get("w")) or fallback[0]), int(_f(clips[idx].get("h")) or fallback[1])
|
||||
|
||||
|
||||
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 []
|
||||
@@ -237,7 +251,9 @@ def render(project, clips: list[dict]) -> dict:
|
||||
except files.ClipError as exc:
|
||||
raise RenderError(str(exc)) from exc
|
||||
if clips:
|
||||
cw, ch = int(_f(clips[0].get("w")) or cw), int(_f(clips[0].get("h")) or ch)
|
||||
cw, ch = export_canvas(project, clips, paths, (cw, ch))
|
||||
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
|
||||
for i, (c, p) in enumerate(zip(clips, paths)):
|
||||
c["has_audio"] = files.has_audio(p)
|
||||
|
||||
@@ -317,3 +317,16 @@ def test_an_audio_lane_whose_file_has_no_sound_is_left_out_and_said(comfy):
|
||||
proj = projects.Project(width=160, height=120, audio_tracks=[{"id": "t", "kind": "separated", "scene_id": "s1"}])
|
||||
out = stitch.render(proj, [clip("quiet.mp4", dur=1.0, scene_id="s1")])
|
||||
assert any("audio lane" in w for w in out["warnings"])
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_the_render_is_made_at_the_clips_own_size_not_the_projects(comfy):
|
||||
make_clip(comfy.output / "a.mp4", 1.0, size="160x120")
|
||||
make_clip(comfy.output / "b.mp4", 1.0, size="96x96")
|
||||
spec = lambda name, **k: clip(name, dur=1.0, w=100, h=100, **k) # noqa: E731 (scene settings say 100x100)
|
||||
size = lambda proj, clips: probe(comfy.temp / stitch.render(proj, clips)["media"]["filename"])[1:3] # noqa: E731
|
||||
clips = [spec("a.mp4", scene_id="s1"), spec("b.mp4", scene_id="s2")]
|
||||
assert size(projects.Project(width=100, height=100), clips) == (160, 120) # the first clip
|
||||
assert size(projects.Project(width=100, height=100, export_size_from="s2"), clips) == (96, 96)
|
||||
assert size(projects.Project(width=100, height=100, export_size_from="project"), clips) == (100, 100)
|
||||
assert size(projects.Project(width=100, height=100, export_size_from="gone"), clips) == (160, 120)
|
||||
|
||||
Reference in New Issue
Block a user