优化住流程和添加复刻视频页面

This commit is contained in:
Azmat@qq.com
2026-08-26 18:44:03 +08:00
parent 0ee498d807
commit c25060c6c6
30 changed files with 2574 additions and 155 deletions
+768
View File
@@ -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>
);
}