diff --git a/app/legacy/inspector.js b/app/legacy/inspector.js index b687e146..b70b17df 100644 --- a/app/legacy/inspector.js +++ b/app/legacy/inspector.js @@ -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"; diff --git a/app/legacy/store.js b/app/legacy/store.js index aea8475c..f239126f 100644 --- a/app/legacy/store.js +++ b/app/legacy/store.js @@ -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, diff --git a/core/projects.py b/core/projects.py index 0198af6b..b0cd3a20 100644 --- a/core/projects.py +++ b/core/projects.py @@ -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")), diff --git a/modules/output/render/files.py b/modules/output/render/files.py index d7322009..7325e98a 100644 --- a/modules/output/render/files.py +++ b/modules/output/render/files.py @@ -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") diff --git a/modules/output/render/stitch.py b/modules/output/render/stitch.py index 637543bc..bc679aee 100644 --- a/modules/output/render/stitch.py +++ b/modules/output/render/stitch.py @@ -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) diff --git a/modules/output/render/tests/test_stitch.py b/modules/output/render/tests/test_stitch.py index 2ecc8fdd..fe7b7621 100644 --- a/modules/output/render/tests/test_stitch.py +++ b/modules/output/render/tests/test_stitch.py @@ -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)