This commit is contained in:
cottongin
2026-02-05 00:50:13 -05:00
parent d112f63a65
commit 054deb873d
5 changed files with 374 additions and 64 deletions

View File

@@ -48,6 +48,10 @@ pub enum WorkerMessage {
progress: f32,
detail: Option<String>,
},
LogMessage {
job_id: usize,
message: String,
},
JobComplete {
job_id: usize,
output_path: PathBuf,
@@ -58,6 +62,13 @@ pub enum WorkerMessage {
},
}
/// A log entry with timestamp
#[derive(Clone)]
pub struct LogEntry {
pub timestamp: std::time::Instant,
pub message: String,
}
/// Application settings for AVIF encoding
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct EncodingSettings {
@@ -101,16 +112,36 @@ pub struct AvifMakerApp {
drop_zone: DropZone,
settings_ui: Settings,
queue_ui: Queue,
/// Console log entries
pub console_log: Vec<LogEntry>,
/// Whether console is visible
pub show_console: bool,
/// Start time for relative timestamps
start_time: std::time::Instant,
}
impl AvifMakerApp {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
// Configure larger fonts for accessibility
let mut style = (*cc.egui_ctx.style()).clone();
// Increase all font sizes
for (_text_style, font_id) in style.text_styles.iter_mut() {
font_id.size *= 1.3; // 30% larger
}
// Increase spacing for touch/visibility
style.spacing.item_spacing = egui::vec2(10.0, 8.0);
style.spacing.button_padding = egui::vec2(8.0, 4.0);
cc.egui_ctx.set_style(style);
let (worker_tx, worker_rx) = mpsc::channel();
// Try to load saved settings
let settings = Self::load_settings().unwrap_or_default();
Self {
let mut app = Self {
settings,
jobs: Vec::new(),
next_job_id: 0,
@@ -120,8 +151,40 @@ impl AvifMakerApp {
drop_zone: DropZone::new(),
settings_ui: Settings::new(),
queue_ui: Queue::new(),
console_log: Vec::new(),
show_console: false,
start_time: std::time::Instant::now(),
};
app.log("AVIF Maker started");
app
}
/// Add a message to the console log
pub fn log(&mut self, message: impl Into<String>) {
self.console_log.push(LogEntry {
timestamp: std::time::Instant::now(),
message: message.into(),
});
// Keep log reasonable size
if self.console_log.len() > 1000 {
self.console_log.remove(0);
}
}
/// Format the console log as plain text for copy/save
fn format_console_log(&self) -> String {
let mut output = String::new();
output.push_str("AVIF Maker Console Log\n");
output.push_str("======================\n\n");
for entry in &self.console_log {
let elapsed = entry.timestamp.duration_since(self.start_time);
output.push_str(&format!("[{:8.2}s] {}\n", elapsed.as_secs_f64(), entry.message));
}
output
}
fn load_settings() -> Option<EncodingSettings> {
let dirs = directories::ProjectDirs::from("", "", "avif-maker")?;
@@ -231,14 +294,41 @@ impl AvifMakerApp {
settings: EncodingSettings,
tx: Sender<WorkerMessage>,
) {
let filename = input_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
// Log start
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("Starting conversion: {}", filename),
});
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!(" Input: {}", input_path.display()),
});
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!(" Output: {}", output_path.display()),
});
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!(" Settings: quality={}, alpha={}, speed={}",
settings.quality, settings.alpha_quality, settings.speed),
});
// Create decode progress callback
let tx_decode = tx.clone();
let tx_decode_log = tx.clone();
let last_logged_frame = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let last_logged_clone = last_logged_frame.clone();
let decode_progress: ProgressCallback = Arc::new(move |current, total| {
let (progress, detail) = if let Some(total) = total {
let pct = current as f32 / total as f32;
(pct * 0.5, format!("frame {}/{}", current, total)) // Decoding is 0-50%
(pct * 0.5, format!("frame {}/{}", current, total))
} else {
// Unknown total - just show frame count
(0.25, format!("frame {}", current))
};
let _ = tx_decode.send(WorkerMessage::ProgressUpdate {
@@ -247,6 +337,18 @@ impl AvifMakerApp {
progress,
detail: Some(detail),
});
// Log every 10 frames or when total is known and we hit milestones
let last = last_logged_clone.load(std::sync::atomic::Ordering::Relaxed);
if current >= last + 10 || current == 1 {
last_logged_clone.store(current, std::sync::atomic::Ordering::Relaxed);
let msg = if let Some(t) = total {
format!(" Decoded frame {}/{}", current, t)
} else {
format!(" Decoded frame {}", current)
};
let _ = tx_decode_log.send(WorkerMessage::LogMessage { job_id, message: msg });
}
});
// Update status: decoding started
@@ -256,12 +358,21 @@ impl AvifMakerApp {
progress: 0.0,
detail: Some("starting...".to_string()),
});
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: "Decoding started...".to_string(),
});
// Decode input with progress
let decode_start = std::time::Instant::now();
let decoder = Decoder::for_path(&input_path);
let frames = match decoder.decode_with_progress(&input_path, Some(decode_progress)) {
Ok(frames) => frames,
Err(e) => {
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("ERROR: Decode failed: {}", e),
});
let _ = tx.send(WorkerMessage::JobFailed {
job_id,
error: format!("Decode error: {}", e),
@@ -269,13 +380,30 @@ impl AvifMakerApp {
return;
}
};
let decode_time = decode_start.elapsed();
let total_frames = frames.frames.len();
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("Decode complete: {} frames in {:.2}s", total_frames, decode_time.as_secs_f64()),
});
if frames.has_alpha {
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: " Source has alpha channel".to_string(),
});
}
// Create encode progress callback
let tx_encode = tx.clone();
let tx_encode_log = tx.clone();
let last_logged_encode = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let last_logged_encode_clone = last_logged_encode.clone();
let total_for_log = total_frames;
let encode_progress: EncodeProgressCallback = Arc::new(move |current, total| {
let pct = 0.5 + (current as f32 / total as f32) * 0.5; // Encoding is 50-100%
let pct = 0.5 + (current as f32 / total as f32) * 0.5;
let detail = format!("frame {}/{}", current, total);
let _ = tx_encode.send(WorkerMessage::ProgressUpdate {
job_id,
@@ -283,6 +411,16 @@ impl AvifMakerApp {
progress: pct,
detail: Some(detail),
});
// Log every 10 frames
let last = last_logged_encode_clone.load(std::sync::atomic::Ordering::Relaxed);
if current >= last + 10 || current == 1 || current == total_for_log {
last_logged_encode_clone.store(current, std::sync::atomic::Ordering::Relaxed);
let _ = tx_encode_log.send(WorkerMessage::LogMessage {
job_id,
message: format!(" Encoded frame {}/{}", current, total),
});
}
});
// Update status: encoding started
@@ -292,17 +430,55 @@ impl AvifMakerApp {
progress: 0.5,
detail: Some(format!("0/{} frames", total_frames)),
});
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("Encoding started ({} frames)...", total_frames),
});
// Encode to AVIF with progress
let encode_start = std::time::Instant::now();
let encoder = AvifEncoder::new(&settings);
match encoder.encode_with_progress(&frames, &output_path, Some(encode_progress)) {
Ok(()) => {
let encode_time = encode_start.elapsed();
let total_time = decode_time + encode_time;
// Get output file size
let file_size = std::fs::metadata(&output_path)
.map(|m| m.len())
.unwrap_or(0);
let size_str = if file_size > 1024 * 1024 {
format!("{:.2} MB", file_size as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} KB", file_size as f64 / 1024.0)
};
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("Encode complete in {:.2}s", encode_time.as_secs_f64()),
});
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("Output size: {}", size_str),
});
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("Total time: {:.2}s", total_time.as_secs_f64()),
});
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("SUCCESS: {}", filename),
});
let _ = tx.send(WorkerMessage::JobComplete {
job_id,
output_path,
});
}
Err(e) => {
let _ = tx.send(WorkerMessage::LogMessage {
job_id,
message: format!("ERROR: Encode failed: {}", e),
});
let _ = tx.send(WorkerMessage::JobFailed {
job_id,
error: format!("Encode error: {}", e),
@@ -326,6 +502,9 @@ impl AvifMakerApp {
job.progress_detail = detail;
}
}
WorkerMessage::LogMessage { job_id: _, message } => {
self.log(message);
}
WorkerMessage::JobComplete { job_id, output_path } => {
if let Some(job) = self.jobs.iter_mut().find(|j| j.id == job_id) {
job.status = JobStatus::Complete;
@@ -340,7 +519,7 @@ impl AvifMakerApp {
WorkerMessage::JobFailed { job_id, error } => {
if let Some(job) = self.jobs.iter_mut().find(|j| j.id == job_id) {
job.status = JobStatus::Failed;
job.error = Some(error);
job.error = Some(error.clone());
job.progress_detail = None;
}
self.is_converting = false;
@@ -376,25 +555,107 @@ impl eframe::App for AvifMakerApp {
ui.horizontal(|ui| {
ui.heading("AVIF Maker");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Console toggle button
let console_label = if self.show_console { "Hide Console" } else { "Show Console" };
if ui.button(console_label).clicked() {
self.show_console = !self.show_console;
}
ui.separator();
ui.label(format!("{} files in queue", self.jobs.len()));
});
});
ui.add_space(4.0);
});
// Left panel with settings
// Left panel with settings (rendered before bottom panel so it claims full height)
egui::SidePanel::left("settings_panel")
.resizable(true)
.default_width(280.0)
.show(ctx, |ui| {
self.settings_ui.show(ui, &mut self.settings);
ui.add_space(16.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
self.settings_ui.show(ui, &mut self.settings);
ui.add_space(16.0);
// Save settings when changed
if ui.button("Save Settings").clicked() {
self.save_settings();
}
// Save settings when changed
if ui.button("Save Settings").clicked() {
self.save_settings();
}
ui.add_space(8.0);
});
});
// Console panel (bottom) - rendered after left panel so it doesn't overlap
if self.show_console {
egui::TopBottomPanel::bottom("console_panel")
.resizable(true)
.default_height(200.0)
.min_height(100.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading("Console");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Clear").clicked() {
self.console_log.clear();
}
if ui.button("Save...").clicked() {
if let Some(path) = rfd::FileDialog::new()
.set_file_name("avif-maker-log.txt")
.add_filter("Text files", &["txt", "log"])
.save_file()
{
let log_text = self.format_console_log();
if let Err(e) = std::fs::write(&path, &log_text) {
self.log(format!("ERROR: Failed to save log: {}", e));
} else {
self.log(format!("Log saved to: {}", path.display()));
}
}
}
if ui.button("Copy").clicked() {
let log_text = self.format_console_log();
ui.ctx().copy_text(log_text);
self.log("Console log copied to clipboard");
}
ui.separator();
ui.label(format!("{} entries", self.console_log.len()));
});
});
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
ui.style_mut().override_font_id = Some(egui::FontId::monospace(14.0));
for entry in &self.console_log {
let elapsed = entry.timestamp.duration_since(self.start_time);
let timestamp = format!("[{:6.2}s]", elapsed.as_secs_f64());
// Color based on message content
let color = if entry.message.contains("ERROR") {
egui::Color32::from_rgb(255, 100, 100)
} else if entry.message.contains("SUCCESS") {
egui::Color32::from_rgb(100, 255, 100)
} else if entry.message.starts_with(" ") {
egui::Color32::from_gray(160)
} else {
egui::Color32::from_gray(220)
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&timestamp).color(egui::Color32::from_gray(120)));
ui.label(egui::RichText::new(&entry.message).color(color));
});
}
});
});
}
// Collect files from drop zone (to avoid borrow issues)
let mut dropped_paths: Vec<PathBuf> = Vec::new();
@@ -413,27 +674,40 @@ impl eframe::App for AvifMakerApp {
// Action buttons
ui.horizontal(|ui| {
let has_queued = self.jobs.iter().any(|j| j.status == JobStatus::Queued);
let has_completed = self.jobs.iter().any(|j|
j.status == JobStatus::Complete || j.status == JobStatus::Failed
);
// Clear Completed on the left
if ui
.add_enabled(
has_queued && !self.is_converting,
egui::Button::new("Convert All"),
)
.add_enabled(has_completed, egui::Button::new("Clear Completed"))
.clicked()
{
self.start_conversion();
}
if ui
.add_enabled(self.is_converting, egui::Button::new("Cancel"))
.clicked()
{
// Cancel is a no-op for now - could implement with cancellation tokens
self.clear_completed();
}
// Convert All and Cancel on the right
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Clear Completed").clicked() {
self.clear_completed();
if ui
.add_enabled(self.is_converting, egui::Button::new("Cancel"))
.clicked()
{
// Cancel is a no-op for now - could implement with cancellation tokens
}
// Make Convert All prominent (green) when there are files to process
let convert_button = if has_queued && !self.is_converting {
egui::Button::new(egui::RichText::new("Convert All").strong())
.fill(egui::Color32::from_rgb(34, 139, 34)) // Forest green
} else {
egui::Button::new("Convert All")
};
if ui
.add_enabled(has_queued && !self.is_converting, convert_button)
.clicked()
{
self.start_conversion();
}
});
});

