1478 lines
60 KiB
TypeScript
1478 lines
60 KiB
TypeScript
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<FreeVideoTask> | 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<FreeVideoTask> | null): ReplaceMode {
|
||
return isCharacterRemix(task) ? "character" : "product";
|
||
}
|
||
|
||
function subjectNameFromTask(task?: Partial<FreeVideoTask> | 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<FreeVideoTask> | 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<FreeVideoTask> | 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<FreeVideoTask> | null) {
|
||
// 商品复刻的参考视频只用来提炼分镜稿,不进 references,引用快照存在 digest_source。
|
||
return (task?.references || []).find((item) => item.type === "video") || task?.digest_source || null;
|
||
}
|
||
|
||
function isDigesting(task?: Partial<FreeVideoTask> | null) {
|
||
return task?.digest_stage === "digesting";
|
||
}
|
||
|
||
function imageRefsFromTask(task?: Partial<FreeVideoTask> | 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<ModelEntity[]>([]);
|
||
const [replaceMode, setReplaceMode] = useState<ReplaceMode>(readReplaceMode);
|
||
// 商品复刻与角色复刻分别拥有独立表单缓存,切换模式互不干扰
|
||
const [forms, setForms] = useState<Record<ReplaceMode, ModeFormState>>({
|
||
product: createInitialModeState(),
|
||
character: createInitialModeState(),
|
||
});
|
||
// 进页面先向后端确认有没有在跑的任务,确认完再决定显示表单还是进度
|
||
const [restoring, setRestoring] = useState(true);
|
||
const [tempPreviews, setTempPreviews] = useState<string[]>([]);
|
||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||
const [pendingProductId, setPendingProductId] = useState("");
|
||
const [pendingModelId, setPendingModelId] = useState("");
|
||
const [history, setHistory] = useState<FreeVideoTask[]>([]);
|
||
const [expandedHistoryId, setExpandedHistoryId] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
|
||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||
const tempInputRef = useRef<HTMLInputElement>(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<ModeFormState> | ((prev: ModeFormState) => Partial<ModeFormState>)) => {
|
||
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<ModeFormState> | ((prev: ModeFormState) => Partial<ModeFormState>)) => {
|
||
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 (
|
||
<div className="vrep-page">
|
||
<div className="vrep-inner">
|
||
<section className="video-tool-page replace-page">
|
||
<header className="page-header">
|
||
<div className="image-title-row">
|
||
<button type="button" className="image-back-button" aria-label="返回上一入口页面" onClick={onBack}>
|
||
<ArrowLeft />
|
||
</button>
|
||
<div className="page-heading">
|
||
<h1>视频复刻</h1>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="video-flow-grid">
|
||
<section
|
||
className={`video-flow-panel replace-flow-panel${restoring ? " is-locked" : ""}`}
|
||
aria-busy={restoring}
|
||
inert={restoring}
|
||
>
|
||
<h2>准备复刻素材</h2>
|
||
<div className="replace-mode-switch" role="tablist" aria-label="选择视频复刻功能">
|
||
<button
|
||
type="button"
|
||
className={`replace-mode-button${replaceMode === "product" ? " active" : ""}`}
|
||
data-replace-mode="product"
|
||
role="tab"
|
||
aria-selected={replaceMode === "product"}
|
||
disabled={generating}
|
||
onClick={() => switchReplaceMode("product")}
|
||
>
|
||
<Package />
|
||
<span>替换商品</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`replace-mode-button${replaceMode === "character" ? " active" : ""}`}
|
||
data-replace-mode="character"
|
||
role="tab"
|
||
aria-selected={replaceMode === "character"}
|
||
disabled={generating}
|
||
onClick={() => switchReplaceMode("character")}
|
||
>
|
||
<UserRound />
|
||
<span>替换角色</span>
|
||
</button>
|
||
</div>
|
||
<div className="video-flow-step">
|
||
<div className="video-flow-step-head">
|
||
<strong>1. 参考视频</strong>
|
||
<span>MP4 / MOV · 最长 {REF_SECONDS_MAX[replaceMode]} 秒</span>
|
||
</div>
|
||
<div
|
||
className={[
|
||
"video-upload-field",
|
||
videoReady ? "has-file" : "",
|
||
videoPreview ? "has-preview" : "",
|
||
videoDrop.dragging ? "is-dragover" : "",
|
||
].filter(Boolean).join(" ")}
|
||
{...videoDrop.dropProps}
|
||
>
|
||
<input
|
||
ref={videoInputRef}
|
||
type="file"
|
||
accept={VIDEO_ACCEPT}
|
||
hidden
|
||
onChange={(event) => {
|
||
void pickVideo(event.target.files?.[0] || null);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
{videoPreview ? (
|
||
<div className="video-upload-preview">
|
||
<video src={videoPreview} controls playsInline preload="metadata" />
|
||
<div className="video-upload-preview-meta">
|
||
<strong>{videoFile?.name || filledSubjectName || "参考视频"}</strong>
|
||
<small>
|
||
{videoUploading
|
||
? "正在上传参考视频…"
|
||
: `${formatClock(videoMeta.duration)} · ${ratioCopy(aspectRatio)} · ${copy.videoReady}`}
|
||
</small>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="video-upload-change"
|
||
disabled={generating || videoUploading}
|
||
onClick={() => videoInputRef.current?.click()}
|
||
>
|
||
<RefreshCw />
|
||
更换视频
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="video-upload-trigger"
|
||
disabled={generating}
|
||
onClick={() => videoInputRef.current?.click()}
|
||
>
|
||
<FileVideo2 />
|
||
<strong>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
|
||
<small>{copy.videoEmpty}</small>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="video-flow-step">
|
||
<div className="video-flow-step-head">
|
||
<strong>{copy.targetStep}</strong>
|
||
<span>请选择一种方式</span>
|
||
</div>
|
||
<div className="product-replace-options">
|
||
<button
|
||
type="button"
|
||
className={`replace-product-method${source === "library" ? " active" : ""}${librarySelected ? " has-product-preview" : ""}${generating ? " is-disabled" : ""}`}
|
||
disabled={generating}
|
||
aria-disabled={generating}
|
||
onClick={() => {
|
||
if (generating) return;
|
||
if (replaceMode === "character") setPendingModelId(selectedModel?.id || "");
|
||
else setPendingProductId(selectedProduct?.id || "");
|
||
setLibraryOpen(true);
|
||
}}
|
||
>
|
||
<span className="replace-product-method-icon">
|
||
{librarySelected && libraryPreview
|
||
? <img src={libraryPreview} alt="" aria-hidden="true" />
|
||
: <LibraryBig />}
|
||
</span>
|
||
<span className="replace-product-method-copy">
|
||
<strong>
|
||
{librarySelected
|
||
? (replaceMode === "character" ? selectedModel?.name : selectedProduct?.title)
|
||
: copy.libraryTitle}
|
||
</strong>
|
||
<small>{librarySelected ? librarySelectionDetail(replaceMode, selectedProduct, selectedModel) || copy.libraryTitle : copy.libraryEmpty}</small>
|
||
</span>
|
||
<ChevronRight />
|
||
</button>
|
||
|
||
<div
|
||
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}${tempDrop.dragging ? " is-dragover" : ""}${generating ? " is-disabled" : ""}`}
|
||
role="button"
|
||
tabIndex={generating ? -1 : 0}
|
||
aria-disabled={generating}
|
||
{...(!generating ? tempDrop.dropProps : {})}
|
||
onClick={() => {
|
||
if (generating) return;
|
||
tempInputRef.current?.click();
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (generating) return;
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
tempInputRef.current?.click();
|
||
}
|
||
}}
|
||
>
|
||
<img className="replace-product-method-background" alt="" aria-hidden="true" />
|
||
<span className="replace-product-method-icon"><Upload /></span>
|
||
<span className="replace-product-method-copy">
|
||
<strong>{copy.temporaryTitle}</strong>
|
||
<small>
|
||
{tempDrop.dragging
|
||
? "松开即可添加"
|
||
: tempDisplay.length
|
||
? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}`
|
||
: copy.temporaryEmpty}
|
||
</small>
|
||
</span>
|
||
<ImagePlus />
|
||
{tempDisplay.length ? (
|
||
<span className="replace-temporary-preview">
|
||
<span className="replace-temporary-grid" aria-label={`临时上传的${copy.temporaryNoun}`}>
|
||
{cells.map((item, index) => (
|
||
<span className={`replace-temporary-cell${item ? "" : " empty"}`} key={item?.key || `temp-${index}`}>
|
||
{item ? (
|
||
<>
|
||
<img src={item.src} alt={item.name} />
|
||
<button
|
||
type="button"
|
||
className="replace-temporary-remove"
|
||
aria-label={`删除第${index + 1}张临时${copy.temporaryNoun}`}
|
||
disabled={generating}
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (generating) return;
|
||
updateCurrentForm((cur) => {
|
||
const nextFiles = cur.tempFiles.filter((_, itemIndex) => itemIndex !== index);
|
||
const nextAssetRefs = cur.tempAssetRefs.filter((_, itemIndex) => itemIndex !== index);
|
||
const nextSource = (nextFiles.length > 0 || nextAssetRefs.length > 0) ? cur.source : "";
|
||
return {
|
||
tempFiles: nextFiles,
|
||
tempAssetRefs: nextAssetRefs,
|
||
source: nextSource,
|
||
job: cur.job && !isInFlight(cur.job.status) ? null : cur.job,
|
||
};
|
||
});
|
||
onNotify("info", `已删除临时${copy.temporaryNoun}`);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</span>
|
||
))}
|
||
</span>
|
||
<span className="replace-temporary-more">
|
||
<span><ImagePlus /></span>
|
||
<strong>继续上传</strong>
|
||
<span className="replace-temporary-count">已上传 {tempDisplay.length} / 9</span>
|
||
<button
|
||
type="button"
|
||
className="replace-temporary-clear"
|
||
disabled={generating}
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (generating) return;
|
||
const cur = forms[replaceMode];
|
||
if (!cur.tempFiles.length && !cur.tempAssetRefs.length) return;
|
||
updateCurrentForm((c) => ({
|
||
tempFiles: [],
|
||
tempAssetRefs: [],
|
||
filledSubjectName: "",
|
||
source: c.source === "temporary" ? "" : c.source,
|
||
job: c.job && !isInFlight(c.job.status) ? null : c.job,
|
||
}));
|
||
onNotify("info", `已清空临时${copy.temporaryNoun}`);
|
||
}}
|
||
>
|
||
清空全部
|
||
</button>
|
||
</span>
|
||
</span>
|
||
) : null}
|
||
<input
|
||
ref={tempInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
multiple
|
||
hidden
|
||
disabled={generating}
|
||
onClick={(event) => event.stopPropagation()}
|
||
onChange={(event) => {
|
||
if (generating) return;
|
||
addTempImages(event.target.files);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{replaceMode === "product" ? (
|
||
<div className="video-flow-step">
|
||
<div className="video-flow-step-head">
|
||
<strong>{REPLACE_MODE_COPY.product.briefStep}</strong>
|
||
<span>{REPLACE_MODE_COPY.product.briefOptional}</span>
|
||
</div>
|
||
<textarea
|
||
className="textarea replace-brief-input"
|
||
value={subjectBrief}
|
||
maxLength={SUBJECT_BRIEF_MAX}
|
||
placeholder={REPLACE_MODE_COPY.product.briefPlaceholder}
|
||
disabled={generating}
|
||
aria-label={REPLACE_MODE_COPY.product.briefStep}
|
||
onChange={(event) => updateCurrentForm({ subjectBrief: event.target.value })}
|
||
/>
|
||
<p className="field-hint replace-brief-hint">{REPLACE_MODE_COPY.product.briefHint}</p>
|
||
</div>
|
||
) : (
|
||
<div className="tip replace-limit-tip" role="note">
|
||
<strong>请替换为同性别的角色</strong>
|
||
女性角色请换成女性、男性换成男性。成片会保留参考视频的原声,
|
||
所以跨性别替换会出现「画面是男生、声音还是女生」的情况。
|
||
</div>
|
||
)}
|
||
|
||
<div className="video-flow-actions replace-generate-action">
|
||
<button
|
||
type="button"
|
||
className="primary-action"
|
||
disabled={restoring || !videoReady || !productReady || generating}
|
||
onClick={() => void startGeneration()}
|
||
>
|
||
<Replace />
|
||
<span>{generateLabel}</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<aside className={panelClass} aria-live="polite">
|
||
<div className="replace-restoring-state" role="status" aria-live="polite">
|
||
<div className="replace-restoring-content">
|
||
<span className="replace-restoring-spinner" aria-hidden="true" />
|
||
<strong>正在读取任务状态</strong>
|
||
<span>确认有没有正在进行的复刻,稍候</span>
|
||
</div>
|
||
</div>
|
||
<div className="video-result-placeholder">
|
||
<div>
|
||
<div className="replace-placeholder-visual">
|
||
<span><Replace /></span>
|
||
</div>
|
||
<strong>等待素材上传</strong>
|
||
</div>
|
||
</div>
|
||
<div className="replace-generating-state" role="status" aria-live="polite">
|
||
<div className="replace-generating-content">
|
||
<div className="replace-generating-visual">
|
||
<span className="replace-generating-frame">{digesting ? <ScanLine /> : reviewing ? <ShieldCheck /> : <Clapperboard />}</span>
|
||
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
|
||
</div>
|
||
<strong>{digesting ? copy.digestingTitle : reviewing ? copy.reviewingTitle : copy.generatingTitle}</strong>
|
||
<span>{digesting ? copy.digestingCopy : reviewing ? copy.reviewingCopy : generatingCopy}</span>
|
||
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
|
||
</div>
|
||
</div>
|
||
<div className="video-analysis-result">
|
||
<h2>{resultCopy.resultTitle}</h2>
|
||
<div className="replace-preview">
|
||
{job?.video_url ? (
|
||
<video src={job.video_url} poster={job.thumbnail_url || undefined} controls playsInline />
|
||
) : null}
|
||
</div>
|
||
<div className="replace-preview-meta">
|
||
<strong>{productName ? `${productName}${resultCopy.modeLabel}` : remixTitle(job)}</strong>
|
||
<span>{(job?.duration || outputDuration)} 秒 · {ratioCopy(job?.aspect_ratio || aspectRatio)} · {job?.resolution || "720p"}</span>
|
||
</div>
|
||
<div className="video-flow-actions replace-result-actions">
|
||
<button type="button" className="secondary-action" onClick={() => job && fillFormFromTask(job)}>
|
||
<RefreshCw />
|
||
重新生成
|
||
</button>
|
||
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || remixTitle(job) || "视频复刻")}>
|
||
<Download />
|
||
下载视频
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<section className="replace-history-section" aria-labelledby="replaceHistoryTitle">
|
||
<div className="replace-history-head">
|
||
<h2 id="replaceHistoryTitle">已完成的视频复刻项目</h2>
|
||
<span>{history.length} 个项目</span>
|
||
</div>
|
||
{history.length === 0 ? (
|
||
<div className="replace-history-empty">还没有完成的视频复刻项目</div>
|
||
) : (
|
||
<div className="replace-history-list">
|
||
{history.map((item) => {
|
||
const open = expandedHistoryId === item.id;
|
||
const sourceVideo = videoRefFromTask(item);
|
||
return (
|
||
<article className={`replace-history-card${open ? " is-open" : ""}`} key={item.id}>
|
||
<button
|
||
type="button"
|
||
className="replace-history-summary"
|
||
aria-expanded={open}
|
||
onClick={() => toggleHistory(item.id)}
|
||
>
|
||
<span className="replace-history-cover">
|
||
{item.thumbnail_url ? <img src={item.thumbnail_url} alt="" /> : null}
|
||
<span>{formatClock(item.duration)}</span>
|
||
</span>
|
||
<span className="replace-history-copy">
|
||
<span>已完成 · {REPLACE_MODE_COPY[modeFromTask(item)].historyKind}</span>
|
||
<strong>{remixTitle(item)}</strong>
|
||
<small>参考视频 {item.duration} 秒 · {remixSourceLabel(item)} · {item.resolution}</small>
|
||
</span>
|
||
<span className="replace-history-toggle">
|
||
<ChevronDown />
|
||
{open ? "收起" : "展开对比"}
|
||
</span>
|
||
</button>
|
||
<div className="replace-history-compare" hidden={!open}>
|
||
<div className="replace-history-compare-pane">
|
||
<span>原视频</span>
|
||
{sourceVideo?.url ? (
|
||
<video src={sourceVideo.url} poster={sourceVideo.thumb_url || undefined} controls playsInline preload="metadata" />
|
||
) : (
|
||
<div className="replace-history-compare-empty">原视频暂不可用</div>
|
||
)}
|
||
</div>
|
||
<div className="replace-history-compare-pane">
|
||
<span>生成后</span>
|
||
{item.video_url ? (
|
||
<video src={item.video_url} poster={item.thumbnail_url || undefined} controls playsInline preload="metadata" />
|
||
) : (
|
||
<div className="replace-history-compare-empty">成片暂不可用</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</section>
|
||
</div>
|
||
|
||
<OverlayPortal>
|
||
<div className={`asset-library-layer${libraryOpen ? " open" : ""}`} aria-hidden={!libraryOpen}>
|
||
<button type="button" className="asset-library-scrim" aria-label="关闭资产库" onClick={() => setLibraryOpen(false)} />
|
||
<aside className="asset-library-drawer" role="dialog" aria-modal="true" aria-labelledby="assetLibraryTitle">
|
||
<header className="asset-library-head">
|
||
<div>
|
||
<h2 id="assetLibraryTitle">{copy.drawerTitle}</h2>
|
||
<p>{copy.drawerDescription}</p>
|
||
</div>
|
||
<button type="button" className="product-drawer-close" aria-label="关闭" onClick={() => setLibraryOpen(false)}>
|
||
<X />
|
||
</button>
|
||
</header>
|
||
<div className="asset-library-grid">
|
||
{replaceMode === "character" ? (
|
||
models.length === 0 ? (
|
||
<div className="asset-library-empty">{copy.drawerEmpty}</div>
|
||
) : models.map((model) => (
|
||
<button
|
||
type="button"
|
||
className={`asset-library-choice${pendingModelId === model.id ? " selected" : ""}`}
|
||
key={model.id}
|
||
onClick={() => setPendingModelId(model.id)}
|
||
>
|
||
<span className="asset-choice-check"><Check /></span>
|
||
{modelCover(model) ? <img src={modelCover(model)} alt={model.name} /> : <img alt={model.name} />}
|
||
<span>
|
||
<strong>{model.name}</strong>
|
||
<small>{model.is_official ? "官方模板" : "我的模特"} · {modelImageCount(model)} 张素材</small>
|
||
</span>
|
||
</button>
|
||
))
|
||
) : products.length === 0 ? (
|
||
<div className="asset-library-empty">{copy.drawerEmpty}</div>
|
||
) : products.map((product) => (
|
||
<button
|
||
type="button"
|
||
className={`asset-library-choice${pendingProductId === product.id ? " selected" : ""}`}
|
||
key={product.id}
|
||
onClick={() => setPendingProductId(product.id)}
|
||
>
|
||
<span className="asset-choice-check"><Check /></span>
|
||
{productCover(product) ? <img src={productCover(product)} alt={product.title} /> : <img alt={product.title} />}
|
||
<span>
|
||
<strong>{product.title}</strong>
|
||
<small>{product.category || "未分类"} · {productImageCount(product)} 张素材</small>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<footer className="asset-library-footer">
|
||
<button type="button" className="secondary-action" onClick={() => setLibraryOpen(false)}>取消</button>
|
||
<button type="button" className="primary-action" onClick={confirmLibrarySelection}>
|
||
<Check />
|
||
<span>确定使用</span>
|
||
</button>
|
||
</footer>
|
||
</aside>
|
||
</div>
|
||
</OverlayPortal>
|
||
</div>
|
||
);
|
||
}
|