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>
235 lines
19 KiB
Markdown
235 lines
19 KiB
Markdown
# 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
|