feat(video): add thumbnail retrieval functionality

- Add new translations for thumbnail operations
- Extend video types to support thumbnail operations
- Implement thumbnail retrieval hook with error handling
- Add thumbnail get action to video list items
- Update video page to handle thumbnail retrieval
- Enhance provider videos hook with thumbnail support
This commit is contained in:
icarus 2025-10-13 22:21:26 +08:00
parent 85daceb417
commit a0627f76d5
7 changed files with 65 additions and 13 deletions

View File

@ -4,7 +4,9 @@ import { getProviderById } from '@renderer/services/ProviderService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addVideoAction, setVideoAction, setVideosAction, updateVideoAction } from '@renderer/store/video'
import { Video } from '@renderer/types/video'
import { getErrorMessage } from '@renderer/utils'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useVideos } from './useVideos'
@ -17,11 +19,19 @@ export const useProviderVideos = (providerId: string) => {
const videos = useAppSelector((state) => state.video.videoMap[providerId])
const videosRef = useRef(videos)
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
videosRef.current = videos
}, [videos])
const getVideo = useCallback(
(id: string) => {
return videos?.find((v) => v.id === id)
},
[videos]
)
const addVideo = useCallback(
(video: Video) => {
if (videos && videos.every((v) => v.id !== video.id)) {
@ -70,12 +80,16 @@ export const useProviderVideos = (providerId: string) => {
const provider = getProviderById(providerId)
const fetcher = async () => {
if (!videos || !provider) return []
const openaiVideos = videos
.filter((v) => v.type === 'openai')
.filter((v) => v.status === 'queued' || v.status === 'in_progress')
const jobs = openaiVideos.map((v) => retrieveVideo({ type: 'openai', videoId: v.id, provider }))
const result = await Promise.allSettled(jobs)
return result.filter((p) => p.status === 'fulfilled').map((p) => p.value)
if (provider.type === 'openai-response') {
const openaiVideos = videos
.filter((v) => v.type === 'openai')
.filter((v) => v.status === 'queued' || v.status === 'in_progress')
const jobs = openaiVideos.map((v) => retrieveVideo({ type: 'openai', videoId: v.id, provider }))
const result = await Promise.allSettled(jobs)
return result.filter((p) => p.status === 'fulfilled').map((p) => p.value)
} else {
throw new Error(`Provider type ${provider.type} is not supported for video status polling`)
}
}
const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 })
const { retrieveThumbnail } = useVideoThumbnail()
@ -132,6 +146,7 @@ export const useProviderVideos = (providerId: string) => {
})
.catch((e) => {
logger.error('Failed to get thumbnail', e as Error)
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
})
}
break
@ -149,6 +164,7 @@ export const useProviderVideos = (providerId: string) => {
return {
videos: videos ?? [],
getVideo,
addVideo,
updateVideo,
setVideos,

View File

@ -15,7 +15,7 @@ export const useVideoThumbnail = () => {
}, [])
const retrieveThumbnail = useCallback(
async (video: Video): Promise<string | null> => {
async (video: Video): Promise<string> => {
const provider = getProviderById(video.providerId)
if (!provider) {
throw new Error(`Provider not found for id ${video.providerId}`)
@ -66,10 +66,10 @@ export const useVideoThumbnail = () => {
return base64
} catch (e) {
logger.error(`Failed to get thumbnail for video ${video.id}`, e as Error)
throw e
} finally {
pendingSet.delete(thumbnailKey)
}
return null
},
[getThumbnailKey]
)

View File

@ -4694,6 +4694,10 @@
"queued": "Queued"
},
"thumbnail": {
"error": {
"get": "Failed to get thumbnail"
},
"get": "Get thumbnail",
"placeholder": "No thumbnail"
},
"title": "Video",

View File

@ -8,9 +8,10 @@ export type VideoListProps = {
activeVideoId?: string
setActiveVideoId: (id: string | undefined) => void
onDelete: (id: string) => void
onGetThumbnail: (id: string) => void
}
export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete }: VideoListProps) => {
export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete, onGetThumbnail }: VideoListProps) => {
return (
<div className="flex w-40 flex-col gap-1 space-y-3 overflow-auto p-2">
<div
@ -26,6 +27,7 @@ export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete }:
isActive={activeVideoId === video.id}
onClick={() => setActiveVideoId(video.id)}
onDelete={() => onDelete(video.id)}
onGetThhumbnail={() => onGetThumbnail(video.id)}
/>
))}
</div>

