优化脚本

This commit is contained in:
Azmat@qq.com
2026-08-25 16:40:22 +08:00
parent e2ec2d14af
commit df6784b90c
22 changed files with 893 additions and 156 deletions
+115 -21
View File
@@ -8,7 +8,8 @@ import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-displ
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
import type { Notice, Page } from "./route-config";
import { stageOrder, statusPill } from "./stage-config";
import { MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
import { DEFAULT_BILLING_RATES, estimateCost } from "../components/free-create/constants";
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import {
@@ -517,6 +518,7 @@ export function PipelinePage(props: {
onNotify?: (type: "success" | "error", text: string) => void;
scriptModelName: string;
textModels?: ModelConfig[];
videoModels?: ModelConfig[];
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
@@ -560,17 +562,22 @@ export function PipelinePage(props: {
}) {
const {
project, loading, navigate, products, assets, onNotify,
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
textModels, videoModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
// ── 团队价格系数(差异化调价):页面各处「N 积分/次」文案按团队系数动态显示,拉不到按标准价 1 ──
const [priceMultiplier, setPriceMultiplier] = useState(1);
const [billingRates, setBillingRates] = useState(DEFAULT_BILLING_RATES);
useEffect(() => {
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
void api.billingConfig().then((cfg) => setBillingRates({
margin: Number(cfg.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
rate: Number(cfg.points_per_yuan) || DEFAULT_BILLING_RATES.rate,
multiplier: Number(cfg.team_price_multiplier) || 1,
})).catch(() => undefined);
}, []);
const priceMultiplier = billingRates.multiplier;
// 与后端 apply_team_price 逐字对齐:挂牌整数积分 × 系数 → HALF_UP 最低 1(toFixed(6) 只吸浮点噪声)
const pts = (base: number) => (priceMultiplier === 1 ? base : Math.max(1, Math.round(Number((base * priceMultiplier).toFixed(6)))));
@@ -827,10 +834,13 @@ export function PipelinePage(props: {
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string): Promise<GenResult> {
if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发)
addBusy(busyKey);
const triKey = kind === "person" ? `${busyKey}:tri` : "";
if (triKey) addBusy(triKey);
try {
return (await onGenerateBaseAsset(kind, prompt, label, referenceAssetId)) as GenResult;
} finally {
delBusy(busyKey);
if (triKey) delBusy(triKey);
}
}
// 据某一版立绘资产生成它配套的三视图(loading 用 busyKey)
@@ -843,7 +853,7 @@ export function PipelinePage(props: {
delBusy(busyKey);
}
}
// 流程步骤4 · 生成人物立绘;三视图只在角色详情里手动生成。
// 流程步骤4 · 生成人物立绘(App 层自动接力三视图)
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) {
return await genBaseAsset("person", prompt, label, busyKey);
}
@@ -855,16 +865,16 @@ export function PipelinePage(props: {
const extractPollRef = useRef(0); // 提取轮询定时器句柄
const extractStartedRef = useRef(false); // 是否已有一条轮询在跑(防认领与手点双轮询)
type ExtractEntity = { id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number };
// 提取完成后(mode≠only)按返回实体循环出参考图(立绘/场景图;三视图手动生成)
// 提取完成后(mode≠only)按返回实体循环出参考图(角色=立绘+三视图,场景=场景图)
async function runGenForEntities(entities: ExtractEntity[], mode: "gen" | "full") {
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
for (const e of entities) {
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
if (e.type === "character") {
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘;三视图手动生成
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk);
else await genBaseAsset("person", e.visual_prompt, e.name, bk);
} else {
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
await genBaseAsset("scene", e.visual_prompt, e.name, bk);
}
}
await onRefreshProject();
@@ -1121,6 +1131,27 @@ export function PipelinePage(props: {
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 defaultVideoModel = (videoModels ?? []).find((m) => m.status === "active") || (videoModels ?? [])[0];
const videoChargeDurations = segments.length
? segments.map((s) => s.target_duration_seconds || 15)
: shots.map((s) => shotSeconds(s));
const videoChargeShots = videoChargeDurations.length;
const videoChargePoints = videoChargeDurations.reduce(
(sum, duration) => sum + estimateCost(defaultVideoModel, { ratio: "9:16", resolution: "720p", duration, refs: [] }, billingRates).points,
0,
);
const sbNextLabel = sbAnyImage || sbAnyGenerating
? "进入故事板"
: `生成故事板 · ${sbChargeShots > 0 ? `${sbChargePoints} 积分` : `${pts(20)} 积分/镜`}`;
const videoNextLabel = videoAnyStarted
? "进入视频"
: videoChargePoints > 0
? `生成视频 · ${videoChargePoints} 积分`
: "生成视频";
// Stage 4 · 视频详情弹窗:选中段 + 查看的版本 + 可编辑的重跑提示词
const [vdSegId, setVdSegId] = useState<string | null>(null);
const [vdVerId, setVdVerId] = useState<string | null>(null);
@@ -1897,6 +1928,10 @@ export function PipelinePage(props: {
// 整张风格提示词:项目级(原 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 Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false));
};
const videoPrompt = "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感";
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
@@ -3649,8 +3684,19 @@ export function PipelinePage(props: {
<span><Info />确认后将使用以上资产创建故事板,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
<div>
<button className="pl-ghost" type="button" onClick={() => goStage(1)}><ArrowLeft /><span>返回脚本</span></button>
<button className="pl-next" type="button" onClick={() => guardGen(shots, () => goStage(3))}>
<span>确认资产,进入故事板</span>
<button
className="pl-next"
type="button"
disabled={sbGenerating && !sbAnyImage && !sbAnyGenerating}
onClick={() => {
if (sbAnyImage || sbAnyGenerating) {
goStage(3);
return;
}
guardGen(shots, () => setChargeConfirm("storyboard"));
}}
>
<span>{sbNextLabel}</span>
<ArrowRight />
</button>
</div>
@@ -3757,10 +3803,6 @@ export function PipelinePage(props: {
onChange={(v) => setStoryboardPrompt(v.trim())}
/>
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={sbAnyGenerating} onClick={() => guardGen(shots, () => { setSbGenerating(true); void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false)); })}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
{sbAnyImage ? "全部重跑" : "开始生成故事板"}
</button>
{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" />生成中…</> : `↻ 重跑本场`}
@@ -3819,10 +3861,25 @@ export function PipelinePage(props: {
<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>
<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; } 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="M5 12h14M12 5l7 7-7 7" /></svg></button>
<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>
@@ -3832,7 +3889,6 @@ export function PipelinePage(props: {
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
{viewStage === 4 && (() => {
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
const segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0);
const segMin = segSeconds.length ? Math.min(...segSeconds) : 0;
const segMax = segSeconds.length ? Math.max(...segSeconds) : 0;
@@ -3864,7 +3920,6 @@ export function PipelinePage(props: {
</div>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardVideoGen(shots, null, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" />提交中…</> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
{/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */}
<button className="btn btn-sm" type="button" disabled={exporting || segDone === 0} title={exportErr || "把所有已完成视频片段打包下载"} onClick={() => void exportAllVideos()}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M7 10l5 5 5-5" /><path d="M12 15V3" /></svg>
@@ -4460,7 +4515,10 @@ export function PipelinePage(props: {
)}
<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(); }}>仍要继续 <span className="rg-warn">可能跟角色对不上</span></button>
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>
<span>仍要继续</span>
<span className="rg-warn">可能跟角色对不上</span>
</button>
</div>
</div>
</div>,
@@ -4549,7 +4607,7 @@ export function PipelinePage(props: {
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
async function regenPortrait() {
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
// 重跑立绘 → 只追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出。
// 重跑立绘 → 追加新立绘并自动接力该版三视图。
// 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。
const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined;
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK, ref);
@@ -4788,6 +4846,42 @@ export function PipelinePage(props: {
</p>
</div>
</TeamModal>
<ConfirmModal
open={chargeConfirm !== null}
title={chargeConfirm === "video" ? "确认生成视频" : "确认生成故事板"}
subtitle="// 失败不扣 · 成功后结算"
icon={<Sparkles size={16} />}
detail={chargeConfirm === "video"
? (
<>
将按故事板生成 <b>{videoChargeShots || "多"} 段</b>视频,预估扣除 <b>{videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}</b>
{videoChargeShots > 0 && videoChargePoints > 0 ? `(约 ${Math.round(videoChargePoints / videoChargeShots)} 积分/段)` : ""}。
确认后进入视频页并开始生成。
</>
)
: (
<>
将按脚本生成 <b>{sbChargeShots || "多"} 场</b>分镜图,预估扣除 <b>{sbChargePoints > 0 ? `${sbChargePoints} 积分` : "按实际用量结算"}</b>
{sbChargeShots > 0 ? `(${pts(20)} 积分/镜 × ${sbChargeShots} 场)` : ""}。
确认后进入故事板并开始生成。
</>
)}
confirmText={chargeConfirm === "video"
? (videoChargePoints > 0 ? `确认生成 · ${videoChargePoints} 积分` : "确认生成")
: (sbChargePoints > 0 ? `确认生成 · ${sbChargePoints} 积分` : "确认生成")}
onCancel={() => setChargeConfirm(null)}
onConfirm={() => {
const kind = chargeConfirm;
setChargeConfirm(null);
if (kind === "storyboard") {
goStage(3);
startStoryboardGeneration();
} else if (kind === "video") {
goStage(4);
submitAllVideosOptimistic();
}
}}
/>
</section>
);
}