expo-popcore-old/app/template/[id]/run.tsx

598 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
},
});