Anchor preset controls to selected media
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:27:55 +08:00
parent 8784432f23
commit 6f64164109

View File

@ -161,6 +161,12 @@ type NodeContextState = {
screenY: number;
} | null;
type PresetControlAnchor = {
bottomY: number;
centerX: number;
topY: number;
};
type CanvasCreateKind =
| "image-input"
| "video-input"
@ -1302,7 +1308,17 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
}, []);
const isBusy = isLoading || isProjectLoading;
const selectedPresetMode = getPresetMode(selectedNode);
const selectedPresetControlNode =
selectedNode?.kind === "media" && selectedNode.inputType !== "audio" ? selectedNode : null;
const selectedPresetMode = getPresetMode(selectedPresetControlNode);
const selectedPresetAnchor = useMemo<PresetControlAnchor | null>(() => {
if (!selectedPresetControlNode) return null;
return {
bottomY: Math.round(viewport.y + (selectedPresetControlNode.y + selectedPresetControlNode.height) * viewport.zoom),
centerX: Math.round(viewport.x + (selectedPresetControlNode.x + selectedPresetControlNode.width / 2) * viewport.zoom),
topY: Math.round(viewport.y + selectedPresetControlNode.y * viewport.zoom),
};
}, [selectedPresetControlNode, viewport.x, viewport.y, viewport.zoom]);
return (
<div
@ -1401,7 +1417,9 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
/>
) : null}
{selectedPresetMode ? <PresetWorkflowControls mode={selectedPresetMode} /> : null}
{selectedPresetMode && selectedPresetAnchor ? (
<PresetWorkflowControls anchor={selectedPresetAnchor} mode={selectedPresetMode} />
) : null}
<TopBar
draftTitle={draftTitle}
@ -1930,12 +1948,18 @@ function MagnetGlyph() {
);
}
function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, null> }) {
function PresetWorkflowControls({
anchor,
mode,
}: {
anchor: PresetControlAnchor;
mode: Exclude<ActiveStarterScene, null>;
}) {
if (mode === "character-three-view") {
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<CharacterPresetToolbar />
<PromptComposer />
<CharacterPresetToolbar anchor={anchor} />
<PromptComposer anchor={anchor} />
</div>
);
}
@ -1944,6 +1968,7 @@ function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, nu
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<PresetFloatingToolbar
anchor={anchor}
items={[
["拆分场景", "按镜号拆分脚本接口待接入。"],
["生成分镜", "分镜生成接口待接入。"],
@ -1952,6 +1977,7 @@ function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, nu
]}
/>
<PresetComposerPanel
anchor={anchor}
credit="8"
prompt={STORY_SCRIPT_PROMPT}
submitMessage="故事脚本生成接口待接入。"
@ -1965,6 +1991,7 @@ function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, nu
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<PresetFloatingToolbar
anchor={anchor}
items={[
["高清", "首帧高清接口待接入。"],
["扩图", "首帧扩图接口待接入。"],
@ -1973,6 +2000,7 @@ function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, nu
]}
/>
<PresetComposerPanel
anchor={anchor}
credit="28"
prompt="以当前图为首帧生成视频。"
submitMessage="首帧图生视频接口待接入。"
@ -1986,6 +2014,7 @@ function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, nu
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<PresetFloatingToolbar
anchor={anchor}
items={[
["节奏匹配", "音频节奏解析接口待接入。"],
["镜头语言", "镜头语言配置接口待接入。"],
@ -1994,6 +2023,7 @@ function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, nu
]}
/>
<PresetComposerPanel
anchor={anchor}
credit="32"
prompt={AUDIO_TO_VIDEO_PROMPT}
submitMessage="音频生视频接口待接入。"
@ -2004,12 +2034,20 @@ function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, nu
);
}
function CharacterPresetToolbar() {
function CharacterPresetToolbar({ anchor }: { anchor?: PresetControlAnchor }) {
const [toolbarMenu, setToolbarMenu] = useState<SceneToolbarMenu>(null);
return (
<>
<div className="pointer-events-auto absolute left-1/2 top-[168px] flex h-[58px] origin-top -translate-x-1/2 scale-[0.7] items-center gap-5 rounded-[14px] border border-white/8 bg-[#262626]/96 px-5 text-[16px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
<div
className={cn(
"pointer-events-auto absolute flex h-[58px] items-center gap-5 rounded-[14px] border border-white/8 bg-[#262626]/96 px-5 text-[16px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]",
anchor
? "origin-bottom -translate-x-1/2 -translate-y-full scale-[0.7]"
: "left-1/2 top-[168px] origin-top -translate-x-1/2 scale-[0.7]"
)}
style={anchor ? { left: anchor.centerX, top: anchor.topY - 14 } : undefined}
>
<SceneTool active={toolbarMenu === "panorama"} kind="panorama" label="全景" badge="NEW" onClick={() => setToolbarMenu((current) => current === "panorama" ? null : "panorama")} />
<SceneTool active={toolbarMenu === "orbit"} kind="orbit" label="多角度" onClick={() => setToolbarMenu((current) => current === "orbit" ? null : "orbit")} />
<SceneTool active={toolbarMenu === "lighting"} kind="lighting" label="打光" onClick={() => setToolbarMenu((current) => current === "lighting" ? null : "lighting")} />
@ -2028,7 +2066,7 @@ function CharacterPresetToolbar() {
</button>
))}
</div>
{toolbarMenu ? <SceneToolbarPopover menu={toolbarMenu} onClose={() => setToolbarMenu(null)} /> : null}
{toolbarMenu ? <SceneToolbarPopover anchor={anchor} menu={toolbarMenu} onClose={() => setToolbarMenu(null)} /> : null}
</>
);
}
@ -2116,9 +2154,23 @@ function AudioToVideoScene() {
);
}
function PresetFloatingToolbar({ items }: { items: Array<[string, string]> }) {
function PresetFloatingToolbar({
anchor,
items,
}: {
anchor?: PresetControlAnchor;
items: Array<[string, string]>;
}) {
return (
<div className="pointer-events-auto absolute left-1/2 top-[94px] flex h-[58px] -translate-x-1/2 items-center gap-2 rounded-[16px] border border-white/8 bg-[#262626]/96 px-4 text-white/78 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
<div
className={cn(
"pointer-events-auto absolute flex h-[58px] items-center gap-2 rounded-[16px] border border-white/8 bg-[#262626]/96 px-4 text-white/78 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]",
anchor
? "origin-bottom -translate-x-1/2 -translate-y-full scale-[0.7]"
: "left-1/2 top-[94px] -translate-x-1/2"
)}
style={anchor ? { left: anchor.centerX, top: anchor.topY - 14 } : undefined}
>
{items.map(([label, message]) => (
<button
className="flex h-10 items-center gap-2 rounded-[10px] px-3 text-[15px] hover:bg-white/8 hover:text-white"
@ -2312,12 +2364,14 @@ function AudioResourcePanel() {
}
function PresetComposerPanel({
anchor,
credit,
prompt,
submitMessage,
thumbnailUrl,
title,
}: {
anchor?: PresetControlAnchor;
credit: string;
prompt: string;
submitMessage: string;
@ -2331,7 +2385,15 @@ function PresetComposerPanel({
}, [prompt]);
return (
<div className="pointer-events-auto absolute bottom-[64px] left-1/2 h-[238px] w-[895px] origin-bottom -translate-x-1/2 scale-[0.5] rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
<div
className={cn(
"pointer-events-auto absolute h-[238px] w-[895px] rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]",
anchor
? "origin-top -translate-x-1/2 scale-[0.5]"
: "bottom-[64px] left-1/2 origin-bottom -translate-x-1/2 scale-[0.5]"
)}
style={anchor ? { left: anchor.centerX, top: anchor.bottomY + 18 } : undefined}
>
<button className="absolute right-5 top-5 text-[26px] text-white/45" onClick={() => toast.info("展开提示词编辑器待接入。")} type="button">↗</button>
<div className="mb-5 flex items-center gap-3">
<span className="rounded-[10px] border border-white/10 bg-[#292929] px-3 py-2 text-[14px] text-white/62">{title}</span>
@ -2446,9 +2508,11 @@ function SceneTool({
}
function SceneToolbarPopover({
anchor,
menu,
onClose,
}: {
anchor?: PresetControlAnchor;
menu: Exclude<SceneToolbarMenu, null>;
onClose: () => void;
}) {
@ -2462,7 +2526,15 @@ function SceneToolbarPopover({
if (menu === "lighting") {
return (
<div className="pointer-events-auto absolute left-1/2 top-[214px] z-40 w-[420px] origin-top -translate-x-1/2 scale-[0.7] rounded-[14px] border border-white/10 bg-[#262626]/98 p-4 text-white shadow-[0_24px_80px_rgba(0,0,0,0.46)] backdrop-blur-[10px]">
<div
className={cn(
"pointer-events-auto absolute z-40 w-[420px] rounded-[14px] border border-white/10 bg-[#262626]/98 p-4 text-white shadow-[0_24px_80px_rgba(0,0,0,0.46)] backdrop-blur-[10px]",
anchor
? "origin-top -translate-x-1/2 scale-[0.7]"
: "left-1/2 top-[214px] origin-top -translate-x-1/2 scale-[0.7]"
)}
style={anchor ? { left: anchor.centerX, top: anchor.topY + 8 } : undefined}
>
<div className="mb-3 flex items-center justify-between">
<div className="font-semibold text-[15px] text-white/86">打光</div>
<button className="rounded-[8px] px-2 py-1 text-white/42 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">×</button>
@ -2483,7 +2555,15 @@ function SceneToolbarPopover({
}
return (
<div className="pointer-events-auto absolute left-1/2 top-[214px] z-40 w-[260px] origin-top -translate-x-1/2 scale-[0.7] rounded-[14px] border border-white/10 bg-[#262626]/98 p-2 text-white shadow-[0_24px_80px_rgba(0,0,0,0.46)] backdrop-blur-[10px]">
<div
className={cn(
"pointer-events-auto absolute z-40 w-[260px] rounded-[14px] border border-white/10 bg-[#262626]/98 p-2 text-white shadow-[0_24px_80px_rgba(0,0,0,0.46)] backdrop-blur-[10px]",
anchor
? "origin-top -translate-x-1/2 scale-[0.7]"
: "left-1/2 top-[214px] origin-top -translate-x-1/2 scale-[0.7]"
)}
style={anchor ? { left: anchor.centerX, top: anchor.topY + 8 } : undefined}
>
{quickOptions[menu].map((option) => (
<button
className="flex h-10 w-full items-center justify-between rounded-[10px] px-3 text-left text-[13px] text-white/76 hover:bg-white/8 hover:text-white"
@ -2570,12 +2650,20 @@ function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: stri
);
}
function PromptComposer() {
function PromptComposer({ anchor }: { anchor?: PresetControlAnchor }) {
const [menu, setMenu] = useState<ComposerMenu>(null);
const [prompt, setPrompt] = useState("");
return (
<div className="pointer-events-auto absolute bottom-[64px] left-1/2 h-[286px] w-[895px] origin-bottom -translate-x-1/2 scale-[0.5] rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
<div
className={cn(
"pointer-events-auto absolute h-[286px] w-[895px] rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]",
anchor
? "origin-top -translate-x-1/2 scale-[0.5]"
: "bottom-[64px] left-1/2 origin-bottom -translate-x-1/2 scale-[0.5]"
)}
style={anchor ? { left: anchor.centerX, top: anchor.bottomY + 18 } : undefined}
>
<button className="absolute right-5 top-5 text-[26px] text-white/45" onClick={() => toast.info("展开提示词编辑器待接入。")} type="button">↗</button>
<div className="flex items-center gap-3">
{[