* Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Cargo.lock: photocraft-ipp at 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`file.package` absolutised the caller's `dir` and then built `folder`, `document`
and every `links` entry on it with `join`, which always writes `/`. On Windows
that left one string spelt both ways:
`C:\Users\...\pkg/Print Me/Links/art.png`. The control channel takes `/` and
rejects alternate separators, so a reported path could not be handed back to it.
Spell the absolutised directory with `/` as well, keeping a leading `\\`
(verbatim and UNC prefixes, where Win32 does not accept `/`) intact.
Main prints through craft-print, which runs `lp`; a Flatpak has no `lp`,
so File › Print fails there. This keeps craft-print as the route wherever
`lp` is on the path (and on Windows) and adds two more behind it:
- `photocraft-ipp`, a pure-Rust IPP client (CUPS-Get-Printers/Classes/
Default, Print-Job over HTTP/1.1) that talks to CUPS over its socket or
TCP; the Flatpak manifests grant --socket=cups for it. A `printServer`
named in the request always takes this route. `file.print.printers`
lists the queues.
- The XDG Print portal as the app's spooler (`Session::print_spooler`),
for a sandbox without the CUPS socket or a system without a printer:
the system print dialog, seeded with copies, paper and orientation.
PHOTOCRAFT_PRINT_PORTAL=1 prefers it, =0 removes it.
A route that exists but fails (unknown printer, refused job) is an error;
only a missing route moves on. Results gain `backend` (lp, windows, ipp,
portal). docs/printing.md describes the routes.
* Web: Place Embedded and canvas drops place images (#5749)
File > Place Embedded was disabled in the browser (the engine command reads a
path), though the menu path reads the picked file's bytes. Files dropped on
the canvas were always opened as new documents. The web shell now tracks the
drag position, and drops on the canvas are placed from their bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Format (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Narrow to the web canvas-drop half: Place Embedded enablement landed in #5585
Drop the picker test that covered the part #5585 now owns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
logging::rotate renamed photocraft.log up the chain on every start without
knowing whether another instance still wrote to it. The running instance
kept its handle, so after a second launch its live log was .1.log, after a
third .2.log, and a fourth launch deleted it.
The instance that writes photocraft.log now holds an exclusive lock on
photocraft.lock until it exits (a separate file, since Windows locks are
mandatory and would keep the log itself from being opened). A start that
finds that lock held leaves the chain alone and writes
photocraft-<pid>.log, marked by photocraft-<pid>.lock. Those side-by-side
logs are pruned to the newest KEEP once their instances have exited; a
held one is never touched. Rotation and pruning run under rotate.lock so
two starts at once can't interleave. On a file system without locks every
start rotates as before.
* fix(desktop): `photocraft --help` prints usage; unknown options are usage errors (#6431)
The desktop binary's argument loop took every argument it didn't know as
a file, so `photocraft --help`, `-h` or a typo such as `--safegpu`
opened the window and logged "Couldn't open --help".
- `--help` / `-h` print the usage (every option and environment
variable) and exit 0.
- Any other argument starting with `-` (except macOS `-psn_`) is named
on stderr and the launch exits 2 before the log file or window, along
with any bad `--control` port (`control_args_exit_code` becomes
`usage_exit_code`).
- `--` ends the options, so a file whose name starts with `-` opens.
- `--help` and `--version` write without `println!`, so a closed pipe
(`photocraft --help | head -1`) can't panic.
Tests: apps/photocraft/tests/command_line_usage.rs runs the binary with
no display; 4 of its 6 tests fail on the old code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(desktop): usage tests exit before the window opens; fmt smart_filter_authorization
`arguments_after_double_dash_are_files` and `macos_process_serial_number_is_still_ignored`
passed only valid arguments, so the app went on to open its window. Removing DISPLAY makes
that fail fast on Linux, but Windows runners have a desktop and both tests hung until
nextest's 600 s timeout. They now add a bad `--control` value so the launch always fails
with the usage errors, and assert stderr names only that error (no "unknown option").
Also `cargo fmt` crates/automation/tests/smart_filter_authorization.rs, left unformatted
on main by #5684, which failed the Format step of every PR's ubuntu test job.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Image Assets: two layers naming the same file no longer overwrite each other (#6614) [WIP]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Image Assets: two layers naming the same file no longer overwrite each other (#6614)
generate_assets wrote every requested asset, so two layers (or two
default variants) naming one file silently replaced each other and the
result listed the file twice. Each file is now written once per run:
layers go top first, as the Layers panel lists them, the first writer
keeps the name, and later ones are reported in errors. Names are
compared ignoring case, since Logo.png and logo.png are one file on
Windows and macOS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Saving writes a temporary file and renames it over the destination. A
rename only needs the folder to be writable, so on Linux and macOS a
read-only (chmod 444) file was silently replaced, while on Windows the
rename failed with a vague "locked or read-only" access error.
atomic_write now asks the AtomicFs whether the existing destination is
read-only (new `is_readonly`, false by default for other implementors)
and refuses before anything is written: "cannot save <file>: the file
is read-only; the original file was not changed". New files and
writable files save as before. Photoshop refuses the same way; the
user saves a copy with Save As.
Tests: a fake AtomicFs reporting read-only gets the error with nothing
written or renamed; a real file marked read-only keeps its bytes and
mode and gets the clear message on every platform.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With Export Preferences › Location = Same Folder, Quick Export wrote
<name>.<format> beside the document's file with no check, so a document
opened from photo.png was exported over photo.png itself. The app now
opens the save dialog for that case, and file.export.quickExport refuses
it without a `path`. Overwriting an earlier export beside a PSD is kept.
Adds file_cmds::same_file (text, dev+inode on Unix, case-insensitive
canonical path on Windows).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The full-registry fuzz (AGENTS.md Rule 9) is #[ignore]d and no workflow ran it, so nothing
enforced "must stay green". The new step reruns the Test step's nextest selection with
--run-ignored only, filtered to the panic_hunt binary, so nothing rebuilds; the ci profile
terminates it if it hangs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`blocks` and `fnv` exist only for `harris_and_describe_hashes_recorded_from_the
_serial_code`, which is `#[cfg(all(target_os = "linux", target_arch =
"x86_64"))]` because the hashes it compares against go through the platform's
hypot / ln / cos / atan2 / sin_cos. Everywhere else the test is compiled out and
the two helpers are left unused, so `-D warnings` fails on `dead_code`:
error: function `blocks` is never used crates/algo/src/features.rs:704
error: function `fnv` is never used crates/algo/src/features.rs:708
They now carry the same cfg as the only thing that calls them.
CI's matrix is ubuntu-latest and windows-latest and runs clippy on both, so this
fails the Windows leg as much as a local run on macOS: `target_os = "linux"` is
false for both.
#5344 added the export_presets field to Persisted; #5616 rewrote load_presets_json to read each set with take() and its Persisted initializer lacked the new field, so main stopped compiling (E0063 at presets/mod.rs:180). Read export_presets the same way as the other sets; the existing load step and round-trip test cover it.
Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com>
N is the whole algo-side Photomerge pipeline on 3 × 8 MP photos (register at the engine's
scale with the Auto layout, gain + vignette fit, seams, multi-band blend), 16 threads:
1042 ms before, 264 ms after, 3.9×, rounded to 4×. The engine's own glue (reading regions,
building weights, writing layers) is not in this number.
Registration and multi-band blending ran almost entirely on one thread per image (four
images on 16 threads), so most of the machine sat idle. Every output is bit for bit what
the serial code produced: a probe that hashes the public Photomerge entry points
(detect_many, match_pairs, align for every layout, photometric, seam_labels, multiband at
1 to 7 levels with 1 and 3 channels, with holes in the alpha, with images sticking out of
the canvas, plus direct harris / describe calls) matches on all 349 outputs before and
after, on 4 × 2 MP, 3 × 8 MP, 97×61, 211×157, 333×251, 320×240 two-image and 40×35 sets,
at 16, 3 and 1 threads.
- features `blur` (Harris window and BRIEF smoothing): the row pass runs rows in
parallel; the column pass cuts the image into bands of 64 columns, each holding the
`&mut` row segments of its own columns (no unsafe), run in parallel. Every column keeps
its own f32 running sum, started from its first window summed in order and updated in
the same order, so only the loop nest changes.
- `harris`: Sobel, the corner response and the non-maximum scan are parallel by rows (the
candidates are fully ordered by score, y, x afterwards, so arrival order is irrelevant).
`describe`: one descriptor per corner in parallel, kept in corner order.
- `reduce`, `expand`, `push_pull`: per-row maps in parallel. `reduce`'s column pass adds
five whole rows in tap order (same per-element sum, now vectorised); `expand` works out
its source columns once and truncates instead of calling libm `floor` (the clamped
position is never negative); `push_pull` builds each coarse row from its two fine rows in
raster order, which is the order the serial scatter-add used.
- `multiband`: grid fill per image, Laplacian difference, band accumulation (every row
walks the bands in the original order), normalisation, collapse and the coverage union
are parallel. Float additions are never reordered.
Left serial on purpose: the peak fold in `harris` (about 1 ms, an order-free max but not
worth the proof), the corner spacing grid, `seam_labels` (a dynamic-programming path whose
rows depend on the previous one), `photometric` sample gathering, and the pyramid levels
themselves (each level needs the one above).
Median of 5 (8 MP rows: 7), release, ms, interleaved before/after runs. The laptop was
busy with unrelated builds the whole time (load average 11 to 18 on 16 threads), so the
1-thread rows are noisy (a single thread is time-sliced against them) and rows can move
by 10 to 20 percent.
| Case (probe) | before (16 thr) | after (16 thr) | speedup | before (1 thr) | after (1 thr) |
|--------------------------------|----------------:|---------------:|--------:|---------------:|--------------:|
| register, 4 × 2 MP | 154 | 60 | 2.6× | 336 | 167 |
| multiband, 4 × 2 MP, 3 ch | 407 | 108 | 3.8× | 591 | 407 |
| multiband, 4 × 2 MP, 1 ch | 307 | 73 | 4.2× | 813 | 566 |
| whole pipeline, 4 × 2 MP | 437 | 160 | 2.7× | 716 | 504 |
| register, 3 × 8 MP (full size) | 327 | 124 | 2.6× | 978 | 391 |
| multiband, 3 × 8 MP, 3 ch | 2348 | 916 | 2.6× | 1742 | 1150 |
| whole pipeline, 3 × 8 MP | 1042 | 264 | 3.9× | 2264 | 1280 |
The 8 MP multiband row at 16 threads is the noisiest (minimum of 7: 959 to 224 ms, 4.3×);
the whole-pipeline row is steady. Max RSS for the 3 × 8 MP whole pipeline (the probe's
inputs are about 1.05 GB of it): 2.79 GB before, 2.79 GB after (three runs each).
Tests: the serial `blur`, `reduce`, `expand`, `push_pull` and `multiband` are kept in the
test modules as references and compared bit for bit over sizes that cross every band and
row boundary (1×1, 1-wide, 1-high, odd sizes, 63 / 64 / 65 / 129 columns, radius 0 to
wider than the image, holes, partial and zero weights, ROIs outside the canvas, 1 to 7
levels). Hard-coded hashes recorded from the old code cover multiband, push_pull,
reduce + expand everywhere (plain f32 arithmetic), and harris + describe on Linux x86-64
(they go through the platform's hypot / ln / cos / atan2 / sin_cos).
Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Save: ask before overwriting a file another app changed (#6596) [WIP]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Save: ask before overwriting a file another app changed (#6596)
File › Save now notes the size and modification time of a document's
own file when it is opened or saved, and checks them again before
writing back. If another app changed the file in between, a prompt asks
whether to replace it (Cancel keeps both; Save As saves elsewhere)
instead of silently overwriting the other app's changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648)
* test(windows): cover the local recovery folder and moving old autosaves (#5648)
A TySh warpValue, warpPerspective or warpPerspectiveOther beyond f32 became inf and the warp was silently dropped; they are now clamped to -100..100 like type.warpText.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Layer › Smart Objects › Stack Mode (`algo::stack::combine`) was one serial
per-pixel loop (1.0x parallelism, identical at 1 and 16 threads), and Entropy
built a HashMap per channel per pixel.
- Output pixels are computed in parallel in 4096-pixel chunks (`par_rows`, so
wasm keeps its serial path). Each chunk owns its scratch buffers.
- Each pixel uses exactly the same arithmetic in the same order as before, so
every mode except Entropy is bit-identical: 110 output hashes (10 modes x 11
cases: 3 x 24 MP, 7 layers of odd/mismatched sizes, transparency, HDR values,
1 layer, no layers) match the old code.
- Entropy: a `[u32; 256]` histogram plus a short list of the bins touched
replaces the HashMap; touched bins are summed in ascending order, so the result
is now deterministic. The old HashMap summed in random iteration order and
differed run to run (up to 1.2e-7 between two runs of the old code). New vs old
output: max abs difference 1.19e-7 (one f32 ulp near 1.0), on up to ~10% of
values in the 7-layer cases; the 3-layer 24 MP case is identical.
- Modes that never use the mean (Median, Min, Max, Range, Summation, Entropy) no
longer compute it; Median uses an unstable sort (same result under total_cmp).
3 x 24 MP, release, median of 5 interleaved before/after runs (load average
~3.5-4.5 from an unrelated build running alongside), ms:
| Mode | before | after (16 thr) | speedup | after (1 thr) |
|------------|-------:|---------------:|--------:|--------------:|
| Median | 1100 | 99 | 11.1x | 1064 |
| Mean | 308 | 36 | 8.6x | 338 |
| StdDev | 342 | 41 | 8.3x | 380 |
| Entropy | 4727 | 269 | 17.5x | 2730 |
| Kurtosis | 458 | 55 | 8.3x | 488 |
| Maximum | 368 | 36 | 10.1x | 346 |
The other modes land between 8x and 10x. Max RSS for Median is unchanged
(1.50 GB, of which 1.13 GB is the input layers).
Tests: hard-coded bit patterns recorded from the old code for every mode but
Entropy; Entropy against hand-computed values and run-to-run determinism; and a
chunk-boundary test comparing a multi-chunk image to per-pixel results.
Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The image crate's Targa encoder let run-length packets run on from the end of
one row into the next. TGA 2.0 says a packet must not cross a scanline, and
readers that decode a row at a time misread such files: Pillow fails on them
with "buffer overrun", and texture tools and game engines reject them.
Targa is now written by our own encoder (codecs::tga), as Photoshop writes
it: RLE true colour or grayscale, every packet within its row, rows stored
bottom-up (the origin readers that ignore the descriptor assume), the alpha
bits in the descriptor and the TGA 2.0 footer. Reading stays with the image
crate.
One malformed set in the preferences file (gradients, swatches, styles...)
made load_presets_json ignore the whole presets blob, so saved New Document
presets vanished at every launch: the shell merges back only changed keys,
leaving the damaged set on disk. Each set now loads on its own; null and
malformed sets keep their defaults.
Refs storytold/photocraft#5602
Co-authored-by: Cursor <cursoragent@cursor.com>
Horizontal scale was applied after line breaking, so paragraph text below
100% wrapped early and text above 100% overflowed the box. Break box text
at the available width divided by the paragraph's horizontal scale (the
largest one when runs mix), scale the first-line indent and alignment
offset back to the box, and keep the scaled trailing space out of the
line extent.
Fixesstorytold/photocraft#5565
Co-authored-by: Cursor <cursoragent@cursor.com>
The prefix cache's chunk textures are only copied to and from
(COPY_SRC | COPY_DST), but went through Tex::new, which always creates
a default view. Vulkan rejects a view on an image with no sampled,
storage or attachment usage (VUID-VkImageViewCreateInfo-image-04441);
wgpu doesn't check it, so it only shows with the validation layers on,
once per new chunk while painting. The view was never used. Chunks now
hold a bare wgpu::Texture. No rendering change.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
usvg builds the glyph outlines of a text element (Text::flattened) with
identity absolute transforms, and the SVG importer read each path's
abs_transform(). Text positioned by its own or an ancestor's transform,
as Illustrator writes every text element (matrix(1 0 0 1 x y)), lost it:
it landed off the canvas, so nothing showed, or at its untransformed
place and size.
The converter now composes each node's transform from its parent chain,
which equals abs_transform() outside text, and sizes raster fallbacks
from the same transform. A new test checks translate, matrix, scale and
transformed groups against resvg's own render; it fails on main.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
On a document over about 5 MP with Low Resolution Previews on, a filter
dialog's live preview ran on a reduced proxy at every zoom, so at 100 % the
canvas showed the image in k-pixel blocks and hid exactly what Unsharp Mask,
High Pass and Smart Blur do.
ensure_filter_preview now caps its factor with proxy::view_factor(k, zoom),
the same rule adjustment previews got in #5764, so from 100 % the preview is
full resolution and stays reduced when zoomed out. The preview already runs on
a cancellable background worker, so the full-resolution path does not block the
UI thread.
Also in this change:
- canvas.rs and filter_preview_ui.rs share one canvas::filter_preview_factor,
so the in-dialog preview window cannot size textures from an uncapped factor
and rebuild them when the first result arrives.
- The cap is gated on cfg!(target_arch = "wasm32"), so the web build keeps its
proxy rather than running a full-resolution filter inline on the UI thread.
- The end-to-end test moved into mod tests and covers Low Resolution Previews
off; a new factor test covers 25/50/75/100/400 %, NaN and zero zoom.
The Community packages heading from #1149 landed before the maintainers' releasing note and the IMPORTANT status admonition, so those two paragraphs rendered under Community packages instead of Get started. Move the section to just above Documentation; the content is unchanged.
Strict workspace Clippy stopped on collapsible_match in affinity, raw, plugins, engine, ui-egui and xtask. Pure conditions become guarded match arms (each falling through to an existing empty arm), and the ruler drag state and the measurement command result are bound to locals, preserving evaluation order, error messages and fallbacks.
The same lint fixes also appear as a side commit in #5157 (David2766), which can drop it on rebase.
With a document open in the web app, Place Embedded was disabled because its menu enablement inherited the engine's native-filesystem check, although the menu path reads the picker's bytes and never touches a path. Place Embedded is now enabled whenever a document and a file-dialog service exist; Place Linked keeps the engine's native guard, since the web could only link to a bare file name (caught in #5817 by Higgs32584). An enablement test runs with the native check forced off, and a file-dialog test covers the browser answer shape, cancel, invalid bytes and the Free Transform handoff.
Fixes#1209. Fixes#5749.
egui places font runs left to right with no bidi reordering, so a right-to-left layer name showed every word correctly but in reversed order. bidi_text::visual puts a label into display order with the Unicode Bidirectional Algorithm (unicode-bidi, already in the tree via usvg): right-to-left words stay in logical order for the shaper, everything else in a right-to-left run is reversed around them and brackets are mirrored. The Layers panel's rows and the app-drawn title bar use it; the title keeps a left-to-right paragraph so '— PhotoCraft' stays at the right, and a long right-to-left name is cut at its logical end so its start survives. Display only: stored names and the OS title keep the logical text.
Fixes#5159.
Co-authored-by: ahmedhosnypro <ahhosnyas@gmail.com>
A command that fails on a layer id or name the document doesn't have now says which layers there are, top-down as id and name (at most 20, names cut at 64 characters), so an agent can recover from the error alone: ids are not positions, and a freshly opened PNG's Background is layer 2. NoLayer becomes UnknownLayer { id, known } at both command entry points, keeping the 'no such layer' prefix. A layer name that matches no layer exactly matches regardless of case; an exact match always wins.
Fixes#5058.
photocraft_algo::faces::detect finds faces in an RGBA image with a score, a box and five landmarks, through a pure-Rust port of libfacedetection's YuNet network (Shiqi Yu, BSD-3-Clause; licence, NOTICE and ATTRIBUTION rows included). The weights are the library's, converted to half precision (107 KB), parsed defensively. image.detectFaces reports the document's faces to scripts and agents; it is read-only with no history step and no menu entry. The network is compiled out of the browser build, where the command reports itself unavailable. Head outputs are pinned against the C++ reference.
Part of #3603.
Adds a Community packages heading to the README, noting these installers are maintained by community members, with the Gentoo recipe for media-gfx/photocraft-bin from the ::snakebyte overlay. The overlay's ebuild fetches the official Linux x86_64 release tarball with Manifest-pinned hashes that match the release's SHA256SUMS.
A single oversized input event reached egui text layout in a numeric field even though it could never become a valid value. number_edit now drops Text, Paste and IME events longer than 128 scalar values while the field has focus, and the custom parser rejects accumulated text over the cap before normalizing. DragValue keeps focus, accessibility, drag and arithmetic behaviour. Six harness tests cover the event kinds, the Unicode boundary, accumulation, undo, and document text fields staying unrestricted.
Part of #2150.
brush.defineFromSelection allocated the sampled tip and the selection mask infallibly over the whole selection, so a large selection could abort the process. Both now go through the engine's fallible allocator and return a normal error that leaves the brush, presets, history and document untouched. A failpoint test asserts all four.
Part of #212.
The caret's blink phase was tied to the wall clock, so an arrow key or keystroke that landed in the off half-cycle moved an invisible caret. A transient caret_blink_time on UiState is reset on every handled key event, on a click that places the caret, and while dragging; draw_overlay computes the phase from it so the caret is solid for 530 ms after each interaction, and the repaint is scheduled to the next phase boundary.
Fixes#5511.
With Only Web Colors on, every picker edit (field, sliders, hex, Lab/CMYK, screen sampler, Current) snaps through the clamped 216-colour ramp. The checkbox is remembered on the Color panel state so the foreground, field, command, Layer Style and type-style pickers open with the same setting. Opening with it on snaps the colour and its original together, so OK without an edit leaves a fill layer unchanged. The hex field follows the colour when another path changes it while focused.
Fixes#4995.
Creating a Solid Color fill from the Layer menu or the Layers panel footer now opens the shared Color Picker seeded with the foreground colour. The layer is created on OK in one history step; Cancel leaves the document and history untouched. Parameterised calls (CLI, MCP, actions) still create the layer directly.
Fixes#5144.