Preview stability round 5: never serve raw moov-at-end video, window the player pool
/result: a failed faststart remux now answers 502 (rate-limited re-attempts) instead of permanently marking the file serve-raw — raw moov-at-end bytes are undecodable on seek in every browser (Firefox: NS_ERROR_DOM_MEDIA_DECODE_ERR / AppleVTDecoder) and the element's cache-busted retries then fail identically forever. Temp-dir failures 503 instead of falling back to raw; the loopback fetch_view fallback spools ISO-BMFF video to temp and serves it through the same remux + FileResponse path (Range support included); moov gates now cover .m4v/.mov too. player: materialize <video> elements only in a window around the playhead (2 behind / 8 ahead, 16 max) instead of one per timeline clip. A montage-scale timeline (100 clips) created 100 elements and 178 concurrent preview-segment requests at once: Chrome hard-blocks media players past 75 per page, and the per-origin connection pool starves on tunneled instances — clips in the back half of the timeline never loaded while the front half played. Evicted and reset elements release their src before detach (connection-pool class). timeline: filmstrip probes run through a shared 2-at-a-time queue with a 20s wedge guard — painting a big timeline no longer starts one preload=auto element per clip simultaneously. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
bb4a87b4b5
commit
e15b08da60
@@ -579,33 +579,87 @@
|
||||
return v;
|
||||
}
|
||||
|
||||
// Materializing a <video> per clip URL for the WHOLE timeline breaks big (montage)
|
||||
// timelines: Chrome refuses new media players past ~75 per page, and every element's
|
||||
// preload=metadata fetch of a per-clip preview segment (each a server-side ffmpeg
|
||||
// encode) fights over the browser's ~6 per-origin connections on a tunneled instance —
|
||||
// so clips in the back half of the timeline sit "loading" forever while the front half
|
||||
// plays. Only clips in a window around the playhead get elements; the window keeps cut
|
||||
// boundaries instant (upcoming clips are materialized ahead) while the element count
|
||||
// stays flat no matter how long the timeline is.
|
||||
const POOL_BEHIND = 2, POOL_AHEAD = 8, POOL_MAX = 16;
|
||||
|
||||
function _windowUrls() {
|
||||
const urls = new Set();
|
||||
let idx = _currentClip ? _clips.indexOf(_currentClip) : -1;
|
||||
if (idx < 0) {
|
||||
const at = _clipAt(_phSec);
|
||||
idx = at ? _clips.indexOf(at) : -1;
|
||||
}
|
||||
if (idx < 0) idx = _clips.findIndex(_hasPlayableClipMedia);
|
||||
if (idx >= 0) {
|
||||
for (let i = Math.max(0, idx - POOL_BEHIND); i < _clips.length && i <= idx + POOL_AHEAD; i++) {
|
||||
const c = _clips[i];
|
||||
if (!c || c.pending || c.blank) continue;
|
||||
const u = _clipUrl(c);
|
||||
if (u) urls.add(u);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function _releasePooledVideo(u, v) {
|
||||
pool.delete(u);
|
||||
if (_active === v) _active = null;
|
||||
v.pause();
|
||||
clearTimeout(v._pmRetryTimer);
|
||||
// Release the socket BEFORE detaching: a detached media element that still has a
|
||||
// src holds its per-origin connection until GC (media-connection-pool class).
|
||||
try { v.removeAttribute("src"); v.load(); } catch (_) {}
|
||||
v._pmFx?.viewport.remove();
|
||||
}
|
||||
|
||||
// Materialize window members that are missing; evict far elements once the pool
|
||||
// outgrows POOL_MAX (the slack keeps recently-visited clips warm for back-and-forth
|
||||
// scrubbing instead of re-fetching on every jump). Runs at every playhead-affecting
|
||||
// step via _updatePreloadHints, and is idempotent/cheap when nothing changed.
|
||||
function _syncPoolWindow() {
|
||||
const want = _windowUrls();
|
||||
for (const u of want) {
|
||||
if (pool.has(u)) continue;
|
||||
const clip = _clips.find((c) => _clipUrl(c) === u);
|
||||
if (clip) _ensureVideo(u, clip);
|
||||
}
|
||||
if (pool.size <= POOL_MAX) return;
|
||||
for (const [u, v] of [...pool]) { // Map order = insertion order ⇒ oldest first
|
||||
if (pool.size <= POOL_MAX) break;
|
||||
if (want.has(u) || v === _active) continue;
|
||||
_releasePooledVideo(u, v);
|
||||
}
|
||||
}
|
||||
|
||||
function _syncPool() {
|
||||
_urlClips.clear();
|
||||
const urls = new Set(_clips.filter((c) => c.media || c.binUrl || c.streamUrl).map((c) => _clipUrl(c)));
|
||||
urls.forEach((u) => {
|
||||
// Timeline rebuild: drop elements whose URL no longer exists on any clip at all
|
||||
// (deleted/regenerated clips) regardless of the window.
|
||||
const live = new Set(_clips.filter((c) => c.media || c.binUrl || c.streamUrl).map((c) => _clipUrl(c)));
|
||||
for (const [u, v] of [...pool]) {
|
||||
if (live.has(u)) continue;
|
||||
_releasePooledVideo(u, v);
|
||||
}
|
||||
_syncPoolWindow();
|
||||
for (const [u, v] of pool) {
|
||||
const clip = _clips.find((c) => _clipUrl(c) === u);
|
||||
const v = _ensureVideo(u, clip);
|
||||
if (clip) _registerUrlClip(u, clip);
|
||||
// Clip rebuild (e.g. a run just completed): give exhausted-but-errored elements a
|
||||
// fresh recovery attempt — the failure was likely the server being busy back then.
|
||||
if (v.error && !v._pmRetryTimer) { v._pmRetries = 0; _scheduleVideoRetry(u, v); }
|
||||
});
|
||||
for (const [u, v] of [...pool]) {
|
||||
if (urls.has(u)) continue;
|
||||
v.pause();
|
||||
clearTimeout(v._pmRetryTimer);
|
||||
v._pmFx?.viewport.remove();
|
||||
pool.delete(u);
|
||||
if (_active === v) { _active = null; }
|
||||
}
|
||||
}
|
||||
|
||||
function _resetPool() {
|
||||
_stopTick();
|
||||
for (const [, v] of pool) {
|
||||
v.pause();
|
||||
clearTimeout(v._pmRetryTimer);
|
||||
v._pmFx?.viewport.remove();
|
||||
}
|
||||
for (const [u, v] of [...pool]) _releasePooledVideo(u, v);
|
||||
pool.clear(); _active = null; _currentClip = null; _seekPending = null; _playPending = false;
|
||||
_urlClips.clear();
|
||||
_clipBoundaryToken = 0;
|
||||
@@ -735,6 +789,7 @@
|
||||
// stay instant); everything else idles at metadata so the pool doesn't saturate the
|
||||
// browser's per-origin connections. A hint change never interrupts an in-flight load.
|
||||
function _updatePreloadHints() {
|
||||
_syncPoolWindow(); // playhead moved — materialize upcoming clips, evict far ones
|
||||
if (!pool.size) return;
|
||||
const hot = new Set();
|
||||
const add = (c) => {
|
||||
|
||||
@@ -201,6 +201,23 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Filmstrip probes run through a small shared queue: painting a big (montage) timeline
|
||||
// used to start one preload=auto <video> per clip AT ONCE — each of them a server-side
|
||||
// ffmpeg segment encode — saturating both the browser's ~6 per-origin connections and
|
||||
// the server, and starving the player pool (clips stuck "loading" on tunneled
|
||||
// instances). Two at a time keeps thumbnails flowing without wedging playback.
|
||||
const _fsQueue = [];
|
||||
let _fsActive = 0;
|
||||
const FS_CONCURRENCY = 2;
|
||||
function _fsPump() {
|
||||
while (_fsActive < FS_CONCURRENCY && _fsQueue.length) {
|
||||
const job = _fsQueue.shift();
|
||||
_fsActive++;
|
||||
job().catch(() => {}).then(() => { _fsActive--; _fsPump(); });
|
||||
}
|
||||
}
|
||||
function _fsEnqueue(job) { _fsQueue.push(job); _fsPump(); }
|
||||
|
||||
function appendFilmstrip(clip, st, scene, widthPx) {
|
||||
if (S.isVideoClip && S.isVideoClip(scene)) return;
|
||||
if (!hasRender(st, scene.id)) return;
|
||||
@@ -220,47 +237,60 @@
|
||||
? API.previewSegmentUrl(st.project.id, scene.id, { media: r.media, renderIn: r.inSec || 0, dur: durSec })
|
||||
: null;
|
||||
const url = segUrl || API.resultUrl(st.project.id, r.media);
|
||||
const vid = document.createElement("video");
|
||||
vid.muted = true; vid.preload = "auto"; vid.src = url;
|
||||
const captureAt = (time) => new Promise((resolve) => {
|
||||
if (!strip.isConnected) { resolve(); return; }
|
||||
const cell = el("div", "clip-fs-cell");
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 48; c.height = 27;
|
||||
const ctx = c.getContext("2d");
|
||||
const thumb = el("img", "clip-fs-thumb");
|
||||
thumb.alt = "";
|
||||
const done = () => {
|
||||
if (!strip.isConnected) { resolve(); return; }
|
||||
try { ctx.drawImage(vid, 0, 0, 48, 27); thumb.src = c.toDataURL("image/jpeg", 0.55); } catch (_) {}
|
||||
cell.append(thumb);
|
||||
strip.append(cell);
|
||||
resolve();
|
||||
_fsEnqueue(() => new Promise((finish) => {
|
||||
if (!strip.isConnected) { finish(); return; } // re-rendered before its turn came up
|
||||
const vid = document.createElement("video");
|
||||
vid.muted = true; vid.preload = "auto"; vid.src = url;
|
||||
// Free the media connection once the strip is captured (or abandoned) — a detached
|
||||
// <video> keeps its socket until GC, and those add up against Chrome's 6-per-origin
|
||||
// cap. Also settles the queue job; the guard timer keeps a wedged load (server busy,
|
||||
// dead tunnel) from clogging a queue slot forever.
|
||||
let settled = false;
|
||||
const release = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(guard);
|
||||
try { vid.removeAttribute("src"); vid.load(); } catch (_) {}
|
||||
finish();
|
||||
};
|
||||
vid.onseeked = done;
|
||||
const dur = vid.duration || durSec;
|
||||
vid.currentTime = Math.max(0, Math.min(time, dur - 0.01));
|
||||
});
|
||||
// Free the media connection once the strip is captured (or abandoned) — a detached
|
||||
// <video> keeps its socket until GC, and those add up against Chrome's 6-per-origin cap.
|
||||
const release = () => { try { vid.removeAttribute("src"); vid.load(); } catch (_) {} };
|
||||
vid.onerror = release;
|
||||
vid.onloadeddata = async () => {
|
||||
if (!strip.isConnected) { release(); return; }
|
||||
const dur = vid.duration || durSec;
|
||||
// A segment starts at 0 (it's already trimmed to this scene); the raw-file fallback
|
||||
// still needs the absolute in-point.
|
||||
const inSec = segUrl ? 0 : (r.inSec || 0) + (scene.source_in || 0);
|
||||
try {
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!strip.isConnected) return;
|
||||
const t = inSec + (dur * (i + 0.5) / n);
|
||||
await captureAt(t);
|
||||
const guard = setTimeout(release, 20000);
|
||||
const captureAt = (time) => new Promise((resolve) => {
|
||||
if (!strip.isConnected) { resolve(); return; }
|
||||
const cell = el("div", "clip-fs-cell");
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 48; c.height = 27;
|
||||
const ctx = c.getContext("2d");
|
||||
const thumb = el("img", "clip-fs-thumb");
|
||||
thumb.alt = "";
|
||||
const done = () => {
|
||||
if (!strip.isConnected) { resolve(); return; }
|
||||
try { ctx.drawImage(vid, 0, 0, 48, 27); thumb.src = c.toDataURL("image/jpeg", 0.55); } catch (_) {}
|
||||
cell.append(thumb);
|
||||
strip.append(cell);
|
||||
resolve();
|
||||
};
|
||||
vid.onseeked = done;
|
||||
const dur = vid.duration || durSec;
|
||||
vid.currentTime = Math.max(0, Math.min(time, dur - 0.01));
|
||||
});
|
||||
vid.onerror = release;
|
||||
vid.onloadeddata = async () => {
|
||||
if (!strip.isConnected) { release(); return; }
|
||||
const dur = vid.duration || durSec;
|
||||
// A segment starts at 0 (it's already trimmed to this scene); the raw-file fallback
|
||||
// still needs the absolute in-point.
|
||||
const inSec = segUrl ? 0 : (r.inSec || 0) + (scene.source_in || 0);
|
||||
try {
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!strip.isConnected) return;
|
||||
const t = inSec + (dur * (i + 0.5) / n);
|
||||
await captureAt(t);
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
const hasRender = (st, sceneId) => !!(st.sceneRenders && st.sceneRenders[sceneId] && st.sceneRenders[sceneId].media);
|
||||
|
||||
+60
-11
@@ -209,6 +209,14 @@ def _ffmpeg_trim_clip(src_path: str, out_path: str, inn, dur, fast: bool = False
|
||||
raise RuntimeError((proc.stderr or "ffmpeg failed")[-1000:])
|
||||
|
||||
|
||||
_ISOBMFF_EXTS = (".mp4", ".m4v", ".mov") # containers where moov/mdat atom order applies
|
||||
|
||||
|
||||
def _is_isobmff(path: str) -> bool:
|
||||
import os
|
||||
return os.path.splitext(path)[1].lower() in _ISOBMFF_EXTS
|
||||
|
||||
|
||||
def _moov_position(path: str) -> str:
|
||||
"""Where the MP4's moov atom sits: "front" (before mdat — browsers can decode from a
|
||||
progressive/Range fetch), "end" (after mdat — ComfyUI's own VHS_VideoCombine output; a
|
||||
@@ -343,8 +351,9 @@ _preview_segment_cache: dict[str, str] = {}
|
||||
_preview_segment_locks: dict = {} # cache key -> asyncio.Lock (dedupe concurrent encodes)
|
||||
|
||||
_faststart_cache: dict[str, tuple] = {} # source path -> (source sig, remuxed faststart path)
|
||||
_faststart_raw_ok: dict[str, tuple] = {} # source path -> sig a stable file failed to remux at (serve raw, don't re-run ffmpeg per request)
|
||||
_faststart_failed: dict[str, tuple] = {} # source path -> (sig, monotonic ts of last failed remux attempt)
|
||||
_faststart_locks: dict = {} # source path -> asyncio.Lock (dedupe concurrent remuxes)
|
||||
_FASTSTART_RETRY_SEC = 30.0 # min gap between remux re-attempts on a file that failed to remux
|
||||
|
||||
|
||||
def _preview_segment_cache_key(pid: str, scene_id: str, clip: dict) -> str:
|
||||
@@ -1831,11 +1840,18 @@ if web is not None and PromptServer is not None:
|
||||
offsets (PIPELINE decode errors) or fails If-Range and restarts the clip from 0.
|
||||
Caches are keyed on the source's (mtime, size) signature and the remux output name is
|
||||
derived from path+signature, so a rewritten source can't serve a stale copy and two
|
||||
sources sharing a basename can't overwrite each other's remux mid-stream."""
|
||||
sources sharing a basename can't overwrite each other's remux mid-stream.
|
||||
|
||||
A moov-at-end file must NEVER be served raw, whatever goes wrong here: deep seeks
|
||||
into it are undecodable in the browser (Firefox: NS_ERROR_DOM_MEDIA_DECODE_ERR /
|
||||
AppleVTDecoder reference-missing), and the element's cache-busted retries then fail
|
||||
identically forever. A remux failure answers 502 (rate-limited re-attempts) instead
|
||||
of falling back to raw bytes — an honest "Preview unavailable" beats undecodable video."""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
if os.path.splitext(src_path)[1].lower() != ".mp4":
|
||||
import time
|
||||
if not _is_isobmff(src_path):
|
||||
return src_path
|
||||
try:
|
||||
sig = _file_sig(src_path)
|
||||
@@ -1861,15 +1877,21 @@ if web is not None and PromptServer is not None:
|
||||
# will change under this URL. 503 lets the player's retry loop try again.
|
||||
raise web.HTTPServiceUnavailable(
|
||||
reason="Video file is still being written.", headers={"Retry-After": "2"})
|
||||
if _faststart_raw_ok.get(src_path) == sig:
|
||||
return src_path
|
||||
failed = _faststart_failed.get(src_path)
|
||||
if failed and failed[0] == sig and time.monotonic() - failed[1] < _FASTSTART_RETRY_SEC:
|
||||
raise web.HTTPBadGateway(
|
||||
reason="Video could not be prepared for playback (remux failed).")
|
||||
try:
|
||||
import folder_paths
|
||||
tempdir = folder_paths.get_temp_directory()
|
||||
except Exception:
|
||||
return src_path
|
||||
# No temp dir means no seek-safe copy can be made; raw moov-at-end bytes
|
||||
# would be undecodable, so tell the player to retry rather than serve them.
|
||||
raise web.HTTPServiceUnavailable(
|
||||
reason="Temp directory unavailable.", headers={"Retry-After": "5"})
|
||||
digest = hashlib.md5(f"{src_path}|{sig[0]}|{sig[1]}".encode()).hexdigest()[:16]
|
||||
out_path = os.path.join(tempdir, f"funpack_faststart_{digest}.mp4")
|
||||
ext = os.path.splitext(src_path)[1].lower()
|
||||
out_path = os.path.join(tempdir, f"funpack_faststart_{digest}{ext}")
|
||||
err = None
|
||||
try:
|
||||
# to_thread: same event-loop-freeze hazard as preview-segment encodes below —
|
||||
@@ -1885,6 +1907,7 @@ if web is not None and PromptServer is not None:
|
||||
sig_now = None # deleted under us — retry will 404 definitively
|
||||
if ok and sig_now == sig:
|
||||
_faststart_cache[src_path] = (sig, out_path)
|
||||
_faststart_failed.pop(src_path, None)
|
||||
return out_path
|
||||
try:
|
||||
os.remove(out_path)
|
||||
@@ -1895,8 +1918,9 @@ if web is not None and PromptServer is not None:
|
||||
raise web.HTTPServiceUnavailable(
|
||||
reason="Video file is still being written.", headers={"Retry-After": "2"})
|
||||
print(f"[FunPack] faststart remux failed for {src_path}: {err}")
|
||||
_faststart_raw_ok[src_path] = sig # stable but un-remuxable: serve raw, once
|
||||
return src_path
|
||||
_faststart_failed[src_path] = (sig, time.monotonic())
|
||||
raise web.HTTPBadGateway(
|
||||
reason="Video could not be prepared for playback (remux failed).")
|
||||
|
||||
@routes.get(UI_PREFIX + "/api/projects/{pid}/result")
|
||||
async def _result(req):
|
||||
@@ -1913,7 +1937,7 @@ if web is not None and PromptServer is not None:
|
||||
# Mirror the GET path's mid-write gate: a moov-less mp4 would 503 on GET, so a
|
||||
# HEAD claiming 200 would lie — the player probes HEAD after a media error to
|
||||
# tell "render still being written" (show "processing") apart from a dead file.
|
||||
if os.path.splitext(path)[1].lower() == ".mp4" and _moov_position(path) == "none":
|
||||
if _is_isobmff(path) and _moov_position(path) == "none":
|
||||
raise web.HTTPServiceUnavailable(
|
||||
reason="Video file is still being written.", headers={"Retry-After": "2"})
|
||||
return web.Response()
|
||||
@@ -1936,6 +1960,31 @@ if web is not None and PromptServer is not None:
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise web.HTTPBadGateway(reason=f"could not fetch result: {e}")
|
||||
fname = req.query.get("filename", "")
|
||||
if _is_isobmff(fname):
|
||||
# A buffered full-body mp4/mov response has no Range support and, for a raw
|
||||
# ComfyUI render, moov at the end — both make seeking undecodable in the
|
||||
# browser. Spool the fetched bytes to temp and serve through the same
|
||||
# remux-and-FileResponse path as resolver hits.
|
||||
import hashlib
|
||||
try:
|
||||
import folder_paths
|
||||
tempdir = folder_paths.get_temp_directory()
|
||||
except Exception:
|
||||
raise web.HTTPServiceUnavailable(
|
||||
reason="Temp directory unavailable.", headers={"Retry-After": "5"})
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
digest = hashlib.md5(
|
||||
f"{fname}|{req.query.get('subfolder', '')}|"
|
||||
f"{req.query.get('type', 'output')}|{len(data)}".encode()).hexdigest()[:16]
|
||||
spool = os.path.join(tempdir, f"funpack_viewspool_{digest}{ext}")
|
||||
if not os.path.isfile(spool) or os.path.getsize(spool) != len(data):
|
||||
tmp = spool + ".part"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(data)
|
||||
os.replace(tmp, spool) # atomic: no reader ever sees a partial spool
|
||||
path = await _faststart_path(spool)
|
||||
return web.FileResponse(path, headers={"Content-Type": ctype.split(";")[0]})
|
||||
return web.Response(body=data, content_type=ctype.split(";")[0])
|
||||
|
||||
# --- API: final render (async job — ffmpeg can exceed Cloudflare tunnel timeouts) ---
|
||||
@@ -1995,7 +2044,7 @@ if web is not None and PromptServer is not None:
|
||||
return web.json_response({"detail": str(e)}, status=400)
|
||||
except OSError as e:
|
||||
return web.json_response({"detail": str(e)}, status=503)
|
||||
if os.path.splitext(src_path)[1].lower() == ".mp4" and _moov_position(src_path) == "none":
|
||||
if _is_isobmff(src_path) and _moov_position(src_path) == "none":
|
||||
# No moov ⇒ still being written (VHS finalizes it last) or an aborted save:
|
||||
# ffmpeg can't trim it and the segment would come out truncated/undecodable.
|
||||
return web.json_response(
|
||||
|
||||
Reference in New Issue
Block a user