Files
Oxicloud/src/infrastructure/services/thumbnail_service_test.rs
T
DioCrafts e7b85e56e2 feat(thumbnails): WebP output with Accept content negotiation
Thumbnails are now generated eagerly as lossy WebP (the primary codec) and
served to clients that advertise `Accept: image/webp`; JPEG is kept as a lazy
fallback for older clients and NextCloud, generated on first request and then
cached like WebP.

- ThumbnailFormat{Webp,Jpeg} enum threaded through encode/render/generate, the
  on-disk path ({hash}.webp / {hash}.jpg), the moka cache key
  (file_id, size, format), and cleanup (both formats removed).
- file_handler: parse Accept -> format, format-keyed ETag, `Vary: Accept` on
  every response (incl. 304) so shared caches never serve the wrong codec;
  Content-Type is byte-sniffed (infer) so it always matches the bytes.
- preview_handler (NextCloud) pins JPEG.
- webp = "0.3" (vendored libwebp via cc, no system dependency).

WEBP_QUALITY=82, chosen via a quality sweep (bench Table E1): SSIM within
~0.005 of JPEG q80 (imperceptible at thumbnail scale) for ~62% fewer bytes. On
the photo-realistic bench corpus the full set (3 sizes x 3 photos) drops 65.6%
(213->73 KB); real photos with edges/text land nearer ~25-40%. Encode is +5ms,
paid once in the eager background generator (off the request path).

The bench corpus is now photo-realistic (per-channel sums of low-frequency
sinusoids) instead of white noise, which had distorted codec byte ratios.
Methodology + numbers in benches/WEBP.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:16:08 +02:00

124 lines
4.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use crate::application::ports::thumbnail_ports::ThumbnailFormat;
use super::thumbnail_service::{ThumbnailService, ThumbnailSize};
/// Minimal valid 1x1 red PNG (68 bytes).
fn tiny_png() -> Vec<u8> {
// Generated from a real 1×1 PNG — smallest valid RGBA image.
let mut img = image::RgbaImage::new(1, 1);
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.expect("encode test PNG");
buf
}
/// Regression test: thumbnail generation must work when the source file lives
/// at a blob-style path (`.blobs/ab/ab1234…`) rather than a logical path
/// (`folder/image.png`). This broke after the blob storage migration
/// (commit 3c7c16f) because the handler passed the logical path — which
/// doesn't exist on disk — to the thumbnail service.
#[tokio::test]
async fn generate_thumbnail_from_blob_path() {
let tmp = tempfile::tempdir().expect("create temp dir");
let storage_root = tmp.path();
// Simulate a blob-store layout: .blobs/ab/<hash>.blob
let blob_dir = storage_root.join(".blobs").join("ab");
std::fs::create_dir_all(&blob_dir).expect("create blob dir");
let blob_path = blob_dir.join("ab1234567890.blob");
std::fs::write(&blob_path, tiny_png()).expect("write test blob");
let svc = Arc::new(ThumbnailService::new(
storage_root,
100,
10 * 1024 * 1024,
Some(Duration::from_secs(30)),
));
svc.initialize().await.expect("init thumbnail dirs");
// The key assertion: the service can read from a blob path (not a logical path)
let result = svc
.get_thumbnail(
"test-file-id",
"ab1234567890",
ThumbnailSize::Icon,
ThumbnailFormat::Jpeg,
&blob_path,
)
.await;
let thumb_bytes = result.expect("thumbnail generation should succeed from blob path");
assert!(!thumb_bytes.is_empty(), "thumbnail bytes must not be empty");
// Verify it's valid JPEG (starts with SOI marker 0xFF 0xD8)
assert!(
thumb_bytes.len() > 2 && thumb_bytes[0] == 0xFF && thumb_bytes[1] == 0xD8,
"output should be JPEG format"
);
}
/// Verify that a non-existent path produces an error, not a panic.
#[tokio::test]
async fn generate_thumbnail_nonexistent_path_returns_error() {
let tmp = tempfile::tempdir().expect("create temp dir");
let svc = Arc::new(ThumbnailService::new(
tmp.path(),
100,
10 * 1024 * 1024,
Some(Duration::from_secs(30)),
));
svc.initialize().await.expect("init thumbnail dirs");
let bad_path = tmp.path().join("does-not-exist.png");
let result = svc
.get_thumbnail(
"missing-id",
"nonexistent-hash",
ThumbnailSize::Icon,
ThumbnailFormat::Jpeg,
&bad_path,
)
.await;
assert!(result.is_err(), "should fail for nonexistent file");
}
/// Regression test: thumbnail generation must also work when the image source
/// is reconstructed from blob storage bytes instead of a single local file.
#[tokio::test]
async fn generate_thumbnail_from_blob_bytes() {
let tmp = tempfile::tempdir().expect("create temp dir");
let storage_root = tmp.path();
let svc = Arc::new(ThumbnailService::new(
storage_root,
100,
10 * 1024 * 1024,
Some(Duration::from_secs(30)),
));
svc.initialize().await.expect("init thumbnail dirs");
let result = svc
.get_thumbnail_from_bytes(
"bytes-file-id",
"bytes-hash-123",
ThumbnailSize::Preview,
ThumbnailFormat::Jpeg,
Bytes::from(tiny_png()),
)
.await;
let thumb_bytes = result.expect("thumbnail generation should succeed from raw bytes");
assert!(!thumb_bytes.is_empty(), "thumbnail bytes must not be empty");
assert!(
thumb_bytes.len() > 2 && thumb_bytes[0] == 0xFF && thumb_bytes[1] == 0xD8,
"output should be JPEG format"
);
}