feat(onboarding): guided first-run tour with background FFmpeg provisioning
Phase 5 of the 0.1.0 release prep: - FFmpeg no longer blocks startup (or crashes the app offline): provisioning runs in a background thread emitting throttled ffmpeg-progress events, with installed/downloading/failed state queryable via get_ffmpeg_status and a retry command - video jobs are invisible to claiming and tier-priority checks until FFmpeg is ready, so they wait as pending without failing — and without starving image embeddings/tagging (include_videos gating in db.rs) - 7-step show-don't-tell onboarding wizard: welcome + live FFmpeg progress, real first-folder picker, faked animating pipeline bar, placeholder gallery tiles, cycling search-syntax demo, views overview, and an AI features step with a real opt-in tagger download - skippable at every point (Escape included), persisted via settings/onboarding_completed.txt, re-runnable from Settings > General, which also gains an FFmpeg status/retry row
This commit is contained in:
+128
-25
@@ -2,9 +2,136 @@ use anyhow::{anyhow, Result};
|
||||
use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent};
|
||||
use ffmpeg_sidecar::ffprobe::ffprobe_path;
|
||||
use ffmpeg_sidecar::paths::ffmpeg_path;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
static FFMPEG_READY: AtomicBool = AtomicBool::new(false);
|
||||
static FFMPEG_DOWNLOADING: AtomicBool = AtomicBool::new(false);
|
||||
// Set when a provision attempt fails, so the frontend can distinguish
|
||||
// "failed before the event listener attached" from "about to start".
|
||||
static FFMPEG_FAILED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// True once both ffmpeg and ffprobe binaries are present on disk. Workers
|
||||
/// gate video jobs on this so a missing/in-flight download never fails jobs.
|
||||
pub fn ffmpeg_ready() -> bool {
|
||||
FFMPEG_READY.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn ffmpeg_downloading() -> bool {
|
||||
FFMPEG_DOWNLOADING.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn ffmpeg_failed() -> bool {
|
||||
FFMPEG_FAILED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FfmpegProgressPayload {
|
||||
pub phase: String,
|
||||
pub downloaded_bytes: Option<u64>,
|
||||
pub total_bytes: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl FfmpegProgressPayload {
|
||||
fn phase(phase: &str) -> Self {
|
||||
Self {
|
||||
phase: phase.to_string(),
|
||||
downloaded_bytes: None,
|
||||
total_bytes: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FFMPEG_PROGRESS_EVENT: &str = "ffmpeg-progress";
|
||||
|
||||
/// Provision FFmpeg in the background, streaming progress to the frontend as
|
||||
/// `ffmpeg-progress` events. Idempotent: re-invoking while a download is in
|
||||
/// flight is a no-op, and re-invoking after success only re-emits `done`.
|
||||
pub fn spawn_ffmpeg_provision(app: AppHandle) {
|
||||
// Fast path: binaries already on disk (previous run, or a manual install).
|
||||
if ffmpeg_path().exists() && ffprobe_path().exists() {
|
||||
FFMPEG_READY.store(true, Ordering::Relaxed);
|
||||
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
|
||||
return;
|
||||
}
|
||||
|
||||
if FFMPEG_DOWNLOADING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return; // a download is already running
|
||||
}
|
||||
FFMPEG_FAILED.store(false, Ordering::SeqCst);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// The Downloading callback fires very frequently; throttle emissions.
|
||||
// Cell because the download callback is Fn, not FnMut.
|
||||
let last_emit = std::cell::Cell::new(Instant::now() - Duration::from_secs(1));
|
||||
let result = auto_download_with_progress(|event| match event {
|
||||
FfmpegDownloadProgressEvent::Starting => {
|
||||
log::info!("Downloading bundled FFmpeg...");
|
||||
let _ = app.emit(
|
||||
FFMPEG_PROGRESS_EVENT,
|
||||
FfmpegProgressPayload::phase("starting"),
|
||||
);
|
||||
}
|
||||
FfmpegDownloadProgressEvent::Downloading {
|
||||
total_bytes,
|
||||
downloaded_bytes,
|
||||
} => {
|
||||
if last_emit.get().elapsed() >= Duration::from_millis(250) {
|
||||
last_emit.set(Instant::now());
|
||||
let _ = app.emit(
|
||||
FFMPEG_PROGRESS_EVENT,
|
||||
FfmpegProgressPayload {
|
||||
phase: "downloading".to_string(),
|
||||
downloaded_bytes: Some(downloaded_bytes),
|
||||
total_bytes: Some(total_bytes),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
FfmpegDownloadProgressEvent::UnpackingArchive => {
|
||||
log::info!("Unpacking bundled FFmpeg...");
|
||||
let _ = app.emit(
|
||||
FFMPEG_PROGRESS_EVENT,
|
||||
FfmpegProgressPayload::phase("unpacking"),
|
||||
);
|
||||
}
|
||||
FfmpegDownloadProgressEvent::Done => {
|
||||
log::info!("Bundled FFmpeg ready.");
|
||||
}
|
||||
});
|
||||
|
||||
FFMPEG_DOWNLOADING.store(false, Ordering::SeqCst);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
FFMPEG_READY.store(true, Ordering::Relaxed);
|
||||
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
|
||||
}
|
||||
Err(error) => {
|
||||
FFMPEG_FAILED.store(true, Ordering::SeqCst);
|
||||
log::error!("FFmpeg provisioning failed: {error}");
|
||||
let _ = app.emit(
|
||||
FFMPEG_PROGRESS_EVENT,
|
||||
FfmpegProgressPayload {
|
||||
phase: "error".to_string(),
|
||||
downloaded_bytes: None,
|
||||
total_bytes: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// On Windows, GUI apps spawn subprocesses with a visible console window by default.
|
||||
// CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation.
|
||||
@@ -36,30 +163,6 @@ impl MediaTools {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_installed() -> Result<()> {
|
||||
// Skip download entirely if both binaries are already present.
|
||||
if ffmpeg_path().exists() && ffprobe_path().exists() {
|
||||
return Ok(());
|
||||
}
|
||||
auto_download_with_progress(|event| match event {
|
||||
FfmpegDownloadProgressEvent::Starting => {
|
||||
log::info!("Downloading bundled FFmpeg...");
|
||||
}
|
||||
FfmpegDownloadProgressEvent::Downloading {
|
||||
total_bytes,
|
||||
downloaded_bytes,
|
||||
} => {
|
||||
log::info!("Downloading bundled FFmpeg: {downloaded_bytes}/{total_bytes} bytes");
|
||||
}
|
||||
FfmpegDownloadProgressEvent::UnpackingArchive => {
|
||||
log::info!("Unpacking bundled FFmpeg...");
|
||||
}
|
||||
FfmpegDownloadProgressEvent::Done => {
|
||||
log::info!("Bundled FFmpeg ready.");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ffmpeg_command(&self) -> Command {
|
||||
let mut cmd = Command::new(&self.ffmpeg_path);
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
Reference in New Issue
Block a user