diff --git a/src/renderer/src/hooks/video/useRetrieveThumbnail.ts b/src/renderer/src/hooks/video/useRetrieveThumbnail.ts new file mode 100644 index 0000000000..b36e5c1819 --- /dev/null +++ b/src/renderer/src/hooks/video/useRetrieveThumbnail.ts @@ -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() + +export const useRetrieveThumbnail = () => { + const retrieveThumbnail = useCallback(async (video: Video): Promise => { + 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((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 +} diff --git a/src/renderer/src/hooks/video/useVideos.ts b/src/renderer/src/hooks/video/useVideos.ts index f7df3a78dc..88cebf0e94 100644 --- a/src/renderer/src/hooks/video/useVideos.ts +++ b/src/renderer/src/hooks/video/useVideos.ts @@ -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, diff --git a/src/renderer/src/types/video.ts b/src/renderer/src/types/video.ts index 4f1b1d0f4f..7f18221cb7 100644 --- a/src/renderer/src/types/video.ts +++ b/src/renderer/src/types/video.ts @@ -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