#![allow(dead_code)]
//! Video export functionality
//!
//! Exports video from the timeline using FFmpeg encoding:
//! - H.264/H.265: MP4 container (most compatible)
//! - VP9: WebM container (web-friendly)
//! - ProRes422: MOV container (professional editing)

use ffmpeg_next as ffmpeg;
use std::sync::Arc;
use lightningbeam_core::document::Document;
use lightningbeam_core::renderer::{ImageCache, render_document_for_compositing, RenderedLayerType};
use lightningbeam_core::video::VideoManager;
use lightningbeam_core::gpu::{
    BufferPool, BufferSpec, BufferFormat, Compositor, CompositorLayer,
    SrgbToLinearConverter, EffectProcessor, YuvConverter, HDR_FORMAT,
};

/// Reusable frame buffers to avoid allocations
struct FrameBuffers {
    /// RGBA buffer from GPU readback (width * height * 4 bytes)
    rgba_buffer: Vec<u8>,
    /// Y plane for YUV420p (full resolution)
    y_plane: Vec<u8>,
    /// U plane for YUV420p (quarter resolution - 2×2 subsampling)
    u_plane: Vec<u8>,
    /// V plane for YUV420p (quarter resolution - 2×2 subsampling)
    v_plane: Vec<u8>,
}

impl FrameBuffers {
    /// Create new frame buffers for the given resolution
    fn new(width: u32, height: u32) -> Self {
        let rgba_size = (width * height * 4) as usize;
        let y_size = (width * height) as usize;
        let uv_size = ((width / 2) * (height / 2)) as usize;

        Self {
            rgba_buffer: vec![0u8; rgba_size],
            y_plane: vec![0u8; y_size],
            u_plane: vec![0u8; uv_size],
            v_plane: vec![0u8; uv_size],
        }
    }
}

/// GPU resources for HDR export pipeline
///
/// This mirrors the resources in stage.rs SharedVelloResources but is owned
/// by the export system to avoid lifetime/locking issues during export.
pub struct ExportGpuResources {
    /// Buffer pool for intermediate render targets
    pub buffer_pool: BufferPool,
    /// HDR compositor for layer blending
    pub compositor: Compositor,
    /// sRGB to linear color converter
    pub srgb_to_linear: SrgbToLinearConverter,
    /// Effect processor for shader effects
    pub effect_processor: EffectProcessor,
    /// GPU-accelerated RGBA to YUV420p converter
    pub yuv_converter: YuvConverter,
    /// HDR accumulator texture for compositing
    pub hdr_texture: wgpu::Texture,
    /// View for HDR texture
    pub hdr_texture_view: wgpu::TextureView,
    /// Persistent RGBA output texture (sRGB, reused for all frames)
    pub output_texture: wgpu::Texture,
    /// View for persistent output texture
    pub output_texture_view: wgpu::TextureView,
    /// Persistent YUV texture for GPU conversion (R8Unorm, height*1.5, reused for all frames)
    pub yuv_texture: wgpu::Texture,
    /// View for persistent YUV texture
    pub yuv_texture_view: wgpu::TextureView,
    /// Persistent staging buffer for GPU→CPU readback (reused for all frames)
    pub staging_buffer: wgpu::Buffer,
    /// Linear to sRGB blit pipeline for final output
    pub linear_to_srgb_pipeline: wgpu::RenderPipeline,
    /// Bind group layout for linear to sRGB blit
    pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout,
    /// Sampler for linear to sRGB conversion
    pub linear_to_srgb_sampler: wgpu::Sampler,
}

impl ExportGpuResources {
    /// Create new export GPU resources for the given dimensions
    pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
        let buffer_pool = BufferPool::new();
        let compositor = Compositor::new(device, HDR_FORMAT);
        let srgb_to_linear = SrgbToLinearConverter::new(device);
        let effect_processor = EffectProcessor::new(device, HDR_FORMAT);
        let yuv_converter = YuvConverter::new(device);

        // Create HDR accumulator texture
        let hdr_texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("export_hdr_texture"),
            size: wgpu::Extent3d {
                width,
                height,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: HDR_FORMAT,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
                | wgpu::TextureUsages::TEXTURE_BINDING
                | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        let hdr_texture_view = hdr_texture.create_view(&wgpu::TextureViewDescriptor::default());

        // Create persistent RGBA output texture (sRGB, reused for all frames)
        let output_texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("export_output_texture"),
            size: wgpu::Extent3d {
                width,
                height,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
                | wgpu::TextureUsages::TEXTURE_BINDING
                | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        let output_texture_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());

        // Create persistent YUV texture (Rgba8Unorm, height*1.5 for packed Y+U+V planes)
        // Note: Using Rgba8Unorm instead of R8Unorm because R8Unorm doesn't support STORAGE_BINDING
        let yuv_height = height + height / 2; // Y plane + U plane + V plane
        let yuv_texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("export_yuv_texture"),
            size: wgpu::Extent3d {
                width,
                height: yuv_height,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        let yuv_texture_view = yuv_texture.create_view(&wgpu::TextureViewDescriptor::default());

        // Create persistent staging buffer for GPU→CPU readback
        let yuv_buffer_size = (width * yuv_height * 4) as u64; // Rgba8Unorm = 4 bytes per pixel
        let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("export_staging_buffer"),
            size: yuv_buffer_size,
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });

