598 lines
17 KiB
TypeScript
598 lines
17 KiB
TypeScript
import Ionicons from '@expo/vector-icons/Ionicons';
|
||
import { ResultDisplay } from '@/components/template-run/result-display';
|
||
import { ThemedText } from '@/components/themed-text';
|
||
import { ThemedView } from '@/components/themed-view';
|
||
import { useTemplateRun } from '@/hooks/use-template-run';
|
||
import { getTemplateById } from '@/lib/api/templates';
|
||
import { subscription } from '@/lib/auth/client';
|
||
import { Template } from '@/lib/types/template';
|
||
import { RunTemplateData } from '@/lib/types/template-run';
|
||
import { Image } from 'expo-image';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { Stack, useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router';
|
||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { Alert, BackHandler, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
type RunStep = 'progress' | 'result';
|
||
|
||
function transformFormData(formData: RunTemplateData): RunTemplateData {
|
||
const transformed: RunTemplateData = {};
|
||
|
||
Object.entries(formData).forEach(([fieldName, value]) => {
|
||
if (fieldName.startsWith('image_')) {
|
||
transformed[fieldName] = {
|
||
url: value,
|
||
type: 'image',
|
||
};
|
||
} else if (fieldName.startsWith('text_')) {
|
||
transformed[fieldName] = {
|
||
content: value,
|
||
type: 'text',
|
||
};
|
||
} else if (fieldName.startsWith('video_')) {
|
||
transformed[fieldName] = {
|
||
url: value,
|
||
type: 'video',
|
||
};
|
||
} else if (fieldName.startsWith('color_')) {
|
||
transformed[fieldName] = {
|
||
value,
|
||
type: 'color',
|
||
};
|
||
} else {
|
||
transformed[fieldName] = value;
|
||
}
|
||
});
|
||
|
||
return transformed;
|
||
}
|
||
|
||
export default function TemplateRunScreen() {
|
||
const { id } = useLocalSearchParams<{ id: string }>();
|
||
const router = useRouter();
|
||
const globalParams = useGlobalSearchParams<{ formData?: string }>();
|
||
const insets = useSafeAreaInsets();
|
||
|
||
const [template, setTemplate] = useState<Template | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [currentStep, setCurrentStep] = useState<RunStep>('progress');
|
||
const [hasStarted, setHasStarted] = useState(false);
|
||
const [formImageUrls, setFormImageUrls] = useState<string[]>([]);
|
||
|
||
const {
|
||
progress,
|
||
result,
|
||
error,
|
||
isLoading,
|
||
executeTemplate,
|
||
cancelRun,
|
||
cleanup,
|
||
} = useTemplateRun({
|
||
onSuccess: () => {
|
||
setCurrentStep('result');
|
||
},
|
||
onError: (runError) => {
|
||
Alert.alert('运行失败', runError.message);
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
const loadTemplate = async () => {
|
||
if (!id) return;
|
||
|
||
try {
|
||
setLoading(true);
|
||
const response = await getTemplateById(id);
|
||
if (response && response.success && response.data) {
|
||
setTemplate(response.data as any);
|
||
} else {
|
||
throw new Error('模板不存在');
|
||
}
|
||
} catch (templateError) {
|
||
console.error('加载模板失败:', templateError);
|
||
Alert.alert('错误', '无法加载模板,请稍后重试。');
|
||
router.back();
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
loadTemplate();
|
||
}, [id, router]);
|
||
|
||
useEffect(() => {
|
||
if (template && !hasStarted && globalParams.formData) {
|
||
try {
|
||
const formData = JSON.parse(globalParams.formData) as RunTemplateData;
|
||
const images = Object.entries(formData)
|
||
.filter(([fieldName, value]) => fieldName.startsWith('image_') && typeof value === 'string' && value.length > 0)
|
||
.map(([, value]) => value as string);
|
||
setFormImageUrls(images);
|
||
setHasStarted(true);
|
||
|
||
const executeWithData = async () => {
|
||
try {
|
||
const list = await subscription.list();
|
||
const listData = list.data ?? [];
|
||
if (listData.length === 0) {
|
||
Alert.alert('未检测到订阅', '请先完成订阅后再尝试生成内容。');
|
||
router.back();
|
||
return;
|
||
}
|
||
|
||
const subscriptionId = listData[0]?.stripeSubscriptionId;
|
||
if (!subscriptionId) {
|
||
Alert.alert('订阅信息缺失', '当前订阅缺少 Stripe 订阅标识,无法记录用量。');
|
||
router.back();
|
||
return;
|
||
}
|
||
|
||
const identify = await subscription.meterEvent({
|
||
event_name: 'token_usage',
|
||
payload: {
|
||
value: '100',
|
||
},
|
||
});
|
||
|
||
const identifier = identify.data?.identifier;
|
||
if (!identifier) {
|
||
Alert.alert('用量记录失败', '无法生成用量凭证,请稍后重试。');
|
||
router.back();
|
||
return;
|
||
}
|
||
|
||
const transformedData = transformFormData(formData);
|
||
await executeTemplate(template.id, transformedData);
|
||
} catch (executeError) {
|
||
console.error('执行模板失败:', executeError);
|
||
Alert.alert('执行失败', '运行模板时出现异常,请稍后重试。');
|
||
router.back();
|
||
}
|
||
};
|
||
|
||
executeWithData();
|
||
} catch (parseError) {
|
||
console.error('解析表单数据失败:', parseError);
|
||
Alert.alert('数据错误', '表单数据格式错误,请重新提交。');
|
||
router.back();
|
||
}
|
||
}
|
||
}, [template, hasStarted, globalParams.formData, executeTemplate, router]);
|
||
|
||
const handleRequestExit = useCallback(() => {
|
||
if (currentStep === 'progress' && isLoading) {
|
||
Alert.alert(
|
||
'确认退出',
|
||
'任务正在运行中,确定要退出吗?',
|
||
[
|
||
{ text: '继续等待', style: 'cancel' },
|
||
{
|
||
text: '停止并返回',
|
||
style: 'destructive',
|
||
onPress: () => {
|
||
cancelRun();
|
||
router.back();
|
||
},
|
||
},
|
||
]
|
||
);
|
||
return true;
|
||
}
|
||
|
||
router.back();
|
||
return true;
|
||
}, [cancelRun, currentStep, isLoading, router]);
|
||
|
||
useEffect(() => {
|
||
const subscription = BackHandler.addEventListener('hardwareBackPress', handleRequestExit);
|
||
return () => subscription.remove();
|
||
}, [handleRequestExit]);
|
||
|
||
useEffect(() => cleanup, [cleanup]);
|
||
|
||
const handleRerun = useCallback(() => {
|
||
router.back();
|
||
}, [router]);
|
||
|
||
const progressMeta = useMemo(() => {
|
||
const rawProgress = typeof progress.progress === 'number' ? progress.progress : 0;
|
||
const normalized = Math.max(0, Math.min(rawProgress, 100));
|
||
return {
|
||
normalized,
|
||
percentLabel: Math.round(normalized),
|
||
fillWidth: normalized <= 0 ? 0 : Math.min(Math.max(normalized, 8), 100),
|
||
};
|
||
}, [progress.progress]);
|
||
|
||
const narrative = useMemo(() => {
|
||
const fallbackTitle = 'Insert Your Product Into An ASMR Video';
|
||
const fallbackDescription =
|
||
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.';
|
||
|
||
const rawTitle = template?.titleEn ?? template?.title ?? fallbackTitle;
|
||
const rawDescription = template?.descriptionEn ?? template?.description ?? fallbackDescription;
|
||
|
||
return {
|
||
title: rawTitle.toUpperCase(),
|
||
description: rawDescription,
|
||
};
|
||
}, [template]);
|
||
|
||
const imagery = useMemo(() => {
|
||
const fallback = template?.previewUrl ?? template?.coverImageUrl ?? undefined;
|
||
const primary = formImageUrls[0] ?? fallback;
|
||
const secondary = formImageUrls[1] ?? template?.previewUrl ?? primary;
|
||
|
||
return {
|
||
hero: primary,
|
||
overlay: secondary,
|
||
preview: template?.previewUrl ?? primary ?? fallback,
|
||
};
|
||
}, [formImageUrls, template]);
|
||
|
||
const renderProgressStep = () => (
|
||
<ScrollView
|
||
style={styles.scroll}
|
||
contentContainerStyle={[
|
||
styles.progressContent,
|
||
{
|
||
paddingTop: insets.top + 16,
|
||
paddingBottom: insets.bottom + 40,
|
||
},
|
||
]}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<TouchableOpacity
|
||
onPress={handleRequestExit}
|
||
activeOpacity={0.8}
|
||
style={[styles.backButton, styles.backButtonProgress]}
|
||
>
|
||
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
|
||
</TouchableOpacity>
|
||
|
||
<View style={styles.titleSection}>
|
||
<ThemedText style={styles.title} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||
{narrative.title}
|
||
</ThemedText>
|
||
<ThemedText
|
||
style={styles.subtitle}
|
||
lightColor="rgba(255,255,255,0.68)"
|
||
darkColor="rgba(255,255,255,0.68)"
|
||
>
|
||
{narrative.description}
|
||
</ThemedText>
|
||
</View>
|
||
|
||
<View style={styles.heroContainer}>
|
||
{imagery.hero ? (
|
||
<Image source={{ uri: imagery.hero }} style={styles.heroImage} contentFit="cover" />
|
||
) : (
|
||
<View style={styles.heroPlaceholder} />
|
||
)}
|
||
|
||
{imagery.overlay && (
|
||
<View style={styles.overlayImageFrame}>
|
||
<Image source={{ uri: imagery.overlay }} style={styles.overlayImage} contentFit="cover" />
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
<LinearGradient colors={['#1E1E21', '#101015']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.progressCard}>
|
||
{imagery.preview ? (
|
||
<View style={styles.previewFrame}>
|
||
<Image source={{ uri: imagery.preview }} style={styles.previewImage} contentFit="cover" />
|
||
</View>
|
||
) : (
|
||
<View style={[styles.previewFrame, styles.previewPlaceholder]} />
|
||
)}
|
||
|
||
<View style={styles.progressCopy}>
|
||
<ThemedText style={styles.progressHeadline} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||
✨ 正在加载生成中...
|
||
</ThemedText>
|
||
<ThemedText
|
||
style={styles.progressMessage}
|
||
lightColor="rgba(255,255,255,0.72)"
|
||
darkColor="rgba(255,255,255,0.72)"
|
||
>
|
||
{progress.message || '正在为你织造 ASMR 场景,请稍候片刻。'}
|
||
</ThemedText>
|
||
</View>
|
||
|
||
<View style={styles.progressBarArea}>
|
||
<View style={styles.progressTrack}>
|
||
{progressMeta.fillWidth > 0 && (
|
||
<View style={[styles.progressFill, { width: `${progressMeta.fillWidth}%` }]}>
|
||
<LinearGradient
|
||
colors={['#78F8FF', '#49A7FF']}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 0 }}
|
||
style={StyleSheet.absoluteFill}
|
||
/>
|
||
</View>
|
||
)}
|
||
</View>
|
||
<ThemedText
|
||
style={styles.progressPercent}
|
||
lightColor="rgba(255,255,255,0.6)"
|
||
darkColor="rgba(255,255,255,0.6)"
|
||
>
|
||
{progressMeta.percentLabel}%
|
||
</ThemedText>
|
||
</View>
|
||
</LinearGradient>
|
||
|
||
<TouchableOpacity
|
||
activeOpacity={0.85}
|
||
style={styles.primaryButton}
|
||
onPress={() => {
|
||
Alert.alert('生成进行中', '系统正在为你制作视频,请耐心等待。');
|
||
}}
|
||
>
|
||
<ThemedText style={styles.primaryButtonText} lightColor="#050505" darkColor="#050505">
|
||
Generate Video
|
||
</ThemedText>
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
);
|
||
|
||
const renderResultStep = () => (
|
||
<View
|
||
style={[
|
||
styles.resultContainer,
|
||
{
|
||
paddingTop: insets.top + 16,
|
||
paddingBottom: Math.max(insets.bottom, 24),
|
||
},
|
||
]}
|
||
>
|
||
<View style={styles.resultHeader}>
|
||
<TouchableOpacity
|
||
onPress={handleRequestExit}
|
||
activeOpacity={0.8}
|
||
style={[styles.backButton, styles.backButtonResult]}
|
||
>
|
||
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
|
||
</TouchableOpacity>
|
||
<ThemedText style={styles.resultTitle} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||
生成结果
|
||
</ThemedText>
|
||
</View>
|
||
|
||
<View style={styles.resultBody}>
|
||
{result ? (
|
||
<ResultDisplay result={result} onRerun={handleRerun} />
|
||
) : (
|
||
<View style={styles.stateContainer}>
|
||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||
正在整理结果,请稍候...
|
||
</ThemedText>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
|
||
if (loading) {
|
||
return (
|
||
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||
<Stack.Screen options={{ headerShown: false }} />
|
||
<View style={styles.stateContainer}>
|
||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||
加载中...
|
||
</ThemedText>
|
||
</View>
|
||
</ThemedView>
|
||
);
|
||
}
|
||
|
||
if (error && !template) {
|
||
return (
|
||
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||
<Stack.Screen options={{ headerShown: false }} />
|
||
<View style={styles.stateContainer}>
|
||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||
{error.message}
|
||
</ThemedText>
|
||
</View>
|
||
</ThemedView>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||
<Stack.Screen
|
||
options={{
|
||
headerShown: false,
|
||
gestureEnabled: currentStep !== 'progress' || !isLoading,
|
||
}}
|
||
/>
|
||
|
||
{currentStep === 'progress' && renderProgressStep()}
|
||
{currentStep === 'result' && renderResultStep()}
|
||
</ThemedView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: {
|
||
flex: 1,
|
||
backgroundColor: '#050505',
|
||
},
|
||
scroll: {
|
||
flex: 1,
|
||
},
|
||
progressContent: {
|
||
paddingHorizontal: 24,
|
||
gap: 28,
|
||
},
|
||
backButton: {
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 22,
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.08)',
|
||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
backButtonProgress: {
|
||
alignSelf: 'flex-start',
|
||
},
|
||
backButtonResult: {
|
||
marginRight: 12,
|
||
},
|
||
titleSection: {
|
||
gap: 12,
|
||
},
|
||
title: {
|
||
fontSize: 20,
|
||
fontWeight: '700',
|
||
letterSpacing: 0.8,
|
||
lineHeight: 28,
|
||
},
|
||
subtitle: {
|
||
fontSize: 13,
|
||
lineHeight: 20,
|
||
},
|
||
heroContainer: {
|
||
position: 'relative',
|
||
borderRadius: 24,
|
||
overflow: 'hidden',
|
||
backgroundColor: '#121212',
|
||
},
|
||
heroImage: {
|
||
width: '100%',
|
||
aspectRatio: 1.58,
|
||
},
|
||
heroPlaceholder: {
|
||
width: '100%',
|
||
aspectRatio: 1.58,
|
||
backgroundColor: '#1F1F20',
|
||
},
|
||
overlayImageFrame: {
|
||
position: 'absolute',
|
||
left: 18,
|
||
bottom: 18,
|
||
width: 72,
|
||
height: 72,
|
||
borderRadius: 18,
|
||
overflow: 'hidden',
|
||
borderWidth: 2,
|
||
borderColor: '#050505',
|
||
shadowColor: '#000000',
|
||
shadowOpacity: 0.4,
|
||
shadowRadius: 8,
|
||
shadowOffset: { width: 0, height: 6 },
|
||
elevation: 6,
|
||
},
|
||
overlayImage: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
progressCard: {
|
||
borderRadius: 28,
|
||
paddingVertical: 28,
|
||
paddingHorizontal: 24,
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.06)',
|
||
gap: 24,
|
||
},
|
||
previewFrame: {
|
||
alignSelf: 'center',
|
||
width: 96,
|
||
height: 148,
|
||
borderRadius: 24,
|
||
overflow: 'hidden',
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.12)',
|
||
backgroundColor: '#0F0F12',
|
||
},
|
||
previewImage: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
previewPlaceholder: {
|
||
backgroundColor: '#18181C',
|
||
},
|
||
progressCopy: {
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
paddingHorizontal: 8,
|
||
},
|
||
progressHeadline: {
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
},
|
||
progressMessage: {
|
||
fontSize: 13,
|
||
lineHeight: 20,
|
||
textAlign: 'center',
|
||
},
|
||
progressBarArea: {
|
||
gap: 12,
|
||
},
|
||
progressTrack: {
|
||
height: 8,
|
||
borderRadius: 4,
|
||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||
overflow: 'hidden',
|
||
},
|
||
progressFill: {
|
||
height: '100%',
|
||
borderRadius: 4,
|
||
overflow: 'hidden',
|
||
},
|
||
progressPercent: {
|
||
fontSize: 12,
|
||
fontWeight: '600',
|
||
textAlign: 'right',
|
||
},
|
||
primaryButton: {
|
||
height: 60,
|
||
borderRadius: 30,
|
||
backgroundColor: '#D7FF1F',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#D7FF1F',
|
||
shadowOpacity: 0.4,
|
||
shadowRadius: 16,
|
||
shadowOffset: { width: 0, height: 12 },
|
||
elevation: 4,
|
||
},
|
||
primaryButtonText: {
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
letterSpacing: 0.6,
|
||
},
|
||
resultContainer: {
|
||
flex: 1,
|
||
paddingHorizontal: 24,
|
||
gap: 24,
|
||
},
|
||
resultHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
resultTitle: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
letterSpacing: 0.4,
|
||
},
|
||
resultBody: {
|
||
flex: 1,
|
||
},
|
||
stateContainer: {
|
||
flex: 1,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 32,
|
||
},
|
||
stateText: {
|
||
fontSize: 15,
|
||
lineHeight: 22,
|
||
textAlign: 'center',
|
||
},
|
||
});
|