diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b256c..03b3f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,10 @@ All notable changes to this project are documented here. The format follows ### Fixed +- An ffmpeg that dies while `materialize_mask_video` is still piping frames now + raises `mask ffmpeg failed (rc=N) after K of N frames: ` instead of + a bare `[Errno 32] Broken pipe`, which threw away ffmpeg's reason. A 352 934-frame + 4K mask rebuild failed this way with nothing to go on. - Two objects visible at once no longer collapse onto one track, leaving one of them unblurred. Association had no per-frame exclusivity — nothing marked a track as already matched — and its center-distance fallback gate was sized by diff --git a/redactcam/mask.py b/redactcam/mask.py index ddcf73a..b034a48 100644 --- a/redactcam/mask.py +++ b/redactcam/mask.py @@ -110,18 +110,28 @@ def materialize_mask_video(timeline: BlurTimeline, output_path: Path, feather_px proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE ) + sent = 0 try: - for idx in range(n): - frame = render_mask_frame(w, h, active_boxes_at(timeline, idx), feather_px) - proc.stdin.write(frame.tobytes()) - proc.stdin.close() + try: + for idx in range(n): + frame = render_mask_frame(w, h, active_boxes_at(timeline, idx), feather_px) + proc.stdin.write(frame.tobytes()) + sent += 1 + proc.stdin.close() + except BrokenPipeError: + # ffmpeg exited mid-stream; its exit code and stderr say why, the + # broken pipe on our side does not. + try: + proc.stdin.close() + except BrokenPipeError: + pass rc = proc.wait() finally: if proc.poll() is None: proc.kill() if rc != 0: err = proc.stderr.read().decode(errors="replace")[-500:] if proc.stderr else "" - raise RuntimeError(f"mask ffmpeg failed (rc={rc}): {err}") + raise RuntimeError(f"mask ffmpeg failed (rc={rc}) after {sent} of {n} frames: {err}") written = _count_frames(output_path) if written != n: raise RuntimeError( diff --git a/tests/test_mask.py b/tests/test_mask.py index 8f2606c..1643d9b 100644 --- a/tests/test_mask.py +++ b/tests/test_mask.py @@ -115,6 +115,21 @@ def kill(self): bm.materialize_mask_video(_timeline(), tmp_path / "m.mkv") +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg required") +def test_ffmpeg_dying_mid_write_reports_its_own_error(tmp_path): + """An ffmpeg that exits while frames are still being piped surfaces as a broken + pipe on the parent's write. The error must carry ffmpeg's exit code and stderr, + not a bare "[Errno 32] Broken pipe" that says nothing about why.""" + out = tmp_path / "mask.mkv" + out.mkdir() # ffmpeg cannot open a directory as its output, so it exits at once + # 512x512 gray frames (256 KB) overflow the 64 KB pipe buffer on the first write. + tl = _timeline(frame_width=512, frame_height=512, source_frame_count=20) + with pytest.raises(RuntimeError, match=r"mask ffmpeg failed \(rc=[1-9]") as exc: + bm.materialize_mask_video(tl, out) + assert "mask.mkv" in str(exc.value) # ffmpeg's own message names the output + assert "of 20 frames" in str(exc.value) # says how far the stream got + + @pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg required") def test_materialize_writes_exact_frame_count(tmp_path): out = bm.materialize_mask_video(_timeline(), tmp_path / "mask.mkv", feather_px=2)