Reconcile main 486afd82cc while preserving reviewed lifecycle, capture, IME and settings migrations. Include canonical dependency pins and the generated lockfile.
All nine CI stages passed: 11,580 passing test executions, zero failures and 91 ignored across 93 summaries. Native/browser acceptance and paired app performance remain open.
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 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>
* 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
* 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
* 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>
* 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>
* Paste: keep the pasted image on the canvas (#6759)
Paste centred the clipboard image on the view centre with no clamp, so an
image that fit the canvas could land partly off it, and one larger than
the canvas could leave blank edges. Now, per axis, an image that fits stays
wholly inside the canvas, as close to the view centre as it can, and one
larger than the canvas starts at the canvas's top-left edge, as in
Photoshop. Paste Into / Outside still centre on the selection
("clampToCanvas": false). Centres are computed edge by edge in f64, so
extreme coordinates no longer overflow.
* Paste: doc comments for Paste Into and the paste placement tests
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Auto-Align: a locked layer becomes the reference instead of being moved (#7080)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: Auto-Align moves the Background as a normal layer
With the Background selected and a Cylindrical or Spherical result (every
layer is reprojected), or another layer as the reference, the Background's
own position lock made Auto-Align refuse. Like Free Transform and Photoshop,
the Background's built-in position lock no longer pins it: it becomes a
normal layer ("Layer 0") when it has to move. Lock All, Lock Pixels and a
user's Lock Position on any other layer still keep it as the reference.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Address CodeRabbit review: the Auto-Align params doc names the locked layer as the default reference
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>
`style.presets.new` with `includeBlending: false` and an explicit `opacity` stores only that
opacity, but `apply` with `add: true` skipped the blending block unless the preset had a blend
mode, so the layer kept its opacity. Test: an_opacity_only_style_added_sets_just_the_opacity
(also checks the layer's blend mode and Fill stay).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The .atn importer kept only bicubic, bilinear and nearest from a
transform's Intr, so a recorded Bicubic Automatic, Sharper or Smoother
Transform Selection replayed with the command's default (bilinear) and
re-exported without its choice. The bicubic variants are now kept (the
transforms already render them as Bicubic), Automatic becomes Bicubic
Automatic and Preserve Details Bicubic, and both transform commands list
the variants (#8697).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(clipboard): render copied shapes in destination canvas space
When a copied shape group is placed beyond its source canvas, rebuild its vector caches against the destination canvas and pixel format. Previously a centered paste into a larger document could clip the caches to an empty source-sized frame, leaving no transform bounds and incorrect effects until a point edit refreshed each shape.
Restore missing shape caches and refresh source-clipped caches when canvas dimensions change. Cover Paste and Copy to Document, nested group contents, 8/16-bit destinations, rendered cache equality, transform bounds and movement Undo.
* fix(clipboard): measure shape placement before destination clipping
Restore missing source-space shape caches before measuring copy bounds, then adapt and translate for the destination. Re-render empty caches when shapes move back onto the canvas instead of shifting an empty raster.
Add focused integration coverage for 32 combinations of partial and full destination clipping, grouped shapes, missing caches, 8/16-bit output, centered and point placement, and Undo/Redo. Clippy and WASM checks pass.
---------
Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
contained_panics_count_and_graceful_errors_do_not used the 4 s hang
budget meant for fuzzed commands. With RUST_BACKTRACE=1 the panic hook
prints a backtrace, and on loaded Windows runners the first symbol
lookup took long enough (19.6 s) that run_watched returned None instead
of Some(false). The self-test now waits up to 120 s; the fuzz keeps 4 s.
Photoshop records the composite entry of a Levels or Curves Set as
Chnl/Chnl/Cmps and silently leaves the adjustment unchanged when it is
Chnl/Ordn/Trgt, which is what the export wrote. Import already reads both.
Fixes#8543
* feat(guides): New Guide Layout takes lengths with units
view.newGuideLayout took pixels only. Its widths, gutters and margins now
also take text with a unit as typed in Photoshop's dialog ("1 cm",
"0.25in", "12 pt", "3pc", "10%"), at the document's resolution; percent
is of the target's width for columns, gutters and left/right margins and
of its height for rows and top/bottom margins, per artboard. The dialog's
Width and Height fields already pass such text through, so they accept
units now too.
Part of #4984
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(guides): New Guide Layout percent resolves per selected artboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(guides): assert the full guide lists after a selected-artboard layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): a recorded Layers-panel drag keeps its drop target by name
Dragging a layer while recording stored layer.moveTo's target as a layer
id, which means nothing in the document the action later plays in, so
playback stopped with "no such layer". The target is now recorded by
name (with targetOccurrence among same-named layers), as layer.select
records its layer, and layer.moveTo accepts a target by name.
Fixes#7845
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): count a drag's same-named targets without the moved layer
Steps are recorded after they run, so a target's occurrence among
same-named layers could shift when the moved layer shares its name. It
is now counted among the other layers only, at recording and at replay,
which the move doesn't reorder. Only a drag of the selected layer is
recorded by name; drags of several layers, or of an unselected one, keep
their ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): a moved group's contents don't count as drop targets
Same-named layers inside a moved group also changed order with the
move, so they are left out of the occurrence count with the group.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): leave every moved layer out of a named drop target
A move of several layers given a target by name no longer counts the
moved layers (or their contents) as candidates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): a recorded ⌥-drag copy beside itself replays by name
A copy leaves its source in place, so the source stays a candidate for
the named target, at recording and at replay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): a recorded copy's own contents don't count as drop targets
The step is recorded after the copy exists; the copy (and, for a group,
its same-named contents) is left out of the occurrence count, as at
replay it doesn't exist yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse partial Color Balance and Hue/Saturation ATN exports
A `layer.setAdjustment` step changes only the settings it names, but the
Color Balance and Hue/Saturation descriptors written to the ATN carry
every setting and filled absent ones with defaults, so a partial edit
replayed as one that also reset the rest. Strict export now refuses such
a step (with skipUnsupported it is listed in Skipped); complete ones
export and round-trip as before.
Fixes#8531
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse Hue/Saturation ranges missing their own settings
A colour range written without its own hue, saturation and lightness
would replay with the missing ones reset to 0, so strict export refuses
it like a partial master setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse wrong-shaped Color Balance and Hue/Saturation settings
Strict export now checks the triplets, numbers and flags it needs are of
the shape the descriptor writer reads, since it would write anything else
as 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse malformed Hue/Saturation range bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(engine): cover malformed and valid Hue/Saturation range bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse fractional Hue/Saturation range bounds
The descriptor stores them as integers, so a fractional bound would be
rounded on export.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(type): type.edit with new text keeps the styles of unchanged text
`type.edit {"text"}` replaced the whole string, so every character took
the first run's style and other styled ranges were lost. It now replaces
only the span that differs (after the common prefix and suffix), so the
unchanged text keeps its character and paragraph styles and an added
character takes the style beside it, as the params doc promised.
Fixes#8489
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(type): text inserted at a paragraph's start joins that paragraph
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The generated droplet shim only needs to run as its creator, so 0700 keeps
other local accounts from reading the CLI path it invokes. The reversed
range in the arrow-key test is the case under test, not an accident.
🤖 Generated with Codebuff
Co-authored-by: Codebuff <noreply@codebuff.com>
refine_mask_with builds its result densely over the selection's bounds
(grown by the spread, plus Refine Edge Brush additions). After a thin
edit across a huge canvas, or with a whole-canvas selection there, that
is billions of pixels. matting::refine_area names that area and
select.refineEdge refuses one over MAX_REFINE_AREA (2^30 px) with an
error before allocating, leaving the selection unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FszacAJDjznVimaxQktoJT
The Brush edit, the Lasso edit and EdgeBrush::new allocated a dense buffer
over their bounding box, which the swept-area cap does not bound: a 1 px
diagonal across a 100,000 px canvas sweeps ~141k px but asked for ~40 GB.
- matting::brush_surface (replaces brush_region) and EdgeBrush paint only
the tiles a stroke touches (painted_tiles), capped at MAX_PAINTED_TILES.
- selection::polygon_surface rasterises only the tiles the Lasso's edges
cross, byte-identical to selection::polygon; interior tiles share one.
- selection::combine_sparse adds/subtracts visiting only the new shape's
tiles, keeping the rest of the selection shared.
- select.refineEdge refuses edits and refineStrokes that would cross too
many tiles before painting anything, leaving the selection unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FszacAJDjznVimaxQktoJT
The tilt-shift reference tests and tiled_clear_and_fill_match_one_pass_over_the_area cross pixel
formats or channel counts with independent geometry and fill settings. Each run takes a quarter of
the combinations through Sweep, a different quarter each UTC day.
precheck_agrees_with_execute built all five document states for every call and kept one; it now
builds only the state the call needs.
Review of #8783:
- The Invert edit no longer builds a canvas-sized buffer: the canvas
takes the inverse of the selection's untouched value (one shared tile
for every fully covered tile, as fill_rect stores it), then only the
tiles the selection holds are inverted. Byte-for-byte equal to the
dense inversion; a 100k x 100k canvas inverts in well under a second.
- A run of Inverts is applied as one or none (pairs cancel exactly), so
4096 of them cost at most one pass between other edits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #8783:
- matting::brush `segments` no longer collects its pieces: they are made
as they are painted, and each segment is first cut to the painted area
grown by the brush. A 1 px stroke zigzagging across 2 million px
(within MAX_SWEPT_AREA) split into ~134 million pieces collected up
front (~2 GB) in brush_region and EdgeBrush::new; now ~5000 pieces.
- select.refineEdge `edits`: the Brush edits' swept area in all is
bounded by MAX_SWEPT_AREA, as refineStrokes' is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
select.refineEdge takes `edits`: the Select and Mask workspace's Quick
Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection
and Invert, applied in order to the starting selection (or layer mask)
before the edge is refined. Cancel discards them, OK is one history step,
and agents drive them over MCP and the control channel.
- engine/select_mask_edits.rs: parse (bounded, every bad field an Err)
and a pure apply/apply_one the workspace preview can share
- algo matting::brush_region: soft round brush coverage (hardness)
- ATN export reports steps with edits instead of dropping them
- "Edits" label in the 21 catalogs
Part of #7069 and #8148 (the workspace UI follows in a second PR).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5598 made File > Generate > Image Assets require Preferences > Plug-ins >
Generator, so #8254's two artboard tests got Disabled. Enable it first,
as #5598's own test does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>