Compare commits

..

7 Commits

Author SHA1 Message Date
Skyler Lehmkuhl a2839f80b1 Clean up build warnings 2026-07-09 13:24:32 -04:00
Skyler Lehmkuhl 6e6feaddf5 Fix FLAC export end-to-end (real backend path) + smart tag defaults
The prior audio-tags commit put real FLAC + metadata into export/audio_exporter.rs
— which turned out to be dead code (declared, never called; whole file was
EngineController::start_export_audio → daw-backend's export_audio, which still
routed FLAC to the erroring hound stub — hence "not implemented in daw-backend".

Move the work to where export actually happens:
- daw-backend/src/audio/export.rs: real ffmpeg FLAC (16-bit S16 / 24-bit S32,
  skipping the trailing empty flush packet the FLAC muxer rejects); apply_metadata
  on MP3/AAC/FLAC output; RIFF LIST/INFO chunk appended to WAV. New metadata field
  on the backend ExportSettings, threaded from the UI in run_audio_export. Tests
  assert real fLaC magic + round-tripped tags, and a valid WAV INFO chunk.
- Delete the dead export/audio_exporter.rs (removes the duplicate FLAC impl).

Smart tag defaults (filled only when empty, never clobbering edits):
- Year → current civil year, computed from the system clock with i64 math (no
  date crate; correct past 2038/2106 — tests cover post-i32/u32 timestamps).
- Artist → last-used value, else the OS username ($USER/%USERNAME%).
- Album → last-used value.
Last-used Artist/Album persist in AppConfig and prefill next export.
2026-07-09 13:09:54 -04:00
Skyler Lehmkuhl 15bdf80ec1 Audio export: real FLAC + tag metadata for all formats
- FLAC is now real FLAC via ffmpeg, not WAV bytes in a .flac file. 16-bit uses
  S16, 24-bit uses S32 (ffmpeg's flac encoder emits bits_per_raw_sample=24).
  The flush emits a trailing empty packet that the FLAC muxer rejects as
  "invalid data" — it's skipped.

- Tag metadata (title/artist/album/genre/year/track/comment) written into every
  format via each container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis
  comments (FLAC) set through ffmpeg's output metadata; RIFF LIST/INFO appended
  to the hound-written WAV (with a fixed-up RIFF size). New AudioMetadata type
  on AudioExportSettings; dialog gains a Tags section and defaults Title to the
  project name.

Tests: FLAC is a real fLaC container with round-tripped tags; WAV keeps a valid
RIFF with a working INFO chunk.
2026-07-09 12:52:37 -04:00
Skyler Lehmkuhl 6b8a1f1386 SVG export: emit text layers as real glyph outlines
SVG export silently dropped Text layers (they fell through the layer_to_svg
catch-all) while the dialog implied only raster/video/effect were excluded, so
title/caption text vanished from a "lossless" export with a success message.

Emit text as actual glyph-outline <path>s: lay the text out with the same
parley path the renderer uses, then extract each positioned glyph's outline
with skrifa (an OutlinePen that maps points into document space — Y flip,
synthetic-italic skew, variable-font normalized coords). Result is
font-independent and needs no <text>/@font-face. Vello rasterizes glyphs on the
GPU and doesn't expose the path, but the skrifa outline API it uses is directly
callable and parley's glyph IDs are real font GIDs, so the outlines match.

Synthetic bold is not applied (rare). Adds a skrifa dep pinned to parley's 0.43.
2026-07-09 07:10:59 -04:00
Skyler Lehmkuhl 53ffb7d528 Export honesty: real lossy WebP, working ProRes, VP8+audio container
Three cases where an export produced something that didn't match what the UI
offered:

- WebP quality slider was a no-op: image 0.25's WebP encoder is lossless-only,
  so the slider did nothing and files were needlessly large. Encode lossy WebP
  via ffmpeg's libwebp instead (already linked); the quality knob is now real
  and alpha is preserved as YUVA420P. Test asserts a lossy VP8 chunk + that
  quality changes file size.

- ProRes 422 always failed to open: the SDR path fed prores_ks 8-bit YUV420P,
  but it requires 10-bit 4:2:2. Add a CpuYuv422P10Converter (RGBA→YUV422P10LE,
  BT.709) and route ProRes through the existing async pipeline in CPU mode;
  setup_video_encoder now emits YUV422P10LE + prores_ks HQ profile and
  encode_frame handles 4:2:2 chroma. Test guards that the encoder opens.

- VP8+audio failed at mux: the parallel path wrote the temp video to a
  hardcoded .mp4, which VP8 can't live in. Derive the temp container from the
  codec (VP8/VP9 → .webm).
2026-07-09 06:58:04 -04:00
Skyler Lehmkuhl c373af461e Add animated GIF export
New export format alongside audio/image/video/SVG. GIF is multi-frame like
video but palette-quantized with no audio, so it reuses the per-frame RGBA
render/readback path (render_frame_to_gpu_rgba) and streams frames to a
background encoder.

- core: GifExportSettings (resolution, framerate, loop, transparency, fit,
  time range) with centisecond-quantized frame delay + tests.
- gif_exporter: encoder pipeline. Per-frame NeuQuant quantization is the
  dominant cost and is per-frame independent, so it's fanned out across a
  worker pool (cores-1, capped 8); a writer thread reorders and LZW-encodes
  sequentially. Uses the `gif` crate directly (already resolved via `image`).
- orchestrator: start_gif_export + render_next_gif_frame (one frame per egui
  update), wired into is_exporting/has_pending_progress/cancel.
- dialog: GIF tab + settings; main.rs: handle ExportResult::Gif and pump frames.
- Cargo: opt-level=3 for gif/color_quant/weezl in the dev profile so debug
  builds aren't crippled by unoptimized NeuQuant loops.

Together these cut a 10s GIF export from ~1:43 to ~3s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 06:02:47 -04:00
Skyler Lehmkuhl 6e361aa30c Reset audio backend on new file / project load
The backend's Command::Reset (full teardown: rebuilds Project, audio/buffer
pools, ID counters) was never invoked from the UI. New File and project loads
only cleared app-side maps, so the previous file's tracks and loaded
instruments stayed resident in the backend and kept getting mixed on every
audio callback.

Add a reset_audio_backend() helper (controller.reset() + clear app-side track
maps and backend-derived caches) and call it in the three teardown paths:
NewFile, create_new_project_with_focus, and apply_loaded_project. Also drops a
duplicate layer_to_track_map.clear() in the NewFile handler.

Race-free: the audio thread drains all command_tx commands before any query_tx
queries each callback, so Reset (a command) runs before the track/pool rebuild
(queries), even though they travel on separate channels.
2026-07-09 06:01:34 -04:00
20 changed files with 1684 additions and 552 deletions

View File

@ -50,6 +50,10 @@ pub struct ExportSettings {
pub end_time: Seconds,
/// Tempo map for beat-position scheduling
pub tempo_map: TempoMap,
/// Tag metadata as (ffmpeg-key, value) pairs (e.g. ("title", "…"), ("artist", "…")). Written to
/// the container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis comments (FLAC), RIFF INFO
/// (WAV). Empty = no tags.
pub metadata: Vec<(String, String)>,
}
impl Default for ExportSettings {
@ -63,10 +67,26 @@ impl Default for ExportSettings {
start_time: Seconds::ZERO,
end_time: Seconds(60.0),
tempo_map: TempoMap::constant(120.0),
metadata: Vec::new(),
}
}
}
/// Set tag metadata on an ffmpeg output context (before `write_header`). FFmpeg maps the standard
/// keys to each container's native tags.
fn apply_metadata(output: &mut ffmpeg_next::format::context::Output, metadata: &[(String, String)]) {
if metadata.is_empty() {
return;
}
let mut dict = ffmpeg_next::Dictionary::new();
for (k, v) in metadata {
if !v.is_empty() {
dict.set(k, v);
}
}
output.set_metadata(dict);
}
/// Export the project to an audio file
///
/// This performs offline rendering, processing the entire timeline
@ -108,17 +128,21 @@ pub fn export_audio<P: AsRef<Path>>(
// Route to appropriate export implementation based on format.
// Ensure export mode is disabled even if an error occurs.
let result = match settings.format {
ExportFormat::Wav | ExportFormat::Flac => {
ExportFormat::Wav => {
let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?;
// Signal that rendering is done and we're now writing the file
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
}
match settings.format {
ExportFormat::Wav => write_wav(&samples, settings, &output_path),
ExportFormat::Flac => write_flac(&samples, settings, &output_path),
_ => unreachable!(),
write_wav(&samples, settings, &output_path)
// hound writes no metadata; append a RIFF INFO chunk for tags.
.and_then(|_| append_wav_info_chunk(output_path.as_ref(), &settings.metadata))
}
ExportFormat::Flac => {
let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?;
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
}
export_flac(&samples, settings, &output_path)
}
ExportFormat::Mp3 => {
export_mp3(project, pool, settings, output_path, event_tx)
@ -273,48 +297,174 @@ fn write_wav<P: AsRef<Path>>(
Ok(())
}
/// Write FLAC file using hound (FLAC is essentially lossless WAV)
fn write_flac<P: AsRef<Path>>(
/// Export real FLAC via ffmpeg from already-rendered interleaved f32 samples (Vorbis-comment
/// metadata). Replaces the former `write_flac`, which wrote WAV bytes to a `.flac` file. 16-bit
/// uses S16; 24-bit uses S32 (ffmpeg's flac encoder emits `bits_per_raw_sample = 24` for S32,
/// taking the top 24 bits).
fn export_flac<P: AsRef<Path>>(
samples: &[f32],
settings: &ExportSettings,
output_path: P,
) -> Result<(), String> {
// For now, we'll use hound to write a WAV-like FLAC file
// In the future, we could use a dedicated FLAC encoder
let spec = hound::WavSpec {
channels: settings.channels as u16,
sample_rate: settings.sample_rate,
bits_per_sample: settings.bit_depth,
sample_format: hound::SampleFormat::Int,
use ffmpeg_next as ffmpeg;
ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::FLAC)
.ok_or("FLAC encoder not found in this ffmpeg build")?;
let mut output = ffmpeg::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
let channel_layout = match settings.channels {
1 => ffmpeg::channel_layout::ChannelLayout::MONO,
2 => ffmpeg::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
};
let mut writer = hound::WavWriter::create(output_path, spec)
.map_err(|e| format!("Failed to create FLAC file: {}", e))?;
// FLAC accepts packed S16 or S32; S32 → 24-bit output.
let use_24 = settings.bit_depth >= 24;
let sample_fmt = if use_24 {
ffmpeg::format::Sample::I32(ffmpeg::format::sample::Type::Packed)
} else {
ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Packed)
};
// Write samples (same as WAV for now)
match settings.bit_depth {
16 => {
for &sample in samples {
let clamped = sample.max(-1.0).min(1.0);
let pcm_value = (clamped * 32767.0) as i16;
writer.write_sample(pcm_value)
.map_err(|e| format!("Failed to write sample: {}", e))?;
let mut encoder = ffmpeg::codec::Context::new_with_codec(codec)
.encoder()
.audio()
.map_err(|e| format!("Failed to create FLAC encoder: {}", e))?;
encoder.set_rate(settings.sample_rate as i32);
encoder.set_channel_layout(channel_layout);
encoder.set_format(sample_fmt);
encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32));
let mut encoder = encoder.open_as(codec)
.map_err(|e| format!("Failed to open FLAC encoder: {}", e))?;
{
let mut stream = output.add_stream(codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
}
apply_metadata(&mut output, &settings.metadata);
output.write_header()
.map_err(|e| format!("Failed to write FLAC header: {}", e))?;
let channels = settings.channels as usize;
let num_frames = samples.len() / channels;
let frame_size = if encoder.frame_size() > 0 { encoder.frame_size() as usize } else { 4096 };
let mut done = 0usize;
while done < num_frames {
let n = (num_frames - done).min(frame_size);
let mut frame = ffmpeg::frame::Audio::new(sample_fmt, n, channel_layout);
frame.set_rate(settings.sample_rate);
frame.set_pts(Some(done as i64)); // samples; the FLAC muxer requires PTS
let buf = frame.data_mut(0); // packed interleaved → plane 0
let base = done * channels;
if use_24 {
for i in 0..n * channels {
let s = samples[base + i].clamp(-1.0, 1.0);
let v = (s as f64 * 2_147_483_647.0) as i32; // full-scale S32; encoder takes top 24
buf[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
24 => {
for &sample in samples {
let clamped = sample.max(-1.0).min(1.0);
let pcm_value = (clamped * 8388607.0) as i32;
writer.write_sample(pcm_value)
.map_err(|e| format!("Failed to write sample: {}", e))?;
} else {
for i in 0..n * channels {
let s = samples[base + i].clamp(-1.0, 1.0);
let v = (s * 32767.0) as i16;
buf[i * 2..i * 2 + 2].copy_from_slice(&v.to_le_bytes());
}
}
_ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)),
}
writer.finalize()
.map_err(|e| format!("Failed to finalize FLAC file: {}", e))?;
encoder.send_frame(&frame).map_err(|e| format!("Failed to send FLAC frame: {}", e))?;
flac_write_packets(&mut encoder, &mut output)?;
done += n;
}
encoder.send_eof().map_err(|e| format!("Failed to flush FLAC encoder: {}", e))?;
flac_write_packets(&mut encoder, &mut output)?;
output.write_trailer().map_err(|e| format!("Failed to finalize FLAC: {}", e))?;
Ok(())
}
/// Drain encoded FLAC packets and write them (non-interleaved). Skips the trailing empty flush
/// packet, which the FLAC muxer otherwise rejects as "Invalid data". Rescales packet ts from the
/// encoder time base to the stream's.
fn flac_write_packets(
encoder: &mut ffmpeg_next::encoder::Audio,
output: &mut ffmpeg_next::format::context::Output,
) -> Result<(), String> {
let mut pkt = ffmpeg_next::Packet::empty();
let enc_tb = encoder.time_base();
let stream_tb = output.stream(0).map(|s| s.time_base()).unwrap_or(enc_tb);
while encoder.receive_packet(&mut pkt).is_ok() {
if pkt.size() == 0 {
continue;
}
pkt.set_stream(0);
pkt.rescale_ts(enc_tb, stream_tb);
pkt.write(output).map_err(|e| format!("Failed to write FLAC packet: {}", e))?;
}
Ok(())
}
/// Append a RIFF `LIST`/`INFO` metadata chunk to a finished WAV file (hound writes no tags), then
/// fix up the top-level RIFF size. Maps ffmpeg-style keys to RIFF INFO sub-chunk IDs. Trailing INFO
/// chunks are ignored by players that don't read them.
fn append_wav_info_chunk(path: &Path, metadata: &[(String, String)]) -> Result<(), String> {
use std::io::{Seek, SeekFrom, Write};
let riff_id = |key: &str| -> Option<&'static [u8; 4]> {
match key {
"title" => Some(b"INAM"),
"artist" => Some(b"IART"),
"album" => Some(b"IPRD"),
"genre" => Some(b"IGNR"),
"comment" => Some(b"ICMT"),
"date" => Some(b"ICRD"),
"track" => Some(b"ITRK"),
_ => None,
}
};
let mut info: Vec<u8> = Vec::new();
info.extend_from_slice(b"INFO");
for (key, val) in metadata {
if val.is_empty() {
continue;
}
let Some(id) = riff_id(key) else { continue };
let mut bytes = val.as_bytes().to_vec();
bytes.push(0); // NUL-terminate
if bytes.len() % 2 == 1 {
bytes.push(0); // pad to even
}
info.extend_from_slice(id);
info.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
info.extend_from_slice(&bytes);
}
if info.len() <= 4 {
return Ok(()); // nothing but the "INFO" tag
}
let mut list: Vec<u8> = Vec::with_capacity(info.len() + 8);
list.extend_from_slice(b"LIST");
list.extend_from_slice(&(info.len() as u32).to_le_bytes());
list.extend_from_slice(&info);
let mut f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|e| format!("Failed to open WAV for tagging: {}", e))?;
let end = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?;
if end % 2 == 1 {
f.write_all(&[0]).map_err(|e| e.to_string())?;
}
f.write_all(&list).map_err(|e| format!("Failed to write WAV tags: {}", e))?;
let new_len = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?;
f.seek(SeekFrom::Start(4)).map_err(|e| e.to_string())?;
f.write_all(&((new_len - 8) as u32).to_le_bytes())
.map_err(|e| format!("Failed to update RIFF size: {}", e))?;
Ok(())
}
@ -362,6 +512,7 @@ fn export_mp3<P: AsRef<Path>>(
stream.set_parameters(&encoder);
}
apply_metadata(&mut output, &settings.metadata);
output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?;
@ -531,6 +682,7 @@ fn export_aac<P: AsRef<Path>>(
stream.set_parameters(&encoder);
}
apply_metadata(&mut output, &settings.metadata);
output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?;
@ -838,4 +990,61 @@ mod tests {
assert_eq!(ExportFormat::Wav.extension(), "wav");
assert_eq!(ExportFormat::Flac.extension(), "flac");
}
fn tagged_settings(format: ExportFormat) -> ExportSettings {
ExportSettings {
format,
sample_rate: 48000,
channels: 2,
bit_depth: 24,
mp3_bitrate: 192,
start_time: Seconds::ZERO,
end_time: Seconds(0.2), // tiny render
tempo_map: TempoMap::constant(120.0),
metadata: vec![
("title".to_string(), "Test Title".to_string()),
("artist".to_string(), "Test Artist".to_string()),
],
}
}
/// FLAC export must be a real FLAC container (not WAV bytes) carrying Vorbis-comment tags.
#[test]
fn flac_export_is_real_flac_with_tags() {
let settings = tagged_settings(ExportFormat::Flac);
let mut project = Project::new(48000);
let pool = AudioPool::new();
let path = std::env::temp_dir().join("lb_be_flac_test.flac");
export_audio(&mut project, &pool, &settings, &path, None).expect("FLAC export failed");
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[0..4], b"fLaC", "not real FLAC (got {:?})", &bytes[0..4]);
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("Test Title"), "title tag missing from FLAC");
assert!(s.contains("Test Artist"), "artist tag missing from FLAC");
std::fs::remove_file(&path).ok();
}
/// WAV export keeps a valid RIFF container and gains a LIST/INFO tag chunk with a fixed-up size.
#[test]
fn wav_export_has_info_chunk() {
let settings = tagged_settings(ExportFormat::Wav);
let mut project = Project::new(48000);
let pool = AudioPool::new();
let path = std::env::temp_dir().join("lb_be_wav_test.wav");
export_audio(&mut project, &pool, &settings, &path, None).expect("WAV export failed");
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
let riff_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
assert_eq!(riff_size, bytes.len() - 8, "RIFF size not fixed up after tagging");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("LIST") && s.contains("INFO") && s.contains("INAM"),
"no RIFF INFO chunk");
assert!(s.contains("Test Title"), "title not in WAV INFO chunk");
std::fs::remove_file(&path).ok();
}
}

