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

595 lines
21 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, VideoExportSettings, VideoCodec, VideoQuality};
use std::path::PathBuf;
/// Export type selection
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportType {
Audio,
Video,
}
/// Export result from dialog
#[derive(Debug, Clone)]
pub enum ExportResult {
AudioOnly(AudioExportSettings, PathBuf),
VideoOnly(VideoExportSettings, PathBuf),
VideoWithAudio(VideoExportSettings, AudioExportSettings, 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,
/// Video export settings
pub video_settings: VideoExportSettings,
/// Include audio with video?
pub include_audio: bool,
/// Output file path
pub output_path: Option<PathBuf>,
/// Selected audio preset index (for UI)
pub selected_audio_preset: usize,
/// Error message (if any)
pub error_message: Option<String>,
}
impl Default for ExportDialog {
fn default() -> Self {
Self {
open: false,
export_type: ExportType::Audio,
audio_settings: AudioExportSettings::default(),
video_settings: VideoExportSettings::default(),
include_audio: true,
output_path: None,
selected_audio_preset: 0,
error_message: None,
}
}
}
impl ExportDialog {
/// Open the dialog with default settings
pub fn open(&mut self, timeline_duration: f64) {
self.open = true;
self.audio_settings.end_time = timeline_duration;
self.video_settings.end_time = timeline_duration;
self.error_message = None;
}
/// Close the dialog
pub fn close(&mut self) {
self.open = false;
self.error_message = None;
}
/// 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 mut open = self.open;
let window_title = match self.export_type {
ExportType::Audio => "Export Audio",
ExportType::Video => "Export Video",
};
egui::Window::new(window_title)
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(ctx, |ui| {
ui.set_width(500.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| {
ui.selectable_value(&mut self.export_type, ExportType::Audio, "🎵 Audio");
ui.selectable_value(&mut self.export_type, ExportType::Video, "🎬 Video");
});
ui.add_space(12.0);
ui.separator();
ui.add_space(12.0);
// Render either audio or video settings
match self.export_type {
ExportType::Audio => self.render_audio_settings(ui),
ExportType::Video => self.render_video_settings(ui),
}
ui.add_space(12.0);
// Time range (common to both)
self.render_time_range(ui);
ui.add_space(12.0);
// Output file path (common to both)
self.render_output_selection(ui);
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;
}
});
});
});
// Update open state (in case user clicked X button)
self.open = open;
if should_close {
self.close();
return None;
}
if should_export {
return self.handle_export();
}
None
}
/// Render audio export settings UI
fn render_audio_settings(&mut self, ui: &mut egui::Ui) {
// Preset selection
ui.heading("Preset");
ui.horizontal(|ui| {
let presets = [
("High Quality WAV", AudioExportSettings::high_quality_wav()),
("High Quality FLAC", AudioExportSettings::high_quality_flac()),
("Standard MP3", AudioExportSettings::standard_mp3()),
("Standard AAC", AudioExportSettings::standard_aac()),
("High Quality MP3", AudioExportSettings::high_quality_mp3()),
("High Quality AAC", AudioExportSettings::high_quality_aac()),
("Podcast MP3", AudioExportSettings::podcast_mp3()),
("Podcast AAC", AudioExportSettings::podcast_aac()),
];
egui::ComboBox::from_id_salt("export_preset")
.selected_text(presets[self.selected_audio_preset].0)
.show_ui(ui, |ui| {
for (i, (name, _)) in presets.iter().enumerate() {
if ui.selectable_value(&mut self.selected_audio_preset, i, *name).clicked() {
// Save current time range before applying preset
let saved_start = self.audio_settings.start_time;
let saved_end = self.audio_settings.end_time;
self.audio_settings = presets[i].1.clone();
// Restore time range
self.audio_settings.start_time = saved_start;
self.audio_settings.end_time = saved_end;
}
}
});
});
ui.add_space(12.0);
ui.add_space(12.0);
// Format settings
ui.heading("Format");
ui.horizontal(|ui| {
ui.label("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::Wav, "WAV (Uncompressed)");
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Flac, "FLAC (Lossless)");
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Mp3, "MP3");
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Aac, "AAC");
});
});
ui.add_space(8.0);
// Audio settings
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");
});
ui.add_space(8.0);
// 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");
});
});
}
}
/// Render video export settings UI
fn render_video_settings(&mut self, ui: &mut egui::Ui) {
// Codec selection
ui.heading("Codec");
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.add_space(12.0);
// Resolution
ui.heading("Resolution");
ui.horizontal(|ui| {
ui.label("Width:");
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("Height:");
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);
}
});
// Resolution presets
ui.horizontal(|ui| {
if ui.button("1080p").clicked() {
self.video_settings.width = Some(1920);
self.video_settings.height = Some(1080);
}
if ui.button("4K").clicked() {
self.video_settings.width = Some(3840);
self.video_settings.height = Some(2160);
}
if ui.button("720p").clicked() {
self.video_settings.width = Some(1280);
self.video_settings.height = Some(720);
}
});
ui.add_space(12.0);
// Framerate
ui.heading("Framerate");
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.add_space(12.0);
// Quality
ui.heading("Quality");
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());
});
});
ui.add_space(12.0);
// Include audio checkbox
ui.checkbox(&mut self.include_audio, "Include Audio");
}
/// 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::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time),
};
ui.heading("Time Range");
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 (common to both audio and video)
fn render_output_selection(&mut self, ui: &mut egui::Ui) {
ui.heading("Output");
ui.horizontal(|ui| {
let path_text = self.output_path.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "No file selected".to_string());
ui.label("File:");
ui.text_edit_singleline(&mut path_text.clone());
if ui.button("Browse...").clicked() {
// Determine file extension and filter based on export type
let (default_name, filter_name, extensions) = match self.export_type {
ExportType::Audio => {
let ext = self.audio_settings.format.extension();
(format!("audio.{}", ext), "Audio", vec![ext])
}
ExportType::Video => {
let ext = self.video_settings.codec.container_format();
(format!("video.{}", ext), "Video", vec![ext])
}
};
if let Some(path) = rfd::FileDialog::new()
.set_file_name(&default_name)
.add_filter(filter_name, &extensions)
.save_file()
{
self.output_path = Some(path);
}
}
});
}
/// Handle export button click
fn handle_export(&mut self) -> Option<ExportResult> {
// Check if output path is set
if self.output_path.is_none() {
self.error_message = Some("Please select an output file".to_string());
return None;
}
let output_path = self.output_path.clone().unwrap();
let result = match self.export_type {
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::Window::new("Exporting...")
.open(&mut self.open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(ctx, |ui| {
ui.set_width(400.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
}
}