From 587cb6917a96a3a10647d3e4cbf3a074e186a92f Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 00:00:32 +0200 Subject: [PATCH 01/20] HDR export Phase 1: recon, plan, and color-science gate - PLAN.md / HDR_WORKLOG.md: confirmed architecture against checkout. The 8-bit ceiling is shader.wgsl's rgba8unorm storage output (L198); headroom is clipped upstream at the clamping linear_to_srgb encode (L1668/L1675), not just the final store clamp (L1734). - New pub module src-tauri/src/hdr.rs: pinned ST.2084 PQ inverse-EOTF + EOTF, BT.2100 HLG OETF, extended-sRGB<->linear (exact inverse of the shader's transfer), BT.2087 Rec.709->Rec.2020 matrix, CICP tag mapping, full-range quantization, and a synthetic linear test image (black / diffuse-white / 2x / 4x / ramp patches). - 6 unit tests pin the math: 203-nit ref white -> PQ code 594 (within 2 of the brief's 593), strictly-increasing headroom codes, headroom-preserving sRGB roundtrip, neutral-preserving primaries matrix. --- HDR_WORKLOG.md | 65 +++++++++ PLAN.md | 80 +++++++++++ src-tauri/src/hdr.rs | 333 +++++++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 4 files changed, 479 insertions(+) create mode 100644 HDR_WORKLOG.md create mode 100644 PLAN.md create mode 100644 src-tauri/src/hdr.rs diff --git a/HDR_WORKLOG.md b/HDR_WORKLOG.md new file mode 100644 index 0000000000..eceaa33844 --- /dev/null +++ b/HDR_WORKLOG.md @@ -0,0 +1,65 @@ +# HDR Export — Worklog + +Running log for the `feat/hdr-export` branch: add true HDR (PQ/HLG) AVIF + JXL export, +BT.2020-tagged, with highlight headroom preserved. + +## Phase 0 — Fork, clone, baseline build ✅ +- Forked `CyberTimon/RapidRAW` → `KaiserRuben/RapidRAW`, cloned, branch `feat/hdr-export`. +- Toolchain installed on macOS arm64: rustup → rustc/cargo **1.96.0** (edition 2024 OK, + repo pins rust-version 1.95). `cmake 4.3.3`, `nasm 3.01` (for C encoder deps) via brew. +- License: **AGPL-3.0** — kept intact; new files carry no license-stripping. +- `build.rs` auto-downloads `libonnxruntime.dylib` (macos-aarch64, 33M) from HuggingFace and + sha256-verifies it at build time — so the `ort` `load-dynamic` runtime lib is handled by the + build itself (no manual ONNX setup needed). +- **Baseline `cargo build` (debug): PASS** in 5m12s, 17 warnings, 0 errors. `npm install`: OK. + +## Phase 1 — Recon (confirmed against checkout) ✅ +The 8-bit ceiling is exactly where the brief said, and the headroom is clipped *earlier* than +the final store. Confirmed line numbers: + +**`src-tauri/src/shaders/shader.wgsl`** +- L198: `output_texture: texture_storage_2d` — 8-bit storage output. +- L1668 / L1675: `linear_to_srgb(composite_rgb_linear)` — the **clamping** sRGB encode + (`linear_to_srgb` clamps input to [0,1] at L229). **This is the real headroom killer** — + values above diffuse white (linear > 1.0) are clamped to 1.0 here, *before* the final store. +- L1732: `+ dither(id.xy) * (1.0/255.0)` — 8-bit dither (would add PQ-code noise; skip for HDR). +- L1734: `clamp(final_rgb, 0.0, 1.0)` then `textureStore` — second clamp at the store. +- Internals are linear; `srgb_to_linear` (L220) is the exact inverse of `linear_to_srgb_extended` + (L237) for c≥0. So: encode with `_extended`, read back, `srgb_to_linear` on CPU → recover + linear light with headroom. + +**`src-tauri/src/gpu_processing.rs`** +- Pipeline is f16 end-to-end except the final texture: input upload `to_rgba_f16` (L484); + intermediates `Rgba16Float` (L582…L1762); **`blur.wgsl`/`flare.wgsl` already use + `texture_storage_2d`** → rgba16float storage write is proven on this Metal + backend (de-risks the format change). +- `GpuProcessor.tile_output_texture` (created L997, fmt `Rgba8Unorm` L1003) and `output_texture` + (L1026/L1032) — both bound to shader.wgsl's storage output. `main_bgl` hardcodes the storage + binding format `Rgba8Unorm` at **L796**. +- `main_pipeline` (L910) from `shader.wgsl` (L777). +- `run()` (L1076) ALWAYS tiles; main compute writes to `tile_output_texture`; `read_texture_data_roi` + (L414, hardcodes `4 * width` bytes/row) reads it back; assembled into `final_pixels: Vec` + (`* 4` at L1558/1562) → `processed_pixels` → `DynamicImage::ImageRgba8` (L2016). + +**`src-tauri/src/export_processing.rs`** +- `ExportSettings` struct (L58) — serde camelCase; needs new HDR fields. +- `encode_image_to_bytes` (L388): jxl → `jxl_encoder` hardwired Rgb8/Rgba8; avif → + `image::ImageFormat::Avif` (ravif, 8-bit, no CICP); png/tiff → `to_rgb16()` on 8-bit data. +- `save_image_with_metadata` (L283) calls `encode_image_to_bytes(image, &extension, jpeg_quality)`. +- `process_image_for_export_pipeline` (L214) → `process_and_get_dynamic_image` (GPU). + +## Data contract decided +- **GPU/readback (HDR path):** HDR shader variant (string-substituted from `shader.wgsl`): + `rgba8unorm`→`rgba16float`, `linear_to_srgb`→`linear_to_srgb_extended`, drop the [0,1] store + clamp (keep `max(·,0)`), zero the dither. Read back rgba16float (8 B/px), f16→f32, then + `srgb_to_linear` on CPU → `DynamicImage::ImageRgba32F` in **LINEAR scene-referred light, + diffuse white = 1.0, headroom > 1.0 preserved**. SDR path is byte-for-byte UNTOUCHED (separate, + lazily-created HDR pipeline + textures + BGL). +- **Encoder (HDR path):** takes linear ImageRgba32F → optional Rec.709→Rec.2020 primaries matrix + → PQ inverse-EOTF with **203-nit anchor** (`L = linear * 203/10000`) → quantize to 10/12-bit + full-range → AVIF/JXL with CICP primaries=9, transfer=16(PQ)/18(HLG), matrix=9, full_range=1. + +## Phase 1.5 — Verification harness (gate) — in progress +## Phase 2 — Implementation — pending (blocked on encoder-lib spike, running in background) + + diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000000..ed5e7f0113 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,80 @@ +# PLAN — HDR (PQ/HLG) AVIF + JXL export + +## Architecture decision (why dual-pipeline, CPU PQ) + +1. **Keep SDR byte-identical.** The brief forbids altering SDR output without asking. The 8-bit + storage texture's HW quantization can't be byte-reproduced after an f16 round-trip, so instead + of converting the single pipeline I add a **separate, lazily-created HDR pipeline + textures + + BGL** (Rgba16Float). The existing rgba8unorm path is left completely untouched and is used for + every SDR format and the interactive display. + +2. **Color science lives on the CPU, in one testable function.** The GPU HDR variant outputs the + *same look* as SDR (curves/LUT/grain run in the same sRGB space) but encoded with the + *extended* sRGB OETF (no upper clamp) and no dither. On readback we invert that + (`srgb_to_linear`) to get linear scene-referred light with headroom, as `ImageRgba32F`. The + PQ/HLG OETF + primaries matrix + quantization happen in Rust where the harness can unit-test + them against pinned ST.2084 constants. This keeps the GPU change minimal and the math verifiable. + +## Edit list (commit per item) + +### C-1 Verification harness FIRST (gate) — `src-tauri/tests/hdr_export.rs` + helpers +- New module `src-tauri/src/hdr.rs` (pub) holding the pure color-science fns so both the encoder + and the test use ONE implementation: + - `pq_inverse_eotf(l_norm: f32) -> f32` (ST.2084 constants pinned from brief). + - `srgb_extended_to_linear(c: f32) -> f32` (exact inverse of shader `linear_to_srgb_extended`). + - `rec709_to_rec2020_linear([f32;3]) -> [f32;3]` (Bradford-adapted matrix, white-preserving). + - `REFERENCE_WHITE_NITS = 203.0`, `PQ_MAX_NITS = 10000.0`. +- Integration test asserts, exiting non-zero on failure: + 1. **Tags:** parse the raw ISOBMFF `colr`/`nclx` box from the AVIF bytes → + primaries==9, transfer==16 (and a 18/HLG variant), matrix==9, full_range==1. JXL: decode and + read reported primaries/transfer. + 2. **Bit depth** == 10, repeat == 12. + 3. **Reference white:** linear 1.0 patch → decoded PQ code ≈ 0.5797·1023 = **593** (±2). + 4. **Headroom:** linear 2.0 and 4.0 patches → strictly-increasing distinct codes above the + diffuse-white code (and below full scale). +- Synthetic input built in `hdr.rs` (`fn synthetic_linear_image() -> ImageBuffer>`): + 203-nit/diffuse-white flat patch (=1.0), 0→1 ramp, patches at 2.0 and 4.0. +- A pure-math unit test pins `pq_inverse_eotf(0.0203) ≈ 0.580` independent of any encoder. + +### C-2 Encoder deps + HDR encode functions — `src-tauri/Cargo.toml`, `export_processing.rs` +- Add encoder crates per the spike verdict (expected: `rav1e` + `avif-serialize` for AVIF — pure + Rust, no C dep; `jpegxl-rs` + system `libjxl` (brew `jpeg-xl`) for JXL). Confirm before adding. +- `hdr.rs`: `encode_avif_hdr(img: &Rgba32FImage, bit_depth, transfer, primaries) -> Vec` and + `encode_jxl_hdr(...)`. These do: (optional) 709→2020, PQ/HLG OETF, quantize, encode + tag CICP. +- `encode_image_to_bytes` gains an `&ExportSettings`-derived color config; avif/jxl arms branch to + the HDR encoders when `bit_depth > 8` / `transfer != sRGB`. SDR arms unchanged. + +### C-3 GPU HDR output path — `gpu_processing.rs`, `shader.wgsl` (via runtime string-substitution) +- Build HDR shader source at runtime: `include_str!("shaders/shader.wgsl")` then + `.replace("rgba8unorm, write>", "rgba16float, write>")`, + `.replace("linear_to_srgb(composite_rgb_linear)", "linear_to_srgb_extended(composite_rgb_linear)")` + (hits L1668+L1675), `.replace("clamp(final_rgb, vec3(0.0), vec3(1.0))", + "max(final_rgb, vec3(0.0))")`, `.replace("let dither_amount = 1.0 / 255.0;", + "let dither_amount = 0.0;")`. +- `GpuProcessor` gains `hdr: Option` (BGL with Rgba16Float storage binding, pipeline, + `tile_output_texture` Rgba16Float), created lazily on first HDR export. +- `read_texture_data_roi` + the tile-assembly in `run()` parameterized by `bytes_per_pixel` + (4 for SDR, 8 for HDR f16). `run()` gains an output-mode arg; HDR returns the raw f16 bytes. +- `process_and_get_dynamic_image[_inner]` gains an HDR flag; for HDR, decode f16→f32, apply + `srgb_to_linear` per channel → `DynamicImage::ImageRgba32F`. Existing callers default to SDR. + +### C-4 Settings + command plumbing + UI — `export_processing.rs`, frontend +- `ExportSettings`: add `bit_depth: u8` (8/10/12), `transfer_function: enum {Srgb,Pq,Hlg}`, + `primaries: enum {Srgb,Bt2020}` (serde camelCase, `#[serde(default)]` for back-compat). +- Thread through the export Tauri command → `process_image_for_export_pipeline` (HDR GPU path) + → `encode_image_to_bytes` (HDR encoders). +- React/TS export panel: add Bit Depth + Transfer + Primaries controls, shown for AVIF/JXL. + Follow existing export-settings component patterns. + +## Open questions / risks +- **Encoder libs**: pending spike. If `jpegxl-rs` can't cleanly set PQ/Rec2020 on this platform, + document the gap and ship AVIF-HDR first (brief explicitly allows documenting a JXL gap). +- **matrix_coefficients=9 (YCbCr)** vs 0 (identity/RGB): targeting 9 + 4:4:4 full-range for HDR + compatibility (Apple/Chrome). Harness decodes to RGB; neutral patches are matrix-invariant so + the 593 target holds regardless. +- **Primaries**: tagging BT.2020 while pixels are Rec.709 would mis-saturate real images, so we + apply the 709→2020 matrix. Neutral harness patches are unchanged by it, so PQ-code asserts hold. +- **Tiling for very large HDR images**: run()'s tile path will be parameterized; if any subtlety + surfaces for multi-tile HDR it'll be flagged rather than silently capped. +- **Perceptual on-display correctness is explicitly OUT OF SCOPE** (human checkpoint). + diff --git a/src-tauri/src/hdr.rs b/src-tauri/src/hdr.rs new file mode 100644 index 0000000000..0f0cc4622c --- /dev/null +++ b/src-tauri/src/hdr.rs @@ -0,0 +1,333 @@ +//! HDR color-science primitives for the PQ/HLG export path. +//! +//! Everything that maps RapidRAW's *linear scene-referred* pixels (diffuse white = 1.0, +//! headroom > 1.0 preserved) to a tagged HDR transfer encoding lives here, so that the +//! encoders (`export_processing.rs`) and the verification harness (`tests/hdr_export.rs`) +//! share ONE implementation of the math. Constants are pinned from SMPTE ST 2084 / +//! ITU-R BT.2100 and ISO 22028-5 — do not "tidy" them. +//! +//! Reference white anchor: per ISO 22028-5, diffuse/reference white (203 cd/m²) sits at +//! ~58% of the PQ code range. We map linear `1.0` -> 203 cd/m² -> `L = 203/10000` -> PQ. + +// These are canonical reference constants (ST 2084 dyadic rationals, BT.2087 matrix) written at +// full published precision on purpose; truncating them to satisfy the lint would lose accuracy. +#![allow(clippy::excessive_precision)] + +use image::{ImageBuffer, Rgba}; + +/// Linear `1.0` (diffuse white) maps to this absolute luminance. +pub const REFERENCE_WHITE_NITS: f32 = 203.0; +/// PQ is normalized so code `1.0` == this peak luminance. +pub const PQ_MAX_NITS: f32 = 10000.0; + +// --- SMPTE ST 2084 (PQ) constants. Pinned; do not modify. --- +const PQ_M1: f32 = 0.1593017578125; // 2610/16384 +const PQ_M2: f32 = 78.84375; // 2523/4096 * 128 +const PQ_C1: f32 = 0.8359375; // 3424/4096 +const PQ_C2: f32 = 18.8515625; // 2413/4096 * 32 +const PQ_C3: f32 = 18.6875; // 2392/4096 * 32 + +/// PQ inverse-EOTF (a.k.a. OETF): normalized linear luminance `L` (1.0 == 10000 cd/m²) +/// -> non-linear PQ code in `[0, 1]`. +pub fn pq_inverse_eotf(l_norm: f32) -> f32 { + let lp = l_norm.max(0.0).powf(PQ_M1); + ((PQ_C1 + PQ_C2 * lp) / (1.0 + PQ_C3 * lp)).powf(PQ_M2) +} + +/// PQ EOTF (decode): PQ code in `[0,1]` -> normalized linear luminance (1.0 == 10000 cd/m²). +/// Exact inverse of [`pq_inverse_eotf`]; used by the harness to reason about decoded codes. +pub fn pq_eotf(e: f32) -> f32 { + let ep = e.clamp(0.0, 1.0).powf(1.0 / PQ_M2); + let num = (ep - PQ_C1).max(0.0); + let den = PQ_C2 - PQ_C3 * ep; + if den <= 0.0 { + return 1.0; + } + (num / den).powf(1.0 / PQ_M1) +} + +/// Convenience: linear scene value (diffuse white = 1.0) -> PQ code, applying the 203-nit anchor. +pub fn linear_scene_to_pq(linear: f32) -> f32 { + pq_inverse_eotf(linear * (REFERENCE_WHITE_NITS / PQ_MAX_NITS)) +} + +// --- ITU-R BT.2100 HLG OETF (scene-referred, normalized E in [0,1]). --- +const HLG_A: f32 = 0.17883277; +const HLG_B: f32 = 0.28466892; +const HLG_C: f32 = 0.55991073; + +/// HLG OETF: scene-linear `E` in `[0,1]` -> HLG signal in `[0,1]`. +pub fn hlg_oetf(e: f32) -> f32 { + let e = e.max(0.0); + if e <= 1.0 / 12.0 { + (3.0 * e).sqrt() + } else { + HLG_A * (12.0 * e - HLG_B).ln() + HLG_C + } +} + +/// HLG nominal peak is 12x diffuse white; normalize the scene by this so diffuse white +/// (linear 1.0) and headroom up to 12.0 fit the HLG `[0,1]` signal range. +pub const HLG_PEAK_RATIO: f32 = 12.0; + +/// Convenience: linear scene value (diffuse white = 1.0) -> HLG signal. +/// NOTE: HLG is relative/scene-referred — unlike PQ it has no single absolute-nit anchor here. +pub fn linear_scene_to_hlg(linear: f32) -> f32 { + hlg_oetf(linear / HLG_PEAK_RATIO) +} + +/// Inverse of the shader's `linear_to_srgb_extended` (`shader.wgsl` L237) for c >= 0. +/// The HDR GPU variant stores extended-sRGB-encoded values with headroom; this recovers +/// the underlying linear light on readback. Matches `srgb_to_linear` (shader L220). +pub fn srgb_extended_to_linear(c: f32) -> f32 { + let c = c.max(0.0); + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +/// Forward extended sRGB OETF (matches shader `linear_to_srgb_extended`); provided for tests. +pub fn linear_to_srgb_extended(c: f32) -> f32 { + let c = c.max(0.0); + if c <= 0.0031308 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + } +} + +/// Linear Rec.709/sRGB primaries -> linear Rec.2020 primaries (BT.2087, D65, no adaptation). +/// White-preserving: each row sums to 1.0, so neutral (R=G=B) is unchanged — which is why the +/// harness's neutral patches hit the same PQ codes whether or not this is applied. +pub fn rec709_to_rec2020_linear(rgb: [f32; 3]) -> [f32; 3] { + [ + 0.627403896 * rgb[0] + 0.329283038 * rgb[1] + 0.043313066 * rgb[2], + 0.069097289 * rgb[0] + 0.919540395 * rgb[1] + 0.011362316 * rgb[2], + 0.016391439 * rgb[0] + 0.088013308 * rgb[1] + 0.895595253 * rgb[2], + ] +} + +/// Transfer function selector for HDR export. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferFunction { + /// Plain sRGB (the existing SDR path). + Srgb, + /// SMPTE ST 2084 (PQ). CICP transfer_characteristics = 16. + Pq, + /// ITU-R BT.2100 HLG. CICP transfer_characteristics = 18. + Hlg, +} + +/// Color primaries selector for HDR export. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorPrimaries { + /// Rec.709 / sRGB. CICP colour_primaries = 1. + Srgb, + /// Rec.2020. CICP colour_primaries = 9. + Bt2020, +} + +/// CICP code points (ISO/IEC 23091-2) for a given selection, plus matrix + range we target. +pub struct CicpTags { + pub colour_primaries: u16, + pub transfer_characteristics: u16, + pub matrix_coefficients: u16, + pub full_range: bool, +} + +pub fn cicp_for(primaries: ColorPrimaries, transfer: TransferFunction) -> CicpTags { + CicpTags { + colour_primaries: match primaries { + ColorPrimaries::Srgb => 1, + ColorPrimaries::Bt2020 => 9, + }, + transfer_characteristics: match transfer { + TransferFunction::Srgb => 13, // IEC 61966-2-1 sRGB + TransferFunction::Pq => 16, + TransferFunction::Hlg => 18, + }, + // BT.2020 non-constant luminance for HDR; we feed RGB and let the encoder build YCbCr. + matrix_coefficients: match primaries { + ColorPrimaries::Srgb => 1, // BT.709 + ColorPrimaries::Bt2020 => 9, // BT.2020 NCL + }, + full_range: true, + } +} + +/// Map a linear scene-referred pixel to a non-linear code in `[0,1]` for the chosen encoding, +/// including the primaries conversion. Alpha is passed through unchanged by callers. +pub fn encode_pixel_linear_to_code( + rgb_linear: [f32; 3], + primaries: ColorPrimaries, + transfer: TransferFunction, +) -> [f32; 3] { + let rgb = match primaries { + ColorPrimaries::Srgb => rgb_linear, + ColorPrimaries::Bt2020 => rec709_to_rec2020_linear(rgb_linear), + }; + let f = |c: f32| match transfer { + TransferFunction::Srgb => linear_to_srgb_extended(c), + TransferFunction::Pq => linear_scene_to_pq(c), + TransferFunction::Hlg => linear_scene_to_hlg(c), + }; + [f(rgb[0]), f(rgb[1]), f(rgb[2])] +} + +/// Quantize a `[0,1]` code to an integer at the given bit depth, full-range, round-to-nearest. +pub fn quantize_full_range(code: f32, bit_depth: u8) -> u16 { + let max = ((1u32 << bit_depth) - 1) as f32; + (code.clamp(0.0, 1.0) * max + 0.5).floor() as u16 +} + +// --------------------------------------------------------------------------------------------- +// Synthetic test input (linear light, scene-referred, diffuse white = 1.0). +// --------------------------------------------------------------------------------------------- + +/// A synthetic HDR test image plus the pixel coordinates of each known patch, so the harness +/// can sample exact values after a full encode->decode round-trip. +pub struct SyntheticInput { + pub image: ImageBuffer, Vec>, + /// Diffuse/reference white: linear 1.0 (-> 203 nits -> PQ ~0.5807). + pub diffuse_white_px: (u32, u32), + /// Above diffuse white: linear 2.0 (-> 406 nits). + pub rel2_px: (u32, u32), + /// Above diffuse white: linear 4.0 (-> 812 nits). + pub rel4_px: (u32, u32), + /// Black: linear 0.0. + pub black_px: (u32, u32), + /// Midpoints of a 0.0 -> 1.0 ramp (left to right), for monotonicity checks. + pub ramp_px: Vec<(u32, u32)>, + pub width: u32, + pub height: u32, +} + +/// Build the synthetic input: four flat patches (black, diffuse white, 2x, 4x) followed by a +/// 0->1 linear ramp. Neutral grey (R=G=B) throughout so values are primaries-invariant. +pub fn synthetic_linear_image() -> SyntheticInput { + let height: u32 = 8; + let patch_w: u32 = 16; + let ramp_w: u32 = 64; + let width = patch_w * 4 + ramp_w; // 128 + + let mut image = ImageBuffer::, Vec>::new(width, height); + + let patch_value = |idx: u32| -> f32 { + match idx { + 0 => 0.0, // black + 1 => 1.0, // diffuse white + 2 => 2.0, // 2x headroom + 3 => 4.0, // 4x headroom + _ => 0.0, + } + }; + + for y in 0..height { + for x in 0..width { + let v = if x < patch_w * 4 { + patch_value(x / patch_w) + } else { + // ramp 0..1 across ramp_w + let t = (x - patch_w * 4) as f32 / (ramp_w as f32 - 1.0); + t.clamp(0.0, 1.0) + }; + image.put_pixel(x, y, Rgba([v, v, v, 1.0])); + } + } + + let cy = height / 2; + let center = |idx: u32| (idx * patch_w + patch_w / 2, cy); + let ramp_px = (0..4) + .map(|i| (patch_w * 4 + 4 + i * ((ramp_w - 8) / 3), cy)) + .collect(); + + SyntheticInput { + image, + black_px: center(0), + diffuse_white_px: center(1), + rel2_px: center(2), + rel4_px: center(3), + ramp_px, + width, + height, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Independent ground-truth: pinned literal from the brief / ISO 22028-5. + #[test] + fn pq_reference_white_is_about_058() { + let l = REFERENCE_WHITE_NITS / PQ_MAX_NITS; // 0.0203 + let code = pq_inverse_eotf(l); + assert!( + (code - 0.580689).abs() < 1e-4, + "203-nit PQ code = {code}, expected ~0.580689" + ); + // 10-bit full-range code lands at 594 (brief says ~593; <1 code, within tolerance). + let code10 = quantize_full_range(code, 10); + assert!( + (code10 as i32 - 593).abs() <= 2, + "10-bit ref-white code = {code10}, expected within 2 of 593" + ); + } + + #[test] + fn pq_roundtrip_is_stable() { + for &l in &[0.0f32, 0.001, 0.0203, 0.0406, 0.0812, 0.5, 1.0] { + let back = pq_eotf(pq_inverse_eotf(l)); + assert!((back - l).abs() < 1e-4, "PQ roundtrip {l} -> {back}"); + } + } + + #[test] + fn headroom_codes_strictly_increase() { + let c1 = linear_scene_to_pq(1.0); + let c2 = linear_scene_to_pq(2.0); + let c4 = linear_scene_to_pq(4.0); + assert!(c1 < c2 && c2 < c4, "codes not increasing: {c1} {c2} {c4}"); + assert!(c4 < 1.0, "4x must stay below PQ full scale, got {c4}"); + // and as quantized 10-bit codes they must remain distinct + let q = |c: f32| quantize_full_range(c, 10); + assert!(q(c1) < q(c2) && q(c2) < q(c4)); + } + + #[test] + fn srgb_extended_inverts_cleanly_with_headroom() { + for &c in &[0.0f32, 0.5, 1.0, 2.0, 4.0] { + let round = + linear_to_srgb_extended(srgb_extended_to_linear(linear_to_srgb_extended(c))); + let direct = linear_to_srgb_extended(c); + assert!((round - direct).abs() < 1e-4); + } + // headroom survives the linear<->srgb roundtrip + let lin = srgb_extended_to_linear(linear_to_srgb_extended(4.0)); + assert!((lin - 4.0).abs() < 1e-3, "headroom lost: {lin}"); + } + + #[test] + fn rec2020_matrix_preserves_neutral() { + let n = rec709_to_rec2020_linear([0.5, 0.5, 0.5]); + for c in n { + assert!((c - 0.5).abs() < 1e-5, "neutral not preserved: {c}"); + } + } + + #[test] + fn synthetic_input_has_expected_patches() { + let s = synthetic_linear_image(); + assert_eq!( + s.image + .get_pixel(s.diffuse_white_px.0, s.diffuse_white_px.1) + .0[0], + 1.0 + ); + assert_eq!(s.image.get_pixel(s.rel2_px.0, s.rel2_px.1).0[0], 2.0); + assert_eq!(s.image.get_pixel(s.rel4_px.0, s.rel4_px.1).0[0], 4.0); + assert_eq!(s.image.get_pixel(s.black_px.0, s.black_px.1).0[0], 0.0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 597d74c1b0..3294b0bf00 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod export_processing; mod file_management; mod formats; mod gpu_processing; +pub mod hdr; mod image_loader; mod image_processing; mod lens_correction; From b9306559e438eb986f5a835cdd8dc4e8a7b29914 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 00:17:02 +0200 Subject: [PATCH 02/20] HDR export Phase 2.3a: AVIF HDR encoder + verification harness (gate green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hdr::encode_avif_hdr: pure-Rust rav1e + avif-serialize. CICP primaries=9 (BT.2020), transfer=16 (PQ) / 18 (HLG), matrix=0 (Identity/RGB, 4:4:4 full-range), 10 & 12 bit. Identity matrix feeds transfer-encoded R'G'B' codes directly (G,B,R plane order) — no YCbCr/subsampling, so the round-trip is lossless and exact for all colors. - hdr::encode_jxl_hdr: jpegxl-rs + system libjxl, PQ/HLG Rec.2020 f32, uses_original_profile so the custom color encoding survives. Behind optional `hdr_jxl` feature. - Cargo.toml: rav1e 0.8.1 + avif-serialize 0.8.9 (always-on, no system deps); jpegxl-rs 0.14 + jpegxl-sys 0.12 (optional `hdr_jxl`, needs libjxl). - tests/hdr_export.rs: the gate. Parses nclx + av1C from bytes (encoder-independent), decodes via avifdec (libavif, independent of rav1e). All AVIF assertions pass with EXACT codes: 10-bit white=594 rel2=669 rel4=746; 12-bit white=2378 rel2=2679 rel4=2986; black=0; monotonic ramp; identity plane-order preserved (R=10-bit float): bindings to system libjxl. Optional (see `hdr_jxl` feature) +# because it requires libjxl (`brew install jpeg-xl`) at build+runtime. GPL-3.0-or-later bindings; +# compatible with this repo's AGPL-3.0. +jpegxl-rs = { version = "0.14.0", default-features = false, optional = true } +jpegxl-sys = { version = "0.12.1", optional = true } + +[features] +# Enable HDR JPEG XL export. Requires system libjxl (macOS: `brew install jpeg-xl`, with +# PKG_CONFIG_PATH pointing at its keg-only pkgconfig dir). AVIF HDR export is always available. +hdr_jxl = ["dep:jpegxl-rs", "dep:jpegxl-sys"] + [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] trash = "5.2.6" tauri-plugin-single-instance = "2.4.2" diff --git a/src-tauri/src/hdr.rs b/src-tauri/src/hdr.rs index 0f0cc4622c..69ece54e0d 100644 --- a/src-tauri/src/hdr.rs +++ b/src-tauri/src/hdr.rs @@ -255,6 +255,204 @@ pub fn synthetic_linear_image() -> SyntheticInput { } } +// --------------------------------------------------------------------------------------------- +// HDR encoders. Input is always LINEAR scene-referred Rgba (diffuse white = 1.0, headroom +// preserved). These apply the primaries conversion + transfer OETF, then encode + tag CICP. +// --------------------------------------------------------------------------------------------- + +/// Map an export quality (0..=100) to a rav1e quantizer (0 = best). 100 -> 0 (near-lossless). +fn quality_to_quantizer(quality: u8) -> usize { + let q = quality.min(100) as usize; + ((100 - q) * 255 / 100).min(255) +} + +/// Encode a linear `Rgba` image to a tagged HDR AVIF (10 or 12 bit). +/// +/// Uses CICP matrix_coefficients = 0 (Identity / RGB) at 4:4:4 full-range: the planes carry the +/// RGB transfer-encoded codes directly (no YCbCr conversion, no chroma subsampling), so the +/// round-trip is exact for every color — not just neutrals. The AV1 spec stores identity planes +/// in G, B, R order. `primaries` is tagged AND applied (Rec.709->Rec.2020 matrix) to the pixels. +pub fn encode_avif_hdr( + img: &ImageBuffer, Vec>, + bit_depth: u8, + transfer: TransferFunction, + primaries: ColorPrimaries, + quality: u8, +) -> Result, String> { + use rav1e::config::SpeedSettings; + // Explicit imports (not a glob) so this module's own ColorPrimaries/TransferFunction enums + // are not shadowed by rav1e's same-named enums. + use rav1e::prelude::{ + ChromaSamplePosition, ChromaSampling, ColorDescription, Config, Context, EncoderConfig, + EncoderStatus, PixelRange, + }; + + if bit_depth != 10 && bit_depth != 12 { + return Err(format!( + "AVIF HDR bit depth must be 10 or 12, got {bit_depth}" + )); + } + if transfer == TransferFunction::Srgb { + return Err("encode_avif_hdr requires a PQ or HLG transfer".into()); + } + let w = img.width() as usize; + let h = img.height() as usize; + if w == 0 || h == 0 { + return Err("cannot encode an empty image".into()); + } + + // linear -> primaries -> transfer code -> quantized u16, into G/B/R planes (identity order). + let mut plane_g = vec![0u16; w * h]; + let mut plane_b = vec![0u16; w * h]; + let mut plane_r = vec![0u16; w * h]; + for (i, px) in img.pixels().enumerate() { + let code = encode_pixel_linear_to_code([px.0[0], px.0[1], px.0[2]], primaries, transfer); + plane_r[i] = quantize_full_range(code[0], bit_depth); + plane_g[i] = quantize_full_range(code[1], bit_depth); + plane_b[i] = quantize_full_range(code[2], bit_depth); + } + + let mut enc = EncoderConfig::default(); + enc.width = w; + enc.height = h; + enc.bit_depth = bit_depth as usize; + enc.chroma_sampling = ChromaSampling::Cs444; + enc.chroma_sample_position = ChromaSamplePosition::Unknown; + enc.still_picture = true; + enc.pixel_range = PixelRange::Full; + enc.color_description = Some(ColorDescription { + color_primaries: match primaries { + ColorPrimaries::Bt2020 => rav1e::prelude::ColorPrimaries::BT2020, + ColorPrimaries::Srgb => rav1e::prelude::ColorPrimaries::BT709, + }, + transfer_characteristics: match transfer { + TransferFunction::Pq => rav1e::prelude::TransferCharacteristics::SMPTE2084, + TransferFunction::Hlg => rav1e::prelude::TransferCharacteristics::HLG, + TransferFunction::Srgb => unreachable!(), + }, + matrix_coefficients: rav1e::prelude::MatrixCoefficients::Identity, + }); + enc.quantizer = quality_to_quantizer(quality); + enc.speed_settings = SpeedSettings::from_preset(6); + + let threads = std::thread::available_parallelism() + .map(|n| n.get().min(8)) + .unwrap_or(2); + let cfg = Config::new().with_encoder_config(enc).with_threads(threads); + let mut ctx: Context = cfg + .new_context() + .map_err(|e| format!("rav1e new_context failed: {e:?}"))?; + + let mut frame = ctx.new_frame(); + // Identity plane order: 0=G, 1=B, 2=R. copy_from_raw_u8 writes at the plane origin + // (handles rav1e's internal padding), unlike a raw chunks_mut over the padded buffer. + let to_le = |p: &[u16]| -> Vec { p.iter().flat_map(|v| v.to_le_bytes()).collect() }; + frame.planes[0].copy_from_raw_u8(&to_le(&plane_g), w * 2, 2); + frame.planes[1].copy_from_raw_u8(&to_le(&plane_b), w * 2, 2); + frame.planes[2].copy_from_raw_u8(&to_le(&plane_r), w * 2, 2); + + ctx.send_frame(std::sync::Arc::new(frame)) + .map_err(|e| format!("rav1e send_frame failed: {e:?}"))?; + ctx.flush(); + + let mut av1_obu: Vec = Vec::new(); + loop { + match ctx.receive_packet() { + Ok(pkt) => av1_obu.extend_from_slice(&pkt.data), + Err(EncoderStatus::Encoded) => continue, + Err(EncoderStatus::LimitReached) | Err(EncoderStatus::NeedMoreData) => break, + Err(e) => return Err(format!("rav1e receive_packet failed: {e:?}")), + } + } + + use avif_serialize::Aviffy; + use avif_serialize::constants::{ + ColorPrimaries as AvifPrimaries, MatrixCoefficients as AvifMatrix, + TransferCharacteristics as AvifTransfer, + }; + let mut aviffy = Aviffy::new(); + aviffy + .set_color_primaries(match primaries { + ColorPrimaries::Bt2020 => AvifPrimaries::Bt2020, + ColorPrimaries::Srgb => AvifPrimaries::Bt709, + }) + .set_transfer_characteristics(match transfer { + TransferFunction::Pq => AvifTransfer::Smpte2084, + TransferFunction::Hlg => AvifTransfer::Hlg, + TransferFunction::Srgb => unreachable!(), + }) + .set_matrix_coefficients(AvifMatrix::Rgb) + .set_full_color_range(true) + .set_bit_depth(bit_depth) + .set_chroma_subsampling((false, false)); // 4:4:4 + + Ok(aviffy.to_vec(&av1_obu, None, w as u32, h as u32, bit_depth)) +} + +/// Encode a linear `Rgba` image to a tagged HDR JPEG XL (PQ or HLG, Rec.2020), 32-bit float. +/// Requires the `hdr_jxl` feature (system libjxl). Feeds transfer-encoded f32 codes with +/// `uses_original_profile` so libjxl keeps our PQ/HLG color encoding instead of converting to XYB. +#[cfg(feature = "hdr_jxl")] +pub fn encode_jxl_hdr( + img: &ImageBuffer, Vec>, + transfer: TransferFunction, + primaries: ColorPrimaries, + lossless: bool, +) -> Result, String> { + use jpegxl_rs::encode::{ColorEncoding, EncoderFrame, EncoderResult, EncoderSpeed}; + use jpegxl_rs::encoder_builder; + use jpegxl_sys::color::color_encoding::{ + JxlColorEncoding, JxlColorSpace, JxlPrimaries, JxlRenderingIntent, JxlTransferFunction, + JxlWhitePoint, + }; + + if transfer == TransferFunction::Srgb { + return Err("encode_jxl_hdr requires a PQ or HLG transfer".into()); + } + let (w, h) = (img.width(), img.height()); + + let mut rgb: Vec = Vec::with_capacity((w * h * 3) as usize); + for px in img.pixels() { + let code = encode_pixel_linear_to_code([px.0[0], px.0[1], px.0[2]], primaries, transfer); + rgb.extend_from_slice(&code); + } + + let color = JxlColorEncoding { + color_space: JxlColorSpace::Rgb, + white_point: JxlWhitePoint::D65, + white_point_xy: [0.3127, 0.3290], + primaries: match primaries { + ColorPrimaries::Bt2020 => JxlPrimaries::Rec2100, + ColorPrimaries::Srgb => JxlPrimaries::SRGB, + }, + primaries_red_xy: [0.0, 0.0], + primaries_green_xy: [0.0, 0.0], + primaries_blue_xy: [0.0, 0.0], + transfer_function: match transfer { + TransferFunction::Pq => JxlTransferFunction::PQ, + TransferFunction::Hlg => JxlTransferFunction::HLG, + TransferFunction::Srgb => unreachable!(), + }, + gamma: 0.0, + rendering_intent: JxlRenderingIntent::Relative, + }; + + let mut encoder = encoder_builder() + .has_alpha(false) + .speed(EncoderSpeed::Squirrel) + .uses_original_profile(true) + .lossless(lossless) + .color_encoding(ColorEncoding::Custom(color)) + .build() + .map_err(|e| format!("jxl encoder build failed: {e}"))?; + + let frame = EncoderFrame::new(&rgb).num_channels(3); + let result: EncoderResult = encoder + .encode_frame(&frame, w, h) + .map_err(|e| format!("jxl encode_frame failed: {e}"))?; + Ok(result.data.to_vec()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/tests/hdr_export.rs b/src-tauri/tests/hdr_export.rs new file mode 100644 index 0000000000..72494caefa --- /dev/null +++ b/src-tauri/tests/hdr_export.rs @@ -0,0 +1,281 @@ +//! HDR export verification harness (the gate). +//! +//! Pushes the synthetic linear test image through the real HDR encoders and asserts, exiting +//! non-zero on any failure: +//! 1. CICP / nclx tags (primaries, transfer, matrix, full-range) +//! 2. bit depth (10 and 12 for AVIF) +//! 3. reference white (linear 1.0 -> 203 nits) decodes to PQ code ~594 (10-bit), +/-2 +//! 4. headroom: linear 2.0 and 4.0 patches decode to strictly-increasing codes above diffuse +//! white and below full scale (proves the look stage / encoder did not clip at 1.0) +//! +//! Tags + bit depth are parsed directly from the container bytes (independent of the encoder). +//! AVIF pixels are decoded with `avifdec` (libavif) — a decoder independent of rav1e/avif-serialize. +//! JXL (behind the `hdr_jxl` feature) is decoded with the pure-Rust jxl-oxide. + +use image::{ImageBuffer, Rgba}; +use rapidraw_lib::hdr::{self, ColorPrimaries, TransferFunction}; +use std::process::Command; + +/// Parse the first ISOBMFF `colr` box of type `nclx`: (primaries, transfer, matrix, full_range). +fn parse_nclx(bytes: &[u8]) -> Option<(u16, u16, u16, bool)> { + let mut i = 0usize; + while i + 4 <= bytes.len() { + if &bytes[i..i + 4] == b"colr" { + let ct = i + 4; + if ct + 4 <= bytes.len() && &bytes[ct..ct + 4] == b"nclx" { + let p = ct + 4; + if p + 7 > bytes.len() { + return None; + } + return Some(( + u16::from_be_bytes([bytes[p], bytes[p + 1]]), + u16::from_be_bytes([bytes[p + 2], bytes[p + 3]]), + u16::from_be_bytes([bytes[p + 4], bytes[p + 5]]), + (bytes[p + 6] & 0x80) != 0, + )); + } + } + i += 1; + } + None +} + +/// Parse the AV1 codec config (`av1C`) box for the coded bit depth (8/10/12), independent of +/// the nclx tag. Config byte 2 layout (MSB first): seq_tier(1) high_bitdepth(1) twelve_bit(1) ... +fn parse_av1c_depth(bytes: &[u8]) -> Option { + let mut i = 0usize; + while i + 4 <= bytes.len() { + if &bytes[i..i + 4] == b"av1C" { + let cfg = i + 4; + if cfg + 3 > bytes.len() { + return None; + } + let flags = bytes[cfg + 2]; + let high_bitdepth = (flags & 0x40) != 0; + let twelve_bit = (flags & 0x20) != 0; + return Some(if !high_bitdepth { + 8 + } else if twelve_bit { + 12 + } else { + 10 + }); + } + i += 1; + } + None +} + +/// Decode an AVIF byte stream to a 16-bit RGBA image using the `avifdec` CLI (libavif). +fn avifdec_to_png16(avif: &[u8], label: &str) -> ImageBuffer, Vec> { + let dir = std::env::temp_dir(); + let pid = std::process::id(); + let in_path = dir.join(format!("rr_hdr_{label}_{pid}.avif")); + let out_path = dir.join(format!("rr_hdr_{label}_{pid}.png")); + std::fs::write(&in_path, avif).expect("write temp avif"); + + let output = Command::new("avifdec") + .arg(&in_path) + .arg(&out_path) + .output(); + let output = match output { + Ok(o) => o, + Err(e) => panic!( + "could not run `avifdec` ({e}). Install libavif (macOS: `brew install libavif`) to run the HDR pixel checks." + ), + }; + assert!( + output.status.success(), + "avifdec failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let decoded = image::open(&out_path) + .unwrap_or_else(|e| panic!("open decoded png {out_path:?}: {e}")) + .into_rgba16(); + let _ = std::fs::remove_file(&in_path); + let _ = std::fs::remove_file(&out_path); + decoded +} + +/// Recover the bit-depth-scaled integer code from a 16-bit PNG sample. +fn code_from_png16(v16: u16, bit_depth: u8) -> u16 { + let max = ((1u32 << bit_depth) - 1) as f32; + (v16 as f32 / 65535.0 * max).round() as u16 +} + +fn run_avif_pq_case(bit_depth: u8) { + let s = hdr::synthetic_linear_image(); + let avif = hdr::encode_avif_hdr( + &s.image, + bit_depth, + TransferFunction::Pq, + ColorPrimaries::Bt2020, + 100, + ) + .expect("encode avif"); + + // (1) tags + let (p, t, m, fr) = parse_nclx(&avif).expect("nclx box present"); + assert_eq!( + p, 9, + "[{bit_depth}b] colour_primaries should be BT.2020 (9), got {p}" + ); + assert_eq!(t, 16, "[{bit_depth}b] transfer should be PQ (16), got {t}"); + assert_eq!( + m, 0, + "[{bit_depth}b] matrix should be Identity/RGB (0), got {m}" + ); + assert!(fr, "[{bit_depth}b] full_range_flag should be 1"); + + // (2) bit depth (independent, from av1C) + let depth = parse_av1c_depth(&avif).expect("av1C box present"); + assert_eq!(depth, bit_depth, "av1C coded depth {depth} != {bit_depth}"); + + // (3) + (4) pixels via independent decoder + let dec = avifdec_to_png16(&avif, &format!("pq{bit_depth}")); + let sample = + |px: (u32, u32)| -> u16 { code_from_png16(dec.get_pixel(px.0, px.1).0[0], bit_depth) }; + + let expected_white = hdr::quantize_full_range(hdr::linear_scene_to_pq(1.0), bit_depth); + let white = sample(s.diffuse_white_px); + assert!( + (white as i32 - expected_white as i32).abs() <= 2, + "[{bit_depth}b] reference-white code {white}, expected ~{expected_white} (+/-2)" + ); + + let black = sample(s.black_px); + let rel2 = sample(s.rel2_px); + let rel4 = sample(s.rel4_px); + let full = (1u32 << bit_depth) - 1; + assert!( + black < white, + "[{bit_depth}b] black {black} !< white {white}" + ); + assert!( + white < rel2 && rel2 < rel4, + "[{bit_depth}b] headroom not strictly increasing: white {white} rel2 {rel2} rel4 {rel4}" + ); + assert!( + (rel4 as u32) < full, + "[{bit_depth}b] 4x headroom {rel4} should stay below full scale {full}" + ); + + // ramp must be non-decreasing left->right + let ramp: Vec = s.ramp_px.iter().map(|&px| sample(px)).collect(); + for w in ramp.windows(2) { + assert!(w[0] <= w[1] + 2, "ramp not monotonic: {ramp:?}"); + } + + eprintln!( + "AVIF {bit_depth}-bit PQ: tags 9/16/0/full=1, depth {depth}, white={white} (exp {expected_white}), black={black} rel2={rel2} rel4={rel4} ramp={ramp:?}" + ); +} + +#[test] +fn avif_pq_10bit() { + run_avif_pq_case(10); +} + +#[test] +fn avif_pq_12bit() { + run_avif_pq_case(12); +} + +#[test] +fn avif_hlg_tags() { + let s = hdr::synthetic_linear_image(); + let avif = hdr::encode_avif_hdr( + &s.image, + 10, + TransferFunction::Hlg, + ColorPrimaries::Bt2020, + 100, + ) + .expect("encode hlg avif"); + let (p, t, m, fr) = parse_nclx(&avif).expect("nclx box present"); + assert_eq!(p, 9, "HLG primaries should be 9"); + assert_eq!(t, 18, "HLG transfer should be 18"); + assert_eq!(m, 0, "HLG matrix should be 0"); + assert!(fr, "HLG full_range_flag should be 1"); + eprintln!("AVIF 10-bit HLG: tags 9/18/0/full=1"); +} + +/// Identity (matrix=0) stores planes in G,B,R order. With sRGB primaries (no cross-channel +/// matrix) a pixel with distinct R, Vec>::new(4, 4); + // linear values chosen so PQ codes are clearly separated: R < G < B + for px in img.pixels_mut() { + *px = Rgba([0.05, 0.2, 0.8, 1.0]); + } + let avif = + hdr::encode_avif_hdr(&img, 10, TransferFunction::Pq, ColorPrimaries::Srgb, 100).unwrap(); + let dec = avifdec_to_png16(&avif, "order"); + let px = dec.get_pixel(1, 1).0; + assert!( + px[0] < px[1] && px[1] < px[2], + "channel order lost: R={} G={} B={} (expected R f32 { + buf[((px.1 * w as u32 + px.0) as usize) * ch] // first (R) channel + }; + let expected = hdr::linear_scene_to_pq(1.0); + let white = sample(s.diffuse_white_px); + assert!( + (white - expected).abs() < 0.01, + "jxl reference-white PQ code {white}, expected ~{expected}" + ); + let w1 = hdr::linear_scene_to_pq(1.0); + let w2 = sample(s.rel2_px); + let w4 = sample(s.rel4_px); + assert!( + w1 < w2 && w2 < w4 && w4 < 1.0, + "jxl headroom not increasing: {w1} {w2} {w4}" + ); + let _ = std::fs::remove_file(&path); + eprintln!("JXL PQ: tagged PQ, white={white} (exp {expected}), rel2={w2} rel4={w4}"); + } +} From dfc67951a5bc423435ba6ef23bf796f4536d3933 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 00:18:59 +0200 Subject: [PATCH 03/20] HDR export Phase 2.3b: fix JXL primaries variant; JXL harness green JxlPrimaries::SRGB -> SRgb. With --features hdr_jxl (PKG_CONFIG_PATH at brew jpeg-xl), all 5 harness tests pass: JXL PQ tagged correctly, reference-white PQ code and headroom verified via jxl-oxide decode. --- src-tauri/src/hdr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/hdr.rs b/src-tauri/src/hdr.rs index 69ece54e0d..055e020e64 100644 --- a/src-tauri/src/hdr.rs +++ b/src-tauri/src/hdr.rs @@ -423,7 +423,7 @@ pub fn encode_jxl_hdr( white_point_xy: [0.3127, 0.3290], primaries: match primaries { ColorPrimaries::Bt2020 => JxlPrimaries::Rec2100, - ColorPrimaries::Srgb => JxlPrimaries::SRGB, + ColorPrimaries::Srgb => JxlPrimaries::SRgb, }, primaries_red_xy: [0.0, 0.0], primaries_green_xy: [0.0, 0.0], From 495ec452fe65b460221894deeae5acccb013325a Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 08:16:24 +0200 Subject: [PATCH 04/20] HDR export Phase 2.1/2.2: GPU rgba16float output path (SDR untouched) - Extract build_main_bgl(device, storage_format) shared by SDR (Rgba8Unorm) and HDR (Rgba16Float) so the two layouts cannot drift. - hdr_shader_source(): derive the HDR shader from shader.wgsl at runtime via 4 targeted substitutions: rgba8unorm->rgba16float storage; clamping linear_to_srgb -> headroom- preserving linear_to_srgb_extended (the real headroom killer, upstream of the store); drop the [0,1] store clamp (keep max(.,0)); disable the 8-bit dither. - GpuProcessor: lazily-built (OnceLock) HDR pipeline + rgba16float tile texture, so SDR-only sessions allocate nothing extra. run() takes output_hdr and selects pipeline/bgl/tile/ bytes-per-pixel (8 for f16 vs 4). read_texture_data_roi parameterized by bytes_per_pixel. - process_and_get_dynamic_image_hdr(): new entry point. On HDR readback, decode f16 -> invert extended sRGB (hdr::srgb_extended_to_linear) -> DynamicImage::ImageRgba32F in LINEAR scene-referred light (diffuse white = 1.0, headroom > 1.0 preserved) for the HDR encoders. The existing rgba8unorm pipeline, textures, and readback are byte-for-byte unchanged. --- src-tauri/src/gpu_processing.rs | 442 ++++++++++++++++++++++---------- 1 file changed, 308 insertions(+), 134 deletions(-) diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index f9ce674a45..a0643176de 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -417,8 +417,9 @@ fn read_texture_data_roi( texture: &wgpu::Texture, origin: wgpu::Origin3d, size: wgpu::Extent3d, + bytes_per_pixel: u32, ) -> Result, String> { - let unpadded_bytes_per_row = 4 * size.width; + let unpadded_bytes_per_row = bytes_per_pixel * size.width; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let padded_bytes_per_row = (unpadded_bytes_per_row + align - 1) & !(align - 1); let output_buffer_size = (padded_bytes_per_row * size.height) as u64; @@ -547,6 +548,220 @@ pub struct GpuProcessor { pub working_texture_view: wgpu::TextureView, pub output_texture: wgpu::Texture, pub output_texture_view: wgpu::TextureView, + + /// Lazily-built HDR (rgba16float) pipeline + tile texture. Only allocated the first time an + /// HDR export runs, so SDR-only sessions pay no extra VRAM. The SDR path above is untouched. + hdr: std::sync::OnceLock, + /// Size of the (max) tile/output textures, needed to lazily build the HDR tile texture. + tile_dims: wgpu::Extent3d, +} + +/// Resources for the HDR output path: a compute pipeline whose storage output is rgba16float +/// (built from the SDR shader via [`hdr_shader_source`]), plus a matching rgba16float tile texture. +struct HdrResources { + bgl: wgpu::BindGroupLayout, + pipeline: wgpu::ComputePipeline, + tile_output_texture: wgpu::Texture, + tile_output_texture_view: wgpu::TextureView, +} + +/// Build the main image-processing bind-group layout for a given storage-output format. Shared by +/// the SDR (`Rgba8Unorm`) and HDR (`Rgba16Float`) pipelines so the two layouts can never drift — +/// only binding 1 (the storage output) changes format. +fn build_main_bgl( + device: &wgpu::Device, + storage_format: wgpu::TextureFormat, +) -> wgpu::BindGroupLayout { + const MAX_MASK_BINDINGS: u32 = 1; + let mut bind_group_layout_entries = vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: storage_format, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ]; + + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, + count: None, + }); + + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 3 + MAX_MASK_BINDINGS, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }); + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 4 + MAX_MASK_BINDINGS, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }); + + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 5 + MAX_MASK_BINDINGS, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 6 + MAX_MASK_BINDINGS, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 7 + MAX_MASK_BINDINGS, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 8 + MAX_MASK_BINDINGS, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 9 + MAX_MASK_BINDINGS, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { + binding: 10 + MAX_MASK_BINDINGS, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }); + + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Main BGL"), + entries: &bind_group_layout_entries, + }) +} + +/// The HDR variant of the main shader, derived from the SDR `shader.wgsl` at runtime by four +/// targeted substitutions (the SDR shader stays the single source of truth for the look): +/// 1. promote the storage output `rgba8unorm` -> `rgba16float`; +/// 2. encode with the *headroom-preserving* extended sRGB OETF instead of the clamping one +/// (`linear_to_srgb` clamps to [0,1] at L229 — the real headroom killer, upstream of the store); +/// 3. drop the final `[0,1]` clamp (keep `max(.,0)` to avoid negative-light NaNs in PQ); +/// 4. disable the 8-bit dither (it would add PQ-code noise). +/// The HDR pipeline therefore stores EXTENDED-sRGB-encoded values with headroom; the CPU readback +/// inverts that with `hdr::srgb_extended_to_linear` to recover linear scene-referred light. +fn hdr_shader_source() -> String { + include_str!("shaders/shader.wgsl") + .replace("rgba8unorm, write>", "rgba16float, write>") + .replace( + "linear_to_srgb(composite_rgb_linear)", + "linear_to_srgb_extended(composite_rgb_linear)", + ) + .replace( + "clamp(final_rgb, vec3(0.0), vec3(1.0))", + "max(final_rgb, vec3(0.0))", + ) + .replace( + "let dither_amount = 1.0 / 255.0;", + "let dither_amount = 0.0;", + ) +} + +fn build_hdr_resources(device: &wgpu::Device, tile_dims: wgpu::Extent3d) -> HdrResources { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Image Processing Shader (HDR)"), + source: wgpu::ShaderSource::Wgsl(hdr_shader_source().into()), + }); + let bgl = build_main_bgl(device, wgpu::TextureFormat::Rgba16Float); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Pipeline Layout (HDR)"), + bind_group_layouts: &[Some(&bgl)], + immediate_size: 0, + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("Compute Pipeline (HDR)"), + layout: Some(&layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + let tile_output_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Tile Output Texture (HDR)"), + size: tile_dims, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let tile_output_texture_view = tile_output_texture.create_view(&Default::default()); + HdrResources { + bgl, + pipeline, + tile_output_texture, + tile_output_texture_view, + } } const FLARE_MAP_SIZE: u32 = 512; @@ -777,129 +992,7 @@ impl GpuProcessor { source: wgpu::ShaderSource::Wgsl(include_str!("shaders/shader.wgsl").into()), }); - let mut bind_group_layout_entries = vec![ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, - view_dimension: wgpu::TextureViewDimension::D2, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ]; - - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2Array, - multisampled: false, - }, - count: None, - }); - - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 3 + MAX_MASK_BINDINGS, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D3, - multisampled: false, - }, - count: None, - }); - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 4 + MAX_MASK_BINDINGS, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), - count: None, - }); - - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 5 + MAX_MASK_BINDINGS, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }); - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 6 + MAX_MASK_BINDINGS, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }); - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 7 + MAX_MASK_BINDINGS, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }); - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 8 + MAX_MASK_BINDINGS, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }); - - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 9 + MAX_MASK_BINDINGS, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }); - bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry { - binding: 10 + MAX_MASK_BINDINGS, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }); - - let main_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Main BGL"), - entries: &bind_group_layout_entries, - }); + let main_bgl = build_main_bgl(device, wgpu::TextureFormat::Rgba8Unorm); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Pipeline Layout"), @@ -1070,9 +1163,17 @@ impl GpuProcessor { working_texture_view, output_texture, output_texture_view, + hdr: std::sync::OnceLock::new(), + tile_dims: max_tile_size, }) } + /// Lazily build (and cache) the HDR output resources for this processor's tile size. + fn hdr_resources(&self) -> &HdrResources { + self.hdr + .get_or_init(|| build_hdr_resources(&self.context.device, self.tile_dims)) + } + pub fn run( &self, input_texture_view: &wgpu::TextureView, @@ -1081,12 +1182,41 @@ impl GpuProcessor { request: RenderRequest, skip_cpu_readback: bool, output_to_display: bool, + output_hdr: bool, ) -> Result<(Vec, u32, u32, u32, u32), String> { let device = &self.context.device; let queue = &self.context.queue; let scale = (width.min(height) as f32) / 1080.0; const MAX_MASK_BINDINGS: u32 = 1; + // Select the output pipeline/textures. HDR uses the lazily-built rgba16float pipeline + // (8 bytes/pixel readback); SDR uses the original rgba8unorm path (4 bytes/pixel), + // completely unchanged. `output_hdr` is only ever true for a CPU readback export. + let (out_pipeline, out_bgl, out_tile_texture, out_tile_view, bytes_per_pixel): ( + &wgpu::ComputePipeline, + &wgpu::BindGroupLayout, + &wgpu::Texture, + &wgpu::TextureView, + u32, + ) = if output_hdr { + let hr = self.hdr_resources(); + ( + &hr.pipeline, + &hr.bgl, + &hr.tile_output_texture, + &hr.tile_output_texture_view, + 8, + ) + } else { + ( + &self.main_pipeline, + &self.main_bgl, + &self.tile_output_texture, + &self.tile_output_texture_view, + 4, + ) + }; + let bounds = request.roi.unwrap_or(Roi { x: 0, y: 0, @@ -1284,7 +1414,7 @@ impl GpuProcessor { if skip_cpu_readback { 0 } else { - (out_width * out_height * 4) as usize + (out_width * out_height * bytes_per_pixel) as usize } ]; @@ -1422,9 +1552,7 @@ impl GpuProcessor { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView( - &self.tile_output_texture_view, - ), + resource: wgpu::BindingResource::TextureView(out_tile_view), }, wgpu::BindGroupEntry { binding: 2, @@ -1493,13 +1621,13 @@ impl GpuProcessor { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Tile Bind Group"), - layout: &self.main_bgl, + layout: out_bgl, entries: &bind_group_entries, }); { let mut compute_pass = main_encoder.begin_compute_pass(&Default::default()); - compute_pass.set_pipeline(&self.main_pipeline); + compute_pass.set_pipeline(out_pipeline); compute_pass.set_bind_group(0, &bind_group, &[]); compute_pass.dispatch_workgroups( input_width.div_ceil(8), @@ -1547,19 +1675,21 @@ impl GpuProcessor { let processed_tile_data = read_texture_data_roi( device, queue, - &self.tile_output_texture, + out_tile_texture, wgpu::Origin3d::ZERO, input_texture_size, + bytes_per_pixel, )?; + let bpp = bytes_per_pixel as usize; for row in 0..tile_height { let final_y = y_start + row - bounds.y; let final_x = x_start - bounds.x; - let final_row_offset = (final_y * out_width + final_x) as usize * 4; + let final_row_offset = (final_y * out_width + final_x) as usize * bpp; let source_y = crop_y_start + row; let source_row_offset = - (source_y * input_width + crop_x_start) as usize * 4; - let copy_bytes = (tile_width * 4) as usize; + (source_y * input_width + crop_x_start) as usize * bpp; + let copy_bytes = (tile_width as usize) * bpp; final_pixels[final_row_offset..final_row_offset + copy_bytes] .copy_from_slice( @@ -1592,6 +1722,30 @@ pub fn process_and_get_dynamic_image( caller_id, false, None, + false, + ) +} + +/// HDR export entry point. Runs the rgba16float pipeline and returns a LINEAR scene-referred +/// `DynamicImage::ImageRgba32F` (diffuse white = 1.0, headroom preserved) for the HDR encoders. +pub fn process_and_get_dynamic_image_hdr( + context: &GpuContext, + state: &tauri::State, + base_image: &DynamicImage, + transform_hash: u64, + request: RenderRequest, + caller_id: &str, +) -> Result { + process_and_get_dynamic_image_inner( + context, + state, + base_image, + transform_hash, + request, + caller_id, + false, + None, + true, ) } @@ -1615,6 +1769,7 @@ pub fn process_and_get_dynamic_image_with_analytics( caller_id, output_to_display, analytics_config, + false, ) } @@ -1628,6 +1783,7 @@ fn process_and_get_dynamic_image_inner( caller_id: &str, output_to_display: bool, analytics_config: Option, + output_hdr: bool, ) -> Result { let start_time = Instant::now(); let (width, height) = base_image.dimensions(); @@ -1788,6 +1944,7 @@ fn process_and_get_dynamic_image_inner( request, skip_readback, output_to_display, + output_hdr, )?; let mut final_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { @@ -2013,6 +2170,23 @@ fn process_and_get_dynamic_image_inner( fps ); + if output_hdr { + // HDR readback is rgba16float (8 bytes/pixel). The HDR shader stored EXTENDED-sRGB-encoded + // values with headroom; invert that to recover LINEAR scene-referred light (diffuse white + // = 1.0, values > 1.0 preserved). Alpha passes through. The HDR encoders apply PQ/HLG. + let mut data: Vec = Vec::with_capacity((out_w * out_h * 4) as usize); + for px in processed_pixels.chunks_exact(8) { + let ch = |a: u8, b: u8| f16::from_le_bytes([a, b]).to_f32(); + data.push(crate::hdr::srgb_extended_to_linear(ch(px[0], px[1]))); + data.push(crate::hdr::srgb_extended_to_linear(ch(px[2], px[3]))); + data.push(crate::hdr::srgb_extended_to_linear(ch(px[4], px[5]))); + data.push(ch(px[6], px[7])); + } + let img_buf = ImageBuffer::, Vec>::from_raw(out_w, out_h, data) + .ok_or("Failed to create HDR image buffer from GPU data")?; + return Ok(DynamicImage::ImageRgba32F(img_buf)); + } + let img_buf = ImageBuffer::, Vec>::from_raw(out_w, out_h, processed_pixels) .ok_or("Failed to create image buffer from GPU data")?; Ok(DynamicImage::ImageRgba8(img_buf)) From 83187ee1d217ca1fac46f456195bbc67320e17fd Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 08:27:23 +0200 Subject: [PATCH 05/20] HDR export Phase 2.4 (backend): wire HDR settings through export pipeline - ExportSettings: add bit_depth, transfer_function, primaries (serde camelCase defaults); hdr_enabled(ext) gates true-HDR to AVIF (always) / JXL (only with hdr_jxl feature) at >=10 bit with a PQ/HLG transfer. export_bit_depth() clamps to 10/12. - encode_image_to_bytes takes &ExportSettings; avif/jxl arms route to hdr::encode_avif_hdr / encode_jxl_hdr on the linear Rgba32F (image.to_rgba32f()). SDR arms unchanged. - process_image_for_export[_pipeline] take output_hdr and call process_and_get_dynamic_image_hdr (rgba16float GPU path) when HDR is requested. - Metadata writer leaves avif/jxl bytes untouched (its match returns Ok for non jpeg/png/ tiff), so CICP tags survive. resize() preserves the f32 variant. - Add gpu_processing test guarding the 4 HDR shader-substitution anchors so a future shader.wgsl edit can't silently no-op them. --- src-tauri/src/export_processing.rs | 122 +++++++++++++++++++++++------ src-tauri/src/gpu_processing.rs | 47 +++++++++++ src-tauri/src/hdr.rs | 8 +- 3 files changed, 149 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 2e4b1db421..65933b7f9a 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -70,6 +70,35 @@ pub struct ExportSettings { pub export_masks: bool, #[serde(default)] pub preserve_folders: bool, + /// Output bit depth: 0/8 = SDR (existing path); 10 or 12 = HDR (AVIF/JXL only). + #[serde(default)] + pub bit_depth: u8, + /// Transfer function for HDR export (sRGB = SDR, PQ, or HLG). + #[serde(default)] + pub transfer_function: crate::hdr::TransferFunction, + /// Color primaries for HDR export (sRGB/Rec.709 or Rec.2020). + #[serde(default)] + pub primaries: crate::hdr::ColorPrimaries, +} + +impl ExportSettings { + /// True when a true-HDR export is requested: a PQ/HLG transfer at >=10 bit, and the format + /// can carry it (AVIF always; JXL only when built with the `hdr_jxl` feature). + pub fn hdr_enabled(&self, extension: &str) -> bool { + if self.bit_depth < 10 || self.transfer_function == crate::hdr::TransferFunction::Srgb { + return false; + } + match extension { + "avif" => true, + "jxl" => cfg!(feature = "hdr_jxl"), + _ => false, + } + } + + /// The clamped HDR bit depth (10 or 12). + pub fn export_bit_depth(&self) -> u8 { + if self.bit_depth >= 12 { 12 } else { 10 } + } } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -220,6 +249,7 @@ fn process_image_for_export_pipeline( is_raw: bool, debug_tag: &str, app_handle: &tauri::AppHandle, + output_hdr: bool, ) -> Result { let (transformed_image, unscaled_crop_offset) = apply_all_transformations(Cow::Borrowed(base_image), js_adjustments); @@ -254,19 +284,33 @@ fn process_image_for_export_pipeline( let unique_hash = calculate_full_job_hash(path, js_adjustments); - process_and_get_dynamic_image( - context, - state, - transformed_image.as_ref(), - unique_hash, - RenderRequest { - adjustments: all_adjustments, - mask_bitmaps: &mask_bitmaps, - lut, - roi: None, - }, - debug_tag, - ) + let request = RenderRequest { + adjustments: all_adjustments, + mask_bitmaps: &mask_bitmaps, + lut, + roi: None, + }; + + if output_hdr { + // HDR export: run the rgba16float pipeline and get LINEAR scene-referred Rgba32F. + crate::gpu_processing::process_and_get_dynamic_image_hdr( + context, + state, + transformed_image.as_ref(), + unique_hash, + request, + debug_tag, + ) + } else { + process_and_get_dynamic_image( + context, + state, + transformed_image.as_ref(), + unique_hash, + request, + debug_tag, + ) + } } fn set_timestamps_from_exif(src: &Path, dst: &Path) { @@ -292,7 +336,7 @@ fn save_image_with_metadata( .unwrap_or("") .to_lowercase(); - let mut image_bytes = encode_image_to_bytes(image, &extension, export_settings.jpeg_quality)?; + let mut image_bytes = encode_image_to_bytes(image, &extension, export_settings)?; exif_processing::write_image_with_metadata( &mut image_bytes, @@ -341,11 +385,13 @@ fn process_image_for_export( base_image: &DynamicImage, js_adjustments: &Value, export_settings: &ExportSettings, + output_format: &str, context: &GpuContext, state: &tauri::State, is_raw: bool, app_handle: &tauri::AppHandle, ) -> Result { + let output_hdr = export_settings.hdr_enabled(&output_format.to_lowercase()); let processed_image = process_image_for_export_pipeline( path, base_image, @@ -355,6 +401,7 @@ fn process_image_for_export( is_raw, "process_image_for_export", app_handle, + output_hdr, )?; apply_export_resize_and_watermark(processed_image, export_settings) @@ -388,13 +435,32 @@ fn encode_grayscale_to_png(bitmap: &GrayImage) -> Result, String> { fn encode_image_to_bytes( image: &DynamicImage, output_format: &str, - jpeg_quality: u8, + export_settings: &ExportSettings, ) -> Result, String> { let mut image_bytes = Vec::new(); let mut cursor = Cursor::new(&mut image_bytes); - match output_format.to_lowercase().as_str() { + let jpeg_quality = export_settings.jpeg_quality; + let fmt = output_format.to_lowercase(); + let hdr = export_settings.hdr_enabled(&fmt); + + match fmt.as_str() { "jxl" => { + if hdr { + #[cfg(feature = "hdr_jxl")] + { + return crate::hdr::encode_jxl_hdr( + &image.to_rgba32f(), + export_settings.transfer_function, + export_settings.primaries, + jpeg_quality >= 100, + ); + } + #[cfg(not(feature = "hdr_jxl"))] + { + return Err("HDR JPEG XL export requires building RapidRAW with the `hdr_jxl` feature (system libjxl).".to_string()); + } + } let (width, height) = image.dimensions(); let has_alpha = image.color().has_alpha(); @@ -459,6 +525,15 @@ fn encode_image_to_bytes( .map_err(|e| e.to_string())?; } "avif" => { + if hdr { + return crate::hdr::encode_avif_hdr( + &image.to_rgba32f(), + export_settings.export_bit_depth(), + export_settings.transfer_function, + export_settings.primaries, + jpeg_quality, + ); + } image .write_to(&mut cursor, image::ImageFormat::Avif) .map_err(|e| e.to_string())?; @@ -908,6 +983,7 @@ pub async fn export_images( &base_image, &main_export_adjustments, &export_settings, + &output_format, &context_clone, &state, is_raw, @@ -1135,11 +1211,8 @@ pub async fn estimate_export_sizes( "estimate_export_size", )?; - let preview_bytes = encode_image_to_bytes( - &processed_preview, - &output_format, - export_settings.jpeg_quality, - )?; + let preview_bytes = + encode_image_to_bytes(&processed_preview, &output_format, &export_settings)?; let preview_byte_size = preview_bytes.len(); let (transformed_full_res, _) = @@ -1273,11 +1346,8 @@ pub async fn estimate_export_sizes( "estimate_batch_export_size", )?; - let preview_bytes = encode_image_to_bytes( - &processed_preview, - &output_format, - export_settings.jpeg_quality, - )?; + let preview_bytes = + encode_image_to_bytes(&processed_preview, &output_format, &export_settings)?; let single_image_estimated_size = preview_bytes.len(); let full_w = (shrunk_w as f32 / raw_scale_factor).round() as u32; diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index a0643176de..7c8ced51ae 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -2191,3 +2191,50 @@ fn process_and_get_dynamic_image_inner( .ok_or("Failed to create image buffer from GPU data")?; Ok(DynamicImage::ImageRgba8(img_buf)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Guards the HDR shader substitution anchors. If a future edit to `shader.wgsl` moves any + /// of these strings, the `.replace()` silently no-ops and HDR breaks (clipped headroom or a + /// storage-format mismatch). This test fails loudly so `hdr_shader_source` gets updated. + #[test] + fn hdr_shader_substitutions_apply() { + let src = include_str!("shaders/shader.wgsl"); + // The SDR shader must still contain the anchors we substitute on. + assert!( + src.contains("rgba8unorm, write>"), + "shader.wgsl storage anchor moved; update hdr_shader_source()" + ); + assert!( + src.contains("linear_to_srgb(composite_rgb_linear)"), + "shader.wgsl sRGB-encode anchor moved; update hdr_shader_source()" + ); + assert!( + src.contains("clamp(final_rgb, vec3(0.0), vec3(1.0))"), + "shader.wgsl store-clamp anchor moved; update hdr_shader_source()" + ); + assert!( + src.contains("let dither_amount = 1.0 / 255.0;"), + "shader.wgsl dither anchor moved; update hdr_shader_source()" + ); + + let hdr = hdr_shader_source(); + assert!(hdr.contains("rgba16float, write>")); + assert!( + !hdr.contains("rgba8unorm, write>"), + "storage not promoted to rgba16float" + ); + assert!(hdr.contains("linear_to_srgb_extended(composite_rgb_linear)")); + assert!( + !hdr.contains("clamp(final_rgb, vec3(0.0), vec3(1.0))"), + "headroom-killing store clamp still present in HDR shader" + ); + assert!(hdr.contains("max(final_rgb, vec3(0.0))")); + assert!( + hdr.contains("let dither_amount = 0.0;"), + "dither not disabled in HDR shader" + ); + } +} diff --git a/src-tauri/src/hdr.rs b/src-tauri/src/hdr.rs index 055e020e64..a41a105a92 100644 --- a/src-tauri/src/hdr.rs +++ b/src-tauri/src/hdr.rs @@ -110,9 +110,11 @@ pub fn rec709_to_rec2020_linear(rgb: [f32; 3]) -> [f32; 3] { } /// Transfer function selector for HDR export. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] pub enum TransferFunction { /// Plain sRGB (the existing SDR path). + #[default] Srgb, /// SMPTE ST 2084 (PQ). CICP transfer_characteristics = 16. Pq, @@ -121,9 +123,11 @@ pub enum TransferFunction { } /// Color primaries selector for HDR export. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] pub enum ColorPrimaries { /// Rec.709 / sRGB. CICP colour_primaries = 1. + #[default] Srgb, /// Rec.2020. CICP colour_primaries = 9. Bt2020, From e6ef3e82cd5390a43183c792592b18ab4076f629 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 09:27:12 +0200 Subject: [PATCH 06/20] HDR export: fix curve-stage headroom clip; add GPU E2E test; de-circularize asserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit (negative-control) revealed two real gaps: 1. The tone-curve stage was STILL clipping headroom to [0,1] even after the sRGB-encode and store-clamp fixes — the deeper half of the bug the brief warned about. A new GPU end-to-end test (headless wgpu device, real pipeline, white at +2 stops) caught it: recovered linear was 1.0 instead of 4.0. Three more targeted hdr_shader_source() substitutions fix it: treat empty curves as default (skip the normalize branch); pass curve-domain headroom through instead of snapping to the top control point; and don't renormalize >1.0 when per-channel RGB curves are active. With these, the GPU now recovers linear 3.997 (~4.0) end-to-end through the full look stage; SDR still clamps to 255. 2. The integration reference-white/headroom assertions were CIRCULAR (expected values derived from the same encoder functions under test). Replaced with independent ST.2084 ground-truth literals (10-bit 594/669/746, 12-bit 2378/2679/2986; JXL 0.5807/0.6542/0.7291). Also: GPU E2E test (exercises run() + f16 readback + srgb_extended_to_linear inversion, skips gracefully without a GPU) and extended the shader-substitution guard to the 3 new curve anchors. Full suite: 8 lib + 5 integration (with hdr_jxl) green. --- src-tauri/src/gpu_processing.rs | 163 ++++++++++++++++++++++++++++++++ src-tauri/tests/hdr_export.rs | 53 +++++++---- 2 files changed, 199 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index 7c8ced51ae..815e3776b3 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -722,6 +722,24 @@ fn hdr_shader_source() -> String { "let dither_amount = 1.0 / 255.0;", "let dither_amount = 0.0;", ) + // 5. The tone-curve stage is display-referred and clamps highlights to [0,1] — the other + // half of the headroom bug. Three targeted curve fixes keep values above diffuse white: + // (a) treat empty curves (count<2) as default so they take the non-normalizing branch; + .replace( + " if (count < 2u) {\n return false;\n }", + " if (count < 2u) {\n return true;\n }", + ) + // (b) above the curve's top control point, pass the headroom excess through instead of + // snapping to the top point's value (identity curve => value passes through); + .replace( + " if (x >= local_points[count - 1u].x) { return local_points[count - 1u].y / 255.0; }", + " if (x >= local_points[count - 1u].x) { return local_points[count - 1u].y / 255.0 + (val - local_points[count - 1u].x / 255.0); }", + ) + // (c) when per-channel RGB curves are active, do not normalize headroom back into gamut. + .replace( + " if (max_comp > 1.0) { final_color = final_color / max_comp; }", + " if (max_comp > 1.0e30) { final_color = final_color / max_comp; }", + ) } fn build_hdr_resources(device: &wgpu::Device, tile_dims: wgpu::Extent3d) -> HdrResources { @@ -2236,5 +2254,150 @@ mod tests { hdr.contains("let dither_amount = 0.0;"), "dither not disabled in HDR shader" ); + + // Curve-stage headroom anchors (the other half of the clip the GPU E2E test exposed). + assert!( + src.contains(" if (count < 2u) {\n return false;\n }"), + "shader.wgsl is_default_curve anchor moved; update hdr_shader_source()" + ); + assert!( + src.contains( + " if (x >= local_points[count - 1u].x) { return local_points[count - 1u].y / 255.0; }" + ), + "shader.wgsl apply_curve top-clamp anchor moved; update hdr_shader_source()" + ); + assert!( + src.contains(" if (max_comp > 1.0) { final_color = final_color / max_comp; }"), + "shader.wgsl curve-normalize anchor moved; update hdr_shader_source()" + ); + assert!(hdr.contains(" if (count < 2u) {\n return true;\n }")); + assert!(hdr.contains("+ (val - local_points[count - 1u].x / 255.0)")); + assert!(hdr.contains("if (max_comp > 1.0e30)")); + } + + /// End-to-end GPU test: runs the REAL pipeline on a headless device, twice on the same input + /// (white at +2 stops -> linear 4.0). The SDR path must clamp to 255; the HDR path must + /// preserve the headroom (recovered linear ~4.0). This exercises what the string-substitution + /// guard cannot: that the substituted WGSL actually compiles, rgba16float storage works, and + /// the f16 readback + extended-sRGB inversion recover linear light above diffuse white. + /// Skips gracefully if no GPU adapter is available (e.g. headless CI). + #[test] + fn hdr_gpu_pipeline_preserves_headroom_end_to_end() { + use crate::image_processing::AllAdjustments; + use wgpu::util::DeviceExt; + + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let adapter = + match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: None, + ..Default::default() + })) { + Ok(a) => a, + Err(_) => { + eprintln!("no GPU adapter available; skipping GPU HDR end-to-end test"); + return; + } + }; + + let mut required_features = wgpu::Features::empty(); + if adapter + .features() + .contains(wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES) + { + required_features |= wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES; + } + let limits = adapter.limits(); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("hdr-test-device"), + required_features, + required_limits: limits.clone(), + experimental_features: wgpu::ExperimentalFeatures::default(), + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::Off, + })) + .expect("request_device"); + + let context = GpuContext { + device: std::sync::Arc::new(device), + queue: std::sync::Arc::new(queue), + limits, + display: std::sync::Arc::new(std::sync::Mutex::new(None)), + }; + + let (w, h) = (16u32, 16u32); + let processor = GpuProcessor::new(context.clone(), w, h).expect("GpuProcessor::new"); + + // sRGB-encoded white (1.0). The shader linearizes it (srgb_to_linear -> 1.0) for non-raw. + let white = [f16::from_f32(1.0); 4]; + let data: Vec = std::iter::repeat_n(white, (w * h) as usize) + .flatten() + .collect(); + let input_tex = context.device.create_texture_with_data( + &context.queue, + &wgpu::TextureDescriptor { + label: Some("hdr-test-input"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + TextureDataOrder::MipMajor, + bytemuck::cast_slice(&data), + ); + let input_view = input_tex.create_view(&Default::default()); + + let mut adjustments = AllAdjustments::default(); + adjustments.global.exposure = 2.0; // +2 stops -> linear x4 -> 4.0, well above diffuse white + adjustments.global.is_raw_image = 0; + let make_request = || RenderRequest { + adjustments, + mask_bitmaps: &[], + lut: None, + roi: None, + }; + + // SDR path: the existing rgba8unorm pipeline clamps to [0,1] -> max byte must be 255. + let (sdr, _, _, _, _) = processor + .run(&input_view, w, h, make_request(), false, false, false) + .expect("SDR run"); + let sdr_max = *sdr.iter().max().unwrap(); + assert_eq!( + sdr_max, 255, + "SDR path should clamp the +2-stop white to 255, got {sdr_max}" + ); + + // HDR path: rgba16float, extended-sRGB encoded WITHOUT clamping, curves headroom-aware. + let (hdr, ow, oh, _, _) = processor + .run(&input_view, w, h, make_request(), false, false, true) + .expect("HDR run"); + assert_eq!( + hdr.len(), + (ow * oh * 8) as usize, + "HDR readback must be 8 bytes/pixel (rgba16float)" + ); + + let stored_srgb = f16::from_le_bytes([hdr[0], hdr[1]]).to_f32(); + let recovered_linear = crate::hdr::srgb_extended_to_linear(stored_srgb); + assert!( + stored_srgb > 1.0, + "stored extended-sRGB value should exceed 1.0 (headroom not clipped), got {stored_srgb}" + ); + // input linear 1.0, +2 stops -> 4.0. The full look stage must carry it through unclipped. + assert!( + recovered_linear > 3.0, + "recovered linear should be ~4.0 (headroom preserved end-to-end), got {recovered_linear}" + ); + eprintln!( + "GPU HDR E2E: SDR max byte={sdr_max} (clamped); HDR recovered linear={recovered_linear:.3} (~4.0 expected)" + ); } } diff --git a/src-tauri/tests/hdr_export.rs b/src-tauri/tests/hdr_export.rs index 72494caefa..fa8a742d4c 100644 --- a/src-tauri/tests/hdr_export.rs +++ b/src-tauri/tests/hdr_export.rs @@ -131,22 +131,35 @@ fn run_avif_pq_case(bit_depth: u8) { let depth = parse_av1c_depth(&avif).expect("av1C box present"); assert_eq!(depth, bit_depth, "av1C coded depth {depth} != {bit_depth}"); - // (3) + (4) pixels via independent decoder + // (3) + (4) pixels via independent decoder. + // INDEPENDENT ground truth: these codes are computed offline from ST.2084 with the 203-nit + // anchor (203/406/812 nits) and hardcoded here, NOT derived from the encoder's own functions, + // so a wrong anchor/transfer in the encoder cannot make this pass circularly. + let (exp_white, exp_rel2, exp_rel4) = match bit_depth { + 10 => (594u16, 669u16, 746u16), + 12 => (2378, 2679, 2986), + _ => panic!("unexpected bit depth {bit_depth}"), + }; let dec = avifdec_to_png16(&avif, &format!("pq{bit_depth}")); let sample = |px: (u32, u32)| -> u16 { code_from_png16(dec.get_pixel(px.0, px.1).0[0], bit_depth) }; - let expected_white = hdr::quantize_full_range(hdr::linear_scene_to_pq(1.0), bit_depth); - let white = sample(s.diffuse_white_px); - assert!( - (white as i32 - expected_white as i32).abs() <= 2, - "[{bit_depth}b] reference-white code {white}, expected ~{expected_white} (+/-2)" - ); + let near = |got: u16, exp: u16, what: &str| { + assert!( + (got as i32 - exp as i32).abs() <= 2, + "[{bit_depth}b] {what} code {got}, expected ~{exp} (+/-2)" + ); + }; + let white = sample(s.diffuse_white_px); let black = sample(s.black_px); let rel2 = sample(s.rel2_px); let rel4 = sample(s.rel4_px); let full = (1u32 << bit_depth) - 1; + + near(white, exp_white, "reference-white (203 nit)"); + near(rel2, exp_rel2, "2x headroom (406 nit)"); + near(rel4, exp_rel4, "4x headroom (812 nit)"); assert!( black < white, "[{bit_depth}b] black {black} !< white {white}" @@ -167,7 +180,7 @@ fn run_avif_pq_case(bit_depth: u8) { } eprintln!( - "AVIF {bit_depth}-bit PQ: tags 9/16/0/full=1, depth {depth}, white={white} (exp {expected_white}), black={black} rel2={rel2} rel4={rel4} ramp={ramp:?}" + "AVIF {bit_depth}-bit PQ: tags 9/16/0/full=1, depth {depth}, white={white} (exp {exp_white}), black={black} rel2={rel2} rel4={rel4} ramp={ramp:?}" ); } @@ -262,20 +275,26 @@ mod jxl { let sample = |px: (u32, u32)| -> f32 { buf[((px.1 * w as u32 + px.0) as usize) * ch] // first (R) channel }; - let expected = hdr::linear_scene_to_pq(1.0); + // INDEPENDENT ground truth (ST.2084, 203/406/812 nit), not derived from the encoder. + const EXP_WHITE: f32 = 0.580_690; // 203 nit + const EXP_REL2: f32 = 0.654_177; // 406 nit + const EXP_REL4: f32 = 0.729_146; // 812 nit let white = sample(s.diffuse_white_px); - assert!( - (white - expected).abs() < 0.01, - "jxl reference-white PQ code {white}, expected ~{expected}" - ); - let w1 = hdr::linear_scene_to_pq(1.0); let w2 = sample(s.rel2_px); let w4 = sample(s.rel4_px); assert!( - w1 < w2 && w2 < w4 && w4 < 1.0, - "jxl headroom not increasing: {w1} {w2} {w4}" + (white - EXP_WHITE).abs() < 0.005, + "jxl reference-white PQ code {white}, expected ~{EXP_WHITE}" + ); + assert!( + (w2 - EXP_REL2).abs() < 0.005 && (w4 - EXP_REL4).abs() < 0.005, + "jxl headroom codes {w2}/{w4}, expected ~{EXP_REL2}/{EXP_REL4}" + ); + assert!( + white < w2 && w2 < w4 && w4 < 1.0, + "jxl headroom not increasing: {white} {w2} {w4}" ); let _ = std::fs::remove_file(&path); - eprintln!("JXL PQ: tagged PQ, white={white} (exp {expected}), rel2={w2} rel4={w4}"); + eprintln!("JXL PQ: tagged PQ, white={white} (exp {EXP_WHITE}), rel2={w2} rel4={w4}"); } } From 5bddbf4c6f929d33afb6a3847e0121ad233338ae Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 09:34:38 +0200 Subject: [PATCH 07/20] HDR export Phase 2.4 (UI): bit-depth / transfer / primaries controls - useExportSettings: add bitDepth (8/10/12), transferFunction ('srgb'|'pq'|'hlg'), primaries ('srgb'|'bt2020') state, threaded through preset apply + currentSettingsObject. - ExportSettings / ExportPreset TS interfaces gain the three optional fields. - ExportPanel: for AVIF/JXL, a Bit Depth dropdown; when >8-bit, Transfer + Primaries dropdowns appear (picking an HDR depth defaults to PQ/Rec.2020) plus an HDR-display hint. The three fields are spread into both the estimate and export settings objects via one memoized hdrSettings (camelCase -> backend serde bit_depth/transfer_function/primaries). - en.json: new export.file.* strings. Vite build passes; no new tsc errors (repo has 117 pre-existing). --- src/components/panel/right/ExportPanel.tsx | 71 ++++++++++++++++++++ src/components/ui/ExportImportProperties.tsx | 9 +++ src/hooks/useExportSettings.ts | 23 ++++++- src/i18n/locales/en.json | 9 ++- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index 1aeeaba744..02c82c9e2a 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -233,6 +233,12 @@ export default function ExportPanel({ setWatermarkOpacity, preserveFolders, setPreserveFolders, + bitDepth, + setBitDepth, + transferFunction, + setTransferFunction, + primaries, + setPrimaries, handleApplyPreset, currentSettingsObject, } = useExportSettings(); @@ -344,6 +350,12 @@ export default function ExportPanel({ [t], ); + // HDR fields shared by the estimate and export settings objects (kept in one place, DRY). + const hdrSettings = useMemo( + () => ({ bitDepth, transferFunction, primaries }), + [bitDepth, transferFunction, primaries], + ); + const debouncedEstimateSize = useMemo( () => debounce(async (paths, currentAdj, currentPath, exportSettings, format) => { @@ -390,6 +402,7 @@ export default function ExportPanel({ opacity: watermarkOpacity, } : null, + ...hdrSettings, }; const format = FILE_FORMATS.find((f: FileFormat) => f.id === fileFormat)?.extensions[0] || 'jpeg'; debouncedEstimateSize(pathsToExport, adjustments, selectedImage?.path, exportSettings, format); @@ -466,6 +479,7 @@ export default function ExportPanel({ opacity: watermarkOpacity, } : null, + ...hdrSettings, }; const lastExportPath = appSettings?.exportPresets?.find((p) => p.id === '__last_used__')?.lastExportPath; @@ -605,6 +619,63 @@ export default function ExportPanel({ /> )} + {[FileFormats.Avif, FileFormats.Jxl].includes(fileFormat as FileFormats) && ( +
+
+ {t('export.file.bitDepth')} + { + const depth = parseInt(v, 10); + setBitDepth(depth); + // Picking an HDR depth implies an HDR transfer; default to PQ/Rec.2020. + if (depth > 8 && transferFunction === 'srgb') { + setTransferFunction('pq'); + setPrimaries('bt2020'); + } + }} + disabled={isExporting} + className="w-44" + /> +
+ {bitDepth > 8 && ( + <> +
+ {t('export.file.transfer')} + +
+
+ {t('export.file.primaries')} + +
+

{t('export.file.hdrHint')}

+ + )} +
+ )} {(numImages > 1 || onClose) && ( diff --git a/src/components/ui/ExportImportProperties.tsx b/src/components/ui/ExportImportProperties.tsx index a51dff33a5..97a42c5872 100644 --- a/src/components/ui/ExportImportProperties.tsx +++ b/src/components/ui/ExportImportProperties.tsx @@ -47,6 +47,12 @@ export interface ExportSettings { watermark: WatermarkSettings | null; exportMasks?: boolean; preserveFolders?: boolean; + /** HDR export (AVIF/JXL): 8 = SDR, 10/12 = HDR. */ + bitDepth?: number; + /** HDR transfer function: 'srgb' | 'pq' | 'hlg'. */ + transferFunction?: string; + /** HDR primaries: 'srgb' | 'bt2020'. */ + primaries?: string; } export enum WatermarkAnchor { @@ -119,4 +125,7 @@ export interface ExportPreset { watermarkSpacing: number; watermarkOpacity: number; lastExportPath?: string; + bitDepth?: number; + transferFunction?: string; + primaries?: string; } diff --git a/src/hooks/useExportSettings.ts b/src/hooks/useExportSettings.ts index 1af496dfdd..a369fbec33 100644 --- a/src/hooks/useExportSettings.ts +++ b/src/hooks/useExportSettings.ts @@ -20,6 +20,10 @@ export function useExportSettings() { const [watermarkScale, setWatermarkScale] = useState(10); const [watermarkSpacing, setWatermarkSpacing] = useState(5); const [watermarkOpacity, setWatermarkOpacity] = useState(75); + // HDR export (AVIF/JXL): bitDepth 8 = SDR; 10/12 = HDR. transferFunction 'srgb'|'pq'|'hlg'. + const [bitDepth, setBitDepth] = useState(8); + const [transferFunction, setTransferFunction] = useState('srgb'); + const [primaries, setPrimaries] = useState('srgb'); const handleApplyPreset = useCallback((preset: ExportPreset) => { setFileFormat(preset.fileFormat); @@ -40,6 +44,9 @@ export function useExportSettings() { setWatermarkScale(preset.watermarkScale); setWatermarkSpacing(preset.watermarkSpacing); setWatermarkOpacity(preset.watermarkOpacity); + setBitDepth(preset.bitDepth ?? 8); + setTransferFunction(preset.transferFunction ?? 'srgb'); + setPrimaries(preset.primaries ?? 'srgb'); }, []); const currentSettingsObject = useMemo( @@ -62,6 +69,9 @@ export function useExportSettings() { watermarkScale, watermarkSpacing, watermarkOpacity, + bitDepth, + transferFunction, + primaries, }), [ fileFormat, @@ -82,7 +92,10 @@ export function useExportSettings() { watermarkScale, watermarkSpacing, watermarkOpacity, - ] + bitDepth, + transferFunction, + primaries, + ], ); return { @@ -122,7 +135,13 @@ export function useExportSettings() { setWatermarkSpacing, watermarkOpacity, setWatermarkOpacity, + bitDepth, + setBitDepth, + transferFunction, + setTransferFunction, + primaries, + setPrimaries, handleApplyPreset, currentSettingsObject, }; -} \ No newline at end of file +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1deee0e41b..3719688515 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -731,7 +731,14 @@ }, "file": { "quality": "Quality", - "qualityLossless": "Quality (Lossless)" + "qualityLossless": "Quality (Lossless)", + "bitDepth": "Bit Depth", + "bitDepth8": "8-bit (SDR)", + "bitDepth10": "10-bit (HDR)", + "bitDepth12": "12-bit (HDR)", + "transfer": "Transfer", + "primaries": "Primaries", + "hdrHint": "HDR output is tagged BT.2020 + PQ/HLG. Needs an HDR display to view correctly." }, "labels": { "image": "Image", From 669b47c553085dcc5b93f1d40f45f94c2ce74cc6 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 09:37:33 +0200 Subject: [PATCH 08/20] HDR export: final report + worklog HDR_REPORT.md: files changed, dependencies + system-lib install, how to run the harness, observed CICP/codes, known limitations (headroom-through-curves choice, matrix=0 vs 9, HLG anchoring, watermark/EXIF), human HDR-display sign-off list, and build commands. --- HDR_REPORT.md | 131 +++++++++++++++++++++++++++++++++++++++++++++++++ HDR_WORKLOG.md | 20 +++++++- 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 HDR_REPORT.md diff --git a/HDR_REPORT.md b/HDR_REPORT.md new file mode 100644 index 0000000000..80cd310eb1 --- /dev/null +++ b/HDR_REPORT.md @@ -0,0 +1,131 @@ +# HDR (PQ/HLG) AVIF + JPEG XL export — final report + +Branch `feat/hdr-export` on fork `KaiserRuben/RapidRAW`. Platform verified: **macOS arm64**, +rustc/cargo 1.96.0. License unchanged (**AGPL-3.0**; the new GPL-3.0 JXL bindings are +copyleft-compatible with AGPL-3.0). + +## Status: gate GREEN + +The automated harness asserts all four "definition of done" criteria and passes with **exact** +numbers for AVIF (10 & 12 bit) and JPEG XL, plus a GPU end-to-end headroom test: + +| check | AVIF 10-bit | AVIF 12-bit | JXL (PQ) | +|---|---|---|---| +| (1) CICP tags | primaries **9**, transfer **16** (PQ) / **18** (HLG), matrix **0**, full-range **1** | same | primaries Rec.2100, transfer **PQ** | +| (2) bit depth (from av1C / codestream) | **10** | **12** | 32-bit float (≥10) | +| (3) 203-nit reference white | code **594** (exp 594, ±2) | **2378** | **0.58069** | +| (4) headroom (406 / 812 nit) | **669 / 746** (strictly increasing) | **2679 / 2986** | **0.6542 / 0.7291** | + +GPU end-to-end (real pipeline, white at +2 stops → linear 4.0): SDR path clamps to byte 255; +HDR path recovers linear **3.997** — headroom preserved through the *entire* look stage. + +## How it works (data flow) + +`linear scene-referred (diffuse white = 1.0, headroom > 1.0)` → +**GPU HDR pipeline** (rgba16float, headroom-preserving) → readback f16 → invert extended sRGB → +`DynamicImage::ImageRgba32F (linear)` → **encoder**: Rec.709→Rec.2020 matrix → PQ/HLG OETF with +the 203-nit anchor (`L = linear·203/10000`) → quantize 10/12-bit full-range → AVIF/JXL + CICP tags. + +The 8-bit SDR path is **completely untouched** — HDR uses a *separate, lazily-built* rgba16float +pipeline + textures, so SDR output is byte-for-byte unchanged and SDR-only sessions allocate +nothing extra. + +## Files changed and why + +**Backend (`src-tauri/`):** +- `Cargo.toml` — add `rav1e 0.8.1` + `avif-serialize 0.8.9` (AVIF, always on); `jpegxl-rs 0.14` + + `jpegxl-sys 0.12` behind optional `hdr_jxl` feature (JXL). New `[features] hdr_jxl`. +- `src/hdr.rs` *(new)* — single source of the color science: pinned ST.2084 PQ inverse-EOTF/EOTF, + BT.2100 HLG OETF, extended-sRGB↔linear (exact inverse of the shader), BT.2087 709→2020 matrix, + CICP mapping, full-range quantization, synthetic test image, `encode_avif_hdr`, `encode_jxl_hdr`, + and unit tests pinning the math. +- `src/lib.rs` — `pub mod hdr;`. +- `src/gpu_processing.rs` — the 8-bit ceiling fix. Extracted `build_main_bgl(device, format)` + (shared by SDR/HDR so the layouts can't drift); `hdr_shader_source()` derives the HDR shader + from `shader.wgsl` at runtime via targeted substitutions (storage rgba8unorm→rgba16float; + clamping→extended sRGB encode; drop the store clamp; disable dither; **+3 curve-stage fixes** so + the tone curves don't re-clip headroom to [0,1]); lazily-built `HdrResources`; `run()` gains + `output_hdr` and selects pipeline/textures/bytes-per-pixel; `read_texture_data_roi` parameterized; + `process_and_get_dynamic_image_hdr` returns linear `ImageRgba32F`. Tests: shader-substitution + guard + GPU end-to-end headroom. +- `src/export_processing.rs` — `ExportSettings` gains `bit_depth`/`transfer_function`/`primaries` + (+ `hdr_enabled()`/`export_bit_depth()`); `encode_image_to_bytes` routes avif/jxl to the HDR + encoders when HDR is requested; `process_image_for_export[_pipeline]` thread `output_hdr` to the + HDR GPU path. +- `tests/hdr_export.rs` *(new)* — the gate. Parses nclx + av1C **from the file bytes** (independent + of the encoder), decodes AVIF with `avifdec` (libavif) and JXL with jxl-oxide, asserts (1)–(4) + against **independent** ST.2084 ground-truth literals (not encoder-derived — see Limitations). + +**Frontend (`src/`):** `hooks/useExportSettings.ts`, `components/ui/ExportImportProperties.tsx`, +`components/panel/right/ExportPanel.tsx` (Bit Depth / Transfer / Primaries controls for AVIF/JXL), +`i18n/locales/en.json`. + +**Docs:** `PLAN.md`, `HDR_WORKLOG.md`, this report. + +## New dependencies + system libs + +- **AVIF (always on, pure Rust):** `rav1e` + `avif-serialize`. No runtime system libs. Build-time: + **`nasm`** is required by rav1e's `asm` feature (`brew install nasm`). To build without nasm, drop + the `asm` feature in `Cargo.toml` (slower encode, pure Rust). +- **JPEG XL (optional `hdr_jxl` feature):** `jpegxl-rs` + `jpegxl-sys` link **system libjxl**. + `brew install jpeg-xl`, and because it is keg-only, set + `PKG_CONFIG_PATH="$(brew --prefix jpeg-xl)/lib/pkgconfig:$PKG_CONFIG_PATH"` at build time. + For distribution the libjxl dylibs must be bundled (or build jpegxl-sys `vendored`, which compiles + libjxl from source via cmake). Default builds do **not** require libjxl. + +## Run the verification harness + +```sh +cd src-tauri +# AVIF only (needs libavif's `avifdec` on PATH for the pixel checks: `brew install libavif`): +cargo test --test hdr_export +# Everything incl. JXL + GPU end-to-end + pinned math: +PKG_CONFIG_PATH="$(brew --prefix jpeg-xl)/lib/pkgconfig:$PKG_CONFIG_PATH" cargo test --features hdr_jxl +``` +The harness exits non-zero on any failure. The GPU end-to-end test skips gracefully if no GPU +adapter is available (headless CI). + +## Known limitations / uncertainty (read before trusting the look) + +1. **Headroom-through-curves is preserved by a deliberate, not perceptually-validated, choice.** + RapidRAW's look pipeline is display-referred; the tone curves clamp to [0,1]. The HDR shader + substitutions make curves pass values *above the top control point* through linearly and disable + the >1.0 renormalization. For default/identity curves this is exactly right (verified: linear + 4.0 survives). For a user-set tone/RGB curve the behaviour above diffuse white is a reasonable + extrapolation but **has not been visually checked** — flagging loudly as the brief requested. +2. **AVIF uses matrix_coefficients = 0 (Identity/GBR, 4:4:4 full-range).** This is lossless and + makes the reference-white code exact, and is explicitly allowed by the brief. It is less + universally supported than YCbCr matrix=9; if a target HDR viewer rejects it, switch to matrix 9 + (requires implementing RGB→BT.2020-NCL YCbCr + 4:2:0, a known follow-up). +3. **HLG is tag- and monotonicity-verified only.** HLG is relative/scene-referred, so there is no + single absolute-nit anchor check; `HLG_PEAK_RATIO = 12` is a reasonable default, not tuned. +4. **Watermarking an HDR export clamps the watermarked pixels to SDR** (the overlay runs in 8-bit). + HDR export *without* a watermark is unaffected. +5. **No EXIF in AVIF/JXL** — the metadata writer only handles JPEG/PNG/TIFF (pre-existing); HDR + files carry CICP tags but not EXIF. +6. **Tiling**: the HDR path reuses the SDR tiling logic (parameterized), exercised on small images + in the harness; very large multi-tile HDR images use the same code but weren't size-stressed. + +## Needs a human + real HDR display (out of scope for automated checks) + +- View exported PQ AVIF/JXL on a true HDR display (Apple XDR, HDR monitor): confirm diffuse white + sits ~203 nits, highlights extend above it without banding/clipping, and there is no color cast. +- Sign off on the **above-diffuse-white look** (limitation #1) for real edits with curves/grading. +- Confirm matrix=0 (identity) AVIF decodes correctly in the target viewers (macOS Preview/Photos, + Chrome, Safari); decide whether matrix=9 is needed (limitation #2). +- Check HLG output on an HLG display. +- Run real RAW files with genuine sensor highlight headroom (not just the synthetic patches). + +## Build the HDR-capable binary + +```sh +npm install + +# AVIF HDR (always available; needs nasm at build time): +npm run tauri build + +# + JPEG XL HDR (needs system libjxl): +brew install jpeg-xl +PKG_CONFIG_PATH="$(brew --prefix jpeg-xl)/lib/pkgconfig:$PKG_CONFIG_PATH" \ + npm run tauri build -- --features hdr_jxl +``` diff --git a/HDR_WORKLOG.md b/HDR_WORKLOG.md index eceaa33844..f8c0b66b3c 100644 --- a/HDR_WORKLOG.md +++ b/HDR_WORKLOG.md @@ -59,7 +59,23 @@ the final store. Confirmed line numbers: → PQ inverse-EOTF with **203-nit anchor** (`L = linear * 203/10000`) → quantize to 10/12-bit full-range → AVIF/JXL with CICP primaries=9, transfer=16(PQ)/18(HLG), matrix=9, full_range=1. -## Phase 1.5 — Verification harness (gate) — in progress -## Phase 2 — Implementation — pending (blocked on encoder-lib spike, running in background) +## Phase 1.5 — Verification harness (gate) ✅ +`src-tauri/tests/hdr_export.rs` + `hdr.rs` unit tests. Tags + bit depth parsed from raw bytes; +AVIF pixels decoded by `avifdec` (independent of the encoder), JXL by jxl-oxide. Assertions use +**independent** ST.2084 ground-truth literals (de-circularized after an audit caught the original +expected-values being derived from the encoder under test). + +## Phase 2 — Implementation ✅ +- Encoder spike: **GO** for both. AVIF via pure-Rust `rav1e` + `avif-serialize` (no C deps); + JXL via `jpegxl-rs` + system libjxl (optional `hdr_jxl` feature). +- 2.1/2.2 GPU: separate lazily-built rgba16float pipeline (SDR untouched). Headroom fix needed + **five** shader substitutions, not two — an audit-driven GPU end-to-end test revealed the + tone-curve stage was *still* clamping to [0,1] (the deeper half of the bug the brief warned + about). With the curve fixes, the GPU recovers linear 3.997 (~4.0) end-to-end. +- 2.3 encoders + 2.4 settings/command/UI wired through `encode_image_to_bytes` and the export panel. + +## Result +Gate GREEN: AVIF 10/12-bit + JXL, all four criteria with exact codes (594/669/746, 2378/2679/2986, +JXL 0.5807). Full details, limitations, human-sign-off list, and build commands in **HDR_REPORT.md**. From c6b6305ab01dbb4fd245d8856c9a2c44977579aa Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 09:50:14 +0200 Subject: [PATCH 09/20] Bump version to 1.5.7-hdr for the HDR-capable build --- src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c7558760c4..58b62d0f2f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -89,5 +89,5 @@ } } }, - "version": "1.5.7" + "version": "1.5.7-hdr" } From 4b79cf3a92a583b7ddf8f87592aeca20c1b372e8 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 10:32:59 +0200 Subject: [PATCH 10/20] HDR export: make all encoder options configurable (matrix/chroma/range/gamut/metadata) hdr.rs introduces HdrEncodeConfig and parameterizes the color science: - matrix: Identity (RGB, exact, 4:4:4) or YCbCr (BT.2020-NCL for Rec.2020, BT.709-NCL for sRGB/P3) with correct non-constant-luminance conversion. - chroma subsampling 4:4:4 / 4:2:2 / 4:2:0 (box-averaged); identity forces 4:4:4. - full vs limited signal range (proper 219/224 + 16/128 scaling per bit depth). - primaries adds Display-P3 (CICP 12; 709->P3 matrix, rav1e SMPTE432, JXL P3). - reference-white nits (default 203) and HLG peak ratio (default 12) parameterized. - mastering metadata: per-frame MaxCLL/MaxFALL (+ BT.2020/D65 MDCV) via avif-serialize. - JXL quality/lossless threaded from jpegQuality. HdrEncodeConfig::default() reproduces the prior identity/4:4:4/full/203 output exactly, so the existing verified path is unchanged. export_processing: ExportSettings gains matrix/chromaSubsampling/range/referenceWhiteNits/ hlgPeakRatio/masteringMetadata (serde camelCase, defaulted); to_hdr_config() builds the config; guardrails normalize invalid inputs (identity->4:4:4, ref-white<=0->203, hlg<=0->12). Tests: colored YCbCr 4:4:4 round-trip (R/G/B 0.1/0.4/0.9 -> 367/499/583, channels not swapped), 4:2:0 uniform round-trip, limited(941) vs full(1023) range via y4m, primaries tagged+applied, mastering clli box present, bad-bit-depth rejected. 13 lib + 11 integration pass (default 13+10). Co-Authored-By: KaiserRuben --- src-tauri/src/export_processing.rs | 69 ++- src-tauri/src/hdr.rs | 651 ++++++++++++++++++++++++++--- src-tauri/tests/hdr_export.rs | 340 ++++++++++++++- 3 files changed, 972 insertions(+), 88 deletions(-) diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 65933b7f9a..92c30350cb 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -76,9 +76,35 @@ pub struct ExportSettings { /// Transfer function for HDR export (sRGB = SDR, PQ, or HLG). #[serde(default)] pub transfer_function: crate::hdr::TransferFunction, - /// Color primaries for HDR export (sRGB/Rec.709 or Rec.2020). + /// Color primaries for HDR export (sRGB/Rec.709, Rec.2020, or Display-P3). #[serde(default)] pub primaries: crate::hdr::ColorPrimaries, + /// AVIF plane layout: identity (RGB, forced 4:4:4) or non-constant-luminance Y'CbCr. + #[serde(default)] + pub matrix: crate::hdr::MatrixMode, + /// Chroma subsampling for the YCbCr matrix path (ignored for identity). + #[serde(default)] + pub chroma_subsampling: crate::hdr::ChromaSubsampling, + /// Quantization range: full or limited/studio. + #[serde(default)] + pub range: crate::hdr::DynamicRange, + /// PQ reference-white anchor in cd/m² (diffuse white). <=0 is treated as 203. + #[serde(default = "default_reference_white_nits")] + pub reference_white_nits: f32, + /// HLG headroom ratio (nominal peak / diffuse white). <=0 is treated as 12. + #[serde(default = "default_hlg_peak_ratio")] + pub hlg_peak_ratio: f32, + /// Emit HDR mastering metadata (MaxCLL/MaxFALL + mastering display) into AVIF. + #[serde(default)] + pub mastering_metadata: bool, +} + +fn default_reference_white_nits() -> f32 { + crate::hdr::REFERENCE_WHITE_NITS +} + +fn default_hlg_peak_ratio() -> f32 { + crate::hdr::HLG_PEAK_RATIO } impl ExportSettings { @@ -99,6 +125,38 @@ impl ExportSettings { pub fn export_bit_depth(&self) -> u8 { if self.bit_depth >= 12 { 12 } else { 10 } } + + /// Build a fully-specified HDR encode config from these export settings. Out-of-range anchors + /// (<=0) fall back to the canonical defaults; the identity matrix forces 4:4:4. + pub fn to_hdr_config(&self) -> crate::hdr::HdrEncodeConfig { + let reference_white_nits = if self.reference_white_nits > 0.0 { + self.reference_white_nits + } else { + crate::hdr::REFERENCE_WHITE_NITS + }; + let hlg_peak_ratio = if self.hlg_peak_ratio > 0.0 { + self.hlg_peak_ratio + } else { + crate::hdr::HLG_PEAK_RATIO + }; + let subsampling = if self.matrix == crate::hdr::MatrixMode::Identity { + crate::hdr::ChromaSubsampling::Cs444 + } else { + self.chroma_subsampling + }; + crate::hdr::HdrEncodeConfig { + bit_depth: self.export_bit_depth(), + transfer: self.transfer_function, + primaries: self.primaries, + matrix: self.matrix, + subsampling, + range: self.range, + reference_white_nits, + hlg_peak_ratio, + quality: self.jpeg_quality, + mastering_metadata: self.mastering_metadata, + } + } } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -451,9 +509,7 @@ fn encode_image_to_bytes( { return crate::hdr::encode_jxl_hdr( &image.to_rgba32f(), - export_settings.transfer_function, - export_settings.primaries, - jpeg_quality >= 100, + &export_settings.to_hdr_config(), ); } #[cfg(not(feature = "hdr_jxl"))] @@ -528,10 +584,7 @@ fn encode_image_to_bytes( if hdr { return crate::hdr::encode_avif_hdr( &image.to_rgba32f(), - export_settings.export_bit_depth(), - export_settings.transfer_function, - export_settings.primaries, - jpeg_quality, + &export_settings.to_hdr_config(), ); } image diff --git a/src-tauri/src/hdr.rs b/src-tauri/src/hdr.rs index a41a105a92..54b0d74802 100644 --- a/src-tauri/src/hdr.rs +++ b/src-tauri/src/hdr.rs @@ -46,9 +46,15 @@ pub fn pq_eotf(e: f32) -> f32 { (num / den).powf(1.0 / PQ_M1) } +/// Linear scene value (diffuse white = 1.0) -> PQ code, anchoring diffuse white at +/// `reference_white_nits`. +pub fn linear_scene_to_pq_with(linear: f32, reference_white_nits: f32) -> f32 { + pq_inverse_eotf(linear * (reference_white_nits / PQ_MAX_NITS)) +} + /// Convenience: linear scene value (diffuse white = 1.0) -> PQ code, applying the 203-nit anchor. pub fn linear_scene_to_pq(linear: f32) -> f32 { - pq_inverse_eotf(linear * (REFERENCE_WHITE_NITS / PQ_MAX_NITS)) + linear_scene_to_pq_with(linear, REFERENCE_WHITE_NITS) } // --- ITU-R BT.2100 HLG OETF (scene-referred, normalized E in [0,1]). --- @@ -70,10 +76,16 @@ pub fn hlg_oetf(e: f32) -> f32 { /// (linear 1.0) and headroom up to 12.0 fit the HLG `[0,1]` signal range. pub const HLG_PEAK_RATIO: f32 = 12.0; +/// Linear scene value (diffuse white = 1.0) -> HLG signal, normalizing by `peak_ratio` +/// (headroom above diffuse white that fits the HLG `[0,1]` signal range). +pub fn linear_scene_to_hlg_with(linear: f32, peak_ratio: f32) -> f32 { + hlg_oetf(linear / peak_ratio) +} + /// Convenience: linear scene value (diffuse white = 1.0) -> HLG signal. /// NOTE: HLG is relative/scene-referred — unlike PQ it has no single absolute-nit anchor here. pub fn linear_scene_to_hlg(linear: f32) -> f32 { - hlg_oetf(linear / HLG_PEAK_RATIO) + linear_scene_to_hlg_with(linear, HLG_PEAK_RATIO) } /// Inverse of the shader's `linear_to_srgb_extended` (`shader.wgsl` L237) for c >= 0. @@ -109,6 +121,16 @@ pub fn rec709_to_rec2020_linear(rgb: [f32; 3]) -> [f32; 3] { ] } +/// Linear Rec.709/sRGB primaries -> linear Display-P3 primaries (D65, no adaptation). +/// White-preserving: each row sums to 1.0, so neutral (R=G=B) is unchanged. +pub fn rec709_to_displayp3_linear(rgb: [f32; 3]) -> [f32; 3] { + [ + 0.822461969 * rgb[0] + 0.177538031 * rgb[1] + 0.0 * rgb[2], + 0.033194199 * rgb[0] + 0.966805801 * rgb[1] + 0.0 * rgb[2], + 0.017082631 * rgb[0] + 0.072397073 * rgb[1] + 0.910520296 * rgb[2], + ] +} + /// Transfer function selector for HDR export. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -131,6 +153,105 @@ pub enum ColorPrimaries { Srgb, /// Rec.2020. CICP colour_primaries = 9. Bt2020, + /// Display-P3 (D65). CICP colour_primaries = 12. + #[serde(rename = "displayp3")] + DisplayP3, +} + +/// AVIF plane layout: store RGB directly (identity matrix) or convert to non-constant-luminance +/// Y'CbCr. Identity is exact for every color (no chroma subsampling); YCbCr matches conventional +/// HDR pipelines and allows chroma subsampling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MatrixMode { + /// CICP matrix_coefficients = 0 (RGB / GBR planes), forced 4:4:4. + #[default] + Identity, + /// Non-constant-luminance Y'CbCr (BT.709 or BT.2020 NCL depending on primaries). + Ycbcr, +} + +/// Chroma subsampling for the YCbCr path (ignored / forced to 4:4:4 for the identity matrix). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub enum ChromaSubsampling { + /// No subsampling. + #[default] + #[serde(rename = "444")] + Cs444, + /// Horizontal 2:1. + #[serde(rename = "422")] + Cs422, + /// 2x2. + #[serde(rename = "420")] + Cs420, +} + +/// Quantization range: full (0..2^n-1) or limited/"video" (16..235-scaled). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DynamicRange { + /// Full range: luma 0..max, chroma symmetric around max/2. + #[default] + Full, + /// Limited / studio range: luma 16..235 (scaled by bit depth), chroma 16..240. + Limited, +} + +/// Fully-specified HDR encode configuration. `Default` reproduces the historical default path +/// (identity matrix, 4:4:4, full range, 203-nit anchor, HLG peak ratio 12, quality 100, no +/// mastering metadata) so existing outputs and tests are unchanged. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HdrEncodeConfig { + pub bit_depth: u8, + pub transfer: TransferFunction, + pub primaries: ColorPrimaries, + pub matrix: MatrixMode, + pub subsampling: ChromaSubsampling, + pub range: DynamicRange, + pub reference_white_nits: f32, + pub hlg_peak_ratio: f32, + pub quality: u8, + pub mastering_metadata: bool, +} + +impl Default for HdrEncodeConfig { + fn default() -> Self { + Self { + bit_depth: 10, + transfer: TransferFunction::Pq, + primaries: ColorPrimaries::Srgb, + matrix: MatrixMode::Identity, + subsampling: ChromaSubsampling::Cs444, + range: DynamicRange::Full, + reference_white_nits: REFERENCE_WHITE_NITS, + hlg_peak_ratio: HLG_PEAK_RATIO, + quality: 100, + mastering_metadata: false, + } + } +} + +impl HdrEncodeConfig { + /// Sanitize the config: clamp out-of-range anchors to sane defaults and force 4:4:4 for the + /// identity matrix. Returns `Err` for unsupported AVIF bit depths. + pub fn sanitized(mut self) -> Result { + if self.bit_depth != 10 && self.bit_depth != 12 { + return Err(format!( + "AVIF HDR bit depth must be 10 or 12, got {}", + self.bit_depth + )); + } + if self.reference_white_nits <= 0.0 || !self.reference_white_nits.is_finite() { + self.reference_white_nits = REFERENCE_WHITE_NITS; + } + if self.hlg_peak_ratio <= 0.0 || !self.hlg_peak_ratio.is_finite() { + self.hlg_peak_ratio = HLG_PEAK_RATIO; + } + if self.matrix == MatrixMode::Identity { + self.subsampling = ChromaSubsampling::Cs444; + } + Ok(self) + } } /// CICP code points (ISO/IEC 23091-2) for a given selection, plus matrix + range we target. @@ -146,6 +267,7 @@ pub fn cicp_for(primaries: ColorPrimaries, transfer: TransferFunction) -> CicpTa colour_primaries: match primaries { ColorPrimaries::Srgb => 1, ColorPrimaries::Bt2020 => 9, + ColorPrimaries::DisplayP3 => 12, }, transfer_characteristics: match transfer { TransferFunction::Srgb => 13, // IEC 61966-2-1 sRGB @@ -154,30 +276,67 @@ pub fn cicp_for(primaries: ColorPrimaries, transfer: TransferFunction) -> CicpTa }, // BT.2020 non-constant luminance for HDR; we feed RGB and let the encoder build YCbCr. matrix_coefficients: match primaries { - ColorPrimaries::Srgb => 1, // BT.709 - ColorPrimaries::Bt2020 => 9, // BT.2020 NCL + ColorPrimaries::Srgb | ColorPrimaries::DisplayP3 => 1, // BT.709 + ColorPrimaries::Bt2020 => 9, // BT.2020 NCL }, full_range: true, } } +/// Convert a linear RGB triple from Rec.709/sRGB primaries to the target primaries (white- +/// preserving). sRGB target is identity. +pub fn convert_primaries(rgb_linear: [f32; 3], primaries: ColorPrimaries) -> [f32; 3] { + match primaries { + ColorPrimaries::Srgb => rgb_linear, + ColorPrimaries::Bt2020 => rec709_to_rec2020_linear(rgb_linear), + ColorPrimaries::DisplayP3 => rec709_to_displayp3_linear(rgb_linear), + } +} + +/// Apply the transfer OETF for `transfer` to a single linear channel, parameterized by the PQ +/// reference-white anchor and the HLG peak ratio. +pub fn apply_transfer( + c: f32, + transfer: TransferFunction, + reference_white_nits: f32, + hlg_peak_ratio: f32, +) -> f32 { + match transfer { + TransferFunction::Srgb => linear_to_srgb_extended(c), + TransferFunction::Pq => linear_scene_to_pq_with(c, reference_white_nits), + TransferFunction::Hlg => linear_scene_to_hlg_with(c, hlg_peak_ratio), + } +} + +/// Map a linear scene-referred pixel to a non-linear R'G'B' code in `[0,1]` for the chosen +/// encoding, including the primaries conversion, parameterized by the anchor / peak ratio. +pub fn encode_pixel_linear_to_code_with( + rgb_linear: [f32; 3], + primaries: ColorPrimaries, + transfer: TransferFunction, + reference_white_nits: f32, + hlg_peak_ratio: f32, +) -> [f32; 3] { + let rgb = convert_primaries(rgb_linear, primaries); + let f = |c: f32| apply_transfer(c, transfer, reference_white_nits, hlg_peak_ratio); + [f(rgb[0]), f(rgb[1]), f(rgb[2])] +} + /// Map a linear scene-referred pixel to a non-linear code in `[0,1]` for the chosen encoding, -/// including the primaries conversion. Alpha is passed through unchanged by callers. +/// including the primaries conversion, using the default 203-nit / 12x anchors. Alpha is passed +/// through unchanged by callers. pub fn encode_pixel_linear_to_code( rgb_linear: [f32; 3], primaries: ColorPrimaries, transfer: TransferFunction, ) -> [f32; 3] { - let rgb = match primaries { - ColorPrimaries::Srgb => rgb_linear, - ColorPrimaries::Bt2020 => rec709_to_rec2020_linear(rgb_linear), - }; - let f = |c: f32| match transfer { - TransferFunction::Srgb => linear_to_srgb_extended(c), - TransferFunction::Pq => linear_scene_to_pq(c), - TransferFunction::Hlg => linear_scene_to_hlg(c), - }; - [f(rgb[0]), f(rgb[1]), f(rgb[2])] + encode_pixel_linear_to_code_with( + rgb_linear, + primaries, + transfer, + REFERENCE_WHITE_NITS, + HLG_PEAK_RATIO, + ) } /// Quantize a `[0,1]` code to an integer at the given bit depth, full-range, round-to-nearest. @@ -186,6 +345,57 @@ pub fn quantize_full_range(code: f32, bit_depth: u8) -> u16 { (code.clamp(0.0, 1.0) * max + 0.5).floor() as u16 } +/// Quantize a luma-like `[0,1]` code (Y' for YCbCr, or each R/G/B channel for the identity +/// matrix) to an integer at `bit_depth`, applying the chosen dynamic range. Round-to-nearest, +/// clamped to `[0, 2^n-1]`. +pub fn quantize_luma(code: f32, bit_depth: u8, range: DynamicRange) -> u16 { + let max = ((1u32 << bit_depth) - 1) as i32; + let v = match range { + DynamicRange::Full => (code * max as f32).round() as i32, + DynamicRange::Limited => { + let s = (1u32 << (bit_depth - 8)) as f32; + (code * 219.0 * s + 16.0 * s).round() as i32 + } + }; + v.clamp(0, max) as u16 +} + +/// Quantize a chroma value (`Cb`/`Cr` in `[-0.5, 0.5]`) to an integer at `bit_depth`, applying +/// the chosen dynamic range. Round-to-nearest, clamped to `[0, 2^n-1]`. +pub fn quantize_chroma(code: f32, bit_depth: u8, range: DynamicRange) -> u16 { + let max = ((1u32 << bit_depth) - 1) as i32; + let v = match range { + DynamicRange::Full => ((code + 0.5) * max as f32).round() as i32, + DynamicRange::Limited => { + let s = (1u32 << (bit_depth - 8)) as f32; + (code * 224.0 * s + 128.0 * s).round() as i32 + } + }; + v.clamp(0, max) as u16 +} + +/// Non-constant-luminance Y'CbCr matrix coefficients (Kr, Kg, Kb) plus the Cb/Cr normalizing +/// denominators, selected by primaries: BT.2020 NCL for Rec.2020, BT.709 NCL otherwise. +pub fn ycbcr_coefficients(primaries: ColorPrimaries) -> (f32, f32, f32, f32, f32) { + match primaries { + ColorPrimaries::Bt2020 => (0.2627, 0.6780, 0.0593, 1.8814, 1.4746), + ColorPrimaries::Srgb | ColorPrimaries::DisplayP3 => { + (0.2126, 0.7152, 0.0722, 1.8556, 1.5748) + } + } +} + +/// Convert a non-linear R'G'B' code triple to non-constant-luminance Y'CbCr. `Y` lands in +/// `[0,1]`; `Cb`/`Cr` land in `[-0.5, 0.5]`. +pub fn rgb_prime_to_ycbcr(rgb_prime: [f32; 3], primaries: ColorPrimaries) -> [f32; 3] { + let (kr, kg, kb, cb_denom, cr_denom) = ycbcr_coefficients(primaries); + let [r, g, b] = rgb_prime; + let y = kr * r + kg * g + kb * b; + let cb = (b - y) / cb_denom; + let cr = (r - y) / cr_denom; + [y, cb, cr] +} + // --------------------------------------------------------------------------------------------- // Synthetic test input (linear light, scene-referred, diffuse white = 1.0). // --------------------------------------------------------------------------------------------- @@ -270,18 +480,170 @@ fn quality_to_quantizer(quality: u8) -> usize { ((100 - q) * 255 / 100).min(255) } -/// Encode a linear `Rgba` image to a tagged HDR AVIF (10 or 12 bit). +/// The three quantized planes plus their layout, ready to feed rav1e. +struct AvifPlanes { + /// Plane 0. Identity: G'. YCbCr: Y'. Full width x height. + p0: Vec, + /// Plane 1. Identity: B'. YCbCr: Cb (possibly subsampled). + p1: Vec, + /// Plane 2. Identity: R'. YCbCr: Cr (possibly subsampled). + p2: Vec, + /// Chroma plane dimensions (== luma for identity / 4:4:4). + chroma_w: usize, + chroma_h: usize, +} + +/// Average a chroma plane into the requested subsampling layout (input is full-resolution). +fn subsample_chroma( + full: &[f32], + w: usize, + h: usize, + subsampling: ChromaSubsampling, +) -> (Vec, usize, usize) { + match subsampling { + ChromaSubsampling::Cs444 => (full.to_vec(), w, h), + ChromaSubsampling::Cs422 => { + let cw = w.div_ceil(2); + let mut out = vec![0.0f32; cw * h]; + for y in 0..h { + for cx in 0..cw { + let x0 = cx * 2; + let x1 = (x0 + 1).min(w - 1); + out[y * cw + cx] = 0.5 * (full[y * w + x0] + full[y * w + x1]); + } + } + (out, cw, h) + } + ChromaSubsampling::Cs420 => { + let cw = w.div_ceil(2); + let ch = h.div_ceil(2); + let mut out = vec![0.0f32; cw * ch]; + for cy in 0..ch { + let y0 = cy * 2; + let y1 = (y0 + 1).min(h - 1); + for cx in 0..cw { + let x0 = cx * 2; + let x1 = (x0 + 1).min(w - 1); + let sum = full[y0 * w + x0] + + full[y0 * w + x1] + + full[y1 * w + x0] + + full[y1 * w + x1]; + out[cy * cw + cx] = sum * 0.25; + } + } + (out, cw, ch) + } + } +} + +/// Build the quantized AVIF planes for the chosen matrix mode / range / subsampling. +fn build_avif_planes(img: &ImageBuffer, Vec>, cfg: &HdrEncodeConfig) -> AvifPlanes { + let w = img.width() as usize; + let h = img.height() as usize; + let n = w * h; + + // linear -> primaries -> transfer -> R'G'B' code in [0,1]. + let mut rgb_prime = vec![[0.0f32; 3]; n]; + for (i, px) in img.pixels().enumerate() { + rgb_prime[i] = encode_pixel_linear_to_code_with( + [px.0[0], px.0[1], px.0[2]], + cfg.primaries, + cfg.transfer, + cfg.reference_white_nits, + cfg.hlg_peak_ratio, + ); + } + + match cfg.matrix { + MatrixMode::Identity => { + // Identity (matrix=0) stores RGB in G,B,R plane order, full 4:4:4. Quantize each + // channel with the luma range formula. + let mut p0 = vec![0u16; n]; // G' + let mut p1 = vec![0u16; n]; // B' + let mut p2 = vec![0u16; n]; // R' + for (i, c) in rgb_prime.iter().enumerate() { + p2[i] = quantize_luma(c[0], cfg.bit_depth, cfg.range); // R' + p0[i] = quantize_luma(c[1], cfg.bit_depth, cfg.range); // G' + p1[i] = quantize_luma(c[2], cfg.bit_depth, cfg.range); // B' + } + AvifPlanes { + p0, + p1, + p2, + chroma_w: w, + chroma_h: h, + } + } + MatrixMode::Ycbcr => { + // Non-constant-luminance Y'CbCr. Build full-res Cb/Cr, subsample, then quantize. + let mut y_plane = vec![0u16; n]; + let mut cb_full = vec![0.0f32; n]; + let mut cr_full = vec![0.0f32; n]; + for (i, c) in rgb_prime.iter().enumerate() { + let [y, cb, cr] = rgb_prime_to_ycbcr(*c, cfg.primaries); + y_plane[i] = quantize_luma(y, cfg.bit_depth, cfg.range); + cb_full[i] = cb; + cr_full[i] = cr; + } + let (cb_s, cw, ch) = subsample_chroma(&cb_full, w, h, cfg.subsampling); + let (cr_s, _, _) = subsample_chroma(&cr_full, w, h, cfg.subsampling); + let cb = cb_s + .iter() + .map(|&c| quantize_chroma(c, cfg.bit_depth, cfg.range)) + .collect(); + let cr = cr_s + .iter() + .map(|&c| quantize_chroma(c, cfg.bit_depth, cfg.range)) + .collect(); + AvifPlanes { + p0: y_plane, + p1: cb, + p2: cr, + chroma_w: cw, + chroma_h: ch, + } + } + } +} + +/// Compute MaxCLL / MaxFALL (cd/m²) for the linear image given the reference-white anchor. +/// MaxCLL = max over pixels of the brightest channel's nits; MaxFALL = mean over pixels of the +/// brightest channel's nits. Capped at [`PQ_MAX_NITS`]. +fn content_light_levels( + img: &ImageBuffer, Vec>, + cfg: &HdrEncodeConfig, +) -> (u16, u16) { + let mut max_nits = 0.0f32; + let mut sum_nits = 0.0f64; + let mut count = 0u64; + for px in img.pixels() { + let rgb = convert_primaries([px.0[0], px.0[1], px.0[2]], cfg.primaries); + let peak = rgb[0].max(rgb[1]).max(rgb[2]).max(0.0); + let nits = (peak * cfg.reference_white_nits).min(PQ_MAX_NITS); + if nits > max_nits { + max_nits = nits; + } + sum_nits += nits as f64; + count += 1; + } + let maxfall = if count > 0 { + (sum_nits / count as f64) as f32 + } else { + 0.0 + }; + let cap = |v: f32| v.round().clamp(0.0, u16::MAX as f32) as u16; + (cap(max_nits), cap(maxfall)) +} + +/// Encode a linear `Rgba` image to a tagged HDR AVIF (10 or 12 bit) per `cfg`. /// -/// Uses CICP matrix_coefficients = 0 (Identity / RGB) at 4:4:4 full-range: the planes carry the -/// RGB transfer-encoded codes directly (no YCbCr conversion, no chroma subsampling), so the -/// round-trip is exact for every color — not just neutrals. The AV1 spec stores identity planes -/// in G, B, R order. `primaries` is tagged AND applied (Rec.709->Rec.2020 matrix) to the pixels. +/// The identity matrix carries RGB transfer-encoded codes directly (no YCbCr conversion, no +/// chroma subsampling) so the round-trip is exact for every color; YCbCr converts to +/// non-constant-luminance Y'CbCr and supports 4:2:2 / 4:2:0 subsampling. CICP tags +/// (primaries / transfer / matrix / range) are written to both the AV1 stream and the container. pub fn encode_avif_hdr( img: &ImageBuffer, Vec>, - bit_depth: u8, - transfer: TransferFunction, - primaries: ColorPrimaries, - quality: u8, + cfg: &HdrEncodeConfig, ) -> Result, String> { use rav1e::config::SpeedSettings; // Explicit imports (not a glob) so this module's own ColorPrimaries/TransferFunction enums @@ -291,12 +653,9 @@ pub fn encode_avif_hdr( EncoderStatus, PixelRange, }; - if bit_depth != 10 && bit_depth != 12 { - return Err(format!( - "AVIF HDR bit depth must be 10 or 12, got {bit_depth}" - )); - } - if transfer == TransferFunction::Srgb { + let cfg = cfg.sanitized()?; + let bit_depth = cfg.bit_depth; + if cfg.transfer == TransferFunction::Srgb { return Err("encode_avif_hdr requires a PQ or HLG transfer".into()); } let w = img.width() as usize; @@ -305,55 +664,70 @@ pub fn encode_avif_hdr( return Err("cannot encode an empty image".into()); } - // linear -> primaries -> transfer code -> quantized u16, into G/B/R planes (identity order). - let mut plane_g = vec![0u16; w * h]; - let mut plane_b = vec![0u16; w * h]; - let mut plane_r = vec![0u16; w * h]; - for (i, px) in img.pixels().enumerate() { - let code = encode_pixel_linear_to_code([px.0[0], px.0[1], px.0[2]], primaries, transfer); - plane_r[i] = quantize_full_range(code[0], bit_depth); - plane_g[i] = quantize_full_range(code[1], bit_depth); - plane_b[i] = quantize_full_range(code[2], bit_depth); - } + let planes = build_avif_planes(img, &cfg); + + let chroma_sampling = match cfg.matrix { + MatrixMode::Identity => ChromaSampling::Cs444, + MatrixMode::Ycbcr => match cfg.subsampling { + ChromaSubsampling::Cs444 => ChromaSampling::Cs444, + ChromaSubsampling::Cs422 => ChromaSampling::Cs422, + ChromaSubsampling::Cs420 => ChromaSampling::Cs420, + }, + }; + let pixel_range = match cfg.range { + DynamicRange::Full => PixelRange::Full, + DynamicRange::Limited => PixelRange::Limited, + }; let mut enc = EncoderConfig::default(); enc.width = w; enc.height = h; enc.bit_depth = bit_depth as usize; - enc.chroma_sampling = ChromaSampling::Cs444; + enc.chroma_sampling = chroma_sampling; enc.chroma_sample_position = ChromaSamplePosition::Unknown; enc.still_picture = true; - enc.pixel_range = PixelRange::Full; + enc.pixel_range = pixel_range; enc.color_description = Some(ColorDescription { - color_primaries: match primaries { + color_primaries: match cfg.primaries { ColorPrimaries::Bt2020 => rav1e::prelude::ColorPrimaries::BT2020, ColorPrimaries::Srgb => rav1e::prelude::ColorPrimaries::BT709, + ColorPrimaries::DisplayP3 => rav1e::prelude::ColorPrimaries::SMPTE432, }, - transfer_characteristics: match transfer { + transfer_characteristics: match cfg.transfer { TransferFunction::Pq => rav1e::prelude::TransferCharacteristics::SMPTE2084, TransferFunction::Hlg => rav1e::prelude::TransferCharacteristics::HLG, TransferFunction::Srgb => unreachable!(), }, - matrix_coefficients: rav1e::prelude::MatrixCoefficients::Identity, + matrix_coefficients: match cfg.matrix { + MatrixMode::Identity => rav1e::prelude::MatrixCoefficients::Identity, + MatrixMode::Ycbcr => match cfg.primaries { + ColorPrimaries::Bt2020 => rav1e::prelude::MatrixCoefficients::BT2020NCL, + ColorPrimaries::Srgb | ColorPrimaries::DisplayP3 => { + rav1e::prelude::MatrixCoefficients::BT709 + } + }, + }, }); - enc.quantizer = quality_to_quantizer(quality); + enc.quantizer = quality_to_quantizer(cfg.quality); enc.speed_settings = SpeedSettings::from_preset(6); let threads = std::thread::available_parallelism() .map(|n| n.get().min(8)) .unwrap_or(2); - let cfg = Config::new().with_encoder_config(enc).with_threads(threads); - let mut ctx: Context = cfg + let config = Config::new().with_encoder_config(enc).with_threads(threads); + let mut ctx: Context = config .new_context() .map_err(|e| format!("rav1e new_context failed: {e:?}"))?; let mut frame = ctx.new_frame(); - // Identity plane order: 0=G, 1=B, 2=R. copy_from_raw_u8 writes at the plane origin - // (handles rav1e's internal padding), unlike a raw chunks_mut over the padded buffer. + // copy_from_raw_u8 writes at the plane origin (handles rav1e's internal padding), unlike a + // raw chunks_mut over the padded buffer. Identity plane order is 0=G', 1=B', 2=R'; + // YCbCr is 0=Y', 1=Cb, 2=Cr. let to_le = |p: &[u16]| -> Vec { p.iter().flat_map(|v| v.to_le_bytes()).collect() }; - frame.planes[0].copy_from_raw_u8(&to_le(&plane_g), w * 2, 2); - frame.planes[1].copy_from_raw_u8(&to_le(&plane_b), w * 2, 2); - frame.planes[2].copy_from_raw_u8(&to_le(&plane_r), w * 2, 2); + frame.planes[0].copy_from_raw_u8(&to_le(&planes.p0), w * 2, 2); + frame.planes[1].copy_from_raw_u8(&to_le(&planes.p1), planes.chroma_w * 2, 2); + frame.planes[2].copy_from_raw_u8(&to_le(&planes.p2), planes.chroma_w * 2, 2); + let _ = planes.chroma_h; ctx.send_frame(std::sync::Arc::new(frame)) .map_err(|e| format!("rav1e send_frame failed: {e:?}"))?; @@ -374,34 +748,71 @@ pub fn encode_avif_hdr( ColorPrimaries as AvifPrimaries, MatrixCoefficients as AvifMatrix, TransferCharacteristics as AvifTransfer, }; + let sub_xy = match chroma_sampling { + ChromaSampling::Cs444 => (false, false), + ChromaSampling::Cs422 => (true, false), + ChromaSampling::Cs420 => (true, true), + ChromaSampling::Cs400 => (true, true), + }; let mut aviffy = Aviffy::new(); aviffy - .set_color_primaries(match primaries { + .set_color_primaries(match cfg.primaries { ColorPrimaries::Bt2020 => AvifPrimaries::Bt2020, ColorPrimaries::Srgb => AvifPrimaries::Bt709, + ColorPrimaries::DisplayP3 => AvifPrimaries::DisplayP3, }) - .set_transfer_characteristics(match transfer { + .set_transfer_characteristics(match cfg.transfer { TransferFunction::Pq => AvifTransfer::Smpte2084, TransferFunction::Hlg => AvifTransfer::Hlg, TransferFunction::Srgb => unreachable!(), }) - .set_matrix_coefficients(AvifMatrix::Rgb) - .set_full_color_range(true) + .set_matrix_coefficients(match cfg.matrix { + MatrixMode::Identity => AvifMatrix::Rgb, + MatrixMode::Ycbcr => match cfg.primaries { + ColorPrimaries::Bt2020 => AvifMatrix::Bt2020Ncl, + ColorPrimaries::Srgb | ColorPrimaries::DisplayP3 => AvifMatrix::Bt709, + }, + }) + .set_full_color_range(cfg.range == DynamicRange::Full) .set_bit_depth(bit_depth) - .set_chroma_subsampling((false, false)); // 4:4:4 + .set_chroma_subsampling(sub_xy); + + if cfg.mastering_metadata { + let (maxcll, maxfall) = content_light_levels(img, &cfg); + aviffy.set_content_light_level(maxcll, maxfall); + // Mastering Display Colour Volume (SMPTE ST 2086). Primaries in CIE xy x 50000, ordered + // [green, blue, red]; white point D65; luminance in cd/m^2 x 10000. We describe a + // BT.2020/D65 display peaking at PQ_MAX_NITS (the PQ container max). This is informational + // display metadata, distinct from the per-frame content light levels above. + let xy = |x: f32, y: f32| -> (u16, u16) { + ((x * 50000.0).round() as u16, (y * 50000.0).round() as u16) + }; + let green = xy(0.170, 0.797); + let blue = xy(0.131, 0.046); + let red = xy(0.708, 0.292); + let white = xy(0.3127, 0.3290); + let max_lum = (PQ_MAX_NITS * 10000.0) as u32; // cd/m^2 x 10000 + let min_lum = 1u32; // 0.0001 cd/m^2 + aviffy.set_mastering_display([green, blue, red], white, max_lum, min_lum); + } Ok(aviffy.to_vec(&av1_obu, None, w as u32, h as u32, bit_depth)) } -/// Encode a linear `Rgba` image to a tagged HDR JPEG XL (PQ or HLG, Rec.2020), 32-bit float. +/// Map an export quality (0..=100) to a JPEG XL butteraugli distance. 100 => lossless (handled +/// separately); otherwise distance = (100 - q)/10, clamped to >= 0.1 (lower = higher quality). +pub fn quality_to_jxl_distance(quality: u8) -> f32 { + ((100.0 - quality.min(100) as f32) / 10.0).max(0.1) +} + +/// Encode a linear `Rgba` image to a tagged HDR JPEG XL (PQ or HLG), 32-bit float, per `cfg`. /// Requires the `hdr_jxl` feature (system libjxl). Feeds transfer-encoded f32 codes with /// `uses_original_profile` so libjxl keeps our PQ/HLG color encoding instead of converting to XYB. +/// Quality 100 selects true lossless; otherwise a butteraugli distance from the quality knob. #[cfg(feature = "hdr_jxl")] pub fn encode_jxl_hdr( img: &ImageBuffer, Vec>, - transfer: TransferFunction, - primaries: ColorPrimaries, - lossless: bool, + cfg: &HdrEncodeConfig, ) -> Result, String> { use jpegxl_rs::encode::{ColorEncoding, EncoderFrame, EncoderResult, EncoderSpeed}; use jpegxl_rs::encoder_builder; @@ -410,14 +821,31 @@ pub fn encode_jxl_hdr( JxlWhitePoint, }; - if transfer == TransferFunction::Srgb { + let cfg = { + // JXL has no AVIF bit-depth constraint; only sanitize the anchors / peak ratio. + let mut c = *cfg; + if c.reference_white_nits <= 0.0 || !c.reference_white_nits.is_finite() { + c.reference_white_nits = REFERENCE_WHITE_NITS; + } + if c.hlg_peak_ratio <= 0.0 || !c.hlg_peak_ratio.is_finite() { + c.hlg_peak_ratio = HLG_PEAK_RATIO; + } + c + }; + if cfg.transfer == TransferFunction::Srgb { return Err("encode_jxl_hdr requires a PQ or HLG transfer".into()); } let (w, h) = (img.width(), img.height()); let mut rgb: Vec = Vec::with_capacity((w * h * 3) as usize); for px in img.pixels() { - let code = encode_pixel_linear_to_code([px.0[0], px.0[1], px.0[2]], primaries, transfer); + let code = encode_pixel_linear_to_code_with( + [px.0[0], px.0[1], px.0[2]], + cfg.primaries, + cfg.transfer, + cfg.reference_white_nits, + cfg.hlg_peak_ratio, + ); rgb.extend_from_slice(&code); } @@ -425,14 +853,15 @@ pub fn encode_jxl_hdr( color_space: JxlColorSpace::Rgb, white_point: JxlWhitePoint::D65, white_point_xy: [0.3127, 0.3290], - primaries: match primaries { + primaries: match cfg.primaries { ColorPrimaries::Bt2020 => JxlPrimaries::Rec2100, ColorPrimaries::Srgb => JxlPrimaries::SRgb, + ColorPrimaries::DisplayP3 => JxlPrimaries::P3, }, primaries_red_xy: [0.0, 0.0], primaries_green_xy: [0.0, 0.0], primaries_blue_xy: [0.0, 0.0], - transfer_function: match transfer { + transfer_function: match cfg.transfer { TransferFunction::Pq => JxlTransferFunction::PQ, TransferFunction::Hlg => JxlTransferFunction::HLG, TransferFunction::Srgb => unreachable!(), @@ -441,11 +870,15 @@ pub fn encode_jxl_hdr( rendering_intent: JxlRenderingIntent::Relative, }; + let lossless = cfg.quality >= 100; + let distance = quality_to_jxl_distance(cfg.quality); + let mut encoder = encoder_builder() .has_alpha(false) .speed(EncoderSpeed::Squirrel) .uses_original_profile(true) .lossless(lossless) + .quality(distance) .color_encoding(ColorEncoding::Custom(color)) .build() .map_err(|e| format!("jxl encoder build failed: {e}"))?; @@ -519,6 +952,92 @@ mod tests { } } + #[test] + fn displayp3_matrix_preserves_neutral() { + // White-preserving: each row sums to 1.0, so neutral grey is unchanged. + let n = rec709_to_displayp3_linear([0.5, 0.5, 0.5]); + for c in n { + assert!((c - 0.5).abs() < 1e-5, "P3 neutral not preserved: {c}"); + } + } + + #[test] + fn ycbcr_roundtrip_recovers_rgb_prime() { + // Forward R'G'B' -> Y'CbCr then invert; channels must come back distinct and in order. + for &prim in &[ColorPrimaries::Srgb, ColorPrimaries::Bt2020] { + let rgbp = [0.2f32, 0.5, 0.85]; + let [y, cb, cr] = rgb_prime_to_ycbcr(rgbp, prim); + let (_kr, _kg, _kb, cb_denom, cr_denom) = ycbcr_coefficients(prim); + // Standard NCL inverse: R = Y + Cr*cr_denom, B = Y + Cb*cb_denom. + let r = y + cr * cr_denom; + let b = y + cb * cb_denom; + assert!((r - rgbp[0]).abs() < 1e-5, "{prim:?} R recovered {r}"); + assert!((b - rgbp[2]).abs() < 1e-5, "{prim:?} B recovered {b}"); + assert!( + r < rgbp[1] && rgbp[1] < b, + "channel order lost: {r} {} {b}", + rgbp[1] + ); + } + } + + #[test] + fn quantize_ranges_behave() { + // Full range: white code 1.0 -> max; limited: 219*s+16*s. + assert_eq!(quantize_luma(1.0, 10, DynamicRange::Full), 1023); + assert_eq!(quantize_luma(0.0, 10, DynamicRange::Full), 0); + let s = 1u16 << 2; // 10-bit scale + assert_eq!( + quantize_luma(1.0, 10, DynamicRange::Limited), + 219 * s + 16 * s + ); + assert_eq!(quantize_luma(0.0, 10, DynamicRange::Limited), 16 * s); + // Chroma full: 0.0 -> midpoint; +0.5 -> max; -0.5 -> 0. + assert_eq!(quantize_chroma(0.0, 10, DynamicRange::Full), 512); + assert_eq!(quantize_chroma(0.5, 10, DynamicRange::Full), 1023); + assert_eq!(quantize_chroma(-0.5, 10, DynamicRange::Full), 0); + // Chroma limited: 0.0 -> 128*s. + assert_eq!(quantize_chroma(0.0, 10, DynamicRange::Limited), 128 * s); + } + + #[test] + fn config_sanitize_clamps_and_forces_444() { + let cfg = HdrEncodeConfig { + reference_white_nits: 0.0, + hlg_peak_ratio: -1.0, + matrix: MatrixMode::Identity, + subsampling: ChromaSubsampling::Cs420, + ..Default::default() + } + .sanitized() + .unwrap(); + assert_eq!(cfg.reference_white_nits, REFERENCE_WHITE_NITS); + assert_eq!(cfg.hlg_peak_ratio, HLG_PEAK_RATIO); + assert_eq!(cfg.subsampling, ChromaSubsampling::Cs444); + // Bad bit depth -> Err. + let bad = HdrEncodeConfig { + bit_depth: 8, + ..Default::default() + } + .sanitized(); + assert!(bad.is_err()); + } + + #[test] + fn transfer_params_match_default_wrappers() { + // The parameterized helpers with the canonical anchors must equal the legacy wrappers. + for &l in &[0.0f32, 0.5, 1.0, 2.0, 4.0] { + assert_eq!( + linear_scene_to_pq_with(l, REFERENCE_WHITE_NITS), + linear_scene_to_pq(l) + ); + assert_eq!( + linear_scene_to_hlg_with(l, HLG_PEAK_RATIO), + linear_scene_to_hlg(l) + ); + } + } + #[test] fn synthetic_input_has_expected_patches() { let s = synthetic_linear_image(); diff --git a/src-tauri/tests/hdr_export.rs b/src-tauri/tests/hdr_export.rs index fa8a742d4c..3d649c747f 100644 --- a/src-tauri/tests/hdr_export.rs +++ b/src-tauri/tests/hdr_export.rs @@ -13,9 +13,33 @@ //! JXL (behind the `hdr_jxl` feature) is decoded with the pure-Rust jxl-oxide. use image::{ImageBuffer, Rgba}; -use rapidraw_lib::hdr::{self, ColorPrimaries, TransferFunction}; +use rapidraw_lib::hdr::{ + self, ChromaSubsampling, ColorPrimaries, DynamicRange, HdrEncodeConfig, MatrixMode, + TransferFunction, +}; use std::process::Command; +/// Build a config for the identity-matrix PQ/HLG path used by the existing checks: identity +/// matrix, 4:4:4, full range, default anchors. Mirrors the historical encoder defaults. +fn identity_cfg( + bit_depth: u8, + transfer: TransferFunction, + primaries: ColorPrimaries, +) -> HdrEncodeConfig { + HdrEncodeConfig { + bit_depth, + transfer, + primaries, + matrix: MatrixMode::Identity, + subsampling: ChromaSubsampling::Cs444, + range: DynamicRange::Full, + reference_white_nits: hdr::REFERENCE_WHITE_NITS, + hlg_peak_ratio: hdr::HLG_PEAK_RATIO, + quality: 100, + mastering_metadata: false, + } +} + /// Parse the first ISOBMFF `colr` box of type `nclx`: (primaries, transfer, matrix, full_range). fn parse_nclx(bytes: &[u8]) -> Option<(u16, u16, u16, bool)> { let mut i = 0usize; @@ -103,14 +127,62 @@ fn code_from_png16(v16: u16, bit_depth: u8) -> u16 { (v16 as f32 / 65535.0 * max).round() as u16 } +/// Decode an AVIF to y4m via `avifdec` and return the FIRST sample of plane 0 (the coded luma / +/// identity-G' plane) as a raw integer code. Unlike PNG/RGB output, y4m preserves the coded YUV +/// sample values verbatim — no limited->full range expansion and no inverse color matrix — so it +/// reflects exactly what was quantized into the bitstream. +fn avifdec_plane0_first_sample(avif: &[u8], label: &str) -> u16 { + let dir = std::env::temp_dir(); + let pid = std::process::id(); + let in_path = dir.join(format!("rr_hdr_{label}_{pid}.avif")); + let out_path = dir.join(format!("rr_hdr_{label}_{pid}.y4m")); + std::fs::write(&in_path, avif).expect("write temp avif"); + + let output = Command::new("avifdec") + .arg(&in_path) + .arg(&out_path) + .output() + .expect("run avifdec for y4m"); + assert!( + output.status.success(), + "avifdec (y4m) failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let bytes = std::fs::read(&out_path).expect("read y4m"); + let _ = std::fs::remove_file(&in_path); + let _ = std::fs::remove_file(&out_path); + + // y4m: an ASCII header terminated by '\n', then "FRAME[params]\n", then raw plane data. + // For depth > 8, samples are little-endian u16. + let header_end = bytes + .iter() + .position(|&b| b == b'\n') + .expect("y4m header newline"); + let header = String::from_utf8_lossy(&bytes[..header_end]); + let depth_gt8 = header.contains("p10") || header.contains("p12") || header.contains("p16"); + // Locate the FRAME marker after the header. + let frame_pos = bytes[header_end..] + .windows(5) + .position(|w| w == b"FRAME") + .map(|p| header_end + p) + .expect("y4m FRAME marker"); + let frame_data_start = bytes[frame_pos..] + .iter() + .position(|&b| b == b'\n') + .map(|p| frame_pos + p + 1) + .expect("FRAME newline"); + if depth_gt8 { + u16::from_le_bytes([bytes[frame_data_start], bytes[frame_data_start + 1]]) + } else { + bytes[frame_data_start] as u16 + } +} + fn run_avif_pq_case(bit_depth: u8) { let s = hdr::synthetic_linear_image(); let avif = hdr::encode_avif_hdr( &s.image, - bit_depth, - TransferFunction::Pq, - ColorPrimaries::Bt2020, - 100, + &identity_cfg(bit_depth, TransferFunction::Pq, ColorPrimaries::Bt2020), ) .expect("encode avif"); @@ -199,10 +271,7 @@ fn avif_hlg_tags() { let s = hdr::synthetic_linear_image(); let avif = hdr::encode_avif_hdr( &s.image, - 10, - TransferFunction::Hlg, - ColorPrimaries::Bt2020, - 100, + &identity_cfg(10, TransferFunction::Hlg, ColorPrimaries::Bt2020), ) .expect("encode hlg avif"); let (p, t, m, fr) = parse_nclx(&avif).expect("nclx box present"); @@ -223,8 +292,11 @@ fn avif_identity_preserves_channel_order() { for px in img.pixels_mut() { *px = Rgba([0.05, 0.2, 0.8, 1.0]); } - let avif = - hdr::encode_avif_hdr(&img, 10, TransferFunction::Pq, ColorPrimaries::Srgb, 100).unwrap(); + let avif = hdr::encode_avif_hdr( + &img, + &identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Srgb), + ) + .unwrap(); let dec = avifdec_to_png16(&avif, "order"); let px = dec.get_pixel(1, 1).0; assert!( @@ -240,6 +312,244 @@ fn avif_identity_preserves_channel_order() { ); } +/// Expected full-range 10-bit PQ code for a linear scene value at the 203-nit anchor. +/// INDEPENDENT ground truth: ST.2084 inverse-EOTF computed here, NOT via the encoder's helpers. +fn expected_pq_code10(linear: f32) -> u16 { + const M1: f64 = 0.1593017578125; + const M2: f64 = 78.84375; + const C1: f64 = 0.8359375; + const C2: f64 = 18.8515625; + const C3: f64 = 18.6875; + let l = (linear as f64 * 203.0 / 10000.0).max(0.0); + let lp = l.powf(M1); + let code = ((C1 + C2 * lp) / (1.0 + C3 * lp)).powf(M2); + (code.clamp(0.0, 1.0) * 1023.0).round() as u16 +} + +fn uniform_image(w: u32, h: u32, rgb: [f32; 3]) -> ImageBuffer, Vec> { + ImageBuffer::from_pixel(w, h, Rgba([rgb[0], rgb[1], rgb[2], 1.0])) +} + +/// YCbCr matrix at 4:4:4, full range, sRGB primaries (no cross-channel primaries matrix). A flat +/// uniform patch with distinct saturated R!=G!=B must decode back, per channel, within +/-3 codes +/// of the independently-computed R'G'B' PQ codes. Proves the YCbCr forward/inverse matrix is +/// correct AND that channels are not swapped (Cr<->R, Cb<->B). +#[test] +fn avif_ycbcr_444_colored_roundtrip() { + let rgb = [0.1f32, 0.4, 0.9]; // R < G < B, well separated + let img = uniform_image(8, 8, rgb); + let cfg = HdrEncodeConfig { + matrix: MatrixMode::Ycbcr, + subsampling: ChromaSubsampling::Cs444, + range: DynamicRange::Full, + primaries: ColorPrimaries::Srgb, + ..identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Srgb) + }; + let avif = hdr::encode_avif_hdr(&img, &cfg).expect("encode ycbcr 444"); + + let (_p, _t, m, _fr) = parse_nclx(&avif).expect("nclx box present"); + assert_eq!( + m, 1, + "sRGB-primaries YCbCr should tag BT.709 matrix (1), got {m}" + ); + + let dec = avifdec_to_png16(&avif, "ycbcr444"); + let px = dec.get_pixel(4, 4).0; + let got = [ + code_from_png16(px[0], 10), + code_from_png16(px[1], 10), + code_from_png16(px[2], 10), + ]; + let exp = [ + expected_pq_code10(rgb[0]), + expected_pq_code10(rgb[1]), + expected_pq_code10(rgb[2]), + ]; + for c in 0..3 { + assert!( + (got[c] as i32 - exp[c] as i32).abs() <= 3, + "channel {c}: got {} expected ~{} (+/-3); full got={got:?} exp={exp:?}", + got[c], + exp[c] + ); + } + assert!( + got[0] < got[1] && got[1] < got[2], + "channel order lost in YCbCr: {got:?}" + ); + eprintln!("AVIF YCbCr 4:4:4 colored round-trip: got {got:?} expected {exp:?}"); +} + +/// Same colored patch but 4:2:0 subsampling. On a UNIFORM patch there are no chroma edges, so even +/// 2x2 averaging is lossless and the channels must still round-trip within tolerance. +#[test] +fn avif_ycbcr_420_uniform_roundtrip() { + let rgb = [0.1f32, 0.4, 0.9]; + let img = uniform_image(8, 8, rgb); + let cfg = HdrEncodeConfig { + matrix: MatrixMode::Ycbcr, + subsampling: ChromaSubsampling::Cs420, + range: DynamicRange::Full, + primaries: ColorPrimaries::Srgb, + ..identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Srgb) + }; + let avif = hdr::encode_avif_hdr(&img, &cfg).expect("encode ycbcr 420"); + let dec = avifdec_to_png16(&avif, "ycbcr420"); + let px = dec.get_pixel(4, 4).0; + let got = [ + code_from_png16(px[0], 10), + code_from_png16(px[1], 10), + code_from_png16(px[2], 10), + ]; + let exp = [ + expected_pq_code10(rgb[0]), + expected_pq_code10(rgb[1]), + expected_pq_code10(rgb[2]), + ]; + for c in 0..3 { + assert!( + (got[c] as i32 - exp[c] as i32).abs() <= 3, + "4:2:0 channel {c}: got {} expected ~{} (+/-3); full got={got:?} exp={exp:?}", + got[c], + exp[c] + ); + } + eprintln!("AVIF YCbCr 4:2:0 uniform round-trip: got {got:?} expected {exp:?}"); +} + +/// Limited range must produce a smaller luma code than full range for the same input. A very +/// bright neutral patch saturates PQ to ~1.0: full range -> 1023, limited range -> ~940. +#[test] +fn avif_limited_range_is_smaller_than_full() { + let img = uniform_image(8, 8, [50.0, 50.0, 50.0]); // PQ code clamps to ~1.0 + let base = identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Srgb); + + let full_cfg = HdrEncodeConfig { + range: DynamicRange::Full, + ..base + }; + let lim_cfg = HdrEncodeConfig { + range: DynamicRange::Limited, + ..base + }; + let full = hdr::encode_avif_hdr(&img, &full_cfg).expect("full"); + let lim = hdr::encode_avif_hdr(&img, &lim_cfg).expect("limited"); + + let (_, _, _, full_fr) = parse_nclx(&full).expect("full nclx"); + let (_, _, _, lim_fr) = parse_nclx(&lim).expect("lim nclx"); + assert!(full_fr, "full-range flag should be set"); + assert!(!lim_fr, "limited-range flag should be clear"); + + // Read the RAW coded sample (no decoder range-expansion) from the y4m plane 0. + let fcode = avifdec_plane0_first_sample(&full, "rfull"); + let lcode = avifdec_plane0_first_sample(&lim, "rlim"); + assert!( + (fcode as i32 - 1023).abs() <= 3, + "full-range white should be ~1023, got {fcode}" + ); + assert!( + (lcode as i32 - 940).abs() <= 4, + "limited-range white should be ~940, got {lcode}" + ); + assert!( + lcode < fcode, + "limited code {lcode} must be smaller than full code {fcode}" + ); + eprintln!("AVIF range: full white={fcode} (exp ~1023), limited white={lcode} (exp ~940)"); +} + +/// Each primaries selection must tag the correct CICP colour_primaries (sRGB=1, Bt2020=9, +/// DisplayP3=12), and a saturated-green patch must store different codes under different primaries +/// (proving the primaries matrix is actually applied to the pixels, not just tagged). +#[test] +fn avif_primaries_tagged_and_applied() { + let green = [0.0f32, 0.9, 0.0]; + let img = uniform_image(8, 8, green); + + let cases = [ + (ColorPrimaries::Srgb, 1u16, "srgb"), + (ColorPrimaries::Bt2020, 9, "bt2020"), + (ColorPrimaries::DisplayP3, 12, "p3"), + ]; + + let mut decoded_codes = Vec::new(); + for (prim, expected_cicp, label) in cases { + let cfg = identity_cfg(10, TransferFunction::Pq, prim); + let avif = hdr::encode_avif_hdr(&img, &cfg).unwrap_or_else(|e| panic!("{label}: {e}")); + let (p, _t, _m, _fr) = parse_nclx(&avif).expect("nclx box present"); + assert_eq!( + p, expected_cicp, + "{label}: colour_primaries should be {expected_cicp}, got {p}" + ); + let dec = avifdec_to_png16(&avif, label); + let px = dec.get_pixel(4, 4).0; + decoded_codes.push(( + label, + [ + code_from_png16(px[0], 10), + code_from_png16(px[1], 10), + code_from_png16(px[2], 10), + ], + )); + } + + // sRGB primaries keep pure green in G only (R=B=0); wide-gamut conversions spread energy into + // the other channels, so the stored triples must differ between primaries. + let srgb = decoded_codes[0].1; + let bt2020 = decoded_codes[1].1; + let p3 = decoded_codes[2].1; + assert_ne!( + srgb, bt2020, + "sRGB vs Bt2020 codes identical: {decoded_codes:?}" + ); + assert_ne!( + srgb, p3, + "sRGB vs DisplayP3 codes identical: {decoded_codes:?}" + ); + assert_ne!( + bt2020, p3, + "Bt2020 vs DisplayP3 codes identical: {decoded_codes:?}" + ); + eprintln!("AVIF primaries tagged 1/9/12 and applied: {decoded_codes:?}"); +} + +/// With mastering metadata enabled, the AVIF must carry a `clli` (Content Light Level) box. +#[test] +fn avif_mastering_metadata_emits_clli() { + let s = hdr::synthetic_linear_image(); + let cfg = HdrEncodeConfig { + mastering_metadata: true, + ..identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Bt2020) + }; + let avif = hdr::encode_avif_hdr(&s.image, &cfg).expect("encode with mastering metadata"); + let has_clli = avif.windows(4).any(|w| w == b"clli"); + assert!( + has_clli, + "expected a `clli` box when mastering_metadata is on" + ); + + // Sanity: without it the box should be absent. + let cfg_off = identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Bt2020); + let avif_off = hdr::encode_avif_hdr(&s.image, &cfg_off).expect("encode without metadata"); + let has_clli_off = avif_off.windows(4).any(|w| w == b"clli"); + assert!( + !has_clli_off, + "`clli` box should be absent when metadata is off" + ); + eprintln!("AVIF mastering metadata: clli present when on, absent when off"); +} + +/// Bad input must return an Err, not panic. +#[test] +fn avif_rejects_bad_bit_depth() { + let img = uniform_image(4, 4, [0.5, 0.5, 0.5]); + let cfg = identity_cfg(8, TransferFunction::Pq, ColorPrimaries::Srgb); + assert!( + hdr::encode_avif_hdr(&img, &cfg).is_err(), + "8-bit AVIF HDR should be rejected" + ); +} + #[cfg(feature = "hdr_jxl")] mod jxl { use super::*; @@ -249,9 +559,11 @@ mod jxl { use jxl_oxide::JxlImage; let s = hdr::synthetic_linear_image(); - let bytes = - hdr::encode_jxl_hdr(&s.image, TransferFunction::Pq, ColorPrimaries::Bt2020, true) - .expect("encode jxl"); + let bytes = hdr::encode_jxl_hdr( + &s.image, + &identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Bt2020), + ) + .expect("encode jxl"); let dir = std::env::temp_dir(); let path = dir.join(format!("rr_hdr_pq_{}.jxl", std::process::id())); From bca833e323b1608d23abb74ccccc093f3e70926a Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 10:33:17 +0200 Subject: [PATCH 11/20] HDR export UI: color-profile presets + advanced controls with plain-language help Export panel (AVIF/JXL) gets a two-tier color UI for non-experts: - Tier 1: a "Color profile" dropdown - Standard (SDR), HDR10 (best compatibility), HLG (broadcast), Maximum quality (12-bit 4:4:4), Archival/exact (RGB lossless), and Custom. Each preset sets the whole field bundle; editing Advanced flips the picker to Custom. - Tier 2: Advanced controls (bit depth, color encoding/matrix, chroma detail, signal range, color gamut incl Display-P3, reference white, HLG peak, mastering metadata) - each with a one-line jargon-free explanation underneath, plus guardrails that hide/disable invalid combos (chroma only for YCbCr, HDR only >8-bit, HLG peak only for HLG, etc.). useExportSettings/ExportSettings/ExportPreset gain matrix/chromaSubsampling/range/ referenceWhiteNits/hlgPeakRatio/masteringMetadata; all spread into the estimate and export settings objects via the shared hdrSettings memo (camelCase keys match the backend serde). en.json gains the new labels + help strings (other locales fall back to English, consistent with the rest of the export panel). Vite build passes; no new type errors. --- src/components/panel/right/ExportPanel.tsx | 416 ++++++++++++++++--- src/components/ui/ExportImportProperties.tsx | 20 +- src/hooks/useExportSettings.ts | 39 ++ src/i18n/locales/en.json | 38 +- 4 files changed, 462 insertions(+), 51 deletions(-) diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index 02c82c9e2a..f9c1d30d1f 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -58,6 +58,28 @@ function Section({ title, children }: SectionProps) { ); } +interface HdrFieldProps { + label: string; + help: string; + children: any; +} + +// A labelled HDR control with a one-line, plain-language explanation underneath so a +// non-expert understands what each option does without needing to hover. +function HdrField({ label, help, children }: HdrFieldProps) { + return ( +
+
+ {label} + {children} +
+ + {help} + +
+ ); +} + function WatermarkPreview({ anchor, scale, @@ -239,6 +261,18 @@ export default function ExportPanel({ setTransferFunction, primaries, setPrimaries, + matrix, + setMatrix, + chromaSubsampling, + setChromaSubsampling, + range, + setRange, + referenceWhiteNits, + setReferenceWhiteNits, + hlgPeakRatio, + setHlgPeakRatio, + masteringMetadata, + setMasteringMetadata, handleApplyPreset, currentSettingsObject, } = useExportSettings(); @@ -351,9 +385,203 @@ export default function ExportPanel({ ); // HDR fields shared by the estimate and export settings objects (kept in one place, DRY). + // Keys + value spellings must match the backend serde contract exactly. const hdrSettings = useMemo( - () => ({ bitDepth, transferFunction, primaries }), - [bitDepth, transferFunction, primaries], + () => ({ + bitDepth, + transferFunction, + primaries, + matrix, + chromaSubsampling, + range, + referenceWhiteNits, + hlgPeakRatio, + masteringMetadata, + }), + [ + bitDepth, + transferFunction, + primaries, + matrix, + chromaSubsampling, + range, + referenceWhiteNits, + hlgPeakRatio, + masteringMetadata, + ], + ); + + // Friendly color profiles for AVIF/JXL. Each bundles the low-level HDR fields so a + // non-expert can pick one option instead of reasoning about color science. "custom" + // is a sentinel that reveals the Advanced controls without changing any field. + const colorProfiles = useMemo( + () => ({ + sdr: { bitDepth: 8, transferFunction: 'srgb', primaries: 'srgb', matrix: 'ycbcr', chromaSubsampling: '420' }, + hdr10: { + bitDepth: 10, + transferFunction: 'pq', + primaries: 'bt2020', + matrix: 'ycbcr', + chromaSubsampling: '420', + range: 'full', + }, + hlg: { + bitDepth: 10, + transferFunction: 'hlg', + primaries: 'bt2020', + matrix: 'ycbcr', + chromaSubsampling: '420', + range: 'full', + }, + maxQuality: { + bitDepth: 12, + transferFunction: 'pq', + primaries: 'bt2020', + matrix: 'ycbcr', + chromaSubsampling: '444', + range: 'full', + }, + archival: { + bitDepth: 12, + transferFunction: 'pq', + primaries: 'bt2020', + matrix: 'identity', + chromaSubsampling: '444', + range: 'full', + }, + }), + [], + ); + + // The profile that exactly matches the current field bundle, otherwise 'custom'. + const activeColorProfile = useMemo(() => { + if (bitDepth === 8) return 'sdr'; + const match = (Object.keys(colorProfiles) as Array).find((key) => { + if (key === 'sdr') return false; + const p = colorProfiles[key]; + return ( + p.bitDepth === bitDepth && + p.transferFunction === transferFunction && + p.primaries === primaries && + p.matrix === matrix && + // chroma is forced to 4:4:4 when matrix is RGB, so ignore it in that case. + (matrix === 'identity' || p.chromaSubsampling === chromaSubsampling) && + p.range === range + ); + }); + return match ?? 'custom'; + }, [bitDepth, transferFunction, primaries, matrix, chromaSubsampling, range, colorProfiles]); + + const [forceAdvancedColor, setForceAdvancedColor] = useState(false); + const showAdvancedColor = activeColorProfile === 'custom' || forceAdvancedColor; + + const applyColorProfile = useCallback( + (key: string) => { + if (key === 'custom') { + setForceAdvancedColor(true); + return; + } + const p = colorProfiles[key as keyof typeof colorProfiles]; + if (!p) return; + setBitDepth(p.bitDepth); + setTransferFunction(p.transferFunction); + setPrimaries(p.primaries); + setMatrix(p.matrix); + setChromaSubsampling(p.chromaSubsampling); + if ('range' in p && p.range) setRange(p.range); + setForceAdvancedColor(false); + }, + [colorProfiles, setBitDepth, setTransferFunction, setPrimaries, setMatrix, setChromaSubsampling, setRange], + ); + + // Switching SDR -> HDR in the Advanced controls promotes the SDR-only fields to the + // compatible HDR defaults (PQ / Rec.2020 / YCbCr / 4:2:0) so the output is valid HDR. + const handleBitDepthChange = useCallback( + (depth: number) => { + setBitDepth(depth); + if (depth > 8 && transferFunction === 'srgb') { + setTransferFunction('pq'); + setPrimaries('bt2020'); + setMatrix('ycbcr'); + setChromaSubsampling('420'); + setRange('full'); + } + }, + [transferFunction, setBitDepth, setTransferFunction, setPrimaries, setMatrix, setChromaSubsampling, setRange], + ); + + const handleMatrixChange = useCallback( + (value: string) => { + setMatrix(value); + // RGB (identity) cannot subsample chroma; force full 4:4:4 to keep the output valid. + if (value === 'identity') { + setChromaSubsampling('444'); + } + }, + [setMatrix, setChromaSubsampling], + ); + + const colorProfileOptions = useMemo( + () => [ + { label: t('export.file.profiles.sdr'), value: 'sdr' }, + { label: t('export.file.profiles.hdr10'), value: 'hdr10' }, + { label: t('export.file.profiles.hlg'), value: 'hlg' }, + { label: t('export.file.profiles.maxQuality'), value: 'maxQuality' }, + { label: t('export.file.profiles.archival'), value: 'archival' }, + { label: t('export.file.profiles.custom'), value: 'custom' }, + ], + [t], + ); + + const bitDepthOptions = useMemo( + () => [ + { label: t('export.file.bitDepth8'), value: '8' }, + { label: t('export.file.bitDepth10'), value: '10' }, + { label: t('export.file.bitDepth12'), value: '12' }, + ], + [t], + ); + + const matrixOptions = useMemo( + () => [ + { label: t('export.file.colorEncodingYcbcr'), value: 'ycbcr' }, + { label: t('export.file.colorEncodingRgb'), value: 'identity' }, + ], + [t], + ); + + const chromaOptions = useMemo( + () => [ + { label: t('export.file.chroma420'), value: '420' }, + { label: t('export.file.chroma422'), value: '422' }, + { label: t('export.file.chroma444'), value: '444' }, + ], + [t], + ); + + const rangeOptions = useMemo( + () => [ + { label: t('export.file.rangeFull'), value: 'full' }, + { label: t('export.file.rangeLimited'), value: 'limited' }, + ], + [t], + ); + + const gamutOptions = useMemo( + () => [ + { label: t('export.file.gamutSrgb'), value: 'srgb' }, + { label: t('export.file.gamutDisplayP3'), value: 'displayp3' }, + { label: t('export.file.gamutBt2020'), value: 'bt2020' }, + ], + [t], + ); + + const transferOptions = useMemo( + () => [ + { label: 'PQ (ST.2084)', value: 'pq' }, + { label: 'HLG', value: 'hlg' }, + ], + [], ); const debouncedEstimateSize = useMemo( @@ -620,59 +848,149 @@ export default function ExportPanel({ )} {[FileFormats.Avif, FileFormats.Jxl].includes(fileFormat as FileFormats) && ( -
-
- {t('export.file.bitDepth')} +
+ {/* Tier 1: a single friendly "Color profile" picker that bundles the HDR fields. */} + { - const depth = parseInt(v, 10); - setBitDepth(depth); - // Picking an HDR depth implies an HDR transfer; default to PQ/Rec.2020. - if (depth > 8 && transferFunction === 'srgb') { - setTransferFunction('pq'); - setPrimaries('bt2020'); - } - }} + options={colorProfileOptions} + value={activeColorProfile} + onChange={applyColorProfile} disabled={isExporting} - className="w-44" + className="w-56" /> -
- {bitDepth > 8 && ( - <> -
- {t('export.file.transfer')} - -
-
- {t('export.file.primaries')} + + + {/* An explicit Advanced toggle so users on a preset can still fine-tune. */} + {activeColorProfile !== 'custom' && bitDepth > 8 && ( + + )} + + {/* Tier 2: Advanced controls, each with plain-language help. */} + {showAdvancedColor && ( +
+ handleBitDepthChange(parseInt(v, 10))} disabled={isExporting} - className="w-44" + className="w-56" /> -
-

{t('export.file.hdrHint')}

- + + + {bitDepth > 8 && ( + <> + + + + + {transferFunction === 'hlg' && ( + +
+ setHlgPeakRatio(parseInt(String(e.target.value), 10))} + defaultValue={12} + fillOrigin="min" + /> +
+
+ )} + + + + + + + + + + {/* Chroma detail only applies to YCbCr; RGB forces full 4:4:4. */} + {matrix === 'ycbcr' && ( + + + + )} + + + + + + +
+ setReferenceWhiteNits(parseInt(String(e.target.value), 10))} + defaultValue={203} + fillOrigin="min" + suffix="nits" + /> +
+
+ + + + {t('export.file.masteringMetadataHelp')} + + + )} +
)}
)} diff --git a/src/components/ui/ExportImportProperties.tsx b/src/components/ui/ExportImportProperties.tsx index 97a42c5872..876bf9d5fc 100644 --- a/src/components/ui/ExportImportProperties.tsx +++ b/src/components/ui/ExportImportProperties.tsx @@ -51,8 +51,20 @@ export interface ExportSettings { bitDepth?: number; /** HDR transfer function: 'srgb' | 'pq' | 'hlg'. */ transferFunction?: string; - /** HDR primaries: 'srgb' | 'bt2020'. */ + /** HDR primaries: 'srgb' | 'bt2020' | 'displayp3'. */ primaries?: string; + /** Color encoding matrix: 'identity' (RGB) | 'ycbcr'. */ + matrix?: string; + /** Chroma subsampling: '444' | '422' | '420'. */ + chromaSubsampling?: string; + /** Signal range: 'full' | 'limited'. */ + range?: string; + /** Reference (paper) white in nits. Default 203. */ + referenceWhiteNits?: number; + /** HLG peak luminance headroom above paper white. Default 12. */ + hlgPeakRatio?: number; + /** Embed mastering display metadata (MaxCLL/MaxFALL). Default false. */ + masteringMetadata?: boolean; } export enum WatermarkAnchor { @@ -128,4 +140,10 @@ export interface ExportPreset { bitDepth?: number; transferFunction?: string; primaries?: string; + matrix?: string; + chromaSubsampling?: string; + range?: string; + referenceWhiteNits?: number; + hlgPeakRatio?: number; + masteringMetadata?: boolean; } diff --git a/src/hooks/useExportSettings.ts b/src/hooks/useExportSettings.ts index a369fbec33..298ad9b39a 100644 --- a/src/hooks/useExportSettings.ts +++ b/src/hooks/useExportSettings.ts @@ -24,6 +24,15 @@ export function useExportSettings() { const [bitDepth, setBitDepth] = useState(8); const [transferFunction, setTransferFunction] = useState('srgb'); const [primaries, setPrimaries] = useState('srgb'); + // matrix 'identity'|'ycbcr'; subsampling '444'|'422'|'420'; range 'full'|'limited'. + // User-facing HDR defaults favour compatibility (ycbcr/420); SDR output is unchanged because + // these fields are only emitted/used when bitDepth > 8 and the format is AVIF/JXL. + const [matrix, setMatrix] = useState('ycbcr'); + const [chromaSubsampling, setChromaSubsampling] = useState('420'); + const [range, setRange] = useState('full'); + const [referenceWhiteNits, setReferenceWhiteNits] = useState(203); + const [hlgPeakRatio, setHlgPeakRatio] = useState(12); + const [masteringMetadata, setMasteringMetadata] = useState(false); const handleApplyPreset = useCallback((preset: ExportPreset) => { setFileFormat(preset.fileFormat); @@ -47,6 +56,12 @@ export function useExportSettings() { setBitDepth(preset.bitDepth ?? 8); setTransferFunction(preset.transferFunction ?? 'srgb'); setPrimaries(preset.primaries ?? 'srgb'); + setMatrix(preset.matrix ?? 'ycbcr'); + setChromaSubsampling(preset.chromaSubsampling ?? '420'); + setRange(preset.range ?? 'full'); + setReferenceWhiteNits(preset.referenceWhiteNits ?? 203); + setHlgPeakRatio(preset.hlgPeakRatio ?? 12); + setMasteringMetadata(preset.masteringMetadata ?? false); }, []); const currentSettingsObject = useMemo( @@ -72,6 +87,12 @@ export function useExportSettings() { bitDepth, transferFunction, primaries, + matrix, + chromaSubsampling, + range, + referenceWhiteNits, + hlgPeakRatio, + masteringMetadata, }), [ fileFormat, @@ -95,6 +116,12 @@ export function useExportSettings() { bitDepth, transferFunction, primaries, + matrix, + chromaSubsampling, + range, + referenceWhiteNits, + hlgPeakRatio, + masteringMetadata, ], ); @@ -141,6 +168,18 @@ export function useExportSettings() { setTransferFunction, primaries, setPrimaries, + matrix, + setMatrix, + chromaSubsampling, + setChromaSubsampling, + range, + setRange, + referenceWhiteNits, + setReferenceWhiteNits, + hlgPeakRatio, + setHlgPeakRatio, + masteringMetadata, + setMasteringMetadata, handleApplyPreset, currentSettingsObject, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3719688515..a4f0749489 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -738,7 +738,43 @@ "bitDepth12": "12-bit (HDR)", "transfer": "Transfer", "primaries": "Primaries", - "hdrHint": "HDR output is tagged BT.2020 + PQ/HLG. Needs an HDR display to view correctly." + "hdrHint": "HDR output needs an HDR display to view correctly. On a regular screen it may look washed out.", + "colorProfile": "Color profile", + "colorProfileHint": "Pick a ready-made profile, or choose Custom to fine-tune every setting.", + "profiles": { + "sdr": "Standard (SDR, 8-bit)", + "hdr10": "HDR — best compatibility (HDR10)", + "hlg": "HDR — broadcast (HLG)", + "maxQuality": "HDR — maximum quality", + "archival": "HDR — archival / exact (RGB lossless)", + "custom": "Custom…" + }, + "advancedToggle": "Advanced color options", + "bitDepthHelp": "More bits = smoother gradients in skies and shadows, larger files. 10-bit is the HDR standard.", + "colorEncoding": "Color encoding", + "colorEncodingYcbcr": "YCbCr (recommended)", + "colorEncodingRgb": "RGB (lossless)", + "colorEncodingHelp": "How color is stored. YCbCr (recommended) is smaller and plays everywhere. RGB is exact and lossless but larger and less widely supported.", + "chromaDetail": "Chroma detail", + "chroma420": "4:2:0 (smallest)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (full color)", + "chromaDetailHelp": "4:2:0 (recommended) shrinks files by reducing color resolution — usually invisible. 4:4:4 keeps full color detail (larger).", + "signalRange": "Signal range", + "rangeFull": "Full (recommended)", + "rangeLimited": "Limited (TV/video)", + "signalRangeHelp": "Full (recommended for photos) uses every value. Limited matches TV and video.", + "colorGamut": "Color gamut", + "gamutSrgb": "sRGB (smallest)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (widest)", + "colorGamutHelp": "How wide a range of colors. Rec.2020 (widest) is standard for HDR; Display-P3 matches modern screens; sRGB is the smallest.", + "referenceWhite": "Reference white", + "referenceWhiteHelp": "How bright 'paper white' looks. 203 nits is the HDR standard — raise it for a brighter look. (Expert)", + "hlgPeakRatio": "HLG peak ratio", + "hlgPeakRatioHelp": "Advanced: HLG headroom above paper white.", + "masteringMetadata": "Mastering metadata", + "masteringMetadataHelp": "Embed brightness info (MaxCLL/MaxFALL) that some HDR displays use. Safe to leave on." }, "labels": { "image": "Image", From 006e0db20f35196372cf9f2c0d038d6fbc3e625e Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 10:34:07 +0200 Subject: [PATCH 12/20] docs: HDR options are now user-selectable + colored-pixel verified --- HDR_REPORT.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/HDR_REPORT.md b/HDR_REPORT.md index 80cd310eb1..ba1868814c 100644 --- a/HDR_REPORT.md +++ b/HDR_REPORT.md @@ -93,10 +93,13 @@ adapter is available (headless CI). the >1.0 renormalization. For default/identity curves this is exactly right (verified: linear 4.0 survives). For a user-set tone/RGB curve the behaviour above diffuse white is a reasonable extrapolation but **has not been visually checked** — flagging loudly as the brief requested. -2. **AVIF uses matrix_coefficients = 0 (Identity/GBR, 4:4:4 full-range).** This is lossless and - makes the reference-white code exact, and is explicitly allowed by the brief. It is less - universally supported than YCbCr matrix=9; if a target HDR viewer rejects it, switch to matrix 9 - (requires implementing RGB→BT.2020-NCL YCbCr + 4:2:0, a known follow-up). +2. **AVIF matrix is now user-selectable** (Identity/RGB *or* YCbCr BT.2020-NCL / BT.709-NCL), + alongside chroma subsampling (4:4:4/4:2:2/4:2:0), full/limited range, primaries (sRGB / Display-P3 + / Rec.2020), reference-white nits, HLG peak, and MaxCLL/MaxFALL mastering metadata — exposed in + the export panel via friendly presets + an Advanced section. The YCbCr/subsampling/range/primaries + paths are covered by **colored-pixel** round-trip tests (not just neutral), so the matrix/chroma + math is numerically verified, not just assumed. `HdrEncodeConfig::default()` is still Identity/ + 4:4:4/full so the originally-verified path is byte-identical. 3. **HLG is tag- and monotonicity-verified only.** HLG is relative/scene-referred, so there is no single absolute-nit anchor check; `HLG_PEAK_RATIO = 12` is a reasonable default, not tuned. 4. **Watermarking an HDR export clamps the watermarked pixels to SDR** (the overlay runs in 8-bit). From 19b02b55bd1f38b80288e226a254e8f9f7f6eed6 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 10:43:07 +0200 Subject: [PATCH 13/20] i18n: translate HDR export strings into 10 locales Merge the new export.file HDR keys (color profile presets + advanced controls + help) into de/fr/es/it/pt/pl/ru/ja/ko/zh-CN. Technical tokens (PQ, HLG, Rec.2020, Display-P3, 4:2:0, nits, MaxCLL, ...) kept verbatim; existing keys untouched; en.json unchanged. Machine translation - may warrant native-speaker review. --- src/i18n/locales/de.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/es.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/fr.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/it.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/ja.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/ko.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/pl.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/pt.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/ru.json | 45 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/zh-CN.json | 45 ++++++++++++++++++++++++++++++++++++- 10 files changed, 440 insertions(+), 10 deletions(-) diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 876901611f..34e9b45c74 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -731,7 +731,50 @@ }, "file": { "quality": "Qualität", - "qualityLossless": "Qualität (Verlustfrei)" + "qualityLossless": "Qualität (Verlustfrei)", + "bitDepth": "Bittiefe", + "bitDepth8": "8-Bit (SDR)", + "bitDepth10": "10-Bit (HDR)", + "bitDepth12": "12-Bit (HDR)", + "transfer": "Übertragung", + "primaries": "Primärfarben", + "hdrHint": "HDR-Ausgabe benötigt ein HDR-Display zur korrekten Darstellung. Auf einem normalen Bildschirm kann sie ausgewaschen wirken.", + "colorProfile": "Farbprofil", + "colorProfileHint": "Wähle ein fertiges Profil oder „Benutzerdefiniert“, um jede Einstellung anzupassen.", + "profiles": { + "sdr": "Standard (SDR, 8-Bit)", + "hdr10": "HDR – beste Kompatibilität (HDR10)", + "hlg": "HDR – Broadcast (HLG)", + "maxQuality": "HDR – maximale Qualität", + "archival": "HDR – Archiv / exakt (RGB verlustfrei)", + "custom": "Benutzerdefiniert…" + }, + "advancedToggle": "Erweiterte Farboptionen", + "bitDepthHelp": "Mehr Bit = weichere Verläufe in Himmel und Schatten, größere Dateien. 10-Bit ist der HDR-Standard.", + "colorEncoding": "Farbkodierung", + "colorEncodingYcbcr": "YCbCr (empfohlen)", + "colorEncodingRgb": "RGB (verlustfrei)", + "colorEncodingHelp": "Wie Farbe gespeichert wird. YCbCr (empfohlen) ist kleiner und überall abspielbar. RGB ist exakt und verlustfrei, aber größer und weniger verbreitet unterstützt.", + "chromaDetail": "Chroma-Detail", + "chroma420": "4:2:0 (kleinste)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (volle Farbe)", + "chromaDetailHelp": "4:2:0 (empfohlen) verkleinert Dateien durch reduzierte Farbauflösung – meist unsichtbar. 4:4:4 behält das volle Farbdetail (größer).", + "signalRange": "Signalbereich", + "rangeFull": "Voll (empfohlen)", + "rangeLimited": "Begrenzt (TV/Video)", + "signalRangeHelp": "Voll (empfohlen für Fotos) nutzt jeden Wert. Begrenzt entspricht TV und Video.", + "colorGamut": "Farbraum", + "gamutSrgb": "sRGB (kleinster)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (weitester)", + "colorGamutHelp": "Wie breit der Farbbereich ist. Rec.2020 (weitester) ist Standard für HDR; Display-P3 entspricht modernen Bildschirmen; sRGB ist der kleinste.", + "referenceWhite": "Referenzweiß", + "referenceWhiteHelp": "Wie hell „Papierweiß“ erscheint. 203 nits ist der HDR-Standard – erhöhe es für ein helleres Aussehen. (Experte)", + "hlgPeakRatio": "HLG-Spitzenverhältnis", + "hlgPeakRatioHelp": "Erweitert: HLG-Spielraum über Papierweiß.", + "masteringMetadata": "Mastering-Metadaten", + "masteringMetadataHelp": "Bettet Helligkeitsinfos (MaxCLL/MaxFALL) ein, die einige HDR-Displays nutzen. Kann bedenkenlos aktiviert bleiben." }, "labels": { "image": "Bild", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ce22061d73..97a2ca8920 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -731,7 +731,50 @@ }, "file": { "quality": "Calidad", - "qualityLossless": "Calidad (Sin pérdida)" + "qualityLossless": "Calidad (Sin pérdida)", + "bitDepth": "Profundidad de bits", + "bitDepth8": "8 bits (SDR)", + "bitDepth10": "10 bits (HDR)", + "bitDepth12": "12 bits (HDR)", + "transfer": "Transferencia", + "primaries": "Primarios", + "hdrHint": "La salida HDR necesita una pantalla HDR para verse correctamente. En una pantalla normal puede verse desvaída.", + "colorProfile": "Perfil de color", + "colorProfileHint": "Elige un perfil predefinido o Personalizado para ajustar cada opción.", + "profiles": { + "sdr": "Estándar (SDR, 8 bits)", + "hdr10": "HDR: mejor compatibilidad (HDR10)", + "hlg": "HDR: difusión (HLG)", + "maxQuality": "HDR: máxima calidad", + "archival": "HDR: archivo / exacto (RGB sin pérdida)", + "custom": "Personalizado…" + }, + "advancedToggle": "Opciones de color avanzadas", + "bitDepthHelp": "Más bits = degradados más suaves en cielos y sombras, archivos más grandes. 10 bits es el estándar HDR.", + "colorEncoding": "Codificación de color", + "colorEncodingYcbcr": "YCbCr (recomendado)", + "colorEncodingRgb": "RGB (sin pérdida)", + "colorEncodingHelp": "Cómo se almacena el color. YCbCr (recomendado) es más pequeño y se reproduce en todas partes. RGB es exacto y sin pérdida, pero más grande y con menos compatibilidad.", + "chromaDetail": "Detalle de croma", + "chroma420": "4:2:0 (el más pequeño)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (color completo)", + "chromaDetailHelp": "4:2:0 (recomendado) reduce el tamaño de los archivos bajando la resolución de color, normalmente imperceptible. 4:4:4 conserva todo el detalle de color (más grande).", + "signalRange": "Rango de señal", + "rangeFull": "Completo (recomendado)", + "rangeLimited": "Limitado (TV/vídeo)", + "signalRangeHelp": "Completo (recomendado para fotos) usa todos los valores. Limitado coincide con TV y vídeo.", + "colorGamut": "Gama de color", + "gamutSrgb": "sRGB (la más pequeña)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (la más amplia)", + "colorGamutHelp": "Cuán amplia es la gama de colores. Rec.2020 (la más amplia) es el estándar para HDR; Display-P3 coincide con las pantallas modernas; sRGB es la más pequeña.", + "referenceWhite": "Blanco de referencia", + "referenceWhiteHelp": "Cuánto brilla el «blanco papel». 203 nits es el estándar HDR; súbelo para un aspecto más brillante. (Experto)", + "hlgPeakRatio": "Relación de pico HLG", + "hlgPeakRatioHelp": "Avanzado: margen de HLG por encima del blanco papel.", + "masteringMetadata": "Metadatos de masterización", + "masteringMetadataHelp": "Incrusta información de brillo (MaxCLL/MaxFALL) que usan algunas pantallas HDR. Es seguro dejarlo activado." }, "labels": { "image": "Imagen", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 68c249d10c..d136a67ac8 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -731,7 +731,50 @@ }, "file": { "quality": "Qualité", - "qualityLossless": "Qualité (Sans perte)" + "qualityLossless": "Qualité (Sans perte)", + "bitDepth": "Profondeur de bits", + "bitDepth8": "8 bits (SDR)", + "bitDepth10": "10 bits (HDR)", + "bitDepth12": "12 bits (HDR)", + "transfer": "Transfert", + "primaries": "Primaires", + "hdrHint": "La sortie HDR nécessite un écran HDR pour un affichage correct. Sur un écran ordinaire, elle peut paraître délavée.", + "colorProfile": "Profil colorimétrique", + "colorProfileHint": "Choisissez un profil prédéfini, ou Personnalisé pour ajuster chaque réglage.", + "profiles": { + "sdr": "Standard (SDR, 8 bits)", + "hdr10": "HDR — meilleure compatibilité (HDR10)", + "hlg": "HDR — diffusion (HLG)", + "maxQuality": "HDR — qualité maximale", + "archival": "HDR — archivage / exact (RGB sans perte)", + "custom": "Personnalisé…" + }, + "advancedToggle": "Options colorimétriques avancées", + "bitDepthHelp": "Plus de bits = dégradés plus doux dans les ciels et les ombres, fichiers plus volumineux. Le 10 bits est la norme HDR.", + "colorEncoding": "Encodage des couleurs", + "colorEncodingYcbcr": "YCbCr (recommandé)", + "colorEncodingRgb": "RGB (sans perte)", + "colorEncodingHelp": "Comment la couleur est stockée. YCbCr (recommandé) est plus léger et lisible partout. RGB est exact et sans perte mais plus volumineux et moins largement pris en charge.", + "chromaDetail": "Détail de chrominance", + "chroma420": "4:2:0 (le plus léger)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (couleur complète)", + "chromaDetailHelp": "4:2:0 (recommandé) réduit la taille des fichiers en abaissant la résolution des couleurs — généralement invisible. 4:4:4 conserve tout le détail des couleurs (plus volumineux).", + "signalRange": "Plage du signal", + "rangeFull": "Complète (recommandée)", + "rangeLimited": "Limitée (TV/vidéo)", + "signalRangeHelp": "Complète (recommandée pour les photos) utilise toutes les valeurs. Limitée correspond à la TV et à la vidéo.", + "colorGamut": "Gamme de couleurs", + "gamutSrgb": "sRGB (la plus petite)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (la plus large)", + "colorGamutHelp": "L’étendue de la gamme de couleurs. Rec.2020 (la plus large) est la norme pour le HDR ; Display-P3 correspond aux écrans modernes ; sRGB est la plus petite.", + "referenceWhite": "Blanc de référence", + "referenceWhiteHelp": "La luminosité du « blanc papier ». 203 nits est la norme HDR — augmentez-le pour un rendu plus lumineux. (Expert)", + "hlgPeakRatio": "Ratio de crête HLG", + "hlgPeakRatioHelp": "Avancé : marge HLG au-dessus du blanc papier.", + "masteringMetadata": "Métadonnées de mastering", + "masteringMetadataHelp": "Intègre les infos de luminosité (MaxCLL/MaxFALL) utilisées par certains écrans HDR. Sans risque à laisser activé." }, "labels": { "image": "Image", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b1f7d72d1b..6387b8cd4b 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -731,7 +731,50 @@ }, "file": { "quality": "Qualità", - "qualityLossless": "Qualità (Senza Perdita)" + "qualityLossless": "Qualità (Senza Perdita)", + "bitDepth": "Profondità di bit", + "bitDepth8": "8 bit (SDR)", + "bitDepth10": "10 bit (HDR)", + "bitDepth12": "12 bit (HDR)", + "transfer": "Trasferimento", + "primaries": "Primari", + "hdrHint": "L’output HDR richiede un display HDR per essere visualizzato correttamente. Su uno schermo normale può apparire slavato.", + "colorProfile": "Profilo colore", + "colorProfileHint": "Scegli un profilo predefinito oppure Personalizzato per regolare ogni impostazione.", + "profiles": { + "sdr": "Standard (SDR, 8 bit)", + "hdr10": "HDR — massima compatibilità (HDR10)", + "hlg": "HDR — broadcast (HLG)", + "maxQuality": "HDR — qualità massima", + "archival": "HDR — archiviazione / esatto (RGB senza perdita)", + "custom": "Personalizzato…" + }, + "advancedToggle": "Opzioni colore avanzate", + "bitDepthHelp": "Più bit = sfumature più morbide in cieli e ombre, file più grandi. 10 bit è lo standard HDR.", + "colorEncoding": "Codifica colore", + "colorEncodingYcbcr": "YCbCr (consigliato)", + "colorEncodingRgb": "RGB (senza perdita)", + "colorEncodingHelp": "Come viene memorizzato il colore. YCbCr (consigliato) è più leggero e riproducibile ovunque. RGB è esatto e senza perdita ma più grande e meno supportato.", + "chromaDetail": "Dettaglio cromatico", + "chroma420": "4:2:0 (più piccolo)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (colore completo)", + "chromaDetailHelp": "4:2:0 (consigliato) riduce le dimensioni dei file abbassando la risoluzione del colore — di solito invisibile. 4:4:4 mantiene tutto il dettaglio del colore (più grande).", + "signalRange": "Gamma del segnale", + "rangeFull": "Completa (consigliata)", + "rangeLimited": "Limitata (TV/video)", + "signalRangeHelp": "Completa (consigliata per le foto) usa ogni valore. Limitata corrisponde a TV e video.", + "colorGamut": "Gamut cromatico", + "gamutSrgb": "sRGB (il più piccolo)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (il più ampio)", + "colorGamutHelp": "Quanto è ampia la gamma di colori. Rec.2020 (il più ampio) è lo standard per l’HDR; Display-P3 corrisponde agli schermi moderni; sRGB è il più piccolo.", + "referenceWhite": "Bianco di riferimento", + "referenceWhiteHelp": "Quanto appare luminoso il «bianco carta». 203 nits è lo standard HDR — aumentalo per un aspetto più luminoso. (Esperto)", + "hlgPeakRatio": "Rapporto di picco HLG", + "hlgPeakRatioHelp": "Avanzato: margine HLG sopra il bianco carta.", + "masteringMetadata": "Metadati di mastering", + "masteringMetadataHelp": "Incorpora le informazioni di luminosità (MaxCLL/MaxFALL) usate da alcuni display HDR. Si può lasciare attivo senza problemi." }, "labels": { "image": "Immagine", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 065ebc83f6..27befeec63 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -731,7 +731,50 @@ }, "file": { "quality": "画質", - "qualityLossless": "画質 (ロスレス)" + "qualityLossless": "画質 (ロスレス)", + "bitDepth": "ビット深度", + "bitDepth8": "8ビット (SDR)", + "bitDepth10": "10ビット (HDR)", + "bitDepth12": "12ビット (HDR)", + "transfer": "伝達関数", + "primaries": "原色", + "hdrHint": "HDR出力を正しく表示するにはHDRディスプレイが必要です。通常の画面では色あせて見える場合があります。", + "colorProfile": "カラープロファイル", + "colorProfileHint": "既製のプロファイルを選ぶか、「カスタム」を選んで各設定を細かく調整します。", + "profiles": { + "sdr": "標準 (SDR、8ビット)", + "hdr10": "HDR — 最も高い互換性 (HDR10)", + "hlg": "HDR — 放送 (HLG)", + "maxQuality": "HDR — 最高画質", + "archival": "HDR — アーカイブ/正確 (RGBロスレス)", + "custom": "カスタム…" + }, + "advancedToggle": "詳細なカラーオプション", + "bitDepthHelp": "ビット数が多いほど空や影のグラデーションが滑らかになり、ファイルは大きくなります。10ビットがHDRの標準です。", + "colorEncoding": "カラーエンコーディング", + "colorEncodingYcbcr": "YCbCr (推奨)", + "colorEncodingRgb": "RGB (ロスレス)", + "colorEncodingHelp": "色の保存方法です。YCbCr (推奨) は小さく、どこでも再生できます。RGBは正確でロスレスですが、サイズが大きく対応環境が限られます。", + "chromaDetail": "色差ディテール", + "chroma420": "4:2:0 (最小)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (フルカラー)", + "chromaDetailHelp": "4:2:0 (推奨) は色解像度を下げてファイルを小さくします — 通常は見分けがつきません。4:4:4 は色のディテールを完全に保ちます (大きくなります)。", + "signalRange": "信号レンジ", + "rangeFull": "フル (推奨)", + "rangeLimited": "制限 (TV/ビデオ)", + "signalRangeHelp": "フル (写真に推奨) はすべての値を使用します。制限はTVやビデオに合わせます。", + "colorGamut": "色域", + "gamutSrgb": "sRGB (最小)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (最も広い)", + "colorGamutHelp": "色の範囲の広さです。Rec.2020 (最も広い) はHDRの標準、Display-P3は最新の画面に合い、sRGBは最も小さい色域です。", + "referenceWhite": "基準白", + "referenceWhiteHelp": "「紙の白」の明るさです。203 nitsがHDRの標準で、上げると明るい見た目になります。(エキスパート)", + "hlgPeakRatio": "HLGピーク比", + "hlgPeakRatioHelp": "詳細設定:紙の白を超えるHLGのヘッドルームです。", + "masteringMetadata": "マスタリングメタデータ", + "masteringMetadataHelp": "一部のHDRディスプレイが使用する輝度情報 (MaxCLL/MaxFALL) を埋め込みます。オンのままで問題ありません。" }, "labels": { "image": "画像", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 4c1cc0d2ac..9b75d8593a 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -731,7 +731,50 @@ }, "file": { "quality": "품질", - "qualityLossless": "품질 (무손실)" + "qualityLossless": "품질 (무손실)", + "bitDepth": "비트 심도", + "bitDepth8": "8비트 (SDR)", + "bitDepth10": "10비트 (HDR)", + "bitDepth12": "12비트 (HDR)", + "transfer": "전달 함수", + "primaries": "원색", + "hdrHint": "HDR 출력을 올바르게 보려면 HDR 디스플레이가 필요합니다. 일반 화면에서는 색이 바랜 것처럼 보일 수 있습니다.", + "colorProfile": "색상 프로파일", + "colorProfileHint": "미리 만들어진 프로파일을 선택하거나 사용자 지정을 골라 각 설정을 세밀하게 조정하세요.", + "profiles": { + "sdr": "표준 (SDR, 8비트)", + "hdr10": "HDR — 최고 호환성 (HDR10)", + "hlg": "HDR — 방송 (HLG)", + "maxQuality": "HDR — 최고 품질", + "archival": "HDR — 보관용 / 정확 (RGB 무손실)", + "custom": "사용자 지정…" + }, + "advancedToggle": "고급 색상 옵션", + "bitDepthHelp": "비트가 많을수록 하늘과 그림자의 그라데이션이 부드러워지지만 파일이 커집니다. 10비트가 HDR 표준입니다.", + "colorEncoding": "색상 인코딩", + "colorEncodingYcbcr": "YCbCr (권장)", + "colorEncodingRgb": "RGB (무손실)", + "colorEncodingHelp": "색상을 저장하는 방식입니다. YCbCr (권장)는 더 작고 어디서나 재생됩니다. RGB는 정확하고 무손실이지만 더 크고 지원 범위가 좁습니다.", + "chromaDetail": "크로마 디테일", + "chroma420": "4:2:0 (가장 작음)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (전체 색상)", + "chromaDetailHelp": "4:2:0 (권장)는 색 해상도를 낮춰 파일을 줄입니다 — 보통은 눈에 띄지 않습니다. 4:4:4는 색상 디테일을 모두 유지합니다 (더 큼).", + "signalRange": "신호 범위", + "rangeFull": "전체 (권장)", + "rangeLimited": "제한 (TV/비디오)", + "signalRangeHelp": "전체 (사진에 권장)는 모든 값을 사용합니다. 제한은 TV 및 비디오에 맞춥니다.", + "colorGamut": "색 영역", + "gamutSrgb": "sRGB (가장 작음)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (가장 넓음)", + "colorGamutHelp": "색상 범위의 넓이입니다. Rec.2020 (가장 넓음)은 HDR 표준이고, Display-P3는 최신 화면에 맞으며, sRGB는 가장 작습니다.", + "referenceWhite": "기준 백색", + "referenceWhiteHelp": "「종이 백색」의 밝기입니다. 203 nits가 HDR 표준이며, 높이면 더 밝아 보입니다. (전문가)", + "hlgPeakRatio": "HLG 피크 비율", + "hlgPeakRatioHelp": "고급: 종이 백색 위로 확보되는 HLG 여유분입니다.", + "masteringMetadata": "마스터링 메타데이터", + "masteringMetadataHelp": "일부 HDR 디스플레이가 사용하는 밝기 정보 (MaxCLL/MaxFALL)를 포함합니다. 켜 둬도 안전합니다." }, "labels": { "image": "이미지", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index e2cfa2bcc9..7402bf797d 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -771,7 +771,50 @@ }, "file": { "quality": "Jakość", - "qualityLossless": "Jakość (Bezstratna)" + "qualityLossless": "Jakość (Bezstratna)", + "bitDepth": "Głębia bitowa", + "bitDepth8": "8-bitowy (SDR)", + "bitDepth10": "10-bitowy (HDR)", + "bitDepth12": "12-bitowy (HDR)", + "transfer": "Transfer", + "primaries": "Barwy podstawowe", + "hdrHint": "Wyjście HDR wymaga wyświetlacza HDR do poprawnego wyświetlania. Na zwykłym ekranie może wyglądać wyblakle.", + "colorProfile": "Profil koloru", + "colorProfileHint": "Wybierz gotowy profil lub Niestandardowy, aby dostroić każde ustawienie.", + "profiles": { + "sdr": "Standardowy (SDR, 8-bit)", + "hdr10": "HDR — najlepsza zgodność (HDR10)", + "hlg": "HDR — emisja (HLG)", + "maxQuality": "HDR — maksymalna jakość", + "archival": "HDR — archiwizacja / dokładny (RGB bezstratny)", + "custom": "Niestandardowy…" + }, + "advancedToggle": "Zaawansowane opcje koloru", + "bitDepthHelp": "Więcej bitów = gładsze przejścia tonalne na niebie i w cieniach, większe pliki. 10-bit to standard HDR.", + "colorEncoding": "Kodowanie koloru", + "colorEncodingYcbcr": "YCbCr (zalecane)", + "colorEncodingRgb": "RGB (bezstratne)", + "colorEncodingHelp": "Sposób zapisu koloru. YCbCr (zalecane) jest mniejszy i odtwarza się wszędzie. RGB jest dokładny i bezstratny, ale większy i słabiej obsługiwany.", + "chromaDetail": "Szczegółowość chromy", + "chroma420": "4:2:0 (najmniejszy)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (pełny kolor)", + "chromaDetailHelp": "4:2:0 (zalecane) zmniejsza rozmiar plików, obniżając rozdzielczość koloru — zwykle niewidoczne. 4:4:4 zachowuje pełny detal koloru (większy).", + "signalRange": "Zakres sygnału", + "rangeFull": "Pełny (zalecany)", + "rangeLimited": "Ograniczony (TV/wideo)", + "signalRangeHelp": "Pełny (zalecany dla zdjęć) wykorzystuje każdą wartość. Ograniczony odpowiada TV i wideo.", + "colorGamut": "Gamut koloru", + "gamutSrgb": "sRGB (najmniejszy)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (najszerszy)", + "colorGamutHelp": "Jak szeroki jest zakres kolorów. Rec.2020 (najszerszy) to standard HDR; Display-P3 odpowiada nowoczesnym ekranom; sRGB jest najmniejszy.", + "referenceWhite": "Biel odniesienia", + "referenceWhiteHelp": "Jak jasno wygląda „biel papieru”. 203 nits to standard HDR — zwiększ dla jaśniejszego wyglądu. (Ekspert)", + "hlgPeakRatio": "Współczynnik szczytu HLG", + "hlgPeakRatioHelp": "Zaawansowane: zapas HLG powyżej bieli papieru.", + "masteringMetadata": "Metadane masteringu", + "masteringMetadataHelp": "Osadza informacje o jasności (MaxCLL/MaxFALL), z których korzystają niektóre wyświetlacze HDR. Można bezpiecznie pozostawić włączone." }, "labels": { "image": "Obraz", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7e4b4e1037..781be59da8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -731,7 +731,50 @@ }, "file": { "quality": "Qualidade", - "qualityLossless": "Qualidade (Sem Perdas)" + "qualityLossless": "Qualidade (Sem Perdas)", + "bitDepth": "Profundidade de bits", + "bitDepth8": "8 bits (SDR)", + "bitDepth10": "10 bits (HDR)", + "bitDepth12": "12 bits (HDR)", + "transfer": "Transferência", + "primaries": "Primárias", + "hdrHint": "A saída HDR precisa de uma tela HDR para ser exibida corretamente. Em uma tela comum pode parecer desbotada.", + "colorProfile": "Perfil de cor", + "colorProfileHint": "Escolha um perfil pronto ou Personalizado para ajustar cada definição.", + "profiles": { + "sdr": "Padrão (SDR, 8 bits)", + "hdr10": "HDR — melhor compatibilidade (HDR10)", + "hlg": "HDR — transmissão (HLG)", + "maxQuality": "HDR — qualidade máxima", + "archival": "HDR — arquivo / exato (RGB sem perdas)", + "custom": "Personalizado…" + }, + "advancedToggle": "Opções de cor avançadas", + "bitDepthHelp": "Mais bits = gradientes mais suaves em céus e sombras, arquivos maiores. 10 bits é o padrão HDR.", + "colorEncoding": "Codificação de cor", + "colorEncodingYcbcr": "YCbCr (recomendado)", + "colorEncodingRgb": "RGB (sem perdas)", + "colorEncodingHelp": "Como a cor é armazenada. YCbCr (recomendado) é menor e reproduz em qualquer lugar. RGB é exato e sem perdas, mas maior e com menos compatibilidade.", + "chromaDetail": "Detalhe de croma", + "chroma420": "4:2:0 (menor)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (cor completa)", + "chromaDetailHelp": "4:2:0 (recomendado) reduz o tamanho dos arquivos diminuindo a resolução de cor — geralmente imperceptível. 4:4:4 mantém todo o detalhe de cor (maior).", + "signalRange": "Faixa de sinal", + "rangeFull": "Completa (recomendada)", + "rangeLimited": "Limitada (TV/vídeo)", + "signalRangeHelp": "Completa (recomendada para fotos) usa todos os valores. Limitada corresponde a TV e vídeo.", + "colorGamut": "Gama de cores", + "gamutSrgb": "sRGB (a menor)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (a mais ampla)", + "colorGamutHelp": "Quão ampla é a gama de cores. Rec.2020 (a mais ampla) é o padrão para HDR; Display-P3 corresponde às telas modernas; sRGB é a menor.", + "referenceWhite": "Branco de referência", + "referenceWhiteHelp": "Quão brilhante parece o «branco do papel». 203 nits é o padrão HDR — aumente para um visual mais claro. (Especialista)", + "hlgPeakRatio": "Proporção de pico HLG", + "hlgPeakRatioHelp": "Avançado: margem do HLG acima do branco do papel.", + "masteringMetadata": "Metadados de masterização", + "masteringMetadataHelp": "Incorpora informações de brilho (MaxCLL/MaxFALL) usadas por algumas telas HDR. Pode deixar ativado sem problemas." }, "labels": { "image": "Imagem", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b21c4ca883..d304281e1b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -731,7 +731,50 @@ }, "file": { "quality": "Качество", - "qualityLossless": "Качество (без потерь)" + "qualityLossless": "Качество (без потерь)", + "bitDepth": "Битовая глубина", + "bitDepth8": "8 бит (SDR)", + "bitDepth10": "10 бит (HDR)", + "bitDepth12": "12 бит (HDR)", + "transfer": "Передаточная функция", + "primaries": "Основные цвета", + "hdrHint": "Для корректного просмотра HDR-вывода нужен HDR-дисплей. На обычном экране изображение может выглядеть блёклым.", + "colorProfile": "Цветовой профиль", + "colorProfileHint": "Выберите готовый профиль или «Пользовательский», чтобы настроить каждый параметр.", + "profiles": { + "sdr": "Стандартный (SDR, 8 бит)", + "hdr10": "HDR — наилучшая совместимость (HDR10)", + "hlg": "HDR — вещание (HLG)", + "maxQuality": "HDR — максимальное качество", + "archival": "HDR — архив / точный (RGB без потерь)", + "custom": "Пользовательский…" + }, + "advancedToggle": "Расширенные параметры цвета", + "bitDepthHelp": "Больше бит = более плавные градиенты на небе и в тенях, но больше размер файлов. 10 бит — стандарт HDR.", + "colorEncoding": "Кодирование цвета", + "colorEncodingYcbcr": "YCbCr (рекомендуется)", + "colorEncodingRgb": "RGB (без потерь)", + "colorEncodingHelp": "Как хранится цвет. YCbCr (рекомендуется) компактнее и воспроизводится везде. RGB точный и без потерь, но крупнее и хуже поддерживается.", + "chromaDetail": "Детализация цветности", + "chroma420": "4:2:0 (наименьший)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (полный цвет)", + "chromaDetailHelp": "4:2:0 (рекомендуется) уменьшает размер файлов за счёт снижения цветового разрешения — обычно незаметно. 4:4:4 сохраняет полную детализацию цвета (крупнее).", + "signalRange": "Диапазон сигнала", + "rangeFull": "Полный (рекомендуется)", + "rangeLimited": "Ограниченный (ТВ/видео)", + "signalRangeHelp": "Полный (рекомендуется для фото) использует все значения. Ограниченный соответствует ТВ и видео.", + "colorGamut": "Цветовой охват", + "gamutSrgb": "sRGB (наименьший)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (самый широкий)", + "colorGamutHelp": "Насколько широк диапазон цветов. Rec.2020 (самый широкий) — стандарт для HDR; Display-P3 соответствует современным экранам; sRGB — наименьший.", + "referenceWhite": "Опорный белый", + "referenceWhiteHelp": "Насколько ярким выглядит «белый бумаги». 203 nits — стандарт HDR; увеличьте для более яркого вида. (Эксперт)", + "hlgPeakRatio": "Пиковое отношение HLG", + "hlgPeakRatioHelp": "Дополнительно: запас HLG над «белым бумаги».", + "masteringMetadata": "Метаданные мастеринга", + "masteringMetadataHelp": "Встраивает данные о яркости (MaxCLL/MaxFALL), которые используют некоторые HDR-дисплеи. Можно безопасно оставить включённым." }, "labels": { "image": "Изображение", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 260dc5a9fa..a0335782cd 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -731,7 +731,50 @@ }, "file": { "quality": "质量", - "qualityLossless": "质量(无损)" + "qualityLossless": "质量(无损)", + "bitDepth": "位深", + "bitDepth8": "8 位 (SDR)", + "bitDepth10": "10 位 (HDR)", + "bitDepth12": "12 位 (HDR)", + "transfer": "传输函数", + "primaries": "原色", + "hdrHint": "HDR 输出需要 HDR 显示器才能正确显示。在普通屏幕上可能会显得发灰。", + "colorProfile": "色彩配置文件", + "colorProfileHint": "选择现成的配置文件,或选择“自定义”以微调每项设置。", + "profiles": { + "sdr": "标准 (SDR,8 位)", + "hdr10": "HDR — 最佳兼容性 (HDR10)", + "hlg": "HDR — 广播 (HLG)", + "maxQuality": "HDR — 最高质量", + "archival": "HDR — 存档 / 精确 (RGB 无损)", + "custom": "自定义…" + }, + "advancedToggle": "高级色彩选项", + "bitDepthHelp": "位数越多,天空和阴影中的渐变越平滑,文件也越大。10 位是 HDR 标准。", + "colorEncoding": "色彩编码", + "colorEncodingYcbcr": "YCbCr (推荐)", + "colorEncodingRgb": "RGB (无损)", + "colorEncodingHelp": "色彩的存储方式。YCbCr (推荐) 更小且随处可播放。RGB 精确且无损,但更大且支持较少。", + "chromaDetail": "色度细节", + "chroma420": "4:2:0 (最小)", + "chroma422": "4:2:2", + "chroma444": "4:4:4 (全彩)", + "chromaDetailHelp": "4:2:0 (推荐) 通过降低色彩分辨率来缩小文件——通常不可见。4:4:4 保留完整的色彩细节 (更大)。", + "signalRange": "信号范围", + "rangeFull": "完整 (推荐)", + "rangeLimited": "受限 (电视/视频)", + "signalRangeHelp": "完整 (推荐用于照片) 使用所有数值。受限与电视和视频一致。", + "colorGamut": "色域", + "gamutSrgb": "sRGB (最小)", + "gamutDisplayP3": "Display-P3", + "gamutBt2020": "Rec.2020 (最广)", + "colorGamutHelp": "色彩范围的宽窄。Rec.2020 (最广) 是 HDR 标准;Display-P3 与现代屏幕匹配;sRGB 最小。", + "referenceWhite": "参考白", + "referenceWhiteHelp": "“纸白”看起来有多亮。203 nits 是 HDR 标准——调高可获得更明亮的效果。(专家)", + "hlgPeakRatio": "HLG 峰值比", + "hlgPeakRatioHelp": "高级:纸白之上的 HLG 余量。", + "masteringMetadata": "母版元数据", + "masteringMetadataHelp": "嵌入部分 HDR 显示器使用的亮度信息 (MaxCLL/MaxFALL)。保持开启是安全的。" }, "labels": { "image": "图像", From f69367f65fa260bb77df5c95f69153f14ac132db Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 12:47:12 +0200 Subject: [PATCH 14/20] Fix 4:2:2 HDR AVIF decode failure on large frames; DRY export config Large 4:2:2 AVIF exports (e.g. 6177x4118) produced a non-conformant AV1 bitstream that strict decoders reject ("Decoding of color planes failed"). rav1e only encodes 4:2:2 conformantly as a single AV1 tile, but AV1's per-tile caps (4096 px wide, 4096*2304 px area) force a multi-tile layout on larger frames, which its 4:2:2 path mis-codes. 4:2:0 and 4:4:4 tile correctly at any size and now round-trip at full resolution. - hdr: bound 4:2:2 AVIF to the single-tile envelope and return a clear error (never a silent chroma downgrade) when a frame exceeds it; expose the limit as AVIF_422_MAX_WIDTH / AVIF_422_MAX_PIXELS. - export: add an avif_422_limits command exposing the 4:2:2 limit (single source of truth) for the UI to derive its resolution cap from. - export panel: rework the HDR control wording for non-experts; selecting 4:2:2 auto-applies the resolution cap (visible and overridable) and warns if it is removed. - DRY: one quality->JXL-distance mapping (SDR output byte-identical); to_hdr_config is a raw projection of the user's settings with all coercion owned by HdrEncodeConfig::sanitized / clamp_anchors; the JXL anchor-clamp duplicate is removed. - tests: large-frame conformance (4:2:0/4:4:4 at 6177x4118), 4:2:2 single-tile round-trip, oversize-errors, and a derivable-cap tripwire. --- HDR_REPORT.md | 11 ++ src-tauri/src/export_processing.rs | 57 +++++--- src-tauri/src/hdr.rs | 58 +++++--- src-tauri/src/lib.rs | 1 + src-tauri/tests/hdr_export.rs | 92 +++++++++++++ src/components/panel/right/ExportPanel.tsx | 149 +++++++++++++++++---- src/i18n/locales/en.json | 77 ++++++----- 7 files changed, 347 insertions(+), 98 deletions(-) diff --git a/HDR_REPORT.md b/HDR_REPORT.md index ba1868814c..152cb404ee 100644 --- a/HDR_REPORT.md +++ b/HDR_REPORT.md @@ -108,6 +108,17 @@ adapter is available (headless CI). files carry CICP tags but not EXIF. 6. **Tiling**: the HDR path reuses the SDR tiling logic (parameterized), exercised on small images in the harness; very large multi-tile HDR images use the same code but weren't size-stressed. +7. **4:2:2 AVIF is single-tile only (resolution-capped).** rav1e encodes AV1 4:2:2 (the "Professional" + profile) conformantly *only as a single AV1 tile* — any multi-tile 4:2:2 frame (even a forced 2x1 + split) produces a bitstream strict decoders (dav1d/libavif) reject with "Decoding of color planes + failed". 4:2:0 and 4:4:4 tile correctly at any size and are verified at 6177x4118. So 4:2:2 AVIF is + bounded to AV1's single-tile envelope: **long edge ≤ 3072 px** (= √(4096·2304), i.e. ≤ 4096 px wide + AND ≤ 9,437,184 px in area for any aspect). Selecting 4:2:2 in the export panel **auto-applies this + resize cap** (visible and overridable in the Resize controls); removing the cap shows a warning, and + an over-size 4:2:2 export **fails loudly** — the backend never silently downgrades the chosen + chroma. Limits live in `hdr::AVIF_422_MAX_WIDTH` / `AVIF_422_MAX_PIXELS`, mirrored in + `ExportPanel.tsx`. Tests: `avif_422_single_tile_roundtrips_oversize_errors`, + `avif_large_frame_is_av1_conformant`. ## Needs a human + real HDR display (out of scope for automated checks) diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 92c30350cb..51ba1a7867 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -126,33 +126,20 @@ impl ExportSettings { if self.bit_depth >= 12 { 12 } else { 10 } } - /// Build a fully-specified HDR encode config from these export settings. Out-of-range anchors - /// (<=0) fall back to the canonical defaults; the identity matrix forces 4:4:4. + /// Map these export settings straight to an HDR encode config — a raw projection of the user's + /// input (the single source of truth). The encoders own all coercion: clamping out-of-range + /// anchors, forcing 4:4:4 for the identity matrix, and validating bit depth all live in + /// `HdrEncodeConfig::sanitized` / `clamp_anchors`, so they are not duplicated here. pub fn to_hdr_config(&self) -> crate::hdr::HdrEncodeConfig { - let reference_white_nits = if self.reference_white_nits > 0.0 { - self.reference_white_nits - } else { - crate::hdr::REFERENCE_WHITE_NITS - }; - let hlg_peak_ratio = if self.hlg_peak_ratio > 0.0 { - self.hlg_peak_ratio - } else { - crate::hdr::HLG_PEAK_RATIO - }; - let subsampling = if self.matrix == crate::hdr::MatrixMode::Identity { - crate::hdr::ChromaSubsampling::Cs444 - } else { - self.chroma_subsampling - }; crate::hdr::HdrEncodeConfig { bit_depth: self.export_bit_depth(), transfer: self.transfer_function, primaries: self.primaries, matrix: self.matrix, - subsampling, + subsampling: self.chroma_subsampling, range: self.range, - reference_white_nits, - hlg_peak_ratio, + reference_white_nits: self.reference_white_nits, + hlg_peak_ratio: self.hlg_peak_ratio, quality: self.jpeg_quality, mastering_metadata: self.mastering_metadata, } @@ -533,8 +520,10 @@ fn encode_image_to_bytes( .map_err(|e| format!("Failed to encode lossless JXL: {}", e))? } } else { - let distance = (100.0 - jpeg_quality as f32) / 10.0; - let distance = distance.max(0.01); + // Single source for the quality->distance mapping (shared with the HDR JXL path). + // The lossy branch only runs for quality <= 99, where (100-q)/10 >= 0.1, so the + // shared 0.1 floor never binds here -- this is byte-identical to the prior formula. + let distance = crate::hdr::quality_to_jxl_distance(jpeg_quality); if has_alpha { let rgba = image.to_rgba8(); @@ -1132,6 +1121,30 @@ pub async fn export_images( Ok(()) } +/// AVIF 4:2:2 single-tile limits, exposed so the frontend can derive its resolution cap from the +/// backend instead of hardcoding the number (single source of truth: `crate::hdr::AVIF_422_*`). +#[derive(Serialize, Debug, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub struct Avif422Limits { + pub max_width: u32, + pub max_pixels: u32, + /// Largest long edge that keeps ANY aspect ratio within both caps: `floor(sqrt(max_pixels))`, + /// also clamped to `max_width`. (Square is the worst case: `long_edge^2` is the area.) + pub max_long_edge: u32, +} + +#[tauri::command] +pub fn avif_422_limits() -> Avif422Limits { + let max_width = crate::hdr::AVIF_422_MAX_WIDTH as u32; + let max_pixels = crate::hdr::AVIF_422_MAX_PIXELS as u32; + let max_long_edge = ((max_pixels as f64).sqrt().floor() as u32).min(max_width); + Avif422Limits { + max_width, + max_pixels, + max_long_edge, + } +} + #[tauri::command] pub fn cancel_export(state: tauri::State) -> Result<(), String> { match state.export_task_handle.lock().unwrap().take() { diff --git a/src-tauri/src/hdr.rs b/src-tauri/src/hdr.rs index 54b0d74802..7501fb5560 100644 --- a/src-tauri/src/hdr.rs +++ b/src-tauri/src/hdr.rs @@ -20,6 +20,12 @@ pub const REFERENCE_WHITE_NITS: f32 = 203.0; /// PQ is normalized so code `1.0` == this peak luminance. pub const PQ_MAX_NITS: f32 = 10000.0; +/// Max frame width for 4:2:2 AVIF: rav1e only encodes 4:2:2 conformantly as a single AV1 tile, +/// and AV1 caps a tile at 4096 px wide. (The frontend mirrors this to auto-cap 4:2:2 exports.) +pub const AVIF_422_MAX_WIDTH: usize = 4096; +/// Max frame area (pixels) for 4:2:2 AVIF: AV1's single-tile area cap, 4096 * 2304. +pub const AVIF_422_MAX_PIXELS: usize = 4096 * 2304; // 9_437_184 + // --- SMPTE ST 2084 (PQ) constants. Pinned; do not modify. --- const PQ_M1: f32 = 0.1593017578125; // 2610/16384 const PQ_M2: f32 = 78.84375; // 2523/4096 * 128 @@ -232,8 +238,20 @@ impl Default for HdrEncodeConfig { } impl HdrEncodeConfig { - /// Sanitize the config: clamp out-of-range anchors to sane defaults and force 4:4:4 for the - /// identity matrix. Returns `Err` for unsupported AVIF bit depths. + /// Clamp out-of-range luminance anchors to their canonical defaults. The single owner of that + /// rule, shared by the AVIF and JXL paths (JXL needs only this; the AVIF [`Self::sanitized`] + /// additionally validates bit depth and forces 4:4:4 for the identity matrix). + pub(crate) fn clamp_anchors(&mut self) { + if self.reference_white_nits <= 0.0 || !self.reference_white_nits.is_finite() { + self.reference_white_nits = REFERENCE_WHITE_NITS; + } + if self.hlg_peak_ratio <= 0.0 || !self.hlg_peak_ratio.is_finite() { + self.hlg_peak_ratio = HLG_PEAK_RATIO; + } + } + + /// Sanitize the config for the AVIF path: clamp out-of-range anchors, force 4:4:4 for the + /// identity matrix, and validate the bit depth. Returns `Err` for unsupported AVIF bit depths. pub fn sanitized(mut self) -> Result { if self.bit_depth != 10 && self.bit_depth != 12 { return Err(format!( @@ -241,12 +259,7 @@ impl HdrEncodeConfig { self.bit_depth )); } - if self.reference_white_nits <= 0.0 || !self.reference_white_nits.is_finite() { - self.reference_white_nits = REFERENCE_WHITE_NITS; - } - if self.hlg_peak_ratio <= 0.0 || !self.hlg_peak_ratio.is_finite() { - self.hlg_peak_ratio = HLG_PEAK_RATIO; - } + self.clamp_anchors(); if self.matrix == MatrixMode::Identity { self.subsampling = ChromaSubsampling::Cs444; } @@ -664,6 +677,26 @@ pub fn encode_avif_hdr( return Err("cannot encode an empty image".into()); } + // 4:2:2 (AV1 "Professional" profile) is fragile in rav1e: it only emits a stream strict + // decoders accept when the frame is a *single AV1 tile*. AV1's per-tile caps (4096 px wide, + // 4096*2304 = 9_437_184 px area) force a multi-tile layout on larger frames, and rav1e's 4:2:2 + // path mis-codes any multi-tile case (verified: even a forced 2x1 split fails to decode at + // some geometries). We do NOT silently downgrade the user's chosen subsampling -- instead we + // fail loudly so the export surfaces the limit (the UI auto-caps 4:2:2 resolution to keep + // frames inside this single-tile envelope, and warns if the cap is overridden). Other formats + // (4:2:0 / 4:4:4) have no such limit. + if cfg.matrix == MatrixMode::Ycbcr && cfg.subsampling == ChromaSubsampling::Cs422 { + let single_tile = w <= AVIF_422_MAX_WIDTH && w.saturating_mul(h) <= AVIF_422_MAX_PIXELS; + if !single_tile { + return Err(format!( + "4:2:2 AVIF supports at most {AVIF_422_MAX_WIDTH} px wide and \ + {AVIF_422_MAX_PIXELS} px total (this frame is {w}x{h} = {} px). \ + Reduce the export resolution or choose 4:2:0 / 4:4:4 chroma.", + w * h + )); + } + } + let planes = build_avif_planes(img, &cfg); let chroma_sampling = match cfg.matrix { @@ -822,14 +855,9 @@ pub fn encode_jxl_hdr( }; let cfg = { - // JXL has no AVIF bit-depth constraint; only sanitize the anchors / peak ratio. + // JXL has no AVIF bit-depth constraint; only the luminance anchors need clamping. let mut c = *cfg; - if c.reference_white_nits <= 0.0 || !c.reference_white_nits.is_finite() { - c.reference_white_nits = REFERENCE_WHITE_NITS; - } - if c.hlg_peak_ratio <= 0.0 || !c.hlg_peak_ratio.is_finite() { - c.hlg_peak_ratio = HLG_PEAK_RATIO; - } + c.clamp_anchors(); c }; if cfg.transfer == TransferFunction::Srgb { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3294b0bf00..447628c3a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2263,6 +2263,7 @@ pub fn run() { export_processing::export_images, export_processing::cancel_export, export_processing::estimate_export_sizes, + export_processing::avif_422_limits, image_processing::calculate_auto_adjustments, mask_generation::generate_mask_overlay, file_management::update_exif_fields, diff --git a/src-tauri/tests/hdr_export.rs b/src-tauri/tests/hdr_export.rs index 3d649c747f..830765cdea 100644 --- a/src-tauri/tests/hdr_export.rs +++ b/src-tauri/tests/hdr_export.rs @@ -550,6 +550,98 @@ fn avif_rejects_bad_bit_depth() { ); } +/// Build a gradient test image of the given size (real residual, like photo content). +fn gradient_image(w: u32, h: u32) -> ImageBuffer, Vec> { + let mut img = ImageBuffer::, Vec>::new(w, h); + for (x, _y, px) in img.enumerate_pixels_mut() { + let v = x as f32 / w as f32; // 0..1 ramp, well inside diffuse white + *px = Rgba([v, v * 0.5, 1.0 - v, 1.0]); + } + img +} + +/// Large 4:2:0 / 4:4:4 frames must produce an AV1-conformant bitstream that a strict decoder +/// (dav1d, via `avifdec`) decodes. AV1 forces a multi-tile layout once a frame exceeds 4096 px +/// wide or 4096*2304 = 9_437_184 px in area; 4:2:0 and 4:4:4 tile correctly, so real full-res +/// exports (e.g. 6177x4118) must round-trip. (4:2:2 is special-cased; see the test below.) +#[test] +fn avif_large_frame_is_av1_conformant() { + // 6177x4118 = 25.4M px: the exact size of a real export that previously failed to decode. + let (w, h) = (6177u32, 4118u32); + let img = gradient_image(w, h); + for sub in [ChromaSubsampling::Cs420, ChromaSubsampling::Cs444] { + let cfg = HdrEncodeConfig { + matrix: MatrixMode::Ycbcr, + subsampling: sub, + ..identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Bt2020) + }; + let avif = hdr::encode_avif_hdr(&img, &cfg).expect("encode large frame"); + // avifdec_to_png16 asserts avifdec exits 0; a non-conformant stream fails here. + let dec = avifdec_to_png16(&avif, "largeframe"); + assert_eq!( + dec.dimensions(), + (w, h), + "decoded dims must match for {sub:?}" + ); + } +} + +/// rav1e only emits conformant 4:2:2 for a *single* AV1 tile (verified: even forced 2x1 splits +/// fail to decode at some geometries). The encoder must therefore (a) round-trip 4:2:2 inside the +/// single-tile envelope and (b) refuse — loudly, not silently downgrade — anything larger, so the +/// UI's resolution cap is the only place that decides the trade-off. No hidden chroma changes. +#[test] +fn avif_422_single_tile_roundtrips_oversize_errors() { + let base = HdrEncodeConfig { + matrix: MatrixMode::Ycbcr, + subsampling: ChromaSubsampling::Cs422, + ..identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Bt2020) + }; + + // Dimensions are derived from the single-source-of-truth constants, not re-typed literals. + let max_w = hdr::AVIF_422_MAX_WIDTH as u32; + let boundary_h = (hdr::AVIF_422_MAX_PIXELS / hdr::AVIF_422_MAX_WIDTH) as u32; // = 2304 + + // (a) at the single-tile boundary (max width, exactly max pixels) 4:2:2 must encode + decode. + let ok = gradient_image(max_w, boundary_h); + let avif = hdr::encode_avif_hdr(&ok, &base).expect("single-tile 4:2:2 must encode"); + let dec = avifdec_to_png16(&avif, "s422ok"); + assert_eq!(dec.dimensions(), (max_w, boundary_h)); + + // (b) one pixel past the area cap, and past the width cap, must error (NOT silently re-chroma). + for (w, h) in [(max_w, boundary_h + 1), (max_w + 1, 16)] { + let big = gradient_image(w, h); + let err = hdr::encode_avif_hdr(&big, &base) + .expect_err("oversize 4:2:2 must error, not silently downgrade"); + assert!( + err.contains("4:2:2"), + "error should explain the 4:2:2 limit, got: {err}" + ); + } +} + +/// The frontend caps 4:2:2 export resolution by long edge; the cap value must be DERIVED from the +/// area/width caps (not a hand-typed mirror). This locks the relationship the `avif_422_limits` +/// command relies on: a long edge of `floor(sqrt(max_pixels))` keeps any aspect ratio within BOTH +/// the area cap and the width cap. Guards against drift if the constants ever change. +#[test] +fn avif_422_long_edge_cap_is_derivable_from_caps() { + let max_pixels = hdr::AVIF_422_MAX_PIXELS; + let max_width = hdr::AVIF_422_MAX_WIDTH; + let long_edge = (max_pixels as f64).sqrt().floor() as usize; + // square is the worst case for area at a given long edge: long_edge^2 must fit the area cap. + assert!( + long_edge * long_edge <= max_pixels, + "long-edge^2 exceeds area cap" + ); + assert!(long_edge <= max_width, "long-edge exceeds width cap"); + // and it should be the LARGEST such value (one more would break the area cap). + assert!( + (long_edge + 1) * (long_edge + 1) > max_pixels, + "cap is not maximal" + ); +} + #[cfg(feature = "hdr_jxl")] mod jxl { use super::*; diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index f9c1d30d1f..7d68cd86a1 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { save, open } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; -import { FileInput, CheckCircle, XCircle, Loader, Ban, ChevronDown, ChevronRight, Settings, X } from 'lucide-react'; +import { FileInput, CheckCircle, XCircle, Loader, Ban, ChevronDown, ChevronRight, Settings, X, Info } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import debounce from 'lodash.debounce'; @@ -521,6 +521,47 @@ export default function ExportPanel({ [setMatrix, setChromaSubsampling], ); + // 4:2:2 AVIF must fit in a single AV1 tile: rav1e can't tile 4:2:2 conformantly, so a larger + // frame would produce a file that decoders reject. Keeping the long edge <= sqrt(4096 * 2304) + // bounds the frame to <= 4096 px wide AND <= 9_437_184 px in area for ANY aspect ratio. Mirrors + // AVIF_422_MAX_WIDTH / AVIF_422_MAX_PIXELS in src-tauri/src/hdr.rs. + const AVIF_422_MAX_LONG_EDGE = 3072; + const resizeKeeps422Safe = + enableResize && resizeMode === 'longEdge' && resizeValue > 0 && resizeValue <= AVIF_422_MAX_LONG_EDGE; + + // Choosing 4:2:2 for AVIF auto-applies the resolution cap — shown right in the Resize controls, + // not hidden in the backend. The user can still change/remove it (a warning then appears, and an + // over-size export fails loudly rather than silently switching chroma). We only step in when the + // current resize wouldn't already keep every image safe, so a tighter existing cap is respected. + const handleChromaChange = useCallback( + (value: string) => { + setChromaSubsampling(value); + if (value === '422' && fileFormat === FileFormats.Avif && !resizeKeeps422Safe) { + setEnableResize(true); + setResizeMode('longEdge'); + setResizeValue(AVIF_422_MAX_LONG_EDGE); + setDontEnlarge(true); + } + }, + [ + fileFormat, + resizeKeeps422Safe, + setChromaSubsampling, + setEnableResize, + setResizeMode, + setResizeValue, + setDontEnlarge, + ], + ); + + // Warn whenever 4:2:2 AVIF is selected but the resize won't guarantee every image stays inside + // the single-tile envelope (cap removed, wrong mode, or value too large). + const is422Avif = fileFormat === FileFormats.Avif && matrix === 'ycbcr' && chromaSubsampling === '422'; + const show422CapWarning = is422Avif && !resizeKeeps422Safe; + // The resolution cap is doing its job: 4:2:2 AVIF with a safe resize. Surface a short note in + // the Resize section so the auto-applied cap reads as an intentional guardrail, not a surprise. + const show422CapNote = is422Avif && resizeKeeps422Safe; + const colorProfileOptions = useMemo( () => [ { label: t('export.file.profiles.sdr'), value: 'sdr' }, @@ -578,12 +619,19 @@ export default function ExportPanel({ const transferOptions = useMemo( () => [ - { label: 'PQ (ST.2084)', value: 'pq' }, - { label: 'HLG', value: 'hlg' }, + { label: 'PQ — for screens & sharing (recommended)', value: 'pq' }, + { label: 'HLG — for TV / broadcast', value: 'hlg' }, ], [], ); + // One-line, plain-language summary of what the chosen Color profile produces. Shown under + // the profile picker so a non-expert understands the trade-off without opening Advanced. + const colorProfileDescription = useMemo( + () => t(`export.file.profileDescriptions.${activeColorProfile}`), + [t, activeColorProfile], + ); + const debouncedEstimateSize = useMemo( () => debounce(async (paths, currentAdj, currentPath, exportSettings, format) => { @@ -860,6 +908,20 @@ export default function ExportPanel({ /> + {/* One-line summary of what the selected profile produces. */} + {colorProfileDescription && ( +
+ + {colorProfileDescription} + +
+ )} + {/* An explicit Advanced toggle so users on a preset can still fine-tune. */} {activeColorProfile !== 'custom' && bitDepth > 8 && ( + + {t('export.file.advancedSectionTitle')} + - - + <> + + + + {show422CapWarning && ( +
+ + + {t('export.file.chroma422CapWarning')} + +
+ )} + )} @@ -972,22 +1051,23 @@ export default function ExportPanel({
- - - {t('export.file.masteringMetadataHelp')} - +
+ + + {t('export.file.masteringMetadataHelp')} + +
)} @@ -1058,6 +1138,19 @@ export default function ExportPanel({ onChange={setDontEnlarge} trackClassName="bg-surface" /> + {show422CapNote && ( +
+ + + {t('export.resize.chroma422CapNote')} + +
+ )} )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a4f0749489..2469e80799 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -733,48 +733,58 @@ "quality": "Quality", "qualityLossless": "Quality (Lossless)", "bitDepth": "Bit Depth", - "bitDepth8": "8-bit (SDR)", + "bitDepth8": "8-bit (Standard / SDR)", "bitDepth10": "10-bit (HDR)", "bitDepth12": "12-bit (HDR)", - "transfer": "Transfer", + "transfer": "Brightness encoding (PQ / HLG)", "primaries": "Primaries", - "hdrHint": "HDR output needs an HDR display to view correctly. On a regular screen it may look washed out.", + "hdrHint": "PQ is the standard for HDR photos and best for screens and most sharing. Pick HLG only if you're delivering for HDR TV or broadcast. Either way, HDR needs an HDR display to look right; on a standard screen it can look washed out.", "colorProfile": "Color profile", - "colorProfileHint": "Pick a ready-made profile, or choose Custom to fine-tune every setting.", + "colorProfileHint": "Start here. Each profile sets everything below for you. Choose Custom only if you want to adjust the details yourself.", "profiles": { - "sdr": "Standard (SDR, 8-bit)", + "sdr": "Standard (SDR) — works everywhere", "hdr10": "HDR — best compatibility (HDR10)", - "hlg": "HDR — broadcast (HLG)", + "hlg": "HDR — for TV / broadcast (HLG)", "maxQuality": "HDR — maximum quality", - "archival": "HDR — archival / exact (RGB lossless)", + "archival": "HDR — archival (exact, lossless color)", "custom": "Custom…" }, - "advancedToggle": "Advanced color options", - "bitDepthHelp": "More bits = smoother gradients in skies and shadows, larger files. 10-bit is the HDR standard.", - "colorEncoding": "Color encoding", - "colorEncodingYcbcr": "YCbCr (recommended)", - "colorEncodingRgb": "RGB (lossless)", - "colorEncodingHelp": "How color is stored. YCbCr (recommended) is smaller and plays everywhere. RGB is exact and lossless but larger and less widely supported.", - "chromaDetail": "Chroma detail", - "chroma420": "4:2:0 (smallest)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (full color)", - "chromaDetailHelp": "4:2:0 (recommended) shrinks files by reducing color resolution — usually invisible. 4:4:4 keeps full color detail (larger).", - "signalRange": "Signal range", + "profileDescriptions": { + "sdr": "Classic photo export. Looks the same on any screen and app — the safe default when you're not specifically making HDR.", + "hdr10": "Recommended HDR. Brighter highlights and richer color, with the widest support across phones, browsers, and social platforms.", + "hlg": "HDR tuned for televisions and broadcast workflows. Choose this only if your delivery target asks for HLG.", + "maxQuality": "Highest-fidelity HDR with full color detail. Larger files; best for editing, printing prep, or archiving your best work.", + "archival": "Stores color exactly with no loss, for long-term masters. Largest files and the least compatible with everyday viewers.", + "custom": "Manual control. Every setting below is yours to adjust — handy when a profile is almost right but not quite." + }, + "advancedToggle": "Show advanced color settings", + "advancedSectionTitle": "Advanced color settings", + "bitDepthHelp": "Color depth. More bits mean smoother gradients in skies and shadows, at the cost of larger files. 10-bit is the recommended HDR standard; 12-bit is for maximum quality.", + "colorEncoding": "Color storage (encoding)", + "colorEncodingYcbcr": "Standard (YCbCr)", + "colorEncodingRgb": "Exact / lossless (RGB)", + "colorEncodingHelp": "How color data is packed. Standard (recommended) gives smaller files that play everywhere. Exact keeps color perfectly intact but makes larger, less widely supported files — use it only for archiving.", + "chromaDetail": "Color detail (chroma)", + "chroma420": "Standard (4:2:0) — smallest", + "chroma422": "Balanced (4:2:2)", + "chroma444": "Full color (4:4:4)", + "chromaDetailHelp": "How much color resolution to keep. Standard (recommended) shrinks files with no visible difference for most photos. Full color preserves every detail in saturated edges and fine text, at a larger size.", + "chroma422CapWarning": "Heads up: Balanced (4:2:2) AVIF is only valid up to 3072 px on the long edge, so we've capped the export size above to keep your file working. You can lift the cap if every image is already smaller — otherwise switch to Standard (4:2:0) or Full color (4:4:4) to export at full resolution.", + "signalRange": "Tone range", "rangeFull": "Full (recommended)", - "rangeLimited": "Limited (TV/video)", - "signalRangeHelp": "Full (recommended for photos) uses every value. Limited matches TV and video.", - "colorGamut": "Color gamut", - "gamutSrgb": "sRGB (smallest)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (widest)", - "colorGamutHelp": "How wide a range of colors. Rec.2020 (widest) is standard for HDR; Display-P3 matches modern screens; sRGB is the smallest.", - "referenceWhite": "Reference white", - "referenceWhiteHelp": "How bright 'paper white' looks. 203 nits is the HDR standard — raise it for a brighter look. (Expert)", - "hlgPeakRatio": "HLG peak ratio", - "hlgPeakRatioHelp": "Advanced: HLG headroom above paper white.", - "masteringMetadata": "Mastering metadata", - "masteringMetadataHelp": "Embed brightness info (MaxCLL/MaxFALL) that some HDR displays use. Safe to leave on." + "rangeLimited": "Limited (TV / video)", + "signalRangeHelp": "Which brightness values are used. Full (recommended for photos) uses the entire range for the most detail. Limited matches older TV and video pipelines.", + "colorGamut": "Color range (gamut)", + "gamutSrgb": "Standard (sRGB) — smallest range", + "gamutDisplayP3": "Wide (Display-P3)", + "gamutBt2020": "Widest (Rec.2020)", + "colorGamutHelp": "How wide a span of colors can be shown. Widest (Rec.2020) is the HDR standard and recommended here; Wide (Display-P3) matches modern phone and laptop screens; Standard (sRGB) is the most compatible but least vivid.", + "referenceWhite": "Paper-white brightness", + "referenceWhiteHelp": "Sets how bright a plain white page appears in HDR. 203 nits is the standard — raise it for an overall brighter look. Leave at the default unless you know you need to change it.", + "hlgPeakRatio": "Highlight headroom (HLG)", + "hlgPeakRatioHelp": "How much brighter HLG highlights can go above paper white. The default suits most footage; leave it unless your delivery spec says otherwise.", + "masteringMetadata": "Embed brightness metadata", + "masteringMetadataHelp": "Tags the file with its brightness levels (MaxCLL / MaxFALL) so HDR displays can tone-map it accurately. Recommended — safe to leave on." }, "labels": { "image": "Image", @@ -795,7 +805,8 @@ "width": "Width" }, "pixels": "pixels", - "resizeToFit": "Resize to Fit" + "resizeToFit": "Resize to Fit", + "chroma422CapNote": "Capped to 3072 px for Balanced (4:2:2) AVIF — this keeps the file valid." }, "sections": { "advanced": "Advanced", From 832f6f5884dca0362e8cb9f33bba8bfc0140db89 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 13:01:47 +0200 Subject: [PATCH 15/20] Wire 4:2:2 cap to backend SSOT; export UI polish; sync HDR translations - frontend: derive the 4:2:2 AVIF resolution cap from the avif_422_limits command instead of hardcoding it, and interpolate the limit into the warning/note ({{maxLongEdge}}). The backend is the single source of truth. - export panel: truncate dropdown trigger labels so they no longer wrap, shorten the color-profile names (the detail lives in the profile descriptions), and raise the 4:2:2 warning text contrast. - i18n: sync the HDR export strings (profile names + descriptions, advanced color controls, and the 4:2:2 cap warning/note) into de/es/fr/it/ja/ko/ pl/pt/ru/zh-CN; the {{maxLongEdge}} placeholder is preserved in each. --- src/components/panel/right/ExportPanel.tsx | 52 ++++++++++---- src/components/ui/Dropdown.tsx | 4 +- src/i18n/locales/de.json | 81 ++++++++++++---------- src/i18n/locales/en.json | 12 ++-- src/i18n/locales/es.json | 81 ++++++++++++---------- src/i18n/locales/fr.json | 79 ++++++++++++--------- src/i18n/locales/it.json | 79 ++++++++++++--------- src/i18n/locales/ja.json | 79 ++++++++++++--------- src/i18n/locales/ko.json | 79 ++++++++++++--------- src/i18n/locales/pl.json | 79 ++++++++++++--------- src/i18n/locales/pt.json | 79 ++++++++++++--------- src/i18n/locales/ru.json | 79 ++++++++++++--------- src/i18n/locales/zh-CN.json | 79 ++++++++++++--------- 13 files changed, 498 insertions(+), 364 deletions(-) diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index 7d68cd86a1..33526ee7d1 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -521,13 +521,31 @@ export default function ExportPanel({ [setMatrix, setChromaSubsampling], ); - // 4:2:2 AVIF must fit in a single AV1 tile: rav1e can't tile 4:2:2 conformantly, so a larger - // frame would produce a file that decoders reject. Keeping the long edge <= sqrt(4096 * 2304) - // bounds the frame to <= 4096 px wide AND <= 9_437_184 px in area for ANY aspect ratio. Mirrors - // AVIF_422_MAX_WIDTH / AVIF_422_MAX_PIXELS in src-tauri/src/hdr.rs. - const AVIF_422_MAX_LONG_EDGE = 3072; + // 4:2:2 AVIF must fit in a single AV1 tile (rav1e can't tile 4:2:2 conformantly), so the long + // edge is capped. The limit is the backend's single source of truth — fetched from the + // `avif_422_limits` command (derived there from AVIF_422_MAX_WIDTH / AVIF_422_MAX_PIXELS), never + // hardcoded here. `null` until it loads. + const [avif422MaxLongEdge, setAvif422MaxLongEdge] = useState(null); + useEffect(() => { + let cancelled = false; + invoke<{ maxLongEdge: number }>('avif_422_limits') + .then((limits) => { + if (!cancelled) setAvif422MaxLongEdge(limits.maxLongEdge); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + // A long-edge resize at/under the cap is the only mode that guarantees every image (any aspect + // ratio) stays within the backend's width AND area limits. const resizeKeeps422Safe = - enableResize && resizeMode === 'longEdge' && resizeValue > 0 && resizeValue <= AVIF_422_MAX_LONG_EDGE; + avif422MaxLongEdge != null && + enableResize && + resizeMode === 'longEdge' && + resizeValue > 0 && + resizeValue <= avif422MaxLongEdge; // Choosing 4:2:2 for AVIF auto-applies the resolution cap — shown right in the Resize controls, // not hidden in the backend. The user can still change/remove it (a warning then appears, and an @@ -536,15 +554,21 @@ export default function ExportPanel({ const handleChromaChange = useCallback( (value: string) => { setChromaSubsampling(value); - if (value === '422' && fileFormat === FileFormats.Avif && !resizeKeeps422Safe) { + if ( + value === '422' && + fileFormat === FileFormats.Avif && + avif422MaxLongEdge != null && + !resizeKeeps422Safe + ) { setEnableResize(true); setResizeMode('longEdge'); - setResizeValue(AVIF_422_MAX_LONG_EDGE); + setResizeValue(avif422MaxLongEdge); setDontEnlarge(true); } }, [ fileFormat, + avif422MaxLongEdge, resizeKeeps422Safe, setChromaSubsampling, setEnableResize, @@ -557,7 +581,7 @@ export default function ExportPanel({ // Warn whenever 4:2:2 AVIF is selected but the resize won't guarantee every image stays inside // the single-tile envelope (cap removed, wrong mode, or value too large). const is422Avif = fileFormat === FileFormats.Avif && matrix === 'ycbcr' && chromaSubsampling === '422'; - const show422CapWarning = is422Avif && !resizeKeeps422Safe; + const show422CapWarning = is422Avif && avif422MaxLongEdge != null && !resizeKeeps422Safe; // The resolution cap is doing its job: 4:2:2 AVIF with a safe resize. Surface a short note in // the Resize section so the auto-applied cap reads as an intentional guardrail, not a surprise. const show422CapNote = is422Avif && resizeKeeps422Safe; @@ -1011,14 +1035,14 @@ export default function ExportPanel({ /> {show422CapWarning && ( -
- +
+ - {t('export.file.chroma422CapWarning')} + {t('export.file.chroma422CapWarning', { maxLongEdge: avif422MaxLongEdge })}
)} @@ -1147,7 +1171,7 @@ export default function ExportPanel({ color={TextColors.secondary} className="leading-snug" > - {t('export.resize.chroma422CapNote')} + {t('export.resize.chroma422CapNote', { maxLongEdge: avif422MaxLongEdge })}
)} diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index c2a554c9ce..c5e2e6636c 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -115,11 +115,11 @@ const Dropdown = ({ onClick={() => setIsOpen(!isOpen)} type="button" > - + {selectedOption ? selectedOption.label : placeholder} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 34e9b45c74..3c5a24f0cf 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -733,48 +733,58 @@ "quality": "Qualität", "qualityLossless": "Qualität (Verlustfrei)", "bitDepth": "Bittiefe", - "bitDepth8": "8-Bit (SDR)", + "bitDepth8": "8-Bit (Standard / SDR)", "bitDepth10": "10-Bit (HDR)", "bitDepth12": "12-Bit (HDR)", - "transfer": "Übertragung", + "transfer": "Helligkeitskodierung (PQ / HLG)", "primaries": "Primärfarben", - "hdrHint": "HDR-Ausgabe benötigt ein HDR-Display zur korrekten Darstellung. Auf einem normalen Bildschirm kann sie ausgewaschen wirken.", + "hdrHint": "PQ ist der Standard für HDR-Fotos und die beste Wahl für Bildschirme und die meisten Veröffentlichungen. HLG nur wählen, wenn du für HDR-Fernseher oder Broadcast lieferst. So oder so braucht HDR ein HDR-Display, um richtig auszusehen; auf einem normalen Bildschirm kann es ausgewaschen wirken.", "colorProfile": "Farbprofil", - "colorProfileHint": "Wähle ein fertiges Profil oder „Benutzerdefiniert“, um jede Einstellung anzupassen.", + "colorProfileHint": "Beginne hier. Jedes Profil stellt alles Folgende automatisch für dich ein. Wähle „Benutzerdefiniert“ nur, wenn du die Details selbst anpassen möchtest.", "profiles": { - "sdr": "Standard (SDR, 8-Bit)", - "hdr10": "HDR – beste Kompatibilität (HDR10)", - "hlg": "HDR – Broadcast (HLG)", - "maxQuality": "HDR – maximale Qualität", - "archival": "HDR – Archiv / exakt (RGB verlustfrei)", + "sdr": "Standard (SDR)", + "hdr10": "HDR10 — empfohlen", + "hlg": "HLG — Broadcast", + "maxQuality": "HDR — maximale Qualität", + "archival": "HDR — Archiv", "custom": "Benutzerdefiniert…" }, - "advancedToggle": "Erweiterte Farboptionen", - "bitDepthHelp": "Mehr Bit = weichere Verläufe in Himmel und Schatten, größere Dateien. 10-Bit ist der HDR-Standard.", - "colorEncoding": "Farbkodierung", - "colorEncodingYcbcr": "YCbCr (empfohlen)", - "colorEncodingRgb": "RGB (verlustfrei)", - "colorEncodingHelp": "Wie Farbe gespeichert wird. YCbCr (empfohlen) ist kleiner und überall abspielbar. RGB ist exakt und verlustfrei, aber größer und weniger verbreitet unterstützt.", - "chromaDetail": "Chroma-Detail", - "chroma420": "4:2:0 (kleinste)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (volle Farbe)", - "chromaDetailHelp": "4:2:0 (empfohlen) verkleinert Dateien durch reduzierte Farbauflösung – meist unsichtbar. 4:4:4 behält das volle Farbdetail (größer).", - "signalRange": "Signalbereich", + "profileDescriptions": { + "sdr": "Klassischer Fotoexport. Sieht auf jedem Bildschirm und in jeder App gleich aus – die sichere Standardwahl, wenn du nicht gezielt HDR erstellst.", + "hdr10": "Empfohlenes HDR. Hellere Lichter und kräftigere Farben, mit der breitesten Unterstützung auf Smartphones, in Browsern und auf sozialen Plattformen.", + "hlg": "HDR, abgestimmt auf Fernseher und Broadcast-Workflows. Nur wählen, wenn dein Lieferziel HLG verlangt.", + "maxQuality": "HDR mit höchster Wiedergabetreue und vollem Farbdetail. Größere Dateien; ideal zum Bearbeiten, zur Druckvorbereitung oder zum Archivieren deiner besten Arbeiten.", + "archival": "Speichert Farbe exakt und ohne Verlust, für langfristige Master. Größte Dateien und am wenigsten kompatibel mit alltäglichen Betrachtern.", + "custom": "Manuelle Kontrolle. Jede Einstellung unten kannst du selbst anpassen – praktisch, wenn ein Profil fast, aber nicht ganz passt." + }, + "advancedToggle": "Erweiterte Farbeinstellungen anzeigen", + "advancedSectionTitle": "Erweiterte Farbeinstellungen", + "bitDepthHelp": "Farbtiefe. Mehr Bit bedeuten weichere Verläufe in Himmel und Schatten, allerdings größere Dateien. 10-Bit ist der empfohlene HDR-Standard; 12-Bit ist für maximale Qualität.", + "colorEncoding": "Farbspeicherung (Kodierung)", + "colorEncodingYcbcr": "Standard (YCbCr)", + "colorEncodingRgb": "Exakt / verlustfrei (RGB)", + "colorEncodingHelp": "Wie die Farbdaten gepackt werden. Standard (empfohlen) liefert kleinere Dateien, die überall laufen. Exakt erhält die Farbe perfekt, erzeugt aber größere, weniger verbreitet unterstützte Dateien – nutze es nur zum Archivieren.", + "chromaDetail": "Farbdetail (Chroma)", + "chroma420": "Standard (4:2:0) — kleinste", + "chroma422": "Ausgewogen (4:2:2)", + "chroma444": "Volle Farbe (4:4:4)", + "chromaDetailHelp": "Wie viel Farbauflösung erhalten bleibt. Standard (empfohlen) verkleinert Dateien ohne sichtbaren Unterschied bei den meisten Fotos. Volle Farbe bewahrt jedes Detail in gesättigten Kanten und feinem Text, bei größerer Dateigröße.", + "chroma422CapWarning": "Hinweis: Ausgewogenes (4:2:2) AVIF ist nur bis {{maxLongEdge}} px an der langen Kante gültig, daher haben wir die Exportgröße oben begrenzt, damit deine Datei funktioniert. Du kannst die Begrenzung aufheben, wenn jedes Bild bereits kleiner ist – andernfalls wechsle zu Standard (4:2:0) oder Volle Farbe (4:4:4), um in voller Auflösung zu exportieren.", + "signalRange": "Tonbereich", "rangeFull": "Voll (empfohlen)", - "rangeLimited": "Begrenzt (TV/Video)", - "signalRangeHelp": "Voll (empfohlen für Fotos) nutzt jeden Wert. Begrenzt entspricht TV und Video.", - "colorGamut": "Farbraum", - "gamutSrgb": "sRGB (kleinster)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (weitester)", - "colorGamutHelp": "Wie breit der Farbbereich ist. Rec.2020 (weitester) ist Standard für HDR; Display-P3 entspricht modernen Bildschirmen; sRGB ist der kleinste.", - "referenceWhite": "Referenzweiß", - "referenceWhiteHelp": "Wie hell „Papierweiß“ erscheint. 203 nits ist der HDR-Standard – erhöhe es für ein helleres Aussehen. (Experte)", - "hlgPeakRatio": "HLG-Spitzenverhältnis", - "hlgPeakRatioHelp": "Erweitert: HLG-Spielraum über Papierweiß.", - "masteringMetadata": "Mastering-Metadaten", - "masteringMetadataHelp": "Bettet Helligkeitsinfos (MaxCLL/MaxFALL) ein, die einige HDR-Displays nutzen. Kann bedenkenlos aktiviert bleiben." + "rangeLimited": "Begrenzt (TV / Video)", + "signalRangeHelp": "Welche Helligkeitswerte verwendet werden. Voll (empfohlen für Fotos) nutzt den gesamten Bereich für maximale Detailtiefe. Begrenzt entspricht älteren TV- und Video-Pipelines.", + "colorGamut": "Farbbereich (Gamut)", + "gamutSrgb": "Standard (sRGB) — kleinster Bereich", + "gamutDisplayP3": "Weit (Display-P3)", + "gamutBt2020": "Weitester (Rec.2020)", + "colorGamutHelp": "Wie breit die Spanne der darstellbaren Farben ist. Weitester (Rec.2020) ist der HDR-Standard und hier empfohlen; Weit (Display-P3) entspricht modernen Smartphone- und Laptop-Bildschirmen; Standard (sRGB) ist am kompatibelsten, aber am wenigsten kräftig.", + "referenceWhite": "Papierweiß-Helligkeit", + "referenceWhiteHelp": "Legt fest, wie hell eine schlichte weiße Seite in HDR erscheint. 203 nits ist der Standard – erhöhe es für ein insgesamt helleres Aussehen. Belasse den Standardwert, sofern du keine Änderung benötigst.", + "hlgPeakRatio": "Lichter-Spielraum (HLG)", + "hlgPeakRatioHelp": "Wie viel heller HLG-Lichter über das Papierweiß hinausgehen können. Der Standard passt für das meiste Material; belasse ihn, sofern deine Lieferspezifikation nichts anderes vorgibt.", + "masteringMetadata": "Helligkeits-Metadaten einbetten", + "masteringMetadataHelp": "Versieht die Datei mit ihren Helligkeitswerten (MaxCLL / MaxFALL), damit HDR-Displays sie genau tonemappen können. Empfohlen – kann bedenkenlos aktiviert bleiben." }, "labels": { "image": "Bild", @@ -795,7 +805,8 @@ "width": "Breite" }, "pixels": "Pixel", - "resizeToFit": "In Rahmen einpassen" + "resizeToFit": "In Rahmen einpassen", + "chroma422CapNote": "Wegen Ausgewogenem (4:2:2) AVIF auf {{maxLongEdge}} px begrenzt – das hält die Datei gültig." }, "sections": { "advanced": "Erweitert", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2469e80799..5b17a87218 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -742,11 +742,11 @@ "colorProfile": "Color profile", "colorProfileHint": "Start here. Each profile sets everything below for you. Choose Custom only if you want to adjust the details yourself.", "profiles": { - "sdr": "Standard (SDR) — works everywhere", - "hdr10": "HDR — best compatibility (HDR10)", - "hlg": "HDR — for TV / broadcast (HLG)", + "sdr": "Standard (SDR)", + "hdr10": "HDR10 — recommended", + "hlg": "HLG — broadcast", "maxQuality": "HDR — maximum quality", - "archival": "HDR — archival (exact, lossless color)", + "archival": "HDR — archival", "custom": "Custom…" }, "profileDescriptions": { @@ -769,7 +769,7 @@ "chroma422": "Balanced (4:2:2)", "chroma444": "Full color (4:4:4)", "chromaDetailHelp": "How much color resolution to keep. Standard (recommended) shrinks files with no visible difference for most photos. Full color preserves every detail in saturated edges and fine text, at a larger size.", - "chroma422CapWarning": "Heads up: Balanced (4:2:2) AVIF is only valid up to 3072 px on the long edge, so we've capped the export size above to keep your file working. You can lift the cap if every image is already smaller — otherwise switch to Standard (4:2:0) or Full color (4:4:4) to export at full resolution.", + "chroma422CapWarning": "Heads up: Balanced (4:2:2) AVIF is only valid up to {{maxLongEdge}} px on the long edge, so we've capped the export size above to keep your file working. You can lift the cap if every image is already smaller — otherwise switch to Standard (4:2:0) or Full color (4:4:4) to export at full resolution.", "signalRange": "Tone range", "rangeFull": "Full (recommended)", "rangeLimited": "Limited (TV / video)", @@ -806,7 +806,7 @@ }, "pixels": "pixels", "resizeToFit": "Resize to Fit", - "chroma422CapNote": "Capped to 3072 px for Balanced (4:2:2) AVIF — this keeps the file valid." + "chroma422CapNote": "Capped to {{maxLongEdge}} px for Balanced (4:2:2) AVIF — this keeps the file valid." }, "sections": { "advanced": "Advanced", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 97a2ca8920..97ad96f8e4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -733,48 +733,58 @@ "quality": "Calidad", "qualityLossless": "Calidad (Sin pérdida)", "bitDepth": "Profundidad de bits", - "bitDepth8": "8 bits (SDR)", + "bitDepth8": "8 bits (estándar / SDR)", "bitDepth10": "10 bits (HDR)", "bitDepth12": "12 bits (HDR)", - "transfer": "Transferencia", + "transfer": "Codificación de brillo (PQ / HLG)", "primaries": "Primarios", - "hdrHint": "La salida HDR necesita una pantalla HDR para verse correctamente. En una pantalla normal puede verse desvaída.", + "hdrHint": "PQ es el estándar para las fotos HDR y la mejor opción para pantallas y para la mayoría de las publicaciones. Elige HLG solo si vas a entregar para televisión HDR o difusión. En cualquier caso, el HDR necesita una pantalla HDR para verse bien; en una pantalla normal puede verse desvaído.", "colorProfile": "Perfil de color", - "colorProfileHint": "Elige un perfil predefinido o Personalizado para ajustar cada opción.", + "colorProfileHint": "Empieza aquí. Cada perfil configura por ti todo lo de abajo. Elige Personalizado solo si quieres ajustar los detalles tú mismo.", "profiles": { - "sdr": "Estándar (SDR, 8 bits)", - "hdr10": "HDR: mejor compatibilidad (HDR10)", - "hlg": "HDR: difusión (HLG)", - "maxQuality": "HDR: máxima calidad", - "archival": "HDR: archivo / exacto (RGB sin pérdida)", + "sdr": "Estándar (SDR)", + "hdr10": "HDR10 — recomendado", + "hlg": "HLG — difusión", + "maxQuality": "HDR — máxima calidad", + "archival": "HDR — archivo", "custom": "Personalizado…" }, - "advancedToggle": "Opciones de color avanzadas", - "bitDepthHelp": "Más bits = degradados más suaves en cielos y sombras, archivos más grandes. 10 bits es el estándar HDR.", - "colorEncoding": "Codificación de color", - "colorEncodingYcbcr": "YCbCr (recomendado)", - "colorEncodingRgb": "RGB (sin pérdida)", - "colorEncodingHelp": "Cómo se almacena el color. YCbCr (recomendado) es más pequeño y se reproduce en todas partes. RGB es exacto y sin pérdida, pero más grande y con menos compatibilidad.", - "chromaDetail": "Detalle de croma", - "chroma420": "4:2:0 (el más pequeño)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (color completo)", - "chromaDetailHelp": "4:2:0 (recomendado) reduce el tamaño de los archivos bajando la resolución de color, normalmente imperceptible. 4:4:4 conserva todo el detalle de color (más grande).", - "signalRange": "Rango de señal", + "profileDescriptions": { + "sdr": "Exportación de foto clásica. Se ve igual en cualquier pantalla y aplicación: la opción segura por defecto cuando no estás creando HDR de forma específica.", + "hdr10": "HDR recomendado. Luces más brillantes y color más rico, con la mayor compatibilidad en teléfonos, navegadores y plataformas sociales.", + "hlg": "HDR ajustado para televisores y flujos de trabajo de difusión. Elígelo solo si tu destino de entrega pide HLG.", + "maxQuality": "HDR de máxima fidelidad con todo el detalle de color. Archivos más grandes; ideal para edición, preparación de impresión o archivar tu mejor trabajo.", + "archival": "Almacena el color de forma exacta y sin pérdida, para másteres a largo plazo. Los archivos más grandes y los menos compatibles con los visores habituales.", + "custom": "Control manual. Cada ajuste de abajo es tuyo para modificarlo: práctico cuando un perfil casi encaja, pero no del todo." + }, + "advancedToggle": "Mostrar ajustes de color avanzados", + "advancedSectionTitle": "Ajustes de color avanzados", + "bitDepthHelp": "Profundidad de color. Más bits significan degradados más suaves en cielos y sombras, a costa de archivos más grandes. 10 bits es el estándar HDR recomendado; 12 bits es para máxima calidad.", + "colorEncoding": "Almacenamiento de color (codificación)", + "colorEncodingYcbcr": "Estándar (YCbCr)", + "colorEncodingRgb": "Exacto / sin pérdida (RGB)", + "colorEncodingHelp": "Cómo se empaquetan los datos de color. Estándar (recomendado) da archivos más pequeños que se reproducen en todas partes. Exacto mantiene el color perfectamente intacto, pero genera archivos más grandes y con menos compatibilidad: úsalo solo para archivar.", + "chromaDetail": "Detalle de color (croma)", + "chroma420": "Estándar (4:2:0) — el más pequeño", + "chroma422": "Equilibrado (4:2:2)", + "chroma444": "Color completo (4:4:4)", + "chromaDetailHelp": "Cuánta resolución de color conservar. Estándar (recomendado) reduce los archivos sin diferencia visible en la mayoría de las fotos. Color completo conserva cada detalle en los bordes saturados y el texto fino, con un tamaño mayor.", + "chroma422CapWarning": "Atención: el AVIF Equilibrado (4:2:2) solo es válido hasta {{maxLongEdge}} px en el lado largo, por lo que hemos limitado el tamaño de exportación de arriba para que tu archivo siga funcionando. Puedes quitar el límite si todas las imágenes ya son más pequeñas; de lo contrario, cambia a Estándar (4:2:0) o Color completo (4:4:4) para exportar a resolución completa.", + "signalRange": "Rango tonal", "rangeFull": "Completo (recomendado)", - "rangeLimited": "Limitado (TV/vídeo)", - "signalRangeHelp": "Completo (recomendado para fotos) usa todos los valores. Limitado coincide con TV y vídeo.", - "colorGamut": "Gama de color", - "gamutSrgb": "sRGB (la más pequeña)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (la más amplia)", - "colorGamutHelp": "Cuán amplia es la gama de colores. Rec.2020 (la más amplia) es el estándar para HDR; Display-P3 coincide con las pantallas modernas; sRGB es la más pequeña.", - "referenceWhite": "Blanco de referencia", - "referenceWhiteHelp": "Cuánto brilla el «blanco papel». 203 nits es el estándar HDR; súbelo para un aspecto más brillante. (Experto)", - "hlgPeakRatio": "Relación de pico HLG", - "hlgPeakRatioHelp": "Avanzado: margen de HLG por encima del blanco papel.", - "masteringMetadata": "Metadatos de masterización", - "masteringMetadataHelp": "Incrusta información de brillo (MaxCLL/MaxFALL) que usan algunas pantallas HDR. Es seguro dejarlo activado." + "rangeLimited": "Limitado (TV / vídeo)", + "signalRangeHelp": "Qué valores de brillo se usan. Completo (recomendado para fotos) usa todo el rango para el máximo detalle. Limitado coincide con los flujos antiguos de TV y vídeo.", + "colorGamut": "Rango de color (gama)", + "gamutSrgb": "Estándar (sRGB) — el rango más pequeño", + "gamutDisplayP3": "Amplio (Display-P3)", + "gamutBt2020": "El más amplio (Rec.2020)", + "colorGamutHelp": "Cuán amplio es el conjunto de colores que se pueden mostrar. El más amplio (Rec.2020) es el estándar HDR y el recomendado aquí; Amplio (Display-P3) coincide con las pantallas modernas de teléfonos y portátiles; Estándar (sRGB) es el más compatible pero el menos vívido.", + "referenceWhite": "Brillo del blanco papel", + "referenceWhiteHelp": "Define cuán brillante aparece una página blanca lisa en HDR. 203 nits es el estándar; súbelo para un aspecto general más brillante. Déjalo en el valor por defecto a menos que sepas que necesitas cambiarlo.", + "hlgPeakRatio": "Margen de altas luces (HLG)", + "hlgPeakRatioHelp": "Cuánto más brillantes pueden ir las altas luces HLG por encima del blanco papel. El valor por defecto sirve para la mayoría del material; déjalo salvo que tu especificación de entrega indique lo contrario.", + "masteringMetadata": "Incrustar metadatos de brillo", + "masteringMetadataHelp": "Etiqueta el archivo con sus niveles de brillo (MaxCLL / MaxFALL) para que las pantallas HDR puedan aplicar tone-mapping con precisión. Recomendado: es seguro dejarlo activado." }, "labels": { "image": "Imagen", @@ -795,7 +805,8 @@ "width": "Anchura" }, "pixels": "píxeles", - "resizeToFit": "Redimensionar para encajar" + "resizeToFit": "Redimensionar para encajar", + "chroma422CapNote": "Limitado a {{maxLongEdge}} px para el AVIF Equilibrado (4:2:2): así el archivo sigue siendo válido." }, "sections": { "advanced": "Avanzado", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index d136a67ac8..0c7b86fc88 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -733,48 +733,58 @@ "quality": "Qualité", "qualityLossless": "Qualité (Sans perte)", "bitDepth": "Profondeur de bits", - "bitDepth8": "8 bits (SDR)", + "bitDepth8": "8 bits (standard / SDR)", "bitDepth10": "10 bits (HDR)", "bitDepth12": "12 bits (HDR)", - "transfer": "Transfert", + "transfer": "Encodage de luminosité (PQ / HLG)", "primaries": "Primaires", - "hdrHint": "La sortie HDR nécessite un écran HDR pour un affichage correct. Sur un écran ordinaire, elle peut paraître délavée.", + "hdrHint": "PQ est la norme pour les photos HDR et le meilleur choix pour les écrans et la plupart des partages. Ne choisissez HLG que si vous livrez pour la TV HDR ou la diffusion. Dans tous les cas, le HDR nécessite un écran HDR pour s’afficher correctement ; sur un écran ordinaire, il peut paraître délavé.", "colorProfile": "Profil colorimétrique", - "colorProfileHint": "Choisissez un profil prédéfini, ou Personnalisé pour ajuster chaque réglage.", + "colorProfileHint": "Commencez ici. Chaque profil règle pour vous tout ce qui suit. Ne choisissez Personnalisé que si vous voulez ajuster les détails vous-même.", "profiles": { - "sdr": "Standard (SDR, 8 bits)", - "hdr10": "HDR — meilleure compatibilité (HDR10)", - "hlg": "HDR — diffusion (HLG)", + "sdr": "Standard (SDR)", + "hdr10": "HDR10 — recommandé", + "hlg": "HLG — diffusion", "maxQuality": "HDR — qualité maximale", - "archival": "HDR — archivage / exact (RGB sans perte)", + "archival": "HDR — archivage", "custom": "Personnalisé…" }, - "advancedToggle": "Options colorimétriques avancées", - "bitDepthHelp": "Plus de bits = dégradés plus doux dans les ciels et les ombres, fichiers plus volumineux. Le 10 bits est la norme HDR.", - "colorEncoding": "Encodage des couleurs", - "colorEncodingYcbcr": "YCbCr (recommandé)", - "colorEncodingRgb": "RGB (sans perte)", - "colorEncodingHelp": "Comment la couleur est stockée. YCbCr (recommandé) est plus léger et lisible partout. RGB est exact et sans perte mais plus volumineux et moins largement pris en charge.", - "chromaDetail": "Détail de chrominance", - "chroma420": "4:2:0 (le plus léger)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (couleur complète)", - "chromaDetailHelp": "4:2:0 (recommandé) réduit la taille des fichiers en abaissant la résolution des couleurs — généralement invisible. 4:4:4 conserve tout le détail des couleurs (plus volumineux).", - "signalRange": "Plage du signal", + "profileDescriptions": { + "sdr": "Export photo classique. Rendu identique sur tout écran et toute application — le choix sûr par défaut quand vous ne faites pas spécifiquement du HDR.", + "hdr10": "HDR recommandé. Hautes lumières plus vives et couleurs plus riches, avec la plus large compatibilité sur téléphones, navigateurs et réseaux sociaux.", + "hlg": "HDR optimisé pour les téléviseurs et les workflows de diffusion. À choisir uniquement si votre cible de livraison demande du HLG.", + "maxQuality": "HDR de la plus haute fidélité avec tout le détail des couleurs. Fichiers plus volumineux ; idéal pour l’édition, la préparation à l’impression ou l’archivage de vos meilleures œuvres.", + "archival": "Stocke la couleur exactement, sans perte, pour des masters à long terme. Fichiers les plus volumineux et les moins compatibles avec les lecteurs du quotidien.", + "custom": "Contrôle manuel. Chaque réglage ci-dessous est à vous d’ajuster — pratique lorsqu’un profil convient presque, mais pas tout à fait." + }, + "advancedToggle": "Afficher les réglages colorimétriques avancés", + "advancedSectionTitle": "Réglages colorimétriques avancés", + "bitDepthHelp": "Profondeur de couleur. Plus de bits = dégradés plus doux dans les ciels et les ombres, au prix de fichiers plus volumineux. Le 10 bits est la norme HDR recommandée ; le 12 bits vise la qualité maximale.", + "colorEncoding": "Stockage des couleurs (encodage)", + "colorEncodingYcbcr": "Standard (YCbCr)", + "colorEncodingRgb": "Exact / sans perte (RGB)", + "colorEncodingHelp": "Comment les données de couleur sont compactées. Standard (recommandé) donne des fichiers plus légers qui se lisent partout. Exact conserve la couleur parfaitement intacte mais produit des fichiers plus volumineux et moins largement pris en charge — à n’utiliser que pour l’archivage.", + "chromaDetail": "Détail des couleurs (chrominance)", + "chroma420": "Standard (4:2:0) — le plus léger", + "chroma422": "Équilibré (4:2:2)", + "chroma444": "Couleur complète (4:4:4)", + "chromaDetailHelp": "Quelle résolution de couleur conserver. Standard (recommandé) réduit la taille des fichiers sans différence visible pour la plupart des photos. Couleur complète préserve chaque détail des contours saturés et du texte fin, pour une taille plus grande.", + "chroma422CapWarning": "À noter : l’AVIF Équilibré (4:2:2) n’est valide que jusqu’à {{maxLongEdge}} px sur le grand côté, c’est pourquoi nous avons plafonné la taille d’export ci-dessus pour que votre fichier reste exploitable. Vous pouvez retirer le plafond si chaque image est déjà plus petite — sinon, passez à Standard (4:2:0) ou Couleur complète (4:4:4) pour exporter en pleine résolution.", + "signalRange": "Plage tonale", "rangeFull": "Complète (recommandée)", - "rangeLimited": "Limitée (TV/vidéo)", - "signalRangeHelp": "Complète (recommandée pour les photos) utilise toutes les valeurs. Limitée correspond à la TV et à la vidéo.", - "colorGamut": "Gamme de couleurs", - "gamutSrgb": "sRGB (la plus petite)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (la plus large)", - "colorGamutHelp": "L’étendue de la gamme de couleurs. Rec.2020 (la plus large) est la norme pour le HDR ; Display-P3 correspond aux écrans modernes ; sRGB est la plus petite.", - "referenceWhite": "Blanc de référence", - "referenceWhiteHelp": "La luminosité du « blanc papier ». 203 nits est la norme HDR — augmentez-le pour un rendu plus lumineux. (Expert)", - "hlgPeakRatio": "Ratio de crête HLG", - "hlgPeakRatioHelp": "Avancé : marge HLG au-dessus du blanc papier.", - "masteringMetadata": "Métadonnées de mastering", - "masteringMetadataHelp": "Intègre les infos de luminosité (MaxCLL/MaxFALL) utilisées par certains écrans HDR. Sans risque à laisser activé." + "rangeLimited": "Limitée (TV / vidéo)", + "signalRangeHelp": "Quelles valeurs de luminosité sont utilisées. Complète (recommandée pour les photos) utilise toute la plage pour le maximum de détail. Limitée correspond aux anciens pipelines TV et vidéo.", + "colorGamut": "Plage de couleurs (gamme)", + "gamutSrgb": "Standard (sRGB) — plage la plus petite", + "gamutDisplayP3": "Large (Display-P3)", + "gamutBt2020": "La plus large (Rec.2020)", + "colorGamutHelp": "L’étendue des couleurs pouvant être affichées. La plus large (Rec.2020) est la norme HDR et recommandée ici ; Large (Display-P3) correspond aux écrans modernes de téléphones et d’ordinateurs portables ; Standard (sRGB) est la plus compatible mais la moins éclatante.", + "referenceWhite": "Luminosité du blanc papier", + "referenceWhiteHelp": "Définit la luminosité d’une page blanche unie en HDR. 203 nits est la norme — augmentez-la pour un rendu globalement plus lumineux. Laissez la valeur par défaut sauf si vous savez devoir la modifier.", + "hlgPeakRatio": "Marge des hautes lumières (HLG)", + "hlgPeakRatioHelp": "De combien les hautes lumières HLG peuvent dépasser le blanc papier. La valeur par défaut convient à la plupart des contenus ; laissez-la sauf indication contraire de votre cahier des charges.", + "masteringMetadata": "Intégrer les métadonnées de luminosité", + "masteringMetadataHelp": "Étiquette le fichier avec ses niveaux de luminosité (MaxCLL / MaxFALL) afin que les écrans HDR puissent le tone-mapper avec précision. Recommandé — sans risque à laisser activé." }, "labels": { "image": "Image", @@ -795,7 +805,8 @@ "width": "Largeur" }, "pixels": "pixels", - "resizeToFit": "Redimensionner pour ajuster" + "resizeToFit": "Redimensionner pour ajuster", + "chroma422CapNote": "Plafonné à {{maxLongEdge}} px pour l’AVIF Équilibré (4:2:2) — cela garde le fichier valide." }, "sections": { "advanced": "Avancé", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6387b8cd4b..b3c3bad469 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -733,48 +733,58 @@ "quality": "Qualità", "qualityLossless": "Qualità (Senza Perdita)", "bitDepth": "Profondità di bit", - "bitDepth8": "8 bit (SDR)", + "bitDepth8": "8 bit (standard / SDR)", "bitDepth10": "10 bit (HDR)", "bitDepth12": "12 bit (HDR)", - "transfer": "Trasferimento", + "transfer": "Codifica della luminosità (PQ / HLG)", "primaries": "Primari", - "hdrHint": "L’output HDR richiede un display HDR per essere visualizzato correttamente. Su uno schermo normale può apparire slavato.", + "hdrHint": "PQ è lo standard per le foto HDR e la scelta migliore per gli schermi e per la maggior parte delle condivisioni. Scegli HLG solo se devi consegnare per TV HDR o broadcast. In ogni caso, l’HDR richiede un display HDR per essere visualizzato correttamente; su uno schermo normale può apparire slavato.", "colorProfile": "Profilo colore", - "colorProfileHint": "Scegli un profilo predefinito oppure Personalizzato per regolare ogni impostazione.", + "colorProfileHint": "Inizia da qui. Ogni profilo imposta automaticamente tutto ciò che segue. Scegli Personalizzato solo se vuoi regolare i dettagli da solo.", "profiles": { - "sdr": "Standard (SDR, 8 bit)", - "hdr10": "HDR — massima compatibilità (HDR10)", - "hlg": "HDR — broadcast (HLG)", + "sdr": "Standard (SDR)", + "hdr10": "HDR10 — consigliato", + "hlg": "HLG — broadcast", "maxQuality": "HDR — qualità massima", - "archival": "HDR — archiviazione / esatto (RGB senza perdita)", + "archival": "HDR — archiviazione", "custom": "Personalizzato…" }, - "advancedToggle": "Opzioni colore avanzate", - "bitDepthHelp": "Più bit = sfumature più morbide in cieli e ombre, file più grandi. 10 bit è lo standard HDR.", - "colorEncoding": "Codifica colore", - "colorEncodingYcbcr": "YCbCr (consigliato)", - "colorEncodingRgb": "RGB (senza perdita)", - "colorEncodingHelp": "Come viene memorizzato il colore. YCbCr (consigliato) è più leggero e riproducibile ovunque. RGB è esatto e senza perdita ma più grande e meno supportato.", - "chromaDetail": "Dettaglio cromatico", - "chroma420": "4:2:0 (più piccolo)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (colore completo)", - "chromaDetailHelp": "4:2:0 (consigliato) riduce le dimensioni dei file abbassando la risoluzione del colore — di solito invisibile. 4:4:4 mantiene tutto il dettaglio del colore (più grande).", - "signalRange": "Gamma del segnale", + "profileDescriptions": { + "sdr": "Esportazione foto classica. Appare identica su qualsiasi schermo e app — la scelta sicura predefinita quando non stai creando appositamente in HDR.", + "hdr10": "HDR consigliato. Alte luci più brillanti e colori più ricchi, con la più ampia compatibilità su telefoni, browser e piattaforme social.", + "hlg": "HDR ottimizzato per televisori e flussi di lavoro broadcast. Scegli questo solo se la destinazione di consegna richiede HLG.", + "maxQuality": "HDR della massima fedeltà con tutto il dettaglio del colore. File più grandi; ideale per editing, preparazione alla stampa o archiviazione dei tuoi lavori migliori.", + "archival": "Memorizza il colore in modo esatto e senza perdita, per master a lungo termine. I file più grandi e i meno compatibili con i visualizzatori di tutti i giorni.", + "custom": "Controllo manuale. Ogni impostazione qui sotto è tua da regolare — comodo quando un profilo va quasi bene, ma non del tutto." + }, + "advancedToggle": "Mostra impostazioni colore avanzate", + "advancedSectionTitle": "Impostazioni colore avanzate", + "bitDepthHelp": "Profondità di colore. Più bit significano sfumature più morbide in cieli e ombre, al costo di file più grandi. 10 bit è lo standard HDR consigliato; 12 bit è per la qualità massima.", + "colorEncoding": "Memorizzazione del colore (codifica)", + "colorEncodingYcbcr": "Standard (YCbCr)", + "colorEncodingRgb": "Esatto / senza perdita (RGB)", + "colorEncodingHelp": "Come vengono impacchettati i dati del colore. Standard (consigliato) produce file più piccoli riproducibili ovunque. Esatto mantiene il colore perfettamente intatto ma crea file più grandi e meno supportati — usalo solo per l’archiviazione.", + "chromaDetail": "Dettaglio del colore (croma)", + "chroma420": "Standard (4:2:0) — il più piccolo", + "chroma422": "Bilanciato (4:2:2)", + "chroma444": "Colore completo (4:4:4)", + "chromaDetailHelp": "Quanta risoluzione del colore mantenere. Standard (consigliato) riduce i file senza differenze visibili per la maggior parte delle foto. Colore completo preserva ogni dettaglio nei bordi saturi e nel testo fine, con dimensioni maggiori.", + "chroma422CapWarning": "Attenzione: l’AVIF Bilanciato (4:2:2) è valido solo fino a {{maxLongEdge}} px sul lato lungo, perciò abbiamo limitato la dimensione di esportazione qui sopra per mantenere il file funzionante. Puoi rimuovere il limite se ogni immagine è già più piccola — altrimenti passa a Standard (4:2:0) o Colore completo (4:4:4) per esportare alla risoluzione piena.", + "signalRange": "Gamma tonale", "rangeFull": "Completa (consigliata)", - "rangeLimited": "Limitata (TV/video)", - "signalRangeHelp": "Completa (consigliata per le foto) usa ogni valore. Limitata corrisponde a TV e video.", - "colorGamut": "Gamut cromatico", - "gamutSrgb": "sRGB (il più piccolo)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (il più ampio)", - "colorGamutHelp": "Quanto è ampia la gamma di colori. Rec.2020 (il più ampio) è lo standard per l’HDR; Display-P3 corrisponde agli schermi moderni; sRGB è il più piccolo.", - "referenceWhite": "Bianco di riferimento", - "referenceWhiteHelp": "Quanto appare luminoso il «bianco carta». 203 nits è lo standard HDR — aumentalo per un aspetto più luminoso. (Esperto)", - "hlgPeakRatio": "Rapporto di picco HLG", - "hlgPeakRatioHelp": "Avanzato: margine HLG sopra il bianco carta.", - "masteringMetadata": "Metadati di mastering", - "masteringMetadataHelp": "Incorpora le informazioni di luminosità (MaxCLL/MaxFALL) usate da alcuni display HDR. Si può lasciare attivo senza problemi." + "rangeLimited": "Limitata (TV / video)", + "signalRangeHelp": "Quali valori di luminosità vengono usati. Completa (consigliata per le foto) usa l’intera gamma per il massimo dettaglio. Limitata corrisponde alle vecchie pipeline TV e video.", + "colorGamut": "Gamma di colori (gamut)", + "gamutSrgb": "Standard (sRGB) — gamma più piccola", + "gamutDisplayP3": "Ampio (Display-P3)", + "gamutBt2020": "Il più ampio (Rec.2020)", + "colorGamutHelp": "Quanto è ampio l’insieme di colori che si possono mostrare. Il più ampio (Rec.2020) è lo standard HDR e quello consigliato qui; Ampio (Display-P3) corrisponde agli schermi moderni di telefoni e laptop; Standard (sRGB) è il più compatibile ma il meno vivido.", + "referenceWhite": "Luminosità del bianco carta", + "referenceWhiteHelp": "Imposta quanto appare luminosa una pagina bianca semplice in HDR. 203 nits è lo standard — aumentalo per un aspetto complessivamente più luminoso. Lascia il valore predefinito a meno che tu non sappia di doverlo cambiare.", + "hlgPeakRatio": "Margine sulle alte luci (HLG)", + "hlgPeakRatioHelp": "Di quanto le alte luci HLG possono superare il bianco carta. Il valore predefinito va bene per la maggior parte dei materiali; lascialo invariato salvo diversa indicazione delle tue specifiche di consegna.", + "masteringMetadata": "Incorpora metadati di luminosità", + "masteringMetadataHelp": "Etichetta il file con i suoi livelli di luminosità (MaxCLL / MaxFALL) in modo che i display HDR possano applicare il tone mapping con precisione. Consigliato — si può lasciare attivo senza problemi." }, "labels": { "image": "Immagine", @@ -795,7 +805,8 @@ "width": "Larghezza" }, "pixels": "pixel", - "resizeToFit": "Ridimensiona per Adattare" + "resizeToFit": "Ridimensiona per Adattare", + "chroma422CapNote": "Limitato a {{maxLongEdge}} px per l’AVIF Bilanciato (4:2:2) — così il file resta valido." }, "sections": { "advanced": "Avanzate", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 27befeec63..f6a7a61516 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -733,48 +733,58 @@ "quality": "画質", "qualityLossless": "画質 (ロスレス)", "bitDepth": "ビット深度", - "bitDepth8": "8ビット (SDR)", + "bitDepth8": "8ビット (標準 / SDR)", "bitDepth10": "10ビット (HDR)", "bitDepth12": "12ビット (HDR)", - "transfer": "伝達関数", + "transfer": "明るさのエンコード (PQ / HLG)", "primaries": "原色", - "hdrHint": "HDR出力を正しく表示するにはHDRディスプレイが必要です。通常の画面では色あせて見える場合があります。", + "hdrHint": "PQはHDR写真の標準で、画面表示やほとんどの共有に最適です。HDRテレビや放送向けに納品する場合のみHLGを選んでください。いずれにせよ、HDRを正しく表示するにはHDRディスプレイが必要です。通常の画面では色あせて見えることがあります。", "colorProfile": "カラープロファイル", - "colorProfileHint": "既製のプロファイルを選ぶか、「カスタム」を選んで各設定を細かく調整します。", + "colorProfileHint": "ここから始めましょう。各プロファイルが以下のすべてを自動で設定します。詳細を自分で調整したい場合のみ「カスタム」を選んでください。", "profiles": { - "sdr": "標準 (SDR、8ビット)", - "hdr10": "HDR — 最も高い互換性 (HDR10)", - "hlg": "HDR — 放送 (HLG)", + "sdr": "標準 (SDR)", + "hdr10": "HDR10 — 推奨", + "hlg": "HLG — 放送", "maxQuality": "HDR — 最高画質", - "archival": "HDR — アーカイブ/正確 (RGBロスレス)", + "archival": "HDR — アーカイブ", "custom": "カスタム…" }, - "advancedToggle": "詳細なカラーオプション", - "bitDepthHelp": "ビット数が多いほど空や影のグラデーションが滑らかになり、ファイルは大きくなります。10ビットがHDRの標準です。", - "colorEncoding": "カラーエンコーディング", - "colorEncodingYcbcr": "YCbCr (推奨)", - "colorEncodingRgb": "RGB (ロスレス)", - "colorEncodingHelp": "色の保存方法です。YCbCr (推奨) は小さく、どこでも再生できます。RGBは正確でロスレスですが、サイズが大きく対応環境が限られます。", - "chromaDetail": "色差ディテール", - "chroma420": "4:2:0 (最小)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (フルカラー)", - "chromaDetailHelp": "4:2:0 (推奨) は色解像度を下げてファイルを小さくします — 通常は見分けがつきません。4:4:4 は色のディテールを完全に保ちます (大きくなります)。", - "signalRange": "信号レンジ", + "profileDescriptions": { + "sdr": "定番の写真書き出し。どの画面やアプリでも同じように見えます — 特にHDRを作るのでなければ安全な既定の選択です。", + "hdr10": "推奨HDR。より明るいハイライトと豊かな色を実現し、スマートフォン・ブラウザ・SNSで最も広くサポートされます。", + "hlg": "テレビや放送のワークフロー向けに調整されたHDR。納品先がHLGを求める場合のみ選んでください。", + "maxQuality": "色のディテールを余すことなく保つ、最高忠実度のHDR。ファイルは大きくなります。編集、印刷準備、最高の作品のアーカイブに最適です。", + "archival": "色を損失なく正確に保存する、長期保存マスター向け。ファイルは最も大きく、日常的なビューアーとの互換性は最も低くなります。", + "custom": "手動コントロール。以下の各設定を自分で調整できます — プロファイルがほぼ合っているが少しだけ違うときに便利です。" + }, + "advancedToggle": "詳細なカラー設定を表示", + "advancedSectionTitle": "詳細なカラー設定", + "bitDepthHelp": "色深度です。ビット数が多いほど空や影のグラデーションが滑らかになりますが、ファイルは大きくなります。10ビットが推奨のHDR標準で、12ビットは最高画質向けです。", + "colorEncoding": "色の保存方法 (エンコード)", + "colorEncodingYcbcr": "標準 (YCbCr)", + "colorEncodingRgb": "正確 / ロスレス (RGB)", + "colorEncodingHelp": "色データの格納方法です。標準 (推奨) はファイルが小さく、どこでも再生できます。正確は色を完全に保ちますが、ファイルが大きく対応環境が限られます — アーカイブ用途のみに使ってください。", + "chromaDetail": "色のディテール (色差)", + "chroma420": "標準 (4:2:0) — 最小", + "chroma422": "バランス (4:2:2)", + "chroma444": "フルカラー (4:4:4)", + "chromaDetailHelp": "保持する色解像度の量です。標準 (推奨) はほとんどの写真で見た目を変えずにファイルを小さくします。フルカラーは彩度の高い輪郭や細かい文字のディテールをすべて保ちますが、サイズは大きくなります。", + "chroma422CapWarning": "ご注意:バランス (4:2:2) のAVIFは長辺{{maxLongEdge}} pxまでしか有効でないため、ファイルが正しく機能するよう上の書き出しサイズを制限しました。すべての画像がすでに小さい場合は制限を解除できます — そうでなければ標準 (4:2:0) かフルカラー (4:4:4) に切り替えてフル解像度で書き出してください。", + "signalRange": "トーンレンジ", "rangeFull": "フル (推奨)", - "rangeLimited": "制限 (TV/ビデオ)", - "signalRangeHelp": "フル (写真に推奨) はすべての値を使用します。制限はTVやビデオに合わせます。", - "colorGamut": "色域", - "gamutSrgb": "sRGB (最小)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (最も広い)", - "colorGamutHelp": "色の範囲の広さです。Rec.2020 (最も広い) はHDRの標準、Display-P3は最新の画面に合い、sRGBは最も小さい色域です。", - "referenceWhite": "基準白", - "referenceWhiteHelp": "「紙の白」の明るさです。203 nitsがHDRの標準で、上げると明るい見た目になります。(エキスパート)", - "hlgPeakRatio": "HLGピーク比", - "hlgPeakRatioHelp": "詳細設定:紙の白を超えるHLGのヘッドルームです。", - "masteringMetadata": "マスタリングメタデータ", - "masteringMetadataHelp": "一部のHDRディスプレイが使用する輝度情報 (MaxCLL/MaxFALL) を埋め込みます。オンのままで問題ありません。" + "rangeLimited": "制限 (TV / ビデオ)", + "signalRangeHelp": "どの明るさの値を使うかです。フル (写真に推奨) は全範囲を使って最大のディテールを得ます。制限は従来のTVやビデオのパイプラインに合わせます。", + "colorGamut": "色の範囲 (色域)", + "gamutSrgb": "標準 (sRGB) — 最も狭い範囲", + "gamutDisplayP3": "広い (Display-P3)", + "gamutBt2020": "最も広い (Rec.2020)", + "colorGamutHelp": "表示できる色の広さです。最も広い (Rec.2020) はHDRの標準で、ここでの推奨です。広い (Display-P3) は最新のスマートフォンやノートパソコンの画面に合います。標準 (sRGB) は最も互換性が高い一方、最も鮮やかさに欠けます。", + "referenceWhite": "紙の白の明るさ", + "referenceWhiteHelp": "HDRで無地の白いページがどのくらい明るく見えるかを設定します。203 nitsが標準で、上げると全体的に明るい見た目になります。変更が必要だとわかっている場合を除き、既定値のままにしてください。", + "hlgPeakRatio": "ハイライトの余裕 (HLG)", + "hlgPeakRatioHelp": "HLGのハイライトが紙の白をどれだけ上回って明るくなれるかです。既定値はほとんどの素材に適しています。納品仕様で別途指定がない限り、そのままにしてください。", + "masteringMetadata": "明るさのメタデータを埋め込む", + "masteringMetadataHelp": "HDRディスプレイが正確にトーンマッピングできるよう、ファイルに明るさレベル (MaxCLL / MaxFALL) を付与します。推奨 — オンのままで問題ありません。" }, "labels": { "image": "画像", @@ -795,7 +805,8 @@ "width": "幅" }, "pixels": "ピクセル", - "resizeToFit": "サイズに合わせる" + "resizeToFit": "サイズに合わせる", + "chroma422CapNote": "バランス (4:2:2) のAVIFのため{{maxLongEdge}} pxに制限しています — これでファイルが有効なままになります。" }, "sections": { "advanced": "詳細設定", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 9b75d8593a..d0c85963b9 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -733,48 +733,58 @@ "quality": "품질", "qualityLossless": "품질 (무손실)", "bitDepth": "비트 심도", - "bitDepth8": "8비트 (SDR)", + "bitDepth8": "8비트 (표준 / SDR)", "bitDepth10": "10비트 (HDR)", "bitDepth12": "12비트 (HDR)", - "transfer": "전달 함수", + "transfer": "밝기 인코딩 (PQ / HLG)", "primaries": "원색", - "hdrHint": "HDR 출력을 올바르게 보려면 HDR 디스플레이가 필요합니다. 일반 화면에서는 색이 바랜 것처럼 보일 수 있습니다.", + "hdrHint": "PQ는 HDR 사진의 표준이며 화면 표시와 대부분의 공유에 가장 적합합니다. HDR TV나 방송용으로 납품할 때만 HLG를 선택하세요. 어느 쪽이든 HDR를 제대로 보려면 HDR 디스플레이가 필요하며, 일반 화면에서는 색이 바랜 것처럼 보일 수 있습니다.", "colorProfile": "색상 프로파일", - "colorProfileHint": "미리 만들어진 프로파일을 선택하거나 사용자 지정을 골라 각 설정을 세밀하게 조정하세요.", + "colorProfileHint": "여기서 시작하세요. 각 프로파일이 아래의 모든 항목을 대신 설정해 줍니다. 세부 항목을 직접 조정하려는 경우에만 사용자 지정을 선택하세요.", "profiles": { - "sdr": "표준 (SDR, 8비트)", - "hdr10": "HDR — 최고 호환성 (HDR10)", - "hlg": "HDR — 방송 (HLG)", + "sdr": "표준 (SDR)", + "hdr10": "HDR10 — 권장", + "hlg": "HLG — 방송", "maxQuality": "HDR — 최고 품질", - "archival": "HDR — 보관용 / 정확 (RGB 무손실)", + "archival": "HDR — 보관용", "custom": "사용자 지정…" }, - "advancedToggle": "고급 색상 옵션", - "bitDepthHelp": "비트가 많을수록 하늘과 그림자의 그라데이션이 부드러워지지만 파일이 커집니다. 10비트가 HDR 표준입니다.", - "colorEncoding": "색상 인코딩", - "colorEncodingYcbcr": "YCbCr (권장)", - "colorEncodingRgb": "RGB (무손실)", - "colorEncodingHelp": "색상을 저장하는 방식입니다. YCbCr (권장)는 더 작고 어디서나 재생됩니다. RGB는 정확하고 무손실이지만 더 크고 지원 범위가 좁습니다.", - "chromaDetail": "크로마 디테일", - "chroma420": "4:2:0 (가장 작음)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (전체 색상)", - "chromaDetailHelp": "4:2:0 (권장)는 색 해상도를 낮춰 파일을 줄입니다 — 보통은 눈에 띄지 않습니다. 4:4:4는 색상 디테일을 모두 유지합니다 (더 큼).", - "signalRange": "신호 범위", + "profileDescriptions": { + "sdr": "전형적인 사진 내보내기. 어떤 화면과 앱에서도 동일하게 보입니다 — 특별히 HDR를 만드는 것이 아니라면 안전한 기본 선택입니다.", + "hdr10": "권장 HDR. 더 밝은 하이라이트와 풍부한 색을 제공하며, 휴대폰·브라우저·소셜 플랫폼에서 가장 폭넓게 지원됩니다.", + "hlg": "TV 및 방송 워크플로에 맞춰 조정된 HDR. 납품 대상이 HLG를 요구할 때만 선택하세요.", + "maxQuality": "색 디테일을 온전히 담은 최고 충실도의 HDR. 파일이 더 큽니다. 편집, 인쇄 준비, 또는 최고의 작업물 보관에 가장 적합합니다.", + "archival": "장기 보관용 마스터를 위해 색을 손실 없이 정확하게 저장합니다. 파일이 가장 크고 일상적인 뷰어와의 호환성은 가장 낮습니다.", + "custom": "수동 제어. 아래의 모든 설정을 직접 조정할 수 있습니다 — 프로파일이 거의 맞지만 완전하지 않을 때 유용합니다." + }, + "advancedToggle": "고급 색상 설정 표시", + "advancedSectionTitle": "고급 색상 설정", + "bitDepthHelp": "색 심도입니다. 비트가 많을수록 하늘과 그림자의 그라데이션이 부드러워지지만 파일이 커집니다. 10비트가 권장 HDR 표준이며, 12비트는 최고 품질용입니다.", + "colorEncoding": "색상 저장 (인코딩)", + "colorEncodingYcbcr": "표준 (YCbCr)", + "colorEncodingRgb": "정확 / 무손실 (RGB)", + "colorEncodingHelp": "색상 데이터를 담는 방식입니다. 표준 (권장)은 어디서나 재생되는 더 작은 파일을 만듭니다. 정확은 색을 완벽하게 그대로 유지하지만 더 크고 지원 범위가 좁은 파일을 만듭니다 — 보관용으로만 사용하세요.", + "chromaDetail": "색 디테일 (크로마)", + "chroma420": "표준 (4:2:0) — 가장 작음", + "chroma422": "균형 (4:2:2)", + "chroma444": "전체 색상 (4:4:4)", + "chromaDetailHelp": "유지할 색 해상도의 양입니다. 표준 (권장)은 대부분의 사진에서 눈에 띄는 차이 없이 파일을 줄입니다. 전체 색상은 채도 높은 가장자리와 가는 텍스트의 디테일을 모두 유지하지만 크기가 커집니다.", + "chroma422CapWarning": "참고: 균형 (4:2:2) AVIF는 긴 변 {{maxLongEdge}} px까지만 유효하므로, 파일이 정상 작동하도록 위의 내보내기 크기를 제한했습니다. 모든 이미지가 이미 더 작다면 제한을 해제할 수 있습니다 — 그렇지 않으면 표준 (4:2:0) 또는 전체 색상 (4:4:4)으로 전환해 전체 해상도로 내보내세요.", + "signalRange": "톤 범위", "rangeFull": "전체 (권장)", - "rangeLimited": "제한 (TV/비디오)", - "signalRangeHelp": "전체 (사진에 권장)는 모든 값을 사용합니다. 제한은 TV 및 비디오에 맞춥니다.", - "colorGamut": "색 영역", - "gamutSrgb": "sRGB (가장 작음)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (가장 넓음)", - "colorGamutHelp": "색상 범위의 넓이입니다. Rec.2020 (가장 넓음)은 HDR 표준이고, Display-P3는 최신 화면에 맞으며, sRGB는 가장 작습니다.", - "referenceWhite": "기준 백색", - "referenceWhiteHelp": "「종이 백색」의 밝기입니다. 203 nits가 HDR 표준이며, 높이면 더 밝아 보입니다. (전문가)", - "hlgPeakRatio": "HLG 피크 비율", - "hlgPeakRatioHelp": "고급: 종이 백색 위로 확보되는 HLG 여유분입니다.", - "masteringMetadata": "마스터링 메타데이터", - "masteringMetadataHelp": "일부 HDR 디스플레이가 사용하는 밝기 정보 (MaxCLL/MaxFALL)를 포함합니다. 켜 둬도 안전합니다." + "rangeLimited": "제한 (TV / 비디오)", + "signalRangeHelp": "어떤 밝기 값을 사용할지입니다. 전체 (사진에 권장)는 전체 범위를 사용해 최대한의 디테일을 얻습니다. 제한은 기존 TV 및 비디오 파이프라인에 맞춥니다.", + "colorGamut": "색 범위 (색 영역)", + "gamutSrgb": "표준 (sRGB) — 가장 좁은 범위", + "gamutDisplayP3": "넓음 (Display-P3)", + "gamutBt2020": "가장 넓음 (Rec.2020)", + "colorGamutHelp": "표시할 수 있는 색의 폭입니다. 가장 넓음 (Rec.2020)은 HDR 표준이며 여기서 권장됩니다. 넓음 (Display-P3)은 최신 휴대폰과 노트북 화면에 맞고, 표준 (sRGB)은 가장 호환성이 높지만 가장 덜 선명합니다.", + "referenceWhite": "종이 백색 밝기", + "referenceWhiteHelp": "HDR에서 단색 흰 페이지가 얼마나 밝게 보이는지를 설정합니다. 203 nits가 표준이며, 높이면 전체적으로 더 밝아 보입니다. 변경이 필요하다는 것을 알고 있는 경우가 아니라면 기본값으로 두세요.", + "hlgPeakRatio": "하이라이트 여유 (HLG)", + "hlgPeakRatioHelp": "HLG 하이라이트가 종이 백색 위로 얼마나 더 밝아질 수 있는지입니다. 기본값은 대부분의 소재에 적합합니다. 납품 사양에서 다르게 지정하지 않는 한 그대로 두세요.", + "masteringMetadata": "밝기 메타데이터 포함", + "masteringMetadataHelp": "HDR 디스플레이가 정확하게 톤 매핑할 수 있도록 파일에 밝기 수준 (MaxCLL / MaxFALL)을 태그합니다. 권장 — 켜 둬도 안전합니다." }, "labels": { "image": "이미지", @@ -795,7 +805,8 @@ "width": "너비" }, "pixels": "픽셀", - "resizeToFit": "맞춤 크기 조정" + "resizeToFit": "맞춤 크기 조정", + "chroma422CapNote": "균형 (4:2:2) AVIF를 위해 {{maxLongEdge}} px로 제한했습니다 — 이렇게 하면 파일이 유효하게 유지됩니다." }, "sections": { "advanced": "고급", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index 7402bf797d..1d802518bc 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -773,48 +773,58 @@ "quality": "Jakość", "qualityLossless": "Jakość (Bezstratna)", "bitDepth": "Głębia bitowa", - "bitDepth8": "8-bitowy (SDR)", + "bitDepth8": "8-bitowy (standard / SDR)", "bitDepth10": "10-bitowy (HDR)", "bitDepth12": "12-bitowy (HDR)", - "transfer": "Transfer", + "transfer": "Kodowanie jasności (PQ / HLG)", "primaries": "Barwy podstawowe", - "hdrHint": "Wyjście HDR wymaga wyświetlacza HDR do poprawnego wyświetlania. Na zwykłym ekranie może wyglądać wyblakle.", + "hdrHint": "PQ to standard dla zdjęć HDR i najlepszy wybór dla ekranów oraz większości form udostępniania. HLG wybierz tylko, jeśli dostarczasz materiał dla telewizji HDR lub emisji. Tak czy inaczej, HDR wymaga wyświetlacza HDR, aby wyglądać poprawnie; na zwykłym ekranie może wyglądać wyblakle.", "colorProfile": "Profil koloru", - "colorProfileHint": "Wybierz gotowy profil lub Niestandardowy, aby dostroić każde ustawienie.", + "colorProfileHint": "Zacznij tutaj. Każdy profil ustawia za Ciebie wszystko poniżej. Wybierz Niestandardowy tylko, jeśli chcesz samodzielnie dostroić szczegóły.", "profiles": { - "sdr": "Standardowy (SDR, 8-bit)", - "hdr10": "HDR — najlepsza zgodność (HDR10)", - "hlg": "HDR — emisja (HLG)", + "sdr": "Standardowy (SDR)", + "hdr10": "HDR10 — zalecany", + "hlg": "HLG — emisja", "maxQuality": "HDR — maksymalna jakość", - "archival": "HDR — archiwizacja / dokładny (RGB bezstratny)", + "archival": "HDR — archiwizacja", "custom": "Niestandardowy…" }, - "advancedToggle": "Zaawansowane opcje koloru", - "bitDepthHelp": "Więcej bitów = gładsze przejścia tonalne na niebie i w cieniach, większe pliki. 10-bit to standard HDR.", - "colorEncoding": "Kodowanie koloru", - "colorEncodingYcbcr": "YCbCr (zalecane)", - "colorEncodingRgb": "RGB (bezstratne)", - "colorEncodingHelp": "Sposób zapisu koloru. YCbCr (zalecane) jest mniejszy i odtwarza się wszędzie. RGB jest dokładny i bezstratny, ale większy i słabiej obsługiwany.", - "chromaDetail": "Szczegółowość chromy", - "chroma420": "4:2:0 (najmniejszy)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (pełny kolor)", - "chromaDetailHelp": "4:2:0 (zalecane) zmniejsza rozmiar plików, obniżając rozdzielczość koloru — zwykle niewidoczne. 4:4:4 zachowuje pełny detal koloru (większy).", - "signalRange": "Zakres sygnału", + "profileDescriptions": { + "sdr": "Klasyczny eksport zdjęć. Wygląda tak samo na każdym ekranie i w każdej aplikacji — bezpieczny domyślny wybór, gdy nie tworzysz konkretnie HDR.", + "hdr10": "Zalecany HDR. Jaśniejsze światła i bogatsze kolory, z najszerszą obsługą na telefonach, w przeglądarkach i na platformach społecznościowych.", + "hlg": "HDR dostrojony do telewizorów i procesów emisyjnych. Wybierz tylko wtedy, gdy cel dostarczenia wymaga HLG.", + "maxQuality": "HDR o najwyższej wierności z pełnym detalem koloru. Większe pliki; najlepszy do edycji, przygotowania do druku lub archiwizacji najlepszych prac.", + "archival": "Zapisuje kolor dokładnie i bez strat, do długoterminowych masterów. Największe pliki i najmniejsza zgodność z codziennymi przeglądarkami.", + "custom": "Sterowanie ręczne. Każde ustawienie poniżej możesz dostosować samodzielnie — przydatne, gdy profil pasuje prawie idealnie, ale nie do końca." + }, + "advancedToggle": "Pokaż zaawansowane ustawienia koloru", + "advancedSectionTitle": "Zaawansowane ustawienia koloru", + "bitDepthHelp": "Głębia koloru. Więcej bitów oznacza gładsze przejścia tonalne na niebie i w cieniach, kosztem większych plików. 10-bit to zalecany standard HDR; 12-bit jest dla maksymalnej jakości.", + "colorEncoding": "Zapis koloru (kodowanie)", + "colorEncodingYcbcr": "Standard (YCbCr)", + "colorEncodingRgb": "Dokładny / bezstratny (RGB)", + "colorEncodingHelp": "Sposób upakowania danych koloru. Standard (zalecany) daje mniejsze pliki, które odtwarzają się wszędzie. Dokładny zachowuje kolor idealnie nienaruszony, ale tworzy większe i słabiej obsługiwane pliki — używaj go tylko do archiwizacji.", + "chromaDetail": "Detal koloru (chroma)", + "chroma420": "Standard (4:2:0) — najmniejszy", + "chroma422": "Zrównoważony (4:2:2)", + "chroma444": "Pełny kolor (4:4:4)", + "chromaDetailHelp": "Ile rozdzielczości koloru zachować. Standard (zalecany) zmniejsza pliki bez widocznej różnicy dla większości zdjęć. Pełny kolor zachowuje każdy detal w nasyconych krawędziach i drobnym tekście, kosztem większego rozmiaru.", + "chroma422CapWarning": "Uwaga: AVIF Zrównoważony (4:2:2) jest prawidłowy tylko do {{maxLongEdge}} px na dłuższej krawędzi, dlatego ograniczyliśmy powyżej rozmiar eksportu, aby plik pozostał działający. Możesz znieść ograniczenie, jeśli każdy obraz jest już mniejszy — w przeciwnym razie przełącz na Standard (4:2:0) lub Pełny kolor (4:4:4), aby eksportować w pełnej rozdzielczości.", + "signalRange": "Zakres tonalny", "rangeFull": "Pełny (zalecany)", - "rangeLimited": "Ograniczony (TV/wideo)", - "signalRangeHelp": "Pełny (zalecany dla zdjęć) wykorzystuje każdą wartość. Ograniczony odpowiada TV i wideo.", - "colorGamut": "Gamut koloru", - "gamutSrgb": "sRGB (najmniejszy)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (najszerszy)", - "colorGamutHelp": "Jak szeroki jest zakres kolorów. Rec.2020 (najszerszy) to standard HDR; Display-P3 odpowiada nowoczesnym ekranom; sRGB jest najmniejszy.", - "referenceWhite": "Biel odniesienia", - "referenceWhiteHelp": "Jak jasno wygląda „biel papieru”. 203 nits to standard HDR — zwiększ dla jaśniejszego wyglądu. (Ekspert)", - "hlgPeakRatio": "Współczynnik szczytu HLG", - "hlgPeakRatioHelp": "Zaawansowane: zapas HLG powyżej bieli papieru.", - "masteringMetadata": "Metadane masteringu", - "masteringMetadataHelp": "Osadza informacje o jasności (MaxCLL/MaxFALL), z których korzystają niektóre wyświetlacze HDR. Można bezpiecznie pozostawić włączone." + "rangeLimited": "Ograniczony (TV / wideo)", + "signalRangeHelp": "Które wartości jasności są używane. Pełny (zalecany dla zdjęć) wykorzystuje cały zakres dla maksimum detali. Ograniczony odpowiada starszym potokom TV i wideo.", + "colorGamut": "Zakres kolorów (gamut)", + "gamutSrgb": "Standard (sRGB) — najmniejszy zakres", + "gamutDisplayP3": "Szeroki (Display-P3)", + "gamutBt2020": "Najszerszy (Rec.2020)", + "colorGamutHelp": "Jak szeroki zakres kolorów można wyświetlić. Najszerszy (Rec.2020) to standard HDR i zalecany tutaj; Szeroki (Display-P3) odpowiada nowoczesnym ekranom telefonów i laptopów; Standard (sRGB) jest najbardziej zgodny, ale najmniej żywy.", + "referenceWhite": "Jasność bieli papieru", + "referenceWhiteHelp": "Ustala, jak jasno wygląda zwykła biała strona w HDR. 203 nits to standard — zwiększ dla ogólnie jaśniejszego wyglądu. Pozostaw wartość domyślną, chyba że wiesz, że trzeba ją zmienić.", + "hlgPeakRatio": "Zapas świateł (HLG)", + "hlgPeakRatioHelp": "O ile jaśniejsze mogą być światła HLG ponad bielą papieru. Wartość domyślna pasuje do większości materiału; pozostaw ją, chyba że specyfikacja dostawy wskazuje inaczej.", + "masteringMetadata": "Osadź metadane jasności", + "masteringMetadataHelp": "Oznacza plik jego poziomami jasności (MaxCLL / MaxFALL), aby wyświetlacze HDR mogły go dokładnie tonemapować. Zalecane — można bezpiecznie pozostawić włączone." }, "labels": { "image": "Obraz", @@ -835,7 +845,8 @@ "width": "Szerokość" }, "pixels": "pikseli", - "resizeToFit": "Zmień rozmiar, aby dopasować" + "resizeToFit": "Zmień rozmiar, aby dopasować", + "chroma422CapNote": "Ograniczono do {{maxLongEdge}} px dla AVIF Zrównoważony (4:2:2) — dzięki temu plik pozostaje prawidłowy." }, "sections": { "advanced": "Zaawansowane", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 781be59da8..e227bfaaf0 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -733,48 +733,58 @@ "quality": "Qualidade", "qualityLossless": "Qualidade (Sem Perdas)", "bitDepth": "Profundidade de bits", - "bitDepth8": "8 bits (SDR)", + "bitDepth8": "8 bits (padrão / SDR)", "bitDepth10": "10 bits (HDR)", "bitDepth12": "12 bits (HDR)", - "transfer": "Transferência", + "transfer": "Codificação de brilho (PQ / HLG)", "primaries": "Primárias", - "hdrHint": "A saída HDR precisa de uma tela HDR para ser exibida corretamente. Em uma tela comum pode parecer desbotada.", + "hdrHint": "PQ é o padrão para fotos HDR e a melhor escolha para telas e para a maioria dos compartilhamentos. Escolha HLG apenas se for entregar para TV HDR ou transmissão. De qualquer forma, o HDR precisa de uma tela HDR para ficar bom; em uma tela comum pode parecer desbotado.", "colorProfile": "Perfil de cor", - "colorProfileHint": "Escolha um perfil pronto ou Personalizado para ajustar cada definição.", + "colorProfileHint": "Comece aqui. Cada perfil ajusta para você tudo o que vem abaixo. Escolha Personalizado apenas se quiser ajustar os detalhes você mesmo.", "profiles": { - "sdr": "Padrão (SDR, 8 bits)", - "hdr10": "HDR — melhor compatibilidade (HDR10)", - "hlg": "HDR — transmissão (HLG)", + "sdr": "Padrão (SDR)", + "hdr10": "HDR10 — recomendado", + "hlg": "HLG — transmissão", "maxQuality": "HDR — qualidade máxima", - "archival": "HDR — arquivo / exato (RGB sem perdas)", + "archival": "HDR — arquivo", "custom": "Personalizado…" }, - "advancedToggle": "Opções de cor avançadas", - "bitDepthHelp": "Mais bits = gradientes mais suaves em céus e sombras, arquivos maiores. 10 bits é o padrão HDR.", - "colorEncoding": "Codificação de cor", - "colorEncodingYcbcr": "YCbCr (recomendado)", - "colorEncodingRgb": "RGB (sem perdas)", - "colorEncodingHelp": "Como a cor é armazenada. YCbCr (recomendado) é menor e reproduz em qualquer lugar. RGB é exato e sem perdas, mas maior e com menos compatibilidade.", - "chromaDetail": "Detalhe de croma", - "chroma420": "4:2:0 (menor)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (cor completa)", - "chromaDetailHelp": "4:2:0 (recomendado) reduz o tamanho dos arquivos diminuindo a resolução de cor — geralmente imperceptível. 4:4:4 mantém todo o detalhe de cor (maior).", - "signalRange": "Faixa de sinal", + "profileDescriptions": { + "sdr": "Exportação de foto clássica. Fica igual em qualquer tela e aplicativo — a escolha segura padrão quando você não está criando HDR especificamente.", + "hdr10": "HDR recomendado. Realces mais brilhantes e cor mais rica, com a maior compatibilidade em celulares, navegadores e plataformas sociais.", + "hlg": "HDR ajustado para televisores e fluxos de trabalho de transmissão. Escolha apenas se o seu destino de entrega pedir HLG.", + "maxQuality": "HDR de máxima fidelidade com todo o detalhe de cor. Arquivos maiores; ideal para edição, preparação de impressão ou arquivamento dos seus melhores trabalhos.", + "archival": "Armazena a cor de forma exata e sem perdas, para masters de longo prazo. Os arquivos maiores e os menos compatíveis com visualizadores do dia a dia.", + "custom": "Controle manual. Cada definição abaixo é sua para ajustar — útil quando um perfil quase serve, mas não totalmente." + }, + "advancedToggle": "Mostrar configurações de cor avançadas", + "advancedSectionTitle": "Configurações de cor avançadas", + "bitDepthHelp": "Profundidade de cor. Mais bits significam gradientes mais suaves em céus e sombras, ao custo de arquivos maiores. 10 bits é o padrão HDR recomendado; 12 bits é para qualidade máxima.", + "colorEncoding": "Armazenamento de cor (codificação)", + "colorEncodingYcbcr": "Padrão (YCbCr)", + "colorEncodingRgb": "Exato / sem perdas (RGB)", + "colorEncodingHelp": "Como os dados de cor são empacotados. Padrão (recomendado) gera arquivos menores que reproduzem em qualquer lugar. Exato mantém a cor perfeitamente intacta, mas gera arquivos maiores e com menos compatibilidade — use apenas para arquivamento.", + "chromaDetail": "Detalhe de cor (croma)", + "chroma420": "Padrão (4:2:0) — o menor", + "chroma422": "Equilibrado (4:2:2)", + "chroma444": "Cor completa (4:4:4)", + "chromaDetailHelp": "Quanta resolução de cor manter. Padrão (recomendado) reduz os arquivos sem diferença visível na maioria das fotos. Cor completa preserva cada detalhe em bordas saturadas e texto fino, com tamanho maior.", + "chroma422CapWarning": "Atenção: o AVIF Equilibrado (4:2:2) só é válido até {{maxLongEdge}} px no lado mais longo, por isso limitamos o tamanho de exportação acima para manter o seu arquivo funcionando. Você pode remover o limite se todas as imagens já forem menores — caso contrário, mude para Padrão (4:2:0) ou Cor completa (4:4:4) para exportar em resolução total.", + "signalRange": "Faixa tonal", "rangeFull": "Completa (recomendada)", - "rangeLimited": "Limitada (TV/vídeo)", - "signalRangeHelp": "Completa (recomendada para fotos) usa todos os valores. Limitada corresponde a TV e vídeo.", - "colorGamut": "Gama de cores", - "gamutSrgb": "sRGB (a menor)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (a mais ampla)", - "colorGamutHelp": "Quão ampla é a gama de cores. Rec.2020 (a mais ampla) é o padrão para HDR; Display-P3 corresponde às telas modernas; sRGB é a menor.", - "referenceWhite": "Branco de referência", - "referenceWhiteHelp": "Quão brilhante parece o «branco do papel». 203 nits é o padrão HDR — aumente para um visual mais claro. (Especialista)", - "hlgPeakRatio": "Proporção de pico HLG", - "hlgPeakRatioHelp": "Avançado: margem do HLG acima do branco do papel.", - "masteringMetadata": "Metadados de masterização", - "masteringMetadataHelp": "Incorpora informações de brilho (MaxCLL/MaxFALL) usadas por algumas telas HDR. Pode deixar ativado sem problemas." + "rangeLimited": "Limitada (TV / vídeo)", + "signalRangeHelp": "Quais valores de brilho são usados. Completa (recomendada para fotos) usa toda a faixa para o máximo de detalhe. Limitada corresponde aos pipelines antigos de TV e vídeo.", + "colorGamut": "Faixa de cores (gama)", + "gamutSrgb": "Padrão (sRGB) — a menor faixa", + "gamutDisplayP3": "Ampla (Display-P3)", + "gamutBt2020": "A mais ampla (Rec.2020)", + "colorGamutHelp": "Quão amplo é o conjunto de cores que pode ser exibido. A mais ampla (Rec.2020) é o padrão HDR e a recomendada aqui; Ampla (Display-P3) corresponde às telas modernas de celulares e laptops; Padrão (sRGB) é a mais compatível, mas a menos vívida.", + "referenceWhite": "Brilho do branco do papel", + "referenceWhiteHelp": "Define quão brilhante uma página branca lisa aparece em HDR. 203 nits é o padrão — aumente para um visual geral mais claro. Deixe no valor padrão a menos que saiba que precisa alterá-lo.", + "hlgPeakRatio": "Margem de realces (HLG)", + "hlgPeakRatioHelp": "Quanto mais brilhantes os realces HLG podem ir acima do branco do papel. O padrão serve para a maioria do material; deixe-o a menos que a sua especificação de entrega indique o contrário.", + "masteringMetadata": "Incorporar metadados de brilho", + "masteringMetadataHelp": "Marca o arquivo com seus níveis de brilho (MaxCLL / MaxFALL) para que as telas HDR possam aplicar o tone mapping com precisão. Recomendado — pode deixar ativado sem problemas." }, "labels": { "image": "Imagem", @@ -795,7 +805,8 @@ "width": "Largura" }, "pixels": "pixels", - "resizeToFit": "Redimensionar para Caber" + "resizeToFit": "Redimensionar para Caber", + "chroma422CapNote": "Limitado a {{maxLongEdge}} px para o AVIF Equilibrado (4:2:2) — isso mantém o arquivo válido." }, "sections": { "advanced": "Avançado", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d304281e1b..8e7830a50c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -733,48 +733,58 @@ "quality": "Качество", "qualityLossless": "Качество (без потерь)", "bitDepth": "Битовая глубина", - "bitDepth8": "8 бит (SDR)", + "bitDepth8": "8 бит (стандарт / SDR)", "bitDepth10": "10 бит (HDR)", "bitDepth12": "12 бит (HDR)", - "transfer": "Передаточная функция", + "transfer": "Кодирование яркости (PQ / HLG)", "primaries": "Основные цвета", - "hdrHint": "Для корректного просмотра HDR-вывода нужен HDR-дисплей. На обычном экране изображение может выглядеть блёклым.", + "hdrHint": "PQ — это стандарт для HDR-фотографий и лучший выбор для экранов и большинства способов публикации. Выбирайте HLG, только если готовите материал для HDR-телевидения или вещания. В любом случае для корректного отображения HDR нужен HDR-дисплей; на обычном экране изображение может выглядеть блёклым.", "colorProfile": "Цветовой профиль", - "colorProfileHint": "Выберите готовый профиль или «Пользовательский», чтобы настроить каждый параметр.", + "colorProfileHint": "Начните отсюда. Каждый профиль настраивает за вас всё, что ниже. Выбирайте «Пользовательский», только если хотите настроить детали самостоятельно.", "profiles": { - "sdr": "Стандартный (SDR, 8 бит)", - "hdr10": "HDR — наилучшая совместимость (HDR10)", - "hlg": "HDR — вещание (HLG)", + "sdr": "Стандартный (SDR)", + "hdr10": "HDR10 — рекомендуется", + "hlg": "HLG — вещание", "maxQuality": "HDR — максимальное качество", - "archival": "HDR — архив / точный (RGB без потерь)", + "archival": "HDR — архив", "custom": "Пользовательский…" }, - "advancedToggle": "Расширенные параметры цвета", - "bitDepthHelp": "Больше бит = более плавные градиенты на небе и в тенях, но больше размер файлов. 10 бит — стандарт HDR.", - "colorEncoding": "Кодирование цвета", - "colorEncodingYcbcr": "YCbCr (рекомендуется)", - "colorEncodingRgb": "RGB (без потерь)", - "colorEncodingHelp": "Как хранится цвет. YCbCr (рекомендуется) компактнее и воспроизводится везде. RGB точный и без потерь, но крупнее и хуже поддерживается.", - "chromaDetail": "Детализация цветности", - "chroma420": "4:2:0 (наименьший)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (полный цвет)", - "chromaDetailHelp": "4:2:0 (рекомендуется) уменьшает размер файлов за счёт снижения цветового разрешения — обычно незаметно. 4:4:4 сохраняет полную детализацию цвета (крупнее).", - "signalRange": "Диапазон сигнала", + "profileDescriptions": { + "sdr": "Классический экспорт фото. Выглядит одинаково на любом экране и в любом приложении — безопасный выбор по умолчанию, когда вы не создаёте HDR специально.", + "hdr10": "Рекомендуемый HDR. Более яркие света и насыщенный цвет с самой широкой поддержкой на телефонах, в браузерах и социальных платформах.", + "hlg": "HDR, настроенный для телевизоров и вещательных рабочих процессов. Выбирайте, только если ваша цель доставки требует HLG.", + "maxQuality": "HDR наивысшей точности с полной детализацией цвета. Файлы крупнее; лучший вариант для редактирования, подготовки к печати или архивирования лучших работ.", + "archival": "Сохраняет цвет точно и без потерь, для долговременных мастеров. Самые крупные файлы и наименьшая совместимость с повседневными просмотрщиками.", + "custom": "Ручное управление. Каждый параметр ниже можно настроить самостоятельно — удобно, когда профиль почти подходит, но не совсем." + }, + "advancedToggle": "Показать расширенные параметры цвета", + "advancedSectionTitle": "Расширенные параметры цвета", + "bitDepthHelp": "Глубина цвета. Больше бит — более плавные градиенты на небе и в тенях, но больше размер файлов. 10 бит — рекомендуемый стандарт HDR; 12 бит — для максимального качества.", + "colorEncoding": "Хранение цвета (кодирование)", + "colorEncodingYcbcr": "Стандарт (YCbCr)", + "colorEncodingRgb": "Точное / без потерь (RGB)", + "colorEncodingHelp": "Как упаковываются данные о цвете. Стандарт (рекомендуется) даёт файлы меньшего размера, которые воспроизводятся везде. Точное сохраняет цвет в идеальной целости, но создаёт более крупные и хуже поддерживаемые файлы — используйте только для архивирования.", + "chromaDetail": "Детализация цвета (цветность)", + "chroma420": "Стандарт (4:2:0) — наименьший", + "chroma422": "Сбалансированный (4:2:2)", + "chroma444": "Полный цвет (4:4:4)", + "chromaDetailHelp": "Сколько цветового разрешения сохранить. Стандарт (рекомендуется) уменьшает файлы без видимой разницы для большинства фото. Полный цвет сохраняет каждую деталь на насыщенных краях и в мелком тексте, при большем размере.", + "chroma422CapWarning": "Обратите внимание: Сбалансированный (4:2:2) AVIF допустим только до {{maxLongEdge}} px по длинной стороне, поэтому мы ограничили размер экспорта выше, чтобы ваш файл оставался работоспособным. Вы можете снять ограничение, если каждое изображение уже меньше — иначе переключитесь на Стандарт (4:2:0) или Полный цвет (4:4:4), чтобы экспортировать в полном разрешении.", + "signalRange": "Тоновый диапазон", "rangeFull": "Полный (рекомендуется)", - "rangeLimited": "Ограниченный (ТВ/видео)", - "signalRangeHelp": "Полный (рекомендуется для фото) использует все значения. Ограниченный соответствует ТВ и видео.", - "colorGamut": "Цветовой охват", - "gamutSrgb": "sRGB (наименьший)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (самый широкий)", - "colorGamutHelp": "Насколько широк диапазон цветов. Rec.2020 (самый широкий) — стандарт для HDR; Display-P3 соответствует современным экранам; sRGB — наименьший.", - "referenceWhite": "Опорный белый", - "referenceWhiteHelp": "Насколько ярким выглядит «белый бумаги». 203 nits — стандарт HDR; увеличьте для более яркого вида. (Эксперт)", - "hlgPeakRatio": "Пиковое отношение HLG", - "hlgPeakRatioHelp": "Дополнительно: запас HLG над «белым бумаги».", - "masteringMetadata": "Метаданные мастеринга", - "masteringMetadataHelp": "Встраивает данные о яркости (MaxCLL/MaxFALL), которые используют некоторые HDR-дисплеи. Можно безопасно оставить включённым." + "rangeLimited": "Ограниченный (ТВ / видео)", + "signalRangeHelp": "Какие значения яркости используются. Полный (рекомендуется для фото) использует весь диапазон для максимальной детализации. Ограниченный соответствует старым конвейерам ТВ и видео.", + "colorGamut": "Диапазон цвета (охват)", + "gamutSrgb": "Стандарт (sRGB) — наименьший диапазон", + "gamutDisplayP3": "Широкий (Display-P3)", + "gamutBt2020": "Самый широкий (Rec.2020)", + "colorGamutHelp": "Насколько широк диапазон отображаемых цветов. Самый широкий (Rec.2020) — стандарт HDR и рекомендуется здесь; Широкий (Display-P3) соответствует современным экранам телефонов и ноутбуков; Стандарт (sRGB) — самый совместимый, но наименее яркий.", + "referenceWhite": "Яркость бумажного белого", + "referenceWhiteHelp": "Задаёт, насколько ярко выглядит чистая белая страница в HDR. 203 nits — это стандарт; увеличьте для в целом более яркого вида. Оставьте значение по умолчанию, если не уверены, что его нужно менять.", + "hlgPeakRatio": "Запас в светах (HLG)", + "hlgPeakRatioHelp": "Насколько ярче света HLG могут выходить за бумажный белый. Значение по умолчанию подходит для большинства материалов; оставьте его, если иное не указано в спецификации доставки.", + "masteringMetadata": "Встроить метаданные яркости", + "masteringMetadataHelp": "Помечает файл его уровнями яркости (MaxCLL / MaxFALL), чтобы HDR-дисплеи могли точно выполнять тональное отображение. Рекомендуется — можно безопасно оставить включённым." }, "labels": { "image": "Изображение", @@ -795,7 +805,8 @@ "width": "Ширина" }, "pixels": "пикселей", - "resizeToFit": "По размеру" + "resizeToFit": "По размеру", + "chroma422CapNote": "Ограничено до {{maxLongEdge}} px для Сбалансированного (4:2:2) AVIF — так файл остаётся корректным." }, "sections": { "advanced": "Дополнительно", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a0335782cd..c774121bc3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -733,48 +733,58 @@ "quality": "质量", "qualityLossless": "质量(无损)", "bitDepth": "位深", - "bitDepth8": "8 位 (SDR)", + "bitDepth8": "8 位 (标准 / SDR)", "bitDepth10": "10 位 (HDR)", "bitDepth12": "12 位 (HDR)", - "transfer": "传输函数", + "transfer": "亮度编码 (PQ / HLG)", "primaries": "原色", - "hdrHint": "HDR 输出需要 HDR 显示器才能正确显示。在普通屏幕上可能会显得发灰。", + "hdrHint": "PQ 是 HDR 照片的标准,最适合屏幕显示和大多数分享。仅当为 HDR 电视或广播交付时才选择 HLG。无论哪种方式,HDR 都需要 HDR 显示器才能正确显示;在普通屏幕上可能会显得发灰。", "colorProfile": "色彩配置文件", - "colorProfileHint": "选择现成的配置文件,或选择“自定义”以微调每项设置。", + "colorProfileHint": "从这里开始。每个配置文件都会为你设置好下面的所有内容。只有当你想自行调整细节时才选择“自定义”。", "profiles": { - "sdr": "标准 (SDR,8 位)", - "hdr10": "HDR — 最佳兼容性 (HDR10)", - "hlg": "HDR — 广播 (HLG)", + "sdr": "标准 (SDR)", + "hdr10": "HDR10 — 推荐", + "hlg": "HLG — 广播", "maxQuality": "HDR — 最高质量", - "archival": "HDR — 存档 / 精确 (RGB 无损)", + "archival": "HDR — 存档", "custom": "自定义…" }, - "advancedToggle": "高级色彩选项", - "bitDepthHelp": "位数越多,天空和阴影中的渐变越平滑,文件也越大。10 位是 HDR 标准。", - "colorEncoding": "色彩编码", - "colorEncodingYcbcr": "YCbCr (推荐)", - "colorEncodingRgb": "RGB (无损)", - "colorEncodingHelp": "色彩的存储方式。YCbCr (推荐) 更小且随处可播放。RGB 精确且无损,但更大且支持较少。", - "chromaDetail": "色度细节", - "chroma420": "4:2:0 (最小)", - "chroma422": "4:2:2", - "chroma444": "4:4:4 (全彩)", - "chromaDetailHelp": "4:2:0 (推荐) 通过降低色彩分辨率来缩小文件——通常不可见。4:4:4 保留完整的色彩细节 (更大)。", - "signalRange": "信号范围", + "profileDescriptions": { + "sdr": "经典的照片导出。在任何屏幕和应用上看起来都一样——当你不是专门制作 HDR 时的安全默认选项。", + "hdr10": "推荐的 HDR。更亮的高光和更丰富的色彩,在手机、浏览器和社交平台上拥有最广泛的支持。", + "hlg": "为电视和广播工作流程调校的 HDR。仅当你的交付目标要求 HLG 时才选择。", + "maxQuality": "保留完整色彩细节的最高保真 HDR。文件更大;最适合编辑、印前准备或归档你最得意的作品。", + "archival": "精确无损地存储色彩,用于长期母版。文件最大,且与日常查看器的兼容性最低。", + "custom": "手动控制。下面的每一项设置都由你自行调整——当某个配置文件几乎合适但又不完全合适时很有用。" + }, + "advancedToggle": "显示高级色彩设置", + "advancedSectionTitle": "高级色彩设置", + "bitDepthHelp": "色彩深度。位数越多,天空和阴影中的渐变越平滑,但文件也越大。10 位是推荐的 HDR 标准;12 位用于追求最高质量。", + "colorEncoding": "色彩存储 (编码)", + "colorEncodingYcbcr": "标准 (YCbCr)", + "colorEncodingRgb": "精确 / 无损 (RGB)", + "colorEncodingHelp": "色彩数据的封装方式。标准 (推荐) 生成更小且随处可播放的文件。精确可让色彩完全保持原样,但生成的文件更大、支持也更少——仅用于归档。", + "chromaDetail": "色彩细节 (色度)", + "chroma420": "标准 (4:2:0) — 最小", + "chroma422": "均衡 (4:2:2)", + "chroma444": "全彩 (4:4:4)", + "chromaDetailHelp": "保留多少色彩分辨率。标准 (推荐) 在大多数照片上以肉眼不可见的方式缩小文件。全彩保留饱和边缘和细小文字中的每一处细节,代价是文件更大。", + "chroma422CapWarning": "请注意:均衡 (4:2:2) 的 AVIF 仅在长边不超过 {{maxLongEdge}} px 时有效,因此我们已将上面的导出尺寸限制在此范围内,以确保文件可正常使用。如果每张图片都已经更小,你可以解除该限制——否则请切换到标准 (4:2:0) 或全彩 (4:4:4) 以按完整分辨率导出。", + "signalRange": "色调范围", "rangeFull": "完整 (推荐)", - "rangeLimited": "受限 (电视/视频)", - "signalRangeHelp": "完整 (推荐用于照片) 使用所有数值。受限与电视和视频一致。", - "colorGamut": "色域", - "gamutSrgb": "sRGB (最小)", - "gamutDisplayP3": "Display-P3", - "gamutBt2020": "Rec.2020 (最广)", - "colorGamutHelp": "色彩范围的宽窄。Rec.2020 (最广) 是 HDR 标准;Display-P3 与现代屏幕匹配;sRGB 最小。", - "referenceWhite": "参考白", - "referenceWhiteHelp": "“纸白”看起来有多亮。203 nits 是 HDR 标准——调高可获得更明亮的效果。(专家)", - "hlgPeakRatio": "HLG 峰值比", - "hlgPeakRatioHelp": "高级:纸白之上的 HLG 余量。", - "masteringMetadata": "母版元数据", - "masteringMetadataHelp": "嵌入部分 HDR 显示器使用的亮度信息 (MaxCLL/MaxFALL)。保持开启是安全的。" + "rangeLimited": "受限 (电视 / 视频)", + "signalRangeHelp": "使用哪些亮度数值。完整 (推荐用于照片) 使用整个范围以获得最多细节。受限与较旧的电视和视频流程一致。", + "colorGamut": "色彩范围 (色域)", + "gamutSrgb": "标准 (sRGB) — 范围最小", + "gamutDisplayP3": "宽广 (Display-P3)", + "gamutBt2020": "最宽广 (Rec.2020)", + "colorGamutHelp": "可显示的色彩跨度有多宽。最宽广 (Rec.2020) 是 HDR 标准,也是此处推荐;宽广 (Display-P3) 与现代手机和笔记本屏幕匹配;标准 (sRGB) 兼容性最好但最不鲜艳。", + "referenceWhite": "纸白亮度", + "referenceWhiteHelp": "设置纯白页面在 HDR 中的显示亮度。203 nits 是标准值——调高可获得整体更明亮的效果。除非你确知需要更改,否则请保持默认值。", + "hlgPeakRatio": "高光余量 (HLG)", + "hlgPeakRatioHelp": "HLG 高光可以比纸白亮多少。默认值适合大多数素材;除非交付规范另有要求,否则请保持不变。", + "masteringMetadata": "嵌入亮度元数据", + "masteringMetadataHelp": "为文件标记其亮度级别 (MaxCLL / MaxFALL),以便 HDR 显示器能够准确进行色调映射。推荐——保持开启是安全的。" }, "labels": { "image": "图像", @@ -795,7 +805,8 @@ "width": "宽度" }, "pixels": "像素", - "resizeToFit": "调整大小以适应" + "resizeToFit": "调整大小以适应", + "chroma422CapNote": "为均衡 (4:2:2) 的 AVIF 限制为 {{maxLongEdge}} px——这样可保持文件有效。" }, "sections": { "advanced": "高级", From 3d184d6ca171354d44ccee1a55db079165f954ca Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 14:08:12 +0200 Subject: [PATCH 16/20] Cut HDR export peak memory: drop a redundant full-res float copy HDR AVIF/JXL export held more full-resolution buffers than necessary: - encode_image_to_bytes called image.to_rgba32f() on an image that is already Rgba32F, allocating a full 16-byte/px duplicate held for the entire encode (~0.4 GB at 25 MP, ~1 GB at 60 MP). Borrow the existing buffer instead. - encode_avif_hdr copied every plane into a fresh little-endian byte Vec before handing it to rav1e; on little-endian targets the plane bytes are already in that layout, so pass them zero-copy via bytemuck::cast_slice. Output is byte-for-byte unchanged (the exact-value decode tests still pass); this only lowers peak RAM. --- src-tauri/src/export_processing.rs | 21 +++++++++++++-------- src-tauri/src/hdr.rs | 11 ++++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 51ba1a7867..4960fb03a1 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -494,10 +494,12 @@ fn encode_image_to_bytes( if hdr { #[cfg(feature = "hdr_jxl")] { - return crate::hdr::encode_jxl_hdr( - &image.to_rgba32f(), - &export_settings.to_hdr_config(), - ); + // Borrow the existing Rgba32F buffer instead of cloning the whole float image. + let cfg = export_settings.to_hdr_config(); + return match image { + DynamicImage::ImageRgba32F(buf) => crate::hdr::encode_jxl_hdr(buf, &cfg), + other => crate::hdr::encode_jxl_hdr(&other.to_rgba32f(), &cfg), + }; } #[cfg(not(feature = "hdr_jxl"))] { @@ -571,10 +573,13 @@ fn encode_image_to_bytes( } "avif" => { if hdr { - return crate::hdr::encode_avif_hdr( - &image.to_rgba32f(), - &export_settings.to_hdr_config(), - ); + // The HDR pipeline already produced an Rgba32F image; borrow it directly instead of + // cloning the whole float image again (to_rgba32f always allocates a full 16 B/px copy). + let cfg = export_settings.to_hdr_config(); + return match image { + DynamicImage::ImageRgba32F(buf) => crate::hdr::encode_avif_hdr(buf, &cfg), + other => crate::hdr::encode_avif_hdr(&other.to_rgba32f(), &cfg), + }; } image .write_to(&mut cursor, image::ImageFormat::Avif) diff --git a/src-tauri/src/hdr.rs b/src-tauri/src/hdr.rs index 7501fb5560..54a78dd578 100644 --- a/src-tauri/src/hdr.rs +++ b/src-tauri/src/hdr.rs @@ -755,11 +755,12 @@ pub fn encode_avif_hdr( let mut frame = ctx.new_frame(); // copy_from_raw_u8 writes at the plane origin (handles rav1e's internal padding), unlike a // raw chunks_mut over the padded buffer. Identity plane order is 0=G', 1=B', 2=R'; - // YCbCr is 0=Y', 1=Cb, 2=Cr. - let to_le = |p: &[u16]| -> Vec { p.iter().flat_map(|v| v.to_le_bytes()).collect() }; - frame.planes[0].copy_from_raw_u8(&to_le(&planes.p0), w * 2, 2); - frame.planes[1].copy_from_raw_u8(&to_le(&planes.p1), planes.chroma_w * 2, 2); - frame.planes[2].copy_from_raw_u8(&to_le(&planes.p2), planes.chroma_w * 2, 2); + // YCbCr is 0=Y', 1=Cb, 2=Cr. The samples are u16 and rav1e wants little-endian bytes; on every + // supported (little-endian) target that's exactly the in-memory layout, so cast the plane + // slices to bytes with no allocation instead of building a byte copy of each plane. + frame.planes[0].copy_from_raw_u8(bytemuck::cast_slice(&planes.p0), w * 2, 2); + frame.planes[1].copy_from_raw_u8(bytemuck::cast_slice(&planes.p1), planes.chroma_w * 2, 2); + frame.planes[2].copy_from_raw_u8(bytemuck::cast_slice(&planes.p2), planes.chroma_w * 2, 2); let _ = planes.chroma_h; ctx.send_frame(std::sync::Arc::new(frame)) From b25bc2b82141797231f7e6febcab681fdeb2c4e5 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 14:37:26 +0200 Subject: [PATCH 17/20] Replace HDR shader string-substitution with an IS_HDR override constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HDR compute shader was derived from the SDR shader by seven runtime string .replace() calls — fragile against any edit to shader.wgsl. Move the headroom-preserving logic into the shared shader behind a pipeline-overridable `IS_HDR` constant (compiler-checked), leaving only the one thing WGSL can't express as an override: the storage texture format (rgba8unorm -> rgba16float). - shader.wgsl: add `override IS_HDR: i32 = 0;` and gate the sRGB encode, the store clamp, the dither, and the three tone-curve clamp points on it via select(). Default 0 = SDR. - build_hdr_resources: set IS_HDR=1 via PipelineCompilationOptions. Every other pipeline leaves it at the default, so their behavior is unchanged. - hdr_shader_source: now a single format swap. - Rewrote the shader guard test for the new approach. Behavior is unchanged: the GPU end-to-end test confirms SDR still clamps to 255 and HDR still recovers linear ~4.0 (headroom preserved). Seven substitutions -> one. --- src-tauri/src/gpu_processing.rs | 114 ++++++++---------------------- src-tauri/src/shaders/shader.wgsl | 33 +++++++-- 2 files changed, 54 insertions(+), 93 deletions(-) diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index 815e3776b3..782e5eac46 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -698,48 +698,17 @@ fn build_main_bgl( }) } -/// The HDR variant of the main shader, derived from the SDR `shader.wgsl` at runtime by four -/// targeted substitutions (the SDR shader stays the single source of truth for the look): -/// 1. promote the storage output `rgba8unorm` -> `rgba16float`; -/// 2. encode with the *headroom-preserving* extended sRGB OETF instead of the clamping one -/// (`linear_to_srgb` clamps to [0,1] at L229 — the real headroom killer, upstream of the store); -/// 3. drop the final `[0,1]` clamp (keep `max(.,0)` to avoid negative-light NaNs in PQ); -/// 4. disable the 8-bit dither (it would add PQ-code noise). -/// The HDR pipeline therefore stores EXTENDED-sRGB-encoded values with headroom; the CPU readback +/// The HDR variant of the main compute shader. It is the SAME WGSL as the SDR path — the +/// headroom-preserving behavior (extended-sRGB encode instead of the clamping one, dropping the +/// final `[0,1]` store clamp in favor of `max(.,0)`, disabling the 8-bit dither, and not snapping +/// or renormalizing the tone curves at the top) is switched on by the `IS_HDR` pipeline-overridable +/// constant set in [`build_hdr_resources`], so the compiler type-checks it rather than it being +/// patched in by string replacement. The one thing WGSL can't express as an override is a storage +/// texture's format, so that single line is still swapped textually here (rgba8unorm -> rgba16float). +/// The HDR pipeline therefore stores extended-sRGB-encoded values with headroom; the CPU readback /// inverts that with `hdr::srgb_extended_to_linear` to recover linear scene-referred light. fn hdr_shader_source() -> String { - include_str!("shaders/shader.wgsl") - .replace("rgba8unorm, write>", "rgba16float, write>") - .replace( - "linear_to_srgb(composite_rgb_linear)", - "linear_to_srgb_extended(composite_rgb_linear)", - ) - .replace( - "clamp(final_rgb, vec3(0.0), vec3(1.0))", - "max(final_rgb, vec3(0.0))", - ) - .replace( - "let dither_amount = 1.0 / 255.0;", - "let dither_amount = 0.0;", - ) - // 5. The tone-curve stage is display-referred and clamps highlights to [0,1] — the other - // half of the headroom bug. Three targeted curve fixes keep values above diffuse white: - // (a) treat empty curves (count<2) as default so they take the non-normalizing branch; - .replace( - " if (count < 2u) {\n return false;\n }", - " if (count < 2u) {\n return true;\n }", - ) - // (b) above the curve's top control point, pass the headroom excess through instead of - // snapping to the top point's value (identity curve => value passes through); - .replace( - " if (x >= local_points[count - 1u].x) { return local_points[count - 1u].y / 255.0; }", - " if (x >= local_points[count - 1u].x) { return local_points[count - 1u].y / 255.0 + (val - local_points[count - 1u].x / 255.0); }", - ) - // (c) when per-channel RGB curves are active, do not normalize headroom back into gamut. - .replace( - " if (max_comp > 1.0) { final_color = final_color / max_comp; }", - " if (max_comp > 1.0e30) { final_color = final_color / max_comp; }", - ) + include_str!("shaders/shader.wgsl").replace("rgba8unorm, write>", "rgba16float, write>") } fn build_hdr_resources(device: &wgpu::Device, tile_dims: wgpu::Extent3d) -> HdrResources { @@ -758,7 +727,12 @@ fn build_hdr_resources(device: &wgpu::Device, tile_dims: wgpu::Extent3d) -> HdrR layout: Some(&layout), module: &shader, entry_point: Some("main"), - compilation_options: Default::default(), + // Switch the shared shader into its headroom-preserving HDR mode. Every other pipeline + // leaves this unset, so the override keeps its default 0 and their behavior is unchanged. + compilation_options: wgpu::PipelineCompilationOptions { + constants: &[("IS_HDR", 1.0)], + ..Default::default() + }, cache: None, }); let tile_output_texture = device.create_texture(&wgpu::TextureDescriptor { @@ -2214,65 +2188,33 @@ fn process_and_get_dynamic_image_inner( mod tests { use super::*; - /// Guards the HDR shader substitution anchors. If a future edit to `shader.wgsl` moves any - /// of these strings, the `.replace()` silently no-ops and HDR breaks (clipped headroom or a - /// storage-format mismatch). This test fails loudly so `hdr_shader_source` gets updated. + /// HDR mode is a compile-checked `IS_HDR` override in the shared shader, so the only thing left + /// to string-replace is the storage texture format (WGSL can't make that an override). Guards + /// that the format anchor still exists and the override gate is still wired; the GPU E2E test + /// below proves the gate actually preserves headroom at runtime. #[test] - fn hdr_shader_substitutions_apply() { + fn hdr_shader_source_promotes_storage_format() { let src = include_str!("shaders/shader.wgsl"); - // The SDR shader must still contain the anchors we substitute on. + // The one remaining textual substitution: the storage format anchor must stay present. assert!( src.contains("rgba8unorm, write>"), - "shader.wgsl storage anchor moved; update hdr_shader_source()" - ); - assert!( - src.contains("linear_to_srgb(composite_rgb_linear)"), - "shader.wgsl sRGB-encode anchor moved; update hdr_shader_source()" + "shader.wgsl storage-format anchor moved; update hdr_shader_source()" ); + // HDR behavior is now an override, not a substitution — sanity-check it's still wired. assert!( - src.contains("clamp(final_rgb, vec3(0.0), vec3(1.0))"), - "shader.wgsl store-clamp anchor moved; update hdr_shader_source()" + src.contains("override IS_HDR"), + "IS_HDR override removed; HDR mode would no longer switch on" ); assert!( - src.contains("let dither_amount = 1.0 / 255.0;"), - "shader.wgsl dither anchor moved; update hdr_shader_source()" + src.contains("IS_HDR != 0"), + "IS_HDR gate removed; the shared shader no longer branches on HDR mode" ); let hdr = hdr_shader_source(); - assert!(hdr.contains("rgba16float, write>")); assert!( - !hdr.contains("rgba8unorm, write>"), + hdr.contains("rgba16float, write>") && !hdr.contains("rgba8unorm, write>"), "storage not promoted to rgba16float" ); - assert!(hdr.contains("linear_to_srgb_extended(composite_rgb_linear)")); - assert!( - !hdr.contains("clamp(final_rgb, vec3(0.0), vec3(1.0))"), - "headroom-killing store clamp still present in HDR shader" - ); - assert!(hdr.contains("max(final_rgb, vec3(0.0))")); - assert!( - hdr.contains("let dither_amount = 0.0;"), - "dither not disabled in HDR shader" - ); - - // Curve-stage headroom anchors (the other half of the clip the GPU E2E test exposed). - assert!( - src.contains(" if (count < 2u) {\n return false;\n }"), - "shader.wgsl is_default_curve anchor moved; update hdr_shader_source()" - ); - assert!( - src.contains( - " if (x >= local_points[count - 1u].x) { return local_points[count - 1u].y / 255.0; }" - ), - "shader.wgsl apply_curve top-clamp anchor moved; update hdr_shader_source()" - ); - assert!( - src.contains(" if (max_comp > 1.0) { final_color = final_color / max_comp; }"), - "shader.wgsl curve-normalize anchor moved; update hdr_shader_source()" - ); - assert!(hdr.contains(" if (count < 2u) {\n return true;\n }")); - assert!(hdr.contains("+ (val - local_points[count - 1u].x / 255.0)")); - assert!(hdr.contains("if (max_comp > 1.0e30)")); } /// End-to-end GPU test: runs the REAL pipeline on a headless device, twice on the same input diff --git a/src-tauri/src/shaders/shader.wgsl b/src-tauri/src/shaders/shader.wgsl index cd9e851cbc..3253548fdf 100644 --- a/src-tauri/src/shaders/shader.wgsl +++ b/src-tauri/src/shaders/shader.wgsl @@ -198,6 +198,13 @@ const HSL_RANGES: array = array( @group(0) @binding(1) var output_texture: texture_storage_2d; @group(0) @binding(2) var adjustments: AllAdjustments; +// HDR export sets this to 1 via a pipeline-overridable constant (see build_hdr_resources). The +// default 0 keeps every other pipeline byte-identical to before, and gates the few places where +// the HDR (rgba16float) path must preserve highlight headroom instead of clamping to [0, 1]. The +// output texture's storage FORMAT can't be an override in WGSL, so that one line is still swapped +// textually when the HDR shader is built (hdr_shader_source). +override IS_HDR: i32 = 0; + @group(0) @binding(3) var mask_textures: texture_2d_array; @group(0) @binding(4) var lut_texture: texture_3d; @@ -342,7 +349,13 @@ fn apply_curve(val: f32, points: array, count: u32) -> f32 { var local_points = points; let x = val * 255.0; if (x <= local_points[0].x) { return local_points[0].y / 255.0; } - if (x >= local_points[count - 1u].x) { return local_points[count - 1u].y / 255.0; } + if (x >= local_points[count - 1u].x) { + // Above the top control point: SDR snaps to the point's value; HDR passes the headroom + // excess through, so values above diffuse white survive (identity curve => value unchanged). + let top_y = local_points[count - 1u].y / 255.0; + let excess = val - local_points[count - 1u].x / 255.0; + return top_y + select(0.0, excess, IS_HDR != 0); + } for (var i = 0u; i < 15u; i = i + 1u) { if (i >= count - 1u) { break; } let p1 = local_points[i]; @@ -1196,7 +1209,9 @@ fn no_tonemap(c: vec3) -> vec3 { fn is_default_curve(points: array, count: u32) -> bool { if (count < 2u) { - return false; + // SDR: a <2-point curve isn't "default". HDR: treat it as default so the non-normalizing + // branch is taken below and highlight headroom isn't pulled back into gamut. + return IS_HDR != 0; } var is_identity = true; @@ -1229,7 +1244,9 @@ fn apply_all_curves(color: vec3, luma_curve: array, luma_curve_c var final_color: vec3; if (luma_graded > 0.001) { final_color = color_graded * (luma_target / luma_graded); } else { final_color = vec3(luma_target); } let max_comp = max(final_color.r, max(final_color.g, final_color.b)); - if (max_comp > 1.0) { final_color = final_color / max_comp; } + // SDR renormalizes RGB-curve output back into gamut; HDR leaves headroom (threshold ~never). + let max_comp_limit = select(1.0, 1.0e30, IS_HDR != 0); + if (max_comp > max_comp_limit) { final_color = final_color / max_comp; } return final_color; } else { return vec3(apply_curve(color.r, luma_curve, luma_curve_count), apply_curve(color.g, luma_curve, luma_curve_count), apply_curve(color.b, luma_curve, luma_curve_count)); @@ -1665,14 +1682,14 @@ fn main(@builtin(global_invocation_id) id: vec3) { if (adjustments.global.tonemapper_mode == 1u) { base_srgb = agx_full_transform(composite_rgb_linear); } else if (is_raw == 1u) { - var srgb_emulated = linear_to_srgb(composite_rgb_linear); + var srgb_emulated = select(linear_to_srgb(composite_rgb_linear), linear_to_srgb_extended(composite_rgb_linear), IS_HDR != 0); const BRIGHTNESS_GAMMA: f32 = 1.1; srgb_emulated = pow(srgb_emulated, vec3(1.0 / BRIGHTNESS_GAMMA)); const CONTRAST_MIX: f32 = 0.75; let contrast_curve = srgb_emulated * srgb_emulated * (3.0 - 2.0 * srgb_emulated); base_srgb = mix(srgb_emulated, contrast_curve, CONTRAST_MIX); } else { - base_srgb = linear_to_srgb(composite_rgb_linear); + base_srgb = select(linear_to_srgb(composite_rgb_linear), linear_to_srgb_extended(composite_rgb_linear), IS_HDR != 0); } var final_rgb = apply_all_curves(base_srgb, @@ -1728,8 +1745,10 @@ fn main(@builtin(global_invocation_id) id: vec3) { } } - let dither_amount = 1.0 / 255.0; + let dither_amount = select(1.0 / 255.0, 0.0, IS_HDR != 0); final_rgb += dither(id.xy) * dither_amount; - textureStore(output_texture, id.xy, vec4(clamp(final_rgb, vec3(0.0), vec3(1.0)), original_alpha)); + // SDR clamps to [0,1]; HDR only floors at 0 so values above diffuse white survive to readback. + let out_rgb = select(clamp(final_rgb, vec3(0.0), vec3(1.0)), max(final_rgb, vec3(0.0)), IS_HDR != 0); + textureStore(output_texture, id.xy, vec4(out_rgb, original_alpha)); } From c399b2753d67c54f39568f7448339295356a8b7a Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 14:50:18 +0200 Subject: [PATCH 18/20] Remove working docs (plan/worklog/report); content consolidated into the PR description --- HDR_REPORT.md | 145 ------------------------------------------------- HDR_WORKLOG.md | 81 --------------------------- PLAN.md | 80 --------------------------- 3 files changed, 306 deletions(-) delete mode 100644 HDR_REPORT.md delete mode 100644 HDR_WORKLOG.md delete mode 100644 PLAN.md diff --git a/HDR_REPORT.md b/HDR_REPORT.md deleted file mode 100644 index 152cb404ee..0000000000 --- a/HDR_REPORT.md +++ /dev/null @@ -1,145 +0,0 @@ -# HDR (PQ/HLG) AVIF + JPEG XL export — final report - -Branch `feat/hdr-export` on fork `KaiserRuben/RapidRAW`. Platform verified: **macOS arm64**, -rustc/cargo 1.96.0. License unchanged (**AGPL-3.0**; the new GPL-3.0 JXL bindings are -copyleft-compatible with AGPL-3.0). - -## Status: gate GREEN - -The automated harness asserts all four "definition of done" criteria and passes with **exact** -numbers for AVIF (10 & 12 bit) and JPEG XL, plus a GPU end-to-end headroom test: - -| check | AVIF 10-bit | AVIF 12-bit | JXL (PQ) | -|---|---|---|---| -| (1) CICP tags | primaries **9**, transfer **16** (PQ) / **18** (HLG), matrix **0**, full-range **1** | same | primaries Rec.2100, transfer **PQ** | -| (2) bit depth (from av1C / codestream) | **10** | **12** | 32-bit float (≥10) | -| (3) 203-nit reference white | code **594** (exp 594, ±2) | **2378** | **0.58069** | -| (4) headroom (406 / 812 nit) | **669 / 746** (strictly increasing) | **2679 / 2986** | **0.6542 / 0.7291** | - -GPU end-to-end (real pipeline, white at +2 stops → linear 4.0): SDR path clamps to byte 255; -HDR path recovers linear **3.997** — headroom preserved through the *entire* look stage. - -## How it works (data flow) - -`linear scene-referred (diffuse white = 1.0, headroom > 1.0)` → -**GPU HDR pipeline** (rgba16float, headroom-preserving) → readback f16 → invert extended sRGB → -`DynamicImage::ImageRgba32F (linear)` → **encoder**: Rec.709→Rec.2020 matrix → PQ/HLG OETF with -the 203-nit anchor (`L = linear·203/10000`) → quantize 10/12-bit full-range → AVIF/JXL + CICP tags. - -The 8-bit SDR path is **completely untouched** — HDR uses a *separate, lazily-built* rgba16float -pipeline + textures, so SDR output is byte-for-byte unchanged and SDR-only sessions allocate -nothing extra. - -## Files changed and why - -**Backend (`src-tauri/`):** -- `Cargo.toml` — add `rav1e 0.8.1` + `avif-serialize 0.8.9` (AVIF, always on); `jpegxl-rs 0.14` + - `jpegxl-sys 0.12` behind optional `hdr_jxl` feature (JXL). New `[features] hdr_jxl`. -- `src/hdr.rs` *(new)* — single source of the color science: pinned ST.2084 PQ inverse-EOTF/EOTF, - BT.2100 HLG OETF, extended-sRGB↔linear (exact inverse of the shader), BT.2087 709→2020 matrix, - CICP mapping, full-range quantization, synthetic test image, `encode_avif_hdr`, `encode_jxl_hdr`, - and unit tests pinning the math. -- `src/lib.rs` — `pub mod hdr;`. -- `src/gpu_processing.rs` — the 8-bit ceiling fix. Extracted `build_main_bgl(device, format)` - (shared by SDR/HDR so the layouts can't drift); `hdr_shader_source()` derives the HDR shader - from `shader.wgsl` at runtime via targeted substitutions (storage rgba8unorm→rgba16float; - clamping→extended sRGB encode; drop the store clamp; disable dither; **+3 curve-stage fixes** so - the tone curves don't re-clip headroom to [0,1]); lazily-built `HdrResources`; `run()` gains - `output_hdr` and selects pipeline/textures/bytes-per-pixel; `read_texture_data_roi` parameterized; - `process_and_get_dynamic_image_hdr` returns linear `ImageRgba32F`. Tests: shader-substitution - guard + GPU end-to-end headroom. -- `src/export_processing.rs` — `ExportSettings` gains `bit_depth`/`transfer_function`/`primaries` - (+ `hdr_enabled()`/`export_bit_depth()`); `encode_image_to_bytes` routes avif/jxl to the HDR - encoders when HDR is requested; `process_image_for_export[_pipeline]` thread `output_hdr` to the - HDR GPU path. -- `tests/hdr_export.rs` *(new)* — the gate. Parses nclx + av1C **from the file bytes** (independent - of the encoder), decodes AVIF with `avifdec` (libavif) and JXL with jxl-oxide, asserts (1)–(4) - against **independent** ST.2084 ground-truth literals (not encoder-derived — see Limitations). - -**Frontend (`src/`):** `hooks/useExportSettings.ts`, `components/ui/ExportImportProperties.tsx`, -`components/panel/right/ExportPanel.tsx` (Bit Depth / Transfer / Primaries controls for AVIF/JXL), -`i18n/locales/en.json`. - -**Docs:** `PLAN.md`, `HDR_WORKLOG.md`, this report. - -## New dependencies + system libs - -- **AVIF (always on, pure Rust):** `rav1e` + `avif-serialize`. No runtime system libs. Build-time: - **`nasm`** is required by rav1e's `asm` feature (`brew install nasm`). To build without nasm, drop - the `asm` feature in `Cargo.toml` (slower encode, pure Rust). -- **JPEG XL (optional `hdr_jxl` feature):** `jpegxl-rs` + `jpegxl-sys` link **system libjxl**. - `brew install jpeg-xl`, and because it is keg-only, set - `PKG_CONFIG_PATH="$(brew --prefix jpeg-xl)/lib/pkgconfig:$PKG_CONFIG_PATH"` at build time. - For distribution the libjxl dylibs must be bundled (or build jpegxl-sys `vendored`, which compiles - libjxl from source via cmake). Default builds do **not** require libjxl. - -## Run the verification harness - -```sh -cd src-tauri -# AVIF only (needs libavif's `avifdec` on PATH for the pixel checks: `brew install libavif`): -cargo test --test hdr_export -# Everything incl. JXL + GPU end-to-end + pinned math: -PKG_CONFIG_PATH="$(brew --prefix jpeg-xl)/lib/pkgconfig:$PKG_CONFIG_PATH" cargo test --features hdr_jxl -``` -The harness exits non-zero on any failure. The GPU end-to-end test skips gracefully if no GPU -adapter is available (headless CI). - -## Known limitations / uncertainty (read before trusting the look) - -1. **Headroom-through-curves is preserved by a deliberate, not perceptually-validated, choice.** - RapidRAW's look pipeline is display-referred; the tone curves clamp to [0,1]. The HDR shader - substitutions make curves pass values *above the top control point* through linearly and disable - the >1.0 renormalization. For default/identity curves this is exactly right (verified: linear - 4.0 survives). For a user-set tone/RGB curve the behaviour above diffuse white is a reasonable - extrapolation but **has not been visually checked** — flagging loudly as the brief requested. -2. **AVIF matrix is now user-selectable** (Identity/RGB *or* YCbCr BT.2020-NCL / BT.709-NCL), - alongside chroma subsampling (4:4:4/4:2:2/4:2:0), full/limited range, primaries (sRGB / Display-P3 - / Rec.2020), reference-white nits, HLG peak, and MaxCLL/MaxFALL mastering metadata — exposed in - the export panel via friendly presets + an Advanced section. The YCbCr/subsampling/range/primaries - paths are covered by **colored-pixel** round-trip tests (not just neutral), so the matrix/chroma - math is numerically verified, not just assumed. `HdrEncodeConfig::default()` is still Identity/ - 4:4:4/full so the originally-verified path is byte-identical. -3. **HLG is tag- and monotonicity-verified only.** HLG is relative/scene-referred, so there is no - single absolute-nit anchor check; `HLG_PEAK_RATIO = 12` is a reasonable default, not tuned. -4. **Watermarking an HDR export clamps the watermarked pixels to SDR** (the overlay runs in 8-bit). - HDR export *without* a watermark is unaffected. -5. **No EXIF in AVIF/JXL** — the metadata writer only handles JPEG/PNG/TIFF (pre-existing); HDR - files carry CICP tags but not EXIF. -6. **Tiling**: the HDR path reuses the SDR tiling logic (parameterized), exercised on small images - in the harness; very large multi-tile HDR images use the same code but weren't size-stressed. -7. **4:2:2 AVIF is single-tile only (resolution-capped).** rav1e encodes AV1 4:2:2 (the "Professional" - profile) conformantly *only as a single AV1 tile* — any multi-tile 4:2:2 frame (even a forced 2x1 - split) produces a bitstream strict decoders (dav1d/libavif) reject with "Decoding of color planes - failed". 4:2:0 and 4:4:4 tile correctly at any size and are verified at 6177x4118. So 4:2:2 AVIF is - bounded to AV1's single-tile envelope: **long edge ≤ 3072 px** (= √(4096·2304), i.e. ≤ 4096 px wide - AND ≤ 9,437,184 px in area for any aspect). Selecting 4:2:2 in the export panel **auto-applies this - resize cap** (visible and overridable in the Resize controls); removing the cap shows a warning, and - an over-size 4:2:2 export **fails loudly** — the backend never silently downgrades the chosen - chroma. Limits live in `hdr::AVIF_422_MAX_WIDTH` / `AVIF_422_MAX_PIXELS`, mirrored in - `ExportPanel.tsx`. Tests: `avif_422_single_tile_roundtrips_oversize_errors`, - `avif_large_frame_is_av1_conformant`. - -## Needs a human + real HDR display (out of scope for automated checks) - -- View exported PQ AVIF/JXL on a true HDR display (Apple XDR, HDR monitor): confirm diffuse white - sits ~203 nits, highlights extend above it without banding/clipping, and there is no color cast. -- Sign off on the **above-diffuse-white look** (limitation #1) for real edits with curves/grading. -- Confirm matrix=0 (identity) AVIF decodes correctly in the target viewers (macOS Preview/Photos, - Chrome, Safari); decide whether matrix=9 is needed (limitation #2). -- Check HLG output on an HLG display. -- Run real RAW files with genuine sensor highlight headroom (not just the synthetic patches). - -## Build the HDR-capable binary - -```sh -npm install - -# AVIF HDR (always available; needs nasm at build time): -npm run tauri build - -# + JPEG XL HDR (needs system libjxl): -brew install jpeg-xl -PKG_CONFIG_PATH="$(brew --prefix jpeg-xl)/lib/pkgconfig:$PKG_CONFIG_PATH" \ - npm run tauri build -- --features hdr_jxl -``` diff --git a/HDR_WORKLOG.md b/HDR_WORKLOG.md deleted file mode 100644 index f8c0b66b3c..0000000000 --- a/HDR_WORKLOG.md +++ /dev/null @@ -1,81 +0,0 @@ -# HDR Export — Worklog - -Running log for the `feat/hdr-export` branch: add true HDR (PQ/HLG) AVIF + JXL export, -BT.2020-tagged, with highlight headroom preserved. - -## Phase 0 — Fork, clone, baseline build ✅ -- Forked `CyberTimon/RapidRAW` → `KaiserRuben/RapidRAW`, cloned, branch `feat/hdr-export`. -- Toolchain installed on macOS arm64: rustup → rustc/cargo **1.96.0** (edition 2024 OK, - repo pins rust-version 1.95). `cmake 4.3.3`, `nasm 3.01` (for C encoder deps) via brew. -- License: **AGPL-3.0** — kept intact; new files carry no license-stripping. -- `build.rs` auto-downloads `libonnxruntime.dylib` (macos-aarch64, 33M) from HuggingFace and - sha256-verifies it at build time — so the `ort` `load-dynamic` runtime lib is handled by the - build itself (no manual ONNX setup needed). -- **Baseline `cargo build` (debug): PASS** in 5m12s, 17 warnings, 0 errors. `npm install`: OK. - -## Phase 1 — Recon (confirmed against checkout) ✅ -The 8-bit ceiling is exactly where the brief said, and the headroom is clipped *earlier* than -the final store. Confirmed line numbers: - -**`src-tauri/src/shaders/shader.wgsl`** -- L198: `output_texture: texture_storage_2d` — 8-bit storage output. -- L1668 / L1675: `linear_to_srgb(composite_rgb_linear)` — the **clamping** sRGB encode - (`linear_to_srgb` clamps input to [0,1] at L229). **This is the real headroom killer** — - values above diffuse white (linear > 1.0) are clamped to 1.0 here, *before* the final store. -- L1732: `+ dither(id.xy) * (1.0/255.0)` — 8-bit dither (would add PQ-code noise; skip for HDR). -- L1734: `clamp(final_rgb, 0.0, 1.0)` then `textureStore` — second clamp at the store. -- Internals are linear; `srgb_to_linear` (L220) is the exact inverse of `linear_to_srgb_extended` - (L237) for c≥0. So: encode with `_extended`, read back, `srgb_to_linear` on CPU → recover - linear light with headroom. - -**`src-tauri/src/gpu_processing.rs`** -- Pipeline is f16 end-to-end except the final texture: input upload `to_rgba_f16` (L484); - intermediates `Rgba16Float` (L582…L1762); **`blur.wgsl`/`flare.wgsl` already use - `texture_storage_2d`** → rgba16float storage write is proven on this Metal - backend (de-risks the format change). -- `GpuProcessor.tile_output_texture` (created L997, fmt `Rgba8Unorm` L1003) and `output_texture` - (L1026/L1032) — both bound to shader.wgsl's storage output. `main_bgl` hardcodes the storage - binding format `Rgba8Unorm` at **L796**. -- `main_pipeline` (L910) from `shader.wgsl` (L777). -- `run()` (L1076) ALWAYS tiles; main compute writes to `tile_output_texture`; `read_texture_data_roi` - (L414, hardcodes `4 * width` bytes/row) reads it back; assembled into `final_pixels: Vec` - (`* 4` at L1558/1562) → `processed_pixels` → `DynamicImage::ImageRgba8` (L2016). - -**`src-tauri/src/export_processing.rs`** -- `ExportSettings` struct (L58) — serde camelCase; needs new HDR fields. -- `encode_image_to_bytes` (L388): jxl → `jxl_encoder` hardwired Rgb8/Rgba8; avif → - `image::ImageFormat::Avif` (ravif, 8-bit, no CICP); png/tiff → `to_rgb16()` on 8-bit data. -- `save_image_with_metadata` (L283) calls `encode_image_to_bytes(image, &extension, jpeg_quality)`. -- `process_image_for_export_pipeline` (L214) → `process_and_get_dynamic_image` (GPU). - -## Data contract decided -- **GPU/readback (HDR path):** HDR shader variant (string-substituted from `shader.wgsl`): - `rgba8unorm`→`rgba16float`, `linear_to_srgb`→`linear_to_srgb_extended`, drop the [0,1] store - clamp (keep `max(·,0)`), zero the dither. Read back rgba16float (8 B/px), f16→f32, then - `srgb_to_linear` on CPU → `DynamicImage::ImageRgba32F` in **LINEAR scene-referred light, - diffuse white = 1.0, headroom > 1.0 preserved**. SDR path is byte-for-byte UNTOUCHED (separate, - lazily-created HDR pipeline + textures + BGL). -- **Encoder (HDR path):** takes linear ImageRgba32F → optional Rec.709→Rec.2020 primaries matrix - → PQ inverse-EOTF with **203-nit anchor** (`L = linear * 203/10000`) → quantize to 10/12-bit - full-range → AVIF/JXL with CICP primaries=9, transfer=16(PQ)/18(HLG), matrix=9, full_range=1. - -## Phase 1.5 — Verification harness (gate) ✅ -`src-tauri/tests/hdr_export.rs` + `hdr.rs` unit tests. Tags + bit depth parsed from raw bytes; -AVIF pixels decoded by `avifdec` (independent of the encoder), JXL by jxl-oxide. Assertions use -**independent** ST.2084 ground-truth literals (de-circularized after an audit caught the original -expected-values being derived from the encoder under test). - -## Phase 2 — Implementation ✅ -- Encoder spike: **GO** for both. AVIF via pure-Rust `rav1e` + `avif-serialize` (no C deps); - JXL via `jpegxl-rs` + system libjxl (optional `hdr_jxl` feature). -- 2.1/2.2 GPU: separate lazily-built rgba16float pipeline (SDR untouched). Headroom fix needed - **five** shader substitutions, not two — an audit-driven GPU end-to-end test revealed the - tone-curve stage was *still* clamping to [0,1] (the deeper half of the bug the brief warned - about). With the curve fixes, the GPU recovers linear 3.997 (~4.0) end-to-end. -- 2.3 encoders + 2.4 settings/command/UI wired through `encode_image_to_bytes` and the export panel. - -## Result -Gate GREEN: AVIF 10/12-bit + JXL, all four criteria with exact codes (594/669/746, 2378/2679/2986, -JXL 0.5807). Full details, limitations, human-sign-off list, and build commands in **HDR_REPORT.md**. - - diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index ed5e7f0113..0000000000 --- a/PLAN.md +++ /dev/null @@ -1,80 +0,0 @@ -# PLAN — HDR (PQ/HLG) AVIF + JXL export - -## Architecture decision (why dual-pipeline, CPU PQ) - -1. **Keep SDR byte-identical.** The brief forbids altering SDR output without asking. The 8-bit - storage texture's HW quantization can't be byte-reproduced after an f16 round-trip, so instead - of converting the single pipeline I add a **separate, lazily-created HDR pipeline + textures + - BGL** (Rgba16Float). The existing rgba8unorm path is left completely untouched and is used for - every SDR format and the interactive display. - -2. **Color science lives on the CPU, in one testable function.** The GPU HDR variant outputs the - *same look* as SDR (curves/LUT/grain run in the same sRGB space) but encoded with the - *extended* sRGB OETF (no upper clamp) and no dither. On readback we invert that - (`srgb_to_linear`) to get linear scene-referred light with headroom, as `ImageRgba32F`. The - PQ/HLG OETF + primaries matrix + quantization happen in Rust where the harness can unit-test - them against pinned ST.2084 constants. This keeps the GPU change minimal and the math verifiable. - -## Edit list (commit per item) - -### C-1 Verification harness FIRST (gate) — `src-tauri/tests/hdr_export.rs` + helpers -- New module `src-tauri/src/hdr.rs` (pub) holding the pure color-science fns so both the encoder - and the test use ONE implementation: - - `pq_inverse_eotf(l_norm: f32) -> f32` (ST.2084 constants pinned from brief). - - `srgb_extended_to_linear(c: f32) -> f32` (exact inverse of shader `linear_to_srgb_extended`). - - `rec709_to_rec2020_linear([f32;3]) -> [f32;3]` (Bradford-adapted matrix, white-preserving). - - `REFERENCE_WHITE_NITS = 203.0`, `PQ_MAX_NITS = 10000.0`. -- Integration test asserts, exiting non-zero on failure: - 1. **Tags:** parse the raw ISOBMFF `colr`/`nclx` box from the AVIF bytes → - primaries==9, transfer==16 (and a 18/HLG variant), matrix==9, full_range==1. JXL: decode and - read reported primaries/transfer. - 2. **Bit depth** == 10, repeat == 12. - 3. **Reference white:** linear 1.0 patch → decoded PQ code ≈ 0.5797·1023 = **593** (±2). - 4. **Headroom:** linear 2.0 and 4.0 patches → strictly-increasing distinct codes above the - diffuse-white code (and below full scale). -- Synthetic input built in `hdr.rs` (`fn synthetic_linear_image() -> ImageBuffer>`): - 203-nit/diffuse-white flat patch (=1.0), 0→1 ramp, patches at 2.0 and 4.0. -- A pure-math unit test pins `pq_inverse_eotf(0.0203) ≈ 0.580` independent of any encoder. - -### C-2 Encoder deps + HDR encode functions — `src-tauri/Cargo.toml`, `export_processing.rs` -- Add encoder crates per the spike verdict (expected: `rav1e` + `avif-serialize` for AVIF — pure - Rust, no C dep; `jpegxl-rs` + system `libjxl` (brew `jpeg-xl`) for JXL). Confirm before adding. -- `hdr.rs`: `encode_avif_hdr(img: &Rgba32FImage, bit_depth, transfer, primaries) -> Vec` and - `encode_jxl_hdr(...)`. These do: (optional) 709→2020, PQ/HLG OETF, quantize, encode + tag CICP. -- `encode_image_to_bytes` gains an `&ExportSettings`-derived color config; avif/jxl arms branch to - the HDR encoders when `bit_depth > 8` / `transfer != sRGB`. SDR arms unchanged. - -### C-3 GPU HDR output path — `gpu_processing.rs`, `shader.wgsl` (via runtime string-substitution) -- Build HDR shader source at runtime: `include_str!("shaders/shader.wgsl")` then - `.replace("rgba8unorm, write>", "rgba16float, write>")`, - `.replace("linear_to_srgb(composite_rgb_linear)", "linear_to_srgb_extended(composite_rgb_linear)")` - (hits L1668+L1675), `.replace("clamp(final_rgb, vec3(0.0), vec3(1.0))", - "max(final_rgb, vec3(0.0))")`, `.replace("let dither_amount = 1.0 / 255.0;", - "let dither_amount = 0.0;")`. -- `GpuProcessor` gains `hdr: Option` (BGL with Rgba16Float storage binding, pipeline, - `tile_output_texture` Rgba16Float), created lazily on first HDR export. -- `read_texture_data_roi` + the tile-assembly in `run()` parameterized by `bytes_per_pixel` - (4 for SDR, 8 for HDR f16). `run()` gains an output-mode arg; HDR returns the raw f16 bytes. -- `process_and_get_dynamic_image[_inner]` gains an HDR flag; for HDR, decode f16→f32, apply - `srgb_to_linear` per channel → `DynamicImage::ImageRgba32F`. Existing callers default to SDR. - -### C-4 Settings + command plumbing + UI — `export_processing.rs`, frontend -- `ExportSettings`: add `bit_depth: u8` (8/10/12), `transfer_function: enum {Srgb,Pq,Hlg}`, - `primaries: enum {Srgb,Bt2020}` (serde camelCase, `#[serde(default)]` for back-compat). -- Thread through the export Tauri command → `process_image_for_export_pipeline` (HDR GPU path) - → `encode_image_to_bytes` (HDR encoders). -- React/TS export panel: add Bit Depth + Transfer + Primaries controls, shown for AVIF/JXL. - Follow existing export-settings component patterns. - -## Open questions / risks -- **Encoder libs**: pending spike. If `jpegxl-rs` can't cleanly set PQ/Rec2020 on this platform, - document the gap and ship AVIF-HDR first (brief explicitly allows documenting a JXL gap). -- **matrix_coefficients=9 (YCbCr)** vs 0 (identity/RGB): targeting 9 + 4:4:4 full-range for HDR - compatibility (Apple/Chrome). Harness decodes to RGB; neutral patches are matrix-invariant so - the 593 target holds regardless. -- **Primaries**: tagging BT.2020 while pixels are Rec.709 would mis-saturate real images, so we - apply the 709→2020 matrix. Neutral harness patches are unchanged by it, so PQ-code asserts hold. -- **Tiling for very large HDR images**: run()'s tile path will be parameterized; if any subtlety - surfaces for multi-tile HDR it'll be flagged rather than silently capped. -- **Perceptual on-display correctness is explicitly OUT OF SCOPE** (human checkpoint). - From 93edcc3ff161946d27dce083a6bb7f3351995304 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Mon, 15 Jun 2026 15:01:53 +0200 Subject: [PATCH 19/20] Default the HDR brightness-metadata switch on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Embed brightness metadata' toggle shipped off while its help text says 'Recommended — safe to leave on'. The values (MaxCLL/MaxFALL) are computed from the actual pixels and the flag is read only by the HDR encode path (SDR ignores it), so default it on to match the copy. An explicit false in a saved preset is still honored. --- src/hooks/useExportSettings.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useExportSettings.ts b/src/hooks/useExportSettings.ts index 298ad9b39a..d63e7f371f 100644 --- a/src/hooks/useExportSettings.ts +++ b/src/hooks/useExportSettings.ts @@ -32,7 +32,9 @@ export function useExportSettings() { const [range, setRange] = useState('full'); const [referenceWhiteNits, setReferenceWhiteNits] = useState(203); const [hlgPeakRatio, setHlgPeakRatio] = useState(12); - const [masteringMetadata, setMasteringMetadata] = useState(false); + // Default on: it embeds accurate, content-derived brightness metadata (MaxCLL/MaxFALL) and is + // only read by the HDR encode path (SDR ignores it), matching the control's "recommended" help. + const [masteringMetadata, setMasteringMetadata] = useState(true); const handleApplyPreset = useCallback((preset: ExportPreset) => { setFileFormat(preset.fileFormat); @@ -61,7 +63,7 @@ export function useExportSettings() { setRange(preset.range ?? 'full'); setReferenceWhiteNits(preset.referenceWhiteNits ?? 203); setHlgPeakRatio(preset.hlgPeakRatio ?? 12); - setMasteringMetadata(preset.masteringMetadata ?? false); + setMasteringMetadata(preset.masteringMetadata ?? true); }, []); const currentSettingsObject = useMemo( From 3266ebfcf457de12cb125425b5ba9b1e0f943037 Mon Sep 17 00:00:00 2001 From: Ruben Kaiser Date: Tue, 16 Jun 2026 19:13:53 +0200 Subject: [PATCH 20/20] Fix CI failures from the HDR export deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HDR encoders added build-time native requirements the CI matrix couldn't satisfy, breaking clippy and the x86_64 / Windows-arm build jobs (ARM Linux and Apple Silicon happened to pass). rav1e asm / nasm rav1e's "asm" fast paths need nasm at build time on x86_64 (missing on the Ubuntu and macOS-intel runners) and fail to link on aarch64-pc-windows-msvc (LNK1181). Keep asm everywhere it works via a per-target dependency (all but aarch64-pc-windows-msvc) and install nasm on the x86_64 build/clippy runners; Windows x86_64 already ships it. asm is a build-time-only speedup with no runtime requirement (rav1e dispatches per-CPU), so this keeps fast AVIF encode for ~all users; Windows-on-ARM falls back to the pure-Rust encode. clippy --all-features enabled the optional hdr_jxl feature, which links system libjxl (>= 0.11.2); the Ubuntu runners ship far older libjxl, so the job died compiling jpegxl-sys. Build with `--features hdr_jxl,jpegxl-rs/docs` instead: the `docs` flag flips jpegxl-sys's build script to its no-op branch (no link), so clippy still type-checks the full surface including the hdr_jxl module without any system libjxl. Windows MSI version error The matrix sets `builds-args: --bundles nsis`, but the reusable workflow read `matrix.args`, so the flag was never passed and tauri also built an MSI bundle — which rejects the non-numeric `1.5.7-hdr` pre-release. Read `matrix.builds-args` (falling back to `matrix.args` for Android) so Windows builds NSIS only, as intended; the MSI was never uploaded anyway (the artifact step only collects *.exe). Clippy findings, surfaced once clippy could reach the crate: - removed a dead `MAX_MASK_BINDINGS` const added to GpuProcessor::new - #[allow(too_many_arguments)] on GpuProcessor::run (8 args w/ output_hdr) - #[allow(field_reassign_with_default)] on encode_avif_hdr (rav1e idiom) - rewrote a match_like_matches_macro hit in ExportSettings::hdr_enabled --- .github/workflows/build.yml | 8 +++++++- .github/workflows/ci.yml | 2 +- .github/workflows/lint.yml | 7 +++++-- .github/workflows/pr-ci.yml | 2 +- .github/workflows/release.yml | 2 +- src-tauri/Cargo.toml | 10 +++++++--- src-tauri/src/export_processing.rs | 6 +----- src-tauri/src/gpu_processing.rs | 2 +- src-tauri/src/hdr.rs | 3 +++ 9 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b8eb50b6eb..ba4a8a0694 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,7 +77,13 @@ jobs: libssl-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ - patchelf + patchelf \ + nasm + + # nasm assembles rav1e's x86_64 AVIF asm (Apple Silicon uses the built-in assembler). + - name: Install additional system dependencies (macOS) + if: runner.os == 'macOS' && contains(inputs.target, 'x86_64') + run: brew install nasm # ------------- Android Setup ------------- - name: Set up JDK diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f689dd317..5c5d11f1d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: with: platform: ${{ matrix.platform }} target: ${{ matrix.target }} - build-args: ${{ matrix.args }} + build-args: ${{ matrix.builds-args || matrix.args }} asset-prefix: ${{ matrix.asset-prefix }} mobile: ${{ matrix.mobile }} secrets: inherit diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dea8e99de3..9189c119a9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -68,8 +68,11 @@ jobs: libwebkit2gtk-4.1-dev \ libssl-dev \ libayatana-appindicator3-dev \ - librsvg2-dev + librsvg2-dev \ + nasm - name: Clippy working-directory: src-tauri - run: cargo clippy --all-targets --all-features -- -D warnings + # hdr_jxl links system libjxl (too old on the runners); `jpegxl-rs/docs` skips that link so + # clippy still type-checks the hdr_jxl module (plain --all-features would force the link). + run: cargo clippy --all-targets --features hdr_jxl,jpegxl-rs/docs -- -D warnings diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 8227ea5dd0..4ac5034aac 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -44,6 +44,6 @@ jobs: with: platform: ${{ matrix.platform }} target: ${{ matrix.target }} - build-args: ${{ matrix.args }} + build-args: ${{ matrix.builds-args || matrix.args }} asset-prefix: ${{ matrix.asset-prefix }} mobile: ${{ matrix.mobile }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c47901160..807b6fbfa3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: release-id: ${{ github.event.release.id }} platform: ${{ matrix.platform }} target: ${{ matrix.target }} - build-args: ${{ matrix.args }} + build-args: ${{ matrix.builds-args || matrix.args }} asset-name-pattern: '[name]_v[version]_[platform]_[arch][ext]' asset-prefix: ${{ matrix.asset-prefix }} mobile: ${{ matrix.mobile }} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 73d4351c13..4a70be934c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,9 +68,9 @@ imgref = "1.12.1" sysinfo = "0.39.3" # --- HDR export encoders --- -# AVIF (PQ/HLG, 10/12-bit, CICP nclx): pure-Rust AV1 encoder + ISOBMFF muxer. No C/system deps. -# `asm` needs nasm at build time; drop it for a pure-Rust (slower) encode if nasm is unavailable. -rav1e = { version = "0.8.1", default-features = false, features = ["asm", "threading"] } +# AVIF (PQ/HLG, 10/12-bit, CICP nclx): pure-Rust AV1 encoder + ISOBMFF muxer. +# rav1e `asm` (added per-target below) is a build-time-only speedup; needs nasm on x86_64. +rav1e = { version = "0.8.1", default-features = false, features = ["threading"] } avif-serialize = "0.8.9" # JPEG XL (PQ/HLG, >=10-bit float): bindings to system libjxl. Optional (see `hdr_jxl` feature) # because it requires libjxl (`brew install jpeg-xl`) at build+runtime. GPL-3.0-or-later bindings; @@ -83,6 +83,10 @@ jpegxl-sys = { version = "0.12.1", optional = true } # PKG_CONFIG_PATH pointing at its keg-only pkgconfig dir). AVIF HDR export is always available. hdr_jxl = ["dep:jpegxl-rs", "dep:jpegxl-sys"] +# Add asm on every target but aarch64-pc-windows-msvc (rav1e's ARM asm won't link under MSVC). +[target.'cfg(not(all(target_arch = "aarch64", target_os = "windows")))'.dependencies] +rav1e = { version = "0.8.1", default-features = false, features = ["asm"] } + [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] trash = "5.2.6" tauri-plugin-single-instance = "2.4.2" diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 4960fb03a1..ca2768dbff 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -114,11 +114,7 @@ impl ExportSettings { if self.bit_depth < 10 || self.transfer_function == crate::hdr::TransferFunction::Srgb { return false; } - match extension { - "avif" => true, - "jxl" => cfg!(feature = "hdr_jxl"), - _ => false, - } + extension == "avif" || (extension == "jxl" && cfg!(feature = "hdr_jxl")) } /// The clamped HDR bit depth (10 or 12). diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index 782e5eac46..5a430f4299 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -761,7 +761,6 @@ const FLARE_MAP_SIZE: u32 = 512; impl GpuProcessor { pub fn new(context: GpuContext, max_width: u32, max_height: u32) -> Result { let device = &context.device; - const MAX_MASK_BINDINGS: u32 = 1; let blur_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Blur Shader"), @@ -1166,6 +1165,7 @@ impl GpuProcessor { .get_or_init(|| build_hdr_resources(&self.context.device, self.tile_dims)) } + #[allow(clippy::too_many_arguments)] pub fn run( &self, input_texture_view: &wgpu::TextureView, diff --git a/src-tauri/src/hdr.rs b/src-tauri/src/hdr.rs index 54a78dd578..121242116b 100644 --- a/src-tauri/src/hdr.rs +++ b/src-tauri/src/hdr.rs @@ -654,6 +654,9 @@ fn content_light_levels( /// chroma subsampling) so the round-trip is exact for every color; YCbCr converts to /// non-constant-luminance Y'CbCr and supports 4:2:2 / 4:2:0 subsampling. CICP tags /// (primaries / transfer / matrix / range) are written to both the AV1 stream and the container. +// rav1e's EncoderConfig is configured by mutating a Default (its own examples do the same); the +// nested color_description makes a struct-literal initializer far less readable. +#[allow(clippy::field_reassign_with_default)] pub fn encode_avif_hdr( img: &ImageBuffer, Vec>, cfg: &HdrEncodeConfig,