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:
co-authored by
Claude Sonnet 5
parent
449532358f
commit
4f121ff2f5
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user