Compare commits

..

15 Commits

Author SHA1 Message Date
Skyler Lehmkuhl b6f43d2e72 Bump version to 1.0.8-alpha 2026-07-09 18:26:37 -04:00
Skyler Lehmkuhl 8fbb6d65c0 Prompt to save on quit too; unify with the switch prompt
Closing the window with unsaved changes was a silent data-loss path — and worse,
a clean exit deletes the recovery file, removing the safety net too.

Fold quitting into the same unsaved-changes flow as file switches: one
PendingAction enum (NewFile / Open / Quit), one modal, one do_action, one
after_save handler. render_unsaved_prompt now also intercepts the window-close
request (CancelClose + queue a Quit prompt); Save & Quit saves then closes,
Discard & Quit closes now, Cancel keeps the window open. confirmed_close lets the
final programmatic close through instead of re-intercepting it.

Replaces the near-duplicate close-prompt/switch-prompt code with a single path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:17:37 -04:00
Skyler Lehmkuhl 6aece26a8b Prompt to save unsaved changes before switching files
New File / Open / Open Recent previously discarded unsaved work with no warning
(three // TODO: Prompt to save markers). They now route through request_switch,
which shows a Save / Don't Save / Cancel modal when the document is modified:
  • Save  — saves first (Save As for untitled/recovered docs), then switches once
    the background save completes (switch_after_save).
  • Don't Save — switches and discards.
  • Cancel — stays put.

"Modified" is tracked without hooking every edit site: current epoch vs a
saved_epoch baseline (rebased on manual save / new / load), plus a media_modified
flag for imports/recordings that don't bump the action epoch. Recovered documents
count as modified until given a real home, so switching away from unrecovered work
also prompts.
2026-07-09 18:17:27 -04:00
Skyler Lehmkuhl ef2b0822bd Autosave: keep recovery files out of Recent Files
Recovering a file loaded it through the normal load path, which added it to
Recent Files — so an internal recovered-*.beam in the app data dir showed up in
the Recent list (and could even be auto-reopened as "last session").

Skip add_recent_file for recovery paths on load, and clean any that older builds
already leaked into Recent at startup (before the auto-reopen check reads it).
2026-07-09 18:05:46 -04:00
Skyler Lehmkuhl 5f09222f3f Autosave: configurable interval + startup logging for testing
- LB_AUTOSAVE_SECS overrides the 45s autosave interval (e.g. =5) so the recovery
  flow can be exercised without a long wait.
- Log the session's recovery file path at startup so it's easy to find.
2026-07-09 17:43:27 -04:00
Skyler Lehmkuhl b0e965b1c2 Crash recovery: restore prompt for leftover recovery files
Second half of autosave/recovery — the read/restore side.

At startup, scan the recovery dir for session-*.beam files. Since a clean exit
deletes this session's file (on_exit), any leftover means a previous session
crashed or was killed with unsaved work. Offer the newest via a modal:
  • Recover — rename it out of the session-* namespace (to recovered-*) and open
    it as the current file. It's renamed, not deleted, because recovered raster
    keyframes page in from it on demand; renaming also stops it being re-offered.
  • Discard — delete it.
  • Later — keep it for next launch, stop prompting this session.

Because a recovered file lives in the app data dir, Save behaves as Save As for
it (both the menu action and the Save-As default directory skip recovery paths),
so the work lands where the user wants rather than back in the data dir.

recovered-* files older than a week are garbage-collected at startup.
2026-07-09 17:29:30 -04:00
Skyler Lehmkuhl 6596acb3db Crash recovery: background autosave to a per-session recovery file
First half of the autosave/recovery feature — the write side.

Every ~45s while the document is dirty, write the full current state into a
per-session recovery .beam in the app data dir (directories::ProjectDirs data
dir + /recovery/session-<uuid>.beam). Fully background: reuses the existing file
worker, so the pool serialization / encode / DB write all happen off the UI
thread. The only UI-thread cost is one document clone — and build_save_command
now stamps the UI layout onto the *snapshot clone* rather than the live document
(the old prepare_document_for_save mutated live state via Arc::make_mut, which
could deep-clone the whole document mid-frame). Removed that dead helper.

Dirtiness is tracked centrally via ActionExecutor::epoch() (now also bumped on
undo/redo, not just execute) plus a pending_event flag set by non-action changes
(imports, finished recordings). The baseline is rebased on new/load/manual-save
so a freshly-loaded or just-saved project stays quiet. Idle-after-edit still gets
one snapshot via a single request_repaint_after wakeup; completion is polled
lazily (no forced repaints during the write).

on_exit deletes the session's recovery file, so a leftover file on next launch
means an unclean shutdown — the hook the recovery prompt (next commit) keys on.
2026-07-09 17:20:54 -04:00
Skyler Lehmkuhl d6b86a14b1 Save: skip re-encoding unchanged raster keyframes
save_beam re-encoded every resident raster keyframe to PNG (+ proxy) on every
save, even untouched frames — the dominant per-save cost for painting/animation
projects (the code noted this as deferred "Phase 3").

The infrastructure to do it incrementally already exists: kf.dirty means "current
pixels not yet in the container" (set on any edit, cleared on a successful save,
per main.rs), and it's preserved in the document clone the save worker receives.
Gate the encode on it: a clean keyframe already stored keeps its full + proxy rows
untouched; only dirty (or not-yet-stored) frames are re-encoded. Media blobs were
already incremental; this closes the raster gap.

