From 60aa5146b43c7a605f7dfa1f1d87d409729cecb9 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 29 Jun 2026 20:42:11 +0800 Subject: [PATCH] docs: special folder video media implementation plan --- .../2026-06-29-special-folder-video-media.md | 1959 +++++++++++++++++ 1 file changed, 1959 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-special-folder-video-media.md diff --git a/docs/superpowers/plans/2026-06-29-special-folder-video-media.md b/docs/superpowers/plans/2026-06-29-special-folder-video-media.md new file mode 100644 index 0000000..0e26c37 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-special-folder-video-media.md @@ -0,0 +1,1959 @@ +# Special Folder Video Media Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add video upload/playback to special-folder photo walls, with asynchronous server-side ffmpeg compression for low-bandwidth playback. + +**Architecture:** Special folders move from photo-only endpoints to media endpoints. A new backend media module owns media ids, metadata files, multipart streaming, ffmpeg command construction, and one-at-a-time transcode coordination; `routes/web.rs` only exposes HTTP handlers. The React photo wall keeps personal `#photo` image behavior unchanged, while special folders use a new media API and render images, processing videos, ready videos, and failed videos in the same draggable wall. + +**Tech Stack:** Rust 2024, axum 0.8.9 with `multipart` feature, tokio, serde/serde_json, ffmpeg runtime dependency, React 19, TypeScript 6, Vite 8. + +## Global Constraints + +- Scope: This feature is only open to special folders; personal `#photo` video upload remains out of scope. +- Video uploads use multipart, not data URLs. +- Supported inputs: images `jpg/png/gif/webp`; videos at least `mp4/mov/m4v/webm/avi/mkv`. +- Single original video max: 500MB. Single image max remains 8MiB. +- Server transcodes videos to MP4/H.264/AAC for 3M bandwidth: 720p class, 24fps, video maxrate 900k, bufsize 1800k, audio 96k, `+faststart`. +- No video download UI. +- Video upload does not limit duration. +- Transcode concurrency inside one Rust process is 1. +- Special folder item limit remains `MAX_ITEMS`: images + videos + decorations together. +- Keep old special-folder `/photos*` endpoints working until the new frontend no longer uses them. +- The server must have `ffmpeg` installed and available on `PATH`. +- Design source: `docs/superpowers/specs/2026-06-29-special-folder-video-media-design.md`. + +--- + +## File Structure + +- `Server/Cargo.toml` — enable axum's `multipart` feature. +- `Server/src/main.rs` — declare `mod media;`. +- `Server/src/media.rs` — new special-folder media store, metadata model, validation helpers, ffmpeg command builder, transcode launcher, tests. +- `Server/src/state.rs` — add `folder_media: FolderMediaStore`. +- `Server/src/routes/web.rs` — add `/api/web/folder/{name}/media*` routes and handlers; keep old `/photos*` routes. +- `Client/src/api/folderMedia.ts` — new special-folder media API wrapper using `FormData` and object URLs. +- `Client/src/components/PhotoWallPage.tsx` — add `video` element type; special folders use media API and `mediaLayout`; personal wall stays image-only. +- `Client/src/index.css` — video card, processing card, failed card, and toolbar copy styles under `.pw-`. +- `docs/photo-wall.md` — document special-folder video support and personal wall exclusion. +- `docs/superpowers/plans/2026-06-18-deploy.md` — add ffmpeg deployment notes. +- `docs/superpowers/specs/2026-06-24-special-folders-design.md` — note that media/video is covered by the 2026-06-29 design. + +--- + +### Task 1: Backend Media Store + Transcode Domain + +**Files:** +- Modify: `Server/Cargo.toml` +- Modify: `Server/src/main.rs` +- Create: `Server/src/media.rs` +- Modify: `Server/src/state.rs` +- Test: `Server/src/media.rs` inline `#[cfg(test)]` + +**Interfaces:** +- Consumes: `crate::photos::{valid_id, MAX_BYTES}`, `crate::folders::MAX_ITEMS`, `SPECIAL_DIR` env var. +- Produces: + - `FolderMediaStore::from_env() -> FolderMediaStore` + - `FolderMediaStore::list(&self, folder: &str) -> (Vec, u64)` + - `FolderMediaStore::save_image_bytes(&self, folder: &str, ext: &str, bytes: &[u8]) -> Result` + - `FolderMediaStore::begin_video_upload(&self, folder: &str, ext: &str, original_name: &str) -> Result` + - `FolderMediaStore::finish_video_upload(&self, job: VideoJob) -> Result` + - `FolderMediaStore::spawn_transcode(&self, job: VideoJob)` + - `FolderMediaStore::read(&self, folder: &str, id: &str) -> Option<(&'static str, Vec)>` + - `FolderMediaStore::status(&self, folder: &str, id: &str) -> Option` + - `FolderMediaStore::delete(&self, folder: &str, id: &str) -> bool` + - `classify_upload(content_type: Option<&str>, filename: Option<&str>) -> Option` + - `ffmpeg_args(input: &Path, output: &Path) -> Vec` + +- [ ] **Step 1: Enable axum multipart feature** + +Edit `Server/Cargo.toml` and replace: + +```toml +axum = "0.8.9" +``` + +with: + +```toml +axum = { version = "0.8.9", features = ["multipart"] } +``` + +- [ ] **Step 2: Wire the new module** + +Edit `Server/src/main.rs` and add `mod media;` after `mod folders;`: + +```rust +mod auth; +mod folders; +mod media; +mod monitor; +mod photos; +mod posts; +mod routes; +mod state; +mod weather; +``` + +- [ ] **Step 3: Add a failing media-domain test file** + +Create `Server/src/media.rs` with the tests and public signatures first: + +```rust +//! Special-folder media storage: images plus videos with async ffmpeg transcode. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaType { + Image, + Video, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaStatus { + Ready, + Processing, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaItem { + pub id: String, + #[serde(rename = "type")] + pub media_type: MediaType, + pub status: MediaStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UploadKind { + Image(&'static str), + Video(&'static str), +} + +#[derive(Debug, PartialEq, Eq)] +pub enum MediaError { + Full, + TooLarge, + BadType, + BadFolder, + Io, +} + +#[derive(Clone)] +pub struct FolderMediaStore { + base: PathBuf, +} + +pub struct VideoJob { + pub folder: String, + pub id: String, + pub input_path: PathBuf, + pub output_path: PathBuf, + pub meta_path: PathBuf, +} + +pub fn classify_upload(_content_type: Option<&str>, _filename: Option<&str>) -> Option { + unimplemented!("implemented in Step 5") +} + +pub fn valid_video_id(_id: &str) -> bool { + unimplemented!("implemented in Step 5") +} + +pub fn ffmpeg_args(_input: &Path, _output: &Path) -> Vec { + unimplemented!("implemented in Step 5") +} + +impl FolderMediaStore { + pub fn from_env() -> Self { + unimplemented!("implemented in Step 5") + } + + pub fn new(_base: impl Into) -> Self { + unimplemented!("implemented in Step 5") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_upload_accepts_common_images_and_videos() { + assert_eq!(classify_upload(Some("image/jpeg"), Some("a.jpg")), Some(UploadKind::Image("jpg"))); + assert_eq!(classify_upload(Some("image/png"), Some("a.png")), Some(UploadKind::Image("png"))); + assert_eq!(classify_upload(Some("video/mp4"), Some("clip.mp4")), Some(UploadKind::Video("mp4"))); + assert_eq!(classify_upload(Some("video/quicktime"), Some("clip.mov")), Some(UploadKind::Video("mov"))); + assert_eq!(classify_upload(Some("application/octet-stream"), Some("clip.mkv")), Some(UploadKind::Video("mkv"))); + assert_eq!(classify_upload(Some("text/plain"), Some("note.txt")), None); + } + + #[test] + fn valid_video_id_is_lower_hex_stem_only() { + assert!(valid_video_id("a1b2c3d4e5f60708")); + assert!(!valid_video_id("a1b2.mp4")); + assert!(!valid_video_id("../a1b2c3d4e5f60708")); + assert!(!valid_video_id("A1b2c3d4e5f60708")); + assert!(!valid_video_id("")); + } + + #[test] + fn ffmpeg_args_match_low_bandwidth_target() { + let args = ffmpeg_args(Path::new("/tmp/in.mov"), Path::new("/tmp/out.mp4")); + let joined = args.join(" "); + assert!(joined.contains("-vf")); + assert!(joined.contains("fps=24")); + assert!(joined.contains("-maxrate 900k")); + assert!(joined.contains("-bufsize 1800k")); + assert!(joined.contains("-b:a 96k")); + assert!(joined.contains("+faststart")); + assert!(joined.contains("/tmp/in.mov")); + assert!(joined.contains("/tmp/out.mp4")); + } +} +``` + +- [ ] **Step 4: Run tests and confirm failure** + +Run: + +```bash +cd Server && cargo test media::tests -- --nocapture +``` + +Expected: tests compile but fail/panic at `unimplemented!`. + +- [ ] **Step 5: Implement media types, validation, metadata, and ffmpeg helpers** + +Replace `Server/src/media.rs` with this complete implementation: + +```rust +//! Special-folder media storage: images plus videos with async ffmpeg transcode. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::process::Command; +use tokio::sync::Semaphore; + +use crate::folders::MAX_ITEMS; +use crate::photos::{self, MAX_BYTES}; + +pub const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaType { + Image, + Video, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaStatus { + Ready, + Processing, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaItem { + pub id: String, + #[serde(rename = "type")] + pub media_type: MediaType, + pub status: MediaStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UploadKind { + Image(&'static str), + Video(&'static str), +} + +#[derive(Debug, PartialEq, Eq)] +pub enum MediaError { + Full, + TooLarge, + BadType, + BadFolder, + Io, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VideoMeta { + id: String, + #[serde(rename = "type")] + media_type: MediaType, + status: MediaStatus, + original_name: String, + created_at: String, + output_file: String, + error: Option, +} + +#[derive(Clone)] +pub struct FolderMediaStore { + base: PathBuf, + transcodes: Arc, +} + +#[derive(Clone)] +pub struct VideoJob { + pub folder: String, + pub id: String, + pub input_path: PathBuf, + pub output_path: PathBuf, + pub meta_path: PathBuf, +} + +impl FolderMediaStore { + pub fn from_env() -> Self { + let base = std::env::var("SPECIAL_DIR") + .unwrap_or_else(|_| "special-folders".to_string()); + Self::new(base) + } + + pub fn new(base: impl Into) -> Self { + Self { + base: base.into(), + transcodes: Arc::new(Semaphore::new(1)), + } + } + + fn folder_dir(&self, folder: &str) -> Option { + let safe = photos::sanitize_user(folder)?; + Some(self.base.join(safe)) + } + + fn media_dir(&self, folder: &str) -> Option { + Some(self.folder_dir(folder)?.join("media")) + } + + fn tmp_dir(&self, folder: &str) -> Option { + Some(self.folder_dir(folder)?.join(".tmp")) + } + + pub fn list(&self, folder: &str) -> (Vec, u64) { + let Some(dir) = self.folder_dir(folder) else { + return (Vec::new(), 0); + }; + let mut items = Vec::new(); + let mut total_bytes = 0u64; + + if let Ok(rd) = std::fs::read_dir(&dir) { + for entry in rd.flatten() { + let path = entry.path(); + if path.is_file() { + if let Ok(meta) = entry.metadata() { + total_bytes += meta.len(); + } + if let Some(name) = entry.file_name().to_str() { + if photos::valid_id(name) { + items.push(MediaItem { + id: name.to_string(), + media_type: MediaType::Image, + status: MediaStatus::Ready, + }); + } + } + } + } + } + + if let Some(media_dir) = self.media_dir(folder) { + if let Ok(rd) = std::fs::read_dir(media_dir) { + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + if let Ok(bytes) = std::fs::read(&path) { + if let Ok(meta) = serde_json::from_slice::(&bytes) { + let item = MediaItem { + id: meta.id.clone(), + media_type: MediaType::Video, + status: meta.status, + }; + if let Some(parent) = path.parent().and_then(|p| p.parent()) { + let output = parent.join(&meta.output_file); + if let Ok(m) = std::fs::metadata(output) { + total_bytes += m.len(); + } + } + items.push(item); + } + } + } + } + } + + items.sort_by(|a, b| a.id.cmp(&b.id)); + (items, total_bytes) + } + + pub fn count_items(&self, folder: &str, decoration_count: usize) -> usize { + self.list(folder).0.len() + decoration_count + } + + pub fn save_image_bytes(&self, folder: &str, ext: &str, bytes: &[u8]) -> Result { + if bytes.len() > MAX_BYTES { + return Err(MediaError::TooLarge); + } + if !matches!(ext, "jpg" | "png" | "gif" | "webp") { + return Err(MediaError::BadType); + } + let dir = self.folder_dir(folder).ok_or(MediaError::BadFolder)?; + std::fs::create_dir_all(&dir).map_err(|_| MediaError::Io)?; + let id = format!("{}.{}", new_stem(), ext); + std::fs::write(dir.join(&id), bytes).map_err(|_| MediaError::Io)?; + Ok(MediaItem { id, media_type: MediaType::Image, status: MediaStatus::Ready }) + } + + pub fn begin_video_upload(&self, folder: &str, ext: &str, original_name: &str) -> Result { + if !matches!(ext, "mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv") { + return Err(MediaError::BadType); + } + let folder_dir = self.folder_dir(folder).ok_or(MediaError::BadFolder)?; + let media_dir = self.media_dir(folder).ok_or(MediaError::BadFolder)?; + let tmp_dir = self.tmp_dir(folder).ok_or(MediaError::BadFolder)?; + std::fs::create_dir_all(&folder_dir).map_err(|_| MediaError::Io)?; + std::fs::create_dir_all(&media_dir).map_err(|_| MediaError::Io)?; + std::fs::create_dir_all(&tmp_dir).map_err(|_| MediaError::Io)?; + + let id = new_stem(); + let input_path = tmp_dir.join(format!("{id}.{ext}")); + let output_path = folder_dir.join(format!("{id}.mp4")); + let meta_path = media_dir.join(format!("{id}.json")); + let meta = VideoMeta { + id: id.clone(), + media_type: MediaType::Video, + status: MediaStatus::Processing, + original_name: original_name.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + output_file: format!("{id}.mp4"), + error: None, + }; + write_meta(&meta_path, &meta)?; + Ok(VideoJob { folder: folder.to_string(), id, input_path, output_path, meta_path }) + } + + pub fn finish_video_upload(&self, job: VideoJob) -> Result { + self.spawn_transcode(job.clone()); + Ok(MediaItem { id: job.id, media_type: MediaType::Video, status: MediaStatus::Processing }) + } + + pub fn spawn_transcode(&self, job: VideoJob) { + let semaphore = self.transcodes.clone(); + tokio::spawn(async move { + let _permit = match semaphore.acquire_owned().await { + Ok(permit) => permit, + Err(_) => return, + }; + let args = ffmpeg_args(&job.input_path, &job.output_path); + let status = Command::new("ffmpeg").args(&args).status().await; + match status { + Ok(s) if s.success() => { + update_video_status(&job.meta_path, MediaStatus::Ready, None); + let _ = tokio::fs::remove_file(&job.input_path).await; + } + Ok(s) => { + let msg = format!("ffmpeg exited with status {s}"); + update_video_status(&job.meta_path, MediaStatus::Failed, Some(msg)); + let _ = tokio::fs::remove_file(&job.input_path).await; + let _ = tokio::fs::remove_file(&job.output_path).await; + } + Err(e) => { + update_video_status(&job.meta_path, MediaStatus::Failed, Some(e.to_string())); + let _ = tokio::fs::remove_file(&job.input_path).await; + let _ = tokio::fs::remove_file(&job.output_path).await; + } + } + }); + } + + pub fn read(&self, folder: &str, id: &str) -> Option<(&'static str, Vec)> { + let dir = self.folder_dir(folder)?; + if photos::valid_id(id) { + let bytes = std::fs::read(dir.join(id)).ok()?; + let ext = id.rsplit_once('.').map(|(_, e)| e)?; + return Some((mime_for_image_ext(ext), bytes)); + } + if valid_video_id(id) { + let meta = self.video_meta(folder, id)?; + if meta.status != MediaStatus::Ready { + return None; + } + let bytes = std::fs::read(dir.join(meta.output_file)).ok()?; + return Some(("video/mp4", bytes)); + } + None + } + + pub fn status(&self, folder: &str, id: &str) -> Option { + if photos::valid_id(id) { + let dir = self.folder_dir(folder)?; + if dir.join(id).is_file() { + return Some(MediaItem { id: id.to_string(), media_type: MediaType::Image, status: MediaStatus::Ready }); + } + } + let meta = self.video_meta(folder, id)?; + Some(MediaItem { id: meta.id, media_type: MediaType::Video, status: meta.status }) + } + + pub fn delete(&self, folder: &str, id: &str) -> bool { + let Some(dir) = self.folder_dir(folder) else { + return false; + }; + if photos::valid_id(id) { + return std::fs::remove_file(dir.join(id)).is_ok(); + } + if !valid_video_id(id) { + return false; + } + let meta = self.video_meta(folder, id); + let mut removed = false; + if let Some(meta) = meta { + removed |= std::fs::remove_file(dir.join(meta.output_file)).is_ok(); + } + if let Some(tmp_dir) = self.tmp_dir(folder) { + if let Ok(rd) = std::fs::read_dir(tmp_dir) { + for entry in rd.flatten() { + if let Some(name) = entry.file_name().to_str() { + if name.starts_with(id) { + removed |= std::fs::remove_file(entry.path()).is_ok(); + } + } + } + } + } + if let Some(media_dir) = self.media_dir(folder) { + removed |= std::fs::remove_file(media_dir.join(format!("{id}.json"))).is_ok(); + } + removed + } + + fn video_meta(&self, folder: &str, id: &str) -> Option { + if !valid_video_id(id) { + return None; + } + let path = self.media_dir(folder)?.join(format!("{id}.json")); + serde_json::from_slice(&std::fs::read(path).ok()?).ok() + } +} + +pub fn classify_upload(content_type: Option<&str>, filename: Option<&str>) -> Option { + let mime = content_type.unwrap_or("").to_ascii_lowercase(); + match mime.as_str() { + "image/jpeg" => return Some(UploadKind::Image("jpg")), + "image/png" => return Some(UploadKind::Image("png")), + "image/gif" => return Some(UploadKind::Image("gif")), + "image/webp" => return Some(UploadKind::Image("webp")), + "video/mp4" => return Some(UploadKind::Video("mp4")), + "video/quicktime" => return Some(UploadKind::Video("mov")), + "video/webm" => return Some(UploadKind::Video("webm")), + "video/x-msvideo" => return Some(UploadKind::Video("avi")), + "video/x-matroska" => return Some(UploadKind::Video("mkv")), + _ => {} + } + let ext = filename + .and_then(|name| name.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()))?; + match ext.as_str() { + "jpg" | "jpeg" => Some(UploadKind::Image("jpg")), + "png" => Some(UploadKind::Image("png")), + "gif" => Some(UploadKind::Image("gif")), + "webp" => Some(UploadKind::Image("webp")), + "mp4" => Some(UploadKind::Video("mp4")), + "mov" => Some(UploadKind::Video("mov")), + "m4v" => Some(UploadKind::Video("m4v")), + "webm" => Some(UploadKind::Video("webm")), + "avi" => Some(UploadKind::Video("avi")), + "mkv" => Some(UploadKind::Video("mkv")), + _ => None, + } +} + +pub fn valid_video_id(id: &str) -> bool { + id.len() == 16 + && id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +pub fn ffmpeg_args(input: &Path, output: &Path) -> Vec { + vec![ + "-y".to_string(), + "-i".to_string(), + input.to_string_lossy().to_string(), + "-vf".to_string(), + "scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(ih,iw),min(1280,ih),-2)',fps=24".to_string(), + "-c:v".to_string(), + "libx264".to_string(), + "-preset".to_string(), + "veryfast".to_string(), + "-crf".to_string(), + "30".to_string(), + "-maxrate".to_string(), + "900k".to_string(), + "-bufsize".to_string(), + "1800k".to_string(), + "-c:a".to_string(), + "aac".to_string(), + "-b:a".to_string(), + "96k".to_string(), + "-movflags".to_string(), + "+faststart".to_string(), + output.to_string_lossy().to_string(), + ] +} + +fn write_meta(path: &Path, meta: &VideoMeta) -> Result<(), MediaError> { + let bytes = serde_json::to_vec_pretty(meta).map_err(|_| MediaError::Io)?; + std::fs::write(path, bytes).map_err(|_| MediaError::Io) +} + +fn update_video_status(path: &Path, status: MediaStatus, error: Option) { + let Ok(bytes) = std::fs::read(path) else { + return; + }; + let Ok(mut meta) = serde_json::from_slice::(&bytes) else { + return; + }; + meta.status = status; + meta.error = error.map(|e| e.chars().take(240).collect()); + let _ = write_meta(path, &meta); +} + +fn mime_for_image_ext(ext: &str) -> &'static str { + match ext { + "jpg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + } +} + +fn new_stem() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let digest = Sha256::digest(format!("{nanos}-{n}").as_bytes()); + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_upload_accepts_common_images_and_videos() { + assert_eq!(classify_upload(Some("image/jpeg"), Some("a.jpg")), Some(UploadKind::Image("jpg"))); + assert_eq!(classify_upload(Some("image/png"), Some("a.png")), Some(UploadKind::Image("png"))); + assert_eq!(classify_upload(Some("video/mp4"), Some("clip.mp4")), Some(UploadKind::Video("mp4"))); + assert_eq!(classify_upload(Some("video/quicktime"), Some("clip.mov")), Some(UploadKind::Video("mov"))); + assert_eq!(classify_upload(Some("application/octet-stream"), Some("clip.mkv")), Some(UploadKind::Video("mkv"))); + assert_eq!(classify_upload(Some("text/plain"), Some("note.txt")), None); + } + + #[test] + fn valid_video_id_is_lower_hex_stem_only() { + assert!(valid_video_id("a1b2c3d4e5f60708")); + assert!(!valid_video_id("a1b2.mp4")); + assert!(!valid_video_id("../a1b2c3d4e5f60708")); + assert!(!valid_video_id("A1b2c3d4e5f60708")); + assert!(!valid_video_id("")); + } + + #[test] + fn ffmpeg_args_match_low_bandwidth_target() { + let args = ffmpeg_args(Path::new("/tmp/in.mov"), Path::new("/tmp/out.mp4")); + let joined = args.join(" "); + assert!(joined.contains("-vf")); + assert!(joined.contains("fps=24")); + assert!(joined.contains("-maxrate 900k")); + assert!(joined.contains("-bufsize 1800k")); + assert!(joined.contains("-b:a 96k")); + assert!(joined.contains("+faststart")); + assert!(joined.contains("/tmp/in.mov")); + assert!(joined.contains("/tmp/out.mp4")); + } +} +``` + +- [ ] **Step 6: Add media store to app state** + +Edit `Server/src/state.rs`. + +Add import: + +```rust +use crate::media::FolderMediaStore; +``` + +Add field after `folder_photos`: + +```rust + /// Special-folder media store (images + videos, folder-scoped). + pub folder_media: FolderMediaStore, +``` + +Initialize it in `AppState::new()` after `folder_photos: FolderStore::from_env(),`: + +```rust + folder_media: FolderMediaStore::from_env(), +``` + +- [ ] **Step 7: Run backend tests** + +Run: + +```bash +cd Server && cargo test media::tests -- --nocapture +``` + +Expected: all `media::tests` pass. + +- [ ] **Step 8: Commit** + +```bash +git add Server/Cargo.toml Server/Cargo.lock Server/src/main.rs Server/src/media.rs Server/src/state.rs +git commit -m "feat(media): special-folder media store and ffmpeg transcode model" +``` + +--- + +### Task 2: Backend Media HTTP Endpoints + +**Files:** +- Modify: `Server/src/routes/web.rs` +- Test: compile via `cargo test` + +**Interfaces:** +- Consumes: `state.folder_media`, `check_access`, `AuthUser`, `MediaItem`, `UploadKind`. +- Produces: + - `GET /api/web/folder/{name}/media` + - `POST /api/web/folder/{name}/media` + - `GET /api/web/folder/{name}/media/{id}` + - `GET /api/web/folder/{name}/media/{id}/status` + - `DELETE /api/web/folder/{name}/media/{id}` + +- [ ] **Step 1: Write handler signatures and routes** + +Edit `Server/src/routes/web.rs`. + +Update imports: + +```rust +use axum::{ + Json, Router, + extract::{ConnectInfo, Multipart, Query, State}, + http::{HeaderMap, Method, StatusCode, header}, + routing::{get, post, put}, +}; +``` + +Add media imports near existing crate imports: + +```rust +use crate::media::{MediaError, MediaItem, MediaStatus, UploadKind, MAX_VIDEO_BYTES, classify_upload}; +``` + +Add a media subrouter after `folder_routes`: + +```rust + // Special-folder media upload can carry 500MB video; multipart handler streams field chunks. + // The body limit still needs to allow multipart overhead. + let folder_media_routes = Router::new() + .route("/api/web/folder/{name}/media", get(list_folder_media).post(upload_folder_media)) + .route("/api/web/folder/{name}/media/{id}", get(get_folder_media).delete(delete_folder_media)) + .route("/api/web/folder/{name}/media/{id}/status", get(get_folder_media_status)) + .layer(DefaultBodyLimit::max(700 * 1024 * 1024)); +``` + +Merge it after `.merge(folder_routes)`: + +```rust + .merge(folder_routes) + .merge(folder_media_routes) + .merge(state_routes) +``` + +Add handler skeletons below the old special-folder photo handlers: + +```rust +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FolderMediaList { + items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + max: Option, + total_bytes: u64, + over_quota: bool, +} + +async fn list_folder_media( + AuthUser(user): AuthUser, + State(state): State, + AxPath(name): AxPath, +) -> Result, StatusCode> { + check_access(&state.folders, &name, &user)?; + let (items, total_bytes) = state.folder_media.list(&name); + Ok(Json(FolderMediaList { + items, + max: Some(MAX_ITEMS), + total_bytes, + over_quota: total_bytes > QUOTA_BYTES, + })) +} + +async fn upload_folder_media( + AuthUser(user): AuthUser, + State(state): State, + AxPath(name): AxPath, + multipart: Multipart, +) -> Result<(StatusCode, Json), StatusCode> { + check_access(&state.folders, &name, &user)?; + save_media_from_multipart(state, name, multipart).await +} + +async fn get_folder_media( + AuthUser(user): AuthUser, + State(state): State, + AxPath((name, id)): AxPath<(String, String)>, +) -> Response { + if check_access(&state.folders, &name, &user).is_err() { + return StatusCode::NOT_FOUND.into_response(); + } + match state.folder_media.read(&name, &id) { + Some((mime, bytes)) => ([(header::CONTENT_TYPE, mime)], bytes).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + +async fn get_folder_media_status( + AuthUser(user): AuthUser, + State(state): State, + AxPath((name, id)): AxPath<(String, String)>, +) -> Result, StatusCode> { + check_access(&state.folders, &name, &user)?; + state.folder_media.status(&name, &id).map(Json).ok_or(StatusCode::NOT_FOUND) +} + +async fn delete_folder_media( + AuthUser(user): AuthUser, + State(state): State, + AxPath((name, id)): AxPath<(String, String)>, +) -> StatusCode { + if check_access(&state.folders, &name, &user).is_err() { + return StatusCode::NOT_FOUND; + } + if state.folder_media.delete(&name, &id) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} +``` + +- [ ] **Step 2: Run compile and confirm missing helper failure** + +Run: + +```bash +cd Server && cargo test +``` + +Expected: compile fails because `save_media_from_multipart` is not defined. + +- [ ] **Step 3: Implement multipart streaming helper** + +Add this helper below `delete_folder_media`: + +```rust +async fn save_media_from_multipart( + state: AppState, + folder: String, + mut multipart: Multipart, +) -> Result<(StatusCode, Json), StatusCode> { + let mut file_seen = false; + while let Some(mut field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? { + if field.name() != Some("file") { + continue; + } + file_seen = true; + let filename = field.file_name().map(|s| s.to_string()).unwrap_or_else(|| "upload".to_string()); + let content_type = field.content_type().map(|s| s.to_string()); + let Some(kind) = classify_upload(content_type.as_deref(), Some(&filename)) else { + return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); + }; + + let decoration_count = state + .folder_photos + .load_state(&folder) + .map(|s| s.decorations.len()) + .unwrap_or(0); + if state.folder_media.count_items(&folder, decoration_count) >= MAX_ITEMS { + return Err(StatusCode::CONFLICT); + } + + match kind { + UploadKind::Image(ext) => { + let mut bytes = Vec::new(); + while let Some(chunk) = field.chunk().await.map_err(|_| StatusCode::BAD_REQUEST)? { + if bytes.len() + chunk.len() > MAX_BYTES { + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + bytes.extend_from_slice(&chunk); + } + let item = state + .folder_media + .save_image_bytes(&folder, ext, &bytes) + .map_err(media_error_status)?; + return Ok((StatusCode::CREATED, Json(item))); + } + UploadKind::Video(ext) => { + let job = state + .folder_media + .begin_video_upload(&folder, ext, &filename) + .map_err(media_error_status)?; + let mut written = 0u64; + let mut out = tokio::fs::File::create(&job.input_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + while let Some(chunk) = field.chunk().await.map_err(|_| StatusCode::BAD_REQUEST)? { + written += chunk.len() as u64; + if written > MAX_VIDEO_BYTES { + let _ = tokio::fs::remove_file(&job.input_path).await; + let _ = tokio::fs::remove_file(&job.meta_path).await; + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + tokio::io::AsyncWriteExt::write_all(&mut out, &chunk) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + tokio::io::AsyncWriteExt::flush(&mut out) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let item = state + .folder_media + .finish_video_upload(job) + .map_err(media_error_status)?; + return Ok((StatusCode::ACCEPTED, Json(item))); + } + } + } + + if file_seen { + Err(StatusCode::UNSUPPORTED_MEDIA_TYPE) + } else { + Err(StatusCode::BAD_REQUEST) + } +} + +fn media_error_status(err: MediaError) -> StatusCode { + match err { + MediaError::Full => StatusCode::CONFLICT, + MediaError::TooLarge => StatusCode::PAYLOAD_TOO_LARGE, + MediaError::BadType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + MediaError::BadFolder => StatusCode::BAD_REQUEST, + MediaError::Io => StatusCode::INTERNAL_SERVER_ERROR, + } +} +``` + +- [ ] **Step 4: Ensure the async write import compiles** + +If the compiler reports `write_all` or `flush` unavailable, add this near the top of `Server/src/routes/web.rs`: + +```rust +use tokio::io::AsyncWriteExt; +``` + +Then change the helper calls from fully qualified: + +```rust +tokio::io::AsyncWriteExt::write_all(&mut out, &chunk).await +tokio::io::AsyncWriteExt::flush(&mut out).await +``` + +to method calls: + +```rust +out.write_all(&chunk).await +out.flush().await +``` + +- [ ] **Step 5: Run backend tests** + +Run: + +```bash +cd Server && cargo test +``` + +Expected: all backend tests pass. + +- [ ] **Step 6: Smoke-test routes locally without ffmpeg** + +Run: + +```bash +cd Server +DATABASE_URL=mysql://root:password@127.0.0.1:3306/internet_project JWT_SECRET=local-test SPECIAL_FOLDERS_FILE=../special_folders.txt SPECIAL_DIR=/tmp/ip-special PORT=8080 cargo run +``` + +In another shell, if local MySQL credentials differ, use the same `DATABASE_URL` that is already used for local login. Then test only unauthenticated behavior: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/web/folder/family/media +``` + +Expected: `401` because media endpoints require `Authorization: Bearer`. + +- [ ] **Step 7: Commit** + +```bash +git add Server/src/routes/web.rs +git commit -m "feat(web): special-folder media endpoints" +``` + +--- + +### Task 3: Frontend Media API Wrapper + +**Files:** +- Create: `Client/src/api/folderMedia.ts` +- Test: `npm run build` + +**Interfaces:** +- Consumes: `VITE_API_BASE` convention from existing API files. +- Produces: + - `listFolderMedia(token, folder)` + - `uploadFolderMedia(token, folder, file)` + - `fetchFolderMediaUrl(token, folder, id)` + - `getFolderMediaStatus(token, folder, id)` + - `deleteFolderMedia(token, folder, id)` + - Types `FolderMediaItem`, `FolderMediaList`, `MediaType`, `MediaStatus`, `MediaUploadError`. + +- [ ] **Step 1: Create API wrapper** + +Create `Client/src/api/folderMedia.ts`: + +```ts +const API_BASE = + (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080' + +function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}` } +} + +export type MediaType = 'image' | 'video' +export type MediaStatus = 'ready' | 'processing' | 'failed' + +export interface FolderMediaItem { + id: string + type: MediaType + status: MediaStatus +} + +export interface FolderMediaList { + items: FolderMediaItem[] + max?: number + totalBytes: number + overQuota: boolean +} + +export type MediaUploadError = 'full' | 'too-large' | 'bad-type' | 'forbidden' | 'failed' + +export async function listFolderMedia(token: string, folder: string): Promise { + const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media`, { + headers: authHeaders(token), + }) + if (res.status === 403) throw new Error('forbidden') + if (!res.ok) throw new Error(`list media failed: ${res.status}`) + return (await res.json()) as FolderMediaList +} + +export async function uploadFolderMedia( + token: string, + folder: string, + file: File, +): Promise { + const body = new FormData() + body.append('file', file, file.name) + const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media`, { + method: 'POST', + headers: authHeaders(token), + body, + }) + if (res.ok) return (await res.json()) as FolderMediaItem + if (res.status === 403) throw new Error('forbidden') + const reason: MediaUploadError = + res.status === 409 + ? 'full' + : res.status === 413 + ? 'too-large' + : res.status === 415 + ? 'bad-type' + : 'failed' + throw new Error(reason) +} + +export async function fetchFolderMediaUrl( + token: string, + folder: string, + id: string, +): Promise { + const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media/${id}`, { + headers: authHeaders(token), + }) + if (res.status === 403) throw new Error('forbidden') + if (!res.ok) throw new Error(`get media failed: ${res.status}`) + return URL.createObjectURL(await res.blob()) +} + +export async function getFolderMediaStatus( + token: string, + folder: string, + id: string, +): Promise { + const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media/${id}/status`, { + headers: authHeaders(token), + }) + if (res.status === 403) throw new Error('forbidden') + if (!res.ok) throw new Error(`get media status failed: ${res.status}`) + return (await res.json()) as FolderMediaItem +} + +export async function deleteFolderMedia( + token: string, + folder: string, + id: string, +): Promise { + const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media/${id}`, { + method: 'DELETE', + headers: authHeaders(token), + }) + if (res.status === 403) throw new Error('forbidden') + if (!res.ok && res.status !== 404) throw new Error(`delete media failed: ${res.status}`) +} +``` + +- [ ] **Step 2: Run frontend build** + +Run: + +```bash +cd Client && npm run build +``` + +Expected: TypeScript and Vite build pass. + +- [ ] **Step 3: Commit** + +```bash +git add Client/src/api/folderMedia.ts +git commit -m "feat(client): special-folder media API" +``` + +--- + +### Task 4: PhotoWallPage Media Model + Video UI + +**Files:** +- Modify: `Client/src/components/PhotoWallPage.tsx` +- Modify: `Client/src/index.css` +- Test: `npm run build` + +**Interfaces:** +- Consumes: + - Task 3 exports from `../api/folderMedia` + - Existing personal wall `photos.ts` + - Existing special-folder state API `fetchFolderState/saveFolderState` +- Produces: + - Special folders load from media API. + - Personal wall keeps old image API. + - `ElType` includes `video`. + - Shared state writes `mediaLayout` and reads old `photoLayout`. + +- [ ] **Step 1: Update imports** + +In `Client/src/components/PhotoWallPage.tsx`, add: + +```ts +import { + listFolderMedia, + uploadFolderMedia, + deleteFolderMedia, + fetchFolderMediaUrl, + getFolderMediaStatus, + type FolderMediaItem, + type MediaStatus, + type MediaUploadError, +} from '../api/folderMedia' +``` + +Keep the existing `api/folders` import for `checkFolder`, state load/save, and legacy code until all references are replaced. + +- [ ] **Step 2: Extend element types** + +Replace: + +```ts +type ElType = 'photo' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle' +``` + +with: + +```ts +type ElType = 'photo' | 'video' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle' +``` + +In `interface El`, replace: + +```ts + /** 服务器图片 id(仅 type==='photo'),也是 localStorage 排版的 key */ + photoId?: string +``` + +with: + +```ts + /** 服务器媒体 id(type 为 photo/video 时存在),也是布局保存的 key */ + mediaId?: string + /** 视频处理状态;图片恒为 ready */ + mediaStatus?: MediaStatus +``` + +For compatibility during the edit, change every `photoId` reference in the file to `mediaId`. + +- [ ] **Step 3: Rename layout model to mediaLayout while reading old photoLayout** + +Replace the layout interfaces with: + +```ts +interface MediaLayout { + x: number + y: number + rot: number + scale: number + z: number + w: number + caption: string +} + +interface SavedState { + bgIndex: number + decorations: El[] + mediaLayout: Record + photoLayout?: Record +} +``` + +In `loadInitial`, replace `photoLayout` local variable with `mediaLayout`, and use this compatibility read: + +```ts + if (data.mediaLayout && typeof data.mediaLayout === 'object') { + mediaLayout = data.mediaLayout + } else if (data.photoLayout && typeof data.photoLayout === 'object') { + mediaLayout = data.photoLayout + } +``` + +Return: + +```ts + return { decorations, mediaLayout, bgIndex, nextId, maxZ } +``` + +Initialize the ref with: + +```ts + const layoutRef = useRef>(initial.mediaLayout) +``` + +- [ ] **Step 4: Save mediaLayout and retain photoLayout compatibility for one release** + +In the autosave effect, replace the `photoLayout` construction with: + +```ts + const mediaLayout: Record = { ...layoutRef.current } + for (const e of els) { + if ((e.type === 'photo' || e.type === 'video') && e.mediaId) { + mediaLayout[e.mediaId] = { + x: e.x, y: e.y, rot: e.rot, scale: e.scale, z: e.z, + w: e.w ?? 160, caption: e.caption ?? '', + } + } + } + layoutRef.current = mediaLayout + const saved = { bgIndex, decorations, mediaLayout, photoLayout: mediaLayout } + localStorage.setItem(storageKey, JSON.stringify(saved)) +``` + +When saving folder state, send: + +```ts + saveFolderState(token, folderName, { + bgIndex, + decorations: decorations as unknown as Record[], + mediaLayout: mediaLayout as unknown as Record>, + photoLayout: mediaLayout as unknown as Record>, + }) +``` + +If TypeScript rejects `mediaLayout` because `WallState` does not include it yet, update `Client/src/api/folders.ts` `WallState` to: + +```ts +export interface WallState { + bgIndex: number + decorations: Record[] + mediaLayout?: Record> + photoLayout?: Record> +} +``` + +In the existing `fetchFolderState` restore block, replace the `serverState.photoLayout` branch with: + +```ts + const restoredLayout = serverState.mediaLayout ?? serverState.photoLayout + if (restoredLayout && typeof restoredLayout === 'object') { + for (const [mediaId, pl] of Object.entries(restoredLayout)) { + if (pl && typeof pl === 'object') { + const p = pl as Record + layoutRef.current[mediaId] = { + x: Number(p.x) || 0, + y: Number(p.y) || 0, + rot: Number(p.rot) || 0, + scale: Number(p.scale) || 1, + z: Number(p.z) || 0, + w: Number(p.w) || 160, + caption: String(p.caption ?? ''), + } + } + } + } +``` + +- [ ] **Step 5: Load special-folder media instead of folder photos** + +In the image/media loading effect, replace the `listFn` branch with: + +```ts + const listResult = isPersonal + ? await listPhotos(token).then(r => ({ + items: r.items.map((item) => ({ id: item.id, type: 'image' as const, status: 'ready' as const })), + max: r.max, + totalBytes: 0, + overQuota: false, + })) + : await listFolderMedia(token, folderName!) + + const { items, max, totalBytes, overQuota: over } = listResult +``` + +Replace the item fetch loop with: + +```ts + for (const item of items) { + if (item.type === 'video' && item.status !== 'ready') { + const layout = layoutRef.current[item.id] + const pos = spawnPos(40) + const w = layout?.w ?? 210 + const base: El = { + id: idRef.current++, type: 'video', mediaId: item.id, mediaStatus: item.status, + src: '', w, h: Math.round(w * 0.62), + x: layout?.x ?? pos.x, y: layout?.y ?? pos.y, + rot: layout?.rot ?? rand(-10, 10), scale: layout?.scale ?? 1, + z: layout?.z ?? ++zRef.current, caption: layout?.caption ?? '', + fix: 'tape', fixRot: rand(-3, 3), + } + if (layout) zRef.current = Math.max(zRef.current, layout.z) + setEls((prev) => [...prev, base]) + if (item.status === 'processing' && folderName) startMediaPolling(item.id) + continue + } + + let url: string + try { + url = isPersonal + ? await fetchPhotoObjectUrl(token, item.id) + : await fetchFolderMediaUrl(token, folderName!, item.id) + } catch { + continue + } + if (!alive) { URL.revokeObjectURL(url); return } + objUrls.current.push(url) + + const layout = layoutRef.current[item.id] + const dims = await mediaDimensions(url, item.type) + if (!alive) return + + const ratio = dims.h / dims.w + const roll = Math.random() + const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2' + const pos = spawnPos(40) + const base: El = layout + ? { + id: idRef.current++, type: item.type === 'video' ? 'video' : 'photo', + src: url, mediaId: item.id, mediaStatus: item.status, + w: layout.w, h: Math.round(layout.w * ratio), + x: layout.x, y: layout.y, rot: layout.rot, scale: layout.scale, + z: layout.z, caption: layout.caption, + fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3), + } + : { + id: idRef.current++, type: item.type === 'video' ? 'video' : 'photo', + src: url, mediaId: item.id, mediaStatus: item.status, + w: item.type === 'video' ? 210 : 170, + h: Math.round((item.type === 'video' ? 210 : 170) * ratio), + x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, z: ++zRef.current, + caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3), + } + if (layout) zRef.current = Math.max(zRef.current, layout.z) + setEls((prev) => [...prev, base]) + } +``` + +Add helpers before the loading effect: + +```ts + const pollTimers = useRef(new Map>()) + + const mediaDimensions = (url: string, type: FolderMediaItem['type']): Promise<{ w: number; h: number }> => + new Promise((resolve) => { + if (type === 'video') { + const video = document.createElement('video') + video.preload = 'metadata' + video.onloadedmetadata = () => resolve({ w: video.videoWidth || 16, h: video.videoHeight || 9 }) + video.onerror = () => resolve({ w: 16, h: 9 }) + video.src = url + return + } + const img = new Image() + img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }) + img.onerror = () => resolve({ w: 1, h: 1 }) + img.src = url + }) +``` + +- [ ] **Step 6: Add polling function** + +Add this callback before `handleFiles`: + +```ts + const startMediaPolling = useCallback((mediaId: string) => { + if (!folderName || pollTimers.current.has(mediaId)) return + const timer = setInterval(async () => { + try { + const item = await getFolderMediaStatus(token, folderName, mediaId) + if (item.status === 'processing') return + clearInterval(timer) + pollTimers.current.delete(mediaId) + if (item.status === 'failed') { + setEls((prev) => prev.map((e) => e.mediaId === mediaId ? { ...e, mediaStatus: 'failed' } : e)) + return + } + const url = await fetchFolderMediaUrl(token, folderName, mediaId) + objUrls.current.push(url) + const dims = await mediaDimensions(url, item.type) + setEls((prev) => prev.map((e) => { + if (e.mediaId !== mediaId) return e + const w = e.w ?? 210 + return { ...e, src: url, mediaStatus: 'ready', h: Math.round(w * (dims.h / dims.w)) } + })) + } catch { + clearInterval(timer) + pollTimers.current.delete(mediaId) + setEls((prev) => prev.map((e) => e.mediaId === mediaId ? { ...e, mediaStatus: 'failed' } : e)) + } + }, 3000) + pollTimers.current.set(mediaId, timer) + }, [folderName, token]) +``` + +In the unmount cleanup effect, stop timers: + +```ts + for (const timer of pollTimers.current.values()) clearInterval(timer) + pollTimers.current.clear() +``` + +- [ ] **Step 7: Upload images and videos for special folders** + +In `handleFiles`, replace: + +```ts + const imgs = list.filter((f) => f.type.startsWith('image/')) +``` + +with: + +```ts + const mediaFiles = list.filter((f) => f.type.startsWith('image/') || (!isPersonal && f.type.startsWith('video/'))) +``` + +Replace subsequent `imgs` references with `mediaFiles`. + +In the per-file upload branch, use: + +```ts + if (!isPersonal && file.type.startsWith('video/')) { + uploadFolderMedia(token, folderName!, file) + .then((item) => { + const pos = spawnPos(40) + const w = 210 + addEl({ + type: 'video', src: '', mediaId: item.id, mediaStatus: item.status, + w, h: Math.round(w * 0.62), + x: pos.x, y: pos.y, rot: rand(-10, 10), scale: 1, + caption: '', fix: 'tape', fixRot: rand(-3, 3), + }) + if (item.status === 'processing') startMediaPolling(item.id) + }) + .catch((err: Error) => { + if (err.message === 'forbidden') { + onForbidden?.() + return + } + const reason = err.message as MediaUploadError + showToast( + reason === 'full' ? `已达上限(${maxItems} 条)` + : reason === 'too-large' ? '视频过大(上限 500MB)' + : reason === 'bad-type' ? '不支持的视频格式' + : '上传失败', + ) + }) + return + } +``` + +For folder images, replace `uploadFolderPhoto(t, folderName!, d)` with: + +```ts +uploadFolderMedia(t, folderName!, dataUrlToFile(d, file.name || 'image.jpg')) + .then((item) => item.id) +``` + +Add helper near `compressImage`: + +```ts + function dataUrlToFile(dataUrl: string, name: string) { + const [meta, b64] = dataUrl.split(',') + const mime = meta.match(/^data:([^;]+)/)?.[1] ?? 'application/octet-stream' + const bin = atob(b64) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + return new File([bytes], name, { type: mime }) + } +``` + +- [ ] **Step 8: Delete media via correct API** + +Replace the media deletion branch in `removeEl` with: + +```ts + if ((target?.type === 'photo' || target?.type === 'video') && target.mediaId) { + if (isPersonal) { + deletePhoto(token, target.mediaId).catch(() => {}) + } else { + deleteFolderMedia(token, folderName!, target.mediaId).catch(() => {}) + } + delete layoutRef.current[target.mediaId] + if (target.src?.startsWith('blob:')) { + URL.revokeObjectURL(target.src) + objUrls.current = objUrls.current.filter((u) => u !== target.src) + } + } +``` + +- [ ] **Step 9: Render video states** + +In `renderInner`, add a `video` case after `photo`: + +```tsx + case 'video': + return ( + <> +
+ {el.mediaStatus === 'processing' && ( +
+
+ AI 正在压缩视频... + 完成后会自动变成播放器 +
+ )} + {el.mediaStatus === 'failed' && ( +
+ 视频处理失败 + 右键删除后可重新上传 +
+ )} + {el.mediaStatus === 'ready' && el.src && ( +
+ {editingId === el.id ? ( + setEditText(e.target.value)} + onBlur={() => commitEdit(el.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); commitEdit(el.id) } + e.stopPropagation() + }} + /> + ) : ( +
{el.caption}
+ )} +
+ + ) +``` + +Update double-click edit condition: + +```ts +if (el.type === 'photo' || el.type === 'video' || el.type === 'note') { e.stopPropagation(); startEdit(el) } +``` + +Update `startEdit` and `commitEdit`: + +```ts + const startEdit = (el: El) => { + setEditText((el.type === 'photo' || el.type === 'video') ? el.caption ?? '' : el.text ?? '') + setEditingId(el.id) + } +``` + +```ts +e.id === id ? ((e.type === 'photo' || e.type === 'video') ? { ...e, caption: val.trim() } : { ...e, text: val }) : e +``` + +- [ ] **Step 10: Update classes and styles** + +In `elClass`, include video: + +```ts + video: 'pw-video', +``` + +In `elStyle`, use width for video: + +```ts + if (el.type === 'photo' || el.type === 'video') s.width = el.w +``` + +In the toolbar upload button: + +```tsx + +``` + +In the drag hint: + +```tsx + {dragHint &&
{isPersonal ? '拖入图片即可添加到照片墙' : '拖入图片或视频即可添加到照片墙'}
} +``` + +Edit the hidden file input if present near the end of the component: + +```tsx + { + if (e.target.files) handleFiles(e.target.files) + e.currentTarget.value = '' + }} + /> +``` + +Add CSS to `Client/src/index.css` near the `.pw-photo` section: + +```css +.pw-video { + background: #fffaf2; + border: 1px solid rgba(69, 52, 38, 0.16); + box-shadow: 8px 14px 28px rgba(0, 0, 0, 0.22); + padding: 10px 10px 22px; +} + +.pw-video-wrap { + display: grid; + place-items: center; + background: #171717; + overflow: hidden; +} + +.pw-video-wrap video { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + background: #111; +} + +.pw-video-state { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: #f8efe0; + text-align: center; + font-size: 13px; + padding: 18px; + background: + radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.14), transparent 28%), + linear-gradient(135deg, #2b2b2b, #141414); +} + +.pw-video-state strong { + font-size: 15px; + font-weight: 700; +} + +.pw-video-state span { + color: rgba(248, 239, 224, 0.75); +} + +.pw-video-failed { + background: linear-gradient(135deg, #3a1515, #191111); +} + +.pw-video-spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(248, 239, 224, 0.28); + border-top-color: #f8efe0; + border-radius: 999px; + animation: pw-spin 1s linear infinite; +} + +@keyframes pw-spin { + to { transform: rotate(360deg); } +} +``` + +- [ ] **Step 11: Run frontend build** + +Run: + +```bash +cd Client && npm run build +``` + +Expected: TypeScript and Vite build pass. + +- [ ] **Step 12: Commit** + +```bash +git add Client/src/api/folders.ts Client/src/components/PhotoWallPage.tsx Client/src/index.css +git commit -m "feat(photo-wall): render special-folder video media" +``` + +--- + +### Task 5: Docs, Deployment Notes, and End-to-End Verification + +**Files:** +- Modify: `docs/photo-wall.md` +- Modify: `docs/superpowers/plans/2026-06-18-deploy.md` +- Modify: `docs/superpowers/specs/2026-06-24-special-folders-design.md` + +**Interfaces:** +- Consumes: implemented endpoints and UI behavior from Tasks 1-4. +- Produces: operator instructions for installing `ffmpeg`, redeploying, and verifying video upload/playback. + +- [ ] **Step 1: Update photo-wall documentation** + +Edit `docs/photo-wall.md`. In the “功能” section, add this bullet after “上传”: + +```markdown +- **特殊文件夹视频**:特殊文件夹(`#<文件夹名>`)支持上传图片或视频;视频上传后先显示 + 「AI 正在压缩视频...」占位卡,服务器用 `ffmpeg` 压缩成 3M 带宽友好的 MP4 后自动变为 + 播放器。个人 `#photo` 暂不开放视频上传。不提供视频下载按钮。 +``` + +In the “线上访问” section, add: + +```markdown +特殊文件夹视频依赖服务器安装 `ffmpeg`,后端改动需全量重部署。 +``` + +- [ ] **Step 2: Update special-folders spec note** + +Append to `docs/superpowers/specs/2026-06-24-special-folders-design.md`: + +```markdown +## 10. 后续扩展:视频媒体 + +特殊文件夹的视频上传、异步转码、媒体接口和 `mediaLayout` 兼容策略由 +[2026-06-29-special-folder-video-media-design.md](2026-06-29-special-folder-video-media-design.md) +接续设计。个人 `#photo` 不在该扩展范围内。 +``` + +- [ ] **Step 3: Update deploy plan** + +In `docs/superpowers/plans/2026-06-18-deploy.md`, add a section after the “照片墙服务器存图” section: + +```markdown +**特殊文件夹视频媒体(2026-06-29,后端改动 + ffmpeg 运行时依赖):** +特殊文件夹支持视频上传后,服务器需要安装 `ffmpeg` 并确保 Rust 服务进程能在 `PATH` 中调用。 +这次改动新增 `/api/web/folder/{name}/media*` 接口和 multipart 上传,需**全量重部署**: +重编 Rust → 传二进制和前端 `dist/*` → `restart_rust.sh` 重启。 + +服务器安装检查: +```bash +ssh root@8.130.143.54 "ffmpeg -version | head -1" +``` + +如未安装,按服务器系统包管理器安装,例如 Debian/Ubuntu: +```bash +ssh root@8.130.143.54 "apt-get update && apt-get install -y ffmpeg" +``` + +转码目标固定为 720p 档、24fps、视频约 900kbps、音频 96kbps、MP4 faststart, +用于适配 3M 带宽播放。不提供视频下载入口。 +``` + +- [ ] **Step 4: Run docs search for stale wording** + +Run: + +```bash +rg -n "上传照片|photoLayout|/photos|ffmpeg|视频" docs/photo-wall.md docs/superpowers/specs/2026-06-24-special-folders-design.md docs/superpowers/plans/2026-06-18-deploy.md +``` + +Expected: +- `上传照片` may still appear for personal wall docs. +- Special-folder video docs mention media/video. +- `photoLayout` references either describe legacy compatibility or old personal image behavior. + +- [ ] **Step 5: Run full verification** + +Run: + +```bash +cd Server && cargo test +cd ../Client && npm run build +``` + +Expected: +- Cargo tests pass. +- Client build passes. + +- [ ] **Step 6: Local video smoke test with generated sample** + +Install ffmpeg locally if absent. Then generate a tiny test video: + +```bash +ffmpeg -f lavfi -i testsrc=size=640x360:rate=24 -f lavfi -i sine=frequency=1000:sample_rate=48000 \ + -t 3 -c:v libx264 -c:a aac /tmp/pw-test-video.mp4 +``` + +Start backend with a registered special folder and frontend dev server. In the browser: + +```text +http://localhost:5173/# +``` + +Expected manual checks: +- Login succeeds for a whitelisted user. +- Uploading `/tmp/pw-test-video.mp4` immediately creates an “AI 正在压缩视频...” card. +- The card becomes a video player after transcode. +- Playback starts only after clicking play. +- Drag, resize, caption edit, and delete work. +- Refresh keeps layout. + +- [ ] **Step 7: Commit** + +```bash +git add docs/photo-wall.md docs/superpowers/plans/2026-06-18-deploy.md docs/superpowers/specs/2026-06-24-special-folders-design.md +git commit -m "docs: special-folder video media deployment notes" +``` + +--- + +## Final Verification Before Merge + +- [ ] Run backend tests: + +```bash +cd Server && cargo test +``` + +Expected: all tests pass. + +- [ ] Run frontend build: + +```bash +cd Client && npm run build +``` + +Expected: TypeScript and Vite build pass. + +- [ ] Confirm git status only contains intentional files: + +```bash +git status --short +``` + +Expected: clean, or only user-owned unrelated files such as `Client/src/assets/sides/*` if they existed before implementation. + +- [ ] Verify production dependency before deploy: + +```bash +ssh root@8.130.143.54 "ffmpeg -version | head -1" +``` + +Expected: prints an ffmpeg version line. + +--- + +## Self-Review + +Spec coverage: +- Special-folder-only scope: Tasks 2-4 route and UI only switch special folders to media; personal `#photo` remains on existing `photos.ts`. +- Multipart upload: Tasks 2 and 3 use `Multipart` and `FormData`. +- ffmpeg compression target: Task 1 builds command and tests 24fps, 900k, 1800k, 96k, `+faststart`. +- Async processing card: Task 4 creates processing video elements and polls status. +- No download UI: Task 4 uses `