完成视频复刻和优化

This commit is contained in:
Azmat@qq.com
2026-08-27 11:54:47 +08:00
parent c25060c6c6
commit 51620bf25b
46 changed files with 2575 additions and 663 deletions
+24 -58
View File
@@ -13,23 +13,16 @@ import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../c
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import {
allowedStructures,
clampDuration,
coercePresentationFormat,
coerceVideoStructure,
DURATION_OPTIONS,
durationWarning,
isForbidden,
PRESENTATION_FORMATS,
PRESENTATION_HINT,
PRESENTATION_KEYS,
recommendDuration,
recommendSetup,
SEGMENT_DURATION_MAX,
STRUCTURE_HINT,
TOTAL_DURATION_MIN,
VIDEO_STRUCTURES,
type PresentationFormat,
type VideoStructure,
} from "../script-setup";
import { isLocalLife } from "../product-business";
@@ -50,8 +43,7 @@ const VO_VOICES = [
{ key: "BV102_streaming", label: "儒雅青年 · 解说男声" },
{ key: "BV002_streaming", label: "通用男声" },
];
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
const FIXED_PRESENTATION_FORMAT = "oral" as const;
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])),
@@ -1442,10 +1434,10 @@ export function PipelinePage(props: {
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 }>>([]);
// ── Stage 1 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定(1.5–1.9):向导那边已删,这里补上 ──
// setupOpen:两个入口之一被选中后,展示四栏选择 + 确认/重新推荐;确认后才真正发起生成。
// ── Stage 1 · 生成前「视频结构 × 人物 × 时长」设定。表现形式固定为口播。 ──
// setupOpen:入口被选中后展示三栏设定,确认后才真正发起生成。
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
// 2.3 按商品品类/人群推荐一组默认值;用户随时可改,推荐只是省掉「从零开始选」
// 2.3 按商品品类推荐默认结构、人物与时长,用户仍可调整。
const setupProduct = products.find((item) => item.id === project.product);
const recommended = useMemo(
() => recommendSetup({
@@ -1457,9 +1449,6 @@ export function PipelinePage(props: {
const wizard = project.metadata?.wizard;
const [setupOpen, setSetupOpen] = useState(false);
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
coercePresentationFormat(wizard?.presentation_format, recommended.format)
);
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
coerceVideoStructure(wizard?.video_structure, recommended.structure)
);
@@ -1475,18 +1464,17 @@ export function PipelinePage(props: {
// 商品是异步拉回来的,首帧 setupProduct 还是 undefined → 上面的初值只能拿到兜底组合。
// 等商品到位后补一次推荐,但只在「用户没动过 + 项目也没存过设定」时才覆盖。
const setupTouched = useRef(false);
const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure);
const wizardHasStructure = Boolean(wizard?.video_structure);
const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration);
const wizardHasPersona = Boolean(wizard?.persona);
useEffect(() => {
if (setupTouched.current) return;
if (!wizardHasCombo) {
setSetupFormat(recommended.format);
if (!wizardHasStructure) {
setSetupStructure(recommended.structure);
if (!wizardHasDuration) setSetupDuration(recommended.duration);
}
if (!wizardHasPersona) setSetupPersona(recommended.persona);
}, [recommended.format, recommended.structure, recommended.persona, recommended.duration, wizardHasCombo, wizardHasDuration, wizardHasPersona]);
}, [recommended.structure, recommended.persona, recommended.duration, wizardHasStructure, wizardHasDuration, wizardHasPersona]);
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
@@ -1497,9 +1485,8 @@ export function PipelinePage(props: {
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} 镜`);
setTplName(`口播 · ${structureLabel} · ${shots.length} 镜`);
setTplOpen(true);
}
async function saveTemplate() {
@@ -1517,16 +1504,10 @@ export function PipelinePage(props: {
}
}
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
function pickFormat(next: PresentationFormat) {
setupTouched.current = true;
setSetupFormat(next);
if (isForbidden(next, setupStructure)) setSetupStructure(allowedStructures(next)[0]);
}
const structureOptions = useMemo(() => Object.keys(VIDEO_STRUCTURES) as VideoStructure[], []);
const durationHint = durationWarning(setupStructure, setupDuration);
// 建议时长跟着**当前选中**的组合走,不是跟着推荐组合走(否则选了短剧还提示口播的 30 秒)
const durationSuggest = recommendDuration(setupFormat, setupStructure);
// 建议时长跟着当前选中的口播结构走。
const durationSuggest = recommendDuration(FIXED_PRESENTATION_FORMAT, setupStructure);
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
// 输入框随内容长高(封顶 260px 后内部滚动)。上传脚本 / 上传视频提炼灌进来的是整篇稿子,
// 固定两行的框没法逐镜校对 —— 而「可人工逐镜编辑」正是这两个入口的硬要求。
@@ -1760,7 +1741,7 @@ export function PipelinePage(props: {
aspect_ratio: "9:16",
// 设定卡参数一路传到后端:每镜固定 15 秒,总时长 15/30/45/60
total_duration: setupDuration,
presentation_format: setupFormat,
presentation_format: FIXED_PRESENTATION_FORMAT,
video_structure: setupStructure,
target_index: targetIndex,
source: source === "manual" || source === "video" ? source : undefined
@@ -1847,27 +1828,22 @@ export function PipelinePage(props: {
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
}
}
// 1.5 · 确认设定后真正发起生成。形式/结构/时长/人物/勾选卖点都走结构化参数,不再塞一句空主题。
// 确认设定后真正发起生成。表现形式固定口播,人物、结构、时长与勾选卖点走结构化参数。
async function runScriptWithSetup() {
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: format,
video_structure: structure,
total_duration: setupDuration,
persona,
},
});
const nextWizard = { ...(project.metadata?.wizard ?? {}) };
nextWizard.presentation_format = FIXED_PRESENTATION_FORMAT;
nextWizard.video_structure = structure;
nextWizard.total_duration = setupDuration;
nextWizard.persona = persona;
await onSaveProjectMeta?.({ wizard: nextWizard });
setSetupOpen(false);
const combo = `${formatLabel} · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
const combo = `口播 · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
if (setupSource === "video") {
const base = chatText.trim();
if (!base) {
@@ -3122,7 +3098,6 @@ 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">{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">{(() => {
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
@@ -3302,7 +3277,7 @@ export function PipelinePage(props: {
<time className="chat-time">{msg.time}</time>
</div>
))}
{/* 1.5 · 选定生成方式后的「表现形式 × 视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
{/* 选定生成方式后的「视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
{setupOpen && (
<div className="chat-msg ai">
<div className="chat-bubble setup-card">
@@ -3314,14 +3289,6 @@ export function PipelinePage(props: {
沿用模板的镜数、每镜作用和节奏,文案、画面、模特与故事板都会按{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)}>
{PRESENTATION_KEYS.map((k) => <option key={k} value={k}>{PRESENTATION_FORMATS[k]}</option>)}
</select>
</label>
<div className="setup-rec">{PRESENTATION_HINT[setupFormat]}</div>
{/* 1.8 组合联动:短剧下拉里没有「测评验证」—— 演出来的实测没有可信度 */}
<label className="setup-field">
<span className="sf-k">视频结构</span>
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}>
@@ -3343,14 +3310,13 @@ export function PipelinePage(props: {
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} 秒</option>)}
</select>
</label>
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `${PRESENTATION_FORMATS[setupFormat]} × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `口播 × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
<div className="setup-foot">
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}>← 返回</button>
<button type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => {
// 重新推荐:回到按商品品类算出的那一组(不是随机换,随机换等于没推荐)
setupTouched.current = false;
setSetupFormat(recommended.format);
setSetupStructure(recommended.structure);
setSetupPersona(recommended.persona);
setSetupDuration(recommended.duration);
@@ -5002,7 +4968,7 @@ export function PipelinePage(props: {
<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">口播 · {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>