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:
co-authored by
Cursor
parent
1072da0ce1
commit
bb48a3aed7
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user