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

View File

@ -41,18 +41,20 @@ export interface VideoInProgress extends VideoBase {
} }
export interface VideoCompleted extends VideoBase { export interface VideoCompleted extends VideoBase {
readonly status: 'completed' 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 thumbnail: string | null
} }
export interface VideoDownloading extends VideoBase { export interface VideoDownloading extends VideoBase {
readonly status: 'downloading' readonly status: 'downloading'
/** Base64 image string */
thumbnail: string | null thumbnail: string | null
/** integer percent */ /** integer percent */
progress: number progress: number
} }
export interface VideoDownloaded extends VideoBase { export interface VideoDownloaded extends VideoBase {
readonly status: 'downloaded' readonly status: 'downloaded'
/** Base64 image string */
thumbnail: string | null thumbnail: string | null
/** Managed by fileManager */ /** Managed by fileManager */
fileId: string fileId: string