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

65 KiB
Raw Blame History

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.

Current status: This is an implementation plan only. The current codebase does not yet include Server/src/media.rs, /api/web/folder/{name}/media*, Client/src/api/folderMedia.ts, or video rendering in PhotoWallPage.

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 explicit video download UI. Use controlsList="nodownload" on <video>, but treat it as UI cleanup only, not a security boundary.
  • 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:

axum = "0.8.9"

with:

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;:

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:

//! 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("force_original_aspect_ratio=decrease"));
        assert!(joined.contains("force_divisible_by=2"));
        assert!(joined.contains("/tmp/in.mov"));
        assert!(joined.contains("/tmp/out.mp4"));
    }
}
  • Step 4: Run tests and confirm failure

Run:

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:

//! 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='min(iw,if(gte(iw,ih),1280,720))':'min(ih,if(gte(iw,ih),720,1280))':force_original_aspect_ratio=decrease:force_divisible_by=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("force_original_aspect_ratio=decrease"));
        assert!(joined.contains("force_divisible_by=2"));
        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:

use crate::media::FolderMediaStore;

Add field after folder_photos:

    /// Special-folder media store (images + videos, folder-scoped).
    pub folder_media: FolderMediaStore,

Initialize it in AppState::new() after folder_photos: FolderStore::from_env(),:

            folder_media: FolderMediaStore::from_env(),
  • Step 7: Run backend tests

Run:

cd Server && cargo test media::tests -- --nocapture

Expected: all media::tests pass.

  • Step 8: Commit
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
  • Modify: Server/src/folders.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:

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:

use crate::media::{MediaError, MediaItem, MediaStatus, UploadKind, MAX_VIDEO_BYTES, classify_upload};

Add a media subrouter after folder_routes:

    // 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):

        .merge(folder_routes)
        .merge(folder_media_routes)
        .merge(state_routes)

Add handler skeletons below the old special-folder photo handlers:

#[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:

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:

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:

use tokio::io::AsyncWriteExt;

Then change the helper calls from fully qualified:

tokio::io::AsyncWriteExt::write_all(&mut out, &chunk).await
tokio::io::AsyncWriteExt::flush(&mut out).await

to method calls:

out.write_all(&chunk).await
out.flush().await
  • Step 5: Migrate shared wall state API to mediaLayout

The frontend task will save mediaLayout, but the current backend state model only accepts photoLayout. Update both Server/src/folders.rs and Server/src/routes/web.rs so the shared folder wall state supports the new key while preserving old saved state.

Required behavior:

  • PUT /api/web/folder/{name}/state accepts either mediaLayout or old photoLayout.
  • GET /api/web/folder/{name}/state returns mediaLayout.
  • During one compatibility release, GET may also return photoLayout with the same value so older frontend builds keep working.
  • Existing wall-state.json files that contain photo_layout/photoLayout must still load.

Implementation direction:

#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct WallState {
    pub bg_index: usize,
    pub decorations: Vec<Decoration>,
    #[serde(default, alias = "photoLayout", alias = "photo_layout")]
    pub media_layout: std::collections::HashMap<String, PhotoLayout>,
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    pub photo_layout: std::collections::HashMap<String, PhotoLayout>,
}

When saving, normalize the layout once:

#[derive(Deserialize)]
struct StateSaveRequest {
    #[serde(rename = "bgIndex")]
    bg_index: usize,
    decorations: Vec<serde_json::Value>,
    #[serde(rename = "mediaLayout")]
    media_layout: Option<std::collections::HashMap<String, serde_json::Value>>,
    #[serde(rename = "photoLayout")]
    photo_layout: Option<std::collections::HashMap<String, serde_json::Value>>,
}

let layout_values = body
    .media_layout
    .or(body.photo_layout)
    .unwrap_or_default();

Then save media_layout and, for the compatibility window, mirror it into photo_layout.

Also add or update backend tests that deserialize:

  • old disk shape with photo_layout,

  • old API shape with photoLayout,

  • new API shape with mediaLayout.

  • Step 6: Run backend tests

Run:

cd Server && cargo test

Expected: all backend tests pass.

  • Step 7: Smoke-test routes locally without ffmpeg

Run:

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:

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 8: Commit
git add Server/src/routes/web.rs Server/src/folders.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:

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:

cd Client && npm run build

