Preview stability round 4: never serve mid-write video, signature-keyed remux/segment caches

Three holes left after the /result faststart remux (e296fe0), matching the
still-live symptoms (decode errors in console, blinking clips, playback
restarting from 0, stale previews):

1. Mid-write serving: /result hit while VHS is still writing the mp4 found no
   moov, failed the remux, and served the raw moov-less bytes anyway —
   undecodable, and the player's cache-busted retries kept blinking against it.
2. Byte-switching under one URL: once the write finished, the same URL switched
   from raw to remuxed bytes with different offsets/length. A browser mid-Range
   stream mixes offsets (PIPELINE_ERROR_DECODE) or fails If-Range revalidation
   and falls back to a full 200 — resetting the element and restarting the clip
   from the beginning.
3. Cache collisions/staleness: the remux cache was keyed by path only (a
   rewritten source kept serving the old remux) and the remux output was named
   by basename only (two sources sharing a basename overwrote each other's
   remuxed copy with -y, possibly while it was being streamed).

Fix: _is_faststart becomes _moov_position (front/end/none) so "remux me"
(moov at end = complete VHS file) is distinguished from "come back later"
(no moov = still being written or aborted save). No-moov sources now get 503 +
Retry-After from both /result and preview-segment instead of undecodable bytes;
the player retry loop already handles that. The remux cache is keyed on the
source's (mtime_ns, size) signature, the output name is derived from
path+signature (no basename collisions), the remuxed file is validated
(nonzero, moov at front) before caching, and a stable-but-unremuxable file is
remembered so ffmpeg isn't re-run per request. The preview-segment cache key
also gains the source signature so a re-rendered source at the same path can't
serve segments encoded from the old bytes.

