完善二期清单

This commit is contained in:
Azmat@qq.com
2026-08-18 14:11:05 +08:00
parent d1ecb52125
commit dfe94a923e
55 changed files with 1754 additions and 137 deletions
+157 -33
View File
@@ -8,13 +8,15 @@ import { isPublicGenerationError, presentGenerationError } from "../generation-e
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
import { MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { IconKitSvg } from "../components/IconKitSvg";
import {
allowedStructures,
clampDuration,
coercePresentationFormat,
coerceVideoStructure,
DURATION_OPTIONS,
durationWarning,
isForbidden,
@@ -30,6 +32,7 @@ import {
type PresentationFormat,
type VideoStructure,
} from "../script-setup";
import { isLocalLife } from "../product-business";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
@@ -50,6 +53,15 @@ const VO_VOICES = [
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
const PERSONA_KEY_BY_LABEL: Record<string, string> = {
...Object.fromEntries(Object.entries(WIZ_PERSONA_LABEL).map(([key, label]) => [label, key])),
: "reviewer",
};
function coercePersona(value: unknown, fallback = "urban"): string {
if (typeof value !== "string" || !value) return fallback;
if (WIZ_PERSONA_LABEL[value]) return value;
return PERSONA_KEY_BY_LABEL[value] || fallback;
}
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
function statusLabel(status: string): string {
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
@@ -1276,12 +1288,12 @@ export function PipelinePage(props: {
const [setupOpen, setSetupOpen] = useState(false);
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
(wizard?.presentation_format as PresentationFormat) || recommended.format
coercePresentationFormat(wizard?.presentation_format, recommended.format)
);
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
(wizard?.video_structure as VideoStructure) || recommended.structure
coerceVideoStructure(wizard?.video_structure, recommended.structure)
);
const [setupPersona, setSetupPersona] = useState<string>(wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
const [setupPersona, setSetupPersona] = useState<string>(coercePersona(wizard?.persona, SETUP_PERSONA_KEYS[0] || "urban"));
const [setupDuration, setSetupDuration] = useState<number>(() => {
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
// 兼容一期向导存的字符串档位("0-30" → 30)
@@ -1299,6 +1311,36 @@ export function PipelinePage(props: {
setSetupStructure(recommended.structure);
if (!wizardHasDuration) setSetupDuration(recommended.duration);
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
const templateOutline = typeof wizard?.template_outline === "string" ? wizard.template_outline : "";
// ── 5.1 存为模板 ── 把这一版脚本的套路存进团队模板库,换商品重开项目时套用
const [tplOpen, setTplOpen] = useState(false);
const [tplName, setTplName] = useState("");
const [tplSaving, setTplSaving] = useState(false);
function openSaveTemplate() {
const formatLabel = PRESENTATION_FORMATS[setupFormat];
const structureLabel = VIDEO_STRUCTURES[setupStructure];
setTplName(`${formatLabel} · ${structureLabel} · ${shots.length}`);
setTplOpen(true);
}
async function saveTemplate() {
const name = tplName.trim();
if (name.length < 2 || tplSaving || !currentScript) return;
setTplSaving(true);
try {
const tpl = await api.saveProjectAsTemplate(project.id, { name, script_version_id: currentScript.id });
setTplOpen(false);
onNotify?.("success", `已存为套路模板「${tpl.name}」,新建项目时可选它换商品重跑`);
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "存模板失败,请重试");
} finally {
setTplSaving(false);
}
}
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
function pickFormat(next: PresentationFormat) {
@@ -1595,18 +1637,21 @@ export function PipelinePage(props: {
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
async function runScriptWithSetup() {
const formatLabel = PRESENTATION_FORMATS[setupFormat];
const structureLabel = VIDEO_STRUCTURES[setupStructure];
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
const format = coercePresentationFormat(setupFormat);
const structure = coerceVideoStructure(setupStructure);
const persona = coercePersona(setupPersona);
const formatLabel = PRESENTATION_FORMATS[format];
const structureLabel = VIDEO_STRUCTURES[structure];
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
await onSaveProjectMeta?.({
wizard: {
...(project.metadata?.wizard ?? {}),
presentation_format: setupFormat,
video_structure: setupStructure,
presentation_format: format,
video_structure: structure,
total_duration: setupDuration,
persona: setupPersona,
persona,
},
});
setSetupOpen(false);
@@ -1645,9 +1690,16 @@ export function PipelinePage(props: {
return;
}
const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成";
// 5.2 换商品重跑:新建项目时选了套路模板 → 把模板的镜序/节奏/转化写法并进 prompt,
// 但明确要求文案按当前商品重写(模板里不含旧文案,转化写法可能带旧品牌词,这里再挡一道)。
const tplLine = templateOutline
? `\n请照这个套路重出一版:\n${templateOutline}\n`
+ `镜数、每镜作用与时长按上面来,文案全部按我当前的商品重新写,`
+ `参考写法里出现的旧商品、品牌、价格一律不要保留。`
: "";
await runScriptGeneration(
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`,
`${sourceLabel}:${combo}`,
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放${tplLine}`,
templateName ? `套用模板「${templateName}」:${combo}` : `${sourceLabel}:${combo}`,
"ai",
);
}
@@ -2473,13 +2525,22 @@ export function PipelinePage(props: {
return () => window.clearInterval(timer);
}, [activeVideoCount, onPollVideosQuiet]);
// Stage 5:进入拼接页时回填已有导出成片(此前导过)
// 进入视频 / 拼接阶段时回填已有成片(此前合成过就直接给出播放/下载入口)
useEffect(() => {
if (viewStage !== 5) return;
if (viewStage !== 4 && viewStage !== 5) return;
onRefreshExport();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewStage]);
// 合成中:静默轮进度。合成本身由发起方(App.submitExport)在轮,这里是为「合成中刷新了页面」
// 兜底 —— 否则发起方的轮询循环随刷新丢掉,界面会卡在旧百分比、按钮一直禁用。
const exportRunning = exportResult?.status === "queued" || exportResult?.status === "running";
useEffect(() => {
if (!exportRunning || (viewStage !== 4 && viewStage !== 5)) return;
const timer = setInterval(() => onRefreshExport(), 3000);
return () => clearInterval(timer);
}, [exportRunning, viewStage, onRefreshExport]);
// 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
async function confirmScript() {
if (currentScript && !scriptAdopted) {
@@ -2645,6 +2706,7 @@ export function PipelinePage(props: {
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
const productRecord = products.find((item) => item.id === project.product);
const productName = productRecord?.title || "透真补水面膜";
const isLocalProduct = isLocalLife(productRecord);
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
// 三视图的「上传」通道:独立上传框,不再借「替换」弹窗绕。AI 生成通道原样保留,两条路并存。
@@ -2795,21 +2857,14 @@ export function PipelinePage(props: {
<div className="script-brief-summary" aria-label="当前创作方向">
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-format">{(() => {
const k = wizard?.presentation_format as PresentationFormat | undefined;
// 旧项目只有一期的「风格」,没有表现形式 —— 显示待确认,别硬套
return k && PRESENTATION_FORMATS[k] ? PRESENTATION_FORMATS[k] : "待确认";
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-structure">{(() => {
const k = wizard?.video_structure as VideoStructure | undefined;
return k && VIDEO_STRUCTURES[k] ? VIDEO_STRUCTURES[k] : "待确认";
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-format">{wizard?.presentation_format ? PRESENTATION_FORMATS[coercePresentationFormat(wizard.presentation_format)] : "待确认"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-structure">{wizard?.video_structure ? VIDEO_STRUCTURES[coerceVideoStructure(wizard.video_structure)] : "待确认"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-duration">{(() => {
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
return `${scripted || setupDuration}`;
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-persona">{(() => { const k = wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-persona">{wizard?.persona ? (WIZ_PERSONA_LABEL[coercePersona(wizard.persona)] || wizard.persona) : "待确认"}</span></span>
</div>
{/* 34/3 · / :AI 稿;,,
线线, */}
@@ -2990,7 +3045,14 @@ export function PipelinePage(props: {
{setupOpen && (
<div className="msg ai">
<div className="bubble setup-card">
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : "我会根据商品信息直接生成第一版。"}</div>
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : templateName ? `已套用模板「${templateName}」,套路参数已填好。` : "我会根据商品信息直接生成第一版。"}</div>
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」,免得用户以为是复制粘贴 */}
{templateOutline && setupSource === "ai" && (
<div className="tpl-expect">
<span className="mono">// 同套路重出</span>
沿,{setupProduct?.title || "当前商品"}
</div>
)}
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
@@ -3134,6 +3196,7 @@ export function PipelinePage(props: {
<div className="stage-foot">
<div className="info"><span className="mono">[ {pts(10)} / · ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading || !currentScript} onClick={openSaveTemplate}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1z" /></svg> </button>
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" 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> </button>
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <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>
@@ -3150,7 +3213,7 @@ export function PipelinePage(props: {
const adoptedTriAsset = productGroup?.adopted_asset || "";
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
const hasTriView = productVersions.length > 0;
const triPanelShow = triPanelOpen || hasTriView || triGenerating;
const triPanelShow = isLocalProduct ? hasTriView : (triPanelOpen || hasTriView || triGenerating);
// 商品主图:优先用序列化器内嵌的 preview_url(全局 assets 已不再全量取,assetUrl 反查会落空),
// 依次 封面 → 主图 → 首图,最后才回退旧的全局反查。
const productAssetUrl =
@@ -3227,7 +3290,7 @@ export function PipelinePage(props: {
<div className="prod-row">
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={adoptedTriAsset || "prod-main"} id="asset-prod-card">
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
{!hasTriView && (
{!isLocalProduct && !hasTriView && (
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
<span className="ico" aria-hidden="true"></span>
<span className="lbl-mono"></span>
@@ -3245,16 +3308,19 @@ export function PipelinePage(props: {
</div>
<div className="prod-body">
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
<div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
<div className="prod-cat">{isLocalProduct ? "本地生活" : ""}{isLocalProduct && productRecord?.category ? " · " : ""}{productRecord?.category || (isLocalProduct ? "" : "未分类")}</div>
<div className="prod-date">{(project.created_at || "").slice(0, 10)} </div>
</div>
<div className="prod-action" id="asset-prod-action">
{/* 双通道:左「上传三视图」(ghost,用户自带图) + 右「AI 生成三视图」(primary,平台生成) */}
{isLocalProduct ? (
<div className="mono prod-skip-note">// 本地生活无需商品三视图,可直接进入下一步</div>
) : (
<div className="hstack">
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triUploading} onClick={() => triFileRef.current?.click()}>{triUploading ? "上传中…" : "上传三视图"}</button>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating || triUploading} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
</div>
)}
</div>
</div>
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
@@ -3691,6 +3757,12 @@ export function PipelinePage(props: {
: activeVideoCount > 0
? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
: "待生成";
// 合成成片(ffmpeg 按场次顺序拼成一条):全部场次出片才能合成。
// 命名避开本作用域已有的 exporting/exportErr(那是「片段打包 zip 下载」)。
const allSegDone = segments.length > 0 && segDone === segments.length;
const mergedUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : "";
const merging = exportResult?.status === "queued" || exportResult?.status === "running";
const mergeProgress = exportResult?.progress ?? 0;
return (
<section className="stage active" data-stage-pane="4">
<div className="queue-bar">
@@ -3756,13 +3828,36 @@ export function PipelinePage(props: {
)}
<div className="stage-foot">
<div className="info"><span className="mono">[ {segDone} · {segTotalSec}s · · ]</span></div>
<div className="info">
<span className="mono">[ {segDone} · {segTotalSec}s · · ]</span>
{merging && <span className="mono" style={{ marginLeft: "10px", color: "var(--heat)" }}>// 合成中 {mergeProgress}%</span>}
{exportResult?.status === "failed" && (
<span className="mono" style={{ marginLeft: "10px", color: "var(--accent-crimson)" }}>// 合成失败:{exportResult.error_message || "请重试"}</span>
)}
</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>
{/* V1 雪藏拼接导出:隐藏「进入拼接」入口(代码保留,V2 恢复 false→true) */}
{false && (
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>, <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>
{/* 合成成片:各场视频按顺序拼成一条完整视频(合成完才有下面的播放/下载) */}
{mergedUrl && (
<>
<button className="btn" type="button" onClick={() => setPreview({ src: mergedUrl, kind: "video", name: `${project.name} · 成片` })}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M6 4l13 8-13 8z" /></svg>
</button>
<a className="btn" href={mergedUrl} target="_blank" rel="noreferrer" download>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg>
</a>
</>
)}
<button
className="btn btn-primary btn-lg"
type="button"
disabled={!allSegDone || merging}
title={allSegDone ? "把各场视频按顺序拼成一条完整视频(不扣积分)" : "所有场次都出片后才能合成"}
onClick={() => onSubmitExport()}
>
{merging ? <><span className="spinner btn-spin" aria-hidden="true" /> {mergeProgress}%</> : mergedUrl ? "重新合成" : "合成完整视频"}
{!merging && <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>
</section>
@@ -4552,6 +4647,35 @@ export function PipelinePage(props: {
onRefresh={() => void onRefreshProject()}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
{/* 5.1 存为模板 · 先把「会被存进去的」摊开给用户看,再让他起名,避免以为连文案一起存了 */}
<TeamModal
open={tplOpen}
title="存为套路模板"
subtitle="// SAVE TEMPLATE"
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1z" /></svg>}
close={() => setTplOpen(false)}
footer={<button className="btn btn-primary" type="button" disabled={tplName.trim().length < 2 || tplSaving} onClick={() => void saveTemplate()}>{tplSaving ? "保存中…" : "保存模板"}</button>}
>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={tplName} maxLength={40} onChange={(event) => setTplName(event.target.value)} placeholder="例如:口播 · 痛点前置 · 5 镜" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"></label>
<div className="tpl-capture">
<div className="row"><span className="k"></span><span className="v">{PRESENTATION_FORMATS[setupFormat]} · {VIDEO_STRUCTURES[setupStructure]}</span></div>
<div className="row"><span className="k"></span><span className="v">{shots.map((s) => s.role || "叙述").join(" → ") || "—"}</span></div>
<div className="row"><span className="k"></span><span className="v">{shots.length} · {shots.map((s) => `${s.duration_seconds}s`).join(" / ") || "—"}</span></div>
<div className="row"><span className="k"></span><span className="v">{WIZ_PERSONA_LABEL[setupPersona] || setupPersona}</span></div>
<div className="row"><span className="k"></span><span className="v">{(shots.find((s) => s.role === "CTA") || shots[shots.length - 1])?.narration || "—"}</span></div>
</div>
<p className="tpl-note">
<span className="mono">// NOTE</span> 模板只存上面这套「怎么讲」。这一版脚本里针对
{" "}{setupProduct?.title || "当前商品"}{" "} AI
</p>
</div>
</TeamModal>
</div>
);
}