完成视频复刻和优化

This commit is contained in:
Azmat@qq.com
2026-08-27 11:54:47 +08:00
parent c25060c6c6
commit 51620bf25b
46 changed files with 2575 additions and 663 deletions
+439 -169
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
Check,
ChevronDown,
ChevronRight,
Clapperboard,
Download,
@@ -13,10 +13,11 @@ import {
RefreshCw,
Replace,
Upload,
UserRound,
X,
} from "lucide-react";
import { api, ApiError } from "../api";
import { MediaLightbox } from "../components/overlays";
import { OverlayPortal, useBodyScrollLock } from "../components/overlays";
import {
DEFAULT_BILLING_RATES,
FC_MODELS,
@@ -27,14 +28,64 @@ import {
isInFlight,
type BillingRates,
} from "../components/free-create/constants";
import type { FreeVideoRef, FreeVideoTask, ModelConfig, Product } from "../types";
import type { FreeVideoRef, FreeVideoTask, ModelConfig, ModelEntity, Product } from "../types";
import type { NavigateFn } from "./route-config";
const JOB_KEY = "airshelf:video-replace-job";
const REMIX_MARK = "[视频复刻]";
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: "正在匹配商品外观与原片镜头",
resultTitle: "商品复刻已完成",
resultPreview: "商品复刻预览",
consistency: "商品一致性检查通过",
drawerTitle: "选择商品",
drawerDescription: "从已经创建的商品中选择一个用于本次视频复刻。",
drawerEmpty: "还没有商品,先去商品库创建一个",
historyKind: "商品",
pickToast: "已选择商品",
},
character: {
modeLabel: "角色复刻",
targetLabel: "角色",
targetStep: "2. 选择自己的角色",
videoEmpty: "系统将自动识别需要替换的原片角色",
videoReady: "视频已就绪,将自动识别原片角色",
libraryTitle: "从人物库选择",
libraryEmpty: "选择已创建的人物",
temporaryTitle: "临时上传角色",
temporaryEmpty: "仅用于本次任务 · 最多 9 张参考图",
temporaryNoun: "角色参考图",
temporaryFallback: "临时角色素材",
generatingTitle: "正在进行角色复刻",
generatingCopy: "正在匹配角色外观、表情与原片动作",
resultTitle: "角色复刻已完成",
resultPreview: "角色复刻预览",
consistency: "角色一致性检查通过",
drawerTitle: "选择模特",
drawerDescription: "从人物库中选择一个角色用于本次视频复刻。",
drawerEmpty: "还没有人物,先去模特库添加",
historyKind: "角色",
pickToast: "已选择角色",
},
} as const;
function readJobId() {
try {
@@ -94,27 +145,59 @@ function clampDuration(seconds: number) {
return Math.min(15, Math.max(4, rounded || 15));
}
function isRemixTask(task: FreeVideoTask) {
return (task.prompt || "").startsWith(REMIX_MARK);
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 productNameFromPrompt(prompt?: string) {
const match = (prompt || "").match(/商品:([^\n。]+)/);
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 name = productNameFromPrompt(task?.prompt);
return name ? `${name}视频复刻` : "视频复刻预览";
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 = productNameFromPrompt(prompt);
if (/商品库/.test(prompt) && name) return `商品库:${name}`;
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) {
return (task?.references || []).find((item) => item.type === "video") || null;
}
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 || "";
}
@@ -123,9 +206,12 @@ function productImageCount(product: Product) {
return product.images?.filter((image) => image.asset || image.preview_url).length || 0;
}
function buildPrompt(productName: string, fromLibrary: boolean) {
const source = fromLibrary ? "商品库中的" : "本次上传的";
return `${REMIX_MARK} 商品:${productName}。使用${source}商品参考图,保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,将画面中的原商品完整替换为该商品。商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。`;
function modelCover(model: ModelEntity) {
return model.portrait || model.triview || "";
}
function modelImageCount(model: ModelEntity) {
return [model.portrait, model.triview].filter(Boolean).length;
}
export function VideoReplacePage({
@@ -143,22 +229,28 @@ export function VideoReplacePage({
navigate?: NavigateFn;
}) {
const [products, setProducts] = useState(initialProducts);
const [models, setModels] = useState<ModelEntity[]>([]);
const [replaceMode, setReplaceMode] = useState<ReplaceMode>("product");
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 [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const tempInputRef = useRef<HTMLInputElement>(null);
const completedNoticeRef = useRef("");
@@ -172,15 +264,33 @@ export function VideoReplacePage({
[videoConfigs],
);
const copy = REPLACE_MODE_COPY[replaceMode];
const videoReady = Boolean(videoRef?.asset_id);
const productName = source === "library"
? (selectedProduct?.title || "")
: tempFiles[0]
? (tempFiles.length > 1
? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"}${tempFiles.length}张参考图)`
: (tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"))
? (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" ? Boolean(selectedProduct) : tempFiles.length > 0;
const libraryPreview = selectedProduct ? productCover(selectedProduct) : "";
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, {
@@ -189,12 +299,16 @@ export function VideoReplacePage({
duration: outputDuration,
refs: [
...(videoRef ? [{ type: "video", duration: videoRef.duration || videoMeta.duration }] : []),
...((source === "library" ? (selectedProduct?.images || []).slice(0, MAX_IMAGES) : tempFiles).map(() => ({ type: "image" }))),
...((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 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" : "",
@@ -203,13 +317,13 @@ export function VideoReplacePage({
const generateLabel = generating
? "正在复刻…"
: hasResult
? `再次复刻 · 消耗 ${points} 积分`
: `开始复刻 · 消耗 ${points} 积分`;
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
const loadHistory = async () => {
try {
const data = await api.freeVideoTasks(0, 50);
setHistory((data.results || []).filter((item) => isRemixTask(item) && item.status === "succeeded"));
const data = await api.videoReplaceTasks(0, 50);
setHistory((data.results || []).filter((item) => item.status === "succeeded"));
} catch {
/* 历史失败不挡当前复刻 */
}
@@ -217,6 +331,7 @@ export function VideoReplacePage({
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,
@@ -238,14 +353,7 @@ export function VideoReplacePage({
return () => urls.forEach((url) => URL.revokeObjectURL(url));
}, [tempFiles]);
useEffect(() => {
if (!libraryOpen) return;
const previous = document.body.classList.contains("asset-library-open");
document.body.classList.add("asset-library-open");
return () => {
if (!previous) document.body.classList.remove("asset-library-open");
};
}, [libraryOpen]);
useBodyScrollLock(libraryOpen);
useEffect(() => {
if (!jobId) return;
@@ -253,7 +361,7 @@ export function VideoReplacePage({
let timer = 0;
const poll = async () => {
try {
const data = await api.pollFreeVideo(jobId);
const data = await api.pollVideoReplace(jobId);
if (cancelled) return;
setJob(data.task);
if (isInFlight(data.task.status)) {
@@ -349,7 +457,7 @@ export function VideoReplacePage({
});
const room = Math.max(0, MAX_IMAGES - current.length);
if (room === 0) {
onNotify("info", "最多上传9张商品图片");
onNotify("info", `最多上传9张${copy.temporaryNoun}`);
return current;
}
if (!unique.length) {
@@ -361,92 +469,107 @@ export function VideoReplacePage({
});
setSource("temporary");
setSelectedProduct(null);
setSelectedModel(null);
setTempAssetRefs([]);
setFilledSubjectName("");
if (job && !isInFlight(job.status)) setJob(null);
};
const confirmLibraryProduct = () => {
const switchReplaceMode = (next: ReplaceMode) => {
if (next === replaceMode || generating) return;
setReplaceMode(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", `已选择商品${product.title}`);
onNotify("success", `${copy.pickToast}${product.title}`);
};
const startGeneration = async () => {
if (!videoFile || !productReady || generating) return;
if (!videoReady || !productReady || generating) return;
if (!preferredModel) {
onNotify("error", "暂无可用视频模型");
return;
}
if (!videoRef?.asset_id) {
onNotify("error", "请先上传参考视频");
return;
}
setSubmitting(true);
try {
let imageRefs: FreeVideoRef[] = [];
if (source === "library" && selectedProduct) {
imageRefs = (selectedProduct.images || [])
.filter((image) => image.asset || image.preview_url)
.slice(0, MAX_IMAGES)
.map((image, index) => ({
url: image.preview_url || "",
type: "image" as const,
role: "reference_image",
label: `${selectedProduct.title}${index + 1}`,
asset_id: image.asset,
source: "asset" as const,
}));
if (!imageRefs.length && (selectedProduct.cover_asset || productCover(selectedProduct))) {
imageRefs = [{
url: productCover(selectedProduct),
type: "image",
role: "reference_image",
label: selectedProduct.title,
asset_id: selectedProduct.cover_asset || undefined,
source: selectedProduct.cover_asset ? "asset" : "upload",
}];
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 (!imageRefs.length) {
onNotify("error", "这个商品还没有可用图片");
if (!imageAssetIds.length) {
onNotify("error", replaceMode === "character" ? "请上传角色参考图" : "请上传商品参考图");
return;
}
} else {
const uploaded: FreeVideoRef[] = [];
for (const file of tempFiles.slice(0, MAX_IMAGES)) {
const form = new FormData();
form.append("file", file);
const data = await api.uploadFreeVideoRef(form);
uploaded.push({
url: data.url,
type: "image",
role: "reference_image",
label: data.name || file.name,
thumb_url: data.thumb_url || data.url,
asset_id: data.asset_id,
source: "upload",
});
}
imageRefs = uploaded;
}
if (!videoRef) {
onNotify("error", "请先上传参考视频");
return;
}
const prompt = buildPrompt(productName.replace(/\d+张参考图)$/, ""), source === "library");
const data = await api.submitFreeVideo({
prompt,
mode: "universal",
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,
seed: -1,
generate_audio: true,
references: [videoRef, ...imageRefs],
});
setJob(data.task);
setJobId(data.task.id);
@@ -465,6 +588,68 @@ export function VideoReplacePage({
}
};
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);
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");
@@ -477,13 +662,11 @@ export function VideoReplacePage({
onNotify("success", "已开始下载视频复刻成片");
};
const openHistory = (item: FreeVideoTask) => {
setJob(item);
setJobId("");
forgetJob();
const toggleHistory = (id: string) => {
setExpandedHistoryId((current) => (current === id ? "" : id));
};
const cells = Array.from({ length: 9 }, (_, index) => tempFiles[index] || null);
const cells = Array.from({ length: 9 }, (_, index) => tempDisplay[index] || null);
return (
<div className="vrep-page">
@@ -503,12 +686,38 @@ export function VideoReplacePage({
<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 · 60 </span>
<span>MP4 / MOV · 15 </span>
</div>
<label className={`video-upload-field${videoFile ? " has-file" : ""}`}>
<label className={`video-upload-field${videoReady ? " has-file" : ""}`}>
<input
ref={videoInputRef}
type="file"
@@ -521,15 +730,15 @@ export function VideoReplacePage({
/>
<span>
<FileVideo2 />
<strong>{videoFile ? videoFile.name : "点击上传参考视频"}</strong>
<small>{videoFile ? "视频已就绪,将自动识别原商品区域" : "系统将自动识别需要替换的商品区域"}</small>
<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>2. </strong>
<strong>{copy.targetStep}</strong>
<span></span>
</div>
<div className="product-replace-options">
@@ -537,46 +746,65 @@ export function VideoReplacePage({
type="button"
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`}
onClick={() => {
setPendingProductId(selectedProduct?.id || "");
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></strong>
<small>{source === "library" && selectedProduct ? `已选择 · ${selectedProduct.title}` : "选择已创建的商品"}</small>
<strong>{copy.libraryTitle}</strong>
<small>
{source === "library" && (replaceMode === "character" ? selectedModel : selectedProduct)
? `已选择 · ${replaceMode === "character" ? selectedModel?.name : selectedProduct?.title}`
: copy.libraryEmpty}
</small>
</span>
<ChevronRight />
</button>
<label
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempFiles.length ? " has-images" : ""}`}
<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></strong>
<small>{tempFiles.length ? `已上传 ${tempFiles.length}商品图` : "仅用于本次任务 · 最多 9 张"}</small>
<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="临时上传的商品图片">
{cells.map((file, index) => (
<span className={`replace-temporary-cell${file ? "" : " empty"}`} key={`temp-${index}`}>
{file ? (
<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={tempPreviews[index]} alt={file.name} />
<img src={item.src} alt={item.name} />
<button
type="button"
className="replace-temporary-remove"
aria-label={`删除第${index + 1}张临时商品图片`}
aria-label={`删除第${index + 1}张临时${copy.temporaryNoun}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
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", "已删除临时商品图片");
onNotify("info", `已删除临时${copy.temporaryNoun}`);
}}
>
×
@@ -589,35 +817,40 @@ export function VideoReplacePage({
<span className="replace-temporary-more">
<span><ImagePlus /></span>
<strong></strong>
<span className="replace-temporary-count"> {tempFiles.length} / 9</span>
<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", "已清空临时商品图片");
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 = "";
}}
/>
</label>
</div>
</div>
</div>
@@ -625,7 +858,7 @@ export function VideoReplacePage({
<button
type="button"
className="primary-action"
disabled={!videoFile || !productReady || generating}
disabled={!videoReady || !productReady || generating}
onClick={() => void startGeneration()}
>
<Replace />
@@ -647,28 +880,30 @@ export function VideoReplacePage({
<div className="replace-generating-content">
<div className="replace-generating-visual">
<span className="replace-generating-frame"><Clapperboard /></span>
<span className="replace-generating-product"><Package /></span>
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
</div>
<strong></strong>
<span></span>
<strong>{copy.generatingTitle}</strong>
<span>{copy.generatingCopy}</span>
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
</div>
</div>
<div className="video-analysis-result">
<h2></h2>
<h2>{resultCopy.resultTitle}</h2>
<div className="replace-preview">
{job?.video_url ? <video src={job.video_url} poster={job.thumbnail_url || undefined} muted playsInline /> : null}
<div className="replace-preview-copy">
<strong>{productName ? `${productName}视频复刻预览` : remixTitle(job)}</strong>
<span>{outputDuration} · {ratioCopy(job?.aspect_ratio || aspectRatio)} · </span>
</div>
{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={() => void startGeneration()}>
<button type="button" className="secondary-action" onClick={() => job && fillFormFromTask(job)}>
<RefreshCw />
</button>
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || "视频复刻")}>
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || remixTitle(job) || "视频复刻")}>
<Download />
</button>
@@ -686,50 +921,92 @@ export function VideoReplacePage({
<div className="replace-history-empty"></div>
) : (
<div className="replace-history-list">
{history.map((item) => (
<article className="replace-history-card" key={item.id}>
<div
className="replace-history-cover"
onClick={() => {
if (!item.video_url) return;
setPlaying({ url: item.video_url, title: remixTitle(item) });
}}
>
{item.thumbnail_url ? <img src={item.thumbnail_url} alt={`${remixTitle(item)}封面`} /> : null}
<span>{formatClock(item.duration)}</span>
</div>
<div className="replace-history-copy">
<span></span>
<h3>{remixTitle(item)}</h3>
<p> {item.duration} · {remixSourceLabel(item)} · {item.aspect_ratio} · {item.resolution}</p>
</div>
<button type="button" className="replace-history-open" onClick={() => openHistory(item)}>
<ArrowUpRight />
</button>
</article>
))}
{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"></h2>
<p></p>
<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">
{products.length === 0 ? (
<div className="asset-library-empty"></div>
{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"
@@ -748,21 +1025,14 @@ export function VideoReplacePage({
</div>
<footer className="asset-library-footer">
<button type="button" className="secondary-action" onClick={() => setLibraryOpen(false)}></button>
<button type="button" className="primary-action" onClick={confirmLibraryProduct}>
<button type="button" className="primary-action" onClick={confirmLibrarySelection}>
<Check />
<span>使</span>
</button>
</footer>
</aside>
</div>
<MediaLightbox
open={Boolean(playing?.url)}
src={playing?.url || ""}
kind="video"
name={playing?.title}
close={() => setPlaying(null)}
/>
</OverlayPortal>
</div>
);
}