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