diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index e762c79..f050993 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -9,7 +9,7 @@ use super::Db; use crate::Result; /// Current schema version. Bump on every forward migration added below. -pub(super) const SCHEMA_VERSION: i64 = 31; +pub(super) const SCHEMA_VERSION: i64 = 32; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -270,6 +270,20 @@ impl Db { tx.execute_batch(MIGRATION_V31)?; } } + if current < 32 { + // Data only, so safe to repeat; each table is guarded for a very + // old fixture that never had it. + for table in ["photos", "album_photos"] { + let has_table: bool = tx.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |row| row.get::<_, i64>(0), + )? > 0; + if has_table { + tx.execute(&MIGRATION_V32.replace("{table}", table), [])?; + } + } + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -952,3 +966,10 @@ ALTER TABLE photos ADD COLUMN resolved_at INTEGER; CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at); UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL; "; + +/// V32: give videos written off as un-thumbnailable another chance. +/// +/// Before local video thumbnails, the fallback fed a video's bytes to the image +/// decoder, which could only fail, and that failure was recorded as the +/// permanent "no thumbnail" verdict. Run once per table in `{table}`. +const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2"; diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 8c77f4b..ec66b33 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -4235,6 +4235,48 @@ fn the_servers_photo_relation_groups_a_live_photo() { /// A database written before grouping existed opens, keeps its timeline, and /// shows every photo — a row with no `group_key` is its own group, which is how /// the gallery behaved before the column. +#[test] +fn migration_v32_retries_videos_written_off_as_unthumbnailable() { + let path = std::env::temp_dir().join(format!( + "pdfs-db-v31-fixture-{}-{}.db", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + { + let db = Db::open(&path).unwrap(); + db.photos_replace(&[ + TimelineRow { + name: Some("VID-20260804-WA0023.mp4".into()), + ..TimelineRow::new("video", 300) + }, + TimelineRow { + name: Some("broken.jpg".into()), + ..TimelineRow::new("photo", 200) + }, + ]) + .unwrap(); + db.photo_set_thumb("video", THUMB_NONE, None).unwrap(); + db.photo_set_thumb("photo", THUMB_NONE, None).unwrap(); + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("UPDATE sync_state SET value = '31' WHERE key = 'schema_version';") + .unwrap(); + } + + let db = Db::open(&path).unwrap(); + let state = |uid: &str| db.photos_by_uid(&[uid.into()]).unwrap()[0].thumb_state; + assert_eq!(state("video"), THUMB_UNKNOWN, "the video is tried again"); + assert_eq!( + state("photo"), + THUMB_NONE, + "an undecodable photo stays written off" + ); + drop(db); + let _ = std::fs::remove_file(&path); +} + #[test] fn migration_v30_leaves_a_v29_timeline_intact() { let path = std::env::temp_dir().join(format!( diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 2cc44ef..66f51e3 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -945,6 +945,7 @@ impl Core { if !meta.complete { return Err("queued create holds an incomplete blob".into()); } + let thumbnails = self.upload_thumbnails(Path::new(blob), name); let guard = self .transfers .begin(name, op.uid.clone(), TransferDirection::Upload, meta.len); @@ -955,7 +956,7 @@ impl Core { media_type_for(name), reader, meta.len as i64, - Vec::new(), + thumbnails, None, false, ))?; @@ -1145,6 +1146,7 @@ impl Core { } let alt = conflict_name(&name, now_secs()); + let thumbnails = self.upload_thumbnails(blob, &alt); let guard = self.transfers .begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len); @@ -1155,7 +1157,7 @@ impl Core { media_type_for(&alt), reader, meta.len as i64, - Vec::new(), + thumbnails, None, false, ))?; @@ -1438,6 +1440,7 @@ impl Core { } let name = self.node_name(&uid); + let thumbnails = self.upload_thumbnails(&blob, &name); let guard = self.transfers .begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len); @@ -1451,7 +1454,7 @@ impl Core { &uid, reader, meta.len as i64, - Vec::new(), + thumbnails, None, )); let took = started.elapsed(); diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index f40550d..5b588cd 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -12,8 +12,8 @@ use std::io::{Read as _, Write as _}; use std::os::unix::fs::OpenOptionsExt as _; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::sync::Once; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Once, OnceLock}; use std::time::{Duration, Instant}; use base64::Engine as _; @@ -25,8 +25,8 @@ use pdfs_core::db::{self, StoredPhoto}; use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::NodeUid; -use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType}; -use tracing::{info, warn}; +use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType}; +use tracing::{debug, info, warn}; use super::{ Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority, @@ -55,6 +55,20 @@ const THUMB_CANCEL_POLL: Duration = Duration::from_millis(100); const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20); static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0); static EXIFTOOL_MISSING_WARNING: Once = Once::new(); +static FFMPEG_MISSING_WARNING: Once = Once::new(); +/// Largest video downloaded whole to grab one frame for its thumbnail. The +/// download lands in memory, so a long 4K recording is left without one. +const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024; +/// A single frame is seconds of work even for 4K; this only stops a wedged +/// ffmpeg from holding a thumbnail permit. +const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30); +/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to +/// this, stepping the JPEG quality down until it fits. +const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024; +/// JPEG qualities tried, in order, for an upload thumbnail over that size. +const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20]; +/// Largest still image read whole to render the thumbnail its upload carries. +const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024; /// The aspect ratio (w/h) of an encoded image, read from its header alone — no /// pixels are decoded. `None` when the format is unknown or the header is torn. @@ -90,7 +104,8 @@ struct RawPreviewUnavailable; /// the photo will never have a thumbnail, and only that verdict is persisted. enum ThumbAttempt { Made(GeneratedThumb), - /// Decoded nothing — a format this build has no decoder for. Permanent. + /// Decoded nothing — a format this build has no decoder for, or a video + /// too large to fetch for one frame. Permanent. Undecodable, /// The photo couldn't be downloaded. Transient: try again next time. Unavailable, @@ -196,6 +211,11 @@ fn scale_thumbnail_with_exiftool( } None => return ScaleAttempt::Undecodable, }; + encode_thumb(&image) +} + +/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio. +fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt { let (width, height) = (image.width(), image.height()); if width == 0 || height == 0 { return ScaleAttempt::Undecodable; @@ -268,9 +288,69 @@ impl Drop for RawTempFile { } } -/// Run one bounded exiftool JSON query while draining stdout concurrently. The -/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a -/// pipe buffer long before the child exits. +/// How a bounded helper run failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ToolError { + /// The helper isn't installed. + Missing, + /// It couldn't be started or read, or overran its budget and was killed. + Unavailable, + /// It ran and exited non-zero. + Failed, +} + +/// Run a helper to completion within `timeout` and return its stdout, drained +/// concurrently. The latter matters for exiftool's full-size `JpgFromRaw` +/// fallbacks and for ffmpeg's frames, either of which can exceed a pipe buffer +/// long before the child exits. +fn run_bounded(mut command: Command, timeout: Duration) -> Result, ToolError> { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command.spawn().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + ToolError::Missing + } else { + ToolError::Unavailable + } + })?; + let Some(mut stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err(ToolError::Unavailable); + }; + let reader = std::thread::spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) + }); + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status.success()), + Ok(None) if started.elapsed() < timeout => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + } + }; + let output = reader + .join() + .ok() + .and_then(Result::ok) + .ok_or(ToolError::Unavailable)?; + match status { + Some(true) => Ok(output), + Some(false) => Err(ToolError::Failed), + None => Err(ToolError::Unavailable), + } +} + +/// Run one bounded exiftool JSON query. fn exiftool_query( exiftool: &OsStr, path: &Path, @@ -280,58 +360,172 @@ fn exiftool_query( let mut command = Command::new(exiftool); command.args(["-j", "-b", "-n"]); command.args(tags); - command - .arg(path) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - let mut child = match command.spawn() { - Ok(child) => child, - Err(error) => { - if error.kind() == std::io::ErrorKind::NotFound { - EXIFTOOL_MISSING_WARNING.call_once(|| { - warn!( - "exiftool is not installed; camera RAW thumbnails are unavailable \ - (Debian/Ubuntu: libimage-exiftool-perl)" - ); - }); - } - return Err(RawPreviewUnavailable); + command.arg(path); + let output = run_bounded(command, timeout).map_err(|error| { + if error == ToolError::Missing { + EXIFTOOL_MISSING_WARNING.call_once(|| { + warn!( + "exiftool is not installed; camera RAW thumbnails are unavailable \ + (Debian/Ubuntu: libimage-exiftool-perl)" + ); + }); } - }; - let Some(mut stdout) = child.stdout.take() else { - let _ = child.kill(); - let _ = child.wait(); - return Err(RawPreviewUnavailable); - }; - let reader = std::thread::spawn(move || { - let mut output = Vec::new(); - stdout.read_to_end(&mut output).map(|_| output) - }); - let started = Instant::now(); - let success = loop { - match child.try_wait() { - Ok(Some(status)) => break status.success(), - Ok(None) if started.elapsed() < timeout => { - std::thread::sleep(Duration::from_millis(10)); - } - Ok(None) | Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - break false; - } - } - }; - let output = reader - .join() - .ok() - .and_then(Result::ok) - .ok_or(RawPreviewUnavailable)?; - if !success { - return Err(RawPreviewUnavailable); - } + RawPreviewUnavailable + })?; serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable) } +/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather +/// than a picture the image decoder can scale. +fn is_video_name(name: &str) -> bool { + PhotoKind::classify(Some(name), None) == PhotoKind::Video +} + +/// Whether ffmpeg can be run at all. Asked once, before a video is downloaded: +/// without ffmpeg every scroll past a video would fetch it whole for nothing. +fn ffmpeg_available() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| { + let mut command = Command::new("ffmpeg"); + command.arg("-version"); + let available = run_bounded(command, Duration::from_secs(5)).is_ok(); + if !available { + warn_ffmpeg_missing(); + } + available + }) +} + +fn warn_ffmpeg_missing() { + FFMPEG_MISSING_WARNING.call_once(|| { + warn!("ffmpeg is not installed; video thumbnails are unavailable"); + }); +} + +/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a +/// staging copy. See [`video_file_thumbnail`]. +fn video_thumbnail( + bytes: &[u8], + name: &str, + staging_dir: &Path, + ffmpeg: &OsStr, + timeout: Duration, +) -> ScaleAttempt { + let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else { + return ScaleAttempt::Unavailable; + }; + video_file_thumbnail(&file.0, ffmpeg, timeout) +} + +/// A thumbnail from one frame of the video at `path`. A second in, past the +/// black lead-in most clips open on, and the first frame for a clip shorter than +/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out +/// upright. +/// +/// Missing or wedged ffmpeg is retryable; a file ffmpeg can take no frame from +/// is the permanent verdict. +fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> ScaleAttempt { + for seek in ["1", "0"] { + let mut command = Command::new(ffmpeg); + command.args(["-nostdin", "-loglevel", "error", "-ss", seek, "-i"]); + command.arg(path); + command.args(["-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-"]); + match run_bounded(command, timeout) { + Ok(frame) if !frame.is_empty() => { + return match image::load_from_memory(&frame) { + Ok(image) => encode_thumb(&image), + Err(_) => ScaleAttempt::Undecodable, + }; + } + // No frame that far in: try from the start. + Ok(_) | Err(ToolError::Failed) => continue, + Err(ToolError::Missing) => { + warn_ffmpeg_missing(); + return ScaleAttempt::Unavailable; + } + Err(ToolError::Unavailable) => return ScaleAttempt::Unavailable, + } + } + ScaleAttempt::Undecodable +} + +/// The thumbnail to attach to an upload of the local file at `path`, which is +/// named `name` in Drive. Without one, Drive's own apps show a generic icon and +/// the gallery has to download the whole file to make its own. +/// +/// Best effort: a file that isn't an image or a video, is too large to read, or +/// can't be decoded uploads without a thumbnail, as it always did. Blocking and +/// CPU-bound, so async callers run it on the blocking pool. +pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec { + if is_video_name(name) && !ffmpeg_available() { + return Vec::new(); + } + upload_thumbnails_with(path, name, staging_dir, OsStr::new("ffmpeg")) +} + +fn upload_thumbnails_with( + path: &Path, + name: &str, + staging_dir: &Path, + ffmpeg: &OsStr, +) -> Vec { + let attempt = if is_video_name(name) { + if !is_video_file(path, name) { + return Vec::new(); + } + video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT) + } else if is_thumbnail_image_name(name) { + let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES); + let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else { + return Vec::new(); + }; + scale_thumbnail(&bytes, name, staging_dir) + } else { + return Vec::new(); + }; + let ScaleAttempt::Made(thumb) = attempt else { + debug!(name, "uploading without a thumbnail; none could be made"); + return Vec::new(); + }; + fit_upload_thumb(thumb.bytes) + .and_then(|bytes| Thumbnail::new(ThumbnailType::Thumbnail, bytes).ok()) + .into_iter() + .collect() +} + +/// `.ts` is TypeScript as often as it is an MPEG transport stream, so a source +/// tree must not cost an ffmpeg run per file. A transport stream opens on its +/// 0x47 sync byte; every other video extension is taken at its word. +fn is_video_file(path: &Path, name: &str) -> bool { + if !name.to_ascii_lowercase().ends_with(".ts") { + return true; + } + let mut first = [0u8; 1]; + std::fs::File::open(path) + .and_then(|mut file| file.read_exact(&mut first)) + .is_ok_and(|()| first[0] == 0x47) +} + +/// Re-encode a thumbnail at falling quality until it fits +/// [`UPLOAD_THUMB_MAX_BYTES`], and at half the size if even the lowest quality +/// doesn't. `None` if nothing fits. +fn fit_upload_thumb(bytes: Vec) -> Option> { + if bytes.len() <= UPLOAD_THUMB_MAX_BYTES { + return Some(bytes); + } + let image = image::load_from_memory(&bytes).ok()?; + let half = image.thumbnail(image.width() / 2, image.height() / 2); + [image.to_rgb8(), half.to_rgb8()].iter().find_map(|image| { + UPLOAD_THUMB_QUALITIES.iter().find_map(|&quality| { + let mut smaller = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut smaller, quality) + .encode_image(image) + .ok()?; + (smaller.len() <= UPLOAD_THUMB_MAX_BYTES).then_some(smaller) + }) + }) +} + fn exiftool_object( value: &serde_json::Value, ) -> Option<&serde_json::Map> { @@ -418,6 +612,24 @@ struct PhotoMeta { } impl Core { + /// [`upload_thumbnails`] for a file about to upload, staging any RAW copy + /// under this cache. Blocking. + pub(crate) fn upload_thumbnails(&self, path: &Path, name: &str) -> Vec { + upload_thumbnails(path, name, self.cache.raw_thumbnail_staging_dir()) + } + + /// [`Core::upload_thumbnails`] on the blocking pool, for async callers. + pub(crate) async fn upload_thumbnails_async( + &self, + path: PathBuf, + name: String, + ) -> Vec { + let staging_dir = self.cache.raw_thumbnail_staging_dir().to_path_buf(); + tokio::task::spawn_blocking(move || upload_thumbnails(&path, &name, &staging_dir)) + .await + .unwrap_or_default() + } + pub(crate) fn photos_timeline( &self, offset: usize, @@ -973,6 +1185,10 @@ impl Core { if !core.thumb_job_current(job) { return (uid, ThumbAttempt::Cancelled); } + let video = is_video_name(&name); + if video && !ffmpeg_available() { + return (uid, ThumbAttempt::Unavailable); + } // One shared permit pool bounds all thumbnail batches, // rather than multiplying the limit by every control @@ -1019,6 +1235,19 @@ impl Core { return (uid, ThumbAttempt::Cancelled); } + // A video is downloaded whole for one frame, so a long + // recording is not worth it — and would not fit in memory. + if video { + match core.photos().get_node(&uid).await { + Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => { + info!(%uid, size = node_size(&node), "video too large for a local thumbnail"); + return (uid, ThumbAttempt::Undecodable); + } + Ok(Some(_)) => {} + Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable), + } + } + let download_uid = uid.clone(); let download = async move { client.download_file(&download_uid).await }; tokio::pin!(download); @@ -1072,7 +1301,17 @@ impl Core { // stall the runtime's worker; hand it to the blocking pool. let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf(); let made = tokio::task::spawn_blocking(move || { - scale_thumbnail(&bytes, &name, &staging_dir) + if video { + video_thumbnail( + &bytes, + &name, + &staging_dir, + OsStr::new("ffmpeg"), + FFMPEG_TIMEOUT, + ) + } else { + scale_thumbnail(&bytes, &name, &staging_dir) + } }) .await .unwrap_or(ScaleAttempt::Unavailable); @@ -1592,10 +1831,12 @@ impl Core { #[cfg(test)] mod thumb_tests { use super::{ - RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail, - scale_thumbnail_with_exiftool, thumbnail_build_may_start, + RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary, + fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, + thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail, }; use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name}; + use proton_drive_rs::ThumbnailType; use std::ffi::OsStr; use std::os::unix::fs::PermissionsExt as _; use std::time::Duration; @@ -1758,6 +1999,103 @@ mod thumb_tests { assert!(matches!(attempt, ScaleAttempt::Unavailable)); } + /// An executable shell script standing in for ffmpeg, in its own directory + /// under `tmp` so parallel tests never share one. + fn fake_ffmpeg(tmp: &std::path::Path, body: &str) -> std::path::PathBuf { + std::fs::create_dir_all(tmp).unwrap(); + let path = tmp.join("ffmpeg"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + fn scratch(test: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id())) + } + + #[test] + fn a_video_frame_becomes_a_thumbnail() { + let tmp = scratch("video-frame"); + let frame = tmp.join("frame.jpg"); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(&frame, jpeg(1080, 1920)).unwrap(); + let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display())); + + let ScaleAttempt::Made(thumb) = video_thumbnail( + b"video bytes", + "clip.mp4", + &tmp, + ffmpeg.as_os_str(), + Duration::from_secs(10), + ) else { + panic!("a frame scales"); + }; + assert!((thumb.ratio - 1080.0 / 1920.0).abs() < 1e-6); + std::fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() { + let tmp = scratch("video-broken"); + let ffmpeg = fake_ffmpeg(&tmp, "exit 1"); + let attempt = video_thumbnail( + b"not a video", + "clip.mp4", + &tmp, + ffmpeg.as_os_str(), + Duration::from_secs(10), + ); + assert!(matches!(attempt, ScaleAttempt::Undecodable)); + std::fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn missing_ffmpeg_is_retryable() { + let attempt = video_thumbnail( + b"video bytes", + "clip.mp4", + &std::env::temp_dir(), + OsStr::new("pdfs-ffmpeg-deliberately-absent"), + Duration::from_millis(10), + ); + assert!(matches!(attempt, ScaleAttempt::Unavailable)); + } + + /// Optional real-ffmpeg check, like the RAW fixture harness above. + #[test] + #[ignore = "set PDFS_VIDEO_FIXTURE to a video file; needs ffmpeg"] + fn real_video_fixture_yields_a_frame() { + let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE"); + let path = std::path::PathBuf::from(path); + let name = path.file_name().unwrap().to_string_lossy(); + let bytes = std::fs::read(&path).unwrap(); + let ScaleAttempt::Made(thumb) = video_thumbnail( + &bytes, + &name, + &std::env::temp_dir(), + OsStr::new("ffmpeg"), + Duration::from_secs(30), + ) else { + panic!("a frame from the video"); + }; + assert!(thumb.ratio > 0.0); + } + + #[test] + fn videos_are_told_apart_by_name() { + for name in [ + "VID-20260804-WA0023.mp4", + "PXL_1.TS.mp4", + "clip.MOV", + "a.webm", + ] { + assert!(is_video_name(name), "{name}"); + } + for name in ["photo.jpg", "shot.NEF", "notes.txt", "mp4"] { + assert!(!is_video_name(name), "{name}"); + } + } + #[test] fn a_running_build_only_accepts_the_same_root() { let status = ThumbnailBuildStatus { @@ -1771,4 +2109,92 @@ mod thumb_tests { let idle = ThumbnailBuildStatus::default(); assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap()); } + + #[test] + fn an_uploaded_photo_carries_its_thumbnail() { + let tmp = scratch("upload-photo"); + std::fs::create_dir_all(&tmp).unwrap(); + let photo = tmp.join("staged-blob"); + std::fs::write(&photo, jpeg(4000, 3000)).unwrap(); + + let thumbnails = upload_thumbnails_with(&photo, "IMG_0001.jpg", &tmp, OsStr::new("ffmpeg")); + let _ = std::fs::remove_dir_all(&tmp); + let [thumbnail] = thumbnails.as_slice() else { + panic!("one thumbnail, got {}", thumbnails.len()); + }; + assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail)); + assert!(thumbnail.content.len() <= UPLOAD_THUMB_MAX_BYTES); + assert_eq!(ratio_of(&thumbnail.content), Some(4.0 / 3.0)); + } + + #[test] + fn an_uploaded_video_carries_a_frame() { + let tmp = scratch("upload-video"); + let frame = tmp.join("frame.jpg"); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(&frame, jpeg(1080, 1920)).unwrap(); + std::fs::write(tmp.join("clip"), b"video bytes").unwrap(); + let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display())); + + let thumbnails = upload_thumbnails_with( + &tmp.join("clip"), + "VID-WA0001.mp4", + &tmp, + ffmpeg.as_os_str(), + ); + let _ = std::fs::remove_dir_all(&tmp); + assert_eq!(thumbnails.len(), 1); + assert!(ratio_of(&thumbnails[0].content).is_some_and(|ratio| ratio < 1.0)); + } + + #[test] + fn other_files_upload_without_a_thumbnail() { + let tmp = scratch("upload-other"); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("file"), b"export const answer = 42;\n").unwrap(); + // An ffmpeg that would hand back a frame, to prove it is never asked. + std::fs::write(tmp.join("frame.jpg"), jpeg(64, 64)).unwrap(); + let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", tmp.join("frame.jpg").display())); + + for name in ["notes.txt", "answer.ts", "broken.jpg"] { + assert!( + upload_thumbnails_with(&tmp.join("file"), name, &tmp, ffmpeg.as_os_str()) + .is_empty(), + "{name} uploads bare" + ); + } + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn an_oversized_thumbnail_is_squeezed_under_the_upload_limit() { + // Noise is the worst case for JPEG: at the gallery's quality a 512 px + // square of it lands far over the limit. + let mut seed = 0x2545_f491_u32; + let noise = image::RgbImage::from_fn(THUMB_EDGE, THUMB_EDGE, |_, _| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let [r, g, b, _] = seed.to_le_bytes(); + image::Rgb([r, g, b]) + }); + let mut bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, super::THUMB_QUALITY) + .encode_image(&noise) + .unwrap(); + assert!( + bytes.len() > UPLOAD_THUMB_MAX_BYTES, + "the fixture is oversized" + ); + + let fitted = fit_upload_thumb(bytes).expect("noise fits at half size"); + assert!(fitted.len() <= UPLOAD_THUMB_MAX_BYTES); + assert_eq!(ratio_of(&fitted), Some(1.0), "shrinking keeps the shape"); + let small = jpeg(64, 64); + assert_eq!( + fit_upload_thumb(small.clone()), + Some(small), + "a small one is untouched" + ); + } } diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index e1257ea..bff1c02 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -1226,6 +1226,9 @@ impl Core { // Taken from the descriptor being streamed, so it names the content that // actually goes up rather than whatever is at the path when we finish. let streamed = LocalSig::from(&meta); + let thumbnails = self + .upload_thumbnails_async(path.clone(), name.to_string()) + .await; // Count the bytes as they are read, so this upload shows up in // `GetQueueStatus` next to a manual one. The uid isn't known until the // draft is sealed, so the transfer registers without one. @@ -1246,7 +1249,7 @@ impl Core { crate::media_type_for(name), reader, meta.len() as i64, - Vec::new(), + thumbnails, Some(mtime), false, ) @@ -1274,6 +1277,9 @@ impl Core { let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?; let mtime = system_mtime(&meta); let streamed = LocalSig::from(&meta); + let thumbnails = self + .upload_thumbnails_async(path.clone(), base_name(rel).to_string()) + .await; let reader = OwnedCountingReader::new( file, self.transfers.begin( @@ -1284,7 +1290,7 @@ impl Core { ), ); self.client - .upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime)) + .upload_new_revision_from(uid, reader, meta.len() as i64, thumbnails, Some(mtime)) .await .map_err(|e| format!("upload revision {rel}: {e}"))?; warn_if_torn(rel, &path, streamed); diff --git a/crates/pdfs-fuse/src/upload.rs b/crates/pdfs-fuse/src/upload.rs index f1bb350..2bb5474 100644 --- a/crates/pdfs-fuse/src/upload.rs +++ b/crates/pdfs-fuse/src/upload.rs @@ -69,6 +69,9 @@ async fn run_uploads( format!("destination access denied: errno {}", error.code()), )); } + let thumbnails = core + .upload_thumbnails_async(t.path.clone(), t.name.clone()) + .await; match core .client .upload_file_from( @@ -77,7 +80,7 @@ async fn run_uploads( media_type_for(&t.name), reader, t.size as i64, - Vec::new(), + thumbnails, mtime, false, ) diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 7c5e78d..577be7b 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -20,6 +20,7 @@ makedepends=('cargo' 'git' 'gettext') provides=('pdfs') # Files-on-demand mount + keyring need these at runtime. optdepends=('gnome-keyring: credential storage via Secret Service' + 'ffmpeg: video thumbnails' 'xdg-utils: "Open folder" action from the tray' 'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)' 'foot: terminal for pdfs-prompt --fzf started from a keybinding') diff --git a/packaging/aur/proton-drive-for-linux-bin/.SRCINFO b/packaging/aur/proton-drive-for-linux-bin/.SRCINFO index d5e0eb4..8f32dee 100644 --- a/packaging/aur/proton-drive-for-linux-bin/.SRCINFO +++ b/packaging/aur/proton-drive-for-linux-bin/.SRCINFO @@ -14,6 +14,7 @@ pkgbase = proton-drive-for-linux-bin depends = glibc depends = hicolor-icon-theme optdepends = perl-image-exiftool: thumbnails for camera RAW files + optdepends = ffmpeg: video thumbnails optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API optdepends = xdg-utils: "Open folder" action in the tray menu diff --git a/packaging/aur/proton-drive-for-linux-bin/PKGBUILD b/packaging/aur/proton-drive-for-linux-bin/PKGBUILD index 9f54db5..a779b4a 100644 --- a/packaging/aur/proton-drive-for-linux-bin/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux-bin/PKGBUILD @@ -10,6 +10,7 @@ license=('MIT') depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc' 'hicolor-icon-theme') optdepends=('perl-image-exiftool: thumbnails for camera RAW files' + 'ffmpeg: video thumbnails' 'gnome-keyring: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API' 'xdg-utils: "Open folder" action in the tray menu' diff --git a/packaging/aur/proton-drive-for-linux-git/.SRCINFO b/packaging/aur/proton-drive-for-linux-git/.SRCINFO index f62f717..b78d280 100644 --- a/packaging/aur/proton-drive-for-linux-git/.SRCINFO +++ b/packaging/aur/proton-drive-for-linux-git/.SRCINFO @@ -17,6 +17,7 @@ pkgbase = proton-drive-for-linux-git depends = glibc depends = hicolor-icon-theme optdepends = perl-image-exiftool: thumbnails for camera RAW files + optdepends = ffmpeg: video thumbnails optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API optdepends = xdg-utils: "Open folder" action in the tray menu diff --git a/packaging/aur/proton-drive-for-linux-git/PKGBUILD b/packaging/aur/proton-drive-for-linux-git/PKGBUILD index ce327b5..23189e7 100644 --- a/packaging/aur/proton-drive-for-linux-git/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux-git/PKGBUILD @@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc' 'hicolor-icon-theme') makedepends=('cargo' 'git' 'gettext') optdepends=('perl-image-exiftool: thumbnails for camera RAW files' + 'ffmpeg: video thumbnails' 'gnome-keyring: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API' 'xdg-utils: "Open folder" action in the tray menu' diff --git a/packaging/aur/proton-drive-for-linux/.SRCINFO b/packaging/aur/proton-drive-for-linux/.SRCINFO index 0220557..08d8828 100644 --- a/packaging/aur/proton-drive-for-linux/.SRCINFO +++ b/packaging/aur/proton-drive-for-linux/.SRCINFO @@ -16,6 +16,7 @@ pkgbase = proton-drive-for-linux depends = glibc depends = hicolor-icon-theme optdepends = perl-image-exiftool: thumbnails for camera RAW files + optdepends = ffmpeg: video thumbnails optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API optdepends = xdg-utils: "Open folder" action in the tray menu diff --git a/packaging/aur/proton-drive-for-linux/PKGBUILD b/packaging/aur/proton-drive-for-linux/PKGBUILD index 9fc7c9c..021bdb1 100644 --- a/packaging/aur/proton-drive-for-linux/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux/PKGBUILD @@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc' 'hicolor-icon-theme') makedepends=('cargo' 'gettext') optdepends=('perl-image-exiftool: thumbnails for camera RAW files' + 'ffmpeg: video thumbnails' 'gnome-keyring: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API' 'xdg-utils: "Open folder" action in the tray menu'