update thumbnails

This commit is contained in:
Hanashi 2026-09-07 00:36:13 -04:00
parent 985435b891
commit 361943033b
40 changed files with 1380 additions and 195 deletions

View File

@ -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));

View File

@ -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

View File

@ -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<InfallibleResponse<GetLocalThumbnailsResponse>, ()> {
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<PathBuf> = 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())
}

View File

@ -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<String>,
pub maybe_first_downloaded_file: Option<String>,
/// 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<String>,
pub maybe_local_animated_preview_path: Option<String>,
}
#[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<GetTaskQueueCommandResponse> {
// 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<Vec<TaskQueueItem>> {
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<String> = 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;
}
}
}

View File

@ -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<String>,
}
impl BasicSendableEvent for LocalThumbnailReadyEvent {
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::LocalThumbnailReadyEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success;
}

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -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(())
}

View File

@ -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<Semaphore> = 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<StdMutex<HashMap<PathBuf, Arc<TokioMutex<()>>>>> =
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<String>)> {
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<TokioMutex<()>> {
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);
}
}

View File

@ -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;

View File

@ -4,4 +4,5 @@ pub mod usage_tracker;
pub mod data_dir;
pub mod database;
pub mod runtime;
pub mod thumbnails;
pub mod window;

View File

@ -0,0 +1 @@
pub mod thumbnail_work_queue;

View File

@ -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<Inner>,
}
struct Inner {
/// FIFO of paths to thumbnail, deduplicated by `queued`.
pending: Mutex<PendingPaths>,
/// Wakes the worker; `notify_one` stores a permit if it isn't waiting.
notify: Notify,
}
#[derive(Default)]
struct PendingPaths {
order: VecDeque<PathBuf>,
queued: HashSet<PathBuf>,
}
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<Path>]) {
match app.try_state::<ThumbnailWorkQueue>() {
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<Path>]) {
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<PathBuf> {
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();
}
}

View File

@ -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;

View File

@ -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) => {

View File

@ -0,0 +1 @@
pub mod thumbnail_worker_thread;

View File

@ -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<PathBuf>,
) {
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<Vec<PathBuf>> {
let mut seen: HashSet<String> = HashSet::new();
let mut work: Vec<PathBuf> = 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)
}

View File

@ -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;

View File

@ -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<P: AsRef<Path>>(
(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<P: AsRef<Path>>(
let _ = decoder.decode(&parameter_sets);
let mut frames: Vec<DecodedFrame> = 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<u32> = 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<P: AsRef<Path>>(
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<P: AsRef<Path>>(
}
}
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()));
}

View File

@ -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<P: AsRef<Path>, Q: AsRef<Path>, R: AsRef<Path>>
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<P: AsRef<Path>, Q: AsRef<Path>, R: AsRef<Path>>
Ok(VideoThumbnailOutcome { wrote_static_thumbnail: true, wrote_animated_preview })
}
fn encode_animated_webp(frames: &[DecodedFrame]) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<DecodedFrame> = (0..24).map(|i| DecodedFrame {
pts_ms: i * 1000 / 24,
image: RgbImage::new(1, 1),
}).collect();
let picked: Vec<u32> = 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<DecodedFrame> = [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());
}
}

View File

@ -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);

View File

@ -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;

View File

@ -26,3 +26,4 @@
pub mod connection;
pub mod error;
pub mod queries;
pub mod thumbnail_state;

View File

@ -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<HashMap<String, LocalFileRecord>, 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<Sqlite> = 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::<LocalFileRecord>()
.fetch_all(db.get_pool())
.await?;
for record in chunk_records {
records.insert(record.file_path.clone(), record);
}
}
Ok(records)
}

View File

@ -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<Vec<LocalFileRecord>, 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)
}

View File

@ -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<String>,
pub thumbnail_jpg_path: Option<String>,
pub animated_preview_webp_path: Option<String>,
pub thumbnail_updated_at: Option<DateTime<Utc>>,
/// Which generator version produced the cache entries (0 = none yet).
pub thumbnail_generator_version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@ -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)
}
}

View File

@ -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;

View File

@ -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<bool, LocalFilesDbError> {
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)
}

View File

@ -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<bool, LocalFilesDbError> {
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)
}

View File

@ -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
"#,
)

View File

@ -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);
}
}

View File

@ -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<String> = 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<String> = 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");
}

View File

@ -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<Vec<String>, 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())
}

View File

@ -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;

View File

@ -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<string>();
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 (
<div
className="flex cursor-pointer items-center gap-2.5 rounded-md p-2 transition-colors hover:bg-ui-controls/40"
@ -425,8 +433,6 @@ const CompletedCard = ({
}
: undefined
}
onMouseEnter={() => setIsPreviewHovered(true)}
onMouseLeave={() => setIsPreviewHovered(false)}
>
{displayedThumbnailUrl ? (
<img
@ -643,11 +649,25 @@ export const TaskQueue = () => {
// 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,
};

View File

@ -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 {

View File

@ -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";

View File

@ -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";

View File

@ -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<void>) => {
useEffect(() => {
let isUnmounted = false;
let unlisten: Promise<UnlistenFn>;
const setup = async () => {
unlisten = listen<BasicEventWrapper<LocalThumbnailReadyEvent>>(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());
};
}, []);
}