        // Create linear to sRGB blit pipeline
        let linear_to_srgb_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("linear_to_srgb_bind_group_layout"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
            ],
        });

        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("linear_to_srgb_pipeline_layout"),
            bind_group_layouts: &[&linear_to_srgb_bind_group_layout],
            push_constant_ranges: &[],
        });

        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("linear_to_srgb_shader"),
            source: wgpu::ShaderSource::Wgsl(LINEAR_TO_SRGB_SHADER.into()),
        });

        let linear_to_srgb_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("linear_to_srgb_pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                buffers: &[],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                targets: &[Some(wgpu::ColorTargetState {
                    format: wgpu::TextureFormat::Rgba8Unorm,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleStrip,
                strip_index_format: None,
                front_face: wgpu::FrontFace::Ccw,
                cull_mode: None,
                polygon_mode: wgpu::PolygonMode::Fill,
                unclipped_depth: false,
                conservative: false,
            },
            depth_stencil: None,
            multisample: wgpu::MultisampleState::default(),
            multiview: None,
            cache: None,
        });

        let linear_to_srgb_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("linear_to_srgb_sampler"),
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            address_mode_w: wgpu::AddressMode::ClampToEdge,
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            mipmap_filter: wgpu::FilterMode::Nearest,
            ..Default::default()
        });

        Self {
            buffer_pool,
            compositor,
            srgb_to_linear,
            effect_processor,
            yuv_converter,
            hdr_texture,
            hdr_texture_view,
            output_texture,
            output_texture_view,
            yuv_texture,
            yuv_texture_view,
            staging_buffer,
            linear_to_srgb_pipeline,
            linear_to_srgb_bind_group_layout,
            linear_to_srgb_sampler,
        }
    }

    /// Resize the HDR texture if dimensions changed
    pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
        self.hdr_texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("export_hdr_texture"),
            size: wgpu::Extent3d {
                width,
                height,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: HDR_FORMAT,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
                | wgpu::TextureUsages::TEXTURE_BINDING
                | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        self.hdr_texture_view = self.hdr_texture.create_view(&wgpu::TextureViewDescriptor::default());
    }
}

/// WGSL shader for linear to sRGB conversion (for final export output)
const LINEAR_TO_SRGB_SHADER: &str = r#"
// Linear to sRGB color space conversion shader

@group(0) @binding(0) var source_tex: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;

struct VertexOutput {
    @builtin(position) position: vec4<f32>,
    @location(0) uv: vec2<f32>,
}

// Fullscreen triangle strip
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
    var out: VertexOutput;

    let x = f32((vertex_index & 1u) << 1u);
    let y = f32(vertex_index & 2u);

    out.position = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
    out.uv = vec2<f32>(x, y);

    return out;
}

// Linear to sRGB color space conversion (per channel)
fn linear_to_srgb_channel(c: f32) -> f32 {
    return select(
        1.055 * pow(c, 1.0 / 2.4) - 0.055,
        c * 12.92,
        c <= 0.0031308
    );
}

fn linear_to_srgb(color: vec3<f32>) -> vec3<f32> {
    return vec3<f32>(
        linear_to_srgb_channel(color.r),
        linear_to_srgb_channel(color.g),
        linear_to_srgb_channel(color.b)
    );
}

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    let src = textureSample(source_tex, source_sampler, in.uv);

    // Convert linear HDR to sRGB
    let srgb = linear_to_srgb(src.rgb);

    // Alpha stays unchanged
    return vec4<f32>(srgb, src.a);
}
"#;

/// Convert RGBA8 pixels to YUV420p format using BT.709 color space
///
/// # Arguments
/// * `rgba` - Interleaved RGBA8 pixels (4 bytes per pixel)
/// * `width` - Frame width in pixels
/// * `height` - Frame height in pixels
///
/// # Returns
/// Tuple of (Y plane, U plane, V plane) as separate byte vectors
///
/// # Color Space
/// Uses BT.709 (HDTV) color space conversion:
/// - Y  = 0.2126*R + 0.7152*G + 0.0722*B
/// - U  = -0.1146*R - 0.3854*G + 0.5000*B + 128
/// - V  = 0.5000*R - 0.4542*G - 0.0458*B + 128
///
/// # Format
/// YUV420p is a planar format with 2×2 chroma subsampling:
/// - Y plane: full resolution (width × height)
/// - U plane: quarter resolution (width/2 × height/2)
/// - V plane: quarter resolution (width/2 × height/2)
pub fn rgba_to_yuv420p(rgba: &[u8], width: u32, height: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
    let w = width as usize;
    let h = height as usize;

    // Round to multiples of 16 for H.264 macroblock alignment
    let aligned_w = (((width + 15) / 16) * 16) as usize;
    let aligned_h = (((height + 15) / 16) * 16) as usize;

    // Allocate Y plane (full aligned resolution, padded with black)
    let mut y_plane = Vec::with_capacity(aligned_w * aligned_h);

    // Convert each pixel to Y (luma), with padding
    for y in 0..aligned_h {
        for x in 0..aligned_w {
            let y_val = if y < h && x < w {
                let idx = (y * w + x) * 4;
                let r = rgba[idx] as f32;
                let g = rgba[idx + 1] as f32;
                let b = rgba[idx + 2] as f32;
                // BT.709 luma conversion
                (0.2126 * r + 0.7152 * g + 0.0722 * b).clamp(0.0, 255.0) as u8
            } else {
                16 // Black in YUV (Y=16 is video black)
            };
            y_plane.push(y_val);
        }
    }

    // Allocate U and V planes (quarter resolution due to 2×2 subsampling)
    let mut u_plane = Vec::with_capacity((aligned_w * aligned_h) / 4);
    let mut v_plane = Vec::with_capacity((aligned_w * aligned_h) / 4);

    // Process 2×2 blocks for chroma subsampling (with padding for aligned dimensions)
    for y in (0..aligned_h).step_by(2) {
        for x in (0..aligned_w).step_by(2) {
            // Check if this block is in the padding region
            let in_padding = y >= h || x >= w;

            let (u_val, v_val) = if in_padding {
                // Padding region: use neutral chroma for black (U=128, V=128)
                (128, 128)
            } else {
                // Average RGB values from 2×2 block
                let mut r_sum = 0.0;
                let mut g_sum = 0.0;
                let mut b_sum = 0.0;

                for dy in 0..2 {
                    for dx in 0..2 {
                        if y + dy < h && x + dx < w {
                            let idx = ((y + dy) * w + (x + dx)) * 4;
                            r_sum += rgba[idx] as f32;
                            g_sum += rgba[idx + 1] as f32;
                            b_sum += rgba[idx + 2] as f32;
                        }
                    }
                }

                let r = r_sum / 4.0;
                let g = g_sum / 4.0;
                let b = b_sum / 4.0;

                // BT.709 chroma conversion (centered at 128)
                let u = (-0.1146 * r - 0.3854 * g + 0.5000 * b + 128.0).clamp(0.0, 255.0) as u8;
                let v = (0.5000 * r - 0.4542 * g - 0.0458 * b + 128.0).clamp(0.0, 255.0) as u8;
                (u, v)
            };

            u_plane.push(u_val);
            v_plane.push(v_val);
        }
    }

    (y_plane, u_plane, v_plane)
}

