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,
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue