优化脚本

This commit is contained in:
Azmat@qq.com
2026-08-21 10:28:35 +08:00
parent 6c588a7fee
commit c2d3a786a3
23 changed files with 1461 additions and 287 deletions
+48 -32
View File
@@ -1,4 +1,5 @@
import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
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";
@@ -352,7 +353,9 @@ function PromptBox({ value, onChange, className = "prompt-box", id, ariaLabel, s
// 分镜「旁白 / 画面」可编辑框:非受控 textarea,落库走 onBlur。
// 聚焦中绝不覆盖,避免提交后父组件用旧值把正在输入的字冲掉。
function ShotArea({ value, placeholder, ariaLabel, onCommit }: { value: string; placeholder: string; ariaLabel: string; onCommit: (text: string) => void }) {
function ShotArea({ value, placeholder, ariaLabel, onCommit, rows = 2 }: {
value: string; placeholder: string; ariaLabel: string; onCommit: (text: string) => void; rows?: number;
}) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
@@ -366,7 +369,7 @@ function ShotArea({ value, placeholder, ariaLabel, onCommit }: { value: string;
defaultValue={value}
placeholder={placeholder}
aria-label={ariaLabel}
rows={2}
rows={rows}
onBlur={(e) => {
const text = e.currentTarget.value.trim();
if (text !== value) onCommit(text);
@@ -404,7 +407,7 @@ function DraftShotCard({ draft, onCommit, onCancel }: {
</label>
<label className="shot-edit">
<span>画面内容</span>
<textarea className="shot-area" rows={2} placeholder="输入这一镜的画面描述…" value={visual}
<textarea className="shot-area" rows={6} placeholder="0-3s:近景……(按秒写出每一镜)" value={visual}
onChange={(e) => setVisual(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); commit(); } else if (e.key === "Escape") { onCancel?.(draft.id); } }} />
</label>
@@ -599,7 +602,7 @@ export function PipelinePage(props: {
try { return new Set<string>(JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]")); } catch { return new Set(); }
});
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).filter((s) => !pendingDeletes.has(s.id));
// 单镜时长自二期起是变长的(4–15s),缺值时按上限兜底,跟后端 script_agent 一致
// 单镜时长主流程固定 15s;缺值时按上限兜底,跟后端 script_agent 一致
const shotSeconds = (s: { duration_seconds?: number | null }) => s.duration_seconds || SEGMENT_DURATION_MAX;
const scriptTotalSeconds = shots.reduce((sum, s) => sum + shotSeconds(s), 0);
// 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata
@@ -1534,9 +1537,11 @@ export function PipelinePage(props: {
mode: agentMode,
prompt,
model_config_id: activeScriptModelId || undefined,
selling_point_ids: Array.isArray(wizard?.selling_point_ids) ? wizard.selling_point_ids : undefined,
persona: setupPersona,
base_version_id: baseVersionId,
aspect_ratio: "9:16",
// 设定卡的三个参数一路传到后端:后端据此挑套路提示词 + 按节奏切镜(不再每镜写死 15 秒)
// 设定卡参数一路传到后端:每镜固定 15 秒,总时长 15/30/45/60
total_duration: setupDuration,
presentation_format: setupFormat,
video_structure: setupStructure,
@@ -1625,8 +1630,7 @@ export function PipelinePage(props: {
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
}
}
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
// 1.5 · 确认设定后真正发起生成。形式/结构/时长/人物/勾选卖点都走结构化参数,不再塞一句空主题。
async function runScriptWithSetup() {
const format = coercePresentationFormat(setupFormat);
const structure = coerceVideoStructure(setupStructure);
@@ -1684,12 +1688,12 @@ export function PipelinePage(props: {
// 5.2 换商品重跑:新建项目时选了套路模板 → 把模板的镜序/节奏/转化写法并进 prompt,
// 但明确要求文案按当前商品重写(模板里不含旧文案,转化写法可能带旧品牌词,这里再挡一道)。
const tplLine = templateOutline
? `\n请照这个套路重出一版:\n${templateOutline}\n`
+ `镜数、每镜作用与时长按上面来,文案全部按我当前的商品重新写,`
? `请照这个套路重出一版:\n${templateOutline}\n`
+ `镜数、每镜作用按上面来(每镜 15 秒),文案全部按我当前的商品重新写,`
+ `参考写法里出现的旧商品、品牌、价格一律不要保留。`
: "";
await runScriptGeneration(
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放${tplLine}`,
tplLine,
templateName ? `套用模板「${templateName}」:${combo}` : `${sourceLabel}:${combo}`,
"ai",
);
@@ -3004,8 +3008,9 @@ export function PipelinePage(props: {
<span>画面内容</span>
<ShotArea
value={visualShown}
placeholder="点击编辑画面内容"
placeholder="0-3s:近景……(按秒写出每一镜)"
ariaLabel={`场景 ${index + 1} 画面内容`}
rows={6}
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, visual_prompt: text }); }}
/>
</label>
@@ -3102,7 +3107,7 @@ export function PipelinePage(props: {
</select>
</label>
<div className="setup-rec">根据商品目标人群推荐</div>
{/* 1.9 时长自由化:5–60 秒、5 秒一档;每镜切多长由后端按表现形式的节奏决定 */}
{/* 时长:15/30/45/60;每镜固定 15 秒 */}
<label className="setup-field">
<span className="sf-k">时长</span>
<select className="setup-select" value={setupDuration} onChange={(e) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(e.target.value))); }}>
@@ -3453,7 +3458,7 @@ export function PipelinePage(props: {
return (
<div className="as-gen" data-asset-kind="scene" key={dk}>
<div className={`as-gen-preview${busy ? " generating" : " pending"}`}>
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span>生成中 · 约 8s</span></span> : null}
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span>生成中</span></span> : null}
</div>
<div className="as-gen-meta">
<input type="text" value={draft.title} placeholder="场景图待生成" aria-label="场景名" disabled={busy} data-stop
@@ -3498,7 +3503,7 @@ export function PipelinePage(props: {
onClick={() => openSeedDetail(kind, tag, promptValue)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSeedDetail(kind, tag, promptValue); } }}
>
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span>生成中 · 约 8s</span></span> : null}
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span>生成中</span></span> : null}
</div>
<div className="as-gen-meta">
<h4 onClick={() => openSeedDetail(kind, tag, promptValue)}>{tag || (kind === "person" ? "角色图待生成" : "场景图待生成")}</h4>
@@ -3823,11 +3828,17 @@ export function PipelinePage(props: {
const showBusy = busy || pending;
const verCount = seg.versions?.length ?? 0;
return (
<div className="video-card" key={seg.id} data-video-id={seg.id}>
<div className="video-thumb" role="button" tabIndex={0} title="点击查看详情" onClick={() => openVideoDetail(seg.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openVideoDetail(seg.id); } }}>
{url
? <video src={`${url}#t=0.1`} muted playsInline preload="metadata" />
: <span className="ph-frame">场 {seg.sort_order + 1}</span>}
<div className={`video-card${url ? " is-ready" : showBusy ? " is-generating" : " is-pending"}`} key={seg.id} data-video-id={seg.id}>
<div
className={`video-thumb${url ? " ready" : ""}${showBusy ? " generating" : ""}${!url && !showBusy ? " pending" : ""}`}
role="button"
tabIndex={0}
title="点击查看详情"
aria-label={showBusy ? `场 ${seg.sort_order + 1} 生成中` : url ? `场 ${seg.sort_order + 1}` : `场 ${seg.sort_order + 1} 待生成`}
onClick={() => openVideoDetail(seg.id)}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openVideoDetail(seg.id); } }}
>
{url ? <video src={`${url}#t=0.1`} muted playsInline preload="metadata" /> : null}
{url && !showBusy && (
<button
className="btn-play"
@@ -3838,7 +3849,7 @@ export function PipelinePage(props: {
<Play size={16} fill="currentColor" />
</button>
)}
{showBusy && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{showBusy ? <span className="video-loader" aria-hidden="true" /> : null}
</div>
<div className="body">
<div className="video-card-head">
@@ -4282,7 +4293,7 @@ export function PipelinePage(props: {
const showBusy = busy || rerunPending.has(vdSeg.id);
const hint = (shots[vdSeg.sort_order]?.visual_prompt || shots[vdSeg.sort_order]?.narration || "").trim();
const rerunPrompt = vdPrompt.trim() || `${videoPrompt} 第 ${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds} 秒`;
return (
return createPortal(
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label="视频详情" onClick={(event) => { if (event.target === event.currentTarget) setVdSegId(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
@@ -4349,9 +4360,10 @@ export function PipelinePage(props: {
{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "↻ 重跑本场"}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
})()}
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
@@ -4359,7 +4371,7 @@ export function PipelinePage(props: {
// 拆两类:noref=连参考图都没有;notri=有立绘但缺三视图。文案分别告诉用户「去哪里点什么」。
const noRef = refGate.missing.filter((m) => m.reason === "noref");
const noTri = refGate.missing.filter((m) => m.reason === "notri");
return (
return createPortal(
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="rg-title">资产还没齐,先补一下?</div>
@@ -4398,11 +4410,12 @@ export function PipelinePage(props: {
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>仍要继续 <span className="rg-warn">可能跟角色对不上</span></button>
</div>
</div>
</div>
</div>,
document.body
);
})()}
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
{reviewGate && (
{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>
@@ -4440,7 +4453,8 @@ export function PipelinePage(props: {
<button type="button" className="btn btn-ghost" onClick={() => setReviewGate(null)}>知道了</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */}
{adDetail && (() => {
@@ -4494,12 +4508,12 @@ export function PipelinePage(props: {
setAdTriId(null);
}
const zoomSvg = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" /></svg>;
return (
return createPortal(
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={`${KIND_LABEL[adDetail.kind]}详情`} onClick={(event) => { if (event.target === event.currentTarget) setAdDetail(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
<h2>资产详情</h2>
<span className="ad-tag">/ {KIND_LABEL[adDetail.kind]} · {entity.name}</span>
<h2>{entity.name}</h2>
<span className="ad-tag">/ {KIND_LABEL[adDetail.kind]}</span>
<button className="x" type="button" aria-label="关闭" onClick={() => setAdDetail(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
@@ -4629,11 +4643,12 @@ export function PipelinePage(props: {
}}>应用当前角色</button>
</div>
</div>
</div>
</div>,
document.body
);
})()}
{/* 问题5(a):用已有素材作三视图——选商品已有图 / 上传本地图,attach 成商品采用版(不走平台 AI 生成、不计费) */}
{assetPick && (
{assetPick && createPortal(
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={assetPick.title} onClick={(e) => { if (e.target === e.currentTarget && !assetPickBusy) setAssetPick(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
@@ -4675,7 +4690,8 @@ export function PipelinePage(props: {
)}
</div>
</div>
</div>
</div>,
document.body
)}
{/* 流程步骤4 · 模特库:浏览 / 添加模特(AI 生成·本地上传)/ 替换回填 */}
<ModelLibrary
+7 -4
View File
@@ -79,7 +79,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
useEffect(() => {
const p = products.find((item) => item.id === productId);
const seed: Record<string, boolean> = {};
(p?.selling_points || []).forEach((sp) => { seed[sp.title] = false; });
(p?.selling_points || []).forEach((sp) => { seed[sp.id || sp.title] = false; });
setPoints(seed);
}, [productId, products]);
@@ -344,12 +344,15 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
<div className="nw-field">
<span>关键卖点<small>可勾选要重点突出的</small></span>
<div className="nw-pills">
{Object.entries(points).map(([key, on]) => (
{Object.entries(points).map(([key, on]) => {
const label = product?.selling_points?.find((sp) => (sp.id || sp.title) === key)?.title || key;
return (
<button className={`nw-pill${on ? " on" : ""}`} type="button" key={key} aria-pressed={on} onClick={() => setPoints((prev) => ({ ...prev, [key]: !prev[key] }))}>
{on && <Check size={13} />}
<span>{key}</span>
<span>{label}</span>
</button>
))}
);
})}
</div>
</div>
)}