From 15b7eb78c1903a01fe4dd807fb6cef4b32fc2fcc Mon Sep 17 00:00:00 2001 From: icarus Date: Mon, 13 Oct 2025 17:46:24 +0800 Subject: [PATCH] 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 --- .../src/hooks/video/useRetrieveThumbnail.ts | 70 +++++++++++++++++++ src/renderer/src/hooks/video/useVideos.ts | 40 +++++++---- src/renderer/src/types/video.ts | 4 +- 3 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/hooks/video/useRetrieveThumbnail.ts 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