From bb48a3aed75cb88e194b60aa2215252f9f633cc2 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:48:10 +0300 Subject: [PATCH] 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 --- movie_editor/frontend/api.js | 2 + movie_editor/frontend/player.js | 50 ++++++++++++++++------ movie_editor/server.py | 69 +++++++++++++++++++++++++++++++ movie_editor/tests/test_server.py | 25 ++++++++++- 4 files changed, 133 insertions(+), 13 deletions(-) diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 74690458..21547eaf 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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; diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index 2181915f..3c9f80ff 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -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; diff --git a/movie_editor/server.py b/movie_editor/server.py index 64779181..11e0e2f7 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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): diff --git a/movie_editor/tests/test_server.py b/movie_editor/tests/test_server.py index 3cc422cc..1d8e8687 100644 --- a/movie_editor/tests/test_server.py +++ b/movie_editor/tests/test_server.py @@ -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 \ No newline at end of file + 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" \ No newline at end of file