优化住流程和添加复刻视频页面
This commit is contained in:
@@ -0,0 +1,768 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clapperboard,
|
||||
Download,
|
||||
FileVideo2,
|
||||
ImagePlus,
|
||||
LibraryBig,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Replace,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import { MediaLightbox } 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, Product } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
const JOB_KEY = "airshelf:video-replace-job";
|
||||
const REMIX_MARK = "[视频复刻]";
|
||||
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
|
||||
|
||||
type ProductSource = "library" | "temporary" | "";
|
||||
|
||||
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 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 isRemixTask(task: FreeVideoTask) {
|
||||
return (task.prompt || "").startsWith(REMIX_MARK);
|
||||
}
|
||||
|
||||
function productNameFromPrompt(prompt?: string) {
|
||||
const match = (prompt || "").match(/商品:([^\n。]+)/);
|
||||
return (match?.[1] || "").trim();
|
||||
}
|
||||
|
||||
function remixTitle(task?: Partial<FreeVideoTask> | null) {
|
||||
const name = productNameFromPrompt(task?.prompt);
|
||||
return name ? `${name}视频复刻` : "视频复刻预览";
|
||||
}
|
||||
|
||||
function remixSourceLabel(task?: Partial<FreeVideoTask> | null) {
|
||||
const prompt = task?.prompt || "";
|
||||
const name = productNameFromPrompt(prompt);
|
||||
if (/商品库/.test(prompt) && name) return `商品库:${name}`;
|
||||
return "临时商品素材";
|
||||
}
|
||||
|
||||
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 buildPrompt(productName: string, fromLibrary: boolean) {
|
||||
const source = fromLibrary ? "商品库中的" : "本次上传的";
|
||||
return `${REMIX_MARK} 商品:${productName}。使用${source}商品参考图,保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,将画面中的原商品完整替换为该商品。商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。`;
|
||||
}
|
||||
|
||||
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 [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 [tempFiles, setTempFiles] = useState<File[]>([]);
|
||||
const [tempPreviews, setTempPreviews] = useState<string[]>([]);
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
const [pendingProductId, setPendingProductId] = useState("");
|
||||
const [jobId, setJobId] = useState(readJobId);
|
||||
const [job, setJob] = useState<FreeVideoTask | null>(null);
|
||||
const [history, setHistory] = useState<FreeVideoTask[]>([]);
|
||||
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("");
|
||||
|
||||
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 productName = source === "library"
|
||||
? (selectedProduct?.title || "")
|
||||
: tempFiles[0]
|
||||
? (tempFiles.length > 1
|
||||
? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"}(${tempFiles.length}张参考图)`
|
||||
: (tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"))
|
||||
: "";
|
||||
const productReady = source === "library" ? Boolean(selectedProduct) : tempFiles.length > 0;
|
||||
const libraryPreview = selectedProduct ? productCover(selectedProduct) : "";
|
||||
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" ? (selectedProduct?.images || []).slice(0, MAX_IMAGES) : tempFiles).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 panelClass = [
|
||||
"video-result-panel replace-result-panel",
|
||||
generating ? "is-generating" : "",
|
||||
hasResult ? "has-result" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const generateLabel = generating
|
||||
? "正在复刻…"
|
||||
: hasResult
|
||||
? `再次复刻 · 消耗 ${points} 积分`
|
||||
: `开始复刻 · 消耗 ${points} 积分`;
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const data = await api.freeVideoTasks(0, 50);
|
||||
setHistory((data.results || []).filter((item) => isRemixTask(item) && item.status === "succeeded"));
|
||||
} catch {
|
||||
/* 历史失败不挡当前复刻 */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void api.products(100).then((payload) => setProducts(payload.results || [])).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]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await api.pollFreeVideo(jobId);
|
||||
if (cancelled) return;
|
||||
setJob(data.task);
|
||||
if (isInFlight(data.task.status)) {
|
||||
timer = window.setTimeout(poll, 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张商品图片");
|
||||
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);
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
};
|
||||
|
||||
const confirmLibraryProduct = () => {
|
||||
const product = products.find((item) => item.id === pendingProductId);
|
||||
if (!product) {
|
||||
onNotify("info", "请先选择或上传商品素材");
|
||||
return;
|
||||
}
|
||||
setSelectedProduct(product);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setLibraryOpen(false);
|
||||
setPendingProductId("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("success", `已选择商品:${product.title}`);
|
||||
};
|
||||
|
||||
const startGeneration = async () => {
|
||||
if (!videoFile || !productReady || generating) return;
|
||||
if (!preferredModel) {
|
||||
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",
|
||||
}];
|
||||
}
|
||||
if (!imageRefs.length) {
|
||||
onNotify("error", "这个商品还没有可用图片");
|
||||
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",
|
||||
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);
|
||||
rememberJob(data.task.id);
|
||||
completedNoticeRef.current = "";
|
||||
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 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 openHistory = (item: FreeVideoTask) => {
|
||||
setJob(item);
|
||||
setJobId("");
|
||||
forgetJob();
|
||||
};
|
||||
|
||||
const cells = Array.from({ length: 9 }, (_, index) => tempFiles[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="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>1. 参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
</div>
|
||||
<label className={`video-upload-field${videoFile ? " 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 ? videoFile.name : "点击上传参考视频"}</strong>
|
||||
<small>{videoFile ? "视频已就绪,将自动识别原商品区域" : "系统将自动识别需要替换的商品区域"}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>2. 选择自己的商品</strong>
|
||||
<span>请选择一种方式</span>
|
||||
</div>
|
||||
<div className="product-replace-options">
|
||||
<button
|
||||
type="button"
|
||||
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`}
|
||||
onClick={() => {
|
||||
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>
|
||||
</span>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
|
||||
<label
|
||||
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempFiles.length ? " has-images" : ""}`}
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
<ImagePlus />
|
||||
<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 ? (
|
||||
<>
|
||||
<img src={tempPreviews[index]} alt={file.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="replace-temporary-remove"
|
||||
aria-label={`删除第${index + 1}张临时商品图片`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("info", "已删除临时商品图片");
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="replace-temporary-more">
|
||||
<span><ImagePlus /></span>
|
||||
<strong>继续上传</strong>
|
||||
<span className="replace-temporary-count">已上传 {tempFiles.length} / 9</span>
|
||||
<button
|
||||
type="button"
|
||||
className="replace-temporary-clear"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTempFiles([]);
|
||||
if (source === "temporary") setSource("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("info", "已清空临时商品图片");
|
||||
}}
|
||||
>
|
||||
清空全部
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
ref={tempInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
addTempImages(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="video-flow-actions replace-generate-action">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
disabled={!videoFile || !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"><Clapperboard /></span>
|
||||
<span className="replace-generating-product"><Package /></span>
|
||||
</div>
|
||||
<strong>正在进行视频复刻</strong>
|
||||
<span>正在匹配商品外观与原片镜头</span>
|
||||
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-analysis-result">
|
||||
<h2>复刻任务已完成</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>
|
||||
</div>
|
||||
<div className="video-flow-actions replace-result-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => void startGeneration()}>
|
||||
<RefreshCw />
|
||||
重新生成
|
||||
</button>
|
||||
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || "视频复刻")}>
|
||||
<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) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
) : 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={confirmLibraryProduct}>
|
||||
<Check />
|
||||
<span>确定使用</span>
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<MediaLightbox
|
||||
open={Boolean(playing?.url)}
|
||||
src={playing?.url || ""}
|
||||
kind="video"
|
||||
name={playing?.title}
|
||||
close={() => setPlaying(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user