From 58c5df92841ecc48df549c2fd56fea0018ba8e7c Mon Sep 17 00:00:00 2001 From: icarus Date: Mon, 13 Oct 2025 16:32:42 +0800 Subject: [PATCH] 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 --- src/renderer/src/pages/video/VideoPanel.tsx | 111 +++++++++++++++---- src/renderer/src/pages/video/VideoViewer.tsx | 27 +++-- 2 files changed, 107 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/pages/video/VideoPanel.tsx b/src/renderer/src/pages/video/VideoPanel.tsx index 444bb9971..8d6d7ba8f 100644 --- a/src/renderer/src/pages/video/VideoPanel.tsx +++ b/src/renderer/src/pages/video/VideoPanel.tsx @@ -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 (
-
+
{video && } {!video && } diff --git a/src/renderer/src/pages/video/VideoViewer.tsx b/src/renderer/src/pages/video/VideoViewer.tsx index 8f8624dd6..e00e32b2f 100644 --- a/src/renderer/src/pages/video/VideoViewer.tsx +++ b/src/renderer/src/pages/video/VideoViewer.tsx @@ -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(undefined) + useEffect(() => { + setLoadSuccess(undefined) + }, [video?.id]) return ( <> -
+
{video === undefined && t('video.undefined')} {video && video.status === 'queued' && } {video && video.status === 'in_progress' && } @@ -194,15 +197,17 @@ const VideoPlayer = ({ setLoadSuccess(false) } + if (isLoading) { + return + } + return ( - - - + ) }