Files
yingqing/core/frontend/src/routes/video-replace.tsx
T
2026-08-27 17:58:48 +08:00

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