完善视频提炼和视频复刻完善提交测试

This commit is contained in:
Azmat@qq.com
2026-08-28 10:15:15 +08:00
parent 52c5cdae1d
commit 849b14a0f6
33 changed files with 1475 additions and 208 deletions
+199 -29
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import { api, ApiError } from "../api";
import { OverlayPortal, useBodyScrollLock } from "../components/overlays";
import { useFileDrop } from "../components/use-file-drop";
import {
DEFAULT_BILLING_RATES,
FC_MODELS,
@@ -37,6 +38,13 @@ const JOB_KEY = "airshelf:video-replace-job";
const MODE_KEY = "airshelf:video-replace-mode";
const CHARACTER_MARK = "[视频复刻·角色]";
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
// 商品复刻的参考视频只用来提炼分镜稿,不发火山,所以能到 60 秒;
// 角色复刻仍把参考视频直传火山,卡死在火山的 15 秒。
const PRODUCT_SOURCE_PURPOSE = "video_replace_product";
const REF_SECONDS_MAX = { product: 60, character: 15 } as const;
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 50 * 1024 * 1024 } as const;
// 火山单次出片上限。参考视频更长时不报错,成片按这个截断,提示词里会要求模型压缩改编。
const SEEDANCE_MAX_OUTPUT_SECONDS = 15;
type ProductSource = "library" | "temporary" | "";
type ReplaceMode = "product" | "character";
@@ -50,7 +58,7 @@ const REPLACE_MODE_COPY = {
videoReady: "视频已就绪,将先提炼分镜稿再复刻",
libraryTitle: "从商品库选择",
libraryEmpty: "选择已创建的商品",
temporaryTitle: "临时上传商品",
temporaryTitle: "临时上传素材",
temporaryEmpty: "仅用于本次任务 · 最多 9 张",
temporaryNoun: "商品图",
temporaryFallback: "临时商品素材",
@@ -170,8 +178,9 @@ function formatClock(seconds: number) {
}
function clampDuration(seconds: number) {
const rounded = Math.round(Number(seconds) || 15);
return Math.min(15, Math.max(4, rounded || 15));
const rounded = Math.round(Number(seconds) || SEEDANCE_MAX_OUTPUT_SECONDS);
// 60 秒参考视频不是错误:成片取火山能出的最长,分镜稿由模型压缩改编。
return Math.min(SEEDANCE_MAX_OUTPUT_SECONDS, Math.max(4, rounded || SEEDANCE_MAX_OUTPUT_SECONDS));
}
function isCharacterRemix(task?: Partial<FreeVideoTask> | null) {
@@ -240,6 +249,24 @@ function productImageCount(product: Product) {
return product.images?.filter((image) => image.asset || image.preview_url).length || 0;
}
/** 选中后这次实际会带给模型的参考图。三视图是 standalone 资产,不在 images 里,单独点名。 */
function librarySelectionDetail(mode: ReplaceMode, product: Product | null, model: ModelEntity | null) {
if (mode === "character") {
if (!model) return "";
const parts = [model.portrait ? "定妆照" : "", model.triview ? "三视图" : ""].filter(Boolean);
return parts.length ? `将带上 ${parts.join(" + ")}` : "";
}
if (!product) return "";
const count = productImageCount(product) || (product.cover_preview_url ? 1 : 0);
const parts = [count ? `${count} 张商品图` : ""];
parts.push(product.triview_preview_url ? "三视图" : "");
const kept = parts.filter(Boolean);
if (!kept.length) return "";
return product.triview_preview_url
? `将带上 ${kept.join(" + ")}`
: `将带上 ${kept.join("")} · 该商品还没有三视图`;
}
function modelCover(model: ModelEntity) {
return model.portrait || model.triview || "";
}
@@ -266,8 +293,12 @@ export function VideoReplacePage({
const [models, setModels] = useState<ModelEntity[]>([]);
const [replaceMode, setReplaceMode] = useState<ReplaceMode>(readReplaceMode);
const [videoFile, setVideoFile] = useState<File | null>(null);
// 选完立刻用本地 objectURL 放预览,不等上传回来 —— 用户要先看见自己选的片子
const [videoPreview, setVideoPreview] = useState("");
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
const [videoUploading, setVideoUploading] = useState(false);
// 进页面先向后端确认有没有在跑的任务,确认完再决定显示表单还是进度
const [restoring, setRestoring] = useState(true);
const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 });
const [source, setSource] = useState<ProductSource>("");
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
@@ -314,6 +345,7 @@ export function VideoReplacePage({
const productReady = source === "library"
? (replaceMode === "character" ? Boolean(selectedModel) : Boolean(selectedProduct))
: (tempFiles.length > 0 || tempAssetRefs.length > 0);
const librarySelected = source === "library" && Boolean(replaceMode === "character" ? selectedModel : selectedProduct);
const libraryPreview = replaceMode === "character"
? (selectedModel ? modelCover(selectedModel) : "")
: (selectedProduct ? productCover(selectedProduct) : "");
@@ -345,7 +377,9 @@ export function VideoReplacePage({
],
}, billingRates);
const points = estimated.points || 220;
const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading;
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
const generating = Boolean(job && isInFlight(job.status)) || submitting;
const digesting = Boolean(job && isDigesting(job));
// 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。
const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created")));
@@ -353,6 +387,7 @@ export function VideoReplacePage({
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
const panelClass = [
"video-result-panel replace-result-panel",
restoring ? "is-restoring" : "",
generating ? "is-generating" : "",
reviewing || digesting ? "is-reviewing" : "",
hasResult ? "has-result" : "",
@@ -367,11 +402,40 @@ export function VideoReplacePage({
try {
const data = await api.videoReplaceTasks(0, 50);
setHistory((data.results || []).filter((item) => item.status === "succeeded"));
return data;
} catch {
/* 历史失败不挡当前复刻 */
return null;
}
};
// 恢复在跑的任务。拉到之前一律显示「读取中」——否则用户以为没任务,又传一次。
const restoreInflight = async () => {
const data = await loadHistory();
const running = data?.inflight || null;
if (running) {
// 静默恢复:只把进度接上。不要走 fillFormFromTask —— 它会弹 toast、滚动页面,
// 还依赖商品/模特列表加载完,拿来做刷新恢复会又吵又不稳。
setJob(running);
setJobId(running.id);
rememberJob(running.id);
const mode = modeFromTask(running);
setReplaceMode(mode);
rememberReplaceMode(mode);
const video = videoRefFromTask(running);
if (video?.url) setVideoPreview(video.url);
setVideoRef(video ? { ...video, type: "video", role: "reference_video", label: video.label || "参考视频" } : null);
setFilledSubjectName(subjectNameFromTask(running));
setVideoMeta({
duration: Number(video?.duration || running.duration || 0),
...sizeFromRatio(running.aspect_ratio || "9:16"),
});
} else {
forgetJob();
}
setRestoring(false);
};
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);
@@ -382,7 +446,7 @@ export function VideoReplacePage({
multiplier: Number(config.team_price_multiplier) || 1,
}))
.catch(() => undefined);
void loadHistory();
void restoreInflight();
}, []);
useEffect(() => {
@@ -455,7 +519,10 @@ export function VideoReplacePage({
const pickVideo = async (file: File | null) => {
if (!file) return;
const check = await checkRefFile(file);
const check = await checkRefFile(file, {
maxSeconds: REF_SECONDS_MAX[replaceMode],
maxVideoBytes: REF_BYTES_MAX[replaceMode],
});
if (!check.ok) {
onNotify("error", check.error);
return;
@@ -465,11 +532,13 @@ export function VideoReplacePage({
return;
}
setVideoFile(file);
setVideoPreview(URL.createObjectURL(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);
if (replaceMode === "product") form.append("purpose", PRODUCT_SOURCE_PURPOSE);
try {
const uploaded = await api.uploadFreeVideoRef(form);
setVideoRef({
@@ -490,13 +559,14 @@ export function VideoReplacePage({
} catch (error) {
setVideoFile(null);
setVideoRef(null);
setVideoPreview("");
onNotify("error", error instanceof Error ? error.message : "参考视频上传失败");
} finally {
setVideoUploading(false);
}
};
const addTempImages = (files: FileList | null) => {
const addTempImages = (files: FileList | File[] | null) => {
const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type));
if (!incoming.length) {
onNotify("error", "请选择 JPG、PNG 或 WebP 图片");
@@ -530,8 +600,33 @@ export function VideoReplacePage({
if (job && !isInFlight(job.status)) setJob(null);
};
useEffect(() => {
// 换一条预览或离开页面时释放 objectURL,不然一路选下去会攒一堆 blob
if (!videoPreview.startsWith("blob:")) return;
return () => URL.revokeObjectURL(videoPreview);
}, [videoPreview]);
const tempDrop = useFileDrop(
(files) => addTempImages(files),
{ disabled: generating }
);
const videoDrop = useFileDrop(
(files) => { void pickVideo(files[0] || null); },
{ disabled: generating || videoUploading }
);
const switchReplaceMode = (next: ReplaceMode) => {
if (next === replaceMode || generating) return;
// 商品复刻能传 60 秒,角色复刻只能 15 秒。带着超长视频切过去会一路走到提交才报错,
// 这里直接清掉并说明原因。
const tooLongForNext = (videoMeta.duration || 0) > REF_SECONDS_MAX[next] + 0.5;
if (tooLongForNext) {
setVideoFile(null);
setVideoRef(null);
setVideoPreview("");
setVideoMeta({ duration: 0, width: 0, height: 0 });
}
setReplaceMode(next);
rememberReplaceMode(next);
setSource("");
@@ -544,7 +639,12 @@ export function VideoReplacePage({
setPendingModelId("");
setLibraryOpen(false);
if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`);
onNotify(
"info",
tooLongForNext
? `已切换为${REPLACE_MODE_COPY[next].modeLabel},参考视频最长 ${REF_SECONDS_MAX[next]} 秒,请重新上传`
: `已切换为${REPLACE_MODE_COPY[next].modeLabel}`
);
};
const confirmLibrarySelection = () => {
@@ -647,6 +747,17 @@ export function VideoReplacePage({
forgetJob();
}
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
// 后端单飞闸:已有复刻在跑。直接接上它,别让用户对着报错干瞪眼。
const running = (error.payload as { inflight?: FreeVideoTask } | undefined)?.inflight;
if (running) {
setJob(running);
setJobId(running.id);
rememberJob(running.id);
}
onNotify("info", error.message || "已有一个视频正在复刻中");
return;
}
onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败");
} finally {
setSubmitting(false);
@@ -666,6 +777,8 @@ export function VideoReplacePage({
setReplaceMode(mode);
rememberReplaceMode(mode);
setVideoFile(null);
// 从历史「重新生成」回填时也要把原视频放进预览框,否则第 1 步看起来像没选
setVideoPreview(video.url || "");
setVideoRef({
...video,
type: "video",
@@ -750,7 +863,11 @@ export function VideoReplacePage({
</header>
<div className="video-flow-grid">
<section className="video-flow-panel replace-flow-panel">
<section
className={`video-flow-panel replace-flow-panel${restoring ? " is-locked" : ""}`}
aria-busy={restoring}
inert={restoring}
>
<h2>准备复刻素材</h2>
<div className="replace-mode-switch" role="tablist" aria-label="选择视频复刻功能">
<button
@@ -781,9 +898,17 @@ export function VideoReplacePage({
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>1. 参考视频</strong>
<span>MP4 / MOV · 最长 15 秒</span>
<span>MP4 / MOV · 最长 {REF_SECONDS_MAX[replaceMode]} 秒</span>
</div>
<label className={`video-upload-field${videoReady ? " has-file" : ""}`}>
<div
className={[
"video-upload-field",
videoReady ? "has-file" : "",
videoPreview ? "has-preview" : "",
videoDrop.dragging ? "is-dragover" : "",
].filter(Boolean).join(" ")}
{...videoDrop.dropProps}
>
<input
ref={videoInputRef}
type="file"
@@ -794,12 +919,40 @@ export function VideoReplacePage({
event.target.value = "";
}}
/>
<span>
<FileVideo2 />
<strong>{videoFile?.name || (videoReady ? "参考视频已就绪" : "点击上传参考视频")}</strong>
<small>{videoReady ? copy.videoReady : copy.videoEmpty}</small>
</span>
</label>
{videoPreview ? (
<div className="video-upload-preview">
<video src={videoPreview} controls playsInline preload="metadata" />
<div className="video-upload-preview-meta">
<strong>{videoFile?.name || filledSubjectName || "参考视频"}</strong>
<small>
{videoUploading
? "正在上传参考视频…"
: `${formatClock(videoMeta.duration)} · ${ratioCopy(aspectRatio)} · ${copy.videoReady}`}
</small>
</div>
<button
type="button"
className="video-upload-change"
disabled={generating || videoUploading}
onClick={() => videoInputRef.current?.click()}
>
<RefreshCw />
更换视频
</button>
</div>
) : (
<button
type="button"
className="video-upload-trigger"
disabled={generating}
onClick={() => videoInputRef.current?.click()}
>
<FileVideo2 />
<strong>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
<small>{copy.videoEmpty}</small>
</button>
)}
</div>
</div>
<div className="video-flow-step">
@@ -810,30 +963,34 @@ export function VideoReplacePage({
<div className="product-replace-options">
<button
type="button"
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`}
className={`replace-product-method${source === "library" ? " active" : ""}${librarySelected ? " has-product-preview" : ""}`}
onClick={() => {
if (replaceMode === "character") setPendingModelId(selectedModel?.id || "");
else setPendingProductId(selectedProduct?.id || "");
setLibraryOpen(true);
}}
>
{libraryPreview ? <img className="replace-product-method-background" src={libraryPreview} alt="" aria-hidden="true" /> : <img className="replace-product-method-background" alt="" aria-hidden="true" />}
<span className="replace-product-method-icon"><LibraryBig /></span>
<span className="replace-product-method-icon">
{librarySelected && libraryPreview
? <img src={libraryPreview} alt="" aria-hidden="true" />
: <LibraryBig />}
</span>
<span className="replace-product-method-copy">
<strong>{copy.libraryTitle}</strong>
<small>
{source === "library" && (replaceMode === "character" ? selectedModel : selectedProduct)
? `已选择 · ${replaceMode === "character" ? selectedModel?.name : selectedProduct?.title}`
: copy.libraryEmpty}
</small>
<strong>
{librarySelected
? (replaceMode === "character" ? selectedModel?.name : selectedProduct?.title)
: copy.libraryTitle}
</strong>
<small>{librarySelected ? librarySelectionDetail(replaceMode, selectedProduct, selectedModel) || copy.libraryTitle : copy.libraryEmpty}</small>
</span>
<ChevronRight />
</button>
<div
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}`}
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}${tempDrop.dragging ? " is-dragover" : ""}`}
role="button"
tabIndex={0}
{...tempDrop.dropProps}
onClick={() => tempInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
@@ -846,7 +1003,13 @@ export function VideoReplacePage({
<span className="replace-product-method-icon"><Upload /></span>
<span className="replace-product-method-copy">
<strong>{copy.temporaryTitle}</strong>
<small>{tempDisplay.length ? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}` : copy.temporaryEmpty}</small>
<small>
{tempDrop.dragging
? "松开即可添加"
: tempDisplay.length
? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}`
: copy.temporaryEmpty}
</small>
</span>
<ImagePlus />
{tempDisplay.length ? (
@@ -924,7 +1087,7 @@ export function VideoReplacePage({
<button
type="button"
className="primary-action"
disabled={!videoReady || !productReady || generating}
disabled={restoring || !videoReady || !productReady || generating}
onClick={() => void startGeneration()}
>
<Replace />
@@ -934,6 +1097,13 @@ export function VideoReplacePage({
</section>
<aside className={panelClass} aria-live="polite">
<div className="replace-restoring-state" role="status" aria-live="polite">
<div className="replace-restoring-content">
<span className="replace-restoring-spinner" aria-hidden="true" />
<strong>正在读取任务状态</strong>
<span>确认有没有正在进行的复刻,稍候</span>
</div>
</div>
<div className="video-result-placeholder">
<div>
<div className="replace-placeholder-visual">