View File

@ -3615,6 +3615,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"skrifa 0.43.2",
"tiny-skia",
"uuid",
"vello",
@ -3641,6 +3642,7 @@ dependencies = [
"egui_extras",
"egui_node_graph2",
"ffmpeg-next",
"gif",
"gpu-video-encoder",
"half",
"image",

View File

@ -83,6 +83,15 @@ opt-level = 2
[profile.dev.package.cpal]
opt-level = 2
# GIF export: NeuQuant palette quantization is tight numeric loops that are punishingly slow
# unoptimized (1030× in debug). Optimize the encoder crates even in dev builds.
[profile.dev.package.gif]
opt-level = 3
[profile.dev.package.color_quant]
opt-level = 3
[profile.dev.package.weezl]
opt-level = 3
# Use local egui fork with ibus/Wayland text input fix
[patch.crates-io]
egui = { path = "../../egui-fork/crates/egui" }

View File

@ -20,6 +20,9 @@ vello = { workspace = true }
# Text layout/shaping (text layers)
parley = { workspace = true }
# Glyph outline extraction for lossless text→SVG export. Pinned to the version parley resolves
# (0.43) so glyph IDs / normalized coords from parley layouts match this outliner.
skrifa = "0.43"
# Image decoding for image fills
image = { workspace = true }

View File

@ -51,6 +51,54 @@ impl AudioFormat {
}
}
/// Optional tag metadata written into the exported audio file. Empty fields are omitted. FFmpeg
/// maps these standard keys to each container's native tags: ID3v2 (MP3), iTunes/MP4 atoms (M4A),
/// Vorbis comments (FLAC), and RIFF INFO (WAV).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AudioMetadata {
pub title: String,
pub artist: String,
pub album: String,
pub genre: String,
pub comment: String,
/// Year or full date (written to the `date` tag).
pub year: String,
/// Track number (written to the `track` tag).
pub track: String,
}
impl AudioMetadata {
/// True when no field is set (so no metadata need be written).
pub fn is_empty(&self) -> bool {
self.title.is_empty()
&& self.artist.is_empty()
&& self.album.is_empty()
&& self.genre.is_empty()
&& self.comment.is_empty()
&& self.year.is_empty()
&& self.track.is_empty()
}
/// The set (ffmpeg-key, value) pairs for non-empty fields, in a stable order.
pub fn pairs(&self) -> Vec<(&'static str, &str)> {
let mut v = Vec::new();
for (key, val) in [
("title", &self.title),
("artist", &self.artist),
("album", &self.album),
("genre", &self.genre),
("comment", &self.comment),
("date", &self.year),
("track", &self.track),
] {
if !val.is_empty() {
v.push((key, val.as_str()));
}
}
v
}
}
/// Audio export settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioExportSettings {
@ -79,6 +127,10 @@ pub struct AudioExportSettings {
/// Project BPM (for beat-position scheduling during export)
pub bpm: f64,
/// Tag metadata (title/artist/…) written into the file. Empty = none.
#[serde(default)]
pub metadata: AudioMetadata,
}
impl Default for AudioExportSettings {
@ -92,6 +144,7 @@ impl Default for AudioExportSettings {
start_time: 0.0,
end_time: 60.0,
bpm: 120.0,
metadata: AudioMetadata::default(),
}
}
}
@ -543,6 +596,82 @@ impl ImageExportSettings {
}
}
// ── Animated GIF export ──────────────────────────────────────────────────────
/// Settings for exporting an animated GIF (multi-frame, palette-quantized, no audio).
///
/// GIF stores a per-frame delay in centiseconds (1/100 s), so effective frame rate is quantized to
/// whole centiseconds — [`Self::frame_delay_ms`] rounds accordingly and the dialog offers sensible
/// GIF rates. Each frame is quantized to a 256-color palette by the encoder.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GifExportSettings {
/// Output width in pixels (None = use document width).
pub width: Option<u32>,
/// Output height in pixels (None = use document height).
pub height: Option<u32>,
/// Frame rate (fps). Snapped to whole-centisecond delays at encode time.
pub framerate: f64,
/// Loop the animation forever (GIF `NETSCAPE2.0` infinite loop). False = play once.
pub loop_forever: bool,
/// Preserve full alpha as GIF 1-bit transparency (pixels below the alpha threshold become the
/// transparent index). When false, frames are composited onto an opaque background first.
pub transparency: bool,
/// How the document is fit into the output frame when aspect ratios differ (default Letterbox).
#[serde(default)]
pub fit: ExportFitMode,
/// Start time in seconds.
pub start_time: f64,
/// End time in seconds.
pub end_time: f64,
}
impl Default for GifExportSettings {
fn default() -> Self {
Self {
width: None,
height: None,
framerate: 15.0,
loop_forever: true,
transparency: false,
fit: ExportFitMode::Letterbox,
start_time: 0.0,
end_time: 5.0,
}
}
}
impl GifExportSettings {
pub fn validate(&self) -> Result<(), String> {
if let Some(w) = self.width { if w == 0 { return Err("Width must be > 0".into()); } }
if let Some(h) = self.height { if h == 0 { return Err("Height must be > 0".into()); } }
if self.framerate <= 0.0 {
return Err("Framerate must be greater than 0".into());
}
if self.start_time < 0.0 {
return Err("Start time cannot be negative".into());
}
if self.end_time <= self.start_time {
return Err("End time must be greater than start time".into());
}
Ok(())
}
/// Duration in seconds.
pub fn duration(&self) -> f64 { self.end_time - self.start_time }
/// Total number of frames to render.
pub fn total_frames(&self) -> usize {
(self.duration() * self.framerate).ceil().max(1.0) as usize
}
/// Per-frame delay in milliseconds, from the framerate (GIF stores this at centisecond
/// resolution, so the effective rate is snapped to the nearest 10 ms, min 10 ms).
pub fn frame_delay_ms(&self) -> u32 {
let ms = 1000.0 / self.framerate;
((ms / 10.0).round().max(1.0) * 10.0) as u32
}
}
/// Progress updates during export
#[derive(Debug, Clone)]
pub enum ExportProgress {
@ -728,6 +857,33 @@ mod tests {
assert_eq!(settings.total_frames(), 300);
}
#[test]
fn test_gif_frame_delay_and_frames() {
// Frame rates that map to clean centisecond delays.
let mk = |fps: f64| GifExportSettings { framerate: fps, ..Default::default() };
assert_eq!(mk(10.0).frame_delay_ms(), 100); // 100 ms
assert_eq!(mk(20.0).frame_delay_ms(), 50); // 50 ms
assert_eq!(mk(50.0).frame_delay_ms(), 20); // 20 ms
// 15 fps = 66.6 ms rounds to 70 ms (7 cs); 25 fps = 40 ms.
assert_eq!(mk(15.0).frame_delay_ms(), 70);
assert_eq!(mk(25.0).frame_delay_ms(), 40);
// Very high fps clamps to the 10 ms minimum (1 cs).
assert_eq!(mk(1000.0).frame_delay_ms(), 10);
let settings = GifExportSettings { framerate: 20.0, start_time: 0.0, end_time: 3.0, ..Default::default() };
assert_eq!(settings.total_frames(), 60);
}
#[test]
fn test_gif_validate() {
let mut s = GifExportSettings::default();
assert!(s.validate().is_ok());
s.framerate = 0.0;
assert!(s.validate().is_err());
s = GifExportSettings { start_time: 5.0, end_time: 2.0, ..Default::default() };
assert!(s.validate().is_err());
}
#[test]
fn test_export_progress_percentage() {
let progress = ExportProgress::FrameRendered { frame: 50, total: 100 };

View File

@ -232,8 +232,9 @@ use crate::vector_graph::{FillId, VectorGraph};
use kurbo::{BezPath, PathEl, Rect, Shape};
/// Serialize the document's **vector** content to a standalone SVG string, at document time `time`.
/// Vector layers (and groups of them) only — raster/video/audio/effect layers are skipped (a later
/// pass can rasterize them to `<image>`). Animation is a single static frame at `time`.
/// Vector layers, groups of them, and text layers (as real glyph outlines) — raster/video/audio/
/// effect layers are skipped (a later pass can rasterize them to `<image>`). Animation is a single
/// static frame at `time`.
pub fn document_to_svg(document: &Document, time: f64) -> String {
let (w, h) = (document.width, document.height);
let mut defs = String::new();
@ -300,11 +301,136 @@ fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut Str
body.push_str("</g>");
}
}
AnyLayer::Text(tl) => text_layer_to_svg(tl, time, parent_opacity, body),
// Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass.
_ => {}
}
}
/// A skrifa outline pen that appends transformed glyph contours to an SVG path `d` string.
///
/// skrifa emits outline points in y-up pixel space (origin at the glyph baseline); this maps each
/// point into document space: `x = gx + px + skew·py`, `y = gy py` (Y flips, `skew` applies any
/// synthetic-italic slant), where `(gx, gy)` is the glyph's document-space pen position.
struct SvgOutlinePen<'a> {
gx: f64,
gy: f64,
skew: f64,
d: &'a mut String,
}
impl<'a> SvgOutlinePen<'a> {
fn map(&self, px: f32, py: f32) -> (f64, f64) {
let (px, py) = (px as f64, py as f64);
(self.gx + px + self.skew * py, self.gy - py)
}
}
impl skrifa::outline::OutlinePen for SvgOutlinePen<'_> {
fn move_to(&mut self, x: f32, y: f32) {
let (x, y) = self.map(x, y);
self.d.push_str(&format!("M{x:.2} {y:.2}"));
}
fn line_to(&mut self, x: f32, y: f32) {
let (x, y) = self.map(x, y);
self.d.push_str(&format!("L{x:.2} {y:.2}"));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let (cx, cy) = self.map(cx, cy);
let (x, y) = self.map(x, y);
self.d.push_str(&format!("Q{cx:.2} {cy:.2} {x:.2} {y:.2}"));
}
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
let (c0x, c0y) = self.map(c0x, c0y);
let (c1x, c1y) = self.map(c1x, c1y);
let (x, y) = self.map(x, y);
self.d.push_str(&format!("C{c0x:.2} {c0y:.2} {c1x:.2} {c1y:.2} {x:.2} {y:.2}"));
}
fn close(&mut self) {
self.d.push('Z');
}
}
/// Append a text layer's glyphs to `body` as a single filled `<path>` of real glyph outlines
/// (lossless — no font dependency in the SVG). Lays the text out with the same parley path the
/// renderer uses, then extracts each glyph's outline with skrifa. Variable-font axis positions and
/// synthetic-italic skew are honored; synthetic bold is not (rare).
fn text_layer_to_svg(
tl: &crate::text_layer::TextLayer,
time: f64,
parent_opacity: f64,
body: &mut String,
) {
use skrifa::MetadataProvider;
if !tl.layer.visible {
return;
}
let content = tl.content_at(time);
if content.text.is_empty() {
return;
}
let (ox, oy) = (tl.box_origin.x, tl.box_origin.y);
let mut d = String::new();
crate::fonts::with_layout(content, tl.box_width as f32, |layout| {
for line in layout.lines() {
for item in line.items() {
let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue };
let run = glyph_run.run();
let font = run.font();
let font_size = run.font_size();
let skew = run
.synthesis()
.skew()
.map(|angle| (angle as f64).to_radians().tan())
.unwrap_or(0.0);
let Ok(font_ref) = skrifa::FontRef::from_index(font.data.data(), font.index) else {
continue;
};
let outlines = font_ref.outline_glyphs();
// Variable-font axis position for this run (empty for static fonts).
let coords: Vec<skrifa::instance::NormalizedCoord> = run
.normalized_coords()
.iter()
.map(|&c| skrifa::instance::NormalizedCoord::from_bits(c))
.collect();
let location = skrifa::instance::LocationRef::new(&coords);
let size = skrifa::instance::Size::new(font_size);
for g in glyph_run.positioned_glyphs() {
let Some(glyph) = outlines.get(skrifa::GlyphId::new(g.id as u32)) else {
continue;
};
let mut pen = SvgOutlinePen {
gx: ox + g.x as f64,
gy: oy + g.y as f64,
skew,
d: &mut d,
};
let settings = skrifa::outline::DrawSettings::unhinted(size, location);
let _ = glyph.draw(settings, &mut pen);
}
}
}
});
if d.is_empty() {
return;
}
let [r, g, b, a] = content.color;
let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
let fill_opacity = (a as f64 * parent_opacity * tl.layer.opacity).clamp(0.0, 1.0);
body.push_str(&format!(
r#"<path fill="rgb({},{},{})" fill-opacity="{:.4}" fill-rule="nonzero" d="{}"/>"#,
to_u8(r), to_u8(g), to_u8(b), fill_opacity, d
));
}
/// Emit a vector graph's fills (`<path fill>`) and stroked edges (`<path stroke>`) into `body`,
/// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform).
fn vector_graph_to_svg(graph: &VectorGraph, body: &mut String, defs: &mut String, grad_n: &mut usize) {
@ -488,4 +614,58 @@ mod export_tests {
// 1 fill path + 3 stroked edges = 4 <path> elements.
assert_eq!(body.matches("<path").count(), 4, "{body}");
}
#[test]
fn outline_pen_maps_yflip_and_skew() {
use skrifa::outline::OutlinePen;
let mut d = String::new();
{
let mut pen = SvgOutlinePen { gx: 10.0, gy: 100.0, skew: 0.0, d: &mut d };
pen.move_to(0.0, 0.0); // baseline origin → (10, 100)
pen.line_to(5.0, 20.0); // 20 up → y = 100 20 = 80
pen.close();
}
assert!(d.contains("M10.00 100.00"), "d={d}");
assert!(d.contains("L15.00 80.00"), "d={d}");
assert!(d.ends_with('Z'));
// Synthetic-italic skew shifts x right in proportion to height.
let mut d2 = String::new();
{
let mut pen = SvgOutlinePen { gx: 0.0, gy: 0.0, skew: 0.5, d: &mut d2 };
pen.move_to(0.0, 10.0); // x = 0 + 0.5·10 = 5, y = 10
}
assert!(d2.contains("M5.00 -10.00"), "d={d2}");
}
#[test]
fn text_layer_emits_real_glyph_outlines() {
use crate::text_layer::TextLayer;
let mut tl = TextLayer::new("t", Point::new(20.0, 60.0));
tl.content.text = "Hi".to_string();
tl.content.font_size = 48.0;
tl.content.color = [1.0, 0.0, 0.0, 1.0];
let mut body = String::new();
text_layer_to_svg(&tl, 0.0, 1.0, &mut body);
// Bundled fonts guarantee glyphs → a filled path with actual outline segments.
assert!(body.contains("<path"), "no path emitted: {body}");
assert!(body.contains(r#"fill="rgb(255,0,0)""#), "wrong fill: {body}");
assert!(
body.contains('C') || body.contains('Q') || body.contains('L'),
"path has no outline segments: {body}"
);
assert!(body.len() > 80, "suspiciously short path: {body}");
}
#[test]
fn empty_text_layer_emits_nothing() {
use crate::text_layer::TextLayer;
let tl = TextLayer::new("t", Point::new(0.0, 0.0)); // no text set
let mut body = String::new();
text_layer_to_svg(&tl, 0.0, 1.0, &mut body);
assert!(body.is_empty(), "empty text should emit nothing: {body}");
}
}

View File

@ -41,6 +41,10 @@ serde_json = { workspace = true }
# Image loading
image = { workspace = true }
# Animated GIF encoding — used directly (not just via `image`) so per-frame NeuQuant palette
# quantization can be parallelized across a worker pool. Pinned to the version `image` already
# resolves (0.13), so this adds no new transitive graph.
gif = "0.13"
resvg = { workspace = true }
tiny-skia = "0.11"

View File

@ -70,6 +70,14 @@ pub struct AppConfig {
/// sooner; larger = smaller pyramid, wider re-decode span. Default 256.
#[serde(default = "defaults::waveform_floor_samples_per_texel")]
pub waveform_floor_samples_per_texel: u32,
/// Last-used audio-export "Artist" tag, remembered so it prefills next time.
#[serde(default)]
pub last_audio_artist: String,
/// Last-used audio-export "Album" tag, remembered so it prefills next time.
#[serde(default)]
pub last_audio_album: String,
}
impl Default for AppConfig {
@ -90,6 +98,8 @@ impl Default for AppConfig {
keybindings: KeybindingConfig::default(),
large_media_default: LargeMediaMode::default(),
waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(),
last_audio_artist: String::new(),
last_audio_album: String::new(),
}
}
}

View File

@ -1,475 +0,0 @@
#![allow(dead_code)]
//! Audio export functionality
//!
//! Exports audio from the timeline to various formats:
//! - WAV and FLAC: Use existing DAW backend export
//! - MP3 and AAC: Use FFmpeg encoding with rendered samples
use lightningbeam_core::export::{AudioExportSettings, AudioFormat};
use daw_backend::audio::{
export::{ExportFormat, ExportSettings as DawExportSettings, render_to_memory},
midi_pool::MidiClipPool,
pool::AudioPool,
project::Project,
};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Export audio to a file
///
/// This function routes to the appropriate export method based on the format:
/// - WAV/FLAC: Use DAW backend export
/// - MP3/AAC: Use FFmpeg encoding (TODO)
pub fn export_audio<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
midi_pool: &MidiClipPool,
settings: &AudioExportSettings,
output_path: P,
cancel_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
// Validate settings
settings.validate()?;
// Check for cancellation before starting
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled by user".to_string());
}
match settings.format {
AudioFormat::Wav | AudioFormat::Flac => {
export_audio_daw_backend(project, pool, midi_pool, settings, output_path)
}
AudioFormat::Mp3 => {
export_audio_ffmpeg_mp3(project, pool, midi_pool, settings, output_path, cancel_flag)
}
AudioFormat::Aac => {
export_audio_ffmpeg_aac(project, pool, midi_pool, settings, output_path, cancel_flag)
}
}
}
/// Export audio using the DAW backend (WAV/FLAC)
fn export_audio_daw_backend<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
_midi_pool: &MidiClipPool,
settings: &AudioExportSettings,
output_path: P,
) -> Result<(), String> {
// Convert our export settings to DAW backend format
let daw_settings = DawExportSettings {
format: match settings.format {
AudioFormat::Wav => ExportFormat::Wav,
AudioFormat::Flac => ExportFormat::Flac,
_ => unreachable!(), // This function only handles WAV/FLAC
},
sample_rate: settings.sample_rate,
channels: settings.channels,
bit_depth: settings.bit_depth,
mp3_bitrate: 320, // Not used for WAV/FLAC
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Use the existing DAW backend export function
// No progress reporting for this direct export path
daw_backend::audio::export::export_audio(
project,
pool,
&daw_settings,
output_path,
None,
)
}
/// Export audio as MP3 using FFmpeg
fn export_audio_ffmpeg_mp3<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
_midi_pool: &MidiClipPool,
settings: &AudioExportSettings,
output_path: P,
cancel_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
use ffmpeg_next as ffmpeg;
// Initialize FFmpeg
ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
// Convert settings to DAW backend format
let daw_settings = DawExportSettings {
format: ExportFormat::Wav, // Unused, but required
sample_rate: settings.sample_rate,
channels: settings.channels,
bit_depth: 16, // Unused
mp3_bitrate: settings.bitrate_kbps,
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Step 1: Render audio to memory
let pcm_samples = render_to_memory(
project,
pool,
&daw_settings,
None, // No progress events for now
)?;
// Check for cancellation
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled".to_string());
}
// Step 2: Set up FFmpeg encoder
let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::MP3)
.ok_or("MP3 encoder (libmp3lame) not found")?;
// Create output file
let mut output = ffmpeg::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
// Create encoder
let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec)
.encoder()
.audio()
.map_err(|e| format!("Failed to create encoder: {}", e))?;
// Configure encoder
let channel_layout = match settings.channels {
1 => ffmpeg::channel_layout::ChannelLayout::MONO,
2 => ffmpeg::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
};
encoder.set_rate(settings.sample_rate as i32);
encoder.set_channel_layout(channel_layout);
encoder.set_format(ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar));
encoder.set_bit_rate((settings.bitrate_kbps * 1000) as usize);
encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32));
// Open encoder
let mut encoder = encoder.open_as(encoder_codec)
.map_err(|e| format!("Failed to open MP3 encoder: {}", e))?;
// Add stream and set parameters
{
let mut stream = output.add_stream(encoder_codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
} // Drop stream here to release the borrow
// Write header
output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?;
// Step 3: Encode frames and write to output
// Convert interleaved f32 samples to planar i16 format
let num_frames = pcm_samples.len() / settings.channels as usize;
let planar_samples = convert_to_planar_i16(&pcm_samples, settings.channels);
// Get encoder frame size
let frame_size = encoder.frame_size();
let samples_per_frame = if frame_size > 0 {
frame_size as usize
} else {
1152 // Default MP3 frame size
};
// Encode in chunks
let mut samples_encoded = 0;
while samples_encoded < num_frames {
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled".to_string());
}
let samples_remaining = num_frames - samples_encoded;
let chunk_size = samples_remaining.min(samples_per_frame);
// Create audio frame
let mut frame = ffmpeg::frame::Audio::new(
ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar),
chunk_size,
channel_layout,
);
frame.set_rate(settings.sample_rate);
// Copy planar samples to frame
// Use plane_mut::<i16> instead of data_mut — data_mut(ch) is buggy for planar audio:
// FFmpeg only sets linesize[0], so data_mut returns 0-length slices for ch > 0.
// plane_mut uses self.samples() for the length, which is correct for all planes.
for ch in 0..settings.channels as usize {
let plane = frame.plane_mut::<i16>(ch);
let offset = samples_encoded;
plane.copy_from_slice(&planar_samples[ch][offset..offset + chunk_size]);
}
// Send frame to encoder
encoder.send_frame(&frame)
.map_err(|e| format!("Failed to send frame: {}", e))?;
// Receive and write packets
receive_and_write_packets(&mut encoder, &mut output)?;
samples_encoded += chunk_size;
}
// Flush encoder
encoder.send_eof()
.map_err(|e| format!("Failed to send EOF: {}", e))?;
receive_and_write_packets(&mut encoder, &mut output)?;
// Write trailer
output.write_trailer()
.map_err(|e| format!("Failed to write trailer: {}", e))?;
Ok(())
}
/// Convert interleaved f32 samples to planar i16 format
fn convert_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec<Vec<i16>> {
let num_frames = interleaved.len() / channels as usize;
let mut planar = vec![vec![0i16; num_frames]; channels as usize];
for (i, chunk) in interleaved.chunks(channels as usize).enumerate() {
for (ch, &sample) in chunk.iter().enumerate() {
// Clamp and convert f32 (-1.0 to 1.0) to i16
let clamped = sample.max(-1.0).min(1.0);
planar[ch][i] = (clamped * 32767.0) as i16;
}
}
planar
}
/// Convert interleaved f32 samples to planar f32 format
fn convert_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec<Vec<f32>> {
let num_frames = interleaved.len() / channels as usize;
let mut planar = vec![vec![0.0f32; num_frames]; channels as usize];
for (i, chunk) in interleaved.chunks(channels as usize).enumerate() {
for (ch, &sample) in chunk.iter().enumerate() {
planar[ch][i] = sample;
}
}
planar
}
/// Receive encoded packets and write to output
fn receive_and_write_packets(
encoder: &mut ffmpeg_next::encoder::Audio,
output: &mut ffmpeg_next::format::context::Output,
) -> Result<(), String> {
let mut encoded = ffmpeg_next::Packet::empty();
while encoder.receive_packet(&mut encoded).is_ok() {
encoded.set_stream(0);
encoded.write_interleaved(output)
.map_err(|e| format!("Failed to write packet: {}", e))?;
}
Ok(())
}
/// Export audio as AAC using FFmpeg
fn export_audio_ffmpeg_aac<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
_midi_pool: &MidiClipPool,
settings: &AudioExportSettings,
output_path: P,
cancel_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
use ffmpeg_next as ffmpeg;
// Initialize FFmpeg
ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
// Convert settings to DAW backend format
let daw_settings = DawExportSettings {
format: ExportFormat::Wav, // Unused, but required
sample_rate: settings.sample_rate,
channels: settings.channels,
bit_depth: 16, // Unused
mp3_bitrate: settings.bitrate_kbps,
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Step 1: Render audio to memory
let pcm_samples = render_to_memory(
project,
pool,
&daw_settings,
None, // No progress events for now
)?;
// Check for cancellation
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled".to_string());
}
// Step 2: Set up FFmpeg encoder
let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::AAC)
.ok_or("AAC encoder not found")?;
// Create output file
let mut output = ffmpeg::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
// Create encoder
let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec)
.encoder()
.audio()
.map_err(|e| format!("Failed to create encoder: {}", e))?;
// Configure encoder
let channel_layout = match settings.channels {
1 => ffmpeg::channel_layout::ChannelLayout::MONO,
2 => ffmpeg::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
};
encoder.set_rate(settings.sample_rate as i32);
encoder.set_channel_layout(channel_layout);
// AAC encoder supports FLTP (F32 Planar) format
encoder.set_format(ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar));
encoder.set_bit_rate((settings.bitrate_kbps * 1000) as usize);
encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32));
// Open encoder
let mut encoder = encoder.open_as(encoder_codec)
.map_err(|e| format!("Failed to open AAC encoder: {}", e))?;
// Add stream and set parameters
{
let mut stream = output.add_stream(encoder_codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
} // Drop stream here to release the borrow
// Write header
output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?;
// Step 3: Encode frames and write to output
// Convert interleaved f32 samples to planar f32 format (no conversion needed, just rearrange)
let num_frames = pcm_samples.len() / settings.channels as usize;
let planar_samples = convert_to_planar_f32(&pcm_samples, settings.channels);
// Get encoder frame size
let frame_size = encoder.frame_size();
let samples_per_frame = if frame_size > 0 {
frame_size as usize
} else {
1024 // Default AAC frame size
};
// Encode in chunks
let mut samples_encoded = 0;
while samples_encoded < num_frames {
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled".to_string());
}
let samples_remaining = num_frames - samples_encoded;
let chunk_size = samples_remaining.min(samples_per_frame);
// Create audio frame
let mut frame = ffmpeg::frame::Audio::new(
ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar),
chunk_size,
channel_layout,
);
frame.set_rate(settings.sample_rate);
// Copy planar samples to frame
unsafe {
for ch in 0..settings.channels as usize {
let plane = frame.data_mut(ch);
let offset = samples_encoded;
let src = &planar_samples[ch][offset..offset + chunk_size];
std::ptr::copy_nonoverlapping(
src.as_ptr() as *const u8,
plane.as_mut_ptr(),
chunk_size * std::mem::size_of::<f32>(),
);
}
}
// Send frame to encoder
encoder.send_frame(&frame)
.map_err(|e| format!("Failed to send frame: {}", e))?;
// Receive and write packets
receive_and_write_packets(&mut encoder, &mut output)?;
samples_encoded += chunk_size;
}
// Flush encoder
encoder.send_eof()
.map_err(|e| format!("Failed to send EOF: {}", e))?;
receive_and_write_packets(&mut encoder, &mut output)?;
// Write trailer
output.write_trailer()
.map_err(|e| format!("Failed to write trailer: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_export_audio_validation() {
let mut settings = AudioExportSettings::default();
settings.sample_rate = 0; // Invalid
let project = Project::new(44100);
let pool = AudioPool::new();
let midi_pool = MidiClipPool::new();
let cancel_flag = Arc::new(AtomicBool::new(false));
let result = export_audio(
&mut project.clone(),
&pool,
&midi_pool,
&settings,
"/tmp/test.wav",
&cancel_flag,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Sample rate"));
}
#[test]
fn test_export_audio_cancellation() {
let settings = AudioExportSettings::default();
let mut project = Project::new(44100);
let pool = AudioPool::new();
let midi_pool = MidiClipPool::new();
let cancel_flag = Arc::new(AtomicBool::new(true)); // Pre-cancelled
let result = export_audio(
&mut project,
&pool,
&midi_pool,
&settings,
"/tmp/test.wav",
&cancel_flag,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cancelled"));
}
}

View File

@ -101,6 +101,94 @@ impl CpuYuvConverter {
}
}
/// CPU RGBA→YUV422P10LE converter (10-bit, 4:2:2) via swscale, for ProRes 422 export.
///
/// ProRes (`prores_ks`) requires a 10-bit 4:2:2 input; the SDR pipeline otherwise produces 8-bit
/// 4:2:0. Source is still 8-bit RGBA (bit-depth is promoted, not conjured), which is normal for
/// SDR ProRes. BT.709 with the requested range, matching the encoder's color tags.
pub struct CpuYuv422P10Converter {
width: u32,
height: u32,
scaler: ffmpeg::software::scaling::Context,
rgba_frame: ffmpeg::frame::Video,
yuv_frame: ffmpeg::frame::Video,
}
impl CpuYuv422P10Converter {
pub fn new(width: u32, height: u32, full_range: bool) -> Result<Self, String> {
let mut scaler = ffmpeg::software::scaling::Context::get(
ffmpeg::format::Pixel::RGBA, width, height,
ffmpeg::format::Pixel::YUV422P10LE, width, height,
ffmpeg::software::scaling::Flags::BILINEAR,
)
.map_err(|e| format!("Failed to create YUV422P10 swscale context: {}", e))?;
// BT.709, requested output range (matches setup_video_encoder's SDR tags). No safe
// ffmpeg-next wrapper for sws_setColorspaceDetails, so this is the raw call (as in
// CpuYuvConverter::new above).
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;
ffmpeg::ffi::sws_setColorspaceDetails(
scaler.as_mut_ptr(),
coeffs, 1,
coeffs, dst_range,
0, one, one,
);
}
let rgba_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
let yuv_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV422P10LE, width, height);
Ok(Self { width, height, scaler, rgba_frame, yuv_frame })
}
/// Convert packed RGBA (width*height*4) to tight YUV422P10LE planes (little-endian, 2 bytes per
/// sample): Y is width×height, U and V are (width/2)×height. Planes are returned tight (stride
/// padding stripped) to match what `encode_frame` expects.
pub fn convert(&mut self, rgba_data: &[u8]) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
let expected = (self.width * self.height * 4) as usize;
assert_eq!(rgba_data.len(), expected,
"RGBA data size mismatch: expected {} bytes, got {}", expected, rgba_data.len());
// Copy RGBA into the source frame honoring its stride (may be padded).
let row_bytes = (self.width * 4) as usize;
let src_stride = self.rgba_frame.stride(0);
{
let dst = self.rgba_frame.data_mut(0);
for row in 0..self.height as usize {
let s = row * row_bytes;
let d = row * src_stride;
dst[d..d + row_bytes].copy_from_slice(&rgba_data[s..s + row_bytes]);
}
}
self.scaler
.run(&self.rgba_frame, &mut self.yuv_frame)
.map_err(|e| format!("YUV422P10 swscale conversion failed: {}", e))?;
// Extract each plane tight (2 bytes/sample). Y: width samples/row × height rows.
// Chroma (4:2:2): width/2 samples/row × height rows.
let extract = |frame: &ffmpeg::frame::Video, idx: usize, samples_w: usize, rows: usize| {
let bytes_per_row = samples_w * 2;
let stride = frame.stride(idx);
let data = frame.data(idx);
let mut out = Vec::with_capacity(bytes_per_row * rows);
for row in 0..rows {
let start = row * stride;
out.extend_from_slice(&data[start..start + bytes_per_row]);
}
out
};
let (w, h) = (self.width as usize, self.height as usize);
let y_plane = extract(&self.yuv_frame, 0, w, h);
let u_plane = extract(&self.yuv_frame, 1, w / 2, h);
let v_plane = extract(&self.yuv_frame, 2, w / 2, h);
Ok((y_plane, u_plane, v_plane))
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -131,6 +219,20 @@ mod tests {
assert_eq!(v.len(), (1920 / 2) * (1080 / 2));
}
#[test]
fn test_yuv422p10_output_sizes() {
// Use a width that forces swscale linesize padding (not a multiple of 32/64) to exercise
// the stride-stripping extraction.
let (w, h) = (1000u32, 720u32);
let mut c = CpuYuv422P10Converter::new(w, h, false).unwrap();
let rgba = vec![0u8; (w * h * 4) as usize];
let (y, u, v) = c.convert(&rgba).unwrap();
// 10-bit → 2 bytes/sample. Y full res; U/V half width, full height (4:2:2).
assert_eq!(y.len(), (w * h * 2) as usize);
assert_eq!(u.len(), ((w / 2) * h * 2) as usize);
assert_eq!(v.len(), ((w / 2) * h * 2) as usize);
}
#[test]
#[should_panic(expected = "RGBA data size mismatch")]
fn test_wrong_input_size_panics() {

View File

@ -5,11 +5,42 @@
use eframe::egui;
use lightningbeam_core::export::{
AudioExportSettings, AudioFormat,
GifExportSettings,
ImageExportSettings, ImageFormat,
VideoExportSettings, VideoCodec, VideoQuality, ColorRange,
};
use std::path::PathBuf;
/// The OS username (`$USER` / `%USERNAME%`), or empty if unavailable. Used as a default Artist tag.
fn os_username() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_default()
}
/// Current civil year (UTC) computed from the system clock — avoids pulling in a date crate.
fn current_year() -> i64 {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
year_from_unix_secs(secs)
}
/// Civil year (UTC) for a Unix timestamp, via Howard Hinnant's `civil_from_days` algorithm.
fn year_from_unix_secs(secs: i64) -> i64 {
let days = secs.div_euclid(86_400);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
y + if m <= 2 { 1 } else { 0 }
}
/// Hint about document content, used to pick a smart default export type.
pub struct DocumentHint {
pub has_video: bool,
@ -25,6 +56,8 @@ pub enum ExportType {
Audio,
Image,
Video,
/// Animated GIF (multi-frame, palette-quantized, no audio).
Gif,
/// Vector-only SVG of the current frame (lossless; raster/video layers skipped).
Svg,
}
@ -36,6 +69,8 @@ pub enum ExportResult {
Image(ImageExportSettings, PathBuf),
VideoOnly(VideoExportSettings, PathBuf),
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
/// Animated GIF export.
Gif(GifExportSettings, PathBuf),
/// SVG of vector layers at the given document time.
Svg(f64, PathBuf),
}
@ -57,6 +92,9 @@ pub struct ExportDialog {
/// Video export settings
pub video_settings: VideoExportSettings,
/// Animated GIF export settings
pub gif_settings: GifExportSettings,
/// Include audio with video?
pub include_audio: bool,
@ -104,6 +142,7 @@ impl Default for ExportDialog {
audio_settings: AudioExportSettings::standard_mp3(),
image_settings: ImageExportSettings::default(),
video_settings: VideoExportSettings::default(),
gif_settings: GifExportSettings::default(),
include_audio: true,
output_path: None,
error_message: None,
@ -120,10 +159,18 @@ impl Default for ExportDialog {
impl ExportDialog {
/// Open the dialog with default settings, using `hint` to pick a smart default tab.
pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) {
pub fn open(
&mut self,
timeline_duration: f64,
project_name: &str,
hint: &DocumentHint,
last_artist: &str,
last_album: &str,
) {
self.open = true;
self.audio_settings.end_time = timeline_duration;
self.video_settings.end_time = timeline_duration;
self.gif_settings.end_time = timeline_duration;
self.image_settings.time = hint.current_time;
// Propagate document dimensions as defaults (None means "use doc size").
self.image_settings.width = None;
@ -143,6 +190,24 @@ impl ExportDialog {
else if only_raster { ExportType::Image }
else { self.export_type } // keep current as fallback
};
// Sensible tag defaults, only filled when empty so a user's edits are never clobbered:
// • Title → project name (on a project switch)
// • Year → current year
// • Artist → last-used artist, else the OS username
// • Album → last-used album
let meta = &mut self.audio_settings.metadata;
if meta.title.is_empty() && !same_project {
meta.title = project_name.to_owned();
}
if meta.year.is_empty() {
meta.year = current_year().to_string();
}
if meta.artist.is_empty() {
meta.artist = if !last_artist.is_empty() { last_artist.to_owned() } else { os_username() };
}
if meta.album.is_empty() && !last_album.is_empty() {
meta.album = last_album.to_owned();
}
self.current_project = project_name.to_owned();
// Restore the last exported path if available; otherwise default to project name.
@ -160,6 +225,7 @@ impl ExportDialog {
ExportType::Audio => self.audio_settings.format.extension(),
ExportType::Image => self.image_settings.format.extension(),
ExportType::Video => self.video_settings.codec.container_format(),
ExportType::Gif => "gif",
ExportType::Svg => "svg",
}
}
@ -203,6 +269,7 @@ impl ExportDialog {
ExportType::Audio => "Export Audio",
ExportType::Image => "Export Image",
ExportType::Video => "Export Video",
ExportType::Gif => "Export GIF",
ExportType::Svg => "Export SVG",
};
@ -225,6 +292,7 @@ impl ExportDialog {
(ExportType::Audio, "Audio"),
(ExportType::Image, "Image"),
(ExportType::Video, "Video"),
(ExportType::Gif, "GIF"),
(ExportType::Svg, "SVG"),
] {
if ui.selectable_value(&mut self.export_type, variant, label).clicked() {
@ -242,6 +310,7 @@ impl ExportDialog {
ExportType::Audio => self.render_audio_basic(ui),
ExportType::Image => self.render_image_settings(ui),
ExportType::Video => self.render_video_basic(ui),
ExportType::Gif => self.render_gif_basic(ui),
ExportType::Svg => self.render_svg_settings(ui),
}
@ -261,6 +330,7 @@ impl ExportDialog {
ExportType::Audio => self.render_audio_advanced(ui),
ExportType::Image => self.render_image_advanced(ui),
ExportType::Video => self.render_video_advanced(ui),
ExportType::Gif => self.render_gif_advanced(ui),
ExportType::Svg => {} // SVG has no advanced settings
}
}
@ -460,10 +530,50 @@ impl ExportDialog {
ui.add_space(8.0);
// Tag metadata (ID3 / MP4 / Vorbis / RIFF-INFO depending on format).
self.render_audio_metadata(ui);
ui.add_space(8.0);
// Time range
self.render_time_range(ui);
}
/// Render the audio tag-metadata fields (title/artist/album/…). Written into the file on export.
fn render_audio_metadata(&mut self, ui: &mut egui::Ui) {
let m = &mut self.audio_settings.metadata;
ui.label(egui::RichText::new("Tags").strong());
// Placeholder styling: italic + a clearly faded color so an empty field's hint never reads
// as a real value (the theme's default weak color is too close to the text color). An
// explicit color overrides egui's weak-color fallback for hint text.
let hint_color = {
let t = ui.visuals().text_color();
egui::Color32::from_rgba_unmultiplied(t.r(), t.g(), t.b(), 100)
};
let year_hint = format!("e.g. {}", current_year());
egui::Grid::new("audio_metadata_grid")
.num_columns(2)
.spacing([8.0, 4.0])
.show(ui, |ui| {
let row = |ui: &mut egui::Ui, label: &str, val: &mut String, hint: &str| {
ui.label(label);
ui.add(
egui::TextEdit::singleline(val)
.hint_text(egui::RichText::new(hint).italics().color(hint_color))
.desired_width(260.0),
);
ui.end_row();
};
row(ui, "Title", &mut m.title, "e.g. My Song");
row(ui, "Artist", &mut m.artist, "e.g. Jane Doe");
row(ui, "Album", &mut m.album, "e.g. Greatest Hits");
row(ui, "Genre", &mut m.genre, "e.g. Electronic");
row(ui, "Year", &mut m.year, &year_hint);
row(ui, "Track", &mut m.track, "e.g. 1 or 1/12");
row(ui, "Comment", &mut m.comment, "Optional notes…");
});
}
/// Video presets: (name, codec, quality, width, height, fps)
const VIDEO_PRESETS: &'static [(&'static str, VideoCodec, VideoQuality, u32, u32, f64)] = &[
("1080p H.264 (Standard)", VideoCodec::H264, VideoQuality::High, 1920, 1080, 30.0),
@ -614,12 +724,65 @@ impl ExportDialog {
self.render_time_range(ui);
}
/// GIF frame-rate presets (fps). GIF delays are centisecond-quantized, so these map to clean
/// per-frame delays (10/15/20/25/50 fps → 100/70/50/40/20 ms after rounding).
const GIF_FPS: &'static [f64] = &[10.0, 15.0, 20.0, 25.0, 50.0];
/// Render basic GIF settings (frame rate + loop).
fn render_gif_basic(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Frame rate:");
egui::ComboBox::from_id_salt("gif_fps")
.selected_text(format!("{} fps", self.gif_settings.framerate as u32))
.show_ui(ui, |ui| {
for &fps in Self::GIF_FPS {
ui.selectable_value(&mut self.gif_settings.framerate, fps, format!("{} fps", fps as u32));
}
});
});
ui.checkbox(&mut self.gif_settings.loop_forever, "Loop forever");
ui.add_space(8.0);
self.render_time_range(ui);
}
/// Render advanced GIF settings (resolution, fit, transparency).
fn render_gif_advanced(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Size:");
let mut w = self.gif_settings.width.unwrap_or(0);
let mut h = self.gif_settings.height.unwrap_or(0);
let changed_w = ui.add(egui::DragValue::new(&mut w).range(0..=u32::MAX).prefix("W ")).changed();
let changed_h = ui.add(egui::DragValue::new(&mut h).range(0..=u32::MAX).prefix("H ")).changed();
if changed_w { self.gif_settings.width = if w == 0 { None } else { Some(w) }; }
if changed_h { self.gif_settings.height = if h == 0 { None } else { Some(h) }; }
ui.weak("(0 = document size)");
});
ui.horizontal(|ui| {
use lightningbeam_core::export::ExportFitMode;
ui.label("Fit:");
egui::ComboBox::from_id_salt("gif_fit_mode")
.selected_text(self.gif_settings.fit.name())
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Letterbox, ExportFitMode::Letterbox.name());
ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Crop, ExportFitMode::Crop.name());
ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Stretch, ExportFitMode::Stretch.name());
});
});
ui.checkbox(&mut self.gif_settings.transparency, "Preserve transparency (1-bit)");
ui.label(egui::RichText::new("GIF supports only on/off transparency; semi-transparent pixels are keyed out.").weak().small());
}
/// Render time range UI (common to both audio and video)
fn render_time_range(&mut self, ui: &mut egui::Ui) {
let (start_time, end_time) = match self.export_type {
ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time),
ExportType::Image | ExportType::Svg => return, // single time field, not a range
ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time),
ExportType::Gif => (&mut self.gif_settings.start_time, &mut self.gif_settings.end_time),
};
ui.horizontal(|ui| {
@ -693,6 +856,13 @@ impl ExportDialog {
Some(ExportResult::Image(self.image_settings.clone(), output_path))
}
ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, output_path)),
ExportType::Gif => {
if let Err(err) = self.gif_settings.validate() {
self.error_message = Some(err);
return None;
}
Some(ExportResult::Gif(self.gif_settings.clone(), output_path))
}
ExportType::Audio => {
// Validate audio settings
if let Err(err) = self.audio_settings.validate() {
@ -861,3 +1031,30 @@ impl ExportProgressDialog {
should_cancel
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn year_from_unix_secs_known_values() {
assert_eq!(year_from_unix_secs(0), 1970); // Unix epoch
assert_eq!(year_from_unix_secs(946_684_800), 2000); // 2000-01-01
assert_eq!(year_from_unix_secs(1_735_689_600), 2025); // 2025-01-01
assert_eq!(year_from_unix_secs(1_767_225_599), 2025); // 2025-12-31 23:59:59
assert_eq!(year_from_unix_secs(1_767_225_600), 2026); // 2026-01-01
// Post-2038: these timestamps exceed i32::MAX (2_147_483_647) — and the last exceeds
// u32::MAX — so a 32-bit time_t would wrap here. i64 math handles them correctly.
assert_eq!(year_from_unix_secs(2_148_595_200), 2038); // 2038-02-01 (> i32::MAX)
assert_eq!(year_from_unix_secs(2_223_331_200), 2040); // 2040-06-15
assert_eq!(year_from_unix_secs(4_102_444_800), 2100); // 2100-01-01 (not a leap year)
assert_eq!(year_from_unix_secs(9_214_646_400), 2262); // 2262-01-01 (> u32::MAX)
}
#[test]
fn current_year_is_plausible() {
let y = current_year();
assert!((2020..3000).contains(&y), "implausible year: {y}");
}
}

View File

@ -0,0 +1,196 @@
//! Animated GIF encoding.
//!
//! Palette-quantizes a stream of RGBA8 frames and writes them to a `.gif`. The expensive part —
//! per-frame NeuQuant 256-color quantization — is embarrassingly parallel (each frame gets its own
//! local palette), so it's fanned out across a worker pool. A single writer thread collects the
//! quantized frames, reorders them, and LZW-encodes them to the file in sequence.
//!
//! Pipeline (all off the UI thread):
//! ```text
//! UI render thread ──RGBA──▶ coordinator ──round-robin──▶ N quantizer workers
//! │ (idx, gif::Frame)
//! ▼
//! writer thread ──▶ .gif
//! ```
//! Rendering + readback happen on the UI thread (see `render_next_gif_frame`); this module owns
//! everything after a raw RGBA frame arrives.
use lightningbeam_core::export::ExportProgress;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::Arc;
/// Message from the UI (render) thread to the GIF encoder coordinator.
pub enum GifFrameMessage {
/// One RGBA8 frame (top-left origin, tightly packed `width*height*4` bytes).
Frame { frame_num: usize, pixels: Vec<u8> },
/// All frames have been sent.
Done,
}
/// gif crate quantization speed (1 = slowest/best, 30 = fastest/worst). 10 balances palette quality
/// against per-frame cost; the parallelism below is what actually recovers the wall-clock.
const QUANT_SPEED: i32 = 10;
/// Run the GIF encoder pipeline. Receives RGBA8 frames from `frame_rx`, quantizes them in parallel,
/// and writes the ordered result to `output_path`, reporting progress. `transparency == false`
/// composites each frame onto opaque black first (GIF's 1-bit transparency would otherwise key out
/// semi-transparent pixels).
#[allow(clippy::too_many_arguments)]
pub fn run_gif_encoder(
frame_rx: Receiver<GifFrameMessage>,
output_path: PathBuf,
width: u32,
height: u32,
total_frames: usize,
delay_ms: u32,
loop_forever: bool,
transparency: bool,
progress_tx: Sender<ExportProgress>,
cancel_flag: Arc<AtomicBool>,
) {
let _ = progress_tx.send(ExportProgress::Started { total_frames });
let delay_cs = ((delay_ms / 10).max(1)) as u16;
let expected_len = (width as usize) * (height as usize) * 4;
// One quantizer worker per spare core (leave one for the UI render thread), capped so we don't
// spawn absurdly many for short exports.
let n_workers = std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(1))
.unwrap_or(1)
.clamp(1, 8);
// Per-worker input channels (coordinator dispatches round-robin) + one shared result channel.
let mut worker_txs: Vec<Sender<(usize, Vec<u8>)>> = Vec::with_capacity(n_workers);
let (result_tx, result_rx) = channel::<(usize, gif::Frame<'static>)>();
let mut worker_handles = Vec::with_capacity(n_workers);
for _ in 0..n_workers {
let (wtx, wrx) = channel::<(usize, Vec<u8>)>();
worker_txs.push(wtx);
let result_tx = result_tx.clone();
let cancel = Arc::clone(&cancel_flag);
worker_handles.push(std::thread::spawn(move || {
while let Ok((idx, mut pixels)) = wrx.recv() {
if cancel.load(Ordering::Relaxed) {
break;
}
// NeuQuant local-palette quantization (the expensive step). `from_rgba_speed` uses
// the RGBA buffer as scratch, so it's fine that we own `pixels` here.
let mut frame =
gif::Frame::from_rgba_speed(width as u16, height as u16, &mut pixels, QUANT_SPEED);
frame.delay = delay_cs;
if result_tx.send((idx, frame)).is_err() {
break; // writer gone
}
}
}));
}
drop(result_tx); // only the workers hold senders now; writer's rx ends when they all finish
// Writer thread: order frames by index and LZW-encode them sequentially.
let writer_progress = progress_tx.clone();
let writer_cancel = Arc::clone(&cancel_flag);
let writer_output = output_path.clone();
let writer = std::thread::spawn(move || -> Result<(), String> {
let file = std::fs::File::create(&writer_output)
.map_err(|e| format!("Failed to create GIF file: {e}"))?;
let mut buf = std::io::BufWriter::new(file);
let mut encoder = gif::Encoder::new(&mut buf, width as u16, height as u16, &[])
.map_err(|e| format!("GIF encoder init failed: {e}"))?;
if loop_forever {
encoder
.set_repeat(gif::Repeat::Infinite)
.map_err(|e| format!("GIF set_repeat failed: {e}"))?;
}
// Frames may arrive out of order; hold stragglers until their turn.
let mut pending: HashMap<usize, gif::Frame<'static>> = HashMap::new();
let mut next = 0usize;
let mut written = 0usize;
while let Ok((idx, frame)) = result_rx.recv() {
if writer_cancel.load(Ordering::Relaxed) {
break;
}
pending.insert(idx, frame);
while let Some(f) = pending.remove(&next) {
encoder
.write_frame(&f)
.map_err(|e| format!("GIF write_frame failed: {e}"))?;
next += 1;
written += 1;
let _ = writer_progress.send(ExportProgress::FrameRendered {
frame: written,
total: total_frames,
});
}
}
// Encoder/BufWriter flush on drop.
Ok(())
});
// Coordinator: pull RGBA frames from the UI thread and dispatch round-robin to the workers.
let mut dispatched = 0usize;
let mut fatal: Option<String> = None;
loop {
match frame_rx.recv() {
Ok(GifFrameMessage::Frame { frame_num, mut pixels }) => {
if cancel_flag.load(Ordering::Relaxed) {
break;
}
if pixels.len() != expected_len {
fatal = Some("GIF frame size mismatch".into());
break;
}
if !transparency {
// Premultiply onto opaque black, then force alpha opaque.
for px in pixels.chunks_exact_mut(4) {
let a = px[3] as u32;
px[0] = (px[0] as u32 * a / 255) as u8;
px[1] = (px[1] as u32 * a / 255) as u8;
px[2] = (px[2] as u32 * a / 255) as u8;
px[3] = 255;
}
}
let w = dispatched % n_workers;
if worker_txs[w].send((frame_num, pixels)).is_err() {
fatal = Some("GIF quantizer worker died".into());
break;
}
dispatched += 1;
}
Ok(GifFrameMessage::Done) => break,
Err(_) => break, // UI thread dropped the sender
}
}
let _ = progress_tx.send(ExportProgress::Finalizing);
// Close worker inputs → workers finish → their result senders drop → writer's loop ends.
drop(worker_txs);
for h in worker_handles {
let _ = h.join();
}
let writer_result = writer.join().unwrap_or_else(|_| Err("GIF writer thread panicked".into()));
if cancel_flag.load(Ordering::Relaxed) {
std::fs::remove_file(&output_path).ok();
// Emit Complete so the UI poll loop clears its state; the dialog was closed on cancel.
let _ = progress_tx.send(ExportProgress::Complete { output_path });
return;
}
match fatal.or_else(|| writer_result.err()) {
Some(message) => {
std::fs::remove_file(&output_path).ok();
let _ = progress_tx.send(ExportProgress::Error { message });
}
None => {
let _ = progress_tx.send(ExportProgress::Complete { output_path });
}
}
}

View File

@ -175,7 +175,9 @@ 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.
/// the packing and BT.709 coefficients stay verifiable without a GPU. Test-only, so it isn't
/// compiled into (and flagged as unused by) release builds.
#[cfg(test)]
fn cpu_reference(rgba: &[u8], width: u32, height: u32, full_range: bool) -> Vec<u8> {
let w = width as usize;
let h = height as usize;

View File

@ -45,13 +45,179 @@ pub fn save_rgba_image(
encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}"))
}
ImageFormat::WebP => {
if allow_transparency {
img.save(path).map_err(|e| format!("WebP save failed: {e}"))
// `image` 0.25's WebP encoder is lossless-only, which ignored the quality slider and
// produced needlessly large files. Encode lossy WebP via ffmpeg's libwebp instead so
// the quality control is real; alpha is preserved (as YUVA420P) when requested.
save_webp_ffmpeg(pixels, width, height, quality, allow_transparency, path)
}
}
}
/// Encode a single frame as lossy WebP via ffmpeg's `libwebp` encoder.
///
/// `quality` is libwebp's 0100 quality factor. When `allow_transparency` is true the source is
/// converted to YUVA420P so libwebp keeps the alpha channel; otherwise it's flattened onto black
/// and converted to YUV420P. Uses swscale's default BT.601 conversion (matching a plain
/// `ffmpeg -i in.png out.webp`).
fn save_webp_ffmpeg(
pixels: &[u8],
width: u32,
height: u32,
quality: u8,
allow_transparency: bool,
path: &Path,
) -> Result<(), String> {
use ffmpeg_next as ffmpeg;
ffmpeg::init().map_err(|e| format!("Failed to initialize ffmpeg: {e}"))?;
let codec = ffmpeg::encoder::find_by_name("libwebp")
.or_else(|| ffmpeg::encoder::find(ffmpeg::codec::Id::WEBP))
.ok_or("libwebp encoder not available in this ffmpeg build")?;
// Flatten onto black up front when alpha isn't wanted, so the source is fully opaque.
let src_rgba: Vec<u8> = if allow_transparency {
pixels.to_vec()
} else {
let flat = flatten_alpha(img);
flat.save(path).map_err(|e| format!("WebP save failed: {e}"))
let mut v = pixels.to_vec();
for px in v.chunks_exact_mut(4) {
let a = px[3] as u32;
px[0] = (px[0] as u32 * a / 255) as u8;
px[1] = (px[1] as u32 * a / 255) as u8;
px[2] = (px[2] as u32 * a / 255) as u8;
px[3] = 255;
}
v
};
let dst_pix = if allow_transparency {
ffmpeg::format::Pixel::YUVA420P
} else {
ffmpeg::format::Pixel::YUV420P
};
// RGBA → YUV(A)420P (swscale defaults: BT.601, limited range — what libwebp expects).
let mut scaler = ffmpeg::software::scaling::Context::get(
ffmpeg::format::Pixel::RGBA, width, height,
dst_pix, width, height,
ffmpeg::software::scaling::Flags::BILINEAR,
)
.map_err(|e| format!("Failed to create swscale context: {e}"))?;
let mut src = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
// Copy row-by-row honoring the frame's stride (may exceed width*4 due to alignment padding).
let stride = src.stride(0);
let row_bytes = (width * 4) as usize;
{
let dst = src.data_mut(0);
for y in 0..height as usize {
let s = y * row_bytes;
let d = y * stride;
dst[d..d + row_bytes].copy_from_slice(&src_rgba[s..s + row_bytes]);
}
}
let mut yuv = ffmpeg::frame::Video::new(dst_pix, width, height);
scaler.run(&src, &mut yuv).map_err(|e| format!("swscale conversion failed: {e}"))?;
yuv.set_pts(Some(0));
let mut octx = ffmpeg::format::output(&path)
.map_err(|e| format!("Failed to create WebP output: {e}"))?;
let mut enc = ffmpeg::codec::Context::new_with_codec(codec)
.encoder()
.video()
.map_err(|e| format!("Failed to create WebP encoder: {e}"))?;
enc.set_width(width);
enc.set_height(height);
enc.set_format(dst_pix);
enc.set_time_base(ffmpeg::Rational(1, 1));
// libwebp private options: quality 0100, lossy.
let mut opts = ffmpeg::Dictionary::new();
opts.set("quality", &quality.to_string());
opts.set("lossless", "0");
let mut enc = enc
.open_with(opts)
.map_err(|e| format!("Failed to open libwebp encoder: {e}"))?;
{
let mut stream = octx.add_stream(codec)
.map_err(|e| format!("Failed to add WebP stream: {e}"))?;
stream.set_parameters(&enc);
stream.set_time_base(ffmpeg::Rational(1, 1));
}
octx.write_header().map_err(|e| format!("Failed to write WebP header: {e}"))?;
enc.send_frame(&yuv).map_err(|e| format!("Failed to send WebP frame: {e}"))?;
enc.send_eof().map_err(|e| format!("Failed to flush WebP encoder: {e}"))?;
let mut packet = ffmpeg::Packet::empty();
while enc.receive_packet(&mut packet).is_ok() {
packet.set_stream(0);
packet
.write_interleaved(&mut octx)
.map_err(|e| format!("Failed to write WebP packet: {e}"))?;
}
octx.write_trailer().map_err(|e| format!("Failed to finalize WebP: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use lightningbeam_core::export::ImageFormat;
/// A gradient RGBA image so the encoder has real content to quantize/compress.
fn gradient(width: u32, height: u32) -> Vec<u8> {
let mut px = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
px.push((x * 255 / width.max(1)) as u8);
px.push((y * 255 / height.max(1)) as u8);
px.push(128);
px.push(255);
}
}
px
}
/// The ffmpeg libwebp path must produce a valid *lossy* WebP (RIFF/WEBP container with a
/// `VP8 ` chunk — lossless would be `VP8L`), and the quality knob must actually change size.
#[test]
fn webp_export_is_real_lossy() {
let (w, h) = (96u32, 64u32);
let px = gradient(w, h);
let dir = std::env::temp_dir();
let lo = dir.join("lb_webp_q10_test.webp");
let hi = dir.join("lb_webp_q95_test.webp");
save_webp_ffmpeg(&px, w, h, 10, false, &lo).expect("low-quality webp encode");
save_webp_ffmpeg(&px, w, h, 95, false, &hi).expect("high-quality webp encode");
let lo_bytes = std::fs::read(&lo).unwrap();
let hi_bytes = std::fs::read(&hi).unwrap();
// RIFF....WEBP container.
assert_eq!(&lo_bytes[0..4], b"RIFF", "not a RIFF container");
assert_eq!(&lo_bytes[8..12], b"WEBP", "not a WEBP file");
// Lossy VP8 chunk (`VP8 ` with trailing space), NOT lossless `VP8L`.
assert_eq!(&lo_bytes[12..16], b"VP8 ", "expected lossy VP8, got {:?}", &lo_bytes[12..16]);
// The quality knob is honored: q10 is meaningfully smaller than q95.
assert!(lo_bytes.len() < hi_bytes.len(),
"quality ignored: q10 {} bytes >= q95 {} bytes", lo_bytes.len(), hi_bytes.len());
std::fs::remove_file(&lo).ok();
std::fs::remove_file(&hi).ok();
}
/// The format enum still advertises a quality control for WebP (now that it works).
#[test]
fn webp_has_quality() {
assert!(ImageFormat::WebP.has_quality());
assert!(ImageFormat::Jpeg.has_quality());
assert!(!ImageFormat::Png.has_quality());
}
}

View File

@ -3,8 +3,8 @@
//! This module provides the export orchestrator and progress tracking
//! for exporting audio and video from the timeline.
pub mod audio_exporter;
pub mod dialog;
pub mod gif_exporter;
pub mod image_exporter;
pub mod video_exporter;
pub mod readback_pipeline;
@ -13,7 +13,8 @@ pub mod cpu_yuv_converter;
pub mod gpu_yuv;
pub mod hdr_frame;
use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress};
use lightningbeam_core::export::{AudioExportSettings, GifExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress};
use gif_exporter::GifFrameMessage;
use lightningbeam_core::document::Document;
use lightningbeam_core::renderer::ImageCache;
use lightningbeam_core::video::VideoManager;
@ -68,6 +69,11 @@ pub struct VideoExportState {
readback_pipeline: Option<readback_pipeline::ReadbackPipeline>,
/// CPU YUV converter for RGBA→YUV420p conversion
cpu_yuv_converter: Option<cpu_yuv_converter::CpuYuvConverter>,
/// ProRes 422 export: forces the CPU (RGBA) readback path and converts to 10-bit 4:2:2 instead
/// of 8-bit 4:2:0. `true` only for `VideoCodec::ProRes422` (SDR).
prores: bool,
/// CPU RGBA→YUV422P10LE converter, used only on the ProRes path.
cpu_yuv422p10: Option<cpu_yuv_converter::CpuYuv422P10Converter>,
/// Frames that have been submitted to GPU but not yet encoded
frames_in_flight: usize,
/// Next frame number to send to encoder (for ordering)
@ -131,6 +137,38 @@ pub struct ExportOrchestrator {
/// Single-frame image export state
image_state: Option<ImageExportState>,
/// Animated GIF export state (frames rendered on the UI thread, encoded on `thread_handle`).
gif_state: Option<GifExportState>,
}
/// State for an in-progress animated GIF export. Frames are rendered + read back on the UI thread
/// (one per `render_next_gif_frame` call) and streamed to the encoder thread over `frame_tx`.
struct GifExportState {
/// Resolved pixel dimensions (after applying any width/height overrides).
width: u32,
height: u32,
/// Total frames to render.
total_frames: usize,
/// Next frame index to render (0-based).
next_frame: usize,
/// Document time (seconds) of frame 0.
start_time: f64,
/// Seconds between frames (1 / framerate).
frame_step: f64,
/// How the document is fit into the export frame.
fit: lightningbeam_core::export::ExportFitMode,
/// Preserve alpha as GIF transparency (else the encoder flattens onto black).
transparency: bool,
/// GPU resources allocated on the first render call, reused each frame.
gpu_resources: Option<video_exporter::ExportGpuResources>,
/// Output RGBA texture (kept separate from gpu_resources to avoid split-borrow issues).
output_texture: Option<wgpu::Texture>,
output_texture_view: Option<wgpu::TextureView>,
/// Staging buffer for synchronous GPU→CPU readback (reused each frame).
staging_buffer: Option<wgpu::Buffer>,
/// Sender to the encoder thread; dropped after the final frame to signal completion.
frame_tx: Option<Sender<GifFrameMessage>>,
}
/// State for parallel audio+video export
@ -168,6 +206,7 @@ impl ExportOrchestrator {
video_state: None,
parallel_export: None,
image_state: None,
gif_state: None,
}
}
@ -250,7 +289,7 @@ impl ExportOrchestrator {
/// unconsumed terminal message). Used to gate the UI poll loop so it doesn't run every
/// repaint forever after an export finishes.
pub fn has_pending_progress(&self) -> bool {
self.parallel_export.is_some() || self.image_state.is_some() || self.progress_rx.is_some()
self.parallel_export.is_some() || self.image_state.is_some() || self.gif_state.is_some() || self.progress_rx.is_some()
}
/// Poll progress for parallel video+audio export
@ -534,6 +573,9 @@ impl ExportOrchestrator {
}
self.video_state = None;
self.image_state = None;
// Dropping gif_state drops its frame sender, unblocking the encoder thread's recv() so it
// observes the cancel flag, removes the partial file, and exits.
self.gif_state = None;
self.progress_rx = None;
self.thread_handle = None;
}
@ -542,6 +584,7 @@ impl ExportOrchestrator {
pub fn is_exporting(&self) -> bool {
if self.parallel_export.is_some() { return true; }
if self.image_state.is_some() { return true; }
if self.gif_state.is_some() { return true; }
if let Some(handle) = &self.thread_handle {
!handle.is_finished()
} else {
@ -719,6 +762,196 @@ impl ExportOrchestrator {
result.map(|_| true)
}
/// Enqueue an animated GIF export. Spawns the encoder thread now; call `render_next_gif_frame()`
/// from the egui update loop (where the wgpu device/queue are available) to render + stream each
/// frame to it.
pub fn start_gif_export(
&mut self,
settings: GifExportSettings,
output_path: PathBuf,
doc_width: u32,
doc_height: u32,
) {
self.cancel_flag.store(false, Ordering::Relaxed);
let width = settings.width.unwrap_or(doc_width).max(1);
let height = settings.height.unwrap_or(doc_height).max(1);
let total_frames = settings.total_frames();
let delay_ms = settings.frame_delay_ms();
let frame_step = 1.0 / settings.framerate;
let (progress_tx, progress_rx) = channel();
let (frame_tx, frame_rx) = channel();
self.progress_rx = Some(progress_rx);
let cancel_flag = Arc::clone(&self.cancel_flag);
let loop_forever = settings.loop_forever;
let transparency = settings.transparency;
let handle = std::thread::spawn(move || {
gif_exporter::run_gif_encoder(
frame_rx, output_path, width, height, total_frames, delay_ms,
loop_forever, transparency, progress_tx, cancel_flag,
);
});
self.thread_handle = Some(handle);
self.gif_state = Some(GifExportState {
width,
height,
total_frames,
next_frame: 0,
start_time: settings.start_time,
frame_step,
fit: settings.fit,
transparency,
gpu_resources: None,
output_texture: None,
output_texture_view: None,
staging_buffer: None,
frame_tx: Some(frame_tx),
});
}
/// Drive the animated GIF export: render + read back one frame per call and stream it to the
/// encoder thread. Returns `Ok(true)` while more frames remain (call again next egui frame),
/// `Ok(false)` once every frame has been sent (encoding then finishes on the background thread).
pub fn render_next_gif_frame(
&mut self,
document: &mut Document,
device: &wgpu::Device,
queue: &wgpu::Queue,
renderer: &mut vello::Renderer,
image_cache: &mut ImageCache,
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
raster_store: Option<&lightningbeam_core::raster_store::RasterStore>,
) -> Result<bool, String> {
if self.cancel_flag.load(Ordering::Relaxed) {
// Dropping frame_tx unblocks the encoder thread's recv() so it can clean up.
self.gif_state = None;
return Ok(false);
}
let state = match self.gif_state.as_mut() {
Some(s) => s,
None => return Ok(false),
};
// All frames sent → drop the sender (signals the encoder to finalize) and finish.
if state.next_frame >= state.total_frames {
if let Some(tx) = state.frame_tx.take() {
let _ = tx.send(GifFrameMessage::Done);
drop(tx);
}
self.gif_state = None;
return Ok(false);
}
let w = state.width;
let h = state.height;
let fit = state.fit;
let timestamp = state.start_time + state.next_frame as f64 * state.frame_step;
if state.gpu_resources.is_none() {
state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, w, h));
}
if state.output_texture.is_none() {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("gif_export_output"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
state.output_texture_view = Some(tex.create_view(&wgpu::TextureViewDescriptor::default()));
state.output_texture = Some(tex);
}
// Render the frame (transparency preserved through readback; the encoder flattens if needed).
{
let gpu = state.gpu_resources.as_mut().unwrap();
let output_view = state.output_texture_view.as_ref().unwrap();
let encoder = video_exporter::render_frame_to_gpu_rgba(
document,
timestamp,
w, h,
device, queue, renderer, image_cache, video_manager,
gpu,
output_view,
None, // no floating raster selection during export
state.transparency,
raster_store,
true, // GIF export composites on the shared device
fit,
)?;
queue.submit(Some(encoder.finish()));
}
// Synchronous readback (wgpu requires bytes_per_row aligned to 256).
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let bytes_per_row = (w * 4 + align - 1) / align * align;
if state.staging_buffer.is_none() {
state.staging_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("gif_export_staging"),
size: (bytes_per_row * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}));
}
let staging = state.staging_buffer.as_ref().unwrap();
let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gif_export_copy"),
});
let output_tex = state.output_texture.as_ref().unwrap();
copy_enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: output_tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: staging,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
queue.submit(Some(copy_enc.finish()));
let slice = staging.slice(..);
slice.map_async(wgpu::MapMode::Read, |_| {});
let _ = device.poll(wgpu::PollType::wait_indefinitely());
let pixels: Vec<u8> = {
let mapped = slice.get_mapped_range();
let mut out = Vec::with_capacity((w * h * 4) as usize);
for row in 0..h {
let start = (row * bytes_per_row) as usize;
out.extend_from_slice(&mapped[start..start + (w * 4) as usize]);
}
out
};
staging.unmap();
let frame_num = state.next_frame;
if let Some(tx) = state.frame_tx.as_ref() {
// If the encoder thread died, stop — its dropped receiver returns an error here.
if tx.send(GifFrameMessage::Frame { frame_num, pixels }).is_err() {
self.gif_state = None;
return Ok(false);
}
}
state.next_frame += 1;
Ok(true)
}
/// Wait for the export to complete
///
/// This blocks until the export thread finishes.
@ -774,6 +1007,12 @@ impl ExportOrchestrator {
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
metadata: settings
.metadata
.pairs()
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
};
// Use DAW backend export for all formats
@ -872,6 +1111,8 @@ impl ExportOrchestrator {
let hdr = settings.hdr;
let fit = settings.fit;
let full_range = settings.color_range.is_full();
let prores = matches!(settings.codec, lightningbeam_core::export::VideoCodec::ProRes422)
&& !hdr.is_hdr();
let handle = std::thread::spawn(move || {
Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames);
});
@ -891,6 +1132,8 @@ impl ExportOrchestrator {
gpu_resources: None,
readback_pipeline: None,
cpu_yuv_converter: None,
prores,
cpu_yuv422p10: None,
frames_in_flight: 0,
next_frame_to_encode: 0,
perf_metrics: Some(perf_metrics::ExportMetrics::new()),
@ -1121,7 +1364,10 @@ impl ExportOrchestrator {
.unwrap()
.as_secs();
let temp_video_path = temp_dir.join(format!("lightningbeam_video_{}.mp4", timestamp));
// Use the codec's real container for the temp video, not a hardcoded .mp4 — VP8 isn't a
// valid MP4 codec, so an .mp4 temp made `write_header` fail for any VP8+audio export.
let temp_video_path = temp_dir.join(format!("lightningbeam_video_{}.{}",
timestamp, video_settings.codec.container_format()));
let temp_audio_path = temp_dir.join(format!("lightningbeam_audio_{}.{}",
timestamp,
match audio_settings.format {
@ -1331,24 +1577,34 @@ impl ExportOrchestrator {
// Enable GPU YUV only when the encoder's YUV420P planes are tight (no linesize
// padding) — then the packed GPU planes copy in without row misalignment.
// Otherwise fall back to RGBA readback + CPU swscale.
let gpu_yuv_tight = std::env::var("LB_DISABLE_GPU_YUV").is_err() && {
// ProRes needs 10-bit 4:2:2 (built on the CPU from the RGBA readback), so it forces the
// RGBA path — the GPU YUV converter only produces 8-bit 4:2:0.
let gpu_yuv_tight = !state.prores && std::env::var("LB_DISABLE_GPU_YUV").is_err() && {
let probe = ffmpeg_next::frame::Video::new(
ffmpeg_next::format::Pixel::YUV420P, width, height,
);
probe.stride(0) == width as usize && probe.stride(1) == (width / 2) as usize
};
if !gpu_yuv_tight {
if !gpu_yuv_tight && !state.prores {
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.full_range));
if state.prores {
state.cpu_yuv422p10 = Some(cpu_yuv_converter::CpuYuv422P10Converter::new(width, height, state.full_range)?);
println!("🎬 [VIDEO EXPORT] ProRes 422: 10-bit 4:2:2 (YUV422P10LE) CPU converter initialized");
} else {
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");
}
let pipeline = state.readback_pipeline.as_mut().unwrap();
let gpu_resources = state.gpu_resources.as_mut().unwrap();
let cpu_converter = state.cpu_yuv_converter.as_mut().unwrap();
// Exactly one of these is present: cpu_yuv422p10 on the ProRes path, cpu_converter on the
// SDR fallback path (or neither is used when the GPU YUV converter is active).
let mut cpu_converter = state.cpu_yuv_converter.as_mut();
let mut cpu_yuv422p10 = state.cpu_yuv422p10.as_mut();
let mut metrics = state.perf_metrics.as_mut();
// Poll for completed async readbacks (non-blocking)
@ -1375,12 +1631,17 @@ impl ExportOrchestrator {
let data = pipeline.extract_rgba_data(result.buffer_id);
let extraction_end = Instant::now();
// YUV planes: GPU-converted (just slice) or CPU swscale fallback (timed).
// YUV planes: ProRes 10-bit 4:2:2, else GPU-converted (just slice), else CPU
// swscale 8-bit 4:2:0 fallback (timed).
let conversion_start = Instant::now();
let (y, u, v) = if pipeline.is_yuv_mode() {
let (y, u, v) = if let Some(conv) = cpu_yuv422p10.as_deref_mut() {
conv.convert(&data)?
} else if pipeline.is_yuv_mode() {
pipeline.split_yuv(&data)
} else {
cpu_converter.convert(&data)?
cpu_converter.as_deref_mut()
.ok_or("SDR export missing its CPU YUV converter")?
.convert(&data)?
};
let conversion_end = Instant::now();
@ -1469,6 +1730,7 @@ impl ExportOrchestrator {
state.gpu_resources = None;
state.readback_pipeline = None;
state.cpu_yuv_converter = None;
state.cpu_yuv422p10 = None;
state.perf_metrics = None;
return Ok(false);
}
@ -1718,6 +1980,8 @@ impl ExportOrchestrator {
// Pixel format the encoder frames are built in (matches setup_video_encoder).
let pixel_format = if settings.hdr.is_hdr() {
ffmpeg_next::format::Pixel::YUV420P10LE
} else if matches!(settings.codec, VideoCodec::ProRes422) {
ffmpeg_next::format::Pixel::YUV422P10LE // ProRes 422: 10-bit 4:2:2
} else {
ffmpeg_next::format::Pixel::YUV420P
};
@ -1832,8 +2096,17 @@ impl ExportOrchestrator {
// Copy each plane row-by-row honoring the frame's stride (10-bit / arbitrary widths can have
// row padding that a flat copy would misalign). `bytes_per_row` = samples × sample size.
let ten_bit = matches!(pixel_format, ffmpeg_next::format::Pixel::YUV420P10LE);
let sample_bytes = if ten_bit { 2usize } else { 1usize };
// Sample size + chroma subsampling depend on the pixel format:
// YUV420P → 8-bit, 4:2:0 (chroma = w/2 × h/2)
// YUV420P10LE → 10-bit, 4:2:0 (chroma = w/2 × h/2)
// YUV422P10LE → 10-bit, 4:2:2 (chroma = w/2 × h, full-height) [ProRes]
use ffmpeg_next::format::Pixel;
let (sample_bytes, chroma_h_div) = match pixel_format {
Pixel::YUV420P => (1usize, 2usize),
Pixel::YUV420P10LE => (2usize, 2usize),
Pixel::YUV422P10LE => (2usize, 1usize),
_ => (1usize, 2usize),
};
let copy_plane = |frame: &mut ffmpeg_next::frame::Video, idx: usize, src: &[u8], w: usize, h: usize| {
let bytes_per_row = w * sample_bytes;
let stride = frame.stride(idx);
@ -1848,8 +2121,8 @@ impl ExportOrchestrator {
};
let (w, h) = (width as usize, height as usize);
copy_plane(&mut video_frame, 0, y_plane, w, h);
copy_plane(&mut video_frame, 1, u_plane, w / 2, h / 2);
copy_plane(&mut video_frame, 2, v_plane, w / 2, h / 2);
copy_plane(&mut video_frame, 1, u_plane, w / 2, h / chroma_h_div);
copy_plane(&mut video_frame, 2, v_plane, w / 2, h / chroma_h_div);
// Set PTS (presentation timestamp) in encoder's time base
// Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000)

View File

@ -616,9 +616,12 @@ pub fn setup_video_encoder(
// Configure encoder parameters BEFORE opening (critical!)
encoder.set_width(aligned_width);
encoder.set_height(aligned_height);
// HDR encodes 10-bit BT.2020 (limited range); SDR keeps 8-bit full-range BT.709.
// ProRes needs 10-bit 4:2:2; HDR needs 10-bit 4:2:0 BT.2020; other SDR is 8-bit 4:2:0.
let is_prores = codec_id == ffmpeg::codec::Id::PRORES;
if hdr.is_hdr() {
encoder.set_format(ffmpeg::format::Pixel::YUV420P10LE);
} else if is_prores {
encoder.set_format(ffmpeg::format::Pixel::YUV422P10LE);
} else {
encoder.set_format(ffmpeg::format::Pixel::YUV420P);
}
@ -650,6 +653,10 @@ pub fn setup_video_encoder(
});
color_opts.set("color_primaries", "bt709");
color_opts.set("color_trc", "bt709");
if is_prores {
// prores_ks profile: 3 = HQ (4:2:2 10-bit). Matches the YUV422P10LE frames we feed.
color_opts.set("profile", "3");
}
}
println!("📐 Video dimensions: {}×{} (aligned to {}×{}){}",
@ -1433,6 +1440,30 @@ mod tests {
assert!(v[0] > 128, "V value: {}", v[0]);
}
/// ProRes must actually open with the 10-bit 4:2:2 format we now feed it. Before the fix the
/// SDR path handed prores_ks 8-bit YUV420P and `open` failed every time — so this opening
/// successfully is the regression guard for "ProRes export always errored".
#[test]
fn prores_encoder_opens_with_yuv422p10() {
ffmpeg::init().unwrap();
// Skip cleanly if this ffmpeg build lacks a ProRes encoder (rather than false-fail).
if ffmpeg::encoder::find(ffmpeg::codec::Id::PRORES).is_none()
&& ffmpeg::encoder::find_by_name("prores_ks").is_none()
{
eprintln!("prores encoder not present in this ffmpeg build; skipping");
return;
}
let r = setup_video_encoder(
ffmpeg::codec::Id::PRORES,
640, 480, 30.0, 20_000,
lightningbeam_core::export::HdrExportMode::Sdr,
false,
);
assert!(r.is_ok(), "ProRes encoder failed to open: {:?}", r.err());
let (encoder, _codec) = r.unwrap();
assert_eq!(encoder.format(), ffmpeg::format::Pixel::YUV422P10LE);
}
// NOTE: `rgba_to_yuv420p` rounds dimensions up to multiples of 16 (H.264
// macroblock alignment), so its plane lengths are the aligned sizes, not the
// tight input dimensions. The former `test_rgba_to_yuv420p_dimensions` and

View File

@ -1735,10 +1735,39 @@ impl EditorApp {
);
}
/// Tear down the audio backend for the currently-open project.
///
/// Sends `Command::Reset` (fully rebuilds the backend `Project`, audio/buffer pools, and ID
/// counters) and clears the app-side track maps + backend-derived caches that pointed at the old
/// tracks. Must be called before building a new document's tracks, otherwise the previous file's
/// tracks/instruments stay resident in the backend and keep getting mixed (orphaned voices).
///
/// Ordering is safe: the audio thread drains all `command_tx` commands before any `query_tx`
/// queries each callback, so a `reset()` pushed here always runs before the `create_*_track_sync`
/// queries that rebuild the project.
fn reset_audio_backend(&mut self) {
if let Some(ref controller_arc) = self.audio_controller {
controller_arc.lock().unwrap().reset();
}
self.layer_to_track_map.clear();
self.track_to_layer_map.clear();
self.clip_instance_to_backend_map.clear();
self.midi_event_cache.clear();
self.audio_duration_cache.clear();
self.raw_audio_cache.clear();
self.waveform_gpu_dirty.clear();
self.waveform_minmax_pools.clear();
self.waveform_pyramid_blobs.clear();
}
/// Create a new project with the specified focus/layout
fn create_new_project_with_focus(&mut self, layout_index: usize) {
use lightningbeam_core::layer::{AnyLayer, AudioLayer, VectorLayer, VideoLayer};
// Drop the previous project's backend tracks/instruments before building the new one, so a
// "new file" while a project is open doesn't leave orphaned tracks resident in the backend.
self.reset_audio_backend();
// Create a new blank document
let mut document = lightningbeam_core::document::Document::with_size(
"Untitled",
@ -3185,23 +3214,17 @@ impl EditorApp {
println!("Menu: New File");
// TODO: Prompt to save current file if modified
// Reset state and return to start screen
self.layer_to_track_map.clear();
self.track_to_layer_map.clear();
self.layer_to_track_map.clear();
self.clip_instance_to_backend_map.clear();
// Tear down the backend (stops old instruments/voices immediately) and clear the
// app-side track maps + backend-derived caches.
self.reset_audio_backend();
// Reset UI state and return to start screen
self.current_file_path = None;
self.selection.clear();
self.editing_context = EditingContext::default();
self.active_layer_id = None;
self.playback_time = 0.0;
self.is_playing = false;
self.midi_event_cache.clear();
self.audio_duration_cache.clear();
self.raw_audio_cache.clear();
self.waveform_gpu_dirty.clear();
self.waveform_minmax_pools.clear();
self.waveform_pyramid_blobs.clear();
self.pane_instances.clear();
self.project_generation += 1;
self.app_mode = AppMode::StartScreen;
@ -3420,7 +3443,13 @@ impl EditorApp {
h
};
self.export_dialog.open(timeline_endpoint, &project_name, &hint);
self.export_dialog.open(
timeline_endpoint,
&project_name,
&hint,
&self.config.last_audio_artist,
&self.config.last_audio_album,
);
}
MenuAction::Quit => {
println!("Menu: Quit");
@ -4146,6 +4175,12 @@ impl EditorApp {
// TODO Phase 5: Show recovery dialog
}
// Tear down the previously-open project's backend tracks/instruments before restoring this
// file's audio pool + tracks, so an open-over-open doesn't leave orphaned tracks resident in
// the backend. Reset is a command; the audio-pool/track restoration below uses queries, which
// the audio thread drains after all commands each callback, so the ordering holds.
self.reset_audio_backend();
// Replace document
let step1_start = std::time::Instant::now();
self.action_executor = ActionExecutor::new(loaded_project.document);
@ -6326,9 +6361,29 @@ impl eframe::App for EditorApp {
}
false // synchronous; no progress dialog
}
ExportResult::Gif(settings, output_path) => {
println!("🎞 [MAIN] Starting GIF export: {}", output_path.display());
let doc = self.action_executor.document();
orchestrator.start_gif_export(
settings,
output_path,
doc.width as u32,
doc.height as u32,
);
true // background encode with progress dialog
}
ExportResult::AudioOnly(settings, output_path) => {
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());
// Remember Artist/Album so they prefill next time.
if !settings.metadata.artist.is_empty() {
self.config.last_audio_artist = settings.metadata.artist.clone();
}
if !settings.metadata.album.is_empty() {
self.config.last_audio_album = settings.metadata.album.clone();
}
self.config.save();
if let Some(audio_controller) = &self.audio_controller {
orchestrator.start_audio_export(
settings,
@ -6480,6 +6535,21 @@ impl eframe::App for EditorApp {
}
}
// Drive incremental GIF export (one frame rendered + streamed per call).
match orchestrator.render_next_gif_frame(
self.action_executor.document_mut(),
device,
queue,
renderer,
image_cache,
&self.video_manager,
Some(&self.raster_store),
) {
Ok(true) => { ctx.request_repaint(); } // more frames to render
Ok(false) => {} // done or not a GIF export
Err(e) => { eprintln!("GIF export failed: {e}"); }
}
// Drive single-frame image export (two-frame async: render then readback).
match orchestrator.render_image_frame(
self.action_executor.document_mut(),

View File

@ -40,7 +40,7 @@ const TOPBAR_H: f32 = 40.0;
/// Clamp a desktop dialog width to fit the current screen (with side margins). A no-op on wide
/// desktop screens (`min` keeps the desired width); on a phone-aspect window it shrinks to fit.
pub fn dialog_width(ctx: &egui::Context, desired: f32) -> f32 {
let avail = ctx.screen_rect().width() - 24.0;
let avail = ctx.content_rect().width() - 24.0;
desired.min(avail.max(200.0))
}

View File

@ -36,8 +36,6 @@ pub struct MobileNodeState {
pub mode: NodeViewMode,
/// The module currently shown in Focus (and centred in Patch).
pub focus_node: Option<NodeId>,
/// Armed cable source in Patch: (node, output-port index).
pub patch_source: Option<(NodeId, usize)>,
/// Whether the add-node picker overlay is open.
pub show_add: bool,
/// Search filter in the add-node picker.
@ -51,7 +49,6 @@ impl Default for MobileNodeState {
Self {
mode: NodeViewMode::Focus,
focus_node: None,
patch_source: None,
show_add: false,
add_search: String::new(),
patch_pick: None,

View File

@ -165,7 +165,7 @@ impl PreferencesDialog {
// mobile modals; on desktop, the familiar draggable window.
let width = crate::mobile::dialog_width(ctx, 550.0);
let scroll_h = if mobile {
(ctx.screen_rect().height() - 220.0).clamp(160.0, 400.0)
(ctx.content_rect().height() - 220.0).clamp(160.0, 400.0)
} else {
400.0
};