mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 13:31:32 +08:00
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:
parent
85daceb417
commit
a0627f76d5
@ -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,
|
||||
|
||||
@ -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]
|
||||
)
|
||||
|
||||
@ -4694,6 +4694,10 @@
|
||||
"queued": "Queued"
|
||||
},
|
||||
"thumbnail": {
|
||||
"error": {
|
||||
"get": "Failed to get thumbnail"
|
||||
},
|
||||
"get": "Get thumbnail",
|
||||
"placeholder": "No thumbnail"
|
||||
},
|
||||
"title": "Video",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user