feat(video): add image reference upload with validation

implement image reference upload functionality in video panel
add validation for file format and size (max 5MB)
replace lodash merge with custom deepUpdate utility
add error messages for invalid uploads
This commit is contained in:
icarus 2025-10-12 07:00:23 +08:00
parent 788b170f98
commit 12323375a5
4 changed files with 120 additions and 20 deletions

View File

@ -4659,6 +4659,10 @@
},
"input_reference": {
"add": {
"error": {
"format": "Not a image",
"size": "This image is too large. It should be under 5MB."
},
"tooltip": "Add image reference"
}
},

View File

@ -5,9 +5,9 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { useProvider } from '@renderer/hooks/useProvider'
import { SystemProviderIds } from '@renderer/types'
import { CreateVideoParams } from '@renderer/types/video'
import { deepUpdate } from '@renderer/utils/deepUpdate'
import { isVideoModel } from '@renderer/utils/model/video'
import { DeepPartial } from 'ai'
import { merge } from 'lodash'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -33,7 +33,7 @@ export const VideoPage = () => {
})
const updateParams = useCallback((update: DeepPartial<Omit<CreateVideoParams, 'type'>>) => {
setParams((prev) => merge({}, prev, update))
setParams((prev) => deepUpdate<CreateVideoParams>(prev, update))
}, [])
const updateModelId = useCallback(

View File

@ -1,13 +1,15 @@
import { Button, Skeleton, Textarea, Tooltip } from '@heroui/react'
import { Button, cn, Image, Skeleton, Textarea, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo'
import { createVideo } from '@renderer/services/ApiService'
import { Provider } 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 { ArrowUp, ImageIcon } from 'lucide-react'
import { useCallback, useState } from 'react'
import { isEmpty } from 'lodash'
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { VideoViewer } from './VideoViewer'
@ -23,11 +25,18 @@ const logger = loggerService.withContext('VideoPanel')
export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanelProps) => {
const { t } = useTranslation()
const [isProcessing, setIsProcessing] = useState(false)
const addOpenAIVideo = useAddOpenAIVideo(provider.id)
const [isProcessing, setIsProcessing] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const inputReference = params.params.input_reference
const couldCreateVideo = useMemo(
() => !isProcessing && !isEmpty(params.params.prompt),
[isProcessing, params.params.prompt]
)
const handleCreateVideo = useCallback(async () => {
if (isProcessing) return
if (!couldCreateVideo) return
setIsProcessing(true)
try {
const result = await createVideo(params)
@ -44,10 +53,49 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
} finally {
setIsProcessing(false)
}
}, [addOpenAIVideo, isProcessing, params, t])
}, [addOpenAIVideo, couldCreateVideo, params, t])
const handleUploadFile = useCallback(() => {
fileInputRef.current?.click()
}, [])
const setPrompt = useCallback((value: string) => updateParams({ params: { prompt: value } }), [updateParams])
const UploadImageReferenceButton = useCallback(() => {
const content = inputReference ? (
<div className="group">
<Image
className="aspect-square max-h-50 max-w-50 object-contain"
src={URL.createObjectURL(inputReference as File)}
/>
<Button
variant="light"
color="danger"
className="absolute top-1 right-1 z-100 h-6 w-6 min-w-0 opacity-0 group-hover:opacity-100"
isIconOnly
startContent={<CircleXIcon size={16} className="text-danger" />}
onPress={() => updateParams({ params: { input_reference: undefined } })}
/>
</div>
) : (
t('video.input_reference.add.tooltip')
)
return (
<>
<Tooltip content={content} closeDelay={0}>
<Button
variant="light"
startContent={<ImageIcon size={16} className={cn(inputReference ? 'text-primary' : undefined)} />}
isIconOnly
className="h-6 w-6 min-w-0"
isDisabled={isProcessing}
onPress={handleUploadFile}
/>
</Tooltip>
</>
)
}, [handleUploadFile, isProcessing, inputReference, t, updateParams])
return (
<div className="flex flex-1 flex-col p-2">
<div className="m-8 flex-1">
@ -73,18 +121,30 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
/>
<div className="absolute bottom-0 flex w-full items-end justify-between p-2">
<div className="flex">
<Tooltip content={t('video.input_reference.add.tooltip')} closeDelay={0}>
<Button
variant="light"
startContent={<ImageIcon size={16} />}
isIconOnly
className="h-6 w-6 min-w-0"
isDisabled={isProcessing}
onPress={() => {
window.toast.info('Not implemented')
}}
/>
</Tooltip>
<UploadImageReferenceButton />
<input
ref={fileInputRef}
type="file"
hidden
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
if (!file.type.startsWith('image/')) {
window.toast.error(t('video.input_reference.add.error.format'))
return
}
const maxSize = 5 * MB
if (file.size > maxSize) {
window.toast.error(t('video.input_reference.add.error.size'))
return
}
updateParams({ params: { input_reference: file } })
} else {
updateParams({ params: { input_reference: undefined } })
}
}}
/>
</div>
<Tooltip content={t('common.send')} closeDelay={0}>
@ -92,6 +152,7 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
color="primary"
radius="full"
isIconOnly
isDisabled={!couldCreateVideo}
isLoading={isProcessing}
className="h-6 w-6 min-w-0"
onPress={handleCreateVideo}>

View File

@ -0,0 +1,35 @@
import { DeepPartial } from 'ai'
import { cloneDeep } from 'lodash'
/**
* Deeply updates an object, allowing undefined to overwrite existing properties, without using `any`
* @param target Original object
* @param update Update object (may contain undefined)
* @returns New object
*/
export function deepUpdate<T extends object>(target: T, update: DeepPartial<T>): T {
const result = cloneDeep(target)
for (const key in update) {
if (Object.hasOwn(update, key)) {
// @ts-ignore it's runtime safe
const prev = result[key]
const next = update[key]
if (
next &&
typeof next === 'object' &&
!Array.isArray(next) &&
prev &&
typeof prev === 'object' &&
!Array.isArray(prev)
) {
// @ts-ignore it's runtime safe
result[key] = deepUpdate(prev, next as any)
} else {
// @ts-ignore it's runtime safe
result[key] = next
}
}
}
return result
}