feat: web video transcoding, admin playback, and viewer fixes
Some checks failed
CI / skip-ci-check (pull_request) Successful in 1m4s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled

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
This commit is contained in:
Tanya 2026-03-25 15:33:05 -04:00
parent c316da02a4
commit ff47c87e41
45 changed files with 1531 additions and 121 deletions

View File

@ -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: <parent>/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

View File

@ -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: |

4
.gitignore vendored
View File

@ -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/

View File

@ -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=

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -171,7 +171,11 @@ function App() {
return (
<AuthProvider>
<DeveloperModeProvider>
<BrowserRouter>
<BrowserRouter
basename={
import.meta.env.BASE_URL.replace(/\/$/, '') || undefined
}
>
<AppRoutes />
</BrowserRouter>
</DeveloperModeProvider>

View File

@ -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

View File

@ -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<ListVideosResponse> => {
const res = await apiClient.get<ListVideosResponse>('/api/v1/videos', { params })
const res = await apiClient.get<ListVideosResponse>(fastApiV1Path('/videos'), { params })
return res.data
},
getVideoPeople: async (videoId: number): Promise<VideoPeopleResponse> => {
const res = await apiClient.get<VideoPeopleResponse>(`/api/v1/videos/${videoId}/people`)
const res = await apiClient.get<VideoPeopleResponse>(
fastApiV1Path(`/videos/${videoId}/people`)
)
return res.data
},
@ -91,7 +109,7 @@ export const videosApi = {
request: IdentifyVideoRequest
): Promise<IdentifyVideoResponse> => {
const res = await apiClient.post<IdentifyVideoResponse>(
`/api/v1/videos/${videoId}/identify`,
fastApiV1Path(`/videos/${videoId}/identify`),
request
)
return res.data
@ -102,25 +120,42 @@ export const videosApi = {
personId: number
): Promise<RemoveVideoPersonResponse> => {
const res = await apiClient.delete<RemoveVideoPersonResponse>(
`/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<WebPlaybackPrepareResponse> => {
const res = await apiClient.post<WebPlaybackPrepareResponse>(
fastApiV1Path(`/videos/${videoId}/web-playback/prepare`)
)
return res.data
},
getWebPlaybackStatus: async (
videoId: number
): Promise<WebPlaybackStatusResponse> => {
const res = await apiClient.get<WebPlaybackStatusResponse>(
fastApiV1Path(`/videos/${videoId}/web-playback/status`)
)
return res.data
},
}

View File

@ -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 (
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
@ -365,19 +415,22 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
{imageError ? (
<div className="text-white text-center">
<div className="text-lg mb-2">Failed to load {currentIsVideo ? 'video' : 'image'}</div>
{webPlayback.error && (
<div className="text-sm text-red-300 mb-2">{webPlayback.error}</div>
)}
<div className="text-sm text-gray-400">{currentPhoto.path}</div>
</div>
) : currentIsVideo ? (
<div className="relative h-full w-full max-h-[calc(90vh-80px)] max-w-full flex items-center justify-center">
<video
key={currentPhoto.id}
src={photoUrl}
src={webPlayback.videoSrc || undefined}
className="object-contain w-full h-full max-w-full max-h-full"
controls={true}
controlsList="nodownload"
style={{
display: 'block',
width: '100%',
style={{
display: 'block',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
@ -399,7 +452,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
}}
>
<img
src={photoUrl}
src={imagePhotoUrl}
alt={currentPhoto.filename || `Photo ${currentIndex + 1}`}
className="max-w-full max-h-full object-contain"
style={{ userSelect: 'none', pointerEvents: 'none' }}

View File

@ -0,0 +1,51 @@
import { forwardRef, type VideoHTMLAttributes } from 'react'
import { useWebPlaybackVideo } from '../hooks/useWebPlaybackVideo'
export interface WebPlaybackVideoProps
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, 'src'> {
videoId: number
/** Filesystem or remote URL from the photo/video record */
mediaPath: string
}
/**
* HTML5 video that uses browser-safe web playback for local files (H.264/AAC transcode)
* and direct URL for remote http(s) sources.
*/
export const WebPlaybackVideo = forwardRef<HTMLVideoElement, WebPlaybackVideoProps>(
function WebPlaybackVideo(
{ videoId, mediaPath, className, onLoadedData, onError, ...rest },
ref
) {
const { videoSrc, preparing, error } = useWebPlaybackVideo(videoId, mediaPath)
return (
<div className="relative w-full">
{error && (
<div className="mb-2 rounded bg-red-900/80 px-2 py-1 text-sm text-red-100">
{error}
</div>
)}
{preparing && !videoSrc && !error && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/50 text-sm text-white">
Preparing video
</div>
)}
<video
ref={ref}
src={videoSrc || undefined}
className={className}
onLoadedData={(e) => {
onLoadedData?.(e)
}}
onError={(e) => {
if (!error) {
onError?.(e)
}
}}
{...rest}
/>
</div>
)
}
)

View File

@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import videosApi from '../api/videos'
import { isRemoteMediaPath } from '../lib/media-path'
const POLL_MS = 1000
const MAX_POLLS = 900
function formatPrepareError(err: unknown): string {
if (err && typeof err === 'object' && 'response' in err) {
const r = (err as { response?: { data?: { detail?: string }; status?: number } })
.response
if (r?.data?.detail) {
return String(r.data.detail)
}
if (r?.status === 503) {
return 'Video prep unavailable (Redis or worker may be down).'
}
}
if (err instanceof Error) {
return err.message
}
return 'Video playback failed'
}
/**
* Resolve a URL for HTML5 video: remote paths use the URL as-is; local files use
* browser-safe transcoded stream after prepare + poll (same behavior as viewer).
*/
export function useWebPlaybackVideo(
videoId: number | null,
mediaPath: string
): { videoSrc: string | null; preparing: boolean; error: string | null } {
const [videoSrc, setVideoSrc] = useState<string | null>(null)
const [preparing, setPreparing] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (videoId === null) {
setVideoSrc(null)
setPreparing(false)
setError(null)
return
}
let cancelled = false
if (isRemoteMediaPath(mediaPath)) {
setVideoSrc(mediaPath)
setPreparing(false)
setError(null)
return () => {
cancelled = true
}
}
const playbackId = videoId
async function run(): Promise<void> {
const id = playbackId
setPreparing(true)
setError(null)
setVideoSrc(null)
try {
const prep = await videosApi.prepareWebPlayback(id)
if (cancelled) {
return
}
if (prep.status === 'ready') {
setVideoSrc(videosApi.getWebPlaybackStreamUrl(id))
setPreparing(false)
return
}
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
if (cancelled) {
return
}
await new Promise((r) => setTimeout(r, POLL_MS))
const st = await videosApi.getWebPlaybackStatus(id)
if (cancelled) {
return
}
if (st.status === 'ready') {
setVideoSrc(videosApi.getWebPlaybackStreamUrl(id))
setPreparing(false)
return
}
if (st.status === 'failed') {
throw new Error(st.error || 'Video transcode failed')
}
}
throw new Error('Video preparation timed out')
} catch (e) {
if (!cancelled) {
setError(formatPrepareError(e))
setPreparing(false)
}
}
}
void run()
return () => {
cancelled = true
}
}, [videoId, mediaPath])
return { videoSrc, preparing, error }
}

View File

@ -0,0 +1,7 @@
/**
* Relative URL under FastAPI's versioned mount (backend uses prefix="/api/v1").
*/
export function fastApiV1Path(suffix: string): string {
const p = suffix.startsWith('/') ? suffix : `/${suffix}`
return `/api/v1${p}`
}

View File

@ -0,0 +1,5 @@
/** True if the library path points at a remote URL (browser plays directly). */
export function isRemoteMediaPath(path: string): boolean {
const t = path.trim().toLowerCase()
return t.startsWith('http://') || t.startsWith('https://')
}

View File

@ -5,6 +5,7 @@ import peopleApi, { Person } from '../api/people'
import { apiClient } from '../api/client'
import tagsApi, { TagResponse } from '../api/tags'
import videosApi, { VideoListItem, VideoPersonInfo, IdentifyVideoRequest } from '../api/videos'
import { WebPlaybackVideo } from '../components/WebPlaybackVideo'
import { useDeveloperMode } from '../context/DeveloperModeContext'
import { useAuth } from '../context/AuthContext'
import pendingIdentificationsApi, {
@ -1942,8 +1943,9 @@ export default function Identify() {
<h3 className="text-lg font-semibold text-gray-700 mb-2">
{selectedVideo.filename}
</h3>
<video
src={videosApi.getVideoUrl(selectedVideo.id)}
<WebPlaybackVideo
videoId={selectedVideo.id}
mediaPath={selectedVideo.path}
controls
className="w-full max-h-64 rounded"
/>

View File

@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useCallback } from 'react'
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonVideoItem, PersonUpdateRequest } from '../api/people'
import facesApi from '../api/faces'
import videosApi from '../api/videos'
import { WebPlaybackVideo } from '../components/WebPlaybackVideo'
import { apiClient } from '../api/client'
interface EditDialogProps {
@ -1145,14 +1146,13 @@ export default function Modify() {
×
</button>
</div>
<video
src={videosApi.getVideoUrl(selectedVideoToPlay.id)}
<WebPlaybackVideo
videoId={selectedVideoToPlay.id}
mediaPath={selectedVideoToPlay.path}
controls
autoPlay
className="w-full max-h-[80vh] rounded"
>
Your browser does not support the video tag.
</video>
/>
{selectedVideoToPlay.date_taken && (
<div className="text-sm text-gray-300 mt-2">
Date taken: {new Date(selectedVideoToPlay.date_taken).toLocaleDateString()}

View File

@ -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')
}}

View File

@ -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)
}

View File

@ -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() {
<td className="p-2">{photo.id}</td>
<td className="p-2">
<a
href={`${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image`}
href={
photo.media_type === 'video'
? `/video/${photo.id}`
: `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image`
}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
title={
photo.media_type === 'video'
? 'Open video (browser-safe playback)'
: 'Open full photo'
}
>
{photo.filename}
</a>
@ -872,7 +882,12 @@ export default function Tags() {
{folderStates[folder.folderPath] === true && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{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 */}
<div className="w-full aspect-square bg-gray-100 overflow-hidden">
<img
src={photoUrl}
src={thumbUrl}
alt={photo.filename}
className="w-full h-full object-cover cursor-pointer"
onClick={() => 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'

View File

@ -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')
}}

View File

@ -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<HTMLVideoElement>(null)
const [showPlayButton, setShowPlayButton] = useState(true)
const [mediaPath, setMediaPath] = useState<string | null>(null)
const [pathError, setPathError] = useState<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="text-white text-xl">Video not found</div>
@ -61,12 +92,29 @@ export default function VideoPlayer() {
)
}
if (pathError && mediaPath === '') {
return (
<div className="min-h-screen flex items-center justify-center bg-black p-4">
<div className="text-red-200 text-center max-w-lg">{pathError}</div>
</div>
)
}
if (mediaPath === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="text-white text-xl">Loading</div>
</div>
)
}
return (
<div className="min-h-screen bg-black flex items-center justify-center p-4">
<div className="relative w-full max-w-full" style={{ maxHeight: 'calc(100vh - 2rem)' }}>
<video
<WebPlaybackVideo
ref={videoRef}
src={videoUrl}
videoId={videoId}
mediaPath={mediaPath}
className="w-full h-auto max-h-[calc(100vh-2rem)] object-contain"
controls={true}
controlsList="nodownload"

View File

@ -1,17 +1,29 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const raw = (env.VITE_BASE_PATH || '').trim()
const base =
raw === ''
? '/'
: raw.endsWith('/')
? raw
: `${raw}/`
return {
base,
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
},
}
})

View File

@ -183,6 +183,7 @@ def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTa
unidentified_face_count=p['unidentified_face_count'],
tags=p['tags'],
people_names=p.get('people_names', ''),
media_type=p.get('media_type') or 'image',
)
for p in photos_data
]

View File

@ -7,6 +7,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, Response, StreamingResponse
from redis import Redis
from rq import Queue
from sqlalchemy.orm import Session
from backend.db.session import get_db
@ -21,6 +23,8 @@ from backend.schemas.videos import (
IdentifyVideoRequest,
IdentifyVideoResponse,
RemoveVideoPersonResponse,
WebPlaybackPrepareResponse,
WebPlaybackStatusResponse,
)
from backend.services.video_service import (
list_videos_for_identification,
@ -30,9 +34,19 @@ from backend.services.video_service import (
get_video_people_count,
)
from backend.services.thumbnail_service import get_video_thumbnail_path
from backend.services.web_video_service import (
expire_web_playable_if_stale,
get_web_playback_status_dict,
prepare_web_playback,
resolve_valid_playable_path,
stream_web_playable_file,
)
router = APIRouter(prefix="/videos", tags=["videos"])
_redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
_video_web_queue = Queue(connection=_redis_conn)
@router.get("", response_model=ListVideosResponse)
def list_videos(
@ -328,34 +342,18 @@ def get_video_file(
media_type = "video/mp4"
file_size = os.path.getsize(video.path)
# Get range header - Starlette normalizes headers to lowercase
range_header = request.headers.get("range")
# Debug: Write to file to verify code execution
try:
with open("/tmp/video_debug.log", "a") as f:
all_headers = {k: v for k, v in request.headers.items()}
f.write(f"Video {video_id}: range_header={range_header}, all_headers={all_headers}\n")
if hasattr(request, 'scope'):
scope_headers = request.scope.get("headers", [])
f.write(f" scope headers: {scope_headers}\n")
f.flush()
except Exception as e:
with open("/tmp/video_debug.log", "a") as f:
f.write(f"Debug write error: {e}\n")
f.flush()
# Also check request scope directly as fallback
if not range_header and hasattr(request, 'scope'):
if not range_header and hasattr(request, "scope"):
scope_headers = request.scope.get("headers", [])
for header_name, header_value in scope_headers:
if header_name.lower() == b"range":
range_header = header_value.decode() if isinstance(header_value, bytes) else header_value
with open("/tmp/video_debug.log", "a") as f:
f.write(f" Found range in scope: {range_header}\n")
f.flush()
range_header = (
header_value.decode()
if isinstance(header_value, bytes)
else header_value
)
break
if range_header:
try:
# Parse range header: "bytes=start-end"
@ -420,6 +418,83 @@ def get_video_file(
return response
@router.post(
"/{video_id}/web-playback/prepare",
response_model=WebPlaybackPrepareResponse,
)
def prepare_video_web_playback(
video_id: int,
db: Session = Depends(get_db),
) -> WebPlaybackPrepareResponse:
"""Queue browser-safe transcoding (deduped per video). Requires Redis + RQ worker."""
try:
_video_web_queue.connection.ping()
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Redis unavailable: {exc}",
) from exc
data = prepare_web_playback(video_id, db, _video_web_queue)
if data.get("status") == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=data.get("message", "Not found"),
)
db.commit()
return WebPlaybackPrepareResponse(
status=data["status"],
message=data.get("message", ""),
)
@router.get(
"/{video_id}/web-playback/status",
response_model=WebPlaybackStatusResponse,
)
def get_video_web_playback_status(
video_id: int,
db: Session = Depends(get_db),
) -> WebPlaybackStatusResponse:
"""Poll transcoding readiness for web playback."""
data = get_web_playback_status_dict(video_id, db)
if data["status"] == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Video not found",
)
return WebPlaybackStatusResponse(
status=data["status"],
error=data.get("error"),
)
@router.get("/{video_id}/web-playback/stream")
def stream_video_web_playback(
video_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""Stream browser-safe MP4 (after prepare + ready). Supports Range requests."""
video = (
db.query(Photo)
.filter(Photo.id == video_id, Photo.media_type == "video")
.first()
)
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Video {video_id} not found",
)
expire_web_playable_if_stale(video)
db.commit()
db.refresh(video)
playable = resolve_valid_playable_path(video)
if not playable:
raise HTTPException(
status_code=getattr(status, "HTTP_425_TOO_EARLY", 425),
detail="Playback not ready. Call POST .../web-playback/prepare and poll status.",
)
return stream_web_playable_file(playable, request)

View File

@ -42,6 +42,11 @@ class Photo(Base):
processed = Column(Boolean, default=False, nullable=False, index=True)
file_hash = Column(Text, nullable=True, index=True) # Nullable to support existing photos without hashes
media_type = Column(Text, default="image", nullable=False, index=True) # "image" or "video"
# Browser-safe playback (H.264/AAC); see web_video_service / migrations.
web_playable_path = Column(Text, nullable=True)
web_transcode_status = Column(Text, default="none", nullable=False)
web_transcode_error = Column(Text, nullable=True)
web_transcode_job_id = Column(Text, nullable=True)
faces = relationship("Face", back_populates="photo", cascade="all, delete-orphan")
photo_tags = relationship(

View File

@ -88,6 +88,7 @@ class PhotoWithTagsItem(BaseModel):
unidentified_face_count: int # Count of faces with person_id IS NULL
tags: str # Comma-separated tags string (matching desktop)
people_names: str = "" # Comma-separated people names string
media_type: str = "image" # "image" or "video"
class PhotosWithTagsResponse(BaseModel):

View File

@ -80,6 +80,20 @@ class IdentifyVideoResponse(BaseModel):
message: str
class WebPlaybackPrepareResponse(BaseModel):
"""Response after requesting browser-safe playback preparation."""
status: str
message: str
class WebPlaybackStatusResponse(BaseModel):
"""Transcode / readiness status for web playback."""
status: str
error: Optional[str] = None
class RemoveVideoPersonResponse(BaseModel):
"""Response for removing a person from a video."""

View File

@ -345,3 +345,78 @@ def process_faces_task(
finally:
db.close()
def transcode_video_for_web_task(photo_id: int) -> dict:
"""Background job: build browser-safe MP4 (or mark original as ready if H.264/AAC)."""
import os
import traceback
from backend.db.models import Photo
from backend.db.session import SessionLocal
from backend.services.web_video_service import (
derived_mp4_path,
expire_web_playable_if_stale,
probe_browser_safe_video,
run_ffmpeg_web_transcode,
web_playback_is_ready,
)
db = SessionLocal()
try:
photo = (
db.query(Photo)
.filter(Photo.id == photo_id)
.with_for_update()
.first()
)
if not photo or photo.media_type != "video":
return {"ok": False, "error": "not_found"}
expire_web_playable_if_stale(photo)
if web_playback_is_ready(photo):
db.commit()
return {"ok": True, "skipped": True}
photo.web_transcode_status = "running"
photo.web_transcode_error = None
db.commit()
if not os.path.isfile(photo.path):
raise RuntimeError("Source video file not found on disk")
if probe_browser_safe_video(photo.path):
photo.web_playable_path = photo.path
photo.web_transcode_status = "ready"
photo.web_transcode_error = None
db.commit()
return {"ok": True, "native": True}
dst = derived_mp4_path(photo_id)
run_ffmpeg_web_transcode(photo.path, dst)
photo.web_playable_path = str(dst)
photo.web_transcode_status = "ready"
photo.web_transcode_error = None
db.commit()
return {"ok": True, "transcoded": True}
except Exception as e:
try:
db.rollback()
except Exception:
pass
try:
print(f"[Task] web transcode failed for photo {photo_id}: {e}")
traceback.print_exc()
except (BrokenPipeError, OSError):
pass
try:
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if photo:
photo.web_transcode_status = "failed"
photo.web_transcode_error = str(e)[:2000]
db.commit()
except Exception:
pass
return {"ok": False, "error": str(e)}
finally:
db.close()

View File

@ -0,0 +1,292 @@
"""Browser-safe video playback: transcode cache with TTL and ffprobe shortcuts."""
from __future__ import annotations
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Any, Optional
from fastapi import Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from sqlalchemy.orm import Session
from backend.db.models import Photo
from backend.settings import get_web_video_cache_directory
# Transcoded files older than this are deleted and must be regenerated.
WEB_PLAYBACK_TTL_SECONDS = 86400 # 24 hours
def derived_mp4_path(photo_id: int) -> Path:
"""Absolute path for cached transcoded MP4."""
return get_web_video_cache_directory() / f"{photo_id}.mp4"
def _file_within_ttl(path: Path) -> bool:
if not path.is_file():
return False
age = time.time() - path.stat().st_mtime
return age < WEB_PLAYBACK_TTL_SECONDS
def expire_web_playable_if_stale(photo: Photo) -> None:
"""Drop derived cache row + file when past TTL (does not delete originals)."""
if photo.media_type != "video":
return
cached = photo.web_playable_path
if not cached:
return
if cached == photo.path:
if not os.path.isfile(photo.path):
photo.web_playable_path = None
photo.web_transcode_status = "none"
photo.web_transcode_error = None
return
p = Path(cached)
if not p.is_file():
photo.web_playable_path = None
photo.web_transcode_status = "none"
photo.web_transcode_error = None
return
if not _file_within_ttl(p):
try:
p.unlink(missing_ok=True)
except OSError:
pass
photo.web_playable_path = None
photo.web_transcode_status = "none"
photo.web_transcode_error = None
def probe_browser_safe_video(video_path: str) -> bool:
"""True if streams look playable in common HTML5 video (H.264 + yuv420p + AAC)."""
if not os.path.isfile(video_path):
return False
try:
out = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"stream=codec_type,codec_name,pix_fmt",
"-of",
"json",
video_path,
],
capture_output=True,
text=True,
timeout=60,
check=False,
)
if out.returncode != 0:
return False
data: dict[str, Any] = json.loads(out.stdout or "{}")
streams = data.get("streams") or []
vcodec: Optional[str] = None
pix: Optional[str] = None
has_aac = False
has_audio = False
for s in streams:
if s.get("codec_type") == "video":
vcodec = (s.get("codec_name") or "").lower()
pix = (s.get("pix_fmt") or "").lower()
elif s.get("codec_type") == "audio":
has_audio = True
if (s.get("codec_name") or "").lower() in ("aac", "mp4a"):
has_aac = True
if vcodec != "h264":
return False
if pix not in ("yuv420p", "yuvj420p"):
return False
if has_audio and not has_aac:
return False
return True
except (OSError, json.JSONDecodeError, subprocess.TimeoutExpired):
return False
def run_ffmpeg_web_transcode(src: str, dst: Path) -> None:
"""Transcode to H.264/AAC MP4 suitable for browsers."""
dst.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg",
"-y",
"-i",
src,
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"23",
"-preset",
"veryfast",
"-c:a",
"aac",
"-b:a",
"160k",
"-movflags",
"+faststart",
str(dst),
]
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=7200,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "")[-2000:]
raise RuntimeError(f"ffmpeg failed (code {proc.returncode}): {err}")
def resolve_valid_playable_path(photo: Photo) -> Optional[Path]:
"""Return filesystem path to serve if ready and valid, else None."""
expire_web_playable_if_stale(photo)
p = photo.web_playable_path
if not p or photo.web_transcode_status != "ready":
return None
if p == photo.path:
path = Path(p)
return path if path.is_file() else None
path = Path(p).resolve()
try:
path.relative_to(get_web_video_cache_directory().resolve())
except ValueError:
return None
if path.is_file() and _file_within_ttl(path):
return path
return None
def web_playback_is_ready(photo: Photo) -> bool:
return resolve_valid_playable_path(photo) is not None
def _parse_range_header(range_header: str | None, file_size: int) -> Optional[tuple[int, int]]:
if not range_header:
return None
if not range_header.startswith("bytes="):
return None
range_part = range_header[6:]
parts = range_part.split("-", 1)
start_str = parts[0].strip()
end_str = parts[1].strip() if len(parts) > 1 else ""
try:
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
except ValueError:
return None
if start < 0 or end >= file_size or start > end:
return None
return start, end
def stream_web_playable_file(video_path: Path, request: Request) -> Response | FileResponse:
"""Range-capable streaming for MP4 (same behavior as legacy video endpoint)."""
file_size = video_path.stat().st_size
range_header = request.headers.get("range")
range_data = _parse_range_header(range_header, file_size)
media_type = "video/mp4"
if range_data:
start, end = range_data
chunk_size = end - start + 1
def generate_chunk():
with open(video_path, "rb") as handle:
handle.seek(start)
remaining = chunk_size
while remaining > 0:
chunk = handle.read(min(8192, remaining))
if not chunk:
break
yield chunk
remaining -= len(chunk)
return StreamingResponse(
generate_chunk(),
status_code=206,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Type": media_type,
"Content-Disposition": "inline",
"Cache-Control": f"public, max-age={WEB_PLAYBACK_TTL_SECONDS}",
},
media_type=media_type,
)
response = FileResponse(
str(video_path),
media_type=media_type,
)
response.headers["Content-Disposition"] = "inline"
response.headers["Accept-Ranges"] = "bytes"
response.headers["Cache-Control"] = f"public, max-age={WEB_PLAYBACK_TTL_SECONDS}"
return response
def prepare_web_playback(photo_id: int, db: Session, queue) -> dict[str, str]:
"""Enqueue or confirm web playback; caller must commit after."""
photo = (
db.query(Photo)
.filter(Photo.id == photo_id, Photo.media_type == "video")
.with_for_update()
.first()
)
if not photo:
return {"status": "not_found", "message": "Video not found"}
if not os.path.isfile(photo.path):
return {"status": "not_found", "message": "File missing on server"}
expire_web_playable_if_stale(photo)
db.flush()
if web_playback_is_ready(photo):
return {"status": "ready", "message": "Playback file available"}
if photo.web_transcode_status in ("queued", "running"):
return {"status": photo.web_transcode_status, "message": "Transcode in progress"}
if photo.web_transcode_status == "failed":
photo.web_transcode_status = "none"
photo.web_transcode_error = None
photo.web_transcode_job_id = None
job = queue.enqueue(
"backend.services.tasks.transcode_video_for_web_task",
photo_id,
job_timeout=7200,
result_ttl=120,
)
photo.web_transcode_status = "queued"
photo.web_transcode_job_id = job.get_id()
photo.web_transcode_error = None
return {"status": "queued", "message": "Transcode queued"}
def get_web_playback_status_dict(photo_id: int, db: Session) -> dict[str, Optional[str]]:
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo or photo.media_type != "video":
return {"status": "not_found", "error": None}
expire_web_playable_if_stale(photo)
db.commit()
db.refresh(photo)
if web_playback_is_ready(photo):
return {"status": "ready", "error": None}
err = photo.web_transcode_error
if photo.web_transcode_status == "failed":
return {"status": "failed", "error": err}
return {"status": photo.web_transcode_status, "error": err}

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import os
from pathlib import Path
APP_TITLE = "PunimTag Web API"
APP_VERSION = "0.1.0"
@ -11,3 +12,46 @@ APP_VERSION = "0.1.0"
PHOTO_STORAGE_DIR = os.getenv("PHOTO_STORAGE_DIR", "data/uploads")
def _project_root() -> Path:
"""Repository root (parent of ``backend/``)."""
return Path(__file__).resolve().parent.parent
def get_web_video_cache_directory() -> Path:
"""Filesystem directory for browser-safe transcoded MP4 cache.
Resolution order:
1. ``WEB_VIDEO_CACHE_DIR`` if set (absolute or relative to project root).
2. Otherwise ``<parent of UPLOAD_DIR>/web_videos`` when ``UPLOAD_DIR`` or
``PENDING_PHOTOS_DIR`` is set (same layout as ``.../pending-photos`` next to
``.../web_videos``).
3. Otherwise ``<project>/data/web_videos`` for local dev when no upload dir
is configured on the API process.
"""
explicit = (os.getenv("WEB_VIDEO_CACHE_DIR") or "").strip()
if explicit:
path = Path(explicit).expanduser()
if not path.is_absolute():
path = (_project_root() / path).resolve()
else:
path = path.resolve()
path.mkdir(parents=True, exist_ok=True)
return path
pending_raw = (os.getenv("UPLOAD_DIR") or os.getenv("PENDING_PHOTOS_DIR") or "").strip()
if pending_raw:
pending = Path(pending_raw).expanduser()
if not pending.is_absolute():
pending = (_project_root() / pending).resolve()
else:
pending = pending.resolve()
out = (pending.parent / "web_videos").resolve()
out.mkdir(parents=True, exist_ok=True)
return out
fallback = (_project_root() / "data" / "web_videos").resolve()
fallback.mkdir(parents=True, exist_ok=True)
return fallback

View File

@ -1058,15 +1058,31 @@ pip install -r requirements.txt
5. Restart: `pm2 restart punimtag-admin`
**Viewer frontend 404 for assets or API routes:**
1. Verify `assetPrefix: '/punim-viewer'` is set in `viewer-frontend/next.config.ts` (NOT `basePath` - proxy strips the path)
1. Verify `assetPrefix: '/punim-viewer'` is set in `viewer-frontend/next.config.ts` (common when proxy strips the prefix)
2. Check `NEXT_PUBLIC_API_URL=/punim-api` in `viewer-frontend/.env`
3. Check `NEXTAUTH_URL=https://your-domain.com/punim-viewer` and `AUTH_URL=https://your-domain.com/punim-viewer` in `viewer-frontend/.env`
4. **If proxy strips `/punim-viewer` before forwarding:**
- All client-side `fetch('/api/...')` calls must be updated to use `/punim-viewer/api/...`
- A helper function `getApiPath()` is available in `lib/api-path.ts` for this
- NextAuth routes (`/api/auth/session`) may need `basePath: '/punim-viewer'` in NextAuth config
5. Clean rebuild: `cd ~/punimtag/viewer-frontend && rm -rf .next && npm run build`
6. Restart: `pm2 restart punimtag-viewer`
3. Ensure NextAuth URLs match your routing strategy:
- Recommended (prefix-aware): `NEXTAUTH_URL=https://your-domain.com/punim-viewer`
- If your hosting proxy forces auth at root: use `NEXTAUTH_URL=https://your-domain.com` and redirect `/api/auth/*` to `/punim-viewer/api/auth/*` via `.htaccess`
4. If you see browser requests hitting domain root paths like `/_next/image`, `/_next/static`, or `/api/...` and you cannot use Apache proxy flags (`[P]`) in `.htaccess`,
add minimal rewrite rules to map root paths into `/punim-viewer/` (server-level proxy still handles the prefixed paths):
```apache
# Do not rewrite already-prefixed app paths
RewriteRule ^punim-viewer/ - [L]
RewriteRule ^punim-admin/ - [L]
RewriteRule ^punim-api/ - [L]
# Next.js assets/images when viewer is hosted under /punim-viewer
RewriteRule ^_next/static/(.*)$ /punim-viewer/_next/static/$1 [L,QSA,NE]
RewriteRule ^_next/image(.*)$ /punim-viewer/_next/image$1 [L,QSA,NE]
# Next.js API routes when client code requests /api/... at domain root
# Use 307 if you need a redirect visible to browser; otherwise use [L] for internal rewrite.
RewriteRule ^api/(.*)$ /punim-viewer/api/$1 [R=307,L,NE,QSA]
```
5. Clean rebuild (viewer): `cd ~/punimtag/viewer-frontend && rm -rf .next && npm run build`
6. Restart: `pm2 restart punimtag-viewer --update-env`
**Note:** If your reverse proxy strips `/punim-viewer` before forwarding to Next.js, you have two options:
- **Option A (Recommended):** Ask hosting provider to preserve `/punim-viewer` prefix when forwarding to Next.js (only strip for static file serving if needed)
@ -1077,6 +1093,104 @@ pip install -r requirements.txt
- Check that `VITE_API_URL` and `NEXT_PUBLIC_API_URL` match your reverse proxy path
- Test API directly: `curl http://localhost:8000/api/v1/health`
### Backend Won't Start: TensorFlow/Keras Error (DeepFace)
If backend logs show:
`ValueError: You have tensorflow ... and this requires tf-keras package`
Install `tf-keras` into the backend virtualenv (no code changes required):
```bash
cd ~/punimtag
source venv/bin/activate
pip install -U tf-keras
pm2 restart punimtag-api --update-env
```
Verify the backend is healthy (use GET, not HEAD):
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://your-domain.com/punim-api/health
```
### Video Thumbnails Return 500/502 in Viewer
Symptoms:
- Viewer grid shows video tiles but thumbnail requests return 500/502
- Backend endpoint `/api/v1/videos/<id>/thumbnail` returns 500
Cause:
- Video thumbnail generation requires **either** OpenCV (`cv2`) **or** `ffmpeg`.
On many cPanel servers, neither is installed by default.
Checks:
```bash
which ffmpeg || echo "ffmpeg missing"
python3 -c "import cv2; print('cv2 ok')" 2>/dev/null || echo "no cv2"
```
If you have sudo on AlmaLinux/Rocky/CentOS 8, install `ffmpeg` via RPM Fusion:
```bash
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --set-enabled powertools || true
sudo dnf -y install \
https://download1.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm \
https://download1.rpmfusion.org/nonfree/el/rpmfusion-nonfree-release-8.noarch.rpm
sudo dnf -y install ffmpeg
```
Then restart backend:
```bash
pm2 restart punimtag-api --update-env
```
Verify thumbnail endpoint (GET, not `-I`/HEAD):
```bash
curl -s -o /dev/null -w "%{http_code}\n" \
https://your-domain.com/punim-api/api/v1/videos/734/thumbnail
```
### Video Playback Notes (AVI / MP4 codecs)
- `.avi` is commonly served as `video/x-msvideo`. Most browsers cannot decode AVI in HTML5 video.
Result: "No video with supported format and MIME type found".
- Some `.mp4` files contain **MPEG-4 Part 2** video (`codec_name=mpeg4`) instead of H.264.
Result: audio plays but video is black in many browsers.
For best compatibility, use MP4 with H.264 video + AAC audio.
### On-demand web video transcoding (viewer)
The viewer transcodes **local** video files to a browser-safe **H.264 + AAC MP4** on first play (RQ job on the backend). Output is cached on disk under `data/web_videos/` with a **24-hour TTL** (files older than 24 hours are removed and regenerated on next play). Videos that already probe as **H.264 + yuv420p + AAC** use the **original file** (no duplicate encode).
**Database migration (main `punimtag` DB):**
From the repo root (loads `.env`, normalizes `DATABASE_URL` for `psql`):
```bash
./scripts/run-psql-migration.sh migrations/add-photos-web-playback-columns.sql
```
Manual alternative: if `DATABASE_URL` uses SQLAlchemys `+psycopg2` or `+asyncpg`, strip that segment before passing to `psql`, e.g. `postgresql+psycopg2://…``postgresql://…`.
**Requirements:**
- **Redis + `punimtag-worker`** (same RQ `default` queue as face processing).
- **`ffmpeg`** and **`ffprobe`** on the server (see FFmpeg install steps above).
**API (FastAPI, also proxied by the viewer):**
- `POST /api/v1/videos/{id}/web-playback/prepare` — enqueue / dedupe transcode.
- `GET /api/v1/videos/{id}/web-playback/status``ready` | `queued` | `running` | `failed`.
- `GET /api/v1/videos/{id}/web-playback/stream` — MP4 stream (Range supported).
The viewer calls `/api/photos/{id}/web-playback/*` (Next.js proxies to the backend using `BACKEND_BASE_URL`). Remote URLs (`http`/`https` paths) still play **directly** without transcoding.
### PM2 Not Persisting After Reboot
```bash

View File

@ -0,0 +1,10 @@
-- Web-playback transcoding cache (H.264 MP4) with TTL enforced in application layer.
-- Run against the main punimtag database (same DB as photos table).
ALTER TABLE photos ADD COLUMN IF NOT EXISTS web_playable_path TEXT;
ALTER TABLE photos ADD COLUMN IF NOT EXISTS web_transcode_status TEXT NOT NULL DEFAULT 'none';
ALTER TABLE photos ADD COLUMN IF NOT EXISTS web_transcode_error TEXT;
ALTER TABLE photos ADD COLUMN IF NOT EXISTS web_transcode_job_id TEXT;
COMMENT ON COLUMN photos.web_playable_path IS 'Path to browser-safe MP4 or original path if already H.264/AAC-safe';
COMMENT ON COLUMN photos.web_transcode_status IS 'none|queued|running|ready|failed';

View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
cd "$PROJECT_ROOT"
if [[ ! -d "venv" ]]; then
echo "ERROR: venv/ not found at $PROJECT_ROOT/venv" >&2
exit 1
fi
echo "Activating venv and installing tf-keras."
source "venv/bin/activate"
python -V
pip -V
pip install -U tf-keras
echo
echo "Installed. Restart backend if needed (example):"
echo " pm2 restart punimtag-api --update-env"

View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Installing ffmpeg on EL8 via RPM Fusion."
echo "This script is intended for AlmaLinux/Rocky/CentOS 8 with sudo access."
echo
if ! command -v dnf >/dev/null 2>&1; then
echo "ERROR: dnf not found. This doesn't look like an EL8 system." >&2
exit 1
fi
echo "Enabling PowerTools (if available)."
sudo dnf -y install dnf-plugins-core >/dev/null
sudo dnf -y config-manager --set-enabled powertools || true
sudo dnf -y config-manager --set-enabled PowerTools || true
echo "Installing RPM Fusion repos (EL8)."
sudo dnf -y install \
https://download1.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm \
https://download1.rpmfusion.org/nonfree/el/rpmfusion-nonfree-release-8.noarch.rpm
echo "Installing ffmpeg."
sudo dnf -y install ffmpeg
echo
echo "Done. Verifying:"
command -v ffmpeg
ffmpeg -version | head -n 1

41
scripts/run-psql-migration.sh Executable file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Run a SQL migration against the main DATABASE_URL from repo .env.
#
# Usage (from anywhere):
# ./scripts/run-psql-migration.sh migrations/add-photos-web-playback-columns.sql
#
# Strips SQLAlchemy driver suffixes (+psycopg2, +asyncpg) so psql accepts the URL.
# For remote Postgres, set DATABASE_URL in .env to postgresql://user:pass@host:port/dbname
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found at $ROOT/.env" >&2
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "ERROR: DATABASE_URL is not set in .env" >&2
exit 1
fi
SQL_FILE="${1:?Usage: $0 path/to/migration.sql}"
if [[ ! -f "$SQL_FILE" ]]; then
echo "ERROR: SQL file not found: $SQL_FILE" >&2
exit 1
fi
URL="$DATABASE_URL"
URL="${URL//+psycopg2/}"
URL="${URL//+asyncpg/}"
exec psql "$URL" -f "$SQL_FILE"

View File

@ -4,6 +4,16 @@
DATABASE_URL=postgresql://punimtag:CHANGE_ME@127.0.0.1:5432/punimtag
DATABASE_URL_AUTH=postgresql://punimtag_auth:CHANGE_ME@127.0.0.1:5432/punimtag_auth
# FastAPI mount URL for server-side proxies (thumbnails, web-playback)—no trailing slash.
# Local: origin only (helper adds /api/v1/...).
BACKEND_BASE_URL=http://127.0.0.1:8000
# Production (public URL through your reverse proxy), e.g.:
# BACKEND_BASE_URL=https://jrccphotos.org/punim-api
# Same machine as Next: http://127.0.0.1:8000 is also valid.
# Production subpath for the Next app (no trailing slash). Local: leave unset.
# NEXT_BASE_PATH=/punim-viewer
# NextAuth
NEXTAUTH_URL=http://127.0.0.1:3001

View File

@ -618,31 +618,26 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
// Handle closing the modal
const handleCloseModal = () => {
// Set flag to prevent useEffect from running
// Skip one modal effect run (avoids racing with stale params during close)
isClosingModalRef.current = true;
// Clear modal state immediately (no reload, instant close)
setModalPhoto(null);
setModalPhotos([]);
setModalIndex(0);
// Update URL directly using history API to avoid triggering Next.js router effects
// This prevents any reload or re-fetch when closing
// Must use Next.js router so useSearchParams() updates. Raw history.replaceState
// leaves the router thinking ?photo=… is still active, so router.push for the same
// photo is a no-op and the modal never reopens.
const params = new URLSearchParams(window.location.search);
params.delete('photo');
params.delete('photos');
params.delete('index');
params.delete('autoplay');
const newUrl = params.toString() ? `/?${params.toString()}` : '/';
// Use window.history directly to avoid Next.js router processing
window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl },
'',
newUrl
);
// Reset flag after a short delay to allow effects to see it
router.replace(newUrl, { scroll: false });
setTimeout(() => {
isClosingModalRef.current = false;
}, 100);

View File

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { fastApiV1Url } from '@/lib/server/fastapi-backend';
import { prisma } from '@/lib/db';
import { readFile } from 'fs/promises';
import { createReadStream } from 'fs';
@ -167,11 +168,9 @@ export async function GET(
// Handle video thumbnail request
if (thumbnail && mediaType === 'video') {
const backendBaseUrl = process.env.BACKEND_BASE_URL || 'http://127.0.0.1:8000';
try {
const backendResponse = await fetch(
`${backendBaseUrl}/api/v1/videos/${photoId}/thumbnail`,
fastApiV1Url(`/videos/${photoId}/thumbnail`),
{ headers: { Accept: 'image/*' } }
);

View File

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { fastApiV1Url } from '@/lib/server/fastapi-backend';
export const dynamic = 'force-dynamic';
/**
* Queue browser-safe transcoding for a video (backend RQ job, deduped per photo).
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const photoId = parseInt(id, 10);
if (Number.isNaN(photoId)) {
return NextResponse.json({ error: 'Invalid photo ID' }, { status: 400 });
}
try {
const res = await fetch(
fastApiV1Url(`/videos/${photoId}/web-playback/prepare`),
{ method: 'POST', cache: 'no-store' }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return NextResponse.json(
{ error: (data as { detail?: string }).detail || res.statusText },
{ status: res.status }
);
}
return NextResponse.json(data, {
headers: { 'Cache-Control': 'no-store, max-age=0' },
});
} catch (e) {
console.error('web-playback prepare proxy error:', e);
return NextResponse.json({ error: 'Backend unreachable' }, { status: 502 });
}
}

View File

@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { fastApiV1Url } from '@/lib/server/fastapi-backend';
export const dynamic = 'force-dynamic';
/**
* Proxy browser-safe video stream from FastAPI (Range requests preserved).
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const photoId = parseInt(id, 10);
if (Number.isNaN(photoId)) {
return new NextResponse('Invalid photo ID', { status: 400 });
}
const url = fastApiV1Url(`/videos/${photoId}/web-playback/stream`);
const headers = new Headers();
const range = request.headers.get('range');
if (range) {
headers.set('Range', range);
}
try {
const backendRes = await fetch(url, { headers, cache: 'no-store' });
const outHeaders = new Headers();
const pass = [
'content-type',
'content-length',
'content-range',
'accept-ranges',
'cache-control',
];
for (const h of pass) {
const v = backendRes.headers.get(h);
if (v) {
outHeaders.set(h, v);
}
}
if (!backendRes.ok && backendRes.status !== 206) {
const text = await backendRes.text();
return new NextResponse(text || backendRes.statusText, {
status: backendRes.status,
headers: outHeaders,
});
}
return new NextResponse(backendRes.body, {
status: backendRes.status,
headers: outHeaders,
});
} catch (e) {
console.error('web-playback stream proxy error:', e);
return new NextResponse('Backend unreachable', { status: 502 });
}
}

View File

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { fastApiV1Url } from '@/lib/server/fastapi-backend';
/** Polling must never hit a cached backend response */
export const dynamic = 'force-dynamic';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const photoId = parseInt(id, 10);
if (Number.isNaN(photoId)) {
return NextResponse.json({ error: 'Invalid photo ID' }, { status: 400 });
}
try {
const res = await fetch(
fastApiV1Url(`/videos/${photoId}/web-playback/status`),
{ cache: 'no-store' }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return NextResponse.json(
{ error: (data as { detail?: string }).detail || res.statusText },
{ status: res.status }
);
}
return NextResponse.json(data, {
headers: { 'Cache-Control': 'no-store, max-age=0' },
});
} catch (e) {
console.error('web-playback status proxy error:', e);
return NextResponse.json({ error: 'Backend unreachable' }, { status: 502 });
}
}

View File

@ -17,7 +17,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { parseFaceLocation, isPointInFaceWithFit } from '@/lib/face-utils';
import { isUrl, isVideo, getImageSrc, getVideoSrc } from '@/lib/photo-utils';
import { isUrl, isVideo, getImageSrc, getVideoSrc, getWebPlaybackStreamUrl } from '@/lib/photo-utils';
import { IdentifyFaceDialog } from '@/components/IdentifyFaceDialog';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
@ -137,6 +137,9 @@ export function PhotoViewerClient({
const [currentPhoto, setCurrentPhoto] = useState<PhotoWithDetails>(normalizePhoto(initialPhoto));
const [currentIdx, setCurrentIdx] = useState(currentIndex);
const [imageLoading, setImageLoading] = useState(false);
/** Local-file videos: URL after web-playback transcode is ready */
const [webPlaybackSrc, setWebPlaybackSrc] = useState<string | null>(null);
const [webPlaybackError, setWebPlaybackError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [currentInterval, setCurrentInterval] = useState(slideInterval);
const slideTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -262,9 +265,111 @@ export function PhotoViewerClient({
});
}, [currentPhoto.id, currentPhoto.faces]);
// Local videos: queue transcode and poll until browser-safe stream is ready
useEffect(() => {
let cancelled = false;
if (!isVideo(currentPhoto) || isUrl(currentPhoto.path)) {
setWebPlaybackSrc(null);
setWebPlaybackError(null);
setImageLoading(false);
return () => {
cancelled = true;
};
}
const photoId = currentPhoto.id;
setWebPlaybackSrc(null);
setWebPlaybackError(null);
setImageLoading(true);
const pollIntervalMs = 1000;
const maxAttempts = 900;
async function prepareAndPoll() {
try {
const prep = await fetch(`/api/photos/${photoId}/web-playback/prepare`, {
method: 'POST',
});
const prepBody = await prep.json().catch(() => ({}));
if (!prep.ok) {
throw new Error(
(prepBody as { error?: string; detail?: string }).error ||
(prepBody as { detail?: string }).detail ||
prep.statusText
);
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (cancelled) {
return;
}
const st = await fetch(`/api/photos/${photoId}/web-playback/status`);
const body = await st.json().catch(() => ({}));
const statusVal = (body as { status?: string }).status;
if (statusVal === 'ready') {
if (!cancelled) {
setWebPlaybackSrc(getWebPlaybackStreamUrl(currentPhoto));
setImageLoading(false);
}
return;
}
if (statusVal === 'failed') {
throw new Error(
(body as { error?: string }).error || 'Video transcode failed'
);
}
if (statusVal === 'not_found') {
throw new Error('Video not found');
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error('Video preparation timed out');
} catch (e) {
if (!cancelled) {
setWebPlaybackError(e instanceof Error ? e.message : 'Playback failed');
setImageLoading(false);
}
}
}
prepareAndPoll();
return () => {
cancelled = true;
};
}, [currentPhoto.id, currentPhoto.path, isVideo(currentPhoto)]);
// Prefetch transcode for neighboring videos (±2) in the strip
useEffect(() => {
if (allPhotos.length === 0) {
return;
}
const idxs = [
currentIdx - 2,
currentIdx - 1,
currentIdx + 1,
currentIdx + 2,
].filter((i) => i >= 0 && i < allPhotos.length);
for (const i of idxs) {
const p = allPhotos[i];
if (!isVideo(p) || isUrl(p.path)) {
continue;
}
fetch(`/api/photos/${p.id}/web-playback/prepare`, { method: 'POST' }).catch(
() => {}
);
}
}, [currentIdx, allPhotos]);
// Auto-play videos when navigated to (only once per photo)
useEffect(() => {
if (isVideo(currentPhoto) && videoRef.current && videoAutoPlayAttemptedRef.current !== currentPhoto.id) {
if (!isVideo(currentPhoto)) {
return;
}
if (!isUrl(currentPhoto.path) && !webPlaybackSrc) {
return;
}
if (videoRef.current && videoAutoPlayAttemptedRef.current !== currentPhoto.id) {
// Mark that we've attempted auto-play for this photo
videoAutoPlayAttemptedRef.current = currentPhoto.id;
// Ensure controls are enabled
@ -282,7 +387,7 @@ export function PhotoViewerClient({
}, 100);
return () => clearTimeout(timer);
}
}, [currentPhoto]);
}, [currentPhoto, webPlaybackSrc]);
// Check report status when photo changes or session changes
useEffect(() => {
@ -1232,10 +1337,20 @@ export function PhotoViewerClient({
justifyContent: 'center',
}}
>
{webPlaybackError && (
<div className="rounded-md bg-black/70 px-4 py-3 text-center text-sm text-white">
{webPlaybackError}
</div>
)}
{!webPlaybackError && (
<video
ref={videoRef}
key={currentPhoto.id}
src={getVideoSrc(currentPhoto)}
src={
isUrl(currentPhoto.path)
? getVideoSrc(currentPhoto)
: webPlaybackSrc || undefined
}
className="object-contain w-full h-full"
controls={true}
controlsList="nodownload"
@ -1259,6 +1374,12 @@ export function PhotoViewerClient({
onPause={() => setIsVideoPlaying(false)}
onEnded={() => setIsVideoPlaying(false)}
/>
)}
{!webPlaybackError && !isUrl(currentPhoto.path) && !webPlaybackSrc && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 text-white text-sm">
Preparing video for playback
</div>
)}
</div>
) : (
<div

View File

@ -57,3 +57,11 @@ export function getVideoSrc(photo: Photo): string {
return `/api/photos/${photo.id}/image`;
}
/**
* Browser-safe transcoded MP4 URL (after prepare + status=ready).
*/
export function getWebPlaybackStreamUrl(photo: Photo): string {
return `/api/photos/${photo.id}/web-playback`;
}

View File

@ -0,0 +1,34 @@
/**
* FastAPI base URL for Next.js route handlers (server-side fetch only).
*
* Set BACKEND_BASE_URL to the **mount point** of FastAPI (no trailing slash).
* This helper appends `/api/v1` and the resource path. It matches how the
* browser sees versioned routes when the proxy maps `/punim-api/` FastAPI root:
*
* BACKEND_BASE_URL=https://jrccphotos.org/punim-api
* fastApiV1Url(`/videos/5/web-playback/stream`)
* https://jrccphotos.org/punim-api/api/v1/videos/5/web-playback/stream
*
* On the same host as the API process you may use `http://127.0.0.1:8000` instead
* (still produces `.../api/v1/...` on port 8000). If BACKEND_BASE_URL already ends
* with `/api/v1`, it is not duplicated.
*/
function normalizeBackendBase(): string {
const raw = (process.env.BACKEND_BASE_URL || 'http://127.0.0.1:8000').trim();
return raw.replace(/\/+$/, '');
}
/**
* Full URL for a resource under FastAPI's /api/v1 prefix.
*
* @param resourcePath Path after /api/v1, e.g. `/videos/5/web-playback/stream`
*/
export function fastApiV1Url(resourcePath: string): string {
const base = normalizeBackendBase();
const path = resourcePath.startsWith('/') ? resourcePath : `/${resourcePath}`;
if (/\/api\/v1$/i.test(base)) {
return `${base}${path}`;
}
return `${base}/api/v1${path}`;
}

View File

@ -1,6 +1,18 @@
import type { NextConfig } from "next";
/** Subpath deploy (e.g. /punim-viewer). Omit or empty for local dev at /. */
function normalizeNextBasePath(raw: string): string {
const t = raw.trim().replace(/\/+$/, "");
if (!t) {
return "";
}
return t.startsWith("/") ? t : `/${t}`;
}
const nextBasePath = normalizeNextBasePath(process.env.NEXT_BASE_PATH || "");
const nextConfig: NextConfig = {
...(nextBasePath ? { basePath: nextBasePath } : {}),
images: {
// Configure remote patterns for external image sources (SharePoint, CDN, etc.)
remotePatterns: [

View File

@ -66,6 +66,23 @@ if [ ${#MISSING_DEPS[@]} -gt 0 ]; then
sudo apt-get install -y ffmpeg
fi
done
elif command -v dnf &> /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