Skip to content

Crop stage feasibility - #42

Merged
nnmarcoo merged 24 commits into
mainfrom
crop-stage-feasibility
Aug 15, 2026
Merged

Crop stage feasibility#42
nnmarcoo merged 24 commits into
mainfrom
crop-stage-feasibility

Conversation

@nnmarcoo

@nnmarcoo nnmarcoo commented Aug 14, 2026

Copy link
Copy Markdown
Owner

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 own crop field 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_spec and 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):

chain no crop crop 50% crop 25%
pointwise x1 3.32 2.57 (1.3x) 1.20 (2.8x)
blur r=8 9.08 4.07 (2.2x) 0.90 (10.1x)
blur r=32 18.58 6.83 (2.7x) 1.65 (11.3x)
blur r=128 20.80 7.10 (2.9x) 2.21 (9.4x)
chromatic aberration 5.95 3.79 (1.6x) 0.73 (8.1x)

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_region carries 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_uv alone 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_tile lifts that arithmetic into a pure function and display_harness drives 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_doc arithmetic. Every new test was checked by breaking the thing it covers.

Known gaps

  • The crop stage costs an allocation and a texture copy that could be elided when its output already lines up with its input's slab.
  • The harness covers placement geometry, not the ROI and quality_scale interaction at zoom, which still cannot be driven from a test.

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.
@nnmarcoo
nnmarcoo merged commit ffb689e into main Aug 15, 2026
1 check passed
@nnmarcoo
nnmarcoo deleted the crop-stage-feasibility branch August 15, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant