From 755a374b67eba8be2b842203689ca118d3e42ab2 Mon Sep 17 00:00:00 2001 From: imeepos Date: Fri, 16 Jan 2026 12:33:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E6=8E=A5=20generateVideo=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 uploadFile 工具函数用于图片上传 - 更新 useTemplateActions hook 使用 handleError 统一错误处理 - 实现 generateVideo 页面视频生成功能 - 根据 formSchema.startNodes 动态构建请求数据 - 支持图片和文本输入 - 添加 loading 状态和错误提示 - 生成成功后显示通知并返回 Co-Authored-By: Claude --- app/generateVideo.tsx | 102 +++++++++++++++++++++++++++------- hooks/use-template-actions.ts | 38 +++++++------ lib/uploadFile.ts | 25 +++++++++ 3 files changed, 126 insertions(+), 39 deletions(-) create mode 100644 lib/uploadFile.ts diff --git a/app/generateVideo.tsx b/app/generateVideo.tsx index 485e500..a8204a8 100644 --- a/app/generateVideo.tsx +++ b/app/generateVideo.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { View, Text, @@ -21,35 +21,44 @@ import { LinearGradient } from 'expo-linear-gradient' import { LeftArrowIcon, UploadIcon, WhitePointsIcon } from '@/components/icon' import UploadReferenceImageDrawer from '@/components/drawer/UploadReferenceImageDrawer' import { StartGeneratingNotification } from '@/components/ui' +import { useTemplateActions } from '@/hooks/use-template-actions' +import { uploadFile } from '@/lib/uploadFile' +import Toast from '@/components/ui/Toast' const { height: screenHeight } = Dimensions.get('window') interface TemplateData { - id: number + id: string videoUrl: any thumbnailUrl: any title: string duration: string + price?: number + formSchema?: { + startNodes?: Array<{ id: string; type: string }> + } } export default function GenerateVideoScreen() { const { t } = useTranslation() const router = useRouter() const params = useLocalSearchParams() + const { runTemplate, loading } = useTemplateActions() + const [description, setDescription] = useState('') - const [uploadedImage, setUploadedImage] = useState(null) + const [uploadedImageUrl, setUploadedImageUrl] = useState('') + const [previewImageUri, setPreviewImageUri] = useState('') const [templateData, setTemplateData] = useState(null) const [drawerVisible, setDrawerVisible] = useState(false) const [showNotification, setShowNotification] = useState(false) - + useEffect(() => { if (params.template && typeof params.template === 'string') { try { const parsed = JSON.parse(params.template) as TemplateData setTemplateData(parsed) - // 使用缩略图作为上传的图片 if (parsed.thumbnailUrl) { - setUploadedImage(parsed.thumbnailUrl) + setPreviewImageUri(parsed.thumbnailUrl) } } catch (error) { console.error('Failed to parse template data:', error) @@ -57,6 +66,61 @@ export default function GenerateVideoScreen() { } }, [params.template]) + const startNodes = useMemo(() => templateData?.formSchema?.startNodes || [], [templateData]) + const hasImageNode = useMemo(() => startNodes.some((node) => node.type === 'image'), [startNodes]) + + const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => { + try { + setPreviewImageUri(imageUri) + const url = await uploadFile({ uri: imageUri, mimeType, fileName }) + setUploadedImageUrl(url) + } catch (error) { + console.error('Upload failed:', error) + } finally { + setDrawerVisible(false) + } + }, []) + + const handleGenerate = useCallback(async () => { + if (!templateData) return + + if (hasImageNode && !uploadedImageUrl) { + Toast.show({ title: t('generateVideo.pleaseUploadImage') || '请上传参考图片' }) + return + } + + Toast.showLoading() + try { + const data: Record = {} + startNodes.forEach((node) => { + data[node.id] = node.type === 'text' ? description : uploadedImageUrl + }) + + const { generationId, error } = await runTemplate({ + templateId: templateData.id, + data, + }) + + Toast.hideLoading() + + if (error) { + Toast.show({ title: error.message || t('generateVideo.generateFailed') || '生成失败' }) + return + } + + if (generationId) { + setShowNotification(true) + setTimeout(() => { + setShowNotification(false) + router.back() + }, 3000) + } + } catch (error) { + Toast.hideLoading() + Toast.show({ title: t('generateVideo.generateFailed') || '生成失败' }) + } + }, [templateData, uploadedImageUrl, description, runTemplate, router, t, hasImageNode, startNodes]) + return ( - {uploadedImage ? ( + {previewImageUri ? ( @@ -143,25 +207,20 @@ export default function GenerateVideoScreen() { {/* 底部生成按钮 */} { - setShowNotification(true) - // 3秒后自动隐藏通知 - setTimeout(() => { - setShowNotification(false) - }, 3000) - }} + onPress={handleGenerate} + disabled={loading} > - {t('generateVideo.generate')} + {loading ? (t('generateVideo.generating') || '生成中...') : (t('generateVideo.generate') || '生成')} - 10 + {templateData?.price || 10} @@ -172,9 +231,7 @@ export default function GenerateVideoScreen() { setDrawerVisible(false)} - onSelectImage={(imageUri) => { - setUploadedImage(imageUri) - }} + onSelectImage={handleSelectImage} /> {/* 通知组件 */} {showNotification && ( @@ -373,5 +430,8 @@ const styles = StyleSheet.create({ notification: { width: '100%', }, + generateButtonDisabled: { + opacity: 0.6, + }, }) diff --git a/hooks/use-template-actions.ts b/hooks/use-template-actions.ts index ef939e5..77fc4b3 100644 --- a/hooks/use-template-actions.ts +++ b/hooks/use-template-actions.ts @@ -1,31 +1,33 @@ -import { OWNER_ID } from "@/lib/auth" -import { ApiError } from "@/lib/types" import { root } from '@repo/core' -import { TemplateController, type RunTemplateInput } from "@repo/sdk" -import { useState } from "react" +import { type RunTemplateInput, TemplateController } from '@repo/sdk' +import { useCallback, useState } from 'react' + +import { type ApiError } from '@/lib/types' +import { handleError } from './use-error' export const useTemplateActions = () => { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const runTemplate = async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => { + const runTemplate = useCallback(async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => { setLoading(true) setError(null) - try { - const template = root.get(TemplateController) - const result = await template.run({ - templateId: params.templateId, - data: params.data || {}, - }) - setLoading(false) - return { generationId: result.generationId } - } catch (e) { - setError(e as ApiError) - setLoading(false) - return { error: e as ApiError } + const template = root.get(TemplateController) + const { data, error } = await handleError(async () => await template.run({ + templateId: params.templateId, + data: params.data || {}, + })) + + setLoading(false) + + if (error) { + setError(error) + return { error } } - } + + return { generationId: data?.generationId, error: null } + }, []) return { loading, diff --git a/lib/uploadFile.ts b/lib/uploadFile.ts new file mode 100644 index 0000000..c319943 --- /dev/null +++ b/lib/uploadFile.ts @@ -0,0 +1,25 @@ +import { root } from '@repo/core' +import { FileController } from '@repo/sdk' +import { Platform } from 'react-native' + +import { handleError } from '@/hooks/use-error' + +export async function uploadFile(params: { uri: string; mimeType?: string; fileName?: string }): Promise { + const { uri, mimeType, fileName } = params + const file = { + name: fileName || 'uploaded_file.jpg', + type: mimeType || 'image/jpeg', + uri: Platform.OS === 'android' ? uri : uri.replace('file://', ''), + } + const formData = new FormData() + formData.append('file', file as any) + + const fileController = root.get(FileController) + const { data, error } = await handleError(async () => await fileController.uploadS3(formData)) + + if (error || !data?.data) { + throw error || new Error('上传失败') + } + + return data.data +}