Files
yingqing/core/frontend/src/routes/video-replace.tsx
T

1478 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
);
}