/// Setup FFmpeg video encoder for the specified codec
///
/// # Arguments
/// * `codec_id` - FFmpeg codec ID (H264, HEVC, VP9, PRORES, etc.)
/// * `width` - Frame width in pixels
/// * `height` - Frame height in pixels
/// * `framerate` - Frames per second
/// * `bitrate_kbps` - Target bitrate in kilobits per second
///
/// # Returns
/// Tuple of (opened encoder, codec) for stream setup
///
/// # Note
/// This function follows the same pattern as the working MP3 export:
/// 1. Find codec
/// 2. Create encoder context with codec
/// 3. Set ALL parameters (width, height, format, timebase, framerate, bitrate, GOP)
/// 4. Open encoder with open_as(codec)
/// 5. Caller should add stream AFTER opening and set parameters from opened encoder
pub fn setup_video_encoder(
    codec_id: ffmpeg::codec::Id,
    width: u32,
    height: u32,
    framerate: f64,
    bitrate_kbps: u32,
) -> Result<(ffmpeg::encoder::Video, ffmpeg::Codec), String> {
    // Try to find codec by ID first
    println!("🔍 Looking for codec: {:?}", codec_id);
    let codec = ffmpeg::encoder::find(codec_id);

    let codec = if codec.is_some() {
        println!("✅ Found codec by ID");
        codec
    } else {
        println!("⚠️  Codec {:?} not found by ID", codec_id);

        // If not found by ID, try by name (e.g., "libx264" for H264)
        let encoder_name = match codec_id {
            ffmpeg::codec::Id::H264 => "libx264",
            ffmpeg::codec::Id::HEVC => "libx265",
            ffmpeg::codec::Id::VP8 => "libvpx",
            ffmpeg::codec::Id::VP9 => "libvpx-vp9",
            ffmpeg::codec::Id::PRORES => "prores_ks",
            _ => {
                println!("❌ No fallback encoder name for {:?}", codec_id);
                return Err(format!("Unsupported codec: {:?}", codec_id));
            }
        };

        println!("🔍 Trying encoder by name: {}", encoder_name);
        let by_name = ffmpeg::encoder::find_by_name(encoder_name);

        if by_name.is_some() {
            println!("✅ Found encoder by name: {}", encoder_name);
        } else {
            println!("❌ Encoder {} not found", encoder_name);
        }

        by_name
    };

    let codec = codec.ok_or_else(|| {
        println!("❌ Failed to find codec: {:?}", codec_id);
        println!("💡 The static FFmpeg build is missing this encoder.");
        format!("Video encoder not found for codec: {:?}. Static build may be missing encoder libraries.", codec_id)
    })?;

    // Create encoder context with codec
    let mut encoder = ffmpeg::codec::Context::new_with_codec(codec)
        .encoder()
        .video()
        .map_err(|e| format!("Failed to create video encoder: {}", e))?;

    // Round dimensions to multiples of 16 for H.264 macroblock alignment
    let aligned_width = ((width + 15) / 16) * 16;
    let aligned_height = ((height + 15) / 16) * 16;

    // Configure encoder parameters BEFORE opening (critical!)
    encoder.set_width(aligned_width);
    encoder.set_height(aligned_height);
    encoder.set_format(ffmpeg::format::Pixel::YUV420P);
    encoder.set_time_base(ffmpeg::Rational(1, (framerate * 1000.0) as i32));
    encoder.set_frame_rate(Some(ffmpeg::Rational(framerate as i32, 1)));
    encoder.set_bit_rate((bitrate_kbps * 1000) as usize);
    encoder.set_gop(framerate as u32); // 1 second GOP

    println!("📐 Video dimensions: {}×{} (aligned to {}×{} for H.264)",
             width, height, aligned_width, aligned_height);

    // Open encoder with codec (like working MP3 export)
    let encoder = encoder
        .open_as(codec)
        .map_err(|e| format!("Failed to open video encoder: {}", e))?;

    Ok((encoder, codec))
}

