diff --git a/crates/api_clients/artcraft_client/src/enums/tauri/ux/tauri_event_name.rs b/crates/api_clients/artcraft_client/src/enums/tauri/ux/tauri_event_name.rs index a6246926..3a8144b4 100644 --- a/crates/api_clients/artcraft_client/src/enums/tauri/ux/tauri_event_name.rs +++ b/crates/api_clients/artcraft_client/src/enums/tauri/ux/tauri_event_name.rs @@ -77,6 +77,11 @@ pub enum TauriEventName { #[serde(rename = "refresh_account_state_event")] RefreshAccountStateEvent, + /// Special event: + /// The thumbnail worker produced a thumbnail for a downloaded file + #[serde(rename = "local_thumbnail_ready_event")] + LocalThumbnailReadyEvent, + /// Special event: /// Show a billing modal (a suggestion to pay, etc. for a specific provider) #[serde(rename = "show_provider_billing_modal_event")] @@ -131,6 +136,7 @@ impl TauriEventName { Self::GaussianGenerationCompleteEvent => "gaussian_generation_complete_event", Self::VideoGenerationCompleteEvent => "video_generation_complete_event", Self::RefreshAccountStateEvent => "refresh_account_state_event", + Self::LocalThumbnailReadyEvent => "local_thumbnail_ready_event", Self::ShowProviderBillingModalEvent => "show_provider_billing_modal_event", Self::ShowCredentialErrorModalEvent => "show_credential_error_modal_event", Self::FlashNoticeEvent => "flash_notice_event", @@ -154,6 +160,7 @@ impl TauriEventName { "gaussian_generation_complete_event" => Ok(Self::GaussianGenerationCompleteEvent), "video_generation_complete_event" => Ok(Self::VideoGenerationCompleteEvent), "refresh_account_state_event" => Ok(Self::RefreshAccountStateEvent), + "local_thumbnail_ready_event" => Ok(Self::LocalThumbnailReadyEvent), "show_provider_billing_modal_event" => Ok(Self::ShowProviderBillingModalEvent), "show_credential_error_modal_event" => Ok(Self::ShowCredentialErrorModalEvent), "flash_notice_event" => Ok(Self::FlashNoticeEvent), @@ -180,6 +187,7 @@ impl TauriEventName { Self::GaussianGenerationCompleteEvent, Self::VideoGenerationCompleteEvent, Self::RefreshAccountStateEvent, + Self::LocalThumbnailReadyEvent, Self::ShowProviderBillingModalEvent, Self::ShowCredentialErrorModalEvent, Self::FlashNoticeEvent, @@ -212,6 +220,7 @@ mod tests { assert_serialization(TauriEventName::GaussianGenerationCompleteEvent, "gaussian_generation_complete_event"); assert_serialization(TauriEventName::VideoGenerationCompleteEvent, "video_generation_complete_event"); assert_serialization(TauriEventName::RefreshAccountStateEvent, "refresh_account_state_event"); + assert_serialization(TauriEventName::LocalThumbnailReadyEvent, "local_thumbnail_ready_event"); assert_serialization(TauriEventName::ShowProviderBillingModalEvent, "show_provider_billing_modal_event"); assert_serialization(TauriEventName::ShowCredentialErrorModalEvent, "show_credential_error_modal_event"); assert_serialization(TauriEventName::FlashNoticeEvent, "flash_notice_event"); @@ -234,6 +243,7 @@ mod tests { assert_eq!(TauriEventName::GaussianGenerationCompleteEvent.to_str(), "gaussian_generation_complete_event"); assert_eq!(TauriEventName::VideoGenerationCompleteEvent.to_str(), "video_generation_complete_event"); assert_eq!(TauriEventName::RefreshAccountStateEvent.to_str(), "refresh_account_state_event"); + assert_eq!(TauriEventName::LocalThumbnailReadyEvent.to_str(), "local_thumbnail_ready_event"); assert_eq!(TauriEventName::ShowProviderBillingModalEvent.to_str(), "show_provider_billing_modal_event"); assert_eq!(TauriEventName::FlashNoticeEvent.to_str(), "flash_notice_event"); assert_eq!(TauriEventName::ProgressNoticeEvent.to_str(), "progress_notice_event"); @@ -255,6 +265,7 @@ mod tests { assert_eq!(TauriEventName::from_str("gaussian_generation_complete_event").unwrap(), TauriEventName::GaussianGenerationCompleteEvent); assert_eq!(TauriEventName::from_str("video_generation_complete_event").unwrap(), TauriEventName::VideoGenerationCompleteEvent); assert_eq!(TauriEventName::from_str("refresh_account_state_event").unwrap(), TauriEventName::RefreshAccountStateEvent); + assert_eq!(TauriEventName::from_str("local_thumbnail_ready_event").unwrap(), TauriEventName::LocalThumbnailReadyEvent); assert_eq!(TauriEventName::from_str("show_provider_billing_modal_event").unwrap(), TauriEventName::ShowProviderBillingModalEvent); assert_eq!(TauriEventName::from_str("show_credential_error_modal_event").unwrap(), TauriEventName::ShowCredentialErrorModalEvent); assert_eq!(TauriEventName::from_str("flash_notice_event").unwrap(), TauriEventName::FlashNoticeEvent); @@ -266,7 +277,7 @@ mod tests { #[test] fn all_variants() { let mut variants = TauriEventName::all_variants(); - assert_eq!(variants.len(), 18); + assert_eq!(variants.len(), 19); assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueSuccessEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueFailureEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationCompleteEvent)); @@ -279,6 +290,7 @@ mod tests { assert_eq!(variants.pop_first(), Some(TauriEventName::GaussianGenerationCompleteEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::VideoGenerationCompleteEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::RefreshAccountStateEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::LocalThumbnailReadyEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::ShowProviderBillingModalEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::ShowCredentialErrorModalEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::FlashNoticeEvent)); diff --git a/crates/artcraftx/Cargo.toml b/crates/artcraftx/Cargo.toml index 8f25998e..264dfc87 100644 --- a/crates/artcraftx/Cargo.toml +++ b/crates/artcraftx/Cargo.toml @@ -79,7 +79,7 @@ tauri-plugin-upload.workspace = true tauri.workspace = true tempdir.workspace = true tempfile.workspace = true -tokio = { version = "1.42", features = ["rt", "rt-multi-thread", "fs"] } +tokio = { version = "1.42", features = ["rt", "rt-multi-thread", "fs", "sync", "time", "macros"] } tokio-util.workspace = true toml.workspace = true url.workspace = true diff --git a/crates/artcraftx/src/commands/local_files/get_local_thumbnails_command.rs b/crates/artcraftx/src/commands/local_files/get_local_thumbnails_command.rs index 0f2b440a..8aa6fdbf 100644 --- a/crates/artcraftx/src/commands/local_files/get_local_thumbnails_command.rs +++ b/crates/artcraftx/src/commands/local_files/get_local_thumbnails_command.rs @@ -1,9 +1,10 @@ -//! Bulk thumbnail lookup/generation for local files. +//! Bulk thumbnail lookup for local files. //! //! Takes one or more absolute file paths and returns, per path, the cached -//! thumbnail (and video animated preview) — generating anything missing on -//! the fly. Per-item statuses mean one bad path never fails the batch, so -//! the command itself is infallible. +//! thumbnail (and video animated preview) if the worker has produced it. +//! Anything still `pending` is handed to the worker thread; the frontend +//! learns of it through `local_thumbnail_ready_event`. Per-item statuses +//! mean one bad path never fails the batch, so the command is infallible. use std::path::PathBuf; @@ -11,12 +12,12 @@ use serde_derive::Deserialize; use crate::commands::utils::response::shorthand::InfallibleResponse; use crate::commands::utils::response::success_response_wrapper::SerializeMarker; -use crate::services::local_files::thumbnail_service::{get_or_generate_thumbnail, LocalThumbnail}; -use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::services::local_files::thumbnail_service::{lookup_thumbnail, LocalThumbnail, LocalThumbnailStatus}; use crate::state::database::local_files_database::LocalFilesDatabase; +use crate::state::thumbnails::thumbnail_work_queue::ThumbnailWorkQueue; use log::info; use serde_derive::Serialize; -use tauri::State; +use tauri::{AppHandle, State}; // ── Request ── @@ -38,19 +39,21 @@ impl SerializeMarker for GetLocalThumbnailsResponse {} #[tauri::command] pub async fn get_local_thumbnails_command( + app: AppHandle, request: GetLocalThumbnailsRequest, - app_data_root: State<'_, AppDataRoot>, local_files_database: State<'_, LocalFilesDatabase>, ) -> Result, ()> { info!("get_local_thumbnails_command called for {} path(s)", request.file_paths.len()); - // Concurrent per path; actual CPU work is capped by the service's - // generation semaphore and de-duplicated by its per-path locks. let file_paths: Vec = request.file_paths.iter().map(PathBuf::from).collect(); - let lookups = file_paths.iter().map(|file_path| { - get_or_generate_thumbnail(&local_files_database, &app_data_root, file_path) - }); + let lookups = file_paths.iter().map(|file_path| lookup_thumbnail(&local_files_database, file_path)); let thumbnails = futures::future::join_all(lookups).await; + let pending: Vec<&str> = thumbnails.iter() + .filter(|thumbnail| thumbnail.status == LocalThumbnailStatus::Pending) + .map(|thumbnail| thumbnail.file_path.as_str()) + .collect(); + ThumbnailWorkQueue::enqueue_for_app(&app, &pending); + Ok(GetLocalThumbnailsResponse { thumbnails }.into()) } diff --git a/crates/artcraftx/src/commands/task_queue/get_task_queue_command.rs b/crates/artcraftx/src/commands/task_queue/get_task_queue_command.rs index 37078e87..9a67a6b3 100644 --- a/crates/artcraftx/src/commands/task_queue/get_task_queue_command.rs +++ b/crates/artcraftx/src/commands/task_queue/get_task_queue_command.rs @@ -1,6 +1,9 @@ use crate::commands::utils::response::shorthand::ResponseOrErrorMessage; use crate::commands::utils::response::success_response_wrapper::SerializeMarker; +use crate::services::local_files::thumbnail_service::generated_thumbnail_paths; +use crate::state::database::local_files_database::LocalFilesDatabase; use crate::state::database::task_database::TaskDatabase; +use local_files_database::queries::get_local_files_by_paths::get_local_files_by_paths; use chrono::{DateTime, Utc}; use core_types::enums::generation_source::GenerationSource; use sqlite_identifiers::enums::task_model_type::TaskModelType; @@ -64,6 +67,13 @@ pub struct CompletedItemData { /// the first (or only) file. Absolute paths recorded at completion time. pub maybe_download_directory: Option, pub maybe_first_downloaded_file: Option, + + /// Thumbnails of the first downloaded file, once the thumbnail worker has + /// produced them (absolute cache paths; render via the asset protocol). + /// Until then the frontend hears about them via + /// `local_thumbnail_ready_event`. + pub maybe_local_thumbnail_path: Option, + pub maybe_local_animated_preview_path: Option, } #[derive(Serialize)] @@ -88,6 +98,7 @@ impl SerializeMarker for GetTaskQueueCommandResponse {} pub async fn get_task_queue_command( _app: AppHandle, task_database: State<'_, TaskDatabase>, + local_files_database: State<'_, LocalFilesDatabase>, ) -> ResponseOrErrorMessage { // NB: This is debug because it spams the logs. @@ -95,6 +106,7 @@ pub async fn get_task_queue_command( let result = handle_request( &task_database, + &local_files_database, ).await; let tasks = match result { @@ -112,6 +124,7 @@ pub async fn get_task_queue_command( pub async fn handle_request( task_database: &TaskDatabase, + local_files_database: &LocalFilesDatabase, ) -> AnyhowResult> { let tasks = list_tasks_for_frontend(task_database.get_connection()) @@ -147,6 +160,8 @@ pub async fn handle_request( maybe_batch_token: task.on_complete_batch_token, maybe_download_directory: task.on_complete_directory_location.clone(), maybe_first_downloaded_file: task.on_complete_first_file_location.clone(), + maybe_local_thumbnail_path: None, + maybe_local_animated_preview_path: None, }); } else { // If either failure field is present, fill out the failure report. @@ -179,5 +194,37 @@ pub async fn handle_request( }) } + attach_local_thumbnails(local_files_database, &mut transformed_tasks).await; + Ok(transformed_tasks) } + +/// Fill in the worker-generated thumbnails for every downloaded result, in +/// one batch lookup. Fails open: a lookup error just leaves them unset. +async fn attach_local_thumbnails(local_files_database: &LocalFilesDatabase, tasks: &mut [TaskQueueItem]) { + let file_paths: Vec = tasks.iter() + .filter_map(|task| task.completed_item.as_ref()) + .filter_map(|item| item.maybe_first_downloaded_file.clone()) + .collect(); + if file_paths.is_empty() { + return; + } + + let records = match get_local_files_by_paths(local_files_database.get_connection(), &file_paths).await { + Ok(records) => records, + Err(err) => { + warn!("Could not look up local thumbnails for the task queue: {}", err); + return; + } + }; + + for item in tasks.iter_mut().filter_map(|task| task.completed_item.as_mut()) { + let Some(record) = item.maybe_first_downloaded_file.as_ref().and_then(|path| records.get(path)) else { + continue; + }; + if let Some((thumbnail_path, maybe_animated_preview_path)) = generated_thumbnail_paths(record) { + item.maybe_local_thumbnail_path = Some(thumbnail_path); + item.maybe_local_animated_preview_path = maybe_animated_preview_path; + } + } +} diff --git a/crates/artcraftx/src/events/functional_events/local_thumbnail_ready_event.rs b/crates/artcraftx/src/events/functional_events/local_thumbnail_ready_event.rs new file mode 100644 index 00000000..6a83d8e8 --- /dev/null +++ b/crates/artcraftx/src/events/functional_events/local_thumbnail_ready_event.rs @@ -0,0 +1,21 @@ +use crate::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use artcraft_client::enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; + +/// The thumbnail worker finished a local file. Paths are absolute; render +/// them through Tauri's asset protocol (`convertFileSrc`). +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct LocalThumbnailReadyEvent { + /// The source file the thumbnail is for (keys the frontend's cache). + pub file_path: String, + /// Static jpg in the thumbnail cache. + pub thumbnail_path: String, + /// ~1s animated webp, videos only. + pub maybe_animated_preview_path: Option, +} + +impl BasicSendableEvent for LocalThumbnailReadyEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::LocalThumbnailReadyEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/artcraftx/src/events/functional_events/mod.rs b/crates/artcraftx/src/events/functional_events/mod.rs index 4382c5cb..d9a92946 100644 --- a/crates/artcraftx/src/events/functional_events/mod.rs +++ b/crates/artcraftx/src/events/functional_events/mod.rs @@ -1,6 +1,7 @@ pub mod app_preferences_changed_event; pub mod credits_balance_changed_event; pub mod gaussian_generation_complete_event; +pub mod local_thumbnail_ready_event; pub mod object_generation_complete_event; pub mod refresh_account_state_event; pub mod show_credential_error_modal_event; diff --git a/crates/artcraftx/src/lifecycle/startup/handle_tauri_startup.rs b/crates/artcraftx/src/lifecycle/startup/handle_tauri_startup.rs index b5da3232..369b6bbb 100644 --- a/crates/artcraftx/src/lifecycle/startup/handle_tauri_startup.rs +++ b/crates/artcraftx/src/lifecycle/startup/handle_tauri_startup.rs @@ -5,6 +5,7 @@ use crate::lifecycle::startup::tasks::set_app_log_level::set_app_log_level; use crate::lifecycle::startup::tasks::spawn_discord_presence_thread::spawn_discord_presence_thread; use crate::lifecycle::startup::tasks::spawn_main_window_thread::spawn_main_window_thread; use crate::lifecycle::startup::tasks::spawn_storyteller_threads::spawn_storyteller_threads; +use crate::lifecycle::startup::tasks::spawn_thumbnail_worker_thread::spawn_thumbnail_worker_thread; use crate::state::runtime::artcraft_platform_info::ArtcraftPlatformInfo; use crate::state::usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; @@ -45,7 +46,15 @@ pub async fn handle_tauri_startup( let task_database = bootstrap_task_database(&app, &root).await?; - bootstrap_local_files_database(&app, &root).await?; + let local_files_database = + bootstrap_local_files_database(&app, &root).await?; + + spawn_thumbnail_worker_thread( + &app, + &root, + &task_database, + &local_files_database, + )?; spawn_main_window_thread( &app, diff --git a/crates/artcraftx/src/lifecycle/startup/tasks/mod.rs b/crates/artcraftx/src/lifecycle/startup/tasks/mod.rs index 01defb86..797ed6cf 100644 --- a/crates/artcraftx/src/lifecycle/startup/tasks/mod.rs +++ b/crates/artcraftx/src/lifecycle/startup/tasks/mod.rs @@ -5,3 +5,4 @@ pub (super) mod initially_size_and_position_windows; pub (super) mod spawn_discord_presence_thread; pub (super) mod spawn_main_window_thread; pub (super) mod spawn_storyteller_threads; +pub (super) mod spawn_thumbnail_worker_thread; diff --git a/crates/artcraftx/src/lifecycle/startup/tasks/spawn_thumbnail_worker_thread.rs b/crates/artcraftx/src/lifecycle/startup/tasks/spawn_thumbnail_worker_thread.rs new file mode 100644 index 00000000..bcb162e3 --- /dev/null +++ b/crates/artcraftx/src/lifecycle/startup/tasks/spawn_thumbnail_worker_thread.rs @@ -0,0 +1,29 @@ +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::database::local_files_database::LocalFilesDatabase; +use crate::state::database::task_database::TaskDatabase; +use crate::state::thumbnails::thumbnail_work_queue::ThumbnailWorkQueue; +use crate::threads::thumbnail_worker_thread::thumbnail_worker_thread::thumbnail_worker_thread; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager}; + +/// Start the background thumbnail worker and expose its wake-up queue as +/// managed state, so downloaders (and commands) can hand it files. +pub fn spawn_thumbnail_worker_thread( + app: &AppHandle, + app_data_root: &AppDataRoot, + task_database: &TaskDatabase, + local_files_database: &LocalFilesDatabase, +) -> AnyhowResult<()> { + let queue = ThumbnailWorkQueue::new(); + app.manage(queue.clone()); + + tauri::async_runtime::spawn(thumbnail_worker_thread( + app.clone(), + app_data_root.clone(), + task_database.clone(), + local_files_database.clone(), + queue, + )); + + Ok(()) +} diff --git a/crates/artcraftx/src/services/local_files/thumbnail_service.rs b/crates/artcraftx/src/services/local_files/thumbnail_service.rs index 6cdef1b9..6f83eea4 100644 --- a/crates/artcraftx/src/services/local_files/thumbnail_service.rs +++ b/crates/artcraftx/src/services/local_files/thumbnail_service.rs @@ -1,11 +1,18 @@ //! Thumbnails for local files, content-addressed and cached. //! -//! The routine: stat the file → get its BLAKE3 (from the local_files index -//! when size+mtime match, else rehash) → serve `cache/thumbnails/{hash}.jpg` -//! (static) and `{hash}.webp` (~1s animated preview, videos only), -//! generating them on first sight. Everything on disk is regenerable; the -//! database rows just make the lookups cheap (no re-reading files that -//! haven't changed). +//! Two entry points with one cache between them: +//! +//! - [`lookup_thumbnail`] is the cheap read path (a stat and a row lookup, +//! never a file read) for request handlers. It reports what exists and +//! says `Pending` for anything the worker hasn't produced yet. +//! - [`generate_thumbnail`] is the write path, run by the thumbnail worker +//! thread: stat → BLAKE3 (from the local_files index when size+mtime +//! match, else rehash) → `cache/thumbnails/{hash}.jpg` (static) and +//! `{hash}.webp` (~1s animated preview, videos only) → record the outcome +//! (state, cache paths, error, attempt count) on the file's row. +//! +//! Everything on disk is regenerable; the database rows make lookups cheap +//! and let the worker find work without walking the cache directory. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -14,24 +21,30 @@ use std::time::UNIX_EPOCH; use file_hashing::hash_file_blake3; use local_files_database::queries::get_local_file_by_path::get_local_file_by_path; -use local_files_database::queries::set_thumbnail_flags::set_thumbnail_flags; +use local_files_database::queries::local_file_record::LocalFileRecord; +use local_files_database::queries::set_thumbnail_result::{set_thumbnail_result, SetThumbnailResultArgs}; use local_files_database::queries::upsert_local_file::{upsert_local_file, UpsertLocalFileArgs}; +use local_files_database::thumbnail_state::ThumbnailState; use log::{info, warn}; use once_cell::sync::Lazy; use serde_derive::Serialize; -use thumbnails::{generate_image_thumbnail, generate_video_thumbnails, ThumbnailError, STATIC_THUMBNAIL_MAX_DIMENSION}; +use thumbnails::{generate_image_thumbnail, generate_video_thumbnails, ThumbnailError, STATIC_THUMBNAIL_MAX_DIMENSION, THUMBNAIL_GENERATOR_VERSION}; use tokio::sync::{Mutex as TokioMutex, Semaphore}; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::database::local_files_database::LocalFilesDatabase; +/// Failed generations are retried until this many attempts; after that the +/// file is left alone (a placeholder is shown). +pub const MAX_THUMBNAIL_ATTEMPTS: i64 = 3; + /// Cap on concurrent thumbnail generations (decode + encode are CPU-heavy). const MAX_CONCURRENT_GENERATIONS: usize = 2; static GENERATION_SEMAPHORE: Lazy = Lazy::new(|| Semaphore::new(MAX_CONCURRENT_GENERATIONS)); -/// Per-path locks so overlapping bulk requests don't hash or generate the -/// same file twice. +/// Per-path locks so the worker and a stray direct call never hash or +/// generate the same file twice at once. static PER_PATH_LOCKS: Lazy>>>> = Lazy::new(|| StdMutex::new(HashMap::new())); @@ -43,11 +56,14 @@ const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov"]; pub enum LocalThumbnailStatus { /// A static thumbnail exists (animated preview too, when the path is set). Ready, + /// Not generated yet; the worker thread will get to it and the frontend + /// hears about it via `local_thumbnail_ready_event`. + Pending, /// The file type (or codec) can't be thumbnailed; show a placeholder. Unsupported, /// The source file no longer exists on disk. MissingFile, - /// Generation errored; a retry may or may not help. + /// Generation errored and won't be retried; show a placeholder. Failed, } @@ -70,10 +86,92 @@ impl LocalThumbnail { maybe_animated_preview_path: None, } } + + fn ready(file_path: &Path, file_hash: String, thumbnail_jpg: &Path, animated_webp: Option<&Path>) -> Self { + Self { + file_path: file_path.to_string_lossy().into_owned(), + status: LocalThumbnailStatus::Ready, + maybe_file_hash: Some(file_hash), + maybe_thumbnail_path: Some(thumbnail_jpg.to_string_lossy().into_owned()), + maybe_animated_preview_path: animated_webp.map(|path| path.to_string_lossy().into_owned()), + } + } } -/// Get (generating if needed) the cached thumbnails for one local file. -pub async fn get_or_generate_thumbnail( +/// The cache paths a `local_files` row points at, if its thumbnail has been +/// generated. No disk access — the row is trusted (the worker's scan +/// re-checks disk and regenerates anything that went missing). +pub fn generated_thumbnail_paths(record: &LocalFileRecord) -> Option<(String, Option)> { + if record.thumbnail_state() != ThumbnailState::Generated { + return None; + } + let jpg = record.thumbnail_jpg_path.clone()?; + Some((jpg, record.animated_preview_webp_path.clone())) +} + +/// Whether the worker should (re)generate this row's thumbnail: never done, +/// still retryable, made by an older generator, or done but the cache file +/// is gone. +pub fn needs_generation(record: &LocalFileRecord) -> bool { + match record.thumbnail_state() { + ThumbnailState::Pending => true, + ThumbnailState::Failed => record.thumbnail_attempts < MAX_THUMBNAIL_ATTEMPTS, + ThumbnailState::Unsupported => false, + ThumbnailState::Generated => { + if record.thumbnail_generator_version < THUMBNAIL_GENERATOR_VERSION { + return true; + } + match record.thumbnail_jpg_path.as_deref() { + Some(jpg) => !Path::new(jpg).is_file(), + None => true, + } + } + } +} + +/// Cheap read: what the cache holds for one file right now. Never hashes or +/// generates; anything not yet produced is `Pending`. +pub async fn lookup_thumbnail(database: &LocalFilesDatabase, file_path: &Path) -> LocalThumbnail { + let Some((file_size_bytes, file_mtime_ms)) = stat_file(file_path) else { + return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::MissingFile, None); + }; + + let path_string = file_path.to_string_lossy().into_owned(); + let record = match get_local_file_by_path(database.get_connection(), &path_string).await { + Ok(Some(record)) if record.matches_stat(file_size_bytes, file_mtime_ms) => record, + Ok(_) => return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Pending, None), + Err(err) => { + warn!("local_files lookup failed for {}: {}", path_string, err); + return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Pending, None); + } + }; + + let file_hash = Some(record.file_hash_blake3.clone()); + match record.thumbnail_state() { + ThumbnailState::Generated => match generated_thumbnail_paths(&record) { + Some((jpg, maybe_webp)) if Path::new(&jpg).is_file() => LocalThumbnail { + file_path: path_string, + status: LocalThumbnailStatus::Ready, + maybe_file_hash: file_hash, + maybe_thumbnail_path: Some(jpg), + maybe_animated_preview_path: maybe_webp, + }, + // Cache file gone; the worker regenerates on its next scan. + _ => LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Pending, file_hash), + }, + ThumbnailState::Unsupported => LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Unsupported, file_hash), + ThumbnailState::Failed if record.thumbnail_attempts >= MAX_THUMBNAIL_ATTEMPTS => { + LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Failed, file_hash) + } + ThumbnailState::Failed | ThumbnailState::Pending => { + LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Pending, file_hash) + } + } +} + +/// Produce (or confirm) the cached thumbnails for one file and record the +/// outcome on its row. The worker thread's unit of work. +pub async fn generate_thumbnail( database: &LocalFilesDatabase, app_data_root: &AppDataRoot, file_path: &Path, @@ -82,15 +180,9 @@ pub async fn get_or_generate_thumbnail( let path_lock = per_path_lock(file_path); let _path_guard = path_lock.lock().await; - let stat = match std::fs::metadata(file_path) { - Ok(metadata) if metadata.is_file() => metadata, - _ => return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::MissingFile, None), + let Some((file_size_bytes, file_mtime_ms)) = stat_file(file_path) else { + return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::MissingFile, None); }; - let file_size_bytes = stat.len() as i64; - let file_mtime_ms = stat.modified().ok() - .and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis() as i64) - .unwrap_or(0); let file_hash = match cached_or_computed_hash(database, file_path, file_size_bytes, file_mtime_ms).await { Ok(hash) => hash, @@ -110,27 +202,34 @@ pub async fn get_or_generate_thumbnail( let thumbnail_jpg = thumbnails_dir.join(format!("{file_hash}.jpg")); let animated_webp = thumbnails_dir.join(format!("{file_hash}.webp")); - // The filesystem is the source of truth; the DB flags are hints. - if thumbnail_jpg.is_file() { - return LocalThumbnail { - file_path: file_path.to_string_lossy().into_owned(), - status: LocalThumbnailStatus::Ready, - maybe_file_hash: Some(file_hash), - maybe_thumbnail_path: Some(thumbnail_jpg.to_string_lossy().into_owned()), - maybe_animated_preview_path: animated_webp.is_file() - .then(|| animated_webp.to_string_lossy().into_owned()), - }; - } - let extension = file_extension(file_path); let kind = if IMAGE_EXTENSIONS.contains(&extension.as_str()) { MediaKind::Image } else if VIDEO_EXTENSIONS.contains(&extension.as_str()) { MediaKind::Video } else { + record_outcome(database, file_path, ThumbnailState::Unsupported, None, None, Some(&format!("unsupported extension {extension:?}"))).await; return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Unsupported, Some(file_hash)); }; + // Already in the cache (same content seen at another path, or a previous + // run of this generator): just point the row at it. Videos count as + // cached only with their animated preview, so an earlier partial result + // gets completed; older generators' output is always redone. + let made_by_current_generator = get_local_file_by_path(database.get_connection(), &file_path.to_string_lossy()).await + .ok() + .flatten() + .is_some_and(|record| record.thumbnail_generator_version >= THUMBNAIL_GENERATOR_VERSION); + let cache_complete = made_by_current_generator && thumbnail_jpg.is_file() && match kind { + MediaKind::Image => true, + MediaKind::Video => animated_webp.is_file(), + }; + if cache_complete { + let has_webp = animated_webp.is_file(); + record_outcome(database, file_path, ThumbnailState::Generated, Some(&thumbnail_jpg), has_webp.then_some(&animated_webp), None).await; + return LocalThumbnail::ready(file_path, file_hash, &thumbnail_jpg, has_webp.then_some(animated_webp.as_path())); + } + let generation = { let _permit = GENERATION_SEMAPHORE.acquire().await.expect("semaphore never closes"); let source = file_path.to_path_buf(); @@ -142,26 +241,22 @@ pub async fn get_or_generate_thumbnail( match generation { Ok(Ok(wrote_animated_preview)) => { info!("Generated thumbnail for {} -> {}", file_path.display(), thumbnail_jpg.display()); - record_flags(database, file_path, true, wrote_animated_preview).await; - LocalThumbnail { - file_path: file_path.to_string_lossy().into_owned(), - status: LocalThumbnailStatus::Ready, - maybe_file_hash: Some(file_hash), - maybe_thumbnail_path: Some(thumbnail_jpg.to_string_lossy().into_owned()), - maybe_animated_preview_path: wrote_animated_preview - .then(|| animated_webp.to_string_lossy().into_owned()), - } + record_outcome(database, file_path, ThumbnailState::Generated, Some(&thumbnail_jpg), wrote_animated_preview.then_some(&animated_webp), None).await; + LocalThumbnail::ready(file_path, file_hash, &thumbnail_jpg, wrote_animated_preview.then_some(animated_webp.as_path())) } Ok(Err(err)) if err.is_unsupported() => { info!("Media not thumbnailable ({}): {}", file_path.display(), err); + record_outcome(database, file_path, ThumbnailState::Unsupported, None, None, Some(&err.to_string())).await; LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Unsupported, Some(file_hash)) } Ok(Err(err)) => { warn!("Thumbnail generation failed for {}: {}", file_path.display(), err); + record_outcome(database, file_path, ThumbnailState::Failed, None, None, Some(&err.to_string())).await; LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Failed, Some(file_hash)) } Err(join_error) => { warn!("Thumbnail generation task panicked for {}: {}", file_path.display(), join_error); + record_outcome(database, file_path, ThumbnailState::Failed, None, None, Some(&join_error.to_string())).await; LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Failed, Some(file_hash)) } } @@ -230,13 +325,46 @@ async fn cached_or_computed_hash( Ok(file_hash) } -async fn record_flags(database: &LocalFilesDatabase, file_path: &Path, has_thumbnail: bool, has_animated_preview: bool) { +async fn record_outcome( + database: &LocalFilesDatabase, + file_path: &Path, + state: ThumbnailState, + maybe_jpg: Option<&Path>, + maybe_webp: Option<&Path>, + maybe_error: Option<&str>, +) { let path_string = file_path.to_string_lossy(); - if let Err(err) = set_thumbnail_flags(database.get_connection(), &path_string, has_thumbnail, has_animated_preview).await { - warn!("Could not record thumbnail flags for {}: {}", path_string, err); + let jpg = maybe_jpg.map(|path| path.to_string_lossy().into_owned()); + let webp = maybe_webp.map(|path| path.to_string_lossy().into_owned()); + let result = set_thumbnail_result(SetThumbnailResultArgs { + db: database.get_connection(), + file_path: &path_string, + state, + generator_version: THUMBNAIL_GENERATOR_VERSION, + maybe_thumbnail_jpg_path: jpg.as_deref(), + maybe_animated_preview_webp_path: webp.as_deref(), + maybe_error, + }).await; + match result { + Ok(true) => {} + Ok(false) => warn!("No local_files row to record thumbnail state for {}", path_string), + Err(err) => warn!("Could not record thumbnail state for {}: {}", path_string, err), } } +/// `(size, mtime_ms)` for a regular file, `None` if it's missing. +fn stat_file(file_path: &Path) -> Option<(i64, i64)> { + let stat = match std::fs::metadata(file_path) { + Ok(metadata) if metadata.is_file() => metadata, + _ => return None, + }; + let file_mtime_ms = stat.modified().ok() + .and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0); + Some((stat.len() as i64, file_mtime_ms)) +} + fn per_path_lock(file_path: &Path) -> Arc> { let mut locks = PER_PATH_LOCKS.lock().expect("lock poisoned"); locks.entry(file_path.to_path_buf()).or_default().clone() @@ -248,3 +376,89 @@ fn file_extension(file_path: &Path) -> String { .map(str::to_ascii_lowercase) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + + // The 4-second H.264 clip the higgsfield_client live tests upload. + const H264_FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../api_clients/higgsfield_client/test_assets/clip_4s.mp4", + ); + + /// The worker's write path against a throwaway data root and index. + #[tokio::test] + async fn generates_records_and_then_serves_a_video_thumbnail() { + let dir = tempfile::tempdir().unwrap(); + let app_data_root = AppDataRoot::create_existing(dir.path().join("root")).unwrap(); + let database = LocalFilesDatabase::connect(&app_data_root).await.unwrap(); + let video = dir.path().join("downloaded.mp4"); + std::fs::copy(H264_FIXTURE, &video).unwrap(); + + // Nothing yet: the read path says pending without touching the file. + let before = lookup_thumbnail(&database, &video).await; + assert_eq!(before.status, LocalThumbnailStatus::Pending); + assert!(before.maybe_file_hash.is_none()); + + let generated = generate_thumbnail(&database, &app_data_root, &video).await; + assert_eq!(generated.status, LocalThumbnailStatus::Ready); + let jpg = PathBuf::from(generated.maybe_thumbnail_path.clone().unwrap()); + let webp = PathBuf::from(generated.maybe_animated_preview_path.clone().unwrap()); + assert!(jpg.is_file(), "static thumbnail written"); + assert!(webp.is_file(), "animated preview written"); + // Content-addressed under the cache dir. + let hash = generated.maybe_file_hash.clone().unwrap(); + assert_eq!(jpg.file_name().unwrap().to_str().unwrap(), format!("{hash}.jpg")); + assert!(jpg.starts_with(app_data_root.cache_dir().get_or_create_thumbnails_dir().unwrap())); + + // The row records the outcome, so readers never stat the cache. + let record = get_local_file_by_path(database.get_connection(), &video.to_string_lossy()).await.unwrap().unwrap(); + assert_eq!(record.thumbnail_state(), ThumbnailState::Generated); + assert_eq!(record.thumbnail_attempts, 1); + assert_eq!(record.thumbnail_generator_version, THUMBNAIL_GENERATOR_VERSION); + assert!(record.has_thumbnail && record.has_animated_preview); + assert_eq!(record.thumbnail_jpg_path.as_deref(), Some(jpg.to_str().unwrap())); + assert!(!needs_generation(&record)); + assert_eq!(generated_thumbnail_paths(&record), Some((jpg.to_string_lossy().into_owned(), Some(webp.to_string_lossy().into_owned())))); + + let served = lookup_thumbnail(&database, &video).await; + assert_eq!(served.status, LocalThumbnailStatus::Ready); + assert_eq!(served.maybe_thumbnail_path.as_deref(), Some(jpg.to_str().unwrap())); + assert_eq!(served.maybe_animated_preview_path.as_deref(), Some(webp.to_str().unwrap())); + + // A second pass is a cache hit (no re-decode) that just re-confirms. + let again = generate_thumbnail(&database, &app_data_root, &video).await; + assert_eq!(again.status, LocalThumbnailStatus::Ready); + + // Cache file deleted: the read path degrades to pending and the scan + // wants it regenerated. + std::fs::remove_file(&jpg).unwrap(); + let record = get_local_file_by_path(database.get_connection(), &video.to_string_lossy()).await.unwrap().unwrap(); + assert!(needs_generation(&record)); + assert_eq!(lookup_thumbnail(&database, &video).await.status, LocalThumbnailStatus::Pending); + let regenerated = generate_thumbnail(&database, &app_data_root, &video).await; + assert_eq!(regenerated.status, LocalThumbnailStatus::Ready); + assert!(jpg.is_file()); + } + + #[tokio::test] + async fn unsupported_and_missing_files_are_reported_not_retried() { + let dir = tempfile::tempdir().unwrap(); + let app_data_root = AppDataRoot::create_existing(dir.path().join("root")).unwrap(); + let database = LocalFilesDatabase::connect(&app_data_root).await.unwrap(); + + let text = dir.path().join("notes.txt"); + std::fs::write(&text, b"not media").unwrap(); + let result = generate_thumbnail(&database, &app_data_root, &text).await; + assert_eq!(result.status, LocalThumbnailStatus::Unsupported); + let record = get_local_file_by_path(database.get_connection(), &text.to_string_lossy()).await.unwrap().unwrap(); + assert_eq!(record.thumbnail_state(), ThumbnailState::Unsupported); + assert!(!needs_generation(&record)); + assert_eq!(lookup_thumbnail(&database, &text).await.status, LocalThumbnailStatus::Unsupported); + + let missing = dir.path().join("gone.mp4"); + assert_eq!(generate_thumbnail(&database, &app_data_root, &missing).await.status, LocalThumbnailStatus::MissingFile); + assert_eq!(lookup_thumbnail(&database, &missing).await.status, LocalThumbnailStatus::MissingFile); + } +} diff --git a/crates/artcraftx/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs b/crates/artcraftx/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs index 8364b9c6..a0c8be1b 100644 --- a/crates/artcraftx/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs +++ b/crates/artcraftx/src/services/storyteller/threads/storyteller_task_polling_thread/handle_storyteller_successful_job.rs @@ -4,6 +4,7 @@ use crate::error::artcraftx_error::ArtcraftXError; use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::database::task_database::TaskDatabase; +use crate::state::thumbnails::thumbnail_work_queue::ThumbnailWorkQueue; use crate::state::app_preferences::settings::preferred_download_filename::{model_slug_from_model_type_str, DownloadFilenameParts}; use chrono::Local; use crate::utils::download::download_url_to_download_dir_via_temp::download_url_to_download_dir_via_temp; @@ -77,6 +78,7 @@ pub async fn handle_successful_job( let downloaded = download_all_files(app_data_root, app_preferences, job, task, &media_files).await; record_task_download_locations(task_database, &task.id, &downloaded).await; + ThumbnailWorkQueue::enqueue_for_app(app_handle, &downloaded); send_additional_success_events(app_handle, job, task, &media_files).await; diff --git a/crates/artcraftx/src/state/mod.rs b/crates/artcraftx/src/state/mod.rs index 1419e9dd..e4761e37 100644 --- a/crates/artcraftx/src/state/mod.rs +++ b/crates/artcraftx/src/state/mod.rs @@ -4,4 +4,5 @@ pub mod usage_tracker; pub mod data_dir; pub mod database; pub mod runtime; +pub mod thumbnails; pub mod window; diff --git a/crates/artcraftx/src/state/thumbnails/mod.rs b/crates/artcraftx/src/state/thumbnails/mod.rs new file mode 100644 index 00000000..89ee1501 --- /dev/null +++ b/crates/artcraftx/src/state/thumbnails/mod.rs @@ -0,0 +1 @@ +pub mod thumbnail_work_queue; diff --git a/crates/artcraftx/src/state/thumbnails/thumbnail_work_queue.rs b/crates/artcraftx/src/state/thumbnails/thumbnail_work_queue.rs new file mode 100644 index 00000000..e6dfa0ed --- /dev/null +++ b/crates/artcraftx/src/state/thumbnails/thumbnail_work_queue.rs @@ -0,0 +1,122 @@ +//! The wake-up channel between downloaders and the thumbnail worker thread. +//! +//! Downloaders push the paths they just wrote and the worker wakes at once; +//! without a push the worker still scans the databases on its own timer, +//! so a lost signal only delays a thumbnail, never loses it. + +use std::collections::{HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use log::{debug, warn}; +use tauri::{AppHandle, Manager}; +use tokio::sync::Notify; + +/// Tauri-managed state. Cheap to clone; all clones share one queue. +#[derive(Clone)] +pub struct ThumbnailWorkQueue { + inner: Arc, +} + +struct Inner { + /// FIFO of paths to thumbnail, deduplicated by `queued`. + pending: Mutex, + /// Wakes the worker; `notify_one` stores a permit if it isn't waiting. + notify: Notify, +} + +#[derive(Default)] +struct PendingPaths { + order: VecDeque, + queued: HashSet, +} + +impl ThumbnailWorkQueue { + pub fn new() -> Self { + Self { + inner: Arc::new(Inner { + pending: Mutex::new(PendingPaths::default()), + notify: Notify::new(), + }), + } + } + + /// Queue freshly downloaded files and wake the worker. Fails open when the + /// queue isn't managed (headless callers, early startup): the worker's + /// periodic database scan picks the files up later. + pub fn enqueue_for_app(app: &AppHandle, file_paths: &[impl AsRef]) { + match app.try_state::() { + Some(queue) => queue.enqueue(file_paths), + None => warn!("[ThumbnailQueue] Not managed yet; {} file(s) will be found by the periodic scan", file_paths.len()), + } + } + + /// Queue paths (skipping ones already queued) and wake the worker. + pub fn enqueue(&self, file_paths: &[impl AsRef]) { + if file_paths.is_empty() { + return; + } + let mut added = 0; + { + let mut pending = self.inner.pending.lock().expect("thumbnail queue lock poisoned"); + for file_path in file_paths { + let file_path = file_path.as_ref().to_path_buf(); + if pending.queued.insert(file_path.clone()) { + pending.order.push_back(file_path); + added += 1; + } + } + } + debug!("[ThumbnailQueue] Queued {} new path(s)", added); + self.inner.notify.notify_one(); + } + + /// Wake the worker without queueing anything (e.g. to force a scan). + pub fn wake(&self) { + self.inner.notify.notify_one(); + } + + /// Take everything queued so far, in FIFO order. + pub fn drain(&self) -> Vec { + let mut pending = self.inner.pending.lock().expect("thumbnail queue lock poisoned"); + pending.queued.clear(); + pending.order.drain(..).collect() + } + + /// Resolves on the next wake-up (or immediately if one is already stored). + pub async fn notified(&self) { + self.inner.notify.notified().await + } +} + +impl Default for ThumbnailWorkQueue { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drain_returns_fifo_without_duplicates() { + let queue = ThumbnailWorkQueue::new(); + queue.enqueue(&["/a.mp4", "/b.mp4"]); + queue.enqueue(&["/a.mp4", "/c.mp4"]); + let drained = queue.drain(); + assert_eq!(drained, vec![PathBuf::from("/a.mp4"), PathBuf::from("/b.mp4"), PathBuf::from("/c.mp4")]); + assert!(queue.drain().is_empty()); + // Once drained a path can be queued again (its content may have changed). + queue.enqueue(&["/a.mp4"]); + assert_eq!(queue.drain().len(), 1); + } + + #[tokio::test] + async fn enqueue_wakes_a_waiter_even_before_it_waits() { + let queue = ThumbnailWorkQueue::new(); + queue.enqueue(&["/a.mp4"]); + // The permit was stored, so this completes immediately. + tokio::time::timeout(std::time::Duration::from_millis(100), queue.notified()).await.unwrap(); + } +} diff --git a/crates/artcraftx/src/threads/mod.rs b/crates/artcraftx/src/threads/mod.rs index 924cf4ab..b22fb82a 100644 --- a/crates/artcraftx/src/threads/mod.rs +++ b/crates/artcraftx/src/threads/mod.rs @@ -2,3 +2,4 @@ pub mod discord_presence_thread; pub mod main_window_thread; pub mod task_completion; pub mod third_party_task_polling_thread; +pub mod thumbnail_worker_thread; diff --git a/crates/artcraftx/src/threads/task_completion/complete_task_with_local_files.rs b/crates/artcraftx/src/threads/task_completion/complete_task_with_local_files.rs index cfc5d7c3..74adcd79 100644 --- a/crates/artcraftx/src/threads/task_completion/complete_task_with_local_files.rs +++ b/crates/artcraftx/src/threads/task_completion/complete_task_with_local_files.rs @@ -1,6 +1,7 @@ use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::database::task_database::TaskDatabase; +use crate::state::thumbnails::thumbnail_work_queue::ThumbnailWorkQueue; use crate::threads::task_completion::save_results_to_download_dir::save_results_to_download_dir; use crate::threads::task_completion::upload_results_to_artcraft::{upload_results_to_artcraft, CompletionPrompt}; use crate::threads::third_party_task_polling_thread::events::notify_frontend_of_completion::{ @@ -57,7 +58,8 @@ pub struct CompleteTaskArgs<'a> { /// 2. upload to ArtCraft when logged in, /// 3. mark the task complete (a no-op if another path already did — the /// Midjourney websocket and long-poller can race), -/// 4. record where the files landed on the task, +/// 4. record where the files landed on the task and hand them to the +/// thumbnail worker, /// 5. notify the frontend. /// /// Returns `Ok(true)` if this call completed the task, `Ok(false)` if it was @@ -132,6 +134,7 @@ pub async fn complete_task_with_local_files(args: CompleteTaskArgs<'_>) -> Anyho } record_task_download_locations(task_database, &task.id, &downloaded).await; + ThumbnailWorkQueue::enqueue_for_app(app_handle, &downloaded); match maybe_uploaded { Some(uploaded) => { diff --git a/crates/artcraftx/src/threads/thumbnail_worker_thread/mod.rs b/crates/artcraftx/src/threads/thumbnail_worker_thread/mod.rs new file mode 100644 index 00000000..9cdda131 --- /dev/null +++ b/crates/artcraftx/src/threads/thumbnail_worker_thread/mod.rs @@ -0,0 +1 @@ +pub mod thumbnail_worker_thread; diff --git a/crates/artcraftx/src/threads/thumbnail_worker_thread/thumbnail_worker_thread.rs b/crates/artcraftx/src/threads/thumbnail_worker_thread/thumbnail_worker_thread.rs new file mode 100644 index 00000000..3eb4b175 --- /dev/null +++ b/crates/artcraftx/src/threads/thumbnail_worker_thread/thumbnail_worker_thread.rs @@ -0,0 +1,142 @@ +//! Background thumbnail generation for downloaded results. +//! +//! Work arrives two ways: +//! +//! 1. Downloaders push the files they just wrote onto the +//! [`ThumbnailWorkQueue`], which wakes this thread immediately. +//! 2. Every [`SCAN_INTERVAL`] the thread asks both databases what's owed: +//! `local_files` rows still pending (retryable, or made by an older +//! generator), and the most recently completed tasks' downloads that have +//! no current thumbnail yet — so history from before this thread existed, +//! and anything a missed wake-up skipped, still gets done. +//! +//! Each finished thumbnail is announced with `local_thumbnail_ready_event` +//! so the task queue can show it without re-polling. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use log::{debug, error, info, warn}; +use local_files_database::queries::get_local_files_by_paths::get_local_files_by_paths; +use local_files_database::queries::list_local_files_needing_thumbnails::list_local_files_needing_thumbnails; +use sqlite_database::queries::read::list_completed_task_download_files::list_completed_task_download_files; +use tauri::AppHandle; +use thumbnails::THUMBNAIL_GENERATOR_VERSION; + +use crate::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::events::functional_events::local_thumbnail_ready_event::LocalThumbnailReadyEvent; +use crate::services::local_files::thumbnail_service::{generate_thumbnail, needs_generation, LocalThumbnailStatus, MAX_THUMBNAIL_ATTEMPTS}; +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::database::local_files_database::LocalFilesDatabase; +use crate::state::database::task_database::TaskDatabase; +use crate::state::thumbnails::thumbnail_work_queue::ThumbnailWorkQueue; + +/// How often the databases are scanned for owed thumbnails when nobody +/// wakes the thread. +const SCAN_INTERVAL: Duration = Duration::from_secs(60); + +/// Per scan, how many `local_files` rows to pick up. +const MAX_LOCAL_FILE_ROWS_PER_SCAN: i64 = 50; + +/// Per scan, how far back through completed tasks to look. +const RECENT_COMPLETED_TASKS_TO_SCAN: i64 = 200; + +pub async fn thumbnail_worker_thread( + app_handle: AppHandle, + app_data_root: AppDataRoot, + task_database: TaskDatabase, + local_files_database: LocalFilesDatabase, + queue: ThumbnailWorkQueue, +) -> ! { + info!("[ThumbnailWorker] Started"); + // Scan right away so history is covered as soon as the app is up. + let mut next_scan = Instant::now(); + + loop { + let queued = queue.drain(); + if !queued.is_empty() { + info!("[ThumbnailWorker] Woken with {} queued file(s)", queued.len()); + process_all(&app_handle, &app_data_root, &local_files_database, queued).await; + } + + if Instant::now() >= next_scan { + match scan_for_work(&task_database, &local_files_database).await { + Ok(paths) => { + if !paths.is_empty() { + info!("[ThumbnailWorker] Scan found {} file(s) owed a thumbnail", paths.len()); + } + process_all(&app_handle, &app_data_root, &local_files_database, paths).await; + } + Err(err) => error!("[ThumbnailWorker] Scan failed: {}", err), + } + next_scan = Instant::now() + SCAN_INTERVAL; + } + + let until_next_scan = next_scan.saturating_duration_since(Instant::now()); + tokio::select! { + _ = queue.notified() => {}, + _ = tokio::time::sleep(until_next_scan) => {}, + } + } +} + +async fn process_all( + app_handle: &AppHandle, + app_data_root: &AppDataRoot, + local_files_database: &LocalFilesDatabase, + paths: Vec, +) { + for path in paths { + let thumbnail = generate_thumbnail(local_files_database, app_data_root, &path).await; + match thumbnail.status { + LocalThumbnailStatus::Ready => { + let Some(thumbnail_path) = thumbnail.maybe_thumbnail_path else { + warn!("[ThumbnailWorker] Ready without a thumbnail path for {}", path.display()); + continue; + }; + LocalThumbnailReadyEvent { + file_path: thumbnail.file_path, + thumbnail_path, + maybe_animated_preview_path: thumbnail.maybe_animated_preview_path, + }.send_infallible(app_handle); + } + LocalThumbnailStatus::MissingFile => debug!("[ThumbnailWorker] File is gone, skipping: {}", path.display()), + // Already logged (and recorded on the row) by the service. + LocalThumbnailStatus::Pending | LocalThumbnailStatus::Unsupported | LocalThumbnailStatus::Failed => {} + } + } +} + +/// Everything the databases say is owed a thumbnail, deduplicated, with the +/// newest downloads first. +async fn scan_for_work( + task_database: &TaskDatabase, + local_files_database: &LocalFilesDatabase, +) -> anyhow::Result> { + let mut seen: HashSet = HashSet::new(); + let mut work: Vec = Vec::new(); + + // Recently completed tasks whose download has no (surviving) thumbnail. + let recent_downloads = list_completed_task_download_files(task_database.get_connection(), RECENT_COMPLETED_TASKS_TO_SCAN).await?; + let known = get_local_files_by_paths(local_files_database.get_connection(), &recent_downloads).await?; + for path in recent_downloads { + let owed = match known.get(&path) { + Some(record) => needs_generation(record), + None => true, + }; + if owed && seen.insert(path.clone()) { + work.push(PathBuf::from(path)); + } + } + + // Rows the index already knows are unfinished (files seen by other means). + let pending_rows = list_local_files_needing_thumbnails(local_files_database.get_connection(), MAX_THUMBNAIL_ATTEMPTS, THUMBNAIL_GENERATOR_VERSION, MAX_LOCAL_FILE_ROWS_PER_SCAN).await?; + for record in pending_rows { + if seen.insert(record.file_path.clone()) { + work.push(PathBuf::from(record.file_path)); + } + } + + Ok(work) +} diff --git a/crates/lib/files/thumbnails/src/lib.rs b/crates/lib/files/thumbnails/src/lib.rs index 1357378f..e173ecc7 100644 --- a/crates/lib/files/thumbnails/src/lib.rs +++ b/crates/lib/files/thumbnails/src/lib.rs @@ -38,7 +38,25 @@ pub const STATIC_THUMBNAIL_MAX_DIMENSION: u32 = 512; pub const ANIMATED_PREVIEW_MAX_DIMENSION: u32 = 320; /// How much leading video the animated preview covers. -pub const ANIMATED_PREVIEW_MAX_DURATION_MS: u32 = 1_100; +pub const ANIMATED_PREVIEW_MAX_DURATION_MS: u32 = 3_100; -/// Frame cap for the animated preview (~1s at 24fps, plus headroom). -pub const ANIMATED_PREVIEW_MAX_FRAMES: usize = 30; +/// Cap on frames decoded for the animated preview (~3s at 30fps). Decoding +/// stops at whichever of this and the duration comes first. +pub const ANIMATED_PREVIEW_MAX_DECODED_FRAMES: usize = 96; + +/// Frame rate the animated preview is subsampled to. Half of the usual +/// 24fps keeps motion smooth at a third of the frames. +pub const ANIMATED_PREVIEW_TARGET_FPS: u32 = 12; + +/// Lossy WebP quality (0-100) for the animated preview. At 320px, 70 is +/// visually clean and ~10x smaller than lossless. +pub const ANIMATED_PREVIEW_QUALITY: f32 = 70.0; + +/// Bump when the thumbnail output changes (size, duration, quality, ...) so +/// the thumbnail worker regenerates cache entries made by older versions. +/// +/// History: +/// 1 - 512px jpg + ~1s lossless 320px webp (decoder truncated B-frame +/// streams to a few frames) +/// 2 - ~3s 12fps lossy-q70 webp; B-frame streams decode fully +pub const THUMBNAIL_GENERATOR_VERSION: i64 = 2; diff --git a/crates/lib/files/thumbnails/src/mp4_frames.rs b/crates/lib/files/thumbnails/src/mp4_frames.rs index c53b97fb..297c6d0f 100644 --- a/crates/lib/files/thumbnails/src/mp4_frames.rs +++ b/crates/lib/files/thumbnails/src/mp4_frames.rs @@ -10,7 +10,8 @@ use std::path::Path; use images::image::RgbImage; use mp4::{MediaType, Mp4Reader, TrackType}; -use openh264::decoder::Decoder; +use openh264::decoder::{Decoder, DecoderConfig, Flush}; +use openh264::OpenH264API; use crate::error::ThumbnailError; @@ -54,7 +55,12 @@ pub fn decode_mp4_leading_frames>( (track.track_id(), track.timescale(), nal_length_size_for(track), sps, pps) }; - let mut decoder = Decoder::new() + // NB: openh264-rs flushes the decoder after every call by default, which + // wrecks B-frame reordering: streams with B-frames (every provider's + // High-profile output) start erroring a handful of samples in and never + // recover. Let the decoder keep its reorder buffer and drain it at the end. + let config = DecoderConfig::new().flush_after_decode(Flush::NoFlush); + let mut decoder = Decoder::with_api_config(OpenH264API::from_source(), config) .map_err(|err| ThumbnailError::Decode(format!("could not create H.264 decoder: {}", err)))?; // Prime the decoder with the parameter sets. @@ -64,6 +70,11 @@ pub fn decode_mp4_leading_frames>( let _ = decoder.decode(¶meter_sets); let mut frames: Vec = Vec::new(); + // Presentation times of every sample fed so far. The decoder emits frames + // in presentation order (that's what its reorder buffer is for), so the + // k-th output frame gets the k-th smallest pts fed — exact regardless of + // B-frame depth. + let mut fed_pts_ms: Vec = Vec::new(); let sample_count = mp4.sample_count(track_id) .map_err(|err| ThumbnailError::Decode(format!("could not count samples: {}", err)))?; @@ -83,14 +94,13 @@ pub fn decode_mp4_leading_frames>( let pts_ms = ((pts_units.max(0) as u128 * 1000) / timescale.max(1) as u128) as u32; let annex_b = length_prefixed_to_annex_b(&sample.bytes, nal_length_size)?; + let insert_at = fed_pts_ms.partition_point(|&fed| fed <= pts_ms); + fed_pts_ms.insert(insert_at, pts_ms); - // NB: openh264 emits frames in decode order; pairing each output with - // the same-iteration sample pts is exact for streams without B-frame - // reordering (the norm for AI-generated video) and off by a frame or - // two otherwise — the final sort by pts keeps playback ordered. match decoder.decode(&annex_b) { Ok(Some(yuv)) => { - frames.push(DecodedFrame { pts_ms, image: yuv_to_rgb(&yuv)? }); + let output_pts_ms = fed_pts_ms[frames.len()]; + frames.push(DecodedFrame { pts_ms: output_pts_ms, image: yuv_to_rgb(&yuv)? }); } Ok(None) => {} // Decoder delay; frame not ready yet. Err(err) => { @@ -100,11 +110,33 @@ pub fn decode_mp4_leading_frames>( } } - if pts_ms >= max_duration_ms { + // Samples arrive in decode order, so a B-frame with an earlier pts can + // follow the sample that crossed the limit; stop once the frames we + // actually have reach it. + if frames.last().is_some_and(|frame| frame.pts_ms >= max_duration_ms) { break; } } + // Frames still parked in the reorder buffer. + if frames.len() < max_frames { + match decoder.flush_remaining() { + Ok(remaining) => { + for yuv in remaining { + if frames.len() >= max_frames || frames.len() >= fed_pts_ms.len() { + break; + } + let output_pts_ms = fed_pts_ms[frames.len()]; + if output_pts_ms > max_duration_ms { + break; + } + frames.push(DecodedFrame { pts_ms: output_pts_ms, image: yuv_to_rgb(&yuv)? }); + } + } + Err(err) => log::warn!("H.264 flush error: {}", err), + } + } + if frames.is_empty() { return Err(ThumbnailError::Decode("no frames decoded".to_string())); } diff --git a/crates/lib/files/thumbnails/src/video_thumbnail.rs b/crates/lib/files/thumbnails/src/video_thumbnail.rs index 55cd4849..2b0083cd 100644 --- a/crates/lib/files/thumbnails/src/video_thumbnail.rs +++ b/crates/lib/files/thumbnails/src/video_thumbnail.rs @@ -1,17 +1,17 @@ -//! Video thumbnails: one decode pass over the leading ~1s produces both the +//! Video thumbnails: one decode pass over the leading ~3s produces both the //! static first-frame JPEG and the animated WebP preview. use std::path::Path; use images::image::DynamicImage; -use webp_animation::Encoder; +use webp_animation::{Encoder, EncoderOptions, EncodingConfig, EncodingType, LossyEncodingConfig}; use crate::error::ThumbnailError; use crate::image_thumbnail::{downscale, write_bytes_atomically, write_jpeg}; use crate::mp4_frames::{decode_mp4_leading_frames, DecodedFrame}; use crate::{ - ANIMATED_PREVIEW_MAX_DIMENSION, ANIMATED_PREVIEW_MAX_DURATION_MS, ANIMATED_PREVIEW_MAX_FRAMES, - STATIC_THUMBNAIL_MAX_DIMENSION, + ANIMATED_PREVIEW_MAX_DECODED_FRAMES, ANIMATED_PREVIEW_MAX_DIMENSION, ANIMATED_PREVIEW_MAX_DURATION_MS, + ANIMATED_PREVIEW_QUALITY, ANIMATED_PREVIEW_TARGET_FPS, STATIC_THUMBNAIL_MAX_DIMENSION, }; #[derive(Debug)] @@ -21,7 +21,8 @@ pub struct VideoThumbnailOutcome { } /// Decode the leading frames of `source` once; write the first frame as a -/// 512px JPEG and the whole ~1s run as a 320px animated WebP. +/// 512px JPEG and the whole ~3s run, subsampled to ~12fps, as a lossy 320px +/// animated WebP. /// /// The animated preview is best-effort: if its encode fails, the static /// thumbnail still lands and the outcome reports the difference. @@ -35,13 +36,14 @@ pub fn generate_video_thumbnails, Q: AsRef, R: AsRef> let frames = decode_mp4_leading_frames( source.as_ref(), ANIMATED_PREVIEW_MAX_DURATION_MS, - ANIMATED_PREVIEW_MAX_FRAMES, + ANIMATED_PREVIEW_MAX_DECODED_FRAMES, )?; let first_frame = DynamicImage::ImageRgb8(frames[0].image.clone()); write_jpeg(&downscale(&first_frame, STATIC_THUMBNAIL_MAX_DIMENSION), destination_jpg.as_ref())?; - let wrote_animated_preview = match encode_animated_webp(&frames) { + let preview_frames = subsample_to_fps(&frames, ANIMATED_PREVIEW_TARGET_FPS); + let wrote_animated_preview = match encode_animated_webp(&preview_frames) { Ok(webp_bytes) => { write_bytes_atomically(&webp_bytes, destination_webp.as_ref())?; true @@ -55,7 +57,25 @@ pub fn generate_video_thumbnails, Q: AsRef, R: AsRef> Ok(VideoThumbnailOutcome { wrote_static_thumbnail: true, wrote_animated_preview }) } -fn encode_animated_webp(frames: &[DecodedFrame]) -> Result, ThumbnailError> { +/// Keep roughly one frame per `1000 / target_fps` ms: the first frame at or +/// after each slot boundary. Frames must be sorted by pts. A source already +/// at or below the target rate passes through untouched. +fn subsample_to_fps(frames: &[DecodedFrame], target_fps: u32) -> Vec<&DecodedFrame> { + let slot_ms = 1000 / target_fps.max(1); + let mut picked = Vec::with_capacity(frames.len()); + let mut next_slot_ms = 0u32; + for frame in frames { + if frame.pts_ms >= next_slot_ms { + picked.push(frame); + // Advance to the slot after this frame, so a frame landing late in its + // slot doesn't also eat the next one. + next_slot_ms = (frame.pts_ms / slot_ms + 1) * slot_ms; + } + } + picked +} + +fn encode_animated_webp(frames: &[&DecodedFrame]) -> Result, ThumbnailError> { let scaled: Vec<(u32, DynamicImage)> = frames.iter() .map(|frame| { let image = DynamicImage::ImageRgb8(frame.image.clone()); @@ -64,7 +84,15 @@ fn encode_animated_webp(frames: &[DecodedFrame]) -> Result, ThumbnailErr .collect(); let (width, height) = (scaled[0].1.width(), scaled[0].1.height()); - let mut encoder = Encoder::new((width, height)) + let options = EncoderOptions { + encoding_config: Some(EncodingConfig { + encoding_type: EncodingType::Lossy(LossyEncodingConfig::default()), + quality: ANIMATED_PREVIEW_QUALITY, + method: 4, + }), + ..Default::default() + }; + let mut encoder = Encoder::new_with_options((width, height), options) .map_err(|err| ThumbnailError::Encode(format!("webp encoder: {}", err)))?; // Normalize timestamps so the preview starts at 0 even if the stream's @@ -92,8 +120,10 @@ fn encode_animated_webp(frames: &[DecodedFrame]) -> Result, ThumbnailErr #[cfg(test)] mod tests { use super::*; + use images::image::RgbImage; - // The 4-second H.264 clip the higgsfield_client live tests upload. + // The 4-second, 12fps, B-frame (High profile) H.264 clip the + // higgsfield_client live tests upload. const H264_FIXTURE: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../../../api_clients/higgsfield_client/test_assets/clip_4s.mp4", @@ -117,6 +147,39 @@ mod tests { assert!(webp_bytes.len() > 12); assert_eq!(&webp_bytes[0..4], b"RIFF"); assert_eq!(&webp_bytes[8..12], b"WEBP"); + // Lossy at 320px: a 3s preview stays well under half a megabyte. + assert!(webp_bytes.len() < 400 * 1024, "preview is {} bytes", webp_bytes.len()); + } + + #[test] + fn b_frame_stream_decodes_the_whole_preview_window() { + let frames = decode_mp4_leading_frames(H264_FIXTURE, ANIMATED_PREVIEW_MAX_DURATION_MS, ANIMATED_PREVIEW_MAX_DECODED_FRAMES).unwrap(); + // 12fps over ~3s; the old flush-per-decode setup stopped after a few + // frames on any B-frame stream. + assert!(frames.len() >= 35, "only {} frames decoded", frames.len()); + // Raw composition times (no edit-list shift), so the first pts is a + // couple of frames in, not exactly 0. + assert!(frames[0].pts_ms < 300, "first pts {}", frames[0].pts_ms); + assert!(frames.last().unwrap().pts_ms >= 2_900); + assert!(frames.windows(2).all(|pair| pair[0].pts_ms < pair[1].pts_ms), "pts must be strictly increasing"); + + // Already at the target rate: nothing dropped. + let preview = subsample_to_fps(&frames, ANIMATED_PREVIEW_TARGET_FPS); + assert_eq!(preview.len(), frames.len()); + } + + #[test] + fn subsampling_keeps_one_frame_per_slot() { + let frames: Vec = (0..24).map(|i| DecodedFrame { + pts_ms: i * 1000 / 24, + image: RgbImage::new(1, 1), + }).collect(); + let picked: Vec = subsample_to_fps(&frames, 12).iter().map(|f| f.pts_ms).collect(); + assert_eq!(picked, vec![0, 83, 166, 250, 333, 416, 500, 583, 666, 750, 833, 916]); + + // Already sparse input passes through. + let sparse: Vec = [0u32, 500, 1000].iter().map(|&pts_ms| DecodedFrame { pts_ms, image: RgbImage::new(1, 1) }).collect(); + assert_eq!(subsample_to_fps(&sparse, 12).len(), 3); } #[test] @@ -129,3 +192,4 @@ mod tests { assert!(err.is_unsupported()); } } + diff --git a/crates/schema/local_files_database/migrations/20260907000000_add_thumbnail_state.sql b/crates/schema/local_files_database/migrations/20260907000000_add_thumbnail_state.sql new file mode 100644 index 00000000..f1d20013 --- /dev/null +++ b/crates/schema/local_files_database/migrations/20260907000000_add_thumbnail_state.sql @@ -0,0 +1,23 @@ +-- Thumbnail generation bookkeeping, so the background worker can find work +-- with one query instead of stat-ing the cache directory per file. +-- +-- `thumbnail_state`: +-- 'pending' - never generated (or the file's content changed) +-- 'generated' - the jpg (and, for videos that allow it, the webp) exist +-- 'unsupported' - the container/codec can't be thumbnailed; don't retry +-- 'failed' - generation errored; retried until `thumbnail_attempts` +-- reaches the worker's cap +-- +-- The cache file paths are recorded so readers (the task queue, the +-- frontend) never have to derive them; they are content-addressed: +-- `cache/thumbnails/{file_hash_blake3}.jpg` / `.webp`. + +ALTER TABLE local_files ADD COLUMN thumbnail_state TEXT NOT NULL DEFAULT 'pending'; +ALTER TABLE local_files ADD COLUMN thumbnail_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE local_files ADD COLUMN thumbnail_last_error TEXT; +ALTER TABLE local_files ADD COLUMN thumbnail_jpg_path TEXT; +ALTER TABLE local_files ADD COLUMN animated_preview_webp_path TEXT; +ALTER TABLE local_files ADD COLUMN thumbnail_updated_at TEXT; + +-- The worker's "what needs doing" scan. +CREATE INDEX idx_local_files_thumbnail_state ON local_files (thumbnail_state); diff --git a/crates/schema/local_files_database/migrations/20260907000001_add_thumbnail_generator_version.sql b/crates/schema/local_files_database/migrations/20260907000001_add_thumbnail_generator_version.sql new file mode 100644 index 00000000..74fd902f --- /dev/null +++ b/crates/schema/local_files_database/migrations/20260907000001_add_thumbnail_generator_version.sql @@ -0,0 +1,6 @@ +-- Which generator produced a row's thumbnails. The worker regenerates rows +-- whose version is older than the current one, so output changes (size, +-- duration, quality) roll out to existing cache entries without anyone +-- deleting the cache. + +ALTER TABLE local_files ADD COLUMN thumbnail_generator_version INTEGER NOT NULL DEFAULT 0; diff --git a/crates/schema/local_files_database/src/lib.rs b/crates/schema/local_files_database/src/lib.rs index 01c1f377..0077d6e8 100644 --- a/crates/schema/local_files_database/src/lib.rs +++ b/crates/schema/local_files_database/src/lib.rs @@ -26,3 +26,4 @@ pub mod connection; pub mod error; pub mod queries; +pub mod thumbnail_state; diff --git a/crates/schema/local_files_database/src/queries/get_local_files_by_paths.rs b/crates/schema/local_files_database/src/queries/get_local_files_by_paths.rs new file mode 100644 index 00000000..eb225342 --- /dev/null +++ b/crates/schema/local_files_database/src/queries/get_local_files_by_paths.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +use sqlx::{QueryBuilder, Sqlite}; + +use crate::connection::LocalFilesDbConnection; +use crate::error::LocalFilesDbError; +use crate::queries::local_file_record::LocalFileRecord; + +/// SQLite's default bound-parameter ceiling; larger inputs are chunked. +const MAX_PATHS_PER_QUERY: usize = 900; + +/// Batch lookup by path (one query per 900 paths). Paths with no row are +/// simply absent from the map. +pub async fn get_local_files_by_paths( + db: &LocalFilesDbConnection, + file_paths: &[String], +) -> Result, LocalFilesDbError> { + let mut records = HashMap::with_capacity(file_paths.len()); + + for chunk in file_paths.chunks(MAX_PATHS_PER_QUERY) { + let mut query_builder: QueryBuilder = QueryBuilder::new("SELECT * FROM local_files WHERE file_path IN ("); + let mut separated = query_builder.separated(", "); + for file_path in chunk { + separated.push_bind(file_path); + } + separated.push_unseparated(")"); + + let chunk_records = query_builder + .build_query_as::() + .fetch_all(db.get_pool()) + .await?; + for record in chunk_records { + records.insert(record.file_path.clone(), record); + } + } + + Ok(records) +} diff --git a/crates/schema/local_files_database/src/queries/list_local_files_needing_thumbnails.rs b/crates/schema/local_files_database/src/queries/list_local_files_needing_thumbnails.rs new file mode 100644 index 00000000..778ddbdb --- /dev/null +++ b/crates/schema/local_files_database/src/queries/list_local_files_needing_thumbnails.rs @@ -0,0 +1,30 @@ +use crate::connection::LocalFilesDbConnection; +use crate::error::LocalFilesDbError; +use crate::queries::local_file_record::LocalFileRecord; + +/// Rows the thumbnail worker should (re)try: never generated, failed fewer +/// than `max_attempts` times, or generated by an older generator than +/// `current_generator_version`. Newest first, so fresh downloads win. +pub async fn list_local_files_needing_thumbnails( + db: &LocalFilesDbConnection, + max_attempts: i64, + current_generator_version: i64, + limit: i64, +) -> Result, LocalFilesDbError> { + let records = sqlx::query_as::<_, LocalFileRecord>( + r#" + SELECT * FROM local_files + WHERE thumbnail_state = 'pending' + OR (thumbnail_state = 'failed' AND thumbnail_attempts < ?1) + OR (thumbnail_state = 'generated' AND thumbnail_generator_version < ?2) + ORDER BY updated_at DESC + LIMIT ?3 + "#, + ) + .bind(max_attempts) + .bind(current_generator_version) + .bind(limit) + .fetch_all(db.get_pool()) + .await?; + Ok(records) +} diff --git a/crates/schema/local_files_database/src/queries/local_file_record.rs b/crates/schema/local_files_database/src/queries/local_file_record.rs index 279135b2..2701446b 100644 --- a/crates/schema/local_files_database/src/queries/local_file_record.rs +++ b/crates/schema/local_files_database/src/queries/local_file_record.rs @@ -1,5 +1,7 @@ use chrono::{DateTime, Utc}; +use crate::thumbnail_state::ThumbnailState; + /// One row of `local_files`. #[derive(Clone, Debug, sqlx::FromRow)] pub struct LocalFileRecord { @@ -10,6 +12,18 @@ pub struct LocalFileRecord { pub file_hash_blake3: String, pub has_thumbnail: bool, pub has_animated_preview: bool, + + /// Raw `thumbnail_state` column; read it through [`Self::thumbnail_state`]. + #[sqlx(rename = "thumbnail_state")] + pub thumbnail_state_raw: String, + pub thumbnail_attempts: i64, + pub thumbnail_last_error: Option, + pub thumbnail_jpg_path: Option, + pub animated_preview_webp_path: Option, + pub thumbnail_updated_at: Option>, + /// Which generator version produced the cache entries (0 = none yet). + pub thumbnail_generator_version: i64, + pub created_at: DateTime, pub updated_at: DateTime, } @@ -19,4 +33,8 @@ impl LocalFileRecord { pub fn matches_stat(&self, file_size_bytes: i64, file_mtime_ms: i64) -> bool { self.file_size_bytes == file_size_bytes && self.file_mtime_ms == file_mtime_ms } + + pub fn thumbnail_state(&self) -> ThumbnailState { + ThumbnailState::from_str_lenient(&self.thumbnail_state_raw) + } } diff --git a/crates/schema/local_files_database/src/queries/mod.rs b/crates/schema/local_files_database/src/queries/mod.rs index 765be25a..ba47600f 100644 --- a/crates/schema/local_files_database/src/queries/mod.rs +++ b/crates/schema/local_files_database/src/queries/mod.rs @@ -1,5 +1,7 @@ pub mod local_file_record; pub mod get_local_file_by_path; pub mod get_local_files_by_hash; -pub mod set_thumbnail_flags; +pub mod get_local_files_by_paths; +pub mod list_local_files_needing_thumbnails; +pub mod set_thumbnail_result; pub mod upsert_local_file; diff --git a/crates/schema/local_files_database/src/queries/set_thumbnail_flags.rs b/crates/schema/local_files_database/src/queries/set_thumbnail_flags.rs deleted file mode 100644 index 584ee9b3..00000000 --- a/crates/schema/local_files_database/src/queries/set_thumbnail_flags.rs +++ /dev/null @@ -1,28 +0,0 @@ -use chrono::Utc; - -use crate::connection::LocalFilesDbConnection; -use crate::error::LocalFilesDbError; - -/// Record which cache artifacts exist for a path. Returns whether a row was -/// updated. -pub async fn set_thumbnail_flags( - db: &LocalFilesDbConnection, - file_path: &str, - has_thumbnail: bool, - has_animated_preview: bool, -) -> Result { - let result = sqlx::query( - r#" - UPDATE local_files - SET has_thumbnail = ?2, has_animated_preview = ?3, updated_at = ?4 - WHERE file_path = ?1 - "#, - ) - .bind(file_path) - .bind(has_thumbnail) - .bind(has_animated_preview) - .bind(Utc::now()) - .execute(db.get_pool()) - .await?; - Ok(result.rows_affected() > 0) -} diff --git a/crates/schema/local_files_database/src/queries/set_thumbnail_result.rs b/crates/schema/local_files_database/src/queries/set_thumbnail_result.rs new file mode 100644 index 00000000..73f9db05 --- /dev/null +++ b/crates/schema/local_files_database/src/queries/set_thumbnail_result.rs @@ -0,0 +1,56 @@ +use chrono::Utc; + +use crate::connection::LocalFilesDbConnection; +use crate::error::LocalFilesDbError; +use crate::thumbnail_state::ThumbnailState; + +pub struct SetThumbnailResultArgs<'a> { + pub db: &'a LocalFilesDbConnection, + pub file_path: &'a str, + pub state: ThumbnailState, + /// The generator that produced this outcome; rows below the current + /// version are regenerated by the worker. + pub generator_version: i64, + /// Absolute cache path of the static jpg, when it was written. + pub maybe_thumbnail_jpg_path: Option<&'a str>, + /// Absolute cache path of the animated webp, when it was written. + pub maybe_animated_preview_webp_path: Option<&'a str>, + /// Why generation failed (or was unsupported); cleared on success. + pub maybe_error: Option<&'a str>, +} + +/// Record the outcome of one generation attempt for a path. Every call +/// counts as an attempt. Returns whether a row was updated (there is no row +/// until the file has been hashed). +pub async fn set_thumbnail_result(args: SetThumbnailResultArgs<'_>) -> Result { + let now = Utc::now(); + let result = sqlx::query( + r#" + UPDATE local_files + SET + thumbnail_state = ?2, + thumbnail_attempts = thumbnail_attempts + 1, + thumbnail_last_error = ?3, + thumbnail_jpg_path = ?4, + animated_preview_webp_path = ?5, + has_thumbnail = ?6, + has_animated_preview = ?7, + thumbnail_updated_at = ?8, + updated_at = ?8, + thumbnail_generator_version = ?9 + WHERE file_path = ?1 + "#, + ) + .bind(args.file_path) + .bind(args.state.to_str()) + .bind(args.maybe_error) + .bind(args.maybe_thumbnail_jpg_path) + .bind(args.maybe_animated_preview_webp_path) + .bind(args.maybe_thumbnail_jpg_path.is_some()) + .bind(args.maybe_animated_preview_webp_path.is_some()) + .bind(now) + .bind(args.generator_version) + .execute(args.db.get_pool()) + .await?; + Ok(result.rows_affected() > 0) +} diff --git a/crates/schema/local_files_database/src/queries/upsert_local_file.rs b/crates/schema/local_files_database/src/queries/upsert_local_file.rs index 27404a7f..3565775a 100644 --- a/crates/schema/local_files_database/src/queries/upsert_local_file.rs +++ b/crates/schema/local_files_database/src/queries/upsert_local_file.rs @@ -13,16 +13,17 @@ pub struct UpsertLocalFileArgs<'a> { } /// Insert or refresh the row for a path. A changed hash (file overwritten -/// in place) resets the thumbnail flags — the cache entries for the old -/// hash no longer describe this path's content. +/// in place) resets all thumbnail bookkeeping to `pending` — the cache +/// entries for the old hash no longer describe this path's content. pub async fn upsert_local_file(args: UpsertLocalFileArgs<'_>) -> Result<(), LocalFilesDbError> { let now = Utc::now(); sqlx::query( r#" INSERT INTO local_files (file_path, file_type, file_size_bytes, file_mtime_ms, file_hash_blake3, - has_thumbnail, has_animated_preview, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?6) + has_thumbnail, has_animated_preview, thumbnail_state, thumbnail_attempts, + created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, 0, 0, 'pending', 0, ?6, ?6) ON CONFLICT(file_path) DO UPDATE SET file_type = excluded.file_type, file_size_bytes = excluded.file_size_bytes, @@ -34,6 +35,24 @@ pub async fn upsert_local_file(args: UpsertLocalFileArgs<'_>) -> Result<(), Loca has_animated_preview = CASE WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 THEN local_files.has_animated_preview ELSE 0 END, + thumbnail_state = CASE + WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 + THEN local_files.thumbnail_state ELSE 'pending' END, + thumbnail_attempts = CASE + WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 + THEN local_files.thumbnail_attempts ELSE 0 END, + thumbnail_last_error = CASE + WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 + THEN local_files.thumbnail_last_error ELSE NULL END, + thumbnail_jpg_path = CASE + WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 + THEN local_files.thumbnail_jpg_path ELSE NULL END, + animated_preview_webp_path = CASE + WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 + THEN local_files.animated_preview_webp_path ELSE NULL END, + thumbnail_generator_version = CASE + WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 + THEN local_files.thumbnail_generator_version ELSE 0 END, updated_at = excluded.updated_at "#, ) diff --git a/crates/schema/local_files_database/src/thumbnail_state.rs b/crates/schema/local_files_database/src/thumbnail_state.rs new file mode 100644 index 00000000..bd250c2b --- /dev/null +++ b/crates/schema/local_files_database/src/thumbnail_state.rs @@ -0,0 +1,56 @@ +use std::fmt; + +/// Where a `local_files` row is in thumbnail generation. Stored as text in +/// `thumbnail_state`; NEVER change an existing value, only add new ones. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum ThumbnailState { + /// Never generated, or the file's content changed since. + Pending, + /// The cache files exist (see the path columns). + Generated, + /// Can't be thumbnailed (container/codec); never retried. + Unsupported, + /// Generation errored; retried until the attempt cap. + Failed, +} + +impl ThumbnailState { + pub fn to_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Generated => "generated", + Self::Unsupported => "unsupported", + Self::Failed => "failed", + } + } + + /// Unknown values (a newer binary wrote them) read as `Pending` so the + /// worker re-derives them rather than erroring the whole row set. + pub fn from_str_lenient(value: &str) -> Self { + match value { + "generated" => Self::Generated, + "unsupported" => Self::Unsupported, + "failed" => Self::Failed, + _ => Self::Pending, + } + } +} + +impl fmt::Display for ThumbnailState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.to_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + for state in [ThumbnailState::Pending, ThumbnailState::Generated, ThumbnailState::Unsupported, ThumbnailState::Failed] { + assert_eq!(ThumbnailState::from_str_lenient(state.to_str()), state); + } + assert_eq!(ThumbnailState::from_str_lenient("something_new"), ThumbnailState::Pending); + } +} diff --git a/crates/schema/local_files_database/tests/local_file_queries.rs b/crates/schema/local_files_database/tests/local_file_queries.rs index d3fd7e16..341e53a4 100644 --- a/crates/schema/local_files_database/tests/local_file_queries.rs +++ b/crates/schema/local_files_database/tests/local_file_queries.rs @@ -3,12 +3,174 @@ use local_files_database::connection::LocalFilesDbConnection; use local_files_database::queries::get_local_file_by_path::get_local_file_by_path; use local_files_database::queries::get_local_files_by_hash::get_local_files_by_hash; -use local_files_database::queries::set_thumbnail_flags::set_thumbnail_flags; +use local_files_database::queries::get_local_files_by_paths::get_local_files_by_paths; +use local_files_database::queries::list_local_files_needing_thumbnails::list_local_files_needing_thumbnails; +use local_files_database::queries::set_thumbnail_result::{set_thumbnail_result, SetThumbnailResultArgs}; use local_files_database::queries::upsert_local_file::{upsert_local_file, UpsertLocalFileArgs}; +use local_files_database::thumbnail_state::ThumbnailState; const HASH_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const GENERATOR_VERSION: i64 = 2; const HASH_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +#[tokio::test] +async fn insert_read_and_stat_matching() { + let dir = tempfile::tempdir().unwrap(); + let db = test_db(&dir).await; + + assert!(get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().is_none()); + + upsert(&db, "/a/video.mp4", 1000, 111, HASH_A).await; + let record = get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().unwrap(); + assert_eq!(record.file_hash_blake3, HASH_A); + assert_eq!(record.file_type.as_deref(), Some("mp4")); + assert!(!record.has_thumbnail); + assert_eq!(record.thumbnail_state(), ThumbnailState::Pending); + assert_eq!(record.thumbnail_attempts, 0); + assert!(record.matches_stat(1000, 111)); + assert!(!record.matches_stat(1000, 222)); + assert!(!record.matches_stat(999, 111)); +} + +#[tokio::test] +async fn thumbnail_result_survives_same_hash_upsert_but_resets_on_new_hash() { + let dir = tempfile::tempdir().unwrap(); + let db = test_db(&dir).await; + + upsert(&db, "/a/video.mp4", 1000, 111, HASH_A).await; + assert!(record_generated(&db, "/a/video.mp4", true).await); + assert!(!record_generated(&db, "/missing", true).await); + + // Same content re-upserted (e.g. mtime refreshed): bookkeeping survives. + upsert(&db, "/a/video.mp4", 1000, 222, HASH_A).await; + let record = get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().unwrap(); + assert!(record.has_thumbnail); + assert!(record.has_animated_preview); + assert_eq!(record.thumbnail_state(), ThumbnailState::Generated); + assert_eq!(record.thumbnail_attempts, 1); + assert_eq!(record.thumbnail_generator_version, GENERATOR_VERSION); + assert_eq!(record.thumbnail_jpg_path.as_deref(), Some("/cache/a.jpg")); + assert_eq!(record.animated_preview_webp_path.as_deref(), Some("/cache/a.webp")); + assert!(record.thumbnail_updated_at.is_some()); + assert_eq!(record.file_mtime_ms, 222); + + // File overwritten in place with new content: everything resets. + upsert(&db, "/a/video.mp4", 2000, 333, HASH_B).await; + let record = get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().unwrap(); + assert!(!record.has_thumbnail); + assert!(!record.has_animated_preview); + assert_eq!(record.thumbnail_state(), ThumbnailState::Pending); + assert_eq!(record.thumbnail_attempts, 0); + assert!(record.thumbnail_jpg_path.is_none()); + assert!(record.animated_preview_webp_path.is_none()); + assert_eq!(record.thumbnail_generator_version, 0); + assert_eq!(record.file_hash_blake3, HASH_B); +} + +#[tokio::test] +async fn failures_count_attempts_and_keep_the_error() { + let dir = tempfile::tempdir().unwrap(); + let db = test_db(&dir).await; + upsert(&db, "/a/video.mp4", 1000, 111, HASH_A).await; + + set_thumbnail_result(SetThumbnailResultArgs { + db: &db, + file_path: "/a/video.mp4", + state: ThumbnailState::Failed, + generator_version: GENERATOR_VERSION, + maybe_thumbnail_jpg_path: None, + maybe_animated_preview_webp_path: None, + maybe_error: Some("decoder exploded"), + }).await.unwrap(); + + let record = get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().unwrap(); + assert_eq!(record.thumbnail_state(), ThumbnailState::Failed); + assert_eq!(record.thumbnail_attempts, 1); + assert_eq!(record.thumbnail_last_error.as_deref(), Some("decoder exploded")); + assert!(!record.has_thumbnail); + + // A later success clears the error. + assert!(record_generated(&db, "/a/video.mp4", false).await); + let record = get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().unwrap(); + assert_eq!(record.thumbnail_attempts, 2); + assert!(record.thumbnail_last_error.is_none()); + assert!(record.has_thumbnail); + assert!(!record.has_animated_preview); +} + +#[tokio::test] +async fn worker_scan_lists_pending_and_retryable_failures_only() { + let dir = tempfile::tempdir().unwrap(); + let db = test_db(&dir).await; + + upsert(&db, "/pending.mp4", 1, 1, HASH_A).await; + upsert(&db, "/generated.mp4", 1, 1, HASH_A).await; + upsert(&db, "/unsupported.mp4", 1, 1, HASH_A).await; + upsert(&db, "/failed_once.mp4", 1, 1, HASH_A).await; + upsert(&db, "/failed_thrice.mp4", 1, 1, HASH_A).await; + + record_generated(&db, "/generated.mp4", true).await; + record_state(&db, "/unsupported.mp4", ThumbnailState::Unsupported).await; + record_state(&db, "/failed_once.mp4", ThumbnailState::Failed).await; + for _ in 0..3 { + record_state(&db, "/failed_thrice.mp4", ThumbnailState::Failed).await; + } + + let mut paths: Vec = list_local_files_needing_thumbnails(&db, 3, GENERATOR_VERSION, 100).await.unwrap() + .into_iter() + .map(|record| record.file_path) + .collect(); + paths.sort(); + assert_eq!(paths, vec!["/failed_once.mp4".to_string(), "/pending.mp4".to_string()]); + + let limited = list_local_files_needing_thumbnails(&db, 3, GENERATOR_VERSION, 1).await.unwrap(); + assert_eq!(limited.len(), 1); + + // A newer generator makes every generated row owed again. + let mut paths: Vec = list_local_files_needing_thumbnails(&db, 3, GENERATOR_VERSION + 1, 100).await.unwrap() + .into_iter() + .map(|record| record.file_path) + .collect(); + paths.sort(); + assert_eq!(paths, vec!["/failed_once.mp4".to_string(), "/generated.mp4".to_string(), "/pending.mp4".to_string()]); +} + +#[tokio::test] +async fn hash_lookup_finds_duplicate_paths() { + let dir = tempfile::tempdir().unwrap(); + let db = test_db(&dir).await; + + upsert(&db, "/a/video.mp4", 1000, 111, HASH_A).await; + upsert(&db, "/b/copy.mp4", 1000, 999, HASH_A).await; + upsert(&db, "/c/other.mp4", 500, 111, HASH_B).await; + + let matches = get_local_files_by_hash(&db, HASH_A).await.unwrap(); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].file_path, "/a/video.mp4"); + assert_eq!(matches[1].file_path, "/b/copy.mp4"); +} + +#[tokio::test] +async fn batch_path_lookup_skips_unknown_paths() { + let dir = tempfile::tempdir().unwrap(); + let db = test_db(&dir).await; + + upsert(&db, "/a/video.mp4", 1000, 111, HASH_A).await; + upsert(&db, "/b/copy.mp4", 1000, 999, HASH_A).await; + + let empty = get_local_files_by_paths(&db, &[]).await.unwrap(); + assert!(empty.is_empty()); + + let paths = vec!["/a/video.mp4".to_string(), "/nope.mp4".to_string(), "/b/copy.mp4".to_string()]; + let found = get_local_files_by_paths(&db, &paths).await.unwrap(); + assert_eq!(found.len(), 2); + assert!(found.contains_key("/a/video.mp4")); + assert!(found.contains_key("/b/copy.mp4")); + assert!(!found.contains_key("/nope.mp4")); +} + +// ── Helpers ── + async fn test_db(dir: &tempfile::TempDir) -> LocalFilesDbConnection { LocalFilesDbConnection::connect_and_migrate(dir.path().join("local_files.sqlite")) .await @@ -28,58 +190,30 @@ async fn upsert(db: &LocalFilesDbConnection, path: &str, size: i64, mtime: i64, .expect("upsert"); } -#[tokio::test] -async fn insert_read_and_stat_matching() { - let dir = tempfile::tempdir().unwrap(); - let db = test_db(&dir).await; - - assert!(get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().is_none()); - - upsert(&db, "/a/video.mp4", 1000, 111, HASH_A).await; - let record = get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().unwrap(); - assert_eq!(record.file_hash_blake3, HASH_A); - assert_eq!(record.file_type.as_deref(), Some("mp4")); - assert!(!record.has_thumbnail); - assert!(record.matches_stat(1000, 111)); - assert!(!record.matches_stat(1000, 222)); - assert!(!record.matches_stat(999, 111)); +async fn record_generated(db: &LocalFilesDbConnection, path: &str, with_animated: bool) -> bool { + set_thumbnail_result(SetThumbnailResultArgs { + db, + file_path: path, + state: ThumbnailState::Generated, + generator_version: GENERATOR_VERSION, + maybe_thumbnail_jpg_path: Some("/cache/a.jpg"), + maybe_animated_preview_webp_path: with_animated.then_some("/cache/a.webp"), + maybe_error: None, + }) + .await + .expect("set result") } -#[tokio::test] -async fn thumbnail_flags_survive_same_hash_upsert_but_reset_on_new_hash() { - let dir = tempfile::tempdir().unwrap(); - let db = test_db(&dir).await; - - upsert(&db, "/a/video.mp4", 1000, 111, HASH_A).await; - assert!(set_thumbnail_flags(&db, "/a/video.mp4", true, true).await.unwrap()); - assert!(!set_thumbnail_flags(&db, "/missing", true, true).await.unwrap()); - - // Same content re-upserted (e.g. mtime refreshed): flags survive. - upsert(&db, "/a/video.mp4", 1000, 222, HASH_A).await; - let record = get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().unwrap(); - assert!(record.has_thumbnail); - assert!(record.has_animated_preview); - assert_eq!(record.file_mtime_ms, 222); - - // File overwritten in place with new content: flags reset. - upsert(&db, "/a/video.mp4", 2000, 333, HASH_B).await; - let record = get_local_file_by_path(&db, "/a/video.mp4").await.unwrap().unwrap(); - assert!(!record.has_thumbnail); - assert!(!record.has_animated_preview); - assert_eq!(record.file_hash_blake3, HASH_B); -} - -#[tokio::test] -async fn hash_lookup_finds_duplicate_paths() { - let dir = tempfile::tempdir().unwrap(); - let db = test_db(&dir).await; - - upsert(&db, "/a/video.mp4", 1000, 111, HASH_A).await; - upsert(&db, "/b/copy.mp4", 1000, 999, HASH_A).await; - upsert(&db, "/c/other.mp4", 500, 111, HASH_B).await; - - let matches = get_local_files_by_hash(&db, HASH_A).await.unwrap(); - assert_eq!(matches.len(), 2); - assert_eq!(matches[0].file_path, "/a/video.mp4"); - assert_eq!(matches[1].file_path, "/b/copy.mp4"); +async fn record_state(db: &LocalFilesDbConnection, path: &str, state: ThumbnailState) { + set_thumbnail_result(SetThumbnailResultArgs { + db, + file_path: path, + state, + generator_version: GENERATOR_VERSION, + maybe_thumbnail_jpg_path: None, + maybe_animated_preview_webp_path: None, + maybe_error: Some("nope"), + }) + .await + .expect("set result"); } diff --git a/crates/schema/sqlite_database/src/queries/read/list_completed_task_download_files.rs b/crates/schema/sqlite_database/src/queries/read/list_completed_task_download_files.rs new file mode 100644 index 00000000..aa838d84 --- /dev/null +++ b/crates/schema/sqlite_database/src/queries/read/list_completed_task_download_files.rs @@ -0,0 +1,30 @@ +use crate::connection::TaskDbConnection; +use crate::error::SqliteTasksError; +use sqlite_identifiers::enums::task_status::TaskStatus; + +/// The first downloaded file of the most recently completed tasks (newest +/// first). Feeds the thumbnail worker's periodic scan: anything here without +/// a generated thumbnail is work. +/// +/// NB: A runtime query on purpose — it needs no compile-time schema and +/// avoids regenerating the `.sqlx` offline data for a read-only scan. +pub async fn list_completed_task_download_files( + db: &TaskDbConnection, + limit: i64, +) -> Result, SqliteTasksError> { + let rows: Vec<(String,)> = sqlx::query_as( + r#" + SELECT on_complete_first_file_location + FROM tasks + WHERE task_status = ?1 + AND on_complete_first_file_location IS NOT NULL + ORDER BY completed_at DESC + LIMIT ?2 + "#, + ) + .bind(TaskStatus::CompleteSuccess.to_str()) + .bind(limit) + .fetch_all(db.get_pool()) + .await?; + Ok(rows.into_iter().map(|(path,)| path).collect()) +} diff --git a/crates/schema/sqlite_database/src/queries/read/mod.rs b/crates/schema/sqlite_database/src/queries/read/mod.rs index f68d6bbc..23557773 100644 --- a/crates/schema/sqlite_database/src/queries/read/mod.rs +++ b/crates/schema/sqlite_database/src/queries/read/mod.rs @@ -1,4 +1,5 @@ pub mod get_task_by_provider_and_provider_job_id; +pub mod list_completed_task_download_files; pub mod list_non_artcraft_pending_tasks; pub mod list_tasks_by_provider_and_status; pub mod list_tasks_by_provider_and_tokens; diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx index cf8682b1..f5e0d41c 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx @@ -35,6 +35,7 @@ import { import type { LocalThumbnail, TaskQueueItem } from "@storyteller/tauri-api"; import { convertFileSrc } from "@tauri-apps/api/core"; import { listen, UnlistenFn } from "@tauri-apps/api/event"; +import { useLocalThumbnailReadyEvent } from "@storyteller/tauri-events"; import { useSelectedImageModel, useSelectedVideoModel, @@ -67,13 +68,13 @@ const localThumbnailPending = new Set(); const localStaticThumbnailUrl = (task: { downloadedFilePath?: string; + localThumbnailPath?: string; }): string | undefined => { const thumb = task.downloadedFilePath ? localThumbnailCache.get(task.downloadedFilePath) : undefined; - return thumb?.maybe_thumbnail_path - ? convertFileSrc(thumb.maybe_thumbnail_path) - : undefined; + const path = task.localThumbnailPath ?? thumb?.maybe_thumbnail_path; + return path ? convertFileSrc(path) : undefined; }; const localFileAssetUrl = (task: { @@ -110,6 +111,10 @@ type CompletedTask = { // completion time, so they survive later download-directory changes). downloadedFilePath?: string; downloadDirectory?: string; + // Backend-generated thumbnails of `downloadedFilePath`, when the task + // queue already knows them (absolute cache paths). + localThumbnailPath?: string; + localAnimatedPreviewPath?: string; }; type FailedTask = { @@ -387,24 +392,27 @@ const CompletedCard = ({ onClick?: () => void; onDismiss?: () => void; }) => { - const [isPreviewHovered, setIsPreviewHovered] = useState(false); - // Server thumbnail first; otherwise the backend-generated thumbnail of - // the downloaded file, rendered off disk via the asset protocol. + // Backend-generated thumbnails of the downloaded file, rendered off disk + // via the asset protocol: from the task queue response when it already + // has them, else from the cache the ready-event and bulk lookup fill. const localThumb = task.downloadedFilePath ? localThumbnailCache.get(task.downloadedFilePath) : undefined; - const staticThumbnailUrl = - task.thumbnailUrl ?? - (localThumb?.maybe_thumbnail_path - ? convertFileSrc(localThumb.maybe_thumbnail_path) - : undefined); - const animatedPreviewUrl = localThumb?.maybe_animated_preview_path - ? convertFileSrc(localThumb.maybe_animated_preview_path) + const localStaticPath = + task.localThumbnailPath ?? localThumb?.maybe_thumbnail_path; + const localAnimatedPath = + task.localAnimatedPreviewPath ?? localThumb?.maybe_animated_preview_path; + const localStaticUrl = localStaticPath + ? convertFileSrc(localStaticPath) : undefined; + const animatedPreviewUrl = localAnimatedPath + ? convertFileSrc(localAnimatedPath) + : undefined; + // Local thumbnails win: they're cut from the actual file, whereas the + // server's video thumbnail can be missing (it 404s into the placeholder). + // The animated preview plays right away when it exists. const displayedThumbnailUrl = - isPreviewHovered && animatedPreviewUrl - ? animatedPreviewUrl - : staticThumbnailUrl; + animatedPreviewUrl ?? localStaticUrl ?? task.thumbnailUrl; return (
setIsPreviewHovered(true)} - onMouseLeave={() => setIsPreviewHovered(false)} > {displayedThumbnailUrl ? ( { // is module-level; this just triggers a re-render). const [, setLocalThumbnailVersion] = useState(0); - // Local-only results have no server thumbnail; ask the backend to serve - // (or generate) thumbnails for their downloaded files, in one bulk call. + // The backend's thumbnail worker announces each thumbnail as it lands. + useLocalThumbnailReadyEvent(async (event) => { + localThumbnailCache.set(event.file_path, { + file_path: event.file_path, + status: "ready", + maybe_thumbnail_path: event.thumbnail_path, + maybe_animated_preview_path: + event.maybe_animated_preview_path ?? undefined, + }); + setLocalThumbnailVersion((v) => v + 1); + }); + + // Ask the backend for thumbnails of downloaded files we don't have yet, in + // one bulk call. Ready ones come back at once; the rest are queued for the + // worker and arrive via the event above. Server thumbnails don't exempt a + // task: the animated preview only exists locally. useEffect(() => { const wanted = completed - .filter((t) => !t.thumbnailUrl && t.downloadedFilePath) + .filter((t) => t.downloadedFilePath) .map((t) => t.downloadedFilePath!) .filter( (p) => !localThumbnailCache.has(p) && !localThumbnailPending.has(p), @@ -657,7 +677,10 @@ export const TaskQueue = () => { GetLocalThumbnails(wanted) .then((thumbs) => { for (const thumb of thumbs) { - localThumbnailCache.set(thumb.file_path, thumb); + // Pending entries are left out so the next poll asks again. + if (thumb.status !== "pending") { + localThumbnailCache.set(thumb.file_path, thumb); + } } setLocalThumbnailVersion((v) => v + 1); }) @@ -935,6 +958,9 @@ export const TaskQueue = () => { batchImageToken: t.completed_item?.maybe_batch_token, downloadedFilePath: t.completed_item?.maybe_first_downloaded_file, downloadDirectory: t.completed_item?.maybe_download_directory, + localThumbnailPath: t.completed_item?.maybe_local_thumbnail_path, + localAnimatedPreviewPath: + t.completed_item?.maybe_local_animated_preview_path, completedAt: t.completed_at, updatedAt: t.updated_at, }; diff --git a/frontend/libs/tauri-api/src/lib/artcraft/task_queue/GetTaskQueue.ts b/frontend/libs/tauri-api/src/lib/artcraft/task_queue/GetTaskQueue.ts index 742d10f9..52ed6cf0 100644 --- a/frontend/libs/tauri-api/src/lib/artcraft/task_queue/GetTaskQueue.ts +++ b/frontend/libs/tauri-api/src/lib/artcraft/task_queue/GetTaskQueue.ts @@ -44,6 +44,11 @@ export interface TaskQueueCompletedItem { // file, as absolute paths recorded at completion time. maybe_download_directory?: string; maybe_first_downloaded_file?: string; + // Thumbnails of the first downloaded file once the backend's thumbnail + // worker has produced them (absolute cache paths; render via + // `convertFileSrc`). Until then, listen for `local_thumbnail_ready_event`. + maybe_local_thumbnail_path?: string; + maybe_local_animated_preview_path?: string; } export interface MediaFileData { diff --git a/frontend/libs/tauri-api/src/lib/local_files/GetLocalThumbnails.ts b/frontend/libs/tauri-api/src/lib/local_files/GetLocalThumbnails.ts index 5b8bb35e..62d053d1 100644 --- a/frontend/libs/tauri-api/src/lib/local_files/GetLocalThumbnails.ts +++ b/frontend/libs/tauri-api/src/lib/local_files/GetLocalThumbnails.ts @@ -2,14 +2,16 @@ import { invoke } from "@tauri-apps/api/core"; import { CommandResult } from "../common/CommandStatus"; /** - * Bulk thumbnail lookup/generation for local files. The backend hashes each - * file (memoized in the local_files index), serves cached thumbnails from - * `cache/thumbnails/{hash}.jpg|.webp`, and generates anything missing. - * Render the returned paths via `convertFileSrc(...)`. + * Bulk thumbnail lookup for local files. The backend serves what its + * thumbnail worker has already produced (`cache/thumbnails/{hash}.jpg|.webp`) + * and queues anything `pending` for the worker; `local_thumbnail_ready_event` + * announces those as they land. Render the returned paths via + * `convertFileSrc(...)`. */ export type LocalThumbnailStatus = | "ready" + | "pending" | "unsupported" | "missing_file" | "failed"; diff --git a/frontend/libs/tauri-events/src/index.ts b/frontend/libs/tauri-events/src/index.ts index 6f4f3306..7bbf2882 100644 --- a/frontend/libs/tauri-events/src/index.ts +++ b/frontend/libs/tauri-events/src/index.ts @@ -1,6 +1,7 @@ export * from "./lib/common/BasicEventWrapper"; export * from "./lib/events/functional/AppPreferencesChangedEvent"; export * from "./lib/events/functional/CreditsBalanceChangedEvent"; +export * from "./lib/events/functional/LocalThumbnailReadyEvent"; export * from "./lib/sync/useAppPreferencesSync"; export * from "./lib/events/functional/ObjectGenerationCompleteEvent"; export * from "./lib/events/functional/RefreshAccountStateEvent"; diff --git a/frontend/libs/tauri-events/src/lib/events/functional/LocalThumbnailReadyEvent.ts b/frontend/libs/tauri-events/src/lib/events/functional/LocalThumbnailReadyEvent.ts new file mode 100644 index 00000000..f51a70b8 --- /dev/null +++ b/frontend/libs/tauri-events/src/lib/events/functional/LocalThumbnailReadyEvent.ts @@ -0,0 +1,41 @@ +import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { BasicEventWrapper } from '../../common/BasicEventWrapper'; +import { useEffect } from 'react'; + +const EVENT_NAME : string = 'local_thumbnail_ready_event'; + +/** The backend's thumbnail worker finished a downloaded file. Paths are + * absolute; render them with `convertFileSrc`. */ +export interface LocalThumbnailReadyEvent { + /** The source file the thumbnail is for (keys the frontend cache). */ + file_path: string, + /** Static jpg in the thumbnail cache. */ + thumbnail_path: string, + /** ~1s animated webp, videos only. */ + maybe_animated_preview_path?: string | null, +} + +export const useLocalThumbnailReadyEvent = (asyncCallback: (event: LocalThumbnailReadyEvent) => Promise) => { + useEffect(() => { + let isUnmounted = false; + let unlisten: Promise; + + const setup = async () => { + unlisten = listen>(EVENT_NAME, async (wrappedEvent) => { + await asyncCallback(wrappedEvent.payload.data); + }); + + if (isUnmounted) { + unlisten.then(f => f()); // Unsubscribe if unmounted early. + } + }; + + setup(); + + return () => { + isUnmounted = true; + unlisten.then(f => f()); + }; + + }, []); +}