mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(fuse,core): thumbnails for videos and for our own uploads
Videos without a server thumbnail fell through to the local fallback, which downloaded the whole file, failed to decode it as an image and wrote the video off for good. Grab one frame with ffmpeg instead, a second in or from the start for shorter clips. Probe for ffmpeg before downloading, bound every run, and skip videos over 256 MiB. Migration V32 gives the videos written off so far another try. Those videos had no server thumbnail because our uploads never sent one. Every upload path (bulk upload, drained mount writes, conflict copies, new revisions and folder sync) now attaches a thumbnail rendered from the local file, kept under the 60 KiB Proton's own clients use. Files that are not images or videos, or cannot be decoded, upload bare as before; a .ts file only counts as video with an MPEG-TS sync byte. ffmpeg is a new optional dependency in the PKGBUILDs.
This commit is contained in:
1 parent
0b7236bd4a
commit
90e110e893
13 files changed
+566
-58
No files matched your search
@@ -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";
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
+477
-51
@@ -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<Vec<u8>, 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,14 +360,9 @@ 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 {
|
||||
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 \
|
||||
@@ -295,43 +370,162 @@ fn exiftool_query(
|
||||
);
|
||||
});
|
||||
}
|
||||
return Err(RawPreviewUnavailable);
|
||||
}
|
||||
};
|
||||
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<bool> = 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<Thumbnail> {
|
||||
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<Thumbnail> {
|
||||
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<u8>) -> Option<Vec<u8>> {
|
||||
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<String, serde_json::Value>> {
|
||||
@@ -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<Thumbnail> {
|
||||
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<Thumbnail> {
|
||||
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 || {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user