/// Receive encoded packets from encoder and write to output file
///
/// # Arguments
/// * `encoder` - FFmpeg video encoder
/// * `output` - FFmpeg output format context
///
/// # Returns
/// Ok(()) on success, Err with message on failure
pub fn receive_and_write_packets(
    encoder: &mut ffmpeg::encoder::Video,
    output: &mut ffmpeg::format::context::Output,
) -> Result<(), String> {
    let mut encoded = ffmpeg::Packet::empty();

    // Get time bases for rescaling
    let encoder_tb = encoder.time_base();
    let stream_tb = output.stream(0).ok_or("No output stream found")?.time_base();

    while encoder.receive_packet(&mut encoded).is_ok() {
        encoded.set_stream(0);
        // Rescale timestamps from encoder time base to stream time base
        encoded.rescale_ts(encoder_tb, stream_tb);

        encoded
            .write_interleaved(output)
            .map_err(|e| format!("Failed to write packet: {}", e))?;
    }

    Ok(())
}

/// Render a document frame at a specific time and read back RGBA pixels from GPU
///
/// # Arguments
/// * `document` - Document to render (current_time will be modified)
/// * `timestamp` - Time in seconds to render at
/// * `width` - Frame width in pixels
/// * `height` - Frame height in pixels
/// * `device` - wgpu device
/// * `queue` - wgpu queue
/// * `renderer` - Vello renderer
/// * `image_cache` - Image cache for rendering
/// * `video_manager` - Video manager for video clips
/// * `rgba_buffer` - Output buffer for RGBA pixels (must be width * height * 4 bytes)
///
/// # Returns
/// Ok(()) on success, Err with message on failure
pub fn render_frame_to_rgba(
    document: &mut Document,
    timestamp: f64,
    width: u32,
    height: u32,
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    renderer: &mut vello::Renderer,
    image_cache: &mut ImageCache,
    video_manager: &Arc<std::sync::Mutex<VideoManager>>,
    rgba_buffer: &mut [u8],
) -> Result<(), String> {
    // Set document time to the frame timestamp
    document.current_time = timestamp;

    // Create offscreen texture for rendering
    let texture = device.create_texture(&wgpu::TextureDescriptor {
        label: Some("video_export_texture"),
        size: wgpu::Extent3d {
            width,
            height,
            depth_or_array_layers: 1,
        },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format: wgpu::TextureFormat::Rgba8Unorm,
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
             | wgpu::TextureUsages::COPY_SRC
             | wgpu::TextureUsages::STORAGE_BINDING, // Required by Vello for compute shaders
        view_formats: &[],
    });

    let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());

    // Render document to Vello scene
    let mut scene = vello::Scene::new();
    lightningbeam_core::renderer::render_document(
        document,
        &mut scene,
        image_cache,
        video_manager,
    );

    // Render scene to texture
    let render_params = vello::RenderParams {
        base_color: vello::peniko::Color::BLACK,
        width,
        height,
        antialiasing_method: vello::AaConfig::Area,
    };

    renderer
        .render_to_texture(device, queue, &scene, &texture_view, &render_params)
        .map_err(|e| format!("Failed to render to texture: {}", e))?;

    // GPU readback: Create staging buffer with proper alignment
    let bytes_per_pixel = 4u32; // RGBA8
    let bytes_per_row_alignment = 256u32; // wgpu::COPY_BYTES_PER_ROW_ALIGNMENT
    let unpadded_bytes_per_row = width * bytes_per_pixel;
    let bytes_per_row = ((unpadded_bytes_per_row + bytes_per_row_alignment - 1)
        / bytes_per_row_alignment) * bytes_per_row_alignment;
    let buffer_size = (bytes_per_row * height) as u64;

    let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("video_export_staging_buffer"),
        size: buffer_size,
        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
        mapped_at_creation: false,
    });

    // Copy texture to staging buffer
    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("video_export_copy_encoder"),
    });

    encoder.copy_texture_to_buffer(
        wgpu::TexelCopyTextureInfo {
            texture: &texture,
            mip_level: 0,
            origin: wgpu::Origin3d::ZERO,
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::TexelCopyBufferInfo {
            buffer: &staging_buffer,
            layout: wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(bytes_per_row),
                rows_per_image: Some(height),
            },
        },
        wgpu::Extent3d {
            width,
            height,
            depth_or_array_layers: 1,
        },
    );

    queue.submit(Some(encoder.finish()));

    // Map buffer and read pixels (synchronous)
    let buffer_slice = staging_buffer.slice(..);
    let (sender, receiver) = std::sync::mpsc::channel();
    buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
        sender.send(result).ok();
    });

    let _ = device.poll(wgpu::PollType::wait_indefinitely());

    receiver
        .recv()
        .map_err(|_| "Failed to receive buffer mapping result")?
        .map_err(|e| format!("Failed to map buffer: {:?}", e))?;

    // Copy data from mapped buffer to output, removing padding
    let data = buffer_slice.get_mapped_range();
    for y in 0..height as usize {
        let src_offset = y * bytes_per_row as usize;
        let dst_offset = y * unpadded_bytes_per_row as usize;
        let row_bytes = unpadded_bytes_per_row as usize;
        rgba_buffer[dst_offset..dst_offset + row_bytes]
            .copy_from_slice(&data[src_offset..src_offset + row_bytes]);
    }

    drop(data);
    staging_buffer.unmap();

    Ok(())
}

