crates/ui-egui/src/lib.rs: replay_save doc comment names both Export As and JPEG; replay_save uses the PR's FormatChoices::from_step, after main's export_dialog::replay branch.
docs/file-format-parity.md: header keeps main's text plus the JPEG Options item (last updated 2026-10-19); TIFF row is main's, JPEG row is the PR's; change-log rows once each, newest first.
docs/gaps.md: header keeps main's text plus the G10 item (last updated 2026-10-19).
apps/photocraft/src/services.rs: SAVE_FILTERS keeps the DDS entry from main and the PR's Cineon entry.
crates/codecs/README.md: Cineon table row from the PR (read and write), then main's DICOM and Amiga IFF rows.
crates/codecs/src/format.rs: caps keep the PR's Cineon (read and write) and main's Dicom and Iff rows.
crates/codecs/src/lib.rs: encode keeps the PR's Cineon arm and main's Dicom and Iff arms.
docs/file-format-parity.md: header kept from main with the Cineon write item added; Cineon / DPX row from the PR, DICOM row from main; the PR's change-log line added once at the top.
docs/gaps.md: header kept from main with the Cineon write item added; Missing list without 'Cineon write'; the PR's Cineon progress line replaces main's read-only one; the PR's change-log line added once at the top.
docs/file-format-parity.md: header line kept from main with the animated WebP item added in front; BMP row from main; WebP row from main plus the PR's animation sentence; the PR's change-log line added once, above the first 2026-10-14 row.
apps/photocraft-web/src/web.rs: OPEN_EXTS kept main's list (with the DICOM extensions) and added iff, lbm, ilbm.
apps/photocraft/src/services.rs: OPEN_EXTS kept main's list (with the DICOM extensions) and added iff, lbm, ilbm.
crates/codecs/README.md: kept both the DICOM and the Amiga IFF table row and section.
crates/codecs/src/format.rs: kept both the Dicom and the Iff variants in every match, detect runs the DICOM check then the IFF check, Format::ALL length re-counted (22).
crates/codecs/src/lib.rs: one use line importing dicom and iff; both decode and encode arms kept.
docs/gaps.md: header line kept from main, with the PR's 'Cineon and Amiga IFF read' wording.
apps/photocraft-web/src/web.rs: OPEN_EXTS kept main's list and added dcm, dicom, dc3 after cin.
apps/photocraft/src/services.rs: OPEN_EXTS kept main's list and added dcm, dicom, dc3 after cin.
Main moved on (integration batch #8095 and others). Two conflicts:
- docs/gaps.md revision history: both rows kept, newest first.
- docs/scorecard.md (generated): main's version taken. It needs
`cargo xtask scorecard` again: with FILE-215-19 done, File
compatibility reads 8 done / 5 missing rather than main's 7 / 6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main added DDS saving (#5993): DDS Options, and FormatChoices carrying the
Targa and DDS answers through every save path (this branch's SaveChoices
carried Targa and JPEG). Resolved by keeping main's name and making it
the one struct for all three answers.
- lib.rs: FormatChoices { tga_bits, dds, jpeg } with from_params (strict),
from_step (lenient JPEG read of a recorded step), apply and record;
main's separate block is folded in. Both prompts (dds_options and
jpeg_options) are kept everywhere: save_to asks Targa, then DDS, then
JPEG Options; save_options_open covers all four. save_in_place takes
main's (path, choices) signature and records the plain-Save step with
plain_save_step for JPEG plus the Targa/DDS answers (save_in_place_with
folded into it). journal_as_plain_save relabels a parked Targa, DDS or
JPEG prompt and keeps tgaBits/dds*/quality/jpegFormat when dropping the
path. replay_save reads choices with from_step.
- SAVE_OPTIONS_OPEN names all four prompts; DDS Options uses it too.
- targa_options_ui: main's plain-Save step (no path) through this
branch's write_answered_save. JPEG Options gets main's Enter-on-a-
focused-Cancel fix.
- view_cmds Save a Copy asks Targa, DDS, then JPEG Options; write_copy
takes FormatChoices.
- control.rs: both app.save tests (JPEG and DDS) kept.
- engine file_cmds: SaveOpts keeps jpeg_format and dds; param docs list
both jpegFormat and ddsCompression/ddsMipmaps. CLI: both --jpeg-format
and --dds-compression/--dds-mipmaps. codecs re-exports JpegFormat and
DdsCompression/DdsOptions.
- docs/book: both entries kept (control-protocol app.save lists both).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DDS textures can now be saved, not just opened: uncompressed (BGRA, BGRX, L8, A8L8; lossless) or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, with an optional box-filtered mip chain, in the legacy header. Save As and Save a Copy ask with DDS Options; scripts pass `ddsCompression`/`ddsMipmaps` (file commands, control `app.save`, RPC `doc.save`, MCP `doc_save`/`doc_export`), and the CLI takes `--dds-compression`/`--dds-mipmaps`. Targa and DDS answers travel together as `FormatChoices` through the Save As flow.
Review follow-ups:
- File › Save behind DDS or Targa Options records a plain Save with its answer, and replaying `file.save` applies it (#3531).
- The block encoder finds the principal axis from every channel, so a channel that varies on its own can't hide the others' variation.
- DDS joins the shared round-trip suite; the header is built before the output buffer is allocated.
- Enter on a focused Cancel in DDS, Targa or TIFF Options cancels instead of saving.
Save As and Save a Copy to a JPEG now ask for JPEG Options before writing,
as Photoshop does. Re-creates #4330 on current main and folds in the
unique parts of #5580 (#5563).
- codecs: `JpegFormat` (Baseline, Optimized, Progressive) on
`EncodeOptions`. Progressive writes SOF2. Baseline Optimized fits the
Huffman tables only at 4:4:4 (Gray, CMYK) or with Progressive: fitted
tables make the baseline scans non-interleaved, which zune-jpeg 0.5.15
decodes wrongly with 4:2:0 chroma (zune-image#419), as Save for Web
already works around.
- engine: a `jpegFormat` save param (file.saveACopy, Batch, Image
Processor, Layers to Files and every SaveOpts user; anything but the
three names is BadParams before writing) and a shared 0–12 -> 1–100
`jpeg_quality()` that maps NaN/inf to 100.
- ui-egui: `jpeg_options_ui` (Quality 0–12 with Low/Medium/High/Maximum
presets, Format Options), parked like Targa Options. It starts at the
opened JPEG's own quality (`file_encoding`, #4032), else the Export
preference; left there, OK writes that quality exactly and the step
records no `quality`. `SaveChoices` (Targa bits + JPEG) replaces the
bare `tga_bits` through file.save / file.saveAs / app.save, replay and
Save a Copy; automation never prompts. `write_answered_save` is shared
with Targa Options. `park_at` + `Start` let any save seed the prompt.
A File › Save that confirms the document's own flat JPEG is still
journalled as a plain `file.save` (#3531).
- CLI: `--jpeg-format`.
- i18n: five new strings in all 20 catalogs (#4330's duplicate de/es/zh
`Low`/`High` rows dropped: main has them since #4008).
- Docs: book raster-formats JPEG bullet, control protocol, file-format
parity, gaps G10, target-app parity; scorecard FILE-215-19 done.
Co-authored-by: SckraT <70698629+SckraT@users.noreply.github.com>
Co-authored-by: PierrunoYT <95778421+PierrunoYT@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PNG: keep a stereo file's sTER pairing on save (#6063)
Read the sTER chunk (PNG Extensions 1.4.0 §3.6) when a PNG opens, store it
on the document's FileEncoding, and write it back when saving, so a stereo
PNG still says it is a stereo pair. Covers the ordinary and the palette
PNG encoders, including interlaced output.
* io tests: pass the new sTER argument to encode_png_indexed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
Write Kodak Cineon 4.5: the 1024-byte generic header (V4.5, one or three
channels designated gray or R/G/B, 10 bits, code values 0-1023 over
densities 0-2.048) and a 1024-byte film header left undefined, then the
samples three to a big-endian 32-bit word, left justified (packing 5),
lines on a word boundary. Samples are written as round(v * 1023 / 65535),
so a frame opened from Cineon saves back with its code values; other
images take the encode plan (alpha dropped, CMYK and Lab to RGB) and are
quantized to 10 bits.
Cineon leaves ASYMMETRIC_EXCEPTIONS, Save As offers .cin, and the codecs
README, file-format parity and G10 are updated.
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab
Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.
- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
importer), with its own Lab <-> sRGB conversion (same formulas as
photocraft-color; codecs stays standalone) for formats without Lab,
which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
tiff crate as RGB and labelled Lab; a* and b* are converted from the
file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
layer is written to TIFF in Lab (other formats still use sRGB).
Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.
Fixes#4936
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))
Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.
* Codecs: open Cineon (.cin) film frames read-only
Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.
* PSD: write tiff blocks without copies, inflate merged ZIP once
- ImageSourceData::to_bytes writes the global mask and global blocks
straight from the model with 4-byte alignment instead of cloning each
block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
buffer (taken and handed back, error or not) instead of building their
own output and copying it up per nesting level. Bodies that fail or are
not transcoded are truncated before being copied as stored or dropped.
Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
when asked: a ZIP stream is inflated once (the decode's stream check is
the parse's); the parse-only check runs only when the decode fails, to
keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)
Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.
A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.
Refs #1366
* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers
Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(history): count snapshot-only tiles against the history budget
History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)
Two more causes of ghosts when removing strings and the small knobs
selected with them:
* Auto sampling summarises the selection's surroundings by a few
clusters. Around a long selection those come from the strings'
surroundings, so a small part elsewhere (a tuning knob in a differently
coloured corner) lost its own surroundings and was filled from far away
in another colour. Around a thin selection the Auto area now always
reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
the anti-aliased fringe or halo that a selection drawn close to the
object leaves outside it.
Refs #1366
* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)
Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.
Refs #1366
* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open
Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.
Two changes in crates/algo:
- A second GrabCut pass whose background model only knows the backdrop. A
colour model fitted to the image frame (minus a margin around the
salient blob, in case the subject runs out of the picture) marks every
pixel it explains as probable background; the blob and whatever the
backdrop cannot account for start as probable foreground. Only
components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
mask) always fill, larger holes up to 5 % fill only when their pixels
fit the foreground model better than the background's.
Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.
* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)
Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.
edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.
* .kys import: tool keys and sets saved on Windows (K3)
Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).
A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.
* cargo fmt the extension lists
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Finn Newick <finnnk@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))
Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.
* Codecs: open Cineon (.cin) film frames read-only
Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.
* cargo fmt the extension lists
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>
Saving an Indexed Color document as BMP went through the ordinary RGB
path and wrote a 24-bit file, so the colour table was lost on reopen
(the PNG exporter already had a palette branch).
export_mode_specific now sends Indexed Color to BMP as well as PNG,
through a new encode_bmp_indexed that writes an 8-bit palette BMP with
the document's table. BMP has no transparency, so a transparent index
is written as its palette colour and the export says so.
bmp_palette returned the decoder's 256-entry padding, so a 3-colour BMP
reopened with 253 extra black entries. It now trims the table to the
colours the header declares.
An animated WebP used to bring only its first frame into a video layer. Now
New Video Layer from File, Replace Footage, Reload Frame and Video Frames to
Layers take every frame, as they do for an animated GIF (#572):
- photocraft-codecs: `decode_webp_frames` returns each frame composited onto
the canvas by image-webp (offsets, blending, disposal), with its duration in
milliseconds and the file's ICC profile. A still WebP is one frame, read as
`decode` reads it. A damaged later frame keeps the frames before it and marks
the first `Truncated`; the frame count and total bytes are checked against
`Limits`. `webp_is_animated` tells the two apart. New `decode_webp` fuzz
target.
- photocraft-io: `import_webp_frames`, sharing `import_gif_frames`' loop.
- photocraft-engine: `load_frames` takes an animated WebP's frames; the
timeline rate comes from the most common duration. A still WebP keeps the
existing path and the 30 fps default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
source_size_probe decoded every embedded file other than PSD/TIFF/PNG/JPEG
in full just to learn its size. photocraft_codecs::probe_size reads the
size from the same decoders' headers (WebP chunks, the image crate's
GIF/BMP/TGA/ICO/QOI/HDR headers, flat EXR part headers), with the same
limit checks and the EXIF quarter-turn swap, so it agrees with decode
wherever decode succeeds. Deep EXR, AVIF and anything else still decode.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Amiga IFF/ILBM (EA 85) artwork opens read-only: the FORM chunks BMHD,
CMAP and BODY, both the interleaved ILBM and planar PBM variants, 1-8
bitplanes through the palette (a grey ramp when there is none), masking
0/1/2, and both stored (0) and ByteRun1/PackBits (1) compression. Chunk
lengths and Run lengths are checked, so a malformed file errors rather
than panicking; other bit-plane counts return Unsupported. No writer, so
.iff/.lbm/.ilbm join the open filters and the format opens in Photoshop
parity's Amiga IFF row.
Address @echelon's review:
- Interleaved/planar sample indexing no longer double-counts the channel and
uses checked arithmetic and get(), so RGB/YBR cannot index past the pixel
data; RGB and YBR_FULL require exactly three samples per pixel.
- The transfer syntax is read from (0002,0010) exactly, and the meta walk
stops at group 0002 (the previous code matched on a literal element number
and validated the whole file as explicit VR LE, breaking implicit VR and
big-endian files).
- Undefined-length sequences outside the pixel data are skipped (nested items
and sequences tracked); encapsulated pixel data is refused.
- Tests added for RGB interleaved and planar, a multi-element meta group, and
implicit VR.
A read-only DirectDraw Surface codec on the model of PCX: the top mip
level of a 2D texture, uncompressed (channel masks, RGB or luminance,
8 to 32 bits) or BC1/BC2/BC3 (DXT1/3/5), with the legacy or the DX10
header. Cubemaps, arrays, volumes and other formats are refused with
an Unsupported error naming what was found; every header field is
treated as hostile (checked sizes, limits before allocating, no
indexing on file offsets).
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(codecs): import XPM3 images with transparency (#3692)
* Correct XPM test expectation for green palette name
* feat(codecs): resolve full X11 rgb.txt color names in XPM
* XPM: fix Format::ALL length after DPX, reject '+' in hex colours, fix non-ASCII byte-string test, fmt
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>
Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image
element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16
bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit
packing 0 or 1. 10- and 12-bit code values open as U16
round(c * 65535 / (2^b - 1)) and come back exactly when written at their
depth. No colour transform: the transfer characteristic and colorimetric
specification are reported as import notes; extra image elements are
reported; the orientation is applied on import.
Write big-endian, one unencoded element, at 8, 10, 12 or 16 bits
(EncodeOptions::dpx_bits; `dpxBits` on the save commands, MCP and RPC;
`--dpx-bits` in the CLI), following the document depth by default. Gray
with alpha is written as luma. Creator, project, copyright, creation
time, transfer, colorimetric, frame position, frame rate and time code
travel as DPX:* text metadata and are written back.
Unsupported descriptors, signed or run-length-encoded data and other bit
depths return CodecError::Unsupported; malformed and truncated files
return errors.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Add a pure-Rust DICOM reader: the file meta group's transfer syntax
(implicit VR little-endian, explicit VR little/big-endian), MONOCHROME1/2
and RGB/YBR_FULL, 8/16-bit allocated samples, interleaved or planar,
BitsStored/HighBit scaling and signed pixel representation. Compressed
syntaxes, sequences, multi-frame data and other photometric interpretations
are refused with Unsupported/Malformed, never wrong pixels. DICOM is
read-only; .dcm/.dicom/.dc3 join the open filters (and .pcx is added to the
web filter, where it was missing).
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.
Refs #2399
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(codecs): decode every frame of an animated GIF (#572)
decode_gif_frames returns each frame composited onto the logical screen
(disposal methods applied) with its delay in milliseconds. decode still
keeps the first frame only. A damaged later frame keeps the frames before
it and marks the first with DecodeWarning::Truncated; dimensions, the
total decoded bytes and a frame cap are checked against the limits.
* feat(io): import_gif_frames hands each GIF frame over as a document (#572)
A frame at a time becomes a single-layer document, so a caller can
flatten and drop it instead of holding every frame twice.
* feat(engine): animated GIFs load every frame into video layers (#572)
New Video Layer from File, Replace Footage, Reload Frame and Video Frames
to Layers take all frames of a GIF instead of the first. The timeline rate
comes from the GIF's delays (the most common one, delays under 20 ms shown
at 100 ms as browsers do); an explicit fps still wins.
* docs: animated GIF frames in video layers (#572)
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A file with no embedded ICC profile whose EXIF ColorSpace tag says sRGB
(what cameras write) now opens as sRGB, as in Photoshop: it is tagged
sRGB before the Color Settings policy runs, so it is kept, reported as a
mismatch, or converted like any embedded profile. Before, it was read as
untagged and took the working space, so with a wide working space such
as Display P3 its colours were reinterpreted.
Preferences › File Handling › Ignore EXIF Profile Tag (off by default)
restores the untagged reading, and is now shown in the dialog.
photocraft_codecs::exif_color_space reads the tag from the Exif sub-IFD,
bounds-checked; anything malformed reads as no tag. Settings that do
nothing: 43 → 42 of 150.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Add FITS read/write codec
FITS (the astronomy format) opens and saves as a flat image: the primary
HDU or first IMAGE extension; gray, or RGB when NAXIS3 = 3; BITPIX 8/16/
32/64/-32/-64 with BSCALE/BZERO/BLANK; rows follow ROWORDER (bottom-up by
default). Camera U16 data stays exact; float data is normalised to 0..1.
Header cards round-trip as text metadata (HIERARCH for long names).
Tile-compressed (fpack) files are refused as unsupported.
Wired into File > Open, Save As and the batch commands as .fits/.fit/.fts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test FITS open and save through photocraft-io
Gray 16-bit, RGB 8-bit and RGB 32-bit float documents round-trip exactly,
and header cards travel as the document's text metadata.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* FITS: hostile-header regression test (absurd axes, bad BITPIX, no END, HDU chains, overflowing sizes)
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>
* EXR parts: import parts, channel groups and an AOV precomp as layers
file.exrParts lists what an EXR holds for a chooser - flat parts, prefixed
channel groups (diffuse.R/G/B), Cryptomatte layers as information;
file.openExrParts imports the chosen ones as the layers of one document
(F16 lossless as F32, linear sRGB tagged), with an optional additive
beauty precomp summed from the light-path AOV set (direct/indirect
diffuse+specular, emission; transmission/sss/volume when present) that
fails naming the missing AOVs. The codec gains decode_exr_channels for
exact named channels of one part; io shares the image_surface band writer.
File › Open EXR Parts… picks an EXR and opens a plain command form: a
checkbox per part and channel group (labels are the data's names with
size and channel count), a both/left/right view dropdown, and the
precomp checkbox. Cryptomatte layers ride along as non-rendered data for
the control channel. Form fields gain __labels for verbatim data-driven
labels; the new strings are catalogued in all sixteen complete languages.
* Regenerate parity and scorecard docs
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* EXR: Cryptomatte layers list, decode and hash to the specification
Cryptomatte (specification 1.2) carries per-pixel object and material
IDs with coverage in <layer>NN.red/.green channel pairs, announced by
cryptomatte/<key>/name|hash|conversion|manifest header attributes - the
ID channels Arnold, V-Ray, Redshift, Mantra/Karma and Cycles write next
to the beauty pass.
cryptomatte_layers lists every layer from the headers alone (name, key,
channels, the embedded manifest parsed and capped at 16 MiB, a sidecar
pointer); decode_cryptomatte returns the per-pixel (ID, coverage) pairs
above zero, sorted by descending coverage; cryptomatte_id hashes names
the reference way (MurmurHash3 x86-32, the exponent fixed away from
denormals/inf/NaN, read as f32 bits) - verified against the official
test vectors - and cryptomatte_key/cryptomatte_preview_color expose the
metadata key and preview colour. Files with deep parts are refused, like
the other flat part decoders.
* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)
* codecs: Cryptomatte decode checks the layer size without truncating casts
Count each pixel's pair list in the allocation check too.
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>
* EXR: multi-part files open the colour part; exr_info lists them all
A Maya/Arnold render writes one part per AOV. The decoder took part 0
silently, so a depth-only part failed the whole file with "no R/G/B or
Y channels". Now the part that looks most like a colour image opens
(RGBA > RGB > YA > a single channel, earliest wins), with a
DecodeWarning::MoreParts naming how many parts exist.
exr_info lists every part (name, view, size, channels, deep/tiled) from
the headers alone, and decode_exr_part decodes a chosen part — F16 parts
stay F16. Deep parts stay with the deep decoder: a file that has any is
refused here with a pointer to decode_deep_exr, by design.
* EXR: cap the parts' sizes together, move the chosen part out instead of cloning it
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>
A camera JPEG (e.g. Nikon D3200) records its resolution only in EXIF IFD0, so it
opened at 72 ppi instead of 300, and saving wrote JFIF 72 next to the source's
EXIF 300.
Reading: a JPEG takes the first usable of APP13 Photoshop ResolutionInfo
(0x03ED), XMP tiff:X/YResolution, EXIF IFD0 X/YResolution + ResolutionUnit
(2 inch, also when absent; 3 cm), and only then the JFIF density. That is the
order Photoshop resolves them in; JFIF comes last because cameras write EXIF
only while many tools write a default JFIF 72. Other formats (WebP, HEIF, PNG
without pHYs, ...) fall back to XMP then EXIF when they have no resolution of
their own. All parsing is bounds-checked; malformed data reads as no
resolution.
Writing: JPEG, PNG, WebP, TIFF (XMP), PSD and Save for Web JPEG rewrite the
EXIF/XMP resolution to the image's: RATIONAL values in place, ResolutionUnit 2,
and an entry that can't be rewritten safely is removed from IFD0 without moving
any other data.
* TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8)
Large and multi-page TIFFs now open the way Photoshop opens them, quickly and without a second
full-size buffer, and any directory of the file can be opened on request.
* photocraft-codecs `tiff_ifd`: a bounds-checked directory walker for classic TIFF and BigTIFF
(LONG8 / IFD8, both byte orders). It follows the whole IFD chain and the SubIFD trees (tag
330) with a seen-set, a depth limit and a 10 000-directory cap, and checks every value range
against the file. `tiff_info` lists the pages (size, kind: page / reduced-resolution / mask,
parent, storage); `default_page` is the first full-resolution page of the main chain, so a
leading thumbnail is skipped. `decode_tiff_page`, `tiff_orientation`, `tiff_page_orientation`.
* Banded decode: the `tiff` crate reads the chosen directory through a patched byte view (header
-> that IFD, its next pointer -> 0, WhiteIsZero / palette -> BlackIsZero), so SubIFDs and
cyclic chains decode. The image is allocated once (fallibly, zeroed in parallel) and each
strip or row of tiles, across every plane, is decoded straight into it, in parallel on native
targets, with one capped chunk-sized scratch buffer per worker. New: planar configuration 2
(was an error), palette images (as RGB), 64-bit integers, associated alpha made straight,
non-alpha extra samples dropped. Image data cut off by the end of the file decodes with a
`Truncated` warning. Before the crate sees a file: structural tag values must lie inside it,
the chunk tables must match the geometry (checked in 64 bits; the crate overflowed and
panicked on `(h / rows + 1) * planes`), and a decompression-bomb guard refuses files far too
small for their declared size.
* WhiteIsZero and BigTIFF Orientation keep upstream #746's behaviour (#691, #693) through the
new decode path; regression tests cover both.
* Encode: `EncodeOptions::tiff_bigtiff` writes BigTIFF; without it, BigTIFF is chosen when the
file could pass 4 GiB (and a classic write that overflows 32-bit offsets is redone as BigTIFF).
* photocraft-io: `import_tiff_page(name, bytes, page)`; the TIFF branch of import goes through it.
* Tests: codecs `tiff_pages.rs` on hand-built files (`tests/common/tiffgen.rs`): BigTIFF LE/BE at
8/16/32f, padded tiles and planar strips/tiles, pages and SubIFDs, cycles, the IFD cap,
truncated and past-EOF strips, huge dimensions / counts / tiles, WhiteIsZero, palette, wide
formats, BigTIFF write, and a cut / bit-flip / random-mutation zoo (an ignored long run did
200 000 mutations without a panic); io `tiff_pages.rs`. The `decode_tiff` fuzz target walks
every page. `tiff_bench.rs` (ignored) times a 30 MP decode against the previous path.
* 30 MP RGB (release, 16 threads, same run): Deflate 344 -> 32 ms, LZW 428 -> 43 ms, 16-bit
Deflate 497 -> 46 ms, uncompressed 15 -> 10 ms, 256 px tiles 36 -> 10 ms, planar error -> 12 ms.
* Scorecard FILE-215-8 done; README and roadmap updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* rustfmt the TIFF reader and tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215)
Photoshop keeps a layered TIFF's layers in two private tags: 37724 (ImageSourceData, the same
layer section a PSD has) and 34377 (the image resources). PhotoCraft now reads and writes both,
so a TIFF opens with its groups, masks, layer styles, type, shapes, smart objects and adjustment
layers exactly as the PSD path would, and saves them back.
* photocraft-psd: a `tiff` module with `ImageSourceData` (parse/write, PSD- and PSB-sized),
the 34377 resource helpers, and a field-by-field byte-order transcoder. In Intel-order TIFFs
Photoshop byte-swaps the whole block structure (reversed keys, little-endian integers and
doubles) while pixel samples stay big-endian; the transcoder knows the layer-info structure,
masks, blending ranges and every block photocraft-io maps (descriptors, TySh, lrFX, the
binary adjustments, paths, patterns, filter effects, metadata settings, linked layers,
annotations...), recognizes descriptor-shaped unknown blocks, and drops the rest with a
warning instead of passing byte-swapped data to other readers. The reader gained an optional
little-endian mode. 23 unit tests; a fuzz target in the CI matrix.
* photocraft-codecs: the two tags travel as opaque bytes on `Metadata`, read by a
bounds-checked walk of IFD0 (classic and BigTIFF, both byte orders) and written back as
BYTE / UNDEFINED tags. `tiff_writes_little_endian()` tells which order the encoder produces.
* photocraft-io: `tiff_layers` rebuilds a PSD model from the TIFF's pixels plus the tags on
import, and on export runs the PSD exporter (new `merged_matte` / `merged_compression`
options), interleaves its merged planes for the TIFF image and stores the layer section in
the encoder's byte order. `ExportOptions.tiff_layers` (default on). Lab and Multichannel
documents save flat; extra alpha channels are not written (one transparency sample).
* UI: the TIFF Options prompt ("Including layers will increase file size." / "Discard Layers
and Save a Copy" / "Don't show again") behind Preferences > File Handling > Ask Before
Saving Layered TIFF Files; `ExportSettings.tiff_layers`; catalogs updated.
* Tests: io round trips at 8/16/32 bits in Gray/RGB/CMYK, equivalence with the PSD path, every
TIFF compression, the flat fall-backs, malformed input, and files written by psdtags in both
byte orders (fixtures from scripts/layered_tiff_fixtures.py); a corpus test (feature
`corpus`) writes every corpus PSD as a layered TIFF and compares the result with the PSD
round trip (700 files, 0 differences, 0 crashes). Codecs tests cover the tag walk on
hand-made files.
* Scorecard FILE-215-7 done, BUG-204-3 partial; README and book updated.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
* Layered TIFF: de and pt-br strings; neutral wording in the psd tiff docs
The four psdtags fixtures stay out of the repository (no binary test data here): the
generator now writes them to the gitignored corpus/layered-tiff/, and the tests that read
them skip with a message when absent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>