Crop stage feasibility - #42
Merged
Merged
Conversation
Crop is applied at display time, so every stage after it runs at full size and the window throws the rest away. As a stage it would shrink the buffer and everything downstream would run smaller. There is no crop stage to time yet, so the bench measures the ceiling directly: the same chain on a genuinely smaller source is what the cropped chain would cost. set_viewport is given the whole image in both columns so the ROI machinery is not quietly doing the crop's job and flattering the numbers. Measured 4096x2731, 100% zoom, best of 5: chain full keep 50% keep 25% pointwise x1 2.80 2.15 (1.3x) 0.86 (3.2x) blur r=8 9.68 4.03 (2.4x) 1.02 (9.5x) blur r=32 18.48 6.09 (3.0x) 1.57 (11.7x) blur r=128 21.01 7.15 (2.9x) 1.88 (11.2x) chromatic aberration 6.13 3.88 (1.6x) 0.77 (8.0x) The saving tracks area for the expensive chains, which is the answer to whether a crop stage is worth its complexity. It does not tell us whether tile culling survives the move; that is a separate question.
Crop culls tiles at display time: view_pipeline intersects the crop rect with each tile and skips the misses. Moving crop into the chain has to get that culling from the backward ROI walk instead, or large documents start reading tiles they do not need -- the regime the benches cannot reach. unmap_region carries a scale but no offset, which is all a resize needs. A crop is the other half: same scale, pure offset, where output (0,0) comes from input (origin). unmap_offset crosses it. crop_stage_feasibility is pure geometry against a 4096x2731 tiling -- no crop stage exists yet, so these gate the feature rather than test it. The walk reproduces today's culling exactly, with a pointwise stage after the crop, and with a whole-frame stage (which widens to the *cropped* frame, so cropping early stays an optimisation). A blur after the crop may keep tiles the plain crop dropped, never fewer, and still culls most of them. the_offset_unmap_is_what_makes_this_work pins why the offset is needed: a scale-only unmap lands at the origin and culls the wrong tiles. Removing unmap_offset fails four of the five.
Crop declared has_effect() false, so the planner never saw it and both backends ignored it; the rect was applied once at composite time as a fraction of the final document. That is why a second crop cannot exist: a fraction of the output cannot say "the region of what the first crop produced". Crop now declares output_spec, and rect_in resolves the stored rect against the stage's real input so a crop that outlives an upstream size change still names a region that exists. Its default becomes the whole frame rather than 1x1, which is what a crop with no image yet means. Pointwise was doing two jobs in the planner. Crop reads one sample per output pixel, so it fused -- and a fused run is evaluated at one coordinate in one space, so the crop was planned, sized, then silently rendered as a passthrough. changes_geometry separates "cheap per pixel" from "same geometry", and only the second earns fusion. It is deliberately per-modifier rather than derived from output_spec on some sample input: a crop currently spanning the whole image still changes geometry, and a plan built at that moment would otherwise fuse it and stop being able to shrink. effective_display_size and crop_origin stop re-applying the crop the chain has already applied. The crop tool still needs the uncropped picture to drag the rect back out over, so it asks for the chain with crops widened. The four scale-free crop tests are rewritten rather than deleted: a crop after a 50% resize now takes 400x300 *of the resized image* instead of halving the document a second time. The backends still do not execute the stage; that is the next commit.
render_full and render_band now implement the crop step. A crop moves data without touching it, so both copy whole rows rather than working per pixel. The band paths needed the same distinction the planner did. rows_needed maps a band through a stage by the input/output height *ratio*, which is right for a resize and wrong for a crop: a crop translates its rows. Both the backward walk in source_rows_for_band and the forward y_off update in render_band now shift by the crop's origin instead of rescaling, since rescaling would land the band on rows the stage never produced. Because the row walk has already shifted the band, the banded stage applies only the horizontal window and the new width, dropping any rows above the crop the band happened to include. bands_match_full_crop_then_blur_then_crop is the workflow the stage exists for -- reframe, apply something that reads its own frame, trim the result -- and it is the case the old display-time crop could not express at all. Band parity alone would pass if both paths agreed on the wrong region, so a_crop_copies_exactly_the_pixels_it_names checks the output against the source rows directly. Breaking the x offset fails both.
The backward ROI walk crossed every stage with unmap_region, which carries a scale and no offset. That is right for a resize and wrong for a crop, which translates its output: crossed as a scale, the read lands on the wrong pixels and the whole region shifts. Both branches of the walk now go through one helper that picks unmap_offset when the stage has an origin, so the scanline and non-scanline paths cannot drift apart. The crop stage itself is a texture copy. out_r is already in the crop's output space and prev.rect in its input, and the walk put them exactly an origin apart, so copying the overlap is the entire operation -- a crop moves pixels without touching them and needs no pass. The golden harness assumed a chain keeps the source's size: assemble maps each tile back to where it started, which a crop invalidates. assemble_doc places tiles by proc_px in the chain's output document instead, and run_golden_dims picks it only when the planned size differs, so size-preserving chains keep the stricter native-scale assertion. It also now checks the CPU oracle produced the planned size, so a backend that silently ignored the stage would fail rather than compare two wrong images. Breaking only the GPU offset fails all four crop goldens at max diff 255.
ExportData carried a crop uv that geom_of applied to the processed buffer after the whole chain had run. With crop as a stage that buffer is already cropped, so keeping the field would have cropped the picture twice. The field is gone. geom_of's cx0/cy0/cw/ch stay because the rotation paths in raster.rs index through them, but they now describe the whole buffer. The test helpers took a uv rect, which was the only crop the old export could express; they now append a real Crop stage, since that is the only way to say it. streamed_png_matches_buffered_with_crop therefore streams a crop through the banded path and still matches the buffered render. Two assertions encoded the old architecture and are rewritten rather than deleted. crop_applies_to_the_resized_buffer required "crop must not change the processed buffer size" -- exactly the property that made a second crop impossible; it now checks the buffer is the cropped 32x24 and that export has no offset left to apply. The crop_tests pair asserted export carried a particular uv; they now assert the exported document's *size*, which is stronger, since a uv can be right while the render ignores it.
"Only one Crop modifier is allowed" was not a product decision. Crop was a single display-time window on the final document, so a second one had nowhere to live and the guard was honest about it. As a chain stage each crop reframes whatever the one before it produced, so the restriction has no remaining basis and is removed. Both crop-creating paths sized a new crop from the source. A crop appended after a resize or another crop receives something smaller, so it now spans stage_input_size(len()) -- the chain's current output. stage_input_size answers one past the end for exactly this, since the tools ask before pushing. The crop tool prefers the selected crop and otherwise takes the last, which is how the text and drawing tools already choose among several. The overlay is bounded by the crop's own stage rather than the source, so its handles clamp against the image the crop actually receives.
The bench previously stood in for a crop by shrinking the source, because there was no crop stage to time. It now prepends a real Crop and keeps the small-source run as a "ceiling" column, so the table says how much of the predicted saving the implementation actually collects. chain no crop crop 50% crop 25% ceiling 25% pointwise x1 3.00 4.83 (0.6x) 2.99 (1.0x) 0.90 blur r=8 9.18 5.36 (1.7x) 3.17 (2.9x) 0.99 blur r=32 17.62 6.94 (2.5x) 3.94 (4.5x) 1.63 blur r=128 21.13 6.66 (3.2x) 4.46 (4.7x) 2.57 chromatic aberration 5.30 3.41 (1.6x) 2.79 (1.9x) 1.53 Expensive chains get the win the earlier bench predicted: a heavy blur is 3.2x faster behind a 50% crop. Two results are recorded rather than smoothed over. Pointwise gets *slower* at 50%, because the stage costs an allocation and a copy while a fused pointwise run is too cheap to amortise it. And nothing reaches the ceiling -- 25% is 16x fewer pixels and the heaviest blur collects 4.7x of a possible 8.2x. Both point at the same fixed cost, noted in the doc comment for whoever picks this up: the crop's copy, plus a gather sized before the chain narrows.
Adjusting any crop parameter stretched the image along that axis: on an 800x1040 source, moving x to ~268 lost half the picture and smeared the rest horizontally. The chain's output is already cropped, but ViewProgram::draw still passed displayed_crop() as crop_uv, and display.wgsl remaps uv into that window: uv = crop.xy + in.uv * (crop.zw - crop.xy). Windowing an already-windowed image leaves a strip and stretches it across the whole quad, which is exactly the reported symptom -- and it grew with the parameter, because a larger crop meant a smaller strip stretched further. image_uv_to_screen had the same double application in reverse, so the overlay and the pixel grid disagreed with what was drawn. crop_uv is now always the unit rect. That leaves crop() and displayed_crop() with no callers at all: the whole display-time crop mechanism is what the stage replaced, so they are deleted rather than left as a trap for the next person. Two tests asserting on displayed_crop now assert on effective_display_size, which is the thing that still exists. a_cropped_document_keeps_its_shape_on_screen walks the reported case -- 800x1040, x at 0, 268 and 400 -- and checks the drawn aspect matches the document's. Restoring the old mapping fails it with the measured numbers: aspect 0.512 drawn as 0.769.
Two defects, both from the crop tool showing a different document than the rest of the view assumed. **The drag jumped.** screen_to_image_uv divides by the source, so uv is a source fraction; image_uv_to_screen had been left treating uv as the displayed document. With the tool open those are different sizes, so the two stopped being inverses -- the viewport centre of an 800x1040 image mapped to uv (1.25, 1.44), well outside the picture, and back to a screen point nearly double the input. The overlay drags in uv and draws in uv, so every drag read one space and drew in another. Both now carry uv through the document actually on screen, which is the uncropped chain while the tool is open. **The preview stretched.** effective_display_size reports the uncropped size with the tool open, but the primitive still handed the pipeline the real stack, so a 400x500 texture was drawn onto an 800x1040 quad. Same defect as the display-time crop window, by a different route. The primitive now renders the widened stack, through the same widen_crops the size math uses so the two cannot disagree. the_rendered_stack_matches_the_document_the_view_lays_out pins the quad and the texture together; restoring the old stack fails it with the measured 400x500 against 800x1040. The round-trip test covers pan and zoom too, since the jumping was reported while panning.
The overlay divides its rect by the bounds it is given to make uv, and multiplies the cursor's uv by those same bounds to get pixels back. Both only work if the program's uv is a fraction of that same image. Two things broke that. screen_to_image_uv went through screen_to_image_coords, which rescales to *source* pixels for the eyedropper, and then divided by the source again. That is the right answer to a different question: the overlays measure against the document on screen, not the source, and the two differ as soon as the chain resizes. It now inverts the display transform directly, making it the exact inverse of image_uv_to_screen rather than approximately so. image_uv_to_screen loses the compensating conversion it had grown. The overlay's bounds went back to the crop's stage input, which is what the rect is measured in. An earlier commit had reverted them to the source to chase the jumping, which treated the symptom. With a 50% resize above the crop, an overlay pixel at (40, 60) came back at (80, 120) -- off by exactly the resize ratio, which is why the rectangle fought every drag. crop_overlay_bounds moves onto ViewProgram so the overlay and the test read one definition. Breaking either half fails the tests with those numbers.
The overlay found its crop with .find() -- the first enabled one -- and ignored active_modifier entirely. With several crops in the stack it drew and edited one the user had not selected. edit.rs already preferred the selected crop, so the two disagreed about which crop the tool meant: the tool pointed at one, the rectangle edited another. That is the same failure as the coordinate bugs before it, one rule written twice. tool_target is now the one definition, shared by the tool's message handling and the overlay that draws it, and it matches what the draw and text overlays already do: the selected modifier when it is of the right kind, otherwise the last of that kind. edit.rs now also requires the crop to be enabled, which it did not before. A disabled crop draws no overlay, so pointing the tool at one left the user with no rectangle at all. Restoring the old .find() fails three of the six new tests with Some(0) where Some(2) is expected -- the top crop instead of the selected one.
Two corrections to how crop geometry crosses into the output document. Both are demonstrable arithmetic errors, but neither reproduces the reported "cuts off too much at some zooms" symptom, so this may not be that bug. The backward ROI walk picked between unmap_offset and unmap_region by asking whether the origin was nonzero. A crop anchored at (0, 0) -- trimming only width or height, the common case -- was therefore crossed as a *scale*. That over-estimates, so it is safe rather than lossy, but it is still the wrong rule: which one applies is a property of the stage, not of the origin it happens to have. tile_out_rect maps a source rect into the document by ratio, which is right for a resize and wrong for a crop. For a 96x64 source cropped to 51x37 at (13, 9), a tile spanning [48,0,96,48] mapped to [26,0,51,28] where it should be [35,0,51,37]: wrong position and nine rows short. to_doc subtracts the chain's accumulated offset first, then maps the edges through grid_edge exactly as tile_out_rect does -- mapping the span directly rounds neighbouring tiles apart and reintroduces the seams grid_edge exists to prevent, which is what tiles_cover_a_resized_odd_sized_document caught. chain_resizes now includes the offset, since a crop can move the document without changing its size.
A cleanliness pass over the branch turned up one real bug. The eyedropper reports source pixels by scaling the cursor's document position by image_size/displayed. That single ratio mixes two different effects: a resize scales the picture, a crop only moves its origin. With a crop in the stack it read the wrong pixel -- the centre of a 400x500 crop at (100, 200) reported (400, 520) instead of (300, 450). doc_to_source now returns the scale and the origin separately, accumulating each from the stages that actually cause it. The crop tool made it worse in the other direction: the view already shows the uncropped chain there, so crop_origin was added on top of an offset that had already been undone. Cleanups with no behaviour change: - cpu.rs had crop_full and an inline copy in the banded arm doing the same row-copy loop; both now call copy_rect. - execute_kernel_chain called infer_specs twice per prepare, allocating the same Vec of stage specs each time. - uncropped_chain_size cloned the whole stack -- Drawing stroke lists, Text bodies and all -- to read two numbers, on every cursor move and every frame. It walks the specs instead. widen_crops still clones for the renderer, which needs real modifiers, and the invariant test keeps the two agreeing. - crop_origin materialised every stage's input via stage_inputs to read one entry; it stops at the first crop. - Dropped a comment claiming the origin-zero fix explained the reported vanishing at some zooms. It does not, and saying so would mislead whoever picks that up.
grid_uniforms offset its bounds and its transform by crop_origin. That is where the kept region sits inside the picture the crop *tool* shows, which is a different question from where the grid draws: the grid overlays the document on screen, and that document starts at its own origin because the chain has already removed everything before it. With the crop tool open on an 800x1040 source cropped at (100, 200), the grid claimed bounds [100, 200, 900, 1240] -- every line shifted by the crop's offset, and a hundred pixels of it past the right edge of an image that is only 800 wide. With a resize as well it was [100, 200, 500, 720] against a 400x520 document. That leaves crop_origin with no callers. Both it and displayed_crop before it described the display-time crop that the stage replaced, and every remaining use of them turned out to be a bug, so it is deleted rather than kept for a caller that should not exist. Its three tests asserted on the accessor; the one behaviour they also covered -- the tool showing the uncropped picture -- survives as a single test on effective_display_size.
Cropping a 30000x30000 image scattered the preview: tiles drawn in the wrong places, moving wrongly when panned. The executor sizes each tile's output with to_doc, which subtracts the chain's offset before applying the ratio. The display path placed the quad with tile_out_rect, which applies the ratio alone. That is right for a resize and wrong for a crop, and the error grows with distance from the origin, so every tile drifts by a different amount -- on a 30000px source with a crop at (5000, 5000), a tile at 8192 lands 1454 document pixels from where its texture was rendered. to_doc moves to geom.rs so both paths call one function, and DocScale carries the offset the way it already carried the sizes. refresh_display_transforms has no plan to recompute from, so the offset is recorded on the pipeline beside doc_size, which exists for exactly that reason. assert_tiles_cover_document could not catch this: it checks proc_px, which the executor already had right. The bug was in the transform built from it, so the new tests pin to_doc's arithmetic directly -- including that it disagrees with the ratio mapping, so they cannot both pass.
crop_uv was doing two jobs. The shader samples with it, and the tiler also read it to decide which part of the source the quads span. Setting it to the unit rect fixed the double-crop in the shader but told the tiler every document covers the whole source, so a cropped one had its quads laid out across 30000px while the view scaled for a 10000px document -- a 3x stretch, visible only on a multi-tile image because a single tile has nothing to be misaligned against. The two roles are now separate. crop_uv stays the unit rect, and doc_region says which source rect the document stands for. It comes from doc_to_source, the same scale-and-offset decomposition the eyedropper uses, so the tiler and the coordinate conversions cannot disagree about where the document sits. A resize after a crop scales the document but not the region: the same source pixels are still on screen. The crop tool reports the whole source, matching the widened chain it renders.
TileOutput.doc recorded the document's dimensions. Moving a crop's origin leaves those identical, so an output rendered for the previous position passed the reuse check and kept its proc_px -- new content placed at the old offset. exec_sig does notice the parameter change, but only after the tile textures have been sized and positioned from that stale comparison. DocId carries the origin alongside the size, so a crop that moves without resizing invalidates like any other change. Also adds a test that drags a crop across five positions at a partial ROI and checks each against the CPU oracle. It passes both before and after, because converge() re-runs to a fixed point and the staleness is transient -- kept anyway, since it pins the reuse path that a live drag exercises and nothing else covered it.
Panning defers the chain: view_pipeline calls refresh_display_transforms, which rebuilds each tile's quad from the output it already has. That path normalized proc_px against the source's dimensions, but proc_px lives in the chain's output document. With a 30000px image cropped to 10000, every quad came out a third of its size while the drag was in flight and snapped back when the pan settled and prepare() rebuilt them properly -- which is exactly "only on large images, only while dragging". The executor already passed doc.size here; this path passed full_w/full_h. Two callers of one function disagreeing about which space its argument is in, again. The transforms go straight into GPU buffers, so a wrong normalization was invisible to every existing test -- the first version of this test passed with the bug still in. tile_display_uv records what the display path computed so the assertion can see it; reverting the fix now fails with uv 0.172 where 0.29 is correct.
Cropping a large image left a fragment of the old picture in the viewport. A tile that falls entirely outside the crop is meant to be marked culled, but that branch sat behind `tile.last_crop_uv != Some(uniforms.crop_uv)`. crop_uv used to say which part of the source the document covered, so it changed whenever the crop did. It is now always the unit rect -- the chain crops, so the shader must not -- which made the guard false from the second frame onward. The excluded tile kept the ndc rect it had when it was still visible, and render_display went on drawing it. The tile cache is now keyed on the document region, which is the thing that actually changes when a crop moves. last_crop_uv becomes last_doc_region. tile_doc_intersection lifts the tile-vs-document clip out of the loop so it can be tested without a GPU: at 30000px in 8192px tiles, a crop of the middle excludes whole tiles, and those must intersect the document in nothing. moving_the_crop_invalidates_a_tiles_cached_placement pins the guard itself -- stale under a moved crop, not stale when held still, and never stale under the constant crop_uv the old key compared.
The table in this doc comment was taken before the tile-placement bugs were found, and it recorded two results that no longer hold: pointwise getting slower behind a crop, and nothing reaching the ceiling. Both were the geometry, not the crop's copy. Tiles were placed by ratio rather than by the crop's offset, so the chain rendered regions it then discarded. With that fixed a 25% crop collects most of its 16x, several chains landing at the ceiling. Sub-2ms rows now read as noise against each other -- two of them come out marginally faster than their own ceiling -- so the comment says to treat those as "at the floor" rather than as a ranking.
Every crop bug that reached the user lived in the per-tile placement, and none were visible to a test. The goldens compare the modifier pipeline's outputs and stop there; the transforms built from those outputs went straight into GPU buffers, so a wrong one could only be seen on screen. Three separate fixes here each broke something else because the coverage was, in effect, single-tile. place_tile lifts that arithmetic out of update(), which owns GPU buffers and so could not be driven from a test. The loop now calls it, so the tested function is the one that runs. The harness drives a 30000px source in 8192px tiles through real crops, zoom levels and pans, and asserts the properties the bugs actually violated: the quads reconstruct the document rather than some other rectangle, they stay centred when the view is not panned, panning translates without reshaping, neighbours meet without a seam, an excluded tile is dropped, and placement is a function of the geometry alone. Verified by reintroducing three real bug classes: layout frame is the tile, not the document 3 of 7 fail centred on the source, not the document 1 of 7 fail excluded tiles never culled (the fragment) 3 of 7 fail The centring test exists because of the second: a wrong document centre translates every quad equally, so the union-of-quads assertions cancel it out and only an absolute position catches it. Two of the harness's own assertions were wrong on the first run -- the pan convention is pre-zoom, and the drawn extent scales by doc/viewport -- and both were corrected against the real transform rather than by loosening the test.
Applies the project's convention to the branch: file headers only, no inline or doc comments. 609 comment lines removed across 18 files, 58 lines of header added. Findings are condensed rather than deleted. The ones that each cost a user-reported bug are now in the header of the file they belong to: - view_pipeline: crop_uv, doc_region and last_doc_region were one field doing three jobs, and separating them cost a bug each -- the stretched picture, and the tile the crop excluded that stayed stranded on screen. - types: changes_geometry is separate from output_spec because it must hold for every input, not the one a caller has; fusing a crop made it plan, size, then render as a passthrough with the suite green. - roi: stage_origin, and that the scale-or-translate choice comes from the stage's kind rather than from whether its origin is nonzero. - viewer, modifier_stack, view_primitive had no header at all and now carry the tool_target, stage-input and doc_region reasoning. Stripped with a tokenizer that tracks strings, raw strings, char literals and block comments, since // appears inside URLs and string bodies. Verified by extracting every string literal from all 123 files before and after: zero changed. No BOMs, no replacement characters, and the one non-ASCII character left in the tree is an em dash inside a println on main, which this branch does not touch.
The av feature did not compile. video.rs is behind cfg(feature = "av"), so the default-feature checks I had been running never saw it, and five ExportData literals there still set the crop field this branch removed. CI runs clippy and the tests with --features heif,av, so this was a broken build, not a lint nit. Four were crop: None and simply go. The fifth cropped the middle half of a 64x48 sample and rotated it, asserting a 24x32 output; it becomes a Crop modifier of 32x24 at (16, 12), which is the same region expressed the only way the chain now understands. video_export_crops_rotates_and_keeps_audio still passes through the real ffmpeg path. Verified with CI's own commands: cargo fmt --check, cargo clippy --features heif,av -- -D warnings, and cargo test --features heif,av, which runs 397 tests against 394 on default features.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Crop as a chain stage
Crop was a display-time window:
has_effect()returned false, the planner never saw it, and both backends ignored it. The rect was applied once at composite time as a fraction of the final document, and export carried its owncropfield applied after the whole chain.That is why "Only one Crop modifier is allowed" existed. It was not a product decision. A fraction of the output cannot express "the region of what the first crop produced", so a second crop had nowhere to live. Crop, effect, crop was unrepresentable.
Crop now declares
output_specand executes as a stage in both backends. The one-crop restriction is gone.What this enables
Reframe, apply something that reads its own frame (vignette, blur, pixel sort), then trim the result. The second crop names a region of what the first one produced.
Performance
Everything after a crop now runs on the smaller image instead of the full one. Measured at 4096x2731, 100% zoom, best of 5 (
gpu_bench_crop_stage):Export gains unconditionally: it used to render the full chain and crop at the end, so every effect paid for pixels that were then discarded.
Behavior change
The crop rect is now measured in the crop's stage pixels, not scale-free source pixels. A 400x300 crop after a 50% resize takes 400x300 of the resized image; previously it halved the document a second time. Four tests pinned the old behavior and were rewritten deliberately.
The crop tool still shows the uncropped picture so the rect can be dragged back out over material the chain would otherwise discard.
Two traps worth knowing about
Pointwise is not the same as fusable. Crop reads one sample per output pixel, so it fused into a pointwise run and rendered as a passthrough with the whole suite green.
is_pointwise()was doing two jobs: "cheap per pixel" and "same geometry".changes_geometry()separates them, and only the second earns fusion.A resize scales, a crop translates.
unmap_regioncarries a scale and no offset, which is all a resize needs. The backward ROI walk, the CPU band row walk, and the tile placement each had to learn the other half. Crossing a crop with the scale rule puts the read on the wrong pixels.Display layer
Most of the work here was not the backends, which matched the CPU oracle almost immediately. It was the display layer, where eight user-visible bugs appeared, all of the same shape: one value carrying two meanings that drifted apart.
crop_uvalone was the shader's sampling window, the tiler's layout region, and the tile cache's key. Those are now three separate things.The goldens compare the pipeline's tile outputs and stop there, and the transforms built from those outputs went straight into GPU buffers, so a wrong one was invisible until it was on screen.
place_tilelifts that arithmetic into a pure function anddisplay_harnessdrives a 30000px source in 8192px tiles through real crops, zooms and pans. Verified by reintroducing three real bug classes from this branch; each fails between 1 and 3 of the 7 assertions.Testing
397 tests pass with
--features heif,av. Clippy clean under CI's-D warnings, fmt clean.New coverage: CPU band parity for crop, crop and crop-blur-crop goldens against the CPU oracle at single and multi tile, the display harness, and
to_docarithmetic. Every new test was checked by breaking the thing it covers.Known gaps
quality_scaleinteraction at zoom, which still cannot be driven from a test.