mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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 <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
24 files changed
+1296
-26
No files matched your search
@@ -15,18 +15,19 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
|
||||
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
|
||||
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
|
||||
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
|
||||
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
|
||||
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
|
||||
@@ -68,17 +69,17 @@ struct Subcommand {
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
|
||||
Subcommand {
|
||||
name: "run",
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
|
||||
bare: &["--tiff-layers"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
|
||||
bare: &["--in-place", "--tiff-layers"],
|
||||
run: batch,
|
||||
},
|
||||
@@ -214,6 +215,10 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
if let Some(b) = a.get("--tga-bits") {
|
||||
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
|
||||
}
|
||||
if let Some(b) = a.get("--dpx-bits") {
|
||||
o.encode.dpx_bits =
|
||||
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
|
||||
@@ -455,6 +455,40 @@ fn quality_outside_1_to_100_is_refused() {
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
|
||||
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
|
||||
#[test]
|
||||
fn dpx_converts_both_ways_with_the_bits_asked_for() {
|
||||
let d = tmp("dpx");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
|
||||
let out = d.join("a.dpx");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--dpx-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
|
||||
}
|
||||
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
|
||||
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
|
||||
let back = d.join("back.png");
|
||||
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
|
||||
assert!(back.exists());
|
||||
for bad in ["24", "0", "abc"] {
|
||||
let out = d.join("bad.dpx");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
|
||||
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
|
||||
/// refused and nothing is written.
|
||||
#[test]
|
||||
|
||||
@@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
|
||||
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"ppm", "pam", "pfm", "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",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
@@ -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", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm",
|
||||
"pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
|
||||
"abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"pgm", "ppm", "pam", "pfm", "pcx", "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",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -62,6 +62,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("Targa", &["tga"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
("FITS", &["fits", "fit"]),
|
||||
("DPX", &["dpx"]),
|
||||
];
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
|
||||
@@ -116,6 +116,7 @@ impl Headless {
|
||||
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
|
||||
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
|
||||
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
|
||||
opts.encode.webp_quality = q.clamp(1, 100) as u8;
|
||||
|
||||
@@ -167,6 +167,9 @@ pub struct SaveParams {
|
||||
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
|
||||
#[serde(default, rename = "tgaBits")]
|
||||
pub tga_bits: Option<u8>,
|
||||
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
|
||||
#[serde(default, rename = "dpxBits")]
|
||||
pub dpx_bits: Option<u8>,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -987,6 +990,11 @@ impl PhotocraftMcp {
|
||||
Some((_, Some(bits))) => Some(bits),
|
||||
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))),
|
||||
};
|
||||
let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) {
|
||||
None => None,
|
||||
Some((_, Some(bits))) => Some(bits),
|
||||
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
|
||||
};
|
||||
if let Some(b) = self.bridge_client() {
|
||||
// The bridge forwards to the running app's `app.save`, which takes only an optional
|
||||
// path (without one it writes back to the document's own layered file, as headless
|
||||
@@ -996,6 +1004,7 @@ impl PhotocraftMcp {
|
||||
p.format.is_some().then_some("format"),
|
||||
p.quality.is_some().then_some("quality"),
|
||||
p.tiff_layers.then_some("tiffLayers"),
|
||||
dpx_bits.is_some().then_some("dpxBits"),
|
||||
p.index.is_some().then_some("index"),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -1016,6 +1025,7 @@ impl PhotocraftMcp {
|
||||
let Some(r) = self
|
||||
.headless_op(move |h| {
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
|
||||
opts.encode.dpx_bits = dpx_bits;
|
||||
if let Some(q) = p.quality {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100);
|
||||
opts.encode.webp_quality = q.clamp(1, 100);
|
||||
|
||||
@@ -45,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
|
||||
| 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 |
|
||||
| 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` |
|
||||
|
||||
@@ -74,6 +75,19 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded
|
||||
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
|
||||
`CodecError::Unsupported`; Bayer mosaics open undebayered.
|
||||
|
||||
* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element,
|
||||
descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits.
|
||||
10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data
|
||||
packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16
|
||||
`round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No
|
||||
colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and
|
||||
colorimetric specification, so a log frame is reported as log. More elements than the first
|
||||
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
|
||||
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.
|
||||
|
||||
* **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
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
//! DPX (SMPTE 268M Digital Picture Exchange), implemented here from the published layout: the
|
||||
//! 1664-byte generic header (file and image information, image source), the 384-byte industry
|
||||
//! header (film and television), then the image data of each element.
|
||||
//!
|
||||
//! Reading:
|
||||
//!
|
||||
//! * Both byte orders: `SDPX` (big-endian) and `XPDS` (little-endian).
|
||||
//! * The first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned, without
|
||||
//! run-length encoding, at 8, 10, 12 or 16 bits. 10-bit samples are packed into 32-bit words
|
||||
//! (packing 0), or three to a word with the padding bits at the bottom (1, method A) or the
|
||||
//! top (2, method B); 12-bit samples are packed (0) or one to a 16-bit word in its top bits
|
||||
//! (1, method A). Lines start on a 32-bit word boundary.
|
||||
//! * 8- and 16-bit code values become U8 and U16 samples unchanged; 10- and 12-bit ones become U16
|
||||
//! as `round(c × 65535 / (2^b − 1))`, so they come back exactly when written at their depth.
|
||||
//! * No colour transform: the transfer characteristic and colorimetric specification are reported
|
||||
//! ([`DecodeWarning::DpxEncoding`]), so a log frame is labelled log, not shown as display-referred.
|
||||
//! * The image orientation is applied on import (the caller turns the image upright).
|
||||
//! * Header fields a pipeline relies on travel as text metadata under the [`KEYS`] names, and a
|
||||
//! DPX export writes them back.
|
||||
//!
|
||||
//! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with
|
||||
//! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image
|
||||
//! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one.
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
|
||||
use crate::options::{DpxBits, EncodeOptions, Limits};
|
||||
|
||||
const F: Format = Format::Dpx;
|
||||
/// Generic plus industry header: where our image data starts.
|
||||
const HEADER_LEN: usize = 2048;
|
||||
const GENERIC_LEN: u32 = 1664;
|
||||
const INDUSTRY_LEN: u32 = 384;
|
||||
/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float).
|
||||
const UNDEF32: u32 = u32::MAX;
|
||||
const UNDEF8: u8 = u8::MAX;
|
||||
/// Offset of the first image element and the size of one.
|
||||
const ELEMENT0: usize = 780;
|
||||
const ELEMENT_LEN: usize = 72;
|
||||
const MAX_ELEMENTS: u16 = 8;
|
||||
|
||||
// Header field offsets (generic file header, image information, film and TV headers).
|
||||
const CREATION_TIME: (usize, usize) = (136, 24);
|
||||
const CREATOR: (usize, usize) = (160, 100);
|
||||
const PROJECT: (usize, usize) = (260, 200);
|
||||
const COPYRIGHT: (usize, usize) = (460, 200);
|
||||
const ORIENTATION: usize = 768;
|
||||
const FRAME_POSITION: usize = 1712;
|
||||
const FRAME_RATE: usize = 1724;
|
||||
const TIME_CODE: usize = 1920;
|
||||
|
||||
/// Text metadata keys of the header fields kept through a DPX → document → DPX round trip.
|
||||
pub(crate) const KEY_CREATOR: &str = "DPX:Creator";
|
||||
pub(crate) const KEY_PROJECT: &str = "DPX:Project";
|
||||
pub(crate) const KEY_COPYRIGHT: &str = "DPX:Copyright";
|
||||
pub(crate) const KEY_CREATION_TIME: &str = "DPX:CreationTime";
|
||||
pub(crate) const KEY_TRANSFER: &str = "DPX:Transfer";
|
||||
pub(crate) const KEY_COLORIMETRIC: &str = "DPX:Colorimetric";
|
||||
pub(crate) const KEY_FRAME_POSITION: &str = "DPX:FramePosition";
|
||||
pub(crate) const KEY_FRAME_RATE: &str = "DPX:FrameRate";
|
||||
pub(crate) const KEY_TIME_CODE: &str = "DPX:TimeCode";
|
||||
pub(crate) const KEYS: [&str; 9] =
|
||||
[KEY_CREATOR, KEY_PROJECT, KEY_COPYRIGHT, KEY_CREATION_TIME, KEY_TRANSFER, KEY_COLORIMETRIC, KEY_FRAME_POSITION, KEY_FRAME_RATE, KEY_TIME_CODE];
|
||||
|
||||
fn err(msg: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, msg)
|
||||
}
|
||||
|
||||
fn unsupported(msg: impl Into<String>) -> CodecError {
|
||||
CodecError::unsupported(F, msg)
|
||||
}
|
||||
|
||||
/// The file's byte order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Order {
|
||||
Big,
|
||||
Little,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
fn bytes<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
|
||||
b.get(at..at.checked_add(N)?)?.try_into().ok()
|
||||
}
|
||||
|
||||
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = Self::bytes::<2>(b, at)?;
|
||||
Some(if self == Order::Big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) })
|
||||
}
|
||||
|
||||
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = Self::bytes::<4>(b, at)?;
|
||||
Some(if self == Order::Big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) })
|
||||
}
|
||||
|
||||
/// A 32-bit field that may be undefined (all ones).
|
||||
fn defined_u32(self, b: &[u8], at: usize) -> Option<u32> {
|
||||
self.u32(b, at).filter(|&v| v != UNDEF32)
|
||||
}
|
||||
|
||||
fn f32(self, b: &[u8], at: usize) -> Option<f32> {
|
||||
self.defined_u32(b, at).map(f32::from_bits).filter(|v| v.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte order from the magic number, `None` when it is not DPX.
|
||||
fn order(b: &[u8]) -> Option<Order> {
|
||||
match b.get(..4)? {
|
||||
b"SDPX" => Some(Order::Big),
|
||||
b"XPDS" => Some(Order::Little),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` when
|
||||
/// empty or undefined (all ones).
|
||||
fn string(b: &[u8], (at, len): (usize, usize)) -> Option<String> {
|
||||
let raw = b.get(at..at.checked_add(len)?)?;
|
||||
let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
|
||||
let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect();
|
||||
Some(s.trim().to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// The descriptive fields of image element 0 that decoding needs.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Element {
|
||||
signed: bool,
|
||||
descriptor: u8,
|
||||
transfer: u8,
|
||||
colorimetric: u8,
|
||||
bits: u8,
|
||||
packing: u16,
|
||||
encoding: u16,
|
||||
data_offset: usize,
|
||||
eol_padding: usize,
|
||||
}
|
||||
|
||||
struct Header {
|
||||
order: Order,
|
||||
width: u32,
|
||||
height: u32,
|
||||
elements: u16,
|
||||
element: Element,
|
||||
}
|
||||
|
||||
fn parse(b: &[u8]) -> Result<Header, CodecError> {
|
||||
let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?;
|
||||
let missing = |what: &str| err(format!("the header ends before {what}"));
|
||||
let image_offset = o.u32(b, 4).ok_or_else(|| missing("the image data offset"))?;
|
||||
o.u16(b, ORIENTATION).ok_or_else(|| missing("the orientation"))?;
|
||||
let elements = o.u16(b, ORIENTATION + 2).ok_or_else(|| missing("the element count"))?;
|
||||
if !(1..=MAX_ELEMENTS).contains(&elements) {
|
||||
return Err(err(format!("invalid number of image elements {elements}")));
|
||||
}
|
||||
let width = o.u32(b, 772).ok_or_else(|| missing("the line length"))?;
|
||||
let height = o.u32(b, 776).ok_or_else(|| missing("the line count"))?;
|
||||
let e = ELEMENT0;
|
||||
let field = |at: usize| o.u32(b, e + at).ok_or_else(|| missing("image element 1"));
|
||||
let byte = |at: usize| b.get(e + at).copied().ok_or_else(|| missing("image element 1"));
|
||||
let half = |at: usize| o.u16(b, e + at).ok_or_else(|| missing("image element 1"));
|
||||
let data_offset = match field(28)? {
|
||||
0 | UNDEF32 => image_offset,
|
||||
v => v,
|
||||
};
|
||||
let eol_padding = match field(32)? {
|
||||
UNDEF32 => 0,
|
||||
v => v,
|
||||
};
|
||||
let element = Element {
|
||||
signed: field(0)? == 1,
|
||||
descriptor: byte(20)?,
|
||||
transfer: byte(21)?,
|
||||
colorimetric: byte(22)?,
|
||||
bits: byte(23)?,
|
||||
packing: half(24)?,
|
||||
encoding: half(26)?,
|
||||
data_offset: usize::try_from(data_offset).map_err(|_| err("image data offset out of range"))?,
|
||||
eol_padding: usize::try_from(eol_padding).map_err(|_| err("line padding out of range"))?,
|
||||
};
|
||||
Ok(Header { order: o, width, height, elements, element })
|
||||
}
|
||||
|
||||
/// The EXIF orientation (1–8) that turns the stored lines upright: the DPX orientation codes 0–7
|
||||
/// in SMPTE 268M order (left-to-right top-to-bottom, mirrored, flipped, rotated, transposed…).
|
||||
/// Anything else, undefined included, is taken as the default (1).
|
||||
pub(crate) fn orientation(b: &[u8]) -> u16 {
|
||||
let Some(o) = order(b).and_then(|o| o.u16(b, ORIENTATION)) else { return 1 };
|
||||
match o {
|
||||
1 => 2,
|
||||
2 => 4,
|
||||
3 => 3,
|
||||
4 => 5,
|
||||
5 => 6,
|
||||
6 => 8,
|
||||
7 => 7,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes in one stored line of `samples` samples, padded to a 32-bit word.
|
||||
fn line_bytes(samples: usize, bits: u8, packing: u16) -> Option<usize> {
|
||||
let words = match (bits, packing) {
|
||||
// Three samples to a word, or one to a 16-bit half word.
|
||||
(10, 1 | 2) => samples.div_ceil(3),
|
||||
(12, 1 | 2) => samples.div_ceil(2),
|
||||
_ => samples.checked_mul(usize::from(bits))?.div_ceil(32),
|
||||
};
|
||||
words.checked_mul(4)
|
||||
}
|
||||
|
||||
/// Code values of one stored line.
|
||||
fn unpack_line(line: &[u8], o: Order, bits: u8, packing: u16, out: &mut [u16]) -> Result<(), CodecError> {
|
||||
let short = || err("truncated image data");
|
||||
match (bits, packing) {
|
||||
(8, _) => {
|
||||
let src = line.get(..out.len()).ok_or_else(short)?;
|
||||
for (v, &c) in out.iter_mut().zip(src) {
|
||||
*v = u16::from(c);
|
||||
}
|
||||
}
|
||||
(16, _) => {
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
*v = o.u16(line, i * 2).ok_or_else(short)?;
|
||||
}
|
||||
}
|
||||
(10, 1 | 2) => {
|
||||
let base = if packing == 1 { 22 } else { 20 };
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
let word = o.u32(line, i / 3 * 4).ok_or_else(short)?;
|
||||
*v = ((word >> (base - 10 * (i % 3) as u32)) & 0x3FF) as u16;
|
||||
}
|
||||
}
|
||||
(12, 1 | 2) => {
|
||||
let shift = if packing == 1 { 4 } else { 0 };
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
*v = (o.u16(line, i * 2).ok_or_else(short)? >> shift) & 0xFFF;
|
||||
}
|
||||
}
|
||||
// Packing 0: one bit stream through the line's 32-bit words, high bits first.
|
||||
_ => {
|
||||
let mask = (1u64 << bits) - 1;
|
||||
let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize);
|
||||
for v in out.iter_mut() {
|
||||
while have < u32::from(bits) {
|
||||
acc = (acc << 32) | u64::from(o.u32(line, word * 4).ok_or_else(short)?);
|
||||
word += 1;
|
||||
have += 32;
|
||||
}
|
||||
have -= u32::from(bits);
|
||||
*v = ((acc >> have) & mask) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A 10- or 12-bit code value as a 16-bit sample: `round(c × 65535 / max)`.
|
||||
fn widen(c: u16, max: u64) -> u16 {
|
||||
((u64::from(c) * 2 * 65535 + max) / (2 * max)).min(65535) as u16
|
||||
}
|
||||
|
||||
/// The inverse of [`widen`]: `round(v × max / 65535)`.
|
||||
fn narrow(v: u16, max: u64) -> u16 {
|
||||
((u64::from(v) * 2 * max + 65535) / (2 * 65535)) as u16
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let h = parse(bytes)?;
|
||||
let e = h.element;
|
||||
let layout = match e.descriptor {
|
||||
6 => ChannelLayout::Gray,
|
||||
50 => ChannelLayout::Rgb,
|
||||
51 => ChannelLayout::Rgba,
|
||||
d => return Err(unsupported(format!("descriptor {d} is not supported (only 6 luma, 50 RGB and 51 RGBA)"))),
|
||||
};
|
||||
if e.signed {
|
||||
return Err(unsupported("data sign 1 (signed samples) is not supported"));
|
||||
}
|
||||
if e.encoding != 0 {
|
||||
return Err(unsupported(format!("encoding {} (run-length encoded data) is not supported", e.encoding)));
|
||||
}
|
||||
let packing_ok = match e.bits {
|
||||
8 | 16 => e.packing == 0,
|
||||
10 => e.packing <= 2,
|
||||
12 => e.packing <= 1,
|
||||
b => return Err(unsupported(format!("bit size {b} is not supported (only 8, 10, 12 and 16)"))),
|
||||
};
|
||||
if !packing_ok {
|
||||
return Err(unsupported(format!("packing {} is not supported at {} bits", e.packing, e.bits)));
|
||||
}
|
||||
let sample = if e.bits == 8 { SampleType::U8 } else { SampleType::U16 };
|
||||
limits.check(h.width, h.height, layout, sample)?;
|
||||
|
||||
let nc = layout.channels();
|
||||
let per_line = (h.width as usize).checked_mul(nc).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
|
||||
let height = h.height as usize;
|
||||
let data = bytes.get(e.data_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?;
|
||||
let too_large = || CodecError::LimitExceeded("image too large".into());
|
||||
let mut line_len = line_bytes(per_line, e.bits, e.packing).ok_or_else(too_large)?;
|
||||
let mut stride = line_len.checked_add(e.eol_padding).ok_or_else(too_large)?;
|
||||
// Lines run `stride` apart; the last one needs no padding after it.
|
||||
let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len);
|
||||
// Some writers pack 8- and 16-bit lines without padding them to a word; take that layout
|
||||
// when the file is too short for padded lines but holds the unpadded ones.
|
||||
let tight = per_line * usize::from(e.bits / 8);
|
||||
if matches!(e.bits, 8 | 16) && e.eol_padding == 0 && line_len != tight && span(stride, line_len).is_none_or(|need| data.len() < need) {
|
||||
(line_len, stride) = (tight, tight);
|
||||
}
|
||||
if span(stride, line_len).ok_or_else(too_large)? > data.len() {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
|
||||
let mut codes = vec![0u16; per_line.checked_mul(height).ok_or_else(too_large)?];
|
||||
for (y, row) in codes.chunks_exact_mut(per_line.max(1)).enumerate() {
|
||||
let start = y * stride;
|
||||
let line = data.get(start..).ok_or_else(|| err("truncated image data"))?;
|
||||
unpack_line(line, h.order, e.bits, e.packing, row)?;
|
||||
}
|
||||
|
||||
let mut img = match e.bits {
|
||||
8 => Image::from_u8(h.width, h.height, layout, codes.iter().map(|&c| c as u8).collect())?,
|
||||
16 => Image::from_u16(h.width, h.height, layout, &codes)?,
|
||||
b => {
|
||||
let max = (1u64 << b) - 1;
|
||||
let wide: Vec<u16> = codes.iter().map(|&c| widen(c, max)).collect();
|
||||
Image::from_u16(h.width, h.height, layout, &wide)?
|
||||
}
|
||||
};
|
||||
if h.elements > 1 {
|
||||
img.warnings.push(DecodeWarning::MoreElements { total: u32::from(h.elements) });
|
||||
}
|
||||
if e.transfer != UNDEF8 || e.colorimetric != UNDEF8 {
|
||||
img.warnings.push(DecodeWarning::DpxEncoding { transfer: e.transfer, colorimetric: e.colorimetric });
|
||||
}
|
||||
img.meta.text = header_text(bytes, h.order, e);
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut put = |k: &str, v: Option<String>| {
|
||||
if let Some(v) = v {
|
||||
text.push((k.to_string(), v));
|
||||
}
|
||||
};
|
||||
put(KEY_CREATOR, string(b, CREATOR));
|
||||
put(KEY_PROJECT, string(b, PROJECT));
|
||||
put(KEY_COPYRIGHT, string(b, COPYRIGHT));
|
||||
put(KEY_CREATION_TIME, string(b, CREATION_TIME));
|
||||
put(KEY_TRANSFER, (e.transfer != UNDEF8).then(|| e.transfer.to_string()));
|
||||
put(KEY_COLORIMETRIC, (e.colorimetric != UNDEF8).then(|| e.colorimetric.to_string()));
|
||||
// The industry header is there when the generic header says so and the file holds it.
|
||||
let industry = o.u32(b, 28).is_some_and(|l| l != 0 && l != UNDEF32) && b.len() >= HEADER_LEN;
|
||||
if industry {
|
||||
put(KEY_FRAME_POSITION, o.defined_u32(b, FRAME_POSITION).map(|v| v.to_string()));
|
||||
put(KEY_FRAME_RATE, o.f32(b, FRAME_RATE).map(|v| v.to_string()));
|
||||
put(KEY_TIME_CODE, o.defined_u32(b, TIME_CODE).map(format_time_code));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD.
|
||||
fn format_time_code(v: u32) -> String {
|
||||
let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF);
|
||||
if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") }
|
||||
}
|
||||
|
||||
/// The inverse of [`format_time_code`]; `None` for anything else.
|
||||
fn parse_time_code(s: &str) -> Option<u32> {
|
||||
if let Some(hex) = s.strip_prefix("0x") {
|
||||
return u32::from_str_radix(hex, 16).ok();
|
||||
}
|
||||
let parts: Vec<&str> = s.split(':').collect();
|
||||
if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) {
|
||||
return None;
|
||||
}
|
||||
parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0')))
|
||||
}
|
||||
|
||||
/// Bits written for `sample` when the options pick none: 8 for U8, 16 otherwise.
|
||||
pub(crate) fn bits_for(opt: Option<DpxBits>, sample: SampleType) -> u8 {
|
||||
match opt {
|
||||
Some(b) => b.bits(),
|
||||
None if sample == SampleType::U8 => 8,
|
||||
None => 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16.
|
||||
pub(crate) fn plan_sample(opt: Option<DpxBits>, src: SampleType) -> SampleType {
|
||||
match opt {
|
||||
Some(DpxBits::Bits8) => SampleType::U8,
|
||||
Some(_) => SampleType::U16,
|
||||
None if src == SampleType::U8 => SampleType::U8,
|
||||
None => SampleType::U16,
|
||||
}
|
||||
}
|
||||
|
||||
struct Writer {
|
||||
h: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn u8(&mut self, at: usize, v: u8) {
|
||||
if let Some(b) = self.h.get_mut(at) {
|
||||
*b = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn u16(&mut self, at: usize, v: u16) {
|
||||
if let Some(s) = self.h.get_mut(at..at + 2) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn u32(&mut self, at: usize, v: u32) {
|
||||
if let Some(s) = self.h.get_mut(at..at + 4) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a run of numeric fields undefined (all ones).
|
||||
fn undefined(&mut self, from: usize, to: usize) {
|
||||
if let Some(s) = self.h.get_mut(from..to) {
|
||||
s.fill(0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
/// An ASCII string field, cut to leave room for a terminating NUL.
|
||||
fn string(&mut self, (at, len): (usize, usize), v: &str) {
|
||||
let ascii: Vec<u8> = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect();
|
||||
if let Some(s) = self.h.get_mut(at..at + ascii.len()) {
|
||||
s.copy_from_slice(&ascii);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text<'a>(img: &'a Image, key: &str) -> Option<&'a str> {
|
||||
img.meta.text.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Appends one line's code values, padded to a 32-bit word.
|
||||
fn pack_line(out: &mut Vec<u8>, codes: &[u16], bits: u8) {
|
||||
let start = out.len();
|
||||
match bits {
|
||||
8 => out.extend(codes.iter().map(|&c| c as u8)),
|
||||
10 => {
|
||||
for chunk in codes.chunks(3) {
|
||||
let word = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (22 - 10 * i as u32)));
|
||||
out.extend_from_slice(&word.to_be_bytes());
|
||||
}
|
||||
}
|
||||
12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())),
|
||||
_ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())),
|
||||
}
|
||||
let written = out.len() - start;
|
||||
out.resize(start + written.div_ceil(4) * 4, 0);
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
let (descriptor, layout) = match img.layout() {
|
||||
ChannelLayout::Gray => (6u8, ChannelLayout::Gray),
|
||||
ChannelLayout::Rgb => (50, ChannelLayout::Rgb),
|
||||
ChannelLayout::Rgba => (51, ChannelLayout::Rgba),
|
||||
l => return Err(CodecError::encode(F, format!("DPX cannot store {l:?}"))),
|
||||
};
|
||||
let bits = bits_for(opts.dpx_bits, img.sample_type());
|
||||
let codes: Vec<u16> = match (img.sample_type(), bits) {
|
||||
(SampleType::U8, 8) => img.data().iter().map(|&v| u16::from(v)).collect(),
|
||||
(SampleType::U16, 16) => img.to_u16_samples().unwrap_or_default(),
|
||||
(SampleType::U16, 10 | 12) => {
|
||||
let max = (1u64 << bits) - 1;
|
||||
img.to_u16_samples().unwrap_or_default().into_iter().map(|v| narrow(v, max)).collect()
|
||||
}
|
||||
(s, b) => return Err(CodecError::encode(F, format!("cannot write {s:?} samples at {b} bits"))),
|
||||
};
|
||||
let per_line = w as usize * layout.channels();
|
||||
if codes.len() != per_line * h as usize {
|
||||
return Err(CodecError::encode(F, "the image has the wrong number of samples"));
|
||||
}
|
||||
let data_len = line_bytes(per_line, bits, if matches!(bits, 10 | 12) { 1 } else { 0 })
|
||||
.and_then(|l| l.checked_mul(h as usize))
|
||||
.ok_or_else(|| CodecError::encode(F, "image too large"))?;
|
||||
let total = u32::try_from(HEADER_LEN + data_len).map_err(|_| CodecError::encode(F, "image too large for a DPX file (4 GiB)"))?;
|
||||
|
||||
let mut wr = Writer { h: vec![0; HEADER_LEN] };
|
||||
// Generic file header.
|
||||
wr.h[..4].copy_from_slice(b"SDPX");
|
||||
wr.u32(4, HEADER_LEN as u32);
|
||||
wr.string((8, 8), "V2.0");
|
||||
wr.u32(16, total);
|
||||
wr.u32(20, 1);
|
||||
wr.u32(24, GENERIC_LEN);
|
||||
wr.u32(28, INDUSTRY_LEN);
|
||||
wr.u32(32, 0);
|
||||
let keep = |key| if opts.embed_metadata { text(&img, key) } else { None };
|
||||
wr.string(CREATION_TIME, keep(KEY_CREATION_TIME).unwrap_or(""));
|
||||
wr.string(CREATOR, keep(KEY_CREATOR).unwrap_or("PhotoCraft"));
|
||||
wr.string(PROJECT, keep(KEY_PROJECT).unwrap_or(""));
|
||||
wr.string(COPYRIGHT, keep(KEY_COPYRIGHT).unwrap_or(""));
|
||||
wr.u32(660, UNDEF32);
|
||||
// Image information: default orientation, one element.
|
||||
wr.u16(ORIENTATION, 0);
|
||||
wr.u16(ORIENTATION + 2, 1);
|
||||
wr.u32(772, w);
|
||||
wr.u32(776, h);
|
||||
let e = ELEMENT0;
|
||||
wr.u32(e, 0);
|
||||
wr.u32(e + 4, 0);
|
||||
wr.u32(e + 8, UNDEF32);
|
||||
wr.u32(e + 12, ((1u32 << bits) - 1).min(65535));
|
||||
wr.u32(e + 16, UNDEF32);
|
||||
wr.u8(e + 20, descriptor);
|
||||
// No claim about the encoding unless the document came with one.
|
||||
let code = |key| keep(key).and_then(|v| v.parse::<u8>().ok()).unwrap_or(UNDEF8);
|
||||
wr.u8(e + 21, code(KEY_TRANSFER));
|
||||
wr.u8(e + 22, code(KEY_COLORIMETRIC));
|
||||
wr.u8(e + 23, bits);
|
||||
wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 });
|
||||
wr.u16(e + 26, 0);
|
||||
wr.u32(e + 28, HEADER_LEN as u32);
|
||||
wr.u32(e + 32, 0);
|
||||
wr.u32(e + 36, 0);
|
||||
// The seven unused elements, the image source header's numbers and the industry header's
|
||||
// numbers are undefined; their strings stay empty.
|
||||
wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS));
|
||||
wr.undefined(1408, 1432);
|
||||
wr.undefined(1620, 1644);
|
||||
wr.undefined(FRAME_POSITION, 1732);
|
||||
wr.undefined(TIME_CODE, 1972);
|
||||
if let Some(v) = keep(KEY_FRAME_POSITION).and_then(|v| v.parse::<u32>().ok()) {
|
||||
wr.u32(FRAME_POSITION, v);
|
||||
}
|
||||
if let Some(v) = keep(KEY_FRAME_RATE).and_then(|v| v.parse::<f32>().ok()).filter(|v| v.is_finite()) {
|
||||
wr.u32(FRAME_RATE, v.to_bits());
|
||||
}
|
||||
if let Some(v) = keep(KEY_TIME_CODE).and_then(parse_time_code) {
|
||||
wr.u32(TIME_CODE, v);
|
||||
}
|
||||
|
||||
let mut out = wr.h;
|
||||
out.try_reserve_exact(data_len).map_err(|_| CodecError::encode(F, "out of memory"))?;
|
||||
for row in codes.chunks_exact(per_line.max(1)) {
|
||||
pack_line(&mut out, row, bits);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal header for `w`×`h` with element 0 set; image data at 2048.
|
||||
fn header(o: Order, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16) -> Vec<u8> {
|
||||
let mut b = vec![0u8; HEADER_LEN];
|
||||
let put32 = |b: &mut Vec<u8>, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
let put16 = |b: &mut Vec<u8>, at: usize, v: u16| b[at..at + 2].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
b[..4].copy_from_slice(if o == Order::Big { b"SDPX" } else { b"XPDS" });
|
||||
put32(&mut b, 4, HEADER_LEN as u32);
|
||||
put32(&mut b, 28, INDUSTRY_LEN);
|
||||
put16(&mut b, ORIENTATION, 0);
|
||||
put16(&mut b, ORIENTATION + 2, 1);
|
||||
put32(&mut b, 772, w);
|
||||
put32(&mut b, 776, h);
|
||||
b[ELEMENT0 + 20] = descriptor;
|
||||
b[ELEMENT0 + 21] = UNDEF8;
|
||||
b[ELEMENT0 + 22] = UNDEF8;
|
||||
b[ELEMENT0 + 23] = bits;
|
||||
put16(&mut b, ELEMENT0 + 24, packing);
|
||||
put32(&mut b, ELEMENT0 + 28, HEADER_LEN as u32);
|
||||
b[FRAME_POSITION..1732].fill(0xFF);
|
||||
b[TIME_CODE..1972].fill(0xFF);
|
||||
b
|
||||
}
|
||||
|
||||
fn words(o: Order, ws: &[u32]) -> Vec<u8> {
|
||||
ws.iter().flat_map(|w| if o == Order::Big { w.to_be_bytes() } else { w.to_le_bytes() }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_method_a_and_b_unpack_three_to_a_word() {
|
||||
for o in [Order::Big, Order::Little] {
|
||||
for (packing, base) in [(1u16, 22u32), (2, 20)] {
|
||||
let mut b = header(o, 1, 1, 50, 10, packing);
|
||||
b.extend(words(o, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))]));
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(512, 1023), widen(1, 1023)], "{o:?} packing {packing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_packing_0_is_one_bit_stream_per_line() {
|
||||
// Four luma samples: 40 bits over two words, the line padded to the second word.
|
||||
let samples = [1u64, 2, 1022, 1023];
|
||||
let stream = samples.iter().fold(0u64, |acc, &s| (acc << 10) | s) << 24;
|
||||
for o in [Order::Big, Order::Little] {
|
||||
let mut b = header(o, 4, 2, 6, 10, 0);
|
||||
let line = words(o, &[(stream >> 32) as u32, stream as u32]);
|
||||
b.extend(&line);
|
||||
b.extend(&line);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
let want: Vec<u16> = samples.iter().map(|&s| widen(s as u16, 1023)).collect();
|
||||
assert_eq!(img.to_u16_samples().unwrap(), [want.clone(), want].concat(), "{o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twelve_bit_method_a_keeps_the_top_bits() {
|
||||
let mut b = header(Order::Big, 2, 1, 6, 12, 1);
|
||||
b.extend_from_slice(&[0xFF, 0xF0, 0x00, 0x10]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(1, 4095)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widen_and_narrow_round_trip_every_code() {
|
||||
for bits in [10u8, 12] {
|
||||
let max = (1u64 << bits) - 1;
|
||||
for c in 0..=max as u16 {
|
||||
assert_eq!(narrow(widen(c, max), max), c, "{bits}-bit {c}");
|
||||
}
|
||||
}
|
||||
assert_eq!(widen(512, 1023), ((512.0f64 * 65535.0 / 1023.0).round()) as u16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_codes_map_to_exif() {
|
||||
let mut b = header(Order::Big, 1, 1, 6, 8, 0);
|
||||
for (dpx, exif) in [(0u16, 1u16), (1, 2), (2, 4), (3, 3), (4, 5), (5, 6), (6, 8), (7, 7), (0xFFFF, 1)] {
|
||||
b[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes());
|
||||
assert_eq!(orientation(&b), exif, "DPX {dpx}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_codes_round_trip() {
|
||||
assert_eq!(format_time_code(0x0102_0304), "01:02:03:04");
|
||||
assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304));
|
||||
assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000));
|
||||
assert_eq!(parse_time_code("1:2:3:4"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_bit_lines_without_word_padding_still_open() {
|
||||
// 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8.
|
||||
let mut b = header(Order::Big, 1, 2, 50, 8, 0);
|
||||
b.extend_from_slice(&[1, 2, 3, 4, 5, 6]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod deep_exr;
|
||||
pub(crate) mod dpx;
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod exr_cryptomatte;
|
||||
pub(crate) mod fits;
|
||||
|
||||
@@ -145,13 +145,19 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
|
||||
layouts[0]
|
||||
}
|
||||
|
||||
pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan {
|
||||
pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan {
|
||||
let c = caps(format);
|
||||
let (layout, sample) = (image.layout(), image.sample_type());
|
||||
match format {
|
||||
// PNM: integer data can use any layout (PAM); float goes to PFM
|
||||
// which only has gray and RGB.
|
||||
Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 },
|
||||
// DPX has no luma + alpha descriptor: gray with alpha is written as luma, alpha dropped.
|
||||
// The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16).
|
||||
Format::Dpx => Plan {
|
||||
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
|
||||
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
|
||||
},
|
||||
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
|
||||
}
|
||||
}
|
||||
@@ -237,7 +243,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
if m.dpi.is_some() && !c.dpi {
|
||||
w.push(W::DpiDropped);
|
||||
}
|
||||
if !m.text.is_empty() && !c.text {
|
||||
// DPX keeps its own header fields, not free-form text.
|
||||
let text_kept = |k: &str| c.text && (format != Format::Dpx || crate::codecs::dpx::KEYS.contains(&k));
|
||||
if m.text.iter().any(|(k, _)| !text_kept(k)) {
|
||||
w.push(W::TextDropped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub enum Format {
|
||||
Heif,
|
||||
/// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data.
|
||||
Fits,
|
||||
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
|
||||
Dpx,
|
||||
}
|
||||
|
||||
/// What a format can hold **and** what this crate reads/writes for it.
|
||||
@@ -84,7 +86,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; 16] = [
|
||||
pub const ALL: [Format; 17] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
@@ -101,6 +103,7 @@ impl Format {
|
||||
Format::Avif,
|
||||
Format::Heif,
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
];
|
||||
|
||||
pub fn caps(self) -> FormatCaps {
|
||||
@@ -131,6 +134,7 @@ impl Format {
|
||||
Format::Avif => "AVIF",
|
||||
Format::Heif => "HEIF",
|
||||
Format::Fits => "FITS",
|
||||
Format::Dpx => "DPX",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +158,7 @@ impl Format {
|
||||
Format::Avif => &["avif"],
|
||||
Format::Heif => &["heic", "heif", "hif"],
|
||||
Format::Fits => &["fits", "fit", "fts"],
|
||||
Format::Dpx => &["dpx"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +180,7 @@ impl Format {
|
||||
Format::Avif => "image/avif",
|
||||
Format::Heif => "image/heif",
|
||||
Format::Fits => "image/fits",
|
||||
Format::Dpx => "image/x-dpx",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +232,8 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
|
||||
}
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +282,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() {
|
||||
return Some(Format::Pnm);
|
||||
}
|
||||
if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") {
|
||||
return Some(Format::Dpx);
|
||||
}
|
||||
if b.starts_with(b"SIMPLE =") {
|
||||
return Some(Format::Fits);
|
||||
}
|
||||
|
||||
@@ -156,6 +156,32 @@ pub enum DecodeWarning {
|
||||
/// Deep image data (per-pixel sample lists) was composited into the flat image; the samples
|
||||
/// themselves (Z and other deep channels) are not part of the flat result.
|
||||
DeepFlattened { format: Format, max_samples_per_pixel: u32 },
|
||||
/// Only the first image element of a DPX file was decoded; `total` counts them all.
|
||||
MoreElements { total: u32 },
|
||||
/// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes,
|
||||
/// 255 when undefined). The code values are imported as stored, without a colour transform.
|
||||
DpxEncoding { transfer: u8, colorimetric: u8 },
|
||||
}
|
||||
|
||||
/// The SMPTE 268M name of a transfer characteristic or colorimetric code.
|
||||
fn dpx_code_name(code: u8) -> &'static str {
|
||||
match code {
|
||||
0 => "user-defined",
|
||||
1 => "printing density",
|
||||
2 => "linear",
|
||||
3 => "logarithmic",
|
||||
4 => "unspecified video",
|
||||
5 => "SMPTE 274M",
|
||||
6 => "ITU-R BT.709",
|
||||
7 => "ITU-R BT.601 625-line",
|
||||
8 => "ITU-R BT.601 525-line",
|
||||
9 => "NTSC composite video",
|
||||
10 => "PAL composite video",
|
||||
11 => "Z linear",
|
||||
12 => "Z homogeneous",
|
||||
255 => "undefined",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeWarning {
|
||||
@@ -175,6 +201,13 @@ impl fmt::Display for DecodeWarning {
|
||||
"{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept",
|
||||
format = format.name()
|
||||
),
|
||||
DecodeWarning::MoreElements { total } => write!(f, "only the first of {total} image elements was imported"),
|
||||
DecodeWarning::DpxEncoding { transfer, colorimetric } => write!(
|
||||
f,
|
||||
"DPX transfer characteristic: {} ({transfer}), colorimetric: {} ({colorimetric}); the code values were imported as stored, without a colour transform",
|
||||
dpx_code_name(*transfer),
|
||||
dpx_code_name(*colorimetric)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
|
||||
pub use crate::image::{
|
||||
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
|
||||
};
|
||||
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
|
||||
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp};
|
||||
use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp};
|
||||
|
||||
/// Detect the format and decode with default [`Limits`].
|
||||
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
@@ -76,6 +76,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
Format::Pnm => pnm::decode(bytes, l),
|
||||
Format::Pcx => pcx::decode(bytes, l),
|
||||
Format::Fits => fits::decode(bytes, l),
|
||||
Format::Dpx => dpx::decode(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),
|
||||
@@ -93,6 +94,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
let o = match format {
|
||||
Format::Tiff => tiff::orientation(bytes, None),
|
||||
Format::Heif => 1,
|
||||
Format::Dpx => dpx::orientation(bytes),
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
finish_decode(img, o, opts)
|
||||
@@ -224,6 +226,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::Pnm => pnm::encode(image, plan, opts),
|
||||
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
|
||||
Format::Fits => fits::encode(image, plan, opts),
|
||||
Format::Dpx => dpx::encode(image, plan, opts),
|
||||
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),
|
||||
|
||||
@@ -121,6 +121,38 @@ pub enum ExrCompression {
|
||||
Piz,
|
||||
}
|
||||
|
||||
/// Bits per sample of a DPX file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DpxBits {
|
||||
Bits8,
|
||||
Bits10,
|
||||
Bits12,
|
||||
Bits16,
|
||||
}
|
||||
|
||||
impl DpxBits {
|
||||
/// `8`, `10`, `12` or `16` as [`DpxBits`]; any other number is `None`.
|
||||
pub fn from_bits(bits: u64) -> Option<Self> {
|
||||
match bits {
|
||||
8 => Some(DpxBits::Bits8),
|
||||
10 => Some(DpxBits::Bits10),
|
||||
12 => Some(DpxBits::Bits12),
|
||||
16 => Some(DpxBits::Bits16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bits per sample.
|
||||
pub fn bits(self) -> u8 {
|
||||
match self {
|
||||
DpxBits::Bits8 => 8,
|
||||
DpxBits::Bits10 => 10,
|
||||
DpxBits::Bits12 => 12,
|
||||
DpxBits::Bits16 => 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for [`crate::encode`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EncodeOptions {
|
||||
@@ -141,6 +173,8 @@ pub struct EncodeOptions {
|
||||
/// only when it could pass the 4 GiB a classic TIFF can address.
|
||||
pub tiff_bigtiff: bool,
|
||||
pub exr_compression: ExrCompression,
|
||||
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
|
||||
pub dpx_bits: Option<DpxBits>,
|
||||
/// Embed the ICC profile when the format supports it.
|
||||
pub embed_icc: bool,
|
||||
/// Embed EXIF/XMP/DPI/text when the format supports it.
|
||||
@@ -159,6 +193,7 @@ impl Default for EncodeOptions {
|
||||
tiff_compression: TiffCompression::Deflate,
|
||||
tiff_bigtiff: false,
|
||||
exr_compression: ExrCompression::Zip16,
|
||||
dpx_bits: None,
|
||||
embed_icc: true,
|
||||
embed_metadata: true,
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ fn default_build_formats_all_symmetric() {
|
||||
Format::OpenExr,
|
||||
Format::Hdr,
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
];
|
||||
for f in expected {
|
||||
assert!(caps(f).read && caps(f).write, "{f:?}");
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! DPX (SMPTE 268M) read and write, against fixtures packed here independently of the codec:
|
||||
//! every descriptor, bit depth and packing it reads, both byte orders, the header fields kept
|
||||
//! through a round trip, the bit-depth choice on export and the files it refuses (#1049).
|
||||
|
||||
use photocraft_codecs::*;
|
||||
|
||||
const HEADER: usize = 2048;
|
||||
const ELEMENT0: usize = 780;
|
||||
|
||||
/// A DPX file built field by field.
|
||||
#[derive(Clone)]
|
||||
struct Fixture {
|
||||
big: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
descriptor: u8,
|
||||
bits: u8,
|
||||
packing: u16,
|
||||
encoding: u16,
|
||||
signed: bool,
|
||||
transfer: u8,
|
||||
colorimetric: u8,
|
||||
orientation: u16,
|
||||
elements: u16,
|
||||
strings: Vec<((usize, usize), &'static str)>,
|
||||
frame_position: u32,
|
||||
frame_rate: f32,
|
||||
time_code: u32,
|
||||
/// Code values, interleaved, top line first.
|
||||
codes: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(descriptor: u8, bits: u8, packing: u16, width: u32, height: u32) -> Self {
|
||||
let channels = channels(descriptor);
|
||||
let max = (1u32 << bits) - 1;
|
||||
// Spread over the whole range, ends included.
|
||||
let n = (width * height) as usize * channels;
|
||||
let codes = (0..n)
|
||||
.map(|i| {
|
||||
if i == 0 {
|
||||
0
|
||||
} else if i == 1 {
|
||||
max as u16
|
||||
} else {
|
||||
((i as u64 * 2654435761) % (u64::from(max) + 1)) as u16
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Fixture {
|
||||
big: true,
|
||||
width,
|
||||
height,
|
||||
descriptor,
|
||||
bits,
|
||||
packing,
|
||||
encoding: 0,
|
||||
signed: false,
|
||||
transfer: 255,
|
||||
colorimetric: 255,
|
||||
orientation: 0,
|
||||
elements: 1,
|
||||
strings: Vec::new(),
|
||||
frame_position: u32::MAX,
|
||||
frame_rate: f32::from_bits(u32::MAX),
|
||||
time_code: u32::MAX,
|
||||
codes,
|
||||
}
|
||||
}
|
||||
|
||||
fn u16(&self, v: u16) -> [u8; 2] {
|
||||
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
|
||||
}
|
||||
|
||||
fn u32(&self, v: u32) -> [u8; 4] {
|
||||
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
|
||||
}
|
||||
|
||||
fn line(&self, codes: &[u16]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
match (self.bits, self.packing) {
|
||||
(8, _) => out.extend(codes.iter().map(|&c| c as u8)),
|
||||
(16, _) => out.extend(codes.iter().flat_map(|&c| self.u16(c))),
|
||||
(10, 1 | 2) => {
|
||||
let base = if self.packing == 1 { 22 } else { 20 };
|
||||
for chunk in codes.chunks(3) {
|
||||
let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (base - 10 * i as u32)));
|
||||
out.extend(self.u32(w));
|
||||
}
|
||||
}
|
||||
(12, 1) => out.extend(codes.iter().flat_map(|&c| self.u16(c << 4))),
|
||||
_ => {
|
||||
// One bit stream, high bits first, cut into 32-bit words.
|
||||
let mut bits: Vec<bool> = codes.iter().flat_map(|&c| (0..self.bits).rev().map(move |b| (c >> b) & 1 == 1)).collect();
|
||||
bits.resize(bits.len().div_ceil(32) * 32, false);
|
||||
for word in bits.chunks(32) {
|
||||
out.extend(self.u32(word.iter().fold(0u32, |w, &b| (w << 1) | u32::from(b))));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.resize(out.len().div_ceil(4) * 4, 0);
|
||||
out
|
||||
}
|
||||
|
||||
fn bytes(&self) -> Vec<u8> {
|
||||
let mut b = vec![0u8; HEADER];
|
||||
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
|
||||
put(0, if self.big { b"SDPX" } else { b"XPDS" });
|
||||
put(4, &self.u32(HEADER as u32));
|
||||
put(8, b"V2.0");
|
||||
put(24, &self.u32(1664));
|
||||
put(28, &self.u32(384));
|
||||
put(768, &self.u16(self.orientation));
|
||||
put(770, &self.u16(self.elements));
|
||||
put(772, &self.u32(self.width));
|
||||
put(776, &self.u32(self.height));
|
||||
for el in 0..usize::from(self.elements.min(8)) {
|
||||
let e = ELEMENT0 + el * 72;
|
||||
put(e, &self.u32(u32::from(self.signed)));
|
||||
put(e + 20, &[self.descriptor, self.transfer, self.colorimetric, self.bits]);
|
||||
put(e + 24, &self.u16(self.packing));
|
||||
put(e + 26, &self.u16(self.encoding));
|
||||
put(e + 28, &self.u32(HEADER as u32));
|
||||
}
|
||||
for &((at, len), s) in &self.strings {
|
||||
assert!(s.len() < len);
|
||||
put(at, s.as_bytes());
|
||||
}
|
||||
put(1712, &self.u32(self.frame_position));
|
||||
put(1724, &self.u32(self.frame_rate.to_bits()));
|
||||
put(1920, &self.u32(self.time_code));
|
||||
let per_line = self.width as usize * channels(self.descriptor);
|
||||
for line in self.codes.chunks(per_line) {
|
||||
b.extend(self.line(line));
|
||||
}
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(descriptor: u8) -> usize {
|
||||
match descriptor {
|
||||
6 => 1,
|
||||
50 => 3,
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(descriptor: u8) -> ChannelLayout {
|
||||
match descriptor {
|
||||
6 => ChannelLayout::Gray,
|
||||
50 => ChannelLayout::Rgb,
|
||||
_ => ChannelLayout::Rgba,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 16-bit sample a code value opens as: `round(c × 65535 / (2^b − 1))`.
|
||||
fn expected(c: u16, bits: u8) -> u16 {
|
||||
(f64::from(c) * 65535.0 / f64::from((1u32 << bits) - 1)).round() as u16
|
||||
}
|
||||
|
||||
/// Reads the code values of a big-endian file we wrote (packing 0 at 8/16 bits, 1 at 10/12).
|
||||
fn written_codes(b: &[u8]) -> (u8, u16, Vec<u16>) {
|
||||
let be16 = |at: usize| u16::from_be_bytes([b[at], b[at + 1]]);
|
||||
let be32 = |at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]);
|
||||
let (w, h) = (be32(772) as usize, be32(776) as usize);
|
||||
let nc = channels(b[ELEMENT0 + 20]);
|
||||
let (bits, packing) = (b[ELEMENT0 + 23], be16(ELEMENT0 + 24));
|
||||
let off = be32(ELEMENT0 + 28) as usize;
|
||||
let per_line = w * nc;
|
||||
let stride = match bits {
|
||||
10 => per_line.div_ceil(3) * 4,
|
||||
_ => (per_line * if bits == 8 { 1 } else { 2 }).div_ceil(4) * 4,
|
||||
};
|
||||
let mut codes = Vec::new();
|
||||
for y in 0..h {
|
||||
let line = &b[off + y * stride..];
|
||||
for i in 0..per_line {
|
||||
codes.push(match bits {
|
||||
8 => u16::from(line[i]),
|
||||
10 => ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16,
|
||||
12 => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]) >> 4,
|
||||
_ => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]),
|
||||
});
|
||||
}
|
||||
}
|
||||
(bits, packing, codes)
|
||||
}
|
||||
|
||||
fn be32_at(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
|
||||
}
|
||||
|
||||
fn opts(bits: Option<DpxBits>) -> EncodeOptions {
|
||||
EncodeOptions { dpx_bits: bits, ..EncodeOptions::default() }
|
||||
}
|
||||
|
||||
const COMBOS: [(u8, u16); 7] = [(8, 0), (10, 0), (10, 1), (10, 2), (12, 0), (12, 1), (16, 0)];
|
||||
|
||||
#[test]
|
||||
fn every_descriptor_depth_and_packing_opens_with_its_model_and_values() {
|
||||
for descriptor in [6, 50, 51] {
|
||||
for (bits, packing) in COMBOS {
|
||||
// Widths that leave partly filled words at the end of a line.
|
||||
let fx = Fixture::new(descriptor, bits, packing, 5, 3);
|
||||
let img = decode(&fx.bytes()).unwrap_or_else(|e| panic!("{descriptor} {bits}/{packing}: {e}"));
|
||||
let what = format!("descriptor {descriptor}, {bits} bits, packing {packing}");
|
||||
assert_eq!(detect(&fx.bytes()), Some(Format::Dpx));
|
||||
assert_eq!((img.width(), img.height()), (5, 3), "{what}");
|
||||
assert_eq!(img.layout(), layout(descriptor), "{what}");
|
||||
assert_eq!(img.layout().has_alpha(), descriptor == 51, "{what}");
|
||||
match bits {
|
||||
8 => assert_eq!(img.as_u8().unwrap(), fx.codes.iter().map(|&c| c as u8).collect::<Vec<_>>(), "{what}"),
|
||||
16 => assert_eq!(img.to_u16_samples().unwrap(), fx.codes, "{what}"),
|
||||
_ => assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, bits)).collect::<Vec<_>>(), "{what}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_and_little_endian_files_open_identically() {
|
||||
for (bits, packing) in COMBOS {
|
||||
let be = Fixture::new(50, bits, packing, 4, 2);
|
||||
let le = Fixture { big: false, ..be.clone() };
|
||||
assert_eq!(&le.bytes()[..4], b"XPDS");
|
||||
assert_eq!(decode(&be.bytes()).unwrap(), decode(&le.bytes()).unwrap(), "{bits} bits, packing {packing}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_code_value_opens_as_the_rounded_16_bit_value() {
|
||||
let mut fx = Fixture::new(6, 10, 1, 1, 1);
|
||||
fx.codes = vec![512];
|
||||
assert_eq!(decode(&fx.bytes()).unwrap().to_u16_samples().unwrap(), vec![(512.0f64 * 65535.0 / 1023.0).round() as u16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_and_twelve_bit_frames_written_back_at_their_depth_keep_their_code_values() {
|
||||
for (bits, choice) in [(10, DpxBits::Bits10), (12, DpxBits::Bits12)] {
|
||||
for packing in [0, 1] {
|
||||
let fx = Fixture::new(50, bits, packing, 7, 3);
|
||||
let out = encode(&decode(&fx.bytes()).unwrap(), Format::Dpx, &opts(Some(choice))).unwrap();
|
||||
let (wbits, wpacking, codes) = written_codes(&out);
|
||||
assert_eq!((wbits, wpacking), (bits, 1));
|
||||
assert_eq!(codes, fx.codes, "{bits} bits, packing {packing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_frames_round_trip_losslessly_and_files_start_with_sdpx() {
|
||||
let fx = Fixture { big: false, ..Fixture::new(51, 16, 0, 6, 4) };
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(&out[..4], b"SDPX");
|
||||
let (bits, packing, codes) = written_codes(&out);
|
||||
assert_eq!((bits, packing, codes), (16, 0, fx.codes));
|
||||
assert_eq!(decode(&out).unwrap().to_u16_samples(), img.to_u16_samples());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_frames_report_their_encoding_and_keep_the_plain_mapping() {
|
||||
let mut fx = Fixture::new(50, 10, 1, 3, 2);
|
||||
(fx.transfer, fx.colorimetric) = (3, 1);
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
assert_eq!(img.warnings, vec![DecodeWarning::DpxEncoding { transfer: 3, colorimetric: 1 }]);
|
||||
let note = img.warnings[0].to_string();
|
||||
assert!(note.contains("logarithmic (3)") && note.contains("printing density (1)"), "{note}");
|
||||
assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, 10)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_of_several_image_elements_is_imported() {
|
||||
let mut fx = Fixture::new(6, 8, 0, 2, 2);
|
||||
fx.elements = 2;
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
assert_eq!(img.warnings.iter().map(ToString::to_string).collect::<Vec<_>>(), ["only the first of 2 image elements was imported"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_header_fields_survive_a_round_trip() {
|
||||
let mut fx = Fixture::new(50, 10, 1, 2, 2);
|
||||
fx.strings = vec![((160, 100), "Resolve 20.1"), ((260, 200), "Reel 7"), ((460, 200), "(c) 2026 Studio"), ((136, 24), "2026:10:12:09:30:00UTC")];
|
||||
(fx.transfer, fx.colorimetric, fx.frame_position, fx.frame_rate, fx.time_code) = (3, 1, 86400, 23.976, 0x0102_0304);
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
for (k, v) in [
|
||||
("DPX:Creator", "Resolve 20.1"),
|
||||
("DPX:Project", "Reel 7"),
|
||||
("DPX:Copyright", "(c) 2026 Studio"),
|
||||
("DPX:CreationTime", "2026:10:12:09:30:00UTC"),
|
||||
("DPX:Transfer", "3"),
|
||||
("DPX:Colorimetric", "1"),
|
||||
("DPX:FramePosition", "86400"),
|
||||
("DPX:FrameRate", "23.976"),
|
||||
("DPX:TimeCode", "01:02:03:04"),
|
||||
] {
|
||||
assert!(img.meta.text.contains(&(k.into(), v.into())), "{k} = {v} in {:?}", img.meta.text);
|
||||
}
|
||||
let out = encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits10))).unwrap();
|
||||
let src = fx.bytes();
|
||||
// Field by field: the strings, the element's encoding codes, film and TV fields.
|
||||
for (at, len) in [(160, 100), (260, 200), (460, 200), (136, 24), (ELEMENT0 + 21, 2), (1712, 4), (1724, 4), (1920, 4)] {
|
||||
assert_eq!(out[at..at + len], src[at..at + len], "field at {at}");
|
||||
}
|
||||
assert_eq!(decode(&out).unwrap().meta.text, img.meta.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_bit_depth_sets_the_header_and_packing() {
|
||||
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000).collect::<Vec<_>>()).unwrap();
|
||||
for (choice, bits, packing) in [(DpxBits::Bits8, 8, 0), (DpxBits::Bits10, 10, 1), (DpxBits::Bits12, 12, 1), (DpxBits::Bits16, 16, 0)] {
|
||||
let out = encode(&img, Format::Dpx, &opts(Some(choice))).unwrap();
|
||||
assert_eq!((out[ELEMENT0 + 23], u16::from_be_bytes([out[ELEMENT0 + 24], out[ELEMENT0 + 25]])), (bits, packing), "{choice:?}");
|
||||
let back = decode(&out).unwrap();
|
||||
assert_eq!((back.width(), back.height(), back.layout()), (5, 2, ChannelLayout::Rgb));
|
||||
}
|
||||
// Without a choice the depth follows the image.
|
||||
let gray8 = Image::from_u8(3, 1, ChannelLayout::Gray, vec![0, 128, 255]).unwrap();
|
||||
assert_eq!(encode(&gray8, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 8);
|
||||
assert_eq!(encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16);
|
||||
let float = img.convert(ChannelLayout::Rgb, SampleType::F32);
|
||||
assert_eq!(encode(&float, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16);
|
||||
assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_with_alpha_is_written_as_luma_with_a_warning() {
|
||||
let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap();
|
||||
let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(out[ELEMENT0 + 20], 6);
|
||||
assert_eq!(decode(&out).unwrap().as_u8().unwrap(), &[10, 200]);
|
||||
assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::AlphaDiscarded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_free_form_text_is_reported_as_dropped() {
|
||||
let mut img = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![1, 2, 3]).unwrap();
|
||||
img.meta.text = vec![("DPX:Project".into(), "Reel 7".into())];
|
||||
assert!(!fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped));
|
||||
img.meta.text.push(("Comment".into(), "from a PNG".into()));
|
||||
assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_orientation_is_turned_upright() {
|
||||
// Orientation 4: the stored lines are the image's columns, left to right.
|
||||
let mut fx = Fixture::new(6, 8, 0, 3, 1);
|
||||
fx.codes = vec![1, 2, 3];
|
||||
fx.orientation = 4;
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.as_u8().unwrap()), (1, 3, &[1u8, 2, 3][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_damaged_files_error_instead_of_panicking() {
|
||||
let unsupported = |bytes: Vec<u8>, what: &str| {
|
||||
assert!(matches!(decode(&bytes), Err(CodecError::Unsupported { format: Format::Dpx, .. })), "{what}");
|
||||
};
|
||||
unsupported(Fixture { descriptor: 100, ..Fixture::new(50, 8, 0, 2, 2) }.bytes(), "ABGR descriptor");
|
||||
unsupported(Fixture { encoding: 1, ..Fixture::new(50, 10, 1, 2, 2) }.bytes(), "run-length encoding");
|
||||
unsupported(Fixture { signed: true, ..Fixture::new(50, 16, 0, 2, 2) }.bytes(), "signed samples");
|
||||
let mut float = Fixture::new(50, 16, 0, 2, 2).bytes();
|
||||
float[ELEMENT0 + 23] = 32;
|
||||
unsupported(float, "32-bit float");
|
||||
let mut method_b = Fixture::new(50, 12, 1, 2, 2).bytes();
|
||||
method_b[ELEMENT0 + 24..ELEMENT0 + 26].copy_from_slice(&2u16.to_be_bytes());
|
||||
unsupported(method_b, "12-bit method B");
|
||||
|
||||
let good = Fixture::new(51, 10, 1, 9, 9).bytes();
|
||||
let mut no_elements = good.clone();
|
||||
no_elements[770..772].copy_from_slice(&0u16.to_be_bytes());
|
||||
let mut past_end = good.clone();
|
||||
past_end[ELEMENT0 + 28..ELEMENT0 + 32].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes());
|
||||
let mut huge = good.clone();
|
||||
huge[772..780].copy_from_slice(&[0xFF; 8]);
|
||||
for (what, bytes) in [
|
||||
("header cut short", good[..900].to_vec()),
|
||||
("truncated element", good[..good.len() - 1].to_vec()),
|
||||
("no image elements", no_elements),
|
||||
("data offset past the end", past_end),
|
||||
("absurd size", huge),
|
||||
("magic alone", b"SDPX".to_vec()),
|
||||
] {
|
||||
assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}");
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ fn truncated_files_mostly_error() {
|
||||
// Chopping off the second half must not silently succeed for formats
|
||||
// with length-checked rasters.
|
||||
for (f, bytes) in samples() {
|
||||
if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
|
||||
if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Dpx | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
|
||||
let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight());
|
||||
assert!(r.is_err(), "{f:?} decoded half a file");
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ mod pnm {
|
||||
mod fits {
|
||||
cases!(Format::Fits);
|
||||
}
|
||||
mod dpx {
|
||||
cases!(Format::Dpx);
|
||||
}
|
||||
mod qoi {
|
||||
cases!(Format::Qoi);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ pub(crate) fn list_images(dir: &str) -> Result<Vec<String>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const OPENABLE: &[&str] = &[
|
||||
"pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm",
|
||||
"fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub",
|
||||
"fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
|
||||
/// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone
|
||||
@@ -229,6 +229,8 @@ pub(crate) struct SaveOpts {
|
||||
/// Targa: 24 or 32 bits per pixel (`"tgaBits"`); unset, 32 with an alpha channel or
|
||||
/// transparency, else 24.
|
||||
pub tga_bits: Option<photocraft_io::TgaBits>,
|
||||
/// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16.
|
||||
pub dpx_bits: Option<photocraft_io::DpxBits>,
|
||||
/// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews).
|
||||
pub image_previews: bool,
|
||||
/// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB
|
||||
@@ -238,7 +240,7 @@ pub(crate) struct SaveOpts {
|
||||
|
||||
impl Default for SaveOpts {
|
||||
fn default() -> Self {
|
||||
SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true, psd_maximize_compatibility: true }
|
||||
SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +255,7 @@ impl SaveOpts {
|
||||
quality: f64_param(p, "quality"),
|
||||
tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false),
|
||||
tga_bits: tga_bits_param(p, cmd)?,
|
||||
dpx_bits: dpx_bits_param(p, cmd)?,
|
||||
image_previews: file_handling.image_previews != crate::prefs::Ask::Never,
|
||||
psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never,
|
||||
})
|
||||
@@ -284,6 +287,19 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::TgaB
|
||||
}
|
||||
}
|
||||
|
||||
/// The DPX bits per sample in `p` (`"dpxBits": 8 | 10 | 12 | 16`; absent or null: the document's
|
||||
/// depth). Anything else is a bad parameter of command `cmd`.
|
||||
pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::DpxBits>> {
|
||||
match p.get("dpxBits") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(photocraft_io::DpxBits::from_bits)
|
||||
.map(Some)
|
||||
.ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`dpxBits` must be 8, 10, 12 or 16, got {v}") }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes `doc` for `path`'s extension.
|
||||
pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
crate::allocation::checkpoint("saving document")?;
|
||||
@@ -298,6 +314,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
|
||||
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
|
||||
// lossy or lossless as it was), as Photoshop's Save does.
|
||||
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
|
||||
opts.encode.dpx_bits = save.dpx_bits;
|
||||
if let Some(q) = save.quality {
|
||||
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
|
||||
opts.encode.jpeg_quality = q;
|
||||
@@ -1405,7 +1422,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save a Copy…",
|
||||
&["File"],
|
||||
Some("Cmd+Alt+S"),
|
||||
r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24)}"##,
|
||||
r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##,
|
||||
native_doc,
|
||||
save_a_copy
|
||||
),
|
||||
@@ -1468,7 +1485,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch…",
|
||||
&["File", "Automate"],
|
||||
None,
|
||||
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
|
||||
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
|
||||
native,
|
||||
batch
|
||||
),
|
||||
@@ -1477,7 +1494,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Image Processor…",
|
||||
&["File", "Scripts"],
|
||||
None,
|
||||
r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
|
||||
r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
|
||||
native,
|
||||
image_processor
|
||||
),
|
||||
@@ -1505,7 +1522,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Layers to Files…",
|
||||
&["File", "Export"],
|
||||
None,
|
||||
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?} → {files}"##,
|
||||
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##,
|
||||
native_doc,
|
||||
layers_to_files
|
||||
),
|
||||
|
||||
@@ -155,6 +155,32 @@ fn save_a_copy_writes_the_targa_bits_asked_for() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `dpxBits` picks a DPX's bits per sample (byte 803 of the header); unset, it follows the
|
||||
/// document's depth. Anything but 8, 10, 12 or 16 is refused before anything is written.
|
||||
#[test]
|
||||
fn save_a_copy_writes_the_dpx_bits_asked_for() {
|
||||
let dir = tmp("dpx-bits");
|
||||
for (depth, params, bits) in
|
||||
[(8, json!({}), 8), (16, json!({}), 16), (16, json!({"dpxBits": 10}), 10), (8, json!({"dpxBits": 12}), 12), (16, json!({"dpxBits": null}), 16)]
|
||||
{
|
||||
let mut s = session(12, 6, depth);
|
||||
let out = join(&dir, "a.dpx");
|
||||
let mut p = params.clone();
|
||||
p["path"] = json!(out);
|
||||
s.execute("file.saveACopy", p).unwrap();
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], bits), "{depth}-bit {params}");
|
||||
assert_eq!(photocraft_io::import("a.dpx", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6));
|
||||
}
|
||||
let mut s = session(8, 8, 8);
|
||||
for bad in [json!(24), json!(0), json!("10"), json!(10.5)] {
|
||||
let out = join(&dir, "bad.dpx");
|
||||
let r = s.execute("file.saveACopy", json!({"path": out, "dpxBits": bad}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}");
|
||||
assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `tgaBits` other than 24 or 32 is a bad parameter, reported before anything is written.
|
||||
#[test]
|
||||
fn bad_targa_bits_are_refused_before_writing() {
|
||||
|
||||
@@ -61,6 +61,7 @@ pub mod text_styles_map;
|
||||
pub mod tiff_layers;
|
||||
pub mod vector_map;
|
||||
|
||||
pub use photocraft_codecs::DpxBits;
|
||||
use photocraft_codecs::{CodecError, EncodeOptions};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_psd::{PsdError, PsdFile};
|
||||
|
||||
@@ -53,6 +53,9 @@ codec_rt!(tiff_gray8, "tiff", ColorMode::Grayscale, SampleType::U8, false, 0.0);
|
||||
codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0);
|
||||
codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0);
|
||||
codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0);
|
||||
codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0);
|
||||
codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0);
|
||||
codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0);
|
||||
|
||||
fn indexed_bmp_8() -> Vec<u8> {
|
||||
const FILE_HEADER_SIZE: usize = 14;
|
||||
@@ -111,6 +114,25 @@ fn fits_header_cards_round_trip_as_text() {
|
||||
}
|
||||
}
|
||||
|
||||
/// DPX header fields travel as the document's text metadata and come back on a DPX export at the
|
||||
/// chosen bit depth; the transfer characteristic is reported when the frame is opened (#1049).
|
||||
#[test]
|
||||
fn dpx_header_fields_and_encoding_notes_round_trip_through_the_document() {
|
||||
let mut d = single(ColorMode::Rgb, SampleType::U16, false);
|
||||
d.metadata.text = vec![("DPX:Project".into(), "Reel 7".into()), ("DPX:Transfer".into(), "3".into()), ("DPX:TimeCode".into(), "01:00:00:12".into())];
|
||||
let mut opts = ExportOptions::default();
|
||||
opts.encode.dpx_bits = Some(DpxBits::Bits10);
|
||||
let r = export(&d, "a.dpx", &opts).unwrap();
|
||||
assert!(r.bytes.starts_with(b"SDPX"));
|
||||
assert_eq!(r.bytes[803], 10, "bits per sample");
|
||||
let back = import("a.dpx", &r.bytes).unwrap();
|
||||
for kv in &d.metadata.text {
|
||||
assert!(back.document.metadata.text.contains(kv), "{kv:?}: {:?}", back.document.metadata.text);
|
||||
}
|
||||
assert!(back.warnings.iter().any(|w| w.contains("transfer characteristic: logarithmic (3)")), "{:?}", back.warnings);
|
||||
assert_eq!(back.document.depth, SampleType::U16);
|
||||
}
|
||||
|
||||
/// EXR stores linear light: a linear document round-trips exactly; an sRGB (untagged) one is
|
||||
/// linearised on export and comes back tagged linear sRGB with the same colours.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DPX read and write, #1049; Photoshop Actions `.atn` read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
|
||||
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
|
||||
@@ -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) | no (#1049) | 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 |
|
||||
| DICOM | R/W | no | no | |
|
||||
| PCX | R/W | no | no | |
|
||||
| Pixar | R/W | no | no | |
|
||||
@@ -140,6 +140,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing |
|
||||
| 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) |
|
||||
| 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) |
|
||||
| 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where PhotoCraft falls short of Photoshop
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **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
|
||||
@@ -195,7 +195,7 @@ 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),
|
||||
DPX/Cineon (#1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop
|
||||
Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, 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); TGA/PNG alpha channels on open (#2225); C2PA (#1050).
|
||||
- **Detail:** [`file-format-parity.md`](file-format-parity.md).
|
||||
@@ -238,6 +238,7 @@ what an agent can't finish alone.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open |
|
||||
|
||||
Reference in new issue
Block a user