When neither Dehaze, a local tone slider nor a camera black point is in play, no
statistic of the whole frame is needed, so the demosaic output no longer has to be
kept. Every band is demosaiced into a small band buffer and encoded straight into the
u16 output. The f32 RGB frame (12 bytes a pixel, 545 MB at 45 MP) and the pass that
read it back are gone. A Bayer sensor also skips the padded CFA plane: each band
normalizes the rows it reads (with the mirrored borders) into a small window. X-Trans
keeps its plane and green plane, LinearRaw has none. Tone, Dehaze and black points
keep the full-frame path.
The bands are the ones the full-frame path computes, with the same arithmetic per
pixel, so the output is bit-identical (tests compare both paths for Bayer, X-Trans and
LinearRaw, odd sizes, band borders, orientations and 1, 3 and 16 threads).
working_memory now charges what each path really holds.
Fixes#9434
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
When neither Dehaze nor a local tone map is active (neutral tone and no
black point; only the Nikon D4 profile sets one), the matrix is applied
into a block of 768 samples and the gamma encode runs over the block:
clamp and square root first (vectorises), then the table with
[value, step] pairs. Same arithmetic, same output; other renderings keep
the old per-sample loop. A test compares the block encode with the
per-sample encode.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Same arithmetic in the same order, so the output is bit for bit the same
(the old code stays under cfg(test) as ahd_ref and a test compares them).
Scratch planes are allocated once per group of bands instead of per band
(no per-band zeroing or page faults) and never cleared, since every cell
a stage reads was written earlier in the same band. The layout is planar
RGB/Lab, the candidate pixel is built from values instead of indexed
stores (which stalled store forwarding), the 3x3 homogeneity sum is
separable, and the LabF table holds [value, step] pairs. The 32-row band
is kept, as a larger band costs peak memory.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A DNG whose BlackLevel is a DOUBLE beyond f32 reads as inf. The periodic
fill then computed (raw - inf) * 0 = NaN, and AHD's f32::clamp with NaN
bounds asserts. The fill now returns (0, 0) for a non-finite black, as
Scale::value does, and AHD uses a compare-and-select clamp that gives the
same result for every non-NaN bound.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Autosave: a recovery listing judges each key once, so an autosave in progress isn't listed twice
* Autosave: the recovery listing builds for the browser too
* security(affinity): charge archive names, failed reads, corner arrays, attachments, pages and font names
Bound the remaining per-occurrence and table-driven expansion work left after
#8938: aggregate archive entry-name bytes (copied only for the winning name),
extraction allowance charged before decoding so failed checksums are not free,
CnrD corner arrays charged as curve records, AdCh and gradient arrays charged
as slots (gradient positions copied only up to the stop limit), a document-wide
page-rectangle cap, and per-field and aggregate limits on retained font names.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* security(affinity): charge pixel state-array scans against the slot budget
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(affinity): charge decoded name length and bound PagR rectangle collection
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Workers that missed the shared-tile memo at the same time each built
and kept a copy, so the number of copies grew with the thread count and
translated_shares_tiles_rebuilt_from_shared_sources failed on machines
with 16 or more threads. Each worker now takes the tile stored first.
Response::hovered is false while the primary button is down, so a
press-drag through a menu never moved the highlight and it desynced
from the cursor. Hit-test with contains_pointer, which keeps answering
while a button is held.
Closes#8887
Co-authored-by: bfenty <1398236+bfenty@users.noreply.github.com>
egui queues focus navigation before the type tool consumes Tab. Removing the
key event leaves that navigation queued, allowing menu focus to block later
Tabs and ordinary text input. Cancel the queued move when inserting a tab.
Add a full-app input regression that fails on the second Tab before the fix
and verifies three Tab presses, caret positions, and subsequent typing.
Arithmetic-coded frames (SOF9-SOF11) and SOF0/SOF1 frames whose precision
isn't 8 bits go to craft-jpeg; everything else stays on zune-jpeg. 12-bit
files open as 16-bit images. PhotoCraft's own marker scan still supplies
ICC, EXIF, XMP, resolution and Photoshop resources, and damaged or truncated
entropy data adds the Truncated warning. A truncated scan too short for the
frame it declares is refused, as on the zune-jpeg path.
craft-jpeg is pinned to the aiongg fork until it lands on storytold.
Co-authored-by: Michael Higgins <mhiggins217@gmail.com>
* Segmentation: anti-aliasing the full-resolution mask skips runs far from its edge
Select Subject, Object Selection and Select Sky finish with antialias_u8
over the whole full-resolution mask, which read nine neighbours through a
bounds-checked closure for every pixel: 171-185 ms of an 800 ms first
Select Subject on 24 MP (ubuntu-latest, 4 cores). A run of 64 pixels whose
3x3 neighbourhood holds one value has no edge pixel, so it is only compared
now; other runs take the per-pixel rule as before. The output is byte for
byte the same, checked against the per-pixel rule over widths around the run
length, single rows and columns, discs, noise and stripes. Part of #211.
* Segmentation: the mask's anti-aliasing and its test share one per-pixel rule
The 3x3 rule moves into edge_value, which antialias_u8 calls for spans that
are not uniform and the test calls for every pixel, so the test checks only
that skipping never misses an edge pixel instead of keeping a second copy of
the rule. A small case pins the rule's values.
PSD export now writes a link to a file it can't open as pixels, so the
test's trusted-export check embedded nothing. Linking a PNG keeps that
check meaningful: the trusted export carries the file's exact bytes and
the workspace save doesn't.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
File › Package copied every linked file into Links and only then
relinked the packaged document, so a link to a file that can't be
placed left its copy behind and the package failed. Such a link is now
listed as missing and not copied, like the PSD export rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Duplicates share a contents identity, so a linked and an embedded copy
of the same bytes shared one cached conversion: whichever was written
first decided for both. The cache key now includes whether the bytes
came from a link.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Step 5 of the issue: launch_args' hand-written loop, USAGE and
LaunchArgs become a clap derive, which flattens photocraft-automation's
ControlArgs and AutomationArgs. `photocraft --help` comes from the doc
comments; `--version` prints the same `photocraft <long version>` line
(build_info::long_version_static), and `-V` now does too. --control
uses photocraft_automation::args::control_port instead of a local copy.
The cases the loop handled by hand are kept:
- An old macOS Finder's -psn_... argument is dropped before clap,
unless it comes after `--`, where it is a file name.
- The per-process options for the single-instance hand-off come from
ArgMatches::value_source, in a fixed order. A PerProcess enum names
each (and PHOTOCRAFT_GPU_ADAPTER): id() gives a flag's clap id,
Display the flag or the variable, so single_instance's log line reads
as before. As since #7242, a flag's variable counts as the flag: one
clap reads (PHOTOCRAFT_IN_WINDOW_MENUS when it turns the menus on, an
automation root that isn't blank) is counted here, as clap read it;
env_launch_options keeps #7242's rules for the three read elsewhere
(present mode, frame latency, stroke trace) and returns PerProcess.
- A file name that isn't valid Unicode is still reported in a notice
instead of being opened (#1108); file_names returns
FileNames { openable, unreadable }.
- Windows release builds still have no console: help, version and
usage errors show only when redirected, as since #6435. clap's
Error::exit, like print_stdout, never panics on a closed stream.
The variables are now declared on their flags: PHOTOCRAFT_CONTROL_PORT
on --control, the token variables through ControlArgs (so
security::token_inputs, which nothing else used, is removed), and
PHOTOCRAFT_AUTOMATION_*_ROOT added on the desktop's Command only, so
photocraft-cli still ignores them (#1551).
Behaviour changes, as step 0 of the issue decided:
- A bad or missing --present-mode, --frame-latency or --stroke-trace
value is a usage error (exit 2) instead of a warning. Their
variables stay lenient (logged and ignored); presentation's value
checks serve both, and the flags refuse a value with a ValueError.
- A repeated flag is a usage error, and a value can't start with `-`
unless it is a negative number (write --opt=-value).
- clap reports the first usage error, not all of them. On an unknown
argument clap resolves the pending option value but drops its error,
so `--control nope --safegpu` names `--safegpu`.
- An empty or blank variable counts as unset; before, only an empty
PHOTOCRAFT_CONTROL_PORT did, and an empty token or root was passed
on as given. PHOTOCRAFT_IN_WINDOW_MENUS also reads false, no and
off as off (FalseyValueParser). A variable that isn't valid Unicode
where text is expected is a usage error instead of being ignored.
- With --control (or its variable), a token together with a token
file, from flags or variables, is clap's either/or usage error at
parse time, before the server's own check. Without it no token is
read, so a stale exported pair stops nothing, as before.
- With --control given, PHOTOCRAFT_CONTROL_PORT is not read, so a
bad value there is no longer reported too.
The CPU-compatibility relaunch puts --safe-gpu before the original
arguments, so a `--` among them can't turn it into a file name.
The launch_args unit tests now check clap's errors instead of the error
list. The process tests put a late usage error (--control 0 with the
token pair) where they used a bad port, so stderr still shows how the
other arguments were taken, and cover the variables.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
With the environment fallbacks, clap's conflicts_with on the token pair
made a stale exported PHOTOCRAFT_CONTROL_TOKEN and
PHOTOCRAFT_CONTROL_TOKEN_FILE a usage error for every mcp and serve, though
headless mcp and stdio serve read no token. As on main, the pair is now
refused only by serve on TCP and mcp bridging, still as clap's either/or
usage error naming each half as it was given.
An automation root on the command line with PHOTOCRAFT_CONTROL_PORT set
is a usage error too: the variable would switch mcp to bridging, which
drops the roots, with only a note on stderr.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The desktop app's --control already reads PHOTOCRAFT_CONTROL_PORT, and
the CLI read the token variables at run time. Now every fallback is
declared with `env =` on its flag (clap's `env` workspace feature, no
new crate in Cargo.lock):
- --control-token and --control-token-file in ControlArgs fall back to
PHOTOCRAFT_CONTROL_TOKEN and PHOTOCRAFT_CONTROL_TOKEN_FILE, as
security::token_inputs did at run time. The CLI no longer calls
token_inputs, so the variables are read in one place; the desktop
app still uses it until step 5. Help names the token's variable but
never shows its value (hide_env_values). A token from the
environment together with the other flag is now clap's either/or
usage error (exit 2) rather than the automation crate's (exit 1).
- `serve` takes PHOTOCRAFT_CONTROL_PORT as --port and `mcp` as the
bridge port when the flag is absent, as #2495 proposed. The variable
must be a port: `mcp` refuses 127.0.0.1:7878 or :7878 from it, as the
desktop app's --control would; only --bridge takes host:port.
- The automation roots stay flag-only for the CLI: a
PHOTOCRAFT_AUTOMATION_*_ROOT exported for the desktop app must not
widen headless file access (#1551).
- A flag wins, and an empty or blank variable counts as unset
(blank_env_is_unset; clap would hand the empty value to the parser),
so stdio and headless stay the default.
- A bad value or conflict from a variable is a usage error naming the
variable instead of the flag it stands in for (name_env_vars).
- The port variable changes what the command does, so when it (not the
flag) turns on TCP or bridging, the command says so on stderr and
names the way back (ArgMatches::value_source tells which).
bin() in the tests removes the token and port variables, so an
exported one can't change a test. New tests:
control_port_env_switches_modes_with_a_notice_and_flags_win (ported
from #2495, also checking the bad values) and
control_token_env_vars_are_the_flags_fallbacks.
Co-authored-by: ahmedhosnypro <ahhosnyas@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Step 4 of the issue: the control token flags and the automation roots
move to photocraft-automation's new args module as two clap structs,
which mcp and serve flatten and the desktop app can flatten in step 5
without moving them again. clap is a plain dependency of the crate.
- ControlArgs holds --control-token and --control-token-file;
AutomationArgs holds the roots, and AutomationArgs::workspace opens
them (replacing the CLI's automation_workspace).
- The port value parser is photocraft_automation::args::control_port,
with its unit tests, for serve --port and --bridge now and the
desktop app's --control later. It ignores surrounding spaces and
refuses anything else with a typed NotAPort.
- --bridge parses to Bridge::Port or Bridge::Addr: it takes a bare
port, meaning 127.0.0.1:<port>, or any value containing a ':', which
the bridge client then validates. Anything else is a usage error
(exit 2) instead of a bridge error.
- 127.0.0.1 addresses are built from Ipv4Addr::LOCALHOST.
mcp_bridge_takes_a_port_or_an_address is new: `--bridge nope` is a
usage error, and `--bridge 1` bridges to 127.0.0.1:1, which a tool
call names when nothing listens there.
No new environment behaviour: the token variables are still read at
run time through security::token_inputs, and the roots stay flag-only.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Step 3 of the issue: handlers get values that are already checked, so
a bad one is a clap usage error naming its flag (exit 2) and the
"bad --x" errors (exit 1) are gone.
- --quality is a u8 in 1..=100; --tga-bits, --dpx-bits and
--dds-compression parse to photocraft_io's TgaBits, DpxBits and
DdsCompression through their from_bits/from_name, and --tiff-compression
and --jpeg-format to photocraft_codecs' TiffCompression and JpegFormat,
refusing a bad one with a typed ValueError; serve --port is a u16. export_opts becomes `impl From<&ExportArgs> for ExportOptions`,
which can't fail.
- run's --new and --params parse to serde_json::Value, so JSON that
doesn't parse is a usage error; whether it is an object is still the
command's to say (exit 1). The --cmd/--params pairing keeps using
ArgMatches::indices_of, now over typed Step values.
- Paths are PathBufs (convert, info, run, batch, the control token
file and automation roots), and run hands the OsStrings to clap
instead of refusing non-Unicode up front: a Latin-1 file name works
as a path (#1108), and one given as text is clap's usage error.
load_actions_file takes a &Path, and photocraft_engine's same_file
takes any AsRef<Path>, so convert's #7437 check compares the PathBufs.
- Rules live in the types: run needs exactly one of <FILE> or --new
(ArgGroup "source") and a --cmd or --out ("work"); batch's --actions,
--in and --out are required; --control-token conflicts with
--control-token-file. The runtime checks and their messages are
deleted. The automation crate's own token check stays, as it also
sees PHOTOCRAFT_CONTROL_TOKEN(_FILE).
Tests changed for this: run_errors, quality_outside_1_to_100_is_refused,
dpx_converts_both_ways_with_the_bits_asked_for,
dds_converts_both_ways_with_the_compression_asked_for,
tga_bits_choose_the_targa_depth, jpeg_format_chooses_the_scans
(exit 2, the error names the flag) and
the #1108 test, now
a_non_unicode_path_is_taken_and_non_unicode_text_is_a_usage_error:
convert reads and writes such a file name end to end, while info opens
through the automation layer, whose paths must still be UTF-8, and
refuses it (exit 1). either_or_flags_are_usage_errors is new.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The behaviour changes decided in step 0 of the issue:
- A repeated single-value flag is a usage error (exit 2) instead of
the last one winning: args_override_self is gone, and with it the
argument scan that found repeats for mcp and serve (#8035). --cmd and
--params stay repeatable.
- A value starting with '-' was already written --out=-image.png or
./-image.png since the clap port (a flag can't be a value since
#8035); this pins it, and that `--filter -1` still works and
`--quality -1` is still refused by its range.
- The bare `version` subcommand is gone; clap's version flag prints
the same `photocraft-cli <long version>` line for --version and now
-V too. `help <subcommand>` stays.
clap's version wants a &'static str, so build_info gains
long_version_static, long_version worked out once in a OnceLock, next
to long_version. The desktop app will need the same in step 5.
flag_value_forms_and_repeats and top_level_help_version_and_no_arguments
now pin the new behaviour; a_value_starting_with_a_dash_is_written_with_equals
is new.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A PSD/PSB/layered-TIFF save read every linked smart object's file and
embedded it, even when no importer can decode it (a text or settings
file the link happened to point at). Such a layer can't show its
contents in any reader. A linked file is now embedded only when it
decodes; otherwise the layer is written as pixels with a warning.
Embedded sources are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* perf(seam): drop the original-column map from the shrink proxy, same pixels (-15 % at 24 MP)
Content-Aware Scale's proxy seam search tracked each panel column's original
column in an idx map, one usize per proxy pixel, shifted by every seam. The
original column is x plus the number of columns already taken at or left of it,
so a per-row list of the taken columns answers the same question without a
buffer the size of the energy map.
The recorded P28 budget number predates the proxies: the 2026-10-05 baseline
commit carves at full height and recomputes energy per seam, and measures
266 s for the 24 MP case on the machine this was timed on, against 2.6 s now.
Documented in docs/seam-carving-performance.md; enforce stays off until a
budgeted-class run can vouch for the whole row.
* review: generate exactly ch samples per pixel in bench_seam, reject ch > 4
The fixture flattened four samples per pixel and then truncated to w*h*ch, so a
three-channel run grouped samples across pixel boundaries and any ch above four
built a buffer shorter than the rows carve_width indexes.
Generate ch samples inside the per-pixel closure and refuse ch outside 1..=4.
The three-channel row is dropped from the perf note rather than re-measured
here: it moved inside the timer noise either way, and the other agents' builds
on this machine make a fresh number worthless. The four-channel hashes are
unchanged, which is the check that this edit touched only the fixture.
* perf(seam): find the removed column by binary search, not a linear scan
Recording a seam recovered each removed original column by scanning the row's
list of already-taken columns, so a deep shrink paid a comparison per removed
column on every row and every seam: quadratic in the seam count, and worse than
the index map this PR replaced once a shrink got deep enough.
`o - i` never decreases along the row's sorted list, so that count is a binary
search, and the original column belongs where the search stopped; the rows are
independent and update in parallel, like the pixel shift. What is left per
removed column is one insert.
Output is unchanged: same columns, same order, same hashes (10 % and 50 % width
cases below), and `counted_removed_columns_match_the_tracked_map` now runs a
tracking and a counting carver side by side to one column and compares every
row's list after every seam.
docs/seam-carving-performance.md gains those cases, states that the ~104x
figure is the two example runs rather than the baseline row, and names
perf/baseline.json as the source of the 954 s.
---------
Co-authored-by: ebosido444-ai <ebosido444-ai@users.noreply.github.com>
* Speed up Crystallize and Pointillize with cell-space rasterization
* Measure pixelate commands on unlocked layers and Background separately
* Record pixel-exact 24 MP filter and command benchmarks
The overlay is rendered from the live preview, but the CPU canvas drew
it over the document's bounds, stretching a rotated preview's overlay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent's ui.menu.invoke reached the main window's menus while a full-window workspace was
open: window.document.N switched documents and the workspace closed, dropping its work.
Photoshop makes these dialogs modal.
menus::modal_allows now refuses other menu commands while one is open (distort_ui::
modal_workspace), so ui.menu.invoke and the macOS menu bar wait for OK or Cancel. The
control channel's Camera Raw continuation is generalised: the open workspace's own
{"ui": {...}} requests still pass, unless another dialog, a discard prompt or a crop is
pending. The menus' cache key includes the open workspace, so opening or cancelling one
without input rebuilds them. engine.execute (MCP run_command) is not a menu click and is
not gated.
Puppet Warp, Perspective Warp and Vanishing Point are on-canvas modes here, with the
in-window menus live, so they stay out.
* security(engine): bound script, batch, adjustment, import and export work from file input
Scripts calling scripts share depth and step budgets; batch inside a played action charges the playback tree; destructive adjustments and mask adjustments visit stored tiles when the content bounds are a huge sparse grid; Copy Layers checks group depth; aux flattening and brush import refuse oversized canvases; video footage frames are budgeted, Render Video names frames after the leaf of the document name and reads no linked files for restricted sessions; ATN names are clipped; profile paths are read as regular files only; prefs.set no longer treats colorSettingsX as colorSettings.
* style(engine): rustfmt
* engine: address review - refuse unbounded sparse adjustments, carry batch playback charges, charge played steps to scripts, clip ATN targets, stricter colorSettings paths, cap image-sequence frames
* security(io): charge sparse layers, shape caches and shared decodes
ORA and PSD export refuse dense boxes past a cap; PSD import and ABR mapping decode shared masks/tips once; Affinity and SVG shape caches are charged to the import budget; SVG embedded JPEG size is probed; Krita tiles are charged for the backing tiles they land in.
* fix(io): clippy range pattern; align the sparse Krita test tiles to the backing grid
* io: address review - offset-aware KRA tile budget, saturating stroke reach, OpenRaster entry limit, PSD retained-surface budget, halved PSD gather cap
The CMYK proptests failed on main now and then (the_split_route_agrees_with_the_exact_path
after #8663). Three gaps, each found by the proptests at 5000 cases:
- cmyk.rs: a clipped Dissolve layer blended like Normal; it now shows each pixel fully or not
at all by the compositor's noise, as the RGB compositor does since #3052.
- cmyk.rs: a Dissolve adjustment layer mixed by its opacity; opacity and mask are now the chance
a pixel takes the adjustment, as on the RGB compositor (#3050).
- native.rs: the display/Flatten ink route didn't round to the document's levels after an
adjustment layer, where the RGB compositor and cmyk.rs do; coverage in a pass-through group
then differed by up to half a level.
The test reference model had the same two Dissolve gaps (ref_clip passed no position, so
Dissolve fell back to Normal), which is why random_documents_match_the_reference never caught
the first one. Three named regression tests cover the minimal cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Fix magnetic lasso gradient polarity cost
* Add magnetic lasso gradient polarity regressions
* style: cargo fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* skip redundant smart object resamples and per-pixel reads
* resample smart objects only when their re-render fails
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A Light layer is stored under the Brightness/Contrast `brit` key with a
`CgEd` descriptor carrying more settings; it imported as
Brightness/Contrast with only Contrast. A `brit` whose `CgEd` has keys
outside the Brightness/Contrast set now stays Adjustment::Unsupported, is
written back byte for byte, and the import warns naming the layer.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Branch Sync <branch-sync@localhost>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Crop: Delete Cropped Pixels keeps type and smart objects whole (#7116)
crop_doc cropped every surface for_each_surface visits, including the
rendered caches of type layers and smart objects. Those move by shifting
their stored pixels, so the cut stayed and showed as soon as they were
moved. Like Photoshop, Delete Cropped Pixels now deletes pixel-layer data
(and fill caches) only; shapes are re-rendered as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: Delete Cropped Pixels crops stored video frames again (#4956)
The pixel-layer-only crop path skipped a video layer's stored frames and
original footage, so scrubbing brought the cropped pixels back. Crop them
with the displayed content, as before, and test a shrinking crop.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Address CodeRabbit review: the turned crop keeps type and smart objects whole before rotating
The pass that drops what a turned crop deletes before the rotation still cut
every cached surface. The rotation re-renders type and smart objects, but a
smart object whose source is unavailable keeps its cache, so it stayed clipped.
That pass now uses the same pixel-layer-only rule as the crop itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>