View File

@@ -14,8 +14,8 @@ fn main() -> eframe::Result<()> {
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([900.0, 700.0])
.with_min_inner_size([600.0, 400.0])
.with_inner_size([1000.0, 900.0])
.with_min_inner_size([700.0, 600.0])
.with_drag_and_drop(true),
..Default::default()
};

View File

@@ -15,7 +15,7 @@ impl DropZone {
F: FnMut(Vec<PathBuf>),
{
let available_width = ui.available_width();
let height = 120.0;
let height = 140.0;
// Check for drag-and-drop hover state
let is_hovering = ui.ctx().input(|i| !i.raw.hovered_files.is_empty());
@@ -82,35 +82,57 @@ impl DropZone {
}
}
// Draw text
let text = if self.is_hovering {
"Drop files here"
} else {
"Drag and drop files here\nor click to browse"
};
// Text colors
let text_color = if self.is_hovering {
Color32::WHITE
} else {
Color32::from_gray(180)
};
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
text_color,
);
let font = egui::FontId::proportional(20.0);
let hint_font = egui::FontId::proportional(15.0);
// Supported formats hint
ui.painter().text(
egui::pos2(rect.center().x, rect.max.y - 20.0),
egui::Align2::CENTER_CENTER,
"GIF, PNG, JPEG, WebP, MP4, MOV, WebM",
egui::FontId::proportional(11.0),
Color32::from_gray(120),
);
// Calculate vertical positions for all text elements to be visually centered together
// Main text line height ~24px, hint ~18px, spacing between ~12px
// Total height: 24 + 24 + 12 + 18 = ~78px for non-hover, center that in rect
let total_text_height = 78.0;
let top_of_text = rect.center().y - total_text_height / 2.0;
if self.is_hovering {
// Single line when hovering - center it
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"Drop files here",
font,
text_color,
);
} else {
// Line 1: "Drag and drop files here"
ui.painter().text(
egui::pos2(rect.center().x, top_of_text + 12.0),
egui::Align2::CENTER_CENTER,
"Drag and drop files here",
font.clone(),
text_color,
);
// Line 2: "or click to browse"
ui.painter().text(
egui::pos2(rect.center().x, top_of_text + 38.0),
egui::Align2::CENTER_CENTER,
"or click to browse",
font,
text_color,
);
// Hint line at bottom of text block
ui.painter().text(
egui::pos2(rect.center().x, top_of_text + 66.0),
egui::Align2::CENTER_CENTER,
"GIF, PNG, JPEG, WebP, MP4, MOV, WebM",
hint_font,
Color32::from_gray(140),
);
}
// Handle click to open file dialog
if response.clicked() {

View File

@@ -16,7 +16,7 @@ impl Queue {
ui.centered_and_justified(|ui| {
ui.label(
RichText::new("No files in queue")
.size(14.0)
.size(18.0)
.color(Color32::from_gray(120)),
);
});

View File

@@ -12,11 +12,17 @@ impl Settings {
ui.heading("Encoding Settings");
ui.add_space(12.0);
// Quality slider
// Quality setting with editable number
ui.horizontal(|ui| {
ui.label("Quality:");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(format!("{}", settings.quality));
let mut quality = settings.quality as i32;
let drag = egui::DragValue::new(&mut quality)
.range(0..=100)
.speed(1);
if ui.add(drag).changed() {
settings.quality = quality.clamp(0, 100) as u8;
}
});
});
let quality_slider = egui::Slider::new(&mut settings.quality, 0..=100)
@@ -25,17 +31,22 @@ impl Settings {
ui.add_space(4.0);
ui.label(
egui::RichText::new("0 = smallest file, 100 = best quality")
.small()
.weak(),
);
ui.add_space(12.0);
// Alpha quality slider
// Alpha quality setting with editable number
ui.horizontal(|ui| {
ui.label("Alpha Quality:");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(format!("{}", settings.alpha_quality));
let mut alpha = settings.alpha_quality as i32;
let drag = egui::DragValue::new(&mut alpha)
.range(0..=100)
.speed(1);
if ui.add(drag).changed() {
settings.alpha_quality = alpha.clamp(0, 100) as u8;
}
});
});
let alpha_slider = egui::Slider::new(&mut settings.alpha_quality, 0..=100)
@@ -44,17 +55,22 @@ impl Settings {
ui.add_space(4.0);
ui.label(
egui::RichText::new("Separate quality for transparency")
.small()
.weak(),
);
ui.add_space(12.0);
// Speed slider
// Speed setting with editable number
ui.horizontal(|ui| {
ui.label("Speed:");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(format!("{}", settings.speed));
let mut speed = settings.speed as i32;
let drag = egui::DragValue::new(&mut speed)
.range(0..=10)
.speed(0.1);
if ui.add(drag).changed() {
settings.speed = speed.clamp(0, 10) as u8;
}
});
});
let speed_slider = egui::Slider::new(&mut settings.speed, 0..=10)
@@ -63,7 +79,6 @@ impl Settings {
ui.add_space(4.0);
ui.label(
egui::RichText::new("0 = slowest/best, 10 = fastest")
.small()
.weak(),
);
@@ -83,7 +98,6 @@ impl Settings {
ui.add_space(4.0);
ui.label(
egui::RichText::new("Perfect quality, larger files")
.small()
.weak(),
);
@@ -148,7 +162,7 @@ impl Settings {
.map(|p| p.display().to_string())
.unwrap_or_else(|| "Not set".to_string());
ui.label(egui::RichText::new(&dir_text).small());
ui.label(egui::RichText::new(&dir_text));
});
if ui.button("Browse...").clicked() {