PersonalWebApplication/docs/superpowers/plans/2026-06-29-special-folder-video-media.md

1960 lines
62 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<MediaItem>, u64)`
- `FolderMediaStore::save_image_bytes(&self, folder: &str, ext: &str, bytes: &[u8]) -> Result<MediaItem, MediaError>`
- `FolderMediaStore::begin_video_upload(&self, folder: &str, ext: &str, original_name: &str) -> Result<VideoJob, MediaError>`
- `FolderMediaStore::finish_video_upload(&self, job: VideoJob) -> Result<MediaItem, MediaError>`
- `FolderMediaStore::spawn_transcode(&self, job: VideoJob)`
- `FolderMediaStore::read(&self, folder: &str, id: &str) -> Option<(&'static str, Vec<u8>)>`
- `FolderMediaStore::status(&self, folder: &str, id: &str) -> Option<MediaItem>`
- `FolderMediaStore::delete(&self, folder: &str, id: &str) -> bool`
- `classify_upload(content_type: Option<&str>, filename: Option<&str>) -> Option<UploadKind>`
- `ffmpeg_args(input: &Path, output: &Path) -> Vec<String>`
- [ ] **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<UploadKind> {
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<String> {
unimplemented!("implemented in Step 5")
}
impl FolderMediaStore {
pub fn from_env() -> Self {
unimplemented!("implemented in Step 5")
}
pub fn new(_base: impl Into<PathBuf>) -> 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<String>,
}
#[derive(Clone)]
pub struct FolderMediaStore {
base: PathBuf,
transcodes: Arc<Semaphore>,
}
#[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<PathBuf>) -> Self {
Self {
base: base.into(),
transcodes: Arc::new(Semaphore::new(1)),
}
}
fn folder_dir(&self, folder: &str) -> Option<PathBuf> {
let safe = photos::sanitize_user(folder)?;
Some(self.base.join(safe))
}
fn media_dir(&self, folder: &str) -> Option<PathBuf> {
Some(self.folder_dir(folder)?.join("media"))
}
fn tmp_dir(&self, folder: &str) -> Option<PathBuf> {
Some(self.folder_dir(folder)?.join(".tmp"))
}
pub fn list(&self, folder: &str) -> (Vec<MediaItem>, 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::<VideoMeta>(&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<MediaItem, MediaError> {
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<VideoJob, MediaError> {
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<MediaItem, MediaError> {
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<u8>)> {
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<MediaItem> {
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<VideoMeta> {
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<UploadKind> {
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<String> {
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<String>) {
let Ok(bytes) = std::fs::read(path) else {
return;
};
let Ok(mut meta) = serde_json::from_slice::<VideoMeta>(&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<MediaItem>,
#[serde(skip_serializing_if = "Option::is_none")]
max: Option<usize>,
total_bytes: u64,
over_quota: bool,
}
async fn list_folder_media(
AuthUser(user): AuthUser,
State(state): State<AppState>,
AxPath(name): AxPath<String>,
) -> Result<Json<FolderMediaList>, 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<AppState>,
AxPath(name): AxPath<String>,
multipart: Multipart,
) -> Result<(StatusCode, Json<MediaItem>), 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<AppState>,
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<AppState>,
AxPath((name, id)): AxPath<(String, String)>,
) -> Result<Json<MediaItem>, 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<AppState>,
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<MediaItem>), 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<string, string> {
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<FolderMediaList> {
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<FolderMediaItem> {
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<string> {
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<FolderMediaItem> {
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<void> {
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
/** æœ<C3A6>务器å¾ç‰‡ id(仅 type==='photo'),也是 localStorage 排版的 key */
photoId?: string
```
with:
```ts
/** æœ<C3A6>务器åªä½“ id(type 为 photo/video 时存在),也是布局ä¿<C3A4>存的 key */
mediaId?: string
/** 视é¢å¤„ç<E2809E>†çжæ€<C3A6>ï¼å¾ç‰‡æ<E280A1>为 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<string, MediaLayout>
photoLayout?: Record<string, MediaLayout>
}
```
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<Record<string, MediaLayout>>(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<string, MediaLayout> = { ...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<string, unknown>[],
mediaLayout: mediaLayout as unknown as Record<string, Record<string, unknown>>,
photoLayout: mediaLayout as unknown as Record<string, Record<string, unknown>>,
})
```
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<string, unknown>[]
mediaLayout?: Record<string, Record<string, unknown>>
photoLayout?: Record<string, Record<string, unknown>>
}
```
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<string, unknown>
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<string, ReturnType<typeof setInterval>>())
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' ? `已达上é™<C3A9>(${maxItems} æ<>¡ï¼‰`
: reason === 'too-large' ? '视é¢è¿‡å¤§ï¼ˆä¸Šé™<C3A9> 500MB)'
: reason === 'bad-type' ? ¸<C3A4>支æŒ<C3A6>çš„è§†é¢æ ¼å¼<C3A5>'
: '上传失败',
)
})
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 (
<>
<div className="pw-video-wrap" style={{ height: el.h }}>
{el.mediaStatus === 'processing' && (
<div className="pw-video-state">
<div className="pw-video-spinner" />
<strong>AI 正在压缩视频...</strong>
<span>完æˆ<EFBFBD>å<EFBFBD>Žä¼šè‡ªåЍå<EFBFBD>˜æˆ<EFBFBD>æ­æ”¾å™¨</span>
</div>
)}
{el.mediaStatus === 'failed' && (
<div className="pw-video-state pw-video-failed">
<strong>视é¢å¤„ç<EFBFBD>†å¤±è´¥</strong>
<span>å<EFBFBD>³é”®åˆ é™¤å<EFBFBD>Žå<EFBFBD>¯é‡<EFBFBD>æ°ä¸Šä¼ </span>
</div>
)}
{el.mediaStatus === 'ready' && el.src && (
<video src={el.src} controls preload="metadata" />
)}
</div>
{editingId === el.id ? (
<input
className="pw-caption-input"
autoFocus
value={editText}
placeholder="写点什么..."
onChange={(e) => setEditText(e.target.value)}
onBlur={() => commitEdit(el.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); commitEdit(el.id) }
e.stopPropagation()
}}
/>
) : (
<div className="pw-caption">{el.caption}</div>
)}
<div className="pw-fix-tape" style={{ transform: `translateX(-50%) rotate(${el.fixRot ?? 0}deg)` }} />
</>
)
```
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
<button className="pw-tbtn" onClick={() => fileRef.current?.click()}>
{isPersonal ? '上传照片' : '上传媒体'}
</button>
```
In the drag hint:
```tsx
{dragHint && <div className="pw-drophint">{isPersonal ? å…¥å¾ç‰‡å<E280A1>³å<C2B3>¯æ·»åŠ åˆ°ç…§ç‰‡å¢™' : å…¥å¾ç‰‡æˆè§†é¢å<E28098>³å<C2B3>¯æ·»åŠ åˆ°ç…§ç‰‡å¢™'}</div>}
```
Edit the hidden file input if present near the end of the component:
```tsx
<input
ref={fileRef}
type="file"
accept={isPersonal ? 'image/*' : 'image/*,video/*'}
multiple
hidden
onChange={(e) => {
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 “功能â€<C3A2> section, add this bullet after “上传â€<C3A2>:
```markdown
- **特殊文件夹视频**:特殊文件夹(`#<文件夹å<EFBFBD><EFBFBD>>`)支æŒ<C3A6>上传å¾ç‰‡æˆè§†é¢ï¼è§†é¢ä¸Šä¼ å<C2A0>Žå…ˆæ˜¾ç¤º
「AI 正在压缩视频...ã€<C3A3>å<EFBFBD> ä½<C3A4>å<EFBFBD>¡ï¼Œæœ<C3A6>务器用 `ffmpeg` åŽç¼©æˆ<C3A6> 3M 带宽å<C2BD>好的 MP4 å<>Žè‡ªåЍå<C2A8>˜ä¸º
播放器。个人 `#photo` æšä¸<C3A4>开放视é¢ä¸Šä¼ ã€ä¸<C3A4>æ<EFBFBD><C3A6>ä¾è§†é¢ä¸è½½æŒ‰é®ã€
```
In the “线上访问â€<C3A2> section, add:
```markdown
特殊æ‡ä»¶å¤¹è§†é¢ä¾<EFBFBD>èµæœ<EFBFBD>务器安装 `ffmpeg`,å<EFBFBD>Žç«¯æ”¹åŠ¨éœ€å…¨é‡<EFBFBD>é‡<EFBFBD>部署ã€
```
- [ ] **Step 2: Update special-folders spec note**
Append to `docs/superpowers/specs/2026-06-24-special-folders-design.md`:
```markdown
## 10. å<>Žç»­æ‰©å±•:视é¢åªä½“
特殊æ‡ä»¶å¤¹çš„视é¢ä¸Šä¼ ã€<EFBFBD>弿­¥è½¬ç <EFBFBD>ã€<EFBFBD>åªä½“接å<EFBFBD>£åŒ `mediaLayout` 兼容策略由
[2026-06-29-special-folder-video-media-design.md](2026-06-29-special-folder-video-media-design.md)
接续设计。个人 `#photo` ä¸<C3A4>在该扩展范å´å†…ã€
```
- [ ] **Step 3: Update deploy plan**
In `docs/superpowers/plans/2026-06-18-deploy.md`, add a section after the “照片墙æœ<C3A6>务器存å¾â€<C3A2> section:
```markdown
**特殊æ‡ä»¶å¤¹è§†é¢åªä½“(2026-06-29,å<C592>Žç«¯æ”¹åЍ + ffmpeg è¿<C3A8>行时ä¾<C3A4>èµï¼‰ï¼š**
特殊æ‡ä»¶å¤¹æ”¯æŒ<EFBFBD>视é¢ä¸Šä¼ å<EFBFBD>Žï¼Œæœ<EFBFBD>务器需è¦<EFBFBD>安装 `ffmpeg` å¹¶ç¡®ä¿<C3A4> Rust æœ<C3A6>务è¿ç¨èƒ½åœ¨ `PATH` 中调用。
这次改动新增 `/api/web/folder/{name}/media*` 接å<C2A5>£åŒ multipart 上传,需**å…¨é‡<C3A9>é‡<C3A9>部署**:
é‡<EFBFBD>ç¼ Rust → 传二è¿åˆ¶åŒå‰<C3A5>端 `dist/*` → `restart_rust.sh` é‡<C3A9>å<EFBFBD>¯ã€
æœ<EFBFBD>务器安装检查:
```bash
ssh root@8.130.143.54 "ffmpeg -version | head -1"
```
妿œªå®‰è£…,按æœ<EFBFBD>务器系统包管ç<EFBFBD>†å™¨å®‰è£…,ä¾å¦ Debian/Ubuntu:
```bash
ssh root@8.130.143.54 "apt-get update && apt-get install -y ffmpeg"
```
转ç <EFBFBD>ç®æ ‡åºå®šä¸º 720p æ¡£ã€<C3A3>24fpsã€<C3A3>视é¢çº¦ 900kbpsã€<C3A3>éŸ³é¢ 96kbpsã€<C3A3>MP4 faststart,
用于é€é…<EFBFBD> 3M å¸¦å®½æ­æ”¾ã€ä¸<C3A4>æ<EFBFBD><C3A6>ä¾è§†é¢ä¸è½½å…¥å<C2A5>£ã€
```
- [ ] **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/#<registered-folder>
```
Expected manual checks:
- Login succeeds for a whitelisted user.
- Uploading `/tmp/pw-test-video.mp4` immediately creates an “AI 正在压缩视频...â€<C3A2> 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 `<video controls>` without a download button; docs state no download entry.
- 500MB max and 8MiB image limit: Tasks 1-2 enforce constants while streaming.
- Concurrency 1: Task 1 `Semaphore::new(1)`.
- `mediaLayout` compatibility: Task 4 reads old `photoLayout` and writes `mediaLayout` plus temporary compatibility `photoLayout`.
- Tests and deploy: Task 5 includes cargo/npm verification, local video smoke test, and ffmpeg production check.
Placeholder scan:
- The plan contains no `TBD`, no `TODO`, and no unspecified implementation slots.
Type consistency:
- `MediaType`, `MediaStatus`, `MediaItem`, `UploadKind`, `MediaError`, and `FolderMediaStore` names are consistent across Tasks 1-4.
- Frontend `FolderMediaItem` matches backend serialized `MediaItem` shape: `id`, `type`, `status`.
- `mediaLayout` naming is consistent in frontend state and docs, with `photoLayout` only retained as compatibility.