feat: 对接 generateVideo 页面后端接口
- 新增 uploadFile 工具函数用于图片上传 - 更新 useTemplateActions hook 使用 handleError 统一错误处理 - 实现 generateVideo 页面视频生成功能 - 根据 formSchema.startNodes 动态构建请求数据 - 支持图片和文本输入 - 添加 loading 状态和错误提示 - 生成成功后显示通知并返回 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4d1e901032
commit
755a374b67
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -21,23 +21,33 @@ import { LinearGradient } from 'expo-linear-gradient'
|
||||||
import { LeftArrowIcon, UploadIcon, WhitePointsIcon } from '@/components/icon'
|
import { LeftArrowIcon, UploadIcon, WhitePointsIcon } from '@/components/icon'
|
||||||
import UploadReferenceImageDrawer from '@/components/drawer/UploadReferenceImageDrawer'
|
import UploadReferenceImageDrawer from '@/components/drawer/UploadReferenceImageDrawer'
|
||||||
import { StartGeneratingNotification } from '@/components/ui'
|
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')
|
const { height: screenHeight } = Dimensions.get('window')
|
||||||
|
|
||||||
interface TemplateData {
|
interface TemplateData {
|
||||||
id: number
|
id: string
|
||||||
videoUrl: any
|
videoUrl: any
|
||||||
thumbnailUrl: any
|
thumbnailUrl: any
|
||||||
title: string
|
title: string
|
||||||
duration: string
|
duration: string
|
||||||
|
price?: number
|
||||||
|
formSchema?: {
|
||||||
|
startNodes?: Array<{ id: string; type: string }>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GenerateVideoScreen() {
|
export default function GenerateVideoScreen() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useLocalSearchParams()
|
const params = useLocalSearchParams()
|
||||||
|
const { runTemplate, loading } = useTemplateActions()
|
||||||
|
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [uploadedImage, setUploadedImage] = useState<any>(null)
|
const [uploadedImageUrl, setUploadedImageUrl] = useState('')
|
||||||
|
const [previewImageUri, setPreviewImageUri] = useState('')
|
||||||
const [templateData, setTemplateData] = useState<TemplateData | null>(null)
|
const [templateData, setTemplateData] = useState<TemplateData | null>(null)
|
||||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||||
const [showNotification, setShowNotification] = useState(false)
|
const [showNotification, setShowNotification] = useState(false)
|
||||||
|
|
@ -47,9 +57,8 @@ export default function GenerateVideoScreen() {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(params.template) as TemplateData
|
const parsed = JSON.parse(params.template) as TemplateData
|
||||||
setTemplateData(parsed)
|
setTemplateData(parsed)
|
||||||
// 使用缩略图作为上传的图片
|
|
||||||
if (parsed.thumbnailUrl) {
|
if (parsed.thumbnailUrl) {
|
||||||
setUploadedImage(parsed.thumbnailUrl)
|
setPreviewImageUri(parsed.thumbnailUrl)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse template data:', error)
|
console.error('Failed to parse template data:', error)
|
||||||
|
|
@ -57,6 +66,61 @@ export default function GenerateVideoScreen() {
|
||||||
}
|
}
|
||||||
}, [params.template])
|
}, [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<string, string> = {}
|
||||||
|
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 (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
|
|
@ -100,9 +164,9 @@ export default function GenerateVideoScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.uploadContainer}>
|
<View style={styles.uploadContainer}>
|
||||||
{uploadedImage ? (
|
{previewImageUri ? (
|
||||||
<Image
|
<Image
|
||||||
source={uploadedImage}
|
source={previewImageUri}
|
||||||
style={styles.uploadedImage}
|
style={styles.uploadedImage}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -143,25 +207,20 @@ export default function GenerateVideoScreen() {
|
||||||
{/* 底部生成按钮 */}
|
{/* 底部生成按钮 */}
|
||||||
<View style={styles.generateButtonContainer}>
|
<View style={styles.generateButtonContainer}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={handleGenerate}
|
||||||
setShowNotification(true)
|
disabled={loading}
|
||||||
// 3秒后自动隐藏通知
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowNotification(false)
|
|
||||||
}, 3000)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#9966FF', '#FF6699', '#FF9966']}
|
colors={['#9966FF', '#FF6699', '#FF9966']}
|
||||||
locations={[0.0015, 0.4985, 0.9956]}
|
locations={[0.0015, 0.4985, 0.9956]}
|
||||||
start={{ x: 1, y: 0 }}
|
start={{ x: 1, y: 0 }}
|
||||||
end={{ x: 0, y: 0 }}
|
end={{ x: 0, y: 0 }}
|
||||||
style={styles.generateButton}
|
style={[styles.generateButton, loading && styles.generateButtonDisabled]}
|
||||||
>
|
>
|
||||||
<Text style={styles.generateButtonText}>{t('generateVideo.generate')}</Text>
|
<Text style={styles.generateButtonText}>{loading ? (t('generateVideo.generating') || '生成中...') : (t('generateVideo.generate') || '生成')}</Text>
|
||||||
<View style={styles.pointsBadge}>
|
<View style={styles.pointsBadge}>
|
||||||
<WhitePointsIcon />
|
<WhitePointsIcon />
|
||||||
<Text style={styles.pointsText}>10</Text>
|
<Text style={styles.pointsText}>{templateData?.price || 10}</Text>
|
||||||
</View>
|
</View>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
@ -172,9 +231,7 @@ export default function GenerateVideoScreen() {
|
||||||
<UploadReferenceImageDrawer
|
<UploadReferenceImageDrawer
|
||||||
visible={drawerVisible}
|
visible={drawerVisible}
|
||||||
onClose={() => setDrawerVisible(false)}
|
onClose={() => setDrawerVisible(false)}
|
||||||
onSelectImage={(imageUri) => {
|
onSelectImage={handleSelectImage}
|
||||||
setUploadedImage(imageUri)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/* 通知组件 */}
|
{/* 通知组件 */}
|
||||||
{showNotification && (
|
{showNotification && (
|
||||||
|
|
@ -373,5 +430,8 @@ const styles = StyleSheet.create({
|
||||||
notification: {
|
notification: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
|
generateButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,34 @@
|
||||||
import { OWNER_ID } from "@/lib/auth"
|
|
||||||
import { ApiError } from "@/lib/types"
|
|
||||||
import { root } from '@repo/core'
|
import { root } from '@repo/core'
|
||||||
import { TemplateController, type RunTemplateInput } from "@repo/sdk"
|
import { type RunTemplateInput, TemplateController } from '@repo/sdk'
|
||||||
import { useState } from "react"
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
import { type ApiError } from '@/lib/types'
|
||||||
|
import { handleError } from './use-error'
|
||||||
|
|
||||||
export const useTemplateActions = () => {
|
export const useTemplateActions = () => {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<ApiError | null>(null)
|
const [error, setError] = useState<ApiError | null>(null)
|
||||||
|
|
||||||
const runTemplate = async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => {
|
const runTemplate = useCallback(async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
|
||||||
const template = root.get(TemplateController)
|
const template = root.get(TemplateController)
|
||||||
const result = await template.run({
|
const { data, error } = await handleError(async () => await template.run({
|
||||||
templateId: params.templateId,
|
templateId: params.templateId,
|
||||||
data: params.data || {},
|
data: params.data || {},
|
||||||
})
|
}))
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return { generationId: result.generationId }
|
|
||||||
} catch (e) {
|
if (error) {
|
||||||
setError(e as ApiError)
|
setError(error)
|
||||||
setLoading(false)
|
return { error }
|
||||||
return { error: e as ApiError }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { generationId: data?.generationId, error: null }
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|
|
||||||
|
|
@ -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<string> {
|
||||||
|
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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue