445 lines
12 KiB
TypeScript
445 lines
12 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { View, Text, StyleSheet, ScrollView, Dimensions, Pressable, TextInput, StatusBar as RNStatusBar, ActivityIndicator, Alert } from 'react-native'
|
|
import { StatusBar } from 'expo-status-bar'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { Image } from 'expo-image'
|
|
import { useRouter, useLocalSearchParams } from 'expo-router'
|
|
import { LinearGradient } from 'expo-linear-gradient'
|
|
|
|
import { LeftArrowIcon, UploadIcon, PointsIcon, WhitePointsIcon } from '@/components/icon'
|
|
import UploadReferenceImageDrawer from '@/components/UploadReferenceImageDrawer'
|
|
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
|
|
import { useFileUpload } from '@/hooks/actions/use-file-upload'
|
|
import { useAigcTask } from '@/hooks/actions/use-aigc-task'
|
|
import { useUserBalance } from '@/hooks/core/use-user-balance'
|
|
import { useAuth } from '@/hooks/core/use-auth'
|
|
|
|
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
|
|
|
|
interface TemplateData {
|
|
id: number | string
|
|
videoUrl: any
|
|
thumbnailUrl: any
|
|
title: string
|
|
duration: string
|
|
creditsCost?: number
|
|
}
|
|
|
|
const CREDITS_COST = 10
|
|
|
|
export default function GenerateVideoScreen() {
|
|
const router = useRouter()
|
|
const params = useLocalSearchParams()
|
|
const { user } = useAuth()
|
|
const { balance, load: loadBalance } = useUserBalance()
|
|
|
|
const [description, setDescription] = useState('')
|
|
const [uploadedImage, setUploadedImage] = useState<any>(null)
|
|
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null)
|
|
const [templateData, setTemplateData] = useState<TemplateData | null>(null)
|
|
const [drawerVisible, setDrawerVisible] = useState(false)
|
|
const [isGenerating, setIsGenerating] = useState(false)
|
|
const [generationProgress, setGenerationProgress] = useState('')
|
|
|
|
const { uploadFile, loading: uploadLoading } = useFileUpload()
|
|
const { submitTask, startPolling, stopPolling, isPolling } = useAigcTask()
|
|
const { runTemplate, createGeneration, loading: actionLoading } = useTemplateActions()
|
|
|
|
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)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to parse template data:', error)
|
|
}
|
|
}
|
|
}, [params.template])
|
|
|
|
useEffect(() => {
|
|
if (user?.id) {
|
|
loadBalance(user.id)
|
|
}
|
|
}, [user?.id])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
stopPolling()
|
|
}
|
|
}, [])
|
|
|
|
const handleImageSelect = async (imageUri: any) => {
|
|
setUploadedImage(imageUri)
|
|
|
|
if (typeof imageUri === 'string' && (imageUri.startsWith('http://') || imageUri.startsWith('https://'))) {
|
|
setUploadedImageUrl(imageUri)
|
|
return
|
|
}
|
|
|
|
setGenerationProgress('上传图片中...')
|
|
const { url, error } = await uploadFile(imageUri, 'templates')
|
|
|
|
if (error) {
|
|
Alert.alert('上传失败', error.message || '图片上传失败,请重试')
|
|
setGenerationProgress('')
|
|
return
|
|
}
|
|
|
|
if (url) {
|
|
setUploadedImageUrl(url)
|
|
setGenerationProgress('')
|
|
}
|
|
}
|
|
|
|
const handleGenerateVideo = async () => {
|
|
if (!templateData?.id) {
|
|
Alert.alert('提示', '模板信息不完整')
|
|
return
|
|
}
|
|
|
|
if (!uploadedImageUrl) {
|
|
Alert.alert('提示', '请先上传参考图')
|
|
return
|
|
}
|
|
|
|
if (balance < CREDITS_COST) {
|
|
Alert.alert('积分不足', `生成视频需要 ${CREDITS_COST} 积分,当前积分:${balance}`)
|
|
return
|
|
}
|
|
|
|
setIsGenerating(true)
|
|
setGenerationProgress('提交生成任务...')
|
|
|
|
const { taskId, error: submitError } = await submitTask({
|
|
model_name: 'video_generation',
|
|
prompt: description || '生成视频',
|
|
img_url: uploadedImageUrl,
|
|
duration: templateData.duration || '3',
|
|
resolution: '720p',
|
|
watermark: false,
|
|
webhook_flag: false,
|
|
})
|
|
|
|
if (submitError) {
|
|
Alert.alert('生成失败', submitError.message || '提交任务失败')
|
|
setIsGenerating(false)
|
|
setGenerationProgress('')
|
|
return
|
|
}
|
|
|
|
if (!taskId) {
|
|
Alert.alert('生成失败', '任务ID获取失败')
|
|
setIsGenerating(false)
|
|
setGenerationProgress('')
|
|
return
|
|
}
|
|
|
|
setGenerationProgress('生成中...')
|
|
|
|
startPolling(
|
|
taskId,
|
|
async (result) => {
|
|
const videoUrls = result.data || []
|
|
const videoUrl = videoUrls[0] || null
|
|
|
|
const { generation, error: createError } = await createGeneration({
|
|
templateId: String(templateData.id),
|
|
type: 'VIDEO',
|
|
resultUrl: videoUrls,
|
|
originalUrl: uploadedImageUrl,
|
|
status: 'completed',
|
|
creditsCost: CREDITS_COST,
|
|
})
|
|
|
|
if (createError) {
|
|
Alert.alert('保存失败', createError.message || '生成记录保存失败')
|
|
} else {
|
|
Alert.alert('生成成功', '视频已生成完成', [
|
|
{
|
|
text: '查看',
|
|
onPress: () => {
|
|
if (generation?.id) {
|
|
router.push({
|
|
pathname: '/generationRecord' as any,
|
|
params: { id: generation.id },
|
|
})
|
|
}
|
|
},
|
|
},
|
|
{ text: '继续生成' },
|
|
])
|
|
|
|
if (user?.id) {
|
|
loadBalance(user.id)
|
|
}
|
|
}
|
|
|
|
setIsGenerating(false)
|
|
setGenerationProgress('')
|
|
},
|
|
(error) => {
|
|
Alert.alert('生成失败', error.message || '视频生成失败')
|
|
setIsGenerating(false)
|
|
setGenerationProgress('')
|
|
}
|
|
)
|
|
}
|
|
|
|
const isLoading = uploadLoading || actionLoading || isGenerating
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<StatusBar style="light" />
|
|
<RNStatusBar barStyle="light-content" />
|
|
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
|
{/* 顶部导航栏 */}
|
|
<View style={styles.header}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<LeftArrowIcon />
|
|
</Pressable>
|
|
</View>
|
|
<View style={styles.content}>
|
|
{/* 标题区域 */}
|
|
<View style={styles.titleSection}>
|
|
<Text style={styles.title}> {templateData?.title}</Text>
|
|
<Text style={styles.subtitle}>{templateData?.title}</Text>
|
|
</View>
|
|
|
|
<View style={styles.uploadContainer}>
|
|
{uploadedImage ? (
|
|
<Image source={uploadedImage} style={styles.uploadedImage} contentFit="cover" />
|
|
) : (
|
|
<View style={styles.avatarPlaceholder}>
|
|
<View style={styles.avatarCircle} />
|
|
</View>
|
|
)}
|
|
<View style={styles.thumbnailContainer}>
|
|
<Image source={templateData?.thumbnailUrl} style={styles.thumbnail} contentFit="cover" />
|
|
</View>
|
|
</View>
|
|
{/* 上传区域 */}
|
|
<Pressable
|
|
style={styles.uploadReferenceButton}
|
|
onPress={() => {
|
|
setDrawerVisible(true)
|
|
}}
|
|
>
|
|
<UploadIcon />
|
|
<Text style={styles.uploadReferenceText}>上传参考图</Text>
|
|
</Pressable>
|
|
{/* 描述输入区域 */}
|
|
<TextInput
|
|
style={styles.descriptionInput}
|
|
value={description}
|
|
onChangeText={setDescription}
|
|
placeholder="描述你想要的视频效果"
|
|
placeholderTextColor="#8A8A8A"
|
|
multiline
|
|
numberOfLines={4}
|
|
textAlignVertical="top"
|
|
/>
|
|
{/* 进度提示 */}
|
|
{generationProgress ? (
|
|
<View style={styles.progressContainer}>
|
|
<ActivityIndicator size="small" color="#9966FF" />
|
|
<Text style={styles.progressText}>{generationProgress}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{/* 底部生成按钮 */}
|
|
<View style={styles.generateButtonContainer}>
|
|
<Pressable onPress={handleGenerateVideo} disabled={isLoading}>
|
|
<LinearGradient
|
|
colors={isLoading ? ['#666666', '#666666', '#666666'] : ['#9966FF', '#FF6699', '#FF9966']}
|
|
locations={[0.0015, 0.4985, 0.9956]}
|
|
start={{ x: 1, y: 0 }}
|
|
end={{ x: 0, y: 0 }}
|
|
style={styles.generateButton}
|
|
>
|
|
{isLoading ? (
|
|
<ActivityIndicator size="small" color="#F5F5F5" />
|
|
) : (
|
|
<>
|
|
<Text style={styles.generateButtonText}>生成视频</Text>
|
|
<View style={styles.pointsBadge}>
|
|
<WhitePointsIcon />
|
|
<Text style={styles.pointsText}>{CREDITS_COST}</Text>
|
|
</View>
|
|
</>
|
|
)}
|
|
</LinearGradient>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
<UploadReferenceImageDrawer
|
|
visible={drawerVisible}
|
|
onClose={() => setDrawerVisible(false)}
|
|
onSelectImage={handleImageSelect}
|
|
/>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
scrollContent: {
|
|
flexGrow: 1,
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
generateButtonContainer: {
|
|
marginTop: 'auto',
|
|
paddingTop: 12,
|
|
},
|
|
content: {
|
|
paddingHorizontal: 12,
|
|
paddingTop: 16,
|
|
paddingBottom: 16,
|
|
backgroundColor: '#1C1E20',
|
|
borderTopLeftRadius: 20,
|
|
borderTopRightRadius: 20,
|
|
gap: 8,
|
|
minHeight: screenHeight - 59,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingTop: 17,
|
|
paddingHorizontal: 12,
|
|
paddingBottom: 20,
|
|
},
|
|
backButton: {
|
|
width: 22,
|
|
height: 22,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
titleSection: {
|
|
paddingHorizontal: 4,
|
|
marginBottom: 11,
|
|
},
|
|
title: {
|
|
color: '#F5F5F5',
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
marginBottom: 5,
|
|
marginLeft: -4,
|
|
},
|
|
subtitle: {
|
|
color: '#CCCCCC',
|
|
fontSize: 12,
|
|
},
|
|
|
|
uploadContainer: {
|
|
height: 140,
|
|
borderRadius: 12,
|
|
backgroundColor: '#262A31',
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
thumbnailContainer: {
|
|
position: 'absolute',
|
|
left: 8,
|
|
bottom: 5,
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
borderWidth: 2,
|
|
borderColor: '#FFFFFF',
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
thumbnail: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
avatarPlaceholder: {
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
backgroundColor: '#2F3134',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
avatarCircle: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: '#4A4C4F',
|
|
},
|
|
uploadedImage: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
uploadReferenceButton: {
|
|
height: 110,
|
|
backgroundColor: '#262A31',
|
|
borderRadius: 12,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 6,
|
|
},
|
|
uploadReferenceText: {
|
|
color: '#F5F5F5',
|
|
fontSize: 12,
|
|
fontWeight: '500',
|
|
},
|
|
descriptionInput: {
|
|
minHeight: 150,
|
|
backgroundColor: '#262A31',
|
|
borderRadius: 12,
|
|
padding: 12,
|
|
color: '#F5F5F5',
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
},
|
|
progressContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
paddingVertical: 8,
|
|
},
|
|
progressText: {
|
|
color: '#9966FF',
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
generateButton: {
|
|
width: '100%',
|
|
height: 48,
|
|
backgroundColor: 'red',
|
|
borderRadius: 12,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
},
|
|
generateButtonText: {
|
|
color: '#F5F5F5',
|
|
fontSize: 16,
|
|
fontWeight: '500',
|
|
},
|
|
pointsBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
pointsText: {
|
|
color: '#f5f5f5',
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
})
|