Compare commits
15 Commits
feat/mobil
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
b6f43d2e72 | |
|
|
8fbb6d65c0 | |
|
|
6aece26a8b | |
|
|
ef2b0822bd | |
|
|
5f09222f3f | |
|
|
b0e965b1c2 | |
|
|
6596acb3db | |
|
|
d6b86a14b1 | |
|
|
a2839f80b1 | |
|
|
6e6feaddf5 | |
|
|
15bdf80ec1 | |
|
|
6b8a1f1386 | |
|
|
53ffb7d528 | |
|
|
c373af461e | |
|
|
6e361aa30c |
20
Changelog.md
20
Changelog.md
|
|
@ -1,3 +1,23 @@
|
||||||
|
# 1.0.8-alpha:
|
||||||
|
Changes:
|
||||||
|
- Mobile/touch UI (experimental, testing only — not built or packaged for mobile yet; enabled on desktop with the LB_MOBILE_UI environment variable): work-in-progress phone-friendly interface with a vertical sliding-window pane stack you drag to reveal panes, a new-file intent picker, a selection inspector sheet, a keyboard-primary music surface, a Focus/Patch node editor, long-press context menus, a command palette, and landscape/orientation support
|
||||||
|
- Text layers: add and edit text with a chosen font; non-bundled fonts are embedded in the project so it renders on machines that lack them
|
||||||
|
- Animated GIF export (parallel palette encoding)
|
||||||
|
- Audio tag metadata (title, artist, album, genre, year, track, comment) is written into exports — ID3v2 for MP3, iTunes/MP4 atoms for AAC, Vorbis comments for FLAC, RIFF INFO for WAV — with sensible defaults (year, artist/album remembered between exports)
|
||||||
|
- Lossy WebP image export, so the quality control now actually applies
|
||||||
|
- SVG export now includes text layers, as real font-independent glyph outlines
|
||||||
|
- Crash recovery: the editor autosaves your work to a recovery file in the background and offers to restore it after an unclean shutdown
|
||||||
|
- Prompt to save unsaved changes before starting a new file, opening another, or quitting
|
||||||
|
- Faster saves on painting projects: unchanged raster frames are no longer re-encoded every save
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- FLAC export previously wrote a WAV file with a .flac extension; it now encodes real (compressed) FLAC
|
||||||
|
- ProRes 422 export always failed; it now encodes 10-bit 4:2:2 correctly
|
||||||
|
- Fix VP8 video export with audio (muxed into WebM instead of an incompatible container)
|
||||||
|
- The WebP "Quality" slider had no effect
|
||||||
|
- Starting a new file now fully resets the audio engine, so instruments and voices from the previous project no longer linger
|
||||||
|
- Fix oscillator/synth phase drift over long playback
|
||||||
|
|
||||||
# 1.0.7-alpha:
|
# 1.0.7-alpha:
|
||||||
Changes:
|
Changes:
|
||||||
- HDR video support: PQ/HLG/BT.2020 video is now read correctly (decoded to scene-linear), with a per-document output mode (clip vs highlight rolloff) and 10-bit HDR export (HEVC Main10, PQ or HLG)
|
- HDR video support: PQ/HLG/BT.2020 video is now read correctly (decoded to scene-linear), with a per-document output mode (clip vs highlight rolloff) and 10-bit HDR export (HEVC Main10, PQ or HLG)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ pub struct ExportSettings {
|
||||||
pub end_time: Seconds,
|
pub end_time: Seconds,
|
||||||
/// Tempo map for beat-position scheduling
|
/// Tempo map for beat-position scheduling
|
||||||
pub tempo_map: TempoMap,
|
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 {
|
impl Default for ExportSettings {
|
||||||
|
|
@ -63,10 +67,26 @@ impl Default for ExportSettings {
|
||||||
start_time: Seconds::ZERO,
|
start_time: Seconds::ZERO,
|
||||||
end_time: Seconds(60.0),
|
end_time: Seconds(60.0),
|
||||||
tempo_map: TempoMap::constant(120.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
|
/// Export the project to an audio file
|
||||||
///
|
///
|
||||||
/// This performs offline rendering, processing the entire timeline
|
/// 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.
|
// Route to appropriate export implementation based on format.
|
||||||
// Ensure export mode is disabled even if an error occurs.
|
// Ensure export mode is disabled even if an error occurs.
|
||||||
let result = match settings.format {
|
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))?;
|
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 {
|
if let Some(ref mut tx) = event_tx {
|
||||||
let _ = tx.push(AudioEvent::ExportFinalizing);
|
let _ = tx.push(AudioEvent::ExportFinalizing);
|
||||||
}
|
}
|
||||||
match settings.format {
|
write_wav(&samples, settings, &output_path)
|
||||||
ExportFormat::Wav => write_wav(&samples, settings, &output_path),
|
// hound writes no metadata; append a RIFF INFO chunk for tags.
|
||||||
ExportFormat::Flac => write_flac(&samples, settings, &output_path),
|
.and_then(|_| append_wav_info_chunk(output_path.as_ref(), &settings.metadata))
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
}
|
||||||
|
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 => {
|
ExportFormat::Mp3 => {
|
||||||
export_mp3(project, pool, settings, output_path, event_tx)
|
export_mp3(project, pool, settings, output_path, event_tx)
|
||||||
|
|
@ -273,48 +297,174 @@ fn write_wav<P: AsRef<Path>>(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write FLAC file using hound (FLAC is essentially lossless WAV)
|
/// Export real FLAC via ffmpeg from already-rendered interleaved f32 samples (Vorbis-comment
|
||||||
fn write_flac<P: AsRef<Path>>(
|
/// 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],
|
samples: &[f32],
|
||||||
settings: &ExportSettings,
|
settings: &ExportSettings,
|
||||||
output_path: P,
|
output_path: P,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// For now, we'll use hound to write a WAV-like FLAC file
|
use ffmpeg_next as ffmpeg;
|
||||||
// In the future, we could use a dedicated FLAC encoder
|
|
||||||
let spec = hound::WavSpec {
|
ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
|
||||||
channels: settings.channels as u16,
|
|
||||||
sample_rate: settings.sample_rate,
|
let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::FLAC)
|
||||||
bits_per_sample: settings.bit_depth,
|
.ok_or("FLAC encoder not found in this ffmpeg build")?;
|
||||||
sample_format: hound::SampleFormat::Int,
|
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)
|
// FLAC accepts packed S16 or S32; S32 → 24-bit output.
|
||||||
.map_err(|e| format!("Failed to create FLAC file: {}", e))?;
|
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)
|
let mut encoder = ffmpeg::codec::Context::new_with_codec(codec)
|
||||||
match settings.bit_depth {
|
.encoder()
|
||||||
16 => {
|
.audio()
|
||||||
for &sample in samples {
|
.map_err(|e| format!("Failed to create FLAC encoder: {}", e))?;
|
||||||
let clamped = sample.max(-1.0).min(1.0);
|
encoder.set_rate(settings.sample_rate as i32);
|
||||||
let pcm_value = (clamped * 32767.0) as i16;
|
encoder.set_channel_layout(channel_layout);
|
||||||
writer.write_sample(pcm_value)
|
encoder.set_format(sample_fmt);
|
||||||
.map_err(|e| format!("Failed to write sample: {}", e))?;
|
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 => {
|
} else {
|
||||||
for &sample in samples {
|
for i in 0..n * channels {
|
||||||
let clamped = sample.max(-1.0).min(1.0);
|
let s = samples[base + i].clamp(-1.0, 1.0);
|
||||||
let pcm_value = (clamped * 8388607.0) as i32;
|
let v = (s * 32767.0) as i16;
|
||||||
writer.write_sample(pcm_value)
|
buf[i * 2..i * 2 + 2].copy_from_slice(&v.to_le_bytes());
|
||||||
.map_err(|e| format!("Failed to write sample: {}", e))?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)),
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.finalize()
|
encoder.send_frame(&frame).map_err(|e| format!("Failed to send FLAC frame: {}", e))?;
|
||||||
.map_err(|e| format!("Failed to finalize FLAC file: {}", 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,6 +512,7 @@ fn export_mp3<P: AsRef<Path>>(
|
||||||
stream.set_parameters(&encoder);
|
stream.set_parameters(&encoder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply_metadata(&mut output, &settings.metadata);
|
||||||
output.write_header()
|
output.write_header()
|
||||||
.map_err(|e| format!("Failed to write header: {}", e))?;
|
.map_err(|e| format!("Failed to write header: {}", e))?;
|
||||||
|
|
||||||
|
|
@ -531,6 +682,7 @@ fn export_aac<P: AsRef<Path>>(
|
||||||
stream.set_parameters(&encoder);
|
stream.set_parameters(&encoder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply_metadata(&mut output, &settings.metadata);
|
||||||
output.write_header()
|
output.write_header()
|
||||||
.map_err(|e| format!("Failed to write header: {}", e))?;
|
.map_err(|e| format!("Failed to write header: {}", e))?;
|
||||||
|
|
||||||
|
|
@ -838,4 +990,61 @@ mod tests {
|
||||||
assert_eq!(ExportFormat::Wav.extension(), "wav");
|
assert_eq!(ExportFormat::Wav.extension(), "wav");
|
||||||
assert_eq!(ExportFormat::Flac.extension(), "flac");
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3615,6 +3615,7 @@ dependencies = [
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"skrifa 0.43.2",
|
||||||
"tiny-skia",
|
"tiny-skia",
|
||||||
"uuid",
|
"uuid",
|
||||||
"vello",
|
"vello",
|
||||||
|
|
@ -3641,6 +3642,7 @@ dependencies = [
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
"egui_node_graph2",
|
"egui_node_graph2",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
|
"gif",
|
||||||
"gpu-video-encoder",
|
"gpu-video-encoder",
|
||||||
"half",
|
"half",
|
||||||
"image",
|
"image",
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,15 @@ opt-level = 2
|
||||||
[profile.dev.package.cpal]
|
[profile.dev.package.cpal]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
|
# GIF export: NeuQuant palette quantization is tight numeric loops that are punishingly slow
|
||||||
|
# unoptimized (10–30× 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
|
# Use local egui fork with ibus/Wayland text input fix
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
egui = { path = "../../egui-fork/crates/egui" }
|
egui = { path = "../../egui-fork/crates/egui" }
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ vello = { workspace = true }
|
||||||
|
|
||||||
# Text layout/shaping (text layers)
|
# Text layout/shaping (text layers)
|
||||||
parley = { workspace = true }
|
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 decoding for image fills
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,7 @@ impl ActionExecutor {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Move to redo stack
|
// Move to redo stack
|
||||||
self.redo_stack.push(action);
|
self.redo_stack.push(action);
|
||||||
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -271,6 +272,7 @@ impl ActionExecutor {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Move back to undo stack
|
// Move back to undo stack
|
||||||
self.undo_stack.push(action);
|
self.undo_stack.push(action);
|
||||||
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -444,6 +446,7 @@ impl ActionExecutor {
|
||||||
|
|
||||||
// Move to redo stack
|
// Move to redo stack
|
||||||
self.redo_stack.push(action);
|
self.redo_stack.push(action);
|
||||||
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -481,6 +484,7 @@ impl ActionExecutor {
|
||||||
|
|
||||||
// Move back to undo stack
|
// Move back to undo stack
|
||||||
self.undo_stack.push(action);
|
self.undo_stack.push(action);
|
||||||
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Audio export settings
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AudioExportSettings {
|
pub struct AudioExportSettings {
|
||||||
|
|
@ -79,6 +127,10 @@ pub struct AudioExportSettings {
|
||||||
|
|
||||||
/// Project BPM (for beat-position scheduling during export)
|
/// Project BPM (for beat-position scheduling during export)
|
||||||
pub bpm: f64,
|
pub bpm: f64,
|
||||||
|
|
||||||
|
/// Tag metadata (title/artist/…) written into the file. Empty = none.
|
||||||
|
#[serde(default)]
|
||||||
|
pub metadata: AudioMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AudioExportSettings {
|
impl Default for AudioExportSettings {
|
||||||
|
|
@ -92,6 +144,7 @@ impl Default for AudioExportSettings {
|
||||||
start_time: 0.0,
|
start_time: 0.0,
|
||||||
end_time: 60.0,
|
end_time: 60.0,
|
||||||
bpm: 120.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
|
/// Progress updates during export
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ExportProgress {
|
pub enum ExportProgress {
|
||||||
|
|
@ -728,6 +857,33 @@ mod tests {
|
||||||
assert_eq!(settings.total_frames(), 300);
|
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]
|
#[test]
|
||||||
fn test_export_progress_percentage() {
|
fn test_export_progress_percentage() {
|
||||||
let progress = ExportProgress::FrameRendered { frame: 50, total: 100 };
|
let progress = ExportProgress::FrameRendered { frame: 50, total: 100 };
|
||||||
|
|
|
||||||
|
|
@ -399,16 +399,29 @@ pub fn save_beam(
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- raster keyframes -> media rows (PNG), keyed by keyframe id ---
|
// --- raster keyframes -> media rows (PNG), keyed by keyframe id ---
|
||||||
// (Phase 0 writes all resident frames each save; a disk-dirty flag to skip
|
// Incremental: only (re)encode a keyframe whose pixels changed since the last save.
|
||||||
// unchanged frames in place is deferred to Phase 3.)
|
// `kf.dirty` means "current pixels are not yet in the container" (set on any edit,
|
||||||
|
// cleared on a successful save — see main.rs); a clean frame already stored is kept
|
||||||
|
// in place, skipping the PNG re-encode of every resident frame on every save.
|
||||||
// Walk ALL layers (incl. nested in groups/clips) so nested raster keyframes
|
// Walk ALL layers (incl. nested in groups/clips) so nested raster keyframes
|
||||||
// are persisted too, and so `live_media` covers them — matching the load path,
|
// are persisted too, and so `live_media` covers them — matching the load path,
|
||||||
// which arms `needs_fault_in` recursively. Top-level-only projects are unaffected.
|
// which arms `needs_fault_in` recursively. Top-level-only projects are unaffected.
|
||||||
let mut raster_count = 0usize;
|
let mut raster_count = 0usize;
|
||||||
|
let mut raster_skipped = 0usize;
|
||||||
for layer in document.all_layers() {
|
for layer in document.all_layers() {
|
||||||
if let crate::layer::AnyLayer::Raster(rl) = layer {
|
if let crate::layer::AnyLayer::Raster(rl) = layer {
|
||||||
for kf in &rl.keyframes {
|
for kf in &rl.keyframes {
|
||||||
if !kf.raw_pixels.is_empty() {
|
if !kf.raw_pixels.is_empty() {
|
||||||
|
// Clean + already stored → keep the existing full + proxy rows untouched.
|
||||||
|
if !kf.dirty && txn.media_exists(kf.id)? {
|
||||||
|
live_media.insert(kf.id);
|
||||||
|
let proxy_id = raster_proxy_media_id(kf.id);
|
||||||
|
if txn.media_exists(proxy_id)? {
|
||||||
|
live_media.insert(proxy_id);
|
||||||
|
}
|
||||||
|
raster_skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let img =
|
let img =
|
||||||
crate::brush_engine::image_from_raw(kf.raw_pixels.clone(), kf.width, kf.height);
|
crate::brush_engine::image_from_raw(kf.raw_pixels.clone(), kf.width, kf.height);
|
||||||
match crate::brush_engine::encode_png(&img) {
|
match crate::brush_engine::encode_png(&img) {
|
||||||
|
|
@ -608,9 +621,10 @@ pub fn save_beam(
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media, {} orphans removed, in {:.2}ms",
|
"📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media ({} unchanged frames skipped), {} orphans removed, in {:.2}ms",
|
||||||
audio_pool_entries.len(),
|
audio_pool_entries.len(),
|
||||||
raster_count,
|
raster_count,
|
||||||
|
raster_skipped,
|
||||||
removed,
|
removed,
|
||||||
fn_start.elapsed().as_secs_f64() * 1000.0
|
fn_start.elapsed().as_secs_f64() * 1000.0
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -232,8 +232,9 @@ use crate::vector_graph::{FillId, VectorGraph};
|
||||||
use kurbo::{BezPath, PathEl, Rect, Shape};
|
use kurbo::{BezPath, PathEl, Rect, Shape};
|
||||||
|
|
||||||
/// Serialize the document's **vector** content to a standalone SVG string, at document time `time`.
|
/// 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
|
/// Vector layers, groups of them, and text layers (as real glyph outlines) — raster/video/audio/
|
||||||
/// pass can rasterize them to `<image>`). Animation is a single static frame at `time`.
|
/// 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 {
|
pub fn document_to_svg(document: &Document, time: f64) -> String {
|
||||||
let (w, h) = (document.width, document.height);
|
let (w, h) = (document.width, document.height);
|
||||||
let mut defs = String::new();
|
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>");
|
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.
|
// 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`,
|
/// 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).
|
/// 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) {
|
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.
|
// 1 fill path + 3 stroked edges = 4 <path> elements.
|
||||||
assert_eq!(body.matches("<path").count(), 4, "{body}");
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "lightningbeam-editor"
|
name = "lightningbeam-editor"
|
||||||
version = "1.0.7-alpha"
|
version = "1.0.8-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Multimedia editor for audio, video and 2D animation"
|
description = "Multimedia editor for audio, video and 2D animation"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
@ -41,6 +41,10 @@ serde_json = { workspace = true }
|
||||||
|
|
||||||
# Image loading
|
# Image loading
|
||||||
image = { workspace = true }
|
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 }
|
resvg = { workspace = true }
|
||||||
tiny-skia = "0.11"
|
tiny-skia = "0.11"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,14 @@ pub struct AppConfig {
|
||||||
/// sooner; larger = smaller pyramid, wider re-decode span. Default 256.
|
/// sooner; larger = smaller pyramid, wider re-decode span. Default 256.
|
||||||
#[serde(default = "defaults::waveform_floor_samples_per_texel")]
|
#[serde(default = "defaults::waveform_floor_samples_per_texel")]
|
||||||
pub waveform_floor_samples_per_texel: u32,
|
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 {
|
impl Default for AppConfig {
|
||||||
|
|
@ -90,6 +98,8 @@ impl Default for AppConfig {
|
||||||
keybindings: KeybindingConfig::default(),
|
keybindings: KeybindingConfig::default(),
|
||||||
large_media_default: LargeMediaMode::default(),
|
large_media_default: LargeMediaMode::default(),
|
||||||
waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(),
|
waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(),
|
||||||
|
last_audio_artist: String::new(),
|
||||||
|
last_audio_album: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -131,6 +219,20 @@ mod tests {
|
||||||
assert_eq!(v.len(), (1920 / 2) * (1080 / 2));
|
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]
|
#[test]
|
||||||
#[should_panic(expected = "RGBA data size mismatch")]
|
#[should_panic(expected = "RGBA data size mismatch")]
|
||||||
fn test_wrong_input_size_panics() {
|
fn test_wrong_input_size_panics() {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,42 @@
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::export::{
|
use lightningbeam_core::export::{
|
||||||
AudioExportSettings, AudioFormat,
|
AudioExportSettings, AudioFormat,
|
||||||
|
GifExportSettings,
|
||||||
ImageExportSettings, ImageFormat,
|
ImageExportSettings, ImageFormat,
|
||||||
VideoExportSettings, VideoCodec, VideoQuality, ColorRange,
|
VideoExportSettings, VideoCodec, VideoQuality, ColorRange,
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
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.
|
/// Hint about document content, used to pick a smart default export type.
|
||||||
pub struct DocumentHint {
|
pub struct DocumentHint {
|
||||||
pub has_video: bool,
|
pub has_video: bool,
|
||||||
|
|
@ -25,6 +56,8 @@ pub enum ExportType {
|
||||||
Audio,
|
Audio,
|
||||||
Image,
|
Image,
|
||||||
Video,
|
Video,
|
||||||
|
/// Animated GIF (multi-frame, palette-quantized, no audio).
|
||||||
|
Gif,
|
||||||
/// Vector-only SVG of the current frame (lossless; raster/video layers skipped).
|
/// Vector-only SVG of the current frame (lossless; raster/video layers skipped).
|
||||||
Svg,
|
Svg,
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +69,8 @@ pub enum ExportResult {
|
||||||
Image(ImageExportSettings, PathBuf),
|
Image(ImageExportSettings, PathBuf),
|
||||||
VideoOnly(VideoExportSettings, PathBuf),
|
VideoOnly(VideoExportSettings, PathBuf),
|
||||||
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
|
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
|
||||||
|
/// Animated GIF export.
|
||||||
|
Gif(GifExportSettings, PathBuf),
|
||||||
/// SVG of vector layers at the given document time.
|
/// SVG of vector layers at the given document time.
|
||||||
Svg(f64, PathBuf),
|
Svg(f64, PathBuf),
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +92,9 @@ pub struct ExportDialog {
|
||||||
/// Video export settings
|
/// Video export settings
|
||||||
pub video_settings: VideoExportSettings,
|
pub video_settings: VideoExportSettings,
|
||||||
|
|
||||||
|
/// Animated GIF export settings
|
||||||
|
pub gif_settings: GifExportSettings,
|
||||||
|
|
||||||
/// Include audio with video?
|
/// Include audio with video?
|
||||||
pub include_audio: bool,
|
pub include_audio: bool,
|
||||||
|
|
||||||
|
|
@ -104,6 +142,7 @@ impl Default for ExportDialog {
|
||||||
audio_settings: AudioExportSettings::standard_mp3(),
|
audio_settings: AudioExportSettings::standard_mp3(),
|
||||||
image_settings: ImageExportSettings::default(),
|
image_settings: ImageExportSettings::default(),
|
||||||
video_settings: VideoExportSettings::default(),
|
video_settings: VideoExportSettings::default(),
|
||||||
|
gif_settings: GifExportSettings::default(),
|
||||||
include_audio: true,
|
include_audio: true,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
|
@ -120,10 +159,18 @@ impl Default for ExportDialog {
|
||||||
|
|
||||||
impl ExportDialog {
|
impl ExportDialog {
|
||||||
/// Open the dialog with default settings, using `hint` to pick a smart default tab.
|
/// 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.open = true;
|
||||||
self.audio_settings.end_time = timeline_duration;
|
self.audio_settings.end_time = timeline_duration;
|
||||||
self.video_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;
|
self.image_settings.time = hint.current_time;
|
||||||
// Propagate document dimensions as defaults (None means "use doc size").
|
// Propagate document dimensions as defaults (None means "use doc size").
|
||||||
self.image_settings.width = None;
|
self.image_settings.width = None;
|
||||||
|
|
@ -143,6 +190,24 @@ impl ExportDialog {
|
||||||
else if only_raster { ExportType::Image }
|
else if only_raster { ExportType::Image }
|
||||||
else { self.export_type } // keep current as fallback
|
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();
|
self.current_project = project_name.to_owned();
|
||||||
|
|
||||||
// Restore the last exported path if available; otherwise default to project name.
|
// 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::Audio => self.audio_settings.format.extension(),
|
||||||
ExportType::Image => self.image_settings.format.extension(),
|
ExportType::Image => self.image_settings.format.extension(),
|
||||||
ExportType::Video => self.video_settings.codec.container_format(),
|
ExportType::Video => self.video_settings.codec.container_format(),
|
||||||
|
ExportType::Gif => "gif",
|
||||||
ExportType::Svg => "svg",
|
ExportType::Svg => "svg",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +269,7 @@ impl ExportDialog {
|
||||||
ExportType::Audio => "Export Audio",
|
ExportType::Audio => "Export Audio",
|
||||||
ExportType::Image => "Export Image",
|
ExportType::Image => "Export Image",
|
||||||
ExportType::Video => "Export Video",
|
ExportType::Video => "Export Video",
|
||||||
|
ExportType::Gif => "Export GIF",
|
||||||
ExportType::Svg => "Export SVG",
|
ExportType::Svg => "Export SVG",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -225,6 +292,7 @@ impl ExportDialog {
|
||||||
(ExportType::Audio, "Audio"),
|
(ExportType::Audio, "Audio"),
|
||||||
(ExportType::Image, "Image"),
|
(ExportType::Image, "Image"),
|
||||||
(ExportType::Video, "Video"),
|
(ExportType::Video, "Video"),
|
||||||
|
(ExportType::Gif, "GIF"),
|
||||||
(ExportType::Svg, "SVG"),
|
(ExportType::Svg, "SVG"),
|
||||||
] {
|
] {
|
||||||
if ui.selectable_value(&mut self.export_type, variant, label).clicked() {
|
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::Audio => self.render_audio_basic(ui),
|
||||||
ExportType::Image => self.render_image_settings(ui),
|
ExportType::Image => self.render_image_settings(ui),
|
||||||
ExportType::Video => self.render_video_basic(ui),
|
ExportType::Video => self.render_video_basic(ui),
|
||||||
|
ExportType::Gif => self.render_gif_basic(ui),
|
||||||
ExportType::Svg => self.render_svg_settings(ui),
|
ExportType::Svg => self.render_svg_settings(ui),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,6 +330,7 @@ impl ExportDialog {
|
||||||
ExportType::Audio => self.render_audio_advanced(ui),
|
ExportType::Audio => self.render_audio_advanced(ui),
|
||||||
ExportType::Image => self.render_image_advanced(ui),
|
ExportType::Image => self.render_image_advanced(ui),
|
||||||
ExportType::Video => self.render_video_advanced(ui),
|
ExportType::Video => self.render_video_advanced(ui),
|
||||||
|
ExportType::Gif => self.render_gif_advanced(ui),
|
||||||
ExportType::Svg => {} // SVG has no advanced settings
|
ExportType::Svg => {} // SVG has no advanced settings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -460,10 +530,50 @@ impl ExportDialog {
|
||||||
|
|
||||||
ui.add_space(8.0);
|
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
|
// Time range
|
||||||
self.render_time_range(ui);
|
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)
|
/// Video presets: (name, codec, quality, width, height, fps)
|
||||||
const VIDEO_PRESETS: &'static [(&'static str, VideoCodec, VideoQuality, u32, u32, f64)] = &[
|
const VIDEO_PRESETS: &'static [(&'static str, VideoCodec, VideoQuality, u32, u32, f64)] = &[
|
||||||
("1080p H.264 (Standard)", VideoCodec::H264, VideoQuality::High, 1920, 1080, 30.0),
|
("1080p H.264 (Standard)", VideoCodec::H264, VideoQuality::High, 1920, 1080, 30.0),
|
||||||
|
|
@ -614,12 +724,65 @@ impl ExportDialog {
|
||||||
self.render_time_range(ui);
|
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)
|
/// Render time range UI (common to both audio and video)
|
||||||
fn render_time_range(&mut self, ui: &mut egui::Ui) {
|
fn render_time_range(&mut self, ui: &mut egui::Ui) {
|
||||||
let (start_time, end_time) = match self.export_type {
|
let (start_time, end_time) = match self.export_type {
|
||||||
ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time),
|
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::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::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| {
|
ui.horizontal(|ui| {
|
||||||
|
|
@ -693,6 +856,13 @@ impl ExportDialog {
|
||||||
Some(ExportResult::Image(self.image_settings.clone(), output_path))
|
Some(ExportResult::Image(self.image_settings.clone(), output_path))
|
||||||
}
|
}
|
||||||
ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, 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 => {
|
ExportType::Audio => {
|
||||||
// Validate audio settings
|
// Validate audio settings
|
||||||
if let Err(err) = self.audio_settings.validate() {
|
if let Err(err) = self.audio_settings.validate() {
|
||||||
|
|
@ -861,3 +1031,30 @@ impl ExportProgressDialog {
|
||||||
should_cancel
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -175,7 +175,9 @@ impl GpuYuv {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CPU reference for the exact math/layout the shader produces — used by unit tests so
|
/// 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> {
|
fn cpu_reference(rgba: &[u8], width: u32, height: u32, full_range: bool) -> Vec<u8> {
|
||||||
let w = width as usize;
|
let w = width as usize;
|
||||||
let h = height as usize;
|
let h = height as usize;
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,179 @@ pub fn save_rgba_image(
|
||||||
encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}"))
|
encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}"))
|
||||||
}
|
}
|
||||||
ImageFormat::WebP => {
|
ImageFormat::WebP => {
|
||||||
if allow_transparency {
|
// `image` 0.25's WebP encoder is lossless-only, which ignored the quality slider and
|
||||||
img.save(path).map_err(|e| format!("WebP save failed: {e}"))
|
// 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 0–100 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 {
|
} else {
|
||||||
let flat = flatten_alpha(img);
|
let mut v = pixels.to_vec();
|
||||||
flat.save(path).map_err(|e| format!("WebP save failed: {e}"))
|
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 0–100, 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
//! This module provides the export orchestrator and progress tracking
|
//! This module provides the export orchestrator and progress tracking
|
||||||
//! for exporting audio and video from the timeline.
|
//! for exporting audio and video from the timeline.
|
||||||
|
|
||||||
pub mod audio_exporter;
|
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
|
pub mod gif_exporter;
|
||||||
pub mod image_exporter;
|
pub mod image_exporter;
|
||||||
pub mod video_exporter;
|
pub mod video_exporter;
|
||||||
pub mod readback_pipeline;
|
pub mod readback_pipeline;
|
||||||
|
|
@ -13,7 +13,8 @@ pub mod cpu_yuv_converter;
|
||||||
pub mod gpu_yuv;
|
pub mod gpu_yuv;
|
||||||
pub mod hdr_frame;
|
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::document::Document;
|
||||||
use lightningbeam_core::renderer::ImageCache;
|
use lightningbeam_core::renderer::ImageCache;
|
||||||
use lightningbeam_core::video::VideoManager;
|
use lightningbeam_core::video::VideoManager;
|
||||||
|
|
@ -68,6 +69,11 @@ pub struct VideoExportState {
|
||||||
readback_pipeline: Option<readback_pipeline::ReadbackPipeline>,
|
readback_pipeline: Option<readback_pipeline::ReadbackPipeline>,
|
||||||
/// CPU YUV converter for RGBA→YUV420p conversion
|
/// CPU YUV converter for RGBA→YUV420p conversion
|
||||||
cpu_yuv_converter: Option<cpu_yuv_converter::CpuYuvConverter>,
|
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 that have been submitted to GPU but not yet encoded
|
||||||
frames_in_flight: usize,
|
frames_in_flight: usize,
|
||||||
/// Next frame number to send to encoder (for ordering)
|
/// Next frame number to send to encoder (for ordering)
|
||||||
|
|
@ -131,6 +137,38 @@ pub struct ExportOrchestrator {
|
||||||
|
|
||||||
/// Single-frame image export state
|
/// Single-frame image export state
|
||||||
image_state: Option<ImageExportState>,
|
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
|
/// State for parallel audio+video export
|
||||||
|
|
@ -168,6 +206,7 @@ impl ExportOrchestrator {
|
||||||
video_state: None,
|
video_state: None,
|
||||||
parallel_export: None,
|
parallel_export: None,
|
||||||
image_state: 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
|
/// unconsumed terminal message). Used to gate the UI poll loop so it doesn't run every
|
||||||
/// repaint forever after an export finishes.
|
/// repaint forever after an export finishes.
|
||||||
pub fn has_pending_progress(&self) -> bool {
|
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
|
/// Poll progress for parallel video+audio export
|
||||||
|
|
@ -534,6 +573,9 @@ impl ExportOrchestrator {
|
||||||
}
|
}
|
||||||
self.video_state = None;
|
self.video_state = None;
|
||||||
self.image_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.progress_rx = None;
|
||||||
self.thread_handle = None;
|
self.thread_handle = None;
|
||||||
}
|
}
|
||||||
|
|
@ -542,6 +584,7 @@ impl ExportOrchestrator {
|
||||||
pub fn is_exporting(&self) -> bool {
|
pub fn is_exporting(&self) -> bool {
|
||||||
if self.parallel_export.is_some() { return true; }
|
if self.parallel_export.is_some() { return true; }
|
||||||
if self.image_state.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 {
|
if let Some(handle) = &self.thread_handle {
|
||||||
!handle.is_finished()
|
!handle.is_finished()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -719,6 +762,196 @@ impl ExportOrchestrator {
|
||||||
result.map(|_| true)
|
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
|
/// Wait for the export to complete
|
||||||
///
|
///
|
||||||
/// This blocks until the export thread finishes.
|
/// This blocks until the export thread finishes.
|
||||||
|
|
@ -774,6 +1007,12 @@ impl ExportOrchestrator {
|
||||||
start_time: daw_backend::Seconds(settings.start_time),
|
start_time: daw_backend::Seconds(settings.start_time),
|
||||||
end_time: daw_backend::Seconds(settings.end_time),
|
end_time: daw_backend::Seconds(settings.end_time),
|
||||||
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
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
|
// Use DAW backend export for all formats
|
||||||
|
|
@ -872,6 +1111,8 @@ impl ExportOrchestrator {
|
||||||
let hdr = settings.hdr;
|
let hdr = settings.hdr;
|
||||||
let fit = settings.fit;
|
let fit = settings.fit;
|
||||||
let full_range = settings.color_range.is_full();
|
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 || {
|
let handle = std::thread::spawn(move || {
|
||||||
Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames);
|
Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames);
|
||||||
});
|
});
|
||||||
|
|
@ -891,6 +1132,8 @@ impl ExportOrchestrator {
|
||||||
gpu_resources: None,
|
gpu_resources: None,
|
||||||
readback_pipeline: None,
|
readback_pipeline: None,
|
||||||
cpu_yuv_converter: None,
|
cpu_yuv_converter: None,
|
||||||
|
prores,
|
||||||
|
cpu_yuv422p10: None,
|
||||||
frames_in_flight: 0,
|
frames_in_flight: 0,
|
||||||
next_frame_to_encode: 0,
|
next_frame_to_encode: 0,
|
||||||
perf_metrics: Some(perf_metrics::ExportMetrics::new()),
|
perf_metrics: Some(perf_metrics::ExportMetrics::new()),
|
||||||
|
|
@ -1121,7 +1364,10 @@ impl ExportOrchestrator {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs();
|
.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_{}.{}",
|
let temp_audio_path = temp_dir.join(format!("lightningbeam_audio_{}.{}",
|
||||||
timestamp,
|
timestamp,
|
||||||
match audio_settings.format {
|
match audio_settings.format {
|
||||||
|
|
@ -1331,24 +1577,34 @@ impl ExportOrchestrator {
|
||||||
// Enable GPU YUV only when the encoder's YUV420P planes are tight (no linesize
|
// 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.
|
// padding) — then the packed GPU planes copy in without row misalignment.
|
||||||
// Otherwise fall back to RGBA readback + CPU swscale.
|
// 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(
|
let probe = ffmpeg_next::frame::Video::new(
|
||||||
ffmpeg_next::format::Pixel::YUV420P, width, height,
|
ffmpeg_next::format::Pixel::YUV420P, width, height,
|
||||||
);
|
);
|
||||||
probe.stride(0) == width as usize && probe.stride(1) == (width / 2) as usize
|
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");
|
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));
|
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)?);
|
state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?);
|
||||||
|
}
|
||||||
println!("🚀 [ASYNC PIPELINE] Triple-buffered pipeline initialized");
|
println!("🚀 [ASYNC PIPELINE] Triple-buffered pipeline initialized");
|
||||||
println!("🚀 [CPU YUV] swscale converter initialized");
|
println!("🚀 [CPU YUV] swscale converter initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
let pipeline = state.readback_pipeline.as_mut().unwrap();
|
let pipeline = state.readback_pipeline.as_mut().unwrap();
|
||||||
let gpu_resources = state.gpu_resources.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();
|
let mut metrics = state.perf_metrics.as_mut();
|
||||||
|
|
||||||
// Poll for completed async readbacks (non-blocking)
|
// Poll for completed async readbacks (non-blocking)
|
||||||
|
|
@ -1375,12 +1631,17 @@ impl ExportOrchestrator {
|
||||||
let data = pipeline.extract_rgba_data(result.buffer_id);
|
let data = pipeline.extract_rgba_data(result.buffer_id);
|
||||||
let extraction_end = Instant::now();
|
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 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)
|
pipeline.split_yuv(&data)
|
||||||
} else {
|
} 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();
|
let conversion_end = Instant::now();
|
||||||
|
|
||||||
|
|
@ -1469,6 +1730,7 @@ impl ExportOrchestrator {
|
||||||
state.gpu_resources = None;
|
state.gpu_resources = None;
|
||||||
state.readback_pipeline = None;
|
state.readback_pipeline = None;
|
||||||
state.cpu_yuv_converter = None;
|
state.cpu_yuv_converter = None;
|
||||||
|
state.cpu_yuv422p10 = None;
|
||||||
state.perf_metrics = None;
|
state.perf_metrics = None;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1718,6 +1980,8 @@ impl ExportOrchestrator {
|
||||||
// Pixel format the encoder frames are built in (matches setup_video_encoder).
|
// Pixel format the encoder frames are built in (matches setup_video_encoder).
|
||||||
let pixel_format = if settings.hdr.is_hdr() {
|
let pixel_format = if settings.hdr.is_hdr() {
|
||||||
ffmpeg_next::format::Pixel::YUV420P10LE
|
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 {
|
} else {
|
||||||
ffmpeg_next::format::Pixel::YUV420P
|
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
|
// 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.
|
// 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);
|
// Sample size + chroma subsampling depend on the pixel format:
|
||||||
let sample_bytes = if ten_bit { 2usize } else { 1usize };
|
// 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 copy_plane = |frame: &mut ffmpeg_next::frame::Video, idx: usize, src: &[u8], w: usize, h: usize| {
|
||||||
let bytes_per_row = w * sample_bytes;
|
let bytes_per_row = w * sample_bytes;
|
||||||
let stride = frame.stride(idx);
|
let stride = frame.stride(idx);
|
||||||
|
|
@ -1848,8 +2121,8 @@ impl ExportOrchestrator {
|
||||||
};
|
};
|
||||||
let (w, h) = (width as usize, height as usize);
|
let (w, h) = (width as usize, height as usize);
|
||||||
copy_plane(&mut video_frame, 0, y_plane, w, h);
|
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, 1, u_plane, w / 2, h / chroma_h_div);
|
||||||
copy_plane(&mut video_frame, 2, v_plane, w / 2, h / 2);
|
copy_plane(&mut video_frame, 2, v_plane, w / 2, h / chroma_h_div);
|
||||||
|
|
||||||
// Set PTS (presentation timestamp) in encoder's time base
|
// Set PTS (presentation timestamp) in encoder's time base
|
||||||
// Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000)
|
// Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000)
|
||||||
|
|
|
||||||
|
|
@ -616,9 +616,12 @@ pub fn setup_video_encoder(
|
||||||
// Configure encoder parameters BEFORE opening (critical!)
|
// Configure encoder parameters BEFORE opening (critical!)
|
||||||
encoder.set_width(aligned_width);
|
encoder.set_width(aligned_width);
|
||||||
encoder.set_height(aligned_height);
|
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() {
|
if hdr.is_hdr() {
|
||||||
encoder.set_format(ffmpeg::format::Pixel::YUV420P10LE);
|
encoder.set_format(ffmpeg::format::Pixel::YUV420P10LE);
|
||||||
|
} else if is_prores {
|
||||||
|
encoder.set_format(ffmpeg::format::Pixel::YUV422P10LE);
|
||||||
} else {
|
} else {
|
||||||
encoder.set_format(ffmpeg::format::Pixel::YUV420P);
|
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_primaries", "bt709");
|
||||||
color_opts.set("color_trc", "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 {}×{}){}",
|
println!("📐 Video dimensions: {}×{} (aligned to {}×{}){}",
|
||||||
|
|
@ -1433,6 +1440,30 @@ mod tests {
|
||||||
assert!(v[0] > 128, "V value: {}", v[0]);
|
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
|
// 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
|
// macroblock alignment), so its plane lengths are the aligned sizes, not the
|
||||||
// tight input dimensions. The former `test_rgba_to_yuv420p_dimensions` and
|
// tight input dimensions. The former `test_rgba_to_yuv420p_dimensions` and
|
||||||
|
|
|
||||||
|
|
@ -681,6 +681,147 @@ enum FileOperation {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How often (seconds) the background autosave runs when the document is dirty.
|
||||||
|
const AUTOSAVE_INTERVAL_SECS: f64 = 45.0;
|
||||||
|
|
||||||
|
/// Background crash-recovery autosave state (see the `autosave` field on `EditorApp`).
|
||||||
|
struct AutosaveState {
|
||||||
|
/// Per-session recovery container path (in the app data dir). `None` disables autosave
|
||||||
|
/// (e.g. the data dir couldn't be resolved/created).
|
||||||
|
recovery_path: Option<std::path::PathBuf>,
|
||||||
|
/// `ActionExecutor::epoch()` captured at the last dispatched autosave (or manual save). The
|
||||||
|
/// document is "dirty" when the current epoch differs, or `pending_event` is set.
|
||||||
|
baseline_epoch: u64,
|
||||||
|
/// Set by non-action changes that still need capturing (import, finished recording).
|
||||||
|
pending_event: bool,
|
||||||
|
/// True while a recovery write is in flight on the worker (don't dispatch another).
|
||||||
|
in_flight: bool,
|
||||||
|
/// Wall-clock of the last dispatched autosave, for interval throttling.
|
||||||
|
last_time: Option<std::time::Instant>,
|
||||||
|
/// Progress channel for the in-flight recovery write.
|
||||||
|
progress_rx: Option<std::sync::mpsc::Receiver<FileProgress>>,
|
||||||
|
/// Recovery files left over from previous sessions that didn't exit cleanly (newest first),
|
||||||
|
/// discovered at startup. Presented to the user as a "recover unsaved work?" prompt.
|
||||||
|
leftover_recoveries: Vec<std::path::PathBuf>,
|
||||||
|
/// Seconds between autosaves while dirty. Defaults to `AUTOSAVE_INTERVAL_SECS`; override with
|
||||||
|
/// `LB_AUTOSAVE_SECS` (e.g. `LB_AUTOSAVE_SECS=5`) to make manual testing practical.
|
||||||
|
interval_secs: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutosaveState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::gc_old_recovered();
|
||||||
|
let recovery_path = Self::make_session_path();
|
||||||
|
eprintln!("💾 [AUTOSAVE] recovery file for this session: {:?}", recovery_path);
|
||||||
|
let leftover_recoveries = Self::find_leftovers(recovery_path.as_deref());
|
||||||
|
if !leftover_recoveries.is_empty() {
|
||||||
|
eprintln!("💾 [AUTOSAVE] found {} leftover recovery file(s) from a previous session",
|
||||||
|
leftover_recoveries.len());
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
recovery_path,
|
||||||
|
baseline_epoch: 0,
|
||||||
|
pending_event: false,
|
||||||
|
in_flight: false,
|
||||||
|
last_time: None,
|
||||||
|
progress_rx: None,
|
||||||
|
leftover_recoveries,
|
||||||
|
interval_secs: std::env::var("LB_AUTOSAVE_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.filter(|s| *s > 0.0)
|
||||||
|
.unwrap_or(AUTOSAVE_INTERVAL_SECS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session recovery `.beam` files present at startup (excluding this session's own path).
|
||||||
|
/// Their existence means a prior session didn't reach `on_exit` — i.e. it crashed or was
|
||||||
|
/// killed — so they hold unsaved work. Sorted newest-first by modification time.
|
||||||
|
fn find_leftovers(exclude: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
|
||||||
|
let Some(dir) = Self::recovery_dir() else { return Vec::new() };
|
||||||
|
let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| {
|
||||||
|
p.extension().and_then(|x| x.to_str()) == Some("beam")
|
||||||
|
&& p.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.starts_with("session-"))
|
||||||
|
&& Some(p.as_path()) != exclude
|
||||||
|
})
|
||||||
|
.filter_map(|p| {
|
||||||
|
let mtime = std::fs::metadata(&p).and_then(|m| m.modified()).ok()?;
|
||||||
|
Some((p, mtime))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
files.sort_by(|a, b| b.1.cmp(&a.1)); // newest first
|
||||||
|
files.into_iter().map(|(p, _)| p).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete `recovered-*` files (already-recovered work the user relocated via Save As) older than
|
||||||
|
/// a week, so the recovery dir doesn't grow without bound. Best-effort.
|
||||||
|
fn gc_old_recovered() {
|
||||||
|
let Some(dir) = Self::recovery_dir() else { return };
|
||||||
|
let Some(cutoff) =
|
||||||
|
std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||||
|
let p = entry.path();
|
||||||
|
let is_recovered = p.extension().and_then(|x| x.to_str()) == Some("beam")
|
||||||
|
&& p.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.starts_with("recovered-"));
|
||||||
|
if !is_recovered {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(mtime) = std::fs::metadata(&p).and_then(|m| m.modified()) {
|
||||||
|
if mtime < cutoff {
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recovery directory (`<data_dir>/recovery`), created if needed.
|
||||||
|
fn recovery_dir() -> Option<std::path::PathBuf> {
|
||||||
|
let proj = directories::ProjectDirs::from("", "", "lightningbeam")?;
|
||||||
|
let dir = proj.data_dir().join("recovery");
|
||||||
|
std::fs::create_dir_all(&dir).ok()?;
|
||||||
|
Some(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh per-session recovery file path (`recovery/session-<uuid>.beam`).
|
||||||
|
fn make_session_path() -> Option<std::path::PathBuf> {
|
||||||
|
Some(Self::recovery_dir()?.join(format!("session-{}.beam", uuid::Uuid::new_v4())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `path` is one of our recovery files (`session-*` / `recovered-*` in the recovery
|
||||||
|
/// dir). Saving one of these should behave as Save As so the work gets a real home instead of
|
||||||
|
/// being written back into the app data dir.
|
||||||
|
fn is_recovery_path(path: &std::path::Path) -> bool {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.starts_with("session-") || n.starts_with("recovered-"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something to do after the unsaved-changes prompt resolves: the action deferred behind "Save
|
||||||
|
/// changes?" (Save runs it after saving; Don't Save runs it immediately; Cancel drops it). Covers
|
||||||
|
/// both file switches and quitting so they share one prompt + one save-then-continue path.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum PendingAction {
|
||||||
|
/// New File (return to the start screen).
|
||||||
|
NewFile,
|
||||||
|
/// Open a specific `.beam` (covers both Open… and Open Recent — the path is already resolved).
|
||||||
|
Open(std::path::PathBuf),
|
||||||
|
/// Quit (close the window).
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
/// Information about an imported asset (for auto-placement)
|
/// Information about an imported asset (for auto-placement)
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)] // name/duration populated for future import UX features
|
#[allow(dead_code)] // name/duration populated for future import UX features
|
||||||
|
|
@ -1163,6 +1304,26 @@ struct EditorApp {
|
||||||
/// Current file operation in progress (if any)
|
/// Current file operation in progress (if any)
|
||||||
file_operation: Option<FileOperation>,
|
file_operation: Option<FileOperation>,
|
||||||
|
|
||||||
|
/// Crash-recovery autosave state. The recovery container is a per-session `.beam` in the app's
|
||||||
|
/// data dir; it's written in the background (reusing the file worker) and deleted on a clean
|
||||||
|
/// exit. A leftover file on next launch signals an unclean shutdown → offer to restore.
|
||||||
|
autosave: AutosaveState,
|
||||||
|
|
||||||
|
/// `ActionExecutor::epoch()` at the last manual save (or new/load) — the document is "modified"
|
||||||
|
/// (has unsaved changes vs. the user's file) when the current epoch differs, or `media_modified`
|
||||||
|
/// is set. Distinct from the autosave baseline, which also moves on every background autosave.
|
||||||
|
saved_epoch: u64,
|
||||||
|
/// Non-action changes since the last save (imports, finished recordings) that `epoch` doesn't
|
||||||
|
/// capture. Cleared on manual save / new / load.
|
||||||
|
media_modified: bool,
|
||||||
|
/// The pending action waiting on the "save changes?" prompt — a file switch (New/Open/Open
|
||||||
|
/// Recent) or a quit, requested while the document had unsaved work.
|
||||||
|
unsaved_prompt: Option<PendingAction>,
|
||||||
|
/// The action to run once an in-flight save (triggered from that prompt via "Save") finishes.
|
||||||
|
after_save: Option<PendingAction>,
|
||||||
|
/// The user agreed to quit — let the next window-close request through instead of intercepting it.
|
||||||
|
confirmed_close: bool,
|
||||||
|
|
||||||
/// Audio extraction channel for background thread communication
|
/// Audio extraction channel for background thread communication
|
||||||
audio_extraction_tx: std::sync::mpsc::Sender<AudioExtractionResult>,
|
audio_extraction_tx: std::sync::mpsc::Sender<AudioExtractionResult>,
|
||||||
audio_extraction_rx: std::sync::mpsc::Receiver<AudioExtractionResult>,
|
audio_extraction_rx: std::sync::mpsc::Receiver<AudioExtractionResult>,
|
||||||
|
|
@ -1246,7 +1407,17 @@ impl EditorApp {
|
||||||
cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||||
|
|
||||||
// Load application config
|
// Load application config
|
||||||
let config = AppConfig::load();
|
let mut config = AppConfig::load();
|
||||||
|
// One-time cleanup: earlier builds added a Recovered file to Recent on restore. Drop any
|
||||||
|
// recovery-dir paths that leaked in (and re-save if we removed any) so they don't show in
|
||||||
|
// Recent or get auto-reopened below.
|
||||||
|
let recent_before = config.recent_files.len();
|
||||||
|
config
|
||||||
|
.recent_files
|
||||||
|
.retain(|p| !AutosaveState::is_recovery_path(p));
|
||||||
|
if config.recent_files.len() != recent_before {
|
||||||
|
config.save();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we should auto-reopen last session
|
// Check if we should auto-reopen last session
|
||||||
let pending_auto_reopen = if config.reopen_last_session {
|
let pending_auto_reopen = if config.reopen_last_session {
|
||||||
|
|
@ -1450,6 +1621,12 @@ impl EditorApp {
|
||||||
config,
|
config,
|
||||||
file_command_tx,
|
file_command_tx,
|
||||||
file_operation: None, // No file operation in progress initially
|
file_operation: None, // No file operation in progress initially
|
||||||
|
autosave: AutosaveState::new(),
|
||||||
|
saved_epoch: 0,
|
||||||
|
media_modified: false,
|
||||||
|
unsaved_prompt: None,
|
||||||
|
after_save: None,
|
||||||
|
confirmed_close: false,
|
||||||
audio_extraction_tx,
|
audio_extraction_tx,
|
||||||
audio_extraction_rx,
|
audio_extraction_rx,
|
||||||
export_dialog: export::dialog::ExportDialog::default(),
|
export_dialog: export::dialog::ExportDialog::default(),
|
||||||
|
|
@ -1735,10 +1912,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
|
/// Create a new project with the specified focus/layout
|
||||||
fn create_new_project_with_focus(&mut self, layout_index: usize) {
|
fn create_new_project_with_focus(&mut self, layout_index: usize) {
|
||||||
use lightningbeam_core::layer::{AnyLayer, AudioLayer, VectorLayer, VideoLayer};
|
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
|
// Create a new blank document
|
||||||
let mut document = lightningbeam_core::document::Document::with_size(
|
let mut document = lightningbeam_core::document::Document::with_size(
|
||||||
"Untitled",
|
"Untitled",
|
||||||
|
|
@ -1790,6 +1996,8 @@ impl EditorApp {
|
||||||
|
|
||||||
// Reset action executor with new document
|
// Reset action executor with new document
|
||||||
self.action_executor = lightningbeam_core::action::ActionExecutor::new(document);
|
self.action_executor = lightningbeam_core::action::ActionExecutor::new(document);
|
||||||
|
// Fresh document → nothing new to recover; rebase the autosave epoch.
|
||||||
|
self.reset_autosave_baseline();
|
||||||
|
|
||||||
// Apply the layout
|
// Apply the layout
|
||||||
if layout_index < self.layouts.len() {
|
if layout_index < self.layouts.len() {
|
||||||
|
|
@ -3183,28 +3391,7 @@ impl EditorApp {
|
||||||
// File menu
|
// File menu
|
||||||
MenuAction::NewFile => {
|
MenuAction::NewFile => {
|
||||||
println!("Menu: New File");
|
println!("Menu: New File");
|
||||||
// TODO: Prompt to save current file if modified
|
self.request_switch(PendingAction::NewFile);
|
||||||
|
|
||||||
// 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();
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
MenuAction::NewWindow => {
|
MenuAction::NewWindow => {
|
||||||
println!("Menu: New Window");
|
println!("Menu: New Window");
|
||||||
|
|
@ -3213,11 +3400,17 @@ impl EditorApp {
|
||||||
MenuAction::Save => {
|
MenuAction::Save => {
|
||||||
use rfd::FileDialog;
|
use rfd::FileDialog;
|
||||||
|
|
||||||
if let Some(path) = &self.current_file_path {
|
// A recovered file has no real home yet — Save behaves as Save As so the work lands
|
||||||
|
// where the user wants, not back in the app data dir.
|
||||||
|
let real_path = self
|
||||||
|
.current_file_path
|
||||||
|
.clone()
|
||||||
|
.filter(|p| !AutosaveState::is_recovery_path(p));
|
||||||
|
if let Some(path) = real_path {
|
||||||
// Save to existing path
|
// Save to existing path
|
||||||
self.save_to_file(path.clone());
|
self.save_to_file(path);
|
||||||
} else {
|
} else {
|
||||||
// No current path, fall through to Save As
|
// No real path (untitled or recovered): fall through to Save As
|
||||||
if let Some(path) = FileDialog::new()
|
if let Some(path) = FileDialog::new()
|
||||||
.add_filter("Lightningbeam Project", &["beam"])
|
.add_filter("Lightningbeam Project", &["beam"])
|
||||||
.set_file_name("Untitled.beam")
|
.set_file_name("Untitled.beam")
|
||||||
|
|
@ -3234,15 +3427,16 @@ impl EditorApp {
|
||||||
.add_filter("Lightningbeam Project", &["beam"])
|
.add_filter("Lightningbeam Project", &["beam"])
|
||||||
.set_file_name("Untitled.beam");
|
.set_file_name("Untitled.beam");
|
||||||
|
|
||||||
// Set initial directory if we have a current file
|
// Default to the current file's directory — but not the recovery dir (a recovered
|
||||||
let dialog = if let Some(current_path) = &self.current_file_path {
|
// file's parent), which the user never chose and shouldn't be steered back into.
|
||||||
if let Some(parent) = current_path.parent() {
|
let dialog = match self
|
||||||
dialog.set_directory(parent)
|
.current_file_path
|
||||||
} else {
|
.as_ref()
|
||||||
dialog
|
.filter(|p| !AutosaveState::is_recovery_path(p))
|
||||||
}
|
.and_then(|p| p.parent())
|
||||||
} else {
|
{
|
||||||
dialog
|
Some(parent) => dialog.set_directory(parent),
|
||||||
|
None => dialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(path) = dialog.save_file() {
|
if let Some(path) = dialog.save_file() {
|
||||||
|
|
@ -3252,21 +3446,19 @@ impl EditorApp {
|
||||||
MenuAction::OpenFile => {
|
MenuAction::OpenFile => {
|
||||||
use rfd::FileDialog;
|
use rfd::FileDialog;
|
||||||
|
|
||||||
// TODO: Prompt to save current file if modified
|
// Pick the file first, then (if there are unsaved changes) prompt to save.
|
||||||
|
|
||||||
if let Some(path) = FileDialog::new()
|
if let Some(path) = FileDialog::new()
|
||||||
.add_filter("Lightningbeam Project", &["beam"])
|
.add_filter("Lightningbeam Project", &["beam"])
|
||||||
.pick_file()
|
.pick_file()
|
||||||
{
|
{
|
||||||
self.load_from_file(path);
|
self.request_switch(PendingAction::Open(path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::OpenRecent(index) => {
|
MenuAction::OpenRecent(index) => {
|
||||||
let recent_files = self.config.get_recent_files();
|
let recent_files = self.config.get_recent_files();
|
||||||
|
|
||||||
if let Some(path) = recent_files.get(index) {
|
if let Some(path) = recent_files.get(index) {
|
||||||
// TODO: Prompt to save current file if modified
|
self.request_switch(PendingAction::Open(path.clone()));
|
||||||
self.load_from_file(path.clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::ClearRecentFiles => {
|
MenuAction::ClearRecentFiles => {
|
||||||
|
|
@ -3420,7 +3612,13 @@ impl EditorApp {
|
||||||
h
|
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 => {
|
MenuAction::Quit => {
|
||||||
println!("Menu: Quit");
|
println!("Menu: Quit");
|
||||||
|
|
@ -3991,19 +4189,45 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prepare document for saving by storing current UI layout
|
/// Prepare document for saving by storing current UI layout
|
||||||
fn prepare_document_for_save(&mut self) {
|
|
||||||
let doc = self.action_executor.document_mut();
|
|
||||||
|
|
||||||
// Store current layout state
|
|
||||||
doc.ui_layout = Some(self.current_layout.clone());
|
|
||||||
|
|
||||||
// Store base layout name for reference
|
|
||||||
if self.current_layout_index < self.layouts.len() {
|
|
||||||
doc.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save the current document to a .beam file
|
/// Save the current document to a .beam file
|
||||||
|
/// Assemble the background save command for `path`: prepares the document (layout), clones it,
|
||||||
|
/// and snapshots the waveform/thumbnail side data. Shared by manual save and background autosave.
|
||||||
|
fn build_save_command(
|
||||||
|
&mut self,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
||||||
|
) -> FileCommand {
|
||||||
|
// Snapshot the document and stamp the current UI layout onto the SNAPSHOT — never the live
|
||||||
|
// document. Mutating the live doc here (as the old prepare_document_for_save did via
|
||||||
|
// Arc::make_mut) would touch UI state and could deep-clone the whole document if a render
|
||||||
|
// callback holds a reference — unacceptable for a frequent background autosave. The live
|
||||||
|
// doc's `ui_layout` is only read at save/serialize time, so leaving it stale is harmless.
|
||||||
|
let mut document = self.action_executor.document().clone();
|
||||||
|
document.ui_layout = Some(self.current_layout.clone());
|
||||||
|
if self.current_layout_index < self.layouts.len() {
|
||||||
|
document.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone());
|
||||||
|
}
|
||||||
|
let waveform_blobs: std::collections::HashMap<usize, Vec<u8>> = self
|
||||||
|
.waveform_pyramid_blobs
|
||||||
|
.iter()
|
||||||
|
.map(|(&idx, blob)| (idx, blob.as_ref().clone()))
|
||||||
|
.collect();
|
||||||
|
let (thumbnail_snapshot, complete_thumbnail_clips) = {
|
||||||
|
let vm = self.video_manager.lock().unwrap();
|
||||||
|
(vm.snapshot_all_thumbnails(), vm.complete_thumbnail_clips())
|
||||||
|
};
|
||||||
|
FileCommand::Save {
|
||||||
|
path,
|
||||||
|
document,
|
||||||
|
layer_to_track_map: self.layer_to_track_map.clone(),
|
||||||
|
large_media_mode: self.config.large_media_default,
|
||||||
|
waveform_blobs,
|
||||||
|
thumbnail_snapshot,
|
||||||
|
complete_thumbnail_clips,
|
||||||
|
progress_tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn save_to_file(&mut self, path: std::path::PathBuf) {
|
fn save_to_file(&mut self, path: std::path::PathBuf) {
|
||||||
println!("Saving to: {}", path.display());
|
println!("Saving to: {}", path.display());
|
||||||
|
|
||||||
|
|
@ -4012,42 +4236,9 @@ impl EditorApp {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare document for save (including layout)
|
|
||||||
self.prepare_document_for_save();
|
|
||||||
|
|
||||||
// Create progress channel
|
// Create progress channel
|
||||||
let (progress_tx, progress_rx) = std::sync::mpsc::channel();
|
let (progress_tx, progress_rx) = std::sync::mpsc::channel();
|
||||||
|
let command = self.build_save_command(path.clone(), progress_tx);
|
||||||
// Clone document for background thread
|
|
||||||
let document = self.action_executor.document().clone();
|
|
||||||
|
|
||||||
// Snapshot the generated waveform pyramids (by pool index) so the worker
|
|
||||||
// can persist them into the container alongside the audio.
|
|
||||||
let waveform_blobs: std::collections::HashMap<usize, Vec<u8>> = self
|
|
||||||
.waveform_pyramid_blobs
|
|
||||||
.iter()
|
|
||||||
.map(|(&idx, blob)| (idx, blob.as_ref().clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Snapshot all video thumbnails (cheap Arc-clone) + which clips finished
|
|
||||||
// generating; the worker PNG-encodes them with a complete/partial flag so a
|
|
||||||
// save mid-generation persists progress and resumes on load.
|
|
||||||
let (thumbnail_snapshot, complete_thumbnail_clips) = {
|
|
||||||
let vm = self.video_manager.lock().unwrap();
|
|
||||||
(vm.snapshot_all_thumbnails(), vm.complete_thumbnail_clips())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send save command to worker thread
|
|
||||||
let command = FileCommand::Save {
|
|
||||||
path: path.clone(),
|
|
||||||
document,
|
|
||||||
layer_to_track_map: self.layer_to_track_map.clone(),
|
|
||||||
large_media_mode: self.config.large_media_default,
|
|
||||||
waveform_blobs,
|
|
||||||
thumbnail_snapshot,
|
|
||||||
complete_thumbnail_clips,
|
|
||||||
progress_tx,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = self.file_command_tx.send(command) {
|
if let Err(e) = self.file_command_tx.send(command) {
|
||||||
eprintln!("❌ Failed to send save command: {}", e);
|
eprintln!("❌ Failed to send save command: {}", e);
|
||||||
|
|
@ -4061,6 +4252,301 @@ impl EditorApp {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark the current document state as the autosave baseline — there is nothing new to recover
|
||||||
|
/// (called after a manual save, and after the document is replaced by new/load). Clears the
|
||||||
|
/// pending-event flag and resets the throttle so the next real change schedules cleanly.
|
||||||
|
fn reset_autosave_baseline(&mut self) {
|
||||||
|
self.autosave.baseline_epoch = self.action_executor.epoch();
|
||||||
|
self.autosave.pending_event = false;
|
||||||
|
self.autosave.last_time = None;
|
||||||
|
// A fresh/loaded document also starts unmodified vs. its on-disk form.
|
||||||
|
self.saved_epoch = self.action_executor.epoch();
|
||||||
|
self.media_modified = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the document has unsaved changes vs. the user's file (edits since the last manual
|
||||||
|
/// save, or an import/recording that `epoch` doesn't track). Recovered work counts as unsaved
|
||||||
|
/// until the user gives it a real home — its only copy is the transient recovery container.
|
||||||
|
fn document_modified(&self) -> bool {
|
||||||
|
self.action_executor.epoch() != self.saved_epoch
|
||||||
|
|| self.media_modified
|
||||||
|
|| self
|
||||||
|
.current_file_path
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| AutosaveState::is_recovery_path(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background crash-recovery autosave: poll any in-flight recovery write, then (if the document
|
||||||
|
/// is dirty, throttled to `AUTOSAVE_INTERVAL_SECS`, and no manual save is running) dispatch a
|
||||||
|
/// write of the current state into the per-session recovery container. Fully background — reuses
|
||||||
|
/// the file worker; the only UI-thread cost is the same document/side-data snapshot a manual
|
||||||
|
/// save does. Never touches `current_file_path` or the raster `dirty` flags (the recovery file
|
||||||
|
/// is a separate container from the user's file).
|
||||||
|
fn maybe_autosave(&mut self, ctx: &egui::Context) {
|
||||||
|
// Drain progress from an in-flight recovery write.
|
||||||
|
if let Some(rx) = &self.autosave.progress_rx {
|
||||||
|
while let Ok(p) = rx.try_recv() {
|
||||||
|
match p {
|
||||||
|
FileProgress::Done => self.autosave.in_flight = false,
|
||||||
|
FileProgress::Error(e) => {
|
||||||
|
eprintln!("⚠️ [AUTOSAVE] recovery write failed: {}", e);
|
||||||
|
self.autosave.in_flight = false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.autosave.in_flight {
|
||||||
|
self.autosave.progress_rx = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(recovery_path) = self.autosave.recovery_path.clone() else { return };
|
||||||
|
if self.autosave.in_flight || self.audio_controller.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Don't compete with a manual save on the single worker.
|
||||||
|
if matches!(self.file_operation, Some(FileOperation::Saving { .. })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let epoch = self.action_executor.epoch();
|
||||||
|
let dirty = epoch != self.autosave.baseline_epoch || self.autosave.pending_event;
|
||||||
|
if !dirty {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(t) = self.autosave.last_time {
|
||||||
|
let elapsed = t.elapsed().as_secs_f64();
|
||||||
|
if elapsed < self.autosave.interval_secs {
|
||||||
|
// Dirty but throttled — wake up when the interval elapses even if the app goes idle,
|
||||||
|
// so an edit-then-idle session still gets its recovery snapshot.
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_secs_f64(
|
||||||
|
(self.autosave.interval_secs - elapsed).max(0.1),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let command = self.build_save_command(recovery_path, tx);
|
||||||
|
if self.file_command_tx.send(command).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.autosave.in_flight = true;
|
||||||
|
self.autosave.progress_rx = Some(rx);
|
||||||
|
self.autosave.baseline_epoch = epoch;
|
||||||
|
self.autosave.pending_event = false;
|
||||||
|
self.autosave.last_time = Some(std::time::Instant::now());
|
||||||
|
eprintln!("💾 [AUTOSAVE] recovery snapshot dispatched");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the crash-recovery prompt when a previous session left a recovery file behind. Recover
|
||||||
|
/// loads it as an untitled document; Discard deletes it; Later keeps it for the next launch.
|
||||||
|
fn render_recovery_prompt(&mut self, ctx: &egui::Context) {
|
||||||
|
// Don't prompt over an in-flight file op (including a recovery load we just started).
|
||||||
|
if self.autosave.leftover_recoveries.is_empty() || self.file_operation.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = self.autosave.leftover_recoveries[0].clone();
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum Choice { Recover, Discard, Later }
|
||||||
|
let mut choice: Option<Choice> = None;
|
||||||
|
|
||||||
|
egui::Modal::new(egui::Id::new("crash_recovery_modal")).show(ctx, |ui| {
|
||||||
|
ui.set_width(crate::mobile::dialog_width(ctx, 460.0));
|
||||||
|
ui.heading("Recover unsaved work?");
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.label(
|
||||||
|
"Lightningbeam didn't shut down cleanly last time. You have unsaved work from your \
|
||||||
|
previous session — recover it?",
|
||||||
|
);
|
||||||
|
if self.autosave.leftover_recoveries.len() > 1 {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.weak(format!(
|
||||||
|
"{} snapshots available; this shows the most recent first.",
|
||||||
|
self.autosave.leftover_recoveries.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ui.add_space(14.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Recover").clicked() {
|
||||||
|
choice = Some(Choice::Recover);
|
||||||
|
}
|
||||||
|
if ui.button("Discard").clicked() {
|
||||||
|
choice = Some(Choice::Discard);
|
||||||
|
}
|
||||||
|
if ui.button("Later").clicked() {
|
||||||
|
choice = Some(Choice::Later);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
match choice {
|
||||||
|
Some(Choice::Recover) => {
|
||||||
|
self.autosave.leftover_recoveries.remove(0);
|
||||||
|
// Rename out of the `session-*` namespace before opening it, so it isn't offered
|
||||||
|
// again next launch — but keep the file, since recovered raster keyframes page in
|
||||||
|
// from it on demand (deleting it would lose paged pixels). It opens as the current
|
||||||
|
// file; the user relocates the work with Save As. Old `recovered-*` files are
|
||||||
|
// garbage-collected at startup.
|
||||||
|
let open_path = match path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
Some(name) => {
|
||||||
|
let renamed =
|
||||||
|
path.with_file_name(name.replacen("session-", "recovered-", 1));
|
||||||
|
if std::fs::rename(&path, &renamed).is_ok() { renamed } else { path }
|
||||||
|
}
|
||||||
|
None => path,
|
||||||
|
};
|
||||||
|
self.load_from_file(open_path);
|
||||||
|
}
|
||||||
|
Some(Choice::Discard) => {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
self.autosave.leftover_recoveries.remove(0);
|
||||||
|
}
|
||||||
|
Some(Choice::Later) => {
|
||||||
|
// Keep the files on disk but stop prompting this session.
|
||||||
|
self.autosave.leftover_recoveries.clear();
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single "save changes?" prompt for any unsaved-work exit point — file switches (New /
|
||||||
|
/// Open / Open Recent) and quitting. Also intercepts the window-close request. Save persists
|
||||||
|
/// first (Save As for untitled/recovered docs) then runs the action; Don't Save runs it and
|
||||||
|
/// discards; Cancel stays put.
|
||||||
|
fn render_unsaved_prompt(&mut self, ctx: &egui::Context) {
|
||||||
|
// Intercept a window-close request with unsaved work → veto it and queue the Quit prompt.
|
||||||
|
if ctx.input(|i| i.viewport().close_requested()) && !self.confirmed_close {
|
||||||
|
if self.document_modified() {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||||
|
self.unsaved_prompt = Some(PendingAction::Quit);
|
||||||
|
}
|
||||||
|
// Unmodified → let the close proceed.
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(pending) = self.unsaved_prompt.as_ref() else { return };
|
||||||
|
// The "…before X?" tail + the affirmative button label, per action.
|
||||||
|
let (desc, discard_label) = match pending {
|
||||||
|
PendingAction::NewFile => ("starting a new file", "Don't Save"),
|
||||||
|
PendingAction::Open(_) => ("opening another file", "Don't Save"),
|
||||||
|
PendingAction::Quit => ("quitting", "Discard & Quit"),
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum Answer {
|
||||||
|
Save,
|
||||||
|
Discard,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
let mut answer: Option<Answer> = None;
|
||||||
|
|
||||||
|
egui::Modal::new(egui::Id::new("unsaved_changes_modal")).show(ctx, |ui| {
|
||||||
|
ui.set_width(crate::mobile::dialog_width(ctx, 440.0));
|
||||||
|
ui.heading("Save changes?");
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.label(format!(
|
||||||
|
"This document has unsaved changes. Save them before {desc}?"
|
||||||
|
));
|
||||||
|
ui.add_space(14.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Save").clicked() {
|
||||||
|
answer = Some(Answer::Save);
|
||||||
|
}
|
||||||
|
if ui.button(discard_label).clicked() {
|
||||||
|
answer = Some(Answer::Discard);
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
answer = Some(Answer::Cancel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
match answer {
|
||||||
|
Some(Answer::Cancel) => {
|
||||||
|
self.unsaved_prompt = None;
|
||||||
|
}
|
||||||
|
Some(Answer::Discard) => {
|
||||||
|
if let Some(action) = self.unsaved_prompt.take() {
|
||||||
|
self.do_action(action, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Answer::Save) => {
|
||||||
|
let action = self.unsaved_prompt.take();
|
||||||
|
// Save to the existing file, or Save As for an untitled / recovered document.
|
||||||
|
let real_path = self
|
||||||
|
.current_file_path
|
||||||
|
.clone()
|
||||||
|
.filter(|p| !AutosaveState::is_recovery_path(p));
|
||||||
|
let target = match real_path {
|
||||||
|
Some(p) => Some(p),
|
||||||
|
None => rfd::FileDialog::new()
|
||||||
|
.add_filter("Lightningbeam Project", &["beam"])
|
||||||
|
.set_file_name("Untitled.beam")
|
||||||
|
.save_file(),
|
||||||
|
};
|
||||||
|
match target {
|
||||||
|
Some(path) => {
|
||||||
|
// Run the action once the save completes.
|
||||||
|
self.after_save = action;
|
||||||
|
self.save_to_file(path);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Save As cancelled → abort, keep the document (still unsaved).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// New File: tear down the current project and return to the start screen. (The guard for
|
||||||
|
/// unsaved changes lives in the menu handler; this is the actual action.)
|
||||||
|
fn do_new_file(&mut self) {
|
||||||
|
// 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 the 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.pane_instances.clear();
|
||||||
|
self.project_generation += 1;
|
||||||
|
self.app_mode = AppMode::StartScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carry out a deferred action once the unsaved-changes prompt is resolved (or when there was
|
||||||
|
/// nothing unsaved to begin with).
|
||||||
|
fn do_action(&mut self, action: PendingAction, ctx: &egui::Context) {
|
||||||
|
match action {
|
||||||
|
PendingAction::NewFile => self.do_new_file(),
|
||||||
|
PendingAction::Open(path) => self.load_from_file(path),
|
||||||
|
PendingAction::Quit => {
|
||||||
|
self.confirmed_close = true;
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin a file switch (New / Open / Open Recent), prompting to save first if the document has
|
||||||
|
/// unsaved changes. Only ever called with `NewFile`/`Open` from the menus, so the immediate path
|
||||||
|
/// needs no `ctx` (only `Quit`, driven by the close interceptor, does).
|
||||||
|
fn request_switch(&mut self, action: PendingAction) {
|
||||||
|
if self.document_modified() {
|
||||||
|
self.unsaved_prompt = Some(action);
|
||||||
|
} else {
|
||||||
|
match action {
|
||||||
|
PendingAction::NewFile => self.do_new_file(),
|
||||||
|
PendingAction::Open(path) => self.load_from_file(path),
|
||||||
|
PendingAction::Quit => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load a document from a .beam file
|
/// Load a document from a .beam file
|
||||||
fn load_from_file(&mut self, path: std::path::PathBuf) {
|
fn load_from_file(&mut self, path: std::path::PathBuf) {
|
||||||
println!("Loading from: {}", path.display());
|
println!("Loading from: {}", path.display());
|
||||||
|
|
@ -4146,9 +4632,17 @@ impl EditorApp {
|
||||||
// TODO Phase 5: Show recovery dialog
|
// 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
|
// Replace document
|
||||||
let step1_start = std::time::Instant::now();
|
let step1_start = std::time::Instant::now();
|
||||||
self.action_executor = ActionExecutor::new(loaded_project.document);
|
self.action_executor = ActionExecutor::new(loaded_project.document);
|
||||||
|
// Freshly loaded document is clean → rebase the autosave epoch (no spurious recovery write).
|
||||||
|
self.reset_autosave_baseline();
|
||||||
eprintln!("📊 [APPLY] Step 1: Replace document took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
eprintln!("📊 [APPLY] Step 1: Replace document took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
|
||||||
// Restore UI layout from loaded document
|
// Restore UI layout from loaded document
|
||||||
|
|
@ -4429,9 +4923,12 @@ impl EditorApp {
|
||||||
// Point the raster paging store at the loaded container so faulting works.
|
// Point the raster paging store at the loaded container so faulting works.
|
||||||
self.raster_store.set_path(self.current_file_path.clone());
|
self.raster_store.set_path(self.current_file_path.clone());
|
||||||
|
|
||||||
// Add to recent files
|
// Add to recent files — but never a recovery file (it's an internal, transient container in
|
||||||
|
// the app data dir, not a project the user opened).
|
||||||
|
if !AutosaveState::is_recovery_path(&path) {
|
||||||
self.config.add_recent_file(path.clone());
|
self.config.add_recent_file(path.clone());
|
||||||
self.update_recent_files_menu();
|
self.update_recent_files_menu();
|
||||||
|
}
|
||||||
|
|
||||||
// Set active layer
|
// Set active layer
|
||||||
if let Some(first) = self.action_executor.document().root.children.first() {
|
if let Some(first) = self.action_executor.document().root.children.first() {
|
||||||
|
|
@ -4687,6 +5184,9 @@ impl EditorApp {
|
||||||
|
|
||||||
fn import_image(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
fn import_image(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
||||||
use lightningbeam_core::clip::ImageAsset;
|
use lightningbeam_core::clip::ImageAsset;
|
||||||
|
// Imported media lives outside the action/undo system, so flag it for the next autosave.
|
||||||
|
self.autosave.pending_event = true;
|
||||||
|
self.media_modified = true;
|
||||||
self.note_possible_large_media(path);
|
self.note_possible_large_media(path);
|
||||||
|
|
||||||
// Get filename for asset name
|
// Get filename for asset name
|
||||||
|
|
@ -4740,6 +5240,8 @@ impl EditorApp {
|
||||||
/// GPU waveform cache.
|
/// GPU waveform cache.
|
||||||
fn import_audio(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
fn import_audio(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
||||||
use lightningbeam_core::clip::AudioClip;
|
use lightningbeam_core::clip::AudioClip;
|
||||||
|
self.autosave.pending_event = true;
|
||||||
|
self.media_modified = true;
|
||||||
self.note_possible_large_media(path);
|
self.note_possible_large_media(path);
|
||||||
|
|
||||||
let name = path.file_stem()
|
let name = path.file_stem()
|
||||||
|
|
@ -4866,6 +5368,8 @@ impl EditorApp {
|
||||||
fn import_video(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
fn import_video(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
||||||
use lightningbeam_core::clip::VideoClip;
|
use lightningbeam_core::clip::VideoClip;
|
||||||
use lightningbeam_core::video::probe_video;
|
use lightningbeam_core::video::probe_video;
|
||||||
|
self.autosave.pending_event = true;
|
||||||
|
self.media_modified = true;
|
||||||
self.note_possible_large_media(path);
|
self.note_possible_large_media(path);
|
||||||
|
|
||||||
let name = path.file_stem()
|
let name = path.file_stem()
|
||||||
|
|
@ -5408,6 +5912,14 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for EditorApp {
|
impl eframe::App for EditorApp {
|
||||||
|
/// Clean shutdown → not a crash → delete this session's recovery file so it isn't offered for
|
||||||
|
/// restore on the next launch. (If we crash instead, `on_exit` never runs and the file remains.)
|
||||||
|
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||||
|
if let Some(path) = self.autosave.recovery_path.take() {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||||
self.tablet.poll(ctx, raw_input, self.selected_tool);
|
self.tablet.poll(ctx, raw_input, self.selected_tool);
|
||||||
|
|
||||||
|
|
@ -5426,6 +5938,13 @@ impl eframe::App for EditorApp {
|
||||||
mobile::apply_touch_style(ctx);
|
mobile::apply_touch_style(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Background crash-recovery autosave (cheap early-out unless dirty + interval elapsed).
|
||||||
|
self.maybe_autosave(ctx);
|
||||||
|
// Offer to restore a previous session's unsaved work (if a recovery file was left behind).
|
||||||
|
self.render_recovery_prompt(ctx);
|
||||||
|
// Prompt to save unsaved changes before switching files (New / Open / Open Recent).
|
||||||
|
self.render_unsaved_prompt(ctx);
|
||||||
|
|
||||||
// === Raster fault-in (Phase 3 paging) ===
|
// === Raster fault-in (Phase 3 paging) ===
|
||||||
// The canvas records raster keyframe ids whose `raw_pixels` weren't resident
|
// The canvas records raster keyframe ids whose `raw_pixels` weren't resident
|
||||||
// (it can't mutate the document while rendering). Drain that sink here, BEFORE
|
// (it can't mutate the document while rendering). Drain that sink here, BEFORE
|
||||||
|
|
@ -5665,6 +6184,9 @@ impl eframe::App for EditorApp {
|
||||||
let mut operation_complete = false;
|
let mut operation_complete = false;
|
||||||
let mut loaded_project_data: Option<(lightningbeam_core::file_io::LoadedProject, std::path::PathBuf)> = None;
|
let mut loaded_project_data: Option<(lightningbeam_core::file_io::LoadedProject, std::path::PathBuf)> = None;
|
||||||
let mut update_recent_menu = false; // Track if we need to update recent files menu
|
let mut update_recent_menu = false; // Track if we need to update recent files menu
|
||||||
|
// An action that was waiting on this save (from the unsaved-changes prompt), to run
|
||||||
|
// after the file_operation borrow ends.
|
||||||
|
let mut after_save: Option<PendingAction> = None;
|
||||||
|
|
||||||
match operation {
|
match operation {
|
||||||
FileOperation::Saving { ref mut progress_rx, ref path } => {
|
FileOperation::Saving { ref mut progress_rx, ref path } => {
|
||||||
|
|
@ -5673,6 +6195,18 @@ impl eframe::App for EditorApp {
|
||||||
FileProgress::Done => {
|
FileProgress::Done => {
|
||||||
println!("✅ Save complete!");
|
println!("✅ Save complete!");
|
||||||
self.current_file_path = Some(path.clone());
|
self.current_file_path = Some(path.clone());
|
||||||
|
// Manual save persisted everything to the user's file → no unsaved
|
||||||
|
// work to recover; rebase the autosave epoch so it stays quiet until
|
||||||
|
// the next real edit. (Inlined rather than reset_autosave_baseline()
|
||||||
|
// to avoid a second &mut self borrow inside the file_operation match.)
|
||||||
|
self.autosave.baseline_epoch = self.action_executor.epoch();
|
||||||
|
self.autosave.pending_event = false;
|
||||||
|
self.autosave.last_time = None;
|
||||||
|
// The document now matches its file → no unsaved changes.
|
||||||
|
self.saved_epoch = self.action_executor.epoch();
|
||||||
|
self.media_modified = false;
|
||||||
|
// If a file switch was waiting on this save, run it after the borrow.
|
||||||
|
after_save = self.after_save.take();
|
||||||
// Container path may be new (Save As); update the
|
// Container path may be new (Save As); update the
|
||||||
// raster paging store so future faults read the right file.
|
// raster paging store so future faults read the right file.
|
||||||
self.raster_store.set_path(self.current_file_path.clone());
|
self.raster_store.set_path(self.current_file_path.clone());
|
||||||
|
|
@ -5790,6 +6324,11 @@ impl eframe::App for EditorApp {
|
||||||
self.update_recent_files_menu();
|
self.update_recent_files_menu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An action that was waiting on this save ("Save" in the prompt → New/Open/Quit) runs now.
|
||||||
|
if let Some(action) = after_save {
|
||||||
|
self.do_action(action, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
// Request repaint to keep updating progress
|
// Request repaint to keep updating progress
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
@ -6003,6 +6542,9 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
if !clip_id.is_nil() {
|
if !clip_id.is_nil() {
|
||||||
// Finalize the clip (update pool_index and duration)
|
// Finalize the clip (update pool_index and duration)
|
||||||
|
// A finished recording (samples in the pool) needs capturing.
|
||||||
|
self.autosave.pending_event = true;
|
||||||
|
self.media_modified = true;
|
||||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) {
|
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) {
|
||||||
if clip.finalize_recording(pool_index, duration) {
|
if clip.finalize_recording(pool_index, duration) {
|
||||||
clip.name = format!("Recording {}", pool_index);
|
clip.name = format!("Recording {}", pool_index);
|
||||||
|
|
@ -6326,9 +6868,29 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
false // synchronous; no progress dialog
|
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) => {
|
ExportResult::AudioOnly(settings, output_path) => {
|
||||||
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());
|
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 {
|
if let Some(audio_controller) = &self.audio_controller {
|
||||||
orchestrator.start_audio_export(
|
orchestrator.start_audio_export(
|
||||||
settings,
|
settings,
|
||||||
|
|
@ -6480,6 +7042,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).
|
// Drive single-frame image export (two-frame async: render then readback).
|
||||||
match orchestrator.render_image_frame(
|
match orchestrator.render_image_frame(
|
||||||
self.action_executor.document_mut(),
|
self.action_executor.document_mut(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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.
|
/// 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 {
|
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))
|
desired.min(avail.max(200.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ pub struct MobileNodeState {
|
||||||
pub mode: NodeViewMode,
|
pub mode: NodeViewMode,
|
||||||
/// The module currently shown in Focus (and centred in Patch).
|
/// The module currently shown in Focus (and centred in Patch).
|
||||||
pub focus_node: Option<NodeId>,
|
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.
|
/// Whether the add-node picker overlay is open.
|
||||||
pub show_add: bool,
|
pub show_add: bool,
|
||||||
/// Search filter in the add-node picker.
|
/// Search filter in the add-node picker.
|
||||||
|
|
@ -51,7 +49,6 @@ impl Default for MobileNodeState {
|
||||||
Self {
|
Self {
|
||||||
mode: NodeViewMode::Focus,
|
mode: NodeViewMode::Focus,
|
||||||
focus_node: None,
|
focus_node: None,
|
||||||
patch_source: None,
|
|
||||||
show_add: false,
|
show_add: false,
|
||||||
add_search: String::new(),
|
add_search: String::new(),
|
||||||
patch_pick: None,
|
patch_pick: None,
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ impl PreferencesDialog {
|
||||||
// mobile modals; on desktop, the familiar draggable window.
|
// mobile modals; on desktop, the familiar draggable window.
|
||||||
let width = crate::mobile::dialog_width(ctx, 550.0);
|
let width = crate::mobile::dialog_width(ctx, 550.0);
|
||||||
let scroll_h = if mobile {
|
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 {
|
} else {
|
||||||
400.0
|
400.0
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue