1960 lines
62 KiB
Markdown
1960 lines
62 KiB
Markdown
# 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.
|