fix: ghost previews keep their in-point, empty segments refused, odd canvases render, clip sound padded to its picture, mixed-fps export, render reports what it left out, upscale notice/busy-state
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
beb5fe331b
commit
ece9e2e9d7
+3
-2
@@ -3450,7 +3450,7 @@
|
||||
if (!pid || _upscaling.has(key)) return;
|
||||
_upscaling.add(key);
|
||||
set({ notice: `Upscaling with ${model}…` });
|
||||
let notice = "";
|
||||
let notice = null; // null: leave whatever else was said meanwhile
|
||||
try {
|
||||
const promptId = await API.queueUpscale(media, model);
|
||||
let misses = 0;
|
||||
@@ -3473,7 +3473,7 @@
|
||||
notice = `Upscale failed: ${e && e.message ? e.message : e}`;
|
||||
} finally {
|
||||
_upscaling.delete(key);
|
||||
set({ notice });
|
||||
set({ notice: notice !== null ? notice : (state.notice && state.notice.startsWith("Upscaling with") ? "" : state.notice) });
|
||||
}
|
||||
}
|
||||
function upscaleRender(sceneId) {
|
||||
@@ -4670,6 +4670,7 @@
|
||||
});
|
||||
const name = (state.project.name || "montage").replace(/[^\w.-]+/g, "_") + `_final_${_stamp()}.mp4`;
|
||||
await _saveBlobAs(API.resultUrl(state.project.id, r.media), name);
|
||||
if (r.warnings && r.warnings.length) set({ notice: `Rendered, but: ${r.warnings.join(" ")}` });
|
||||
} catch (e) {
|
||||
set({ gen: { state: "error", promptId: null, media: [], msg: "Render failed: " + _friendlyGenError(e.message) } });
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
// and shared across every scene in the run, so a filmstrip per clip meant N full-file
|
||||
// downloads with preload=auto fighting the player pool over Chrome's 6-per-origin cap.
|
||||
const segUrl = API.previewSegmentUrl && st.project.id
|
||||
? API.previewSegmentUrl(st.project.id, scene.id, { media: r.media, renderIn: r.inSec || 0, dur: durSec })
|
||||
? API.previewSegmentUrl(st.project.id, scene.id, { media: r.media, renderIn: r.inSec || 0, srcIn: scene.source_in || 0, dur: durSec })
|
||||
: null;
|
||||
const url = segUrl || API.resultUrl(st.project.id, r.media);
|
||||
_fsEnqueue(() => new Promise((finish) => {
|
||||
@@ -1335,7 +1335,7 @@
|
||||
// trimmed segment is this scene's span only, so no inSec slice is needed to paint it.
|
||||
const API = window.MovieEditorAPI;
|
||||
const segUrl = API.previewSegmentUrl
|
||||
? API.previewSegmentUrl(st.project.id, scene.id, { media: r.media, renderIn: r.inSec || 0, dur: durSec })
|
||||
? API.previewSegmentUrl(st.project.id, scene.id, { media: r.media, renderIn: r.inSec || 0, srcIn: scene.source_in || 0, dur: durSec })
|
||||
: null;
|
||||
_attachWaveform(canvas, `scene-aud:${scene.id}:${inSec.toFixed(3)}:${durSec.toFixed(3)}`, segUrl || API.resultUrl(st.project.id, r.media), segUrl ? {
|
||||
width: w,
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
renders: st.sceneRenders,
|
||||
// The Upscale button: shown by the mode setting, disabled while that render upscales.
|
||||
upscale: [window.Store?.getEditorSetting?.("upscaleMode"),
|
||||
st.selectedSceneId ? window.Store?.isUpscaling?.(st.selectedSceneId) : false],
|
||||
(st.project?.scenes || []).map((s) => !!window.Store?.isUpscaling?.(s.id))],
|
||||
ratings: (st.project?.scenes || []).map((s) => [s.id, s.rating, s.source?.type]),
|
||||
conditioning: st.project?.conditioning_slot,
|
||||
// The Best-FaceID warning chip lives in this zone, so everything its message
|
||||
|
||||
@@ -132,7 +132,7 @@ def remux_faststart(src: str, out: str) -> None:
|
||||
run([ffmpeg(), "-y", "-i", src, "-c", "copy", "-movflags", "+faststart", out])
|
||||
|
||||
|
||||
def trim(src: str, out: str, start=None, dur=None, *, fast=False, reverse=False) -> None:
|
||||
def trim(src: str, out: str, start=None, dur=None, *, fast=False, reverse=False, fps=None) -> None:
|
||||
"""Cut [start, start+dur) out of `src` into a seekable h264/aac mp4.
|
||||
|
||||
`fast` is for previews, where the latency of a scrub across a clip boundary matters
|
||||
@@ -145,6 +145,8 @@ def trim(src: str, out: str, start=None, dur=None, *, fast=False, reverse=False)
|
||||
cmd += ["-i", src]
|
||||
if reverse:
|
||||
cmd += ["-vf", "reverse", "-af", "areverse"] # after the trim: only the clip is buffered
|
||||
if fps:
|
||||
cmd += ["-r", str(fps)] # parts to be joined must share one rate
|
||||
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p"]
|
||||
if fast:
|
||||
cmd += ["-preset", "veryfast"]
|
||||
@@ -180,6 +182,18 @@ def duration(path: str) -> float | None:
|
||||
return 0.0 if proc.returncode == 0 else 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")
|
||||
if not probe:
|
||||
return None
|
||||
proc = subprocess.run([probe, "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||
"stream=r_frame_rate", "-of", "default=nw=1:nk=1", path],
|
||||
capture_output=True, text=True)
|
||||
out = proc.stdout.strip()
|
||||
return out if out and out != "0/0" else None
|
||||
|
||||
|
||||
def add_silence(src: str, out: str) -> None:
|
||||
"""Copy `src` with a silent stereo track added, so it can be joined to clips that have sound."""
|
||||
run([ffmpeg(), "-y", "-i", src, "-f", "lavfi", "-i", f"anullsrc=r={AUDIO_RATE}:cl=stereo",
|
||||
|
||||
@@ -200,7 +200,7 @@ def ghost_clip(q) -> dict | None:
|
||||
if not q.get("filename") or q.get("dur") is None:
|
||||
return None
|
||||
try:
|
||||
start, dur = float(q.get("render_in") or 0), float(q["dur"])
|
||||
start, dur = float(q.get("render_in") or 0) + float(q.get("src_in") or 0), float(q["dur"])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if start != start or dur != dur:
|
||||
@@ -245,6 +245,11 @@ async def segment(request, project) -> "web.StreamResponse":
|
||||
f"{projects.safe_part(scene_id)}_{int(time.time() * 1000)}.mp4")
|
||||
await asyncio.to_thread(files.trim, src, out, clip.get("in"), clip.get("dur"),
|
||||
fast=True, reverse=reverse)
|
||||
got = files.duration(out)
|
||||
if got is not None and got <= 0: # a window past the end trims to a file with no picture
|
||||
os.remove(out)
|
||||
return web.json_response({"detail": "This clip's window starts past the end of its render: "
|
||||
"generate it again."}, status=400)
|
||||
except files.ClipError as exc:
|
||||
return web.json_response({"detail": str(exc)}, status=503)
|
||||
_segments.put(key, (None, out))
|
||||
|
||||
@@ -59,6 +59,7 @@ def build_filter(clips: list[dict], tracks: list[dict] | None = None, *, keep_or
|
||||
raise RenderError("There is nothing to render: no clips, no overlays and no audio.")
|
||||
cw = int(_f(clips[0].get("w")) or 768)
|
||||
ch = int(_f(clips[0].get("h")) or 768)
|
||||
cw, ch = cw - cw % 2, ch - ch % 2 # yuv420p cannot be encoded at an odd size
|
||||
fps = _f(clips[0].get("fps")) or 25.0
|
||||
for i, c in enumerate(clips):
|
||||
dur = _f(c.get("dur"))
|
||||
@@ -131,6 +132,8 @@ def _clip_audio(i: int, clip: dict, dur: float) -> str:
|
||||
vol = _f(clip.get("volume"), 1.0)
|
||||
if abs(vol - 1.0) > 1e-3:
|
||||
af += f",volume={max(0.0, vol):.3f}"
|
||||
if dur > 0: # exactly the picture's length: a short sound must not pull later clips early
|
||||
af += f",apad,atrim=0:{dur:.3f},asetpts=PTS-STARTPTS"
|
||||
return f"[{i}:a:0]{af}[a{i}]"
|
||||
|
||||
|
||||
@@ -224,12 +227,12 @@ def render(project, clips: list[dict]) -> dict:
|
||||
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 = cw - cw % 2, ch - ch % 2 # yuv420p cannot be encoded at an odd size
|
||||
fps = _f(clips[0].get("fps")) or fps
|
||||
for c, p in zip(clips, paths):
|
||||
c["has_audio"] = files.has_audio(p)
|
||||
blank_canvas = None
|
||||
else:
|
||||
cw, ch = cw - cw % 2, ch - ch % 2
|
||||
blank_canvas = {"w": cw, "h": ch, "fps": fps, "dur": graphics_duration(project)}
|
||||
try:
|
||||
ffmpeg = files.ffmpeg()
|
||||
@@ -263,6 +266,11 @@ def render(project, clips: list[dict]) -> dict:
|
||||
|
||||
drawn, pictures = overlays.prepare(project.overlay_tracks, project.overlay_lanes, tempdir=tempdir,
|
||||
image_path=image_path)
|
||||
warnings = []
|
||||
if len(drawn) < len(project.overlay_tracks):
|
||||
warnings.append(f"{len(project.overlay_tracks) - len(drawn)} overlay(s) left out: their picture is gone from the media library.")
|
||||
if len(tracks) < len(project.audio_tracks):
|
||||
warnings.append(f"{len(project.audio_tracks) - len(tracks)} audio lane(s) left out: their file is gone.")
|
||||
for path in pictures:
|
||||
cmd += ["-i", path]
|
||||
graph, has_audio = build_filter(clips, tracks, keep_original=keep, base_input=base, blank=blank_canvas)
|
||||
@@ -282,7 +290,7 @@ def render(project, clips: list[dict]) -> dict:
|
||||
except files.ClipError as exc:
|
||||
raise RenderError(f"ffmpeg could not make the render: {exc}") from exc
|
||||
return {"media": {"filename": name, "subfolder": "", "type": "temp", "kind": "videos"},
|
||||
"clips": len(clips) or 1}
|
||||
"clips": len(clips) or 1, "warnings": warnings}
|
||||
|
||||
|
||||
def concat(clips: list[dict]) -> dict:
|
||||
@@ -293,10 +301,14 @@ def concat(clips: list[dict]) -> dict:
|
||||
raise RenderError("There is nothing to export.")
|
||||
stamp = _stamp()
|
||||
parts = []
|
||||
fps = None
|
||||
try:
|
||||
for i, c in enumerate(clips):
|
||||
out = files.temp_file(f"funpack_seg_{stamp}_{i}.mp4")
|
||||
files.trim(files.clip_path(c), out, c.get("in"), c.get("dur"))
|
||||
src = files.clip_path(c)
|
||||
if fps is None:
|
||||
fps = files.frame_rate(src) # the first clip's rate: the joiner keeps one
|
||||
files.trim(src, out, c.get("in"), c.get("dur"), fps=fps)
|
||||
parts.append(out)
|
||||
got, want = files.duration(out), _f(c.get("dur"))
|
||||
if got is not None and (got <= 0 or (want > 0 and got < want - max(0.5, 0.1 * want))):
|
||||
|
||||
@@ -182,3 +182,24 @@ def test_the_url_window_wins_over_a_project_that_has_not_been_saved_yet(comfy, s
|
||||
# a different head trim is a different segment, not the cached one
|
||||
other = get(server, f"{BASE}/projects/{proj.id}/preview-segment/s1?dur=2.5&src_in=0")[2]
|
||||
assert other != body
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_a_ghost_whose_scene_is_not_saved_yet_still_gets_its_in_point(comfy, server):
|
||||
make_clip(comfy.output / "a.mp4", 4.0)
|
||||
proj = _project(comfy)
|
||||
url = (f"{BASE}/projects/{proj.id}/preview-segment/new?filename=a.mp4&subfolder=&type=output"
|
||||
"&render_in=0&src_in=2&dur=1.5")
|
||||
status, _h, a = get(server, url)
|
||||
status2, _h, b = get(server, url.replace("src_in=2", "src_in=0"))
|
||||
assert status == status2 == 200 and a != b
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_a_window_past_the_end_of_the_render_is_refused_not_served_empty(comfy, server):
|
||||
make_clip(comfy.output / "a.mp4", 1.0)
|
||||
proj = _project(comfy)
|
||||
url = (f"{BASE}/projects/{proj.id}/preview-segment/gone?filename=a.mp4&subfolder=&type=output"
|
||||
"&render_in=5&dur=1")
|
||||
status, _h, body = get(server, url)
|
||||
assert status == 400 and b"past the end" in body
|
||||
|
||||
@@ -215,3 +215,51 @@ def test_two_exports_in_one_second_do_not_share_a_file(comfy):
|
||||
first = stitch.concat([{"filename": "a.mp4", "dur": 1.0}])["media"]["filename"]
|
||||
second = stitch.concat([{"filename": "a.mp4", "dur": 1.0}])["media"]["filename"]
|
||||
assert first != second and (comfy.temp / first).is_file() and (comfy.temp / second).is_file()
|
||||
|
||||
|
||||
def _audio_seconds(path):
|
||||
import subprocess
|
||||
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
|
||||
"stream=duration", "-of", "default=nw=1:nk=1", str(path)],
|
||||
capture_output=True, text=True).stdout.split()
|
||||
return float(out[0])
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_a_clip_whose_sound_is_shorter_than_its_picture_does_not_pull_later_sound_early(comfy):
|
||||
import subprocess
|
||||
short = comfy.output / "short.mp4"
|
||||
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "testsrc=duration=2:size=160x120:rate=25",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=1.5", "-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p", "-c:a", "aac", str(short)], check=True, capture_output=True)
|
||||
out = stitch.render(projects.Project(width=160, height=120),
|
||||
[clip("short.mp4", dur=2.0), clip("short.mp4", dur=2.0), clip("short.mp4", dur=2.0)])
|
||||
assert _audio_seconds(comfy.temp / out["media"]["filename"]) == pytest.approx(6.0, abs=0.15)
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_an_odd_canvas_renders(comfy):
|
||||
make_clip(comfy.output / "a.mp4", 1.0, size="160x120")
|
||||
out = stitch.render(projects.Project(width=161, height=121), [clip("a.mp4", dur=1.0, w=161, h=121)])
|
||||
assert probe(comfy.temp / out["media"]["filename"])[1:3] == (160, 120)
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_export_of_clips_with_different_frame_rates_keeps_picture_and_sound_together(comfy):
|
||||
make_clip(comfy.output / "a.mp4", 2.0, rate=25)
|
||||
make_clip(comfy.output / "b.mp4", 2.0, rate=30)
|
||||
out = stitch.concat([{"filename": "a.mp4", "dur": 2.0}, {"filename": "b.mp4", "dur": 2.0}])
|
||||
path = comfy.temp / out["media"]["filename"]
|
||||
assert probe(path)[0] == pytest.approx(4.0, abs=0.25)
|
||||
assert _audio_seconds(path) == pytest.approx(4.0, abs=0.25)
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_a_render_says_what_it_left_out(comfy):
|
||||
make_clip(comfy.output / "a.mp4", 1.0)
|
||||
proj = projects.Project(width=160, height=120,
|
||||
overlay_tracks=[{"id": "o1", "kind": "image", "media_ref": "aaaaaaaaaaaa",
|
||||
"start_sec": 0, "duration_sec": 1}],
|
||||
audio_tracks=[{"id": "t1", "media_ref": "bbbbbbbbbbbb", "start_sec": 0}])
|
||||
out = stitch.render(proj, [clip("a.mp4", dur=1.0)])
|
||||
assert len(out["warnings"]) == 2 and "overlay" in out["warnings"][0] and "audio" in out["warnings"][1]
|
||||
|
||||
Reference in New Issue
Block a user