Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m44s

This commit is contained in:
Aleksandr Bogomiakov
2026-08-09 01:05:47 +01:00
parent 9ee5048a43
commit 15c9528f47
14 changed files with 885 additions and 116 deletions
+41 -30
View File
@@ -480,23 +480,25 @@ async fn admin_media_view(
media: Media,
) -> cot::Result<MediaView> {
let media_id = media.id.unwrap();
let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
let url = storage
.public_url(&media.file_path, format!("/admin/uploads/{media_id}"))
.await
.map_err(crate::uploads::storage_error)?;
let thumbnail_path =
if media.file_type == "photo" && crate::uploads::supports_thumbnail(&media.file_path) {
crate::uploads::thumbnail_db_path(&media.file_path)
} else {
media.file_path.clone()
};
let thumbnail_url = storage
.public_url(
&thumbnail_path,
format!("/admin/uploads/{media_id}/thumbnail"),
)
.public_url(&delivery.media_path, format!("/admin/uploads/{media_id}"))
.await
.map_err(crate::uploads::storage_error)?;
let thumbnail_fallback = format!("/admin/uploads/{media_id}/thumbnail");
let thumbnail_url = if storage.is_r2()
&& storage
.exists(&delivery.thumbnail_path)
.await
.map_err(crate::uploads::storage_error)?
{
storage
.public_url(&delivery.thumbnail_path, thumbnail_fallback)
.await
.map_err(crate::uploads::storage_error)?
} else {
thumbnail_fallback
};
Ok(MediaView {
media,
url,
@@ -2450,9 +2452,10 @@ async fn media_upload_submit(
let file_path = if file_type == "photo" {
save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await?
} else {
let path = crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
storage
.write(&path, &data)
let original_path =
crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
let path = crate::uploads::normalized_video_db_path(&original_path);
crate::uploads::write_compact_video(&storage, &path, &ext, &data)
.await
.map_err(crate::uploads::storage_error)?;
path
@@ -2542,20 +2545,26 @@ async fn serve_upload_thumbnail(
return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response();
}
let media = match query!(Media, $id == media_id).get(&db).await? {
Some(media) if media.status == "active" && media.file_type == "photo" => media,
Some(media) if media.status == "active" => media,
_ => return Html::new("404").into_response(),
};
let storage = crate::uploads::Storage::load(&db).await?;
let display_path = if crate::uploads::supports_thumbnail(&media.file_path) {
match crate::uploads::ensure_thumbnail(&storage, &media.file_path).await {
Ok(path) => path,
Err(error) => {
tracing::warn!(media_id, %error, "failed to create media thumbnail");
let display_path = match crate::uploads::ensure_media_delivery_paths(
&storage,
&media.file_type,
&media.file_path,
)
.await
{
Ok(paths) => paths.thumbnail_path,
Err(error) => {
tracing::warn!(media_id, %error, "failed to create media thumbnail");
if media.file_type == "photo" {
media.file_path.clone()
} else {
return Html::new("404").into_response();
}
}
} else {
media.file_path.clone()
};
if storage.is_r2() {
let url = storage
@@ -2617,17 +2626,19 @@ async fn serve_upload(
.map(str::to_owned);
let storage = crate::uploads::Storage::load(&db).await?;
let display_path =
crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
if storage.is_r2() {
let url = storage
.public_url(&media.file_path, String::new())
.public_url(&display_path, String::new())
.await
.map_err(crate::uploads::storage_error)?;
return Redirect::new(url).into_response();
}
match crate::uploads::ranged_local_file_response(
&media.file_path,
crate::uploads::content_type_for_path(&media.file_path),
&display_path,
crate::uploads::content_type_for_path(&display_path),
range.as_deref(),
)
.await
@@ -2637,8 +2648,8 @@ async fn serve_upload(
tracing::warn!(
target: "uploads",
media_id,
db_path = %media.file_path,
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
db_path = %display_path,
resolved_path = %crate::uploads::resolved_display_path(&display_path),
error = %err,
"uploaded file is missing or unreadable"
);
+31 -4
View File
@@ -39,6 +39,29 @@ async fn add_thumbnail(
}
}
async fn add_video_derivatives(
sources: &mut BTreeMap<String, bool>,
db_path: &str,
required: bool,
) -> bool {
if !uploads::supports_video_preview(db_path) {
return true;
}
match uploads::ensure_video_thumbnail(&uploads::Storage::Local, db_path).await {
Ok(thumbnail_path) => {
sources
.entry(thumbnail_path)
.and_modify(|is_required| *is_required |= required)
.or_insert(required);
true
}
Err(error) => {
eprintln!("Could not create video derivatives for {db_path}: {error}");
!required
}
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::new(database_url()).await?;
let storage = uploads::Storage::load_configured_r2(&db).await?;
@@ -51,10 +74,14 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.entry(media.file_path.clone())
.and_modify(|is_required| *is_required |= required)
.or_insert(required);
if media.file_type == "photo"
&& !add_thumbnail(&mut sources, &media.file_path, required).await
{
preparation_failed = true;
match media.file_type.as_str() {
"photo" if !add_thumbnail(&mut sources, &media.file_path, required).await => {
preparation_failed = true;
}
"video" if !add_video_derivatives(&mut sources, &media.file_path, required).await => {
preparation_failed = true;
}
_ => {}
}
}
+138
View File
@@ -0,0 +1,138 @@
#[allow(dead_code)]
#[path = "../models.rs"]
mod models;
#[allow(dead_code)]
#[path = "../uploads.rs"]
mod uploads;
use cot::db::{Database, Model};
use models::Media;
fn database_url() -> String {
std::env::var("WEB_PETTING_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/web_petting".to_string())
}
async fn local_file_exists(path: &std::path::Path) -> Result<bool, std::io::Error> {
tokio::fs::try_exists(path).await
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::new(database_url()).await?;
let r2 = uploads::Storage::load_configured_r2(&db).await?;
let mut converted = 0usize;
let mut already_normalized = 0usize;
let mut missing_archived = 0usize;
let mut failed = 0usize;
for mut media in Media::objects().all(&db).await? {
if media.file_type != "video" {
continue;
}
let media_id = media.id.unwrap();
let original_db_path = media.file_path.clone();
let target_db_path = uploads::normalized_video_db_path(&original_db_path);
let target_thumbnail_db_path = uploads::thumbnail_db_path(&target_db_path);
let target_in_r2 = r2.exists(&target_db_path).await?;
let thumbnail_in_r2 = r2.exists(&target_thumbnail_db_path).await?;
if !target_in_r2 || !thumbnail_in_r2 {
let source_path = uploads::resolve_db_path(&original_db_path);
let target_path = uploads::resolve_db_path(&target_db_path);
let target_thumbnail_path = uploads::resolve_db_path(&target_thumbnail_db_path);
let source_exists = local_file_exists(&source_path).await?;
let target_exists = local_file_exists(&target_path).await?;
let target_thumbnail_exists = local_file_exists(&target_thumbnail_path).await?;
if !source_exists && !target_exists {
eprintln!(
"Missing PVC source for media {media_id}: {}",
source_path.display()
);
if media.status == "active" {
failed += 1;
} else {
missing_archived += 1;
}
continue;
}
if original_db_path == target_db_path || target_exists {
if !target_thumbnail_exists
&& let Err(error) =
uploads::ensure_video_thumbnail(&uploads::Storage::Local, &target_db_path)
.await
{
eprintln!("Could not create preview for media {media_id}: {error}");
failed += 1;
continue;
}
} else {
if let Some(parent) = target_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
println!("Converting media {media_id}: {original_db_path}");
if let Err(error) = uploads::create_compact_video_files(
&source_path,
&target_path,
&target_thumbnail_path,
)
.await
{
eprintln!("Could not convert media {media_id}: {error}");
failed += 1;
continue;
}
}
if !target_in_r2 {
r2.upload_local_copy(&target_db_path, &target_path).await?;
println!("Uploaded compact video: {target_db_path}");
}
if !thumbnail_in_r2 {
r2.upload_local_copy(&target_thumbnail_db_path, &target_thumbnail_path)
.await?;
println!("Uploaded video preview: {target_thumbnail_db_path}");
}
}
if original_db_path != target_db_path {
// New objects are safely present before the old R2 keys are removed.
// The source on the PVC is deliberately left untouched.
r2.remove(&original_db_path).await?;
let original_thumbnail = uploads::thumbnail_db_path(&original_db_path);
if original_thumbnail != target_thumbnail_db_path {
r2.remove(&original_thumbnail).await?;
}
media.file_path = target_db_path.clone();
media.save(&db).await?;
converted += 1;
println!("Normalized media {media_id}: {target_db_path}");
} else {
already_normalized += 1;
}
}
db.close().await?;
println!(
"Video normalization summary: converted={converted}, already_normalized={already_normalized}, missing_archived={missing_archived}, failed={failed}"
);
println!("Original PVC files were not changed or deleted.");
if failed > 0 {
return Err("video normalization did not complete successfully".into());
}
Ok(())
}
fn main() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build Tokio runtime");
if let Err(error) = runtime.block_on(run()) {
eprintln!("Video normalization failed: {error}");
std::process::exit(1);
}
}
+17 -2
View File
@@ -303,6 +303,11 @@ pub struct Translations {
pub media_delete: &'static str,
pub media_delete_confirm: &'static str,
pub media_all_clients: &'static str,
pub media_files_selected: &'static str,
pub media_upload_sending: &'static str,
pub media_upload_processing: &'static str,
pub media_upload_done: &'static str,
pub media_upload_connection_error: &'static str,
// Client portal
pub portal_title: &'static str,
@@ -429,7 +434,7 @@ static RU: Translations = Translations {
settings_r2_access_key_id: "R2 Access Key ID",
settings_r2_secret_access_key: "R2 Secret Access Key",
settings_r2_secret_unchanged: "Секрет уже сохранён; оставьте поле пустым, чтобы не менять его",
settings_r2_migration_help: "Порядок перехода: сохраните реквизиты с выключенным R2, выполните в контейнере migrate_uploads_to_r2, затем включите R2 и отключите PVC. Для бакета разрешите CORS GET/HEAD с домена сайта и заголовок Range.",
settings_r2_migration_help: "Порядок перехода: сохраните реквизиты с выключенным R2, выполните в контейнере migrate_uploads_to_r2 и normalize_videos_to_r2, затем включите R2 и отключите PVC. Для бакета разрешите CORS GET/HEAD с домена сайта и заголовок Range.",
settings_r2_error_incomplete: "R2 не включён: проверьте Account ID, имя бакета, Access Key ID и Secret Access Key.",
settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления",
settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.",
@@ -463,6 +468,11 @@ static RU: Translations = Translations {
media_delete: "Удалить",
media_delete_confirm: "Удалить этот файл?",
media_all_clients: "Все клиенты",
media_files_selected: "Выбрано файлов",
media_upload_sending: "Загрузка на сервер...",
media_upload_processing: "Конвертация и загрузка в R2...",
media_upload_done: "Готово — обновляем медиагалерею...",
media_upload_connection_error: "Ошибка соединения",
portal_title: "Визиты",
portal_upcoming: "Предстоящие визиты",
@@ -689,7 +699,7 @@ static EN: Translations = Translations {
settings_r2_access_key_id: "R2 Access Key ID",
settings_r2_secret_access_key: "R2 Secret Access Key",
settings_r2_secret_unchanged: "A secret is already stored; leave this blank to keep it unchanged",
settings_r2_migration_help: "Migration order: save the credentials with R2 disabled, run migrate_uploads_to_r2 inside the container, then enable R2 and detach the PVC. Allow CORS GET/HEAD from the site domain and the Range header on the bucket.",
settings_r2_migration_help: "Migration order: save the credentials with R2 disabled, run migrate_uploads_to_r2 and normalize_videos_to_r2 inside the container, then enable R2 and detach the PVC. Allow CORS GET/HEAD from the site domain and the Range header on the bucket.",
settings_r2_error_incomplete: "R2 was not enabled: check the Account ID, bucket name, Access Key ID, and Secret Access Key.",
settings_client_notifications_enabled: "Allow client browser notifications",
settings_client_notifications_help: "Shows clients the completed-visit notification setting.",
@@ -723,6 +733,11 @@ static EN: Translations = Translations {
media_delete: "Delete",
media_delete_confirm: "Delete this file?",
media_all_clients: "All clients",
media_files_selected: "Files selected",
media_upload_sending: "Uploading to the server...",
media_upload_processing: "Converting and uploading to R2...",
media_upload_done: "Done — refreshing the media gallery...",
media_upload_connection_error: "Connection error",
portal_title: "Visits",
portal_upcoming: "Upcoming visits",
+37 -28
View File
@@ -273,26 +273,28 @@ async fn portal_media_view(
client_token: &str,
) -> cot::Result<PortalMediaView> {
let media_id = media.id.unwrap();
let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
let url = storage
.public_url(
&media.file_path,
&delivery.media_path,
format!("/client/{client_token}/media/{media_id}"),
)
.await
.map_err(crate::uploads::storage_error)?;
let thumbnail_path =
if media.file_type == "photo" && crate::uploads::supports_thumbnail(&media.file_path) {
crate::uploads::thumbnail_db_path(&media.file_path)
} else {
media.file_path.clone()
};
let thumbnail_url = storage
.public_url(
&thumbnail_path,
format!("/client/{client_token}/media/{media_id}/thumbnail"),
)
.await
.map_err(crate::uploads::storage_error)?;
let thumbnail_fallback = format!("/client/{client_token}/media/{media_id}/thumbnail");
let thumbnail_url = if storage.is_r2()
&& storage
.exists(&delivery.thumbnail_path)
.await
.map_err(crate::uploads::storage_error)?
{
storage
.public_url(&delivery.thumbnail_path, thumbnail_fallback)
.await
.map_err(crate::uploads::storage_error)?
} else {
thumbnail_fallback
};
Ok(PortalMediaView {
media,
url,
@@ -858,17 +860,19 @@ async fn portal_media(
.map(str::to_owned);
let storage = crate::uploads::Storage::load(&db).await?;
let display_path =
crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
if storage.is_r2() {
let url = storage
.public_url(&media.file_path, String::new())
.public_url(&display_path, String::new())
.await
.map_err(crate::uploads::storage_error)?;
return Redirect::new(url).into_response();
}
match crate::uploads::ranged_local_file_response(
&media.file_path,
crate::uploads::content_type_for_path(&media.file_path),
&display_path,
crate::uploads::content_type_for_path(&display_path),
range.as_deref(),
)
.await
@@ -878,8 +882,8 @@ async fn portal_media(
tracing::warn!(
target: "uploads",
media_id,
db_path = %media.file_path,
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
db_path = %display_path,
resolved_path = %crate::uploads::resolved_display_path(&display_path),
error = %err,
"portal media file is missing or unreadable"
);
@@ -900,8 +904,7 @@ async fn portal_media_thumbnail(
let media = match query!(Media, $id == media_id).get(&db).await? {
Some(media)
if media.client_id.primary_key().unwrap() == client.id.unwrap()
&& media.status == "active"
&& media.file_type == "photo" =>
&& media.status == "active" =>
{
media
}
@@ -915,16 +918,22 @@ async fn portal_media_thumbnail(
}
}
let storage = crate::uploads::Storage::load(&db).await?;
let display_path = if crate::uploads::supports_thumbnail(&media.file_path) {
match crate::uploads::ensure_thumbnail(&storage, &media.file_path).await {
Ok(path) => path,
Err(error) => {
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
let display_path = match crate::uploads::ensure_media_delivery_paths(
&storage,
&media.file_type,
&media.file_path,
)
.await
{
Ok(paths) => paths.thumbnail_path,
Err(error) => {
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
if media.file_type == "photo" {
media.file_path.clone()
} else {
return Html::new("404").into_response();
}
}
} else {
media.file_path.clone()
};
if storage.is_r2() {
let url = storage
+312 -1
View File
@@ -11,11 +11,15 @@ use cot::response::Response;
use cot::{Body, StatusCode};
use image::codecs::jpeg::JpegEncoder;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::process::Command;
use crate::models::Setting;
const THUMBNAIL_MAX_DIMENSION: u32 = 360;
const THUMBNAIL_JPEG_QUALITY: u8 = 70;
pub const VIDEO_PREVIEW_FRAME_COUNT: usize = 4;
const VIDEO_PREVIEW_FRAME_WIDTH: u32 = 320;
const VIDEO_PREVIEW_FRAME_HEIGHT: u32 = 240;
const PRESIGNED_URL_TTL: Duration = Duration::from_secs(6 * 60 * 60);
const DEFAULT_UPLOAD_DIR: &str = "uploads";
@@ -171,6 +175,25 @@ impl R2Storage {
Ok(bytes.into_bytes().to_vec())
}
async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
let response = self
.client
.get_object()
.bucket(&self.bucket)
.key(object_key(db_path))
.send()
.await
.map_err(|error| StorageError::new("failed to download object from R2", error))?;
let mut reader = response.body.into_async_read();
let mut file = tokio::fs::File::create(destination)
.await
.map_err(|error| StorageError::new("failed to create temporary media file", error))?;
tokio::io::copy(&mut reader, &mut file)
.await
.map_err(|error| StorageError::new("failed to stream R2 object to disk", error))?;
Ok(())
}
async fn delete(&self, db_path: &str) -> StorageResult<()> {
self.client
.delete_object()
@@ -324,6 +347,20 @@ impl Storage {
}
}
async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
match self {
Self::Local => {
tokio::fs::copy(resolve_db_path(db_path), destination)
.await
.map_err(|error| {
StorageError::new("failed to copy local media to temporary file", error)
})?;
Ok(())
}
Self::R2(storage) => storage.download_to_path(db_path, destination).await,
}
}
/// Stream a local PVC file into R2 without loading large videos into memory.
#[allow(dead_code)]
pub async fn upload_local_copy(&self, db_path: &str, local_path: &Path) -> StorageResult<()> {
@@ -388,6 +425,14 @@ pub fn thumbnail_db_path(db_path: &str) -> String {
}
}
pub fn normalized_video_db_path(db_path: &str) -> String {
match db_path.rsplit_once('.') {
Some((stem, _)) if stem.ends_with(".web") => format!("{stem}.mp4"),
Some((stem, _)) => format!("{stem}.web.mp4"),
None => format!("{db_path}.web.mp4"),
}
}
pub fn supports_thumbnail(db_path: &str) -> bool {
matches!(
db_path
@@ -400,6 +445,18 @@ pub fn supports_thumbnail(db_path: &str) -> bool {
)
}
pub fn supports_video_preview(db_path: &str) -> bool {
matches!(
db_path
.rsplit('.')
.next()
.unwrap_or("")
.to_ascii_lowercase()
.as_str(),
"mp4" | "mov" | "avi" | "mkv" | "webm"
)
}
pub fn content_type_for_path(path: &str) -> &'static str {
match path
.rsplit('.')
@@ -481,6 +538,240 @@ pub async fn ensure_local_thumbnail(db_path: &str) -> StorageResult<String> {
Ok(thumbnail_path)
}
#[derive(Debug)]
pub struct MediaDeliveryPaths {
pub media_path: String,
pub thumbnail_path: String,
}
async fn create_video_workspace() -> StorageResult<PathBuf> {
let path = std::env::temp_dir().join(format!(
"web-petting-video-preview-{}",
uuid::Uuid::new_v4()
));
tokio::fs::create_dir(&path)
.await
.map_err(|error| StorageError::new("failed to create video workspace", error))?;
Ok(path)
}
async fn run_media_command(command: &mut Command, context: &str) -> StorageResult<()> {
let output = command
.output()
.await
.map_err(|error| StorageError::new(context, error))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(StorageError::new(
context,
stderr
.trim()
.lines()
.last()
.unwrap_or("unknown ffmpeg error"),
))
}
async fn transcode_video_for_browser(source: &Path, destination: &Path) -> StorageResult<()> {
let mut command = Command::new("ffmpeg");
command
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(source)
.args(["-map", "0:v:0", "-map", "0:a?"])
.args([
"-vf",
"scale=w='min(1920,iw)':h='min(1920,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-threads",
"2",
])
.arg(destination);
run_media_command(&mut command, "failed to transcode video with ffmpeg").await
}
async fn video_duration(source: &Path) -> StorageResult<f64> {
let mut command = Command::new("ffprobe");
command
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
])
.arg(source);
let output = command
.output()
.await
.map_err(|error| StorageError::new("failed to inspect video with ffprobe", error))?;
if !output.status.success() {
return Err(StorageError::new(
"failed to inspect video with ffprobe",
String::from_utf8_lossy(&output.stderr).trim(),
));
}
let duration = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<f64>()
.map_err(|error| StorageError::new("failed to parse video duration", error))?;
if !duration.is_finite() || duration <= 0.0 {
return Err(StorageError::new(
"failed to inspect video duration",
"duration is zero or invalid",
));
}
Ok(duration)
}
async fn create_video_sprite(source: &Path, destination: &Path) -> StorageResult<()> {
let duration = video_duration(source).await?;
let frame_times = [0.08, 0.34, 0.60, 0.86].map(|position| duration * position);
let mut command = Command::new("ffmpeg");
command.args(["-hide_banner", "-loglevel", "error", "-y"]);
for timestamp in frame_times {
command
.arg("-ss")
.arg(format!("{timestamp:.3}"))
.arg("-i")
.arg(source);
}
let frame_filter = format!(
"scale={VIDEO_PREVIEW_FRAME_WIDTH}:{VIDEO_PREVIEW_FRAME_HEIGHT}:force_original_aspect_ratio=decrease,pad={VIDEO_PREVIEW_FRAME_WIDTH}:{VIDEO_PREVIEW_FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1"
);
let filter = format!(
"[0:v]{frame_filter}[v0];[1:v]{frame_filter}[v1];[2:v]{frame_filter}[v2];[3:v]{frame_filter}[v3];[v0][v1][v2][v3]hstack=inputs={VIDEO_PREVIEW_FRAME_COUNT}[out]"
);
command
.arg("-filter_complex")
.arg(filter)
.args(["-map", "[out]", "-frames:v", "1", "-q:v", "4"])
.arg(destination);
run_media_command(&mut command, "failed to create video preview with ffmpeg").await
}
/// Create the compact browser MP4 and its four-frame JPEG sprite on disk.
/// The source path is only read and is never changed or removed.
pub async fn create_compact_video_files(
source: &Path,
video_destination: &Path,
thumbnail_destination: &Path,
) -> StorageResult<()> {
transcode_video_for_browser(source, video_destination).await?;
create_video_sprite(video_destination, thumbnail_destination).await
}
/// Convert an incoming upload and store only the compact browser MP4 and its
/// JPEG sprite. The original upload bytes are never written to storage.
pub async fn write_compact_video(
storage: &Storage,
db_path: &str,
source_extension: &str,
source_data: &[u8],
) -> StorageResult<()> {
let workspace = create_video_workspace().await?;
let source = workspace.join(format!("source.{source_extension}"));
let compact_video = workspace.join("video.mp4");
let thumbnail = workspace.join("preview.jpg");
let result = async {
tokio::fs::write(&source, source_data)
.await
.map_err(|error| StorageError::new("failed to write temporary video", error))?;
create_compact_video_files(&source, &compact_video, &thumbnail).await?;
let video_data = tokio::fs::read(&compact_video)
.await
.map_err(|error| StorageError::new("failed to read transcoded video", error))?;
let thumbnail_data = tokio::fs::read(&thumbnail)
.await
.map_err(|error| StorageError::new("failed to read video preview", error))?;
let thumbnail_path = thumbnail_db_path(db_path);
storage.write(db_path, &video_data).await?;
storage.write(&thumbnail_path, &thumbnail_data).await?;
Ok(())
}
.await;
let _ = tokio::fs::remove_dir_all(&workspace).await;
result
}
/// Create and persist a missing video sprite. In R2 mode the compact source is
/// downloaded only once; subsequent page loads reuse the generated JPEG.
pub async fn ensure_video_thumbnail(storage: &Storage, db_path: &str) -> StorageResult<String> {
let thumbnail_path = thumbnail_db_path(db_path);
if storage.exists(&thumbnail_path).await? {
return Ok(thumbnail_path);
}
let workspace = create_video_workspace().await?;
let extension = db_path.rsplit('.').next().unwrap_or("mp4");
let source = workspace.join(format!("source.{extension}"));
let thumbnail = workspace.join("preview.jpg");
let result = async {
storage.download_to_path(db_path, &source).await?;
create_video_sprite(&source, &thumbnail).await?;
let data = tokio::fs::read(&thumbnail)
.await
.map_err(|error| StorageError::new("failed to read video preview", error))?;
storage.write(&thumbnail_path, &data).await?;
Ok(thumbnail_path.clone())
}
.await;
let _ = tokio::fs::remove_dir_all(&workspace).await;
result
}
pub async fn ensure_media_delivery_paths(
storage: &Storage,
file_type: &str,
db_path: &str,
) -> StorageResult<MediaDeliveryPaths> {
if file_type == "video" && supports_video_preview(db_path) {
let thumbnail_path = ensure_video_thumbnail(storage, db_path).await?;
return Ok(MediaDeliveryPaths {
media_path: db_path.to_string(),
thumbnail_path,
});
}
let thumbnail_path = if file_type == "photo" && supports_thumbnail(db_path) {
ensure_thumbnail(storage, db_path).await?
} else {
db_path.to_string()
};
Ok(MediaDeliveryPaths {
media_path: db_path.to_string(),
thumbnail_path,
})
}
pub fn media_delivery_paths(file_type: &str, db_path: &str) -> MediaDeliveryPaths {
let thumbnail_path = if (file_type == "photo" && supports_thumbnail(db_path))
|| (file_type == "video" && supports_video_preview(db_path))
{
thumbnail_db_path(db_path)
} else {
db_path.to_string()
};
MediaDeliveryPaths {
media_path: db_path.to_string(),
thumbnail_path,
}
}
enum ByteRange {
Full,
Partial { start: u64, end: u64 },
@@ -604,7 +895,27 @@ pub fn storage_error(error: StorageError) -> cot::Error {
#[cfg(test)]
mod tests {
use super::{ByteRange, R2Config, R2Storage, object_key, parse_byte_range};
use super::{
ByteRange, R2Config, R2Storage, normalized_video_db_path, object_key, parse_byte_range,
supports_video_preview, thumbnail_db_path,
};
#[test]
fn creates_stable_normalized_video_paths() {
assert_eq!(
normalized_video_db_path("uploads/1/report.mov"),
"uploads/1/report.web.mp4"
);
assert_eq!(
normalized_video_db_path("uploads/1/report.web.mp4"),
"uploads/1/report.web.mp4"
);
assert_eq!(
thumbnail_db_path("uploads/1/report.web.mp4"),
"uploads/1/report.web.thumb.jpg"
);
assert!(supports_video_preview("report.MOV"));
}
#[test]
fn parses_byte_ranges() {