1102 lines
45 KiB
TypeScript
1102 lines
45 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 {
|
||
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>
|
||
);
|
||
}
|