feat(video): add thumbnail retrieval hook and update video interfaces

Implement useRetrieveThumbnail hook to handle thumbnail fetching and caching
Update video interfaces to clarify thumbnail field types and add missing documentation
Refactor useVideos to use new thumbnail hook instead of direct API calls
This commit is contained in:
icarus 2025-10-13 17:46:24 +08:00
parent efd5e9dcf2
commit 15b7eb78c1
3 changed files with 98 additions and 16 deletions

View File

@ -0,0 +1,70 @@
import { loggerService } from '@logger'
import { retrieveVideoContent } from '@renderer/services/ApiService'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderById } from '@renderer/services/ProviderService'
import { Video } from '@renderer/types'
import { useCallback } from 'react'
const logger = loggerService.withContext('useRetrieveThumbnail')
const pendingSet = new Set<string>()
export const useRetrieveThumbnail = () => {
const retrieveThumbnail = useCallback(async (video: Video): Promise<string | null> => {
const provider = getProviderById(video.providerId)
if (!provider) {
throw new Error(`Provider not found for id ${video.providerId}`)
}
const thumbnailKey = `video-thumbnail-${video.id}`
if (pendingSet.has(thumbnailKey)) {
throw new Error('Thumbnail retrieval already pending')
}
pendingSet.add(thumbnailKey)
try {
const cachedThumbnail = await ImageStorage.get(thumbnailKey)
if (cachedThumbnail) {
return cachedThumbnail
}
const result = await retrieveVideoContent({
type: 'openai',
provider,
videoId: video.id,
query: { variant: 'thumbnail' }
})
const { response } = result
if (!response.ok) {
throw new Error(`Unexpected thumbnail status: ${response.status}`)
}
const blob = await response.blob()
if (!blob || blob.size === 0) {
throw new Error('Thumbnail response body is empty')
}
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert thumbnail to base64'))
}
}
reader.onerror = () => reject(reader.error ?? new Error('Failed to read thumbnail blob'))
reader.readAsDataURL(blob)
})
await ImageStorage.set(thumbnailKey, base64)
return base64
} catch (e) {
logger.error(`Failed to get thumbnail for video ${video.id}`, e as Error)
} finally {
pendingSet.delete(thumbnailKey)
}
return null
}, [])
return retrieveThumbnail
}

View File

@ -1,5 +1,5 @@
import { loggerService } from '@logger'
import { retrieveVideo, retrieveVideoContent } from '@renderer/services/ApiService'
import { retrieveVideo } from '@renderer/services/ApiService'
import { getProviderById } from '@renderer/services/ProviderService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
@ -13,6 +13,8 @@ import { Video } from '@renderer/types/video'
import { useCallback, useEffect, useRef } from 'react'
import useSWR from 'swr'
import { useRetrieveThumbnail } from './useRetrieveThumbnail'
const logger = loggerService.withContext('useVideo')
export const useVideos = (providerId: string) => {
@ -80,6 +82,7 @@ export const useVideos = (providerId: string) => {
return result.filter((p) => p.status === 'fulfilled').map((p) => p.value)
}
const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 })
const retrieveThumbnail = useRetrieveThumbnail()
useEffect(() => {
if (error) {
logger.error('Failed to fetch video status updates', error)
@ -110,26 +113,33 @@ export const useVideos = (providerId: string) => {
})
}
break
case 'completed':
// Only update it when in_progress/queued -> completed
case 'completed': {
if (storeVideo.status === 'in_progress' || storeVideo.status === 'queued') {
setVideo({ ...storeVideo, status: 'completed', thumbnail: null, metadata: retrievedVideo })
// try to request thumbnail here.
retrieveVideoContent({
type: 'openai',
provider,
videoId: retrievedVideo.id,
query: { variant: 'thumbnail' }
})
.then((v) => {
// TODO: this is a iamge/webp type response. save it somewhere.
logger.debug('thumbnail resposne', v.response)
const newVideo = { ...storeVideo, status: 'completed', thumbnail: null, metadata: retrievedVideo } as const
setVideo(newVideo)
// Try to get thumbnail
retrieveThumbnail(newVideo)
.then((thumbnail) => {
const latestVideo = videosRef.current?.find((v) => v.id === newVideo.id)
if (
thumbnail !== null &&
latestVideo &&
latestVideo.status !== 'queued' &&
latestVideo.status !== 'in_progress' &&
latestVideo.status !== 'failed'
) {
setVideo({
...latestVideo,
thumbnail
})
}
})
.catch((e) => {
logger.error(`Failed to get thumbnail for video ${retrievedVideo.id}`, e as Error)
logger.error('Failed to get thumbnail', e as Error)
})
}
break
}
case 'failed':
setVideo({
...storeVideo,

View File

@ -41,18 +41,20 @@ export interface VideoInProgress extends VideoBase {
}
export interface VideoCompleted extends VideoBase {
readonly status: 'completed'
/** When generation completed, firstly try to retrieve thumbnail. */
/** Base64 image string. When generation completed, firstly try to retrieve thumbnail. */
thumbnail: string | null
}
export interface VideoDownloading extends VideoBase {
readonly status: 'downloading'
/** Base64 image string */
thumbnail: string | null
/** integer percent */
progress: number
}
export interface VideoDownloaded extends VideoBase {
readonly status: 'downloaded'
/** Base64 image string */
thumbnail: string | null
/** Managed by fileManager */
fileId: string