Fix undecodable scene previews: faststart-remux raw ComfyUI renders in /result

ComfyUI's VHS_VideoCombine node writes moov at the end of the mp4 (no
+faststart). Deep-seeking that file is exactly what preview-segment already
routes multi-scene chain clips around, but a "shallow" single clip (inSec
<=0.5) or just opening a scene's own preview hits /result directly, gets
served the raw moov-at-end file, and fails to decode the moment the user
scrubs anywhere into it — reproduced live as repeated
NS_ERROR_DOM_MEDIA_DECODE_ERR/AppleVTDecoder failures on the same file even
after cache-busted retries, since the bytes on disk are permanently bad for
seeking, not transiently mid-write.

Fix at the source instead of extending the segment path: /result now
stream-copy remuxes (+faststart, no re-encode) any .mp4 it serves that
doesn't already have moov up front, caching the remuxed copy so it only
happens once per file. FunPack's own segment/export/final-render output
already sets +faststart, so this is a no-op for everything except the raw
VHS output — verified the atom-order detection and remux against real
ffmpeg-produced faststart/non-faststart files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-07-08 06:24:16 +03:00
co-authored by Claude Sonnet 5
parent 449532358f
commit 4f121ff2f5
+96
View File
@@ -209,6 +209,56 @@ 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."""
import os
try:
with open(path, "rb") as f:
size = os.fstat(f.fileno()).st_size
offset = 0
while offset + 8 <= size:
f.seek(offset)
header = f.read(8)
if len(header) < 8:
break
box_size = int.from_bytes(header[:4], "big")
box_type = header[4:8]
if box_type == b"moov":
return True
if box_type == b"mdat":
return False
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
if box_size < 8:
break
offset += box_size
except OSError:
return False
return False
def _remux_faststart(src_path: str, out_path: str) -> None:
"""Stream-copy remux (no re-encode) that just relocates moov to the front."""
import shutil
import subprocess
ff = shutil.which("ffmpeg")
if not ff:
raise RuntimeError("ffmpeg not found on PATH.")
cmd = [ff, "-y", "-i", src_path, "-c", "copy", "-movflags", "+faststart", out_path]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError((proc.stderr or "ffmpeg failed")[-1000:])
def _playback_render_from_query(query) -> Optional[dict]:
"""Optional live render override from preview-segment query params."""
filename = query.get("filename") if query else None
@@ -281,6 +331,9 @@ 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_locks: dict = {} # source path -> asyncio.Lock (dedupe concurrent remuxes)
def _preview_segment_cache_key(pid: str, scene_id: str, clip: dict) -> str:
return (
@@ -1749,6 +1802,47 @@ if web is not None and PromptServer is not None:
async def _temp_list(_req):
return web.json_response({"files": _list_temp_media()})
async def _faststart_path(src_path: str) -> str:
"""Serve a stream-copy-remuxed (moov-at-front) copy in place of a raw ComfyUI render.
VHS_VideoCombine writes moov at the end; deep-seeking that directly in the browser is
exactly what preview-segment already routes chain clips around, but a "shallow" single
clip (or just opening one) hits /result directly and gets the same undecodable-on-seek
file. Cached + lock-deduped like the segment cache below; skipped entirely (no-op) for
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)."""
import asyncio
import os
if os.path.splitext(src_path)[1].lower() != ".mp4":
return src_path
cached = _faststart_cache.get(src_path)
if cached and os.path.isfile(cached):
return cached
if _is_faststart(src_path):
return src_path
lock = _faststart_locks.setdefault(src_path, asyncio.Lock())
async with lock:
cached = _faststart_cache.get(src_path)
if cached and os.path.isfile(cached):
return cached
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")
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)
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
@routes.get(UI_PREFIX + "/api/projects/{pid}/result")
async def _result(req):
import os
@@ -1769,6 +1863,8 @@ if web is not None and PromptServer is not None:
if path and os.path.isfile(path):
import mimetypes as _mt
ctype = _mt.guess_type(path)[0] or "application/octet-stream"
if ctype.startswith("video/"):
path = await _faststart_path(path)
return web.FileResponse(path, headers={"Content-Type": ctype})
# Fallback (custom output dirs the resolver doesn't know): loopback /view fetch.
try: