Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs

864 lines
34 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,
ImageExportSettings, ImageFormat,
VideoExportSettings, VideoCodec, VideoQuality, ColorRange,
};
use std::path::PathBuf;
/// 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,
/// 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),
/// 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,
/// 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(),
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) {
self.open = true;
self.audio_settings.end_time = timeline_duration;
self.video_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
};
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::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::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::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::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::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);
// Time range
self.render_time_range(ui);
}
/// 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);
}
/// 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),
};
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::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
}
}