/// Render a document frame using the HDR compositing pipeline with effects
///
/// This function uses the same rendering pipeline as the stage preview,
/// ensuring effects are applied correctly during export.
///
/// # Arguments
/// * `document` - Document to render (current_time will be modified)
/// * `timestamp` - Time in seconds to render at
/// * `width` - Frame width in pixels
/// * `height` - Frame height in pixels
/// * `device` - wgpu device
/// * `queue` - wgpu queue
/// * `renderer` - Vello renderer
/// * `image_cache` - Image cache for rendering
/// * `video_manager` - Video manager for video clips
/// * `gpu_resources` - HDR GPU resources for compositing
///
/// # Returns
/// Ok((y_plane, u_plane, v_plane)) with YUV420p planes on success, Err with message on failure
pub fn render_frame_to_rgba_hdr(
    document: &mut Document,
    timestamp: f64,
    width: u32,
    height: u32,
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    renderer: &mut vello::Renderer,
    image_cache: &mut ImageCache,
    video_manager: &Arc<std::sync::Mutex<VideoManager>>,
    gpu_resources: &mut ExportGpuResources,
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
    use vello::kurbo::Affine;

    // Set document time to the frame timestamp
    document.current_time = timestamp;

    // Use identity transform for export (document coordinates = pixel coordinates)
    let base_transform = Affine::IDENTITY;

    // Render document for compositing (returns per-layer scenes)
    let composite_result = render_document_for_compositing(
        document,
        base_transform,
        image_cache,
        video_manager,
        None, // No skipping during export
    );

    // Buffer specs for layer rendering
    let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
    let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);

    // Render parameters for Vello (transparent background for layers)
    let layer_render_params = vello::RenderParams {
        base_color: vello::peniko::Color::TRANSPARENT,
        width,
        height,
        antialiasing_method: vello::AaConfig::Area,
    };

    // First, render background and composite it
    let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
    let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);

    if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
        gpu_resources.buffer_pool.get_view(bg_srgb_handle),
        gpu_resources.buffer_pool.get_view(bg_hdr_handle),
    ) {
        // Render background scene
        renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
            .map_err(|e| format!("Failed to render background: {}", e))?;

        // Convert sRGB to linear HDR
        let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("export_bg_srgb_to_linear_encoder"),
        });
        gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view);
        queue.submit(Some(convert_encoder.finish()));

        // Composite background onto HDR texture (first layer, clears to black for export)
        let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("export_bg_composite_encoder"),
        });
        // Clear to black for export (unlike stage preview which has gray background)
        gpu_resources.compositor.composite(
            device,
            queue,
            &mut encoder,
            &[bg_compositor_layer],
            &gpu_resources.buffer_pool,
            &gpu_resources.hdr_texture_view,
            Some([0.0, 0.0, 0.0, 1.0]),
        );
        queue.submit(Some(encoder.finish()));
    }
    gpu_resources.buffer_pool.release(bg_srgb_handle);
    gpu_resources.buffer_pool.release(bg_hdr_handle);

    // Now render and composite each layer incrementally
    for rendered_layer in &composite_result.layers {
        if !rendered_layer.has_content {
            continue;
        }

        match &rendered_layer.layer_type {
            RenderedLayerType::Content => {
                // Regular content layer - render to sRGB, convert to linear, then composite
                let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
                let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);

                if let (Some(srgb_view), Some(hdr_layer_view)) = (
                    gpu_resources.buffer_pool.get_view(srgb_handle),
                    gpu_resources.buffer_pool.get_view(hdr_layer_handle),
                ) {
                    // Render layer scene to sRGB buffer
                    renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
                        .map_err(|e| format!("Failed to render layer: {}", e))?;

                    // Convert sRGB to linear HDR
                    let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                        label: Some("export_layer_srgb_to_linear_encoder"),
                    });
                    gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
                    queue.submit(Some(convert_encoder.finish()));

                    // Composite this layer onto the HDR accumulator with its opacity
                    let compositor_layer = CompositorLayer::new(
                        hdr_layer_handle,
                        rendered_layer.opacity,
                        rendered_layer.blend_mode,
                    );

                    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                        label: Some("export_layer_composite_encoder"),
                    });
                    gpu_resources.compositor.composite(
                        device,
                        queue,
                        &mut encoder,
                        &[compositor_layer],
                        &gpu_resources.buffer_pool,
                        &gpu_resources.hdr_texture_view,
                        None, // Don't clear - blend onto existing content
                    );
                    queue.submit(Some(encoder.finish()));
                }

                gpu_resources.buffer_pool.release(srgb_handle);
                gpu_resources.buffer_pool.release(hdr_layer_handle);
            }
            RenderedLayerType::Effect { effect_instances } => {
                // Effect layer - apply effects to the current HDR accumulator
                let current_time = document.current_time;

                for effect_instance in effect_instances {
                    // Get effect definition from document
                    let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else {
                        continue;
                    };

                    // Compile effect if needed
                    if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
                        let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
                        if !success {
                            eprintln!("Failed to compile effect: {}", effect_def.name);
                            continue;
                        }
                    }

                    // Create EffectInstance from ClipInstance for the processor
                    let effect_inst = lightningbeam_core::effect::EffectInstance::new(
                        effect_def,
                        effect_instance.timeline_start,
                        effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
                    );

                    // Acquire temp buffer for effect output (HDR format)
                    let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);

                    if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
                        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                            label: Some("export_effect_encoder"),
                        });

                        // Apply effect: HDR accumulator → effect output buffer
                        let applied = gpu_resources.effect_processor.apply_effect(
                            device,
                            queue,
                            &mut encoder,
                            effect_def,
                            &effect_inst,
                            &gpu_resources.hdr_texture_view,
                            effect_output_view,
                            width,
                            height,
                            current_time,
                        );

                        if applied {
                            queue.submit(Some(encoder.finish()));

                            // Copy effect output back to HDR accumulator
                            let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                                label: Some("export_effect_copy_encoder"),
                            });

                            // Use compositor to copy (replacing content)
                            let effect_layer = CompositorLayer::normal(
                                effect_output_handle,
                                rendered_layer.opacity, // Apply effect layer opacity
                            );
                            gpu_resources.compositor.composite(
                                device,
                                queue,
                                &mut copy_encoder,
                                &[effect_layer],
                                &gpu_resources.buffer_pool,
                                &gpu_resources.hdr_texture_view,
                                Some([0.0, 0.0, 0.0, 0.0]), // Clear with transparent (we're replacing)
                            );
                            queue.submit(Some(copy_encoder.finish()));
                        }
                    }

                    gpu_resources.buffer_pool.release(effect_output_handle);
                }
            }
        }
    }

    // Advance frame counter for buffer cleanup
    gpu_resources.buffer_pool.next_frame();

    // Use persistent output texture (already created in ExportGpuResources)
    let output_view = &gpu_resources.output_texture_view;

    // Convert HDR to sRGB for output
    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
        label: Some("export_linear_to_srgb_bind_group"),
        layout: &gpu_resources.linear_to_srgb_bind_group_layout,
        entries: &[
            wgpu::BindGroupEntry {
                binding: 0,
                resource: wgpu::BindingResource::TextureView(&gpu_resources.hdr_texture_view),
            },
            wgpu::BindGroupEntry {
                binding: 1,
                resource: wgpu::BindingResource::Sampler(&gpu_resources.linear_to_srgb_sampler),
            },
        ],
    });

    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("export_linear_to_srgb_encoder"),
    });

    {
        let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: Some("export_linear_to_srgb_pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view: &output_view,
                resolve_target: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
                    store: wgpu::StoreOp::Store,
                },
                depth_slice: None,
            })],
            depth_stencil_attachment: None,
            occlusion_query_set: None,
            timestamp_writes: None,
        });

        render_pass.set_pipeline(&gpu_resources.linear_to_srgb_pipeline);
        render_pass.set_bind_group(0, &bind_group, &[]);
        render_pass.draw(0..4, 0..1);
    }

    queue.submit(Some(encoder.finish()));

    // GPU YUV conversion: Convert RGBA output to YUV420p
    let mut yuv_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("export_yuv_conversion_encoder"),
    });

    gpu_resources.yuv_converter.convert(
        device,
        &mut yuv_encoder,
        output_view,
        &gpu_resources.yuv_texture_view,
        width,
        height,
    );

    // Copy YUV texture to persistent staging buffer
    let yuv_height = height + height / 2; // Y plane + U plane + V plane
    yuv_encoder.copy_texture_to_buffer(
        wgpu::TexelCopyTextureInfo {
            texture: &gpu_resources.yuv_texture,
            mip_level: 0,
            origin: wgpu::Origin3d::ZERO,
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::TexelCopyBufferInfo {
            buffer: &gpu_resources.staging_buffer,
            layout: wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(width * 4), // Rgba8Unorm = 4 bytes per pixel
                rows_per_image: Some(yuv_height),
            },
        },
        wgpu::Extent3d {
            width,
            height: yuv_height,
            depth_or_array_layers: 1,
        },
    );

    queue.submit(Some(yuv_encoder.finish()));

    // Map buffer and read YUV pixels (synchronous)
    let buffer_slice = gpu_resources.staging_buffer.slice(..);
    let (sender, receiver) = std::sync::mpsc::channel();
    buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
        sender.send(result).ok();
    });

    let _ = device.poll(wgpu::PollType::wait_indefinitely());

    receiver
        .recv()
        .map_err(|_| "Failed to receive buffer mapping result")?
        .map_err(|e| format!("Failed to map buffer: {:?}", e))?;

    // Extract Y, U, V planes from packed YUV buffer
    let data = buffer_slice.get_mapped_range();
    let width_usize = width as usize;
    let height_usize = height as usize;

    // Y plane: rows 0 to height-1 (extract R channel from Rgba8Unorm)
    let y_plane_size = width_usize * height_usize;
    let mut y_plane = vec![0u8; y_plane_size];
    for y in 0..height_usize {
        let src_row_offset = y * width_usize * 4; // 4 bytes per pixel (Rgba8Unorm)
        let dst_row_offset = y * width_usize;
        for x in 0..width_usize {
            y_plane[dst_row_offset + x] = data[src_row_offset + x * 4]; // Extract R channel
        }
    }

    // U and V planes: rows height to height + height/2 - 1 (half resolution, side-by-side layout)
    // U plane is in left half (columns 0 to width/2-1), V plane is in right half (columns width/2 to width-1)
    let chroma_width = width_usize / 2;
    let chroma_height = height_usize / 2;
    let chroma_row_start = height_usize * width_usize * 4; // Start of chroma rows in bytes

    let mut u_plane = vec![0u8; chroma_width * chroma_height];
    let mut v_plane = vec![0u8; chroma_width * chroma_height];

    for y in 0..chroma_height {
        let row_offset = chroma_row_start + y * width_usize * 4; // Full width rows in chroma region

        // Extract U plane (left half: columns 0 to chroma_width-1)
        let u_start = row_offset;
        let dst_offset = y * chroma_width;
        for x in 0..chroma_width {
            u_plane[dst_offset + x] = data[u_start + x * 4]; // Extract R channel
        }

        // Extract V plane (right half: columns width/2 to width/2+chroma_width-1)
        let v_start = row_offset + chroma_width * 4;
        for x in 0..chroma_width {
            v_plane[dst_offset + x] = data[v_start + x * 4]; // Extract R channel
        }
    }

    drop(data);
    gpu_resources.staging_buffer.unmap();

    Ok((y_plane, u_plane, v_plane))
}

