Fix FLAC export end-to-end (real backend path) + smart tag defaults

The prior audio-tags commit put real FLAC + metadata into export/audio_exporter.rs
— which turned out to be dead code (declared, never called; whole file was
EngineController::start_export_audio → daw-backend's export_audio, which still
routed FLAC to the erroring hound stub — hence "not implemented in daw-backend".

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

Smart tag defaults (filled only when empty, never clobbering edits):
- Year → current civil year, computed from the system clock with i64 math (no
  date crate; correct past 2038/2106 — tests cover post-i32/u32 timestamps).
- Artist → last-used value, else the OS username ($USER/%USERNAME%).
- Album → last-used value.
Last-used Artist/Album persist in AppConfig and prefill next export.
This commit is contained in:
Skyler Lehmkuhl 2026-07-09 12:53:36 -04:00
parent 15bdf80ec1
commit 6e6feaddf5
5 changed files with 386 additions and 30 deletions

View File

@ -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,17 +297,175 @@ fn write_wav<P: AsRef<Path>>(
Ok(()) Ok(())
} }
/// FLAC export is not implemented in the backend. It previously wrote WAV bytes to a `.flac` file /// Export real FLAC via ffmpeg from already-rendered interleaved f32 samples (Vorbis-comment
/// via `hound` — a real, silent misrepresentation (the output was not FLAC at all). The UI now /// metadata). Replaces the former `write_flac`, which wrote WAV bytes to a `.flac` file. 16-bit
/// encodes FLAC properly with ffmpeg (`export/audio_exporter.rs::export_audio_ffmpeg_flac`), so this /// uses S16; 24-bit uses S32 (ffmpeg's flac encoder emits `bits_per_raw_sample = 24` for S32,
/// path is unused; rather than lie, it returns an error if anything reaches it. /// taking the top 24 bits).
fn write_flac<P: AsRef<Path>>( fn export_flac<P: AsRef<Path>>(
_samples: &[f32], samples: &[f32],
_settings: &ExportSettings, settings: &ExportSettings,
_output_path: P, output_path: P,
) -> Result<(), String> { ) -> Result<(), String> {
Err("FLAC export is not implemented in daw-backend; use the ffmpeg encoder in the UI \ use ffmpeg_next as ffmpeg;
(export_audio_ffmpeg_flac). This path formerly wrote mislabeled WAV bytes.".to_string())
ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::FLAC)
.ok_or("FLAC encoder not found in this ffmpeg build")?;
let mut output = ffmpeg::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
let channel_layout = match settings.channels {
1 => ffmpeg::channel_layout::ChannelLayout::MONO,
2 => ffmpeg::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
};
// FLAC accepts packed S16 or S32; S32 → 24-bit output.
let use_24 = settings.bit_depth >= 24;
let sample_fmt = if use_24 {
ffmpeg::format::Sample::I32(ffmpeg::format::sample::Type::Packed)
} else {
ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Packed)
};
let mut encoder = ffmpeg::codec::Context::new_with_codec(codec)
.encoder()
.audio()
.map_err(|e| format!("Failed to create FLAC encoder: {}", e))?;
encoder.set_rate(settings.sample_rate as i32);
encoder.set_channel_layout(channel_layout);
encoder.set_format(sample_fmt);
encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32));
let mut encoder = encoder.open_as(codec)
.map_err(|e| format!("Failed to open FLAC encoder: {}", e))?;
{
let mut stream = output.add_stream(codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
}
apply_metadata(&mut output, &settings.metadata);
output.write_header()
.map_err(|e| format!("Failed to write FLAC header: {}", e))?;
let channels = settings.channels as usize;
let num_frames = samples.len() / channels;
let frame_size = if encoder.frame_size() > 0 { encoder.frame_size() as usize } else { 4096 };
let mut done = 0usize;
while done < num_frames {
let n = (num_frames - done).min(frame_size);
let mut frame = ffmpeg::frame::Audio::new(sample_fmt, n, channel_layout);
frame.set_rate(settings.sample_rate);
frame.set_pts(Some(done as i64)); // samples; the FLAC muxer requires PTS
let buf = frame.data_mut(0); // packed interleaved → plane 0
let base = done * channels;
if use_24 {
for i in 0..n * channels {
let s = samples[base + i].clamp(-1.0, 1.0);
let v = (s as f64 * 2_147_483_647.0) as i32; // full-scale S32; encoder takes top 24
buf[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
} else {
for i in 0..n * channels {
let s = samples[base + i].clamp(-1.0, 1.0);
let v = (s * 32767.0) as i16;
buf[i * 2..i * 2 + 2].copy_from_slice(&v.to_le_bytes());
}
}
encoder.send_frame(&frame).map_err(|e| format!("Failed to send FLAC frame: {}", e))?;
flac_write_packets(&mut encoder, &mut output)?;
done += n;
}
encoder.send_eof().map_err(|e| format!("Failed to flush FLAC encoder: {}", e))?;
flac_write_packets(&mut encoder, &mut output)?;
output.write_trailer().map_err(|e| format!("Failed to finalize FLAC: {}", e))?;
Ok(())
}
/// Drain encoded FLAC packets and write them (non-interleaved). Skips the trailing empty flush
/// packet, which the FLAC muxer otherwise rejects as "Invalid data". Rescales packet ts from the
/// encoder time base to the stream's.
fn flac_write_packets(
encoder: &mut ffmpeg_next::encoder::Audio,
output: &mut ffmpeg_next::format::context::Output,
) -> Result<(), String> {
let mut pkt = ffmpeg_next::Packet::empty();
let enc_tb = encoder.time_base();
let stream_tb = output.stream(0).map(|s| s.time_base()).unwrap_or(enc_tb);
while encoder.receive_packet(&mut pkt).is_ok() {
if pkt.size() == 0 {
continue;
}
pkt.set_stream(0);
pkt.rescale_ts(enc_tb, stream_tb);
pkt.write(output).map_err(|e| format!("Failed to write FLAC packet: {}", e))?;
}
Ok(())
}
/// Append a RIFF `LIST`/`INFO` metadata chunk to a finished WAV file (hound writes no tags), then
/// fix up the top-level RIFF size. Maps ffmpeg-style keys to RIFF INFO sub-chunk IDs. Trailing INFO
/// chunks are ignored by players that don't read them.
fn append_wav_info_chunk(path: &Path, metadata: &[(String, String)]) -> Result<(), String> {
use std::io::{Seek, SeekFrom, Write};
let riff_id = |key: &str| -> Option<&'static [u8; 4]> {
match key {
"title" => Some(b"INAM"),
"artist" => Some(b"IART"),
"album" => Some(b"IPRD"),
"genre" => Some(b"IGNR"),
"comment" => Some(b"ICMT"),
"date" => Some(b"ICRD"),
"track" => Some(b"ITRK"),
_ => None,
}
};
let mut info: Vec<u8> = Vec::new();
info.extend_from_slice(b"INFO");
for (key, val) in metadata {
if val.is_empty() {
continue;
}
let Some(id) = riff_id(key) else { continue };
let mut bytes = val.as_bytes().to_vec();
bytes.push(0); // NUL-terminate
if bytes.len() % 2 == 1 {
bytes.push(0); // pad to even
}
info.extend_from_slice(id);
info.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
info.extend_from_slice(&bytes);
}
if info.len() <= 4 {
return Ok(()); // nothing but the "INFO" tag
}
let mut list: Vec<u8> = Vec::with_capacity(info.len() + 8);
list.extend_from_slice(b"LIST");
list.extend_from_slice(&(info.len() as u32).to_le_bytes());
list.extend_from_slice(&info);
let mut f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|e| format!("Failed to open WAV for tagging: {}", e))?;
let end = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?;
if end % 2 == 1 {
f.write_all(&[0]).map_err(|e| e.to_string())?;
}
f.write_all(&list).map_err(|e| format!("Failed to write WAV tags: {}", e))?;
let new_len = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?;
f.seek(SeekFrom::Start(4)).map_err(|e| e.to_string())?;
f.write_all(&((new_len - 8) as u32).to_le_bytes())
.map_err(|e| format!("Failed to update RIFF size: {}", e))?;
Ok(())
} }
/// Export audio as MP3 using FFmpeg (streaming - render and encode simultaneously) /// Export audio as MP3 using FFmpeg (streaming - render and encode simultaneously)
@ -330,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))?;
@ -499,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))?;
@ -806,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();
}
} }

