# 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 `