Merge pull request 'feat: web video transcoding, admin playback, and viewer fixes' (#41) from feat/web-playback-viewer-admin into dev
Reviewed-on: #41
This commit is contained in:
commit
ccfd83e0f1
@ -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
|
||||
|
||||
|
||||
@ -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
4
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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=
|
||||
|
||||
BIN
admin-frontend/public/logo.svg
Normal file
BIN
admin-frontend/public/logo.svg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@ -171,7 +171,11 @@ function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DeveloperModeProvider>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
basename={
|
||||
import.meta.env.BASE_URL.replace(/\/$/, '') || undefined
|
||||
}
|
||||
>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</DeveloperModeProvider>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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' }}
|
||||
|
||||
51
admin-frontend/src/components/WebPlaybackVideo.tsx
Normal file
51
admin-frontend/src/components/WebPlaybackVideo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
107
admin-frontend/src/hooks/useWebPlaybackVideo.ts
Normal file
107
admin-frontend/src/hooks/useWebPlaybackVideo.ts
Normal 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 }
|
||||
}
|
||||
7
admin-frontend/src/lib/fastapi-path.ts
Normal file
7
admin-frontend/src/lib/fastapi-path.ts
Normal 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}`
|
||||
}
|
||||
5
admin-frontend/src/lib/media-path.ts
Normal file
5
admin-frontend/src/lib/media-path.ts
Normal 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://')
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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')
|
||||
}}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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')
|
||||
}}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
292
backend/services/web_video_service.py
Normal file
292
backend/services/web_video_service.py
Normal 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}
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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 SQLAlchemy’s `+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
|
||||
|
||||
10
migrations/add-photos-web-playback-columns.sql
Normal file
10
migrations/add-photos-web-playback-columns.sql
Normal 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';
|
||||
22
scripts/install_backend_tf_keras.sh
Executable file
22
scripts/install_backend_tf_keras.sh
Executable 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"
|
||||
29
scripts/install_ffmpeg_el8_rpmfusion.sh
Executable file
29
scripts/install_ffmpeg_el8_rpmfusion.sh
Executable 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
41
scripts/run-psql-migration.sh
Executable 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"
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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/*' } }
|
||||
);
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
59
viewer-frontend/app/api/photos/[id]/web-playback/route.ts
Normal file
59
viewer-frontend/app/api/photos/[id]/web-playback/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
34
viewer-frontend/lib/server/fastapi-backend.ts
Normal file
34
viewer-frontend/lib/server/fastapi-backend.ts
Normal 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}`;
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user