完成极速成品和脚本优化

This commit is contained in:
Azmat@qq.com
2026-08-25 11:13:07 +08:00
parent 00fc454db7
commit e2ec2d14af
46 changed files with 4734 additions and 432 deletions
+74 -29
View File
@@ -4,7 +4,7 @@ import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } fr
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
import { api, ApiError } from "../api";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
import { publicModelDisplayName } from "../model-display";
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
import type { Notice, Page } from "./route-config";
import { stageOrder, statusPill } from "./stage-config";
@@ -516,7 +516,8 @@ export function PipelinePage(props: {
logout: () => void;
onNotify?: (type: "success" | "error", text: string) => void;
scriptModelName: string;
textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise<unknown>;
textModels?: 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>;
onDeleteShot: (segmentId: string) => Promise<unknown>;
@@ -1399,18 +1400,53 @@ export function PipelinePage(props: {
} catch { /* localStorage 不可用则忽略 */ }
}, [chatKey, chatMsgs]);
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
// 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型)
// 脚本模型下拉:用户可选 豆包/GPT 等;Gemini 3.1(AirShelf Script)只给视频提炼,不出现在这里
const [scriptModelId, setScriptModelId] = useState<string>("");
const activeScriptModelId = scriptModelId || textModels?.[0]?.id || "";
const scriptPickerModels = useMemo(
() => (textModels || []).filter((model) => !isHiddenFromScriptPicker(model)),
[textModels],
);
const activeScriptModelId = (
scriptPickerModels.some((model) => model.id === scriptModelId) ? scriptModelId : ""
) || scriptPickerModels[0]?.id || "";
// 模型选择小按钮(输入框下方)· 自建 restraint 下拉(幽灵触发 + popover 菜单),不再用原生 select + inline
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const activeModel = textModels?.find((m) => m.id === activeScriptModelId);
const [modelMenuPos, setModelMenuPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const modelPickRef = useRef<HTMLDivElement>(null);
const activeModel = scriptPickerModels.find((m) => m.id === activeScriptModelId);
const activeModelName = publicModelDisplayName(activeModel);
useEffect(() => {
if (!modelMenuOpen) return;
const close = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".chat-model-pick")) setModelMenuOpen(false); };
if (!modelMenuOpen) {
setModelMenuPos(null);
return;
}
const place = () => {
const trigger = modelPickRef.current?.querySelector("button.chip");
if (!(trigger instanceof HTMLElement)) return;
const rect = trigger.getBoundingClientRect();
setModelMenuPos({
left: Math.min(rect.left, window.innerWidth - Math.max(200, rect.width) - 8),
bottom: window.innerHeight - rect.top + 4,
width: Math.max(200, rect.width),
});
};
place();
const close = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest(".chat-model-pick") || target.closest(".chat-model-menu")) return;
setModelMenuOpen(false);
};
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setModelMenuOpen(false); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
document.addEventListener("keydown", onKey);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
document.removeEventListener("click", close);
document.removeEventListener("keydown", onKey);
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [modelMenuOpen]);
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
const [armedDelete, setArmedDelete] = useState<string | null>(null);
@@ -1660,9 +1696,10 @@ export function PipelinePage(props: {
setChatText("");
// 上传视频提炼:参考的是**结构与节奏**,画面里的商品是别人的,必须换成用户自己的商品重写
await runScriptGeneration(
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的叙事结构、镜头节奏和每镜的作用顺序,`
+ `把内容整体换成我自己的商品重写一遍;参考视频里出现的商品、品牌、人物一律不要保留。`
+ `原稿里标注「看不清 / 缺失 / 存疑」的地方,由你按我的商品补全。目标人群:${personaLabel}`,
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,`
+ `把人物、商品、品牌、台词全部换成我自己的商品;参考视频里出现的商品、品牌、人物一律不要保留。`
+ `写 visual 时把每镜的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。`
+ `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。目标人群:${personaLabel}`,
`上传视频提炼 · ${combo}`,
"video",
);
@@ -1782,8 +1819,8 @@ export function PipelinePage(props: {
pushMsg(
"ai",
digest.input === "native_video"
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对景别、运镜、台词和声音再点确定。确认后我按你的商品改写脚本,镜头节奏会跟稿子对齐。`
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对景别、运镜、台词和声音再点确定。确认后我按你的商品改写脚本,镜头节奏会跟稿子对齐。`
);
chatTextareaRef.current?.focus();
} catch (error) {
@@ -3195,22 +3232,30 @@ export function PipelinePage(props: {
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>}
</button>
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
{textModels && textModels.length > 0 ? (
<div className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
<button type="button" className="chip" title="选择脚本生成模型" onClick={() => setModelMenuOpen((v) => !v)}>
{scriptPickerModels.length > 0 ? (
<div ref={modelPickRef} className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
<button type="button" className="chip" title="选择脚本生成模型" aria-expanded={modelMenuOpen} aria-haspopup="listbox" onClick={() => setModelMenuOpen((v) => !v)}>
{activeModelName}
<svg className="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div className="chip-menu align-up">
{textModels.map((m) => (
<div key={m.id} className={`mi${m.id === activeScriptModelId ? " selected" : ""}`} role="menuitemradio" aria-checked={m.id === activeScriptModelId} tabIndex={0}
onClick={() => { setScriptModelId(m.id); setModelMenuOpen(false); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}>
{publicModelDisplayName(m)}
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
</div>
))}
</div>
{modelMenuOpen && modelMenuPos && createPortal(
<div
className="chip-menu chat-model-menu"
role="listbox"
aria-label="脚本生成模型"
style={{ left: modelMenuPos.left, bottom: modelMenuPos.bottom, minWidth: modelMenuPos.width }}
>
{scriptPickerModels.map((m) => (
<div key={m.id} className={`mi${m.id === activeScriptModelId ? " selected" : ""}`} role="option" aria-selected={m.id === activeScriptModelId} tabIndex={0}
onClick={() => { setScriptModelId(m.id); setModelMenuOpen(false); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}>
{publicModelDisplayName(m)}
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
</div>
))}
</div>,
document.body,
)}
</div>
) : null}
<span className="spacer"></span>
@@ -3876,9 +3921,9 @@ export function PipelinePage(props: {
)}
{/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */}
<span className="spacer"></span>
{url
? <a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
: <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled>下载</button>}
{url && !showBusy ? (
<a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
) : null}
</div>
</div>
</div>