A linked smart object's path comes from the document. Every place that
follows it now goes through one resolver (photocraft_io::doc_path, built on
craft-docpath): a relative path is taken from the document's folder, and
only local files or files on the share the document itself is on are
opened. Paths to other computers, device paths and URLs are treated like a
missing file: the layer keeps its last rendering.
- Link watch: such links are not stamped, and no background look starts
when no link of the document is followed.
- Update Modified Content and other re-renders read only followed files;
the error names why a link is not followed.
- Package lists such links as missing instead of copying them.
- Reveal in Finder for a linked layer shows only a followed file; the
document's own Reveal is unchanged. The macOS invocation ends options
with `--`, and Explorer is not asked to select a path containing a comma
or a quotation mark.
- Place Linked stores a relative path as an absolute one.
The document path travels to smart-object renders in a thread-local scope
entered by Session::edit, command dispatch and background jobs.
Co-authored-by: David Mann <daaavid.mann@gmail.com>
* Security: enforce the automation read boundary on stored smart-filter params
Smart filters store a command id and params inside the document, so a
crafted .pcraft could resolve stored ambient paths (displace mapPath,
color-lookup LUT files) at render time without the authorize gate:
synchronous smart-object rendering has no Session argument, spawned job
threads drop the requesting session's authority, and a trusted UI
render could warm the process-wide stack cache with a stage the gate
never saw.
Thread the requesting session's gate through a thread-local in jobs.rs
(entered around command execution, reinstalled on spawned workers and on
job apply, restored on drop), re-check stored filter commands in
render_in before source resolution and any cache lookup and in
apply_filter_to_surface, and re-authorize layer.smartFilter.setParam
with the complete merged params after merging stored document params —
including edits whose request contains no path and actions.play steps.
Denial is conservative: the document is unchanged and no worker starts.
Regression tests in the automation crate cover rerender, cached-stage
reuse, merged-param edits, action playback, background jobs, and filter
transfer, for both the displace map and color-lookup LUT vectors.
* Review: carry render authority through Session::edit and surface denials
Session::edit is the engine's universal mutation entry (linked-file
refresh, transforms, place); renders reached through it ran with no
authorization scope installed, so stored smart-filter params read
ambiently there even after the execute-path fix. Install the scope in
Session::edit as well, keyed on the session's authorize gate.
apply_filter_to_surface also conflated a denied filter with an unknown
one: authorize_render_command's Result was discarded with .ok()?, so a
denial silently rendered unfiltered pixels and the stage entered the
process-wide stack cache. The signature becomes
Result<Option<Surface>>: Err for denials (callers propagate), None for
ids that aren't pixel filters (pass-through semantics unchanged), and an
unreadable map keeps its deliberate pass-through. apply_smart_filter and
apply_smart_filters propagate instead of cur.clone().
Two tests: a refresh driven outside command execution (Session::edit)
is denied and leaves the document unchanged; a denied render caches no
stage. Nine authorization tests pass, engine suite and strict Clippy
clean, panic hunt green.
* ui_set description: name the layerFilter field main added
The Layers-panel filter dropdown (#5048) added layerFilter to the
control method's UI_SET_FIELDS without extending the ui_set tool's
description, so the drift guard (mcp.rs, ui_set_description_names_every_
accepted_field) fails on Windows CI for any branch built on main. Name
the field with its patch shape: by (kind, name or mode), kinds (a list
of layer kinds), name (matches part of the layer name), mode (a blend
mode name). The 40 MCP tests pass; strict Clippy clean.
* wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate
Main's browser build is 171 KB over the 24 MiB wasm gate in CI
(25,337,343 bytes), so every branch built on current main fails the
web size check. Demote three pixel crates in the wasm-release profile:
- cms: transforms are built once and cached (transform::cached),
so "z" costs little runtime (measured -42 KB).
- compose: the browser app composites per frame on the GPU (crates/gpu,
WebGPU/WebGL2), so the CPU compositor is not the browser's hot path
there (measured -122 KB).
- algo: filters run per pixel on every interactive filter application.
"z" there is the lever of last resort (measured -331 KB), taken
because the gate is enforced in CI; if browser filters feel too slow,
restore 3 here and reclaim the bytes from a feature-gated decoder.
Final size 24,811,949 bytes locally, 354 KB under the gate; the native
profiles are untouched. If a maintainer prefers keeping algo at 3, the
alternative is raising the self-imposed 24 MiB early-warning limit
(Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/
package.sh, not a code fix.
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab
Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.
- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
importer), with its own Lab <-> sRGB conversion (same formulas as
photocraft-color; codecs stays standalone) for formats without Lab,
which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
tiff crate as RGB and labelled Lab; a* and b* are converted from the
file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
layer is written to TIFF in Lab (other formats still use sRGB).
Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.
Fixes#4936
* PSD: a smart object's filter mask shows when the file opens
Photoshop stores a smart object's layer pixels with its filters applied
but not its filter mask. When it composites, it mixes the unfiltered
pixels (cached in FEid) back in through the mask. We showed the stored
pixels as they were, so a PSD whose smart filter has a mask opened with
the filter everywhere. Only after something re-rendered the smart object
did the mask appear, because our re-render mixes it.
- compose::filter_mask holds the mix, moved from the engine, and its
inverse.
- Import decodes the unfiltered pixels from FEid
(smart_map::unfiltered_from_item) and keeps the mixed result as the
smart object's pixels, as the engine's re-render does.
- Export writes the layer record the way Photoshop does: unmixed with
the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes
back to what the document showed. Transparent pixels stay empty.
Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and
sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148.
psd-tools and io corpora unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)
DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.
The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.
testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))
Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.
* Codecs: open Cineon (.cin) film frames read-only
Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.
* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)
- flatten: a path's flattened points are capped at 1 << 20; past it every
curve keeps a proportional share of its pieces (at least one), so hostile
handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
dash_polyline keeps the dashes made so far and draws the rest solid
instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
slope (vanishingly short) are skipped, so huge finite coordinates no
longer produce NaN coverage. fill_rasterizer sorts edges once instead of
after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
shared mask render body, map_knots helper, shared straight-segment test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)
- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
every pattern of a block, a .pat file and the pattern blocks of one
document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
coordinates clamp to i32, and a v6 trailing entry without a usable id
attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
use the bounded preallocation; ImageData::decode_channel (decoded every
plane to return one, unused outside its test) is removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Vector: bound stroke outline points (#5211)
- stroke_polygons dashes all lines first, then counts round caps and
joins. When their full-resolution tessellation would exceed 1 << 20
outline points, every round cap and join gets the same smaller segment
count (down to 4 per circle, 1 per join; area-exact radius kept).
Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
instead of per polyline, so many subpaths cannot multiply the dash
(and cap) count. Past it the rest is drawn solid, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PSD: write tiff blocks without copies, inflate merged ZIP once
- ImageSourceData::to_bytes writes the global mask and global blocks
straight from the model with 4-byte alignment instead of cloning each
block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
buffer (taken and handed back, error or not) instead of building their
own output and copying it up per nesting level. Bodies that fail or are
not transcoded are truncated before being copied as stored or dropped.
Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
when asked: a ZIP stream is inflated once (the decode's stream check is
the parse's); the parse-only check runs only when the decode fails, to
keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)
Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.
A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.
Refs #1366
* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers
Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(history): count snapshot-only tiles against the history budget
History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)
Two more causes of ghosts when removing strings and the small knobs
selected with them:
* Auto sampling summarises the selection's surroundings by a few
clusters. Around a long selection those come from the strings'
surroundings, so a small part elsewhere (a tuning knob in a differently
coloured corner) lost its own surroundings and was filled from far away
in another colour. Around a thin selection the Auto area now always
reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
the anti-aliased fringe or halo that a selection drawn close to the
object leaves outside it.
Refs #1366
* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)
Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.
Refs #1366
* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open
Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.
Two changes in crates/algo:
- A second GrabCut pass whose background model only knows the backdrop. A
colour model fitted to the image frame (minus a margin around the
salient blob, in case the subject runs out of the picture) marks every
pixel it explains as probable background; the blob and whatever the
backdrop cannot account for start as probable foreground. Only
components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
mask) always fill, larger holes up to 5 % fill only when their pixels
fit the foreground model better than the background's.
Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.
* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)
Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.
edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.
* .kys import: tool keys and sets saved on Windows (K3)
Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).
A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.
* cargo fmt the extension lists
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Fabio Lissi <fabio.lissi@gmail.com>
Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Finn Newick <finnnk@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
Resolve smart_ui.rs: a Filter Gallery smart filter opens main's gallery
window (#3426) before this PR's editor routing; catalogs keep main's
hu/sr/tr/vi rows and this PR's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Smart Objects: inspect and Replace Contents show shared instances (#4075)
Placed layers that share a source have imported as one set of contents
since #2222, and Replace Contents changes every instance, but nothing
reported it: `linkGroup` is Link Layers, and `smartSource` can't tell
copies of one contents from independent copies of the same file.
document.inspect now gives each smart object `smartContents`: the
contents `id` its copies share and how many `instances` there are.
Replace Contents, Relink to File and Convert to Embedded/Linked list the
`instances` they changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test rendered caches after shared Smart Object replacement
---------
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Export Contents wrote the smart object's file as it was whatever the
path, so out.png held .pcraft or .psb data. With the contents' own
extension (or none) it still copies the file; with another one it opens
the contents and exports them in that format, as File > Export does
("converted": true, with the export's warnings). jpg/jpeg and tif/tiff
count as the same; an unknown extension is an error.
Fixes#4846
Resolve the conflicts with main's smart filter row selection, drag and
reorder (#4371, #4897), channel-targeted filter previews and the new
hu/tr/vi rows:
- smart_ui.rs: filter rows keep this PR's layout (name and Blending
Options icon as separate targets, indents) and take main's click to
select, drag and drag-to-reorder; the name forwards them to the row.
This PR's widget tests move to smart_ui/editor_tests.rs next to main's
inline unit tests.
- filter_dialog.rs, canvas.rs: the smart filter preview scaling and
smart_preview_factor run inside main's preview_document_for_target,
with its channel target and source documents.
- i18n: main's rows plus this PR's; hu rows use informal address (#4334).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.
- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
"Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
lock the Background's position like every other place that creates one.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A command whose params document "layer":id acts on that layer, but its
availability check looked at the active layer. With another layer selected,
`layer.smartObjects.replaceContents {"layer": N, "path": …}` failed with "the
active layer is a Group layer, not a smart object", although layer N is a
smart object; likewise Rasterize, Export Contents, Merge Down and the rest.
Scripts, recorded actions and MCP agents hit this whenever the selection
differs from the layer they name.
Two allow-lists covered part of it: #466 (smart filters, Edit Contents,
Convert to Layers) and #2457 (hide, show, rename with nothing selected). The
#466 check now applies to every command that documents "layer":id (210
today; the old list is a subset): the precondition runs with the named layer
as the active one, then the active layer is restored. A name that isn't a
layer of the active document falls back to the usual check, so bad ids still
fail as before. It moves from smart_cmds to Session, as it isn't specific to
smart objects any more.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Smart Objects: linked smart objects follow their files while open
As Photoshop documents ("Linked Smart Objects update automatically when
the Photoshop document is open, provided the source file has changed"),
a linked smart object whose file another app saves is re-rendered
without Update Modified Content. The desktop app stamps the active
document's linked files (size + modification time) on a worker thread
every two seconds and when its window comes back to the front; each
changed file is one Update Modified Content undo step, independent of
the selected layer. Stamps are kept per document; a file that can't be
read yet leaves no step and is tried again; files changed while the
document was closed still wait for Update Modified Content.
layer.smartObjects.updateChanged runs one look (refused over the
control channel like the other layer.smartObjects commands).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Smart Objects: a linked file that can't be read is tried again once it changes, not on every look
A linked file the automatic update couldn't read (still being written, or a
format smart objects don't read) kept its old stamp, so every two-second look
saw it as changed and read it again on the UI thread. It now keeps its new
stamp and is tried again once it changes again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Katt <238968166+KattKnight@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Showing, hiding or editing a smart filter re-rendered the whole stack from the
placed source on the UI thread: on a 3000x4498 layer with five filters every
click on a filter's eye froze the window for about 1.5 s with no feedback.
- Each render keeps the placed source and the result after every visible filter
(blake3 of the source contents, the placement and the filters so far), so an
edit reruns only the filters from the edited one up. Edit > Purge > All drops
them.
- Smart filter edits apply the edit at once (so bad params still fail
immediately) and re-render as a background job when started with
Session::start: the window keeps drawing, with progress and Esc to cancel
between filters. Session::execute is unchanged.
Convert to Smart Object with several selected layers wrapped them in a
temporary group before embedding, so Edit Contents showed one group named
after the top layer. Photoshop puts the selected layers at the top level of
the embedded document. The embedding now goes through layers_to_smart, which
takes the layers directly; a pass-through root composites the same, so the
smart object looks unchanged. A single selected group still converts as is.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Make large pixel operations allocation-fallible
* Warp: drop the fixed 400 MP cap; fallible reservations already cover large transforms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Adapt #2530 to main: fallible steep-Distort warp, Result from mask paste in float moves; regenerate scorecard
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>
From the menu these Smart Object commands failed with "pass `path`": no file
dialog was wired to them. Replace Contents now asks for a file (by contents,
so it works on the web too) and updates every instance; Export Contents and
Convert to Linked ask where to save, suggesting the contents' own file name.
The engine gains replace_contents_bytes and contents_of for the UI to call.
Relink to File is left to #2416, which adds its picker.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The app still showed the Adobe Photoshop name in several user-visible
strings. Per the product rule (user-facing text says PhotoCraft only),
none of these mention Photoshop now.
UI labels, tooltips and status messages:
- New Document preset "Default Photoshop Size" -> "Default PhotoCraft Size"
- "All shortcuts reset to Photoshop defaults." -> "All shortcuts reset to
PhotoCraft defaults."
- "Import Photoshop brushes (.abr)" -> "Import brushes (.abr)"
- smart-filter fallback name "Photoshop filter" -> "Smart filter"
- File > Save As .psd/.psb filter "Photoshop" -> "PSD Document"
Errors and warnings:
- "this Photoshop filter isn't implemented in PhotoCraft" -> "this smart
filter isn't implemented in PhotoCraft"
- smart_map: "has no PSD equivalent", "not a recognized filter", "its
filter data is missing/unreadable", "this filter has no PSD equivalent"
- tiff_layers: "the PSD layer data...", "the PSD image resources..."
- multichannel: "32-bit Multichannel isn't supported"
- abr: "not a readable brush file (.abr)"
- gradient import: "not a readable gradient file (.grd)"
Translations: updated the changed tl! sources in all 16 catalogs and
dropped the stale "Load Photoshop brushes (.abr)" entries.
Installer / OS metadata: de-branded the nfpm description, the PSB MIME
comment, the AppStream summary/description, the macOS UTType
descriptions, the Windows ProgId Description and the desktop launcher's
Keywords. The Photocraft.PhotoshopDocument ProgId and the
image/vnd.adobe.photoshop MIME/UTType stay: machine identifiers,
changing them hurts file fidelity and Open-with registration.
Command params docs (agent-facing): dropped Photoshop from the
type_cmds kernPair text, the layer_menu_cmds Advanced Blending defaults
and the mask_view_cmds backslash-key text. The "photoshopManages" wire
value stays. Comments, format identifiers and the "photoshop" theme
alias are out of scope and unchanged.
The macOS native-menu shortcut resolution messages no longer say "as in
Photoshop" (Hide/Minimize).
The embedded document built by layer_to_smart now carries the document's patterns, so pattern fills
and pattern effects keep rendering inside the smart object and after Edit Contents. Fixes#1867.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes#1833. Control and MCP may run Edit Contents, Convert to Layers and Save Contents on smart objects whose source is in memory (embedded, or a Photoshop UUID backed by embedded liFD data). Before any disk read the engine re-checks the source's stored path through the session's authorize gate, including nested sources re-rendered while unpacking, so external linked files stay refused; explicit layer targets work with another layer selected, and interactive linked-file access is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: File › Open and batch extension lists (SVG, swatches and
Affinity together); the import dispatcher (upstream's type-layer
preparation with the new result fields); the pinned EXR and Affinity
corpora side by side; README, book and development docs.
Affinity type layers now draw through text_import::prepare, so they get
the same raster bound as other imported type, and SVG import fills the
two new ImportResult fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
* SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215)
crates/io/src/svg.rs: usvg parses (already in the tree through egui_extras); paths become
ShapeLayer knots with the transform chain baked in, quadratics raised to cubics and every
subpath after the first joining the component so the fill rule spans the path as SVG does;
groups keep opacity and blend mode (transform-only and id-only wrapper groups vanish);
text is shaped into outline shapes with the craft fonts plus the system's; gradients become
the layer's gradient fill (stops and angle, with a warning). Clip paths, masks, filters,
pattern paints and images are rasterised by resvg into pixel layers over their bounding box
rather than dropped. Limits: 16384 px canvas, 2000 elements, 24 M px per raster, bounded
recursion; malformed input is IoError::Svg.
smart_cmds::render rasterises an SVG source at the placement's axis scale (1/16 steps,
cached per scale) and divides the placement by it, affine and projective alike; place_bytes
renders that cache up front. svg/svgz join the openable lists; book page; scorecard row done.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* SVG import: never read local files from <image href>
usvg's default image resolver reads any absolute or relative path in an
<image href>, so opening an untrusted SVG could embed local files
(/etc/..., ~/.ssh/...) into the document. Parse with a resolver that
only accepts data: URLs (resources_dir None, string hrefs ignored).
Embedded sub-SVGs already get no <image> resolution from usvg. The font
database (and its system-font scan) is now only loaded when the drawing
may contain text. Regression test: a PNG on disk referenced by absolute
path, file:// URL and relative path is not loaded; a data: URL still is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Edit Contents switches to the already-open contents document
Opening a smart object's contents a second time opened another copy.
It now activates the document already linked to that smart object.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* rustfmt smart_cmds.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Duplicating a layer (Layer › Duplicate, ⌥-drag with the Move tool, Layer
via Copy without a selection, New Smart Object via Copy, the Edit menu's
duplicate, Select and Mask's new layer) names the copy with
`Document::copy_name`: "Layer 1 copy" first, then "Layer 1 copy 2",
"Layer 1 copy 3"…, unique in the document. A name already ending in
"copy" or "copy N" is numbered from its root instead of growing another
"copy"; names that merely contain the word keep it.
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Read the indexed version-12 PNG preview with bounds, chunk ordering and CRC checks. Show native-content and resolution warnings, preserve the source through normal and automation opening, and reject auxiliary routes that cannot display the warning. Add format documentation, malformed-input and save-lifecycle tests, native-oracle hooks and parser/import fuzz targets. Editable native layers and Affinity export remain unsupported.
* Layer > Smart Objects > Convert to Layers (#535)
Unpacks a smart object's contents back into layers at its placement:
several layers land in a group named after the smart object (carrying
its opacity, blend mode, masks and style); a single layer replaces it
directly when those fold in exactly. Contents are conformed to the
document's mode, profile and depth; smart filters are discarded.
Also in the Layers panel context menu and the Properties quick actions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Parity: Convert to Layers is live (627 of 627); raise FLOOR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Convert to Layers: pass no inherited group locks to transform_layer (#847 added the parameter)
The layers are transformed inside released(), which lifts every lock in their tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* it: translate Convert to Layers (Italian landed after this PR was written)
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>
* Camera Raw: interactive histogram, clipping warnings and scopes (part of #215)
- RGB histogram of the preview proxy (algo::histogram), drawn as RGB ribbons;
Before/After, five draggable tonal zones, shadow/highlight clipping warnings
(U/O) and Alt-drag channel diagnostics.
- Pixel hover readout, up to nine RGB/Lab colour samplers (S), an ICC-managed
vectorscope (algo::vectorscope) with skin-tone line, and a floating scope panel.
- Settings regrouped as Light, Color, Effects, Curve, Color Mixer, Color Grading
and Detail; Camera Raw labels translated through the cameraRaw context.
- One point-curve editor (point_curve.rs) shared by Camera Raw, the Curves dialog
and adjustment-layer Properties; fixes Camera Raw curve points not dragging.
- Double-clicking a Camera Raw smart filter reopens the Camera Raw dialog with its
settings and updates that filter, instead of the generic parameter form.
- New point curves are validated (2-16 points, increasing inputs); stored Smart
Filters keep loading.
- camera_raw_bench for P31/P36 (not enforced until a baseline includes them).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Histogram tests: as_chunks for triangle indices (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md after rebasing onto main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n tests: close the Mixer Brush test before the Camera Raw one (rebase)
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>
Every open path read the whole file with std::fs::read, which asks
Windows for up to 4 GiB in a single ReadFile. Some file systems (exFAT,
network redirectors) reject a read that large with
ERROR_INVALID_PARAMETER, "The parameter is incorrect" (os error 87),
as reported for a 4.75 GB PSB in #375.
Add photocraft_format::read_file and read_all beside atomic_write:
64 MiB reads into a buffer reserved up front, and an OutOfMemory error
that says how much the file needed. Route every document read through
them:
- the background open job (engine jobs.rs read_path);
- the engine's read_file;
- Open Recent and OS open events;
- desktop drag and drop, which now goes through open_path (and so
through the background job when jobs are on);
- the File > Open dialog;
- .pcraft loading;
- the automation workspace read and automation::files::open;
- smart object Replace Contents and linked files.
The dialog no longer fails silently: PickOpenFn returns the picked name
with the read result, and a failure is reported through open_failed and
the notices like any other open failure.
Speed is unchanged (1.11 s vs 1.13 s for a warm 4.75 GB file).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Smart objects keep their four-corner (Distort / Perspective) placement
A smart object's placement was an affine transform, so a perspective
placement lost its fourth corner: a mockup PSD's distorted screen looked
right on open (Photoshop's cached pixels) but re-rendered as a
parallelogram after Edit Contents → Save.
- SmartObject gains `perspective`, the full projective map (source px →
document px); `transform` keeps its affine approximation
- Re-renders go through it (place_source_projective), as do moves,
canvas rotations and edit.transform, so Distort / Perspective now work
on smart objects instead of being refused
- PSD import reads the quad (nonAffineTransform / Trnf) and export writes
it back; .pcraft stores the field (absent for affine placements)
- Test: a distorted smart object survives Edit Contents → Save
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Smart objects: warp maps through a distorted placement; perspective round-trip tests
Warp handles and stored warps on a Distort / Perspective smart object go through its full
projective placement (smart_cmds::placement) instead of the affine approximation, so they sit
on the distorted corners. Tests: SoLd perspective round trip and quad_moved (io), a perspective
smart layer in the .pcraft rich_doc, warp on a distorted smart object (engine). Comments no
longer name another product.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Gradients honour explicit transparency stops with colors, a preset or the current gradient (#465)
`paint.gradient` and `gradient.fill.create` dropped `transparency` unless `stops` was
given. Explicit transparency stops now replace the gradient's own opacity stops whatever
supplies the colours. One shared parser (`transparency_param`) serves paint.gradient,
gradient.fill.create/set and the presets; malformed stops are an error, not dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Smart filter commands check an explicit layer target, not the active layer (#466)
`layer.smartFilter.*` with `"layer":id` were refused when the active layer was not a
smart object with filters, although they edit the named layer. Their precondition is now
checked on that layer; the active layer stays unchanged, and without `layer` (menus)
nothing changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Gradients: reword the opacity-merge doc comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>