Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ffmpeg stderr>` 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
Expand Down
20 changes: 15 additions & 5 deletions redactcam/mask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions tests/test_mask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading