优化脚本
This commit is contained in:
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
|
||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
|
||||
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
|
||||
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
@@ -275,8 +275,7 @@ const PIPELINE_RAIL = [
|
||||
{ n: "01", title: "选择商品", desc: "确定本次内容生产的商品主体" },
|
||||
{ n: "02", title: "脚本创建", desc: "生成并确认镜头脚本" },
|
||||
{ n: "03", title: "资产选择", desc: "准备商品、角色与场景资产" },
|
||||
{ n: "04", title: "故事板", desc: "生成并确认视频分镜画面" },
|
||||
{ n: "05", title: "视频生成", desc: "按故事板生成视频片段" },
|
||||
{ n: "04", title: "视频生成", desc: "按脚本的秒级分镜直接生成视频" },
|
||||
];
|
||||
const OUTPUT_RATIOS = [
|
||||
{ value: "9:16", label: "9:16 竖屏" },
|
||||
@@ -302,11 +301,10 @@ function modelResolutions(config: ModelConfig | undefined) {
|
||||
}
|
||||
|
||||
const PIPELINE_HEAD: Record<number, { title: string; desc: string; status: string }> = {
|
||||
1: { title: "脚本创建", desc: "围绕商品卖点组织镜头脚本,确认后进入下一步内容生产", status: "镜头脚本" },
|
||||
2: { title: "资产选择", desc: "准备故事板所需的商品、角色与场景资产,确保后续画面保持一致", status: "资产选择" },
|
||||
3: { title: "故事板", desc: "检查每个镜头的画面、台词、运镜和节奏,确认后进入视频生成。", status: "故事板" },
|
||||
4: { title: "视频生成", desc: "系统已按故事板场次自动开始生成,各场视频会并行完成。", status: "视频生成" },
|
||||
5: { title: "视频生成", desc: "系统已按故事板场次自动开始生成,各场视频会并行完成。", status: "视频生成" },
|
||||
1: { title: "脚本创建", desc: "围绕商品卖点组织镜头脚本,画面按秒写清景别、机位、运镜,确认后进入下一步", status: "镜头脚本" },
|
||||
2: { title: "资产选择", desc: "准备商品、角色与场景参考图,用来锁住出片时的脸、商品和场景", status: "资产选择" },
|
||||
3: { title: "视频生成", desc: "系统已按脚本的秒级分镜逐镜生成,各场视频会并行完成。", status: "视频生成" },
|
||||
4: { title: "视频生成", desc: "系统已按脚本的秒级分镜逐镜生成,各场视频会并行完成。", status: "视频生成" },
|
||||
};
|
||||
|
||||
// 行34 · 行内「添加标签」:点 + 展开输入框,回车 / 失焦提交(空则收起)
|
||||
@@ -557,12 +555,7 @@ export function PipelinePage(props: {
|
||||
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
|
||||
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
|
||||
onRenameModel?: (assetId: string, name: string) => Promise<unknown>;
|
||||
onGenerateStoryboard: (prompt: string) => void | Promise<unknown>;
|
||||
// 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本
|
||||
onRerunStoryboardShot?: (shotId: string, prompt?: string) => Promise<unknown>;
|
||||
onAdoptStoryboardShotVersion?: (shotId: string, versionId: string) => void | Promise<unknown>;
|
||||
onPollStoryboardQuiet?: () => void | Promise<void>;
|
||||
onSkipStoryboard: () => Promise<unknown>;
|
||||
onSubmitVideo: (segmentId: string, prompt: string) => void | Promise<unknown>;
|
||||
onSubmitAllVideos: (prompt: string) => void | Promise<unknown>;
|
||||
onPollVideosQuiet: () => void | Promise<void>;
|
||||
@@ -578,7 +571,7 @@ export function PipelinePage(props: {
|
||||
const {
|
||||
project, loading, navigate, products, assets, onNotify,
|
||||
textModels, videoModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel,
|
||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
|
||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||
} = props;
|
||||
@@ -983,26 +976,13 @@ export function PipelinePage(props: {
|
||||
setExtractErr(err instanceof Error ? err.message : "提取失败,请重试");
|
||||
}
|
||||
}
|
||||
// ── 流程步骤4/5 · 兜底弹窗拦截:生成故事板/视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
||||
// ── 流程步骤3 · 兜底弹窗拦截:生成视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
||||
// 缺了就弹窗(去补齐 / 仍要生成),不静默退化文生图、不让用户花冤枉钱。商品永远有主图、不算缺。
|
||||
// reason:"noref" = 还没参考图(立绘/场景图);"notri" = 角色有立绘但缺三视图(故事板合成需多角度参考)。
|
||||
type RefMiss = { name: string; type: string; reason: "noref" | "notri" };
|
||||
// reason:"noref" = 还没参考图(立绘/场景图)。原来还拦「缺三视图」,那是故事板多图合成的要求;
|
||||
// 故事板下线后视频只取立绘锁脸,不再拦三视图。
|
||||
type RefMiss = { name: string; type: string; reason: "noref" };
|
||||
const [refGate, setRefGate] = useState<{ missing: RefMiss[]; proceed: () => void } | null>(null);
|
||||
useBodyScrollLock(Boolean(refGate));
|
||||
// 某角色(按名字)已采用的立绘有没有配套三视图:取该角色代表组的 adopted_asset → 查它的三视图组。
|
||||
// 用与详情弹窗同款判定(triview 组有候选 / 资产 metadata 标记 / 模特库正面图)。
|
||||
function personHasTriview(name: string): boolean {
|
||||
const ent = buildEntities("person").find((e) => (e.name || "").trim() === (name || "").trim());
|
||||
const portrait = ent?.group.adopted_asset;
|
||||
if (!portrait) return false;
|
||||
const tri = triGroupForAsset(portrait);
|
||||
if (tri && (tri.adopted_asset || (tri.candidate_assets?.length ?? 0) > 0)) return true;
|
||||
const m: Record<string, unknown> = byId.get(portrait)?.metadata || {};
|
||||
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
|
||||
if (has === true || (Array.isArray(has) && has.length > 0) || (typeof has === "string" && (has as string).trim())) return true;
|
||||
if (m.view === "frontal") return true; // 模特库正面图与三视图同批生成
|
||||
return false;
|
||||
}
|
||||
function missingRefsFor(segs: Array<{ entity_refs?: string[] }>): RefMiss[] {
|
||||
const ents = project.metadata?.script_entities;
|
||||
if (!Array.isArray(ents) || !ents.length) return []; // 没提取过实体就不拦(交给提取闸门)
|
||||
@@ -1019,9 +999,7 @@ export function PipelinePage(props: {
|
||||
const kind = ent.type === "character" ? "person" : "scene";
|
||||
const name = (ent.name || "").trim();
|
||||
if (!name) continue;
|
||||
if (!adopted[kind].has(name)) { miss.set(name, { name, type: ent.type, reason: "noref" }); continue; }
|
||||
// 已有立绘/场景图 → 角色再查三视图:故事板 @图 合成需正/侧/背多角度参考,缺则拦(ZWQ#3 防呆)
|
||||
if (kind === "person" && !personHasTriview(name)) miss.set(name, { name, type: ent.type, reason: "notri" });
|
||||
if (!adopted[kind].has(name)) miss.set(name, { name, type: ent.type, reason: "noref" });
|
||||
}
|
||||
}
|
||||
return [...miss.values()];
|
||||
@@ -1032,9 +1010,9 @@ export function PipelinePage(props: {
|
||||
if (missing.length) setRefGate({ missing, proceed: action });
|
||||
else action();
|
||||
}
|
||||
// ── 流程步骤5 · 生成视频前的「过审闸」:含真人脸的人物立绘/故事板分镜必须先过审(火山要素材库已过审引用),
|
||||
// 否则火山直接报 InputImageSensitiveContentDetected。未过审 → 弹窗指明哪一镜/哪个人物/哪张分镜,挡住不生成。
|
||||
type ReviewBlocker = { video_segment_id: string; sort_order: number; scene_no: number; kind: "person" | "storyboard"; name: string; asset_id: string; review_status: string };
|
||||
// ── 流程步骤3 · 生成视频前的「过审闸」:含真人脸的人物立绘必须先过审(火山要素材库已过审引用),
|
||||
// 否则火山直接报 InputImageSensitiveContentDetected。未过审 → 弹窗指明哪一镜/哪个人物,挡住不生成。
|
||||
type ReviewBlocker = { video_segment_id: string; sort_order: number; scene_no: number; kind: "person"; name: string; asset_id: string; review_status: string };
|
||||
const [reviewGate, setReviewGate] = useState<{ blockers: ReviewBlocker[]; submitting: boolean } | null>(null);
|
||||
useBodyScrollLock(Boolean(reviewGate));
|
||||
// 过审闸:有未过审项 → 弹窗(去送审/等过审),不放行;全过审 → 执行生成。segmentId 给定=只校验该段。
|
||||
@@ -1136,63 +1114,12 @@ export function PipelinePage(props: {
|
||||
}, [adDetail]);
|
||||
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
|
||||
|
||||
// ── Stage 3:分镜制(对标视频段)—— 每镜一个 StoryboardShot,各自采用版图 + 历史版本,可单场重跑/切版 ──
|
||||
const sbShots = [...(project.storyboard_shots ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const [sbSelected, setSbSelected] = useState(0);
|
||||
const sbActiveShot = sbShots[Math.min(sbSelected, Math.max(0, sbShots.length - 1))] || null;
|
||||
// 「查看的版本」与「采用的版本」分离(对标视频详情弹窗):点历史缩略图只切预览(纯本地态),
|
||||
// 绝不动后端状态 → 重跑在制时切版本不会把 RUNNING 状态冲掉、不丢任务;采用要点显式「采用此版本」。
|
||||
const [sbViewVerId, setSbViewVerId] = useState<string | null>(null);
|
||||
// 切换场 / 切项目 / 采用版变化(重跑落地或手动采用)时复位预览到「采用版」,这样重跑出片后自动看到新图
|
||||
useEffect(() => { setSbViewVerId(null); }, [sbActiveShot?.id, sbActiveShot?.adopted_version]);
|
||||
const sbAnyImage = sbShots.some((s) => Boolean(s.adopted_asset_url || s.adopted_asset));
|
||||
// 全部场都出片(有采用版)= 故事板完成,可进视频;在制中(queued/running)用于转圈veil
|
||||
const sbAllDone = sbShots.length > 0 && sbShots.every((s) => Boolean(s.adopted_version));
|
||||
const sbAnyGenerating = sbShots.some((s) => ["queued", "running"].includes(s.status));
|
||||
// 故事板按「采用版脚本」逐镜出图 → 还没出图时,先按采用版镜头数铺等量空占位(场1…场N),
|
||||
// 让用户一眼看出「这里本该有几张」,出图后逐个填真图。无采用版脚本则为 0(显示「暂无」)。
|
||||
const sbAdoptedScript = scripts.find((s) => s.is_adopted) || null;
|
||||
const sbExpectedShots = sbAdoptedScript ? (sbAdoptedScript.segments?.length ?? 0) : 0;
|
||||
// 整张/全部生成中:不全屏遮挡,只在每张分镜占位上转个 spinner(样式同基础资产趴)
|
||||
const [sbGenerating, setSbGenerating] = useState(false);
|
||||
// 单场重跑的乐观态:点下立刻给该场转圈(撑到状态回传 queued/running 接管)
|
||||
const [sbBusyShots, setSbBusyShots] = useState<Set<string>>(() => new Set());
|
||||
const clearSbBusy = (id: string) => setSbBusyShots((s) => { if (!s.has(id)) return s; const n = new Set(s); n.delete(id); return n; });
|
||||
// 单场重跑:乐观置忙 + 调后端 → 由 poll 出图;状态进 queued/running 后由状态接管,失败/超时兜底解禁
|
||||
function rerunStoryboardShotOptimistic(shotId: string) {
|
||||
setSbBusyShots((s) => new Set(s).add(shotId));
|
||||
Promise.resolve(onRerunStoryboardShot?.(shotId, storyboardPrompt || SB_PROMPT_DEFAULT))
|
||||
.then((res) => { if (res == null) clearSbBusy(shotId); })
|
||||
.catch(() => clearSbBusy(shotId));
|
||||
window.setTimeout(() => clearSbBusy(shotId), 90000);
|
||||
}
|
||||
// 进入 succeeded/failed 终态的场,从乐观忙集合摘除(此后由真实状态驱动)
|
||||
const sbStatusKey = sbShots.map((s) => `${s.id}:${s.status}`).join("|");
|
||||
useEffect(() => {
|
||||
setSbBusyShots((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const s of sbShots) if (next.has(s.id) && ["succeeded", "failed"].includes(s.status)) next.delete(s.id);
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sbStatusKey]);
|
||||
// 没生成故事板时点「确认故事板」→ 弹气泡提示先去生成(3.5s 自动收起)
|
||||
const [sbConfirmHint, setSbConfirmHint] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!sbConfirmHint) return;
|
||||
const t = window.setTimeout(() => setSbConfirmHint(false), 3500);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [sbConfirmHint]);
|
||||
|
||||
// ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)──
|
||||
const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length;
|
||||
const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);
|
||||
const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status));
|
||||
const [chargeConfirm, setChargeConfirm] = useState<"storyboard" | "video" | null>(null);
|
||||
const sbChargeShots = shots.length || sbExpectedShots;
|
||||
const sbChargePoints = sbChargeShots * pts(20);
|
||||
const [chargeConfirm, setChargeConfirm] = useState<"video" | null>(null);
|
||||
const videoConfigs = videoModels ?? [];
|
||||
const defaultVideoModel = videoConfigs.find((m) => m.status === "active" && m.name === DEFAULT_VIDEO_MODEL_NAME)
|
||||
|| videoConfigs.find((m) => m.status === "active")
|
||||
@@ -1300,9 +1227,6 @@ export function PipelinePage(props: {
|
||||
(sum, duration) => sum + estimateCost(outputModel, { ratio: outputAspect, resolution: outputResolution, duration, refs: [] }, billingRates).points,
|
||||
0,
|
||||
);
|
||||
const sbNextLabel = sbAnyImage || sbAnyGenerating
|
||||
? "进入故事板"
|
||||
: `生成故事板 · ${sbChargeShots > 0 ? `${sbChargePoints} 积分` : `${pts(20)} 积分/镜`}`;
|
||||
const videoNextLabel = videoAnyStarted
|
||||
? "进入视频"
|
||||
: videoChargePoints > 0
|
||||
@@ -1381,7 +1305,7 @@ export function PipelinePage(props: {
|
||||
// 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
|
||||
// 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
|
||||
// V1 雪藏拼接导出:已完成项目落到第 4 阶段(视频),不落到被藏的第 5(V2 恢复改回 5)
|
||||
const projectStage = project.status === "completed" ? 4 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
|
||||
const projectStage = project.status === "completed" ? 3 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
|
||||
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
|
||||
// 进入项目默认落到「项目当前进行到的阶段」(视频在生成/已生成 → 直接到第4阶段),而不是恒显第1阶段(ZWQ#16)。
|
||||
// 仍尊重地址栏 #stage-N(分享某阶段链接 / 浏览器前进后退)。
|
||||
@@ -1409,7 +1333,7 @@ export function PipelinePage(props: {
|
||||
// 在基础资产趴(stage 2)/ 故事板趴(stage 3)/ 视频趴(stage 4)定时轮询审核状态,刷新徽章
|
||||
// (processing → active绿 / failed红)。stage4 也轮询:过审闸里「一键送审」后,要在视频阶段就能看到盾变绿再生成。
|
||||
useEffect(() => {
|
||||
if (viewStage !== 2 && viewStage !== 3 && viewStage !== 4) return;
|
||||
if (viewStage !== 2 && viewStage !== 3) return;
|
||||
let alive = true;
|
||||
let timer = 0;
|
||||
const tick = async () => {
|
||||
@@ -1429,14 +1353,6 @@ export function PipelinePage(props: {
|
||||
timer = window.setInterval(tick, 8000);
|
||||
return () => { alive = false; window.clearInterval(timer); };
|
||||
}, [viewStage, project.id, genBusy, reviewWake, pendingGenKey]);
|
||||
// 故事板有在制场(queued/running)时,后台每 5s 静默轮询驱动出图 + 刷新(对标视频段轮询)。
|
||||
// 不占全局 loading → 单场重跑不会锁住其它场的按钮,可并行重跑多场。
|
||||
useEffect(() => {
|
||||
if (viewStage !== 3 || !sbAnyGenerating) return;
|
||||
const timer = window.setInterval(() => { void onPollStoryboardQuiet?.(); }, 5000);
|
||||
void onPollStoryboardQuiet?.();
|
||||
return () => window.clearInterval(timer);
|
||||
}, [viewStage, sbAnyGenerating, onPollStoryboardQuiet]);
|
||||
// 外部 hash 变化(浏览器前进/后退、地址栏改 #stage-N)也要切阶段——镜像有 hashchange 监听,这里补齐
|
||||
useEffect(() => {
|
||||
function onHashChange() {
|
||||
@@ -1453,7 +1369,7 @@ export function PipelinePage(props: {
|
||||
}, []);
|
||||
const activeDot = navigated ? viewStage : projectStage;
|
||||
const [chatText, setChatText] = useState("");
|
||||
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
|
||||
// 媒体预览灯箱(视频片段播放)
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const [chatMode, setChatMode] = useState<"ai" | "manual" | "video">("ai");
|
||||
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||||
@@ -2073,20 +1989,9 @@ export function PipelinePage(props: {
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [gutterDragging]);
|
||||
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);
|
||||
const startStoryboardGeneration = () => {
|
||||
setSbGenerating(true);
|
||||
void persistOutputSpec()
|
||||
.catch(() => undefined)
|
||||
.then(() => onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT))
|
||||
.finally(() => setSbGenerating(false));
|
||||
};
|
||||
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||||
|
||||
// ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
|
||||
// ── Stage 4 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
|
||||
const isVideoAsset = (id: string | null | undefined): boolean => {
|
||||
const a = id ? byId.get(id) : null;
|
||||
if (!a) return false;
|
||||
@@ -2481,7 +2386,7 @@ export function PipelinePage(props: {
|
||||
if (cur === "succeeded" && (prev === "running" || prev === "queued")) setPreviewFinal(true);
|
||||
prevExportStatusRef.current = cur;
|
||||
}, [exportResult?.status]);
|
||||
const showingFinal = viewStage === 5 && previewFinal && exportResult?.status === "succeeded" && Boolean(exportResult?.output_url);
|
||||
const showingFinal = viewStage === 4 && previewFinal && exportResult?.status === "succeeded" && Boolean(exportResult?.output_url);
|
||||
|
||||
// ── 播放意图 effect:edPlaying 是唯一事实源,元素跟着意图走。
|
||||
// 边界换 src 浏览器自动 pause 也不会丢意图——effect 在新片段上接着 play,跨段连续播放。
|
||||
@@ -2697,7 +2602,7 @@ export function PipelinePage(props: {
|
||||
const [bgmPeaks, setBgmPeaks] = useState<number[] | null>(null);
|
||||
const videoAssetKey = edClips.filter((c) => c.isVideo && c.assetId).map((c) => c.assetId).join(",");
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5 || !videoAssetKey) return;
|
||||
if (viewStage !== 4 || !videoAssetKey) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
for (const id of Array.from(new Set(videoAssetKey.split(",")))) {
|
||||
@@ -2710,7 +2615,7 @@ export function PipelinePage(props: {
|
||||
}, [viewStage, videoAssetKey]);
|
||||
const bgmAssetId = (project.timeline?.bgm_tracks ?? [])[0]?.asset || "";
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5 || !bgmAssetId) { setBgmPeaks(null); return; }
|
||||
if (viewStage !== 4 || !bgmAssetId) { setBgmPeaks(null); return; }
|
||||
let cancelled = false;
|
||||
void extractWavePeaks(bgmAssetId).then((peaks) => { if (!cancelled && peaks.length) setBgmPeaks(peaks); });
|
||||
return () => { cancelled = true; };
|
||||
@@ -2733,7 +2638,7 @@ export function PipelinePage(props: {
|
||||
|
||||
// 键盘:仅 stage5 编辑预览态生效(空格播放/暂停,←/→ 逐帧);看成片时交给原生 <video controls>
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5 || showingFinal) return;
|
||||
if (viewStage !== 4 || showingFinal) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
@@ -2756,7 +2661,7 @@ export function PipelinePage(props: {
|
||||
|
||||
// 进入视频 / 拼接阶段时回填已有成片(此前合成过就直接给出播放/下载入口)
|
||||
useEffect(() => {
|
||||
if (viewStage !== 4 && viewStage !== 5) return;
|
||||
if (viewStage !== 3 && viewStage !== 4) return;
|
||||
onRefreshExport();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewStage]);
|
||||
@@ -2765,7 +2670,7 @@ export function PipelinePage(props: {
|
||||
// 兜底 —— 否则发起方的轮询循环随刷新丢掉,界面会卡在旧百分比、按钮一直禁用。
|
||||
const exportRunning = exportResult?.status === "queued" || exportResult?.status === "running";
|
||||
useEffect(() => {
|
||||
if (!exportRunning || (viewStage !== 4 && viewStage !== 5)) return;
|
||||
if (!exportRunning || (viewStage !== 3 && viewStage !== 4)) return;
|
||||
const timer = setInterval(() => onRefreshExport(), 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [exportRunning, viewStage, onRefreshExport]);
|
||||
@@ -2781,7 +2686,7 @@ export function PipelinePage(props: {
|
||||
// ── Stage 5 编辑器:水合 + 片段操作 + 撤销/重做 + 保存负载 ──
|
||||
const [edZoom, setEdZoom] = useState(100);
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5) { edHydratedRef.current = false; return; }
|
||||
if (viewStage !== 4) { edHydratedRef.current = false; return; }
|
||||
if (edHydratedRef.current) return;
|
||||
const hasData = Boolean(project.timeline?.clips?.length) || segments.some((s) => s.adopted_asset);
|
||||
if (!hasData) return;
|
||||
@@ -2901,7 +2806,7 @@ export function PipelinePage(props: {
|
||||
}
|
||||
// (旧的转场黑闪提示层已删:双缓冲槽位的 opacity 过渡就是真转场预览)
|
||||
|
||||
// Stage 4 / 5 文件上传
|
||||
// Stage 3 / 4 文件上传
|
||||
// 导出全部:把本项目所有已采用视频片段打成一个 zip 下载
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportErr, setExportErr] = useState("");
|
||||
@@ -3033,8 +2938,8 @@ export function PipelinePage(props: {
|
||||
const railMeta = productRecord
|
||||
? `${isLocalProduct ? "本地生活" : (productRecord.category || "未分类")} · ${(productRecord.images?.length || (railCover ? 1 : 0))} 张参考图`
|
||||
: "尚未选择商品";
|
||||
const workflowCurrent = Math.min(5, viewStage + 1);
|
||||
const unlockedWorkflow = Math.max(2, Math.min(5, projectStage + 1), workflowCurrent);
|
||||
const workflowCurrent = Math.min(4, viewStage + 1);
|
||||
const unlockedWorkflow = Math.max(2, Math.min(4, projectStage + 1), workflowCurrent);
|
||||
const plHead = PIPELINE_HEAD[viewStage] || PIPELINE_HEAD[1];
|
||||
function goWorkflow(step: number) {
|
||||
if (step > unlockedWorkflow || step <= 1) return;
|
||||
@@ -3066,8 +2971,8 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</header>
|
||||
<div className="pl-rail-summary">
|
||||
<span>已解锁 {unlockedWorkflow} / 5 · 当前第 {workflowCurrent} 步</span>
|
||||
<div className="pl-rail-track"><span style={{ width: `${(unlockedWorkflow / 5) * 100}%` }} /></div>
|
||||
<span>已解锁 {unlockedWorkflow} / 4 · 当前第 {workflowCurrent} 步</span>
|
||||
<div className="pl-rail-track"><span style={{ width: `${(unlockedWorkflow / 4) * 100}%` }} /></div>
|
||||
</div>
|
||||
<div className="pl-rail-steps">
|
||||
{PIPELINE_RAIL.map((step, index) => {
|
||||
@@ -3830,9 +3735,9 @@ export function PipelinePage(props: {
|
||||
})}
|
||||
</div>
|
||||
<footer className="as-action-bar">
|
||||
<span><Info />确认后将使用以上资产创建故事板,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
|
||||
<span><Info />确认后将用以上资产直接生成视频,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
|
||||
<div>
|
||||
{sbAnyImage || sbAnyGenerating ? (
|
||||
{videoAnyStarted ? (
|
||||
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
||||
<span className="dot"></span>当前成片 · {outputSpecSummary}
|
||||
</span>
|
||||
@@ -3841,16 +3746,15 @@ export function PipelinePage(props: {
|
||||
<button
|
||||
className="pl-next"
|
||||
type="button"
|
||||
disabled={sbGenerating && !sbAnyImage && !sbAnyGenerating}
|
||||
onClick={() => {
|
||||
if (sbAnyImage || sbAnyGenerating) {
|
||||
if (videoAnyStarted) {
|
||||
goStage(3);
|
||||
return;
|
||||
}
|
||||
guardGen(shots, () => setChargeConfirm("storyboard"));
|
||||
guardVideoGen(shots, null, () => setChargeConfirm("video"));
|
||||
}}
|
||||
>
|
||||
<span>{sbNextLabel}</span>
|
||||
<span>{videoNextLabel}</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
@@ -3860,191 +3764,8 @@ export function PipelinePage(props: {
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
|
||||
{viewStage === 3 && (() => {
|
||||
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
|
||||
let cum = 0;
|
||||
const sceneTimes = shots.map((s) => { const st = cum; cum += shotSeconds(s); return `${st}~${cum}s`; });
|
||||
const shotImg = (s?: StoryboardShot | null) => frameUrl(s ? { asset: s.adopted_asset ?? "", asset_url: s.adopted_asset_url } : null);
|
||||
const shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status);
|
||||
const cardCount = sbShots.length || sbExpectedShots;
|
||||
const activeBusy = sbActiveShot ? shotBusy(sbActiveShot) : sbGenerating;
|
||||
const activeVers = [...(sbActiveShot?.versions ?? [])];
|
||||
// 当前「查看」的版本(纯预览,默认=采用版);切换它只动本地预览,不动后端、不丢重跑
|
||||
const sbViewedVer = activeVers.find((v) => v.id === sbViewVerId)
|
||||
|| activeVers.find((v) => v.is_adopted || v.id === sbActiveShot?.adopted_version) || null;
|
||||
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 || "";
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="3">
|
||||
<div className="stage-storyboard">
|
||||
<div className="sb-canvas">
|
||||
<div className="sb-scenes-col" id="sb-scenes-row">
|
||||
{sbShots.length ? sbShots.map((shot, idx) => {
|
||||
const url = shotImg(shot);
|
||||
const aid = shot.adopted_asset || "";
|
||||
return (
|
||||
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={shot.id} data-sid={shot.id} onClick={() => setSbSelected(idx)}>
|
||||
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
|
||||
<span className="ph-frame">场 {idx + 1}</span>
|
||||
{shotBusy(shot) && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
|
||||
{/* 失败帧:角标红字「失败」,hover 看友好提示;不再灰着没反应(点该场到右侧看完整原因+重跑) */}
|
||||
{!shotBusy(shot) && shot.status === "failed" && (
|
||||
<span title={shot.error_message || "生成失败,点开本场查看原因并重跑"} style={{ position: "absolute", left: 6, top: 6, display: "inline-flex", alignItems: "center", gap: 3, padding: "2px 6px", borderRadius: 6, background: "var(--err, #d33)", color: "#fff", fontSize: 11, fontWeight: 600, zIndex: 2 }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>失败
|
||||
</span>
|
||||
)}
|
||||
{aid && <span className="sb-frame-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[aid] || shot.review_status || "") as ReviewStatus} error={shot.review_error} onSubmit={() => void submitAssetReview(aid)} busy={reviewBusyId === aid} /></span>}
|
||||
</div>
|
||||
<div className="nm">场 {idx + 1}</div>
|
||||
<div className="sub">{sceneTimes[idx] || `#${shot.sort_order + 1}`}</div>
|
||||
</div>
|
||||
);
|
||||
}) : sbExpectedShots ? (
|
||||
/* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */
|
||||
Array.from({ length: sbExpectedShots }, (_, idx) => (
|
||||
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={`ph-${idx}`} onClick={() => setSbSelected(idx)}>
|
||||
<div className="placeholder"><span className="ph-frame">场 {idx + 1}</span>{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}</div>
|
||||
<div className="nm">场 {idx + 1}</div>
|
||||
<div className="sub">待生成</div>
|
||||
</div>
|
||||
))
|
||||
) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">暂无</span></div>}
|
||||
</div>
|
||||
{(() => {
|
||||
const url = mainUrl;
|
||||
const aid = mainAid;
|
||||
return (
|
||||
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }); } } : undefined}>
|
||||
<span className="ph-frame">{url ? `场 ${sbSelected + 1}${sbViewedIsAdopted ? "" : " · 预览历史版"}` : sbExpectedShots ? `场 ${sbSelected + 1} · 待生成` : "故事板未生成"}</span>
|
||||
{activeBusy && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
|
||||
{aid && <span className="sb-main-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[aid] || sbViewedVer?.review_status || sbActiveShot?.review_status || "") as ReviewStatus} error={sbViewedVer?.review_error || sbActiveShot?.review_error} onSubmit={() => void submitAssetReview(aid)} busy={reviewBusyId === aid} /></span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="sb-side">
|
||||
<div className="pane" style={{ padding: "18px" }}>
|
||||
<div className="hstack" style={{ marginBottom: "10px" }}>
|
||||
<strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">{sbShots.length ? `场 ${sbSelected + 1}` : "—"}</span></strong>
|
||||
<span className="spacer"></span>
|
||||
{sbActiveShot
|
||||
? (activeBusy
|
||||
? <span className="pill neutral"><span className="dot"></span>生成中</span>
|
||||
: sbActiveShot.status === "failed"
|
||||
? <span className="pill bad"><span className="dot"></span>生成失败</span>
|
||||
: sbActiveShot.adopted_version
|
||||
? <span className="pill ok"><span className="dot"></span>已出片</span>
|
||||
: <span className="pill neutral"><span className="dot"></span>待生成</span>)
|
||||
: <span className="pill neutral"><span className="dot"></span>未生成</span>}
|
||||
</div>
|
||||
{/* 失败原因·友好提示(后端已把 yunqi 真因翻成中文,如内容审核拦截 → 提示改措辞) */}
|
||||
{sbActiveShot?.status === "failed" && !activeBusy && sbActiveShot.error_message && (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "flex-start", color: "var(--err, #d33)", fontSize: "12.5px", lineHeight: 1.55, margin: "2px 0 10px", padding: "8px 10px", background: "rgba(211,51,51,.07)", borderRadius: 8 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, marginTop: 1 }}><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
<span>{sbActiveShot.error_message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>每个场由 image-2 直接生成一张完整导演故事板图片;可单独「重跑本场」只重出这一张,每场各自留历史版本,互不影响。</div>
|
||||
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>整张风格提示词(重跑时生效,可编辑)</div>
|
||||
<PromptBox
|
||||
className="prompt-edit"
|
||||
id="sb-prompt-edit"
|
||||
stop={false}
|
||||
value={storyboardPrompt}
|
||||
onChange={(v) => setStoryboardPrompt(v.trim())}
|
||||
/>
|
||||
<div className="sb-stage-actions">
|
||||
{sbActiveShot && (
|
||||
<button className="btn btn-sm" type="button" disabled={activeBusy} title="只重出当前这一场(新增一条该场历史版本)" onClick={() => guardGen(shots.filter((s) => s.sort_order === sbActiveShot.sort_order), () => rerunStoryboardShotOptimistic(sbActiveShot.id))}>
|
||||
{activeBusy ? <><span className="spinner btn-spin" aria-hidden="true" />生成中…</> : `↻ 重跑本场`}
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer"></span>
|
||||
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>{pts(20)} 积分/镜</span>
|
||||
</div>
|
||||
<div className="sb-history">
|
||||
<div className="sb-history-h">本场历史版本(<span id="sb-history-ct">{activeVers.length}</span>)· 点击预览</div>
|
||||
<div className="sb-history-row" id="sb-history-row">
|
||||
{activeVers.length ? activeVers.map((ver) => {
|
||||
const cover = frameUrl({ asset: ver.asset ?? "", asset_url: ver.asset_url });
|
||||
const isAdopted = ver.is_adopted || ver.id === sbActiveShot?.adopted_version;
|
||||
const isViewed = sbViewedVer?.id === ver.id;
|
||||
// 点击只切「预览」(本地态),不动后端 → 重跑在制时切版本不丢任务。采用要点下方按钮。
|
||||
return (
|
||||
<div className={`sb-history-thumb${isViewed ? " current" : ""}`} key={ver.id} data-vi={ver.id} role="button" tabIndex={0} title={isAdopted ? "当前采用版(点击预览)" : "点击预览此版本"} onClick={() => setSbViewVerId(ver.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewVerId(ver.id); } }}>
|
||||
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{isAdopted ? "采用" : "历史"}</span></div>
|
||||
<div className="ts">{formatShanghaiClock(ver.created_at)}</div>
|
||||
</div>
|
||||
);
|
||||
}) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>本场暂无历史</span>}
|
||||
</div>
|
||||
{/* 预览的是非采用版 → 显式「采用此版本」(对标视频详情弹窗的采用按钮);采用不影响在制重跑 */}
|
||||
{sbViewedVer && !sbViewedIsAdopted && sbActiveShot && (
|
||||
<button className="btn btn-sm btn-primary" type="button" style={{ marginTop: "8px" }} onClick={() => void onAdoptStoryboardShotVersion?.(sbActiveShot.id, sbViewedVer.id)}>采用此版本</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="divider" style={{ marginTop: "16px" }}></div>
|
||||
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>绑定的资产</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
|
||||
{(() => {
|
||||
const bound = [
|
||||
...groupsByKind("product").filter((g) => g.adopted_asset).map((g) => ({ id: g.id, name: (g.metadata?.label || "").trim() || KIND_LABEL.product, kind: "product" as const, url: groupMainUrl(g) })),
|
||||
...buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "person" as const, url: groupMainUrl(e.group) })),
|
||||
...buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "scene" as const, url: groupMainUrl(e.group) })),
|
||||
];
|
||||
return bound.length ? bound.map((b) => (
|
||||
<span className="asset-tag" key={b.id}>{b.url ? <span className="asset-thumb" style={{ backgroundImage: `url(${b.url})` }} /> : <span className="dotc"></span>}{b.name}({KIND_LABEL[b.kind]})</span>
|
||||
)) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>暂无绑定资产</span>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ image-2 逐场输出 · {cardCount ? `${cardCount} 场` : "0 场"} · 单场可重跑,失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
||||
<span className="dot"></span>当前成片 · {outputSpecSummary}
|
||||
</span>
|
||||
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
|
||||
<div className="sb-confirm-wrap">
|
||||
{sbConfirmHint && (
|
||||
<div className="sb-confirm-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
故事板还没出齐
|
||||
</span>
|
||||
<span className="pop-body">请先等每场故事板都出片,再点 <b>生成视频</b>。</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
if (!sbAllDone) { setSbConfirmHint(true); return; }
|
||||
if (videoAnyStarted) {
|
||||
goStage(4);
|
||||
return;
|
||||
}
|
||||
guardVideoGen(shots, null, () => setChargeConfirm("video"));
|
||||
}}
|
||||
>
|
||||
{videoNextLabel}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
|
||||
{viewStage === 4 && (() => {
|
||||
{viewStage === 3 && (() => {
|
||||
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
||||
const segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0);
|
||||
const segMin = segSeconds.length ? Math.min(...segSeconds) : 0;
|
||||
@@ -4069,7 +3790,7 @@ export function PipelinePage(props: {
|
||||
const merging = exportResult?.status === "queued" || exportResult?.status === "running";
|
||||
const mergeProgress = exportResult?.progress ?? 0;
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="4">
|
||||
<section className="stage active" data-stage-pane="3">
|
||||
<div className="queue-bar">
|
||||
<div>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
|
||||
@@ -4155,7 +3876,7 @@ export function PipelinePage(props: {
|
||||
)}
|
||||
</div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回故事板</button>
|
||||
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
|
||||
{/* 合成成片:各场视频按顺序拼成一条完整视频(合成完才有下面的播放/下载) */}
|
||||
{mergedUrl && (
|
||||
<>
|
||||
@@ -4183,7 +3904,7 @@ export function PipelinePage(props: {
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
|
||||
{viewStage === 5 && (() => {
|
||||
{viewStage === 4 && (() => {
|
||||
const previewUrl = edCur?.url || assetUrl(tlClips[0]?.asset) || segUrl(segments.find((s) => s.adopted_asset));
|
||||
const aspect = timeline?.aspect_ratio || "9:16";
|
||||
const resolution = timeline?.resolution || "1080×1920";
|
||||
@@ -4211,7 +3932,7 @@ export function PipelinePage(props: {
|
||||
const serverBgmName = serverBgm?.asset_name || (serverBgm ? "背景音乐" : "");
|
||||
const subVisible = edState.subtitleEnabled;
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="5">
|
||||
<section className="stage active" data-stage-pane="4">
|
||||
<div className="editor">
|
||||
<div className="editor-preview">
|
||||
<div className={`canvas${!showVideo && !showFinal && previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={!showVideo && !showFinal && previewUrl ? mediaStyle(previewUrl) : undefined}>
|
||||
@@ -4534,7 +4255,7 @@ export function PipelinePage(props: {
|
||||
{!canExport && !finalUrl && !exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--black-alpha-48)" }}>待全部视频片段生成完成后可导出</span>}
|
||||
</div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回片段</button>
|
||||
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回片段</button>
|
||||
<button className="btn" type="button" disabled={loading} onClick={() => onSaveTimeline(buildSavePayload())}>保存草稿</button>
|
||||
{finalUrl && (
|
||||
<a className="btn" href={finalUrl} target="_blank" rel="noreferrer" download>
|
||||
@@ -4631,11 +4352,10 @@ export function PipelinePage(props: {
|
||||
document.body
|
||||
);
|
||||
})()}
|
||||
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
|
||||
{/* ── 流程步骤3 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
|
||||
{refGate && (() => {
|
||||
// 拆两类:noref=连参考图都没有;notri=有立绘但缺三视图。文案分别告诉用户「去哪里点什么」。
|
||||
// noref=连参考图都没有。文案告诉用户「去哪里点什么」。
|
||||
const noRef = refGate.missing.filter((m) => m.reason === "noref");
|
||||
const noTri = refGate.missing.filter((m) => m.reason === "notri");
|
||||
return createPortal(
|
||||
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
|
||||
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -4643,7 +4363,7 @@ export function PipelinePage(props: {
|
||||
{noRef.length > 0 && (
|
||||
<>
|
||||
<div className="rg-body">
|
||||
下面这些角色 / 场景<strong>还没生成参考图</strong>。请回「基础资产」页,点对应卡片进详情后<strong>「生成立绘 / 场景图」</strong>并采用,否则故事板会变成纯文生图、跟你的设定对不上(白花钱):
|
||||
下面这些角色 / 场景<strong>还没生成参考图</strong>。请回「基础资产」页,点对应卡片进详情后<strong>「生成立绘 / 场景图」</strong>并采用,否则视频会变成纯文生视频、跟你的设定对不上(白花钱):
|
||||
</div>
|
||||
<div className="rg-list">
|
||||
{noRef.map((m) => (
|
||||
@@ -4655,21 +4375,6 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{noTri.length > 0 && (
|
||||
<>
|
||||
<div className="rg-body" style={{ marginTop: noRef.length > 0 ? 14 : 0 }}>
|
||||
下面这些角色已有立绘,但<strong>还没生成三视图</strong>。故事板 @图 合成要靠正 / 侧 / 背多角度锁人物,缺三视图角色容易跑形。请回「基础资产」页,点角色卡进详情后点<strong>「AI 生成三视图」</strong>:
|
||||
</div>
|
||||
<div className="rg-list">
|
||||
{noTri.map((m) => (
|
||||
<span className="rg-chip" key={`notri:${m.type}:${m.name}`}>
|
||||
<span className="rg-kind rg-kind-person">角色</span>
|
||||
{m.name} <span className="rg-warn">缺三视图</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="rg-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}>去基础资产页补齐</button>
|
||||
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>
|
||||
@@ -4682,13 +4387,13 @@ export function PipelinePage(props: {
|
||||
document.body
|
||||
);
|
||||
})()}
|
||||
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
|
||||
{/* ── 流程步骤3 · 过审闸弹窗:含真人脸的人物未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
|
||||
{reviewGate && createPortal(
|
||||
<div className="ref-gate-mask" onClick={() => setReviewGate(null)}>
|
||||
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="rg-title">这些素材还没过审,先过审再生成视频</div>
|
||||
<div className="rg-body">
|
||||
含真人脸的<strong>人物形象</strong>和<strong>故事板分镜</strong>必须先通过火山合规审核(变<span style={{ color: "var(--ok, #16a34a)" }}>绿盾·已过审</span>),
|
||||
含真人脸的<strong>人物形象</strong>必须先通过火山合规审核(变<span style={{ color: "var(--ok, #16a34a)" }}>绿盾·已过审</span>),
|
||||
否则视频生成会被火山以「疑似真人」拒绝。下面这些还没过审:
|
||||
</div>
|
||||
<div className="rg-list">
|
||||
@@ -5008,38 +4713,22 @@ export function PipelinePage(props: {
|
||||
</TeamModal>
|
||||
<ConfirmModal
|
||||
open={chargeConfirm !== null}
|
||||
title={chargeConfirm === "video" ? "确认生成视频" : "确认生成故事板"}
|
||||
title="确认生成视频"
|
||||
subtitle="// 失败不扣 · 成功后结算"
|
||||
icon={<Sparkles size={16} />}
|
||||
detail={chargeConfirm === "video"
|
||||
? (
|
||||
<>
|
||||
将按故事板生成 <b>{videoChargeShots || "多"} 段</b>视频({outputSpecSummary}),预估扣除 <b>{videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{videoChargeShots > 0 && videoChargePoints > 0 ? `(约 ${Math.round(videoChargePoints / videoChargeShots)} 积分/段)` : ""}。
|
||||
确认后进入视频页并开始生成。
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
将按脚本生成 <b>{sbChargeShots || "多"} 场</b>分镜图({outputAspect}),预估扣除 <b>{sbChargePoints > 0 ? `${sbChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{sbChargeShots > 0 ? `(${pts(20)} 积分/镜 × ${sbChargeShots} 场)` : ""}。
|
||||
确认后进入故事板并开始生成。
|
||||
</>
|
||||
)}
|
||||
confirmText={chargeConfirm === "video"
|
||||
? (videoChargePoints > 0 ? `确认生成 · ${videoChargePoints} 积分` : "确认生成")
|
||||
: (sbChargePoints > 0 ? `确认生成 · ${sbChargePoints} 积分` : "确认生成")}
|
||||
detail={(
|
||||
<>
|
||||
将按脚本的秒级分镜生成 <b>{videoChargeShots || "多"} 段</b>视频({outputSpecSummary}),预估扣除 <b>{videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{videoChargeShots > 0 && videoChargePoints > 0 ? `(约 ${Math.round(videoChargePoints / videoChargeShots)} 积分/段)` : ""}。
|
||||
确认后进入视频页并开始生成。
|
||||
</>
|
||||
)}
|
||||
confirmText={videoChargePoints > 0 ? `确认生成 · ${videoChargePoints} 积分` : "确认生成"}
|
||||
onCancel={() => setChargeConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const kind = chargeConfirm;
|
||||
setChargeConfirm(null);
|
||||
if (kind === "storyboard") {
|
||||
goStage(3);
|
||||
startStoryboardGeneration();
|
||||
} else if (kind === "video") {
|
||||
goStage(4);
|
||||
submitAllVideosOptimistic();
|
||||
}
|
||||
goStage(3);
|
||||
submitAllVideosOptimistic();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user