Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m44s
Build and Publish / Build and Publish Docker Image (push) Successful in 1m44s
This commit is contained in:
Generated
+1
-1
@@ -4763,7 +4763,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ serde_json = "1"
|
|||||||
multer = "3"
|
multer = "3"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
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"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|||||||
+2
-1
@@ -7,11 +7,12 @@ COPY templates ./templates
|
|||||||
RUN cargo build --release --bins
|
RUN cargo build --release --bins
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
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
|
WORKDIR /data
|
||||||
ENV WEB_PETTING_UPLOAD_DIR=/data/uploads
|
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/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/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
|
COPY static /app/static
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["web-petting"]
|
CMD ["web-petting"]
|
||||||
|
|||||||
+41
-30
@@ -480,23 +480,25 @@ async fn admin_media_view(
|
|||||||
media: Media,
|
media: Media,
|
||||||
) -> cot::Result<MediaView> {
|
) -> cot::Result<MediaView> {
|
||||||
let media_id = media.id.unwrap();
|
let media_id = media.id.unwrap();
|
||||||
|
let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
|
||||||
let url = storage
|
let url = storage
|
||||||
.public_url(&media.file_path, format!("/admin/uploads/{media_id}"))
|
.public_url(&delivery.media_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"),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(crate::uploads::storage_error)?;
|
.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 {
|
Ok(MediaView {
|
||||||
media,
|
media,
|
||||||
url,
|
url,
|
||||||
@@ -2450,9 +2452,10 @@ async fn media_upload_submit(
|
|||||||
let file_path = if file_type == "photo" {
|
let file_path = if file_type == "photo" {
|
||||||
save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await?
|
save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await?
|
||||||
} else {
|
} else {
|
||||||
let path = crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
|
let original_path =
|
||||||
storage
|
crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
|
||||||
.write(&path, &data)
|
let path = crate::uploads::normalized_video_db_path(&original_path);
|
||||||
|
crate::uploads::write_compact_video(&storage, &path, &ext, &data)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::uploads::storage_error)?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
path
|
path
|
||||||
@@ -2542,20 +2545,26 @@ async fn serve_upload_thumbnail(
|
|||||||
return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response();
|
return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response();
|
||||||
}
|
}
|
||||||
let media = match query!(Media, $id == media_id).get(&db).await? {
|
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(),
|
_ => return Html::new("404").into_response(),
|
||||||
};
|
};
|
||||||
let storage = crate::uploads::Storage::load(&db).await?;
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
let display_path = if crate::uploads::supports_thumbnail(&media.file_path) {
|
let display_path = match crate::uploads::ensure_media_delivery_paths(
|
||||||
match crate::uploads::ensure_thumbnail(&storage, &media.file_path).await {
|
&storage,
|
||||||
Ok(path) => path,
|
&media.file_type,
|
||||||
Err(error) => {
|
&media.file_path,
|
||||||
tracing::warn!(media_id, %error, "failed to create media thumbnail");
|
)
|
||||||
|
.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()
|
media.file_path.clone()
|
||||||
|
} else {
|
||||||
|
return Html::new("404").into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
media.file_path.clone()
|
|
||||||
};
|
};
|
||||||
if storage.is_r2() {
|
if storage.is_r2() {
|
||||||
let url = storage
|
let url = storage
|
||||||
@@ -2617,17 +2626,19 @@ async fn serve_upload(
|
|||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
let storage = crate::uploads::Storage::load(&db).await?;
|
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() {
|
if storage.is_r2() {
|
||||||
let url = storage
|
let url = storage
|
||||||
.public_url(&media.file_path, String::new())
|
.public_url(&display_path, String::new())
|
||||||
.await
|
.await
|
||||||
.map_err(crate::uploads::storage_error)?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
return Redirect::new(url).into_response();
|
return Redirect::new(url).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
match crate::uploads::ranged_local_file_response(
|
match crate::uploads::ranged_local_file_response(
|
||||||
&media.file_path,
|
&display_path,
|
||||||
crate::uploads::content_type_for_path(&media.file_path),
|
crate::uploads::content_type_for_path(&display_path),
|
||||||
range.as_deref(),
|
range.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -2637,8 +2648,8 @@ async fn serve_upload(
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "uploads",
|
target: "uploads",
|
||||||
media_id,
|
media_id,
|
||||||
db_path = %media.file_path,
|
db_path = %display_path,
|
||||||
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
|
resolved_path = %crate::uploads::resolved_display_path(&display_path),
|
||||||
error = %err,
|
error = %err,
|
||||||
"uploaded file is missing or unreadable"
|
"uploaded file is missing or unreadable"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>> {
|
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let db = Database::new(database_url()).await?;
|
let db = Database::new(database_url()).await?;
|
||||||
let storage = uploads::Storage::load_configured_r2(&db).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())
|
.entry(media.file_path.clone())
|
||||||
.and_modify(|is_required| *is_required |= required)
|
.and_modify(|is_required| *is_required |= required)
|
||||||
.or_insert(required);
|
.or_insert(required);
|
||||||
if media.file_type == "photo"
|
match media.file_type.as_str() {
|
||||||
&& !add_thumbnail(&mut sources, &media.file_path, required).await
|
"photo" if !add_thumbnail(&mut sources, &media.file_path, required).await => {
|
||||||
{
|
preparation_failed = true;
|
||||||
preparation_failed = true;
|
}
|
||||||
|
"video" if !add_video_derivatives(&mut sources, &media.file_path, required).await => {
|
||||||
|
preparation_failed = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -303,6 +303,11 @@ pub struct Translations {
|
|||||||
pub media_delete: &'static str,
|
pub media_delete: &'static str,
|
||||||
pub media_delete_confirm: &'static str,
|
pub media_delete_confirm: &'static str,
|
||||||
pub media_all_clients: &'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
|
// Client portal
|
||||||
pub portal_title: &'static str,
|
pub portal_title: &'static str,
|
||||||
@@ -429,7 +434,7 @@ static RU: Translations = Translations {
|
|||||||
settings_r2_access_key_id: "R2 Access Key ID",
|
settings_r2_access_key_id: "R2 Access Key ID",
|
||||||
settings_r2_secret_access_key: "R2 Secret Access Key",
|
settings_r2_secret_access_key: "R2 Secret Access Key",
|
||||||
settings_r2_secret_unchanged: "Секрет уже сохранён; оставьте поле пустым, чтобы не менять его",
|
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_r2_error_incomplete: "R2 не включён: проверьте Account ID, имя бакета, Access Key ID и Secret Access Key.",
|
||||||
settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления",
|
settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления",
|
||||||
settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.",
|
settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.",
|
||||||
@@ -463,6 +468,11 @@ static RU: Translations = Translations {
|
|||||||
media_delete: "Удалить",
|
media_delete: "Удалить",
|
||||||
media_delete_confirm: "Удалить этот файл?",
|
media_delete_confirm: "Удалить этот файл?",
|
||||||
media_all_clients: "Все клиенты",
|
media_all_clients: "Все клиенты",
|
||||||
|
media_files_selected: "Выбрано файлов",
|
||||||
|
media_upload_sending: "Загрузка на сервер...",
|
||||||
|
media_upload_processing: "Конвертация и загрузка в R2...",
|
||||||
|
media_upload_done: "Готово — обновляем медиагалерею...",
|
||||||
|
media_upload_connection_error: "Ошибка соединения",
|
||||||
|
|
||||||
portal_title: "Визиты",
|
portal_title: "Визиты",
|
||||||
portal_upcoming: "Предстоящие визиты",
|
portal_upcoming: "Предстоящие визиты",
|
||||||
@@ -689,7 +699,7 @@ static EN: Translations = Translations {
|
|||||||
settings_r2_access_key_id: "R2 Access Key ID",
|
settings_r2_access_key_id: "R2 Access Key ID",
|
||||||
settings_r2_secret_access_key: "R2 Secret Access Key",
|
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_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_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_enabled: "Allow client browser notifications",
|
||||||
settings_client_notifications_help: "Shows clients the completed-visit notification setting.",
|
settings_client_notifications_help: "Shows clients the completed-visit notification setting.",
|
||||||
@@ -723,6 +733,11 @@ static EN: Translations = Translations {
|
|||||||
media_delete: "Delete",
|
media_delete: "Delete",
|
||||||
media_delete_confirm: "Delete this file?",
|
media_delete_confirm: "Delete this file?",
|
||||||
media_all_clients: "All clients",
|
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_title: "Visits",
|
||||||
portal_upcoming: "Upcoming visits",
|
portal_upcoming: "Upcoming visits",
|
||||||
|
|||||||
+37
-28
@@ -273,26 +273,28 @@ async fn portal_media_view(
|
|||||||
client_token: &str,
|
client_token: &str,
|
||||||
) -> cot::Result<PortalMediaView> {
|
) -> cot::Result<PortalMediaView> {
|
||||||
let media_id = media.id.unwrap();
|
let media_id = media.id.unwrap();
|
||||||
|
let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
|
||||||
let url = storage
|
let url = storage
|
||||||
.public_url(
|
.public_url(
|
||||||
&media.file_path,
|
&delivery.media_path,
|
||||||
format!("/client/{client_token}/media/{media_id}"),
|
format!("/client/{client_token}/media/{media_id}"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::uploads::storage_error)?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
let thumbnail_path =
|
let thumbnail_fallback = format!("/client/{client_token}/media/{media_id}/thumbnail");
|
||||||
if media.file_type == "photo" && crate::uploads::supports_thumbnail(&media.file_path) {
|
let thumbnail_url = if storage.is_r2()
|
||||||
crate::uploads::thumbnail_db_path(&media.file_path)
|
&& storage
|
||||||
} else {
|
.exists(&delivery.thumbnail_path)
|
||||||
media.file_path.clone()
|
.await
|
||||||
};
|
.map_err(crate::uploads::storage_error)?
|
||||||
let thumbnail_url = storage
|
{
|
||||||
.public_url(
|
storage
|
||||||
&thumbnail_path,
|
.public_url(&delivery.thumbnail_path, thumbnail_fallback)
|
||||||
format!("/client/{client_token}/media/{media_id}/thumbnail"),
|
.await
|
||||||
)
|
.map_err(crate::uploads::storage_error)?
|
||||||
.await
|
} else {
|
||||||
.map_err(crate::uploads::storage_error)?;
|
thumbnail_fallback
|
||||||
|
};
|
||||||
Ok(PortalMediaView {
|
Ok(PortalMediaView {
|
||||||
media,
|
media,
|
||||||
url,
|
url,
|
||||||
@@ -858,17 +860,19 @@ async fn portal_media(
|
|||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
let storage = crate::uploads::Storage::load(&db).await?;
|
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() {
|
if storage.is_r2() {
|
||||||
let url = storage
|
let url = storage
|
||||||
.public_url(&media.file_path, String::new())
|
.public_url(&display_path, String::new())
|
||||||
.await
|
.await
|
||||||
.map_err(crate::uploads::storage_error)?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
return Redirect::new(url).into_response();
|
return Redirect::new(url).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
match crate::uploads::ranged_local_file_response(
|
match crate::uploads::ranged_local_file_response(
|
||||||
&media.file_path,
|
&display_path,
|
||||||
crate::uploads::content_type_for_path(&media.file_path),
|
crate::uploads::content_type_for_path(&display_path),
|
||||||
range.as_deref(),
|
range.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -878,8 +882,8 @@ async fn portal_media(
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "uploads",
|
target: "uploads",
|
||||||
media_id,
|
media_id,
|
||||||
db_path = %media.file_path,
|
db_path = %display_path,
|
||||||
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
|
resolved_path = %crate::uploads::resolved_display_path(&display_path),
|
||||||
error = %err,
|
error = %err,
|
||||||
"portal media file is missing or unreadable"
|
"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? {
|
let media = match query!(Media, $id == media_id).get(&db).await? {
|
||||||
Some(media)
|
Some(media)
|
||||||
if media.client_id.primary_key().unwrap() == client.id.unwrap()
|
if media.client_id.primary_key().unwrap() == client.id.unwrap()
|
||||||
&& media.status == "active"
|
&& media.status == "active" =>
|
||||||
&& media.file_type == "photo" =>
|
|
||||||
{
|
{
|
||||||
media
|
media
|
||||||
}
|
}
|
||||||
@@ -915,16 +918,22 @@ async fn portal_media_thumbnail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let storage = crate::uploads::Storage::load(&db).await?;
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
let display_path = if crate::uploads::supports_thumbnail(&media.file_path) {
|
let display_path = match crate::uploads::ensure_media_delivery_paths(
|
||||||
match crate::uploads::ensure_thumbnail(&storage, &media.file_path).await {
|
&storage,
|
||||||
Ok(path) => path,
|
&media.file_type,
|
||||||
Err(error) => {
|
&media.file_path,
|
||||||
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
|
)
|
||||||
|
.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()
|
media.file_path.clone()
|
||||||
|
} else {
|
||||||
|
return Html::new("404").into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
media.file_path.clone()
|
|
||||||
};
|
};
|
||||||
if storage.is_r2() {
|
if storage.is_r2() {
|
||||||
let url = storage
|
let url = storage
|
||||||
|
|||||||
+312
-1
@@ -11,11 +11,15 @@ use cot::response::Response;
|
|||||||
use cot::{Body, StatusCode};
|
use cot::{Body, StatusCode};
|
||||||
use image::codecs::jpeg::JpegEncoder;
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::models::Setting;
|
use crate::models::Setting;
|
||||||
|
|
||||||
const THUMBNAIL_MAX_DIMENSION: u32 = 360;
|
const THUMBNAIL_MAX_DIMENSION: u32 = 360;
|
||||||
const THUMBNAIL_JPEG_QUALITY: u8 = 70;
|
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 PRESIGNED_URL_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
|
||||||
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
||||||
@@ -171,6 +175,25 @@ impl R2Storage {
|
|||||||
Ok(bytes.into_bytes().to_vec())
|
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<()> {
|
async fn delete(&self, db_path: &str) -> StorageResult<()> {
|
||||||
self.client
|
self.client
|
||||||
.delete_object()
|
.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.
|
/// Stream a local PVC file into R2 without loading large videos into memory.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn upload_local_copy(&self, db_path: &str, local_path: &Path) -> StorageResult<()> {
|
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 {
|
pub fn supports_thumbnail(db_path: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
db_path
|
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 {
|
pub fn content_type_for_path(path: &str) -> &'static str {
|
||||||
match path
|
match path
|
||||||
.rsplit('.')
|
.rsplit('.')
|
||||||
@@ -481,6 +538,240 @@ pub async fn ensure_local_thumbnail(db_path: &str) -> StorageResult<String> {
|
|||||||
Ok(thumbnail_path)
|
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 {
|
enum ByteRange {
|
||||||
Full,
|
Full,
|
||||||
Partial { start: u64, end: u64 },
|
Partial { start: u64, end: u64 },
|
||||||
@@ -604,7 +895,27 @@ pub fn storage_error(error: StorageError) -> cot::Error {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn parses_byte_ranges() {
|
fn parses_byte_ranges() {
|
||||||
|
|||||||
+12
-10
@@ -28,12 +28,16 @@
|
|||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
{% if item.media.file_type == "photo" %}
|
{% if item.media.file_type == "photo" %}
|
||||||
<a href="{{ item.media.url }}" data-lightbox="photo">
|
<a href="{{ item.media.url }}" data-lightbox="photo">
|
||||||
<img src="{{ item.media.thumbnail_url }}" alt="" loading="lazy">
|
<span class="photo-thumb media-loading-frame is-loading">
|
||||||
|
<img src="{{ item.media.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ item.media.url }}" data-lightbox="video">
|
<a href="{{ item.media.url }}" data-lightbox="video">
|
||||||
<div class="video-thumb">
|
<div class="video-thumb media-loading-frame{% if !item.media.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||||
<video src="{{ item.media.url }}#t=0.1" preload="metadata" muted playsinline></video>
|
{% if !item.media.thumbnail_url.is_empty() %}
|
||||||
|
<img class="video-preview-sprite" src="{{ item.media.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
|
||||||
|
{% endif %}
|
||||||
<span class="video-play">▶</span>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -88,18 +92,16 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.media-card .photo-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
.media-card .video-thumb {
|
.media-card .video-thumb {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
.media-card .video-thumb video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.media-card .video-play {
|
.media-card .video-play {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -132,7 +134,7 @@
|
|||||||
.media-grid {
|
.media-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
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;
|
height: 120px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||||
</div>
|
</div>
|
||||||
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||||
|
<div id="uploadQueue" class="upload-queue"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
var form = document.getElementById('uploadForm');
|
var form = document.getElementById('uploadForm');
|
||||||
var filesInput = document.getElementById('uploadFiles');
|
var filesInput = document.getElementById('uploadFiles');
|
||||||
var fileCount = document.getElementById('fileCount');
|
var fileCount = document.getElementById('fileCount');
|
||||||
|
var queue = document.getElementById('uploadQueue');
|
||||||
var progress = document.getElementById('uploadProgress');
|
var progress = document.getElementById('uploadProgress');
|
||||||
var bar = document.getElementById('uploadBar');
|
var bar = document.getElementById('uploadBar');
|
||||||
var percent = document.getElementById('uploadPercent');
|
var percent = document.getElementById('uploadPercent');
|
||||||
@@ -57,9 +59,41 @@
|
|||||||
|
|
||||||
filesInput.addEventListener('change', function() {
|
filesInput.addEventListener('change', function() {
|
||||||
var n = this.files.length;
|
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) {
|
form.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!filesInput.files.length) return;
|
if (!filesInput.files.length) return;
|
||||||
@@ -69,7 +103,9 @@
|
|||||||
|
|
||||||
progress.style.display = 'block';
|
progress.style.display = 'block';
|
||||||
submitBtn.disabled = true;
|
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%';
|
bar.style.width = '0%';
|
||||||
percent.textContent = '0%';
|
percent.textContent = '0%';
|
||||||
|
|
||||||
@@ -78,24 +114,42 @@
|
|||||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
bar.style.width = pct + '%';
|
bar.style.width = pct + '%';
|
||||||
percent.textContent = 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() {
|
xhr.addEventListener('load', function() {
|
||||||
if (xhr.status >= 200 && xhr.status < 400) {
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
bar.style.width = '100%';
|
bar.style.width = '100%';
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
percent.textContent = '100%';
|
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);
|
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
|
||||||
} else {
|
} else {
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||||
|
updateQueue('Ошибка загрузки', 0, false);
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = '{{ t.media_upload }}';
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', function() {
|
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.disabled = false;
|
||||||
submitBtn.textContent = '{{ t.media_upload }}';
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
});
|
});
|
||||||
@@ -106,4 +160,17 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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%); } }
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -114,12 +114,16 @@
|
|||||||
<div class="visit-media-item">
|
<div class="visit-media-item">
|
||||||
{% if m.file_type == "photo" %}
|
{% if m.file_type == "photo" %}
|
||||||
<a href="{{ m.url }}" data-lightbox="photo">
|
<a href="{{ m.url }}" data-lightbox="photo">
|
||||||
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy">
|
<span class="photo-thumb media-loading-frame is-loading">
|
||||||
|
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ m.url }}" data-lightbox="video">
|
<a href="{{ m.url }}" data-lightbox="video">
|
||||||
<div class="video-thumb-sm">
|
<div class="video-thumb-sm media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||||
<video src="{{ m.url }}#t=0.1" preload="metadata" muted playsinline></video>
|
{% if !m.thumbnail_url.is_empty() %}
|
||||||
|
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
|
||||||
|
{% endif %}
|
||||||
<span class="video-play">▶</span>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -163,6 +167,7 @@
|
|||||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||||
</div>
|
</div>
|
||||||
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||||
|
<div id="uploadQueue" class="upload-queue"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_caption }}</label>
|
<label class="label">{{ t.media_caption }}</label>
|
||||||
@@ -238,18 +243,16 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.visit-media-item .photo-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
.visit-media-item .video-thumb-sm {
|
.visit-media-item .video-thumb-sm {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
.visit-media-item .video-thumb-sm video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.visit-media-item .video-play {
|
.visit-media-item .video-play {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -297,6 +300,16 @@
|
|||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
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%); } }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -316,6 +329,7 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|||||||
var form = document.getElementById('uploadForm');
|
var form = document.getElementById('uploadForm');
|
||||||
var filesInput = document.getElementById('uploadFiles');
|
var filesInput = document.getElementById('uploadFiles');
|
||||||
var fileCount = document.getElementById('fileCount');
|
var fileCount = document.getElementById('fileCount');
|
||||||
|
var queue = document.getElementById('uploadQueue');
|
||||||
var progress = document.getElementById('uploadProgress');
|
var progress = document.getElementById('uploadProgress');
|
||||||
var bar = document.getElementById('uploadBar');
|
var bar = document.getElementById('uploadBar');
|
||||||
var percent = document.getElementById('uploadPercent');
|
var percent = document.getElementById('uploadPercent');
|
||||||
@@ -336,9 +350,41 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|||||||
// Show selected file count
|
// Show selected file count
|
||||||
filesInput.addEventListener('change', function() {
|
filesInput.addEventListener('change', function() {
|
||||||
var n = this.files.length;
|
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 + '%';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Submit via XHR for progress tracking
|
// Submit via XHR for progress tracking
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -350,7 +396,9 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|||||||
// Show progress bar, disable submit
|
// Show progress bar, disable submit
|
||||||
progress.style.display = 'block';
|
progress.style.display = 'block';
|
||||||
submitBtn.disabled = true;
|
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%';
|
bar.style.width = '0%';
|
||||||
percent.textContent = '0%';
|
percent.textContent = '0%';
|
||||||
|
|
||||||
@@ -359,25 +407,43 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|||||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
bar.style.width = pct + '%';
|
bar.style.width = pct + '%';
|
||||||
percent.textContent = 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() {
|
xhr.addEventListener('load', function() {
|
||||||
if (xhr.status >= 200 && xhr.status < 400) {
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
bar.style.width = '100%';
|
bar.style.width = '100%';
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
percent.textContent = '100%';
|
percent.textContent = '100%';
|
||||||
statusText.textContent = 'Готово!';
|
statusText.textContent = '{{ t.media_upload_done }}';
|
||||||
|
updateQueue('{{ t.media_upload_done }}', 100, false);
|
||||||
// Reload page to show uploaded media
|
// Reload page to show uploaded media
|
||||||
setTimeout(function() { window.location.reload(); }, 300);
|
setTimeout(function() { window.location.reload(); }, 300);
|
||||||
} else {
|
} else {
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||||
|
updateQueue('Ошибка загрузки', 0, false);
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = '{{ t.media_upload }}';
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', function() {
|
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.disabled = false;
|
||||||
submitBtn.textContent = '{{ t.media_upload }}';
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
.media-row img {
|
.media-row img {
|
||||||
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
|
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
.media-row .media-thumb-frame {
|
||||||
|
width: 80px; height: 60px; border-radius: 6px;
|
||||||
|
}
|
||||||
.media-row .vid-thumb {
|
.media-row .vid-thumb {
|
||||||
position: relative; width: 80px; height: 60px; border-radius: 6px;
|
position: relative; width: 80px; height: 60px; border-radius: 6px;
|
||||||
overflow: hidden; background: #111;
|
overflow: hidden; background: #111;
|
||||||
@@ -192,12 +195,16 @@
|
|||||||
{% for m in &pv.media %}
|
{% for m in &pv.media %}
|
||||||
{% if m.file_type == "photo" %}
|
{% if m.file_type == "photo" %}
|
||||||
<a href="{{ m.url }}" data-lightbox="photo">
|
<a href="{{ m.url }}" data-lightbox="photo">
|
||||||
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy">
|
<span class="media-thumb-frame media-loading-frame is-loading">
|
||||||
|
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ m.url }}" data-lightbox="video">
|
<a href="{{ m.url }}" data-lightbox="video">
|
||||||
<div class="vid-thumb">
|
<div class="vid-thumb media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||||
<video src="{{ m.url }}#t=0.1" preload="metadata" muted playsinline></video>
|
{% if !m.thumbnail_url.is_empty() %}
|
||||||
|
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
|
||||||
|
{% endif %}
|
||||||
<span class="video-play">▶</span>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,49 +1,164 @@
|
|||||||
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)">
|
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)">
|
||||||
<button class="lightbox-close" onclick="closeLightbox(event)">×</button>
|
<button class="lightbox-close" type="button" onclick="closeLightbox(event)">×</button>
|
||||||
<img id="lightboxImg" src="" alt="">
|
<div class="lightbox-stage" id="lightboxStage">
|
||||||
<video id="lightboxVideo" controls style="display:none;"></video>
|
<span class="lightbox-loader" aria-hidden="true"></span>
|
||||||
|
<img id="lightboxImg" src="" alt="">
|
||||||
|
<video id="lightboxVideo" controls playsinline preload="auto" style="display:none;"></video>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
|
.media-loading-frame {
|
||||||
|
position: relative; display: block; overflow: hidden; background: #eceaf5;
|
||||||
|
}
|
||||||
|
.media-loading-frame::after, .lightbox-loader {
|
||||||
|
content: ""; position: absolute; z-index: 3; top: 50%; left: 50%;
|
||||||
|
width: 22px; height: 22px; margin: -11px 0 0 -11px;
|
||||||
|
border: 3px solid rgba(124,108,255,.22); border-top-color: #7c6cff;
|
||||||
|
border-radius: 50%; animation: media-spinner .75s linear infinite;
|
||||||
|
}
|
||||||
|
.media-loading-frame:not(.is-loading)::after { display: none; }
|
||||||
|
.media-loading-frame img[data-media-load] { opacity: 0; transition: opacity .18s ease; }
|
||||||
|
.media-loading-frame.is-loaded img[data-media-load] { opacity: 1; }
|
||||||
|
.media-loading-frame.is-error::after {
|
||||||
|
display: block; content: "!"; width: 24px; height: 24px; margin: -12px 0 0 -12px;
|
||||||
|
border: 0; animation: none; color: #9b93bb; font-weight: 700; text-align: center;
|
||||||
|
}
|
||||||
|
.video-preview-sprite {
|
||||||
|
display: block !important; width: 400% !important; max-width: none !important;
|
||||||
|
height: 100% !important; object-fit: fill !important;
|
||||||
|
transform: translateX(0); transition: transform .08s linear;
|
||||||
|
}
|
||||||
|
@keyframes media-spinner { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
.lightbox-overlay {
|
.lightbox-overlay {
|
||||||
display:none; position:fixed; inset:0; z-index:200;
|
display:none; position:fixed; inset:0; z-index:200;
|
||||||
background:rgba(0,0,0,0.85); align-items:center; justify-content:center;
|
background:rgba(9,8,18,.9); align-items:center; justify-content:center;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.lightbox-overlay.is-open { display:flex; }
|
.lightbox-overlay.is-open { display:flex; }
|
||||||
.lightbox-overlay img, .lightbox-overlay video {
|
.lightbox-stage {
|
||||||
max-width:92vw; max-height:88vh; border-radius:8px; object-fit:contain;
|
position: relative; display: flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 96px; min-height: 96px; max-width: 94vw; max-height: 90vh;
|
||||||
}
|
}
|
||||||
|
.lightbox-stage img, .lightbox-stage video {
|
||||||
|
max-width:92vw; max-height:88vh; border-radius:10px; object-fit:contain;
|
||||||
|
background:#090909; box-shadow:0 18px 60px rgba(0,0,0,.42);
|
||||||
|
}
|
||||||
|
.lightbox-stage.is-loading img, .lightbox-stage.is-loading video { opacity: .18; }
|
||||||
|
.lightbox-stage:not(.is-loading) .lightbox-loader { display: none; }
|
||||||
|
.lightbox-stage.is-error .lightbox-loader {
|
||||||
|
display: block; animation: none; border: 0; color: white;
|
||||||
|
}
|
||||||
|
.lightbox-stage.is-error .lightbox-loader::after { content: "!"; font-size: 2rem; }
|
||||||
.lightbox-close {
|
.lightbox-close {
|
||||||
position:absolute; top:0.75rem; right:1rem; background:none; border:none;
|
position:absolute; top:0.75rem; right:1rem; background:rgba(0,0,0,.25); border:none;
|
||||||
color:#fff; font-size:2.2rem; cursor:pointer; line-height:1; z-index:201;
|
color:#fff; font-size:2.2rem; cursor:pointer; line-height:1; z-index:201;
|
||||||
|
width:44px; height:44px; border-radius:50%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
|
(function() {
|
||||||
|
function finishThumbnail(image, loaded) {
|
||||||
|
var frame = image.closest('.media-loading-frame');
|
||||||
|
if (!frame) return;
|
||||||
|
frame.classList.remove('is-loading');
|
||||||
|
frame.classList.add(loaded ? 'is-loaded' : 'is-error');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('img[data-media-load]').forEach(function(image) {
|
||||||
|
image.addEventListener('load', function() { finishThumbnail(image, true); });
|
||||||
|
image.addEventListener('error', function() { finishThumbnail(image, false); });
|
||||||
|
if (image.complete) finishThumbnail(image, image.naturalWidth > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-video-preview]').forEach(function(preview) {
|
||||||
|
var sprite = preview.querySelector('[data-preview-frames]');
|
||||||
|
if (!sprite) return;
|
||||||
|
var timer;
|
||||||
|
var frame = 0;
|
||||||
|
var frames = Number(sprite.dataset.previewFrames) || 4;
|
||||||
|
function showFrame() {
|
||||||
|
sprite.style.transform = 'translateX(-' + (frame * 100 / frames) + '%)';
|
||||||
|
}
|
||||||
|
function start() {
|
||||||
|
if (timer || !preview.classList.contains('is-loaded')) return;
|
||||||
|
frame = 1;
|
||||||
|
showFrame();
|
||||||
|
timer = window.setInterval(function() {
|
||||||
|
frame = (frame + 1) % frames;
|
||||||
|
showFrame();
|
||||||
|
}, 650);
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
frame = 0;
|
||||||
|
showFrame();
|
||||||
|
}
|
||||||
|
preview.addEventListener('pointerenter', start);
|
||||||
|
preview.addEventListener('pointerleave', stop);
|
||||||
|
preview.closest('a').addEventListener('focus', start);
|
||||||
|
preview.closest('a').addEventListener('blur', stop);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function setLightboxState(state) {
|
||||||
|
var stage = document.getElementById('lightboxStage');
|
||||||
|
stage.classList.remove('is-loading', 'is-error');
|
||||||
|
if (state) stage.classList.add(state);
|
||||||
|
}
|
||||||
function openLightbox(url, isVideo) {
|
function openLightbox(url, isVideo) {
|
||||||
var lb = document.getElementById('lightbox');
|
var lb = document.getElementById('lightbox');
|
||||||
var img = document.getElementById('lightboxImg');
|
var img = document.getElementById('lightboxImg');
|
||||||
var vid = document.getElementById('lightboxVideo');
|
var vid = document.getElementById('lightboxVideo');
|
||||||
|
setLightboxState('is-loading');
|
||||||
|
lb.classList.add('is-open');
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
img.style.display = 'none';
|
img.style.display = 'none';
|
||||||
|
img.removeAttribute('src');
|
||||||
vid.style.display = '';
|
vid.style.display = '';
|
||||||
vid.src = url;
|
vid.src = url;
|
||||||
|
vid.load();
|
||||||
|
var playback = vid.play();
|
||||||
|
if (playback) playback.catch(function() {});
|
||||||
} else {
|
} else {
|
||||||
vid.style.display = 'none';
|
vid.style.display = 'none';
|
||||||
vid.pause && vid.pause(); vid.src = '';
|
vid.pause();
|
||||||
|
vid.removeAttribute('src');
|
||||||
|
vid.load();
|
||||||
img.style.display = '';
|
img.style.display = '';
|
||||||
img.src = url;
|
img.src = url;
|
||||||
}
|
}
|
||||||
lb.classList.add('is-open');
|
|
||||||
}
|
}
|
||||||
function closeLightbox(e) {
|
function closeLightbox(event) {
|
||||||
if (e && e.target !== document.getElementById('lightbox') && e.target.className !== 'lightbox-close') return;
|
|
||||||
var lb = document.getElementById('lightbox');
|
var lb = document.getElementById('lightbox');
|
||||||
|
if (event && event.target !== lb && !event.target.closest('.lightbox-close')) return;
|
||||||
lb.classList.remove('is-open');
|
lb.classList.remove('is-open');
|
||||||
var vid = document.getElementById('lightboxVideo');
|
var vid = document.getElementById('lightboxVideo');
|
||||||
vid.pause && vid.pause(); vid.src = '';
|
vid.pause();
|
||||||
|
vid.removeAttribute('src');
|
||||||
|
vid.load();
|
||||||
|
var img = document.getElementById('lightboxImg');
|
||||||
|
img.removeAttribute('src');
|
||||||
|
setLightboxState(null);
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeLightbox(null); });
|
document.getElementById('lightboxImg').addEventListener('load', function() { setLightboxState(null); });
|
||||||
document.addEventListener('click', function(e) {
|
document.getElementById('lightboxImg').addEventListener('error', function() { setLightboxState('is-error'); });
|
||||||
var a = e.target.closest('[data-lightbox]');
|
var lightboxVideo = document.getElementById('lightboxVideo');
|
||||||
if (a) { e.preventDefault(); openLightbox(a.href, a.dataset.lightbox === 'video'); }
|
['loadeddata', 'canplay', 'playing'].forEach(function(name) {
|
||||||
|
lightboxVideo.addEventListener(name, function() { setLightboxState(null); });
|
||||||
|
});
|
||||||
|
['waiting', 'stalled', 'seeking'].forEach(function(name) {
|
||||||
|
lightboxVideo.addEventListener(name, function() { setLightboxState('is-loading'); });
|
||||||
|
});
|
||||||
|
lightboxVideo.addEventListener('error', function() { setLightboxState('is-error'); });
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Escape') closeLightbox(null);
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
var link = event.target.closest('[data-lightbox]');
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
openLightbox(link.href, link.dataset.lightbox === 'video');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user