Files
ImageApi/specs/001-video-wall/tasks.md
Cameron 19c099360e 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>
2026-02-25 19:40:17 -05:00

19 KiB

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

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

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

  • 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.
  • 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.
  • 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().
  • 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.
  • 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.
  • 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).
  • 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

  • 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.
  • 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}.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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

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

  • 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).
  • 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.
  • 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

  • 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.
  • 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.
  • 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.
  • T030 Verify cargo build and cargo clippy pass with all backend changes. Fix any warnings or errors.
  • 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

# 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

# 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