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 { useFileDrop } from "../components/use-file-drop"; import { DEFAULT_BILLING_RATES, 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"; // 复刻固定 Seedance 2.5:参考视频只用来提炼分镜稿,成片最长 30 秒。 const REPLACE_SEEDANCE_MODEL = "doubao-seedance-2-5-260628"; /** 与后端 DIGEST_VISION_MODEL_NAME 一致:商品复刻拆镜用 Gemini。 */ const DIGEST_GEMINI_MODEL = "gemini-3.1-pro-preview"; const DIGEST_POINTS_FALLBACK = 30; const PRODUCT_SOURCE_PURPOSE = "video_replace_product"; const REF_SECONDS_MAX = { product: 30, character: 30 } as const; const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const; const SEEDANCE_MAX_OUTPUT_SECONDS = 30; // 第 3 步补充信息上限,与后端 MAX_SUBJECT_BRIEF 保持一致 const SUBJECT_BRIEF_MAX = 500; 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: "已选择商品", briefStep: "3. 商品信息", briefOptional: "选填 · 填了更准", briefPlaceholder: "例:素颜霜,膏体按压泵,主打提亮和保湿,适合日常通勤", briefHint: "填写商品名称、品类和卖点后,口播和使用动作会按真实用途来写;不填则只按参考图的外观拍。", }, 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) || SEEDANCE_MAX_OUTPUT_SECONDS); return Math.min(SEEDANCE_MAX_OUTPUT_SECONDS, Math.max(4, rounded || SEEDANCE_MAX_OUTPUT_SECONDS)); } 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; } /** 选中后这次实际会带给模型的参考图。三视图是 standalone 资产,不在 images 里,单独点名。 */ function librarySelectionDetail(mode: ReplaceMode, product: Product | null, model: ModelEntity | null) { if (mode === "character") { if (!model) return ""; const parts = [model.portrait ? "定妆照" : "", model.triview ? "三视图" : ""].filter(Boolean); return parts.length ? `将带上 ${parts.join(" + ")}` : ""; } if (!product) return ""; const count = productImageCount(product) || (product.cover_preview_url ? 1 : 0); const parts = [count ? `${count} 张商品图` : ""]; parts.push(product.triview_preview_url ? "三视图" : ""); const kept = parts.filter(Boolean); if (!kept.length) return ""; return product.triview_preview_url ? `将带上 ${kept.join(" + ")}` : `将带上 ${kept.join("")} · 该商品还没有三视图`; } function modelCover(model: ModelEntity) { return model.portrait || model.triview || ""; } function modelImageCount(model: ModelEntity) { return [model.portrait, model.triview].filter(Boolean).length; } interface ModeFormState { videoFile: File | null; videoPreview: string; videoRef: FreeVideoRef | null; videoUploading: boolean; videoMeta: { duration: number; width: number; height: number }; source: ProductSource; selectedProduct: Product | null; selectedModel: ModelEntity | null; tempFiles: File[]; tempAssetRefs: FreeVideoRef[]; filledSubjectName: string; subjectBrief: string; job: FreeVideoTask | null; jobId: string; } const createInitialModeState = (): ModeFormState => ({ videoFile: null, videoPreview: "", videoRef: null, videoUploading: false, videoMeta: { duration: 0, width: 0, height: 0 }, source: "", selectedProduct: null, selectedModel: null, tempFiles: [], tempAssetRefs: [], filledSubjectName: "", subjectBrief: "", job: null, jobId: "", }); 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 [forms, setForms] = useState>({ product: createInitialModeState(), character: createInitialModeState(), }); // 进页面先向后端确认有没有在跑的任务,确认完再决定显示表单还是进度 const [restoring, setRestoring] = useState(true); const [tempPreviews, setTempPreviews] = useState([]); const [libraryOpen, setLibraryOpen] = useState(false); const [pendingProductId, setPendingProductId] = useState(""); const [pendingModelId, setPendingModelId] = useState(""); 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 formsRef = useRef(forms); formsRef.current = forms; const currentForm = forms[replaceMode]; const { videoFile, videoPreview, videoRef, videoUploading, videoMeta, source, selectedProduct, selectedModel, tempFiles, tempAssetRefs, filledSubjectName, subjectBrief, job, jobId, } = currentForm; const updateCurrentForm = (updater: Partial | ((prev: ModeFormState) => Partial)) => { setForms((prev) => { const cur = prev[replaceMode]; const patch = typeof updater === "function" ? updater(cur) : updater; return { ...prev, [replaceMode]: { ...cur, ...patch }, }; }); }; const updateModeForm = (mode: ReplaceMode, updater: Partial | ((prev: ModeFormState) => Partial)) => { setForms((prev) => { const cur = prev[mode]; const patch = typeof updater === "function" ? updater(cur) : updater; return { ...prev, [mode]: { ...cur, ...patch }, }; }); }; const videoConfigs = useMemo( () => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"), [modelConfigs], ); const preferredModel = useMemo( () => videoConfigs.find((config) => config.name === REPLACE_SEEDANCE_MODEL) || videoConfigs[0], [videoConfigs], ); const digestModel = useMemo(() => { const texts = modelConfigs.filter((c) => c.capability === "text" && c.status === "active"); return ( texts.find((c) => c.name === DIGEST_GEMINI_MODEL) || texts.find((c) => c.name.includes("gemini-3.1") || (c.display_name || "").includes("Gemini 3.1")) || null ); }, [modelConfigs]); 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 librarySelected = source === "library" && Boolean(replaceMode === "character" ? selectedModel : selectedProduct); 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 || SEEDANCE_MAX_OUTPUT_SECONDS); // 上传前不显示积分;上传后 = Gemini 提炼挂牌(仅商品复刻) + Seedance 2.5 720P × 时长 // 商品复刻:原片只提炼、不进 Seedance → 普通秒价;角色复刻:原片作引用视频 → 含引用秒价 const digestHangpai = (() => { if (replaceMode !== "product") return 0; const pricing = (digestModel?.metadata?.points_pricing as { points_per_call?: unknown } | undefined)?.points_per_call; if (pricing != null) { const n = Number(pricing); if (Number.isFinite(n) && n > 0) return n; } return DIGEST_POINTS_FALLBACK; })(); const imageRefCount = source === "library" ? Math.max(1, libraryImageCount) : Math.max(0, (tempFiles.length ? tempFiles : tempAssetRefs).length); const seedanceRefs: { type: string }[] = [ ...(replaceMode === "character" && videoReady ? [{ type: "video" as const }] : []), ...Array.from({ length: imageRefCount }, () => ({ type: "image" as const })), ]; const estimated = estimateCost(preferredModel, { ratio: aspectRatio, resolution: "720p", duration: outputDuration, refs: seedanceRefs, }, billingRates); const points = videoReady ? (estimated.points + (replaceMode === "product" ? digestHangpai : 0)) : 0; // 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。 // 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。 const generating = Boolean(job && isInFlight(job.status)) || submitting; const digesting = Boolean((job && isDigesting(job)) || (submitting && !job)); const reviewing = !digesting && Boolean(job && (job.review_stage === "reviewing" || job.status === "created")); const hasResult = Boolean(job && job.status === "succeeded" && job.video_url); const shotTotal = Number(job?.shot_total || 0); const shotIndex = Number(job?.shot_index || 0); const generatingCopy = shotTotal > 1 && shotIndex > 0 ? `正在生成第 ${shotIndex}/${shotTotal} 镜,逐镜还原后再拼成完整片子` : copy.generatingCopy; const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy; const panelClass = [ "video-result-panel replace-result-panel", restoring ? "is-restoring" : "", generating ? "is-generating" : "", reviewing || digesting ? "is-reviewing" : "", hasResult ? "has-result" : "", ].filter(Boolean).join(" "); const generateLabel = generating ? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : (shotTotal > 1 && shotIndex > 0 ? `正在复刻 ${shotIndex}/${shotTotal} 镜…` : "正在复刻…")) : hasResult ? (points > 0 ? `再次${copy.modeLabel} · 消耗 ${points} 积分` : `再次${copy.modeLabel}`) : (points > 0 ? `开始${copy.modeLabel} · 消耗 ${points} 积分` : `开始${copy.modeLabel}`); const loadHistory = async () => { try { const data = await api.videoReplaceTasks(0, 50); setHistory((data.results || []).filter((item) => item.status === "succeeded")); return data; } catch { /* 历史失败不挡当前复刻 */ return null; } }; // 恢复在跑的任务。拉到之前一律显示「读取中」——否则用户以为没任务,又传一次。 const restoreInflight = async () => { const data = await loadHistory(); const running = data?.inflight || null; if (running) { // 静默恢复:只把进度接上。不要走 fillFormFromTask —— 它会弹 toast、滚动页面, // 还依赖商品/模特列表加载完,拿来做刷新恢复会又吵又不稳。 const mode = modeFromTask(running); setReplaceMode(mode); rememberReplaceMode(mode); const video = videoRefFromTask(running); updateModeForm(mode, { job: running, jobId: running.id, videoPreview: video?.url || "", videoRef: video ? { ...video, type: "video", role: "reference_video", label: video.label || "参考视频" } : null, filledSubjectName: subjectNameFromTask(running), videoMeta: { duration: Number(video?.duration || running.duration || 0), ...sizeFromRatio(running.aspect_ratio || "9:16"), }, }); rememberJob(running.id); } else { forgetJob(); } setRestoring(false); }; 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 restoreInflight(); return () => { Object.values(formsRef.current).forEach((f) => { if (f.videoPreview.startsWith("blob:")) { try { URL.revokeObjectURL(f.videoPreview); } catch { /* ignore */ } } }); }; }, []); 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); const productJobId = forms.product.jobId; const characterJobId = forms.character.jobId; useEffect(() => { if (!productJobId) return; let cancelled = false; let timer = 0; const poll = async () => { try { const data = await api.pollVideoReplace(productJobId); if (cancelled) return; updateModeForm("product", { job: data.task }); 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?.(); } updateModeForm("product", { jobId: "" }); forgetJob(); } catch (error) { if (cancelled) return; if (error instanceof ApiError && error.status === 404) { updateModeForm("product", { job: null, jobId: "" }); forgetJob(); return; } timer = window.setTimeout(poll, 8000); } }; void poll(); return () => { cancelled = true; window.clearTimeout(timer); }; }, [productJobId, onNotify, onTaskSettled]); useEffect(() => { if (!characterJobId) return; let cancelled = false; let timer = 0; const poll = async () => { try { const data = await api.pollVideoReplace(characterJobId); if (cancelled) return; updateModeForm("character", { job: data.task }); 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?.(); } updateModeForm("character", { jobId: "" }); forgetJob(); } catch (error) { if (cancelled) return; if (error instanceof ApiError && error.status === 404) { updateModeForm("character", { job: null, jobId: "" }); forgetJob(); return; } timer = window.setTimeout(poll, 8000); } }; void poll(); return () => { cancelled = true; window.clearTimeout(timer); }; }, [characterJobId, onNotify, onTaskSettled]); const pickVideo = async (file: File | null) => { if (!file) return; const check = await checkRefFile(file, { maxSeconds: REF_SECONDS_MAX[replaceMode], maxVideoBytes: REF_BYTES_MAX[replaceMode], }); if (!check.ok) { onNotify("error", check.error); return; } if (check.type !== "video") { onNotify("error", "只支持 mp4 / mov 视频"); return; } const previewUrl = URL.createObjectURL(file); updateCurrentForm((cur) => { if (cur.videoPreview.startsWith("blob:")) { try { URL.revokeObjectURL(cur.videoPreview); } catch { /* ignore */ } } return { videoFile: file, videoPreview: previewUrl, videoUploading: true, job: cur.job && isInFlight(cur.job.status) ? cur.job : null, }; }); const form = new FormData(); form.append("file", file); form.append("purpose", PRODUCT_SOURCE_PURPOSE); try { const uploaded = await api.uploadFreeVideoRef(form); updateCurrentForm({ videoRef: { 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", }, videoMeta: { duration: uploaded.duration || check.duration || 0, width: uploaded.width || 0, height: uploaded.height || 0, }, }); } catch (error) { updateCurrentForm({ videoFile: null, videoRef: null, videoPreview: "", }); onNotify("error", error instanceof Error ? error.message : "参考视频上传失败"); } finally { updateCurrentForm({ videoUploading: false }); } }; const addTempImages = (files: FileList | File[] | null) => { const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type)); if (!incoming.length) { onNotify("error", "请选择 JPG、PNG 或 WebP 图片"); return; } updateCurrentForm((cur) => { const existing = new Set(cur.tempFiles.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 - cur.tempFiles.length); if (room === 0) { onNotify("info", `最多上传9张${copy.temporaryNoun}`); return {}; } if (!unique.length) { onNotify("info", "所选图片已在九宫格中"); return {}; } if (unique.length > room) onNotify("info", `最多上传9张图片,已保留前${room}张`); return { tempFiles: [...cur.tempFiles, ...unique.slice(0, room)], source: "temporary", selectedProduct: null, selectedModel: null, tempAssetRefs: [], filledSubjectName: "", job: cur.job && !isInFlight(cur.job.status) ? null : cur.job, }; }); }; const switchReplaceMode = (next: ReplaceMode) => { if (next === replaceMode || generating) return; setReplaceMode(next); rememberReplaceMode(next); setLibraryOpen(false); 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; } updateModeForm("character", (cur) => ({ selectedModel: model, selectedProduct: null, source: "library", tempFiles: [], tempAssetRefs: [], filledSubjectName: "", job: cur.job && isInFlight(cur.job.status) ? cur.job : null, })); setLibraryOpen(false); setPendingModelId(""); onNotify("success", `${copy.pickToast}:${model.name}`); return; } const product = products.find((item) => item.id === pendingProductId); if (!product) { onNotify("info", "请先选择或上传商品素材"); return; } updateModeForm("product", (cur) => ({ selectedProduct: product, selectedModel: null, source: "library", tempFiles: [], tempAssetRefs: [], filledSubjectName: "", job: cur.job && isInFlight(cur.job.status) ? cur.job : null, })); setLibraryOpen(false); setPendingProductId(""); onNotify("success", `${copy.pickToast}:${product.title}`); }; const startGeneration = async () => { const current = forms[replaceMode]; if (!videoReady || !productReady || generating) return; if (!preferredModel) { onNotify("error", "暂无可用视频模型"); return; } if (!current.videoRef?.asset_id) { onNotify("error", "请先上传参考视频"); return; } setSubmitting(true); try { let imageAssetIds: string[] = []; if (current.source === "temporary") { if (current.tempFiles.length) { for (const file of current.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 = current.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: current.videoRef.asset_id, product_id: current.source === "library" && replaceMode === "product" ? current.selectedProduct?.id : undefined, model_id: current.source === "library" && replaceMode === "character" ? current.selectedModel?.id : undefined, image_asset_ids: current.source === "temporary" ? imageAssetIds : undefined, subject_brief: current.subjectBrief.trim() || undefined, model: preferredModel.name, aspect_ratio: aspectRatio, resolution: "720p", duration: outputDuration, }); const taskMode = modeFromTask(data.task) || replaceMode; updateModeForm(taskMode, { job: data.task, jobId: data.task.id, }); rememberJob(data.task.id); rememberReplaceMode(taskMode); 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) { if (error instanceof ApiError && error.status === 409) { // 后端单飞闸:已有复刻在跑。直接接上它,别让用户对着报错干瞪眼。 const running = (error.payload as { inflight?: FreeVideoTask } | undefined)?.inflight; if (running) { const taskMode = modeFromTask(running); updateModeForm(taskMode, { job: running, jobId: running.id, }); rememberJob(running.id); } onNotify("info", error.message || "已有一个视频正在复刻中"); return; } onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败"); } finally { setSubmitting(false); } }; const fillFormFromTask = (task: FreeVideoTask) => { const mode = modeFromTask(task); if (forms[mode].job && isInFlight(forms[mode].job.status)) return; const video = videoRefFromTask(task); if (!video?.asset_id) { onNotify("error", "这条记录没有可用的参考视频,请重新上传"); return; } const images = imageRefsFromTask(task); const subject = subjectNameFromTask(task); setReplaceMode(mode); rememberReplaceMode(mode); if (mode === "character") { const model = models.find((item) => item.id === task.model_id) || models.find((item) => item.name === subject); updateModeForm("character", { videoFile: null, videoPreview: video.url || "", videoRef: { ...video, type: "video", role: "reference_video", label: video.label || "参考视频", }, videoMeta: { duration: Number(video.duration || task.duration || 0), ...sizeFromRatio(task.aspect_ratio || "9:16"), }, filledSubjectName: subject, subjectBrief: task.subject_brief || "", selectedModel: model || null, selectedProduct: null, source: model ? "library" : (images.length ? "temporary" : ""), tempFiles: [], tempAssetRefs: model ? [] : 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); updateModeForm("product", { videoFile: null, videoPreview: video.url || "", videoRef: { ...video, type: "video", role: "reference_video", label: video.label || "参考视频", }, videoMeta: { duration: Number(video.duration || task.duration || 0), ...sizeFromRatio(task.aspect_ratio || "9:16"), }, filledSubjectName: subject, subjectBrief: task.subject_brief || "", selectedProduct: product || null, selectedModel: null, source: product ? "library" : (images.length ? "temporary" : ""), tempFiles: [], tempAssetRefs: product ? [] : 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 tempDrop = useFileDrop( (files) => addTempImages(files), { disabled: generating } ); const videoDrop = useFileDrop( (files) => { void pickVideo(files[0] || null); }, { disabled: generating || videoUploading } ); const cells = Array.from({ length: 9 }, (_, index) => tempDisplay[index] || null); return (

视频复刻

准备复刻素材

1. 参考视频 MP4 / MOV · 最长 {REF_SECONDS_MAX[replaceMode]} 秒
{ void pickVideo(event.target.files?.[0] || null); event.target.value = ""; }} /> {videoPreview ? (
) : ( )}
{copy.targetStep} 请选择一种方式
{ if (generating) return; tempInputRef.current?.click(); }} onKeyDown={(event) => { if (generating) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); tempInputRef.current?.click(); } }} > {copy.temporaryTitle} {tempDrop.dragging ? "松开即可添加" : 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) => { if (generating) return; addTempImages(event.target.files); event.target.value = ""; }} />
{replaceMode === "product" ? (
{REPLACE_MODE_COPY.product.briefStep} {REPLACE_MODE_COPY.product.briefOptional}