Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs

1455 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![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,
);
// 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,
);
// 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]);
}
}