File size: 10,047 Bytes
3e8de70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | """Standalone verification for merge_video_track_with_ffmpeg failure_reason.
Runs without ComfyUI/torch: stubs the heavy imports, then exercises every
``return None`` path of merge_video_track_with_ffmpeg and the
compose_h3_project_video error propagation, asserting each produces a
specific, human-readable reason.
Usage: python scripts/test_merge_failure_reason.py
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
def _install_stubs() -> None:
# Minimal torch stub: only attributes evaluated at IMPORT time are needed
# (annotations like `torch.Tensor`, `torch.device`, `torch.dtype` in
# image.py, and `torch.nn.functional` imported by image/audio/multitrack).
# No torch function actually runs on the paths under test.
if "torch" not in sys.modules:
torch = types.ModuleType("torch")
torch.__path__ = [] # mark as package so torch.nn.functional imports resolve
nn = types.ModuleType("torch.nn")
functional = types.ModuleType("torch.nn.functional")
functional.pad = lambda *a, **k: None
nn.functional = functional
torch.nn = nn
torch.Tensor = object
torch.device = object
torch.dtype = object
torch.float32 = object()
torch.float64 = object()
sys.modules["torch"] = torch
sys.modules["torch.nn"] = nn
sys.modules["torch.nn.functional"] = functional
if "folder_paths" not in sys.modules:
fp = types.ModuleType("folder_paths")
temp_dir = PACKAGE_ROOT / ".tmp-temp"
temp_dir.mkdir(exist_ok=True)
fp.get_output_directory = lambda: str(PACKAGE_ROOT / ".tmp-output")
fp.get_temp_directory = lambda: str(PACKAGE_ROOT / ".tmp-temp")
fp.get_input_directory = lambda: ""
fp.get_filename_list = lambda *a, **k: []
fp.get_full_path = lambda *a, **k: None
sys.modules["folder_paths"] = fp
if "server" not in sys.modules:
sys.modules["server"] = types.ModuleType("server")
_install_stubs()
sys.path.insert(0, str(PACKAGE_ROOT))
# Import the REAL utils package: with the stubs above, the eager `from .image
# import *` in utils/__init__.py resolves, so multitrack's `from . import
# FFMPEG_RESIZE_METHODS, ...` also works and h3_project loads untouched.
from utils.video import merge_video_track_with_ffmpeg # noqa: E402
import utils.video as video_mod # noqa: E402
import utils.h3_project as h3_project # noqa: E402
CHECKS: list[tuple[str, bool, str]] = []
def check(name: str, condition: bool, detail: str = "") -> None:
CHECKS.append((name, bool(condition), detail))
# ---------------------------------------------------------------------------
# 1. FFmpeg not found: point PATH lookup at an empty search space.
# ---------------------------------------------------------------------------
import shutil # noqa: E402
_real_which = shutil.which
shutil.which = lambda name, **k: None
try:
reasons: list[str] = []
out = merge_video_track_with_ffmpeg(
[{"source": "whatever.mp4", "start_frame": 0, "end_frame": 10}],
10,
24.0,
64,
64,
failure_reason=reasons,
)
check("ffmpeg-missing returns None", out is None)
check(
"ffmpeg-missing reason mentions FFmpeg executable",
len(reasons) == 1 and "FFmpeg executable not found" in reasons[0],
str(reasons),
)
finally:
shutil.which = _real_which
# ---------------------------------------------------------------------------
# 2. Without failure_reason the function still returns None silently and no
# caller-visible behavior changes (backwards compatibility).
# ---------------------------------------------------------------------------
shutil.which = lambda name, **k: None
try:
out = merge_video_track_with_ffmpeg(
[{"source": "whatever.mp4", "start_frame": 0, "end_frame": 10}],
10,
24.0,
64,
64,
)
check("no-collector path still returns None", out is None)
finally:
shutil.which = _real_which
# ---------------------------------------------------------------------------
# 3. ffmpeg "found": redirect get_ffmpeg_path via monkeypatched shutil.which to
# a real executable so we reach the precondition checks.
# ---------------------------------------------------------------------------
import os # noqa: E402
fake_ffmpeg = Path(PACKAGE_ROOT / ".tmp-fake-ffmpeg")
fake_ffmpeg.write_text("")
os.chmod(fake_ffmpeg, 0o755)
shutil.which = lambda name, **k: str(fake_ffmpeg) if name == "ffmpeg" else None
try:
# Empty timeline
reasons = []
out = merge_video_track_with_ffmpeg([], 0, 24.0, 64, 64, failure_reason=reasons)
check("empty timeline returns None", out is None)
check(
"empty timeline reason",
len(reasons) == 1 and "timeline is empty" in reasons[0],
str(reasons),
)
# Invalid frame rate
reasons = []
out = merge_video_track_with_ffmpeg(
[{"source": "x.mp4", "start_frame": 0, "end_frame": 1}],
1,
0.0,
64,
64,
failure_reason=reasons,
)
check("invalid fps returns None", out is None)
check("invalid fps reason", len(reasons) == 1 and "frame rate" in reasons[0], str(reasons))
# Odd dimensions
reasons = []
out = merge_video_track_with_ffmpeg(
[{"source": "x.mp4", "start_frame": 0, "end_frame": 1}],
1,
24.0,
63,
64,
failure_reason=reasons,
)
check("odd width returns None", out is None)
check(
"odd width reason names size",
len(reasons) == 1 and "63x64" in reasons[0] and "odd" in reasons[0],
str(reasons),
)
# Missing segment sources
reasons = []
out = merge_video_track_with_ffmpeg(
[
{"source": "/definitely/not/here/a.mp4", "start_frame": 0, "end_frame": 4},
{"source": "/definitely/not/here/b.mp4", "start_frame": 4, "end_frame": 8},
{"source": "/definitely/not/here/c.mp4", "start_frame": 8, "end_frame": 12},
],
12,
24.0,
64,
64,
failure_reason=reasons,
)
check("missing sources returns None", out is None)
check(
"missing sources reason lists each file",
len(reasons) == 1
and "a.mp4" in reasons[0]
and "b.mp4" in reasons[0]
and "c.mp4" in reasons[0]
and "missing" in reasons[0],
str(reasons),
)
# Missing locked audio
reasons = []
out = merge_video_track_with_ffmpeg(
[
{
"source": str(fake_ffmpeg),
"audio_source": "/definitely/not/here/locked.wav",
"start_frame": 0,
"end_frame": 4,
}
],
4,
24.0,
64,
64,
failure_reason=reasons,
)
check("missing locked audio returns None", out is None)
check(
"locked audio reason names file",
len(reasons) == 1 and "locked.wav" in reasons[0],
str(reasons),
)
# FFmpeg command failure: run the real (invalid) fake binary -> OSError or
# non-zero return. The fake file is not executable code; on POSIX exec
# raises OSError (ENOEXEC / format error), on the sandbox it surfaces as
# either the OSError path or a non-zero returncode path. Both must record.
reasons = []
out = merge_video_track_with_ffmpeg(
[{"source": str(fake_ffmpeg), "start_frame": 0, "end_frame": 1}],
1,
24.0,
64,
64,
failure_reason=reasons,
)
check("command failure returns None", out is None)
check(
"command failure records a reason",
len(reasons) == 1
and ("failed to start" in reasons[0] or "exited with code" in reasons[0]),
str(reasons),
)
finally:
shutil.which = _real_which
fake_ffmpeg.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# 4. compose_h3_project_video propagates the reason into the RuntimeError.
# ---------------------------------------------------------------------------
def _fake_merge(segments, total_length, frame_rate, w, h, **kwargs):
reasons = kwargs.get("failure_reason")
if reasons is not None:
reasons.append("FFmpeg exited with code 1: Unknown encoder 'libx264'")
return None
# compose_h3_project_video imports merge_video_track_with_ffmpeg from the
# `utils.video` module at call time (function-local import), so patch the
# attribute on the already-loaded video module, not on h3_project.
h3_project.load_h3_project_data = lambda *a, **k: {
"width": 848,
"height": 480,
"frame_rate": 24.0,
"clips": [
{
"index": 0,
"enabled": True,
"file_path": "easy_media/projects/p/out/seg_000.mp4",
"updated_at": 1,
"source_frame_count": 16,
"source_start_frame": 0,
"source_end_frame": 16,
"video_files": [],
}
],
}
video_mod.merge_video_track_with_ffmpeg = _fake_merge
try:
h3_project.compose_h3_project_video("proj", None)
check("compose raises on None", False, "no exception raised")
except RuntimeError as error:
message = str(error)
check(
"compose error carries the real reason",
"could not compose the H3 project video" in message
and "Unknown encoder 'libx264'" in message,
message,
)
except Exception as error: # noqa: BLE001
check("compose raises RuntimeError only", False, repr(error))
# ---------------------------------------------------------------------------
passed = sum(1 for _, ok, _ in CHECKS if ok)
failed = len(CHECKS) - passed
for name, ok, detail in CHECKS:
print(f"{'PASS' if ok else 'FAIL'}: {name}" + (f" [{detail}]" if detail and not ok else ""))
print(f"\n{passed}/{len(CHECKS)} checks passed")
sys.exit(1 if failed else 0)
|