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
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:
parent
c316da02a4
commit
ff47c87e41
@ -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