完成视频复刻和优化
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user