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.lock b/src-tauri/Cargo.lock index 50cae50f1f..65259edafe 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7,6 +7,7 @@ name = "RapidRAW" version = "0.0.0" dependencies = [ "anyhow", + "avif-serialize", "base64 0.22.1", "blake3", "bytemuck", @@ -27,6 +28,8 @@ dependencies = [ "imgref", "include_dir", "jni 0.21.1", + "jpegxl-rs", + "jpegxl-sys", "jxl-encoder", "jxl-oxide", "kamadak-exif", @@ -46,6 +49,7 @@ dependencies = [ "pollster", "quick-xml 0.40.1", "rand 0.10.1", + "rav1e", "rawler", "rayon", "regex", @@ -649,6 +653,31 @@ dependencies = [ "piper", ] +[[package]] +name = "bon" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f04f6fef12d70d42a77b1433c9e0f065238479a6cefc4f5bab105e9873a3c3" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d0bd4c2f75335ad98052a37efb54f428b492f64340257143b3429c8a508fa7b" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "brotli" version = "8.0.2" @@ -3083,6 +3112,28 @@ dependencies = [ "libc", ] +[[package]] +name = "jpegxl-rs" +version = "0.14.0+libjxl-0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7d16fe73fb6d2e16b986392b716409c6ae6fabd35769d66bea391b016cdbc9" +dependencies = [ + "bon", + "byteorder", + "half", + "jpegxl-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "jpegxl-sys" +version = "0.12.1+libjxl-0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00cb4f7ffb45ee4327e9ec6ca1a732f3abef470ee008f7d4be3afb414075cd7d" +dependencies = [ + "pkg-config", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -3897,6 +3948,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "jobserver", + "log", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -5126,6 +5187,7 @@ dependencies = [ "av1-grain", "bitstream-io", "built", + "cc", "cfg-if", "interpolate_name", "itertools", @@ -5133,6 +5195,7 @@ dependencies = [ "libfuzzer-sys", "log", "maybe-rayon", + "nasm-rs", "new_debug_unreachable", "noop_proc_macro", "num-derive", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c86cdba8b3..4a70be934c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,6 +67,26 @@ rgb = "0.8.53" 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. +# 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; +# 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"] + +# 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 06acff300c..6ede0d7ac6 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -72,6 +72,76 @@ 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, 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 { + /// 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; + } + extension == "avif" || (extension == "jxl" && cfg!(feature = "hdr_jxl")) + } + + /// The clamped HDR bit depth (10 or 12). + pub fn export_bit_depth(&self) -> u8 { + if self.bit_depth >= 12 { 12 } else { 10 } + } + + /// 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 { + crate::hdr::HdrEncodeConfig { + bit_depth: self.export_bit_depth(), + transfer: self.transfer_function, + primaries: self.primaries, + matrix: self.matrix, + subsampling: self.chroma_subsampling, + range: self.range, + reference_white_nits: self.reference_white_nits, + hlg_peak_ratio: self.hlg_peak_ratio, + quality: self.jpeg_quality, + mastering_metadata: self.mastering_metadata, + } + } } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -222,6 +292,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); @@ -256,19 +327,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) { @@ -294,7 +379,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, @@ -343,11 +428,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, @@ -357,6 +444,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) @@ -390,13 +478,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")] + { + // 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"))] + { + 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(); @@ -413,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(); @@ -461,6 +570,15 @@ fn encode_image_to_bytes( .map_err(|e| e.to_string())?; } "avif" => { + if hdr { + // 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) .map_err(|e| e.to_string())?; @@ -910,6 +1028,7 @@ pub async fn export_images( &base_image, &main_export_adjustments, &export_settings, + &output_format, &context_clone, &state, is_raw, @@ -1005,6 +1124,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() { @@ -1137,11 +1280,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, _) = @@ -1275,11 +1415,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 f9ce674a45..5a430f4299 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,212 @@ 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 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>") +} + +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"), + // 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 { + 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; @@ -554,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"), @@ -777,129 +983,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 +1154,18 @@ 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)) + } + + #[allow(clippy::too_many_arguments)] pub fn run( &self, input_texture_view: &wgpu::TextureView, @@ -1081,12 +1174,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 +1406,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 +1544,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 +1613,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 +1667,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 +1714,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 +1761,7 @@ pub fn process_and_get_dynamic_image_with_analytics( caller_id, output_to_display, analytics_config, + false, ) } @@ -1628,6 +1775,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 +1936,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,7 +2162,184 @@ 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)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// 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_source_promotes_storage_format() { + let src = include_str!("shaders/shader.wgsl"); + // The one remaining textual substitution: the storage format anchor must stay present. + assert!( + src.contains("rgba8unorm, write>"), + "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("override IS_HDR"), + "IS_HDR override removed; HDR mode would no longer switch on" + ); + assert!( + 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>") && !hdr.contains("rgba8unorm, write>"), + "storage not promoted to rgba16float" + ); + } + + /// 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/src/hdr.rs b/src-tauri/src/hdr.rs new file mode 100644 index 0000000000..121242116b --- /dev/null +++ b/src-tauri/src/hdr.rs @@ -0,0 +1,1086 @@ +//! 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; + +/// 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 +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) +} + +/// 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 { + linear_scene_to_pq_with(linear, REFERENCE_WHITE_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; + +/// 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 { + linear_scene_to_hlg_with(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], + ] +} + +/// 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")] +pub enum TransferFunction { + /// Plain sRGB (the existing SDR path). + #[default] + 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, 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, + /// 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 { + /// 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!( + "AVIF HDR bit depth must be 10 or 12, got {}", + self.bit_depth + )); + } + self.clamp_anchors(); + 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. +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, + ColorPrimaries::DisplayP3 => 12, + }, + 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 | 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, 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] { + 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. +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 +} + +/// 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). +// --------------------------------------------------------------------------------------------- + +/// 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, + } +} + +// --------------------------------------------------------------------------------------------- +// 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) +} + +/// 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`. +/// +/// 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. +// 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, +) -> 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, + }; + + 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; + let h = img.height() as usize; + if w == 0 || h == 0 { + 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 { + 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 = chroma_sampling; + enc.chroma_sample_position = ChromaSamplePosition::Unknown; + enc.still_picture = true; + enc.pixel_range = pixel_range; + enc.color_description = Some(ColorDescription { + 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 cfg.transfer { + TransferFunction::Pq => rav1e::prelude::TransferCharacteristics::SMPTE2084, + TransferFunction::Hlg => rav1e::prelude::TransferCharacteristics::HLG, + TransferFunction::Srgb => unreachable!(), + }, + 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(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 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(); + // 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. 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)) + .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 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 cfg.primaries { + ColorPrimaries::Bt2020 => AvifPrimaries::Bt2020, + ColorPrimaries::Srgb => AvifPrimaries::Bt709, + ColorPrimaries::DisplayP3 => AvifPrimaries::DisplayP3, + }) + .set_transfer_characteristics(match cfg.transfer { + TransferFunction::Pq => AvifTransfer::Smpte2084, + TransferFunction::Hlg => AvifTransfer::Hlg, + TransferFunction::Srgb => unreachable!(), + }) + .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(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)) +} + +/// 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>, + cfg: &HdrEncodeConfig, +) -> 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, + }; + + let cfg = { + // JXL has no AVIF bit-depth constraint; only the luminance anchors need clamping. + let mut c = *cfg; + c.clamp_anchors(); + 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_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); + } + + let color = JxlColorEncoding { + color_space: JxlColorSpace::Rgb, + white_point: JxlWhitePoint::D65, + white_point_xy: [0.3127, 0.3290], + 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 cfg.transfer { + TransferFunction::Pq => JxlTransferFunction::PQ, + TransferFunction::Hlg => JxlTransferFunction::HLG, + TransferFunction::Srgb => unreachable!(), + }, + gamma: 0.0, + 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}"))?; + + 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::*; + + // 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 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(); + 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 9e6e33074c..dc6d44be4d 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 hdr_deghosting; mod image_loader; mod image_processing; @@ -2259,6 +2260,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/src/shaders/shader.wgsl b/src-tauri/src/shaders/shader.wgsl index 7bfb5953b2..a542d03c89 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]; @@ -1198,7 +1211,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; @@ -1231,7 +1246,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)); @@ -1667,14 +1684,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, @@ -1730,8 +1747,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)); } diff --git a/src-tauri/tests/hdr_export.rs b/src-tauri/tests/hdr_export.rs new file mode 100644 index 0000000000..830765cdea --- /dev/null +++ b/src-tauri/tests/hdr_export.rs @@ -0,0 +1,704 @@ +//! 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, 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; + 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 +} + +/// 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, + &identity_cfg(bit_depth, TransferFunction::Pq, ColorPrimaries::Bt2020), + ) + .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. + // 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 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}" + ); + 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 {exp_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, + &identity_cfg(10, TransferFunction::Hlg, ColorPrimaries::Bt2020), + ) + .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, + &identity_cfg(10, TransferFunction::Pq, ColorPrimaries::Srgb), + ) + .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 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" + ); +} + +/// 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::*; + + #[test] + fn jxl_pq_tags_and_refwhite() { + use jxl_oxide::JxlImage; + + let s = hdr::synthetic_linear_image(); + 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())); + std::fs::write(&path, &bytes).unwrap(); + + let image = JxlImage::builder().open(&path).expect("jxl-oxide open"); + // (1) tags: jxl-oxide classifies PQ HDR + assert_eq!( + image.hdr_type(), + Some(jxl_oxide::HdrType::Pq), + "jxl should be tagged PQ; colour_encoding = {:?}", + image.image_header().metadata.colour_encoding + ); + + // (3)+(4) pixels: decode and read the PQ codes back + let render = image.render_frame(0).expect("render frame"); + let fb = render.image_all_channels(); + let w = fb.width(); + let ch = fb.channels(); + let buf = fb.buf(); + let sample = |px: (u32, u32)| -> f32 { + buf[((px.1 * w as u32 + px.0) as usize) * ch] // first (R) channel + }; + // 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); + let w2 = sample(s.rel2_px); + let w4 = sample(s.rel4_px); + assert!( + (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 {EXP_WHITE}), rel2={w2} rel4={w4}"); + } +} diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index aca43197ea..46d2f54d2b 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'; @@ -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, @@ -233,6 +255,24 @@ export default function ExportPanel({ setWatermarkOpacity, preserveFolders, setPreserveFolders, + bitDepth, + setBitDepth, + transferFunction, + setTransferFunction, + primaries, + setPrimaries, + matrix, + setMatrix, + chromaSubsampling, + setChromaSubsampling, + range, + setRange, + referenceWhiteNits, + setReferenceWhiteNits, + hlgPeakRatio, + setHlgPeakRatio, + masteringMetadata, + setMasteringMetadata, handleApplyPreset, currentSettingsObject, } = useExportSettings(); @@ -344,6 +384,278 @@ export default function ExportPanel({ [t], ); + // 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, + 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], + ); + + // 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 = + 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 + // 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 && + avif422MaxLongEdge != null && + !resizeKeeps422Safe + ) { + setEnableResize(true); + setResizeMode('longEdge'); + setResizeValue(avif422MaxLongEdge); + setDontEnlarge(true); + } + }, + [ + fileFormat, + avif422MaxLongEdge, + 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 && 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; + + 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 — 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) => { @@ -390,6 +702,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 +779,7 @@ export default function ExportPanel({ opacity: watermarkOpacity, } : null, + ...hdrSettings, }; const lastExportPath = appSettings?.exportPresets?.find((p) => p.id === '__last_used__')?.lastExportPath; @@ -605,6 +919,185 @@ export default function ExportPanel({ /> )} + {[FileFormats.Avif, FileFormats.Jxl].includes(fileFormat as FileFormats) && ( +
+ {/* Tier 1: a single friendly "Color profile" picker that bundles the HDR fields. */} + + + + + {/* 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 && ( + + )} + + {/* Tier 2: Advanced controls, each with plain-language help. */} + {showAdvancedColor && ( +
+ + {t('export.file.advancedSectionTitle')} + + + handleBitDepthChange(parseInt(v, 10))} + disabled={isExporting} + className="w-56" + /> + + + {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' && ( + <> + + + + {show422CapWarning && ( +
+ + + {t('export.file.chroma422CapWarning', { maxLongEdge: avif422MaxLongEdge })} + +
+ )} + + )} + + + + + + +
+ setReferenceWhiteNits(parseInt(String(e.target.value), 10))} + defaultValue={203} + fillOrigin="min" + suffix="nits" + /> +
+
+ +
+ + + {t('export.file.masteringMetadataHelp')} + +
+ + )} +
+ )} +
+ )} {numImages > 1 && ( @@ -669,6 +1162,19 @@ export default function ExportPanel({ onChange={setDontEnlarge} trackClassName="bg-surface" /> + {show422CapNote && ( +
+ + + {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/components/ui/ExportImportProperties.tsx b/src/components/ui/ExportImportProperties.tsx index a51dff33a5..876bf9d5fc 100644 --- a/src/components/ui/ExportImportProperties.tsx +++ b/src/components/ui/ExportImportProperties.tsx @@ -47,6 +47,24 @@ 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' | '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 { @@ -119,4 +137,13 @@ export interface ExportPreset { watermarkSpacing: number; watermarkOpacity: number; lastExportPath?: string; + 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 1af496dfdd..d63e7f371f 100644 --- a/src/hooks/useExportSettings.ts +++ b/src/hooks/useExportSettings.ts @@ -20,6 +20,21 @@ 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'); + // 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); + // 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); @@ -40,6 +55,15 @@ export function useExportSettings() { setWatermarkScale(preset.watermarkScale); setWatermarkSpacing(preset.watermarkSpacing); setWatermarkOpacity(preset.watermarkOpacity); + 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 ?? true); }, []); const currentSettingsObject = useMemo( @@ -62,6 +86,15 @@ export function useExportSettings() { watermarkScale, watermarkSpacing, watermarkOpacity, + bitDepth, + transferFunction, + primaries, + matrix, + chromaSubsampling, + range, + referenceWhiteNits, + hlgPeakRatio, + masteringMetadata, }), [ fileFormat, @@ -82,7 +115,16 @@ export function useExportSettings() { watermarkScale, watermarkSpacing, watermarkOpacity, - ] + bitDepth, + transferFunction, + primaries, + matrix, + chromaSubsampling, + range, + referenceWhiteNits, + hlgPeakRatio, + masteringMetadata, + ], ); return { @@ -122,7 +164,25 @@ export function useExportSettings() { setWatermarkSpacing, watermarkOpacity, setWatermarkOpacity, + bitDepth, + setBitDepth, + transferFunction, + setTransferFunction, + primaries, + setPrimaries, + matrix, + setMatrix, + chromaSubsampling, + setChromaSubsampling, + range, + setRange, + referenceWhiteNits, + setReferenceWhiteNits, + hlgPeakRatio, + setHlgPeakRatio, + masteringMetadata, + setMasteringMetadata, handleApplyPreset, currentSettingsObject, }; -} \ No newline at end of file +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 4b161b5d9f..9f0cc2015b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -737,7 +737,60 @@ }, "file": { "quality": "Qualität", - "qualityLossless": "Qualität (Verlustfrei)" + "qualityLossless": "Qualität (Verlustfrei)", + "bitDepth": "Bittiefe", + "bitDepth8": "8-Bit (Standard / SDR)", + "bitDepth10": "10-Bit (HDR)", + "bitDepth12": "12-Bit (HDR)", + "transfer": "Helligkeitskodierung (PQ / HLG)", + "primaries": "Primärfarben", + "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": "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)", + "hdr10": "HDR10 — empfohlen", + "hlg": "HLG — Broadcast", + "maxQuality": "HDR — maximale Qualität", + "archival": "HDR — Archiv", + "custom": "Benutzerdefiniert…" + }, + "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": "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", @@ -758,7 +811,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 e9665b760e..b70e3957e9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -737,7 +737,60 @@ }, "file": { "quality": "Quality", - "qualityLossless": "Quality (Lossless)" + "qualityLossless": "Quality (Lossless)", + "bitDepth": "Bit Depth", + "bitDepth8": "8-bit (Standard / SDR)", + "bitDepth10": "10-bit (HDR)", + "bitDepth12": "12-bit (HDR)", + "transfer": "Brightness encoding (PQ / HLG)", + "primaries": "Primaries", + "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": "Start here. Each profile sets everything below for you. Choose Custom only if you want to adjust the details yourself.", + "profiles": { + "sdr": "Standard (SDR)", + "hdr10": "HDR10 — recommended", + "hlg": "HLG — broadcast", + "maxQuality": "HDR — maximum quality", + "archival": "HDR — archival", + "custom": "Custom…" + }, + "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 {{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)", + "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", @@ -758,7 +811,8 @@ "width": "Width" }, "pixels": "pixels", - "resizeToFit": "Resize to Fit" + "resizeToFit": "Resize to Fit", + "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 412843fe67..143633ffc6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -737,7 +737,60 @@ }, "file": { "quality": "Calidad", - "qualityLossless": "Calidad (Sin pérdida)" + "qualityLossless": "Calidad (Sin pérdida)", + "bitDepth": "Profundidad de bits", + "bitDepth8": "8 bits (estándar / SDR)", + "bitDepth10": "10 bits (HDR)", + "bitDepth12": "12 bits (HDR)", + "transfer": "Codificación de brillo (PQ / HLG)", + "primaries": "Primarios", + "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": "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)", + "hdr10": "HDR10 — recomendado", + "hlg": "HLG — difusión", + "maxQuality": "HDR — máxima calidad", + "archival": "HDR — archivo", + "custom": "Personalizado…" + }, + "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": "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", @@ -758,7 +811,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 f513ff84e2..7f7be22745 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -737,7 +737,60 @@ }, "file": { "quality": "Qualité", - "qualityLossless": "Qualité (Sans perte)" + "qualityLossless": "Qualité (Sans perte)", + "bitDepth": "Profondeur de bits", + "bitDepth8": "8 bits (standard / SDR)", + "bitDepth10": "10 bits (HDR)", + "bitDepth12": "12 bits (HDR)", + "transfer": "Encodage de luminosité (PQ / HLG)", + "primaries": "Primaires", + "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": "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)", + "hdr10": "HDR10 — recommandé", + "hlg": "HLG — diffusion", + "maxQuality": "HDR — qualité maximale", + "archival": "HDR — archivage", + "custom": "Personnalisé…" + }, + "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": "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", @@ -758,7 +811,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 0fdb3ac174..db75af1661 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -737,7 +737,60 @@ }, "file": { "quality": "Qualità", - "qualityLossless": "Qualità (Senza Perdita)" + "qualityLossless": "Qualità (Senza Perdita)", + "bitDepth": "Profondità di bit", + "bitDepth8": "8 bit (standard / SDR)", + "bitDepth10": "10 bit (HDR)", + "bitDepth12": "12 bit (HDR)", + "transfer": "Codifica della luminosità (PQ / HLG)", + "primaries": "Primari", + "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": "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)", + "hdr10": "HDR10 — consigliato", + "hlg": "HLG — broadcast", + "maxQuality": "HDR — qualità massima", + "archival": "HDR — archiviazione", + "custom": "Personalizzato…" + }, + "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": "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", @@ -758,7 +811,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 380e80ac6d..34d3fd2ff4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -737,7 +737,60 @@ }, "file": { "quality": "画質", - "qualityLossless": "画質 (ロスレス)" + "qualityLossless": "画質 (ロスレス)", + "bitDepth": "ビット深度", + "bitDepth8": "8ビット (標準 / SDR)", + "bitDepth10": "10ビット (HDR)", + "bitDepth12": "12ビット (HDR)", + "transfer": "明るさのエンコード (PQ / HLG)", + "primaries": "原色", + "hdrHint": "PQはHDR写真の標準で、画面表示やほとんどの共有に最適です。HDRテレビや放送向けに納品する場合のみHLGを選んでください。いずれにせよ、HDRを正しく表示するにはHDRディスプレイが必要です。通常の画面では色あせて見えることがあります。", + "colorProfile": "カラープロファイル", + "colorProfileHint": "ここから始めましょう。各プロファイルが以下のすべてを自動で設定します。詳細を自分で調整したい場合のみ「カスタム」を選んでください。", + "profiles": { + "sdr": "標準 (SDR)", + "hdr10": "HDR10 — 推奨", + "hlg": "HLG — 放送", + "maxQuality": "HDR — 最高画質", + "archival": "HDR — アーカイブ", + "custom": "カスタム…" + }, + "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": "HDRで無地の白いページがどのくらい明るく見えるかを設定します。203 nitsが標準で、上げると全体的に明るい見た目になります。変更が必要だとわかっている場合を除き、既定値のままにしてください。", + "hlgPeakRatio": "ハイライトの余裕 (HLG)", + "hlgPeakRatioHelp": "HLGのハイライトが紙の白をどれだけ上回って明るくなれるかです。既定値はほとんどの素材に適しています。納品仕様で別途指定がない限り、そのままにしてください。", + "masteringMetadata": "明るさのメタデータを埋め込む", + "masteringMetadataHelp": "HDRディスプレイが正確にトーンマッピングできるよう、ファイルに明るさレベル (MaxCLL / MaxFALL) を付与します。推奨 — オンのままで問題ありません。" }, "labels": { "image": "画像", @@ -758,7 +811,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 0aa5a2edcd..cbb580c583 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -737,7 +737,60 @@ }, "file": { "quality": "품질", - "qualityLossless": "품질 (무손실)" + "qualityLossless": "품질 (무손실)", + "bitDepth": "비트 심도", + "bitDepth8": "8비트 (표준 / SDR)", + "bitDepth10": "10비트 (HDR)", + "bitDepth12": "12비트 (HDR)", + "transfer": "밝기 인코딩 (PQ / HLG)", + "primaries": "원색", + "hdrHint": "PQ는 HDR 사진의 표준이며 화면 표시와 대부분의 공유에 가장 적합합니다. HDR TV나 방송용으로 납품할 때만 HLG를 선택하세요. 어느 쪽이든 HDR를 제대로 보려면 HDR 디스플레이가 필요하며, 일반 화면에서는 색이 바랜 것처럼 보일 수 있습니다.", + "colorProfile": "색상 프로파일", + "colorProfileHint": "여기서 시작하세요. 각 프로파일이 아래의 모든 항목을 대신 설정해 줍니다. 세부 항목을 직접 조정하려는 경우에만 사용자 지정을 선택하세요.", + "profiles": { + "sdr": "표준 (SDR)", + "hdr10": "HDR10 — 권장", + "hlg": "HLG — 방송", + "maxQuality": "HDR — 최고 품질", + "archival": "HDR — 보관용", + "custom": "사용자 지정…" + }, + "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": "HDR에서 단색 흰 페이지가 얼마나 밝게 보이는지를 설정합니다. 203 nits가 표준이며, 높이면 전체적으로 더 밝아 보입니다. 변경이 필요하다는 것을 알고 있는 경우가 아니라면 기본값으로 두세요.", + "hlgPeakRatio": "하이라이트 여유 (HLG)", + "hlgPeakRatioHelp": "HLG 하이라이트가 종이 백색 위로 얼마나 더 밝아질 수 있는지입니다. 기본값은 대부분의 소재에 적합합니다. 납품 사양에서 다르게 지정하지 않는 한 그대로 두세요.", + "masteringMetadata": "밝기 메타데이터 포함", + "masteringMetadataHelp": "HDR 디스플레이가 정확하게 톤 매핑할 수 있도록 파일에 밝기 수준 (MaxCLL / MaxFALL)을 태그합니다. 권장 — 켜 둬도 안전합니다." }, "labels": { "image": "이미지", @@ -758,7 +811,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 0b7ad88e18..f747ddebb1 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -777,7 +777,60 @@ }, "file": { "quality": "Jakość", - "qualityLossless": "Jakość (Bezstratna)" + "qualityLossless": "Jakość (Bezstratna)", + "bitDepth": "Głębia bitowa", + "bitDepth8": "8-bitowy (standard / SDR)", + "bitDepth10": "10-bitowy (HDR)", + "bitDepth12": "12-bitowy (HDR)", + "transfer": "Kodowanie jasności (PQ / HLG)", + "primaries": "Barwy podstawowe", + "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": "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)", + "hdr10": "HDR10 — zalecany", + "hlg": "HLG — emisja", + "maxQuality": "HDR — maksymalna jakość", + "archival": "HDR — archiwizacja", + "custom": "Niestandardowy…" + }, + "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": "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", @@ -798,7 +851,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 439d4d99e3..756c093b46 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -737,7 +737,60 @@ }, "file": { "quality": "Qualidade", - "qualityLossless": "Qualidade (Sem Perdas)" + "qualityLossless": "Qualidade (Sem Perdas)", + "bitDepth": "Profundidade de bits", + "bitDepth8": "8 bits (padrão / SDR)", + "bitDepth10": "10 bits (HDR)", + "bitDepth12": "12 bits (HDR)", + "transfer": "Codificação de brilho (PQ / HLG)", + "primaries": "Primárias", + "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": "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)", + "hdr10": "HDR10 — recomendado", + "hlg": "HLG — transmissão", + "maxQuality": "HDR — qualidade máxima", + "archival": "HDR — arquivo", + "custom": "Personalizado…" + }, + "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": "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", @@ -758,7 +811,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 cf11c6bab8..84d8803b5c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -737,7 +737,60 @@ }, "file": { "quality": "Качество", - "qualityLossless": "Качество (без потерь)" + "qualityLossless": "Качество (без потерь)", + "bitDepth": "Битовая глубина", + "bitDepth8": "8 бит (стандарт / SDR)", + "bitDepth10": "10 бит (HDR)", + "bitDepth12": "12 бит (HDR)", + "transfer": "Кодирование яркости (PQ / HLG)", + "primaries": "Основные цвета", + "hdrHint": "PQ — это стандарт для HDR-фотографий и лучший выбор для экранов и большинства способов публикации. Выбирайте HLG, только если готовите материал для HDR-телевидения или вещания. В любом случае для корректного отображения HDR нужен HDR-дисплей; на обычном экране изображение может выглядеть блёклым.", + "colorProfile": "Цветовой профиль", + "colorProfileHint": "Начните отсюда. Каждый профиль настраивает за вас всё, что ниже. Выбирайте «Пользовательский», только если хотите настроить детали самостоятельно.", + "profiles": { + "sdr": "Стандартный (SDR)", + "hdr10": "HDR10 — рекомендуется", + "hlg": "HLG — вещание", + "maxQuality": "HDR — максимальное качество", + "archival": "HDR — архив", + "custom": "Пользовательский…" + }, + "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": "Задаёт, насколько ярко выглядит чистая белая страница в HDR. 203 nits — это стандарт; увеличьте для в целом более яркого вида. Оставьте значение по умолчанию, если не уверены, что его нужно менять.", + "hlgPeakRatio": "Запас в светах (HLG)", + "hlgPeakRatioHelp": "Насколько ярче света HLG могут выходить за бумажный белый. Значение по умолчанию подходит для большинства материалов; оставьте его, если иное не указано в спецификации доставки.", + "masteringMetadata": "Встроить метаданные яркости", + "masteringMetadataHelp": "Помечает файл его уровнями яркости (MaxCLL / MaxFALL), чтобы HDR-дисплеи могли точно выполнять тональное отображение. Рекомендуется — можно безопасно оставить включённым." }, "labels": { "image": "Изображение", @@ -758,7 +811,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 cdd70789e7..debbe0f228 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -737,7 +737,60 @@ }, "file": { "quality": "质量", - "qualityLossless": "质量(无损)" + "qualityLossless": "质量(无损)", + "bitDepth": "位深", + "bitDepth8": "8 位 (标准 / SDR)", + "bitDepth10": "10 位 (HDR)", + "bitDepth12": "12 位 (HDR)", + "transfer": "亮度编码 (PQ / HLG)", + "primaries": "原色", + "hdrHint": "PQ 是 HDR 照片的标准,最适合屏幕显示和大多数分享。仅当为 HDR 电视或广播交付时才选择 HLG。无论哪种方式,HDR 都需要 HDR 显示器才能正确显示;在普通屏幕上可能会显得发灰。", + "colorProfile": "色彩配置文件", + "colorProfileHint": "从这里开始。每个配置文件都会为你设置好下面的所有内容。只有当你想自行调整细节时才选择“自定义”。", + "profiles": { + "sdr": "标准 (SDR)", + "hdr10": "HDR10 — 推荐", + "hlg": "HLG — 广播", + "maxQuality": "HDR — 最高质量", + "archival": "HDR — 存档", + "custom": "自定义…" + }, + "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": "设置纯白页面在 HDR 中的显示亮度。203 nits 是标准值——调高可获得整体更明亮的效果。除非你确知需要更改,否则请保持默认值。", + "hlgPeakRatio": "高光余量 (HLG)", + "hlgPeakRatioHelp": "HLG 高光可以比纸白亮多少。默认值适合大多数素材;除非交付规范另有要求,否则请保持不变。", + "masteringMetadata": "嵌入亮度元数据", + "masteringMetadataHelp": "为文件标记其亮度级别 (MaxCLL / MaxFALL),以便 HDR 显示器能够准确进行色调映射。推荐——保持开启是安全的。" }, "labels": { "image": "图像", @@ -758,7 +811,8 @@ "width": "宽度" }, "pixels": "像素", - "resizeToFit": "调整大小以适应" + "resizeToFit": "调整大小以适应", + "chroma422CapNote": "为均衡 (4:2:2) 的 AVIF 限制为 {{maxLongEdge}} px——这样可保持文件有效。" }, "sections": { "advanced": "高级",