diff --git a/scripts/guardrails-baseline.json b/scripts/guardrails-baseline.json index 0523f23..56a3fac 100644 --- a/scripts/guardrails-baseline.json +++ b/scripts/guardrails-baseline.json @@ -24,7 +24,7 @@ "long_functions": 1 }, "src/matteloop/jobs/render.py": { - "lines": 2871, + "lines": 2872, "long_functions": 11 }, "src/matteloop/jobs/segmentation_host.py": { diff --git a/src/matteloop/jobs/render.py b/src/matteloop/jobs/render.py index 6688aa7..d2083d2 100644 --- a/src/matteloop/jobs/render.py +++ b/src/matteloop/jobs/render.py @@ -1580,6 +1580,7 @@ def _produce_cut_frame( ) tracker.register(segmented) if request.segmentation.edge_mode is EdgeMode.DECONTAMINATE_COLORS: + segmented = tracker.register(segmented.copy()) _decontaminate_edge_colors_in_place(segmented) result = Image.frombytes( "RGBA", diff --git a/tests/jobs/render_support.py b/tests/jobs/render_support.py index 7582582..435887c 100644 --- a/tests/jobs/render_support.py +++ b/tests/jobs/render_support.py @@ -110,6 +110,12 @@ def decode( self._explode() +def frozen_segmentation_result(result: np.ndarray) -> np.ndarray: + """Match SegmentationHost: hand segmentation output back read-only.""" + result.setflags(write=False) + return result + + class FakeSegmenter: def __init__(self) -> None: self.calls: list[SegmentRequest] = [] @@ -119,7 +125,7 @@ def segment(self, frame: np.ndarray, request: SegmentRequest) -> np.ndarray: result = np.zeros(frame.shape[:2] + (4,), dtype=np.uint8) result[24:104, 32:96, :3] = frame[24:104, 32:96, :3] result[24:104, 32:96, 3] = 255 - return result + return frozen_segmentation_result(result) class FakeEncoder: diff --git a/tests/jobs/test_preview.py b/tests/jobs/test_preview.py index a97585e..7dfc7f3 100644 --- a/tests/jobs/test_preview.py +++ b/tests/jobs/test_preview.py @@ -20,6 +20,7 @@ FakeSegmenter, FakeSource, binding, + frozen_segmentation_result, job, render_service, request, @@ -238,7 +239,7 @@ def segment(self, frame, request): result[0, 0] = (10, 20, 30, 0) result[0, 1] = (25, 50, 100, 128) result[0, 2] = (7, 8, 9, 255) - return result + return frozen_segmentation_result(result) segmenter = EdgeSegmenter() service = PreviewService( diff --git a/tests/jobs/test_render.py b/tests/jobs/test_render.py index bb4fc0f..d09d440 100644 --- a/tests/jobs/test_render.py +++ b/tests/jobs/test_render.py @@ -21,9 +21,11 @@ from matteloop.core.specs import ( CollisionPolicy, CropSpec, + EdgeMode, FramingSpec, OutputSpec, SamplingSpec, + SegmentationSpec, TransformSpec, ) from matteloop.core.state import JobKind @@ -51,6 +53,7 @@ FakeEncoder, FakeSegmenter, FakeSource, + frozen_segmentation_result, job, render_service, request, @@ -1204,6 +1207,44 @@ def test_render_encodes_and_validates_a_real_lossless_webp(tmp_path) -> None: assert artifact.ownership_current == 0 +def test_decontaminate_edge_mode_renders_without_mutating_frozen_segmentation( + tmp_path, +) -> None: + import numpy as np + + frame = np.zeros((128, 128, 4), dtype=np.uint8) + frame[0, 0] = (200, 150, 50, 255) # opaque + frame[1, 1] = (100, 75, 30, 128) # translucent, black-premultiplied + frame[2, 2] = (10, 20, 30, 0) # fully transparent, non-zero RGB + frozen = frozen_segmentation_result(frame) + original = frozen.copy() + + class FixedSegmenter(FakeSegmenter): + def segment(self, frame, request): + self.calls.append(request) + return frozen + + standard_artifact = render_service(segmenter=FixedSegmenter()).render( + request(tmp_path), job(tmp_path, "standard-run", JobKind.RENDER) + ) + standard_bytes = standard_artifact.output_path.read_bytes() + + decontaminate_request = request( + tmp_path, + segmentation=SegmentationSpec( + "birefnet-portrait", EdgeMode.DECONTAMINATE_COLORS + ), + ) + decontaminate_artifact = render_service(segmenter=FixedSegmenter()).render( + decontaminate_request, job(tmp_path, "decontaminate-run", JobKind.RENDER) + ) + decontaminate_bytes = decontaminate_artifact.output_path.read_bytes() + + assert standard_bytes != decontaminate_bytes + assert frozen.flags.writeable is False + assert np.array_equal(frozen, original) + + def test_render_keeps_decode_working_set_bounded_across_60_frames(tmp_path) -> None: """No decoded frame may outlive the frame that produced it. @@ -1436,7 +1477,9 @@ class FirstEmptySegmenter(FakeSegmenter): def segment(self, frame, request): if not self.calls: self.calls.append(request) - return __import__("numpy").zeros(frame.shape[:2] + (4,), dtype="uint8") + return frozen_segmentation_result( + __import__("numpy").zeros(frame.shape[:2] + (4,), dtype="uint8") + ) return super().segment(frame, request) render_request = replace( @@ -1464,7 +1507,7 @@ def segment(self, frame, request): result = np.zeros(frame.shape[:2] + (4,), dtype=np.uint8) if visible_size is not None: result[:visible_size, :visible_size, 3] = 255 - return result + return frozen_segmentation_result(result) render_request = replace( request(tmp_path), diff --git a/tests/ui/test_preview_controller.py b/tests/ui/test_preview_controller.py index 6360927..49f69ef 100644 --- a/tests/ui/test_preview_controller.py +++ b/tests/ui/test_preview_controller.py @@ -31,6 +31,7 @@ PreviewRuntime, ) from matteloop.ui.store import ReducerStore +from tests.jobs.render_support import frozen_segmentation_result @dataclass(frozen=True) @@ -80,7 +81,7 @@ def prepare( def segment(self, frame, request): del request - return frame + return frozen_segmentation_result(frame.copy()) def preview( self, request, playhead: Fraction, context: JobContext diff --git a/tests/ui/test_render_controller.py b/tests/ui/test_render_controller.py index 6d344ac..15a9208 100644 --- a/tests/ui/test_render_controller.py +++ b/tests/ui/test_render_controller.py @@ -42,6 +42,7 @@ from matteloop.ui.render_pipeline import _StageReporter, render_prepared from matteloop.ui.store import ReducerStore from tests.fixtures.media_factory import make_video +from tests.jobs.render_support import frozen_segmentation_result @dataclass(frozen=True) @@ -56,7 +57,7 @@ class Metadata: class FakeSegmenter: def segment(self, frame, request): del request - return frame + return frozen_segmentation_result(frame.copy()) def test_stage_change_preserves_current_frame_and_overall_counts(tmp_path) -> None: