1061 lines
43 KiB
Rust
1061 lines
43 KiB
Rust
//! 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<PathBuf>,
|
|
|
|
/// Error message (if any)
|
|
pub error_message: Option<String>,
|
|
|
|
/// 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<ExportType>,
|
|
|
|
/// Full path of the most recent successful export. Restored as the default on next open.
|
|
last_exported_path: Option<PathBuf>,
|
|
}
|
|
|
|
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<ExportResult> {
|
|
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<ExportResult> {
|
|
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<std::time::Instant>,
|
|
|
|
/// 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}");
|
|
}
|
|
}
|