update thumbnails
This commit is contained in:
parent
985435b891
commit
361943033b
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ pub mod usage_tracker;
|
|||
pub mod data_dir;
|
||||
pub mod database;
|
||||
pub mod runtime;
|
||||
pub mod thumbnails;
|
||||
pub mod window;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
pub mod thumbnail_work_queue;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
pub mod thumbnail_worker_thread;
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(¶meter_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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -26,3 +26,4 @@
|
|||
pub mod connection;
|
||||
pub mod error;
|
||||
pub mod queries;
|
||||
pub mod thumbnail_state;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
"#,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
};
|
||||
|
||||
}, []);
|
||||
}
|
||||
Loading…
Reference in New Issue