From 092cd520d42618eab6a12f0be7f4e138ed8a7d0f Mon Sep 17 00:00:00 2001 From: Hanashi Date: Tue, 1 Sep 2026 19:01:03 -0400 Subject: [PATCH] local files database; log failures --- CLAUDE.md | 1 + Cargo.lock | 161 ++++++++++- Cargo.toml | 10 + crates/CLAUDE.md | 3 + crates/artcraftx/Cargo.toml | 3 + .../get_local_thumbnails_command.rs | 56 ++++ .../artcraftx/src/commands/local_files/mod.rs | 1 + crates/artcraftx/src/commands/mod.rs | 1 + .../task_queue/get_task_queue_command.rs | 39 +-- crates/artcraftx/src/lib.rs | 2 + .../lifecycle/startup/handle_tauri_startup.rs | 3 + .../tasks/bootstrap_local_files_database.rs | 10 + .../src/lifecycle/startup/tasks/mod.rs | 1 + .../artcraftx/src/services/local_files/mod.rs | 1 + .../services/local_files/thumbnail_service.rs | 250 ++++++++++++++++++ crates/artcraftx/src/services/mod.rs | 1 + .../src/state/data_dir/app_data_root.rs | 8 + .../data_dir/subdirectory/app_cache_dir.rs | 37 +++ .../data_dir/subdirectory/app_state_dir.rs | 7 + .../src/state/data_dir/subdirectory/mod.rs | 1 + .../state/database/local_files_database.rs | 24 ++ crates/artcraftx/src/state/database/mod.rs | 1 + .../higgsfield/handle_higgsfield_failure.rs | 21 +- .../higgsfield/higgsfield_failure_reason.rs | 223 ++++++++++++++++ .../handlers/higgsfield/mod.rs | 1 + .../higgsfield/poll_higgsfield_tasks.rs | 50 +++- .../artcraftx/tauri-dev-hot-reload.conf.json | 1 + crates/artcraftx/tauri-mac.conf.json | 1 + crates/artcraftx/tauri.conf.json | 1 + crates/lib/files/file_hashing/Cargo.toml | 23 ++ .../lib/files/file_hashing/src/hash_file.rs | 66 +++++ crates/lib/files/file_hashing/src/lib.rs | 11 + crates/lib/files/thumbnails/Cargo.toml | 32 +++ crates/lib/files/thumbnails/src/error.rs | 41 +++ .../files/thumbnails/src/image_thumbnail.rs | 108 ++++++++ crates/lib/files/thumbnails/src/lib.rs | 44 +++ crates/lib/files/thumbnails/src/mp4_frames.rs | 153 +++++++++++ .../files/thumbnails/src/video_thumbnail.rs | 131 +++++++++ crates/schema/local_files_database/Cargo.toml | 34 +++ .../20260901000000_create_local_files.sql | 36 +++ .../local_files_database/src/connection.rs | 66 +++++ .../schema/local_files_database/src/error.rs | 23 ++ crates/schema/local_files_database/src/lib.rs | 28 ++ .../src/queries/get_local_file_by_path.rs | 16 ++ .../src/queries/get_local_files_by_hash.rs | 18 ++ .../src/queries/local_file_record.rs | 22 ++ .../local_files_database/src/queries/mod.rs | 5 + .../src/queries/set_thumbnail_flags.rs | 28 ++ .../src/queries/upsert_local_file.rs | 49 ++++ .../tests/local_file_queries.rs | 85 ++++++ .../PromptShell/useComposerTasks.ts | 3 +- .../components/signaled/TopBar/TaskQueue.tsx | 102 ++++++- frontend/libs/tauri-api/src/index.ts | 1 + .../lib/artcraft/task_queue/GetTaskQueue.ts | 7 +- .../src/lib/local_files/GetLocalThumbnails.ts | 43 +++ 55 files changed, 2046 insertions(+), 48 deletions(-) create mode 100644 crates/artcraftx/src/commands/local_files/get_local_thumbnails_command.rs create mode 100644 crates/artcraftx/src/commands/local_files/mod.rs create mode 100644 crates/artcraftx/src/lifecycle/startup/tasks/bootstrap_local_files_database.rs create mode 100644 crates/artcraftx/src/services/local_files/mod.rs create mode 100644 crates/artcraftx/src/services/local_files/thumbnail_service.rs create mode 100644 crates/artcraftx/src/state/data_dir/subdirectory/app_cache_dir.rs create mode 100644 crates/artcraftx/src/state/database/local_files_database.rs create mode 100644 crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/higgsfield_failure_reason.rs create mode 100644 crates/lib/files/file_hashing/Cargo.toml create mode 100644 crates/lib/files/file_hashing/src/hash_file.rs create mode 100644 crates/lib/files/file_hashing/src/lib.rs create mode 100644 crates/lib/files/thumbnails/Cargo.toml create mode 100644 crates/lib/files/thumbnails/src/error.rs create mode 100644 crates/lib/files/thumbnails/src/image_thumbnail.rs create mode 100644 crates/lib/files/thumbnails/src/lib.rs create mode 100644 crates/lib/files/thumbnails/src/mp4_frames.rs create mode 100644 crates/lib/files/thumbnails/src/video_thumbnail.rs create mode 100644 crates/schema/local_files_database/Cargo.toml create mode 100644 crates/schema/local_files_database/migrations/20260901000000_create_local_files.sql create mode 100644 crates/schema/local_files_database/src/connection.rs create mode 100644 crates/schema/local_files_database/src/error.rs create mode 100644 crates/schema/local_files_database/src/lib.rs create mode 100644 crates/schema/local_files_database/src/queries/get_local_file_by_path.rs create mode 100644 crates/schema/local_files_database/src/queries/get_local_files_by_hash.rs create mode 100644 crates/schema/local_files_database/src/queries/local_file_record.rs create mode 100644 crates/schema/local_files_database/src/queries/mod.rs create mode 100644 crates/schema/local_files_database/src/queries/set_thumbnail_flags.rs create mode 100644 crates/schema/local_files_database/src/queries/upsert_local_file.rs create mode 100644 crates/schema/local_files_database/tests/local_file_queries.rs create mode 100644 frontend/libs/tauri-api/src/lib/local_files/GetLocalThumbnails.ts diff --git a/CLAUDE.md b/CLAUDE.md index ec90ef85..b12fc333 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ artcraftx/ │ ├── lib/ # Various utility libraries │ ├── router/ # Provider routing for generation requests (Artcraft, Fal, ...) │ ├── schema/ # Data definition layer +│ │ ├── local_files_database/ # Separate SQLite DB: content-hash index of local files (thumbnails, dedup) │ │ ├── sqlite_database/ # Desktop app's "tasks" SQLite database: queries + embedded migrations │ │ └── sqlite_identifiers/ # Enums and IDs used in sqlite queries as enums or keys │ ├── testing/ # Test data and test utilities diff --git a/Cargo.lock b/Cargo.lock index 26b38e00..5719e760 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,6 +226,7 @@ dependencies = [ "discord-rich-presence", "errors", "fal_client", + "file_hashing", "filesys", "futures", "grok_consumer_client", @@ -234,6 +235,7 @@ dependencies = [ "image", "images", "indexmap 2.13.0", + "local_files_database", "log", "memory_store", "midjourney_client", @@ -261,6 +263,7 @@ dependencies = [ "tauri-plugin-upload", "tempdir", "tempfile", + "thumbnails", "tokio", "tokio-util", "toml 1.0.7+spec-1.1.0", @@ -609,6 +612,19 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1142,6 +1158,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cookie" version = "0.18.1" @@ -1272,6 +1294,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.0.1" @@ -2017,6 +2048,15 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "file_hashing" +version = "0.0.1" +dependencies = [ + "blake3", + "tempfile", + "workspace-build-acceleration", +] + [[package]] name = "file_to_base64_url" version = "0.0.1" @@ -3565,7 +3605,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.12", ] [[package]] @@ -3708,6 +3748,19 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys2" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4790186411a6843ecc0a141c8948c8e623a0bb5e886834b1b6c90f3dfa85bb99" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.22" @@ -3744,6 +3797,19 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +[[package]] +name = "local_files_database" +version = "0.0.1" +dependencies = [ + "chrono", + "errors", + "log", + "sqlx", + "tempfile", + "tokio", + "workspace-build-acceleration", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -3993,6 +4059,20 @@ dependencies = [ "workspace-build-acceleration", ] +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.63", +] + [[package]] name = "muda" version = "0.19.2" @@ -4014,6 +4094,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "log", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -4150,6 +4239,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "serde", ] [[package]] @@ -4480,6 +4570,27 @@ dependencies = [ "wreq-util", ] +[[package]] +name = "openh264" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef0655e143954965073374f5390411131590d0bc793208aabf7c6785430fa00" +dependencies = [ + "openh264-sys2", + "wide", +] + +[[package]] +name = "openh264-sys2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad97e73d98000c46623ec4719e4fd2d7f79076a75350af8ae3878abf682c071d" +dependencies = [ + "cc", + "nasm-rs", + "walkdir", +] + [[package]] name = "openssl" version = "0.10.75" @@ -5787,6 +5898,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6242,7 +6362,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.12", "digest 0.10.7", ] @@ -6253,7 +6373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.12", "digest 0.10.7", ] @@ -6264,7 +6384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.12", "digest 0.11.2", ] @@ -7400,6 +7520,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thumbnails" +version = "0.0.1" +dependencies = [ + "images", + "log", + "mp4", + "openh264", + "tempfile", + "webp-animation", + "workspace-build-acceleration", +] + [[package]] name = "tiff" version = "0.9.0" @@ -8408,6 +8541,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webp-animation" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a656b424e13a9e8b35f2cb7f96ff81c9d796b7ce3b6966cd4f8fd4a15feba4" +dependencies = [ + "libwebp-sys2", + "log", +] + [[package]] name = "webpki-root-certs" version = "1.0.2" @@ -8493,6 +8636,16 @@ dependencies = [ "wasite", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 7674ba26..638c1ae2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,16 +41,19 @@ members = [ "crates/lib/data/primitives", "crates/lib/data/web_base64", "crates/lib/errors", + "crates/lib/files/file_hashing", "crates/lib/files/file_to_base64_url", "crates/lib/files/filesys", "crates/lib/files/images", "crates/lib/files/mimetypes", + "crates/lib/files/thumbnails", "crates/lib/http_headers", "crates/lib/jwt_light", "crates/lib/url_utils", "crates/lib/uuid_utils", # Local task storage (SQLite, embedded migrations) + "crates/schema/local_files_database", "crates/schema/sqlite_database", "crates/schema/sqlite_identifiers", @@ -100,6 +103,7 @@ browser_emulation = { path = "crates/lib/browser_emulation" } cookie_store_wrapper = { path = "crates/lib/cookie_store_wrapper" } errors = { path = "crates/lib/errors" } fal_client = { path = "crates/api_clients/fal_client" } +file_hashing = { path = "crates/lib/files/file_hashing" } file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" } filesys = { path = "crates/lib/files/filesys" } gmicloud_client = { path = "crates/api_clients/gmicloud_client" } @@ -119,9 +123,11 @@ openai_sora_client = { path = "crates/api_clients/openai_sora_client" } primitives = { path = "crates/lib/data/primitives" } router = { path = "crates/router" } seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" } +local_files_database = { path = "crates/schema/local_files_database" } sqlite_database = { path = "crates/schema/sqlite_database" } sqlite_identifiers = { path = "crates/schema/sqlite_identifiers" } test_data = { path = "crates/testing/test_data" } +thumbnails = { path = "crates/lib/files/thumbnails" } test_utils = { path = "crates/testing/test_utils" } url_utils = { path = "crates/lib/url_utils" } uuid_utils = { path = "crates/lib/uuid_utils" } @@ -135,6 +141,7 @@ worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_clie anyhow = "1.0.102" assertor = "0.0.3" base64 = "0.22.1" +blake3 = "1.5" bstr = "1.12.1" byteorder = "1.5.0" bytes = "1.11.1" @@ -145,6 +152,8 @@ env_logger = "0.11.9" futures = { version = "0.3.32" } indexmap = { version = "2.13.0", features = ["serde"] } log = "0.4.29" +mp4 = "0.14.0" +openh264 = "0.8.0" once_cell = "1.21.4" rand = "0.9.1" regex = "1.12.3" @@ -169,6 +178,7 @@ toml = "1.0.7+spec-1.1.0" url = { version = "2.5.8", features = ["serde"] } uuid = "1.22.0" walkdir = "2.4.0" +webp-animation = "0.9.0" # NB: wreq gets past fingerprinting, but we have to deal with platform-specific build issues. # diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md index 6e56ba9d..f86e4060 100644 --- a/crates/CLAUDE.md +++ b/crates/CLAUDE.md @@ -53,4 +53,7 @@ helpers go in the parent `mod tests` so sub-modules can `use super::*`. - `seedance2pro_client` — HTTP client for the Kinovi/Seedance2Pro video generation service - `core_types` — highest-level shared types: `GenerationSource`, the desktop app's own IDs (e.g. `credential_{ulid}`) - `sqlite_database` — SQLx queries + embedded migrations for the desktop app's local task database +- `local_files_database` — separate SQLite DB (runtime SQLx queries, no macros): BLAKE3 content-hash index of local files + thumbnail accounting +- `thumbnails` — pure-Rust thumbnail generation: images via `image`, H.264 MP4 first-frame JPEG + ~1s animated WebP via `mp4`/`openh264`/`webp-animation` +- `file_hashing` — streaming BLAKE3 content hashing - `sqlite_identifiers` — enums and IDs used in sqlite queries as enums or keys diff --git a/crates/artcraftx/Cargo.toml b/crates/artcraftx/Cargo.toml index 39d7c819..301d9ab3 100644 --- a/crates/artcraftx/Cargo.toml +++ b/crates/artcraftx/Cargo.toml @@ -32,6 +32,7 @@ build_metadata.workspace = true cookie_store_wrapper.workspace = true errors.workspace = true fal_client.workspace = true +file_hashing.workspace = true filesys.workspace = true grok_consumer_client.workspace = true higgsfield_client.workspace = true @@ -39,12 +40,14 @@ grok_consumer_statsig.workspace = true uuid_utils.workspace = true browser_emulation.workspace = true images.workspace = true +local_files_database.workspace = true memory_store.workspace = true midjourney_client.workspace = true mimetypes.workspace = true openai_sora_client.workspace = true primitives.workspace = true sqlite_database.workspace = true +thumbnails.workspace = true core_types.workspace = true models.workspace = true sqlite_identifiers.workspace = true diff --git a/crates/artcraftx/src/commands/local_files/get_local_thumbnails_command.rs b/crates/artcraftx/src/commands/local_files/get_local_thumbnails_command.rs new file mode 100644 index 00000000..0f2b440a --- /dev/null +++ b/crates/artcraftx/src/commands/local_files/get_local_thumbnails_command.rs @@ -0,0 +1,56 @@ +//! Bulk thumbnail lookup/generation 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. + +use std::path::PathBuf; + +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::state::database::local_files_database::LocalFilesDatabase; +use log::info; +use serde_derive::Serialize; +use tauri::State; + +// ── Request ── + +#[derive(Debug, Deserialize)] +pub struct GetLocalThumbnailsRequest { + /// Absolute local file paths (typically completed results on disk). + pub file_paths: Vec, +} + +// ── Response ── + +#[derive(Debug, Serialize)] +pub struct GetLocalThumbnailsResponse { + /// One entry per requested path, in request order. + pub thumbnails: Vec, +} + +impl SerializeMarker for GetLocalThumbnailsResponse {} + +#[tauri::command] +pub async fn get_local_thumbnails_command( + request: GetLocalThumbnailsRequest, + app_data_root: State<'_, AppDataRoot>, + local_files_database: State<'_, LocalFilesDatabase>, +) -> Result, ()> { + info!("get_local_thumbnails_command called for {} path(s)", request.file_paths.len()); + + // Concurrent per path; actual CPU work is capped by the service's + // generation semaphore and de-duplicated by its per-path locks. + let file_paths: Vec = request.file_paths.iter().map(PathBuf::from).collect(); + let lookups = file_paths.iter().map(|file_path| { + get_or_generate_thumbnail(&local_files_database, &app_data_root, file_path) + }); + let thumbnails = futures::future::join_all(lookups).await; + + Ok(GetLocalThumbnailsResponse { thumbnails }.into()) +} diff --git a/crates/artcraftx/src/commands/local_files/mod.rs b/crates/artcraftx/src/commands/local_files/mod.rs new file mode 100644 index 00000000..f3a0eea2 --- /dev/null +++ b/crates/artcraftx/src/commands/local_files/mod.rs @@ -0,0 +1 @@ +pub mod get_local_thumbnails_command; diff --git a/crates/artcraftx/src/commands/mod.rs b/crates/artcraftx/src/commands/mod.rs index 64d235c2..af3e21d9 100644 --- a/crates/artcraftx/src/commands/mod.rs +++ b/crates/artcraftx/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod cost_estimate; pub mod credentials; pub mod download; pub mod generate; +pub mod local_files; pub mod service; pub mod task_queue; pub mod utils; diff --git a/crates/artcraftx/src/commands/task_queue/get_task_queue_command.rs b/crates/artcraftx/src/commands/task_queue/get_task_queue_command.rs index 45e4d58e..37078e87 100644 --- a/crates/artcraftx/src/commands/task_queue/get_task_queue_command.rs +++ b/crates/artcraftx/src/commands/task_queue/get_task_queue_command.rs @@ -49,7 +49,10 @@ pub struct TaskQueueItem { #[derive(Serialize)] pub struct CompletedItemData { - pub primary_media_file: MediaFileData, + /// Present only when the result was uploaded to ArtCraft (requires a + /// logged-in session). Local-only completions have download locations but + /// no cloud media file. + pub primary_media_file: Option, /// The type of file(s) generated. pub media_file_class: Option, @@ -121,26 +124,30 @@ pub async fn handle_request( let mut failure_reason = None; if task.status == TaskStatus::CompleteSuccess { - let token_and_url = task.on_complete_primary_media_file_token - .zip(task.on_complete_primary_media_file_cdn_url); - - if let Some((primary_media_file_token, media_file_url)) = token_and_url { - completed_item = Some(CompletedItemData { - primary_media_file: MediaFileData { + // The cloud media file only exists when the result was uploaded to + // ArtCraft; a local-only completion still gets a completed item so + // the frontend can render (and thumbnail) the downloaded files. + let primary_media_file = task.on_complete_primary_media_file_token + .zip(task.on_complete_primary_media_file_cdn_url) + .map(|(primary_media_file_token, media_file_url)| MediaFileData { token: primary_media_file_token, - cdn_url: media_file_url.clone(), + cdn_url: media_file_url, maybe_thumbnail_url_template: task.on_complete_primary_media_file_thumbnail_url_template.clone(), // NB: This isn't the exact completion date. Also, fallback to now if missing. created_at: task.completed_at.unwrap_or_else(Utc::now), - }, - media_file_class: task.on_complete_primary_media_file_class, - 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(), - }); - } else { - warn!("Task {} is marked complete but has no primary media file token or URL.", task.id); + }); + + if primary_media_file.is_none() && task.on_complete_first_file_location.is_none() { + warn!("Task {} is marked complete but has neither a cloud media file nor a local download.", task.id); } + + completed_item = Some(CompletedItemData { + primary_media_file, + media_file_class: task.on_complete_primary_media_file_class, + 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(), + }); } else { // If either failure field is present, fill out the failure report. if let Some(failure_message) = task.on_failure_message.as_deref() { diff --git a/crates/artcraftx/src/lib.rs b/crates/artcraftx/src/lib.rs index 64b39b4a..9c4c6962 100644 --- a/crates/artcraftx/src/lib.rs +++ b/crates/artcraftx/src/lib.rs @@ -51,6 +51,7 @@ use crate::commands::generate::generate_video::generate_video_command::generate_ use crate::commands::service::get_app_info_command::get_app_info_command; use crate::commands::service::load_without_cors_command::load_without_cors_command; use crate::commands::service::platform_info_command::platform_info_command; +use crate::commands::local_files::get_local_thumbnails_command::get_local_thumbnails_command; use crate::commands::task_queue::get_task_queue_command::get_task_queue_command; use crate::commands::task_queue::mark_task_as_dismissed_command::mark_task_as_dismissed_command; use crate::commands::task_queue::tasks_nuke_all_command::tasks_nuke_all_command; @@ -236,6 +237,7 @@ pub fn run() { get_app_info_command, get_app_preferences_command, get_promptbox_state_command, + get_local_thumbnails_command, get_task_queue_command, load_custom_sound_command, load_without_cors_command, diff --git a/crates/artcraftx/src/lifecycle/startup/handle_tauri_startup.rs b/crates/artcraftx/src/lifecycle/startup/handle_tauri_startup.rs index 443a5ee6..43eac7f3 100644 --- a/crates/artcraftx/src/lifecycle/startup/handle_tauri_startup.rs +++ b/crates/artcraftx/src/lifecycle/startup/handle_tauri_startup.rs @@ -1,3 +1,4 @@ +use crate::lifecycle::startup::tasks::bootstrap_local_files_database::bootstrap_local_files_database; use crate::lifecycle::startup::tasks::bootstrap_task_database::bootstrap_task_database; use crate::lifecycle::startup::tasks::initially_size_and_position_windows::initially_size_and_position_windows; use crate::lifecycle::startup::tasks::set_app_log_level::set_app_log_level; @@ -49,6 +50,8 @@ pub async fn handle_tauri_startup( let task_database = bootstrap_task_database(&app, &root).await?; + bootstrap_local_files_database(&app, &root).await?; + spawn_main_window_thread( &app, &root, diff --git a/crates/artcraftx/src/lifecycle/startup/tasks/bootstrap_local_files_database.rs b/crates/artcraftx/src/lifecycle/startup/tasks/bootstrap_local_files_database.rs new file mode 100644 index 00000000..c88d8bfa --- /dev/null +++ b/crates/artcraftx/src/lifecycle/startup/tasks/bootstrap_local_files_database.rs @@ -0,0 +1,10 @@ +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::database::local_files_database::LocalFilesDatabase; +use errors::AnyhowResult; +use tauri::{AppHandle, Manager}; + +pub async fn bootstrap_local_files_database(app: &AppHandle, root: &AppDataRoot) -> AnyhowResult { + let local_files_database = LocalFilesDatabase::connect(root).await?; + app.manage(local_files_database.clone()); + Ok(local_files_database) +} diff --git a/crates/artcraftx/src/lifecycle/startup/tasks/mod.rs b/crates/artcraftx/src/lifecycle/startup/tasks/mod.rs index 01da7b6f..206bc812 100644 --- a/crates/artcraftx/src/lifecycle/startup/tasks/mod.rs +++ b/crates/artcraftx/src/lifecycle/startup/tasks/mod.rs @@ -1,3 +1,4 @@ +pub (super) mod bootstrap_local_files_database; pub (super) mod bootstrap_task_database; pub (super) mod set_app_log_level; pub (super) mod initially_size_and_position_windows; diff --git a/crates/artcraftx/src/services/local_files/mod.rs b/crates/artcraftx/src/services/local_files/mod.rs new file mode 100644 index 00000000..dae4ce99 --- /dev/null +++ b/crates/artcraftx/src/services/local_files/mod.rs @@ -0,0 +1 @@ +pub mod thumbnail_service; diff --git a/crates/artcraftx/src/services/local_files/thumbnail_service.rs b/crates/artcraftx/src/services/local_files/thumbnail_service.rs new file mode 100644 index 00000000..6cdef1b9 --- /dev/null +++ b/crates/artcraftx/src/services/local_files/thumbnail_service.rs @@ -0,0 +1,250 @@ +//! 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). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex as StdMutex}; +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::upsert_local_file::{upsert_local_file, UpsertLocalFileArgs}; +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 tokio::sync::{Mutex as TokioMutex, Semaphore}; + +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::database::local_files_database::LocalFilesDatabase; + +/// Cap on concurrent thumbnail generations (decode + encode are CPU-heavy). +const MAX_CONCURRENT_GENERATIONS: usize = 2; + +static GENERATION_SEMAPHORE: Lazy = Lazy::new(|| Semaphore::new(MAX_CONCURRENT_GENERATIONS)); + +/// Per-path locks so overlapping bulk requests don't hash or generate the +/// same file twice. +static PER_PATH_LOCKS: Lazy>>>> = + Lazy::new(|| StdMutex::new(HashMap::new())); + +const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "gif", "bmp", "tiff", "avif"]; +const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov"]; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LocalThumbnailStatus { + /// A static thumbnail exists (animated preview too, when the path is set). + Ready, + /// 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. + Failed, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LocalThumbnail { + pub file_path: String, + pub status: LocalThumbnailStatus, + pub maybe_file_hash: Option, + pub maybe_thumbnail_path: Option, + pub maybe_animated_preview_path: Option, +} + +impl LocalThumbnail { + fn without_thumbnail(file_path: &Path, status: LocalThumbnailStatus, maybe_file_hash: Option) -> Self { + Self { + file_path: file_path.to_string_lossy().into_owned(), + status, + maybe_file_hash, + maybe_thumbnail_path: None, + maybe_animated_preview_path: None, + } + } +} + +/// Get (generating if needed) the cached thumbnails for one local file. +pub async fn get_or_generate_thumbnail( + database: &LocalFilesDatabase, + app_data_root: &AppDataRoot, + file_path: &Path, +) -> LocalThumbnail { + // Serialize work per path; overlapping calls wait, then hit the cache. + 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 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, + Err(message) => { + warn!("Could not hash {}: {}", file_path.display(), message); + return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Failed, None); + } + }; + + let thumbnails_dir = match app_data_root.cache_dir().get_or_create_thumbnails_dir() { + Ok(dir) => dir, + Err(err) => { + warn!("Could not create thumbnails cache dir: {}", err); + return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Failed, Some(file_hash)); + } + }; + 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 { + return LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Unsupported, Some(file_hash)); + }; + + let generation = { + let _permit = GENERATION_SEMAPHORE.acquire().await.expect("semaphore never closes"); + let source = file_path.to_path_buf(); + let jpg = thumbnail_jpg.clone(); + let webp = animated_webp.clone(); + tokio::task::spawn_blocking(move || generate(kind, &source, &jpg, &webp)).await + }; + + 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()), + } + } + Ok(Err(err)) if err.is_unsupported() => { + info!("Media not thumbnailable ({}): {}", file_path.display(), err); + LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Unsupported, Some(file_hash)) + } + Ok(Err(err)) => { + warn!("Thumbnail generation failed for {}: {}", file_path.display(), err); + LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Failed, Some(file_hash)) + } + Err(join_error) => { + warn!("Thumbnail generation task panicked for {}: {}", file_path.display(), join_error); + LocalThumbnail::without_thumbnail(file_path, LocalThumbnailStatus::Failed, Some(file_hash)) + } + } +} + +// ── Private ── + +#[derive(Copy, Clone, Debug)] +enum MediaKind { + Image, + Video, +} + +/// Returns whether an animated preview was written alongside the static jpg. +fn generate(kind: MediaKind, source: &Path, jpg: &Path, webp: &Path) -> Result { + match kind { + MediaKind::Image => { + generate_image_thumbnail(source, jpg, STATIC_THUMBNAIL_MAX_DIMENSION)?; + Ok(false) + } + MediaKind::Video => { + let outcome = generate_video_thumbnails(source, jpg, webp)?; + Ok(outcome.wrote_animated_preview) + } + } +} + +/// The memoization at the heart of this module: trust the indexed hash when +/// size+mtime match (a row lookup), rehash only when the file changed (a +/// full read). +async fn cached_or_computed_hash( + database: &LocalFilesDatabase, + file_path: &Path, + file_size_bytes: i64, + file_mtime_ms: i64, +) -> Result { + let path_string = file_path.to_string_lossy().into_owned(); + + 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) => { + return Ok(record.file_hash_blake3); + } + Ok(_) => {} + Err(err) => warn!("local_files lookup failed for {}: {}", path_string, err), + } + + let hash_path = file_path.to_path_buf(); + let file_hash = tokio::task::spawn_blocking(move || hash_file_blake3(&hash_path)) + .await + .map_err(|join_error| join_error.to_string())? + .map_err(|io_error| io_error.to_string())?; + + let extension = file_extension(file_path); + let upsert = upsert_local_file(UpsertLocalFileArgs { + db: database.get_connection(), + file_path: &path_string, + maybe_file_type: (!extension.is_empty()).then_some(extension.as_str()), + file_size_bytes, + file_mtime_ms, + file_hash_blake3: &file_hash, + }).await; + if let Err(err) = upsert { + warn!("local_files upsert failed for {}: {}", path_string, err); + } + + Ok(file_hash) +} + +async fn record_flags(database: &LocalFilesDatabase, file_path: &Path, has_thumbnail: bool, has_animated_preview: bool) { + 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); + } +} + +fn per_path_lock(file_path: &Path) -> Arc> { + let mut locks = PER_PATH_LOCKS.lock().expect("lock poisoned"); + locks.entry(file_path.to_path_buf()).or_default().clone() +} + +fn file_extension(file_path: &Path) -> String { + file_path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default() +} diff --git a/crates/artcraftx/src/services/mod.rs b/crates/artcraftx/src/services/mod.rs index b917f285..ec740843 100644 --- a/crates/artcraftx/src/services/mod.rs +++ b/crates/artcraftx/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod grok; +pub mod local_files; pub mod higgsfield; pub mod midjourney; pub mod sora; diff --git a/crates/artcraftx/src/state/data_dir/app_data_root.rs b/crates/artcraftx/src/state/data_dir/app_data_root.rs index 2ecfa118..3e36cfc1 100644 --- a/crates/artcraftx/src/state/data_dir/app_data_root.rs +++ b/crates/artcraftx/src/state/data_dir/app_data_root.rs @@ -1,3 +1,4 @@ +use crate::state::data_dir::subdirectory::app_cache_dir::AppCacheDir; use crate::state::data_dir::subdirectory::app_credentials_dir::AppCredentialsDir; use crate::state::data_dir::subdirectory::app_downloads_dir::AppDownloadsDir; use crate::state::data_dir::subdirectory::app_settings_dir::AppSettingsDir; @@ -27,6 +28,7 @@ pub struct AppDataRoot { log_file_name: PathBuf, log_file_name_string: String, + cache_dir: AppCacheDir, credentials_dir: AppCredentialsDir, downloads_dir: AppDownloadsDir, settings_dir: AppSettingsDir, @@ -65,6 +67,7 @@ impl AppDataRoot { } } + let cache_dir = AppCacheDir::get_or_create_in_root_dir(&dir)?; let credentials_dir = AppCredentialsDir::get_or_create_in_root_dir(&dir)?; let downloads_dir = AppDownloadsDir::get_or_create_in_root_dir(&dir)?; let settings_dir = AppSettingsDir::get_or_create_in_root_dir(&dir)?; @@ -80,6 +83,7 @@ impl AppDataRoot { path: dir, log_file_name, log_file_name_string, + cache_dir, credentials_dir, downloads_dir, settings_dir, @@ -88,6 +92,10 @@ impl AppDataRoot { }) } + pub fn cache_dir(&self) -> &AppCacheDir { + &self.cache_dir + } + pub fn credentials_dir(&self) -> &AppCredentialsDir { &self.credentials_dir } diff --git a/crates/artcraftx/src/state/data_dir/subdirectory/app_cache_dir.rs b/crates/artcraftx/src/state/data_dir/subdirectory/app_cache_dir.rs new file mode 100644 index 00000000..cbc43a66 --- /dev/null +++ b/crates/artcraftx/src/state/data_dir/subdirectory/app_cache_dir.rs @@ -0,0 +1,37 @@ +use std::path::{Path, PathBuf}; + +use crate::state::data_dir::subdirectory::trait_data_subdir::DataSubdir; + +/// `/cache` — regenerable derived data (thumbnails, previews). Safe to +/// delete wholesale; everything here rebuilds on demand. +#[derive(Clone)] +pub struct AppCacheDir { + path: PathBuf, +} + +impl DataSubdir for AppCacheDir { + const DIRECTORY_NAME: &'static str = "cache"; + + fn new_from>(dir: P) -> Self { + Self { + path: dir.as_ref().to_path_buf(), + } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl AppCacheDir { + /// `cache/thumbnails/` — content-addressed thumbnails + /// (`{blake3_hex}.jpg` static, `{blake3_hex}.webp` animated preview). + /// Created on demand. + pub fn get_or_create_thumbnails_dir(&self) -> anyhow::Result { + let dir = self.path.join("thumbnails"); + if !dir.exists() { + std::fs::create_dir_all(&dir)?; + } + Ok(dir) + } +} diff --git a/crates/artcraftx/src/state/data_dir/subdirectory/app_state_dir.rs b/crates/artcraftx/src/state/data_dir/subdirectory/app_state_dir.rs index 3e5aeee2..8ae25fd0 100644 --- a/crates/artcraftx/src/state/data_dir/subdirectory/app_state_dir.rs +++ b/crates/artcraftx/src/state/data_dir/subdirectory/app_state_dir.rs @@ -28,6 +28,13 @@ impl AppStateDir { self.path.join(format!("tasks_v{TASK_DATABASE_VERSION}.sqlite")) } + /// The local_files content-hash index. Unlike the tasks database this + /// keeps a STABLE filename: it migrates additively, and on migration + /// failure it's deleted and rebuilt (every row is derivable). + pub fn get_local_files_sqlite_database_path(&self) -> PathBuf { + self.path.join("local_files.sqlite") + } + pub fn get_window_size_config_file(&self) -> PathBuf { self.path.join("window_size.json") } diff --git a/crates/artcraftx/src/state/data_dir/subdirectory/mod.rs b/crates/artcraftx/src/state/data_dir/subdirectory/mod.rs index c3407b52..d7ad15f7 100644 --- a/crates/artcraftx/src/state/data_dir/subdirectory/mod.rs +++ b/crates/artcraftx/src/state/data_dir/subdirectory/mod.rs @@ -1,3 +1,4 @@ +pub mod app_cache_dir; pub mod app_credentials_dir; pub mod app_downloads_dir; pub mod app_settings_dir; diff --git a/crates/artcraftx/src/state/database/local_files_database.rs b/crates/artcraftx/src/state/database/local_files_database.rs new file mode 100644 index 00000000..84907781 --- /dev/null +++ b/crates/artcraftx/src/state/database/local_files_database.rs @@ -0,0 +1,24 @@ +use crate::state::data_dir::app_data_root::AppDataRoot; +use errors::AnyhowResult; +use local_files_database::connection::LocalFilesDbConnection; + +/// The content-hash index of local files (thumbnail accounting today; the +/// upload-dedup ledger later). Deliberately a separate SQLite file from the +/// tasks database: it's a rebuildable index with its own write bursts and +/// lock domain. +#[derive(Clone)] +pub struct LocalFilesDatabase { + connection: LocalFilesDbConnection, +} + +impl LocalFilesDatabase { + pub async fn connect(root: &AppDataRoot) -> AnyhowResult { + let path = root.state_dir().get_local_files_sqlite_database_path(); + let connection = LocalFilesDbConnection::connect_and_migrate(path).await?; + Ok(Self { connection }) + } + + pub fn get_connection(&self) -> &LocalFilesDbConnection { + &self.connection + } +} diff --git a/crates/artcraftx/src/state/database/mod.rs b/crates/artcraftx/src/state/database/mod.rs index 4f0f9443..dc7bc7ee 100644 --- a/crates/artcraftx/src/state/database/mod.rs +++ b/crates/artcraftx/src/state/database/mod.rs @@ -1,2 +1,3 @@ +pub mod local_files_database; pub mod task_database; pub mod task_database_version; diff --git a/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/handle_higgsfield_failure.rs b/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/handle_higgsfield_failure.rs index 7e54c1e4..1f463c5a 100644 --- a/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/handle_higgsfield_failure.rs +++ b/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/handle_higgsfield_failure.rs @@ -2,25 +2,36 @@ use crate::events::basic_sendable_event_trait::BasicSendableEvent; use crate::events::generation_events::common::{GenerationAction, GenerationServiceProvider}; use crate::events::generation_events::generation_failed_event::GenerationFailedEvent; use crate::state::database::task_database::TaskDatabase; +use crate::threads::third_party_task_polling_thread::handlers::higgsfield::higgsfield_failure_reason::HiggsfieldFailureReport; use log::{error, info}; use sqlite_database::queries::task::Task; -use sqlite_database::queries::update::update_task_status::{update_task_status, UpdateTaskArgs}; +use sqlite_database::queries::update::update_task_status_with_rich_failure::{ + update_task_status_with_rich_failure, UpdateTaskWithRichFailureArgs, +}; use sqlite_identifiers::enums::task_status::TaskStatus; use sqlite_identifiers::enums::task_type::TaskType; use tauri::AppHandle; +/// Mark the task failed, persisting the failure type + friendly message to +/// the tasks database (so the task queue can render it later) and notifying +/// the frontend. The technical record goes to the logs. pub async fn handle_higgsfield_failure( app_handle: &AppHandle, task_database: &TaskDatabase, task: &Task, - reason: &str, + report: &HiggsfieldFailureReport, ) { - info!("[HiggsfieldPolling] Marking task {} as failed: {}", task.id.as_str(), reason); + info!( + "[HiggsfieldPolling] Marking task {} as failed ({}): {} | details: {}", + task.id.as_str(), report.failure_type, report.user_message, report.log_details, + ); - let update_result = update_task_status(UpdateTaskArgs { + let update_result = update_task_status_with_rich_failure(UpdateTaskWithRichFailureArgs { db: task_database.get_connection(), task_id: &task.id, status: TaskStatus::CompleteFailure, + maybe_failure_type: Some(report.failure_type), + maybe_failure_message: Some(&report.user_message), }).await; if let Err(err) = update_result { @@ -31,7 +42,7 @@ pub async fn handle_higgsfield_failure( action: task_type_to_generation_action(task.task_type), service: GenerationServiceProvider::Higgsfield, model: None, - reason: Some(reason.to_string()), + reason: Some(report.user_message.clone()), }; event.send_infallible(app_handle); diff --git a/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/higgsfield_failure_reason.rs b/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/higgsfield_failure_reason.rs new file mode 100644 index 00000000..c433e679 --- /dev/null +++ b/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/higgsfield_failure_reason.rs @@ -0,0 +1,223 @@ +//! Turn Higgsfield job failures into something a person can act on. +//! +//! Two audiences, two strings: `user_message` is what the frontend shows +//! (no job ids, no jargon, a next step where one exists) and `log_details` +//! is the full technical record (every job id, raw status, and the server's +//! `fail_reason`) for the logs and the tasks database. + +use higgsfield_client::types::ids::JobId; +use higgsfield_client::types::job_status::JobStatus; +use sqlite_identifiers::enums::task_failure_type::TaskFailureType; + +/// One job's failure, as observed while polling. +pub struct HiggsfieldJobFailure { + pub job_id: JobId, + pub kind: HiggsfieldJobFailureKind, + /// The server's `meta.fail_reason`, when the full job record gave one. + pub maybe_server_reason: Option, +} + +pub enum HiggsfieldJobFailureKind { + /// The job ended in a non-success terminal status. + TerminalStatus(JobStatus), + /// The job claims success but returned no result URL to download. + CompletedWithoutResult, +} + +/// A whole task's failure, ready to persist and display. +pub struct HiggsfieldFailureReport { + pub failure_type: TaskFailureType, + /// Frontend-facing: friendly, actionable, no identifiers. + pub user_message: String, + /// Log/database-facing: every job id, raw status, and server reason. + pub log_details: String, +} + +impl HiggsfieldJobFailure { + /// Higher wins when picking the message for a mixed batch: a stated + /// server reason is the most specific thing we have, then the content + /// filter (actionable), then the generic states. + fn priority(&self) -> u8 { + if self.maybe_server_reason.is_some() { + return 6; + } + match &self.kind { + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Nsfw) => 5, + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Failed) => 4, + HiggsfieldJobFailureKind::CompletedWithoutResult => 3, + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Cancelled) => 2, + HiggsfieldJobFailureKind::TerminalStatus(_) => 1, + } + } + + fn user_message(&self) -> String { + match &self.kind { + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Nsfw) => { + "Higgsfield's content filter flagged this generation as NSFW. If your prompt and \ + references are innocuous this can be a false positive — try rewording the prompt \ + or swapping the reference media.".to_string() + } + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Failed) => { + match &self.maybe_server_reason { + Some(reason) => format!("Higgsfield reported an error: {}", reason), + None => "The generation failed on Higgsfield's side without a stated reason. \ + This is often transient — try again.".to_string(), + } + } + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Cancelled) => { + "The generation was cancelled on Higgsfield before it finished.".to_string() + } + HiggsfieldJobFailureKind::TerminalStatus(status) => { + format!("The generation ended in an unexpected state on Higgsfield (\"{}\").", status) + } + HiggsfieldJobFailureKind::CompletedWithoutResult => { + "Higgsfield reported the generation as complete but returned no output file. \ + Trying again usually works.".to_string() + } + } + } + + fn failure_type(&self) -> TaskFailureType { + match &self.kind { + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Nsfw) => TaskFailureType::RuleBansGeneratedContent, + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Failed) => TaskFailureType::GenerationFailed, + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Cancelled) => TaskFailureType::GenerationFailed, + HiggsfieldJobFailureKind::TerminalStatus(_) => TaskFailureType::Unknown, + HiggsfieldJobFailureKind::CompletedWithoutResult => TaskFailureType::GenerationFailed, + } + } + + fn log_detail(&self) -> String { + let raw_state = match &self.kind { + HiggsfieldJobFailureKind::TerminalStatus(status) => format!("status={}", status), + HiggsfieldJobFailureKind::CompletedWithoutResult => "status=completed, no result URL".to_string(), + }; + match &self.maybe_server_reason { + Some(reason) => format!("job {}: {}, fail_reason={:?}", self.job_id, raw_state, reason), + None => format!("job {}: {}", self.job_id, raw_state), + } + } +} + +/// Summarize why a task (one or more Higgsfield jobs) failed. +/// `total_jobs` is the batch size, so partial context survives in the copy. +pub fn build_failure_report(failures: &[HiggsfieldJobFailure], total_jobs: usize) -> HiggsfieldFailureReport { + let Some(primary) = failures.iter().max_by_key(|failure| failure.priority()) else { + // Shouldn't happen (callers only report with >= 1 failure), but never + // leave the user message empty. + return HiggsfieldFailureReport { + failure_type: TaskFailureType::Unknown, + user_message: "The generation failed on Higgsfield for an unknown reason.".to_string(), + log_details: "no job outcomes recorded".to_string(), + }; + }; + + let mut user_message = primary.user_message(); + if total_jobs > 1 { + user_message = format!( + "{} of {} generations in this batch failed. {}", + failures.len(), total_jobs, user_message, + ); + } + + let log_details = failures.iter() + .map(HiggsfieldJobFailure::log_detail) + .collect::>() + .join("; "); + + HiggsfieldFailureReport { + failure_type: primary.failure_type(), + user_message, + log_details, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn failure(kind: HiggsfieldJobFailureKind, maybe_server_reason: Option<&str>) -> HiggsfieldJobFailure { + HiggsfieldJobFailure { + job_id: JobId::new("e82ac3d7-5282-4b71-be90-c7cfad846299"), + kind, + maybe_server_reason: maybe_server_reason.map(str::to_string), + } + } + + #[test] + fn nsfw_maps_to_content_rule_with_actionable_copy() { + let report = build_failure_report( + &[failure(HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Nsfw), None)], + 1, + ); + assert_eq!(report.failure_type, TaskFailureType::RuleBansGeneratedContent); + assert!(report.user_message.contains("content filter")); + assert!(report.user_message.contains("false positive")); + // Job ids stay out of user copy, but land in the log details. + assert!(!report.user_message.contains("e82ac3d7")); + assert!(report.log_details.contains("e82ac3d7")); + assert!(report.log_details.contains("status=nsfw")); + } + + #[test] + fn server_fail_reason_is_surfaced_verbatim() { + let report = build_failure_report( + &[failure( + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Failed), + Some("Input audio duration is not supported. Please try a shorter audio."), + )], + 1, + ); + assert_eq!(report.failure_type, TaskFailureType::GenerationFailed); + assert!(report.user_message.contains("Input audio duration is not supported")); + assert!(report.log_details.contains("fail_reason=\"Input audio duration")); + } + + #[test] + fn failed_without_reason_reads_as_transient() { + let report = build_failure_report( + &[failure(HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Failed), None)], + 1, + ); + assert!(report.user_message.contains("try again")); + } + + #[test] + fn unexpected_status_names_the_state() { + let report = build_failure_report( + &[failure(HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Other("mystery".into())), None)], + 1, + ); + assert_eq!(report.failure_type, TaskFailureType::Unknown); + assert!(report.user_message.contains("mystery")); + } + + #[test] + fn batch_failures_carry_counts_and_pick_the_most_specific_message() { + let report = build_failure_report( + &[ + failure(HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Cancelled), None), + failure( + HiggsfieldJobFailureKind::TerminalStatus(JobStatus::Failed), + Some("Resolution not supported."), + ), + ], + 4, + ); + assert!(report.user_message.starts_with("2 of 4 generations in this batch failed.")); + assert!(report.user_message.contains("Resolution not supported.")); + // Both jobs appear in the technical record. + assert_eq!(report.log_details.matches("job ").count(), 2); + } + + #[test] + fn completed_without_result_is_its_own_story() { + let report = build_failure_report( + &[failure(HiggsfieldJobFailureKind::CompletedWithoutResult, None)], + 1, + ); + assert_eq!(report.failure_type, TaskFailureType::GenerationFailed); + assert!(report.user_message.contains("no output file")); + assert!(report.log_details.contains("no result URL")); + } +} diff --git a/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/mod.rs b/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/mod.rs index 7fc80ed6..07f3c6d5 100644 --- a/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/mod.rs +++ b/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/mod.rs @@ -6,5 +6,6 @@ pub mod handle_higgsfield_complete; pub mod handle_higgsfield_failure; +pub mod higgsfield_failure_reason; pub mod higgsfield_poll_sessions; pub mod poll_higgsfield_tasks; diff --git a/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/poll_higgsfield_tasks.rs b/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/poll_higgsfield_tasks.rs index 6b85c66f..5aa503f4 100644 --- a/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/poll_higgsfield_tasks.rs +++ b/crates/artcraftx/src/threads/third_party_task_polling_thread/handlers/higgsfield/poll_higgsfield_tasks.rs @@ -15,7 +15,11 @@ use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::database::task_database::TaskDatabase; use crate::threads::third_party_task_polling_thread::handlers::higgsfield::handle_higgsfield_complete::handle_higgsfield_complete; use crate::threads::third_party_task_polling_thread::handlers::higgsfield::handle_higgsfield_failure::handle_higgsfield_failure; +use crate::threads::third_party_task_polling_thread::handlers::higgsfield::higgsfield_failure_reason::{ + build_failure_report, HiggsfieldFailureReport, HiggsfieldJobFailure, HiggsfieldJobFailureKind, +}; use crate::threads::third_party_task_polling_thread::handlers::higgsfield::higgsfield_poll_sessions::HiggsfieldPollSessions; +use sqlite_identifiers::enums::task_failure_type::TaskFailureType; /// Check every pending Higgsfield task. Jobs are looked up through each /// stored Higgsfield account in turn (a job only answers on the account that @@ -82,7 +86,12 @@ async fn poll_single_task( if job_ids.is_empty() { warn!("[HiggsfieldPolling] Task {} has no job ids; marking as failed", task.id.as_str()); - handle_higgsfield_failure(app_handle, task_database, task, "No Higgsfield job ids to check").await; + let report = HiggsfieldFailureReport { + failure_type: TaskFailureType::Unknown, + user_message: "This generation lost track of its Higgsfield job and can't be resumed. Please generate again.".to_string(), + log_details: "task record has no provider job ids".to_string(), + }; + handle_higgsfield_failure(app_handle, task_database, task, &report).await; return Ok(()); } @@ -99,15 +108,39 @@ async fn poll_single_task( // Fetch the full record (result URLs, prompt) of every finished job. let mut finished: Vec = Vec::new(); - let mut failures: Vec = Vec::new(); + let mut failures: Vec = Vec::new(); for outcome in &outcomes { if !outcome.status.is_success() { - failures.push(format!("{} ended {}", outcome.job_id, outcome.status)); + // Best-effort: the full job record carries the server's fail_reason + // (e.g. "Input audio duration is not supported"), which is the most + // useful thing we can show. A fetch error just means no reason. + let maybe_server_reason = match session.job_status(&outcome.job_id).await { + Ok(job) => job.fail_reason().map(str::to_string), + Err(err) => { + warn!( + "[HiggsfieldPolling] Could not fetch fail_reason for job {} (task {}): {}", + outcome.job_id, task.id.as_str(), err, + ); + None + } + }; + failures.push(HiggsfieldJobFailure { + job_id: outcome.job_id.clone(), + kind: HiggsfieldJobFailureKind::TerminalStatus(outcome.status.clone()), + maybe_server_reason, + }); continue; } match session.job_status(&outcome.job_id).await { Ok(job) if job.result_url().is_some() => finished.push(job), - Ok(job) => failures.push(format!("{} completed without a result URL", job.id)), + Ok(job) => { + let maybe_server_reason = job.fail_reason().map(str::to_string); + failures.push(HiggsfieldJobFailure { + job_id: job.id.clone(), + kind: HiggsfieldJobFailureKind::CompletedWithoutResult, + maybe_server_reason, + }); + } Err(err) => { // Transient: try again next iteration rather than failing the task. warn!("[HiggsfieldPolling] Could not fetch job {} for task {}: {}", outcome.job_id, task.id.as_str(), err); @@ -117,15 +150,16 @@ async fn poll_single_task( } if finished.is_empty() { - let reason = if failures.is_empty() { "Higgsfield produced no output".to_string() } else { failures.join("; ") }; - handle_higgsfield_failure(app_handle, task_database, task, &reason).await; + let report = build_failure_report(&failures, outcomes.len()); + handle_higgsfield_failure(app_handle, task_database, task, &report).await; return Ok(()); } if !failures.is_empty() { + let report = build_failure_report(&failures, outcomes.len()); warn!( - "[HiggsfieldPolling] Task {}: {} of {} job(s) failed ({}); delivering the rest", - task.id.as_str(), failures.len(), outcomes.len(), failures.join("; "), + "[HiggsfieldPolling] Task {}: {} of {} job(s) failed; delivering the rest. Details: {}", + task.id.as_str(), failures.len(), outcomes.len(), report.log_details, ); } diff --git a/crates/artcraftx/tauri-dev-hot-reload.conf.json b/crates/artcraftx/tauri-dev-hot-reload.conf.json index fa6e3f9f..d31d445a 100644 --- a/crates/artcraftx/tauri-dev-hot-reload.conf.json +++ b/crates/artcraftx/tauri-dev-hot-reload.conf.json @@ -53,6 +53,7 @@ "'self'", "'unsafe-inline'", "*", + "asset:", "blob:", "data:", "https://*.fakeyou.com", diff --git a/crates/artcraftx/tauri-mac.conf.json b/crates/artcraftx/tauri-mac.conf.json index 3ef9c86c..f24cce42 100644 --- a/crates/artcraftx/tauri-mac.conf.json +++ b/crates/artcraftx/tauri-mac.conf.json @@ -57,6 +57,7 @@ "'self'", "'unsafe-inline'", "*", + "asset:", "blob:", "data:", "https://*.fakeyou.com", diff --git a/crates/artcraftx/tauri.conf.json b/crates/artcraftx/tauri.conf.json index 4e2bb89a..8369b361 100644 --- a/crates/artcraftx/tauri.conf.json +++ b/crates/artcraftx/tauri.conf.json @@ -57,6 +57,7 @@ "'self'", "'unsafe-inline'", "*", + "asset:", "blob:", "data:", "https://*.fakeyou.com", diff --git a/crates/lib/files/file_hashing/Cargo.toml b/crates/lib/files/file_hashing/Cargo.toml new file mode 100644 index 00000000..b1272c2c --- /dev/null +++ b/crates/lib/files/file_hashing/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "file_hashing" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "file_hashing" +path = "src/lib.rs" + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[dependencies] +# cargo-hakari workspace hack (faster compile times) +workspace-build-acceleration.workspace = true + +# External +# KEEP THIS TO A MINIMUM OR NIL. +blake3.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/lib/files/file_hashing/src/hash_file.rs b/crates/lib/files/file_hashing/src/hash_file.rs new file mode 100644 index 00000000..53502480 --- /dev/null +++ b/crates/lib/files/file_hashing/src/hash_file.rs @@ -0,0 +1,66 @@ +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::Path; + +/// Streaming read size. 1 MiB keeps syscall counts low without holding a +/// large buffer per concurrent hash. +const READ_BUFFER_BYTES: usize = 1024 * 1024; + +/// BLAKE3 of a file's contents, as 64 lowercase hex characters. +/// +/// Streams the file; memory use is one buffer regardless of file size. This +/// is blocking I/O — call it from `spawn_blocking` in async contexts. +pub fn hash_file_blake3>(path: P) -> std::io::Result { + let file = File::open(path)?; + let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file); + let mut hasher = blake3::Hasher::new(); + let mut buffer = vec![0u8; READ_BUFFER_BYTES]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finalize().to_hex().to_string()) +} + +/// BLAKE3 of in-memory bytes, as 64 lowercase hex characters. +pub fn hash_bytes_blake3(bytes: &[u8]) -> String { + blake3::hash(bytes).to_hex().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Known-answer test vector: blake3 of the empty input. + const EMPTY_BLAKE3: &str = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"; + + #[test] + fn known_answers() { + assert_eq!(hash_bytes_blake3(b""), EMPTY_BLAKE3); + assert_eq!( + hash_bytes_blake3(b"hello world"), + "d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24", + ); + } + + #[test] + fn file_and_bytes_agree() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.bin"); + let contents = vec![7u8; 3 * 1024 * 1024 + 17]; // spans several read buffers + std::fs::write(&path, &contents).unwrap(); + assert_eq!(hash_file_blake3(&path).unwrap(), hash_bytes_blake3(&contents)); + + let empty = dir.path().join("empty.bin"); + std::fs::write(&empty, b"").unwrap(); + assert_eq!(hash_file_blake3(&empty).unwrap(), EMPTY_BLAKE3); + } + + #[test] + fn missing_file_errors() { + assert!(hash_file_blake3("/definitely/not/a/real/file").is_err()); + } +} diff --git a/crates/lib/files/file_hashing/src/lib.rs b/crates/lib/files/file_hashing/src/lib.rs new file mode 100644 index 00000000..97ff6704 --- /dev/null +++ b/crates/lib/files/file_hashing/src/lib.rs @@ -0,0 +1,11 @@ +//! Content hashing for local files. +//! +//! BLAKE3 is the house hash for file identity: faster than the disk it +//! reads from, 256-bit, and cryptographically collision-resistant — safe to +//! use as a content address (thumbnail cache keys, upload-dedup ledgers). +//! Column/field naming should carry the family (`file_hash_blake3`) so a +//! second family can coexist later. + +pub mod hash_file; + +pub use hash_file::{hash_bytes_blake3, hash_file_blake3}; diff --git a/crates/lib/files/thumbnails/Cargo.toml b/crates/lib/files/thumbnails/Cargo.toml new file mode 100644 index 00000000..c4c599ea --- /dev/null +++ b/crates/lib/files/thumbnails/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "thumbnails" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "thumbnails" +path = "src/lib.rs" + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[dependencies] +# cargo-hakari workspace hack (faster compile times) +workspace-build-acceleration.workspace = true + +# Internal +images.workspace = true + +# External +log.workspace = true +# MP4 demuxing (pure Rust). +mp4.workspace = true +# H.264 decoding — Cisco's openh264, built from vendored source at compile +# time; no runtime dependency on the customer machine. +openh264.workspace = true +# Animated WebP encoding — vendored libwebp, same no-runtime-deps property. +webp-animation.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/lib/files/thumbnails/src/error.rs b/crates/lib/files/thumbnails/src/error.rs new file mode 100644 index 00000000..4ddfa3e6 --- /dev/null +++ b/crates/lib/files/thumbnails/src/error.rs @@ -0,0 +1,41 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum ThumbnailError { + Io(std::io::Error), + + /// The file isn't a container/codec we can read (e.g. webm, HEVC). The + /// caller should show a placeholder, not retry. + Unsupported(String), + + /// The container parsed but no decodable frame came out. + Decode(String), + + Encode(String), +} + +impl ThumbnailError { + pub fn is_unsupported(&self) -> bool { + matches!(self, Self::Unsupported(_)) + } +} + +impl Error for ThumbnailError {} + +impl Display for ThumbnailError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "Thumbnail I/O error: {}", err), + Self::Unsupported(message) => write!(f, "Unsupported media for thumbnailing: {}", message), + Self::Decode(message) => write!(f, "Could not decode media: {}", message), + Self::Encode(message) => write!(f, "Could not encode thumbnail: {}", message), + } + } +} + +impl From for ThumbnailError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} diff --git a/crates/lib/files/thumbnails/src/image_thumbnail.rs b/crates/lib/files/thumbnails/src/image_thumbnail.rs new file mode 100644 index 00000000..bc824ba6 --- /dev/null +++ b/crates/lib/files/thumbnails/src/image_thumbnail.rs @@ -0,0 +1,108 @@ +use std::fs::File; +use std::io::BufWriter; +use std::path::Path; + +use images::image::codecs::jpeg::JpegEncoder; +use images::image::DynamicImage; +use images::resize_preserving_aspect::resize_preserving_aspect; + +use crate::error::ThumbnailError; + +const JPEG_QUALITY: u8 = 80; + +/// Decode an image file, downscale it (aspect-preserving, longest edge = +/// `max_dimension`, never upscaled), and write it as a JPEG. +/// +/// Blocking CPU + I/O — call from `spawn_blocking` in async contexts. +pub fn generate_image_thumbnail, Q: AsRef>( + source: P, + destination_jpg: Q, + max_dimension: u32, +) -> Result<(), ThumbnailError> { + let decoded = images::image::open(source.as_ref()) + .map_err(|err| ThumbnailError::Unsupported(format!("{}: {}", source.as_ref().display(), err)))?; + let thumbnail = downscale(&decoded, max_dimension); + write_jpeg(&thumbnail, destination_jpg.as_ref()) +} + +/// Downscale only; small sources pass through at native size. +pub(crate) fn downscale(decoded: &DynamicImage, max_dimension: u32) -> DynamicImage { + if decoded.width() <= max_dimension && decoded.height() <= max_dimension { + decoded.clone() + } else { + resize_preserving_aspect(decoded, max_dimension, max_dimension, false) + } +} + +/// Write via a temp file + rename so a crash never leaves a half-written +/// cache entry behind. +pub(crate) fn write_jpeg(thumbnail: &DynamicImage, destination: &Path) -> Result<(), ThumbnailError> { + let temp_path = temp_sibling(destination); + { + let file = File::create(&temp_path)?; + let mut encoder = JpegEncoder::new_with_quality(BufWriter::new(file), JPEG_QUALITY); + encoder.encode_image(&thumbnail.to_rgb8()) + .map_err(|err| ThumbnailError::Encode(err.to_string()))?; + } + std::fs::rename(&temp_path, destination)?; + Ok(()) +} + +pub(crate) fn write_bytes_atomically(bytes: &[u8], destination: &Path) -> Result<(), ThumbnailError> { + let temp_path = temp_sibling(destination); + std::fs::write(&temp_path, bytes)?; + std::fs::rename(&temp_path, destination)?; + Ok(()) +} + +fn temp_sibling(destination: &Path) -> std::path::PathBuf { + let mut name = destination.file_name().unwrap_or_default().to_os_string(); + name.push(".tmp"); + destination.with_file_name(name) +} + +#[cfg(test)] +mod tests { + use images::image::RgbImage; + + use super::*; + + #[test] + fn thumbnails_are_downscaled_and_written_atomically() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source.png"); + let wide = DynamicImage::ImageRgb8(RgbImage::from_fn(1024, 512, |x, _| { + images::image::Rgb([(x % 256) as u8, 0, 0]) + })); + wide.save(&source).unwrap(); + + let destination = dir.path().join("thumb.jpg"); + generate_image_thumbnail(&source, &destination, 512).unwrap(); + + let thumbnail = images::image::open(&destination).unwrap(); + assert_eq!(thumbnail.width(), 512); + assert_eq!(thumbnail.height(), 256); + assert!(!dir.path().join("thumb.jpg.tmp").exists()); + } + + #[test] + fn small_images_are_not_upscaled() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("small.png"); + DynamicImage::ImageRgb8(RgbImage::new(100, 60)).save(&source).unwrap(); + + let destination = dir.path().join("thumb.jpg"); + generate_image_thumbnail(&source, &destination, 512).unwrap(); + let thumbnail = images::image::open(&destination).unwrap(); + assert_eq!((thumbnail.width(), thumbnail.height()), (100, 60)); + } + + #[test] + fn non_images_are_unsupported() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("not_an_image.bin"); + std::fs::write(&source, b"definitely not pixels").unwrap(); + let err = generate_image_thumbnail(&source, dir.path().join("t.jpg"), 512).unwrap_err(); + assert!(err.is_unsupported()); + } +} diff --git a/crates/lib/files/thumbnails/src/lib.rs b/crates/lib/files/thumbnails/src/lib.rs new file mode 100644 index 00000000..1357378f --- /dev/null +++ b/crates/lib/files/thumbnails/src/lib.rs @@ -0,0 +1,44 @@ +//! thumbnails +//! +//! Pure-Rust thumbnail generation for local media files: static JPEG +//! thumbnails for images and videos, plus a ~1-second animated WebP preview +//! for videos. Video support covers H.264 in MP4 — what every current +//! provider emits — via vendored decoders (no ffmpeg, no runtime deps on +//! the customer machine). Anything else returns a typed "unsupported" +//! error for the caller to placeholder. + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] + +pub mod error; +pub mod image_thumbnail; +pub mod mp4_frames; +pub mod video_thumbnail; + +pub use error::ThumbnailError; +pub use image_thumbnail::generate_image_thumbnail; +pub use video_thumbnail::generate_video_thumbnails; + +/// Longest edge of static thumbnails (matches the frontend's +/// `THUMBNAIL_SIZES.LARGE`). +pub const STATIC_THUMBNAIL_MAX_DIMENSION: u32 = 512; + +/// Longest edge of animated previews (kept small; these hold ~24 frames). +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; + +/// Frame cap for the animated preview (~1s at 24fps, plus headroom). +pub const ANIMATED_PREVIEW_MAX_FRAMES: usize = 30; diff --git a/crates/lib/files/thumbnails/src/mp4_frames.rs b/crates/lib/files/thumbnails/src/mp4_frames.rs new file mode 100644 index 00000000..c53b97fb --- /dev/null +++ b/crates/lib/files/thumbnails/src/mp4_frames.rs @@ -0,0 +1,153 @@ +//! Decode the leading frames of an H.264 MP4 into RGB images. +//! +//! `mp4` demuxes the container; `openh264` (vendored Cisco decoder) decodes +//! the access units. Sample payloads are length-prefixed NAL units, so each +//! is rewritten to Annex-B (start codes) with the track's SPS/PPS fed first. + +use std::fs::File; +use std::io::BufReader; +use std::path::Path; + +use images::image::RgbImage; +use mp4::{MediaType, Mp4Reader, TrackType}; +use openh264::decoder::Decoder; + +use crate::error::ThumbnailError; + +const ANNEX_B_START_CODE: [u8; 4] = [0, 0, 0, 1]; + +/// One decoded frame with its presentation time. +pub struct DecodedFrame { + pub pts_ms: u32, + pub image: RgbImage, +} + +/// Decode frames from the start of the file until `max_duration_ms` of +/// presentation time or `max_frames` frames, whichever comes first. Frames +/// are returned sorted by presentation time. +/// +/// Blocking CPU work — call from `spawn_blocking` in async contexts. +pub fn decode_mp4_leading_frames>( + path: P, + max_duration_ms: u32, + max_frames: usize, +) -> Result, ThumbnailError> { + let file = File::open(path.as_ref())?; + let size = file.metadata()?.len(); + let mut mp4 = Mp4Reader::read_header(BufReader::new(file), size) + .map_err(|err| ThumbnailError::Unsupported(format!("not a readable MP4: {}", err)))?; + + let (track_id, timescale, nal_length_size, sps, pps) = { + let track = mp4.tracks().values() + .find(|track| { + track.track_type().is_ok_and(|kind| kind == TrackType::Video) + && track.media_type().is_ok_and(|media| media == MediaType::H264) + }) + .ok_or_else(|| ThumbnailError::Unsupported("no H.264 video track".to_string()))?; + + let sps = track.sequence_parameter_set() + .map_err(|err| ThumbnailError::Unsupported(format!("missing SPS: {}", err)))? + .to_vec(); + let pps = track.picture_parameter_set() + .map_err(|err| ThumbnailError::Unsupported(format!("missing PPS: {}", err)))? + .to_vec(); + (track.track_id(), track.timescale(), nal_length_size_for(track), sps, pps) + }; + + let mut decoder = Decoder::new() + .map_err(|err| ThumbnailError::Decode(format!("could not create H.264 decoder: {}", err)))?; + + // Prime the decoder with the parameter sets. + let mut parameter_sets = Vec::with_capacity(sps.len() + pps.len() + 8); + append_annex_b(&mut parameter_sets, &sps); + append_annex_b(&mut parameter_sets, &pps); + let _ = decoder.decode(¶meter_sets); + + let mut frames: Vec = Vec::new(); + let sample_count = mp4.sample_count(track_id) + .map_err(|err| ThumbnailError::Decode(format!("could not count samples: {}", err)))?; + + for sample_id in 1..=sample_count { + if frames.len() >= max_frames { + break; + } + + let sample = match mp4.read_sample(track_id, sample_id) { + Ok(Some(sample)) => sample, + Ok(None) => break, + Err(err) => return Err(ThumbnailError::Decode(format!("could not read sample {}: {}", sample_id, err))), + }; + + // Presentation time: decode time + composition offset. + let pts_units = sample.start_time as i64 + sample.rendering_offset as i64; + 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)?; + + // 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)? }); + } + Ok(None) => {} // Decoder delay; frame not ready yet. + Err(err) => { + // A corrupt tail sample shouldn't discard the frames we have. + log::warn!("H.264 decode error at sample {}: {}", sample_id, err); + break; + } + } + + if pts_ms >= max_duration_ms { + break; + } + } + + if frames.is_empty() { + return Err(ThumbnailError::Decode("no frames decoded".to_string())); + } + + frames.sort_by_key(|frame| frame.pts_ms); + Ok(frames) +} + +/// The avcC NAL length-prefix size (1, 2, or 4 bytes; 4 is ubiquitous). +fn nal_length_size_for(track: &mp4::Mp4Track) -> usize { + track.trak.mdia.minf.stbl.stsd.avc1.as_ref() + .map(|avc1| (avc1.avcc.length_size_minus_one as usize & 0b11) + 1) + .unwrap_or(4) +} + +fn length_prefixed_to_annex_b(sample_bytes: &[u8], length_size: usize) -> Result, ThumbnailError> { + let mut annex_b = Vec::with_capacity(sample_bytes.len() + 16); + let mut offset = 0usize; + while offset + length_size <= sample_bytes.len() { + let mut nal_length = 0usize; + for byte in &sample_bytes[offset..offset + length_size] { + nal_length = (nal_length << 8) | *byte as usize; + } + offset += length_size; + let end = offset.checked_add(nal_length) + .filter(|end| *end <= sample_bytes.len()) + .ok_or_else(|| ThumbnailError::Decode("NAL length overruns sample".to_string()))?; + append_annex_b(&mut annex_b, &sample_bytes[offset..end]); + offset = end; + } + Ok(annex_b) +} + +fn append_annex_b(buffer: &mut Vec, nal: &[u8]) { + buffer.extend_from_slice(&ANNEX_B_START_CODE); + buffer.extend_from_slice(nal); +} + +fn yuv_to_rgb(yuv: &openh264::decoder::DecodedYUV<'_>) -> Result { + use openh264::formats::YUVSource; + let (width, height) = yuv.dimensions(); + let mut rgb = vec![0u8; width * height * 3]; + yuv.write_rgb8(&mut rgb); + RgbImage::from_raw(width as u32, height as u32, rgb) + .ok_or_else(|| ThumbnailError::Decode("YUV → RGB buffer size mismatch".to_string())) +} diff --git a/crates/lib/files/thumbnails/src/video_thumbnail.rs b/crates/lib/files/thumbnails/src/video_thumbnail.rs new file mode 100644 index 00000000..55cd4849 --- /dev/null +++ b/crates/lib/files/thumbnails/src/video_thumbnail.rs @@ -0,0 +1,131 @@ +//! Video thumbnails: one decode pass over the leading ~1s 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 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, +}; + +#[derive(Debug)] +pub struct VideoThumbnailOutcome { + pub wrote_static_thumbnail: bool, + pub wrote_animated_preview: bool, +} + +/// 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. +/// +/// The animated preview is best-effort: if its encode fails, the static +/// thumbnail still lands and the outcome reports the difference. +/// +/// Blocking CPU work — call from `spawn_blocking` in async contexts. +pub fn generate_video_thumbnails, Q: AsRef, R: AsRef>( + source: P, + destination_jpg: Q, + destination_webp: R, +) -> Result { + let frames = decode_mp4_leading_frames( + source.as_ref(), + ANIMATED_PREVIEW_MAX_DURATION_MS, + ANIMATED_PREVIEW_MAX_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) { + Ok(webp_bytes) => { + write_bytes_atomically(&webp_bytes, destination_webp.as_ref())?; + true + } + Err(err) => { + log::warn!("Animated preview encode failed for {}: {}", source.as_ref().display(), err); + false + } + }; + + Ok(VideoThumbnailOutcome { wrote_static_thumbnail: true, wrote_animated_preview }) +} + +fn encode_animated_webp(frames: &[DecodedFrame]) -> Result, ThumbnailError> { + let scaled: Vec<(u32, DynamicImage)> = frames.iter() + .map(|frame| { + let image = DynamicImage::ImageRgb8(frame.image.clone()); + (frame.pts_ms, downscale(&image, ANIMATED_PREVIEW_MAX_DIMENSION)) + }) + .collect(); + + let (width, height) = (scaled[0].1.width(), scaled[0].1.height()); + let mut encoder = Encoder::new((width, height)) + .map_err(|err| ThumbnailError::Encode(format!("webp encoder: {}", err)))?; + + // Normalize timestamps so the preview starts at 0 even if the stream's + // first pts doesn't. + let first_pts = scaled[0].0; + let mut last_timestamp_ms: i32 = 0; + for (pts_ms, image) in &scaled { + last_timestamp_ms = pts_ms.saturating_sub(first_pts) as i32; + let rgba = image.to_rgba8(); + encoder.add_frame(rgba.as_raw(), last_timestamp_ms) + .map_err(|err| ThumbnailError::Encode(format!("webp frame: {}", err)))?; + } + + // Close the last frame with a plausible display duration. + let average_frame_ms = if scaled.len() > 1 { + (last_timestamp_ms / (scaled.len() as i32 - 1)).max(1) + } else { + 500 + }; + let webp_data = encoder.finalize(last_timestamp_ms + average_frame_ms) + .map_err(|err| ThumbnailError::Encode(format!("webp finalize: {}", err)))?; + Ok(webp_data.to_vec()) +} + +#[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", + ); + + #[test] + fn h264_mp4_produces_static_and_animated_thumbnails() { + let dir = tempfile::tempdir().unwrap(); + let jpg = dir.path().join("thumb.jpg"); + let webp = dir.path().join("preview.webp"); + + let outcome = generate_video_thumbnails(H264_FIXTURE, &jpg, &webp).unwrap(); + assert!(outcome.wrote_static_thumbnail); + assert!(outcome.wrote_animated_preview); + + let thumbnail = images::image::open(&jpg).unwrap(); + assert!(thumbnail.width() > 0 && thumbnail.height() > 0); + assert!(thumbnail.width() <= 512 && thumbnail.height() <= 512); + + let webp_bytes = std::fs::read(&webp).unwrap(); + assert!(webp_bytes.len() > 12); + assert_eq!(&webp_bytes[0..4], b"RIFF"); + assert_eq!(&webp_bytes[8..12], b"WEBP"); + } + + #[test] + fn non_mp4_is_unsupported() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("not_a_video.mp4"); + std::fs::write(&source, b"this is not an mp4 file at all").unwrap(); + let err = generate_video_thumbnails(&source, dir.path().join("t.jpg"), dir.path().join("p.webp")) + .unwrap_err(); + assert!(err.is_unsupported()); + } +} diff --git a/crates/schema/local_files_database/Cargo.toml b/crates/schema/local_files_database/Cargo.toml new file mode 100644 index 00000000..bdce66e8 --- /dev/null +++ b/crates/schema/local_files_database/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "local_files_database" +edition = "2024" +version = "0.0.1" +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "local_files_database" +path = "src/lib.rs" + +[dependencies] +# cargo-hakari workspace hack (faster compile times) +workspace-build-acceleration.workspace = true + +# Internal +errors.workspace = true + +# External +chrono.workspace = true +log.workspace = true + +# NB: Unlike `sqlite_database`, this crate uses RUNTIME queries (no sqlx +# compile-time macros). The workspace's `.sqlx` offline data and the single +# global DATABASE_URL can only describe one schema; runtime queries keep this +# second database free of the `cargo sqlx prepare` dance. +sqlx = { package = "sqlx", version = "0.7.4", features = ["runtime-tokio-rustls", "sqlite", "chrono"] } + +[dev-dependencies] +# Integration tests run queries against a throwaway migrated database. +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/schema/local_files_database/migrations/20260901000000_create_local_files.sql b/crates/schema/local_files_database/migrations/20260901000000_create_local_files.sql new file mode 100644 index 00000000..eb01d4e7 --- /dev/null +++ b/crates/schema/local_files_database/migrations/20260901000000_create_local_files.sql @@ -0,0 +1,36 @@ +-- The content-hash index of local files the app has seen. +-- +-- One row per absolute file path. `file_size_bytes` + `file_mtime_ms` exist +-- so lookups can trust the cached hash without re-reading the file: if the +-- stat matches, the hash is current; if not, rehash and update. +-- +-- `has_thumbnail` / `has_animated_preview` are hints — the thumbnail cache +-- directory on disk is the source of truth (files are keyed by +-- `{file_hash_blake3}.jpg` / `.webp` and regenerate if missing). + +CREATE TABLE local_files ( + -- Absolute local file path. + file_path TEXT PRIMARY KEY NOT NULL, + + -- Lowercase extension-ish type: "mp4", "png", "jpg", ... + file_type TEXT, + + -- Stat fields captured when the hash was computed (invalidation). + file_size_bytes INTEGER NOT NULL, + file_mtime_ms INTEGER NOT NULL, + + -- BLAKE3 content hash, 64 lowercase hex characters. + file_hash_blake3 TEXT NOT NULL, + + -- A static thumbnail jpg exists in the cache for this hash. + has_thumbnail INTEGER NOT NULL DEFAULT 0, + + -- A ~1s animated preview webp exists in the cache for this hash. + has_animated_preview INTEGER NOT NULL DEFAULT 0, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Content-addressed lookups (dedup; the future upload ledger joins on this). +CREATE INDEX idx_local_files_hash_blake3 ON local_files (file_hash_blake3); diff --git a/crates/schema/local_files_database/src/connection.rs b/crates/schema/local_files_database/src/connection.rs new file mode 100644 index 00000000..5a70f7f3 --- /dev/null +++ b/crates/schema/local_files_database/src/connection.rs @@ -0,0 +1,66 @@ +use std::path::Path; +use std::time::Duration; + +use errors::AnyhowResult; +use log::{error, info}; +use sqlx::migrate::MigrateError; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous}; +use sqlx::{Pool, Sqlite, SqlitePool}; + +/// How long a writer waits for the lock before erroring. Thumbnail +/// generation writes in bursts; without this SQLite returns SQLITE_BUSY +/// immediately. +const BUSY_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone)] +pub struct LocalFilesDbConnection { + pool: Pool, +} + +impl LocalFilesDbConnection { + /// Open (creating if missing) and migrate the database. + /// + /// On migration failure the file is deleted and recreated — acceptable + /// while every row is derivable from the filesystem (hashes and thumbnail + /// flags regenerate on demand). Revisit before storing anything + /// irreplaceable here (e.g. the upload-dedup ledger). + pub async fn connect_and_migrate>(database_file: P) -> AnyhowResult { + match run_migrations(&database_file).await { + Ok(pool) => return Ok(Self { pool }), + Err(err) => { + error!("Error running local_files SQLite migrations: {:?}", err); + } + } + + info!("Deleting and recreating local_files SQLite database at {:?}", database_file.as_ref()); + + if let Err(err) = std::fs::remove_file(&database_file) { + error!("Error deleting local_files SQLite database file: {:?}", err); + } + + let pool = run_migrations(database_file).await?; + + Ok(Self { pool }) + } + + pub fn get_pool(&self) -> &Pool { + &self.pool + } +} + +async fn run_migrations>(database_file: P) -> Result { + let connection_options = SqliteConnectOptions::new() + .filename(database_file) + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .busy_timeout(BUSY_TIMEOUT); + + let pool = SqlitePool::connect_with(connection_options).await?; + + // Additive migrations against a stable filename; sqlx tracks which have + // run. The SQL is compiled into the binary. + sqlx::migrate!().run(&pool).await?; + + Ok(pool) +} diff --git a/crates/schema/local_files_database/src/error.rs b/crates/schema/local_files_database/src/error.rs new file mode 100644 index 00000000..954856a5 --- /dev/null +++ b/crates/schema/local_files_database/src/error.rs @@ -0,0 +1,23 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum LocalFilesDbError { + SqlxError(sqlx::Error), +} + +impl Error for LocalFilesDbError {} + +impl Display for LocalFilesDbError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + LocalFilesDbError::SqlxError(err) => write!(f, "SQLx error: {:?}", err), + } + } +} + +impl From for LocalFilesDbError { + fn from(err: sqlx::Error) -> Self { + LocalFilesDbError::SqlxError(err) + } +} diff --git a/crates/schema/local_files_database/src/lib.rs b/crates/schema/local_files_database/src/lib.rs new file mode 100644 index 00000000..01c1f377 --- /dev/null +++ b/crates/schema/local_files_database/src/lib.rs @@ -0,0 +1,28 @@ +//! local_files_database +//! +//! A content-hash index of local files the app has seen, separate from the +//! tasks database on purpose: this is a rebuildable index (thumbnail +//! accounting today; an (account, checksum) upload-dedup ledger later), +//! with its own write patterns, lock domain, and lifecycle. The tasks +//! database is irreplaceable history; this one can be nuked and re-derived. +//! +//! Unlike the tasks database it keeps a STABLE filename with additive +//! migrations — no version-bumped filenames. + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] + +pub mod connection; +pub mod error; +pub mod queries; diff --git a/crates/schema/local_files_database/src/queries/get_local_file_by_path.rs b/crates/schema/local_files_database/src/queries/get_local_file_by_path.rs new file mode 100644 index 00000000..782d95ab --- /dev/null +++ b/crates/schema/local_files_database/src/queries/get_local_file_by_path.rs @@ -0,0 +1,16 @@ +use crate::connection::LocalFilesDbConnection; +use crate::error::LocalFilesDbError; +use crate::queries::local_file_record::LocalFileRecord; + +pub async fn get_local_file_by_path( + db: &LocalFilesDbConnection, + file_path: &str, +) -> Result, LocalFilesDbError> { + let record = sqlx::query_as::<_, LocalFileRecord>( + "SELECT * FROM local_files WHERE file_path = ?1", + ) + .bind(file_path) + .fetch_optional(db.get_pool()) + .await?; + Ok(record) +} diff --git a/crates/schema/local_files_database/src/queries/get_local_files_by_hash.rs b/crates/schema/local_files_database/src/queries/get_local_files_by_hash.rs new file mode 100644 index 00000000..0d39bcc1 --- /dev/null +++ b/crates/schema/local_files_database/src/queries/get_local_files_by_hash.rs @@ -0,0 +1,18 @@ +use crate::connection::LocalFilesDbConnection; +use crate::error::LocalFilesDbError; +use crate::queries::local_file_record::LocalFileRecord; + +/// Every known path with this content hash (dedup lookups; the future +/// upload-dedup ledger keys on this). +pub async fn get_local_files_by_hash( + db: &LocalFilesDbConnection, + file_hash_blake3: &str, +) -> Result, LocalFilesDbError> { + let records = sqlx::query_as::<_, LocalFileRecord>( + "SELECT * FROM local_files WHERE file_hash_blake3 = ?1 ORDER BY file_path", + ) + .bind(file_hash_blake3) + .fetch_all(db.get_pool()) + .await?; + Ok(records) +} diff --git a/crates/schema/local_files_database/src/queries/local_file_record.rs b/crates/schema/local_files_database/src/queries/local_file_record.rs new file mode 100644 index 00000000..279135b2 --- /dev/null +++ b/crates/schema/local_files_database/src/queries/local_file_record.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, Utc}; + +/// One row of `local_files`. +#[derive(Clone, Debug, sqlx::FromRow)] +pub struct LocalFileRecord { + pub file_path: String, + pub file_type: Option, + pub file_size_bytes: i64, + pub file_mtime_ms: i64, + pub file_hash_blake3: String, + pub has_thumbnail: bool, + pub has_animated_preview: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl LocalFileRecord { + /// Whether the cached hash is still current for a file with this stat. + 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 + } +} diff --git a/crates/schema/local_files_database/src/queries/mod.rs b/crates/schema/local_files_database/src/queries/mod.rs new file mode 100644 index 00000000..765be25a --- /dev/null +++ b/crates/schema/local_files_database/src/queries/mod.rs @@ -0,0 +1,5 @@ +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 upsert_local_file; diff --git a/crates/schema/local_files_database/src/queries/set_thumbnail_flags.rs b/crates/schema/local_files_database/src/queries/set_thumbnail_flags.rs new file mode 100644 index 00000000..584ee9b3 --- /dev/null +++ b/crates/schema/local_files_database/src/queries/set_thumbnail_flags.rs @@ -0,0 +1,28 @@ +use chrono::Utc; + +use crate::connection::LocalFilesDbConnection; +use crate::error::LocalFilesDbError; + +/// Record which cache artifacts exist for a path. Returns whether a row was +/// updated. +pub async fn set_thumbnail_flags( + db: &LocalFilesDbConnection, + file_path: &str, + has_thumbnail: bool, + has_animated_preview: bool, +) -> Result { + let result = sqlx::query( + r#" + UPDATE local_files + SET has_thumbnail = ?2, has_animated_preview = ?3, updated_at = ?4 + WHERE file_path = ?1 + "#, + ) + .bind(file_path) + .bind(has_thumbnail) + .bind(has_animated_preview) + .bind(Utc::now()) + .execute(db.get_pool()) + .await?; + Ok(result.rows_affected() > 0) +} diff --git a/crates/schema/local_files_database/src/queries/upsert_local_file.rs b/crates/schema/local_files_database/src/queries/upsert_local_file.rs new file mode 100644 index 00000000..27404a7f --- /dev/null +++ b/crates/schema/local_files_database/src/queries/upsert_local_file.rs @@ -0,0 +1,49 @@ +use chrono::Utc; + +use crate::connection::LocalFilesDbConnection; +use crate::error::LocalFilesDbError; + +pub struct UpsertLocalFileArgs<'a> { + pub db: &'a LocalFilesDbConnection, + pub file_path: &'a str, + pub maybe_file_type: Option<&'a str>, + pub file_size_bytes: i64, + pub file_mtime_ms: i64, + pub file_hash_blake3: &'a str, +} + +/// 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. +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) + ON CONFLICT(file_path) DO UPDATE SET + file_type = excluded.file_type, + file_size_bytes = excluded.file_size_bytes, + file_mtime_ms = excluded.file_mtime_ms, + file_hash_blake3 = excluded.file_hash_blake3, + has_thumbnail = CASE + WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 + THEN local_files.has_thumbnail ELSE 0 END, + has_animated_preview = CASE + WHEN local_files.file_hash_blake3 = excluded.file_hash_blake3 + THEN local_files.has_animated_preview ELSE 0 END, + updated_at = excluded.updated_at + "#, + ) + .bind(args.file_path) + .bind(args.maybe_file_type) + .bind(args.file_size_bytes) + .bind(args.file_mtime_ms) + .bind(args.file_hash_blake3) + .bind(now) + .execute(args.db.get_pool()) + .await?; + Ok(()) +} diff --git a/crates/schema/local_files_database/tests/local_file_queries.rs b/crates/schema/local_files_database/tests/local_file_queries.rs new file mode 100644 index 00000000..d3fd7e16 --- /dev/null +++ b/crates/schema/local_files_database/tests/local_file_queries.rs @@ -0,0 +1,85 @@ +//! Integration tests against a throwaway migrated database in a tempdir. + +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::upsert_local_file::{upsert_local_file, UpsertLocalFileArgs}; + +const HASH_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const HASH_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +async fn test_db(dir: &tempfile::TempDir) -> LocalFilesDbConnection { + LocalFilesDbConnection::connect_and_migrate(dir.path().join("local_files.sqlite")) + .await + .expect("connect and migrate") +} + +async fn upsert(db: &LocalFilesDbConnection, path: &str, size: i64, mtime: i64, hash: &str) { + upsert_local_file(UpsertLocalFileArgs { + db, + file_path: path, + maybe_file_type: Some("mp4"), + file_size_bytes: size, + file_mtime_ms: mtime, + file_hash_blake3: hash, + }) + .await + .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)); +} + +#[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"); +} diff --git a/frontend/apps/artcraft/app/src/components/PromptShell/useComposerTasks.ts b/frontend/apps/artcraft/app/src/components/PromptShell/useComposerTasks.ts index 947d32f7..4c95bb8a 100644 --- a/frontend/apps/artcraft/app/src/components/PromptShell/useComposerTasks.ts +++ b/frontend/apps/artcraft/app/src/components/PromptShell/useComposerTasks.ts @@ -120,7 +120,8 @@ async function expandTaskFiles( const provider = t.provider ? String(t.provider) : undefined; const primary: CompletedFile = { id: t.id, - url: t.completed_item!.primary_media_file.cdn_url, + // Callers filter on cdn_url presence before expanding (line ~57). + url: t.completed_item!.primary_media_file!.cdn_url, provider, }; diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx index 5a96412d..cf8682b1 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx @@ -23,6 +23,7 @@ import { import type { GalleryItem } from "@storyteller/ui-gallery-modal"; import { useEffect, useMemo, useRef, useState } from "react"; import { + GetLocalThumbnails, GetTaskQueue, MarkTaskAsDismissed, OpenLocalFile, @@ -31,7 +32,8 @@ import { getFileManagerName, localFileBasename, } from "@storyteller/tauri-api"; -import type { TaskQueueItem } from "@storyteller/tauri-api"; +import type { LocalThumbnail, TaskQueueItem } from "@storyteller/tauri-api"; +import { convertFileSrc } from "@tauri-apps/api/core"; import { listen, UnlistenFn } from "@tauri-apps/api/event"; import { useSelectedImageModel, @@ -57,6 +59,28 @@ import { useCreditsState } from "@storyteller/credits"; import { getMetaForTask, cleanupOldEntries } from "./taskEnqueueMeta"; import { twMerge } from "tailwind-merge"; +// Backend-generated thumbnails for local result files, keyed by source +// path. Module-level (like coverImageCache) so results survive re-mounts; +// entries are content-addressed on the backend and never go stale. +const localThumbnailCache = new Map(); +const localThumbnailPending = new Set(); + +const localStaticThumbnailUrl = (task: { + downloadedFilePath?: string; +}): string | undefined => { + const thumb = task.downloadedFilePath + ? localThumbnailCache.get(task.downloadedFilePath) + : undefined; + return thumb?.maybe_thumbnail_path + ? convertFileSrc(thumb.maybe_thumbnail_path) + : undefined; +}; + +const localFileAssetUrl = (task: { + downloadedFilePath?: string; +}): string | undefined => + task.downloadedFilePath ? convertFileSrc(task.downloadedFilePath) : undefined; + type InProgressTask = { id: string; title: string; @@ -363,6 +387,24 @@ 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. + 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) + : undefined; + const displayedThumbnailUrl = + isPreviewHovered && animatedPreviewUrl + ? animatedPreviewUrl + : staticThumbnailUrl; return (
setIsPreviewHovered(true)} + onMouseLeave={() => setIsPreviewHovered(false)} > - {task.thumbnailUrl ? ( + {displayedThumbnailUrl ? ( {task.title} { e.currentTarget.src = getPlaceholderForMediaClass( @@ -395,7 +439,7 @@ const CompletedCard = ({ e.currentTarget.style.opacity = "0.3"; // Set the `data-brokenurl` property for debugging the broken images: (e.currentTarget as HTMLImageElement).dataset.brokenurl = - task.thumbnailUrl; + displayedThumbnailUrl; }} className="h-full w-full object-cover" /> @@ -595,6 +639,31 @@ export const TaskQueue = () => { const [isModalOpen, setModalOpen] = useState(false); const [inProgress, setInProgress] = useState([]); const [completed, setCompleted] = useState([]); + // Bumped when backend-generated local thumbnails arrive (the cache itself + // 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. + useEffect(() => { + const wanted = completed + .filter((t) => !t.thumbnailUrl && t.downloadedFilePath) + .map((t) => t.downloadedFilePath!) + .filter( + (p) => !localThumbnailCache.has(p) && !localThumbnailPending.has(p), + ); + if (wanted.length === 0) return; + wanted.forEach((p) => localThumbnailPending.add(p)); + GetLocalThumbnails(wanted) + .then((thumbs) => { + for (const thumb of thumbs) { + localThumbnailCache.set(thumb.file_path, thumb); + } + setLocalThumbnailVersion((v) => v + 1); + }) + .catch((err) => console.error("GetLocalThumbnails failed:", err)) + .finally(() => wanted.forEach((p) => localThumbnailPending.delete(p))); + }, [completed]); const [failed, setFailed] = useState([]); const [lastReadAt, setLastReadAt] = useState(() => { const stored = localStorage.getItem("taskQueueLastReadAt"); @@ -889,10 +958,9 @@ export const TaskQueue = () => { const failureReason = fr ? FAILURE_REASON_LABEL[fr.failure_type] || undefined : undefined; - const failureMessage = - fr?.failure_message && fr.failure_type !== "unknown" - ? fr.failure_message - : undefined; + // Backend failure messages are curated, user-facing copy — + // show them whenever present, whatever the failure type. + const failureMessage = fr?.failure_message || undefined; const meta = getMetaForTask( t.id, t.model_type ? String(t.model_type) : undefined, @@ -1185,8 +1253,14 @@ export const TaskQueue = () => { const item: GalleryItem = { id: firstMediaToken, label: t.title, - thumbnail: t.thumbnailUrl || null, - fullImage: t.imageUrls?.[0] || null, + thumbnail: + t.thumbnailUrl || + localStaticThumbnailUrl(t) || + null, + fullImage: + t.imageUrls?.[0] || + localFileAssetUrl(t) || + null, createdAt: ( t.completedAt || new Date() ).toISOString(), @@ -1329,8 +1403,12 @@ export const TaskQueue = () => { const item: GalleryItem = { id: t.id, label: t.title, - thumbnail: t.thumbnailUrl || null, - fullImage: t.imageUrls?.[0] || null, + thumbnail: + t.thumbnailUrl || + localStaticThumbnailUrl(t) || + null, + fullImage: + t.imageUrls?.[0] || localFileAssetUrl(t) || null, createdAt: ( t.completedAt || new Date() ).toISOString(), diff --git a/frontend/libs/tauri-api/src/index.ts b/frontend/libs/tauri-api/src/index.ts index e560bc7a..7c3220ea 100644 --- a/frontend/libs/tauri-api/src/index.ts +++ b/frontend/libs/tauri-api/src/index.ts @@ -22,6 +22,7 @@ export * from "./lib/generate/models/image/ListImageModels.js"; export * from "./lib/generate/models/mesh/ListMeshModels.js"; export * from "./lib/generate/models/splat/ListSplatModels.js"; export * from "./lib/generate/models/video/ListVideoModels.js"; +export * from "./lib/local_files/GetLocalThumbnails.js"; export * from "./lib/events/BasicEventWrapper.js"; export * from "./lib/fal/FalBackgroundRemoval.js"; export * from "./lib/fal/FalHunyuanImageTo3d.js"; diff --git a/frontend/libs/tauri-api/src/lib/artcraft/task_queue/GetTaskQueue.ts b/frontend/libs/tauri-api/src/lib/artcraft/task_queue/GetTaskQueue.ts index e2eec8f3..742d10f9 100644 --- a/frontend/libs/tauri-api/src/lib/artcraft/task_queue/GetTaskQueue.ts +++ b/frontend/libs/tauri-api/src/lib/artcraft/task_queue/GetTaskQueue.ts @@ -34,7 +34,10 @@ export interface TaskQueueItem { } export interface TaskQueueCompletedItem { - primary_media_file: MediaFileData; + // Present only when the result was uploaded to ArtCraft (logged-in + // session). Local-only completions carry download paths but no cloud + // media file. + primary_media_file?: MediaFileData; media_file_class?: TaskMediaFileClass; maybe_batch_token?: string; // If the results were downloaded: the directory and the first (or only) @@ -65,7 +68,7 @@ export const GetTaskQueue = async (): Promise => { const newTasks: TaskQueueItem[] = tasks.map((task) => { const completed_item = task.completed_item; - if (completed_item) { + if (completed_item?.primary_media_file) { completed_item.primary_media_file.created_at = new Date( completed_item.primary_media_file.created_at ); diff --git a/frontend/libs/tauri-api/src/lib/local_files/GetLocalThumbnails.ts b/frontend/libs/tauri-api/src/lib/local_files/GetLocalThumbnails.ts new file mode 100644 index 00000000..5b8bb35e --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/local_files/GetLocalThumbnails.ts @@ -0,0 +1,43 @@ +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(...)`. + */ + +export type LocalThumbnailStatus = + | "ready" + | "unsupported" + | "missing_file" + | "failed"; + +export interface LocalThumbnail { + file_path: string; + status: LocalThumbnailStatus; + maybe_file_hash?: string; + /** Absolute path of the static JPEG thumbnail (512px longest edge). */ + maybe_thumbnail_path?: string; + /** Absolute path of the ~1s animated WebP preview (videos only). */ + maybe_animated_preview_path?: string; +} + +interface GetLocalThumbnailsResponse { + thumbnails: LocalThumbnail[]; +} + +interface GetLocalThumbnailsSuccess extends CommandResult { + payload: GetLocalThumbnailsResponse; +} + +export const GetLocalThumbnails = async ( + filePaths: string[], +): Promise => { + if (filePaths.length === 0) return []; + const result = (await invoke("get_local_thumbnails_command", { + request: { file_paths: filePaths }, + })) as GetLocalThumbnailsSuccess; + return result?.payload?.thumbnails ?? []; +};