优化视频复刻

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
+14
View File
@@ -1231,6 +1231,20 @@
.sb-scene-thumb .placeholder .sb-frame-rv .rv-label { display: none; }
.sb-main-img .sb-main-rv { position: absolute; top: 10px; left: 10px; z-index: 4; }
/* 故事板说明层:从已确认脚本直接排版,不把文字烧进给视频模型的关键帧。 */
.sb-director-board { margin: 0 0 14px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }
.sb-director-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border-faint); background: var(--surface); }
.sb-director-head > div { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.sb-director-head .mono { font-family: var(--font-mono); font-size: 10px; letter-spacing: .05em; color: var(--black-alpha-48); white-space: nowrap; }
.sb-director-head strong { font-size: 12px; font-weight: 500; color: var(--accent-black); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sb-director-beat { display: grid; grid-template-columns: 54px minmax(0, 1fr); border-bottom: 1px solid var(--border-faint); }
.sb-director-beat:last-child { border-bottom: 0; }
.sb-director-time { display: flex; align-items: flex-start; justify-content: center; padding: 11px 6px; background: var(--surface); color: var(--heat); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
.sb-director-copy { min-width: 0; padding: 10px 12px; }
.sb-director-direction { color: var(--accent-black); font-size: 12px; line-height: 1.55; }
.sb-director-narration { margin-top: 5px; color: var(--black-alpha-56); font-size: 11.5px; line-height: 1.55; }
.sb-director-empty { padding: 14px 12px; color: var(--black-alpha-48); font-size: 12px; line-height: 1.6; }
.sb-rerun-note { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 14px; background: rgba(180,83,9,.08); border: 1px solid rgba(180,83,9,.20); border-radius: var(--r-md); color: #7C3A05; line-height: 1.55; }
.sb-rerun-note .warn-ic { width: 22px; height: 22px; border-radius: var(--r-sm); background: rgba(180,83,9,.12); color: #B45309; display: grid; place-items: center; flex: 0 0 22px; }
.sb-rerun-note .warn-ic svg { width: 14px; height: 14px; }
+44 -2
View File
@@ -34,6 +34,26 @@ const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色"
// 脚本来源 → 「来源」brief pill 文案
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
type DirectorBeat = { time: string; direction: string; narration: string };
const DIRECTOR_BEAT_RE = /(?:^|\n)\s*(\d{1,2})\s*[-–—~到至]\s*(\d{1,2})\s*(?:s|秒)?\s*[:]*\s*([^\n]+)/g;
function buildDirectorBeats(visual: string, narration: string, duration: number): DirectorBeat[] {
const matches = [...(visual || "").matchAll(DIRECTOR_BEAT_RE)];
const narrationLines = (narration || "").split(/(?<=[。!?!?])/).map((line) => line.trim()).filter(Boolean);
if (matches.length) {
return matches.slice(0, 5).map((match, index) => ({
time: `${match[1]}${match[2]}s`,
direction: match[3].trim(),
narration: narrationLines[index] || (index === 0 ? narration.trim() : ""),
}));
}
const count = Math.min(3, Math.max(1, narrationLines.length || 1));
const step = duration / count;
return Array.from({ length: count }, (_, index) => ({
time: `${Math.round(index * step)}${Math.round((index + 1) * step)}s`,
direction: visual.trim() || "按本镜脚本完成关键动作与商品露出",
narration: narrationLines[index] || (index === 0 ? narration.trim() : ""),
}));
}
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
const VO_VOICES = [
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
@@ -2050,7 +2070,7 @@ export function PipelinePage(props: {
document.body.style.userSelect = "";
};
}, [gutterDragging]);
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成可直接指导视频的分镜图";
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格生成干净、可直接指导视频的关键帧;导演分镜说明按脚本自动生成";
// 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
const sbSavedPrompt = (project.metadata as Record<string, unknown> | undefined)?.storyboard_prompt as string | undefined;
const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
@@ -3851,6 +3871,13 @@ export function PipelinePage(props: {
const sbViewedIsAdopted = !sbViewedVer || sbViewedVer.is_adopted || sbViewedVer.id === sbActiveShot?.adopted_version;
const mainUrl = sbViewedVer ? (sbViewedVer.asset_url || assetUrl(sbViewedVer.asset)) : shotImg(sbActiveShot);
const mainAid = sbViewedVer?.asset || sbActiveShot?.adopted_asset || "";
const activeScriptShot = sbActiveShot ? shots[sbActiveShot.sort_order] : null;
const directorBeats = buildDirectorBeats(
activeScriptShot?.visual_prompt || "",
activeScriptShot?.narration || "",
activeScriptShot?.duration_seconds || SEGMENT_DURATION_MAX,
);
const directorRange = sceneTimes[sbSelected] || "—";
return (
<section className="stage active" data-stage-pane="3">
<div className="stage-storyboard">
@@ -3922,7 +3949,22 @@ export function PipelinePage(props: {
<span>{sbActiveShot.error_message}</span>
</div>
)}
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}> · ,,</div>
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>稿使</div>
<div className="sb-director-board" aria-label="本场导演故事板">
<div className="sb-director-head">
<div><span className="mono">DIRECTOR BOARD</span><strong> {sbSelected + 1} · {directorRange}</strong></div>
<span className="pill pill-l3 neutral"><span className="dot" />9:16 · </span>
</div>
{directorBeats.length ? directorBeats.map((beat, index) => (
<div className="sb-director-beat" key={`${beat.time}-${index}`}>
<div className="sb-director-time">{beat.time}</div>
<div className="sb-director-copy">
<div className="sb-director-direction">{beat.direction}</div>
{beat.narration && <div className="sb-director-narration">/{beat.narration}</div>}
</div>
</div>
)) : <div className="sb-director-empty"></div>}
</div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>(,)</div>
<PromptBox
className="prompt-edit"
+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>
+6
View File
@@ -663,6 +663,12 @@ export type FreeVideoTask = {
product_id?: string;
model_id?: string;
review_stage?: "reviewing" | "";
// 商品复刻专有:参考视频先被提炼成分镜稿,再连商品图一起交给 Seedance。
digest_stage?: "digesting" | "";
digest_text?: string;
digest_shots?: number;
digest_source_name?: string;
digest_source?: FreeVideoRef | null;
references: FreeVideoRef[];
estimated_tokens: number;
actual_tokens: number;