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:
icarus 2025-10-13 16:32:42 +08:00
parent c20394f460
commit 58c5df9284
2 changed files with 107 additions and 31 deletions

View File

@ -3,13 +3,16 @@ import { loggerService } from '@logger'
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo'
import { useVideos } from '@renderer/hooks/video/useVideos'
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 { getErrorMessage } from '@renderer/utils'
import { MB } from '@shared/config/constant'
import { DeepPartial } from 'ai'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
import mime from 'mime-types'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -80,29 +83,97 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
window.toast.info('Not implemented')
}, [])
const handleDownloadVideo = async () => {
const handleDownloadVideo = useCallback(async () => {
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({
...video,
status: 'downloading',
progress: 0
})
const promise = retrieveVideoContent({ type: 'openai', videoId: video.id, provider })
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)
status: 'downloaded',
thumbnail: video.thumbnail,
fileId: uploadedFile.id,
name: uploadedFile.origin_name
})
} 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(() => {
fileInputRef.current?.click()
@ -147,7 +218,7 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
return (
<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}>
{video && <VideoViewer video={video} onDownload={handleDownloadVideo} onRegenerate={handleRegenerateVideo} />}
{!video && <VideoViewer video={video} />}

View File

@ -14,7 +14,7 @@ import FileManager from '@renderer/services/FileManager'
import { Video, VideoDownloaded, VideoFailed } from '@renderer/types/video'
import dayjs from 'dayjs'
import { CheckCircleIcon, CircleXIcon, Clock9Icon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
@ -33,9 +33,12 @@ export type VideoViewerProps =
export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProps) => {
const { t } = useTranslation()
const [loadSuccess, setLoadSuccess] = useState<boolean | undefined>(undefined)
useEffect(() => {
setLoadSuccess(undefined)
}, [video?.id])
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 && video.status === 'queued' && <QueuedVideo />}
{video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />}
@ -194,15 +197,17 @@ const VideoPlayer = ({
setLoadSuccess(false)
}
if (isLoading) {
return <Skeleton />
}
return (
<Skeleton isLoaded={!isLoading}>
<video
controls
className="h-full w-full"
onLoadedData={() => setLoadSuccess(true)}
onError={() => setLoadSuccess(false)}>
<source src={src} type="video/mp4" />
</video>
</Skeleton>
<video
controls
className="h-full w-full rounded-2xl bg-black object-contain"
onLoadedData={() => setLoadSuccess(true)}
onError={() => setLoadSuccess(false)}>
<source src={`file://${src}`} type="video/mp4" />
</video>
)
}