完成视频复刻和优化
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user