mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-08 06:19:05 +08:00
feat(video): implement video download functionality and improve viewer
- Add video download logic with progress tracking in VideoPanel - Reset load state when video changes in VideoViewer - Improve video player styling and loading state handling - Add file upload and metadata handling for downloaded videos
This commit is contained in:
parent
c20394f460
commit
58c5df9284
@ -3,13 +3,16 @@ import { loggerService } from '@logger'
|
|||||||
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo'
|
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo'
|
||||||
import { useVideos } from '@renderer/hooks/video/useVideos'
|
import { useVideos } from '@renderer/hooks/video/useVideos'
|
||||||
import { createVideo, retrieveVideoContent } from '@renderer/services/ApiService'
|
import { createVideo, retrieveVideoContent } from '@renderer/services/ApiService'
|
||||||
import { Provider } from '@renderer/types'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
|
import { FileTypes, Provider, VideoFileMetadata } from '@renderer/types'
|
||||||
import { CreateVideoParams, Video } from '@renderer/types/video'
|
import { CreateVideoParams, Video } from '@renderer/types/video'
|
||||||
import { getErrorMessage } from '@renderer/utils'
|
import { getErrorMessage } from '@renderer/utils'
|
||||||
import { MB } from '@shared/config/constant'
|
import { MB } from '@shared/config/constant'
|
||||||
import { DeepPartial } from 'ai'
|
import { DeepPartial } from 'ai'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
|
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
|
||||||
|
import mime from 'mime-types'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@ -80,29 +83,97 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
|||||||
window.toast.info('Not implemented')
|
window.toast.info('Not implemented')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDownloadVideo = async () => {
|
const handleDownloadVideo = useCallback(async () => {
|
||||||
if (!video) return
|
if (!video) return
|
||||||
if (video.status === 'completed' || video.status === 'downloaded') {
|
if (video.status !== 'completed' && video.status !== 'downloaded') return
|
||||||
|
|
||||||
|
const baseVideo: Video = {
|
||||||
|
...video,
|
||||||
|
status: 'downloading',
|
||||||
|
progress: 0,
|
||||||
|
thumbnail: video.thumbnail
|
||||||
|
}
|
||||||
|
setVideo(baseVideo)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { response } = await retrieveVideoContent({ type: 'openai', videoId: video.id, provider })
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Video response body is empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const contentLengthHeader = response.headers.get('content-length')
|
||||||
|
const totalSize = contentLengthHeader ? Number(contentLengthHeader) : undefined
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let receivedLength = 0
|
||||||
|
let progressValue = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
if (!value) continue
|
||||||
|
|
||||||
|
chunks.push(value)
|
||||||
|
receivedLength += value.length
|
||||||
|
|
||||||
|
if (totalSize && Number.isFinite(totalSize) && totalSize > 0) {
|
||||||
|
progressValue = Math.floor((receivedLength / totalSize) * 100)
|
||||||
|
} else {
|
||||||
|
progressValue = Math.min(progressValue + 1, 99)
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideo({
|
||||||
|
...baseVideo,
|
||||||
|
progress: Math.min(progressValue, 99)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileData = new Uint8Array(receivedLength)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
fileData.set(chunk, offset)
|
||||||
|
offset += chunk.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? 'video/mp4'
|
||||||
|
const normalizedContentType = contentType.split(';')[0]?.trim() || 'video/mp4'
|
||||||
|
const extension = (() => {
|
||||||
|
const ext = mime.extension(normalizedContentType)
|
||||||
|
return ext ? `.${ext}` : '.mp4'
|
||||||
|
})()
|
||||||
|
|
||||||
|
const fileName = `${video.id}${extension}`.toLowerCase()
|
||||||
|
|
||||||
|
const tempFilePath = await window.api.file.createTempFile(fileName)
|
||||||
|
await window.api.file.write(tempFilePath, fileData)
|
||||||
|
|
||||||
|
const tempFileMetadata = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: fileName,
|
||||||
|
origin_name: fileName,
|
||||||
|
path: tempFilePath,
|
||||||
|
size: receivedLength,
|
||||||
|
ext: extension,
|
||||||
|
type: FileTypes.VIDEO,
|
||||||
|
created_at: dayjs().toISOString(),
|
||||||
|
count: 1
|
||||||
|
} satisfies VideoFileMetadata
|
||||||
|
|
||||||
|
const uploadedFile = await FileManager.uploadFile(tempFileMetadata)
|
||||||
|
|
||||||
setVideo({
|
setVideo({
|
||||||
...video,
|
...video,
|
||||||
status: 'downloading',
|
status: 'downloaded',
|
||||||
progress: 0
|
thumbnail: video.thumbnail,
|
||||||
})
|
fileId: uploadedFile.id,
|
||||||
const promise = retrieveVideoContent({ type: 'openai', videoId: video.id, provider })
|
name: uploadedFile.origin_name
|
||||||
promise
|
|
||||||
.then((result) => result.response)
|
|
||||||
.then((response) => {
|
|
||||||
// TODO: implement download
|
|
||||||
logger.debug('download response', response)
|
|
||||||
})
|
|
||||||
promise.catch((e) => {
|
|
||||||
logger.error(`Failed to download video ${video.id}.`, e as Error)
|
|
||||||
window.toast.error(t('video.error.download'))
|
|
||||||
// rollback
|
|
||||||
setVideo(video)
|
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to download video ${video.id}.`, error as Error)
|
||||||
|
window.toast.error(t('video.error.download'))
|
||||||
|
setVideo(video)
|
||||||
}
|
}
|
||||||
}
|
}, [provider, setVideo, t, video])
|
||||||
|
|
||||||
const handleUploadFile = useCallback(() => {
|
const handleUploadFile = useCallback(() => {
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click()
|
||||||
@ -147,7 +218,7 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col p-2">
|
<div className="flex flex-1 flex-col p-2">
|
||||||
<div className="m-8 flex-1">
|
<div className="m-8 flex-1 overflow-hidden">
|
||||||
<Skeleton className="h-full w-full rounded-2xl" classNames={{ content: 'h-full w-full' }} isLoaded={true}>
|
<Skeleton className="h-full w-full rounded-2xl" classNames={{ content: 'h-full w-full' }} isLoaded={true}>
|
||||||
{video && <VideoViewer video={video} onDownload={handleDownloadVideo} onRegenerate={handleRegenerateVideo} />}
|
{video && <VideoViewer video={video} onDownload={handleDownloadVideo} onRegenerate={handleRegenerateVideo} />}
|
||||||
{!video && <VideoViewer video={video} />}
|
{!video && <VideoViewer video={video} />}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import FileManager from '@renderer/services/FileManager'
|
|||||||
import { Video, VideoDownloaded, VideoFailed } from '@renderer/types/video'
|
import { Video, VideoDownloaded, VideoFailed } from '@renderer/types/video'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { CheckCircleIcon, CircleXIcon, Clock9Icon } from 'lucide-react'
|
import { CheckCircleIcon, CircleXIcon, Clock9Icon } from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useSWRImmutable from 'swr/immutable'
|
import useSWRImmutable from 'swr/immutable'
|
||||||
|
|
||||||
@ -33,9 +33,12 @@ export type VideoViewerProps =
|
|||||||
export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProps) => {
|
export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [loadSuccess, setLoadSuccess] = useState<boolean | undefined>(undefined)
|
const [loadSuccess, setLoadSuccess] = useState<boolean | undefined>(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadSuccess(undefined)
|
||||||
|
}, [video?.id])
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full w-full items-center justify-center rounded-2xl bg-foreground-200">
|
<div className="flex h-full max-h-full w-full items-center justify-center rounded-2xl bg-foreground-200">
|
||||||
{video === undefined && t('video.undefined')}
|
{video === undefined && t('video.undefined')}
|
||||||
{video && video.status === 'queued' && <QueuedVideo />}
|
{video && video.status === 'queued' && <QueuedVideo />}
|
||||||
{video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />}
|
{video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />}
|
||||||
@ -194,15 +197,17 @@ const VideoPlayer = ({
|
|||||||
setLoadSuccess(false)
|
setLoadSuccess(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Skeleton isLoaded={!isLoading}>
|
<video
|
||||||
<video
|
controls
|
||||||
controls
|
className="h-full w-full rounded-2xl bg-black object-contain"
|
||||||
className="h-full w-full"
|
onLoadedData={() => setLoadSuccess(true)}
|
||||||
onLoadedData={() => setLoadSuccess(true)}
|
onError={() => setLoadSuccess(false)}>
|
||||||
onError={() => setLoadSuccess(false)}>
|
<source src={`file://${src}`} type="video/mp4" />
|
||||||
<source src={src} type="video/mp4" />
|
</video>
|
||||||
</video>
|
|
||||||
</Skeleton>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user