//! Export dialog UI //! //! Provides a user interface for configuring and starting audio/video exports. use eframe::egui; use lightningbeam_core::export::{ AudioExportSettings, AudioFormat, GifExportSettings, ImageExportSettings, ImageFormat, VideoExportSettings, VideoCodec, VideoQuality, ColorRange, }; use std::path::PathBuf; /// The OS username (`$USER` / `%USERNAME%`), or empty if unavailable. Used as a default Artist tag. fn os_username() -> String { std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) .unwrap_or_default() } /// Current civil year (UTC) computed from the system clock — avoids pulling in a date crate. fn current_year() -> i64 { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) as i64; year_from_unix_secs(secs) } /// Civil year (UTC) for a Unix timestamp, via Howard Hinnant's `civil_from_days` algorithm. fn year_from_unix_secs(secs: i64) -> i64 { let days = secs.div_euclid(86_400); let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = z - era * 146_097; // [0, 146096] let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] let mp = (5 * doy + 2) / 153; // [0, 11] let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] y + if m <= 2 { 1 } else { 0 } } /// Hint about document content, used to pick a smart default export type. pub struct DocumentHint { pub has_video: bool, pub has_audio: bool, pub has_raster: bool, pub has_vector: bool, pub current_time: f64, } /// Export type selection #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExportType { Audio, Image, Video, /// Animated GIF (multi-frame, palette-quantized, no audio). Gif, /// Vector-only SVG of the current frame (lossless; raster/video layers skipped). Svg, } /// Export result from dialog #[derive(Debug, Clone)] pub enum ExportResult { AudioOnly(AudioExportSettings, PathBuf), Image(ImageExportSettings, PathBuf), VideoOnly(VideoExportSettings, PathBuf), VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf), /// Animated GIF export. Gif(GifExportSettings, PathBuf), /// SVG of vector layers at the given document time. Svg(f64, PathBuf), } /// Export dialog state pub struct ExportDialog { /// Is the dialog open? pub open: bool, /// Export type (Audio or Video) pub export_type: ExportType, /// Audio export settings pub audio_settings: AudioExportSettings, /// Image export settings pub image_settings: ImageExportSettings, /// Video export settings pub video_settings: VideoExportSettings, /// Animated GIF export settings pub gif_settings: GifExportSettings, /// Include audio with video? pub include_audio: bool, /// Output file path pub output_path: Option, /// Error message (if any) pub error_message: Option, /// Whether advanced settings are shown pub show_advanced: bool, /// Selected video preset index pub selected_video_preset: usize, /// Output filename (editable text, without directory) pub output_filename: String, /// Output directory pub output_dir: PathBuf, /// Project name from the last `open()` call — used to detect file switches. current_project: String, /// Export type used the last time the user actually clicked Export for `current_project`. last_export_type: Option, /// Full path of the most recent successful export. Restored as the default on next open. last_exported_path: Option, } impl Default for ExportDialog { fn default() -> Self { let home = std::env::var("HOME") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(".")); let music_dir = { let m = home.join("Music"); if m.is_dir() { m } else { home } }; Self { open: false, export_type: ExportType::Audio, audio_settings: AudioExportSettings::standard_mp3(), image_settings: ImageExportSettings::default(), video_settings: VideoExportSettings::default(), gif_settings: GifExportSettings::default(), include_audio: true, output_path: None, error_message: None, show_advanced: false, selected_video_preset: 0, output_filename: String::new(), current_project: String::new(), last_export_type: None, last_exported_path: None, output_dir: music_dir, } } } impl ExportDialog { /// Open the dialog with default settings, using `hint` to pick a smart default tab. pub fn open( &mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint, last_artist: &str, last_album: &str, ) { self.open = true; self.audio_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; // Propagate document dimensions as defaults (None means "use doc size"). self.image_settings.width = None; self.image_settings.height = None; self.error_message = None; // Determine export type: prefer the type used last time for this file, // then fall back to document-content hints. let same_project = self.current_project == project_name; self.export_type = if same_project && self.last_export_type.is_some() { self.last_export_type.unwrap() } else { let only_audio = hint.has_audio && !hint.has_video && !hint.has_raster && !hint.has_vector; let only_raster = hint.has_raster && !hint.has_video && !hint.has_audio && !hint.has_vector; if hint.has_video { ExportType::Video } else if only_audio { ExportType::Audio } else if only_raster { ExportType::Image } 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(); // Restore the last exported path if available; otherwise default to project name. if let Some(ref last) = self.last_exported_path.clone() { if let Some(dir) = last.parent() { self.output_dir = dir.to_path_buf(); } if let Some(name) = last.file_name() { self.output_filename = name.to_string_lossy().into_owned(); } } else if self.output_filename.is_empty() || !self.output_filename.contains(project_name) { self.output_filename = format!("{}.{}", project_name, self.current_extension()); } } /// Extension for the currently selected export type. fn current_extension(&self) -> &'static str { match self.export_type { ExportType::Audio => self.audio_settings.format.extension(), ExportType::Image => self.image_settings.format.extension(), ExportType::Video => self.video_settings.codec.container_format(), ExportType::Gif => "gif", ExportType::Svg => "svg", } } /// Close the dialog pub fn close(&mut self) { self.open = false; self.error_message = None; } /// Update the filename extension to match the current format fn update_filename_extension(&mut self) { let ext = self.current_extension(); // Replace extension in filename if let Some(dot_pos) = self.output_filename.rfind('.') { self.output_filename.truncate(dot_pos + 1); self.output_filename.push_str(ext); } else if !self.output_filename.is_empty() { self.output_filename.push('.'); self.output_filename.push_str(ext); } } /// Build the full output path from directory + filename fn build_output_path(&self) -> PathBuf { self.output_dir.join(&self.output_filename) } /// Render the export dialog /// /// Returns Some(ExportResult) if the user clicked Export, None otherwise. pub fn render(&mut self, ctx: &egui::Context) -> Option { if !self.open { return None; } let mut should_export = false; let mut should_close = false; let window_title = match self.export_type { ExportType::Audio => "Export Audio", ExportType::Image => "Export Image", ExportType::Video => "Export Video", ExportType::Gif => "Export GIF", ExportType::Svg => "Export SVG", }; let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal")) .show(ctx, |ui| { ui.set_width(crate::mobile::dialog_width(ctx, 500.0)); ui.heading(window_title); ui.add_space(8.0); // Error message (if any) if let Some(error) = &self.error_message { ui.colored_label(egui::Color32::RED, error); ui.add_space(8.0); } // Export type selection (tabs) ui.horizontal(|ui| { for (variant, label) in [ (ExportType::Audio, "Audio"), (ExportType::Image, "Image"), (ExportType::Video, "Video"), (ExportType::Gif, "GIF"), (ExportType::Svg, "SVG"), ] { if ui.selectable_value(&mut self.export_type, variant, label).clicked() { self.update_filename_extension(); } } }); ui.add_space(12.0); ui.separator(); ui.add_space(12.0); // Basic settings match self.export_type { ExportType::Audio => self.render_audio_basic(ui), ExportType::Image => self.render_image_settings(ui), ExportType::Video => self.render_video_basic(ui), ExportType::Gif => self.render_gif_basic(ui), ExportType::Svg => self.render_svg_settings(ui), } ui.add_space(12.0); // Output file self.render_output_selection(ui); ui.add_space(4.0); // Advanced toggle ui.toggle_value(&mut self.show_advanced, "Advanced settings"); if self.show_advanced { ui.add_space(8.0); match self.export_type { ExportType::Audio => self.render_audio_advanced(ui), ExportType::Image => self.render_image_advanced(ui), ExportType::Video => self.render_video_advanced(ui), ExportType::Gif => self.render_gif_advanced(ui), ExportType::Svg => {} // SVG has no advanced settings } } ui.add_space(16.0); // Buttons ui.horizontal(|ui| { if ui.button("Cancel").clicked() { should_close = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Export").clicked() { should_export = true; } }); }); }); // Close on backdrop click or escape if modal_response.backdrop_response.clicked() { should_close = true; } if should_close { self.close(); return None; } if should_export { self.output_path = Some(self.build_output_path()); return self.handle_export(); } None } /// Render basic audio settings (format + filename) fn render_audio_basic(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label("Format:"); let prev_format = self.audio_settings.format; egui::ComboBox::from_id_salt("audio_format") .selected_text(self.audio_settings.format.name()) .show_ui(ui, |ui| { ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Mp3, "MP3"); ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Aac, "AAC"); ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Flac, "FLAC (Lossless)"); ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Wav, "WAV (Uncompressed)"); }); if self.audio_settings.format != prev_format { self.update_filename_extension(); // Apply sensible defaults when switching formats match self.audio_settings.format { AudioFormat::Mp3 => { self.audio_settings.sample_rate = 44100; self.audio_settings.bitrate_kbps = 192; } AudioFormat::Aac => { self.audio_settings.sample_rate = 44100; self.audio_settings.bitrate_kbps = 256; } AudioFormat::Flac | AudioFormat::Wav => { self.audio_settings.sample_rate = 48000; self.audio_settings.bit_depth = 24; } } } }); } /// Render basic image export settings (format, quality, transparency). fn render_image_settings(&mut self, ui: &mut egui::Ui) { // Format ui.horizontal(|ui| { ui.label("Format:"); let prev = self.image_settings.format; egui::ComboBox::from_id_salt("image_format") .selected_text(self.image_settings.format.name()) .show_ui(ui, |ui| { ui.selectable_value(&mut self.image_settings.format, ImageFormat::Png, "PNG"); ui.selectable_value(&mut self.image_settings.format, ImageFormat::Jpeg, "JPEG"); ui.selectable_value(&mut self.image_settings.format, ImageFormat::WebP, "WebP"); }); if self.image_settings.format != prev { self.update_filename_extension(); } }); // Quality (JPEG / WebP only) if self.image_settings.format.has_quality() { ui.horizontal(|ui| { ui.label("Quality:"); ui.add(egui::Slider::new(&mut self.image_settings.quality, 1..=100)); }); } // Transparency (PNG / WebP only — JPEG has no alpha) if self.image_settings.format != ImageFormat::Jpeg { ui.checkbox(&mut self.image_settings.allow_transparency, "Allow transparency"); } } /// Render SVG export settings — just the frame time (reuses the image time field). fn render_svg_settings(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label("Time:"); ui.add(egui::DragValue::new(&mut self.image_settings.time) .speed(0.01) .range(0.0..=f64::MAX) .suffix(" s")); }); ui.add_space(4.0); ui.weak("Exports vector layers losslessly at this frame. Raster, video, and"); ui.weak("effect layers are not included."); } /// Render advanced image export settings (time, resolution override). fn render_image_advanced(&mut self, ui: &mut egui::Ui) { // Time (which frame to export) ui.horizontal(|ui| { ui.label("Time:"); ui.add(egui::DragValue::new(&mut self.image_settings.time) .speed(0.01) .range(0.0..=f64::MAX) .suffix(" s")); }); // Resolution override (None = use document size; 0 means "use doc size") ui.horizontal(|ui| { ui.label("Size:"); let mut w = self.image_settings.width.unwrap_or(0); let mut h = self.image_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.image_settings.width = if w == 0 { None } else { Some(w) }; } if changed_h { self.image_settings.height = if h == 0 { None } else { Some(h) }; } ui.weak("(0 = document size)"); }); // Fit mode — how the document maps into the output frame when aspect ratios differ. ui.horizontal(|ui| { use lightningbeam_core::export::ExportFitMode; ui.label("Fit:"); egui::ComboBox::from_id_salt("image_fit_mode") .selected_text(self.image_settings.fit.name()) .show_ui(ui, |ui| { ui.selectable_value(&mut self.image_settings.fit, ExportFitMode::Letterbox, ExportFitMode::Letterbox.name()); ui.selectable_value(&mut self.image_settings.fit, ExportFitMode::Crop, ExportFitMode::Crop.name()); ui.selectable_value(&mut self.image_settings.fit, ExportFitMode::Stretch, ExportFitMode::Stretch.name()); }); }); } /// Render advanced audio settings (sample rate, channels, bit depth, bitrate, time range) fn render_audio_advanced(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label("Sample Rate:"); egui::ComboBox::from_id_salt("sample_rate") .selected_text(format!("{} Hz", self.audio_settings.sample_rate)) .show_ui(ui, |ui| { ui.selectable_value(&mut self.audio_settings.sample_rate, 44100, "44100 Hz"); ui.selectable_value(&mut self.audio_settings.sample_rate, 48000, "48000 Hz"); ui.selectable_value(&mut self.audio_settings.sample_rate, 96000, "96000 Hz"); }); }); ui.horizontal(|ui| { ui.label("Channels:"); ui.radio_value(&mut self.audio_settings.channels, 1, "Mono"); ui.radio_value(&mut self.audio_settings.channels, 2, "Stereo"); }); // Format-specific settings if self.audio_settings.format.supports_bit_depth() { ui.horizontal(|ui| { ui.label("Bit Depth:"); ui.radio_value(&mut self.audio_settings.bit_depth, 16, "16-bit"); ui.radio_value(&mut self.audio_settings.bit_depth, 24, "24-bit"); }); } if self.audio_settings.format.uses_bitrate() { ui.horizontal(|ui| { ui.label("Bitrate:"); egui::ComboBox::from_id_salt("bitrate") .selected_text(format!("{} kbps", self.audio_settings.bitrate_kbps)) .show_ui(ui, |ui| { ui.selectable_value(&mut self.audio_settings.bitrate_kbps, 128, "128 kbps"); ui.selectable_value(&mut self.audio_settings.bitrate_kbps, 192, "192 kbps"); ui.selectable_value(&mut self.audio_settings.bitrate_kbps, 256, "256 kbps"); ui.selectable_value(&mut self.audio_settings.bitrate_kbps, 320, "320 kbps"); }); }); } 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 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) 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 60fps", VideoCodec::H264, VideoQuality::High, 1920, 1080, 60.0), ("4K H.264", VideoCodec::H264, VideoQuality::VeryHigh, 3840, 2160, 30.0), ("720p H.264 (Small)", VideoCodec::H264, VideoQuality::Medium, 1280, 720, 30.0), ("1080p H.265 (Smaller)", VideoCodec::H265, VideoQuality::High, 1920, 1080, 30.0), ("1080p VP9 (WebM)", VideoCodec::VP9, VideoQuality::High, 1920, 1080, 30.0), ("1080p ProRes 422", VideoCodec::ProRes422, VideoQuality::VeryHigh, 1920, 1080, 30.0), ]; /// Render basic video settings (preset dropdown) fn render_video_basic(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label("Preset:"); egui::ComboBox::from_id_salt("video_preset") .selected_text(Self::VIDEO_PRESETS[self.selected_video_preset].0) .show_ui(ui, |ui| { for (i, preset) in Self::VIDEO_PRESETS.iter().enumerate() { if ui.selectable_value(&mut self.selected_video_preset, i, preset.0).clicked() { let (_, codec, quality, w, h, fps) = *preset; self.video_settings.codec = codec; self.video_settings.quality = quality; self.video_settings.width = Some(w); self.video_settings.height = Some(h); self.video_settings.framerate = fps; self.update_filename_extension(); } } }); }); } /// Render advanced video settings (codec, resolution, framerate, quality, time range) fn render_video_advanced(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label("Codec:"); egui::ComboBox::from_id_salt("video_codec") .selected_text(format!("{:?}", self.video_settings.codec)) .show_ui(ui, |ui| { ui.selectable_value(&mut self.video_settings.codec, VideoCodec::H264, "H.264 (Most Compatible)"); ui.selectable_value(&mut self.video_settings.codec, VideoCodec::H265, "H.265 (Better Compression)"); ui.selectable_value(&mut self.video_settings.codec, VideoCodec::VP8, "VP8 (WebM)"); ui.selectable_value(&mut self.video_settings.codec, VideoCodec::VP9, "VP9 (WebM)"); ui.selectable_value(&mut self.video_settings.codec, VideoCodec::ProRes422, "ProRes 422 (Professional)"); }); }); ui.horizontal(|ui| { ui.label("Resolution:"); let mut custom_width = self.video_settings.width.unwrap_or(1920); if ui.add(egui::DragValue::new(&mut custom_width).range(1..=7680)).changed() { self.video_settings.width = Some(custom_width); } ui.label("x"); let mut custom_height = self.video_settings.height.unwrap_or(1080); if ui.add(egui::DragValue::new(&mut custom_height).range(1..=4320)).changed() { self.video_settings.height = Some(custom_height); } }); ui.horizontal(|ui| { if ui.small_button("1080p").clicked() { self.video_settings.width = Some(1920); self.video_settings.height = Some(1080); } if ui.small_button("4K").clicked() { self.video_settings.width = Some(3840); self.video_settings.height = Some(2160); } if ui.small_button("720p").clicked() { self.video_settings.width = Some(1280); self.video_settings.height = Some(720); } }); // Fit mode — how the document maps into the export frame when the aspect ratios differ. ui.horizontal(|ui| { use lightningbeam_core::export::ExportFitMode; ui.label("Fit:"); egui::ComboBox::from_id_salt("video_fit_mode") .selected_text(self.video_settings.fit.name()) .show_ui(ui, |ui| { ui.selectable_value(&mut self.video_settings.fit, ExportFitMode::Letterbox, ExportFitMode::Letterbox.name()); ui.selectable_value(&mut self.video_settings.fit, ExportFitMode::Crop, ExportFitMode::Crop.name()); ui.selectable_value(&mut self.video_settings.fit, ExportFitMode::Stretch, ExportFitMode::Stretch.name()); }); }); ui.horizontal(|ui| { ui.label("FPS:"); egui::ComboBox::from_id_salt("framerate") .selected_text(format!("{}", self.video_settings.framerate as u32)) .show_ui(ui, |ui| { ui.selectable_value(&mut self.video_settings.framerate, 24.0, "24"); ui.selectable_value(&mut self.video_settings.framerate, 30.0, "30"); ui.selectable_value(&mut self.video_settings.framerate, 60.0, "60"); }); }); ui.horizontal(|ui| { ui.label("Quality:"); egui::ComboBox::from_id_salt("video_quality") .selected_text(self.video_settings.quality.name()) .show_ui(ui, |ui| { ui.selectable_value(&mut self.video_settings.quality, VideoQuality::Low, VideoQuality::Low.name()); ui.selectable_value(&mut self.video_settings.quality, VideoQuality::Medium, VideoQuality::Medium.name()); ui.selectable_value(&mut self.video_settings.quality, VideoQuality::High, VideoQuality::High.name()); ui.selectable_value(&mut self.video_settings.quality, VideoQuality::VeryHigh, VideoQuality::VeryHigh.name()); }); }); // Color range applies to H.264 (the VAAPI zero-copy encoder honors it). Limited/TV is the // compatible default; Full/PC only looks right in players that read the full-range tag. if matches!(self.video_settings.codec, VideoCodec::H264) { ui.horizontal(|ui| { ui.label("Color range:"); egui::ComboBox::from_id_salt("video_color_range") .selected_text(self.video_settings.color_range.name()) .show_ui(ui, |ui| { ui.selectable_value(&mut self.video_settings.color_range, ColorRange::Limited, ColorRange::Limited.name()); ui.selectable_value(&mut self.video_settings.color_range, ColorRange::Full, ColorRange::Full.name()); }); }); } // HDR output: 10-bit BT.2020 PQ/HLG (HEVC). Forces H.265; software path (no zero-copy). ui.horizontal(|ui| { use lightningbeam_core::export::HdrExportMode; ui.label("Dynamic range:"); egui::ComboBox::from_id_salt("video_hdr_mode") .selected_text(self.video_settings.hdr.name()) .show_ui(ui, |ui| { ui.selectable_value(&mut self.video_settings.hdr, HdrExportMode::Sdr, HdrExportMode::Sdr.name()); ui.selectable_value(&mut self.video_settings.hdr, HdrExportMode::Pq, HdrExportMode::Pq.name()); ui.selectable_value(&mut self.video_settings.hdr, HdrExportMode::Hlg, HdrExportMode::Hlg.name()); }); }); if self.video_settings.hdr.is_hdr() { ui.label(egui::RichText::new("HDR exports as 10-bit HEVC (H.265), BT.2020.").weak().small()); } ui.checkbox(&mut self.include_audio, "Include Audio"); ui.add_space(8.0); // Time range 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) fn render_time_range(&mut self, ui: &mut egui::Ui) { let (start_time, end_time) = match self.export_type { 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::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.label("Start:"); ui.add(egui::DragValue::new(start_time) .speed(0.1) .range(0.0..=*end_time) .suffix(" s")); ui.label("End:"); ui.add(egui::DragValue::new(end_time) .speed(0.1) .range(*start_time..=f64::MAX) .suffix(" s")); }); let duration = *end_time - *start_time; ui.label(format!("Duration: {:.2} seconds", duration)); } /// Render output file selection UI — single OS save-file dialog. fn render_output_selection(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { // Show the current path (truncated if long). let full_path = self.build_output_path(); let path_str = full_path.display().to_string(); ui.label("Save to:"); ui.add(egui::Label::new( egui::RichText::new(&path_str).weak() ).truncate()); }); if ui.button("Choose location...").clicked() { let ext = self.current_extension(); let dialog = rfd::FileDialog::new() .set_directory(&self.output_dir) .set_file_name(&self.output_filename) .add_filter(ext.to_uppercase(), &[ext]); if let Some(path) = dialog.save_file() { if let Some(dir) = path.parent() { self.output_dir = dir.to_path_buf(); } if let Some(name) = path.file_name() { self.output_filename = name.to_string_lossy().into_owned(); // Ensure the extension matches the selected format. self.update_filename_extension(); } } } } /// Handle export button click fn handle_export(&mut self) -> Option { if self.output_filename.trim().is_empty() { self.error_message = Some("Please enter a filename".to_string()); return None; } let output_path = self.output_path.clone().unwrap(); // Remember this export type and path for next time the dialog is opened. self.last_export_type = Some(self.export_type); self.last_exported_path = Some(output_path.clone()); let result = match self.export_type { ExportType::Image => { if let Err(err) = self.image_settings.validate() { self.error_message = Some(err); return None; } Some(ExportResult::Image(self.image_settings.clone(), 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 => { // Validate audio settings if let Err(err) = self.audio_settings.validate() { self.error_message = Some(err); return None; } Some(ExportResult::AudioOnly(self.audio_settings.clone(), output_path)) } ExportType::Video => { // Validate video settings if let Err(err) = self.video_settings.validate() { self.error_message = Some(err); return None; } if self.include_audio { // Validate audio settings too if let Err(err) = self.audio_settings.validate() { self.error_message = Some(err); return None; } // Sync time range from video to audio self.audio_settings.start_time = self.video_settings.start_time; self.audio_settings.end_time = self.video_settings.end_time; Some(ExportResult::VideoWithAudio( self.video_settings.clone(), self.audio_settings.clone(), output_path, )) } else { Some(ExportResult::VideoOnly(self.video_settings.clone(), output_path)) } } }; self.close(); result } } /// Export progress dialog state pub struct ExportProgressDialog { /// Is the dialog open? pub open: bool, /// Current progress message pub message: String, /// Progress (0.0 to 1.0) pub progress: f32, /// Start time for elapsed time calculation pub start_time: Option, /// Was cancel requested? pub cancel_requested: bool, } impl Default for ExportProgressDialog { fn default() -> Self { Self { open: false, message: String::new(), progress: 0.0, start_time: None, cancel_requested: false, } } } impl ExportProgressDialog { /// Open the progress dialog pub fn open(&mut self) { self.open = true; self.message = "Starting export...".to_string(); self.progress = 0.0; self.start_time = Some(std::time::Instant::now()); self.cancel_requested = false; } /// Close the dialog pub fn close(&mut self) { self.open = false; self.start_time = None; self.cancel_requested = false; } /// Update progress pub fn update_progress(&mut self, message: String, progress: f32) { self.message = message; self.progress = progress.clamp(0.0, 1.0); } /// Render the export progress dialog /// /// Returns true if the user clicked Cancel pub fn render(&mut self, ctx: &egui::Context) -> bool { if !self.open { return false; } let mut should_cancel = false; egui::Modal::new(egui::Id::new("export_progress_modal")) .show(ctx, |ui| { ui.set_width(crate::mobile::dialog_width(ctx, 400.0)); ui.heading("Exporting..."); ui.add_space(8.0); // Status message ui.label(&self.message); ui.add_space(8.0); // Progress bar let progress_text = format!("{:.0}%", self.progress * 100.0); ui.add(egui::ProgressBar::new(self.progress).text(progress_text)); ui.add_space(8.0); // Elapsed time and estimate if let Some(start_time) = self.start_time { let elapsed = start_time.elapsed(); let elapsed_secs = elapsed.as_secs(); ui.horizontal(|ui| { ui.label(format!( "Elapsed: {}:{:02}", elapsed_secs / 60, elapsed_secs % 60 )); // Estimate remaining time if we have progress if self.progress > 0.01 { let total_estimated = elapsed.as_secs_f32() / self.progress; let remaining = total_estimated - elapsed.as_secs_f32(); if remaining > 0.0 { ui.label(format!( " | Remaining: ~{}:{:02}", (remaining as u64) / 60, (remaining as u64) % 60 )); } } }); } ui.add_space(12.0); // Cancel button ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Cancel").clicked() { should_cancel = true; } }); }); }); if should_cancel { self.cancel_requested = true; } 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}"); } }