Load LibTV starter workflows on canvas
This commit is contained in:
parent
c3b4e35997
commit
cfd48b6b3b
@ -31,6 +31,7 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@ -105,7 +106,7 @@ type WorkflowPreset = {
|
||||
cover?: string;
|
||||
};
|
||||
|
||||
type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save";
|
||||
type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save" | "media" | "script";
|
||||
|
||||
type CloneNode = {
|
||||
id: string;
|
||||
@ -614,6 +615,242 @@ function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: numb
|
||||
};
|
||||
}
|
||||
|
||||
function getDraftString(node: CloneNode | null | undefined, key: string) {
|
||||
const value = node?.draftValues?.[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
|
||||
const value = getDraftString(node, "targetPreset");
|
||||
if (
|
||||
value === "story-script" ||
|
||||
value === "character-three-view" ||
|
||||
value === "first-frame" ||
|
||||
value === "audio-to-video"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createPresetMediaNode({
|
||||
action,
|
||||
generated = false,
|
||||
height,
|
||||
mediaType,
|
||||
mediaUrl,
|
||||
preset,
|
||||
prompt,
|
||||
title,
|
||||
width,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
action: string;
|
||||
generated?: boolean;
|
||||
height: number;
|
||||
mediaType: NonNullable<CloneNode["inputType"]>;
|
||||
mediaUrl?: string;
|
||||
preset: Exclude<ActiveStarterScene, null>;
|
||||
prompt?: string;
|
||||
title: string;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}): CloneNode {
|
||||
return {
|
||||
id: makeId(mediaType),
|
||||
kind: "media",
|
||||
title,
|
||||
subtitle: generated ? "生成结果" : mediaType === "audio" ? "音频资源" : "素材资源",
|
||||
inputType: mediaType,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
|
||||
draftValues: {
|
||||
action,
|
||||
generated,
|
||||
mediaType,
|
||||
mediaUrl: mediaUrl || "",
|
||||
params: prompt ? { prompt } : undefined,
|
||||
prompt: prompt || "",
|
||||
targetPreset: preset,
|
||||
url: mediaUrl ? [mediaUrl] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPresetScriptNode({
|
||||
preset,
|
||||
role,
|
||||
title,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
preset: Exclude<ActiveStarterScene, null>;
|
||||
role: "resource" | "generator";
|
||||
title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}): CloneNode {
|
||||
const isGenerator = role === "generator";
|
||||
return {
|
||||
id: makeId(isGenerator ? "script" : "text"),
|
||||
kind: "script",
|
||||
title,
|
||||
subtitle: isGenerator ? "脚本生成器" : "文本资源",
|
||||
inputType: "text",
|
||||
x,
|
||||
y,
|
||||
width: 560,
|
||||
height: 430,
|
||||
accent: "#f0d06b",
|
||||
draftValues: {
|
||||
action: isGenerator ? "SCRIPT_GENERATE" : "TEXT_RESOURCE",
|
||||
content: isGenerator ? [] : [STORY_SCRIPT_TEXT],
|
||||
params: isGenerator ? { count: 1, prompt: STORY_SCRIPT_PROMPT } : undefined,
|
||||
prompt: isGenerator ? STORY_SCRIPT_PROMPT : "",
|
||||
rows: isGenerator ? [] : undefined,
|
||||
scriptRole: role,
|
||||
targetPreset: preset,
|
||||
viewMode: isGenerator ? "table" : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makePresetEdges(leftNodes: CloneNode[], rightNode: CloneNode): CloneEdge[] {
|
||||
return leftNodes.map((node) => ({
|
||||
id: makeId("edge"),
|
||||
source: node.id,
|
||||
target: rightNode.id,
|
||||
}));
|
||||
}
|
||||
|
||||
function createStarterWorkflow(
|
||||
starterTitle: string,
|
||||
center: { x: number; y: number }
|
||||
): { nodes: CloneNode[]; edges: CloneEdge[]; selectedId: string } | null {
|
||||
if (starterTitle === "故事脚本生成") {
|
||||
const preset = "story-script";
|
||||
const left = createPresetScriptNode({
|
||||
preset,
|
||||
role: "resource",
|
||||
title: "剧本",
|
||||
x: Math.round(center.x - 610),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
const right = createPresetScriptNode({
|
||||
preset,
|
||||
role: "generator",
|
||||
title: "脚本生成器",
|
||||
x: Math.round(center.x + 50),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
|
||||
}
|
||||
|
||||
if (starterTitle === "角色三视图") {
|
||||
const preset = "character-three-view";
|
||||
const distance = 370;
|
||||
const left = createPresetMediaNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: 446,
|
||||
mediaType: "image",
|
||||
mediaUrl: CHARACTER_IMAGE_URL,
|
||||
preset,
|
||||
title: "角色图",
|
||||
width: 734,
|
||||
x: Math.round(center.x - 734 - distance / 2),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
const right = createPresetMediaNode({
|
||||
action: "IMAGE_GENERATE",
|
||||
generated: true,
|
||||
height: 446,
|
||||
mediaType: "image",
|
||||
mediaUrl: CHARACTER_THREE_VIEW_URL,
|
||||
preset,
|
||||
title: "角色三视图",
|
||||
width: 556,
|
||||
x: Math.round(center.x + distance / 2),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
|
||||
}
|
||||
|
||||
if (starterTitle === "首帧图生视频") {
|
||||
const preset = "first-frame";
|
||||
const left = createPresetMediaNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: 430,
|
||||
mediaType: "image",
|
||||
mediaUrl: FIRST_FRAME_IMAGE_URL,
|
||||
preset,
|
||||
title: "首帧",
|
||||
width: 620,
|
||||
x: Math.round(center.x - 675),
|
||||
y: Math.round(center.y - 310),
|
||||
});
|
||||
const right = createPresetMediaNode({
|
||||
action: "VIDEO_GENERATE",
|
||||
generated: true,
|
||||
height: 430,
|
||||
mediaType: "video",
|
||||
preset,
|
||||
title: "视频",
|
||||
width: 560,
|
||||
x: Math.round(center.x + 55),
|
||||
y: Math.round(center.y - 310),
|
||||
});
|
||||
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
|
||||
}
|
||||
|
||||
if (starterTitle === "音频生视频") {
|
||||
const preset = "audio-to-video";
|
||||
const leftX = Math.round(center.x - 510);
|
||||
const leftY = Math.round(center.y - 320);
|
||||
const audio = createPresetMediaNode({
|
||||
action: "AUDIO_RESOURCE",
|
||||
height: 158,
|
||||
mediaType: "audio",
|
||||
mediaUrl: AUDIO_SOURCE_URL,
|
||||
preset,
|
||||
title: "音频",
|
||||
width: 430,
|
||||
x: leftX,
|
||||
y: leftY,
|
||||
});
|
||||
const image = createPresetMediaNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: 252,
|
||||
mediaType: "image",
|
||||
mediaUrl: AUDIO_REFERENCE_IMAGE_URL,
|
||||
preset,
|
||||
title: "图片",
|
||||
width: 430,
|
||||
x: leftX,
|
||||
y: leftY + 190,
|
||||
});
|
||||
const right = createPresetMediaNode({
|
||||
action: "VIDEO_GENERATE",
|
||||
generated: true,
|
||||
height: 430,
|
||||
mediaType: "video",
|
||||
preset,
|
||||
prompt: AUDIO_TO_VIDEO_PROMPT,
|
||||
title: "视频",
|
||||
width: 560,
|
||||
x: Math.round(center.x + 100),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
return { nodes: [audio, image, right], edges: makePresetEdges([audio, image], right), selectedId: right.id };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: LiblibVideoCanvasReplicaProps) {
|
||||
const router = useRouter();
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -626,7 +863,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const [draftTitle, setDraftTitle] = useState("未命名");
|
||||
const [isTitleEditing, setIsTitleEditing] = useState(false);
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
|
||||
const [activeStarterScene, setActiveStarterScene] = useState<ActiveStarterScene>(null);
|
||||
const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部");
|
||||
const [toolSearch, setToolSearch] = useState("");
|
||||
const [isProjectLoading, setIsProjectLoading] = useState(true);
|
||||
@ -833,7 +1069,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
: createSimpleNode(`${TOOL_TYPE_META[type]?.label || "工具"}生成`, type === "text" ? "text" : "model", canvasCommand.canvasX, canvasCommand.canvasY)
|
||||
);
|
||||
addNodes([node], []);
|
||||
setActiveStarterScene(null);
|
||||
setCanvasCommand(null);
|
||||
toast.success(`已添加 ${node.title}`);
|
||||
},
|
||||
@ -886,23 +1121,21 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
|
||||
const createStarter = useCallback(
|
||||
(starter: (typeof STARTER_CARDS)[number]) => {
|
||||
const starterScenes: Record<string, Exclude<ActiveStarterScene, null>> = {
|
||||
故事脚本生成: "story-script",
|
||||
角色三视图: "character-three-view",
|
||||
首帧图生视频: "first-frame",
|
||||
音频生视频: "audio-to-video",
|
||||
};
|
||||
const starterScene = starterScenes[starter.title];
|
||||
if (starterScene) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
setSelectedNodeId(null);
|
||||
const center =
|
||||
typeof window === "undefined"
|
||||
? { x: 1000, y: 520 }
|
||||
: toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||
const workflow = createStarterWorkflow(starter.title, center);
|
||||
if (workflow) {
|
||||
addNodes(workflow.nodes, workflow.edges);
|
||||
setSelectedNodeId(workflow.selectedId);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setActivePanel(null);
|
||||
setActiveStarterScene(starterScene);
|
||||
toast.success(`已载入 ${starter.title} 工作流`);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStarterScene(null);
|
||||
const baseX = 380;
|
||||
const baseY = 325;
|
||||
const newNodes = starter.steps.map((title, index) => {
|
||||
@ -923,7 +1156,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
}));
|
||||
addNodes(newNodes, newEdges);
|
||||
},
|
||||
[addNodes]
|
||||
[addNodes, toCanvasPoint]
|
||||
);
|
||||
|
||||
const updateNodeDraft = useCallback((key: string, value: unknown) => {
|
||||
@ -971,7 +1204,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId && !activeStarterScene) {
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId) {
|
||||
event.preventDefault();
|
||||
deleteNode(selectedNodeId);
|
||||
}
|
||||
@ -982,7 +1215,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [activeStarterScene, deleteNode, selectedNodeId]);
|
||||
}, [deleteNode, selectedNodeId]);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@ -1037,6 +1270,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
}, []);
|
||||
|
||||
const isBusy = isLoading || isProjectLoading;
|
||||
const selectedPresetMode = getPresetMode(selectedNode);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -1066,12 +1300,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeStarterScene === "story-script" ? <StoryScriptScene /> : null}
|
||||
{activeStarterScene === "character-three-view" ? <CharacterThreeViewScene /> : null}
|
||||
{activeStarterScene === "first-frame" ? <FirstFrameVideoScene /> : null}
|
||||
{activeStarterScene === "audio-to-video" ? <AudioToVideoScene /> : null}
|
||||
|
||||
{nodes.length === 0 && !activePanel && !activeStarterScene ? (
|
||||
{nodes.length === 0 && !activePanel ? (
|
||||
<div
|
||||
className="-translate-x-1/2 -translate-y-1/2 absolute left-1/2 top-[51%] z-20 w-[900px] max-w-[calc(100vw-180px)]"
|
||||
data-canvas-overlay="true"
|
||||
@ -1139,31 +1368,29 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!activeStarterScene ? (
|
||||
<>
|
||||
<TopBar
|
||||
draftTitle={draftTitle}
|
||||
isSaving={isSaving}
|
||||
isTitleEditing={isTitleEditing}
|
||||
onAccount={() => toggleTopPanel("account")}
|
||||
onBell={() => toggleTopPanel("notifications")}
|
||||
onCommitTitle={commitTitle}
|
||||
onDraftTitleChange={setDraftTitle}
|
||||
onMember={() => toggleTopPanel("member")}
|
||||
onSave={saveCanvas}
|
||||
onShare={() => toggleTopPanel("share")}
|
||||
onSkills={() => toggleTopPanel("skills")}
|
||||
onStartTitleEdit={() => setIsTitleEditing(true)}
|
||||
projectTitle={projectTitle}
|
||||
routerBack={() => router.push("/")}
|
||||
/>
|
||||
{selectedPresetMode ? <PresetWorkflowControls mode={selectedPresetMode} /> : null}
|
||||
|
||||
<LeftToolbar activePanel={activePanel} onOpen={(panel) => setActivePanel((current) => current === panel ? null : panel)} />
|
||||
</>
|
||||
) : null}
|
||||
<TopBar
|
||||
draftTitle={draftTitle}
|
||||
isSaving={isSaving}
|
||||
isTitleEditing={isTitleEditing}
|
||||
onAccount={() => toggleTopPanel("account")}
|
||||
onBell={() => toggleTopPanel("notifications")}
|
||||
onCommitTitle={commitTitle}
|
||||
onDraftTitleChange={setDraftTitle}
|
||||
onMember={() => toggleTopPanel("member")}
|
||||
onSave={saveCanvas}
|
||||
onShare={() => toggleTopPanel("share")}
|
||||
onSkills={() => toggleTopPanel("skills")}
|
||||
onStartTitleEdit={() => setIsTitleEditing(true)}
|
||||
projectTitle={projectTitle}
|
||||
routerBack={() => router.push("/")}
|
||||
/>
|
||||
|
||||
<LeftToolbar activePanel={activePanel} onOpen={(panel) => setActivePanel((current) => current === panel ? null : panel)} />
|
||||
|
||||
<ZoomDock
|
||||
compact={Boolean(activeStarterScene)}
|
||||
compact={false}
|
||||
zoom={viewport.zoom}
|
||||
onCenter={() => setViewport(DEFAULT_VIEWPORT)}
|
||||
onZoomIn={() => zoomBy(0.1)}
|
||||
@ -1190,7 +1417,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedNode ? (
|
||||
{selectedNode && !selectedPresetMode ? (
|
||||
<InspectorPanel
|
||||
node={selectedNode}
|
||||
onChangeProperty={updateNodeDraft}
|
||||
@ -1670,6 +1897,109 @@ function MagnetGlyph() {
|
||||
);
|
||||
}
|
||||
|
||||
function PresetWorkflowControls({ mode }: { 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 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "story-script") {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||||
<PresetFloatingToolbar
|
||||
items={[
|
||||
["拆分场景", "按镜号拆分脚本接口待接入。"],
|
||||
["生成分镜", "分镜生成接口待接入。"],
|
||||
["表格视图", "脚本表格视图接口待接入。"],
|
||||
["导出", "脚本导出接口待接入。"],
|
||||
]}
|
||||
/>
|
||||
<PresetComposerPanel
|
||||
credit="8"
|
||||
prompt={STORY_SCRIPT_PROMPT}
|
||||
submitMessage="故事脚本生成接口待接入。"
|
||||
title="脚本生成器"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "first-frame") {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||||
<PresetFloatingToolbar
|
||||
items={[
|
||||
["高清", "首帧高清接口待接入。"],
|
||||
["扩图", "首帧扩图接口待接入。"],
|
||||
["替换背景", "背景替换接口待接入。"],
|
||||
["下载", "图片下载接口待接入。"],
|
||||
]}
|
||||
/>
|
||||
<PresetComposerPanel
|
||||
credit="28"
|
||||
prompt="以当前图为首帧生成视频。"
|
||||
submitMessage="首帧图生视频接口待接入。"
|
||||
thumbnailUrl={FIRST_FRAME_IMAGE_URL}
|
||||
title="首帧图生视频"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||||
<PresetFloatingToolbar
|
||||
items={[
|
||||
["节奏匹配", "音频节奏解析接口待接入。"],
|
||||
["镜头语言", "镜头语言配置接口待接入。"],
|
||||
["生成音效", "音效生成接口待接入。"],
|
||||
["下载", "素材下载接口待接入。"],
|
||||
]}
|
||||
/>
|
||||
<PresetComposerPanel
|
||||
credit="32"
|
||||
prompt={AUDIO_TO_VIDEO_PROMPT}
|
||||
submitMessage="音频生视频接口待接入。"
|
||||
thumbnailUrl={AUDIO_REFERENCE_IMAGE_URL}
|
||||
title="音频生视频"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterPresetToolbar() {
|
||||
const [toolbarMenu, setToolbarMenu] = useState<SceneToolbarMenu>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[90px] flex h-[72px] -translate-x-1/2 items-center gap-6 rounded-[18px] border border-white/8 bg-[#262626]/96 px-7 text-[18px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
|
||||
<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")} />
|
||||
<SceneTool active={toolbarMenu === "grid"} kind="grid" label="九宫格" dropdown onClick={() => setToolbarMenu((current) => current === "grid" ? null : "grid")} />
|
||||
<SceneTool active={toolbarMenu === "hd"} kind="hd" label="高清" dropdown onClick={() => setToolbarMenu((current) => current === "hd" ? null : "hd")} />
|
||||
<SceneTool active={toolbarMenu === "crop"} kind="crop" label="宫格切分" dropdown onClick={() => setToolbarMenu((current) => current === "crop" ? null : "crop")} />
|
||||
<div className="h-9 w-px bg-white/8" />
|
||||
{["⌁", "↔", "↓", "↗"].map((item, index) => (
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-[28px] text-white/84 hover:bg-white/8"
|
||||
key={item}
|
||||
onClick={() => toast.info(["绘制编辑待接入。", "对比预览待接入。", "下载素材待接入。", "全屏预览待接入。"][index])}
|
||||
type="button"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{toolbarMenu ? <SceneToolbarPopover menu={toolbarMenu} onClose={() => setToolbarMenu(null)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StoryScriptScene() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||||
@ -2546,35 +2876,47 @@ function CloneCanvasNode({
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
if (node.kind === "media") {
|
||||
return (
|
||||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
|
||||
<PresetMediaCanvasNode node={node} selected={selected} />
|
||||
</CanvasNodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.kind === "script") {
|
||||
return (
|
||||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
|
||||
<PresetScriptCanvasNode node={node} selected={selected} />
|
||||
</CanvasNodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon;
|
||||
const isInput = node.kind === "input";
|
||||
const isWarm = node.kind === "text" || node.kind === "audio";
|
||||
const previewUrl = getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute cursor-grab select-none shadow-[0_14px_34px_rgba(0,0,0,0.35)] active:cursor-grabbing",
|
||||
selected && "z-20"
|
||||
)}
|
||||
data-canvas-overlay="true"
|
||||
onContextMenu={onContextMenu}
|
||||
onPointerDown={onPointerDown}
|
||||
style={{ height: node.height, left: node.x, top: node.y, width: node.width }}
|
||||
>
|
||||
<span className="absolute left-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
|
||||
<span className="absolute right-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
|
||||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
|
||||
{isInput ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col items-center justify-center rounded-[4px] bg-[#f8f9ff] text-center text-[#2a2d3a]",
|
||||
"relative flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-[4px] bg-[#f8f9ff] text-center text-[#2a2d3a]",
|
||||
selected && "outline outline-2 outline-[#8b96ff]"
|
||||
)}
|
||||
>
|
||||
<span className="mb-3 flex h-8 w-8 items-center justify-center rounded-[8px] border border-[#dfe3ff] bg-white text-[#7a85ff]">
|
||||
{previewUrl ? (
|
||||
<>
|
||||
<img alt="" className="absolute inset-0 h-full w-full object-cover opacity-28" src={previewUrl} />
|
||||
<span className="absolute inset-0 bg-white/62" />
|
||||
</>
|
||||
) : null}
|
||||
<span className="relative mb-3 flex h-8 w-8 items-center justify-center rounded-[8px] border border-[#dfe3ff] bg-white text-[#7a85ff]">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="font-semibold text-[14px]">{node.title}</div>
|
||||
<div className="mt-2 line-clamp-3 px-5 text-[11px] leading-5 text-[#6f7482]">{node.subtitle}</div>
|
||||
<button className="mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]" type="button">
|
||||
<div className="relative font-semibold text-[14px]">{node.title}</div>
|
||||
<div className="relative mt-2 line-clamp-3 px-5 text-[11px] leading-5 text-[#6f7482]">{node.subtitle}</div>
|
||||
<button className="relative mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]" type="button">
|
||||
选择文件
|
||||
</button>
|
||||
</div>
|
||||
@ -2597,13 +2939,212 @@ function CloneCanvasNode({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 text-[12px] text-[#697080]">{node.subtitle || "工具"}</div>
|
||||
<div className="truncate font-semibold text-[20px] tracking-tight">{node.title}</div>
|
||||
<div className="truncate font-semibold text-[20px] tracking-[0]">{node.title}</div>
|
||||
</div>
|
||||
<div className="flex h-[58px] w-8 items-center justify-center rounded-full border border-black/8 bg-white/62 text-center text-[13px] leading-4 text-[#272a32]">
|
||||
待<br />配置
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CanvasNodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasNodeShell({
|
||||
children,
|
||||
node,
|
||||
onContextMenu,
|
||||
onPointerDown,
|
||||
selected,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
node: CloneNode;
|
||||
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute cursor-grab select-none shadow-[0_14px_34px_rgba(0,0,0,0.35)] active:cursor-grabbing",
|
||||
selected && "z-20"
|
||||
)}
|
||||
data-canvas-overlay="true"
|
||||
onContextMenu={onContextMenu}
|
||||
onPointerDown={onPointerDown}
|
||||
style={{ height: node.height, left: node.x, top: node.y, width: node.width }}
|
||||
>
|
||||
<span className="absolute left-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
|
||||
<span className="absolute right-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
|
||||
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
|
||||
const mediaUrl = getDraftString(node, "mediaUrl");
|
||||
const generated = Boolean(node.draftValues?.generated);
|
||||
const Icon = mediaType === "audio" ? Music2 : mediaType === "video" ? Video : ImageIcon;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-2 flex h-6 items-center gap-1 text-[20px] font-semibold text-white/50">
|
||||
<Icon className="h-5 w-5" />
|
||||
{node.title}
|
||||
</div>
|
||||
{mediaType === "audio" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-[calc(100%-32px)] rounded-[10px] border border-white/12 bg-[#f4f5f7] p-4 text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||||
selected && "outline outline-2 outline-[#8b96ff]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[#111] text-white"
|
||||
onClick={() => toast.info("音频播放控制待接入。")}
|
||||
type="button"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[14px] font-semibold">情绪配乐.mp3</div>
|
||||
<div className="mt-1 text-[12px] text-[#697080]">音频生视频参考素材</div>
|
||||
</div>
|
||||
{mediaUrl ? (
|
||||
<a className="rounded-[8px] px-2 py-1 text-[12px] text-[#4f5cff] hover:bg-black/5" href={mediaUrl} rel="noreferrer" target="_blank">
|
||||
试听
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 flex h-12 items-end gap-1">
|
||||
{Array.from({ length: 38 }).map((_, index) => (
|
||||
<span
|
||||
className="flex-1 rounded-full bg-[#7b86ff]"
|
||||
key={index}
|
||||
style={{ height: `${18 + ((index * 17) % 28)}px`, opacity: 0.34 + ((index % 5) * 0.12) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : mediaType === "video" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-[calc(100%-32px)] items-center justify-center overflow-hidden rounded-[10px] border border-white/12 bg-[#101010] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||||
selected && "outline outline-2 outline-[#8b96ff]"
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.12)_1px,transparent_1px)] bg-[length:18px_18px] opacity-20" />
|
||||
<div className="relative text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-white/8 text-white/70">
|
||||
<Video className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="mt-4 text-[18px] font-semibold text-white/78">待生成视频</div>
|
||||
<div className="mt-2 text-[13px] text-white/38">生成后将在这里预览</div>
|
||||
</div>
|
||||
<button
|
||||
className="absolute right-3 top-3 rounded-[8px] bg-white/8 px-3 py-2 text-[12px] text-white/72 hover:bg-white/12"
|
||||
onClick={() => toast.info("视频全屏预览待接入。")}
|
||||
type="button"
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-white shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||||
selected && "outline outline-2 outline-[#8b96ff]"
|
||||
)}
|
||||
>
|
||||
{mediaUrl ? (
|
||||
<img
|
||||
alt={node.title}
|
||||
className={cn("h-full w-full", node.title === "角色三视图" ? "object-contain" : "object-cover")}
|
||||
src={mediaUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[13px] text-black/40">等待图片</div>
|
||||
)}
|
||||
{!generated ? (
|
||||
<button
|
||||
className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-[8px] bg-black/56 text-white/90"
|
||||
onClick={() => toast.info(`${node.title}素材替换接口待接入。`)}
|
||||
type="button"
|
||||
>
|
||||
↥
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
|
||||
const scriptRole = getDraftString(node, "scriptRole");
|
||||
const rows = [
|
||||
["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"],
|
||||
["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"],
|
||||
["03", "长安街市", "诗声落下,盛唐山河展开", "史诗音乐起"],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-2 flex h-6 items-center gap-1 text-[20px] font-semibold text-white/50">
|
||||
{scriptRole === "generator" ? <GridTilesGlyph /> : <FileText className="h-5 w-5" />}
|
||||
{node.title}
|
||||
</div>
|
||||
{scriptRole === "generator" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-white text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||||
selected && "outline outline-2 outline-[#8b96ff]"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between border-[#e5e7eb] border-b px-4">
|
||||
<span className="font-semibold text-[15px]">故事脚本</span>
|
||||
<span className="rounded-full bg-[#eef0ff] px-3 py-1 text-[12px] text-[#5161ff]">待生成</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_120px_1fr_110px] border-[#eef0f3] border-b bg-[#f7f8fa] text-[12px] text-[#697080]">
|
||||
{["镜号", "场景", "画面", "声音"].map((item) => (
|
||||
<div className="border-[#eef0f3] border-r px-3 py-2 last:border-r-0" key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div className="grid grid-cols-[64px_120px_1fr_110px] border-[#eef0f3] border-b text-[12px]" key={row[0]}>
|
||||
{row.map((cell) => (
|
||||
<div className="border-[#eef0f3] border-r px-3 py-3 last:border-r-0" key={cell}>{cell}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="m-4 h-10 w-[calc(100%-32px)] rounded-[10px] bg-[#111] text-[13px] font-semibold text-white"
|
||||
onClick={() => toast.info("故事脚本生成接口待接入。")}
|
||||
type="button"
|
||||
>
|
||||
生成完整脚本
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-[#f4f5f7] text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||||
selected && "outline outline-2 outline-[#8b96ff]"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-11 items-center justify-between border-[#dfe2e8] border-b px-4 text-[13px] text-[#697080]">
|
||||
<span>输入内容</span>
|
||||
<button className="rounded-[8px] px-2 py-1 text-[#4f5cff] hover:bg-black/5" onClick={() => toast.info("编辑剧本接口待接入。")} type="button">
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-44px)] overflow-y-auto whitespace-pre-wrap px-5 py-4 text-[14px] leading-7">
|
||||
{STORY_SCRIPT_TEXT}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user