Commit Graph
70 Commits
Author SHA1 Message Date
FredApps d8d4c05620 Merge pull request #9747 from vangel76/perf/9743-cms-exact8-first-stage
perf(cms): tabulate the first-stage curves for 8-bit exact pipelines
2026-11-07 15:39:59 +01:00
FredApps c431a9490b Merge pull request #9746 from vangel76/perf/9742-cms-matrix-trc-blocks
perf(cms): block the 8/16-bit matrix/TRC conversion over 64 pixels
2026-11-07 15:39:51 +01:00
David MannandClaude Opus 5.5 16000b01ef Address CodeRabbit review: batch 17 findings
Engine: droplet dependency capture skips disabled steps; a nested Batch
that was cancelled or could not write its error log fails the caller;
runDroplet forwards overrideOpen; actions.rename follows named Batch
steps; Batch steps inside a script count against MAX_SCRIPT_STEPS;
output claims compare destination identity; Save and Close never
replaces a read-only source. ATN: Md must be a BlnM enumeration,
adjustment-layer names must round-trip, Calculations exports a missing
blending as Multiply, Apply Image refuses unnamed-layer transparency.
Filters: Shape Blur records its resolved geometry, reserves its kernel
memory first, and reports a preparation failure as an error, not a
cancel; Crop keeps an artboard preset when the size is unchanged and
fits the board on a turned crop.

Codecs/cms/algo: zero-axis FITS image HDUs are empty; a GIF walk that
breaks off before the trailer fails closed; two-value .3dl shaper rows
are applied; fragmented custom kernels are refused and their work polls
cancellation.

UI: Conditional branches play through panel playback; paused actions
refuse to resume after being replaced; Playback Options OK validates
its fields; transform double-click ignores a hidden pivot; path boxes
snap to a stationary selection; modal blocking uses the effective tool;
a picker-consumed release clears snap lines and ends a Move drag; the
Crop preset control shows Selection and checks only it; Crop box undo
restores frame_from_selection; Bosnian fill labels.

