Commit Graph
36 Commits
Author SHA1 Message Date
Dylan M. Taylor 3877bb1dbe chore: resolve SonarCloud maintainability issues (#9044)
* chore: resolve SonarCloud maintainability issues

* fix: format imports and fix effect_entry argument type

* fix: restore test closure and wildcard import that broke compilation

* refactor: split composite_to_surface to reduce cognitive complexity
2026-10-25 19:38:31 -04:00
berceandBrandon Thomas 05c75fe855 fix(cms): read UTF-16 LUT files by their byte-order mark (#9129)
A UTF-16 .look that Photoshop loads was read as UTF-8 and failed with "no <size> in .look", in Color Lookup and in PSD import. Decode FF FE / FE FF as UTF-16 before picking the format.

Fixes #9118

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-25 10:31:58 -04:00
DuBearteandClaude Opus 5.5 6f1705a32b fix(cms): headerless .3dl tables read on at least the 12-bit scale (#9121) (#9215)
A table with no header peaking at 255 or 1023 was scaled to full range,
so white came out 255 where Photoshop gives 16 or 64. Without a header,
the inferred output depth now starts at 12 bits.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:17:39 -04:00
DuBearteandClaude Opus 5.5 7e8386c94c fix(cms): ignore tags inside .look XML comments (#9109) (#9210)
A comment holding <size>/<data> text was read as the LUT, so a commented
inverse .look rendered as identity. Drop comments before finding tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:17:34 -04:00
SimoneandLucenx9 4092fe79c9 fix(cms): reject malformed hex data in .look LUTs (#8657)
Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
2026-10-23 17:40:16 -04:00
Dylan M. Taylor 78ae50f7b2 security: bisect flat-table and gradient-stop lookups; charge ICC curve samples; bound PSD planes, RAW ljpeg retention and TIFF dump preview 2026-10-23 14:41:53 -04:00
92262d5950 fix(cms): confirm ICC profile identity by bytes, not the 64-bit hash alone (#8863)
Profile::content_hash is a fixed-key 64-bit SipHash of the ICC bytes, so a
crafted profile can share another profile's hash (about 2^32 work). Two
places took an equal hash as proof of an identical profile:

- transform::cached keyed its process-wide cache on the two hashes and the
  options, so a colliding profile was handed the other profile's cached
  transform and converted with the wrong colours.
- Profile::same_colors returned true on equal hashes, so a colliding
  profile was taken for the other one and a needed conversion was skipped.

Add Profile::same_bytes, which compares the ICC bytes (an Arc pointer check
first, then the hash, then the bytes). same_colors uses it instead of the
hash. Each transform cache entry now keeps the ICC bytes of its two
profiles; a hit counts only when they match, and a colliding pair gets its
own transform without replacing the cached entry. The transform is built
after the cache lock is released, as before.

The regression tests give Display P3 the content hash of sRGB (a stand-in
for a found collision): before the fix, same_colors called them the same
and the cache returned sRGB's transform for P3; now both see P3's colours.

Fixes #8613

Co-authored-by: John Turnbach <jturnbach@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:54:42 -04:00
aiongg 934eb71d84 Integration: merge main
crates/engine/src/file_cmds.rs: batch keeps the save-point closure (takes the output path) and also copies the session's authorize hook into each scratch session, as main now does.
crates/raw/src/sensor.rs: both sides cap the memory of retained tiles; kept the existing retained_segment_bytes check (checked multiplication, same limit) and dropped main's duplicate inline loop.
2026-10-22 15:39:51 +00:00
aiongg aba7e03d60 Merge PR #8083: Write DOMAIN_MIN/DOMAIN_MAX in the .cube writer
crates/cms/src/lutfile.rs: both PRs added a test at the same place; kept both whole, each with its own closing brace
2026-10-22 15:37:38 +00:00
aiongg 0974ee5394 Merge PR #8023: Keep '#' inside quoted .cube TITLE when parsing 2026-10-22 15:37:38 +00:00
Dylan M. Taylor 9e8a3eecf0 security(cms): bound inverse-curve and CLUT interpolation work
Two per-pixel/per-sample amplifiers driven by a crafted ICC profile:

- table_inverse recomputed a sampled curve's flat-run edge indices by
  scanning the whole table on every call, and transform construction
  inverts each destination curve OUT_TABLE times. Compute the edges once
  per curve via a new Curve::inverse_table batch method.
- eval_multilinear enumerated 2^inputs cube corners over every declared
  input, including single-grid-point (degenerate) dimensions that
  contribute nothing. Interpolate over the effective dimensions only, so
  a 15-channel CLUT of one-point dims no longer does 2^15 iterations per
  pixel.

Findings:
- acfbe3a3-ee58-4b7e-b72a-5a4877dd15c9 (CWE-407, repeated flat-run scans)
- 9fcc72e7-46d6-4f75-9333-cb14a65eeafd (CWE-407, degenerate-dim corner blowup)
2026-10-22 07:11:02 -04:00
Higgs32584andClaude Opus 5.5 b0acb119d6 Merge upstream main into fix/6752-heic-hlg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:19:06 -04:00
NetterSilverandClaude Haiku 5.5 67314ff606 Write DOMAIN_MIN/DOMAIN_MAX in the .cube writer
Found while testing crates/cms/src/lutfile.rs:227 (write_cube)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:46:33 +02:00
NetterSilverandClaude Haiku 5.5 b85b638e77 Keep '#' inside quoted .cube TITLE when parsing
Found while testing crates/cms/src/lutfile.rs:111 (parse_cube) and :227 (write_cube)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:15:46 +00:00
ha1des 472a8d6f29 Preserve near-black ICC colors on the GPU canvas 2026-10-20 14:18:00 +02:00
Michael Higgins d356513052 Merge branch 'storytold:main' into fix/6752-heic-hlg 2026-10-16 20:27:09 -04:00
Higgs32584andClaude Opus 5.5 41bc07d8f5 HEIF: read the nclx colour description; HLG/PQ HEICs open as linear BT.2020 32-bit (#6752)
The colr nclx primaries and transfer were dropped, so a 10-bit BT.2020 HLG
HEIC opened as if its samples were sRGB. photocraft-heif now returns the
nclx (inherited from grid tiles like the ICC profile), codecs decodes PQ and
HLG to linear light in 32-bit float (BT.2100 inverse OETF + OOTF at 1000
cd/m2, 203 cd/m2 diffuse white = 1.0), and io tags the document with the
matching profile from the new photocraft_cms::builtin::cicp_rgb. BT.709/sRGB
SDR files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:24:47 -04:00
wwew e33384c9ba docs: write product name as PhotoCraft in crate module docs (#5455) 2026-10-16 16:58:50 -06:00
wwew 98fc6ab072 fix(cms): write the product name as PhotoCraft in built-in profile descriptions (#5614) 2026-10-14 15:06:35 -07:00
Elvis33LEandBrandon Thomas aa44eafe2c ACEScg: File Handling option opens colour-less EXR renders in the ACES working space (#4908)
* ACEScg: File Handling option opens colour-less EXR renders in the ACES working space

Our render pipelines write ACEScg (AP1 primaries, ACES white ~D60,
linear) and — like most — state no colour space in the EXR header, so
io's linear-sRGB default misrepresented them (saturated hues shifted).
- cms: AcesCg joins the built-in profiles, generated from the published
  AP1 primaries and ACES white point with a linear TRC (id "aces-cg",
  aliases acescg).
- engine: File Handling gets "Open color-less EXR as ACEScg". When on,
  an EXR that io tagged with the linear-sRGB default opens as ACEScg
  instead; embedded or EXIF-declared profiles stay untouched.
Translated in every catalog. ACES view transforms (OCIO-based display)
remain future work; this covers the document's source colours.

* scorecard: 26 of 158 (main's #3472 preference added one too)

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:46:27 -04:00
Michael HigginsandClaude Opus 5.5 b059a6d8b3 ICC parser: checked arithmetic on offsets and counts from the file (#4976) (#5227)
Offsets and counts stored in a profile reach u32::MAX, which is
usize::MAX on 32-bit targets such as wasm32. Several places added to or
multiplied them before any bounds check, so a malformed profile could
panic (overflow checks on) or read from a wrapped offset (checks off).

- curv: reject an over-long count before computing the element length.
- mAB/mBA: the matrix and CLUT read through one bounds-checked slice at
  the stored offset instead of adding to the offset.
- mluc: the record offset is computed with checked arithmetic.
- XYZ: one bounds-checked slice instead of off + 4 and off + 8.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:45:46 -04:00
4062d40070 Color: Dot Gain 20%-compatible default working gray, as in Photoshop (#4610)
* feat(color): Dot Gain 20%-compatible default working gray, as in Photoshop

Photoshop's default working gray is Dot Gain 20%, and its RGB -> Grayscale
measured in Photoshop 27.11 (default Color Settings) is D50 luminance
encoded with gamma 1.732: sRGB 64 / 128 / 192 grey and pure red become
46 / 105 / 177 / 107. PhotoCraft defaulted to sGray, which kept the sRGB
value (64 / 128 / 192 / 130), up to 24 levels off.

Adds the built-in "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"
gray profile (id dot-gain-20-compat; our own fitted curve, not Adobe's
profile, the same gamma PhotoCraft already uses for gray adjustments) and
makes it the default working gray and the fallback for untagged gray
documents. sGray and Gray Gamma 2.2 stay selectable.

This changes a default: untagged gray documents now convert and display
as Photoshop shows them (midtones lighter than under sGray); documents with
an embedded profile are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Status bar test: the default working gray is now Dot Gain 20%-compatible

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* .atn test: #202020 in the Dot Gain 20%-compatible working gray

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>
2026-10-13 02:45:09 -04:00
HEOJUNFOandClaude Opus 5.5 bb17dd555c Color: convert input-only embedded profiles to the working space on open (#3876) (#3900)
An iPhone photo embeds Apple's Wide Color Sharing Profile, an input-class
profile with only AToB tables. Preserve kept it as the document's space, so
every composite into the document failed with "RGB profile without
matrix/TRC or BToA tags". Such a profile can't be a destination, so open
converts from it to the working space whatever the policy (Off still
discards it) and the report says inputOnly.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:57:11 -04:00
Valentin KleinandClaude Opus 5.5 63fee8c07c wasm: gate native-only constants so wasm32 builds without dead-code warnings (#2808) (#2813)
PAR_MIN_PIXELS / PAR_CHUNK_PIXELS (photocraft-cms) and OPENABLE
(photocraft-engine) are only used by native-only code (the rayon paths
and list_images). Give them the same cfg so wasm32 check and clippy
-D warnings are clean. No behaviour change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:48:19 -04:00
bfbc9903d5 cms: accept ICC DeviceLinks whose output isn't a 3-channel PCS (#2348)
A DeviceLink's header PCS field holds its output colour space
(ICC.1:2010 §7.2.7), so a CMYK→CMYK link has a 4→4 AToB0. The LUT
channel check hard-coded 3 outputs and rejected such files as invalid,
so users were told a well-formed profile was corrupt and never reached
the "DeviceLink profiles cannot be used" error.

Check DeviceLink LUTs against the header's output space instead, and
only let a Lab DeviceLink through link_stages when its tables end in
3 channels, so a Lab→CMYK link gets the unsupported error rather than
building a mismatched transform.

Fixes #2337


Claude-Session: https://claude.ai/code/session_01X5k8jSoLtGswanmHBipHER

Co-authored-by: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:16:32 -04:00
F4LCONandBrandon Thomas ae03e7cd66 OpenEXR/HDR export: linearize grayscale documents too (#2386) (#2448)
* OpenEXR/HDR export: linearize grayscale documents too

Fixes #2386. The EXR/HDR branch of export_flat converted RGB images to
linear sRGB but left gray ones as stored, so an sGray #808080 was written
as 0.502 instead of 0.216. Gray images are now converted from their
profile (sGray when untagged) to linear gray. An untagged grayscale
OpenEXR opens tagged linear gray (an EXR Y channel is linear luminance),
and the open policy keeps that profile without a mismatch prompt, as it
already does for linear sRGB.

* contributors: add itsF4LCON

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 13:13:40 -04:00
53985e5f34 Color Lookup: keep the .cube input domain and reject the ones we cannot apply (#1130)
* Color Lookup: keep the .cube input domain and reject the ones we cannot apply

DOMAIN_MIN/DOMAIN_MAX (and LUT_3D_INPUT_RANGE) were parsed and thrown
away, so a log-domain or extended-range look was applied to plain 0..1
inputs: silently wrong colours, exactly what the in-code comment
admitted. The domain now travels with the parsed LutFile (validated:
every component finite and min < max), and colorLookup refuses a
non-default domain with the reason instead of shifting the look -
the same unsupported-over-wrong-output call the codecs make. Honoring
a custom domain end to end means carrying it through ColorLookup
(doc, compose, gpu, PSD round trip) and stays open as the follow-up.

* tests: the colorLookup domain rejection needs a pixel layer active

The outer session of the test still had the adjustment layer active, so
the command was disabled before reaching the domain rejection - the
assertion only passed when the active layer happened to differ. Make it
deterministic with a fresh pixel layer.

* Color Lookup: bake a 1D .cube's input domain into the resample

The 1D branch of parse_cube still ignored DOMAIN_MIN/MAX and
LUT_1D_INPUT_RANGE. Since the 1D table is resampled onto a 0..1 cube anyway,
the domain is now applied there (saturating outside), and a degenerate
domain is rejected like the 3D one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cargo fmt after rebasing onto main

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>
2026-10-08 19:41:07 -04:00
4860cef703 Absolute colorimetric refuses a zero or non-finite media white (#972) (#1125)
connection() scaled the PCS by src.media_white() / dst.media_white() with
no check on the divisor, so a v4 profile whose wtpt tag holds zeros (parsed
and stored verbatim) gave a NaN scale, and every colour through a direct
absolute transform or a paper-simulating proof came out NaN. The transform
is now refused with CmsError::Invalid when either white has a zero,
negative or non-finite component; the other intents never divide by it and
are unchanged.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:41:32 -04:00
Alex GorevskiandCopilot e70deecbe5 fix(cms): check LUT declared sizes before arithmetic (#725)
Fixes #689

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 15:41:09 -04:00
ljkxandClaude Opus 5.5 d2576b27eb Profile mismatch: compare profiles by colour, not bytes (#394)
Opening any file Photoshop tagged with sRGB IEC61966-2.1 asked
"Embedded Profile Mismatch: sRGB IEC61966-2.1 vs sRGB IEC61966-2.1":
open_policy compared a hash of the ICC bytes, and Photoshop's v2 profile
(1024-entry tables) and the built-in v4 sRGB (parametric curves) are the
same colours in different bytes. 32-bit documents in Photoshop's linear
sRGB missed the stay-linear exception for the same reason.

Add Profile::same_colors: same colour space and every sample of a device
grid within dE76 0.5 through both (re-encoding costs 0.025; moving the
red primary 2% costs 1.0). Colours are compared rather than round-tripped
because CMYK -> PCS -> CMYK is not an identity even for one profile.

Of the 75 profile-tagged PSDs in the test corpora, all 75 prompted on
open; now 6 do (Adobe RGB, Gray Gamma 2.2, SWOP), each a real mismatch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:43:18 +07:00
Brandon ThomasandClaude Opus 5.5 48d1a4f9e5 Never crash: cms is panic-free (Rule 9) (#36)
- Real crash: a gray ICC profile whose A2B0 tag is stored as "mBA "
  parsed fine, but re-encoding its RGB view (gray_as_rgb, used for gray
  documents' composites) hit panic!("unsupported lutAtoB stage layout").
  The ICC writer now leaves out a LUT its tag type can't store, and
  gray_as_rgb drops the gray LUTs (one input channel) so the RGB view is
  the matrix/TRC model it builds. Regression test
  gray_profile_with_reversed_lut_type_reencodes.
- math::rgb_to_xyz_matrix returns None for dependent primaries.
- Builtin::profile indexes its cache by discriminant (tested); the shipped
  CMYK profile falls back to synth::coated_cmyk() instead of expect.
- Two constant-only expects (Bradford inverse, built-in primaries) use the
  documented narrow allow.
- cms denies unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:31 -04:00
Brandon ThomasandClaude Opus 5.5 1c09936154 Clippy 1.99: chunks_exact with a constant size -> as_chunks; drop a no-op Option filter
Rust 1.99's clippy adds chunks_exact_to_as_chunks and some_filter, which
broke the Clippy step on every CI runner (it uses the latest stable). All
sites are mechanical: as_chunks drops the remainder exactly like
chunks_exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:14 -04:00
Brandon ThomasandClaude Opus 5.5 f7eb6ea9e7 Format: adopt the craft-family rustfmt.toml and format the workspace
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>
2026-10-04 11:31:17 -04:00
Brandon Thomas 536a7346fb more algorithms, progress 2026-10-01 08:25:06 -04:00
Brandon Thomas 14c5c1bc51 readme, misc 2026-09-30 11:37:50 -04:00
Brandon Thomas b00b24739a more work 2026-09-30 11:36:20 -04:00