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:
digital-garbage
2026-07-15 20:58:51 +03:00
co-authored by Claude Fable 5
parent bb4a87b4b5
commit e15b08da60
3 changed files with 200 additions and 66 deletions
+71 -16
View File
@@ -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) => {
+69 -39
View File
@@ -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
View File
@@ -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(