Tests: timing asserts moved to an ignored test, plus the review's
coverage nitpicks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:37:38 +01:00
yoriswind 6c831b7569 fix: retain LUT shapers and resolve edited Shape Blur geometry 2026-11-04 12:34:44 -08:00
yoriswind 0681361e6e Preserve Batch sources and cancellable action filters
Resolve recorded layer targets per Batch input and refuse destructive EXR Save and Close output. Prepare custom Shape Blur kernels in cancellable jobs, retaining Smart Filter progress and geometry. Preserve supported ATN descriptor metadata, defaults, channel sources and three-value LUT shapers; cover rebuilt naming rules, non-identity Custom kernels and float lookup dither.
2026-11-02 09:37:49 -08:00
Hector VillarrealandClaude Opus 5.5 07dda13742 Color Lookup: the .3dl header tests use a plain header word
Main now reads `Mesh 4 12` as a declared 17³ cube (#9073, #9078), so the
preamble tests put a non-declaring header line in front of their 4-node
tables. They still check that a header is skipped and the rows after it are
read strictly: three_dl_mesh_preamble_is_accepted,
three_dl_malformed_first_token_is_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:00:34 -06:00
Hector VillarrealandClaude Opus 5.5 c2c685a4bb Color Lookup: a .3dl row starting like a number is never a header
CodeRabbit (discussion_r4368798643): a shaper such as `0oops 114 455 1023`
failed to parse as a number, so it was skipped as a header and the table
loaded without its shaper. A header is now `3DMESH` or a line whose first
token starts with a letter and isn't a number; anything else is read
strictly and a bad token is refused.

Test: three_dl_malformed_first_token_is_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:59:19 -06:00
Hector VillarrealandClaude Opus 5.5 17326e5e21 Color Lookup: a .3dl shaper too fine to keep is refused
CodeRabbit finding on #9170 ("Refuse non-even shapers when the aligned bake
grid exceeds MAX_SIZE"), as @bommerts asked too ("clear refusal if the
requested precision cannot be retained"): past MAX_SIZE the bake fell back
to a coarser grid, which can land only on a bent shaper's unbent knots and
render the table alone. Such a shaper is refused. New test:
three_dl_shaper_too_fine_to_keep_is_refused (a 257-point shaper bent at odd
knots loaded unshaped without the fix; a 17-point shaper on a 17³ table
still loads).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:58:56 -06:00
Hector VillarrealandClaude Opus 5.5 909ded33f8 Color Lookup: a .3dl shaper starting with NaN or inf isn't a header
CodeRabbit finding on #9170 ("Reject shaper rows that begin with NaN or
inf"): lines starting with a letter were skipped as headers, so a shaper
beginning with NaN or inf was dropped before the non-finite check and the
table loaded alone. A header is now a line whose first word isn't a number
(3DMESH, Mesh 4 12); NaN and inf parse as numbers, so such a shaper is
read and refused. New test: three_dl_shaper_starting_non_finite_is_refused
(the files loaded without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:58:56 -06:00
Hector VillarrealandClaude Opus 5.5 068791475e Color Lookup: a non-finite .3dl shaper is refused
CodeRabbit finding on #9170 ("Reject non-finite shapers instead of silently
dropping them"), which @bommerts' refusal control agrees with: a NaN or inf
shaper was dropped and the table rendered alone, breaking apply-or-refuse.
apply_shaper refuses it. New test: three_dl_non_finite_shaper_is_refused
(NaN, inf and -inf shapers loaded without the fix); the PR's own NaN case
now expects refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:58:29 -06:00
Hector VillarrealandClaude Opus 5.5 e370386753 Color Lookup: the 3DMESH preamble of a .3dl still reads
Review by @bommerts on #9170: strict numeric parsing (999a2c7be) refused the
standard 3DMESH / Mesh 4 12 preamble, because 3DMESH starts with a digit, so
every headered .3dl failed with "bad line 3DMESH". The 3DMESH line is
skipped as a header; rows after it stay strict. New test:
three_dl_mesh_preamble_is_accepted (bad line 3DMESH without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:58:29 -06:00
Hector VillarrealandClaude Opus 5.5 cba7ddc4f2 Color Lookup: a malformed or second .3dl shaper row is refused
Review by @bommerts on #9170: a shaper row with a token that isn't a number
(0 341 junk 682 1023) had the token dropped, which can turn a bent shaper
into an even ramp and lose it, and a second shaper row was ignored. Every
token of a numeric line must parse, and only one shaper row ahead of the
table is read. New test: three_dl_malformed_shaper_rows_are_refused (both
files loaded without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:58:10 -06:00
Hector VillarrealandClaude Opus 5.5 42ddf561cc Color Lookup: a .3dl shaper keeps its knots over any table size
Review by @bommerts on #9170: baking at max(table nodes, shaper points) only
kept the shaper's breakpoints when the two grids lined up. Shaper
0 0 512 1023 over a 5³ table read 0.0834 at 1/3, where the shaper is 0
(about 21 8-bit levels off). The bake grid now holds both the table's nodes
and the shaper's knots (lcm of their intervals, up to MAX_SIZE), so an
identity table carries the curve exactly. New test:
three_dl_shaper_knots_survive_a_nonaligned_table (0.0834 without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:57:09 -06:00
Hector VillarrealandClaude Opus 5.5 216b53301f Color Lookup: an extreme .3dl shaper can't bake NaN into the table
CodeRabbit finding on #9170 ("Check the baked table for non-finite values"):
a finite but extreme shaper such as 0 1e308 -1e308 0 overflowed to infinity
when interpolated over the resampled grid, and infinity times 0 put NaN in the
table. The shaper is scaled to 0..1 before interpolating, and the baked table
is checked like any parsed one. New test:
three_dl_extreme_shaper_bakes_only_finite_values (NaN without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:57:09 -06:00
Hector VillarrealandClaude Opus 5.5 759edaa3c7 Color Lookup: a shaper finer than the .3dl table keeps its curve
CodeRabbit finding on #9170 ("Refuse a non-even shaper that has more points
than the 3D table"): the shaper was baked by reading it only at the table's
own nodes, so a 2x2x2 table kept just its two ends and lost the midtones.
Instead of refusing the file (Photoshop loads it), the table is now baked
with as many nodes as the shaper has points, up to MAX_SIZE. New test:
three_dl_shaper_finer_than_the_table_keeps_its_curve (grey 128 stays 128
without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:57:09 -06:00
Hector VillarrealandClaude Opus 5.5 594e0e54ec Color Lookup: a bent float .3dl shaper is applied
CodeRabbit finding on #9170 ("Fix the even-ramp tolerance for shapers with
scale == 1.0"): the even-ramp check allowed one integer code (1.0) even for a
float shaper in 0..1, so every float shaper passed as even and was dropped.
A float shaper is now held to one 10-bit step. New test:
three_dl_float_shaper_is_applied (grey 128 stays 128 without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:57:09 -06:00
Hector VillarrealandClaude Opus 5.5 0baeea93b0 Color Lookup: apply a .3dl's shaper before the table (#8982)
A .3dl's first row is a shaper: Photoshop applies it as a 1-D curve to
each input channel before the 3-D table. PhotoCraft skipped the row and
treated every shaper as the even ramp, so a file with a bent shaper
rendered as if it had none (grey 128 stayed 128 where Photoshop gives 71
for the shaper 0 114 455 1023, and 64 for a 17-point squared one).
parse_3dl now keeps the shaper and, when it isn't the even ramp, bakes it
into the table: each node becomes the table's trilinear value at the
shaped input. Even ramps (the usual case) are left untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:57:09 -06:00
FredApps e8692b87a4 Merge approved PhotoCraft PR #9418: Color Lookup: honour the .3dl Mesh header's cube edge and output depth (#9078, #9073) 2026-10-29 03:29:52 +01:00
vangel76andClaude Sonnet 5.5 d61c52e8ae test(cms): cover the 8-bit first-stage table with curv table TRCs and the 16-bit path
The exact-core test only used sRGB, whose curves are parametric. Run it over
a profile with distinct per-channel curv table TRCs as well, and check
convert_u16 against the untabulated evaluation for both.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 00:47:24 +01:00
vangel76andClaude Sonnet 5.5 c64ca3365b perf(cms): tabulate the first-stage curves for 8-bit exact pipelines
run_u8 on a core without a lookup table (for example sRGB to Lab) evaluated the
first stage's curves with powf on every channel of every pixel. An 8-bit channel
has 256 values, so the curves are tabulated once per transform with the same
Curve::eval and the remaining stages run from there. Output is bit-identical.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:58:03 +01:00
vangel76andClaude Sonnet 5.5 914de3cd21 perf(cms): block the matrix/TRC maths of 8/16-bit conversion over 64 pixels
run_u8 and run_u16 pushed every pixel through Vec/Option indirection
(apply_matrix, Post::eval, eval_fast). For a three-channel matrix/TRC transform
the matrix and the output-table index maths now run over blocks of 64 pixels on
fixed-size arrays. Same float operations in the same order per pixel, so the
output is bit-identical.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:56:02 +01:00
David Mann 7e4ceba5df Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/cms/src/lutfile.rs
2026-10-26 14:26:50 +01:00
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
highperfocused da4b838e54 Merge upstream/main into fix/3dl-mesh-header
Keep the Mesh declaration's cube edge and output range (#9078) alongside
main's header-aware inference (#9121): a declared range wins for in-range
node codes, and anything else falls back to the depth inference, where a
header (the 3DMESH magic included) allows a 10-bit reading.

main's three_dl_without_a_header test used 'Mesh 1 10' as an arbitrary
header line on a 2-cube; that is now a 3-cube declaration, so it uses the
bare 3DMESH magic as its header.
2026-10-25 15:44:14 +01: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
highperfocused 8929175c08 fix(cms): reject malformed Mesh declarations and honour the declared range (#9078)
A `Mesh` line is now a declaration only when it carries exactly two integer
fields after the keyword; tokens like `Mesh invalid 1 12` no longer recover a
declaration from numbers in the wrong positions and fall back to headerless
inference. Under a valid declaration the node codes are integers in the declared
output range, so the declared range is used for every in-range value — including
a small max, where code 1 is 1/declared of full output, not full output itself
(#9073). Only a node beyond the declared range still falls back to max-value
inference.
2026-10-25 13:44:30 +01:00
highperfocused 788c8956c0 Color Lookup: honour the .3dl Mesh header's cube edge and output depth (#9078, #9073)
The Lustre `3DMESH` / `Mesh <inBits> <outBits>` header declares the cube edge
(2^inBits + 1) and the output code range (up to 2^outBits - 1); the parser
skipped every text line, so it inferred both from the data. That rejected a
3-squared cube whose three-value shaper row counted as a 28th node, and
divided 10-bit codes under a 12-bit declaration by 1023 instead of 4095.
Photoshop 27.11 accepts and renders both (measured by the reporters).
2026-10-25 12:17:57 +01:00
David Mann feb20ebc60 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/gpu/src/plan.rs
#	crates/io/src/adjust_map.rs
2026-10-24 02:59:10 +02: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
David Mann c16f899467 Merge origin/main into fix/6544-lut-format-roundtrip
# Conflicts:
#	crates/format/tests/it/roundtrip.rs
2026-10-23 00:06:48 +02:00
aiongg ffa400c754 Keep tests correct and docs current with one test binary per crate
- gpu: the test modules share one GPU_LOCK, and timing and vector_mask
  take it too, so one process drives one wgpu device at a time.
- ui-egui: the wgpu tests in pattern_preview_canvas, adjust_preview_gpu,
  marquee_autoscroll and layout_perf take gpu_lock().
- affinity compound_limits: the child process selects its test by the
  module path (compound_limits::<name>); with --exact, the bare name
  matches no test and the child passes without running the check.
- xtask scorecard reads crates/io/tests/it/corpus.rs.
- Docs and comments name the new test paths and the --test it filters.
2026-10-22 16:55:34 +00:00
aiongg a6428d3dfb Build each crate's integration tests as one binary
Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs; helper modules under tests/common move with them.

These keep their own binary (own process):
- engine panic_hunt: a process-wide panic hook counts every panic.
- engine text_gamma: changes the process-wide text gamma.
- engine smart_filter_stages, mask_cache_purge: assert on global caches.
- codecs pnm_peak_memory: reads the process's peak RSS.
- photocraft mac_menu_appkit: harness = false.
2026-10-22 16:55:34 +00: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
David Mann 14b928e8a8 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 10:56:06 +02: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
David MannandClaude Opus 5.5 47063c29c5 Merge main into fix/6544-lut-format-roundtrip
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:39:12 +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
David MannandClaude Opus 5.5 750a91c8b2 Color Lookup: PSD save keeps a .3dl/.look LUT file as loaded (#6544)
A Color Lookup layer embeds its LUT file in the PSD (LUT3DFileData,
LUTFormat, LUT3DFileName). Photoshop stores the file as loaded and tags
its format; it never converts it. PhotoCraft kept only the parsed table,
so every save re-encoded it as .cube with LUTFormatCUBE while keeping the
original name: a layer loaded from "Film.3dl" was saved as .cube data
under a .3dl name.

The layer now keeps the file it was loaded from (its format and bytes,
`ColorLookup::source`), set when a PSD is read and when the colorLookup
command loads `data`/`file`, and cleared when a built-in look or "none"
replaces the table. A PSD save writes those bytes and format back
unchanged, but only while they still parse to the layer's table, so the
stored file can never go stale. Otherwise (a table made in PhotoCraft,
or one that no longer matches) it falls back to .cube as before and
swaps a .3dl/.look extension in the name for .cube.

The new field is `#[serde(default)]` and skipped when empty, so older
.pcraft files load unchanged. lutfile gains Format::detect/parse_as so
the recorded format is exactly the one the parser used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 20:59:24 +02:00