Add LibTV composer trigger interactions
This commit is contained in:
parent
6f64164109
commit
d348be844b
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user