diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index d5004334c1..eb350d17eb 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", + "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 36c7c2ccf5..ae2b3fae84 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", - "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", + "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..d4f7094894 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -47,6 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | +| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -93,7 +94,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. Other descriptors, signed or run-length-encoded data and other bit depths return - `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. + `CodecError::Unsupported` naming the field. + +* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), + read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray) + or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word + boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white + reference points are a log-conversion choice and are not applied. A depth other than 10 bits or + a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than + panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index 8b772707cc..091403d641 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -337,6 +337,78 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +// ---------------------------------------------------------------- Cineon (Kodak .cin) +// +// Cineon is the 10-bit film-scan format DPX grew from (same Kodak header family, big-endian). +// The generic header is 1024 bytes: the file information fields, then at offset 192 the image +// orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors +// (the first carries the bits per pixel and the line/image dimensions). Image data is three +// 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as +// DPX's 10-bit packing method A, so the unpacking is shared. + +/// The Cineon magic number (`802A5FD7` hex), big-endian. +pub(crate) fn is_cineon(b: &[u8]) -> bool { + b.get(..4) == Some(&[0x80, 0x2A, 0x5F, 0xD7]) +} + +/// The EXIF orientation (1–8) for a Cineon header's orientation code (the same 0–7 order as DPX). +pub(crate) fn cineon_orientation(b: &[u8]) -> u16 { + let Some(code) = b.get(192).copied() else { return 1 }; + match code { + 1 => 2, + 2 => 4, + 3 => 3, + 4 => 5, + 5 => 6, + 6 => 8, + 7 => 7, + _ => 1, + } +} + +/// Decode a Cineon (`.cin`) frame: 10-bit logarithmic film scans, big-endian, one (gray) or +/// three (RGB) channels. The 10-bit code values become U16 as `round(c × 65535 / 1023)`; the +/// file's black/white reference points are not applied (a log conversion is a display choice). +pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result { + if !is_cineon(bytes) { + return Err(err("missing the Cineon magic number (802A5FD7)")); + } + let o = Order::Big; + let missing = |what: &str| err(format!("the header ends before {what}")); + let image_offset = usize::try_from(o.u32(bytes, 4).ok_or_else(|| missing("the image data offset"))?).map_err(|_| err("image data offset out of range"))?; + let channels = *bytes.get(193).ok_or_else(|| missing("the channel count"))?; + let bits = *bytes.get(198).ok_or_else(|| missing("the bits per pixel"))?; + if bits != 10 { + return Err(unsupported(format!("{bits}-bit samples (Cineon frames are 10-bit)"))); + } + let layout = match channels { + 1 => ChannelLayout::Gray, + 3 => ChannelLayout::Rgb, + n => return Err(unsupported(format!("{n} channels (Cineon frames have 1 or 3)"))), + }; + let width = o.u32(bytes, 200).ok_or_else(|| missing("the pixels per line"))?; + let height = o.u32(bytes, 204).ok_or_else(|| missing("the lines per image"))?; + limits.check(width, height, layout, SampleType::U16)?; + + let (w, h) = (width as usize, height as usize); + let per_line = w.checked_mul(layout.channels()).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + // Three 10-bit samples per 32-bit word, lines on a word boundary (DPX packing 1). + let line_len = line_bytes(per_line, 10, 1).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + let data = bytes.get(image_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?; + let need = line_len.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?; + if data.len() < need { + return Err(err("truncated image data")); + } + let mut codes = vec![0u16; per_line.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?]; + for (y, row) in codes.chunks_exact_mut(per_line).enumerate() { + let start = y * line_len; + let line = data.get(start..start + line_len).ok_or_else(|| err("truncated image data"))?; + unpack_line(line, o, 10, 1, row)?; + } + let wide: Vec = codes.iter().map(|&c| widen(c, 1023)).collect(); + Image::from_u16(width, height, layout, &wide) +} + /// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { let mut text = Vec::new(); @@ -653,4 +725,60 @@ mod tests { let img = decode(&b, &Limits::default()).unwrap(); assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]); } + + /// A minimal Cineon: a 1024-byte generic header, then `w × h` 10-bit samples packed three + /// to a 32-bit word (RGB in word order, or gray three pixels per word). + fn cineon(w: u32, h: u32, channels: u8, px: &[[u16; 3]]) -> Vec { + let mut b = vec![0u8; 1024]; + b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); // magic (802A5FD7) + b[4..8].copy_from_slice(&1024u32.to_be_bytes()); // image data offset + b[192] = 0; // orientation: left-to-right, top-to-bottom + b[193] = channels; + b[198] = 10; // bits per pixel (channel 1) + b[200..204].copy_from_slice(&w.to_be_bytes()); // pixels per line + b[204..208].copy_from_slice(&h.to_be_bytes()); // lines per image + let word = |a: u16, c: u16, e: u16| ((u32::from(a) << 22) | (u32::from(c) << 12) | (u32::from(e) << 2)).to_be_bytes(); + if channels == 3 { + for p in px { + b.extend_from_slice(&word(p[0], p[1], p[2])); + } + } else { + let g = |i: usize| px.get(i).map_or(0, |p| p[0]); + for i in (0..px.len()).step_by(3) { + b.extend_from_slice(&word(g(i), g(i + 1), g(i + 2))); + } + } + b + } + + #[test] + fn cineon_reads_ten_bit_rgb() { + let px = [[0, 512, 1023], [1023, 0, 256]]; + let b = cineon(2, 1, 3, &px); + assert!(is_cineon(&b)); + assert_eq!(order(&b), None, "Cineon is not DPX"); + let img = decode_cineon(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb)); + assert_eq!( + img.to_u16_samples().unwrap(), + vec![widen(0, 1023), widen(512, 1023), widen(1023, 1023), widen(1023, 1023), widen(0, 1023), widen(256, 1023)] + ); + } + + #[test] + fn cineon_reads_grayscale_and_rejects_other_depths() { + let px = [[10, 0, 0], [20, 0, 0], [30, 0, 0], [40, 0, 0]]; + let b = cineon(4, 1, 1, &px); + let img = decode_cineon(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.to_u16_samples().unwrap(), vec![widen(10, 1023), widen(20, 1023), widen(30, 1023), widen(40, 1023)]); + // A depth other than 10 is unsupported, and truncation errors rather than panicking. + let mut bad = cineon(1, 1, 3, &[[0, 0, 0]]); + bad[198] = 8; + assert!(matches!(decode_cineon(&bad, &Limits::default()), Err(CodecError::Unsupported { .. }))); + let full = cineon(4, 4, 3, &[[0; 3]; 16]); + for cut in [0, 4, 200, 1024, full.len() - 1] { + assert!(decode_cineon(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}"); + } + } } diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index d3730c08fb..2d330cc976 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -33,6 +33,8 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, + /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only. + Cineon, } /// What a format can hold **and** what this crate reads/writes for it. @@ -78,6 +80,11 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.", ), + ( + Format::Cineon, + "Cineon frames are imported for film work; this crate has no Cineon writer, so save an \ + imported frame as DPX, TIFF or EXR instead.", + ), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -95,7 +102,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 19] = [ + pub const ALL: [Format; 20] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -115,6 +122,7 @@ impl Format { Format::Heif, Format::Fits, Format::Dpx, + Format::Cineon, ]; pub fn caps(self) -> FormatCaps { @@ -148,6 +156,7 @@ impl Format { Format::Heif => "HEIF", Format::Fits => "FITS", Format::Dpx => "DPX", + Format::Cineon => "Cineon", } } @@ -174,6 +183,7 @@ impl Format { Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], Format::Dpx => &["dpx"], + Format::Cineon => &["cin"], } } @@ -198,6 +208,7 @@ impl Format { Format::Heif => "image/heif", Format::Fits => "image/fits", Format::Dpx => "image/x-dpx", + Format::Cineon => "image/x-cin", } } @@ -253,6 +264,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, + Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, } } @@ -307,6 +319,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { return Some(Format::Dpx); } + if crate::codecs::dpx::is_cineon(b) { + return Some(Format::Cineon); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3831cf654f..eb010a758f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -79,6 +79,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), + Format::Cineon => dpx::decode_cineon(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -97,6 +98,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Tiff => tiff::orientation(bytes, None), Format::Heif => 1, Format::Dpx => dpx::orientation(bytes), + Format::Cineon => dpx::cineon_orientation(bytes), _ => img.meta.exif.as_deref().map_or(1, exif_orientation), }; finish_decode(img, o, opts) @@ -252,6 +254,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), + Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 22bf227eb8..f2191e05f1 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -187,6 +187,17 @@ fn detect_magic_hdr() { assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr)); } #[test] +fn detect_magic_cineon() { + let mut b = vec![0u8; 208]; + b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); + assert_eq!(detect(&b), Some(Format::Cineon)); + assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon)); + assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only"); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); + // A bare magic is an error, never a panic. + assert!(decode_as(Format::Cineon, &b).is_err()); +} +#[test] fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ae0806374d..382034e320 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -43,7 +43,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon no | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; no Cineon, no RLE | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB, read-only | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; Cineon read-only, no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | diff --git a/docs/gaps.md b/docs/gaps.md index d90ce1b74d..d964b01eb5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -224,10 +224,12 @@ what an agent can't finish alone. ### G10. Missing and partial file formats - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the + DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. - **Estimate:** 120–220 h. ## Hardware gaps @@ -292,6 +294,7 @@ what an agent can't finish alone. | 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | | 2026-10-12 | minor | G1: CMYK Invert, Posterize and Threshold work on the inks, 16-bit Lab Invert keeps greys neutral; Photoshop-oracle floor 133 → 145 of 258 (145 already passed on main) | | 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | +| 2026-10-13 | minor | G10: Cineon read landed (10-bit gray/RGB, shares the DPX path); Cineon write remains open | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed |