diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs index cbe6a51..c130dd3 100644 --- a/daw-backend/src/audio/pool.rs +++ b/daw-backend/src/audio/pool.rs @@ -582,8 +582,21 @@ impl AudioClipPool { // or a container without exact stream duration). They will never // arrive — stop waiting and let the render fill silence, instead of // burning the 10s safety valve on every remaining chunk. + // + // BUT `finished` may be stale: we just moved the target with + // `set_target_frame`, and on a forward jump past the buffer the reader + // will reset()+seek() (clearing `finished`) on its next poll. The reader + // re-seeks when `target < buf_start || target > buf_end + sample_rate` + // (see disk_reader.rs), so only trust EOF when the target is inside the + // current window — otherwise a stale flag from the previous region would + // make us render silence over audio that's about to be decoded. if ra.is_finished() { - break; + let (buf_start, buf_end) = ra.snapshot(); + let sr = audio_file.sample_rate as u64; + let seek_pending = src_start < buf_start || src_start > buf_end + sr; + if !seek_pending { + break; + } } std::thread::sleep(std::time::Duration::from_micros(100)); wait_iters += 1; diff --git a/lightningbeam-ui/gpu-video-encoder/src/dmabuf.rs b/lightningbeam-ui/gpu-video-encoder/src/dmabuf.rs index 4922f16..8b536ff 100644 --- a/lightningbeam-ui/gpu-video-encoder/src/dmabuf.rs +++ b/lightningbeam-ui/gpu-video-encoder/src/dmabuf.rs @@ -36,6 +36,47 @@ impl Drop for MemoryGuard { } } +/// Frees the duplicated dma-buf fd and any partially-created Vulkan objects when an import +/// errors out before ownership has been handed to wgpu/`MemoryGuard`. `commit()` disarms it on +/// the success path; `fd_consumed()` is called once `vkAllocateMemory` has taken the fd. +struct ImportGuard { + device: ash::Device, + fd: libc::c_int, + img_y: vk::Image, + img_uv: vk::Image, + memory: vk::DeviceMemory, + armed: bool, +} +impl ImportGuard { + fn fd_consumed(&mut self) { + self.fd = -1; // vkAllocateMemory owns the fd now; don't close it ourselves + } + fn commit(&mut self) { + self.armed = false; + } +} +impl Drop for ImportGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + unsafe { + if self.img_uv != vk::Image::null() { + self.device.destroy_image(self.img_uv, None); + } + if self.img_y != vk::Image::null() { + self.device.destroy_image(self.img_y, None); + } + if self.memory != vk::DeviceMemory::null() { + self.device.free_memory(self.memory, None); + } + if self.fd >= 0 { + libc::close(self.fd); + } + } + } +} + /// A VAAPI surface imported as two wgpu plane textures. The underlying Vulkan image/ /// memory are destroyed by wgpu (via drop callbacks) when these textures drop. pub struct ImportedNv12 { @@ -103,6 +144,16 @@ pub fn import_raw( if dup_fd < 0 { return Err("dup(dma-buf fd) failed".into()); } + // Owns the fd + any Vk objects created below until ownership transfers to wgpu; on any + // early `?`/return before that, its Drop frees them (was leaking on every failed import). + let mut guard = ImportGuard { + device: raw_device.clone(), + fd: dup_fd, + img_y: vk::Image::null(), + img_uv: vk::Image::null(), + memory: vk::DeviceMemory::null(), + armed: true, + }; // 16-bit-norm plane formats (P010) are NOT renderable, so the import is sample-only for // those (decode path). 8-bit planes keep COLOR_ATTACHMENT for the encoder's RGBA→NV12 write. @@ -155,7 +206,9 @@ pub fn import_raw( }; let img_y = make_image(vk_y, buf.width, buf.height, buf.y_pitch)?; + guard.img_y = img_y; let img_uv = make_image(vk_uv, buf.width / 2, buf.height / 2, buf.uv_pitch)?; + guard.img_uv = img_uv; let fd_dev = ash::khr::external_memory_fd::Device::new(instance, &raw_device); let mut fd_props = vk::MemoryFdPropertiesKHR::default(); @@ -180,6 +233,8 @@ pub fn import_raw( let memory = raw_device .allocate_memory(&alloc, None) .map_err(|e| format!("vkAllocateMemory(import dma-buf) failed: {e:?}"))?; + guard.fd_consumed(); // the import transferred fd ownership to Vulkan + guard.memory = memory; raw_device .bind_image_memory(img_y, memory, buf.y_offset) @@ -242,6 +297,9 @@ pub fn import_raw( }, ) }; + // Ownership of the images (→ texture drop callbacks) and memory (→ MemoryGuard) has now + // transferred to wgpu; disarm the cleanup guard so it doesn't double-free them. + guard.commit(); let y = wrap(img_y, wgpu_y, buf.width, buf.height); let uv = wrap(img_uv, wgpu_uv, buf.width / 2, buf.height / 2); drop(hal_device); diff --git a/lightningbeam-ui/gpu-video-encoder/src/vaapi.rs b/lightningbeam-ui/gpu-video-encoder/src/vaapi.rs index f7502e0..5eb0da2 100644 --- a/lightningbeam-ui/gpu-video-encoder/src/vaapi.rs +++ b/lightningbeam-ui/gpu-video-encoder/src/vaapi.rs @@ -157,10 +157,18 @@ impl MappedSurface { let desc = (*drm).data[0] as *const ff::AVDRMFrameDescriptor; // Expect 1 object, 2 layers (Y=R8, UV=GR88). if (*desc).nb_objects != 1 || (*desc).nb_layers != 2 { - return Err(format!( + let msg = format!( "unexpected DRM layout: {} objects, {} layers", (*desc).nb_objects, (*desc).nb_layers - )); + ); + // Free everything mapped/allocated above (this path was leaking the device, + // frames context, and both AVFrames on every odd-layout surface). + ff::av_frame_free(&mut (drm as *mut _)); + ff::av_frame_free(&mut (surf as *mut _)); + let mut fr = frames_ref; + ff::av_buffer_unref(&mut fr); + ff::av_buffer_unref(&mut hw_device); + return Err(msg); } let obj = &(*desc).objects[0]; let y = &(*desc).layers[0].planes[0]; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_diff.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_diff.rs index 15a5f16..1911750 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_diff.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_diff.rs @@ -98,11 +98,16 @@ impl RasterDiff { self.before_region.len() + self.after_region.len() } - /// Restore the pre-edit pixels into `raw` (undo / first-execute rollback). - pub fn apply_before(&self, raw: &mut Vec) { + /// Restore the pre-edit pixels into `raw` (undo / first-execute rollback). `cur_w`/`cur_h` + /// are the keyframe's current dimensions; if they differ from the diff's captured size the + /// diff predates a resize and is skipped rather than applied at mismatched dims. + pub fn apply_before(&self, raw: &mut Vec, cur_w: u32, cur_h: u32) { if self.bbox.is_none() { return; // no change } + if cur_w != self.full_width || cur_h != self.full_height { + return; // diff predates a keyframe resize + } if self.before_blank { // The frame was blank before this edit (it was the first stroke); undoing // it returns to blank regardless of the current buffer. @@ -112,11 +117,16 @@ impl RasterDiff { self.stamp_resident(&self.before_region, raw); } - /// Apply the post-edit pixels into `raw` (commit / redo). - pub fn apply_after(&self, raw: &mut Vec) { + /// Apply the post-edit pixels into `raw` (commit / redo). `cur_w`/`cur_h` are the keyframe's + /// current dimensions; a mismatch with the captured size means the diff predates a resize, so + /// it's skipped rather than rebuilding the buffer at stale dimensions. + pub fn apply_after(&self, raw: &mut Vec, cur_w: u32, cur_h: u32) { if self.bbox.is_none() { return; // no change } + if cur_w != self.full_width || cur_h != self.full_height { + return; // diff predates a keyframe resize + } if self.before_blank { // Base was blank: build a full transparent buffer then stamp the bbox. The // commit/redo path frequently starts from empty `raw_pixels` here. @@ -175,9 +185,9 @@ mod tests { assert_eq!(diff.bbox, Some((3, 2, 2, 2))); let mut buf = after.clone(); - diff.apply_before(&mut buf); + diff.apply_before(&mut buf, w, h); assert_eq!(buf, before, "undo must reproduce the pre-edit buffer exactly"); - diff.apply_after(&mut buf); + diff.apply_after(&mut buf, w, h); assert_eq!(buf, after, "redo must reproduce the post-edit buffer exactly"); } @@ -195,15 +205,15 @@ mod tests { // First execute / redo from EMPTY raw_pixels (the real commit path): builds // the full buffer from transparent + the stroke. let mut buf: Vec = Vec::new(); - diff.apply_after(&mut buf); + diff.apply_after(&mut buf, w, h); assert_eq!(buf, after, "commit/redo must build the frame from a blank base"); // Undo the first stroke → back to blank (empty). - diff.apply_before(&mut buf); + diff.apply_before(&mut buf, w, h); assert!(buf.is_empty(), "undoing the first stroke restores the blank keyframe"); // Redo again from the now-empty buffer. - diff.apply_after(&mut buf); + diff.apply_after(&mut buf, w, h); assert_eq!(buf, after); } @@ -215,7 +225,7 @@ mod tests { assert_eq!(diff.bbox, None); assert_eq!(diff.byte_size(), 0); let mut b = buf.clone(); - diff.apply_before(&mut b); + diff.apply_before(&mut b, w, h); assert_eq!(b, buf); } @@ -226,7 +236,7 @@ mod tests { let after = solid(w, h, [1, 2, 3, 255]); let diff = RasterDiff::compute(&before, &after, w, h); let mut empty: Vec = Vec::new(); - diff.apply_before(&mut empty); // base not resident + diff.apply_before(&mut empty, w, h); // base not resident assert!(empty.is_empty(), "must not resize/corrupt a non-resident base"); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs index 92c0dd0..3693d0d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs @@ -53,7 +53,7 @@ impl Action for RasterFillAction { if let Some(full) = self.full_after.take() { kf.raw_pixels = full; } else { - self.diff.apply_after(&mut kf.raw_pixels); + self.diff.apply_after(&mut kf.raw_pixels, kf.width, kf.height); } kf.texture_dirty = true; kf.dirty = true; @@ -71,7 +71,7 @@ impl Action for RasterFillAction { let kf = raster .keyframe_at_mut(self.time) .ok_or_else(|| format!("No raster keyframe at/before t={}", self.time))?; - self.diff.apply_before(&mut kf.raw_pixels); + self.diff.apply_before(&mut kf.raw_pixels, kf.width, kf.height); kf.texture_dirty = true; kf.dirty = true; Ok(()) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs index 06517b9..392fcb0 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs @@ -61,7 +61,7 @@ impl Action for RasterStrokeAction { kf.raw_pixels = full; } else { // Redo: replay via the diff onto the (resident) base. - self.diff.apply_after(&mut kf.raw_pixels); + self.diff.apply_after(&mut kf.raw_pixels, kf.width, kf.height); } kf.texture_dirty = true; kf.dirty = true; @@ -70,7 +70,7 @@ impl Action for RasterStrokeAction { fn rollback(&mut self, document: &mut Document) -> Result<(), String> { let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; - self.diff.apply_before(&mut kf.raw_pixels); + self.diff.apply_before(&mut kf.raw_pixels, kf.width, kf.height); kf.texture_dirty = true; kf.dirty = true; Ok(()) diff --git a/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs b/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs index 39ef088..770b544 100644 --- a/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs +++ b/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs @@ -374,7 +374,11 @@ impl BeamArchive { let rows = stmt .query_map([&id_bytes], |r| r.get::<_, Vec>(0)) .map_err(map_sql)?; - let mut out = Vec::with_capacity(info.total_len as usize); + // `total_len` is an untrusted DB field; cap the preallocation so a corrupt/oversized + // value can't trigger a multi-GB eager allocation (or, on 32-bit, a truncated `as usize`). + // The Vec still grows to fit the actual chunk bytes regardless. + const PREALLOC_CAP: u64 = 64 * 1024 * 1024; + let mut out = Vec::with_capacity(info.total_len.min(PREALLOC_CAP) as usize); for row in rows { out.extend_from_slice(&row.map_err(map_sql)?); } diff --git a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs index ad36f80..008bc4e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs @@ -266,6 +266,9 @@ pub fn document_to_svg(document: &Document, time: f64) -> String { fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut String, defs: &mut String, grad_n: &mut usize) { match layer { AnyLayer::Vector(vl) => { + if !vl.layer.visible { + return; // hidden layers are not rendered, so don't export them + } let opacity = parent_opacity * vl.layer.opacity; if let Some(graph) = vl.tweened_graph_at(time) { let wrap = opacity < 0.999; @@ -281,12 +284,21 @@ fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut Str // exported — a refinement once loose-geometry export is verified. } AnyLayer::Group(g) => { - let opacity = parent_opacity * g.layer.opacity; - body.push_str(&format!(r#""#)); - for child in &g.children { - layer_to_svg(child, time, 1.0, body, defs, grad_n); + if !g.layer.visible { + return; + } + // Render children first; only emit the wrapper if it has exportable content + // (avoids empty groups when every child is a non-vector/hidden layer). + let mut inner = String::new(); + for child in &g.children { + layer_to_svg(child, time, 1.0, &mut inner, defs, grad_n); + } + if !inner.is_empty() { + let opacity = parent_opacity * g.layer.opacity; + body.push_str(&format!(r#""#)); + body.push_str(&inner); + body.push_str(""); } - body.push_str(""); } // Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass. _ => {} diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs index e5744a6..646fe31 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs @@ -1302,21 +1302,26 @@ impl VectorGraph { /// bouncing across near-coincident duplicate edges). These are zero-area and would make /// `boundary_to_bezpath` render a stray hair; collapsing them yields a simple loop. fn collapse_boundary_spikes(&self, face: &mut Vec<(EdgeId, Direction)>) { - let dstart = |entry: &(EdgeId, Direction)| -> Point { + // The four control points of an entry's curve in its traversal order. + let traversed = |entry: &(EdgeId, Direction)| -> [Point; 4] { let c = self.edges[entry.0.idx()].curve; match entry.1 { - Direction::Forward => c.p0, - Direction::Backward => c.p3, - } - }; - let dend = |entry: &(EdgeId, Direction)| -> Point { - let c = self.edges[entry.0.idx()].curve; - match entry.1 { - Direction::Forward => c.p3, - Direction::Backward => c.p0, + Direction::Forward => [c.p0, c.p1, c.p2, c.p3], + Direction::Backward => [c.p3, c.p2, c.p1, c.p0], } }; const EPS: f64 = 0.5; + // Entries i and j cancel only when j is the *exact reverse* of i — every control point of + // j matches the mirror of i. Testing endpoints alone would also collapse a genuine + // lens/sliver (two distinct edges that merely share near-coincident endpoints), silently + // deleting real boundary geometry and dropping the fill. + let reverses = |a: &(EdgeId, Direction), b: &(EdgeId, Direction)| -> bool { + let (ca, cb) = (traversed(a), traversed(b)); + (0..4).all(|k| { + let (p, q) = (ca[k], cb[3 - k]); + (p.x - q.x).hypot(p.y - q.y) < EPS + }) + }; loop { let n = face.len(); if n < 2 { @@ -1325,10 +1330,7 @@ impl VectorGraph { let mut collapsed = false; for i in 0..n { let j = (i + 1) % n; - // entries i and j cancel when j ends back at i's start. - let si = dstart(&face[i]); - let ej = dend(&face[j]); - if (si.x - ej.x).hypot(si.y - ej.y) < EPS { + if reverses(&face[i], &face[j]) { let (hi, lo) = if i > j { (i, j) } else { (j, i) }; face.remove(hi); face.remove(lo); diff --git a/lightningbeam-ui/lightningbeam-core/src/video.rs b/lightningbeam-ui/lightningbeam-core/src/video.rs index 9b57350..1f631f9 100644 --- a/lightningbeam-ui/lightningbeam-core/src/video.rs +++ b/lightningbeam-ui/lightningbeam-core/src/video.rs @@ -120,6 +120,153 @@ enum DecodedFrame { Gpu(GpuVideoFrame), } +/// Result of processing one decoded frame against the requested target timestamp. +enum FrameOutcome { + /// Frame consumed; keep decoding. + Continue, + /// `current_frame_ts >= frame_ts` — caller should return the best frame found. + ReachedTarget, + /// Hardware surface import failed; caller should fall back to software. + HwImportFailed, +} + +/// Process one decoded `frame`: update the best-so-far (`best_*`) toward `frame_ts`, importing a +/// GPU surface (`gpu_out`) or swscaling to RGBA otherwise. Pulled out of the decode loop so the +/// same logic runs both for packet-fed frames and for frames flushed out of the codec at EOF. +#[allow(clippy::too_many_arguments)] +fn process_decoded_video_frame( + frame: &mut ffmpeg::util::frame::Video, + decoder: &ffmpeg::decoder::Video, + gpu_out: bool, + hw: bool, + out_w: u32, + out_h: u32, + frame_ts: i64, + importer: Option<&Arc>, + scaler: &mut Option<(ffmpeg::format::Pixel, u32, u32, u32, u32, SendScaler)>, + best_frame_data: &mut Option>, + best_gpu: &mut Option, + best_frame_ts: &mut Option, + last_decoded_ts: &mut i64, + scale_time_ms: &mut u128, +) -> Result { + use std::time::Instant; + + // A frame with no PTS continues monotonically from the last decoded position rather than + // snapping to 0 — treating "no timestamp" as ts=0 would look like a huge backward jump and + // corrupt the best-frame / seek logic on streams with missing PTS. + let current_frame_ts = frame.timestamp().unwrap_or(*last_decoded_ts + 1); + *last_decoded_ts = current_frame_ts; + + let is_better = match *best_frame_ts { + None => true, + Some(best_ts) => (current_frame_ts - frame_ts).abs() < (best_ts - frame_ts).abs(), + }; + + if is_better { + if gpu_out { + // VAAPI hw frames often don't carry the stream's colour tags, so the importer would + // mis-detect transfer/gamut. Copy the authoritative values from the codec context + // (parsed from the bitstream) onto the frame when it left them unspecified. + unsafe { + use ffmpeg::ffi::*; + let fp = frame.as_mut_ptr(); + let cp = decoder.as_ptr(); + if (*fp).color_trc == AVColorTransferCharacteristic::AVCOL_TRC_UNSPECIFIED { + (*fp).color_trc = (*cp).color_trc; + } + if (*fp).color_primaries == AVColorPrimaries::AVCOL_PRI_UNSPECIFIED { + (*fp).color_primaries = (*cp).color_primaries; + } + if (*fp).colorspace == AVColorSpace::AVCOL_SPC_UNSPECIFIED { + (*fp).colorspace = (*cp).colorspace; + } + if (*fp).color_range == AVColorRange::AVCOL_RANGE_UNSPECIFIED { + (*fp).color_range = (*cp).color_range; + } + } + let importer = importer.unwrap(); + match unsafe { importer.import(frame.as_mut_ptr() as *mut std::ffi::c_void) } { + Some(gpu) => { + *best_gpu = Some(gpu); + *best_frame_ts = Some(current_frame_ts); + } + None => return Ok(FrameOutcome::HwImportFailed), + } + } else { + let t_scale_start = Instant::now(); + + // A hardware decoder produces VAAPI surfaces; a CPU consumer (export) downloads to + // system memory first, then swscales like the software path. + let downloaded; + let src: &ffmpeg::util::frame::Video = if hw { + let mut dl = ffmpeg::util::frame::Video::empty(); + let r = unsafe { + ffmpeg::ffi::av_hwframe_transfer_data(dl.as_mut_ptr(), frame.as_ptr(), 0) + }; + if r < 0 { + return Err(format!("av_hwframe_transfer_data failed: {r}")); + } + downloaded = dl; + &downloaded + } else { + &*frame + }; + + // Reuse the RGBA scaler across frames; rebuild only if the input format/size or the + // requested output size changes. + let need_new = match scaler { + Some((fmt, w, h, ow, oh, _)) => { + *fmt != src.format() || *w != src.width() || *h != src.height() + || *ow != out_w || *oh != out_h + } + None => true, + }; + if need_new { + let ctx = ffmpeg::software::scaling::context::Context::get( + src.format(), + src.width(), + src.height(), + ffmpeg::format::Pixel::RGBA, + out_w, + out_h, + ffmpeg::software::scaling::flag::Flags::BILINEAR, + ).map_err(|e| e.to_string())?; + *scaler = Some((src.format(), src.width(), src.height(), out_w, out_h, SendScaler(ctx))); + } + let scaler = &mut scaler.as_mut().unwrap().5.0; + + let mut rgb_frame = ffmpeg::util::frame::Video::empty(); + scaler.run(src, &mut rgb_frame) + .map_err(|e| e.to_string())?; + + // Remove stride padding to create tightly packed RGBA data + let width = out_w as usize; + let height = out_h as usize; + let stride = rgb_frame.stride(0); + let row_size = width * 4; // RGBA = 4 bytes per pixel + let source_data = rgb_frame.data(0); + + let mut packed_data = Vec::with_capacity(row_size * height); + for y in 0..height { + let row_start = y * stride; + let row_end = row_start + row_size; + packed_data.extend_from_slice(&source_data[row_start..row_end]); + } + + *scale_time_ms += t_scale_start.elapsed().as_millis(); + *best_frame_data = Some(packed_data); + *best_frame_ts = Some(current_frame_ts); + } + } + + if current_frame_ts >= frame_ts { + Ok(FrameOutcome::ReachedTarget) + } else { + Ok(FrameOutcome::Continue) + } +} + /// `get_format` callback for hardware decode: select VAAPI surfaces. With `hw_device_ctx` set, /// FFmpeg auto-allocates the frames context. unsafe extern "C" fn get_vaapi_format( @@ -459,159 +606,73 @@ impl VideoDecoder { let mut scale_time_ms = 0u128; let mut hw_import_failed = false; - 'decode: for (stream, packet) in input.packets() { - if stream.index() == self.stream_index { - decoder.send_packet(&packet) - .map_err(|e| e.to_string())?; + 'decode: { + for (stream, packet) in input.packets() { + if stream.index() == self.stream_index { + decoder.send_packet(&packet) + .map_err(|e| e.to_string())?; + let mut frame = ffmpeg::util::frame::Video::empty(); + while decoder.receive_frame(&mut frame).is_ok() { + decode_count += 1; + match process_decoded_video_frame( + &mut frame, decoder, gpu_out, hw, out_w, out_h, frame_ts, + self.importer.as_ref(), &mut self.scaler, + &mut best_frame_data, &mut best_gpu, &mut best_frame_ts, + &mut self.last_decoded_ts, &mut scale_time_ms, + )? { + FrameOutcome::Continue => {} + FrameOutcome::ReachedTarget => break 'decode, + FrameOutcome::HwImportFailed => { + self.hw_failed = true; + hw_import_failed = true; + break 'decode; + } + } + } + } + } + + // Flush: the codec may still hold buffered frames (B-frame reorder delay) past the + // last packet. Drain them so requesting the final frame(s) of a clip — scrubbing to + // the end or exporting the tail — doesn't fail with "Failed to decode frame". + if !hw_import_failed { + let _ = decoder.send_eof(); let mut frame = ffmpeg::util::frame::Video::empty(); while decoder.receive_frame(&mut frame).is_ok() { decode_count += 1; - let current_frame_ts = frame.timestamp().unwrap_or(0); - self.last_decoded_ts = current_frame_ts; // Update last decoded position - - // Check if this frame is closer to our target than the previous best - let is_better = match best_frame_ts { - None => true, - Some(best_ts) => { - (current_frame_ts - frame_ts).abs() < (best_ts - frame_ts).abs() + match process_decoded_video_frame( + &mut frame, decoder, gpu_out, hw, out_w, out_h, frame_ts, + self.importer.as_ref(), &mut self.scaler, + &mut best_frame_data, &mut best_gpu, &mut best_frame_ts, + &mut self.last_decoded_ts, &mut scale_time_ms, + )? { + FrameOutcome::Continue => {} + FrameOutcome::ReachedTarget => break 'decode, + FrameOutcome::HwImportFailed => { + self.hw_failed = true; + hw_import_failed = true; + break 'decode; } - }; - - if is_better { - if gpu_out { - // Hardware + GPU consumer: import the VAAPI surface as wgpu NV12 textures - // (no CPU copy). - // VAAPI hw frames often don't carry the stream's colour tags, so the - // importer (which only sees the frame) would mis-detect transfer/gamut. - // Copy the authoritative values from the codec context (parsed from the - // bitstream) onto the frame when it left them unspecified. - unsafe { - use ffmpeg::ffi::*; - let fp = frame.as_mut_ptr(); - let cp = decoder.as_ptr(); - if (*fp).color_trc == AVColorTransferCharacteristic::AVCOL_TRC_UNSPECIFIED { - (*fp).color_trc = (*cp).color_trc; - } - if (*fp).color_primaries == AVColorPrimaries::AVCOL_PRI_UNSPECIFIED { - (*fp).color_primaries = (*cp).color_primaries; - } - if (*fp).colorspace == AVColorSpace::AVCOL_SPC_UNSPECIFIED { - (*fp).colorspace = (*cp).colorspace; - } - if (*fp).color_range == AVColorRange::AVCOL_RANGE_UNSPECIFIED { - (*fp).color_range = (*cp).color_range; - } - } - let importer = self.importer.as_ref().unwrap(); - match unsafe { importer.import(frame.as_mut_ptr() as *mut std::ffi::c_void) } { - Some(gpu) => { - best_gpu = Some(gpu); - best_frame_ts = Some(current_frame_ts); - } - None => { - // Import failed → fall back to software for this clip. - self.hw_failed = true; - hw_import_failed = true; - break 'decode; - } - } - } else { - let t_scale_start = Instant::now(); - - // A hardware decoder produces VAAPI surfaces; a CPU consumer (export) - // downloads to system memory first, then swscales like the software path. - let downloaded; - let src: &ffmpeg::util::frame::Video = if hw { - let mut dl = ffmpeg::util::frame::Video::empty(); - let r = unsafe { - ffmpeg::ffi::av_hwframe_transfer_data(dl.as_mut_ptr(), frame.as_ptr(), 0) - }; - if r < 0 { - return Err(format!("av_hwframe_transfer_data failed: {r}")); - } - downloaded = dl; - &downloaded - } else { - &frame - }; - - // Reuse the RGBA scaler across frames; rebuild only if the input - // format/size or the requested output size changes. - let need_new = match &self.scaler { - Some((fmt, w, h, ow, oh, _)) => { - *fmt != src.format() || *w != src.width() || *h != src.height() - || *ow != out_w || *oh != out_h - } - None => true, - }; - if need_new { - let ctx = ffmpeg::software::scaling::context::Context::get( - src.format(), - src.width(), - src.height(), - ffmpeg::format::Pixel::RGBA, - out_w, - out_h, - ffmpeg::software::scaling::flag::Flags::BILINEAR, - ).map_err(|e| e.to_string())?; - self.scaler = Some((src.format(), src.width(), src.height(), out_w, out_h, SendScaler(ctx))); - } - let scaler = &mut self.scaler.as_mut().unwrap().5.0; - - let mut rgb_frame = ffmpeg::util::frame::Video::empty(); - scaler.run(src, &mut rgb_frame) - .map_err(|e| e.to_string())?; - - // Remove stride padding to create tightly packed RGBA data - let width = out_w as usize; - let height = out_h as usize; - let stride = rgb_frame.stride(0); - let row_size = width * 4; // RGBA = 4 bytes per pixel - let source_data = rgb_frame.data(0); - - let mut packed_data = Vec::with_capacity(row_size * height); - for y in 0..height { - let row_start = y * stride; - let row_end = row_start + row_size; - packed_data.extend_from_slice(&source_data[row_start..row_end]); - } - - scale_time_ms += t_scale_start.elapsed().as_millis(); - best_frame_data = Some(packed_data); - best_frame_ts = Some(current_frame_ts); - } - } - - // If we've reached or passed the target timestamp, we can stop - if current_frame_ts >= frame_ts { - if video_debug() { - let total_time = t_start.elapsed().as_millis(); - let decode_time = t_decode_start.elapsed().as_millis(); - eprintln!("[Video Timing] ts={:.3}s | Decoded {} frames in {}ms | Scale: {}ms | Total: {}ms | {}", - timestamp, decode_count, decode_time, scale_time_ms, total_time, if hw { "hw" } else { "sw" }); - } - if gpu_out { - if let Some(gpu) = best_gpu.take() { - return Ok(DecodedFrame::Gpu(gpu)); - } - } else if let Some(data) = best_frame_data { - self.frame_cache.put(cache_key, data.clone()); - return Ok(DecodedFrame::Cpu { rgba: data, width: out_w, height: out_h }); - } - break 'decode; } } } } - // Reached EOF without hitting the target, or HW import failed mid-stream. + if video_debug() { + let total_time = t_start.elapsed().as_millis(); + let decode_time = t_decode_start.elapsed().as_millis(); + eprintln!("[Video Timing] ts={:.3}s | Decoded {} frames in {}ms | Scale: {}ms | Total: {}ms | {}", + timestamp, decode_count, decode_time, scale_time_ms, total_time, if hw { "hw" } else { "sw" }); + } + + // Reached the target, EOF, or HW import failed mid-stream. if hw_import_failed { self.decoder = None; // force a software rebuild next call (decoder borrow ended here) self.input = None; return Err("hardware frame import failed; retrying software".to_string()); } - // EOF: return the closest frame we found, if any. + // Return the closest frame we found, if any. if gpu_out { if let Some(gpu) = best_gpu.take() { return Ok(DecodedFrame::Gpu(gpu)); @@ -673,8 +734,23 @@ pub fn generate_keyframe_thumbnails( } // Decode at the thumbnail width (large height so width is the constraint), capped to native. // Thumbnail decoders are always software (no hardware importer). - if let Ok(DecodedFrame::Cpu { rgba, .. }) = decoder.get_frame(ks, thumb_width, 100_000, false) { - on_thumb(ks, Arc::new(rgba)); + if let Ok(DecodedFrame::Cpu { rgba, width, height }) = decoder.get_frame(ks, thumb_width, 100_000, false) { + // `capped_output` never upscales, so a source narrower than `thumb_width` decodes + // smaller — but `get_thumbnail_at` reconstructs height assuming an exact `thumb_width`. + // Force the exact width here (rare path) so that assumption holds and the thumbnail + // isn't shown stretched. + let data = if width == thumb_width || width == 0 || height == 0 { + rgba + } else { + let new_h = ((thumb_width as u64 * height as u64) / width as u64).max(1) as u32; + match image::RgbaImage::from_raw(width, height, rgba) { + Some(img) => image::imageops::resize( + &img, thumb_width, new_h, image::imageops::FilterType::Triangle, + ).into_raw(), + None => continue, + } + }; + on_thumb(ks, Arc::new(data)); } } Ok(()) diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs index 2ba087c..bfd4271 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs @@ -26,9 +26,9 @@ impl CpuYuvConverter { /// # Arguments /// * `width` - Frame width in pixels /// * `height` - Frame height in pixels - pub fn new(width: u32, height: u32) -> Result { + pub fn new(width: u32, height: u32, full_range: bool) -> Result { // BT.709 (HD) RGBA→YUV420p context, created once. - let scaler = ffmpeg::software::scaling::Context::get( + let mut scaler = ffmpeg::software::scaling::Context::get( ffmpeg::format::Pixel::RGBA, width, height, @@ -38,6 +38,23 @@ impl CpuYuvConverter { ffmpeg::software::scaling::Flags::BILINEAR, ) .map_err(|e| format!("Failed to create swscale context: {}", e))?; + + // swscale defaults to BT.601 + limited range; force BT.709 with the requested output + // range so this fallback matches the GPU path and the encoder's color tags + // (otherwise non-%8-width exports come out with shifted hue / wrong levels). There is + // no safe ffmpeg-next wrapper for sws_setColorspaceDetails, so this is the raw call. + unsafe { + let coeffs = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709 as i32); + let dst_range = if full_range { 1 } else { 0 }; + let one = 1 << 16; // 16.16 fixed-point 1.0 + ffmpeg::ffi::sws_setColorspaceDetails( + scaler.as_mut_ptr(), + coeffs, 1, // source table (RGB input is full-range) + coeffs, dst_range, // dest table = BT.709, dest range = requested + 0, one, one, // brightness, contrast, saturation (neutral) + ); + } + let rgba_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height); let yuv_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, width, height); Ok(Self { width, height, scaler, rgba_frame, yuv_frame }) @@ -90,13 +107,13 @@ mod tests { #[test] fn test_converter_creation() { - let converter = CpuYuvConverter::new(1920, 1080); + let converter = CpuYuvConverter::new(1920, 1080, true); assert!(converter.is_ok()); } #[test] fn test_conversion_output_sizes() { - let mut converter = CpuYuvConverter::new(1920, 1080).unwrap(); + let mut converter = CpuYuvConverter::new(1920, 1080, true).unwrap(); // Create dummy RGBA data (all black) let rgba_data = vec![0u8; 1920 * 1080 * 4]; @@ -117,7 +134,7 @@ mod tests { #[test] #[should_panic(expected = "RGBA data size mismatch")] fn test_wrong_input_size_panics() { - let mut converter = CpuYuvConverter::new(1920, 1080).unwrap(); + let mut converter = CpuYuvConverter::new(1920, 1080, true).unwrap(); // Wrong size input let rgba_data = vec![0u8; 1000]; diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/gpu_yuv.rs b/lightningbeam-ui/lightningbeam-editor/src/export/gpu_yuv.rs index adcdb32..c5cc8a0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/gpu_yuv.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/gpu_yuv.rs @@ -6,8 +6,8 @@ //! 8.3 MB RGBA) and — more importantly — the per-frame CPU `rgba_to_yuv420p` (swscale) //! is eliminated. //! -//! Color math is BT.709 **full-range** (JPEG range), matching the encoder color tags -//! set in `setup_video_encoder` (`Space::BT709` + `Range::JPEG`). +//! Color math is BT.709; the Y/chroma scale+offset (full vs limited range) is selected by +//! the `full_range` flag and must match the encoder color tags set in `setup_video_encoder`. //! //! Output buffer layout (tight, little-endian byte packing into `array`): //! - `[0, W*H)` Y plane, row stride `W` @@ -30,15 +30,36 @@ pub fn yuv420p_len(width: u32, height: u32) -> usize { y + 2 * c } +/// `(y_offset, y_scale, chroma_offset, chroma_scale)` as fractions of 255, selecting +/// limited (TV, 16–235 / 16–240) vs full (PC, 0–255) range. Mirrors `render_nv12`. +fn range_params(full_range: bool) -> [f32; 4] { + if full_range { + [0.0, 1.0, 128.0 / 255.0, 1.0] + } else { + [16.0 / 255.0, 219.0 / 255.0, 128.0 / 255.0, 224.0 / 255.0] + } +} + /// GPU compute pipeline: `Rgba8Unorm` texture → tight planar YUV420p storage buffer. pub struct GpuYuv { y_pipeline: wgpu::ComputePipeline, uv_pipeline: wgpu::ComputePipeline, bind_group_layout: wgpu::BindGroupLayout, + range_buffer: wgpu::Buffer, } impl GpuYuv { - pub fn new(device: &wgpu::Device) -> Self { + /// `full_range`: true → full/PC range (Y 0–255), false → limited/TV range (Y 16–235). + /// The encoder must tag the stream to match (`setup_video_encoder`'s `full_range`). + pub fn new(device: &wgpu::Device, full_range: bool) -> Self { + use wgpu::util::DeviceExt; + let params = range_params(full_range); + let range_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("gpu_yuv_range"), + contents: bytemuck::cast_slice(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("gpu_yuv_bgl"), entries: &[ @@ -64,6 +85,17 @@ impl GpuYuv { }, count: None, }, + // 2: range params (y_offset, y_scale, chroma_offset, chroma_scale) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, ], }); @@ -93,6 +125,7 @@ impl GpuYuv { y_pipeline: mk("y_main"), uv_pipeline: mk("uv_main"), bind_group_layout, + range_buffer, } } @@ -118,6 +151,7 @@ impl GpuYuv { entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(rgba_view) }, wgpu::BindGroupEntry { binding: 1, resource: yuv_buffer.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: self.range_buffer.as_entire_binding() }, ], }); @@ -142,12 +176,12 @@ impl GpuYuv { /// CPU reference for the exact math/layout the shader produces — used by unit tests so /// the packing and BT.709 coefficients stay verifiable without a GPU. -#[cfg(test)] -fn cpu_reference(rgba: &[u8], width: u32, height: u32) -> Vec { +fn cpu_reference(rgba: &[u8], width: u32, height: u32, full_range: bool) -> Vec { let w = width as usize; let h = height as usize; let cw = w / 2; let ch = h / 2; + let [yo, ys, co, cs] = range_params(full_range); let mut out = vec![0u8; yuv420p_len(width, height)]; let to_byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; let px = |x: usize, y: usize| { @@ -158,7 +192,8 @@ fn cpu_reference(rgba: &[u8], width: u32, height: u32) -> Vec { for y in 0..h { for x in 0..w { let p = px(x, y); - out[y * w + x] = to_byte(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]); + let yy = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + out[y * w + x] = to_byte(yo + ys * yy); } } // U/V (2x2 average) @@ -172,19 +207,21 @@ fn cpu_reference(rgba: &[u8], width: u32, height: u32) -> Vec { acc[0] += p[0]; acc[1] += p[1]; acc[2] += p[2]; } let a = [acc[0] / 4.0, acc[1] / 4.0, acc[2] / 4.0]; - let u = -0.1146 * a[0] - 0.3854 * a[1] + 0.5000 * a[2] + 0.5; - let v = 0.5000 * a[0] - 0.4542 * a[1] - 0.0458 * a[2] + 0.5; - out[y_size + cy * cw + cx] = to_byte(u); - out[y_size + uv_size + cy * cw + cx] = to_byte(v); + let uc = -0.1146 * a[0] - 0.3854 * a[1] + 0.5000 * a[2]; + let vc = 0.5000 * a[0] - 0.4542 * a[1] - 0.0458 * a[2]; + out[y_size + cy * cw + cx] = to_byte(co + cs * uc); + out[y_size + uv_size + cy * cw + cx] = to_byte(co + cs * vc); } } out } const SHADER: &str = r#" -// RGBA -> tight planar YUV420p (BT.709 full-range), packed 4 bytes/u32. +// RGBA -> tight planar YUV420p (BT.709), packed 4 bytes/u32. +// rng = (y_offset, y_scale, chroma_offset, chroma_scale): selects limited vs full range. @group(0) @binding(0) var input_rgba: texture_2d; @group(0) @binding(1) var out_buf: array; +@group(0) @binding(2) var rng: vec4; fn to_byte(v: f32) -> u32 { return u32(clamp(v, 0.0, 1.0) * 255.0 + 0.5); } @@ -201,7 +238,7 @@ fn y_main(@builtin(global_invocation_id) gid: vec3) { for (var i = 0u; i < 4u; i = i + 1u) { let c = textureLoad(input_rgba, vec2(x4 + i, y), 0).rgb; let yy = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; - packed = packed | (to_byte(yy) << (8u * i)); + packed = packed | (to_byte(rng.x + rng.y * yy) << (8u * i)); } out_buf[(y * w + x4) / 4u] = packed; } @@ -230,10 +267,11 @@ fn uv_main(@builtin(global_invocation_id) gid: vec3) { let p01 = textureLoad(input_rgba, vec2(sx, sy + 1u), 0).rgb; let p11 = textureLoad(input_rgba, vec2(sx + 1u, sy + 1u), 0).rgb; let a = (p00 + p10 + p01 + p11) * 0.25; - let u = -0.1146 * a.r - 0.3854 * a.g + 0.5000 * a.b + 0.5; - let v = 0.5000 * a.r - 0.4542 * a.g - 0.0458 * a.b + 0.5; - up = up | (to_byte(u) << (8u * i)); - vp = vp | (to_byte(v) << (8u * i)); + // Centered chroma in [-0.5, 0.5], then map to range via (offset + scale*coef). + let uc = -0.1146 * a.r - 0.3854 * a.g + 0.5000 * a.b; + let vc = 0.5000 * a.r - 0.4542 * a.g - 0.0458 * a.b; + up = up | (to_byte(rng.z + rng.w * uc) << (8u * i)); + vp = vp | (to_byte(rng.z + rng.w * vc) << (8u * i)); } out_buf[(y_size + cy * cw + cx4) / 4u] = up; out_buf[(y_size + uv_size + cy * cw + cx4) / 4u] = vp; @@ -264,14 +302,14 @@ mod tests { fn reference_known_colors() { // 8x2 solid white → Y≈255, U≈V≈128. Solid black → Y=0, U=V≈128. let white = vec![255u8; 8 * 2 * 4]; - let out = cpu_reference(&white, 8, 2); + let out = cpu_reference(&white, 8, 2, true); let (cw, ch) = (4usize, 1usize); let y_size = 8 * 2; for &y in &out[..y_size] { assert!(y >= 254, "white Y={y}"); } for &u in &out[y_size..y_size + cw * ch] { assert!((u as i32 - 128).abs() <= 1, "white U={u}"); } let black = vec![0u8; 8 * 2 * 4]; - let out = cpu_reference(&black, 8, 2); + let out = cpu_reference(&black, 8, 2, true); for &y in &out[..y_size] { assert_eq!(y, 0); } for &v in &out[y_size + cw * ch..] { assert!((v as i32 - 128).abs() <= 1, "black V={v}"); } } @@ -280,7 +318,7 @@ mod tests { fn reference_red_bt709() { // Solid red (255,0,0): Y=0.2126*255≈54; V high, U low (full range). let red: Vec = (0..8 * 2).flat_map(|_| [255u8, 0, 0, 255]).collect(); - let out = cpu_reference(&red, 8, 2); + let out = cpu_reference(&red, 8, 2, true); assert!((out[0] as i32 - 54).abs() <= 1, "red Y={}", out[0]); let y_size = 8 * 2; let u = out[y_size]; diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 7b7a7fc..57aef6c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -57,6 +57,9 @@ pub struct VideoExportState { hdr: lightningbeam_core::export::HdrExportMode, /// How the document is fit into the export frame (stretch/letterbox/crop). fit: lightningbeam_core::export::ExportFitMode, + /// SDR color range: true = full (PC, 0–255), false = limited (TV, 16–235). The YUV + /// converters and the encoder color tag must agree on this. + full_range: bool, /// Channel to send rendered frames to encoder thread frame_tx: Option>, /// HDR GPU resources for compositing pipeline (effects, color conversion) @@ -868,6 +871,7 @@ impl ExportOrchestrator { ) -> (std::thread::JoinHandle<()>, VideoExportState) { let hdr = settings.hdr; let fit = settings.fit; + let full_range = settings.color_range.is_full(); let handle = std::thread::spawn(move || { Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames); }); @@ -882,6 +886,7 @@ impl ExportOrchestrator { height, hdr, fit, + full_range, frame_tx: Some(frame_tx), gpu_resources: None, readback_pipeline: None, @@ -1333,8 +1338,8 @@ impl ExportOrchestrator { if !gpu_yuv_tight { println!("🎬 [VIDEO EXPORT] YUV planes are padded at {width}x{height}; using CPU YUV path"); } - state.readback_pipeline = Some(readback_pipeline::ReadbackPipeline::new(device, queue, width, height, gpu_yuv_tight)); - state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height)?); + state.readback_pipeline = Some(readback_pipeline::ReadbackPipeline::new(device, queue, width, height, gpu_yuv_tight, state.full_range)); + state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?); println!("🚀 [ASYNC PIPELINE] Triple-buffered pipeline initialized"); println!("🚀 [CPU YUV] swscale converter initialized"); } @@ -1638,6 +1643,21 @@ impl ExportOrchestrator { // Convert codec enum to FFmpeg codec ID. HDR requires 10-bit HEVC (Main10), so force HEVC // regardless of the chosen codec when an HDR mode is selected. let codec_id = if settings.hdr.is_hdr() { + // HEVC can only be muxed into MP4/MOV, not WebM — reject the incompatible combo up + // front with a clear message instead of letting the muxer fail cryptically. + if settings.codec.container_format() == "webm" { + return Err(format!( + "HDR export needs H.265/HEVC in an MP4 container, but {} uses WebM. \ + Pick H.265 (or H.264) for HDR.", + settings.codec.name() + )); + } + if !matches!(settings.codec, VideoCodec::H265) { + println!( + "⚠️ [ENCODER] HDR selected: overriding codec {} → H.265/HEVC (Main10)", + settings.codec.name() + ); + } ffmpeg_next::codec::Id::HEVC } else { match settings.codec { @@ -1690,6 +1710,7 @@ impl ExportOrchestrator { framerate, bitrate_kbps, settings.hdr, + settings.color_range.is_full(), )?; // Pixel format the encoder frames are built in (matches setup_video_encoder). diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/readback_pipeline.rs b/lightningbeam-ui/lightningbeam-editor/src/export/readback_pipeline.rs index 5997e5e..df552f3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/readback_pipeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/readback_pipeline.rs @@ -91,12 +91,12 @@ impl ReadbackPipeline { /// `enable_gpu_yuv` should be `true` only when the caller has verified the encoder's /// `YUV420P` plane strides are tight (== width / width-2), so the packed GPU planes /// drop straight into the `AVFrame` without row misalignment. - pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32, enable_gpu_yuv: bool) -> Self { + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32, enable_gpu_yuv: bool, full_range: bool) -> Self { let (readback_tx, readback_rx) = channel(); // GPU YUV conversion when enabled AND the dimensions fit the packed shader; else RGBA + CPU. let gpu_yuv = if enable_gpu_yuv && super::gpu_yuv::supports(width, height) { - Some(super::gpu_yuv::GpuYuv::new(device)) + Some(super::gpu_yuv::GpuYuv::new(device, full_range)) } else { None }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 1c65ace..b27694f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -560,6 +560,7 @@ pub fn setup_video_encoder( framerate: f64, bitrate_kbps: u32, hdr: lightningbeam_core::export::HdrExportMode, + full_range: bool, ) -> Result<(ffmpeg::encoder::Video, ffmpeg::Codec), String> { // Try to find codec by ID first println!("🔍 Looking for codec: {:?}", codec_id); @@ -641,7 +642,12 @@ pub fn setup_video_encoder( color_opts.set("profile", "main10"); } else { encoder.set_colorspace(ffmpeg::color::Space::BT709); - encoder.set_color_range(ffmpeg::color::Range::JPEG); // full range + // Range must match what the YUV converters (gpu_yuv / cpu_yuv) actually produce. + encoder.set_color_range(if full_range { + ffmpeg::color::Range::JPEG // full (PC, 0–255) + } else { + ffmpeg::color::Range::MPEG // limited (TV, 16–235) + }); color_opts.set("color_primaries", "bt709"); color_opts.set("color_trc", "bt709"); } @@ -1133,232 +1139,9 @@ fn upload_transient_texture( tex } -/// 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>, - gpu_resources: &mut ExportGpuResources, -) -> Result<(Vec, Vec, Vec), String> { - use vello::kurbo::Affine; - - // Set document time to the frame timestamp - document.current_time = timestamp; - - // Scale the document to the export resolution. The core renderer bakes this - // base transform into every layer (vector scenes, raster and video layer - // transforms), so the whole stage scales up/down to fill the output. When the - // export size matches the document this is the identity. - let base_transform = if document.width > 0.0 && document.height > 0.0 { - Affine::scale_non_uniform( - width as f64 / document.width, - height as f64 / document.height, - ) - } else { - Affine::IDENTITY - }; - - // Export composites on the encoder's own device, not the shared one, so it cannot use - // hardware-decoded GPU frames (textures can't cross devices). Force software frames; a - // hardware decoder downloads its surface to CPU instead. - if let Ok(mut vm) = video_manager.lock() { - vm.set_render_hardware_ok(false); - } - - // Render document for compositing (returns per-layer scenes) - let composite_result = render_document_for_compositing( - document, - base_transform, - image_cache, - video_manager, - None, // No webcam during export - None, // No floating selection during export - false, // No checkerboard in export - ); - - // Video export is never transparent. - composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, false)?; - - // 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, - }); - - let final_pipeline = match document.hdr_output_mode { - lightningbeam_core::document::HdrOutputMode::HighlightRolloff => &gpu_resources.linear_to_srgb_pipeline_rolloff, - lightningbeam_core::document::HdrOutputMode::Clip => &gpu_resources.linear_to_srgb_pipeline, - }; - render_pass.set_pipeline(final_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 +/// 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. /// diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 80586d4..07ba0b1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -1627,12 +1627,24 @@ impl GpuBrushEngine { self.proxy_layer_cache.get(kf_id) } - /// Remove the cached texture for a raster layer keyframe (e.g. when deleted). + /// Remove the cached texture for a raster layer keyframe (e.g. when deleted or edited). pub fn remove_layer_texture(&mut self, kf_id: &Uuid) { - if self.raster_layer_cache.remove(kf_id).is_some() { + let mut changed = self.raster_layer_cache.remove(kf_id).is_some(); + if changed { if let Some(pos) = self.raster_layer_lru.iter().position(|id| id == kf_id) { self.raster_layer_lru.remove(pos); } + } + // Also drop the low-res proxy: proxies are uploaded once and never refreshed, so a + // stale pre-edit proxy left here would be blitted (flashing old content) if the full-res + // texture is later evicted before the edited pixels page back in. + if self.proxy_layer_cache.remove(kf_id).is_some() { + if let Some(pos) = self.proxy_layer_lru.iter().position(|id| id == kf_id) { + self.proxy_layer_lru.remove(pos); + } + changed = true; + } + if changed { self.report_raster_cache_vram(); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 7073e2d..09aff3f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2392,6 +2392,12 @@ impl EditorApp { kf.needs_fault_in = false; } } + // Track these resident pixels in the LRU so they count toward + // RASTER_RESIDENT_MAX and can be evicted later; without this, a frame + // faulted in for undo/redo that ends up clean would stay resident forever, + // letting resident RAM grow past the cap. + self.raster_resident_lru.retain(|id| *id != kf_id); + self.raster_resident_lru.push_back(kf_id); } } } @@ -4523,7 +4529,9 @@ impl EditorApp { let bytes = match std::fs::read(path) { Ok(b) => b, Err(e) => { - eprintln!("❌ Failed to read SVG {}: {}", path.display(), e); + let msg = format!("Failed to read SVG: {}", e); + eprintln!("❌ {} ({})", msg, path.display()); + notifications::notify_error("SVG Import Failed", &msg); return; } }; @@ -4532,6 +4540,7 @@ impl EditorApp { Ok(g) => g, Err(e) => { eprintln!("❌ {}", e); + notifications::notify_error("SVG Import Failed", &e); return; } }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/notifications.rs b/lightningbeam-ui/lightningbeam-editor/src/notifications.rs index 2222d58..0489df2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/notifications.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/notifications.rs @@ -32,6 +32,11 @@ pub fn notify_export_complete(output_path: &Path) { /// Show a desktop notification for an export error (fire-and-forget). pub fn notify_export_error(error_message: &str) { + notify_error("Export Failed", error_message); +} + +/// Show a desktop error notification with a custom title (fire-and-forget). +pub fn notify_error(title: &'static str, error_message: &str) { // Truncate very long error messages (on a char boundary). let truncated = if error_message.chars().count() > 100 { let prefix: String = error_message.chars().take(97).collect(); @@ -42,7 +47,7 @@ pub fn notify_export_error(error_message: &str) { std::thread::spawn(move || { if let Err(e) = Notification::new() - .summary("Export Failed") + .summary(title) .body(&truncated) .icon("dialog-error") // Standard error icon .timeout(10000) // 10 seconds for errors (longer to read) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 46beb28..92d85be 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -2080,7 +2080,11 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // fills the black's gaps → interlocking black/yellow marching-ants. if let Some(active_id) = self.ctx.active_layer_id { if let Some(lightningbeam_core::layer::AnyLayer::Raster(rl)) = self.ctx.document.get_layer(&active_id) { - if let Some(kf) = rl.keyframe_at(self.ctx.document.current_time) { + // Use playback_time (clip-local when editing a movie clip) like every other + // keyframe lookup in prepare(), and overlay_transform so the outline tracks the + // layer's pixels through any clip-instance affine (it equals camera_transform + // outside clip-edit mode). + if let Some(kf) = rl.keyframe_at(self.ctx.playback_time) { let rect = vello::kurbo::Rect::new(0.0, 0.0, kf.width as f64, kf.height as f64); // Sizes are in document space; divide by zoom so they're ~constant on screen. let inv_zoom = 1.0 / (self.ctx.zoom as f64).max(1e-3); @@ -2089,14 +2093,14 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let pattern = [dash, dash]; scene.stroke( &vello::kurbo::Stroke::new(stroke_w).with_dashes(0.0, pattern), - camera_transform, + overlay_transform, vello::peniko::Color::new([0.0, 0.0, 0.0, 1.0]), None, &rect, ); scene.stroke( &vello::kurbo::Stroke::new(stroke_w).with_dashes(dash, pattern), - camera_transform, + overlay_transform, vello::peniko::Color::new([1.0, 0.85, 0.0, 1.0]), None, &rect, diff --git a/lightningbeam-ui/lightningbeam-editor/src/svg_import.rs b/lightningbeam-ui/lightningbeam-editor/src/svg_import.rs index c0cf4b1..be793eb 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/svg_import.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/svg_import.rs @@ -118,10 +118,14 @@ fn convert_path(path: &usvg::Path, graph: &mut VectorGraph) { slot.color = Some(ShapeColor::rgba(c.red, c.green, c.blue, opacity_u8(fill.opacity()))); } usvg::Paint::LinearGradient(g) => { - slot.gradient_fill = Some(linear_gradient(g, ts)); + let mut grad = linear_gradient(g, ts); + apply_fill_opacity(&mut grad, fill.opacity()); + slot.gradient_fill = Some(grad); } usvg::Paint::RadialGradient(g) => { - slot.gradient_fill = Some(radial_gradient(g, ts)); + let mut grad = radial_gradient(g, ts); + apply_fill_opacity(&mut grad, fill.opacity()); + slot.gradient_fill = Some(grad); } usvg::Paint::Pattern(_) => { // Patterns aren't representable yet — neutral gray so the shape stays visible. @@ -217,6 +221,17 @@ fn radial_gradient(g: &usvg::RadialGradient, abs: usvg::Transform) -> ShapeGradi } } +/// Fold the path's `fill-opacity` into a gradient's stop alphas (SVG multiplies them). +fn apply_fill_opacity(grad: &mut ShapeGradient, op: usvg::Opacity) { + let f = op.get(); + if f >= 1.0 { + return; + } + for s in &mut grad.stops { + s.color.a = (s.color.a as f32 * f).round().clamp(0.0, 255.0) as u8; + } +} + fn gradient_stops(base: &usvg::BaseGradient) -> Vec { base.stops() .iter()