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:
parent
15bdf80ec1
commit
6e6feaddf5
|
|
@ -50,6 +50,10 @@ pub struct ExportSettings {
|
|||
pub end_time: Seconds,
|
||||
/// Tempo map for beat-position scheduling
|
||||
pub tempo_map: TempoMap,
|
||||
/// Tag metadata as (ffmpeg-key, value) pairs (e.g. ("title", "…"), ("artist", "…")). Written to
|
||||
/// the container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis comments (FLAC), RIFF INFO
|
||||
/// (WAV). Empty = no tags.
|
||||
pub metadata: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Default for ExportSettings {
|
||||
|
|
@ -63,10 +67,26 @@ impl Default for ExportSettings {
|
|||
start_time: Seconds::ZERO,
|
||||
end_time: Seconds(60.0),
|
||||
tempo_map: TempoMap::constant(120.0),
|
||||
metadata: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set tag metadata on an ffmpeg output context (before `write_header`). FFmpeg maps the standard
|
||||
/// keys to each container's native tags.
|
||||
fn apply_metadata(output: &mut ffmpeg_next::format::context::Output, metadata: &[(String, String)]) {
|
||||
if metadata.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut dict = ffmpeg_next::Dictionary::new();
|
||||
for (k, v) in metadata {
|
||||
if !v.is_empty() {
|
||||
dict.set(k, v);
|
||||
}
|
||||
}
|
||||
output.set_metadata(dict);
|
||||
}
|
||||
|
||||
/// Export the project to an audio file
|
||||
///
|
||||
/// This performs offline rendering, processing the entire timeline
|
||||
|
|
@ -108,17 +128,21 @@ pub fn export_audio<P: AsRef<Path>>(
|
|||
// Route to appropriate export implementation based on format.
|
||||
// Ensure export mode is disabled even if an error occurs.
|
||||
let result = match settings.format {
|
||||
ExportFormat::Wav | ExportFormat::Flac => {
|
||||
ExportFormat::Wav => {
|
||||
let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?;
|
||||
// Signal that rendering is done and we're now writing the file
|
||||
if let Some(ref mut tx) = event_tx {
|
||||
let _ = tx.push(AudioEvent::ExportFinalizing);
|
||||
}
|
||||
match settings.format {
|
||||
ExportFormat::Wav => write_wav(&samples, settings, &output_path),
|
||||
ExportFormat::Flac => write_flac(&samples, settings, &output_path),
|
||||
_ => unreachable!(),
|
||||
write_wav(&samples, settings, &output_path)
|
||||
// hound writes no metadata; append a RIFF INFO chunk for tags.
|
||||
.and_then(|_| append_wav_info_chunk(output_path.as_ref(), &settings.metadata))
|
||||
}
|
||||
ExportFormat::Flac => {
|
||||
let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?;
|
||||
if let Some(ref mut tx) = event_tx {
|
||||
let _ = tx.push(AudioEvent::ExportFinalizing);
|
||||
}
|
||||
export_flac(&samples, settings, &output_path)
|
||||
}
|
||||
ExportFormat::Mp3 => {
|
||||
export_mp3(project, pool, settings, output_path, event_tx)
|
||||
|
|
@ -273,17 +297,175 @@ fn write_wav<P: AsRef<Path>>(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// FLAC export is not implemented in the backend. It previously wrote WAV bytes to a `.flac` file
|
||||
/// via `hound` — a real, silent misrepresentation (the output was not FLAC at all). The UI now
|
||||
/// encodes FLAC properly with ffmpeg (`export/audio_exporter.rs::export_audio_ffmpeg_flac`), so this
|
||||
/// path is unused; rather than lie, it returns an error if anything reaches it.
|
||||
fn write_flac<P: AsRef<Path>>(
|
||||
_samples: &[f32],
|
||||
_settings: &ExportSettings,
|
||||
_output_path: P,
|
||||
/// Export real FLAC via ffmpeg from already-rendered interleaved f32 samples (Vorbis-comment
|
||||
/// metadata). Replaces the former `write_flac`, which wrote WAV bytes to a `.flac` file. 16-bit
|
||||
/// uses S16; 24-bit uses S32 (ffmpeg's flac encoder emits `bits_per_raw_sample = 24` for S32,
|
||||
/// taking the top 24 bits).
|
||||
fn export_flac<P: AsRef<Path>>(
|
||||
samples: &[f32],
|
||||
settings: &ExportSettings,
|
||||
output_path: P,
|
||||
) -> Result<(), String> {
|
||||
Err("FLAC export is not implemented in daw-backend; use the ffmpeg encoder in the UI \
|
||||
(export_audio_ffmpeg_flac). This path formerly wrote mislabeled WAV bytes.".to_string())
|
||||
use ffmpeg_next as ffmpeg;
|
||||
|
||||
ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
|
||||
|
||||
let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::FLAC)
|
||||
.ok_or("FLAC encoder not found in this ffmpeg build")?;
|
||||
let mut output = ffmpeg::format::output(&output_path)
|
||||
.map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||
|
||||
let channel_layout = match settings.channels {
|
||||
1 => ffmpeg::channel_layout::ChannelLayout::MONO,
|
||||
2 => ffmpeg::channel_layout::ChannelLayout::STEREO,
|
||||
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
|
||||
};
|
||||
|
||||
// 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)
|
||||
|
|
@ -330,6 +512,7 @@ fn export_mp3<P: AsRef<Path>>(
|
|||
stream.set_parameters(&encoder);
|
||||
}
|
||||
|
||||
apply_metadata(&mut output, &settings.metadata);
|
||||
output.write_header()
|
||||
.map_err(|e| format!("Failed to write header: {}", e))?;
|
||||
|
||||
|
|
@ -499,6 +682,7 @@ fn export_aac<P: AsRef<Path>>(
|
|||
stream.set_parameters(&encoder);
|
||||
}
|
||||
|
||||
apply_metadata(&mut output, &settings.metadata);
|
||||
output.write_header()
|
||||
.map_err(|e| format!("Failed to write header: {}", e))?;
|
||||
|
||||
|
|
@ -806,4 +990,61 @@ mod tests {
|
|||
assert_eq!(ExportFormat::Wav.extension(), "wav");
|
||||
assert_eq!(ExportFormat::Flac.extension(), "flac");
|
||||
}
|
||||
|
||||
fn tagged_settings(format: ExportFormat) -> ExportSettings {
|
||||
ExportSettings {
|
||||
format,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
bit_depth: 24,
|
||||
mp3_bitrate: 192,
|
||||
start_time: Seconds::ZERO,
|
||||
end_time: Seconds(0.2), // tiny render
|
||||
tempo_map: TempoMap::constant(120.0),
|
||||
metadata: vec![
|
||||
("title".to_string(), "Test Title".to_string()),
|
||||
("artist".to_string(), "Test Artist".to_string()),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// FLAC export must be a real FLAC container (not WAV bytes) carrying Vorbis-comment tags.
|
||||
#[test]
|
||||
fn flac_export_is_real_flac_with_tags() {
|
||||
let settings = tagged_settings(ExportFormat::Flac);
|
||||
let mut project = Project::new(48000);
|
||||
let pool = AudioPool::new();
|
||||
let path = std::env::temp_dir().join("lb_be_flac_test.flac");
|
||||
|
||||
export_audio(&mut project, &pool, &settings, &path, None).expect("FLAC export failed");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
|
||||
assert_eq!(&bytes[0..4], b"fLaC", "not real FLAC (got {:?})", &bytes[0..4]);
|
||||
let s = String::from_utf8_lossy(&bytes);
|
||||
assert!(s.contains("Test Title"), "title tag missing from FLAC");
|
||||
assert!(s.contains("Test Artist"), "artist tag missing from FLAC");
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
/// WAV export keeps a valid RIFF container and gains a LIST/INFO tag chunk with a fixed-up size.
|
||||
#[test]
|
||||
fn wav_export_has_info_chunk() {
|
||||
let settings = tagged_settings(ExportFormat::Wav);
|
||||
let mut project = Project::new(48000);
|
||||
let pool = AudioPool::new();
|
||||
let path = std::env::temp_dir().join("lb_be_wav_test.wav");
|
||||
|
||||
export_audio(&mut project, &pool, &settings, &path, None).expect("WAV export failed");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
|
||||
assert_eq!(&bytes[0..4], b"RIFF");
|
||||
assert_eq!(&bytes[8..12], b"WAVE");
|
||||
let riff_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
|
||||
assert_eq!(riff_size, bytes.len() - 8, "RIFF size not fixed up after tagging");
|
||||
let s = String::from_utf8_lossy(&bytes);
|
||||
assert!(s.contains("LIST") && s.contains("INFO") && s.contains("INAM"),
|
||||
"no RIFF INFO chunk");
|
||||
assert!(s.contains("Test Title"), "title not in WAV INFO chunk");
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ pub struct AppConfig {
|
|||
/// sooner; larger = smaller pyramid, wider re-decode span. Default 256.
|
||||
#[serde(default = "defaults::waveform_floor_samples_per_texel")]
|
||||
pub waveform_floor_samples_per_texel: u32,
|
||||
|
||||
/// Last-used audio-export "Artist" tag, remembered so it prefills next time.
|
||||
#[serde(default)]
|
||||
pub last_audio_artist: String,
|
||||
|
||||
/// Last-used audio-export "Album" tag, remembered so it prefills next time.
|
||||
#[serde(default)]
|
||||
pub last_audio_album: String,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
|
|
@ -90,6 +98,8 @@ impl Default for AppConfig {
|
|||
keybindings: KeybindingConfig::default(),
|
||||
large_media_default: LargeMediaMode::default(),
|
||||
waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(),
|
||||
last_audio_artist: String::new(),
|
||||
last_audio_album: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,36 @@ use lightningbeam_core::export::{
|
|||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The OS username (`$USER` / `%USERNAME%`), or empty if unavailable. Used as a default Artist tag.
|
||||
fn os_username() -> String {
|
||||
std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Current civil year (UTC) computed from the system clock — avoids pulling in a date crate.
|
||||
fn current_year() -> i64 {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0) as i64;
|
||||
year_from_unix_secs(secs)
|
||||
}
|
||||
|
||||
/// Civil year (UTC) for a Unix timestamp, via Howard Hinnant's `civil_from_days` algorithm.
|
||||
fn year_from_unix_secs(secs: i64) -> i64 {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
y + if m <= 2 { 1 } else { 0 }
|
||||
}
|
||||
|
||||
/// Hint about document content, used to pick a smart default export type.
|
||||
pub struct DocumentHint {
|
||||
pub has_video: bool,
|
||||
|
|
@ -129,7 +159,14 @@ impl Default for ExportDialog {
|
|||
|
||||
impl ExportDialog {
|
||||
/// Open the dialog with default settings, using `hint` to pick a smart default tab.
|
||||
pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) {
|
||||
pub fn open(
|
||||
&mut self,
|
||||
timeline_duration: f64,
|
||||
project_name: &str,
|
||||
hint: &DocumentHint,
|
||||
last_artist: &str,
|
||||
last_album: &str,
|
||||
) {
|
||||
self.open = true;
|
||||
self.audio_settings.end_time = timeline_duration;
|
||||
self.video_settings.end_time = timeline_duration;
|
||||
|
|
@ -153,10 +190,23 @@ impl ExportDialog {
|
|||
else if only_raster { ExportType::Image }
|
||||
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
|
||||
// different project — don't clobber an in-progress edit).
|
||||
if self.audio_settings.metadata.title.is_empty() && !same_project {
|
||||
self.audio_settings.metadata.title = project_name.to_owned();
|
||||
// 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();
|
||||
|
||||
|
|
@ -493,6 +543,14 @@ impl ExportDialog {
|
|||
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])
|
||||
|
|
@ -501,18 +559,18 @@ impl ExportDialog {
|
|||
ui.label(label);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(val)
|
||||
.hint_text(hint)
|
||||
.hint_text(egui::RichText::new(hint).italics().color(hint_color))
|
||||
.desired_width(260.0),
|
||||
);
|
||||
ui.end_row();
|
||||
};
|
||||
row(ui, "Title", &mut m.title, "Track title");
|
||||
row(ui, "Artist", &mut m.artist, "Artist");
|
||||
row(ui, "Album", &mut m.album, "Album");
|
||||
row(ui, "Genre", &mut m.genre, "Genre");
|
||||
row(ui, "Year", &mut m.year, "e.g. 2026");
|
||||
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, "Comment");
|
||||
row(ui, "Comment", &mut m.comment, "Optional notes…");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -973,3 +1031,30 @@ impl ExportProgressDialog {
|
|||
should_cancel
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn year_from_unix_secs_known_values() {
|
||||
assert_eq!(year_from_unix_secs(0), 1970); // Unix epoch
|
||||
assert_eq!(year_from_unix_secs(946_684_800), 2000); // 2000-01-01
|
||||
assert_eq!(year_from_unix_secs(1_735_689_600), 2025); // 2025-01-01
|
||||
assert_eq!(year_from_unix_secs(1_767_225_599), 2025); // 2025-12-31 23:59:59
|
||||
assert_eq!(year_from_unix_secs(1_767_225_600), 2026); // 2026-01-01
|
||||
|
||||
// Post-2038: these timestamps exceed i32::MAX (2_147_483_647) — and the last exceeds
|
||||
// u32::MAX — so a 32-bit time_t would wrap here. i64 math handles them correctly.
|
||||
assert_eq!(year_from_unix_secs(2_148_595_200), 2038); // 2038-02-01 (> i32::MAX)
|
||||
assert_eq!(year_from_unix_secs(2_223_331_200), 2040); // 2040-06-15
|
||||
assert_eq!(year_from_unix_secs(4_102_444_800), 2100); // 2100-01-01 (not a leap year)
|
||||
assert_eq!(year_from_unix_secs(9_214_646_400), 2262); // 2262-01-01 (> u32::MAX)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_year_is_plausible() {
|
||||
let y = current_year();
|
||||
assert!((2020..3000).contains(&y), "implausible year: {y}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
//! This module provides the export orchestrator and progress tracking
|
||||
//! for exporting audio and video from the timeline.
|
||||
|
||||
pub mod audio_exporter;
|
||||
pub mod dialog;
|
||||
pub mod gif_exporter;
|
||||
pub mod image_exporter;
|
||||
|
|
@ -1008,6 +1007,12 @@ impl ExportOrchestrator {
|
|||
start_time: daw_backend::Seconds(settings.start_time),
|
||||
end_time: daw_backend::Seconds(settings.end_time),
|
||||
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||
metadata: settings
|
||||
.metadata
|
||||
.pairs()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Use DAW backend export for all formats
|
||||
|
|
|
|||
|
|
@ -3443,7 +3443,13 @@ impl EditorApp {
|
|||
h
|
||||
};
|
||||
|
||||
self.export_dialog.open(timeline_endpoint, &project_name, &hint);
|
||||
self.export_dialog.open(
|
||||
timeline_endpoint,
|
||||
&project_name,
|
||||
&hint,
|
||||
&self.config.last_audio_artist,
|
||||
&self.config.last_audio_album,
|
||||
);
|
||||
}
|
||||
MenuAction::Quit => {
|
||||
println!("Menu: Quit");
|
||||
|
|
@ -6369,6 +6375,15 @@ impl eframe::App for EditorApp {
|
|||
ExportResult::AudioOnly(settings, output_path) => {
|
||||
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());
|
||||
|
||||
// Remember Artist/Album so they prefill next time.
|
||||
if !settings.metadata.artist.is_empty() {
|
||||
self.config.last_audio_artist = settings.metadata.artist.clone();
|
||||
}
|
||||
if !settings.metadata.album.is_empty() {
|
||||
self.config.last_audio_album = settings.metadata.album.clone();
|
||||
}
|
||||
self.config.save();
|
||||
|
||||
if let Some(audio_controller) = &self.audio_controller {
|
||||
orchestrator.start_audio_export(
|
||||
settings,
|
||||
|
|
|
|||
Loading…
Reference in New Issue