Add LibTV composer trigger interactions
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-18 16:38:21 +08:00
parent 6f64164109
commit d348be844b

View File

@ -29,6 +29,7 @@ import {
} from "lucide-react";
import { useRouter } from "next/navigation";
import {
type ChangeEvent as ReactChangeEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
@ -73,6 +74,8 @@ type ComposerMenu =
| "translate"
| "settings"
| "count"
| "reference"
| "slash"
| null;
type MemberStoreTab = "vip" | "model" | "credits";
@ -167,6 +170,15 @@ type PresetControlAnchor = {
topY: number;
};
type ComposerSettings = {
camera: string;
count: string;
model: string;
panorama: string;
ratio: string;
resolution: string;
};
type CanvasCreateKind =
| "image-input"
| "video-input"
@ -346,6 +358,14 @@ const CREDIT_PACKS = [
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
const AUDIO_TO_VIDEO_PROMPT =
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
const SLASH_IMAGE_ITEMS = [
"剧情推演四宫格",
"25宫格连贯分镜",
"电影级光影校正",
"角色三视图生成",
"画面推演-3秒后",
"画面推演-5秒前",
];
const STORY_SCRIPT_TEXT = `《我在盛唐写天下》
类型:古风 / 穿越 / 爽文漫剧
@ -1218,6 +1238,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (event.key === "Escape") {
setCanvasCommand(null);
setNodeContextMenu(null);
setSelectedNodeId(null);
setActivePanel(null);
}
};
window.addEventListener("keydown", onKeyDown);
@ -1276,7 +1298,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const onCanvasWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
const target = event.target as HTMLElement;
const overlay = target.closest("[data-canvas-overlay]");
const canvasNode = target.closest("[data-canvas-node]");
if (overlay && !canvasNode) return;
event.preventDefault();
if (event.ctrlKey || event.metaKey) {
zoomAtPoint(event.deltaY > 0 ? -0.08 : 0.08, event.clientX, event.clientY);
@ -1418,7 +1443,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
) : null}
{selectedPresetMode && selectedPresetAnchor ? (
<PresetWorkflowControls anchor={selectedPresetAnchor} mode={selectedPresetMode} />
<PresetWorkflowControls anchor={selectedPresetAnchor} mode={selectedPresetMode} nodes={nodes} />
) : null}
<TopBar
@ -1951,15 +1976,17 @@ function MagnetGlyph() {
function PresetWorkflowControls({
anchor,
mode,
nodes,
}: {
anchor: PresetControlAnchor;
mode: Exclude<ActiveStarterScene, null>;
nodes: CloneNode[];
}) {
if (mode === "character-three-view") {
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<CharacterPresetToolbar anchor={anchor} />
<PromptComposer anchor={anchor} />
<PromptComposer anchor={anchor} nodes={nodes} />
</div>
);
}
@ -2650,9 +2677,66 @@ function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: stri
);
}
function PromptComposer({ anchor }: { anchor?: PresetControlAnchor }) {
function PromptComposer({
anchor,
nodes = [],
}: {
anchor?: PresetControlAnchor;
nodes?: CloneNode[];
}) {
const [menu, setMenu] = useState<ComposerMenu>(null);
const [prompt, setPrompt] = useState("");
const [references, setReferences] = useState<string[]>([]);
const [slashScene, setSlashScene] = useState<string | null>(null);
const [settings, setSettings] = useState<ComposerSettings>({
camera: "摄像机",
count: "1张",
model: "Lib Nano Pro",
panorama: "全景",
ratio: "16:9",
resolution: "2K",
});
const referenceNodes = useMemo(
() => nodes.filter((node) => node.kind !== "script" && node.inputType !== "audio"),
[nodes]
);
const insertReference = useCallback((node: CloneNode) => {
const label = node.title || "节点";
setPrompt((current) => `${current.replace(/@$/, "").trimEnd()} @${label} `);
setReferences((current) => (current.includes(node.id) ? current : [...current, node.id]));
setMenu(null);
}, []);
const insertSlashScene = useCallback((scene: string) => {
setSlashScene(scene);
setPrompt((current) => `${current.replace(/\/$/, "").trimEnd()} /${scene} `);
setMenu(null);
}, []);
const applyComposerOption = useCallback((activeMenu: Exclude<ComposerMenu, null>, option: string) => {
if (activeMenu === "model" || activeMenu === "ratio" || activeMenu === "resolution" || activeMenu === "camera" || activeMenu === "panorama" || activeMenu === "count") {
setSettings((current) => ({ ...current, [activeMenu]: option }));
} else if (activeMenu === "style" || activeMenu === "mark" || activeMenu === "focus" || activeMenu === "translate" || activeMenu === "settings") {
setPrompt((current) => `${current.trimEnd()} ${option}`.trimStart());
}
setMenu(null);
toast.info(`${option} 已应用,生成接口待接入。`);
}, []);
const onPromptChange = useCallback((event: ReactChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
setPrompt(value);
const beforeCursor = value.slice(0, event.target.selectionStart ?? value.length);
if (beforeCursor.endsWith("@")) {
setMenu("reference");
return;
}
if (beforeCursor.endsWith("/")) {
setMenu("slash");
return;
}
}, []);
return (
<div
@ -2684,24 +2768,65 @@ function PromptComposer({ anchor }: { anchor?: PresetControlAnchor }) {
<span className="absolute right-0 top-0 flex h-5 min-w-5 items-center justify-center rounded-bl-[8px] bg-black/50 px-1 text-xs">1</span>
</div>
</div>
{menu ? <ComposerPopover menu={menu} /> : null}
{references.length > 0 || slashScene ? (
<div className="mt-3 flex max-w-[780px] flex-wrap gap-2">
{slashScene ? (
<button
className="rounded-[8px] border border-[#42eaff]/24 bg-[#0e6371]/40 px-3 py-1 text-[13px] text-[#72f1ff]"
onClick={() => setMenu("slash")}
type="button"
>
/ {slashScene}
</button>
) : null}
{references.map((nodeId) => {
const node = nodes.find((item) => item.id === nodeId);
if (!node) return null;
const previewUrl = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
return (
<button
className="flex h-7 items-center gap-2 rounded-[8px] border border-white/10 bg-white/8 px-2 text-[12px] text-white/76"
key={nodeId}
onClick={() => setMenu("reference")}
type="button"
>
{previewUrl ? <img alt="" className="h-5 w-5 rounded-[5px] object-cover" src={previewUrl} /> : <ImageIcon className="h-4 w-4" />}
@{node.title}
</button>
);
})}
</div>
) : null}
{menu === "reference" ? (
<ComposerReferencePopover nodes={referenceNodes} onSelect={insertReference} />
) : menu === "slash" ? (
<ComposerSlashPopover onSelect={insertSlashScene} />
) : menu === "focus" ? (
<ComposerFocusPopover onApply={(value) => applyComposerOption("focus", value)} />
) : menu ? (
<ComposerPopover menu={menu} onSelect={(option) => applyComposerOption(menu, option)} />
) : null}
<textarea
className="mt-5 h-[86px] w-full resize-none bg-transparent text-[20px] leading-8 text-white/64 outline-none placeholder:text-white/42"
onChange={(event) => setPrompt(event.target.value)}
onChange={onPromptChange}
onFocus={() => {
if (prompt.endsWith("@")) setMenu("reference");
if (prompt.endsWith("/")) setMenu("slash");
}}
placeholder="描述你想要生成的画面内容,按 / 呼出指令,@引用素材"
value={prompt}
/>
<div className="absolute bottom-5 left-5 right-5 flex items-center gap-6 text-[18px] text-white/78">
<ComposerControl active={menu === "model"} label="Lib Nano Pro" icon="◉" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
<ComposerControl active={menu === "ratio"} label="16:9" icon="▭" dropdown onClick={() => setMenu((current) => current === "ratio" ? null : "ratio")} />
<ComposerControl active={menu === "resolution"} label="2K" dropdown onClick={() => setMenu((current) => current === "resolution" ? null : "resolution")} />
<ComposerControl active={menu === "camera"} label="摄像机" icon="▣" onClick={() => setMenu((current) => current === "camera" ? null : "camera")} />
<ComposerControl active={menu === "panorama"} label="全景" icon="♫" onClick={() => setMenu((current) => current === "panorama" ? null : "panorama")} />
<ComposerControl active={menu === "model"} label={settings.model} icon="◉" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
<ComposerControl active={menu === "ratio"} label={settings.ratio} icon="▭" dropdown onClick={() => setMenu((current) => current === "ratio" ? null : "ratio")} />
<ComposerControl active={menu === "resolution"} label={settings.resolution} dropdown onClick={() => setMenu((current) => current === "resolution" ? null : "resolution")} />
<ComposerControl active={menu === "camera"} label={settings.camera} icon="▣" onClick={() => setMenu((current) => current === "camera" ? null : "camera")} />
<ComposerControl active={menu === "panorama"} label={settings.panorama} icon="♫" onClick={() => setMenu((current) => current === "panorama" ? null : "panorama")} />
<ComposerControl active={menu === "translate"} label="文" onClick={() => setMenu((current) => current === "translate" ? null : "translate")} />
<ComposerControl active={menu === "settings"} label="" icon="⌘" onClick={() => setMenu((current) => current === "settings" ? null : "settings")} />
<ComposerControl active={menu === "count"} label="1张" dropdown onClick={() => setMenu((current) => current === "count" ? null : "count")} />
<ComposerControl active={menu === "count"} label={settings.count} dropdown onClick={() => setMenu((current) => current === "count" ? null : "count")} />
<ComposerControl label="14" icon="⚡" muted onClick={() => toast.info("积分估算接口待接入。")} />
<button
className="ml-auto flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white"
@ -2741,8 +2866,90 @@ function PromptActionButton({
);
}
function ComposerPopover({ menu }: { menu: Exclude<ComposerMenu, null> }) {
const options: Record<Exclude<ComposerMenu, "focus" | null>, string[]> = {
function ComposerFocusPopover({ onApply }: { onApply: (value: string) => void }) {
return (
<div className="absolute left-[178px] top-[124px] z-50 w-[308px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-4 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
<div className="font-semibold text-[14px] text-white/86">聚焦模式</div>
<div className="mt-2 rounded-[10px] border border-dashed border-white/14 bg-white/5 px-3 py-4 text-[13px] text-white/46">
请在图片上框选聚焦区域
</div>
<button
className="mt-3 h-9 w-full rounded-[10px] bg-white text-[13px] font-semibold text-black"
onClick={() => onApply("聚焦区域")}
type="button"
>
进入聚焦编辑
</button>
</div>
);
}
function ComposerReferencePopover({
nodes,
onSelect,
}: {
nodes: CloneNode[];
onSelect: (node: CloneNode) => void;
}) {
return (
<div className="absolute left-5 top-[124px] z-50 w-[320px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
<div className="px-3 pb-2 pt-1 text-[12px] text-white/42">节点</div>
{nodes.length === 0 ? (
<div className="rounded-[10px] border border-white/8 bg-white/5 px-3 py-4 text-[13px] text-white/42">暂无可引用节点</div>
) : (
nodes.map((node) => {
const previewUrl = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
const Icon = node.inputType === "video" ? Video : node.kind === "text" ? FileText : ImageIcon;
return (
<button
className="flex h-11 w-full items-center gap-3 rounded-[9px] px-2 text-left text-[13px] text-white/76 hover:bg-white/8 hover:text-white"
key={node.id}
onClick={() => onSelect(node)}
type="button"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-[7px] bg-white/10 text-white/60">
{previewUrl ? <img alt="" className="h-full w-full object-cover" src={previewUrl} /> : <Icon className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{node.title}</span>
<span className="block truncate text-[11px] text-white/36">{node.subtitle || node.inputType || node.kind}</span>
</span>
<span className="text-white/28">↵</span>
</button>
);
})
)}
</div>
);
}
function ComposerSlashPopover({ onSelect }: { onSelect: (scene: string) => void }) {
return (
<div className="absolute left-5 top-[124px] z-50 w-[320px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
<div className="px-3 pb-2 pt-1 text-[12px] text-white/42">指令</div>
{SLASH_IMAGE_ITEMS.map((item) => (
<button
className="flex h-10 w-full items-center justify-between rounded-[9px] px-3 text-left text-[13px] text-white/76 hover:bg-white/8 hover:text-white"
key={item}
onClick={() => onSelect(item)}
type="button"
>
<span>{item}</span>
<span className="rounded-[6px] bg-white/8 px-2 py-0.5 text-[11px] text-white/38">/</span>
</button>
))}
</div>
);
}
function ComposerPopover({
menu,
onSelect,
}: {
menu: Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>;
onSelect: (option: string) => void;
}) {
const options: Record<Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>, string[]> = {
camera: ["摄像机", "手持镜头", "固定镜头"],
count: ["1张", "2张", "4张"],
mark: ["添加人物标记", "添加构图标记", "添加动作标记"],
@ -2755,27 +2962,13 @@ function ComposerPopover({ menu }: { menu: Exclude<ComposerMenu, null> }) {
translate: ["中文", "English", "自动翻译"],
};
if (menu === "focus") {
return (
<div className="absolute left-[178px] top-[102px] z-50 w-[308px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-4 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
<div className="font-semibold text-[14px] text-white/86">聚焦模式</div>
<div className="mt-2 rounded-[10px] border border-dashed border-white/14 bg-white/5 px-3 py-4 text-[13px] text-white/46">
请在图片上框选聚焦区域
</div>
<button className="mt-3 h-9 w-full rounded-[10px] bg-white text-[13px] font-semibold text-black" onClick={() => toast.info("聚焦区域编辑接口待接入。")} type="button">
进入聚焦编辑
</button>
</div>
);
}
return (
<div className="absolute left-5 top-[102px] z-50 w-[260px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
{options[menu].map((option) => (
<button
className="flex h-9 w-full items-center justify-between rounded-[9px] px-3 text-left text-[13px] text-white/72 hover:bg-white/8 hover:text-white"
key={option}
onClick={() => toast.info(`${option} 参数接口待接入。`)}
onClick={() => onSelect(option)}
type="button"
>
{option}
@ -3105,6 +3298,7 @@ function CanvasNodeShell({
"absolute cursor-grab select-none shadow-[0_14px_34px_rgba(0,0,0,0.35)] active:cursor-grabbing",
selected && "z-20"
)}
data-canvas-node="true"
data-canvas-overlay="true"
onContextMenu={onContextMenu}
onPointerDown={onPointerDown}