完成极速成品和脚本优化
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user