/// Render frame to GPU RGBA texture (non-blocking, for async pipeline)
///
/// Similar to render_frame_to_rgba_hdr but renders to an external RGBA texture view
/// (provided by ReadbackPipeline) and returns the command encoder WITHOUT blocking on readback.
/// The caller (ReadbackPipeline) will submit the encoder and handle async readback.
///
/// # Arguments
/// * `document` - Document to render
/// * `timestamp` - Time in seconds to render at
/// * `width` - Frame width in pixels
/// * `height` - Frame height in pixels
/// * `device` - wgpu device
/// * `queue` - wgpu queue
/// * `renderer` - Vello renderer
/// * `image_cache` - Image cache for rendering
/// * `video_manager` - Video manager for video clips
/// * `gpu_resources` - HDR GPU resources for compositing
/// * `rgba_texture_view` - External RGBA texture view (from ReadbackPipeline)
///
/// # Returns
/// Command encoder ready for submission (caller submits via ReadbackPipeline)
pub fn render_frame_to_gpu_rgba(
    document: &mut Document,
    timestamp: f64,
    width: u32,
    height: u32,
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    renderer: &mut vello::Renderer,
    image_cache: &mut ImageCache,
    video_manager: &Arc<std::sync::Mutex<VideoManager>>,
    gpu_resources: &mut ExportGpuResources,
    rgba_texture_view: &wgpu::TextureView,
) -> Result<wgpu::CommandEncoder, String> {
    use vello::kurbo::Affine;

    // Set document time to the frame timestamp
    document.current_time = timestamp;

    // Use identity transform for export (document coordinates = pixel coordinates)
    let base_transform = Affine::IDENTITY;

    // Render document for compositing (returns per-layer scenes)
    let composite_result = render_document_for_compositing(
        document,
        base_transform,
        image_cache,
        video_manager,
        None, // No skipping during export
    );

    // Buffer specs for layer rendering
    let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
    let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);

    // Render parameters for Vello (transparent background for layers)
    let layer_render_params = vello::RenderParams {
        base_color: vello::peniko::Color::TRANSPARENT,
        width,
        height,
        antialiasing_method: vello::AaConfig::Area,
    };

    // Render background and composite it
    let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
    let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);

    if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
        gpu_resources.buffer_pool.get_view(bg_srgb_handle),
        gpu_resources.buffer_pool.get_view(bg_hdr_handle),
    ) {
        renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
            .map_err(|e| format!("Failed to render background: {}", e))?;

        let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("export_bg_srgb_to_linear_encoder"),
        });
        gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view);
        queue.submit(Some(convert_encoder.finish()));

        let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("export_bg_composite_encoder"),
        });
        gpu_resources.compositor.composite(
            device,
            queue,
            &mut encoder,
            &[bg_compositor_layer],
            &gpu_resources.buffer_pool,
            &gpu_resources.hdr_texture_view,
            Some([0.0, 0.0, 0.0, 1.0]),
        );
        queue.submit(Some(encoder.finish()));
    }
    gpu_resources.buffer_pool.release(bg_srgb_handle);
    gpu_resources.buffer_pool.release(bg_hdr_handle);

    // Render and composite each layer incrementally
    for rendered_layer in &composite_result.layers {
        if !rendered_layer.has_content {
            continue;
        }

        match &rendered_layer.layer_type {
            RenderedLayerType::Content => {
                let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
                let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);

                if let (Some(srgb_view), Some(hdr_layer_view)) = (
                    gpu_resources.buffer_pool.get_view(srgb_handle),
                    gpu_resources.buffer_pool.get_view(hdr_layer_handle),
                ) {
                    renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
                        .map_err(|e| format!("Failed to render layer: {}", e))?;

                    let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                        label: Some("export_layer_srgb_to_linear_encoder"),
                    });
                    gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
                    queue.submit(Some(convert_encoder.finish()));

                    let compositor_layer = CompositorLayer::normal(hdr_layer_handle, rendered_layer.opacity);
                    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                        label: Some("export_layer_composite_encoder"),
                    });
                    gpu_resources.compositor.composite(
                        device,
                        queue,
                        &mut encoder,
                        &[compositor_layer],
                        &gpu_resources.buffer_pool,
                        &gpu_resources.hdr_texture_view,
                        None,
                    );
                    queue.submit(Some(encoder.finish()));
                }
                gpu_resources.buffer_pool.release(srgb_handle);
                gpu_resources.buffer_pool.release(hdr_layer_handle);
            }
            RenderedLayerType::Effect { effect_instances } => {
                // Effect layer - apply effects to the current HDR accumulator
                let current_time = document.current_time;

                for effect_instance in effect_instances {
                    // Get effect definition from document
                    let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else {
                        continue;
                    };

                    // Compile effect if needed
                    if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
                        let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
                        if !success {
                            eprintln!("Failed to compile effect: {}", effect_def.name);
                            continue;
                        }
                    }

                    // Create EffectInstance from ClipInstance for the processor
                    let effect_inst = lightningbeam_core::effect::EffectInstance::new(
                        effect_def,
                        effect_instance.timeline_start,
                        effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
                    );

                    // Acquire temp buffer for effect output (HDR format)
                    let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);

                    if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
                        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                            label: Some("export_effect_encoder"),
                        });

                        // Apply effect: HDR accumulator → effect output buffer
                        let applied = gpu_resources.effect_processor.apply_effect(
                            device,
                            queue,
                            &mut encoder,
                            effect_def,
                            &effect_inst,
                            &gpu_resources.hdr_texture_view,
                            effect_output_view,
                            width,
                            height,
                            current_time,
                        );

                        if applied {
                            // Copy effect output back to HDR accumulator
                            encoder.copy_texture_to_texture(
                                wgpu::TexelCopyTextureInfo {
                                    texture: gpu_resources.buffer_pool.get_texture(effect_output_handle).unwrap(),
                                    mip_level: 0,
                                    origin: wgpu::Origin3d::ZERO,
                                    aspect: wgpu::TextureAspect::All,
                                },
                                wgpu::TexelCopyTextureInfo {
                                    texture: &gpu_resources.hdr_texture,
                                    mip_level: 0,
                                    origin: wgpu::Origin3d::ZERO,
                                    aspect: wgpu::TextureAspect::All,
                                },
                                wgpu::Extent3d {
                                    width,
                                    height,
                                    depth_or_array_layers: 1,
                                },
                            );
                        }

                        queue.submit(Some(encoder.finish()));
                    }

                    gpu_resources.buffer_pool.release(effect_output_handle);
                }
            }
        }
    }

    // Convert HDR to sRGB (linear → sRGB), render directly to external RGBA texture
    let output_view = rgba_texture_view;
    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
        label: Some("export_linear_to_srgb_bind_group"),
        layout: &gpu_resources.linear_to_srgb_bind_group_layout,
        entries: &[
            wgpu::BindGroupEntry {
                binding: 0,
                resource: wgpu::BindingResource::TextureView(&gpu_resources.hdr_texture_view),
            },
            wgpu::BindGroupEntry {
                binding: 1,
                resource: wgpu::BindingResource::Sampler(&gpu_resources.linear_to_srgb_sampler),
            },
        ],
    });

    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("export_linear_to_srgb_encoder"),
    });

    {
        let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: Some("export_linear_to_srgb_pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view: &output_view,
                resolve_target: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
                    store: wgpu::StoreOp::Store,
                },
                depth_slice: None,
            })],
            depth_stencil_attachment: None,
            occlusion_query_set: None,
            timestamp_writes: None,
        });

        render_pass.set_pipeline(&gpu_resources.linear_to_srgb_pipeline);
        render_pass.set_bind_group(0, &bind_group, &[]);
        render_pass.draw(0..4, 0..1);
    }

    // Return encoder for caller to submit (ReadbackPipeline will handle submission and async readback)
    // Frame is already rendered to external RGBA texture, no GPU YUV conversion needed
    Ok(encoder)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_rgba_to_yuv420p_white() {
        // White: R=255, G=255, B=255
        let rgba = vec![255u8, 255, 255, 255]; // 1 pixel
        let (y, u, v) = rgba_to_yuv420p(&rgba, 1, 1);

        // Expected: Y=255 (full brightness), U=128, V=128 (neutral chroma)
        assert_eq!(y[0], 255);
        assert_eq!(u[0], 128);
        assert_eq!(v[0], 128);
    }

    #[test]
    fn test_rgba_to_yuv420p_black() {
        // Black: R=0, G=0, B=0
        let rgba = vec![0u8, 0, 0, 255]; // 1 pixel
        let (y, u, v) = rgba_to_yuv420p(&rgba, 1, 1);

        // Expected: Y=0 (no brightness), U=128, V=128 (neutral chroma)
        assert_eq!(y[0], 0);
        assert_eq!(u[0], 128);
        assert_eq!(v[0], 128);
    }

    #[test]
    fn test_rgba_to_yuv420p_red() {
        // Red: R=255, G=0, B=0
        let rgba = vec![255u8, 0, 0, 255]; // 1 pixel
        let (y, u, v) = rgba_to_yuv420p(&rgba, 1, 1);

        // Red has:
        // - Y around 54 (low luma due to low green coefficient)
        // - U < 128 (negative blue component)
        // - V > 128 (positive red component)
        assert!(y[0] >= 50 && y[0] <= 60, "Y value: {}", y[0]);
        assert!(u[0] < 128, "U value: {}", u[0]);
        assert!(v[0] > 128, "V value: {}", v[0]);
    }

    #[test]
    fn test_rgba_to_yuv420p_dimensions() {
        // 4×4 image (16 pixels)
        let rgba = vec![0u8; 4 * 4 * 4]; // All black
        let (y, u, v) = rgba_to_yuv420p(&rgba, 4, 4);

        // Y should be full resolution: 4×4 = 16 pixels
        assert_eq!(y.len(), 16);

        // U and V should be quarter resolution: 2×2 = 4 pixels each
        assert_eq!(u.len(), 4);
        assert_eq!(v.len(), 4);
    }

    #[test]
    fn test_rgba_to_yuv420p_2x2_subsampling() {
        // Create 2×2 image with different colors in each corner
        let mut rgba = vec![0u8; 2 * 2 * 4];

        // Top-left: Red
        rgba[0] = 255;
        rgba[1] = 0;
        rgba[2] = 0;
        rgba[3] = 255;

        // Top-right: Green
        rgba[4] = 0;
        rgba[5] = 255;
        rgba[6] = 0;
        rgba[7] = 255;

        // Bottom-left: Blue
        rgba[8] = 0;
        rgba[9] = 0;
        rgba[10] = 255;
        rgba[11] = 255;

        // Bottom-right: White
        rgba[12] = 255;
        rgba[13] = 255;
        rgba[14] = 255;
        rgba[15] = 255;

        let (y, u, v) = rgba_to_yuv420p(&rgba, 2, 2);

        // Y plane should have 4 distinct values (one per pixel)
        assert_eq!(y.len(), 4);

        // U and V should have 1 value each (averaged over 2×2 block)
        assert_eq!(u.len(), 1);
        assert_eq!(v.len(), 1);

        // The averaged chroma should be close to neutral (128)
        // since we have all primary colors + white
        assert!(u[0] >= 100 && u[0] <= 156, "U value: {}", u[0]);
        assert!(v[0] >= 100 && v[0] <= 156, "V value: {}", v[0]);
    }
}
