import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, Check, ChevronDown, ChevronRight, Clapperboard, Download, FileVideo2, ImagePlus, LibraryBig, Package, RefreshCw, Replace, ScanLine, ShieldCheck, Upload, UserRound, X, } from "lucide-react"; import { api, ApiError } from "../api"; import { OverlayPortal, useBodyScrollLock } from "../components/overlays"; import { DEFAULT_BILLING_RATES, FC_MODELS, IMAGE_TYPES, MAX_IMAGES, checkRefFile, estimateCost, isInFlight, type BillingRates, } from "../components/free-create/constants"; import type { FreeVideoRef, FreeVideoTask, ModelConfig, ModelEntity, Product } from "../types"; import type { NavigateFn } from "./route-config"; const JOB_KEY = "airshelf:video-replace-job"; const MODE_KEY = "airshelf:video-replace-mode"; const CHARACTER_MARK = "[视频复刻·角色]"; const VIDEO_ACCEPT = "video/mp4,video/quicktime"; type ProductSource = "library" | "temporary" | ""; type ReplaceMode = "product" | "character"; const REPLACE_MODE_COPY = { product: { modeLabel: "商品复刻", targetLabel: "商品", targetStep: "2. 选择自己的商品", videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的商品", videoReady: "视频已就绪,将先提炼分镜稿再复刻", libraryTitle: "从商品库选择", libraryEmpty: "选择已创建的商品", temporaryTitle: "临时上传商品", temporaryEmpty: "仅用于本次任务 · 最多 9 张", temporaryNoun: "商品图", temporaryFallback: "临时商品素材", generatingTitle: "正在进行商品复刻", generatingCopy: "正在照着分镜稿还原镜头,并换上你的商品", reviewingTitle: "正在审核参考素材", reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻", digestingTitle: "正在提炼参考视频", digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻", resultTitle: "商品复刻已完成", resultPreview: "商品复刻预览", consistency: "商品一致性检查通过", drawerTitle: "选择商品", drawerDescription: "从已经创建的商品中选择一个用于本次视频复刻。", drawerEmpty: "还没有商品,先去商品库创建一个", historyKind: "商品", pickToast: "已选择商品", }, character: { modeLabel: "角色复刻", targetLabel: "角色", targetStep: "2. 选择自己的角色", videoEmpty: "系统将自动识别需要替换的原片角色", videoReady: "视频已就绪,将自动识别原片角色", libraryTitle: "从人物库选择", libraryEmpty: "选择已创建的人物", temporaryTitle: "临时上传角色", temporaryEmpty: "仅用于本次任务 · 最多 9 张参考图", temporaryNoun: "角色参考图", temporaryFallback: "临时角色素材", generatingTitle: "正在进行角色复刻", generatingCopy: "正在匹配角色外观、表情与原片动作", reviewingTitle: "正在审核参考素材", reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻", digestingTitle: "正在提炼参考视频", digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻", resultTitle: "角色复刻已完成", resultPreview: "角色复刻预览", consistency: "角色一致性检查通过", drawerTitle: "选择模特", drawerDescription: "从人物库中选择一个角色用于本次视频复刻。", drawerEmpty: "还没有人物,先去模特库添加", historyKind: "角色", pickToast: "已选择角色", }, } as const; function readJobId() { try { return localStorage.getItem(JOB_KEY) || ""; } catch { return ""; } } function rememberJob(id: string) { try { localStorage.setItem(JOB_KEY, id); } catch { /* 无痕模式忽略 */ } } function forgetJob() { try { localStorage.removeItem(JOB_KEY); } catch { /* 无痕模式忽略 */ } } function readReplaceMode(): ReplaceMode { try { const stored = localStorage.getItem(MODE_KEY); if (stored === "character" || stored === "product") return stored; } catch { /* 无痕模式忽略 */ } return "product"; } function rememberReplaceMode(mode: ReplaceMode) { try { localStorage.setItem(MODE_KEY, mode); } catch { /* 无痕模式忽略 */ } } function fileKey(file: File) { return `${file.name}:${file.size}:${file.lastModified}`; } function ratioFromSize(width: number, height: number) { if (!width || !height) return "9:16"; const r = width / height; if (Math.abs(r - 9 / 16) < 0.08) return "9:16"; if (Math.abs(r - 16 / 9) < 0.08) return "16:9"; if (Math.abs(r - 1) < 0.08) return "1:1"; if (Math.abs(r - 3 / 4) < 0.08) return "3:4"; if (Math.abs(r - 4 / 3) < 0.08) return "4:3"; if (Math.abs(r - 21 / 9) < 0.12) return "21:9"; return width > height ? "16:9" : "9:16"; } function ratioCopy(ratio: string) { if (ratio === "9:16") return "竖屏 9:16"; if (ratio === "16:9") return "横屏 16:9"; return ratio; } function formatClock(seconds: number) { const total = Math.max(0, Math.round(Number(seconds) || 0)); const minutes = Math.floor(total / 60); const rest = total % 60; return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; } function clampDuration(seconds: number) { const rounded = Math.round(Number(seconds) || 15); return Math.min(15, Math.max(4, rounded || 15)); } function isCharacterRemix(task?: Partial | null) { if (task?.replace_mode === "character") return true; if (task?.replace_mode === "product") return false; return (task?.prompt || "").startsWith(CHARACTER_MARK); } function modeFromTask(task?: Partial | null): ReplaceMode { return isCharacterRemix(task) ? "character" : "product"; } function subjectNameFromTask(task?: Partial | null) { const named = (task?.subject_name || "").trim(); if (named) return named; const match = (task?.prompt || "").match(/(?:商品|角色):([^\n。]+)/); return (match?.[1] || "").trim(); } function remixTitle(task?: Partial | null) { const copy = REPLACE_MODE_COPY[modeFromTask(task)]; const name = subjectNameFromTask(task); if (name) return `${name}${copy.modeLabel}`; return copy.resultPreview; } function remixSourceLabel(task?: Partial | null) { const prompt = task?.prompt || ""; const name = subjectNameFromTask(task); const source = task?.subject_source; if (modeFromTask(task) === "character") { if ((source === "library" || /人物库/.test(prompt)) && name) return `人物库:${name}`; return "临时角色素材"; } if ((source === "library" || /商品库/.test(prompt)) && name) return `商品库:${name}`; return "临时商品素材"; } function videoRefFromTask(task?: Partial | null) { // 商品复刻的参考视频只用来提炼分镜稿,不进 references,引用快照存在 digest_source。 return (task?.references || []).find((item) => item.type === "video") || task?.digest_source || null; } function isDigesting(task?: Partial | null) { return task?.digest_stage === "digesting"; } function imageRefsFromTask(task?: Partial | null) { return (task?.references || []).filter((item) => item.type === "image" && (item.url || item.asset_id)); } function sizeFromRatio(ratio: string) { if (ratio === "16:9") return { width: 1280, height: 720 }; if (ratio === "1:1") return { width: 1080, height: 1080 }; if (ratio === "3:4") return { width: 834, height: 1112 }; if (ratio === "4:3") return { width: 1112, height: 834 }; if (ratio === "21:9") return { width: 1470, height: 630 }; return { width: 720, height: 1280 }; } function productCover(product: Product) { return product.cover_preview_url || product.images?.find((image) => image.is_primary)?.preview_url || product.images?.[0]?.preview_url || ""; } function productImageCount(product: Product) { return product.images?.filter((image) => image.asset || image.preview_url).length || 0; } function modelCover(model: ModelEntity) { return model.portrait || model.triview || ""; } function modelImageCount(model: ModelEntity) { return [model.portrait, model.triview].filter(Boolean).length; } export function VideoReplacePage({ products: initialProducts = [], modelConfigs = [], onNotify, onBack, onTaskSettled, }: { products?: Product[]; modelConfigs?: ModelConfig[]; onNotify: (type: "success" | "error" | "info", text: string) => void; onBack: () => void; onTaskSettled?: () => void; navigate?: NavigateFn; }) { const [products, setProducts] = useState(initialProducts); const [models, setModels] = useState([]); const [replaceMode, setReplaceMode] = useState(readReplaceMode); const [videoFile, setVideoFile] = useState(null); const [videoRef, setVideoRef] = useState(null); const [videoUploading, setVideoUploading] = useState(false); const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 }); const [source, setSource] = useState(""); const [selectedProduct, setSelectedProduct] = useState(null); const [selectedModel, setSelectedModel] = useState(null); const [tempFiles, setTempFiles] = useState([]); const [tempPreviews, setTempPreviews] = useState([]); const [tempAssetRefs, setTempAssetRefs] = useState([]); const [filledSubjectName, setFilledSubjectName] = useState(""); const [libraryOpen, setLibraryOpen] = useState(false); const [pendingProductId, setPendingProductId] = useState(""); const [pendingModelId, setPendingModelId] = useState(""); const [jobId, setJobId] = useState(readJobId); const [job, setJob] = useState(null); const [history, setHistory] = useState([]); const [expandedHistoryId, setExpandedHistoryId] = useState(""); const [submitting, setSubmitting] = useState(false); const [billingRates, setBillingRates] = useState(DEFAULT_BILLING_RATES); const videoInputRef = useRef(null); const tempInputRef = useRef(null); const completedNoticeRef = useRef(""); const wasReviewingRef = useRef(false); const wasDigestingRef = useRef(false); const videoConfigs = useMemo( () => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"), [modelConfigs], ); const preferredModel = useMemo( () => videoConfigs.find((config) => config.name === FC_MODELS[0].name) || videoConfigs[0], [videoConfigs], ); const copy = REPLACE_MODE_COPY[replaceMode]; const videoReady = Boolean(videoRef?.asset_id); const productName = source === "library" ? (replaceMode === "character" ? (selectedModel?.name || "") : (selectedProduct?.title || "")) : source === "temporary" ? (tempFiles[0] ? (tempFiles.length > 1 ? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback}(${tempFiles.length}张参考图)` : (tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback)) : (filledSubjectName || copy.temporaryFallback)) : ""; const productReady = source === "library" ? (replaceMode === "character" ? Boolean(selectedModel) : Boolean(selectedProduct)) : (tempFiles.length > 0 || tempAssetRefs.length > 0); const libraryPreview = replaceMode === "character" ? (selectedModel ? modelCover(selectedModel) : "") : (selectedProduct ? productCover(selectedProduct) : ""); const libraryImageCount = replaceMode === "character" ? (selectedModel ? modelImageCount(selectedModel) : 0) : (selectedProduct ? productImageCount(selectedProduct) : 0); const tempDisplay = tempFiles.length ? tempFiles.map((file, index) => ({ key: fileKey(file), src: tempPreviews[index], name: file.name })) : tempAssetRefs.map((item, index) => ({ key: item.asset_id || `${item.url}-${index}`, src: item.url || item.thumb_url || "", name: item.label || `${copy.temporaryNoun}${index + 1}`, })); const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height); const outputDuration = clampDuration(videoMeta.duration || 15); const estimated = estimateCost(preferredModel, { ratio: aspectRatio, resolution: "720p", duration: outputDuration, refs: [ ...(videoRef ? [{ type: "video", duration: videoRef.duration || videoMeta.duration }] : []), ...((source === "library" ? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const })) : (tempFiles.length ? tempFiles : tempAssetRefs) ).map(() => ({ type: "image" }))), ], }, billingRates); const points = estimated.points || 220; const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading; const digesting = Boolean(job && isDigesting(job)); // 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。 const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created"))); const hasResult = Boolean(job && job.status === "succeeded" && job.video_url); const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy; const panelClass = [ "video-result-panel replace-result-panel", generating ? "is-generating" : "", reviewing || digesting ? "is-reviewing" : "", hasResult ? "has-result" : "", ].filter(Boolean).join(" "); const generateLabel = generating ? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : "正在复刻…") : hasResult ? `再次${copy.modeLabel} · 消耗 ${points} 积分` : `开始${copy.modeLabel} · 消耗 ${points} 积分`; const loadHistory = async () => { try { const data = await api.videoReplaceTasks(0, 50); setHistory((data.results || []).filter((item) => item.status === "succeeded")); } catch { /* 历史失败不挡当前复刻 */ } }; useEffect(() => { void api.products(100).then((payload) => setProducts(payload.results || [])).catch(() => undefined); void api.listModels({ pageSize: 200 }).then((payload) => setModels((payload.results || []).filter((item) => !item.is_deleted))).catch(() => undefined); void api.billingConfig() .then((config) => setBillingRates({ margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin, rate: Number(config.points_per_yuan) || DEFAULT_BILLING_RATES.rate, multiplier: Number(config.team_price_multiplier) || 1, })) .catch(() => undefined); void loadHistory(); }, []); useEffect(() => { if (!initialProducts.length) return; setProducts((current) => (current.length ? current : initialProducts)); }, [initialProducts]); useEffect(() => { const urls = tempFiles.map((file) => URL.createObjectURL(file)); setTempPreviews(urls); return () => urls.forEach((url) => URL.revokeObjectURL(url)); }, [tempFiles]); useBodyScrollLock(libraryOpen); useEffect(() => { if (!jobId) return; let cancelled = false; let timer = 0; const poll = async () => { try { const data = await api.pollVideoReplace(jobId); if (cancelled) return; setJob(data.task); const jobMode = modeFromTask(data.task); setReplaceMode(jobMode); rememberReplaceMode(jobMode); const stillDigesting = isDigesting(data.task); const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created"; if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current; if (stillReviewing) wasReviewingRef.current = true; else if (wasReviewingRef.current && isInFlight(data.task.status)) { wasReviewingRef.current = false; onNotify("success", wasDigestingRef.current ? "分镜稿已提炼完成,正在复刻" : "素材审核已通过,正在复刻"); wasDigestingRef.current = false; } if (isInFlight(data.task.status)) { timer = window.setTimeout(poll, stillReviewing ? 2000 : 2500); return; } if (data.task.status === "succeeded") { if (completedNoticeRef.current !== data.task.id) { completedNoticeRef.current = data.task.id; onNotify("success", "视频复刻成片已生成"); onTaskSettled?.(); } void loadHistory(); } else if (data.task.status === "failed") { onNotify("error", data.task.error_message || "视频复刻未完成,请重试"); onTaskSettled?.(); } forgetJob(); } catch (error) { if (cancelled) return; if (error instanceof ApiError && error.status === 404) { setJob(null); setJobId(""); forgetJob(); return; } timer = window.setTimeout(poll, 8000); } }; void poll(); return () => { cancelled = true; window.clearTimeout(timer); }; }, [jobId, onNotify, onTaskSettled]); const pickVideo = async (file: File | null) => { if (!file) return; const check = await checkRefFile(file); if (!check.ok) { onNotify("error", check.error); return; } if (check.type !== "video") { onNotify("error", "只支持 mp4 / mov 视频"); return; } setVideoFile(file); setVideoUploading(true); setJob((current) => (current && isInFlight(current.status) ? current : null)); if (job && !isInFlight(job.status)) setJob(null); const form = new FormData(); form.append("file", file); try { const uploaded = await api.uploadFreeVideoRef(form); setVideoRef({ url: uploaded.url, type: "video", role: "reference_video", label: "参考视频", thumb_url: uploaded.thumb_url, duration: uploaded.duration || check.duration, asset_id: uploaded.asset_id, source: "upload", }); setVideoMeta({ duration: uploaded.duration || check.duration || 0, width: uploaded.width || 0, height: uploaded.height || 0, }); } catch (error) { setVideoFile(null); setVideoRef(null); onNotify("error", error instanceof Error ? error.message : "参考视频上传失败"); } finally { setVideoUploading(false); } }; const addTempImages = (files: FileList | null) => { const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type)); if (!incoming.length) { onNotify("error", "请选择 JPG、PNG 或 WebP 图片"); return; } setTempFiles((current) => { const existing = new Set(current.map(fileKey)); const unique = incoming.filter((file) => { const key = fileKey(file); if (existing.has(key)) return false; existing.add(key); return true; }); const room = Math.max(0, MAX_IMAGES - current.length); if (room === 0) { onNotify("info", `最多上传9张${copy.temporaryNoun}`); return current; } if (!unique.length) { onNotify("info", "所选图片已在九宫格中"); return current; } if (unique.length > room) onNotify("info", `最多上传9张图片,已保留前${room}张`); return [...current, ...unique.slice(0, room)]; }); setSource("temporary"); setSelectedProduct(null); setSelectedModel(null); setTempAssetRefs([]); setFilledSubjectName(""); if (job && !isInFlight(job.status)) setJob(null); }; const switchReplaceMode = (next: ReplaceMode) => { if (next === replaceMode || generating) return; setReplaceMode(next); rememberReplaceMode(next); setSource(""); setSelectedProduct(null); setSelectedModel(null); setTempFiles([]); setTempAssetRefs([]); setFilledSubjectName(""); setPendingProductId(""); setPendingModelId(""); setLibraryOpen(false); if (job && !isInFlight(job.status)) setJob(null); onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`); }; const confirmLibrarySelection = () => { if (replaceMode === "character") { const model = models.find((item) => item.id === pendingModelId); if (!model) { onNotify("info", "请先选择一个角色"); return; } if (!modelCover(model)) { onNotify("error", "这个角色还没有可用图片"); return; } setSelectedModel(model); setSelectedProduct(null); setSource("library"); setTempFiles([]); setTempAssetRefs([]); setFilledSubjectName(""); setLibraryOpen(false); setPendingModelId(""); if (job && !isInFlight(job.status)) setJob(null); onNotify("success", `${copy.pickToast}:${model.name}`); return; } const product = products.find((item) => item.id === pendingProductId); if (!product) { onNotify("info", "请先选择或上传商品素材"); return; } setSelectedProduct(product); setSelectedModel(null); setSource("library"); setTempFiles([]); setTempAssetRefs([]); setFilledSubjectName(""); setLibraryOpen(false); setPendingProductId(""); if (job && !isInFlight(job.status)) setJob(null); onNotify("success", `${copy.pickToast}:${product.title}`); }; const startGeneration = async () => { if (!videoReady || !productReady || generating) return; if (!preferredModel) { onNotify("error", "暂无可用视频模型"); return; } if (!videoRef?.asset_id) { onNotify("error", "请先上传参考视频"); return; } setSubmitting(true); try { let imageAssetIds: string[] = []; if (source === "temporary") { if (tempFiles.length) { for (const file of tempFiles.slice(0, MAX_IMAGES)) { const form = new FormData(); form.append("file", file); const data = await api.uploadFreeVideoRef(form); if (data.asset_id) imageAssetIds.push(data.asset_id); } } else { imageAssetIds = tempAssetRefs.map((item) => item.asset_id || "").filter(Boolean); } if (!imageAssetIds.length) { onNotify("error", replaceMode === "character" ? "请上传角色参考图" : "请上传商品参考图"); return; } } const data = await api.submitVideoReplace({ replace_mode: replaceMode, video_asset_id: videoRef.asset_id, product_id: source === "library" && replaceMode === "product" ? selectedProduct?.id : undefined, model_id: source === "library" && replaceMode === "character" ? selectedModel?.id : undefined, image_asset_ids: source === "temporary" ? imageAssetIds : undefined, model: preferredModel.name, aspect_ratio: aspectRatio, resolution: "720p", duration: outputDuration, }); setJob(data.task); setJobId(data.task.id); rememberJob(data.task.id); rememberReplaceMode(modeFromTask(data.task) || replaceMode); completedNoticeRef.current = ""; wasReviewingRef.current = data.task.review_stage === "reviewing" || data.task.status === "created"; wasDigestingRef.current = isDigesting(data.task); if (wasDigestingRef.current) { onNotify("success", "已开始提炼参考视频,拆完自动进入复刻"); } else if (wasReviewingRef.current) { onNotify("success", "已提交素材审核,通过后自动开始复刻"); } else { onNotify("success", "视频复刻任务已开始"); } if (!isInFlight(data.task.status) && data.task.status === "succeeded") { onNotify("success", "视频复刻成片已生成"); void loadHistory(); forgetJob(); } } catch (error) { onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败"); } finally { setSubmitting(false); } }; const fillFormFromTask = (task: FreeVideoTask) => { if (generating) return; const mode = modeFromTask(task); const video = videoRefFromTask(task); if (!video?.asset_id) { onNotify("error", "这条记录没有可用的参考视频,请重新上传"); return; } const images = imageRefsFromTask(task); const subject = subjectNameFromTask(task); setReplaceMode(mode); rememberReplaceMode(mode); setVideoFile(null); setVideoRef({ ...video, type: "video", role: "reference_video", label: video.label || "参考视频", }); setVideoMeta({ duration: Number(video.duration || task.duration || 0), ...sizeFromRatio(task.aspect_ratio || "9:16"), }); setFilledSubjectName(subject); if (mode === "character") { const model = models.find((item) => item.id === task.model_id) || models.find((item) => item.name === subject); if (model) { setSelectedModel(model); setSelectedProduct(null); setSource("library"); setTempFiles([]); setTempAssetRefs([]); } else { setSelectedModel(null); setSelectedProduct(null); setSource(images.length ? "temporary" : ""); setTempFiles([]); setTempAssetRefs(images); if (!model && task.subject_source === "library") onNotify("info", "原角色已不在人物库,请重新选择"); } } else { const product = products.find((item) => item.id === task.product_id) || products.find((item) => item.title === subject); if (product) { setSelectedProduct(product); setSelectedModel(null); setSource("library"); setTempFiles([]); setTempAssetRefs([]); } else { setSelectedProduct(null); setSelectedModel(null); setSource(images.length ? "temporary" : ""); setTempFiles([]); setTempAssetRefs(images); if (!product && task.subject_source === "library") onNotify("info", "原商品已不在商品库,请重新选择"); } } onNotify("success", "已填入上次素材,确认后可再次生成"); document.querySelector(".replace-flow-panel")?.scrollIntoView({ behavior: "smooth", block: "start" }); }; const downloadVideo = (url: string, title: string) => { if (!url) return; const link = document.createElement("a"); link.href = url; link.download = `${title || "视频复刻"}.mp4`; link.rel = "noopener"; document.body.appendChild(link); link.click(); link.remove(); onNotify("success", "已开始下载视频复刻成片"); }; const toggleHistory = (id: string) => { setExpandedHistoryId((current) => (current === id ? "" : id)); }; const cells = Array.from({ length: 9 }, (_, index) => tempDisplay[index] || null); return (

视频复刻

准备复刻素材

1. 参考视频 MP4 / MOV · 最长 15 秒
{copy.targetStep} 请选择一种方式
tempInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); tempInputRef.current?.click(); } }} > {copy.temporaryTitle} {tempDisplay.length ? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}` : copy.temporaryEmpty} {tempDisplay.length ? ( {cells.map((item, index) => ( {item ? ( <> {item.name} ) : null} ))} 继续上传 已上传 {tempDisplay.length} / 9 ) : null} event.stopPropagation()} onChange={(event) => { addTempImages(event.target.files); event.target.value = ""; }} />

已完成的视频复刻项目

{history.length} 个项目
{history.length === 0 ? (
还没有完成的视频复刻项目
) : (
{history.map((item) => { const open = expandedHistoryId === item.id; const sourceVideo = videoRefFromTask(item); return (
); })}
)}
{replaceMode === "character" ? ( models.length === 0 ? (
{copy.drawerEmpty}
) : models.map((model) => ( )) ) : products.length === 0 ? (
{copy.drawerEmpty}
) : products.map((product) => ( ))}
); }