优化视频复刻

This commit is contained in:
Azmat@qq.com
2026-08-27 17:58:48 +08:00
parent d2b786a3cf
commit b3bf159e08
10 changed files with 545 additions and 64 deletions
+57 -13
View File
@@ -12,6 +12,7 @@ import {
Package,
RefreshCw,
Replace,
ScanLine,
ShieldCheck,
Upload,
UserRound,
@@ -33,6 +34,7 @@ import type { FreeVideoRef, FreeVideoTask, ModelConfig, ModelEntity, Product } f
import type { NavigateFn } from "./route-config";
const JOB_KEY = "airshelf:video-replace-job";
const MODE_KEY = "airshelf:video-replace-mode";
const CHARACTER_MARK = "[视频复刻·角色]";
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
@@ -44,8 +46,8 @@ const REPLACE_MODE_COPY = {
modeLabel: "商品复刻",
targetLabel: "商品",
targetStep: "2. 选择自己的商品",
videoEmpty: "系统将自动识别需要替换的商品区域",
videoReady: "视频已就绪,将自动识别原商品区域",
videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的商品",
videoReady: "视频已就绪,将先提炼分镜稿再复刻",
libraryTitle: "从商品库选择",
libraryEmpty: "选择已创建的商品",
temporaryTitle: "临时上传商品",
@@ -53,9 +55,11 @@ const REPLACE_MODE_COPY = {
temporaryNoun: "商品图",
temporaryFallback: "临时商品素材",
generatingTitle: "正在进行商品复刻",
generatingCopy: "正在匹配商品外观与原片镜头",
generatingCopy: "正在照着分镜稿还原镜头,并换上你的商品",
reviewingTitle: "正在审核参考素材",
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
digestingTitle: "正在提炼参考视频",
digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻",
resultTitle: "商品复刻已完成",
resultPreview: "商品复刻预览",
consistency: "商品一致性检查通过",
@@ -81,6 +85,8 @@ const REPLACE_MODE_COPY = {
generatingCopy: "正在匹配角色外观、表情与原片动作",
reviewingTitle: "正在审核参考素材",
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
digestingTitle: "正在提炼参考视频",
digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻",
resultTitle: "角色复刻已完成",
resultPreview: "角色复刻预览",
consistency: "角色一致性检查通过",
@@ -116,6 +122,24 @@ function forgetJob() {
}
}
function readReplaceMode(): ReplaceMode {
try {
const stored = localStorage.getItem(MODE_KEY);
if (stored === "character" || stored === "product") return stored;
} catch {
/* 无痕模式忽略 */
}
return "product";
}
function rememberReplaceMode(mode: ReplaceMode) {
try {
localStorage.setItem(MODE_KEY, mode);
} catch {
/* 无痕模式忽略 */
}
}
function fileKey(file: File) {
return `${file.name}:${file.size}:${file.lastModified}`;
}
@@ -187,7 +211,12 @@ function remixSourceLabel(task?: Partial<FreeVideoTask> | null) {
}
function videoRefFromTask(task?: Partial<FreeVideoTask> | null) {
return (task?.references || []).find((item) => item.type === "video") || null;
// 商品复刻的参考视频只用来提炼分镜稿,不进 references,引用快照存在 digest_source。
return (task?.references || []).find((item) => item.type === "video") || task?.digest_source || null;
}
function isDigesting(task?: Partial<FreeVideoTask> | null) {
return task?.digest_stage === "digesting";
}
function imageRefsFromTask(task?: Partial<FreeVideoTask> | null) {
@@ -235,7 +264,7 @@ export function VideoReplacePage({
}) {
const [products, setProducts] = useState(initialProducts);
const [models, setModels] = useState<ModelEntity[]>([]);
const [replaceMode, setReplaceMode] = useState<ReplaceMode>("product");
const [replaceMode, setReplaceMode] = useState<ReplaceMode>(readReplaceMode);
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
const [videoUploading, setVideoUploading] = useState(false);
@@ -260,6 +289,7 @@ export function VideoReplacePage({
const tempInputRef = useRef<HTMLInputElement>(null);
const completedNoticeRef = useRef("");
const wasReviewingRef = useRef(false);
const wasDigestingRef = useRef(false);
const videoConfigs = useMemo(
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
@@ -313,17 +343,19 @@ export function VideoReplacePage({
}, billingRates);
const points = estimated.points || 220;
const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading;
const reviewing = submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created"));
const digesting = Boolean(job && isDigesting(job));
// 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。
const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created")));
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" : "",
reviewing ? "is-reviewing" : "",
reviewing || digesting ? "is-reviewing" : "",
hasResult ? "has-result" : "",
].filter(Boolean).join(" ");
const generateLabel = generating
? (reviewing ? "正在审核素材…" : "正在复刻…")
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : "正在复刻…")
: hasResult
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
@@ -372,11 +404,17 @@ export function VideoReplacePage({
const data = await api.pollVideoReplace(jobId);
if (cancelled) return;
setJob(data.task);
const jobMode = modeFromTask(data.task);
setReplaceMode(jobMode);
rememberReplaceMode(jobMode);
const stillDigesting = isDigesting(data.task);
const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created";
if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current;
if (stillReviewing) wasReviewingRef.current = true;
else if (wasReviewingRef.current && isInFlight(data.task.status)) {
wasReviewingRef.current = false;
onNotify("success", "素材审核已通过,正在复刻");
onNotify("success", wasDigestingRef.current ? "分镜稿已提炼完成,正在复刻" : "素材审核已通过,正在复刻");
wasDigestingRef.current = false;
}
if (isInFlight(data.task.status)) {
timer = window.setTimeout(poll, stillReviewing ? 2000 : 2500);
@@ -492,6 +530,7 @@ export function VideoReplacePage({
const switchReplaceMode = (next: ReplaceMode) => {
if (next === replaceMode || generating) return;
setReplaceMode(next);
rememberReplaceMode(next);
setSource("");
setSelectedProduct(null);
setSelectedModel(null);
@@ -588,9 +627,13 @@ export function VideoReplacePage({
setJob(data.task);
setJobId(data.task.id);
rememberJob(data.task.id);
rememberReplaceMode(modeFromTask(data.task) || replaceMode);
completedNoticeRef.current = "";
wasReviewingRef.current = data.task.review_stage === "reviewing" || data.task.status === "created";
if (wasReviewingRef.current) {
wasDigestingRef.current = isDigesting(data.task);
if (wasDigestingRef.current) {
onNotify("success", "已开始提炼参考视频,拆完自动进入复刻");
} else if (wasReviewingRef.current) {
onNotify("success", "已提交素材审核,通过后自动开始复刻");
} else {
onNotify("success", "视频复刻任务已开始");
@@ -618,6 +661,7 @@ export function VideoReplacePage({
const images = imageRefsFromTask(task);
const subject = subjectNameFromTask(task);
setReplaceMode(mode);
rememberReplaceMode(mode);
setVideoFile(null);
setVideoRef({
...video,
@@ -898,11 +942,11 @@ export function VideoReplacePage({
<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">{reviewing ? <ShieldCheck /> : <Clapperboard />}</span>
<span className="replace-generating-frame">{digesting ? <ScanLine /> : reviewing ? <ShieldCheck /> : <Clapperboard />}</span>
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
</div>
<strong>{reviewing ? copy.reviewingTitle : copy.generatingTitle}</strong>
<span>{reviewing ? copy.reviewingCopy : copy.generatingCopy}</span>
<strong>{digesting ? copy.digestingTitle : reviewing ? copy.reviewingTitle : copy.generatingTitle}</strong>
<span>{digesting ? copy.digestingCopy : reviewing ? copy.reviewingCopy : copy.generatingCopy}</span>
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
</div>
</div>