Expected: TypeScript and Vite build pass.

  • Step 3: Commit
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:

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:

type ElType = 'photo' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle'

with:

type ElType = 'photo' | 'video' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle'

In interface El, replace:

  /** 服务器图片 id仅 type==='photo'),也是 localStorage 排版的 key */
  photoId?: string

with:

  /** 服务器媒体 idtype 为 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:

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:

      if (data.mediaLayout && typeof data.mediaLayout === 'object') {
        mediaLayout = data.mediaLayout
      } else if (data.photoLayout && typeof data.photoLayout === 'object') {
        mediaLayout = data.photoLayout
      }

Return:

  return { decorations, mediaLayout, bgIndex, nextId, maxZ }

Initialize the ref with:

  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:

        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:

          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:

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:

              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:

        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:

        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:

  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:

  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:

      for (const timer of pollTimers.current.values()) clearInterval(timer)
      pollTimers.current.clear()
  • Step 7: Upload images and videos for special folders

In handleFiles, replace:

      const imgs = list.filter((f) => f.type.startsWith('image/'))

with:

      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:

          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:

uploadFolderMedia(t, folderName!, dataUrlToFile(d, file.name || 'image.jpg'))
  .then((item) => item.id)

Add helper near compressImage:

  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:

    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:

      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>thinking</strong>
                  <span>完成后会自动变成播放器</span>
                </div>
              )}
              {el.mediaStatus === 'failed' && (
                <div className="pw-video-state pw-video-failed">
                  <strong>视频处理失败</strong>
                  <span>右键删除后可重新上传</span>
                </div>
              )}
              {el.mediaStatus === 'ready' && el.src && (
                <video src={el.src} controls controlsList="nodownload" 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:

if (el.type === 'photo' || el.type === 'video' || el.type === 'note') { e.stopPropagation(); startEdit(el) }

Update startEdit and commitEdit:

  const startEdit = (el: El) => {
    setEditText((el.type === 'photo' || el.type === 'video') ? el.caption ?? '' : el.text ?? '')
    setEditingId(el.id)
  }
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:

      video: 'pw-video',

In elStyle, use width for video:

    if (el.type === 'photo' || el.type === 'video') s.width = el.w

In the toolbar upload button:

        <button className="pw-tbtn" onClick={() => fileRef.current?.click()}>
          {isPersonal ? '上传照片' : '上传媒体'}
        </button>

In the drag hint:

        {dragHint && <div className="pw-drophint">{isPersonal ? '拖入图片即可添加到照片墙' : '拖入图片或视频即可添加到照片墙'}</div>}

Edit the hidden file input if present near the end of the component:

      <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:

.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:

cd Client && npm run build

Expected: TypeScript and Vite build pass.

  • Step 12: Commit
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 “上传”:

- **特殊文件夹视频**:特殊文件夹(`#<文件夹名>`)支持上传图片或视频;视频上传后先显示
  「thinking」占位卡服务器用 `ffmpeg` 压缩成 3M 带宽友好的 MP4 后自动变为
  播放器。个人 `#photo` 暂不开放视频上传。不提供显式视频下载按钮。

In the “线上访问” section, add:

特殊文件夹视频依赖服务器安装 `ffmpeg`,后端改动需全量重部署。
  • Step 2: Update special-folders spec note

Append to docs/superpowers/specs/2026-06-24-special-folders-design.md:

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

**特殊文件夹视频媒体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

ssh root@8.130.143.54 "apt-get update && apt-get install -y ffmpeg"

转码目标固定为 720p 档、24fps、视频约 900kbps、音频 96kbps、MP4 faststart 用于适配 3M 带宽播放。不提供显式视频下载入口;controlsList="nodownload" 只是 UI 降噪, 不是防下载安全边界。


- [ ] **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:

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:

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:

http://localhost:5173/#<registered-folder>

Expected manual checks:

  • Login succeeds for a whitelisted user.

  • Uploading /tmp/pw-test-video.mp4 immediately creates a “thinking” 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

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:
cd Server && cargo test

Expected: all tests pass.

  • Run frontend build:
cd Client && npm run build

Expected: TypeScript and Vite build pass.

  • Confirm git status only contains intentional files:
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:
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 explicit download UI: Task 4 uses <video controls controlsList="nodownload">; docs state this is UI cleanup, not a security boundary.
  • 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.