Verified against real ffmpeg files: end/front/none discrimination on complete,
faststart, truncated-mid-write, and empty mp4s; remux of a truncated file
raises instead of caching garbage. All 160 movie_editor tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-07-13 09:19:01 +03:00
co-authored by Claude Fable 5
parent 4f121ff2f5
commit 9b202fec2d
+96 -27
View File
@@ -209,17 +209,19 @@ def _ffmpeg_trim_clip(src_path: str, out_path: str, inn, dur, fast: bool = False
raise RuntimeError((proc.stderr or "ffmpeg failed")[-1000:])
def _is_faststart(path: str) -> bool:
"""True if the MP4's moov atom comes before mdat (so browsers can start decoding from a
progressive/Range fetch without seeking to the tail first). ComfyUI's own VHS_VideoCombine
output has no +faststart, so its moov sits at the end — deep-seeking that raw file is what
the preview-segment path already routes around; this lets /result fix it for everyone else
(scrubbing a "shallow" clip, or just opening one) without needing a segment at all."""
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
complete file, but deep-seeking it raw is undecodable in the browser), or "none" (no moov
anywhere — the file is still being written, or a save was aborted; ffmpeg can't read it
either, so it must never be served as video). Distinguishing "end" from "none" is what
lets /result tell "remux me" apart from "come back later"."""
import os
try:
with open(path, "rb") as f:
size = os.fstat(f.fileno()).st_size
offset = 0
seen_mdat = False
while offset + 8 <= size:
f.seek(offset)
header = f.read(8)
@@ -228,22 +230,31 @@ def _is_faststart(path: str) -> bool:
box_size = int.from_bytes(header[:4], "big")
box_type = header[4:8]
if box_type == b"moov":
return True
return "end" if seen_mdat else "front"
if box_type == b"mdat":
return False
seen_mdat = True
if box_size == 1:
ext = f.read(8)
if len(ext) < 8:
break
box_size = int.from_bytes(ext, "big")
elif box_size == 0:
return False # box runs to EOF (typically mdat) without a moov seen yet
# Box runs to EOF without a size (mdat still being streamed to disk —
# ffmpeg backfills the size on finalize): nothing after it, no moov.
return "none"
if box_size < 8:
break
offset += box_size
except OSError:
return False
return False
pass
return "none"
def _file_sig(path: str) -> tuple:
"""Change detector for cache keys: same (mtime_ns, size) ⇒ same bytes for our purposes."""
import os
st = os.stat(path)
return (st.st_mtime_ns, st.st_size)
def _remux_faststart(src_path: str, out_path: str) -> None:
@@ -331,7 +342,8 @@ def _scene_playback_clip_spec(
_preview_segment_cache: dict[str, str] = {}
_preview_segment_locks: dict = {} # cache key -> asyncio.Lock (dedupe concurrent encodes)
_faststart_cache: dict[str, str] = {} # source path -> remuxed (faststart) path
_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_locks: dict = {} # source path -> asyncio.Lock (dedupe concurrent remuxes)
@@ -1811,37 +1823,80 @@ if web is not None and PromptServer is not None:
anything that already has moov up front, e.g. FunPack's own segment/export/final-render
output, which already sets +faststart itself. MP4/MOV only (moov/mdat is an ISO-BMFF
concept — a non-.mp4 container falling through the caller's extension gate would just
get echoed back as-is here, not silently mis-remuxed into a mismatched container)."""
get echoed back as-is here, not silently mis-remuxed into a mismatched container).
A file with NO moov (still being written, or an aborted save) raises 503 instead of
being served: raw moov-less bytes are undecodable, and once the write finishes the
same URL would start serving different bytes — a browser mid-Range-stream then mixes
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."""
import asyncio
import hashlib
import os
if os.path.splitext(src_path)[1].lower() != ".mp4":
return src_path
try:
sig = _file_sig(src_path)
except OSError:
return src_path # caller re-checks existence / falls through to 404
cached = _faststart_cache.get(src_path)
if cached and os.path.isfile(cached):
return cached
if _is_faststart(src_path):
return src_path
if cached and cached[0] == sig and os.path.isfile(cached[1]):
return cached[1]
lock = _faststart_locks.setdefault(src_path, asyncio.Lock())
async with lock:
try:
sig = _file_sig(src_path)
except OSError:
return src_path
cached = _faststart_cache.get(src_path)
if cached and os.path.isfile(cached):
return cached
if cached and cached[0] == sig and os.path.isfile(cached[1]):
return cached[1]
pos = _moov_position(src_path)
if pos == "front":
return src_path
if pos == "none":
# Mid-write (or truncated junk): never serve — undecodable, and the bytes
# 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
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception:
return src_path
base = os.path.splitext(os.path.basename(src_path))[0]
out_path = os.path.join(tempdir, f"funpack_faststart_{base}.mp4")
digest = hashlib.md5(f"{src_path}|{sig[0]}|{sig[1]}".encode()).hexdigest()[:16]
out_path = os.path.join(tempdir, f"funpack_faststart_{digest}.mp4")
err = None
try:
# to_thread: same event-loop-freeze hazard as preview-segment encodes below —
# this is a stream copy (no re-encode) so it's fast, but still blocking I/O.
await asyncio.to_thread(_remux_faststart, src_path, out_path)
ok = os.path.getsize(out_path) > 0 and _moov_position(out_path) == "front"
except Exception as e: # noqa: BLE001
print(f"[FunPack] faststart remux failed for {src_path}: {e}")
return src_path
_faststart_cache[src_path] = out_path
return out_path
ok = False
err = e
try:
sig_now = _file_sig(src_path)
except OSError:
sig_now = None # deleted under us — retry will 404 definitively
if ok and sig_now == sig:
_faststart_cache[src_path] = (sig, out_path)
return out_path
try:
os.remove(out_path)
except OSError:
pass
if sig_now != sig:
# Source changed under the remux — it's being (re)written right now.
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
@routes.get(UI_PREFIX + "/api/projects/{pid}/result")
async def _result(req):
@@ -1927,7 +1982,22 @@ if web is not None and PromptServer is not None:
clip = _ghost_playback_clip_spec(req.query)
if clip is None:
return web.json_response({"detail": str(e)}, status=404)
key = _preview_segment_cache_key(pid, scene_id, clip)
try:
src_path = _resolve_clip_src_path(clip)
src_sig = _file_sig(src_path)
except FileNotFoundError as e:
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":
# 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(
{"detail": "Source video is still being written."},
status=503, headers={"Retry-After": "2"})
# Source signature in the key: a re-rendered source at the SAME path must not keep
# serving the segment encoded from the old bytes (stale previews).
key = _preview_segment_cache_key(pid, scene_id, clip) + f":{src_sig[0]}:{src_sig[1]}"
# Serialize per segment: the pool preloads every clip at once after a run, and a
# scrub can re-request the same URL — without this lock each request would start
# its own duplicate encode of the same segment.
@@ -1947,7 +2017,6 @@ if web is not None and PromptServer is not None:
f"funpack_preview_{pid[:8]}_{scene_id}_{int(_time.time() * 1000)}.mp4",
)
try:
src_path = _resolve_clip_src_path(clip)
# to_thread: this encode takes seconds — run inline it would freeze the ONE
# aiohttp event loop ComfyUI has, stalling every playing /result stream and
# the whole editor API for its duration (stale/black previews on scrub).