Add VideoWall feature: server-side preview clip generation and mobile grid view
Backend (Rust/Actix-web): - Add video_preview_clips table and PreviewDao for tracking preview generation - Add ffmpeg preview clip generator: 10 equally-spaced 1s segments at 480p with CUDA NVENC auto-detection - Add PreviewClipGenerator actor with semaphore-limited concurrent processing - Add GET /video/preview and POST /video/preview/status endpoints - Extend file watcher to detect and queue previews for new videos - Use relative paths consistently for DB storage (matching EXIF convention) Frontend (React Native/Expo): - Add VideoWall grid view with 2-3 column layout of looping preview clips - Add VideoWallItem component with ActiveVideoPlayer sub-component for lifecycle management - Add useVideoWall hook for batch status polling with 5s refresh - Add navigation button in grid header (visible when videos exist) - Use TextureView surface type to fix Android z-ordering issues - Optimize memory: players only mount while visible via FlatList windowSize - Configure ExoPlayer buffer options and caching for short clips - Tap to toggle audio focus, long press to open in full viewer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS video_preview_clips;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE video_preview_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
duration_seconds REAL,
|
||||
file_size_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_preview_clips_file_path ON video_preview_clips(file_path);
|
||||
CREATE INDEX idx_preview_clips_status ON video_preview_clips(status);
|
||||
36
specs/001-video-wall/checklists/requirements.md
Normal file
36
specs/001-video-wall/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: VideoWall
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-25
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass validation.
|
||||
- Assumptions section documents reasonable defaults for format choice, column layout interpretation, and infrastructure reuse.
|
||||
- No [NEEDS CLARIFICATION] markers were needed — the user description was specific enough to make informed decisions for all requirements.
|
||||
91
specs/001-video-wall/contracts/api-endpoints.md
Normal file
91
specs/001-video-wall/contracts/api-endpoints.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# API Contracts: VideoWall
|
||||
|
||||
## GET /video/preview
|
||||
|
||||
Retrieve the preview clip MP4 file for a given video. If the preview is not yet generated, triggers on-demand generation and returns 202.
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
|
||||
**Query Parameters**:
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| path | string | yes | Relative path of the source video from BASE_PATH |
|
||||
|
||||
**Responses**:
|
||||
|
||||
| Status | Content-Type | Body | Description |
|
||||
|--------|-------------|------|-------------|
|
||||
| 200 | video/mp4 | MP4 file stream | Preview clip is ready and served |
|
||||
| 202 | application/json | `{"status": "processing", "path": "<path>"}` | Preview generation has been triggered; client should retry |
|
||||
| 400 | application/json | `{"error": "Invalid path"}` | Path validation failed |
|
||||
| 404 | application/json | `{"error": "Video not found"}` | Source video does not exist |
|
||||
| 500 | application/json | `{"error": "Generation failed: <detail>"}` | Preview generation failed |
|
||||
|
||||
**Behavior**:
|
||||
1. Validate path with `is_valid_full_path()`
|
||||
2. Check if preview clip exists on disk and status is `complete` → serve MP4 (200)
|
||||
3. If status is `pending` or no record exists → trigger generation, return 202
|
||||
4. If status is `processing` → return 202
|
||||
5. If status is `failed` → return 500 with error detail
|
||||
|
||||
---
|
||||
|
||||
## POST /video/preview/status
|
||||
|
||||
Check the preview generation status for a batch of video paths. Used by the mobile app to determine which previews are ready before requesting them.
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
|
||||
**Request Body** (application/json):
|
||||
|
||||
```json
|
||||
{
|
||||
"paths": [
|
||||
"2024/vacation/beach.mov",
|
||||
"2024/vacation/sunset.mp4",
|
||||
"2024/birthday.avi"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| paths | string[] | yes | Array of relative video paths from BASE_PATH |
|
||||
|
||||
**Response** (200, application/json):
|
||||
|
||||
```json
|
||||
{
|
||||
"previews": [
|
||||
{
|
||||
"path": "2024/vacation/beach.mov",
|
||||
"status": "complete",
|
||||
"preview_url": "/video/preview?path=2024/vacation/beach.mov"
|
||||
},
|
||||
{
|
||||
"path": "2024/vacation/sunset.mp4",
|
||||
"status": "processing",
|
||||
"preview_url": null
|
||||
},
|
||||
{
|
||||
"path": "2024/birthday.avi",
|
||||
"status": "pending",
|
||||
"preview_url": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| previews | object[] | Status for each requested path |
|
||||
| previews[].path | string | The requested video path |
|
||||
| previews[].status | string | One of: `pending`, `processing`, `complete`, `failed`, `not_found` |
|
||||
| previews[].preview_url | string? | Relative URL to fetch the preview (only when status is `complete`) |
|
||||
|
||||
**Behavior**:
|
||||
1. Accept up to 200 paths per request
|
||||
2. Batch query the `video_preview_clips` table for all paths
|
||||
3. For paths not in the table, return status `not_found` (video may not exist or hasn't been scanned yet)
|
||||
4. Return results in the same order as the input paths
|
||||
62
specs/001-video-wall/data-model.md
Normal file
62
specs/001-video-wall/data-model.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Data Model: VideoWall
|
||||
|
||||
## Entities
|
||||
|
||||
### VideoPreviewClip
|
||||
|
||||
Tracks the generation status and metadata of preview clips derived from source videos.
|
||||
|
||||
**Table**: `video_preview_clips`
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique identifier |
|
||||
| file_path | TEXT | NOT NULL, UNIQUE | Relative path of the source video from BASE_PATH |
|
||||
| status | TEXT | NOT NULL, DEFAULT 'pending' | Generation status: `pending`, `processing`, `complete`, `failed` |
|
||||
| duration_seconds | REAL | NULLABLE | Duration of the generated preview clip (≤10s) |
|
||||
| file_size_bytes | INTEGER | NULLABLE | Size of the generated MP4 file |
|
||||
| error_message | TEXT | NULLABLE | Error details if status is `failed` |
|
||||
| created_at | TEXT | NOT NULL | ISO 8601 timestamp when record was created |
|
||||
| updated_at | TEXT | NOT NULL | ISO 8601 timestamp when record was last updated |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_preview_clips_file_path` on `file_path` (unique, used for lookups and batch queries)
|
||||
- `idx_preview_clips_status` on `status` (used by file watcher to find pending/failed clips)
|
||||
|
||||
### Relationships
|
||||
|
||||
- **VideoPreviewClip → Source Video**: One-to-one via `file_path`. The preview clip file on disk is located at `{PREVIEW_CLIPS_DIRECTORY}/{file_path}.mp4`.
|
||||
- **VideoPreviewClip → image_exif**: Implicit relationship via shared `file_path`. No foreign key needed — the EXIF table may not have an entry for every video.
|
||||
|
||||
## State Transitions
|
||||
|
||||
```
|
||||
[new video detected] → pending
|
||||
pending → processing (when generation starts)
|
||||
processing → complete (when ffmpeg succeeds)
|
||||
processing → failed (when ffmpeg fails or times out)
|
||||
failed → pending (on retry / re-scan)
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- `file_path` must be a valid relative path within BASE_PATH
|
||||
- `status` must be one of: `pending`, `processing`, `complete`, `failed`
|
||||
- `duration_seconds` must be > 0 and ≤ 10.0 when status is `complete`
|
||||
- `file_size_bytes` must be > 0 when status is `complete`
|
||||
- `error_message` should only be non-null when status is `failed`
|
||||
|
||||
## Storage Layout (Filesystem)
|
||||
|
||||
```
|
||||
{PREVIEW_CLIPS_DIRECTORY}/
|
||||
├── 2024/
|
||||
│ ├── vacation/
|
||||
│ │ ├── beach.mp4 # Preview for BASE_PATH/2024/vacation/beach.mov
|
||||
│ │ └── sunset.mp4 # Preview for BASE_PATH/2024/vacation/sunset.mp4
|
||||
│ └── birthday.mp4 # Preview for BASE_PATH/2024/birthday.avi
|
||||
└── 2025/
|
||||
└── trip.mp4 # Preview for BASE_PATH/2025/trip.mkv
|
||||
```
|
||||
|
||||
All preview clips use `.mp4` extension regardless of source format.
|
||||
79
specs/001-video-wall/plan.md
Normal file
79
specs/001-video-wall/plan.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Implementation Plan: VideoWall
|
||||
|
||||
**Branch**: `001-video-wall` | **Date**: 2026-02-25 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/001-video-wall/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a VideoWall feature spanning the Rust API backend and React Native mobile app. The backend generates 480p MP4 preview clips (up to 10 seconds, composed of 10 equally spaced 1-second segments) using ffmpeg, extending the existing `OverviewVideo` pattern in `src/video/ffmpeg.rs`. The mobile app adds a VideoWall view using `expo-video` and FlatList to display a responsive 2-3 column grid of simultaneously looping, muted preview clips with audio-on-long-press. Preview clips are cached on disk, served via new API endpoints, and generated proactively by the file watcher.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Rust (stable, Cargo) for backend API; TypeScript / React Native (Expo SDK 52) for mobile app
|
||||
**Primary Dependencies**: actix-web 4, Diesel 2.2 (SQLite), ffmpeg/ffprobe (CLI), expo-video 3.0, expo-router 6.0, react-native-reanimated 4.1
|
||||
**Storage**: SQLite (preview clip status tracking), filesystem (MP4 preview clips in `PREVIEW_CLIPS_DIRECTORY`)
|
||||
**Testing**: `cargo test` for backend; manual testing for mobile app
|
||||
**Target Platform**: Linux server (API), iOS/Android (mobile app via Expo)
|
||||
**Project Type**: Mobile app + REST API (two separate repositories)
|
||||
**Performance Goals**: <3s VideoWall load for 50 pre-generated previews; <30s per clip generation; <5MB per clip; smooth simultaneous playback of 6-12 clips
|
||||
**Constraints**: Semaphore-limited concurrent ffmpeg processes (existing pattern); 480p resolution to keep bandwidth/CPU manageable; audio track preserved but muted by default
|
||||
**Scale/Scope**: Hundreds to low thousands of videos per library; single user at a time
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
Constitution is an unfilled template — no project-specific gates defined. **PASS** (no violations possible).
|
||||
|
||||
Post-Phase 1 re-check: Still PASS — no gates to evaluate.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/001-video-wall/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
│ └── api-endpoints.md
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
# Backend (ImageApi - Rust)
|
||||
src/
|
||||
├── video/
|
||||
│ ├── ffmpeg.rs # Add generate_preview_clip() using existing pattern
|
||||
│ ├── actors.rs # Add PreviewClipGenerator actor (semaphore-limited)
|
||||
│ └── mod.rs # Add generate_preview_clips() batch function
|
||||
├── main.rs # Add GET /video/preview, POST /video/preview/status endpoints
|
||||
│ # Extend file watcher to trigger preview generation
|
||||
├── database/
|
||||
│ ├── schema.rs # Add video_preview_clips table
|
||||
│ └── models.rs # Add VideoPreviewClip model
|
||||
│ └── preview_dao.rs # New DAO for preview clip status tracking
|
||||
└── data/
|
||||
└── mod.rs # Add PreviewClipRequest, PreviewStatusRequest types
|
||||
|
||||
# Frontend (SynologyFileViewer - React Native)
|
||||
app/(app)/grid/
|
||||
├── video-wall.tsx # New VideoWall view (FlatList grid)
|
||||
└── _layout.tsx # Add video-wall route to stack
|
||||
|
||||
components/
|
||||
└── VideoWallItem.tsx # Single preview clip cell (expo-video player)
|
||||
|
||||
hooks/
|
||||
└── useVideoWall.ts # Preview clip fetching, status polling, audio state
|
||||
```
|
||||
|
||||
**Structure Decision**: Mobile + API pattern. Backend changes extend existing `src/video/` module and `src/main.rs` handlers following established conventions. Frontend adds a new route under the existing grid stack navigator with a dedicated component and hook.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations to justify.
|
||||
115
specs/001-video-wall/quickstart.md
Normal file
115
specs/001-video-wall/quickstart.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Quickstart: VideoWall
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust toolchain (stable) with `cargo`
|
||||
- `diesel_cli` installed (`cargo install diesel_cli --no-default-features --features sqlite`)
|
||||
- ffmpeg and ffprobe available on PATH
|
||||
- Node.js 18+ and Expo CLI for mobile app
|
||||
- `.env` file configured with existing variables plus `PREVIEW_CLIPS_DIRECTORY`
|
||||
|
||||
## New Environment Variable
|
||||
|
||||
Add to `.env`:
|
||||
```bash
|
||||
PREVIEW_CLIPS_DIRECTORY=/path/to/preview-clips # Directory for generated preview MP4s
|
||||
```
|
||||
|
||||
## Backend Development
|
||||
|
||||
### 1. Create database migration
|
||||
|
||||
```bash
|
||||
cd C:\Users\ccord\RustroverProjects\ImageApi
|
||||
diesel migration generate create_video_preview_clips
|
||||
```
|
||||
|
||||
Edit the generated `up.sql`:
|
||||
```sql
|
||||
CREATE TABLE video_preview_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
duration_seconds REAL,
|
||||
file_size_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_preview_clips_file_path ON video_preview_clips(file_path);
|
||||
CREATE INDEX idx_preview_clips_status ON video_preview_clips(status);
|
||||
```
|
||||
|
||||
Edit `down.sql`:
|
||||
```sql
|
||||
DROP TABLE IF EXISTS video_preview_clips;
|
||||
```
|
||||
|
||||
Regenerate schema:
|
||||
```bash
|
||||
diesel migration run
|
||||
diesel print-schema > src/database/schema.rs
|
||||
```
|
||||
|
||||
### 2. Build and test backend
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
cargo run
|
||||
```
|
||||
|
||||
Test preview endpoint:
|
||||
```bash
|
||||
# Check preview status
|
||||
curl -X POST http://localhost:8080/video/preview/status \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"paths": ["some/video.mp4"]}'
|
||||
|
||||
# Request preview clip
|
||||
curl http://localhost:8080/video/preview?path=some/video.mp4 \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-o preview.mp4
|
||||
```
|
||||
|
||||
### 3. Verify preview clip generation
|
||||
|
||||
Check that preview clips appear in `PREVIEW_CLIPS_DIRECTORY` with the expected directory structure mirroring `BASE_PATH`.
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### 1. Start the mobile app
|
||||
|
||||
```bash
|
||||
cd C:\Users\ccord\development\SynologyFileViewer
|
||||
npx expo start
|
||||
```
|
||||
|
||||
### 2. Navigate to VideoWall
|
||||
|
||||
From the grid view of any folder containing videos, switch to VideoWall mode. The view should display a 2-3 column grid of looping preview clips.
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
### Backend (ImageApi)
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/video/ffmpeg.rs` | Add `generate_preview_clip()` function |
|
||||
| `src/video/actors.rs` | Add `PreviewClipGenerator` actor |
|
||||
| `src/video/mod.rs` | Add `generate_preview_clips()` batch function |
|
||||
| `src/main.rs` | Add endpoints, extend file watcher |
|
||||
| `src/database/schema.rs` | Regenerated by Diesel |
|
||||
| `src/database/models.rs` | Add `VideoPreviewClip` struct |
|
||||
| `src/database/preview_dao.rs` | New DAO file |
|
||||
| `src/data/mod.rs` | Add request/response types |
|
||||
| `src/state.rs` | Add PreviewClipGenerator to AppState |
|
||||
|
||||
### Frontend (SynologyFileViewer)
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `app/(app)/grid/video-wall.tsx` | New VideoWall view |
|
||||
| `app/(app)/grid/_layout.tsx` | Add route |
|
||||
| `components/VideoWallItem.tsx` | New preview clip cell component |
|
||||
| `hooks/useVideoWall.ts` | New hook for preview state management |
|
||||
91
specs/001-video-wall/research.md
Normal file
91
specs/001-video-wall/research.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Research: VideoWall
|
||||
|
||||
## R1: FFmpeg Preview Clip Generation Strategy
|
||||
|
||||
**Decision**: Use ffmpeg's `select` filter with segment-based extraction, extending the existing `OverviewVideo` pattern in `src/video/ffmpeg.rs`.
|
||||
|
||||
**Rationale**: The codebase already has a nearly identical pattern at `src/video/ffmpeg.rs` using `select='lt(mod(t,{interval}),1)'` which selects 1-second frames at evenly spaced intervals across the video duration. The existing pattern outputs GIF; we adapt it to output MP4 at 480p with audio.
|
||||
|
||||
**Approach**:
|
||||
1. Use `ffprobe` to get video duration (existing `get_video_duration()` pattern)
|
||||
2. Calculate interval: `duration / 10` (or fewer segments for short videos)
|
||||
3. Use ffmpeg with:
|
||||
- Video filter: `select='lt(mod(t,{interval}),1)',setpts=N/FRAME_RATE/TB,scale=-2:480`
|
||||
- Audio filter: `aselect='lt(mod(t,{interval}),1)',asetpts=N/SR/TB`
|
||||
- Output: MP4 with H.264 video + AAC audio
|
||||
- CRF 28 (lower quality acceptable for previews, reduces file size)
|
||||
- Preset: `veryfast` (matches existing HLS transcoding pattern)
|
||||
|
||||
**Alternatives considered**:
|
||||
- Generating separate segment files and concatenating: More complex, no benefit over select filter
|
||||
- Using GIF output: Rejected per clarification — MP4 is 5-10x smaller with better quality
|
||||
- Stream copy (no transcode): Not possible since we're extracting non-contiguous segments
|
||||
|
||||
## R2: Preview Clip Storage and Caching
|
||||
|
||||
**Decision**: Store preview clips on filesystem in a dedicated `PREVIEW_CLIPS_DIRECTORY` mirroring the source directory structure (same pattern as `THUMBNAILS` and `GIFS_DIRECTORY`).
|
||||
|
||||
**Rationale**: The project already uses this directory-mirroring pattern for thumbnails and GIF previews. It's simple, requires no database for file lookup (path is deterministic), and integrates naturally with the existing file watcher cleanup logic.
|
||||
|
||||
**Storage path formula**: `{PREVIEW_CLIPS_DIRECTORY}/{relative_path_from_BASE_PATH}.mp4`
|
||||
- Example: Video at `BASE_PATH/2024/vacation.mov` → Preview at `PREVIEW_CLIPS_DIRECTORY/2024/vacation.mp4`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Database BLOBs: Too large, not suited for binary video files
|
||||
- Content-addressed storage (hash-based): Unnecessary complexity for single-user system
|
||||
- Flat directory with UUID names: Loses the intuitive mapping that thumbnails/GIFs use
|
||||
|
||||
## R3: Preview Generation Status Tracking
|
||||
|
||||
**Decision**: Track generation status in SQLite via a new `video_preview_clips` table with Diesel ORM, following the existing DAO pattern.
|
||||
|
||||
**Rationale**: The batch status endpoint (FR-004) needs to efficiently check which previews are ready for a list of video paths. A database table is the right tool — it supports batch queries (existing `get_exif_batch()` pattern), survives restarts, and tracks failure states. The file watcher already uses batch DB queries to detect unprocessed files.
|
||||
|
||||
**Status values**: `pending`, `processing`, `complete`, `failed`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Filesystem-only (check if .mp4 exists): Cannot track `processing` or `failed` states; race conditions on concurrent requests
|
||||
- In-memory HashMap: Lost on restart; doesn't support batch queries efficiently across actor boundaries
|
||||
|
||||
## R4: Concurrent Generation Limits
|
||||
|
||||
**Decision**: Use `Arc<Semaphore>` with a limit of 2 concurrent ffmpeg preview generation processes, matching the existing `PlaylistGenerator` pattern.
|
||||
|
||||
**Rationale**: The `PlaylistGenerator` actor in `src/video/actors.rs` already uses this exact pattern to limit concurrent ffmpeg processes. Preview generation is CPU-intensive (transcoding), so limiting concurrency prevents server overload. The semaphore pattern is proven in this codebase.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Unbounded concurrency: Would overwhelm the server with many simultaneous ffmpeg processes
|
||||
- Queue with single worker: Too slow for batch generation; 2 concurrent is a good balance
|
||||
- Sharing the existing PlaylistGenerator semaphore: Would cause HLS generation and preview generation to compete for the same slots; better to keep them independent
|
||||
|
||||
## R5: Mobile App Video Playback Strategy
|
||||
|
||||
**Decision**: Use `expo-video` `VideoView` components inside FlatList items, with muted autoplay and viewport-based pause/resume.
|
||||
|
||||
**Rationale**: The app already uses `expo-video` (v3.0.15) for the single video player in `viewer/video.tsx`. The library supports multiple simultaneous players, `loop` mode, and programmatic mute/unmute. FlatList's `viewabilityConfig` callback can be used to pause/resume players based on viewport visibility.
|
||||
|
||||
**Key configuration per cell**:
|
||||
- `player.loop = true`
|
||||
- `player.muted = true` (default)
|
||||
- `player.play()` when visible, `player.pause()` when offscreen
|
||||
- `nativeControls={false}` (no controls needed in grid)
|
||||
|
||||
**Audio-on-focus**: On long-press, unmute the pressed player and mute all others. Track the "focused" player ID in hook state.
|
||||
|
||||
**Alternatives considered**:
|
||||
- HLS streaming for previews: Overkill for <10s clips; direct MP4 download is simpler and faster
|
||||
- Animated GIF display via Image component: Rejected per clarification — MP4 with expo-video is better
|
||||
- WebView-based player: Poor performance, no native gesture integration
|
||||
|
||||
## R6: API Endpoint Design
|
||||
|
||||
**Decision**: Two new endpoints — one to serve preview clips, one for batch status checking.
|
||||
|
||||
**Rationale**:
|
||||
- `GET /video/preview?path=...` serves the MP4 file directly (or triggers on-demand generation and returns 202 Accepted). Follows the pattern of `GET /image?path=...` for serving files.
|
||||
- `POST /video/preview/status` accepts a JSON body with an array of video paths and returns their preview generation status. This allows the mobile app to efficiently determine which previews are ready in a single request (batch pattern from `get_exif_batch()`).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Single endpoint that blocks until generation completes: Bad UX — generation takes up to 30s
|
||||
- WebSocket for real-time status: Overkill for this use case; polling with batch status is simpler
|
||||
- Including preview URL in the existing `/photos` response: Would couple the photo listing endpoint to preview generation; better to keep separate
|
||||
136
specs/001-video-wall/spec.md
Normal file
136
specs/001-video-wall/spec.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Feature Specification: VideoWall
|
||||
|
||||
**Feature Branch**: `001-video-wall`
|
||||
**Created**: 2026-02-25
|
||||
**Status**: Draft
|
||||
**Input**: User description: "I would like to implement a new View 'VideoWall' in the React native mobile app, with supporting API/tasks to generate at most 10 second long GIF/Videos that are 10 equally spaced 1 second clips of the original video. This view will display a grid 2/3 columns wide of all these clips playing simultaneously. It should let the user view all videos in the current folder/search results."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-25
|
||||
|
||||
- Q: What format should preview clips be generated in (GIF vs video)? → A: MP4 video clips (small files, hardware-accelerated playback, best quality-to-size ratio).
|
||||
- Q: What resolution should preview clips be generated at? → A: 480p scaled down (sharp in grid cells, small files, smooth simultaneous playback).
|
||||
- Q: How should audio be handled in preview clips? → A: Audio on focus — muted by default, audio plays when user long-presses on a clip. Audio track is preserved during generation.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Browse Videos as a Visual Wall (Priority: P1)
|
||||
|
||||
A user navigates to a folder containing videos and switches to the VideoWall view. The screen fills with a grid of video previews — short looping clips that give a visual summary of each video. All previews play simultaneously, creating an immersive "wall of motion" that lets the user quickly scan and identify videos of interest without opening each one individually.
|
||||
|
||||
**Why this priority**: This is the core experience. Without the visual grid of simultaneously playing previews, the feature has no value. This story delivers the primary browsing capability.
|
||||
|
||||
**Independent Test**: Can be fully tested by navigating to any folder with videos, switching to VideoWall view, and confirming that preview clips display in a grid and play simultaneously. Delivers immediate visual browsing value.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is viewing a folder containing 6 videos, **When** they switch to VideoWall view, **Then** they see a grid of 6 video previews arranged in 2-3 columns, all playing simultaneously in a loop.
|
||||
2. **Given** a user is viewing a folder containing 20 videos, **When** they switch to VideoWall view, **Then** the grid is scrollable and loads previews progressively as they scroll.
|
||||
3. **Given** a user is in VideoWall view, **When** they tap on a video preview, **Then** they navigate to the full video player for that video.
|
||||
4. **Given** a user is in VideoWall view with all clips muted, **When** they long-press on a preview clip, **Then** that clip's audio unmutes and all other clips remain muted.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Server Generates Preview Clips (Priority: P1)
|
||||
|
||||
When preview clips are requested for a video that has not yet been processed, the server generates a short preview clip. The preview is composed of 10 equally spaced 1-second segments extracted from the original video, concatenated into a single clip of at most 10 seconds. Once generated, the preview is cached so subsequent requests are served instantly.
|
||||
|
||||
**Why this priority**: The VideoWall view depends entirely on having preview clips available. Without server-side generation, there is nothing to display. This is co-priority with Story 1 as they are interdependent.
|
||||
|
||||
**Independent Test**: Can be tested by requesting a preview clip for any video via the API and confirming the response is a playable clip of at most 10 seconds composed of segments from different parts of the original video.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a video exists that has no preview clip yet, **When** a preview is requested, **Then** the system generates a clip of at most 10 seconds composed of 10 equally spaced 1-second segments from the original video.
|
||||
2. **Given** a video is shorter than 10 seconds, **When** a preview is requested, **Then** the system generates a preview using fewer segments (as many 1-second clips as the video duration allows), resulting in a shorter preview.
|
||||
3. **Given** a preview clip was previously generated for a video, **When** it is requested again, **Then** the cached version is served without re-processing.
|
||||
4. **Given** a video file no longer exists, **When** a preview is requested, **Then** the system returns an appropriate error indicating the source video is missing.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - VideoWall from Search Results (Priority: P2)
|
||||
|
||||
A user performs a search or applies filters (tags, date range, camera, location) and the results include videos. They switch to VideoWall view to see preview clips of all matching videos displayed in the same grid layout, allowing visual browsing of search results.
|
||||
|
||||
**Why this priority**: Extends the core VideoWall browsing to work with filtered/search result sets. Important for discoverability but depends on Story 1 and 2 being functional first.
|
||||
|
||||
**Independent Test**: Can be tested by performing a search that returns videos, switching to VideoWall view, and confirming that only matching videos appear as previews in the grid.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user has search results containing 8 videos and 12 photos, **When** they switch to VideoWall view, **Then** only the 8 video previews are displayed in the grid.
|
||||
2. **Given** a user applies a tag filter that matches 3 videos, **When** they view the VideoWall, **Then** exactly 3 video previews are shown.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Background Preview Generation (Priority: P3)
|
||||
|
||||
Preview clips are generated proactively in the background for videos discovered during file watching, so that when a user opens VideoWall, most previews are already available and the experience feels instant.
|
||||
|
||||
**Why this priority**: Enhances performance and perceived responsiveness. The feature works without this (on-demand generation), but background processing greatly improves the user experience for large libraries.
|
||||
|
||||
**Independent Test**: Can be tested by adding new video files to a monitored folder and confirming that preview clips are generated automatically within the next scan cycle, before any user requests them.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a new video is added to the media library, **When** the file watcher detects it, **Then** a preview clip is generated in the background without user intervention.
|
||||
2. **Given** the system is generating previews in the background, **When** a user opens VideoWall, **Then** already-generated previews display immediately while pending ones show a placeholder.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a video is corrupted or cannot be processed? The system shows a placeholder/error state for that video and does not block other previews from loading.
|
||||
- What happens when the user scrolls quickly through a large library? Previews outside the visible viewport should pause or not load to conserve resources, and resume when scrolled back into view.
|
||||
- What happens when a video is extremely long (e.g., 4+ hours)? The same algorithm applies — 10 equally spaced 1-second clips — ensuring the preview still represents the full video.
|
||||
- What happens when a video is exactly 10 seconds long? Each 1-second segment starts at second 0, 1, 2, ... 9, effectively previewing the entire video.
|
||||
- What happens when storage for preview clips runs low? Preview clips should be reasonably compressed and sized to minimize storage impact.
|
||||
- What happens when many previews are requested simultaneously (e.g., opening a folder with 100 videos)? The system should queue generation and serve already-cached previews immediately while others are processed.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST generate preview clips for videos as MP4 files scaled to 480p resolution, where each preview is composed of up to 10 equally spaced 1-second segments from the original video, resulting in a clip of at most 10 seconds.
|
||||
- **FR-002**: System MUST cache generated preview clips so they are only generated once per source video.
|
||||
- **FR-003**: System MUST provide an endpoint to retrieve a preview clip for a given video path.
|
||||
- **FR-004**: System MUST provide an endpoint to retrieve preview availability status for a batch of video paths so the client knows which previews are ready.
|
||||
- **FR-005**: The mobile app MUST display a VideoWall view showing video previews in a grid of 2 columns on smaller screens and 3 columns on larger screens.
|
||||
- **FR-006**: All visible preview clips in the VideoWall MUST play simultaneously, muted, and loop continuously.
|
||||
- **FR-006a**: When a user long-presses on a preview clip, the app MUST unmute that clip's audio. Only one clip may have audio at a time.
|
||||
- **FR-006b**: Preview clips MUST retain their audio track during generation (not stripped) to support audio-on-focus playback.
|
||||
- **FR-007**: The VideoWall MUST support browsing videos from both folder navigation and search/filter results.
|
||||
- **FR-008**: Tapping a preview clip in the VideoWall MUST navigate the user to the full video.
|
||||
- **FR-009**: For videos shorter than 10 seconds, the system MUST generate a preview using as many full 1-second segments as the video duration allows.
|
||||
- **FR-010**: The system MUST display a placeholder for videos whose preview clips are not yet generated.
|
||||
- **FR-011**: The system MUST handle unprocessable videos gracefully by showing an error state rather than failing the entire wall.
|
||||
- **FR-012**: The VideoWall MUST support scrolling through large numbers of videos, loading previews progressively.
|
||||
- **FR-013**: Preview clips outside the visible viewport SHOULD pause playback to conserve device resources.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Video Preview Clip**: A short looping MP4 video (at most 10 seconds) scaled to 480p resolution, derived from a source video. Composed of up to 10 equally spaced 1-second segments. Associated with exactly one source video by file path. Has a generation status (pending, processing, complete, failed).
|
||||
- **VideoWall View**: A scrollable grid layout displaying video preview clips. Adapts column count based on screen size (2 or 3 columns). Operates on a set of videos from a folder or search result context.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can visually browse all videos in a folder within 3 seconds of opening VideoWall (for folders with up to 50 videos with pre-generated previews).
|
||||
- **SC-002**: Preview clips accurately represent the source video by sampling from evenly distributed points across the full duration.
|
||||
- **SC-003**: All visible previews play simultaneously without noticeable stuttering on standard mobile devices.
|
||||
- **SC-004**: Generated preview clips are each under 5 MB in size to keep storage and bandwidth manageable.
|
||||
- **SC-005**: The VideoWall view correctly filters to show only videos (not photos) from the current folder or search results.
|
||||
- **SC-006**: Users can identify and select a video of interest from the VideoWall and navigate to it in a single tap.
|
||||
- **SC-007**: Preview generation for a single video completes within 30 seconds on typical hardware.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The existing file watcher and thumbnail generation infrastructure will be extended to also trigger preview clip generation.
|
||||
- Preview clips will be stored alongside existing thumbnails/GIFs in a designated directory on the server.
|
||||
- The React Native mobile app already has folder navigation and search/filter capabilities that provide the video list context for VideoWall.
|
||||
- The server already has ffmpeg available for video processing (used for existing HLS and GIF generation).
|
||||
- Authentication and authorization follow the existing JWT-based pattern; no new auth requirements.
|
||||
- "2/3 columns" means a responsive layout: 2 columns on phones (portrait), 3 columns on tablets or landscape orientation.
|
||||
- Preview clips are generated as MP4 video files for optimal quality-to-size ratio and hardware-accelerated mobile playback.
|
||||
234
specs/001-video-wall/tasks.md
Normal file
234
specs/001-video-wall/tasks.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Tasks: VideoWall
|
||||
|
||||
**Input**: Design documents from `/specs/001-video-wall/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Not explicitly requested — test tasks omitted.
|
||||
|
||||
**Organization**: Tasks grouped by user story. US2 (server generation) comes before US1 (mobile view) because the mobile app depends on the API endpoints existing.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
- **Backend (ImageApi)**: `src/` at `C:\Users\ccord\RustroverProjects\ImageApi`
|
||||
- **Frontend (SynologyFileViewer)**: `app/`, `components/`, `hooks/` at `C:\Users\ccord\development\SynologyFileViewer`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Database migration, new environment variable, shared types
|
||||
|
||||
- [x] T001 Create Diesel migration for `video_preview_clips` table: run `diesel migration generate create_video_preview_clips`, write `up.sql` with table definition (id, file_path UNIQUE, status DEFAULT 'pending', duration_seconds, file_size_bytes, error_message, created_at, updated_at) and indexes (idx_preview_clips_file_path, idx_preview_clips_status), write `down.sql` with DROP TABLE. See `data-model.md` for full schema.
|
||||
- [x] T002 Run migration and regenerate schema: execute `diesel migration run` then `diesel print-schema > src/database/schema.rs` to add the `video_preview_clips` table to `src/database/schema.rs`
|
||||
- [x] T003 Add `PREVIEW_CLIPS_DIRECTORY` environment variable: read it in `src/main.rs` startup (alongside existing `GIFS_DIRECTORY`), create the directory if it doesn't exist, and add it to `AppState` or pass it where needed. Follow the pattern used for `GIFS_DIRECTORY` and `THUMBNAILS`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Diesel model, DAO, and request/response types that all user stories depend on
|
||||
|
||||
**CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [x] T004 [P] Add `VideoPreviewClip` Diesel model struct in `src/database/models.rs` with fields matching the `video_preview_clips` schema table (Queryable, Insertable derives). Add a `NewVideoPreviewClip` struct for inserts.
|
||||
- [x] T005 [P] Add `PreviewClipRequest` and `PreviewStatusRequest`/`PreviewStatusResponse` types in `src/data/mod.rs`. `PreviewClipRequest` has `path: String`. `PreviewStatusRequest` has `paths: Vec<String>`. `PreviewStatusResponse` has `previews: Vec<PreviewStatusItem>` where each item has `path`, `status`, `preview_url: Option<String>`. All with Serialize/Deserialize derives.
|
||||
- [x] T006 Create `PreviewDao` trait and `SqlitePreviewDao` implementation in `src/database/preview_dao.rs`. Methods: `insert_preview(file_path, status) -> Result`, `update_status(file_path, status, duration_seconds?, file_size_bytes?, error_message?) -> Result`, `get_preview(file_path) -> Result<Option<VideoPreviewClip>>`, `get_previews_batch(file_paths: &[String]) -> Result<Vec<VideoPreviewClip>>`, `get_by_status(status) -> Result<Vec<VideoPreviewClip>>`. Follow the `ExifDao`/`SqliteExifDao` pattern with `Arc<Mutex<SqliteConnection>>` and OpenTelemetry tracing spans.
|
||||
- [x] T007 Register `preview_dao` module in `src/database/mod.rs` and add `PreviewDao` to the database module exports. Wire `SqlitePreviewDao` into `AppState` in `src/state.rs` following the existing DAO pattern (e.g., how `ExifDao` is added).
|
||||
|
||||
**Checkpoint**: Foundation ready — DAO, models, and types available for all stories
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - Server Generates Preview Clips (Priority: P1) MVP
|
||||
|
||||
**Goal**: Backend can generate 480p MP4 preview clips (10 equally spaced 1-second segments) and serve them via API endpoints with on-demand generation and batch status checking.
|
||||
|
||||
**Independent Test**: Request `GET /video/preview?path=<video>` for any video — should return an MP4 file of at most 10 seconds. Request `POST /video/preview/status` with video paths — should return status for each.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T008 [P] [US2] Add `generate_preview_clip()` function in `src/video/ffmpeg.rs`. Takes input video path, output MP4 path, and video duration. Uses ffprobe to get duration (existing pattern). Calculates interval = `duration / 10` (or fewer for short videos per FR-009). Builds ffmpeg command with: video filter `select='lt(mod(t,{interval}),1)',setpts=N/FRAME_RATE/TB,scale=-2:480`, audio filter `aselect='lt(mod(t,{interval}),1)',asetpts=N/SR/TB`, codec H.264 CRF 28 preset veryfast, AAC audio. Output path uses `.mp4` extension. Creates parent directories for output. Returns `Result<(f64, u64)>` with (duration_seconds, file_size_bytes). See `research.md` R1 for full ffmpeg strategy.
|
||||
- [x] T009 [P] [US2] Create `PreviewClipGenerator` actor in `src/video/actors.rs`. Struct holds `Arc<Semaphore>` (limit 2 concurrent), preview clips directory path, base path, and `Arc<dyn PreviewDao>`. Handles `GeneratePreviewMessage { video_path: String }`: acquires semaphore permit, updates DB status to `processing`, calls `generate_preview_clip()`, updates DB to `complete` with duration/size on success or `failed` with error on failure. Follow the `PlaylistGenerator` actor pattern with `tokio::spawn` for async processing.
|
||||
- [x] T010 [US2] Add `PreviewClipGenerator` actor to `AppState` in `src/state.rs`. Initialize it during server startup in `src/main.rs` with the `PREVIEW_CLIPS_DIRECTORY`, `BASE_PATH`, and preview DAO reference. Start the actor with `PreviewClipGenerator::new(...).start()`.
|
||||
- [x] T011 [US2] Implement `GET /video/preview` handler in `src/main.rs`. Validate path with `is_valid_full_path()`. Check preview DAO for status: if `complete` → serve MP4 file with `NamedFile::open()` (200); if `processing` → return 202 JSON; if `pending`/not found → insert/update record as `pending`, send `GeneratePreviewMessage` to actor, return 202 JSON; if `failed` → return 500 with error. See `contracts/api-endpoints.md` for full response contract.
|
||||
- [x] T012 [US2] Implement `POST /video/preview/status` handler in `src/main.rs`. Accept `PreviewStatusRequest` JSON body. Call `preview_dao.get_previews_batch()` for all paths. Map results: for each path, return status and `preview_url` (only when `complete`). Paths not in DB get status `not_found`. Limit to 200 paths per request. Return `PreviewStatusResponse` JSON.
|
||||
- [x] T013 [US2] Register both new endpoints in route configuration in `src/main.rs`. Add `web::resource("/video/preview").route(web::get().to(get_video_preview))` and `web::resource("/video/preview/status").route(web::post().to(get_preview_status))`. Both require authentication (Claims extraction).
|
||||
- [x] T014 [US2] Handle short videos (< 10 seconds) in `generate_preview_clip()` in `src/video/ffmpeg.rs`. When duration < 10s, calculate segment count as `floor(duration)` and interval as `duration / segment_count`. When duration < 1s, use the entire video as the preview (just transcode to 480p MP4). Add this logic to the interval calculation in T008.
|
||||
|
||||
**Checkpoint**: Backend fully functional — preview clips can be generated, cached, and served via API
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 1 - Browse Videos as a Visual Wall (Priority: P1) MVP
|
||||
|
||||
**Goal**: Mobile app displays a responsive 2-3 column grid of simultaneously looping, muted video previews with long-press audio and tap-to-navigate.
|
||||
|
||||
**Independent Test**: Navigate to a folder with videos in the app, switch to VideoWall view, confirm grid displays with playing previews. Long-press to hear audio. Tap to open full video.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T015 [P] [US1] Create `useVideoWall` hook in `hooks/useVideoWall.ts` (SynologyFileViewer). Accepts array of `GridItem[]` (video items only, filtered from current files context). Calls `POST /video/preview/status` with video paths on mount to get availability. Returns `{ previewStatuses: Map<string, PreviewStatus>, focusedVideoPath: string | null, setFocusedVideo: (path) => void, refreshStatuses: () => void }`. Uses `authenticatedFetch()` from auth hook. Polls status every 5 seconds for any items still in `pending`/`processing` state, stops polling when all are `complete` or `failed`.
|
||||
- [x] T016 [P] [US1] Create `VideoWallItem` component in `components/VideoWallItem.tsx` (SynologyFileViewer). Renders an `expo-video` `VideoView` for a single preview clip. Props: `videoPath: string, previewStatus: string, isFocused: boolean, onTap: () => void, onLongPress: () => void, isVisible: boolean`. When `previewStatus === 'complete'`: create `useVideoPlayer` with source URL `${baseUrl}/video/preview?path=${videoPath}` and auth headers, set `player.loop = true`, `player.muted = !isFocused`. When `isVisible` is true → `player.play()`, false → `player.pause()`. When status is not complete: show placeholder (thumbnail image from existing `/image?path=&size=thumb` endpoint with a loading indicator overlay). When `failed`: show error icon overlay. Aspect ratio 16:9 with `nativeControls={false}`.
|
||||
- [x] T017 [US1] Create VideoWall view in `app/(app)/grid/video-wall.tsx` (SynologyFileViewer). Use `FlatList` with `numColumns` calculated as `Math.floor(dimensions.width / 180)` (targeting 2-3 columns). Get video items from `FilesContext` — filter `allItems` or `filteredItems` to only include video extensions (use same detection as existing `isVideo()` check). Pass items to `useVideoWall` hook. Use `viewabilityConfig` with `viewAreaCoveragePercentThreshold: 50` and `onViewableItemsChanged` callback to track visible items, passing `isVisible` to each `VideoWallItem`. Implement `keyExtractor` using video path. Add scroll-to-top FAB button following existing grid pattern.
|
||||
- [x] T018 [US1] Add `video-wall` route to stack navigator in `app/(app)/grid/_layout.tsx` (SynologyFileViewer). Add `<Stack.Screen name="video-wall" options={{ title: "Video Wall" }} />` to the existing Stack navigator.
|
||||
- [x] T019 [US1] Add navigation entry point to switch to VideoWall from the grid view. In `app/(app)/grid/[path].tsx` (SynologyFileViewer), add a header button (e.g., a grid/video icon from `@expo/vector-icons`) that calls `router.push("/grid/video-wall")`. Only show the button when the current folder contains at least one video file.
|
||||
- [x] T020 [US1] Implement long-press audio-on-focus behavior. In `VideoWallItem`, wrap the VideoView in a `Pressable` with `onLongPress` calling `onLongPress` prop. In `video-wall.tsx`, when `onLongPress` fires for an item: call `setFocusedVideo(path)` if different from current, or `setFocusedVideo(null)` to toggle off. The `isFocused` prop drives `player.muted` in `VideoWallItem` — when focused, unmute; all others stay muted.
|
||||
- [x] T021 [US1] Implement tap-to-navigate to full video player. In `VideoWallItem`, the `onTap` prop triggers navigation. In `video-wall.tsx`, the `onTap` handler sets the `currentIndex` in `FilesContext` to the tapped video's index and calls `router.push("/grid/viewer/video")` following the existing pattern from `[path].tsx` grid item press.
|
||||
|
||||
**Checkpoint**: Full VideoWall experience works for folder browsing with simultaneous playback, audio-on-focus, and tap-to-view
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - VideoWall from Search Results (Priority: P2)
|
||||
|
||||
**Goal**: VideoWall works with search/filter results, showing only matching videos.
|
||||
|
||||
**Independent Test**: Perform a search with filters that returns videos, switch to VideoWall, confirm only matching videos appear.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T022 [US3] Ensure VideoWall uses `filteredItems` when available. In `app/(app)/grid/video-wall.tsx` (SynologyFileViewer), check if `filteredItems` from `FilesContext` is non-empty — if so, use `filteredItems` filtered to videos only; otherwise use `allItems` filtered to videos. This should already work if T017 reads from the context correctly, but verify the logic handles both folder browsing and search result modes.
|
||||
- [x] T023 [US3] Add VideoWall toggle from search results. In `app/(app)/search.tsx` (SynologyFileViewer), add a button (same icon as T019) that navigates to `/grid/video-wall` when search results contain at least one video. The `filteredItems` in `FilesContext` should already be populated by the search, so VideoWall will pick them up automatically.
|
||||
|
||||
**Checkpoint**: VideoWall works with both folder navigation and search/filter results
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Background Preview Generation (Priority: P3)
|
||||
|
||||
**Goal**: Preview clips are generated proactively during file watching so most are ready before users open VideoWall.
|
||||
|
||||
**Independent Test**: Add new video files to a monitored folder, wait for file watcher scan cycle, confirm preview clips appear in `PREVIEW_CLIPS_DIRECTORY` without any user request.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T024 [US4] Extend `process_new_files()` in `src/main.rs` to detect videos missing preview clips. After the existing EXIF batch query, add a batch query via `preview_dao.get_previews_batch()` for all discovered video paths. Collect videos that have no record or have `failed` status (for retry).
|
||||
- [x] T025 [US4] Queue preview generation for new/unprocessed videos in `process_new_files()` in `src/main.rs`. For each video missing a preview, insert a `pending` record via `preview_dao.insert_preview()` (skip if already exists), then send `GeneratePreviewMessage` to the `PreviewClipGenerator` actor. Follow the existing pattern of sending `QueueVideosMessage` to `VideoPlaylistManager`.
|
||||
- [x] T026 [US4] Add preview clip directory creation to startup scan in `src/main.rs`. During the initial startup thumbnail generation phase, also check for videos missing preview clips and queue them for generation (same logic as T024/T025 but for the initial full scan). Ensure the `PREVIEW_CLIPS_DIRECTORY` is created at startup if it doesn't exist.
|
||||
|
||||
**Checkpoint**: New videos automatically get preview clips generated during file watcher scans
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Error handling, loading states, observability
|
||||
|
||||
- [x] T027 [P] Add loading/placeholder state for pending previews in `components/VideoWallItem.tsx` (SynologyFileViewer). Show the existing thumbnail from `/image?path=&size=thumb` with a semi-transparent overlay and a loading spinner when preview status is `pending` or `processing`.
|
||||
- [x] T028 [P] Add error state for failed previews in `components/VideoWallItem.tsx` (SynologyFileViewer). Show the existing thumbnail with an error icon overlay and optional "Retry" text when preview status is `failed`.
|
||||
- [x] T029 [P] Add OpenTelemetry tracing spans for preview generation in `src/video/actors.rs` and `src/main.rs` endpoints. Follow the existing pattern of `global_tracer().start("preview_clip_generate")` with status and duration attributes.
|
||||
- [x] T030 Verify cargo build and cargo clippy pass with all backend changes. Fix any warnings or errors.
|
||||
- [x] T031 Run quickstart.md validation: test both API endpoints manually with curl, verify preview clip file is generated in correct directory structure, confirm mobile app connects and displays VideoWall.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 (migration must run first)
|
||||
- **US2 - Server Generation (Phase 3)**: Depends on Phase 2 (needs DAO, models, types)
|
||||
- **US1 - Mobile VideoWall (Phase 4)**: Depends on Phase 3 (needs API endpoints to exist)
|
||||
- **US3 - Search Results (Phase 5)**: Depends on Phase 4 (extends VideoWall view)
|
||||
- **US4 - Background Generation (Phase 6)**: Depends on Phase 3 only (backend only, no mobile dependency)
|
||||
- **Polish (Phase 7)**: Depends on Phases 4 and 6
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US2 (P1)**: Can start after Foundational — no other story dependencies
|
||||
- **US1 (P1)**: Depends on US2 (needs preview API endpoints)
|
||||
- **US3 (P2)**: Depends on US1 (extends the VideoWall view)
|
||||
- **US4 (P3)**: Depends on US2 only (extends file watcher with preview generation; independent of mobile app)
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Models/types before services/DAO
|
||||
- DAO before actors
|
||||
- Actors before endpoints
|
||||
- Backend endpoints before mobile app views
|
||||
- Core view before navigation integration
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
**Phase 2**: T004, T005 can run in parallel (different files)
|
||||
**Phase 3**: T008, T009 can run in parallel (ffmpeg.rs vs actors.rs)
|
||||
**Phase 4**: T015, T016 can run in parallel (hook vs component, different files)
|
||||
**Phase 6**: T024, T025 are sequential (same file) but Phase 6 can run in parallel with Phase 4/5
|
||||
**Phase 7**: T027, T028, T029 can all run in parallel (different files)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch parallelizable tasks together:
|
||||
Task T008: "Add generate_preview_clip() function in src/video/ffmpeg.rs"
|
||||
Task T009: "Create PreviewClipGenerator actor in src/video/actors.rs"
|
||||
|
||||
# Then sequential tasks (depend on T008+T009):
|
||||
Task T010: "Add PreviewClipGenerator to AppState in src/state.rs"
|
||||
Task T011: "Implement GET /video/preview handler in src/main.rs"
|
||||
Task T012: "Implement POST /video/preview/status handler in src/main.rs"
|
||||
Task T013: "Register endpoints in route configuration in src/main.rs"
|
||||
Task T014: "Handle short videos in generate_preview_clip() in src/video/ffmpeg.rs"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch parallelizable tasks together:
|
||||
Task T015: "Create useVideoWall hook in hooks/useVideoWall.ts"
|
||||
Task T016: "Create VideoWallItem component in components/VideoWallItem.tsx"
|
||||
|
||||
# Then sequential tasks (depend on T015+T016):
|
||||
Task T017: "Create VideoWall view in app/(app)/grid/video-wall.tsx"
|
||||
Task T018: "Add video-wall route to stack navigator"
|
||||
Task T019: "Add navigation entry point from grid view"
|
||||
Task T020: "Implement long-press audio-on-focus"
|
||||
Task T021: "Implement tap-to-navigate to full video player"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US2 + US1)
|
||||
|
||||
1. Complete Phase 1: Setup (migration, env var)
|
||||
2. Complete Phase 2: Foundational (model, DAO, types)
|
||||
3. Complete Phase 3: US2 — Server generates preview clips
|
||||
4. **STOP and VALIDATE**: Test API with curl per quickstart.md
|
||||
5. Complete Phase 4: US1 — Mobile VideoWall view
|
||||
6. **STOP and VALIDATE**: Test end-to-end on device
|
||||
7. Deploy/demo — this is the MVP!
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Foundation ready
|
||||
2. US2 (Server Generation) → Backend API working (testable with curl)
|
||||
3. US1 (Mobile VideoWall) → Full end-to-end MVP (testable on device)
|
||||
4. US3 (Search Results) → Extended browsing from search (incremental value)
|
||||
5. US4 (Background Generation) → Performance enhancement (clips pre-generated)
|
||||
6. Polish → Error states, tracing, validation
|
||||
|
||||
### Note on US4 Parallelism
|
||||
|
||||
US4 (Background Generation) only depends on US2 (backend), not on the mobile app. It can be developed in parallel with US1 by a second developer, or deferred to after MVP is validated.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story
|
||||
- Backend work is in `C:\Users\ccord\RustroverProjects\ImageApi`
|
||||
- Frontend work is in `C:\Users\ccord\development\SynologyFileViewer`
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
@@ -371,6 +371,29 @@ pub struct GpsPhotosResponse {
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PreviewClipRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PreviewStatusRequest {
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PreviewStatusResponse {
|
||||
pub previews: Vec<PreviewStatusItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PreviewStatusItem {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_url: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Claims;
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod daily_summary_dao;
|
||||
pub mod insights_dao;
|
||||
pub mod location_dao;
|
||||
pub mod models;
|
||||
pub mod preview_dao;
|
||||
pub mod schema;
|
||||
pub mod search_dao;
|
||||
|
||||
@@ -21,6 +22,7 @@ pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao};
|
||||
pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao};
|
||||
pub use insights_dao::{InsightDao, SqliteInsightDao};
|
||||
pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao};
|
||||
pub use preview_dao::{PreviewDao, SqlitePreviewDao};
|
||||
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
|
||||
|
||||
pub trait UserDao {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::database::schema::{favorites, image_exif, photo_insights, users};
|
||||
use crate::database::schema::{favorites, image_exif, photo_insights, users, video_preview_clips};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Insertable)]
|
||||
@@ -93,3 +93,24 @@ pub struct PhotoInsight {
|
||||
pub generated_at: i64,
|
||||
pub model_version: String,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = video_preview_clips)]
|
||||
pub struct InsertVideoPreviewClip {
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Queryable, Clone, Debug)]
|
||||
pub struct VideoPreviewClip {
|
||||
pub id: i32,
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
pub duration_seconds: Option<f32>,
|
||||
pub file_size_bytes: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
183
src/database/preview_dao.rs
Normal file
183
src/database/preview_dao.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::database::models::{InsertVideoPreviewClip, VideoPreviewClip};
|
||||
use crate::database::{connect, DbError, DbErrorKind};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
pub trait PreviewDao: Sync + Send {
|
||||
fn insert_preview(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
status_val: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
fn update_status(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
status_val: &str,
|
||||
duration: Option<f32>,
|
||||
size: Option<i32>,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
fn get_preview(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
) -> Result<Option<VideoPreviewClip>, DbError>;
|
||||
|
||||
fn get_previews_batch(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_paths: &[String],
|
||||
) -> Result<Vec<VideoPreviewClip>, DbError>;
|
||||
|
||||
fn get_by_status(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
status_val: &str,
|
||||
) -> Result<Vec<VideoPreviewClip>, DbError>;
|
||||
}
|
||||
|
||||
pub struct SqlitePreviewDao {
|
||||
connection: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
|
||||
impl Default for SqlitePreviewDao {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SqlitePreviewDao {
|
||||
pub fn new() -> Self {
|
||||
SqlitePreviewDao {
|
||||
connection: Arc::new(Mutex::new(connect())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreviewDao for SqlitePreviewDao {
|
||||
fn insert_preview(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
status_val: &str,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "insert", "insert_preview", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
diesel::insert_or_ignore_into(video_preview_clips)
|
||||
.values(InsertVideoPreviewClip {
|
||||
file_path: file_path_val.to_string(),
|
||||
status: status_val.to_string(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn update_status(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
status_val: &str,
|
||||
duration: Option<f32>,
|
||||
size: Option<i32>,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "update", "update_preview_status", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
diesel::update(video_preview_clips.filter(file_path.eq(file_path_val)))
|
||||
.set((
|
||||
status.eq(status_val),
|
||||
duration_seconds.eq(duration),
|
||||
file_size_bytes.eq(size),
|
||||
error_message.eq(error),
|
||||
updated_at.eq(&now),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn get_preview(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
) -> Result<Option<VideoPreviewClip>, DbError> {
|
||||
trace_db_call(context, "query", "get_preview", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
|
||||
match video_preview_clips
|
||||
.filter(file_path.eq(file_path_val))
|
||||
.first::<VideoPreviewClip>(connection.deref_mut())
|
||||
{
|
||||
Ok(clip) => Ok(Some(clip)),
|
||||
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||
Err(e) => Err(anyhow::anyhow!("Query error: {}", e)),
|
||||
}
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_previews_batch(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_paths: &[String],
|
||||
) -> Result<Vec<VideoPreviewClip>, DbError> {
|
||||
trace_db_call(context, "query", "get_previews_batch", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
if file_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
|
||||
video_preview_clips
|
||||
.filter(file_path.eq_any(file_paths))
|
||||
.load::<VideoPreviewClip>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_by_status(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
status_val: &str,
|
||||
) -> Result<Vec<VideoPreviewClip>, DbError> {
|
||||
trace_db_call(context, "query", "get_previews_by_status", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
|
||||
video_preview_clips
|
||||
.filter(status.eq(status_val))
|
||||
.load::<VideoPreviewClip>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,19 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
video_preview_clips (id) {
|
||||
id -> Integer,
|
||||
file_path -> Text,
|
||||
status -> Text,
|
||||
duration_seconds -> Nullable<Float>,
|
||||
file_size_bytes -> Nullable<Integer>,
|
||||
error_message -> Nullable<Text>,
|
||||
created_at -> Text,
|
||||
updated_at -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(tagged_photo -> tags (tag_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
@@ -167,4 +180,5 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
tagged_photo,
|
||||
tags,
|
||||
users,
|
||||
video_preview_clips,
|
||||
);
|
||||
|
||||
302
src/main.rs
302
src/main.rs
@@ -46,8 +46,8 @@ use crate::service::ServiceBuilder;
|
||||
use crate::state::AppState;
|
||||
use crate::tags::*;
|
||||
use crate::video::actors::{
|
||||
ProcessMessage, QueueVideosMessage, ScanDirectoryMessage, VideoPlaylistManager,
|
||||
create_playlist, generate_video_thumbnail,
|
||||
GeneratePreviewClipMessage, ProcessMessage, QueueVideosMessage, ScanDirectoryMessage,
|
||||
VideoPlaylistManager, create_playlist, generate_video_thumbnail,
|
||||
};
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
|
||||
@@ -583,6 +583,225 @@ async fn get_video_part(
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/video/preview")]
|
||||
async fn get_video_preview(
|
||||
_claims: Claims,
|
||||
request: HttpRequest,
|
||||
req: web::Query<PreviewClipRequest>,
|
||||
app_state: Data<AppState>,
|
||||
preview_dao: Data<Mutex<Box<dyn PreviewDao>>>,
|
||||
) -> impl Responder {
|
||||
let tracer = global_tracer();
|
||||
let context = extract_context_from_request(&request);
|
||||
let mut span = tracer.start_with_context("get_video_preview", &context);
|
||||
|
||||
// Validate path
|
||||
let full_path = match is_valid_full_path(&app_state.base_path, &req.path, true) {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
span.set_status(Status::error("Invalid path"));
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "Invalid path"}));
|
||||
}
|
||||
};
|
||||
|
||||
let full_path_str = full_path.to_string_lossy().to_string();
|
||||
|
||||
// Use relative path (from BASE_PATH) for DB storage, consistent with EXIF convention
|
||||
let relative_path = full_path_str
|
||||
.strip_prefix(&app_state.base_path)
|
||||
.unwrap_or(&full_path_str)
|
||||
.trim_start_matches(['/', '\\'])
|
||||
.to_string();
|
||||
|
||||
// Check preview status in DB
|
||||
let preview = {
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
dao.get_preview(&context, &relative_path)
|
||||
};
|
||||
|
||||
match preview {
|
||||
Ok(Some(clip)) => match clip.status.as_str() {
|
||||
"complete" => {
|
||||
let preview_path = PathBuf::from(&app_state.preview_clips_path)
|
||||
.join(&relative_path)
|
||||
.with_extension("mp4");
|
||||
|
||||
match NamedFile::open(&preview_path) {
|
||||
Ok(file) => {
|
||||
span.set_status(Status::Ok);
|
||||
file.into_response(&request)
|
||||
}
|
||||
Err(_) => {
|
||||
// File missing on disk but DB says complete - reset and regenerate
|
||||
let mut dao =
|
||||
preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
let _ = dao.update_status(
|
||||
&context,
|
||||
&relative_path,
|
||||
"pending",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
app_state
|
||||
.preview_clip_generator
|
||||
.do_send(GeneratePreviewClipMessage {
|
||||
video_path: full_path_str,
|
||||
});
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Accepted().json(serde_json::json!({
|
||||
"status": "processing",
|
||||
"path": req.path
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
"processing" => {
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Accepted().json(serde_json::json!({
|
||||
"status": "processing",
|
||||
"path": req.path
|
||||
}))
|
||||
}
|
||||
"failed" => {
|
||||
let error_msg =
|
||||
clip.error_message.unwrap_or_else(|| "Unknown error".to_string());
|
||||
span.set_status(Status::error(format!(
|
||||
"Generation failed: {}",
|
||||
error_msg
|
||||
)));
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Generation failed: {}", error_msg)
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
// pending or unknown status - trigger generation
|
||||
app_state
|
||||
.preview_clip_generator
|
||||
.do_send(GeneratePreviewClipMessage {
|
||||
video_path: full_path_str,
|
||||
});
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Accepted().json(serde_json::json!({
|
||||
"status": "processing",
|
||||
"path": req.path
|
||||
}))
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
// No record exists - insert as pending and trigger generation
|
||||
{
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
let _ = dao.insert_preview(&context, &relative_path, "pending");
|
||||
}
|
||||
app_state
|
||||
.preview_clip_generator
|
||||
.do_send(GeneratePreviewClipMessage {
|
||||
video_path: full_path_str,
|
||||
});
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Accepted().json(serde_json::json!({
|
||||
"status": "processing",
|
||||
"path": req.path
|
||||
}))
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("Database error"));
|
||||
HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/video/preview/status")]
|
||||
async fn get_preview_status(
|
||||
_claims: Claims,
|
||||
request: HttpRequest,
|
||||
body: web::Json<PreviewStatusRequest>,
|
||||
app_state: Data<AppState>,
|
||||
preview_dao: Data<Mutex<Box<dyn PreviewDao>>>,
|
||||
) -> impl Responder {
|
||||
let tracer = global_tracer();
|
||||
let context = extract_context_from_request(&request);
|
||||
let mut span = tracer.start_with_context("get_preview_status", &context);
|
||||
|
||||
// Limit to 200 paths per request
|
||||
if body.paths.len() > 200 {
|
||||
span.set_status(Status::error("Too many paths"));
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "Maximum 200 paths per request"}));
|
||||
}
|
||||
|
||||
let previews = {
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
dao.get_previews_batch(&context, &body.paths)
|
||||
};
|
||||
|
||||
match previews {
|
||||
Ok(clips) => {
|
||||
// Build a map of file_path -> VideoPreviewClip for quick lookup
|
||||
let clip_map: HashMap<String, _> = clips
|
||||
.into_iter()
|
||||
.map(|clip| (clip.file_path.clone(), clip))
|
||||
.collect();
|
||||
|
||||
let mut items: Vec<PreviewStatusItem> = Vec::with_capacity(body.paths.len());
|
||||
|
||||
for path in &body.paths {
|
||||
if let Some(clip) = clip_map.get(path) {
|
||||
items.push(PreviewStatusItem {
|
||||
path: path.clone(),
|
||||
status: clip.status.clone(),
|
||||
preview_url: if clip.status == "complete" {
|
||||
Some(format!(
|
||||
"/video/preview?path={}",
|
||||
urlencoding::encode(path)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// No record exists — insert as pending and trigger generation
|
||||
{
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
let _ = dao.insert_preview(&context, path, "pending");
|
||||
}
|
||||
|
||||
// Build full path for ffmpeg (actor needs the absolute path for input)
|
||||
let full_path = format!(
|
||||
"{}/{}",
|
||||
app_state.base_path.trim_end_matches(['/', '\\']),
|
||||
path.trim_start_matches(['/', '\\'])
|
||||
);
|
||||
|
||||
info!("Triggering preview generation for '{}'", path);
|
||||
app_state
|
||||
.preview_clip_generator
|
||||
.do_send(GeneratePreviewClipMessage {
|
||||
video_path: full_path,
|
||||
});
|
||||
|
||||
items.push(PreviewStatusItem {
|
||||
path: path.clone(),
|
||||
status: "pending".to_string(),
|
||||
preview_url: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Ok().json(PreviewStatusResponse { previews: items })
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("Database error"));
|
||||
HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("image/favorites")]
|
||||
async fn favorites(
|
||||
claims: Claims,
|
||||
@@ -836,9 +1055,10 @@ fn main() -> std::io::Result<()> {
|
||||
directory: app_state.base_path.clone(),
|
||||
});
|
||||
|
||||
// Start file watcher with playlist manager
|
||||
// Start file watcher with playlist manager and preview generator
|
||||
let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone();
|
||||
watch_files(playlist_mgr_for_watcher);
|
||||
let preview_gen_for_watcher = app_state.preview_clip_generator.as_ref().clone();
|
||||
watch_files(playlist_mgr_for_watcher, preview_gen_for_watcher);
|
||||
|
||||
// Start orphaned playlist cleanup job
|
||||
cleanup_orphaned_playlists();
|
||||
@@ -855,7 +1075,8 @@ fn main() -> std::io::Result<()> {
|
||||
let start_date = Some(NaiveDate::from_ymd_opt(2015, 10, 1).unwrap());
|
||||
let end_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
|
||||
let contacts_to_summarize = vec!["Domenique", "Zach", "Paul"]; // Add more contacts as needed
|
||||
// let contacts_to_summarize = vec!["Domenique", "Zach", "Paul"]; // Add more contacts as needed
|
||||
let contacts_to_summarize = vec![]; // Add more contacts as needed
|
||||
|
||||
let ollama = app_state.ollama.clone();
|
||||
let sms_client = app_state.sms_client.clone();
|
||||
@@ -895,6 +1116,7 @@ fn main() -> std::io::Result<()> {
|
||||
let tag_dao = SqliteTagDao::default();
|
||||
let exif_dao = SqliteExifDao::new();
|
||||
let insight_dao = SqliteInsightDao::new();
|
||||
let preview_dao = SqlitePreviewDao::new();
|
||||
let cors = Cors::default()
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
// Allow all origins in development, or check against CORS_ALLOWED_ORIGINS env var
|
||||
@@ -944,6 +1166,8 @@ fn main() -> std::io::Result<()> {
|
||||
.service(upload_image)
|
||||
.service(generate_video)
|
||||
.service(stream_video)
|
||||
.service(get_video_preview)
|
||||
.service(get_preview_status)
|
||||
.service(get_video_part)
|
||||
.service(favorites)
|
||||
.service(put_add_favorite)
|
||||
@@ -971,6 +1195,9 @@ fn main() -> std::io::Result<()> {
|
||||
.app_data::<Data<Mutex<Box<dyn InsightDao>>>>(Data::new(Mutex::new(Box::new(
|
||||
insight_dao,
|
||||
))))
|
||||
.app_data::<Data<Mutex<Box<dyn PreviewDao>>>>(Data::new(Mutex::new(Box::new(
|
||||
preview_dao,
|
||||
))))
|
||||
.app_data::<Data<InsightGenerator>>(Data::new(app_data.insight_generator.clone()))
|
||||
.wrap(prometheus.clone())
|
||||
})
|
||||
@@ -1118,7 +1345,10 @@ fn cleanup_orphaned_playlists() {
|
||||
});
|
||||
}
|
||||
|
||||
fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
||||
fn watch_files(
|
||||
playlist_manager: Addr<VideoPlaylistManager>,
|
||||
preview_generator: actix::Addr<crate::video::actors::PreviewClipGenerator>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let base_str = dotenv::var("BASE_PATH").unwrap();
|
||||
let base_path = PathBuf::from(&base_str);
|
||||
@@ -1141,10 +1371,13 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
||||
info!(" Full scan interval: {} seconds", full_interval_secs);
|
||||
info!(" Watching directory: {}", base_str);
|
||||
|
||||
// Create EXIF DAO for tracking processed files
|
||||
// Create DAOs for tracking processed files
|
||||
let exif_dao = Arc::new(Mutex::new(
|
||||
Box::new(SqliteExifDao::new()) as Box<dyn ExifDao>
|
||||
));
|
||||
let preview_dao = Arc::new(Mutex::new(
|
||||
Box::new(SqlitePreviewDao::new()) as Box<dyn PreviewDao>
|
||||
));
|
||||
|
||||
let mut last_quick_scan = SystemTime::now();
|
||||
let mut last_full_scan = SystemTime::now();
|
||||
@@ -1165,8 +1398,10 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
||||
process_new_files(
|
||||
&base_path,
|
||||
Arc::clone(&exif_dao),
|
||||
Arc::clone(&preview_dao),
|
||||
None,
|
||||
playlist_manager.clone(),
|
||||
preview_generator.clone(),
|
||||
);
|
||||
last_full_scan = now;
|
||||
} else {
|
||||
@@ -1181,8 +1416,10 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
||||
process_new_files(
|
||||
&base_path,
|
||||
Arc::clone(&exif_dao),
|
||||
Arc::clone(&preview_dao),
|
||||
Some(check_since),
|
||||
playlist_manager.clone(),
|
||||
preview_generator.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1221,8 +1458,10 @@ fn playlist_needs_generation(video_path: &Path, playlist_path: &Path) -> bool {
|
||||
fn process_new_files(
|
||||
base_path: &Path,
|
||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
|
||||
modified_since: Option<SystemTime>,
|
||||
playlist_manager: Addr<VideoPlaylistManager>,
|
||||
preview_generator: actix::Addr<crate::video::actors::PreviewClipGenerator>,
|
||||
) {
|
||||
let context = opentelemetry::Context::new();
|
||||
let thumbs = dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined");
|
||||
@@ -1385,6 +1624,55 @@ fn process_new_files(
|
||||
});
|
||||
}
|
||||
|
||||
// Check for videos that need preview clips
|
||||
// Collect (full_path, relative_path) for video files
|
||||
let video_files: Vec<(String, String)> = files
|
||||
.iter()
|
||||
.filter(|(file_path, _)| is_video_file(file_path))
|
||||
.map(|(file_path, rel_path)| (file_path.to_string_lossy().to_string(), rel_path.clone()))
|
||||
.collect();
|
||||
|
||||
if !video_files.is_empty() {
|
||||
// Query DB using relative paths (consistent with how GET/POST handlers store them)
|
||||
let video_rel_paths: Vec<String> = video_files.iter().map(|(_, rel)| rel.clone()).collect();
|
||||
|
||||
let existing_previews: HashMap<String, String> = {
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
match dao.get_previews_batch(&context, &video_rel_paths) {
|
||||
Ok(clips) => clips
|
||||
.into_iter()
|
||||
.map(|clip| (clip.file_path, clip.status))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
error!("Error batch querying preview clips: {:?}", e);
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (full_path, relative_path) in &video_files {
|
||||
let status = existing_previews.get(relative_path).map(|s| s.as_str());
|
||||
let needs_preview = match status {
|
||||
None => true, // No record at all
|
||||
Some("failed") => true, // Retry failed
|
||||
_ => false, // pending, processing, or complete
|
||||
};
|
||||
|
||||
if needs_preview {
|
||||
// Insert pending record using relative path
|
||||
if status.is_none() {
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
let _ = dao.insert_preview(&context, relative_path, "pending");
|
||||
}
|
||||
|
||||
// Send full path in the message — the actor will derive relative path from it
|
||||
preview_generator.do_send(GeneratePreviewClipMessage {
|
||||
video_path: full_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thumbnails for all files that need them
|
||||
if new_files_found {
|
||||
info!("Processing thumbnails for new files...");
|
||||
|
||||
35
src/state.rs
35
src/state.rs
@@ -4,7 +4,10 @@ use crate::database::{
|
||||
SqliteCalendarEventDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao,
|
||||
SqliteLocationHistoryDao, SqliteSearchHistoryDao,
|
||||
};
|
||||
use crate::video::actors::{PlaylistGenerator, StreamActor, VideoPlaylistManager};
|
||||
use crate::database::{PreviewDao, SqlitePreviewDao};
|
||||
use crate::video::actors::{
|
||||
PlaylistGenerator, PreviewClipGenerator, StreamActor, VideoPlaylistManager,
|
||||
};
|
||||
use actix::{Actor, Addr};
|
||||
use std::env;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -12,10 +15,12 @@ use std::sync::{Arc, Mutex};
|
||||
pub struct AppState {
|
||||
pub stream_manager: Arc<Addr<StreamActor>>,
|
||||
pub playlist_manager: Arc<Addr<VideoPlaylistManager>>,
|
||||
pub preview_clip_generator: Arc<Addr<PreviewClipGenerator>>,
|
||||
pub base_path: String,
|
||||
pub thumbnail_path: String,
|
||||
pub video_path: String,
|
||||
pub gif_path: String,
|
||||
pub preview_clips_path: String,
|
||||
pub excluded_dirs: Vec<String>,
|
||||
pub ollama: OllamaClient,
|
||||
pub sms_client: SmsApiClient,
|
||||
@@ -29,22 +34,32 @@ impl AppState {
|
||||
thumbnail_path: String,
|
||||
video_path: String,
|
||||
gif_path: String,
|
||||
preview_clips_path: String,
|
||||
excluded_dirs: Vec<String>,
|
||||
ollama: OllamaClient,
|
||||
sms_client: SmsApiClient,
|
||||
insight_generator: InsightGenerator,
|
||||
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
|
||||
) -> Self {
|
||||
let playlist_generator = PlaylistGenerator::new();
|
||||
let video_playlist_manager =
|
||||
VideoPlaylistManager::new(video_path.clone(), playlist_generator.start());
|
||||
|
||||
let preview_clip_generator = PreviewClipGenerator::new(
|
||||
preview_clips_path.clone(),
|
||||
base_path.clone(),
|
||||
preview_dao,
|
||||
);
|
||||
|
||||
Self {
|
||||
stream_manager,
|
||||
playlist_manager: Arc::new(video_playlist_manager.start()),
|
||||
preview_clip_generator: Arc::new(preview_clip_generator.start()),
|
||||
base_path,
|
||||
thumbnail_path,
|
||||
video_path,
|
||||
gif_path,
|
||||
preview_clips_path,
|
||||
excluded_dirs,
|
||||
ollama,
|
||||
sms_client,
|
||||
@@ -94,6 +109,8 @@ impl Default for AppState {
|
||||
Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
|
||||
let daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new())));
|
||||
let preview_dao: Arc<Mutex<Box<dyn PreviewDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqlitePreviewDao::new())));
|
||||
|
||||
// Initialize Google Takeout DAOs
|
||||
let calendar_dao: Arc<Mutex<Box<dyn CalendarEventDao>>> =
|
||||
@@ -119,16 +136,23 @@ impl Default for AppState {
|
||||
base_path.clone(),
|
||||
);
|
||||
|
||||
// Ensure preview clips directory exists
|
||||
let preview_clips_path = env::var("PREVIEW_CLIPS_DIRECTORY")
|
||||
.unwrap_or_else(|_| "preview_clips".to_string());
|
||||
std::fs::create_dir_all(&preview_clips_path).expect("Failed to create PREVIEW_CLIPS_DIRECTORY");
|
||||
|
||||
Self::new(
|
||||
Arc::new(StreamActor {}.start()),
|
||||
base_path,
|
||||
env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"),
|
||||
env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"),
|
||||
env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"),
|
||||
preview_clips_path,
|
||||
Self::parse_excluded_dirs(),
|
||||
ollama,
|
||||
sms_client,
|
||||
insight_generator,
|
||||
preview_dao,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -142,10 +166,11 @@ impl AppState {
|
||||
let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
|
||||
let base_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create subdirectories for thumbnails, videos, and gifs
|
||||
// Create subdirectories for thumbnails, videos, gifs, and preview clips
|
||||
let thumbnail_path = create_test_subdir(&base_path, "thumbnails");
|
||||
let video_path = create_test_subdir(&base_path, "videos");
|
||||
let gif_path = create_test_subdir(&base_path, "gifs");
|
||||
let preview_clips_path = create_test_subdir(&base_path, "preview_clips");
|
||||
|
||||
// Initialize test AI clients
|
||||
let ollama = OllamaClient::new(
|
||||
@@ -186,6 +211,10 @@ impl AppState {
|
||||
base_path_str.clone(),
|
||||
);
|
||||
|
||||
// Initialize test preview DAO
|
||||
let preview_dao: Arc<Mutex<Box<dyn PreviewDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqlitePreviewDao::new())));
|
||||
|
||||
// Create the AppState with the temporary paths
|
||||
AppState::new(
|
||||
Arc::new(StreamActor {}.start()),
|
||||
@@ -193,10 +222,12 @@ impl AppState {
|
||||
thumbnail_path.to_string_lossy().to_string(),
|
||||
video_path.to_string_lossy().to_string(),
|
||||
gif_path.to_string_lossy().to_string(),
|
||||
preview_clips_path.to_string_lossy().to_string(),
|
||||
Vec::new(), // No excluded directories for test state
|
||||
ollama,
|
||||
sms_client,
|
||||
insight_generator,
|
||||
preview_dao,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::database::PreviewDao;
|
||||
use crate::is_video;
|
||||
use crate::otel::global_tracer;
|
||||
use crate::video::ffmpeg::generate_preview_clip;
|
||||
use actix::prelude::*;
|
||||
use futures::TryFutureExt;
|
||||
use log::{debug, error, info, trace, warn};
|
||||
@@ -8,7 +10,7 @@ use opentelemetry::trace::{Span, Status, Tracer};
|
||||
use std::io::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Semaphore;
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
// ffmpeg -i test.mp4 -c:v h264 -flags +cgop -g 30 -hls_time 3 out.m3u8
|
||||
@@ -484,3 +486,118 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct GeneratePreviewClipMessage {
|
||||
pub video_path: String,
|
||||
}
|
||||
|
||||
pub struct PreviewClipGenerator {
|
||||
semaphore: Arc<Semaphore>,
|
||||
preview_clips_dir: String,
|
||||
base_path: String,
|
||||
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
|
||||
}
|
||||
|
||||
impl PreviewClipGenerator {
|
||||
pub fn new(
|
||||
preview_clips_dir: String,
|
||||
base_path: String,
|
||||
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
|
||||
) -> Self {
|
||||
PreviewClipGenerator {
|
||||
semaphore: Arc::new(Semaphore::new(2)),
|
||||
preview_clips_dir,
|
||||
base_path,
|
||||
preview_dao,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for PreviewClipGenerator {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
|
||||
impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
|
||||
type Result = ResponseFuture<()>;
|
||||
|
||||
fn handle(
|
||||
&mut self,
|
||||
msg: GeneratePreviewClipMessage,
|
||||
_ctx: &mut Self::Context,
|
||||
) -> Self::Result {
|
||||
let semaphore = self.semaphore.clone();
|
||||
let preview_clips_dir = self.preview_clips_dir.clone();
|
||||
let base_path = self.base_path.clone();
|
||||
let preview_dao = self.preview_dao.clone();
|
||||
let video_path = msg.video_path;
|
||||
|
||||
Box::pin(async move {
|
||||
let permit = semaphore
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("Unable to acquire preview semaphore");
|
||||
|
||||
// Compute relative path (from BASE_PATH) for DB operations, consistent with EXIF convention
|
||||
let relative_path = video_path
|
||||
.strip_prefix(&base_path)
|
||||
.unwrap_or(&video_path)
|
||||
.trim_start_matches(['/', '\\'])
|
||||
.to_string();
|
||||
|
||||
// Update status to processing
|
||||
{
|
||||
let otel_ctx = opentelemetry::Context::current();
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
let _ = dao.update_status(&otel_ctx, &relative_path, "processing", None, None, None);
|
||||
}
|
||||
|
||||
// Compute output path: join preview_clips_dir with relative path, change ext to .mp4
|
||||
let output_path = PathBuf::from(&preview_clips_dir)
|
||||
.join(&relative_path)
|
||||
.with_extension("mp4");
|
||||
let output_str = output_path.to_string_lossy().to_string();
|
||||
|
||||
let video_path_owned = video_path.clone();
|
||||
let relative_path_owned = relative_path.clone();
|
||||
tokio::spawn(async move {
|
||||
match generate_preview_clip(&video_path_owned, &output_str).await {
|
||||
Ok((duration, size)) => {
|
||||
info!(
|
||||
"Preview clip complete for '{}' ({:.1}s, {} bytes)",
|
||||
relative_path_owned, duration, size
|
||||
);
|
||||
let otel_ctx = opentelemetry::Context::current();
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
let _ = dao.update_status(
|
||||
&otel_ctx,
|
||||
&relative_path_owned,
|
||||
"complete",
|
||||
Some(duration as f32),
|
||||
Some(size as i32),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to generate preview clip for '{}': {}",
|
||||
relative_path_owned, e
|
||||
);
|
||||
let otel_ctx = opentelemetry::Context::current();
|
||||
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
||||
let _ = dao.update_status(
|
||||
&otel_ctx,
|
||||
&relative_path_owned,
|
||||
"failed",
|
||||
None,
|
||||
None,
|
||||
Some(&e.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,40 @@ use futures::TryFutureExt;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::io::Result;
|
||||
use std::process::{Output, Stdio};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
use tokio::process::Command;
|
||||
|
||||
static NVENC_AVAILABLE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Check if NVIDIA NVENC hardware encoder is available via ffmpeg.
|
||||
async fn check_nvenc_available() -> bool {
|
||||
Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-encoders"])
|
||||
.output()
|
||||
.await
|
||||
.map(|out| {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
stdout.contains("h264_nvenc")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns whether NVENC is available, caching the result after first check.
|
||||
async fn is_nvenc_available() -> bool {
|
||||
if let Some(&available) = NVENC_AVAILABLE.get() {
|
||||
return available;
|
||||
}
|
||||
let available = check_nvenc_available().await;
|
||||
let _ = NVENC_AVAILABLE.set(available);
|
||||
if available {
|
||||
info!("CUDA NVENC hardware acceleration detected and enabled for preview clips");
|
||||
} else {
|
||||
info!("NVENC not available, using CPU encoding for preview clips");
|
||||
}
|
||||
available
|
||||
}
|
||||
|
||||
pub struct Ffmpeg;
|
||||
|
||||
pub enum GifType {
|
||||
@@ -152,7 +183,7 @@ impl Ffmpeg {
|
||||
Ok(output_file.to_string())
|
||||
}
|
||||
|
||||
async fn create_gif_from_frames(&self, frame_base_dir: &str, output_file: &str) -> Result<i32> {
|
||||
pub async fn create_gif_from_frames(&self, frame_base_dir: &str, output_file: &str) -> Result<i32> {
|
||||
let output = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.args(["-framerate", "4"])
|
||||
@@ -183,3 +214,114 @@ impl Ffmpeg {
|
||||
Ok(output.status.code().unwrap_or(-1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get video duration in seconds as f64 for precise interval calculation.
|
||||
async fn get_duration_seconds(input_file: &str) -> Result<f64> {
|
||||
Command::new("ffprobe")
|
||||
.args(["-i", input_file])
|
||||
.args(["-show_entries", "format=duration"])
|
||||
.args(["-v", "quiet"])
|
||||
.args(["-of", "csv=p=0"])
|
||||
.output()
|
||||
.await
|
||||
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
.and_then(|duration_str| {
|
||||
duration_str
|
||||
.parse::<f64>()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a preview clip from a video file.
|
||||
///
|
||||
/// Creates a ~10 second MP4 by extracting up to 10 equally-spaced 1-second segments
|
||||
/// at 480p with H.264 video and AAC audio. For short videos (<10s), uses fewer segments.
|
||||
/// For very short videos (<1s), transcodes the entire video.
|
||||
///
|
||||
/// Returns (duration_seconds, file_size_bytes) on success.
|
||||
pub async fn generate_preview_clip(input_file: &str, output_file: &str) -> Result<(f64, u64)> {
|
||||
info!("Generating preview clip for: '{}'", input_file);
|
||||
let start = Instant::now();
|
||||
|
||||
let duration = get_duration_seconds(input_file).await?;
|
||||
let use_nvenc = is_nvenc_available().await;
|
||||
|
||||
// Create parent directories for output
|
||||
if let Some(parent) = std::path::Path::new(output_file).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.arg("-y");
|
||||
|
||||
// Use CUDA hardware-accelerated decoding when available
|
||||
if use_nvenc {
|
||||
cmd.args(["-hwaccel", "cuda"]);
|
||||
}
|
||||
|
||||
cmd.arg("-i").arg(input_file);
|
||||
|
||||
if duration < 1.0 {
|
||||
// Very short video (<1s): transcode the whole thing to 480p MP4
|
||||
cmd.args(["-vf", "scale=-2:480"]);
|
||||
} else {
|
||||
let segment_count = if duration < 10.0 {
|
||||
duration.floor() as u32
|
||||
} else {
|
||||
10
|
||||
};
|
||||
let interval = duration / segment_count as f64;
|
||||
|
||||
let vf = format!(
|
||||
"select='lt(mod(t,{:.4}),1)',setpts=N/FRAME_RATE/TB,scale=-2:480",
|
||||
interval
|
||||
);
|
||||
let af = format!(
|
||||
"aselect='lt(mod(t,{:.4}),1)',asetpts=N/SR/TB",
|
||||
interval
|
||||
);
|
||||
|
||||
cmd.args(["-vf", &vf]);
|
||||
cmd.args(["-af", &af]);
|
||||
}
|
||||
|
||||
// Use NVENC for encoding when available, otherwise fall back to libx264
|
||||
if use_nvenc {
|
||||
cmd.args(["-c:v", "h264_nvenc", "-preset", "p4", "-cq:v", "28"]);
|
||||
} else {
|
||||
cmd.args(["-c:v", "libx264", "-crf", "28", "-preset", "veryfast"]);
|
||||
}
|
||||
cmd.args(["-c:a", "aac"]);
|
||||
|
||||
cmd.arg(output_file);
|
||||
cmd.stdout(Stdio::null());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(std::io::Error::other(format!(
|
||||
"ffmpeg preview generation failed: {}",
|
||||
stderr
|
||||
)));
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(output_file)?;
|
||||
let file_size = metadata.len();
|
||||
|
||||
let clip_duration = if duration < 1.0 {
|
||||
duration
|
||||
} else if duration < 10.0 {
|
||||
duration.floor()
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
|
||||
info!(
|
||||
"Generated preview clip '{}' ({:.1}s, {} bytes) in {:?}",
|
||||
output_file, clip_duration, file_size, start.elapsed()
|
||||
);
|
||||
|
||||
Ok((clip_duration, file_size))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user