* Fix multi-file open dialog (#595)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
* Multi-file open test: write its fixtures under the temp dir
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>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Menus:
- Titles open their menu on the press and never toggle on the release (no
blink), so one gesture can press a title, drag to an item and release on
it to run it. A plain click still opens the menu and leaves it open;
pressing an open title closes it.
Title bar:
- The window draws under a transparent title bar, so AppKit dragged it from
anywhere in that strip, menus included. AppKit's own title-bar dragging is
off (NSWindow.movable = NO via safe objc2 calls, no unsafe; objc2 crates
pinned to the versions already in Cargo.lock), and the app's drag region
is only the free gap between the menus and the right-hand controls.
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Open HEIC/HEIF photos (#355)
iPhones save HEIC by default and Photoshop opens it; PhotoCraft had no
HEIF code, so .heic was missing from File > Open and failed to decode.
Add Format::Heif to photocraft-codecs, decoded by heic-rs (pure Rust,
forbid(unsafe), MIT OR Apache-2.0, wasm-ready): single pictures and
grid-tiled photos, 8-bit to U8 and 10/12-bit to U16, auxiliary alpha,
ICC, EXIF and XMP. The container's irot/imir/clap are applied and EXIF
Orientation is rewritten to 1, so photos are never turned twice. Limits
are checked against the declared size before decoding. Read-only (no
pure-Rust HEVC encoder), listed in ASYMMETRIC_EXCEPTIONS.
File > Open (desktop, web) and the batch list accept heic/heif/hif.
Tests compare against Apple's own decode and a 16-bit source, cover
orientation, metadata, limits and every truncation; fixtures from
heic-rs and pillow-heif are attributed in ATTRIBUTION.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* HEIF: pin heic-rs exactly and add a decode_heif fuzz target
Review follow-up: heic-rs is young and parses untrusted files, so pin it
(=0.1.1) and fuzz the HEIF path. The target covers both orientation
paths and is meant to run with -timeout so a hang is reported, not
only a panic. It is added to the CI fuzz matrix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* HEIF: a heic-rs panic is a malformed-file error, not a crash
Fuzzing decode_heif found two out-of-range slices in heic-rs 0.1.1 on
malformed files:
- in its box parser (boxes.rs:130), reachable from a 72-byte file, even
through the metadata-only probe;
- during HEVC reconstruction (hevc/decode/recon.rs:70).
The background job path catches panics, but the synchronous ones (jobs
off, the CLI) did not. The HEIF codec now runs the decoder under
catch_unwind and reports a panic as CodecError::Malformed, as
gpu/health.rs already does around wgpu.
The fuzz target also calls heic-rs directly, so its panics still reach
the fuzzer. The 72-byte input is a regression test, which fails
without the guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* HEIF: move the decoder into an optional photocraft-heif crate
The heic-rs decoding from #372 now lives in `crates/heif` (photocraft-heif, standalone, depends
only on heic-rs =0.1.1): probe, decode with a pixel limit, container transforms, alpha, 8/10-bit,
ICC/EXIF/XMP and the catch_unwind guard, behind a small API of its own (Info, Options, Decoded,
Error). photocraft-codecs uses it behind a new non-default `heif` feature; the apps and
photocraft-io forward it. HEIF detection, extensions and mime stay compiled in, so without the
feature a HEIC file is still recognised and opening it is a clear "HEIC/HEIF support isn't
included in this build of PhotoCraft" error. `cargo xtask layers` allows exactly codecs -> heif
between standalone crates.
The fixtures are no longer committed: `cargo xtask corpus --heif` fetches them from heic-rs
(tests/fixtures, v0.1.1) and pillow-heif (tests/images) at pinned commits, verified against
xtask/heif-corpus.sha256 (same bytes as the PR's copies). The fixture tests run with
`--features corpus,heif` (`cargo xtask test-corpus` enables both).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Build CI and official binaries with HEIC support (--features heif)
CI test and clippy, the FreeBSD job, and the macOS, Linux (and Flatpak), FreeBSD, Windows and
web packaging all build with the `heif` feature; CI also tests codecs/io without it on Linux so
the "not in this build" path stays covered. `cargo xtask wasm` also checks photocraft-codecs with
`--features heif`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: HEIC open is an optional build feature, on in official builds
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>
* Remember desktop window and panel sizes across restarts
* Window layout: discard a saved ui.ron that would crash startup
eframe restores the window geometry and egui memory before the app runs, and a
zoom factor of 0, NaN or 1e30, or a NaN/huge window size aborted every launch
(dpi/wgpu assertions). Check the file first and remove it if it holds such values.
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>
* Monitor profile: say which profile is in use and why; apply a late display reading (#569)
Color Settings › Monitor Profile used to fall back to sRGB without a
word when the display's profile was missing, unreadable, not RGB or
unusable as a destination, and dropped a macOS reading that took longer
than 2 s. `ColorState::monitor_status` now reports the profile actually
applied, its fingerprint, the source (auto / manual / fallback) and the
reason for a fallback. `edit.colorSettings` (`monitorStatus`),
Help › System Info and the Color Settings dialog show it, and a fallback
in auto posts a notice.
The macOS reader also names the display it read. On a dual-display Mac
it reports the menu-bar display ("Built-in Retina Display") while the
canvas is on the external monitor, which is the display-selection bug
in #569; following the window's display is the next step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Monitor profile auto: each window uses the profile of the display it is on (#569)
The macOS reader now lists every display (id, name, frame, ICC profile
and its System Settings name) instead of asking for the "main screen",
which from a windowless helper is the menu-bar display. Each window
picks the display it overlaps most, as NSWindow.screen does, and its
canvas is shown in that display's profile: moving a window to another
display switches the profile without a restart, and two windows of one
document on different displays each keep their own. The GPU canvas
shares the document texture and keeps a display LUT per (document,
display); CPU textures are per (document, display).
Displays are read again when PhotoCraft comes back to the front (a
profile changed in System Settings or by a calibration tool), and when
a window is on an unknown display or one whose size changed (connected,
removed, rearranged), at most every 5 s. A failed re-read keeps the
previous reading. Help › System Info lists each display's profile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Monitor profile auto: act on the r1 review; no periodic re-read (#569)
Following the owner's decision on echo round r1:
- No periodic discovery: a read costs about 0.2 s of CPU, and a profile
reassigned while PhotoCraft stays in front with no window moving
practically doesn't happen. It is picked up at the next return to the
front, or when Edit › Color Settings opens, which now reads at once.
- Every document window, not only the main one, asks for a reading when
it is on an unknown display or one whose size changed.
- The focus re-read is throttled to one per 30 s; a trigger inside that
window is deferred, not dropped.
- Starting a reading schedules the frame that applies its result, so an
idle window doesn't wait for unrelated input.
- The osascript helper is stopped after 10 s (killed and reaped, both
pipes drained meanwhile), so a hung reading can't block later ones.
- The Color Settings dialog recomputes "Monitor profile in use" as it
draws, and the status says when an earlier reading is kept after a
failed re-read (detection `retained`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Monitor profile auto: document windows count for focus and wake the main window (#569)
Echo round r2 on #591:
- Coming back to PhotoCraft through a document window (macOS reports
focus per window, so the main window stays unfocused) now counts as a
return to the front and reads the displays again, within the 30 s
throttle.
- A document window that is on an unknown or resized display, or that
brings PhotoCraft back to the front, asks the main window for a frame:
the reading starts there, so it no longer waits for unrelated input.
One frame per trigger, no continuous repaints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Monitor profile: pass the notice dismiss preference added on main
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>
* Paste: a copied image file pastes its pixels (part of #338)
Copying a file in GNOME Files, Finder or Explorer puts its path on the clipboard, not pixels, so Edit > Paste found no image and said the clipboard was empty. When the clipboard has no image, the native clipboard service now reads arboard's file list and pastes the first file that decodes (upright, as RGBA8), skipping non-images after reading only their header. TGA, which has no magic number, also needs its extension.
arboard splits text/uri-list on LF only, but the list is CRLF-terminated (RFC 2483) and GTK writes it that way, so paths copied in GNOME Files arrive with a trailing '\r'; it is trimmed before opening.
* Paste: copied files before the clipboard image; require arboard 3.6
Finder puts a copied file's icon on the clipboard as an image next to
the file URL, so reading the image first pasted the icon on macOS. The
file list is now read first and the plain image is the fallback.
file_list() needs arboard 3.5 or later, so the requirement says 3.6
(what the lockfile already has).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add rendering policy with legacy preference migration and safe adapter selection
* Add rendering mode preferences and GPU fallback recovery warning
* Complete CPU fallback recovery and validate rendering mode UI
* Simplify GPU recovery and reuse preference persistence
* Rendering modes: rebase onto the preferences merge/retry save path; drop docs screenshots
save_preferences now writes through write_prefs (merging other instances' changes) and
only marks the revision saved when the write succeeds, so a failed recovery choice is
still retried. Removes a leftover diff3 marker in es.tsv, the docs/images/rendering-*.png
screenshots and their ATTRIBUTION row (no binary assets in the repo), and regenerates
docs/scorecard.md for the new performance.renderingMode preference.
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>
Decoders now report what the decoded image doesn't show in a new
`Image::warnings` (`photocraft_codecs::DecodeWarning`), and `image_to_document`
turns them into import warnings, so they reach `photocraft-cli info` /
`convert` stderr, `doc.open` over MCP and serve, and the app's open notice
through the existing import-warnings path.
* #523: APNG (acTL frames, plus a default image outside the animation),
animated GIF (image descriptors counted by walking the blocks), animated
WebP and multi-page TIFF (IFDs walked without decoding; reduced-resolution
copies and transparency masks are not pages) say "only the first of N
frames/pages was imported". Single images stay quiet; the kept frame's
pixels are unchanged.
* #518: a JPEG whose data runs out before its end-of-image marker (marker
segments skipped by length, scan data by byte stuffing) still decodes
leniently but warns "JPEG data ends early (the file is truncated or
damaged); part of the image is missing". A JPEG that ends before any scan
data is an error.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Preferences: saves merge with what another tab or window stored, and failed writes are retried (#475, #476)
A save now writes only the values this instance changed since it last
loaded or saved them over what storage holds (a three-way merge over the
JSON tree; arrays are leaves), so a second browser tab no longer reverts
the other's changes. The saved revision moves only when the write
succeeds: a failure is retried after 2 s, doubling to 30 s (sooner after a
newer change), with a status message on the first failure and a notice
while it keeps failing. Shared by the desktop and web builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Web: a startup that never gets going shows what may have failed and a Reload link (#477)
When the .wasm fails to download or doesn't match the page, no Rust runs,
so the loading page itself (CSS only, no script) adds the hint after 20 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Command params that are not a JSON object are an error (#506)
`Session::dispatch` now rejects array, string, number and boolean params
with "invalid parameters for `<id>`: params must be a JSON object" instead
of running the command with every default; `null` still means no params.
The one check covers the control channel, `serve`, MCP (headless and
bridge) and the CLI, and batch steps fail per step. `photocraft-cli run
--new` prefixes its error with `--new`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Headless requests apply finished background jobs first (#503)
A `wait: false` job was applied to its document only by a later command
or `jobs.list`, so `doc.save`, export, `doc.inspect`, `doc.render` and
`session.list` (and their MCP tools) still read and wrote the document
without it. `Headless::sync_jobs` now runs at the top of every JSON-lines
request and before every headless MCP tool call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* `serve` stdio keeps the session after a rejected request line (#505)
An over-long request line (or one that is not UTF-8) ended the stdio
session, losing every open document. It now gets one error reply, the
rest of that line is skipped without being dispatched, and the next
request is served. `read_bounded_line` reads bytes, so a cut inside a
multi-byte character is reported as too long instead of an I/O error.
TCP connections still close after an oversized line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP `command_batch` keeps its step results when the reply budget stops it (#504)
The batch budget charged each step's plain JSON size, but MCP sends the
batch reply as escaped text content, so a batch the budget had already
stopped could still exceed the tool-result ceiling and come back as a
bare "response exceeds" error. MCP (headless and bridge) now uses
`BatchReplyBudget::escaped`, which charges the escaped size, through the
new `Headless::batch_with_budget`; `serve` keeps the plain budget.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: the 1 MiB request-line limit does not apply to MCP stdio (#529)
The book stated the 1 MiB request-line limit without naming a transport.
It applies to desktop and headless TCP and to the headless JSON-lines
stdio server; MCP over stdio reads its own lines and only its tool
results are size-checked. Say so in the automation security pages and in
the control protocol's transport limits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Launch no longer deletes recovery entries right after loading them. A
recovered document adopts the entry it came from: its next autosave
replaces it in place (atomically) and saving or closing the document
removes it, so a second crash before the next autosave loses nothing.
New documents autosave under per-launch keys, so a document id reused
from an earlier launch never overwrites a surviving entry, and sidecars
whose key doesn't match their file name are ignored.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* CLI --quality refuses every value outside 1-100 (#491)
--quality was parsed as a u8 and clamped, so 0 and 101-255 were silently
accepted while 256 and above were rejected. Every value outside 1-100 is now
a bad --quality error, checked before any file is read or written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* CLI batch: --format with a leading dot names outputs <stem>.<ext> (#490)
batch built output names from the raw --format value, so --format .jpg wrote
a..jpg. One leading dot is now dropped, as the save path and
file.automate.batch already do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* CLI batch --actions takes recorded-action and droplet steps (#489)
batch read only {"command", "params"} objects, so the [id, params] pairs a
recorded action or droplet stores failed with 'missing "command"'. It now
uses the engine's action parser (automate_cmds::parse_action, split out of
parse_script), which takes [id, params], objects and bare ids, bare or
wrapped in {"steps": ...} or a droplet; {"actions": ...} still works.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* CLI batch refuses an --out folder that is the --in folder (#492)
Results are saved as <out>/<stem>.<ext>, so --out naming the input folder
silently replaced the originals. batch now compares the canonical folders
(relative spellings, trailing separators, symbolic links) and fails before
writing anything, naming the folder; the new --in-place flag opts in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Windows places a new window that has no position by cascading it from
the top-left corner, so PhotoCraft opened at a different offset from
the corner on every launch. The window now starts centred on the main
monitor (eframe's `centered`), at the same 1440 × 900, which eframe
shrinks to the monitor first and `work_area::fit_window` still
maximizes when it doesn't fit (#315). Wayland compositors keep placing
windows themselves. The window options moved into `native_options()` so
a test can check them.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Any `--flag` a subcommand didn't know was recorded and ignored, so
`batch --fromat jpg` wrote the source formats and exited 0, `run
--param '{…}'` turned the JSON into a stray file name, and
`<subcommand> --help` ran the subcommand (`mcp --help` started a
server). One table, `SUBCOMMANDS`, now lists each subcommand's value
flags, bare flags and handler, replacing the shared VALUE_FLAGS list:
an unknown flag in either form (`--flag value` or `--flag=value`) is a
usage error naming it (exit 2) before any work, a value on a bare flag
is an error, and `--help` or `-h` after a subcommand prints usage and
exits 0. Every documented flag keeps working in both forms.
The usage text now lists `--quality` for `run` and `batch`, which
already honoured it, and the book's CLI page describes the stricter
parsing.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`file.automate.batch`, `file.scripts.imageProcessor`,
`file.automate.lensCorrection`, droplets (which run batch) and
`photocraft-cli batch` named every output `<stem>.<ext>` in one folder.
When two inputs mapped to the same name (`a.png` and `a.jpg` saved as
JPEG, or the same file name in two input folders), the later save
replaced the earlier result while both were listed as written and the
run exited 0.
`engine::file_cmds::OutputClaims` remembers the outputs written in a
run, ignoring case as macOS and Windows file systems do. An input whose
output was already written is skipped before any work and reported in
`errors` (the CLI prints FAIL and exits non-zero); the first result is
kept. Runs whose names don't collide, including the same stem kept in
its own formats, are unchanged. The commands' params docs say so.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The headless server dispatched `jobs.list` and `jobs.cancel` but left
them out of `METHODS`, so a client discovering the server through
`methods` couldn't find the job methods `engine.execute {"wait": false}`
depends on. Add them, and name them in the rpc.rs module doc and the
`photocraft-cli serve` usage text. A test checks that the job methods
are listed and that every listed name is dispatched.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Re-lands #290, which was merged into its stacked base branch (jobs/engine-210) instead of main.
Same tree as the gated #290 commit 39152db.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Test: retry the GPU startup sentinel check while a forked child briefly shares the lock
monitor_profile tests spawn osascript in the same binary; between fork and exec the child holds
a copy of the marker file descriptor, so flock reports WouldBlock and begin() returns Busy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Background jobs: engine job API, progress and cancel (part of #210) (#289)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
User and imported .abr brush presets now survive a restart. The new
photocraft_engine::preset_store keeps one JSON file per preset group plus
content-addressed, deflated tip bitmaps (8-bit when the tip came from 8-bit
data) in <config dir>/Presets, and an index with group order and deleted
built-ins. Built-ins are never written; writes are atomic and bounded;
corrupt or oversized files are skipped with a warning and left on disk.
The desktop app loads the store on a background thread and attaches it when
it arrives; sessions without a store (CLI, MCP, web, tests) stay hermetic.
Brush preset mutations bump ToolState::presets_rev and the store syncs after
the command, rewriting only groups whose content changed.
Fixes#78
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Compose: render in place and in bands; area-reduced and proxy thumbnails
render_tiled wrote every tile into a Vec and then copied them into the output:
two full-size float copies (6.3 GB for a 14000² document). Tiles are now
written straight into the output band by band.
New streaming APIs so large documents never need a full-size float
composite: render_bands (sink per band of ~8 MP), render_reduced (banded
area average, bit-identical to the old flatten-then-average thumbnail) and
flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other
flattened-surface users).
thumbnail() reduces a downsampled proxy (moved from ui-egui to
compose::proxy, now also scaling spot channels) for documents over 16 MP
when that is faithful (no layer effects or vector masks), so navigator,
channel and histogram thumbnails of a 196 MP document cost milliseconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail
egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected
every document wider or taller than 8192 px and each refresh fell back to a
full CPU flatten. The desktop and web apps now request the adapter's own
max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits);
the benches use the same limits.
The CPU fallback (documents beyond the GPU limit, or features it lacks) is
GpuCanvas::refresh: it renders and uploads the composite band by band
instead of a full float composite plus a full RGBA8 copy. The CPU canvas
texture is reduced in bands too, and the Navigator on the GPU canvas uses a
cached thumbnail instead of a full-resolution CPU composite per revision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* IO: banded export and import; no full-size copies in encoders; larger decode limits
- Flat export renders the composite in bands and quantises each band; alpha
is dropped in place when every pixel is opaque. The single-layer native
path copies surface rows a band at a time (no f32 read_region and no
per-pixel Vec). Buffers come from try_reserve: Err, not an abort.
- PSD export encodes the merged image band by band into its planes (no
flatten, no second plane copy); near-opaque documents are re-encoded
without the white matte so the output is unchanged. Layer interleaved
bytes are freed before their planes are compressed.
- Flat import converts in ~32 MB bands (and not at all when already RGBA);
the PSD merged-image un-matte works per tile row.
- Encoders borrow the image when no conversion is needed (Image::converted)
instead of cloning it; PNG only copies 16-bit data for the byte swap.
- Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG
was refused at 2^28).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Filters: write tile results in memory-bounded groups
apply_tiled collected every tile's float result before writing any (3.1 GB
for a 14000² layer). Results are now produced and written in groups of about
256 MB (at least one tile per core).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* History: bound pixel memory by Preferences > Performance > Memory Usage
History::max_bytes caps the current document's tiles plus the tiles only
history holds (layers, masks, alpha channels); History::trim drops the
oldest states beyond it after every recorded step and when preferences
change, always keeping the last step undoable. The engine sets it from
memory_usage_mb, which was unused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves
One operation per run on a cached synthetic photo, with the GPU device created
the way the app creates it, so /usr/bin/time -l reports that operation's peak
memory. Documented in docs/development.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Rebased onto main (#58/#61/#63/#66/#70/#74) by the maintainers: automation
open/save return and surface import/export warnings (app.open/app.save
reply with `warnings`), automation opens route .abr/.grd to the preset
libraries with script events suppressed, interactive opens keep open_file's
path/recent/notice handling.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* psd: .abr brush (v1/v2/v6+) and .grd gradient (v5) parsers and writers
Clean-room from public format descriptions: legacy computed/sampled brushes,
8BIM-sectioned samp/patt/desc files, 8/16-bit raw and PackBits tips. Bounded
(MAX_EDGE, MAX_TOTAL_BYTES), damaged brushes are skipped with warnings, hostile
input returns Err. Adds an abr fuzz target.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* paint: 36 grouped built-in brush presets and new procedural tips
General / Dry Media / Wet Media / Special Effects groups (pressure size/opacity
variants, airbrush, pencils, charcoal, pastel, crayon, rake, watercolour, ink,
oil, foliage, grass, stipple, sparkle, halftone…). Leaf, grass, charcoal,
sponge, star and rake tips are generated in code. BrushPreset gains a group.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io: map .abr brushes onto BrushSettings
Tip shape, shape dynamics, scattering, texture (embedded patt patterns),
dual brush, colour dynamics, transfer, pose, noise, wet edges, build-up and
captured tool options; unmapped settings are reported as warnings. Includes
bench_abr (100-brush import, 5000-dab stroke).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* engine: brush.presets.importAbr and gradient.presets.importGrd
Path or base64 data, group per file (re-import replaces), unique names next to
the built-ins; graceful-failure tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ui: real stylus pressure/tilt, grouped Brushes panel, Import Brushes
Pen force from egui Touch events (Windows WM_POINTER via winit), Pointer Events
pressure/tilt/twist on the web, tilt via ui.pointer; mouse stays 1.0. .abr/.grd
open from File > Open, drag-and-drop, Brushes panel and Preset Manager. Preset
previews keyed on a cheap signature instead of the full brush JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* CMYK documents display and export through their embedded CMYK profile
A thread-local CmykSpace (photocraft_color::convert::with_cmyk_space) replaces the built-in
coated CMYK while the CPU compositor, the GPU compositor's uploads and the PSD merged
composite convert a document's CMYK pixels; GPU residents track the profile they were
converted with.
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* EXR/HDR are linear: tag imports linear sRGB, linearise exports
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Colour-managed canvas: document profile -> monitor profile on GPU and CPU paths
* engine display_color: CanvasDisplay per (document profile, mode, monitor), identity fast
path when they match (sRGB on sRGB costs nothing); linear composites are stored
sRGB-encoded in the 8-bit texture. canvas_lut now always carries the CM transform, with
Proof Colors / Gamut Warning / 32-bit preview on top.
* GPU canvas applies the display LUT whenever the transform isn't the identity (also for
filter/proxy previews); the CPU canvas and Navigator transform the 8-bit composite.
* Edit > Color Settings > Monitor Profile (auto|built-in|.icc). macOS 'auto' reads the main
display's ICC profile at launch via osascript/AppKit (no unsafe); elsewhere sRGB.
* Tests: P3 (hand-computed), Adobe RGB, ProPhoto, Gray Gamma 2.2, synthetic CMYK, linear,
monitor setting; GPU+CPU canvas test via kittest (skips without Rgba32Float).
Fixes#46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io: tag EXR/HDR linear in import_flat (adapt to the shared image_to_document)
#65 moved flat decoding into image_to_document(name, img) so camera raws can reuse it; the
linear-EXR tagging needs the file bytes for format detection, so it now runs in import_flat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* raw: clean-room camera raw decoder and developer (photocraft-raw)
A new standalone crate (no workspace deps, wasm-safe, no unsafe) written
from TIFF 6.0, TIFF/EP, the DNG 1.7 spec, ITU-T T.81 Annex H and published
demosaicing papers:
- Lossless JPEG (LJ92) decoder: predictors 1-7, point transform, restart
intervals, combined Huffman+extra-bits fast table.
- DNG: uncompressed (8-16 bit, packed) and LJ92, strips and tiles, CFA and
LinearRaw, linearization, black level (repeat, deltas), white level,
active area, default crop, ColorMatrix/CameraCalibration/ForwardMatrix
interpolation by CCT, AnalogBalance, AsShotNeutral/WhiteXY,
BaselineExposure, orientation.
- CR2 (LJ92 slices, SensorInfo borders, ColorData as-shot WB, measured
black) and TIFF/EP raws (NEF/ARW/PEF uncompressed or LJ92, incl. Sony
lossless ARW tiles, Nikon/Sony documented black and WB tags).
- Develop: black/white scaling, WB with highlight clipping, bilinear /
Malvar-He-Cutler / AHD demosaic (rayon bands, sequential on wasm),
camera -> XYZ D50 -> ProPhoto, exposure, gamma 1.8, 16-bit, orientation.
- Embedded JPEG preview finder; clear Unsupported errors for CR3, RAF,
ORF, RW2, vendor-compressed NEF/ARW/PEF, lossy/JXL/float DNG, X-Trans.
- Limits checked before every allocation; testgen feature with an LJ92
encoder and DNG/CR2/TIFF-EP writers; round-trip, colour-reference,
truncation and corruption tests.
Registered as a standalone crate in xtask layers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io: open camera raws through photocraft-raw (16-bit ProPhoto), preview fallback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* raw: DNG GainMap opcode, fuzz targets, stress tools; open raws from the apps
- Apply DNG OpcodeList2 GainMap (lens shading, e.g. phone DNGs); report other opcodes.
- cargo-fuzz targets (develop, ljpeg) and a mutation stress example.
- Raw extensions in the desktop/web open dialogs and the batch folder list.
- file.open returns the import notes (how a raw was developed, preview fallback).
- codecs: the camera raw refusal points at photocraft-raw.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* raw: clippy 1.99 (is_multiple_of, clamp)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* File open/save: set the path everywhere, Open Recent, show errors and import/export warnings
File › Open, drag-and-drop onto the window and launch paths now share
PhotocraftApp::open_file: the document is named after the file (not the full
path), keeps its path so File › Save writes back, and goes to Open Recent.
Failures are shown as errors (status bar + a non-blocking notice).
Import/export warnings from photocraft-io are no longer thrown away: they show
in the status bar and in a dismissable notice (native and web), and the
control protocol returns them from app.open/app.save/file.open/file.save/
file.saveAs/file.saveACopy. .pcraft is in the Open and Save filters, and
File › Save writes .pcraft documents in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* macOS: open files from Finder, Open With and the Dock (Apple events)
Finder double-click, Open With, drops on the Dock icon and open -a deliver
files as kAEOpenDocuments Apple events, not argv, and winit 0.30 ignores them.
Register an NSAppleEventManager handler through the small fmv-macos-events
crate (MIT OR Apache-2.0, 168 lines, audited, pinned =0.1.0; it keeps the
Objective-C unsafe out of our forbid(unsafe_code) workspace) before the event
loop starts, so both the launching event and later ones reach the UI as
OsEvent::Open; the window is focused. The quit Apple event goes through the
normal close path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Session::execute wraps a command's run in catch_unwind: a panic that
escapes anyway becomes an EngineError ("`id` failed with an internal
error (logged); the document is unchanged"). Session::edit only commits
after its closure returns, so documents stay intact. This covers the
UI, CLI, MCP and control channels.
- apps/photocraft: a new crash_guard module installs a logging panic hook
(keeping the default one) and guards the native import/export services,
so a panic while opening or exporting a file is reported as an error
and the open documents stay. Wired in with two lines in main.rs.
- services::import_flat returns an error instead of unwrap; photocraft
and photocraft-web deny unwrap/expect/panic/unimplemented/todo/
unreachable.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- rpc::respond (the JSON-lines server behind `photocraft-cli run`/TCP)
catches a panicking request and answers it with an error, and keeps
using a poisoned session lock (as the MCP server already does) instead
of failing every later call with "session lock poisoned". Regression
test poisoned_session_keeps_serving.
- MCP server: the eight bridge_client().expect("bridge") and the
headless_op(..).expect("headless") calls return a tool error instead.
- bridge: the connection is taken with let-else, not expect.
- automation and photocraft-cli deny unwrap/expect/panic/unimplemented/
todo/unreachable.
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>
ctx.open_url is unreliable on native (notably Windows), so the Help menu,
the Discord buttons, and the start-page links did nothing. Added an open_url
Service hook wired to the `open` crate on native (ShellExecute on Windows);
links::open prefers it and falls back to ctx.open_url (web). link_row now
routes clicks through it instead of ui.hyperlink_to. Test drives every Help
link id + the open() helper through a mock service.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- Lowercase reverse-DNS id ai.storyteller.photocraft across icons, Linux
desktop/metainfo/mime/flatpak, Windows WiX, macOS Info.plist and workflows.
- PhotoCraft display name in the title bar, start screen, web loader,
Windows exe metadata, README and docs.
- .gitignore target/, roadmap refresh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>