diff --git a/Cargo.lock b/Cargo.lock index 5a01a6a..4691919 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4763,7 +4763,7 @@ dependencies = [ [[package]] name = "web-petting" -version = "1.0.3" +version = "1.0.4" dependencies = [ "async-trait", "aws-sdk-s3", diff --git a/Cargo.toml b/Cargo.toml index 410a18a..e1d3db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ serde_json = "1" multer = "3" futures = "0.3" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } -tokio = { version = "1", features = ["fs", "rt-multi-thread"] } +tokio = { version = "1", features = ["fs", "process", "rt-multi-thread"] } uuid = { version = "1", features = ["v4"] } base64 = "0.22" urlencoding = "2" diff --git a/Dockerfile b/Dockerfile index 1013d45..3ae4ea8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,11 +7,12 @@ COPY templates ./templates RUN cargo build --release --bins FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ca-certificates ffmpeg && rm -rf /var/lib/apt/lists/* WORKDIR /data ENV WEB_PETTING_UPLOAD_DIR=/data/uploads COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting COPY --from=builder /app/target/release/migrate_uploads_to_r2 /usr/local/bin/migrate_uploads_to_r2 +COPY --from=builder /app/target/release/normalize_videos_to_r2 /usr/local/bin/normalize_videos_to_r2 COPY static /app/static EXPOSE 3000 CMD ["web-petting"] diff --git a/src/admin.rs b/src/admin.rs index dfd128d..22279f7 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -480,23 +480,25 @@ async fn admin_media_view( media: Media, ) -> cot::Result { 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" ); diff --git a/src/bin/migrate_uploads_to_r2.rs b/src/bin/migrate_uploads_to_r2.rs index 8f602c2..b5332a3 100644 --- a/src/bin/migrate_uploads_to_r2.rs +++ b/src/bin/migrate_uploads_to_r2.rs @@ -39,6 +39,29 @@ async fn add_thumbnail( } } +async fn add_video_derivatives( + sources: &mut BTreeMap, + 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> { 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> { .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; + } + _ => {} } } diff --git a/src/bin/normalize_videos_to_r2.rs b/src/bin/normalize_videos_to_r2.rs new file mode 100644 index 0000000..f1dcf0e --- /dev/null +++ b/src/bin/normalize_videos_to_r2.rs @@ -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 { + tokio::fs::try_exists(path).await +} + +async fn run() -> Result<(), Box> { + 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); + } +} diff --git a/src/i18n.rs b/src/i18n.rs index 6c09231..8572f4a 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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", diff --git a/src/public.rs b/src/public.rs index a7c9786..36e995d 100644 --- a/src/public.rs +++ b/src/public.rs @@ -273,26 +273,28 @@ async fn portal_media_view( client_token: &str, ) -> cot::Result { 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 diff --git a/src/uploads.rs b/src/uploads.rs index d31ee21..5de6dfb 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -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 { Ok(thumbnail_path) } +#[derive(Debug)] +pub struct MediaDeliveryPaths { + pub media_path: String, + pub thumbnail_path: String, +} + +async fn create_video_workspace() -> StorageResult { + 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 { + 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::() + .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 { + 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 { + 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() { diff --git a/templates/admin/media.html b/templates/admin/media.html index d219f5b..d1537a0 100644 --- a/templates/admin/media.html +++ b/templates/admin/media.html @@ -28,12 +28,16 @@
{% if item.media.file_type == "photo" %} - + + + {% else %} -
- +
+ {% if !item.media.thumbnail_url.is_empty() %} + + {% endif %}
@@ -88,18 +92,16 @@ object-fit: cover; display: block; } + .media-card .photo-thumb { + width: 100%; + height: 160px; + } .media-card .video-thumb { position: relative; width: 100%; height: 160px; background: #111; } - .media-card .video-thumb video { - width: 100%; - height: 100%; - display: block; - object-fit: cover; - } .media-card .video-play { position: absolute; top: 50%; @@ -132,7 +134,7 @@ .media-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } - .media-card img, .media-card .video-thumb { + .media-card img, .media-card .photo-thumb, .media-card .video-thumb { height: 120px; } } diff --git a/templates/admin/media_upload.html b/templates/admin/media_upload.html index b811f47..732357c 100644 --- a/templates/admin/media_upload.html +++ b/templates/admin/media_upload.html @@ -21,6 +21,7 @@

+
@@ -49,6 +50,7 @@ var form = document.getElementById('uploadForm'); var filesInput = document.getElementById('uploadFiles'); var fileCount = document.getElementById('fileCount'); + var queue = document.getElementById('uploadQueue'); var progress = document.getElementById('uploadProgress'); var bar = document.getElementById('uploadBar'); var percent = document.getElementById('uploadPercent'); @@ -57,9 +59,41 @@ filesInput.addEventListener('change', function() { var n = this.files.length; - fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : ''; + fileCount.textContent = n > 0 ? ('{{ t.media_files_selected }}: ' + n) : ''; + queue.replaceChildren(); + Array.from(this.files).forEach(function(file) { + var item = document.createElement('div'); + item.className = 'upload-queue-item'; + var icon = document.createElement('span'); + icon.className = 'upload-queue-icon'; + icon.textContent = file.type.indexOf('video/') === 0 ? '🎬' : '🖼️'; + var details = document.createElement('div'); + var name = document.createElement('div'); + name.className = 'upload-queue-name'; + name.textContent = file.name; + var state = document.createElement('div'); + state.className = 'upload-queue-state'; + state.textContent = '0%'; + var track = document.createElement('div'); + track.className = 'upload-queue-track'; + var itemBar = document.createElement('div'); + itemBar.className = 'upload-queue-bar'; + track.appendChild(itemBar); + details.append(name, state, track); + item.append(icon, details); + queue.appendChild(item); + }); }); + function updateQueue(state, progressValue, processing) { + queue.querySelectorAll('.upload-queue-item').forEach(function(item) { + item.querySelector('.upload-queue-state').textContent = state; + var itemBar = item.querySelector('.upload-queue-bar'); + itemBar.classList.toggle('is-processing', processing); + if (!processing) itemBar.style.width = progressValue + '%'; + }); + } + form.addEventListener('submit', function(e) { e.preventDefault(); if (!filesInput.files.length) return; @@ -69,7 +103,9 @@ progress.style.display = 'block'; submitBtn.disabled = true; - submitBtn.textContent = 'Загрузка...'; + submitBtn.textContent = '{{ t.media_upload_sending }}'; + statusText.textContent = '{{ t.media_upload_sending }}'; + bar.classList.remove('is-processing'); bar.style.width = '0%'; percent.textContent = '0%'; @@ -78,24 +114,42 @@ var pct = Math.round(ev.loaded / ev.total * 100); bar.style.width = pct + '%'; percent.textContent = pct + '%'; - if (pct === 100) statusText.textContent = 'Обработка...'; + updateQueue(pct + '%', pct, false); + if (pct === 100) { + statusText.textContent = '{{ t.media_upload_processing }}'; + percent.textContent = '•••'; + bar.classList.add('is-processing'); + updateQueue('{{ t.media_upload_processing }}', 100, true); + } + }); + xhr.upload.addEventListener('load', function() { + statusText.textContent = '{{ t.media_upload_processing }}'; + percent.textContent = '•••'; + bar.classList.add('is-processing'); + updateQueue('{{ t.media_upload_processing }}', 100, true); }); xhr.addEventListener('load', function() { if (xhr.status >= 200 && xhr.status < 400) { bar.style.width = '100%'; + bar.classList.remove('is-processing'); percent.textContent = '100%'; - statusText.textContent = 'Готово!'; + statusText.textContent = '{{ t.media_upload_done }}'; + updateQueue('{{ t.media_upload_done }}', 100, false); setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300); } else { + bar.classList.remove('is-processing'); statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')'; + updateQueue('Ошибка загрузки', 0, false); submitBtn.disabled = false; submitBtn.textContent = '{{ t.media_upload }}'; } }); xhr.addEventListener('error', function() { - statusText.textContent = 'Ошибка соединения'; + bar.classList.remove('is-processing'); + statusText.textContent = '{{ t.media_upload_connection_error }}'; + updateQueue('{{ t.media_upload_connection_error }}', 0, false); submitBtn.disabled = false; submitBtn.textContent = '{{ t.media_upload }}'; }); @@ -106,4 +160,17 @@ })();
+ + {% endblock %} diff --git a/templates/admin/schedule_edit.html b/templates/admin/schedule_edit.html index 7dc8af0..a316cf7 100644 --- a/templates/admin/schedule_edit.html +++ b/templates/admin/schedule_edit.html @@ -114,12 +114,16 @@
{% if m.file_type == "photo" %} - + + + {% else %} -

+
@@ -238,18 +243,16 @@ object-fit: cover; display: block; } + .visit-media-item .photo-thumb { + width: 100%; + height: 80px; + } .visit-media-item .video-thumb-sm { position: relative; width: 100%; height: 80px; background: #111; } - .visit-media-item .video-thumb-sm video { - width: 100%; - height: 100%; - display: block; - object-fit: cover; - } .visit-media-item .video-play { position: absolute; top: 50%; @@ -297,6 +300,16 @@ max-width: 420px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); } + .upload-queue { display:flex; flex-direction:column; gap:.4rem; margin-top:.65rem; } + .upload-queue:empty { display:none; } + .upload-queue-item { display:grid; grid-template-columns:34px minmax(0,1fr); gap:.55rem; align-items:center; padding:.45rem .55rem; background:#f7f6ff; border-radius:8px; } + .upload-queue-icon { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:#ebe8ff; font-size:1.05rem; } + .upload-queue-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; color:#494467; } + .upload-queue-state { font-size:.68rem; color:#8a84a5; } + .upload-queue-track { height:3px; overflow:hidden; border-radius:99px; background:#dedbea; margin-top:.25rem; } + .upload-queue-bar { height:100%; width:0; background:#7567e8; transition:width .2s; } + #uploadBar.is-processing, .upload-queue-bar.is-processing { width:35% !important; animation:upload-processing 1.15s ease-in-out infinite; } + @keyframes upload-processing { from { transform:translateX(-110%); } to { transform:translateX(300%); } }