View File

@ -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(),
} }
} }
} }

View File

@ -11,6 +11,36 @@ use lightningbeam_core::export::{
}; };
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,
@ -129,7 +159,14 @@ 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;
@ -153,10 +190,23 @@ 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
}; };
// Default the audio "Title" tag to the project name (only if the user hasn't set one for a // Sensible tag defaults, only filled when empty so a user's edits are never clobbered:
// different project — don't clobber an in-progress edit). // • Title → project name (on a project switch)
if self.audio_settings.metadata.title.is_empty() && !same_project { // • Year → current year
self.audio_settings.metadata.title = project_name.to_owned(); // • 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();
@ -493,6 +543,14 @@ impl ExportDialog {
fn render_audio_metadata(&mut self, ui: &mut egui::Ui) { fn render_audio_metadata(&mut self, ui: &mut egui::Ui) {
let m = &mut self.audio_settings.metadata; let m = &mut self.audio_settings.metadata;
ui.label(egui::RichText::new("Tags").strong()); 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") egui::Grid::new("audio_metadata_grid")
.num_columns(2) .num_columns(2)
.spacing([8.0, 4.0]) .spacing([8.0, 4.0])
@ -501,18 +559,18 @@ impl ExportDialog {
ui.label(label); ui.label(label);
ui.add( ui.add(
egui::TextEdit::singleline(val) egui::TextEdit::singleline(val)
.hint_text(hint) .hint_text(egui::RichText::new(hint).italics().color(hint_color))
.desired_width(260.0), .desired_width(260.0),
); );
ui.end_row(); ui.end_row();
}; };
row(ui, "Title", &mut m.title, "Track title"); row(ui, "Title", &mut m.title, "e.g. My Song");
row(ui, "Artist", &mut m.artist, "Artist"); row(ui, "Artist", &mut m.artist, "e.g. Jane Doe");
row(ui, "Album", &mut m.album, "Album"); row(ui, "Album", &mut m.album, "e.g. Greatest Hits");
row(ui, "Genre", &mut m.genre, "Genre"); row(ui, "Genre", &mut m.genre, "e.g. Electronic");
row(ui, "Year", &mut m.year, "e.g. 2026"); row(ui, "Year", &mut m.year, &year_hint);
row(ui, "Track", &mut m.track, "e.g. 1 or 1/12"); row(ui, "Track", &mut m.track, "e.g. 1 or 1/12");
row(ui, "Comment", &mut m.comment, "Comment"); row(ui, "Comment", &mut m.comment, "Optional notes…");
}); });
} }
@ -973,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}");
}
}

View File

@ -3,7 +3,6 @@
//! 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 gif_exporter;
pub mod image_exporter; pub mod image_exporter;
@ -1008,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

View File

@ -3443,7 +3443,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");
@ -6369,6 +6375,15 @@ impl eframe::App for EditorApp {
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,