From ad179a450a66f61fad0c6abde40c13fea55bd8a9 Mon Sep 17 00:00:00 2001 From: Dimitry Fayerman Date: Mon, 3 Aug 2026 18:38:35 -0400 Subject: [PATCH 1/2] feat: add true 8-bit and 16-bit TIFF export --- README.md | 4 + src-tauri/src/export_processing.rs | 221 ++++++++++++-- src-tauri/src/gpu_processing.rs | 295 ++++++++++++++++--- src-tauri/src/image_processing.rs | 4 +- src-tauri/src/launch_request.rs | 100 +++++++ src-tauri/src/lib.rs | 36 ++- src-tauri/src/shaders/shader.wgsl | 8 +- src/components/panel/right/ExportPanel.tsx | 28 ++ src/components/ui/ExportImportProperties.tsx | 4 + src/hooks/useExportSettings.ts | 12 +- src/hooks/useExternalEditSession.ts | 1 + src/i18n/locales/de.json | 5 +- src/i18n/locales/en.json | 5 +- src/i18n/locales/es.json | 5 +- src/i18n/locales/fr.json | 5 +- src/i18n/locales/it.json | 5 +- src/i18n/locales/ja.json | 5 +- src/i18n/locales/ko.json | 5 +- src/i18n/locales/pl.json | 5 +- src/i18n/locales/pt.json | 5 +- src/i18n/locales/ru.json | 5 +- src/i18n/locales/zh-CN.json | 5 +- src/i18n/locales/zh-TW.json | 5 +- 23 files changed, 675 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 66c4c0e8fa..3fbdbc7fbb 100644 --- a/README.md +++ b/README.md @@ -668,6 +668,9 @@ rapidraw export /path/to/photos --output /path/to/output_dir --format jpeg --qua # Export a single image directly to a specific target file rapidraw export /path/to/photo.raw --output /path/to/output.png --format png +# Export a true 16-bit TIFF (the TIFF default; use 8 for an RGB8 TIFF) +rapidraw export /path/to/photo.raw --output /path/to/output.tiff --format tiff --tiff-bit-depth 16 + # Batch export a folder using a custom adjustments JSON file to override sidecars rapidraw export /path/to/photos --output /path/to/output_dir --adjustments /path/to/preset.json ``` @@ -680,6 +683,7 @@ rapidraw export /path/to/photos --output /path/to/output_dir --adjustments /path | `--output ` | Target directory or specific output file path | _(Required)_ | | `--format ` | Output format (`jpeg`, `png`, `webp`, `avif`, `tiff`, `jxl`, `cube`) | `jpeg` | | `--quality <1-100>` | Image export quality | `90` | +| `--tiff-bit-depth ` | TIFF channel depth (`8` or `16`) | `16` | | `--keep-metadata` | Retain EXIF/capture metadata in exported files | `false` | | `--adjustments ` | Path to a custom JSON file containing adjustments to override sidecars | _(Auto-detected)_ | diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 8c1faaddc6..9292ff81e7 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -27,9 +27,9 @@ use crate::image_loader::{ composite_patches_on_image, load_and_composite, load_base_image_from_bytes, }; use crate::image_processing::{ - AllAdjustments, Crop, GpuContext, RenderRequest, downscale_f32_image, + AllAdjustments, Crop, GpuContext, RenderOutputPrecision, RenderRequest, downscale_f32_image, get_all_adjustments_from_json, get_or_init_gpu_context, process_and_get_dynamic_image, - resolve_tonemapper_override_from_handle, + process_and_get_dynamic_image_with_precision, resolve_tonemapper_override_from_handle, }; use crate::lut_processing::{ convert_image_to_cube_lut, generate_identity_lut_image, get_or_load_lut, @@ -42,6 +42,35 @@ use crate::{ hydrate_adjustments, load_settings, resolve_warped_image_for_masks, }; +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(try_from = "u8", into = "u8")] +pub enum TiffBitDepth { + Eight = 8, + #[default] + Sixteen = 16, +} + +impl TryFrom for TiffBitDepth { + type Error = String; + + fn try_from(value: u8) -> Result { + match value { + 8 => Ok(Self::Eight), + 16 => Ok(Self::Sixteen), + _ => Err(format!( + "Invalid TIFF bit depth '{}'; expected 8 or 16.", + value + )), + } + } +} + +impl From for u8 { + fn from(value: TiffBitDepth) -> Self { + value as u8 + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum ResizeMode { @@ -63,6 +92,8 @@ pub struct ResizeOptions { #[serde(rename_all = "camelCase")] pub struct ExportSettings { pub jpeg_quality: u8, + #[serde(default)] + pub tiff_bit_depth: TiffBitDepth, pub resize: Option, pub keep_metadata: bool, #[serde(default)] @@ -130,16 +161,10 @@ fn apply_watermark( let scaled_watermark = watermark_img.resize_exact(new_wm_w, new_wm_h, image::imageops::FilterType::Lanczos3); - let mut scaled_watermark_rgba = scaled_watermark.to_rgba8(); - let opacity_factor = (watermark_settings.opacity / 100.0).clamp(0.0, 1.0); - for pixel in scaled_watermark_rgba.pixels_mut() { - pixel[3] = (pixel[3] as f32 * opacity_factor) as u8; - } - let final_watermark = DynamicImage::ImageRgba8(scaled_watermark_rgba); let spacing_pixels = (base_min_dim * (watermark_settings.spacing / 100.0)) as i64; - let (wm_w, wm_h) = final_watermark.dimensions(); + let (wm_w, wm_h) = scaled_watermark.dimensions(); let x = match watermark_settings.anchor { WatermarkAnchor::TopLeft | WatermarkAnchor::CenterLeft | WatermarkAnchor::BottomLeft => { @@ -165,7 +190,37 @@ fn apply_watermark( | WatermarkAnchor::BottomRight => base_h as i64 - wm_h as i64 - spacing_pixels, }; - image::imageops::overlay(base_image, &final_watermark, x, y); + if matches!( + base_image, + DynamicImage::ImageRgb16(_) | DynamicImage::ImageRgba16(_) + ) { + let mut base_rgba = base_image.to_rgba16(); + let mut watermark_rgba = scaled_watermark.to_rgba16(); + for pixel in watermark_rgba.pixels_mut() { + pixel[3] = (pixel[3] as f32 * opacity_factor).round() as u16; + } + image::imageops::overlay(&mut base_rgba, &watermark_rgba, x, y); + *base_image = DynamicImage::ImageRgba16(base_rgba); + } else if matches!( + base_image, + DynamicImage::ImageRgb32F(_) | DynamicImage::ImageRgba32F(_) + ) { + let mut base_rgba = base_image.to_rgba32f(); + let mut watermark_rgba = scaled_watermark.to_rgba32f(); + for pixel in watermark_rgba.pixels_mut() { + pixel[3] *= opacity_factor; + } + image::imageops::overlay(&mut base_rgba, &watermark_rgba, x, y); + *base_image = DynamicImage::ImageRgba32F(base_rgba); + } else { + let mut base_rgba = base_image.to_rgba8(); + let mut watermark_rgba = scaled_watermark.to_rgba8(); + for pixel in watermark_rgba.pixels_mut() { + pixel[3] = (pixel[3] as f32 * opacity_factor).round() as u8; + } + image::imageops::overlay(&mut base_rgba, &watermark_rgba, x, y); + *base_image = DynamicImage::ImageRgba8(base_rgba); + } Ok(()) } @@ -414,6 +469,7 @@ fn process_image_for_export_pipeline( is_raw: bool, debug_tag: &str, app_handle: &tauri::AppHandle, + output_precision: RenderOutputPrecision, ) -> Result { let (transformed_image, unscaled_crop_offset) = apply_all_transformations(Cow::Borrowed(base_image), js_adjustments); @@ -448,7 +504,7 @@ fn process_image_for_export_pipeline( let unique_hash = calculate_full_job_hash(path, js_adjustments); - process_and_get_dynamic_image( + process_and_get_dynamic_image_with_precision( context, state, transformed_image.as_ref(), @@ -460,9 +516,23 @@ fn process_image_for_export_pipeline( roi: None, }, debug_tag, + output_precision, ) } +fn render_output_precision( + output_format: &str, + export_settings: &ExportSettings, +) -> RenderOutputPrecision { + if matches!(output_format.to_lowercase().as_str(), "tif" | "tiff") + && export_settings.tiff_bit_depth == TiffBitDepth::Sixteen + { + RenderOutputPrecision::SixteenBit + } else { + RenderOutputPrecision::EightBit + } +} + fn set_timestamps_from_exif(src: &Path, dst: &Path) { let capture_dt = exif_processing::get_creation_date_from_path(src); let ft = filetime::FileTime::from_unix_time( @@ -486,7 +556,12 @@ 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.jpeg_quality, + export_settings.tiff_bit_depth, + )?; exif_processing::write_image_with_metadata( &mut image_bytes, @@ -539,6 +614,7 @@ fn process_image_for_export( state: &tauri::State, is_raw: bool, app_handle: &tauri::AppHandle, + output_format: &str, ) -> Result { let processed_image = process_image_for_export_pipeline( path, @@ -549,6 +625,7 @@ fn process_image_for_export( is_raw, "process_image_for_export", app_handle, + render_output_precision(output_format, export_settings), )?; apply_export_resize_and_watermark(processed_image, export_settings) @@ -583,6 +660,7 @@ fn encode_image_to_bytes( image: &DynamicImage, output_format: &str, jpeg_quality: u8, + tiff_bit_depth: TiffBitDepth, ) -> Result, String> { let mut image_bytes = Vec::new(); let mut cursor = Cursor::new(&mut image_bytes); @@ -647,8 +725,12 @@ fn encode_image_to_bytes( .write_to(&mut cursor, image::ImageFormat::Png) .map_err(|e| e.to_string())?; } - "tiff" => { - DynamicImage::ImageRgb16(image.to_rgb16()) + "tif" | "tiff" => { + let image_to_encode = match tiff_bit_depth { + TiffBitDepth::Eight => DynamicImage::ImageRgb8(image.to_rgb8()), + TiffBitDepth::Sixteen => DynamicImage::ImageRgb16(image.to_rgb16()), + }; + image_to_encode .write_to(&mut cursor, image::ImageFormat::Tiff) .map_err(|e| e.to_string())?; } @@ -724,7 +806,7 @@ fn export_masks_for_image( let full_white_mask = ImageBuffer::from_fn(img_w, img_h, |_, _| Luma([255u8])); let single_bitmaps: Vec, Vec>> = vec![full_white_mask]; - let processed = process_and_get_dynamic_image( + let processed = process_and_get_dynamic_image_with_precision( context, state, transformed_image.as_ref(), @@ -736,6 +818,7 @@ fn export_masks_for_image( roi: None, }, "export_mask_image", + render_output_precision(extension, export_settings), )?; ensure_export_not_cancelled(cancellation_token)?; @@ -1127,6 +1210,11 @@ pub(crate) async fn export_images_impl( obj.insert("masks".to_string(), serde_json::json!([])); } + let actual_output_format = output_path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or(output_format.as_str()); + let final_image = process_image_for_export( &source_path_str, &base_image, @@ -1136,6 +1224,7 @@ pub(crate) async fn export_images_impl( &state, is_raw, &app_handle_clone, + actual_output_format, )?; ensure_export_not_cancelled(&cancellation_token_clone)?; save_image_with_metadata( @@ -1329,6 +1418,7 @@ pub async fn run_headless_export( let export_settings = ExportSettings { jpeg_quality: session.quality, + tiff_bit_depth: session.tiff_bit_depth, resize: None, keep_metadata: session.keep_metadata, preserve_timestamps: true, @@ -1509,7 +1599,7 @@ pub async fn estimate_export_sizes( let unique_hash = calculate_full_job_hash(&loaded_image.path, &adjustments_clone).wrapping_add(1); - let processed_preview = process_and_get_dynamic_image( + let processed_preview = process_and_get_dynamic_image_with_precision( &context, &state, &preview_image, @@ -1521,12 +1611,14 @@ pub async fn estimate_export_sizes( roi: None, }, "estimate_export_size", + render_output_precision(&output_format, &export_settings), )?; let preview_bytes = encode_image_to_bytes( &processed_preview, &output_format, export_settings.jpeg_quality, + export_settings.tiff_bit_depth, )?; let preview_byte_size = preview_bytes.len(); @@ -1647,7 +1739,7 @@ pub async fn estimate_export_sizes( let unique_hash = calculate_full_job_hash(&source_path_str, &js_adjustments).wrapping_add(1); - let processed_preview = process_and_get_dynamic_image( + let processed_preview = process_and_get_dynamic_image_with_precision( &context, &state, &preview_base, @@ -1659,12 +1751,14 @@ pub async fn estimate_export_sizes( roi: None, }, "estimate_batch_export_size", + render_output_precision(&output_format, &export_settings), )?; let preview_bytes = encode_image_to_bytes( &processed_preview, &output_format, export_settings.jpeg_quality, + export_settings.tiff_bit_depth, )?; let single_image_estimated_size = preview_bytes.len(); @@ -1690,3 +1784,96 @@ pub async fn estimate_export_sizes( Ok(single_image_extrapolated_size * paths.len()) } + +#[cfg(test)] +mod tests { + use super::*; + use image::{ColorType, Rgba}; + + fn legacy_export_settings_json() -> serde_json::Value { + serde_json::json!({ + "jpegQuality": 90, + "resize": null, + "keepMetadata": true, + "stripGps": false, + "filenameTemplate": null, + "watermark": null + }) + } + + #[test] + fn legacy_export_settings_default_tiff_to_sixteen_bit() { + let settings: ExportSettings = + serde_json::from_value(legacy_export_settings_json()).expect("legacy settings"); + assert_eq!(settings.tiff_bit_depth, TiffBitDepth::Sixteen); + } + + #[test] + fn tiff_bit_depth_serializes_as_number_and_rejects_invalid_values() { + assert_eq!( + serde_json::to_value(TiffBitDepth::Eight).expect("serialize"), + serde_json::json!(8) + ); + assert_eq!( + serde_json::to_value(TiffBitDepth::Sixteen).expect("serialize"), + serde_json::json!(16) + ); + + let mut invalid = legacy_export_settings_json(); + invalid["tiffBitDepth"] = serde_json::json!(12); + let error = serde_json::from_value::(invalid) + .expect_err("invalid TIFF bit depth must fail"); + assert!(error.to_string().contains("expected 8 or 16")); + } + + #[test] + fn tiff_encoder_writes_real_rgb8_and_rgb16() { + let samples = vec![1, 258, 4095, 65535, 65534, 32769, 12345, 60000]; + let source = DynamicImage::ImageRgba16( + ImageBuffer::, Vec>::from_raw(2, 1, samples) + .expect("synthetic RGBA16 image"), + ); + + let encoded_8 = encode_image_to_bytes(&source, "tiff", 90, TiffBitDepth::Eight) + .expect("encode RGB8 TIFF"); + let decoded_8 = image::load_from_memory_with_format(&encoded_8, ImageFormat::Tiff) + .expect("decode RGB8 TIFF"); + assert_eq!(decoded_8.color(), ColorType::Rgb8); + assert_eq!(decoded_8.to_rgb8(), source.to_rgb8()); + + let encoded_16 = encode_image_to_bytes(&source, "tiff", 90, TiffBitDepth::Sixteen) + .expect("encode RGB16 TIFF"); + let decoded_16 = image::load_from_memory_with_format(&encoded_16, ImageFormat::Tiff) + .expect("decode RGB16 TIFF"); + assert_eq!(decoded_16.color(), ColorType::Rgb16); + assert_eq!( + decoded_16.to_rgb16().into_raw(), + vec![1, 258, 4095, 65534, 32769, 12345] + ); + } + + #[test] + fn resize_preserves_synthetic_sixteen_bit_samples() { + let source = DynamicImage::ImageRgba16(ImageBuffer::from_fn(4, 4, |x, y| { + let value = 1000 + (x + y * 4) as u16 * 997; + Rgba([value, value + 1, value + 2, u16::MAX]) + })); + let mut settings: ExportSettings = + serde_json::from_value(legacy_export_settings_json()).expect("settings"); + settings.resize = Some(ResizeOptions { + mode: ResizeMode::Width, + value: 3, + dont_enlarge: false, + }); + + let resized = apply_export_resize_and_watermark(source, &settings).expect("resize"); + assert_eq!(resized.color(), ColorType::Rgba16); + assert!( + resized + .to_rgba16() + .pixels() + .flat_map(|pixel| pixel.0[..3].iter().copied()) + .any(|sample| sample % 257 != 0) + ); + } +} diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index b82599e352..b774b57326 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -21,6 +21,13 @@ pub struct Roi { pub height: u32, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RenderOutputPrecision { + #[default] + EightBit, + SixteenBit, +} + pub struct RenderRequest<'a> { pub adjustments: AllAdjustments, pub mask_bitmaps: &'a [ImageBuffer, Vec>], @@ -418,8 +425,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; @@ -535,6 +543,10 @@ pub struct GpuProcessor { main_bgl: wgpu::BindGroupLayout, main_pipeline: wgpu::ComputePipeline, + high_precision_bgl: wgpu::BindGroupLayout, + high_precision_pipeline: wgpu::ComputePipeline, + high_precision_tile: std::sync::OnceLock, + tile_output_size: wgpu::Extent3d, adjustments_buffer: wgpu::Buffer, dummy_blur_view: wgpu::TextureView, dummy_lut_view: wgpu::TextureView, @@ -553,6 +565,20 @@ pub struct GpuProcessor { pub output_texture_view: wgpu::TextureView, } +struct HighPrecisionTile { + texture: wgpu::Texture, + view: wgpu::TextureView, +} + +enum RenderedPixels { + U8(Vec), + U16(Vec), +} + +fn high_precision_shader_source() -> String { + include_str!("shaders/shader.wgsl").replace("rgba8unorm, write>", "rgba16float, write>") +} + const FLARE_MAP_SIZE: u32 = 512; impl GpuProcessor { @@ -920,6 +946,39 @@ impl GpuProcessor { cache: None, }); + bind_group_layout_entries[1].ty = wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba16Float, + view_dimension: wgpu::TextureViewDimension::D2, + }; + let high_precision_bgl = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("High Precision Main BGL"), + entries: &bind_group_layout_entries, + }); + let high_precision_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("High Precision Pipeline Layout"), + bind_group_layouts: &[Some(&high_precision_bgl)], + immediate_size: 0, + }); + let high_precision_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("High Precision Image Processing Shader"), + source: wgpu::ShaderSource::Wgsl(high_precision_shader_source().into()), + }); + let high_precision_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("High Precision Compute Pipeline"), + layout: Some(&high_precision_pipeline_layout), + module: &high_precision_shader, + entry_point: Some("main"), + compilation_options: wgpu::PipelineCompilationOptions { + constants: &[("HIGH_PRECISION_OUTPUT", 1.0)], + ..Default::default() + }, + cache: None, + }); + let adjustments_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Adjustments Buffer"), size: std::mem::size_of::() as u64, @@ -1071,6 +1130,10 @@ impl GpuProcessor { flare_sampler, main_bgl, main_pipeline, + high_precision_bgl, + high_precision_pipeline, + high_precision_tile: std::sync::OnceLock::new(), + tile_output_size: clamped_tile_size, adjustments_buffer, dummy_blur_view, dummy_lut_view, @@ -1089,7 +1152,29 @@ impl GpuProcessor { }) } - pub fn run( + fn high_precision_tile(&self) -> &HighPrecisionTile { + self.high_precision_tile.get_or_init(|| { + let texture = self + .context + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("High Precision Tile Output Texture"), + size: self.tile_output_size, + 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 view = texture.create_view(&Default::default()); + HighPrecisionTile { texture, view } + }) + } + + fn run( &self, input_texture_view: &wgpu::TextureView, width: u32, @@ -1097,12 +1182,45 @@ impl GpuProcessor { request: RenderRequest, skip_cpu_readback: bool, output_to_display: bool, - ) -> Result<(Vec, u32, u32, u32, u32), String> { + output_precision: RenderOutputPrecision, + ) -> Result<(RenderedPixels, 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; + if output_precision == RenderOutputPrecision::SixteenBit + && (skip_cpu_readback || output_to_display) + { + return Err( + "High-precision GPU output is only supported for CPU-readback renders.".to_string(), + ); + } + + let high_precision_tile = if output_precision == RenderOutputPrecision::SixteenBit { + Some(self.high_precision_tile()) + } else { + None + }; + let (output_pipeline, output_bgl, output_tile_texture, output_tile_view, bytes_per_pixel) = + if let Some(high_precision_tile) = high_precision_tile { + ( + &self.high_precision_pipeline, + &self.high_precision_bgl, + &high_precision_tile.texture, + &high_precision_tile.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, @@ -1295,14 +1413,15 @@ impl GpuProcessor { const TILE_SIZE: u32 = 2048; const TILE_OVERLAP: u32 = 128; - let mut final_pixels = vec![ - 0u8; - if skip_cpu_readback { - 0 - } else { - (out_width * out_height * 4) as usize - } - ]; + let output_len = if skip_cpu_readback { + 0 + } else { + (out_width * out_height * 4) as usize + }; + let mut final_pixels = match output_precision { + RenderOutputPrecision::EightBit => RenderedPixels::U8(vec![0u8; output_len]), + RenderOutputPrecision::SixteenBit => RenderedPixels::U16(vec![0u16; output_len]), + }; let start_tile_x = bounds.x / TILE_SIZE; let start_tile_y = bounds.y / TILE_SIZE; @@ -1438,9 +1557,7 @@ impl GpuProcessor { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView( - &self.tile_output_texture_view, - ), + resource: wgpu::BindingResource::TextureView(output_tile_view), }, wgpu::BindGroupEntry { binding: 2, @@ -1509,13 +1626,13 @@ impl GpuProcessor { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Tile Bind Group"), - layout: &self.main_bgl, + layout: output_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(output_pipeline); compute_pass.set_bind_group(0, &bind_group, &[]); compute_pass.dispatch_workgroups( input_width.div_ceil(8), @@ -1563,25 +1680,54 @@ impl GpuProcessor { let processed_tile_data = read_texture_data_roi( device, queue, - &self.tile_output_texture, + output_tile_texture, wgpu::Origin3d::ZERO, input_texture_size, + bytes_per_pixel, )?; - 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 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; - - final_pixels[final_row_offset..final_row_offset + copy_bytes] - .copy_from_slice( - &processed_tile_data - [source_row_offset..source_row_offset + copy_bytes], - ); + match &mut final_pixels { + RenderedPixels::U8(final_pixels) => { + 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 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; + + final_pixels[final_row_offset..final_row_offset + copy_bytes] + .copy_from_slice( + &processed_tile_data + [source_row_offset..source_row_offset + copy_bytes], + ); + } + } + RenderedPixels::U16(final_pixels) => { + 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 source_y = crop_y_start + row; + let source_pixel_offset = + (source_y * input_width + crop_x_start) as usize; + + for sample in 0..(tile_width as usize * 4) { + let source_byte_offset = (source_pixel_offset * 4 + sample) * 2; + let bits = u16::from_ne_bytes([ + processed_tile_data[source_byte_offset], + processed_tile_data[source_byte_offset + 1], + ]); + let value = f16::from_bits(bits).to_f32(); + final_pixels[final_row_offset + sample] = if value.is_finite() { + (value.clamp(0.0, 1.0) * u16::MAX as f32).round() as u16 + } else { + 0 + }; + } + } + } } } } @@ -1606,6 +1752,29 @@ pub fn process_and_get_dynamic_image( transform_hash, request, caller_id, + RenderOutputPrecision::EightBit, + false, + None, + ) +} + +pub fn process_and_get_dynamic_image_with_precision( + context: &GpuContext, + state: &tauri::State, + base_image: &DynamicImage, + transform_hash: u64, + request: RenderRequest, + caller_id: &str, + output_precision: RenderOutputPrecision, +) -> Result { + process_and_get_dynamic_image_inner( + context, + state, + base_image, + transform_hash, + request, + caller_id, + output_precision, false, None, ) @@ -1629,6 +1798,7 @@ pub fn process_and_get_dynamic_image_with_analytics( transform_hash, request, caller_id, + RenderOutputPrecision::EightBit, output_to_display, analytics_config, ) @@ -1642,6 +1812,7 @@ fn process_and_get_dynamic_image_inner( transform_hash: u64, request: RenderRequest, caller_id: &str, + output_precision: RenderOutputPrecision, output_to_display: bool, analytics_config: Option, ) -> Result { @@ -1765,6 +1936,7 @@ fn process_and_get_dynamic_image_inner( request, skip_readback, output_to_display, + output_precision, )?; let mut final_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { @@ -1914,20 +2086,24 @@ fn process_and_get_dynamic_image_inner( } }); } else { - let pixels_clone = processed_pixels.clone(); - std::thread::spawn(move || { - if let Some(img_buf) = - ImageBuffer::, _>::from_raw(out_w, out_h, pixels_clone) - { - let dynamic_img = DynamicImage::ImageRgba8(img_buf); - let _ = analytics.sender.send(crate::AnalyticsJob { - path: analytics.path, - image: std::sync::Arc::new(dynamic_img), - compute_waveform: analytics.compute_waveform, - active_waveform_channel: analytics.active_waveform_channel, - }); - } - }); + if let RenderedPixels::U8(pixels) = &processed_pixels { + let pixels_clone = pixels.clone(); + std::thread::spawn(move || { + if let Some(img_buf) = + ImageBuffer::, _>::from_raw(out_w, out_h, pixels_clone) + { + let dynamic_img = DynamicImage::ImageRgba8(img_buf); + let _ = analytics.sender.send(crate::AnalyticsJob { + path: analytics.path, + image: std::sync::Arc::new(dynamic_img), + compute_waveform: analytics.compute_waveform, + active_waveform_channel: analytics.active_waveform_channel, + }); + } + }); + } else { + log::warn!("Skipping analytics for a high-precision CPU readback"); + } } } @@ -1994,7 +2170,30 @@ fn process_and_get_dynamic_image_inner( fps ); - 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)) + match processed_pixels { + RenderedPixels::U8(pixels) => { + let img_buf = ImageBuffer::, Vec>::from_raw(out_w, out_h, pixels) + .ok_or("Failed to create 8-bit image buffer from GPU data")?; + Ok(DynamicImage::ImageRgba8(img_buf)) + } + RenderedPixels::U16(pixels) => { + let img_buf = ImageBuffer::, Vec>::from_raw(out_w, out_h, pixels) + .ok_or("Failed to create 16-bit image buffer from GPU data")?; + Ok(DynamicImage::ImageRgba16(img_buf)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn high_precision_shader_keeps_shared_transfer_and_changes_storage_format() { + let source = high_precision_shader_source(); + assert!(source.contains("texture_storage_2d")); + assert!(!source.contains("texture_storage_2d")); + assert!(source.contains("override HIGH_PRECISION_OUTPUT: u32 = 0u")); + assert!(source.contains("base_srgb = linear_to_srgb")); + } } diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 59c9f040d3..1a1e268433 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -14,8 +14,8 @@ use std::f32::consts::PI; use std::sync::Arc; pub use crate::gpu_processing::{ - RenderRequest, get_or_init_gpu_context, process_and_get_dynamic_image, - process_and_get_dynamic_image_with_analytics, + RenderOutputPrecision, RenderRequest, get_or_init_gpu_context, process_and_get_dynamic_image, + process_and_get_dynamic_image_with_analytics, process_and_get_dynamic_image_with_precision, }; use crate::{AppState, mask_generation::MaskDefinition}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; diff --git a/src-tauri/src/launch_request.rs b/src-tauri/src/launch_request.rs index 51a5ae9f50..ee2b223a29 100644 --- a/src-tauri/src/launch_request.rs +++ b/src-tauri/src/launch_request.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tauri::Emitter; +use crate::export_processing::TiffBitDepth; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditSession { @@ -17,6 +19,7 @@ pub struct HeadlessExportSession { pub output: String, pub format: String, pub quality: u8, + pub tiff_bit_depth: TiffBitDepth, pub keep_metadata: bool, pub adjustments_override: Option, } @@ -27,6 +30,7 @@ pub enum LaunchRequest { OpenFile(String), EditSession(ExternalEditSession), HeadlessExport(HeadlessExportSession), + InvalidHeadless(String), } #[derive(Serialize, Default)] @@ -44,6 +48,7 @@ pub fn parse_launch_args(args: &[String]) -> LaunchRequest { let mut output = String::new(); let mut format = String::from("jpeg"); let mut quality = 90; + let mut tiff_bit_depth = TiffBitDepth::default(); let mut keep_metadata = false; let mut adjustments_override = None; @@ -70,6 +75,26 @@ pub fn parse_launch_args(args: &[String]) -> LaunchRequest { quality = q.parse().unwrap_or(90); } } + "--tiff-bit-depth" => { + let Some(value) = iter.next() else { + return LaunchRequest::InvalidHeadless( + "Missing value for --tiff-bit-depth; expected 8 or 16.".to_string(), + ); + }; + let Ok(value) = value.parse::() else { + return LaunchRequest::InvalidHeadless(format!( + "Invalid TIFF bit depth '{}'; expected 8 or 16.", + value + )); + }; + let Ok(value) = TiffBitDepth::try_from(value) else { + return LaunchRequest::InvalidHeadless(format!( + "Invalid TIFF bit depth '{}'; expected 8 or 16.", + value + )); + }; + tiff_bit_depth = value; + } "--keep-metadata" => keep_metadata = true, "--adjustments" => { if let Some(adj) = iter.next() { @@ -85,6 +110,7 @@ pub fn parse_launch_args(args: &[String]) -> LaunchRequest { output, format, quality, + tiff_bit_depth, keep_metadata, adjustments_override, }); @@ -159,6 +185,80 @@ pub fn emit_launch_request(app_handle: &tauri::AppHandle, request: LaunchRequest "Error: Headless export cannot be attached to an already running GUI instance." ); } + LaunchRequest::InvalidHeadless(error) => { + log::error!("Invalid headless export request: {}", error); + } LaunchRequest::None => {} } } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn headless_tiff_bit_depth_defaults_to_sixteen() { + let request = parse_launch_args(&args(&[ + "export", + "input.raw", + "--output", + "output.tiff", + "--format", + "tiff", + ])); + + let LaunchRequest::HeadlessExport(session) = request else { + panic!("expected headless export request"); + }; + assert_eq!(session.tiff_bit_depth, TiffBitDepth::Sixteen); + } + + #[test] + fn headless_tiff_bit_depth_accepts_eight_and_sixteen() { + for (value, expected) in [("8", TiffBitDepth::Eight), ("16", TiffBitDepth::Sixteen)] { + let request = parse_launch_args(&args(&[ + "export", + "input.raw", + "--output", + "output.tiff", + "--format", + "tiff", + "--tiff-bit-depth", + value, + ])); + + let LaunchRequest::HeadlessExport(session) = request else { + panic!("expected headless export request for {value}"); + }; + assert_eq!(session.tiff_bit_depth, expected); + } + } + + #[test] + fn headless_tiff_bit_depth_rejects_invalid_or_missing_values() { + for values in [ + vec!["--tiff-bit-depth", "12"], + vec!["--tiff-bit-depth", "sixteen"], + vec!["--tiff-bit-depth"], + ] { + let mut full_args = args(&[ + "export", + "input.raw", + "--output", + "output.tiff", + "--format", + "tiff", + ]); + full_args.extend(args(&values)); + + let LaunchRequest::InvalidHeadless(error) = parse_launch_args(&full_args) else { + panic!("expected invalid headless request for {values:?}"); + }; + assert!(error.contains("expected 8 or 16")); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5a63bbfc57..dac74b4e55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1904,6 +1904,10 @@ pub fn run() { let args: Vec = std::env::args().skip(1).collect(); let launch_req = parse_launch_args(&args); + if let LaunchRequest::InvalidHeadless(error) = &launch_req { + eprintln!("Headless export failed: {}", error); + std::process::exit(2); + } let is_headless = matches!(launch_req, LaunchRequest::HeadlessExport(_)); #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -2060,22 +2064,26 @@ pub fn run() { } } - if let LaunchRequest::HeadlessExport(session) = launch_req { - let app_handle_clone = app_handle.clone(); - tauri::async_runtime::spawn(async move { - match crate::export_processing::run_headless_export(session, app_handle_clone.clone()).await { - Ok(_) => { - println!("Headless export completed successfully."); - app_handle_clone.exit(0); - } - Err(e) => { - eprintln!("Headless export failed: {}", e); - app_handle_clone.exit(1); + match launch_req { + LaunchRequest::HeadlessExport(session) => { + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + match crate::export_processing::run_headless_export(session, app_handle_clone.clone()).await { + Ok(_) => { + println!("Headless export completed successfully."); + app_handle_clone.exit(0); + } + Err(e) => { + eprintln!("Headless export failed: {}", e); + app_handle_clone.exit(1); + } } - } - }); + }); - return Ok(()); + return Ok(()); + } + LaunchRequest::InvalidHeadless(_) => unreachable!("invalid headless arguments exit before app setup"), + _ => {} } start_preview_worker(app_handle.clone()); diff --git a/src-tauri/src/shaders/shader.wgsl b/src-tauri/src/shaders/shader.wgsl index a0347a4959..2e9c3de797 100644 --- a/src-tauri/src/shaders/shader.wgsl +++ b/src-tauri/src/shaders/shader.wgsl @@ -211,6 +211,8 @@ const HSL_RANGES: array = array( @group(0) @binding(10) var flare_texture: texture_2d; @group(0) @binding(11) var flare_sampler: sampler; +override HIGH_PRECISION_OUTPUT: u32 = 0u; + const LUMA_COEFF = vec3(0.2126, 0.7152, 0.0722); fn get_luma(c: vec3) -> f32 { @@ -1744,8 +1746,10 @@ fn main(@builtin(global_invocation_id) id: vec3) { } } - let dither_amount = 1.0 / 255.0; - final_rgb += dither(id.xy) * dither_amount; + if (HIGH_PRECISION_OUTPUT == 0u) { + let dither_amount = 1.0 / 255.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)); } diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index 8641f3f4a2..3ad7051052 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -19,6 +19,7 @@ import { Status, ExportState, FileFormats, + TiffBitDepth, WatermarkAnchor, } from '../../ui/ExportImportProperties'; import { Invokes, SelectedImage, AppSettings, Panel } from '../../ui/AppProperties'; @@ -196,11 +197,21 @@ export default function ExportPanel({ [t], ); + const tiffBitDepthOptions = useMemo( + () => [ + { label: t('export.file.tiffBitDepth8'), value: '8' }, + { label: t('export.file.tiffBitDepth16'), value: '16' }, + ], + [t], + ); + const { fileFormat, setFileFormat, jpegQuality, setJpegQuality, + tiffBitDepth, + setTiffBitDepth, enableResize, setEnableResize, resizeMode, @@ -378,6 +389,7 @@ export default function ExportPanel({ const exportSettings: ExportSettings = { filenameTemplate, jpegQuality, + tiffBitDepth, keepMetadata, preserveTimestamps, preserveFolders, @@ -420,6 +432,7 @@ export default function ExportPanel({ selectedImage?.path, fileFormat, jpegQuality, + tiffBitDepth, enableResize, resizeMode, resizeValue, @@ -470,6 +483,7 @@ export default function ExportPanel({ const exportSettings: ExportSettings = { filenameTemplate: finalFilenameTemplate, jpegQuality, + tiffBitDepth, keepMetadata, preserveTimestamps, preserveFolders, @@ -625,6 +639,20 @@ export default function ExportPanel({ /> )} + {fileFormat === FileFormats.Tiff && ( +
+ + {t('export.file.tiffBitDepth')} + + setTiffBitDepth(Number(value) as TiffBitDepth)} + disabled={isExporting} + className="w-full" + /> +
+ )} {numImages > 1 && ( diff --git a/src/components/ui/ExportImportProperties.tsx b/src/components/ui/ExportImportProperties.tsx index ec647ba0b2..6a0a6f4b0f 100644 --- a/src/components/ui/ExportImportProperties.tsx +++ b/src/components/ui/ExportImportProperties.tsx @@ -33,9 +33,12 @@ export const FILENAME_VARIABLES: Array = [ '{mm}', ]; +export type TiffBitDepth = 8 | 16; + export interface ExportSettings { filenameTemplate: string | null; jpegQuality: number; + tiffBitDepth: TiffBitDepth; keepMetadata: boolean; preserveTimestamps: boolean; resize: { @@ -103,6 +106,7 @@ export interface ExportPreset { name: string; fileFormat: string; jpegQuality: number; + tiffBitDepth?: TiffBitDepth; enableResize: boolean; resizeMode: string; resizeValue: number; diff --git a/src/hooks/useExportSettings.ts b/src/hooks/useExportSettings.ts index 1af496dfdd..1a3ee7854d 100644 --- a/src/hooks/useExportSettings.ts +++ b/src/hooks/useExportSettings.ts @@ -1,9 +1,10 @@ import { useState, useMemo, useCallback } from 'react'; -import { ExportPreset, WatermarkAnchor } from '../components/ui/ExportImportProperties'; +import { ExportPreset, TiffBitDepth, WatermarkAnchor } from '../components/ui/ExportImportProperties'; export function useExportSettings() { const [fileFormat, setFileFormat] = useState('jpeg'); const [jpegQuality, setJpegQuality] = useState(90); + const [tiffBitDepth, setTiffBitDepth] = useState(16); const [enableResize, setEnableResize] = useState(false); const [resizeMode, setResizeMode] = useState('longEdge'); const [resizeValue, setResizeValue] = useState(2048); @@ -24,6 +25,7 @@ export function useExportSettings() { const handleApplyPreset = useCallback((preset: ExportPreset) => { setFileFormat(preset.fileFormat); setJpegQuality(preset.jpegQuality); + setTiffBitDepth(preset.tiffBitDepth ?? 16); setEnableResize(preset.enableResize); setResizeMode(preset.resizeMode); setResizeValue(preset.resizeValue); @@ -46,6 +48,7 @@ export function useExportSettings() { () => ({ fileFormat, jpegQuality, + tiffBitDepth, enableResize, resizeMode, resizeValue, @@ -66,6 +69,7 @@ export function useExportSettings() { [ fileFormat, jpegQuality, + tiffBitDepth, enableResize, resizeMode, resizeValue, @@ -82,7 +86,7 @@ export function useExportSettings() { watermarkScale, watermarkSpacing, watermarkOpacity, - ] + ], ); return { @@ -90,6 +94,8 @@ export function useExportSettings() { setFileFormat, jpegQuality, setJpegQuality, + tiffBitDepth, + setTiffBitDepth, enableResize, setEnableResize, resizeMode, @@ -125,4 +131,4 @@ export function useExportSettings() { handleApplyPreset, currentSettingsObject, }; -} \ No newline at end of file +} diff --git a/src/hooks/useExternalEditSession.ts b/src/hooks/useExternalEditSession.ts index 52b7b54f81..09e77e3c58 100644 --- a/src/hooks/useExternalEditSession.ts +++ b/src/hooks/useExternalEditSession.ts @@ -56,6 +56,7 @@ export function useExternalEditSession(handleImageSelect: (path: string) => void const exportSettings: ExportSettings = { filenameTemplate: null, jpegQuality: session.jpegQuality, + tiffBitDepth: 16, keepMetadata: true, preserveTimestamps: false, preserveFolders: false, diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bed64e52c7..2788e499a9 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -758,7 +758,10 @@ }, "file": { "quality": "Qualität", - "qualityLossless": "Qualität (Verlustfrei)" + "qualityLossless": "Qualität (Verlustfrei)", + "tiffBitDepth": "TIFF-Bittiefe", + "tiffBitDepth8": "8 Bit", + "tiffBitDepth16": "16 Bit" }, "labels": { "image": "Bild", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3fe3075a87..25b8d8b284 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -758,7 +758,10 @@ }, "file": { "quality": "Quality", - "qualityLossless": "Quality (Lossless)" + "qualityLossless": "Quality (Lossless)", + "tiffBitDepth": "TIFF bit depth", + "tiffBitDepth8": "8-bit", + "tiffBitDepth16": "16-bit" }, "labels": { "image": "Image", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ea7f522dd8..f0f065d1e7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -780,7 +780,10 @@ }, "file": { "quality": "Calidad", - "qualityLossless": "Calidad (Sin pérdida)" + "qualityLossless": "Calidad (Sin pérdida)", + "tiffBitDepth": "Profundidad de bits TIFF", + "tiffBitDepth8": "8 bits", + "tiffBitDepth16": "16 bits" }, "labels": { "image": "Imagen", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2c97393ef5..e3d2355ea9 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -780,7 +780,10 @@ }, "file": { "quality": "Qualité", - "qualityLossless": "Qualité (Sans perte)" + "qualityLossless": "Qualité (Sans perte)", + "tiffBitDepth": "Profondeur de bits TIFF", + "tiffBitDepth8": "8 bits", + "tiffBitDepth16": "16 bits" }, "labels": { "image": "Image", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b7aa46ce99..a4d1916f01 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -780,7 +780,10 @@ }, "file": { "quality": "Qualità", - "qualityLossless": "Qualità (Senza Perdita)" + "qualityLossless": "Qualità (Senza Perdita)", + "tiffBitDepth": "Profondità bit TIFF", + "tiffBitDepth8": "8 bit", + "tiffBitDepth16": "16 bit" }, "labels": { "image": "Immagine", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ff332e988a..819cbf91b1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -756,7 +756,10 @@ }, "file": { "quality": "画質", - "qualityLossless": "画質 (ロスレス)" + "qualityLossless": "画質 (ロスレス)", + "tiffBitDepth": "TIFF ビット深度", + "tiffBitDepth8": "8 ビット", + "tiffBitDepth16": "16 ビット" }, "labels": { "image": "画像", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 5ccfbb9494..34e6611fe9 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -756,7 +756,10 @@ }, "file": { "quality": "품질", - "qualityLossless": "품질 (무손실)" + "qualityLossless": "품질 (무손실)", + "tiffBitDepth": "TIFF 비트 심도", + "tiffBitDepth8": "8비트", + "tiffBitDepth16": "16비트" }, "labels": { "image": "이미지", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index ded24b3052..b96b27d84b 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -802,7 +802,10 @@ }, "file": { "quality": "Jakość", - "qualityLossless": "Jakość (Bezstratna)" + "qualityLossless": "Jakość (Bezstratna)", + "tiffBitDepth": "Głębia bitowa TIFF", + "tiffBitDepth8": "8 bitów", + "tiffBitDepth16": "16 bitów" }, "labels": { "image": "Obraz", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a17ea045b3..db3fd5933e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -780,7 +780,10 @@ }, "file": { "quality": "Qualidade", - "qualityLossless": "Qualidade (Sem Perdas)" + "qualityLossless": "Qualidade (Sem Perdas)", + "tiffBitDepth": "Profundidade de bits TIFF", + "tiffBitDepth8": "8 bits", + "tiffBitDepth16": "16 bits" }, "labels": { "image": "Imagem", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2c786729de..2a0f1beb3a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -802,7 +802,10 @@ }, "file": { "quality": "Качество", - "qualityLossless": "Качество (без потерь)" + "qualityLossless": "Качество (без потерь)", + "tiffBitDepth": "Глубина цвета TIFF", + "tiffBitDepth8": "8 бит", + "tiffBitDepth16": "16 бит" }, "labels": { "image": "Изображение", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 50a2133241..33cfbf9072 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -756,7 +756,10 @@ }, "file": { "quality": "质量", - "qualityLossless": "质量(无损)" + "qualityLossless": "质量(无损)", + "tiffBitDepth": "TIFF 位深", + "tiffBitDepth8": "8 位", + "tiffBitDepth16": "16 位" }, "labels": { "image": "图像", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 4edd815f2b..407f054e6e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -756,7 +756,10 @@ }, "file": { "quality": "品質", - "qualityLossless": "品質(無損)" + "qualityLossless": "品質(無損)", + "tiffBitDepth": "TIFF 位元深度", + "tiffBitDepth8": "8 位元", + "tiffBitDepth16": "16 位元" }, "labels": { "image": "影像", From 8f60ec4b107f5c2b40207a9b8e873bd5af9625ca Mon Sep 17 00:00:00 2001 From: Dimitry Fayerman Date: Wed, 5 Aug 2026 22:29:57 -0400 Subject: [PATCH 2/2] fix: remove redundant GPU readback flag --- src-tauri/src/gpu_processing.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index 0ad4ac4eb7..6f425edd46 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -1176,7 +1176,6 @@ impl GpuProcessor { width: u32, height: u32, request: RenderRequest, - skip_cpu_readback: bool, output_to_display: bool, output_precision: RenderOutputPrecision, ) -> Result<(RenderedPixels, u32, u32, u32, u32), String> { @@ -1185,9 +1184,7 @@ impl GpuProcessor { let scale = (width.min(height) as f32) / 1080.0; const MAX_MASK_BINDINGS: u32 = 1; - if output_precision == RenderOutputPrecision::SixteenBit - && (skip_cpu_readback || output_to_display) - { + if output_precision == RenderOutputPrecision::SixteenBit && output_to_display { return Err( "High-precision GPU output is only supported for CPU-readback renders.".to_string(), ); @@ -1409,7 +1406,7 @@ impl GpuProcessor { const TILE_SIZE: u32 = 2048; const TILE_OVERLAP: u32 = 128; - let output_len = if skip_cpu_readback { + let output_len = if output_to_display { 0 } else { (out_width * out_height * 4) as usize @@ -1672,7 +1669,7 @@ impl GpuProcessor { queue.submit(Some(main_encoder.finish())); - if !skip_cpu_readback { + if !output_to_display { let processed_tile_data = read_texture_data_roi( device, queue, @@ -1930,7 +1927,6 @@ fn process_and_get_dynamic_image_inner( cache.width, cache.height, request, - skip_readback, output_to_display, output_precision, )?;