No new data-loss risk: the mid-save-edit race (edit between the document clone and
save completion) is pre-existing and identical to the old full-write path.
2026-07-09 13:58:34 -04:00
Skyler Lehmkuhl a2839f80b1 Clean up build warnings 2026-07-09 13:24:32 -04:00
Skyler Lehmkuhl 6e6feaddf5 Fix FLAC export end-to-end (real backend path) + smart tag defaults
The prior audio-tags commit put real FLAC + metadata into export/audio_exporter.rs
— which turned out to be dead code (declared, never called; whole file was
EngineController::start_export_audio → daw-backend's export_audio, which still
routed FLAC to the erroring hound stub — hence "not implemented in daw-backend".

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -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)

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,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());
}
} 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());
} }
} }
24 => {
for &sample in samples { encoder.send_frame(&frame).map_err(|e| format!("Failed to send FLAC frame: {}", e))?;
let clamped = sample.max(-1.0).min(1.0); flac_write_packets(&mut encoder, &mut output)?;
let pcm_value = (clamped * 8388607.0) as i32; done += n;
writer.write_sample(pcm_value) }
.map_err(|e| format!("Failed to write sample: {}", e))?; 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;
} }
_ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)), 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
} }
writer.finalize() let mut list: Vec<u8> = Vec::with_capacity(info.len() + 8);
.map_err(|e| format!("Failed to finalize FLAC file: {}", e))?; 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();
}
} }

View File

@ -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",

View File

@ -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 (1030× in debug). Optimize the encoder crates even in dev builds.
[profile.dev.package.gif]
opt-level = 3
[profile.dev.package.color_quant]
opt-level = 3
[profile.dev.package.weezl]
opt-level = 3
# Use local egui fork with ibus/Wayland text input fix # 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" }

View File

@ -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 }

View File

@ -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 {

View File

@ -51,6 +51,54 @@ impl AudioFormat {
} }
} }
/// Optional tag metadata written into the exported audio file. Empty fields are omitted. FFmpeg
/// maps these standard keys to each container's native tags: ID3v2 (MP3), iTunes/MP4 atoms (M4A),
/// Vorbis comments (FLAC), and RIFF INFO (WAV).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AudioMetadata {
pub title: String,
pub artist: String,
pub album: String,
pub genre: String,
pub comment: String,
/// Year or full date (written to the `date` tag).
pub year: String,
/// Track number (written to the `track` tag).
pub track: String,
}
impl AudioMetadata {
/// True when no field is set (so no metadata need be written).
pub fn is_empty(&self) -> bool {
self.title.is_empty()
&& self.artist.is_empty()
&& self.album.is_empty()
&& self.genre.is_empty()
&& self.comment.is_empty()
&& self.year.is_empty()
&& self.track.is_empty()
}
/// The set (ffmpeg-key, value) pairs for non-empty fields, in a stable order.
pub fn pairs(&self) -> Vec<(&'static str, &str)> {
let mut v = Vec::new();
for (key, val) in [
("title", &self.title),
("artist", &self.artist),
("album", &self.album),
("genre", &self.genre),
("comment", &self.comment),
("date", &self.year),
("track", &self.track),
] {
if !val.is_empty() {
v.push((key, val.as_str()));
}
}
v
}
}
/// Audio export settings /// 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 };

View File

@ -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
); );

View File

@ -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}");
}
} }

View File

@ -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"

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

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

View File

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

View File

@ -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}");
}
}

View File

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

View File

@ -175,7 +175,9 @@ impl GpuYuv {
} }
/// CPU reference for the exact math/layout the shader produces — used by unit tests so /// 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;

View File

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

View File

@ -3,8 +3,8 @@
//! This module provides the export orchestrator and progress tracking //! 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));
state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?); if state.prores {
state.cpu_yuv422p10 = Some(cpu_yuv_converter::CpuYuv422P10Converter::new(width, height, state.full_range)?);
println!("🎬 [VIDEO EXPORT] ProRes 422: 10-bit 4:2:2 (YUV422P10LE) CPU converter initialized");
} else {
state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?);
}
println!("🚀 [ASYNC PIPELINE] Triple-buffered pipeline initialized"); println!("🚀 [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)

View File

@ -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

View File

@ -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) { /// Save the current document to a .beam file
let doc = self.action_executor.document_mut(); /// 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.
// Store current layout state fn build_save_command(
doc.ui_layout = Some(self.current_layout.clone()); &mut self,
path: std::path::PathBuf,
// Store base layout name for reference 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() { if self.current_layout_index < self.layouts.len() {
doc.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone()); 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,
} }
} }
/// Save the current document to a .beam file
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
self.config.add_recent_file(path.clone()); // the app data dir, not a project the user opened).
self.update_recent_files_menu(); if !AutosaveState::is_recovery_path(&path) {
self.config.add_recent_file(path.clone());
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(),

View File

@ -40,7 +40,7 @@ const TOPBAR_H: f32 = 40.0;
/// Clamp a desktop dialog width to fit the current screen (with side margins). A no-op on wide /// 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))
} }

View File

@ -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,

View File

@ -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
}; };