View File

@ -2,19 +2,21 @@ import { cn, Progress, Spinner } from '@heroui/react'
import { DeleteIcon } from '@renderer/components/Icons'
import { Video } from '@renderer/types/video'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { CheckCircleIcon, CircleXIcon, ClockIcon, DownloadIcon } from 'lucide-react'
import { CheckCircleIcon, CircleXIcon, ClockIcon, DownloadIcon, ImageDownIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export const VideoListItem = ({
video,
isActive,
onClick,
onDelete
onDelete,
onGetThhumbnail
}: {
video: Video
isActive: boolean
onClick: () => void
onDelete: () => void
onGetThhumbnail: () => void
}) => {
const { t } = useTranslation()
@ -140,6 +142,12 @@ export const VideoListItem = ({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{video.thumbnail === null && (
<ContextMenuItem onSelect={onGetThhumbnail}>
<ImageDownIcon />
<span>{t('video.thumbnail.get')}</span>
</ContextMenuItem>
)}
<ContextMenuItem onSelect={onDelete}>
<DeleteIcon className="text-danger" />
<span className="text-danger">{t('common.delete')}</span>

View File

@ -36,12 +36,12 @@ export const VideoPage = () => {
},
options: {}
})
const { videos, removeVideo } = useProviderVideos(providerId)
const { videos, removeVideo, getVideo, updateVideo } = useProviderVideos(providerId)
// const activeVideo = useMemo(() => mockVideos.find((v) => v.id === activeVideoId), [activeVideoId])
const [activeVideoId, setActiveVideoId] = useState<string>()
const activeVideo = useMemo(() => videos.find((v) => v.id === activeVideoId), [activeVideoId, videos])
const { setPending } = usePending()
const { removeThumbnail } = useVideoThumbnail()
const { removeThumbnail, retrieveThumbnail } = useVideoThumbnail()
const updateParams = useCallback((update: DeepPartial<Omit<CreateVideoParams, 'type'>>) => {
setParams((prev) => deepUpdate<CreateVideoParams>(prev, update))
@ -106,6 +106,25 @@ export const VideoPage = () => {
[afterDeleteVideo, provider, setPending, t]
)
const handleGetThumbnail = useCallback(
async (id: string) => {
const video = getVideo(id)
if (video && video.thumbnail === null) {
try {
const promise = retrieveThumbnail(video)
window.toast.loading({ title: t('video.thumbnail.get'), promise })
const thumbnail = await promise
if (thumbnail) {
updateVideo({ id: video.id, thumbnail })
}
} catch (e) {
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
}
}
},
[getVideo, retrieveThumbnail, t, updateVideo]
)
return (
<div className="flex flex-1 flex-col">
<Navbar>
@ -133,6 +152,7 @@ export const VideoPage = () => {
activeVideoId={activeVideoId}
setActiveVideoId={setActiveVideoId}
onDelete={handleDeleteVideo}
onGetThumbnail={handleGetThumbnail}
/>
</div>
</div>

View File

@ -34,12 +34,14 @@ interface OpenAIVideoBase {
export interface VideoQueued extends VideoBase {
readonly status: 'queued'
thumbnail?: never
}
export interface VideoInProgress extends VideoBase {
readonly status: 'in_progress'
/** integer percent */
progress: number
thumbnail?: never
}
export interface VideoCompleted extends VideoBase {
readonly status: 'completed'