Fix multi-scene preview failing at scene 3 with ffmpeg-trimmed segments.

Serve per-scene faststart MP4s for chain playback instead of deep-seeking the raw ComfyUI output file.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
digital-garbage
2026-06-11 22:48:10 +03:00
co-authored by Cursor
parent 1072da0ce1
commit bb48a3aed7
4 changed files with 133 additions and 13 deletions
+2
View File
@@ -121,6 +121,8 @@
resultUrl: (id, m) =>
API(`/projects/${id}/result?filename=${encodeURIComponent(m.filename)}`) +
`&subfolder=${encodeURIComponent(m.subfolder || "")}&type=${encodeURIComponent(m.type || "output")}`,
previewSegmentUrl: (id, sceneId) =>
API(`/projects/${id}/preview-segment/${encodeURIComponent(sceneId)}`),
};
window.MovieEditorAPI = ClientAPI;
+38 -12
View File
@@ -48,7 +48,13 @@
let _currentClip = null;
const _urlFor = (media) => API.resultUrl(S.get().project?.id, media);
const _clipUrl = (clip) => clip.binUrl || (clip.media ? _urlFor(clip.media) : "");
const _clipUrl = (clip) => (clip && clip.streamUrl) || clip.binUrl || (clip.media ? _urlFor(clip.media) : "");
const _clipInSec = (clip) => ((clip && clip.streamUrl) ? 0 : ((clip && clip.inSec) || 0));
const _mediaChainKey = (clip) => {
if (!clip?.media?.filename) return null;
const m = clip.media;
return `${m.type || "output"}:${m.subfolder || ""}:${m.filename}`;
};
const _clampT = (v, t) => Math.max(0, Math.min(t, (v.duration || (t + 1)) - 0.02));
function _applyVideoTime(v, target, fast) {
@@ -103,7 +109,26 @@
function _clipWithinSec() {
if (!_active || !_currentClip || _currentClip.pending) return 0;
return _active.currentTime - (_currentClip.inSec || 0);
return _active.currentTime - _clipInSec(_currentClip);
}
// Multi-scene chain outputs share one ComfyUI file; deep seeks fail in the browser
// unless the MP4 is faststart-trimmed like export. Serve per-scene segments instead.
function _assignChainPreviewSegments(st, clips) {
const pid = st.project?.id;
if (!pid || !API.previewSegmentUrl) return;
const groups = new Map();
for (const c of clips) {
if (c.pending || c.ghost || c.binUrl || !c.sceneId) continue;
const k = _mediaChainKey(c);
if (!k) continue;
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(c);
}
for (const list of groups.values()) {
if (list.length < 2) continue;
for (const c of list) c.streamUrl = API.previewSegmentUrl(pid, c.sceneId);
}
}
function _onVideoSeeked(v) {
@@ -226,6 +251,7 @@
});
}
}
_assignChainPreviewSegments(st, out);
return out;
}
@@ -376,7 +402,7 @@
}
function _syncPool() {
const urls = new Set(_clips.filter((c) => c.media || c.binUrl).map((c) => _clipUrl(c)));
const urls = new Set(_clips.filter((c) => c.media || c.binUrl || c.streamUrl).map((c) => _clipUrl(c)));
urls.forEach((u) => _ensureVideo(u));
for (const [u, v] of [...pool]) {
if (urls.has(u)) continue;
@@ -509,11 +535,11 @@
if (play) _startTick();
return;
}
if (!clip.media && !clip.binUrl) return;
if (!clip.media && !clip.binUrl && !clip.streamUrl) return;
const v = _ensureVideo(_clipUrl(clip));
_currentClip = clip; _setActive(v);
_applyClipUi(clip);
const target = (clip.inSec || 0) + Math.max(0, offset);
const target = _clipInSec(clip) + Math.max(0, offset);
if (v.readyState >= 1) {
if (play && Math.abs(v.currentTime - _clampT(v, target)) > 0.04) _clipBoundaryToken++;
_applyVideoTime(v, target, !play);
@@ -542,8 +568,8 @@
&& _urlFor(next.media) === _urlFor(prev.media);
const sameBin = next.binUrl && prev.binUrl && next.binUrl === prev.binUrl;
const sharedSource = sameSource || sameBin;
const contiguous = sharedSource
&& Math.abs((next.inSec || 0) - ((prev.inSec || 0) + prev.durationSec)) < 0.05;
const contiguous = sharedSource && !prev.streamUrl && !next.streamUrl
&& Math.abs(_clipInSec(next) - (_clipInSec(prev) + prev.durationSec)) < 0.05;
_phSec = next.startSec;
_currentClip = next;
const crossClip = next.sceneId !== prev.sceneId || next.ghostId !== prev.ghostId;
@@ -580,7 +606,7 @@
return;
}
if (!_active) {
if (_currentClip.media || _currentClip.binUrl) {
if (_currentClip.media || _currentClip.binUrl || _currentClip.streamUrl) {
_goto(_currentClip, Math.max(0, _phSec - _currentClip.startSec), true);
if (_playing) _raf = requestAnimationFrame(_tick);
return;
@@ -616,17 +642,17 @@
&& (clip.media || clip.binUrl);
if (sameVideo && _active.readyState >= 1) {
const crossScene = prevClip && clip && prevClip !== clip
&& (prevClip.sceneId !== clip.sceneId || Math.abs((prevClip.inSec || 0) - (clip.inSec || 0)) > 0.05);
&& (prevClip.sceneId !== clip.sceneId || Math.abs(_clipInSec(prevClip) - _clipInSec(clip)) > 0.05);
_currentClip = clip;
_applyClipUi(clip);
const target = (clip.inSec || 0) + Math.max(0, offset);
const target = _clipInSec(clip) + Math.max(0, offset);
if (crossScene) _flushVideoSeek(true);
_scheduleVideoSeek(target, clip, offset);
} else if (!sameClip || clip.pending) {
_flushVideoSeek(true);
_goto(clip, offset, false);
} else if (!clip.pending && (clip.media || clip.binUrl)) {
const target = (clip.inSec || 0) + Math.max(0, offset);
const target = _clipInSec(clip) + Math.max(0, offset);
const v = _active;
if (v && v.readyState >= 1) _scheduleVideoSeek(target, clip, offset);
else _goto(clip, offset, false);
@@ -873,7 +899,7 @@
const hash = _clips.map((c) => {
if (c.pending) return "pending:" + (c.sceneId || c.ghostId || "");
if (c.ghost) return "ghost:" + c.ghostId;
return (_clipUrl(c) || "x") + "@" + c.startSec.toFixed(2) + "+" + (c.inSec || 0).toFixed(2);
return (_clipUrl(c) || "x") + "@" + c.startSec.toFixed(2) + "+" + _clipInSec(c).toFixed(2);
}).join("|");
if (hash !== _lastSegHash) {
_lastSegHash = hash;
+69
View File
@@ -149,6 +149,40 @@ def _ffmpeg_trim_clip(src_path: str, out_path: str, inn, dur) -> None:
raise RuntimeError((proc.stderr or "ffmpeg failed")[-1000:])
def _scene_playback_clip_spec(project: Project, scene_id: str) -> dict:
"""Export-matching trim spec for one scene's preview segment."""
scene = next((s for s in project.scenes if s.id == scene_id), None)
if not scene or scene.excluded:
raise KeyError(f"scene {scene_id}")
render = (project.scene_renders or {}).get(scene_id) or {}
media = render.get("media") or {}
if not media.get("filename"):
raise KeyError(f"no render for {scene_id}")
in_sec = float(scene.source_in or 0) + float(render.get("inSec") or 0)
if scene.source_dur is not None:
dur = float(scene.source_dur)
else:
fps = scene.eff_fps(project) or 25
dur = scene.eff_frames(project) / float(fps)
return {
"filename": media.get("filename", ""),
"subfolder": media.get("subfolder") or "",
"type": media.get("type") or "output",
"in": in_sec,
"dur": dur,
}
_preview_segment_cache: dict[str, str] = {}
def _preview_segment_cache_key(pid: str, scene_id: str, clip: dict) -> str:
return (
f"{pid}:{scene_id}:{clip.get('filename')}:{clip.get('subfolder') or ''}:"
f"{clip.get('type') or 'output'}:{clip.get('in')}:{clip.get('dur')}"
)
def _clip_bytes_for_media(clip: dict) -> bytes:
"""Read (and optionally trim) a clip spec into bytes for the media bin."""
import os
@@ -1391,6 +1425,41 @@ if web is not None and PromptServer is not None:
return web.json_response({"detail": "Render job not found."}, status=404)
return web.json_response(job)
# --- API: preview segment (ffmpeg trim + faststart, same as export per scene) ---
@routes.get(UI_PREFIX + "/api/projects/{pid}/preview-segment/{scene_id}")
async def _preview_segment(req):
import os
pid = req.match_info["pid"]
scene_id = req.match_info["scene_id"]
proj = _project_or_404(pid)
try:
clip = _scene_playback_clip_spec(proj, scene_id)
except KeyError as e:
return web.json_response({"detail": str(e)}, status=404)
key = _preview_segment_cache_key(pid, scene_id, clip)
cached = _preview_segment_cache.get(key)
if cached and os.path.isfile(cached):
return web.FileResponse(cached, headers={"Cache-Control": "private, max-age=3600"})
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Temp directory unavailable: {e}"}, status=500)
import time as _time
out_path = os.path.join(
tempdir,
f"funpack_preview_{pid[:8]}_{scene_id}_{int(_time.time() * 1000)}.mp4",
)
try:
src_path = _resolve_clip_src_path(clip)
_ffmpeg_trim_clip(src_path, out_path, clip.get("in"), clip.get("dur"))
except FileNotFoundError as e:
return web.json_response({"detail": str(e)}, status=400)
except RuntimeError as e:
return web.json_response({"detail": str(e)}, status=503)
_preview_segment_cache[key] = out_path
return web.FileResponse(out_path, headers={"Cache-Control": "private, max-age=3600"})
# --- API: export one clip (ffmpeg trim from a chain output using in/dur) ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/export-clip")
async def _export_clip(req):
+24 -1
View File
@@ -12,6 +12,7 @@ from movie_editor.server import (
_resolve_run_seed,
_run_sampler_inputs,
_run_studio_inputs,
_scene_playback_clip_spec,
_shortcut_seed,
)
@@ -280,4 +281,26 @@ def test_clip_bytes_copy_from_media_bin(tmp_path, monkeypatch):
entry = media.save_upload("take.mp4", b"MP4DATA")
assert _clip_needs_trim({"bin_media_ref": entry["id"]}) is False
assert _clip_bytes_for_media({"bin_media_ref": entry["id"]}) == b"MP4DATA"
assert _clip_needs_trim({"bin_media_ref": entry["id"], "in": 1.0}) is True
assert _clip_needs_trim({"bin_media_ref": entry["id"], "in": 1.0}) is True
def test_scene_playback_clip_spec_chain_offsets():
p = _project(scenes=[
{"id": "s1", "text": "a"},
{"id": "s2", "text": "b", "source_in": 0.5},
{"id": "s3", "text": "c", "source_dur": 2.5},
], frame_rate=25, num_frames_per_scene=97)
p.scene_renders = {
"s1": {"inSec": 0, "media": {"filename": "chain.mp4", "subfolder": "", "type": "output"}},
"s2": {"inSec": 3.88, "media": {"filename": "chain.mp4", "subfolder": "", "type": "output"}},
"s3": {"inSec": 7.4, "media": {"filename": "chain.mp4", "subfolder": "", "type": "output"}},
}
c1 = _scene_playback_clip_spec(p, "s1")
c2 = _scene_playback_clip_spec(p, "s2")
c3 = _scene_playback_clip_spec(p, "s3")
assert c1["in"] == 0
assert c1["dur"] == 97 / 25
assert c2["in"] == 3.88 + 0.5
assert c3["in"] == 7.4
assert c3["dur"] == 2.5
assert c1["filename"] == c2["filename"] == c3["filename"] == "chain.mp4"