From ff47c87e4182b7b03e848e54b25711962cdd1cb8 Mon Sep 17 00:00:00 2001 From: Tanya Date: Wed, 25 Mar 2026 15:33:05 -0400 Subject: [PATCH] feat: web video transcoding, admin playback, and viewer fixes Add on-demand H.264/AAC web playback (RQ, ffmpeg) with API routes and Next.js proxies; extend admin UI with WebPlaybackVideo and shared hooks. Store transcode cache beside pending-photos (WEB_VIDEO_CACHE_DIR / UPLOAD_DIR) and ignore data/web_videos. Centralize FastAPI URL helpers, optional Vite and Next base paths for subfolder deploy, and fix modal reopen by using router.replace when closing the home photo viewer. Include migration, install scripts, deployment doc updates, and CI admin build env tweak. Made-with: Cursor --- .env_example | 6 + .gitea/workflows/ci.yml | 2 +- .gitignore | 4 +- admin-frontend/.env_example | 8 +- admin-frontend/public/logo.svg | Bin 0 -> 4748 bytes admin-frontend/src/App.tsx | 6 +- admin-frontend/src/api/client.ts | 6 +- admin-frontend/src/api/videos.ts | 63 +++- admin-frontend/src/components/PhotoViewer.tsx | 87 +++++- .../src/components/WebPlaybackVideo.tsx | 51 +++ .../src/hooks/useWebPlaybackVideo.ts | 107 +++++++ admin-frontend/src/lib/fastapi-path.ts | 7 + admin-frontend/src/lib/media-path.ts | 5 + admin-frontend/src/pages/Identify.tsx | 6 +- admin-frontend/src/pages/Modify.tsx | 10 +- admin-frontend/src/pages/ReportedPhotos.tsx | 2 +- admin-frontend/src/pages/Search.tsx | 4 +- admin-frontend/src/pages/Tags.tsx | 26 +- admin-frontend/src/pages/UserTaggedPhotos.tsx | 2 +- admin-frontend/src/pages/VideoPlayer.tsx | 60 +++- admin-frontend/vite.config.ts | 32 +- backend/api/tags.py | 1 + backend/api/videos.py | 121 ++++++-- backend/db/models.py | 5 + backend/schemas/tags.py | 1 + backend/schemas/videos.py | 14 + backend/services/tasks.py | 75 +++++ backend/services/web_video_service.py | 292 ++++++++++++++++++ backend/settings.py | 44 +++ docs/DEPLOY_CPANEL_STEP_BY_STEP.md | 130 +++++++- .../add-photos-web-playback-columns.sql | 10 + scripts/install_backend_tf_keras.sh | 22 ++ scripts/install_ffmpeg_el8_rpmfusion.sh | 29 ++ scripts/run-psql-migration.sh | 41 +++ viewer-frontend/.env_example | 10 + viewer-frontend/app/HomePageContent.tsx | 23 +- .../app/api/photos/[id]/image/route.ts | 5 +- .../photos/[id]/web-playback/prepare/route.ts | 39 +++ .../app/api/photos/[id]/web-playback/route.ts | 59 ++++ .../photos/[id]/web-playback/status/route.ts | 37 +++ .../components/PhotoViewerClient.tsx | 129 +++++++- viewer-frontend/lib/photo-utils.ts | 8 + viewer-frontend/lib/server/fastapi-backend.ts | 34 ++ viewer-frontend/next.config.ts | 12 + .../scripts/install-dependencies.sh | 17 + 45 files changed, 1531 insertions(+), 121 deletions(-) create mode 100644 admin-frontend/public/logo.svg create mode 100644 admin-frontend/src/components/WebPlaybackVideo.tsx create mode 100644 admin-frontend/src/hooks/useWebPlaybackVideo.ts create mode 100644 admin-frontend/src/lib/fastapi-path.ts create mode 100644 admin-frontend/src/lib/media-path.ts create mode 100644 backend/services/web_video_service.py create mode 100644 migrations/add-photos-web-playback-columns.sql create mode 100755 scripts/install_backend_tf_keras.sh create mode 100755 scripts/install_ffmpeg_el8_rpmfusion.sh create mode 100755 scripts/run-psql-migration.sh create mode 100644 viewer-frontend/app/api/photos/[id]/web-playback/prepare/route.ts create mode 100644 viewer-frontend/app/api/photos/[id]/web-playback/route.ts create mode 100644 viewer-frontend/app/api/photos/[id]/web-playback/status/route.ts create mode 100644 viewer-frontend/lib/server/fastapi-backend.ts diff --git a/.env_example b/.env_example index 10474cf..fb6cb1b 100644 --- a/.env_example +++ b/.env_example @@ -14,6 +14,12 @@ ADMIN_PASSWORD=CHANGE_ME # Photo storage PHOTO_STORAGE_DIR=/opt/punimtag/data/uploads +# Pending viewer uploads (same value as viewer UPLOAD_DIR when using that feature). +# Web transcode cache defaults to a sibling folder: /web_videos next to +# .../pending-photos (override with WEB_VIDEO_CACHE_DIR if needed). +# UPLOAD_DIR=/mnt/db-server-uploads/pending-photos +# WEB_VIDEO_CACHE_DIR=/mnt/db-server-uploads/web_videos + # Redis (RQ jobs) REDIS_URL=redis://127.0.0.1:6379/0 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5767c8b..3c3d04b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -735,7 +735,7 @@ jobs: npm run build continue-on-error: true env: - VITE_API_URL: http://localhost:8000 + VITE_API_URL: '' - name: Install viewer-frontend dependencies run: | diff --git a/.gitignore b/.gitignore index 66ca02d..2816edf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,10 @@ dist/ downloads/ eggs/ .eggs/ -# Python lib directories (but not viewer-frontend/lib/) +# Python lib directories (but not viewer-frontend/lib/ or admin-frontend TS lib/) lib/ !viewer-frontend/lib/ +!admin-frontend/src/lib/ lib64/ parts/ sdist/ @@ -83,3 +84,4 @@ data/thumbnails/ # PM2 ecosystem config (server-specific paths) ecosystem.config.js +data/web_videos/ diff --git a/admin-frontend/.env_example b/admin-frontend/.env_example index e9e5bd3..790cbb9 100644 --- a/admin-frontend/.env_example +++ b/admin-frontend/.env_example @@ -1,8 +1,14 @@ # Admin frontend env (copy to ".env" ) -# Backend API base URL (must be reachable from the browser) +# Backend API origin as seen by the browser. Leave empty for local dev: Vite proxies +# /api to http://127.0.0.1:8000 (see vite.config.ts). +# Production (same host as admin, proxy at /punim-api/): VITE_API_URL=/punim-api VITE_API_URL= +# Production subpath for static assets (Vite base + React Router basename). +# Local dev: leave unset (served at http://localhost:3000/). +# VITE_BASE_PATH=/punim-admin + # Enable developer mode (shows additional debug info and options) # Set to "true" to enable, leave empty or unset to disable VITE_DEVELOPER_MODE= diff --git a/admin-frontend/public/logo.svg b/admin-frontend/public/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..28090da6e1fb59e7bed6c34b45dcf28792d2e33b GIT binary patch literal 4748 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 literal 0 HcmV?d00001 diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 04f7c28..b1d4094 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -171,7 +171,11 @@ function App() { return ( - + diff --git a/admin-frontend/src/api/client.ts b/admin-frontend/src/api/client.ts index fb6729d..2553715 100644 --- a/admin-frontend/src/api/client.ts +++ b/admin-frontend/src/api/client.ts @@ -1,9 +1,7 @@ import axios from 'axios' -// Get API base URL from environment variable or use default -// The .env file should contain: VITE_API_URL=http://127.0.0.1:8000 -// Alternatively, Vite proxy can be used (configured in vite.config.ts) by setting VITE_API_URL to empty string -// When VITE_API_URL is empty/undefined, use relative path to work with HTTPS proxy +// API origin as seen by the browser. For local dev, leave VITE_API_URL unset/empty so +// requests use relative /api/v1/... and Vite proxies /api → http://127.0.0.1:8000. const envApiUrl = import.meta.env.VITE_API_URL const API_BASE_URL = envApiUrl && envApiUrl.trim() !== '' ? envApiUrl diff --git a/admin-frontend/src/api/videos.ts b/admin-frontend/src/api/videos.ts index 45fdc73..3ad7bdc 100644 --- a/admin-frontend/src/api/videos.ts +++ b/admin-frontend/src/api/videos.ts @@ -1,4 +1,5 @@ import apiClient from './client' +import { fastApiV1Path } from '../lib/fastapi-path' export interface PersonInfo { id: number @@ -65,6 +66,21 @@ export interface RemoveVideoPersonResponse { message: string } +export interface WebPlaybackPrepareResponse { + status: string + message: string +} + +export interface WebPlaybackStatusResponse { + status: string + error?: string | null +} + +function apiBasePath(): string { + const envApiUrl = import.meta.env.VITE_API_URL + return envApiUrl && envApiUrl.trim() !== '' ? envApiUrl : '' +} + export const videosApi = { listVideos: async (params: { page?: number @@ -77,12 +93,14 @@ export const videosApi = { sort_by?: string sort_dir?: string }): Promise => { - const res = await apiClient.get('/api/v1/videos', { params }) + const res = await apiClient.get(fastApiV1Path('/videos'), { params }) return res.data }, getVideoPeople: async (videoId: number): Promise => { - const res = await apiClient.get(`/api/v1/videos/${videoId}/people`) + const res = await apiClient.get( + fastApiV1Path(`/videos/${videoId}/people`) + ) return res.data }, @@ -91,7 +109,7 @@ export const videosApi = { request: IdentifyVideoRequest ): Promise => { const res = await apiClient.post( - `/api/v1/videos/${videoId}/identify`, + fastApiV1Path(`/videos/${videoId}/identify`), request ) return res.data @@ -102,25 +120,42 @@ export const videosApi = { personId: number ): Promise => { const res = await apiClient.delete( - `/api/v1/videos/${videoId}/people/${personId}` + fastApiV1Path(`/videos/${videoId}/people/${personId}`) ) return res.data }, getThumbnailUrl: (videoId: number): string => { - const envApiUrl = import.meta.env.VITE_API_URL - const baseURL = envApiUrl && envApiUrl.trim() !== '' - ? envApiUrl - : '' // Use relative path when empty - works with proxy and HTTPS - return `${baseURL}/api/v1/videos/${videoId}/thumbnail` + const baseURL = apiBasePath() + return `${baseURL}${fastApiV1Path(`/videos/${videoId}/thumbnail`)}` }, getVideoUrl: (videoId: number): string => { - const envApiUrl = import.meta.env.VITE_API_URL - const baseURL = envApiUrl && envApiUrl.trim() !== '' - ? envApiUrl - : '' // Use relative path when empty - works with proxy and HTTPS - return `${baseURL}/api/v1/videos/${videoId}/video` + const baseURL = apiBasePath() + return `${baseURL}${fastApiV1Path(`/videos/${videoId}/video`)}` + }, + + getWebPlaybackStreamUrl: (videoId: number): string => { + const baseURL = apiBasePath() + return `${baseURL}${fastApiV1Path(`/videos/${videoId}/web-playback/stream`)}` + }, + + prepareWebPlayback: async ( + videoId: number + ): Promise => { + const res = await apiClient.post( + fastApiV1Path(`/videos/${videoId}/web-playback/prepare`) + ) + return res.data + }, + + getWebPlaybackStatus: async ( + videoId: number + ): Promise => { + const res = await apiClient.get( + fastApiV1Path(`/videos/${videoId}/web-playback/status`) + ) + return res.data }, } diff --git a/admin-frontend/src/components/PhotoViewer.tsx b/admin-frontend/src/components/PhotoViewer.tsx index 2b0df99..97d571b 100644 --- a/admin-frontend/src/components/PhotoViewer.tsx +++ b/admin-frontend/src/components/PhotoViewer.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useRef } from 'react' import { PhotoSearchResult, photosApi } from '../api/photos' import { apiClient } from '../api/client' import videosApi from '../api/videos' +import { useWebPlaybackVideo } from '../hooks/useWebPlaybackVideo' +import { isRemoteMediaPath } from '../lib/media-path' interface PhotoViewerProps { photos: PhotoSearchResult[] @@ -52,13 +54,14 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView return photo.media_type === 'video' } - // Get photo/video URL - const getPhotoUrl = (photoId: number, mediaType?: string) => { - if (mediaType === 'video') { - return videosApi.getVideoUrl(photoId) - } - return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image` - } + const currentIsVideo = currentPhoto ? isVideo(currentPhoto) : false + const webPlayback = useWebPlaybackVideo( + currentPhoto && currentIsVideo ? currentPhoto.id : null, + currentPhoto && currentIsVideo ? currentPhoto.path : '' + ) + + const getImageUrl = (photoId: number) => + `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image` // Preload adjacent images (skip videos) const preloadAdjacent = (index: number) => { @@ -69,7 +72,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView const nextPhotoId = nextPhoto.id if (!preloadedImages.current.has(nextPhotoId)) { const img = new Image() - img.src = getPhotoUrl(nextPhotoId, nextPhoto.media_type) + img.src = getImageUrl(nextPhotoId) preloadedImages.current.add(nextPhotoId) } } @@ -81,7 +84,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView const prevPhotoId = prevPhoto.id if (!preloadedImages.current.has(prevPhotoId)) { const img = new Image() - img.src = getPhotoUrl(prevPhotoId, prevPhoto.media_type) + img.src = getImageUrl(prevPhotoId) preloadedImages.current.add(prevPhotoId) } } @@ -192,7 +195,9 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView useEffect(() => { if (!currentPhoto) return - setImageLoading(true) + if (currentPhoto.media_type !== 'video') { + setImageLoading(true) + } setImageError(false) // Reset zoom when photo changes setZoom(1) @@ -211,6 +216,52 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView preloadAdjacent(currentIndex) }, [currentIndex, currentPhoto, photos.length]) + // Sync loading / error for local web playback (videos) + useEffect(() => { + if (!currentPhoto || currentPhoto.media_type !== 'video') { + return + } + if (webPlayback.error) { + setImageError(true) + setImageLoading(false) + return + } + if (!webPlayback.videoSrc || webPlayback.preparing) { + setImageLoading(true) + setImageError(false) + return + } + setImageError(false) + setImageLoading(true) + }, [ + currentPhoto?.id, + currentPhoto?.media_type, + webPlayback.error, + webPlayback.preparing, + webPlayback.videoSrc, + ]) + + // Prefetch browser-safe transcode for nearby local videos (viewer parity) + useEffect(() => { + if (photos.length === 0) { + return + } + const idxs = [ + currentIndex - 2, + currentIndex - 1, + currentIndex + 1, + currentIndex + 2, + ].filter((i) => i >= 0 && i < photos.length) + + for (const i of idxs) { + const p = photos[i] + if (!isVideo(p) || isRemoteMediaPath(p.path)) { + continue + } + videosApi.prepareWebPlayback(p.id).catch(() => {}) + } + }, [currentIndex, photos]) + // Toggle favorite const toggleFavorite = async () => { if (loadingFavorite || !currentPhoto) return @@ -273,8 +324,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView return null } - const photoUrl = getPhotoUrl(currentPhoto.id, currentPhoto.media_type) - const currentIsVideo = isVideo(currentPhoto) + const imagePhotoUrl = getImageUrl(currentPhoto.id) return (
@@ -365,19 +415,22 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView {imageError ? (
Failed to load {currentIsVideo ? 'video' : 'image'}
+ {webPlayback.error && ( +
{webPlayback.error}
+ )}
{currentPhoto.path}
) : currentIsVideo ? (
- + /> {selectedVideoToPlay.date_taken && (
Date taken: {new Date(selectedVideoToPlay.date_taken).toLocaleDateString()} diff --git a/admin-frontend/src/pages/ReportedPhotos.tsx b/admin-frontend/src/pages/ReportedPhotos.tsx index 49910ee..0f29d61 100644 --- a/admin-frontend/src/pages/ReportedPhotos.tsx +++ b/admin-frontend/src/pages/ReportedPhotos.tsx @@ -363,7 +363,7 @@ export default function ReportedPhotos() { onClick={() => { const isVideo = reported.photo_media_type === 'video' const url = isVideo - ? videosApi.getVideoUrl(reported.photo_id) + ? `/video/${reported.photo_id}` : `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image` window.open(url, '_blank') }} diff --git a/admin-frontend/src/pages/Search.tsx b/admin-frontend/src/pages/Search.tsx index e2acd17..d48566d 100644 --- a/admin-frontend/src/pages/Search.tsx +++ b/admin-frontend/src/pages/Search.tsx @@ -309,7 +309,9 @@ export default function Search() { } catch (error: any) { console.error('Error searching photos:', error) const errorMessage = error.response?.data?.detail || error.message || 'Unknown error' - alert(`Error searching photos: ${errorMessage}\n\nPlease check:\n1. Backend API is running (http://127.0.0.1:8000)\n2. You are logged in\n3. Database connection is working`) + alert( + `Error searching photos: ${errorMessage}\n\nPlease check:\n1. Backend API is running\n2. You are logged in\n3. Database connection is working` + ) } finally { setLoading(false) } diff --git a/admin-frontend/src/pages/Tags.tsx b/admin-frontend/src/pages/Tags.tsx index 2327155..98859a7 100644 --- a/admin-frontend/src/pages/Tags.tsx +++ b/admin-frontend/src/pages/Tags.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react' import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags' import { useDeveloperMode } from '../context/DeveloperModeContext' import { apiClient } from '../api/client' +import videosApi from '../api/videos' type ViewMode = 'list' | 'icons' | 'compact' @@ -754,10 +755,19 @@ export default function Tags() { {photo.id} {photo.filename} @@ -872,7 +882,12 @@ export default function Tags() { {folderStates[folder.folderPath] === true && (
{folder.photos.map(photo => { - const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image` + const isVideo = photo.media_type === 'video' + const imageUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image` + const thumbUrl = isVideo + ? videosApi.getThumbnailUrl(photo.id) + : imageUrl + const openUrl = isVideo ? `/video/${photo.id}` : imageUrl const isSelected = selectedPhotoIds.has(photo.id) return ( @@ -928,10 +943,13 @@ export default function Tags() { {/* Thumbnail */}
{photo.filename} window.open(photoUrl, '_blank')} + onClick={() => window.open(openUrl, '_blank')} + title={ + isVideo ? 'Open video' : 'Open full photo' + } onError={(e) => { const target = e.target as HTMLImageElement target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="150" height="150"%3E%3Crect fill="%23ddd" width="150" height="150"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3ENo Image%3C/text%3E%3C/svg%3E' diff --git a/admin-frontend/src/pages/UserTaggedPhotos.tsx b/admin-frontend/src/pages/UserTaggedPhotos.tsx index 83fd100..ae47866 100644 --- a/admin-frontend/src/pages/UserTaggedPhotos.tsx +++ b/admin-frontend/src/pages/UserTaggedPhotos.tsx @@ -443,7 +443,7 @@ export default function UserTaggedPhotos() { onClick={() => { const isVideo = linkage.photo_media_type === 'video' const url = isVideo - ? videosApi.getVideoUrl(linkage.photo_id) + ? `/video/${linkage.photo_id}` : `${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image` window.open(url, '_blank') }} diff --git a/admin-frontend/src/pages/VideoPlayer.tsx b/admin-frontend/src/pages/VideoPlayer.tsx index 18d7249..4072e54 100644 --- a/admin-frontend/src/pages/VideoPlayer.tsx +++ b/admin-frontend/src/pages/VideoPlayer.tsx @@ -1,14 +1,45 @@ import { useState, useRef, useEffect } from 'react' import { useParams } from 'react-router-dom' -import videosApi from '../api/videos' +import { photosApi } from '../api/photos' +import { WebPlaybackVideo } from '../components/WebPlaybackVideo' export default function VideoPlayer() { const { id } = useParams<{ id: string }>() - const videoId = id ? parseInt(id, 10) : null + const parsed = id ? parseInt(id, 10) : NaN + const videoId = Number.isFinite(parsed) ? parsed : null const videoRef = useRef(null) const [showPlayButton, setShowPlayButton] = useState(true) + const [mediaPath, setMediaPath] = useState(null) + const [pathError, setPathError] = useState(null) - const videoUrl = videoId ? videosApi.getVideoUrl(videoId) : '' + useEffect(() => { + if (videoId === null || Number.isNaN(videoId)) { + setMediaPath(null) + setPathError(null) + return + } + let cancelled = false + setMediaPath(null) + setPathError(null) + photosApi + .getPhoto(videoId) + .then((p) => { + if (!cancelled) { + setMediaPath(p.path) + } + }) + .catch((e) => { + if (!cancelled) { + setPathError( + e?.response?.data?.detail || e?.message || 'Failed to load video' + ) + setMediaPath('') + } + }) + return () => { + cancelled = true + } + }, [videoId]) const handlePlay = () => { if (videoRef.current) { @@ -53,7 +84,7 @@ export default function VideoPlayer() { } }, []) - if (!videoId || !videoUrl) { + if (videoId === null || Number.isNaN(videoId)) { return (
Video not found
@@ -61,12 +92,29 @@ export default function VideoPlayer() { ) } + if (pathError && mediaPath === '') { + return ( +
+
{pathError}
+
+ ) + } + + if (mediaPath === null) { + return ( +
+
Loading…
+
+ ) + } + return (
-
) : (
/dev/null; then + for dep in "${MISSING_DEPS[@]}"; do + if [ "$dep" = "ffmpeg" ]; then + echo "Installing ffmpeg with dnf..." + echo "Note: On EL8 (Alma/Rocky/CentOS), ffmpeg may require RPM Fusion repos." + echo "" + if sudo dnf -y install ffmpeg; then + echo "✅ ffmpeg installed" + else + echo "" + echo "⚠️ dnf couldn't find ffmpeg." + echo " If this is EL8, run the helper installer from the repo root:" + echo " sudo bash ../../scripts/install_ffmpeg_el8_rpmfusion.sh" + echo "" + fi + fi + done elif command -v brew &> /dev/null; then for dep in "${MISSING_DEPS[@]}"; do if [ "$dep" = "libvips-dev" ]; then