Improve LibTV video canvas replica
This commit is contained in:
parent
3278776e7f
commit
7bc4e768b6
9969
app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
Normal file
9969
app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
50
app/canvas/[id]/CanvasPageClientRouter.tsx
Normal file
50
app/canvas/[id]/CanvasPageClientRouter.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const CanvasEditorLegacyPage = dynamic(() => import("./CanvasEditorLegacyPageClient"), {
|
||||
loading: () => <CanvasRouteLoader label="正在加载画布..." />,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const LiblibVideoCanvasReplica = dynamic(
|
||||
() => import("@/components/canvas/LiblibVideoCanvasReplica").then((module) => module.LiblibVideoCanvasReplica),
|
||||
{
|
||||
loading: () => <CanvasRouteLoader label="正在加载视频画布..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
export function CanvasPageClientRouter({ initialId }: { initialId: string }) {
|
||||
const routeParams = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const routeId = routeParams?.id;
|
||||
const id = Array.isArray(routeId) ? routeId[0] || initialId : typeof routeId === "string" ? routeId : initialId;
|
||||
const mode = searchParams.get("mode");
|
||||
const requestedWorkspaceType = searchParams.get("workspaceType");
|
||||
const requestedAgentMode = mode === "agent";
|
||||
const isVideoMode = !requestedAgentMode && (mode === "video" || requestedWorkspaceType === "video");
|
||||
const legacyParams = useMemo(() => Promise.resolve({ id }), [id]);
|
||||
|
||||
if (!id) {
|
||||
return <CanvasRouteLoader label="正在加载画布..." />;
|
||||
}
|
||||
|
||||
if (isVideoMode) {
|
||||
return <LiblibVideoCanvasReplica canvasId={id} />;
|
||||
}
|
||||
|
||||
return <CanvasEditorLegacyPage params={legacyParams} />;
|
||||
}
|
||||
|
||||
function CanvasRouteLoader({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-[#151515] text-white/70">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -200,10 +200,29 @@ type TargetModuleActionLabel =
|
||||
| "音频生视频"
|
||||
| "首尾帧生成视频"
|
||||
| "首帧生成视频"
|
||||
| "脚本生成分镜脚本"
|
||||
| "剧本生成分镜脚本"
|
||||
| "视频参考生成分镜脚本"
|
||||
| "角色生成分镜脚本";
|
||||
|
||||
type ImageToolbarAction =
|
||||
| "panorama"
|
||||
| "orbit"
|
||||
| "lighting"
|
||||
| "grid"
|
||||
| "hd"
|
||||
| "crop"
|
||||
| "annotate"
|
||||
| "rotate"
|
||||
| "download"
|
||||
| "preview";
|
||||
|
||||
type ComposerSubmitPayload = {
|
||||
prompt: string;
|
||||
references: string[];
|
||||
settings: ComposerSettings;
|
||||
slashScene: string | null;
|
||||
};
|
||||
|
||||
type LiblibVideoCanvasReplicaProps = {
|
||||
canvasId: string;
|
||||
isLoading?: boolean;
|
||||
@ -305,6 +324,9 @@ const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 };
|
||||
const PRESET_VIEWPORT = { x: 144, y: 100, zoom: 0.77 };
|
||||
const PRESET_WORKFLOW_CENTER = { x: 1024, y: 512 };
|
||||
const CANVAS_EXTENT = 20000;
|
||||
const OFFICIAL_NODE_GAP = 100;
|
||||
const OFFICIAL_TEXT_RESOURCE_HEIGHT = 232;
|
||||
const OFFICIAL_TEXT_RESOURCE_WIDTH = 350;
|
||||
|
||||
const TARGET_AVATAR =
|
||||
"https://liblibai-online.liblib.cloud/web/1656e2f1b3c74f1ba823b610f1b2cbdf/0c3eb5e2-1dc0-405c-a823-07b2570237f8.jpg?x-oss-process=image/resize,w_100,m_lfit/format,webp";
|
||||
@ -617,10 +639,10 @@ function restoreNodes(canvasData: unknown): CloneNode[] {
|
||||
const nodes = (canvasData as { nodes?: unknown[] } | null)?.nodes;
|
||||
if (!Array.isArray(nodes)) return [];
|
||||
return nodes
|
||||
.map((item) => {
|
||||
.map((item, index) => {
|
||||
const metadata = ((item as any)?.data?.metadata || {}) as Record<string, unknown>;
|
||||
const clone = metadata.liblibCloneNode as CloneNode | undefined;
|
||||
return clone && typeof clone.id === "string" ? clone : null;
|
||||
return clone && typeof clone.id === "string" ? normalizeRestoredNode(clone, index + 1) : null;
|
||||
})
|
||||
.filter(Boolean) as CloneNode[];
|
||||
}
|
||||
@ -724,7 +746,7 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index
|
||||
return {
|
||||
id: makeId("script"),
|
||||
kind: "script",
|
||||
title: "脚本生成器",
|
||||
title: `脚本 ${index}`,
|
||||
subtitle: "脚本生成",
|
||||
inputType: "text",
|
||||
x,
|
||||
@ -915,9 +937,9 @@ function createMediaResourceNode({
|
||||
|
||||
function createTextResourceNode({
|
||||
content,
|
||||
height = 430,
|
||||
height = OFFICIAL_TEXT_RESOURCE_HEIGHT,
|
||||
title,
|
||||
width = 560,
|
||||
width = OFFICIAL_TEXT_RESOURCE_WIDTH,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
@ -954,6 +976,13 @@ function getDraftString(node: CloneNode | null | undefined, key: string) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function getNodeMediaUrl(node: CloneNode | null | undefined) {
|
||||
const direct = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
|
||||
if (direct) return direct;
|
||||
const urls = node?.draftValues?.url;
|
||||
return Array.isArray(urls) && typeof urls[0] === "string" ? urls[0] : "";
|
||||
}
|
||||
|
||||
function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
|
||||
const value = getDraftString(node, "targetPreset");
|
||||
if (
|
||||
@ -986,6 +1015,109 @@ function getTargetModuleKind(node: CloneNode): AddModuleKind | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTrailingNumber(value: string | undefined) {
|
||||
const match = String(value || "").match(/(\d+)\s*$/);
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
function inferLegacyTargetModuleKind(node: CloneNode): AddModuleKind | null {
|
||||
if (node.kind === "media" || getPresetMode(node)) return null;
|
||||
const action = getDraftString(node, "action");
|
||||
if (action.endsWith("_RESOURCE") || action === "TEXT_RESOURCE") return null;
|
||||
|
||||
const nodeType = getDraftString(node, "nodeType");
|
||||
if (nodeType === "text" || nodeType === "image" || nodeType === "video" || nodeType === "audio" || nodeType === "script") {
|
||||
return nodeType;
|
||||
}
|
||||
if (nodeType === "video-clip" || nodeType === "video_clip" || nodeType === "videoGroup") return "video-clip";
|
||||
|
||||
const haystack = `${node.title} ${node.subtitle || ""} ${node.kind} ${node.inputType || ""} ${action}`.toLowerCase();
|
||||
if (/脚本|script/.test(haystack)) return "script";
|
||||
if (/视频合成|视频组|video-clip|video_clip|video_group|clip/.test(haystack)) return "video-clip";
|
||||
if (/音频|audio/.test(haystack)) return "audio";
|
||||
if (/图片|image/.test(haystack)) return "image";
|
||||
if (/视频|video/.test(haystack)) return "video";
|
||||
if (/文本|文字|text/.test(haystack)) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTextResourceNode(node: CloneNode): CloneNode {
|
||||
const shouldUseOfficialSize =
|
||||
Boolean(node.draftValues?.manualEditing) || /^(文本|文本生成|文本节点)/.test(String(node.title || ""));
|
||||
return {
|
||||
...node,
|
||||
kind: "script",
|
||||
title: node.title && !/文本生成|文本节点/.test(node.title) ? node.title : "文本",
|
||||
subtitle: "文本资源",
|
||||
inputType: "text",
|
||||
width: shouldUseOfficialSize ? OFFICIAL_TEXT_RESOURCE_WIDTH : node.width > 260 ? node.width : OFFICIAL_TEXT_RESOURCE_WIDTH,
|
||||
height: shouldUseOfficialSize ? OFFICIAL_TEXT_RESOURCE_HEIGHT : node.height > 120 ? node.height : OFFICIAL_TEXT_RESOURCE_HEIGHT,
|
||||
accent: "#f0d06b",
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
action: "TEXT_RESOURCE",
|
||||
content: Array.isArray(node.draftValues?.content) ? node.draftValues?.content : [],
|
||||
nodeType: "text",
|
||||
scriptRole: "resource",
|
||||
viewMode: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shouldResetTargetModuleTitle(title: string | undefined, kind: AddModuleKind) {
|
||||
const value = String(title || "").trim();
|
||||
if (!value) return true;
|
||||
const legacyTitlePattern =
|
||||
/^(文本|文本生成|文本节点|图片|图片生成|图片节点|视频|视频生成|视频节点|音频|音频生成|音频节点|脚本|脚本生成|脚本生成器|视频合成|视频合成节点)(\s*\d+)?$/;
|
||||
if (!legacyTitlePattern.test(value)) return false;
|
||||
if (kind === "video-clip") return !/^视频合成\s*\d+$/.test(value);
|
||||
const officialPrefix =
|
||||
kind === "text"
|
||||
? "文本节点"
|
||||
: kind === "image"
|
||||
? "图片节点"
|
||||
: kind === "video"
|
||||
? "视频节点"
|
||||
: kind === "audio"
|
||||
? "音频节点"
|
||||
: "脚本";
|
||||
return !new RegExp(`^${officialPrefix}\\s*\\d+$`).test(value);
|
||||
}
|
||||
|
||||
function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbackIndex: number): CloneNode {
|
||||
const index = extractTrailingNumber(node.title) || fallbackIndex;
|
||||
const official = createTargetModuleNode(kind, node.x, node.y, index);
|
||||
const isOfficialShape = Math.abs(node.width - official.width) <= 2 && Math.abs(node.height - official.height) <= 2;
|
||||
const resetTitle = shouldResetTargetModuleTitle(node.title, kind);
|
||||
|
||||
if (isOfficialShape && !resetTitle) return node;
|
||||
|
||||
return {
|
||||
...official,
|
||||
id: node.id,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
title: resetTitle ? official.title : node.title,
|
||||
draftValues: {
|
||||
...(official.draftValues || {}),
|
||||
...(node.draftValues || {}),
|
||||
nodeType: kind,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode {
|
||||
const action = getDraftString(node, "action");
|
||||
if (action === "TEXT_RESOURCE" && !getPresetMode(node)) {
|
||||
return normalizeTextResourceNode(node);
|
||||
}
|
||||
|
||||
const kind = getTargetModuleKind(node) || inferLegacyTargetModuleKind(node);
|
||||
if (!kind) return node;
|
||||
|
||||
return normalizeTargetModuleNode(node, kind, fallbackIndex);
|
||||
}
|
||||
|
||||
function createPresetMediaNode({
|
||||
action,
|
||||
generated = false,
|
||||
@ -1223,6 +1355,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const [canvasCommand, setCanvasCommand] = useState<CanvasCommandState>(null);
|
||||
const [nodeContextMenu, setNodeContextMenu] = useState<NodeContextState>(null);
|
||||
const [connectionMenu, setConnectionMenu] = useState<ConnectionMenuState>(null);
|
||||
const [previewNode, setPreviewNode] = useState<CloneNode | null>(null);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((node) => node.id === selectedNodeId) || null,
|
||||
@ -1446,7 +1579,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const anchor = nodes.find((node) => node.id === connectionMenu.nodeId);
|
||||
if (!anchor) return;
|
||||
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
|
||||
const gap = 120;
|
||||
const gap = OFFICIAL_NODE_GAP;
|
||||
const node =
|
||||
connectionMenu.side === "source"
|
||||
? {
|
||||
@ -1476,8 +1609,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const anchor = nodes.find((node) => node.id === nodeId);
|
||||
if (!anchor) return;
|
||||
|
||||
const leftX = Math.round(anchor.x - 560 - 120);
|
||||
const rightX = Math.round(anchor.x + anchor.width + 120);
|
||||
const leftOfAnchor = (width: number) => Math.round(anchor.x - width - OFFICIAL_NODE_GAP);
|
||||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||||
const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2);
|
||||
const nextNodes: CloneNode[] = [];
|
||||
const nextEdges: CloneEdge[] = [];
|
||||
@ -1512,7 +1645,22 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
|
||||
switch (actionLabel) {
|
||||
case "自己编写内容": {
|
||||
draftUpdates = { action: "TEXT_RESOURCE", content: [], manualEditing: true };
|
||||
anchorUpdates = {
|
||||
kind: "script",
|
||||
title: "文本",
|
||||
subtitle: "文本资源",
|
||||
inputType: "text",
|
||||
width: OFFICIAL_TEXT_RESOURCE_WIDTH,
|
||||
height: OFFICIAL_TEXT_RESOURCE_HEIGHT,
|
||||
};
|
||||
draftUpdates = {
|
||||
action: "TEXT_RESOURCE",
|
||||
content: [],
|
||||
manualEditing: true,
|
||||
nodeType: "text",
|
||||
scriptRole: "resource",
|
||||
viewMode: undefined,
|
||||
};
|
||||
toast.success("已切换为文本编写节点");
|
||||
break;
|
||||
}
|
||||
@ -1528,7 +1676,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
title: "图片",
|
||||
width: 420,
|
||||
height: 280,
|
||||
x: leftX,
|
||||
x: leftOfAnchor(420),
|
||||
y: centerY(280),
|
||||
});
|
||||
draftUpdates = {
|
||||
@ -1609,7 +1757,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
width: 430,
|
||||
height: 252,
|
||||
x: anchor.x,
|
||||
y: Math.round(anchor.y + 170 + 120),
|
||||
y: Math.round(anchor.y + 170 + OFFICIAL_NODE_GAP),
|
||||
});
|
||||
nextNodes.push(video, image);
|
||||
nextEdges.push(
|
||||
@ -1628,7 +1776,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
title: "首帧",
|
||||
width: 420,
|
||||
height: 280,
|
||||
x: leftX,
|
||||
x: leftOfAnchor(420),
|
||||
y: centerY(280),
|
||||
})
|
||||
);
|
||||
@ -1643,7 +1791,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
title: "首帧",
|
||||
width: 420,
|
||||
height: 240,
|
||||
x: leftX,
|
||||
x: leftOfAnchor(420),
|
||||
y: Math.round(anchor.y + 18),
|
||||
});
|
||||
const last = createMediaResourceNode({
|
||||
@ -1652,7 +1800,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
title: "尾帧",
|
||||
width: 420,
|
||||
height: 240,
|
||||
x: leftX,
|
||||
x: leftOfAnchor(420),
|
||||
y: Math.round(anchor.y + 286),
|
||||
});
|
||||
connectToAnchor(first);
|
||||
@ -1660,8 +1808,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
selectedId = anchor.id;
|
||||
break;
|
||||
}
|
||||
case "脚本生成分镜脚本": {
|
||||
connectToAnchor(createTextResourceNode({ content: STORY_SCRIPT_TEXT, title: "文本", x: leftX, y: centerY(430) }));
|
||||
case "剧本生成分镜脚本": {
|
||||
connectToAnchor(
|
||||
createTextResourceNode({
|
||||
content: STORY_SCRIPT_TEXT,
|
||||
title: "文本",
|
||||
x: leftOfAnchor(OFFICIAL_TEXT_RESOURCE_WIDTH),
|
||||
y: centerY(OFFICIAL_TEXT_RESOURCE_HEIGHT),
|
||||
})
|
||||
);
|
||||
selectedId = anchor.id;
|
||||
break;
|
||||
}
|
||||
@ -1676,7 +1831,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
title: "视频",
|
||||
width: 560,
|
||||
height: 315,
|
||||
x: leftX,
|
||||
x: leftOfAnchor(560),
|
||||
y: centerY(315),
|
||||
})
|
||||
);
|
||||
@ -1691,7 +1846,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
title: "角色 1",
|
||||
width: 300,
|
||||
height: 300,
|
||||
x: leftX,
|
||||
x: leftOfAnchor(300),
|
||||
y: Math.round(anchor.y + 10),
|
||||
});
|
||||
const second = createMediaResourceNode({
|
||||
@ -1700,7 +1855,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
title: "角色 2",
|
||||
width: 300,
|
||||
height: 300,
|
||||
x: leftX,
|
||||
x: leftOfAnchor(300),
|
||||
y: Math.round(anchor.y + 330),
|
||||
});
|
||||
connectToAnchor(first);
|
||||
@ -1740,6 +1895,217 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
[nodes]
|
||||
);
|
||||
|
||||
const runSelectedImageToolAction = useCallback(
|
||||
(nodeId: string, action: ImageToolbarAction, option?: string) => {
|
||||
const anchor = nodes.find((node) => node.id === nodeId);
|
||||
if (!anchor) return;
|
||||
const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||||
|
||||
if (action === "download") {
|
||||
if (!sourceUrl) {
|
||||
toast.error("当前素材没有可下载地址");
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = sourceUrl;
|
||||
link.download = `${anchor.title || "libtv-image"}.png`;
|
||||
link.rel = "noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
toast.success("已开始下载当前素材");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "preview") {
|
||||
setPreviewNode(anchor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "annotate") {
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === anchor.id
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
annotationMode: true,
|
||||
annotations: node.draftValues?.annotations || [{ label: "主体", x: 42, y: 46 }],
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
setSelectedNodeId(anchor.id);
|
||||
toast.success("已进入标注模式");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "rotate") {
|
||||
const currentRotation = Number(anchor.draftValues?.rotation || 0);
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === anchor.id
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
rotation: (currentRotation + 90) % 360,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
setSelectedNodeId(anchor.id);
|
||||
toast.success("已旋转当前素材");
|
||||
return;
|
||||
}
|
||||
|
||||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||||
const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2);
|
||||
const nextNodes: CloneNode[] = [];
|
||||
const nextEdges: CloneEdge[] = [];
|
||||
|
||||
if (action === "crop") {
|
||||
const parsed = Number.parseInt(option || "9", 10);
|
||||
const tileCount = Number.isFinite(parsed) && parsed > 0 ? parsed : 9;
|
||||
const columns = Math.ceil(Math.sqrt(tileCount));
|
||||
const tileSize = 180;
|
||||
const gap = 26;
|
||||
const rows = Math.ceil(tileCount / columns);
|
||||
const startY = Math.round(anchor.y + anchor.height / 2 - (rows * tileSize + (rows - 1) * gap) / 2);
|
||||
|
||||
Array.from({ length: tileCount }).forEach((_, index) => {
|
||||
const tile = createMediaResourceNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: tileSize,
|
||||
mediaType: "image",
|
||||
mediaUrl: sourceUrl,
|
||||
prompt: option || "宫格切分",
|
||||
title: `切分 ${index + 1}`,
|
||||
width: tileSize,
|
||||
x: rightX + (index % columns) * (tileSize + gap),
|
||||
y: startY + Math.floor(index / columns) * (tileSize + gap),
|
||||
});
|
||||
nextNodes.push(tile);
|
||||
nextEdges.push({ id: makeId("edge"), source: anchor.id, target: tile.id });
|
||||
});
|
||||
|
||||
setNodes((current) => [...current, ...nextNodes]);
|
||||
setEdges((current) => [...current, ...nextEdges]);
|
||||
setSelectedNodeId(anchor.id);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setConnectionMenu(null);
|
||||
setActivePanel(null);
|
||||
toast.success(`已创建「${option || "宫格切分"}」结果节点`);
|
||||
return;
|
||||
}
|
||||
|
||||
const labels: Record<Exclude<ImageToolbarAction, "annotate" | "rotate" | "download" | "preview" | "crop">, string> = {
|
||||
grid: "九宫格",
|
||||
hd: "高清",
|
||||
lighting: "打光",
|
||||
orbit: "多角度",
|
||||
panorama: "720 全景",
|
||||
};
|
||||
const title = labels[action];
|
||||
const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1);
|
||||
const prompt = option?.trim() || title;
|
||||
const resultNode: CloneNode = {
|
||||
...created,
|
||||
title,
|
||||
y: centerY(created.height),
|
||||
draftValues: {
|
||||
...(created.draftValues || {}),
|
||||
generatorType: action === "hd" ? "ENHANCE" : "DEFAULT",
|
||||
params: {
|
||||
mode: action,
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
toolbarOption: option || "",
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((current) => [...current, resultNode]);
|
||||
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: resultNode.id }]);
|
||||
setSelectedNodeId(anchor.id);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setConnectionMenu(null);
|
||||
setActivePanel(null);
|
||||
toast.success(`已创建「${title}」工作流节点`);
|
||||
},
|
||||
[nodes]
|
||||
);
|
||||
|
||||
const updateTextResourceContent = useCallback((nodeId: string, content: string) => {
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
content: content ? [content] : [],
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const submitSelectedImagePrompt = useCallback(
|
||||
(nodeId: string, payload: ComposerSubmitPayload) => {
|
||||
const anchor = nodes.find((node) => node.id === nodeId);
|
||||
if (!anchor) return;
|
||||
const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||||
const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1);
|
||||
const prompt = payload.prompt.trim() || payload.slashScene || "生成角色三视图";
|
||||
const resultNode: CloneNode = {
|
||||
...created,
|
||||
title: payload.slashScene || "图片生成",
|
||||
y: Math.round(anchor.y + anchor.height / 2 - created.height / 2),
|
||||
draftValues: {
|
||||
...(created.draftValues || {}),
|
||||
params: {
|
||||
prompt,
|
||||
references: payload.references,
|
||||
settings: payload.settings,
|
||||
slashScene: payload.slashScene,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
};
|
||||
const referenceEdges = payload.references
|
||||
.filter((referenceId) => referenceId !== anchor.id && nodes.some((node) => node.id === referenceId))
|
||||
.map((referenceId) => ({ id: makeId("edge"), source: referenceId, target: resultNode.id }));
|
||||
|
||||
setNodes((current) => [...current, resultNode]);
|
||||
setEdges((current) => [
|
||||
...current,
|
||||
{ id: makeId("edge"), source: anchor.id, target: resultNode.id },
|
||||
...referenceEdges,
|
||||
]);
|
||||
setSelectedNodeId(anchor.id);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setConnectionMenu(null);
|
||||
setActivePanel(null);
|
||||
toast.success("已按当前提示词创建图片生成节点");
|
||||
},
|
||||
[nodes]
|
||||
);
|
||||
|
||||
const createFreeNode = useCallback(
|
||||
(kind: CanvasCreateKind) => {
|
||||
if (!canvasCommand) return;
|
||||
@ -2034,6 +2400,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
|
||||
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
|
||||
onPointerDown={(event) => startNodeDrag(event, node)}
|
||||
onTextResourceChange={(content) => updateTextResourceContent(node.id, content)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -2116,8 +2483,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedPresetMode && selectedPresetAnchor ? (
|
||||
<PresetWorkflowControls anchor={selectedPresetAnchor} mode={selectedPresetMode} nodes={nodes} />
|
||||
{selectedPresetMode && selectedPresetAnchor && selectedPresetControlNode ? (
|
||||
<PresetWorkflowControls
|
||||
anchor={selectedPresetAnchor}
|
||||
mode={selectedPresetMode}
|
||||
nodes={nodes}
|
||||
onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)}
|
||||
onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)}
|
||||
selectedNode={selectedPresetControlNode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TopBar
|
||||
@ -2272,6 +2646,12 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
<AccountPopover onClose={() => setActivePanel(null)} />
|
||||
) : null}
|
||||
|
||||
{previewNode ? (
|
||||
<LibModal onClose={() => setPreviewNode(null)} title={previewNode.title || "预览"} width={920}>
|
||||
<ImageNodePreview node={previewNode} />
|
||||
</LibModal>
|
||||
) : null}
|
||||
|
||||
{isBusy ? (
|
||||
<div className="absolute inset-0 z-[120] flex items-center justify-center bg-[#151515]">
|
||||
<div className="flex flex-col items-center gap-3 text-white/72">
|
||||
@ -2656,16 +3036,27 @@ function PresetWorkflowControls({
|
||||
anchor,
|
||||
mode,
|
||||
nodes,
|
||||
onComposerSubmit,
|
||||
onImageToolAction,
|
||||
selectedNode,
|
||||
}: {
|
||||
anchor: PresetControlAnchor;
|
||||
mode: Exclude<ActiveStarterScene, null>;
|
||||
nodes: CloneNode[];
|
||||
onComposerSubmit: (payload: ComposerSubmitPayload) => void;
|
||||
onImageToolAction: (action: ImageToolbarAction, option?: string) => void;
|
||||
selectedNode: 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} nodes={nodes} />
|
||||
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
|
||||
<PromptComposer
|
||||
anchor={anchor}
|
||||
nodes={nodes}
|
||||
onSubmit={onComposerSubmit}
|
||||
referenceThumbnailUrl={getNodeMediaUrl(selectedNode) || CHARACTER_IMAGE_URL}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2740,8 +3131,21 @@ function PresetWorkflowControls({
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterPresetToolbar({ anchor }: { anchor?: PresetControlAnchor }) {
|
||||
function CharacterPresetToolbar({
|
||||
anchor,
|
||||
onToolAction,
|
||||
}: {
|
||||
anchor?: PresetControlAnchor;
|
||||
onToolAction: (action: ImageToolbarAction, option?: string) => void;
|
||||
}) {
|
||||
const [toolbarMenu, setToolbarMenu] = useState<SceneToolbarMenu>(null);
|
||||
const iconActions: Array<{ action: ImageToolbarAction; icon: string; label: string }> = [
|
||||
{ action: "annotate", icon: "⌁", label: "标注" },
|
||||
{ action: "rotate", icon: "↔", label: "旋转" },
|
||||
{ action: "download", icon: "↓", label: "下载" },
|
||||
{ action: "preview", icon: "↗", label: "预览" },
|
||||
];
|
||||
const activeToolbarMenu = toolbarMenu;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -2761,18 +3165,29 @@ function CharacterPresetToolbar({ anchor }: { anchor?: PresetControlAnchor }) {
|
||||
<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) => (
|
||||
{iconActions.map((item) => (
|
||||
<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])}
|
||||
key={item.action}
|
||||
onClick={() => onToolAction(item.action)}
|
||||
title={item.label}
|
||||
type="button"
|
||||
>
|
||||
{item}
|
||||
{item.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{toolbarMenu ? <SceneToolbarPopover anchor={anchor} menu={toolbarMenu} onClose={() => setToolbarMenu(null)} /> : null}
|
||||
{activeToolbarMenu ? (
|
||||
<SceneToolbarPopover
|
||||
anchor={anchor}
|
||||
menu={activeToolbarMenu}
|
||||
onClose={() => setToolbarMenu(null)}
|
||||
onSelect={(option) => {
|
||||
onToolAction(activeToolbarMenu, option);
|
||||
setToolbarMenu(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -3217,11 +3632,14 @@ function SceneToolbarPopover({
|
||||
anchor,
|
||||
menu,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
anchor?: PresetControlAnchor;
|
||||
menu: Exclude<SceneToolbarMenu, null>;
|
||||
onClose: () => void;
|
||||
onSelect?: (option?: string) => void;
|
||||
}) {
|
||||
const [lightingPrompt, setLightingPrompt] = useState("");
|
||||
const quickOptions: Record<Exclude<SceneToolbarMenu, "lighting" | null>, string[]> = {
|
||||
crop: ["4 宫格切分", "9 宫格切分", "16 宫格切分", "25 宫格切分"],
|
||||
grid: ["2 x 2", "3 x 3", "4 x 4", "自定义宫格"],
|
||||
@ -3247,11 +3665,13 @@ function SceneToolbarPopover({
|
||||
</div>
|
||||
<textarea
|
||||
className="h-24 w-full resize-none rounded-[10px] border border-white/10 bg-[#1d1d1d] px-3 py-2 text-[13px] text-white/78 outline-none placeholder:text-white/34"
|
||||
onChange={(event) => setLightingPrompt(event.target.value)}
|
||||
placeholder="简单描述你想实现的打光效果,或者情绪风格"
|
||||
value={lightingPrompt}
|
||||
/>
|
||||
<button
|
||||
className="mt-3 h-10 w-full rounded-[10px] bg-white font-semibold text-black hover:bg-white/90"
|
||||
onClick={() => toast.info("打光生成接口待接入。")}
|
||||
onClick={() => onSelect?.(lightingPrompt.trim() || "柔和自然光")}
|
||||
type="button"
|
||||
>
|
||||
应用打光
|
||||
@ -3274,7 +3694,7 @@ function SceneToolbarPopover({
|
||||
<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"
|
||||
key={option}
|
||||
onClick={() => toast.info(`${option} 接口待接入。`)}
|
||||
onClick={() => onSelect?.(option)}
|
||||
type="button"
|
||||
>
|
||||
{option}
|
||||
@ -3359,9 +3779,13 @@ function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: stri
|
||||
function PromptComposer({
|
||||
anchor,
|
||||
nodes = [],
|
||||
onSubmit,
|
||||
referenceThumbnailUrl = CHARACTER_IMAGE_URL,
|
||||
}: {
|
||||
anchor?: PresetControlAnchor;
|
||||
nodes?: CloneNode[];
|
||||
onSubmit?: (payload: ComposerSubmitPayload) => void;
|
||||
referenceThumbnailUrl?: string;
|
||||
}) {
|
||||
const [menu, setMenu] = useState<ComposerMenu>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@ -3443,7 +3867,7 @@ function PromptComposer({
|
||||
/>
|
||||
))}
|
||||
<div className="relative h-[70px] w-[70px] overflow-hidden rounded-[10px]">
|
||||
<img alt="角色图缩略图" className="h-full w-full object-cover" src={CHARACTER_IMAGE_URL} />
|
||||
<img alt="角色图缩略图" className="h-full w-full object-cover" src={referenceThumbnailUrl} />
|
||||
<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>
|
||||
@ -3509,7 +3933,16 @@ function PromptComposer({
|
||||
<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"
|
||||
onClick={() => toast.info("角色三视图生成接口待接入。")}
|
||||
onClick={() =>
|
||||
onSubmit
|
||||
? onSubmit({
|
||||
prompt,
|
||||
references,
|
||||
settings,
|
||||
slashScene,
|
||||
})
|
||||
: toast.info("角色三视图生成接口待接入。")
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
↑
|
||||
@ -3962,6 +4395,7 @@ function CloneCanvasNode({
|
||||
onModuleAction,
|
||||
onOpenConnectionMenu,
|
||||
onPointerDown,
|
||||
onTextResourceChange,
|
||||
selected,
|
||||
}: {
|
||||
node: CloneNode;
|
||||
@ -3969,6 +4403,7 @@ function CloneCanvasNode({
|
||||
onModuleAction: (actionLabel: TargetModuleActionLabel) => void;
|
||||
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onTextResourceChange: (content: string) => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const targetModuleKind = getTargetModuleKind(node);
|
||||
@ -3991,7 +4426,7 @@ function CloneCanvasNode({
|
||||
if (node.kind === "script") {
|
||||
return (
|
||||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
|
||||
<PresetScriptCanvasNode node={node} selected={selected} />
|
||||
<PresetScriptCanvasNode node={node} onTextResourceChange={onTextResourceChange} selected={selected} />
|
||||
</CanvasNodeShell>
|
||||
);
|
||||
}
|
||||
@ -4074,7 +4509,7 @@ function TargetModuleCanvasNode({
|
||||
{ icon: "hd", label: "图片高清" },
|
||||
],
|
||||
script: [
|
||||
{ icon: FileText, label: "脚本生成分镜脚本" },
|
||||
{ icon: FileText, label: "剧本生成分镜脚本" },
|
||||
{ icon: Video, label: "视频参考生成分镜脚本" },
|
||||
{ icon: UserRound, label: "角色生成分镜脚本" },
|
||||
],
|
||||
@ -4263,8 +4698,10 @@ function CanvasNodeShell({
|
||||
|
||||
function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
|
||||
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
|
||||
const mediaUrl = getDraftString(node, "mediaUrl");
|
||||
const mediaUrl = getNodeMediaUrl(node);
|
||||
const generated = Boolean(node.draftValues?.generated);
|
||||
const annotationMode = Boolean(node.draftValues?.annotationMode);
|
||||
const rotation = Number(node.draftValues?.rotation || 0);
|
||||
const Icon = mediaType === "audio" ? Music2 : mediaType === "video" ? Video : ImageIcon;
|
||||
|
||||
return (
|
||||
@ -4347,10 +4784,17 @@ function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected:
|
||||
alt={node.title}
|
||||
className={cn("h-full w-full", node.title === "角色三视图" ? "object-contain" : "object-cover")}
|
||||
src={mediaUrl}
|
||||
style={{ transform: `rotate(${rotation}deg)` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[13px] text-black/40">等待图片</div>
|
||||
)}
|
||||
{annotationMode ? (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<span className="absolute left-[38%] top-[32%] h-[34%] w-[24%] rounded-[8px] border border-white/85 bg-black/10 shadow-[0_0_0_1px_rgba(0,0,0,0.3)]" />
|
||||
<span className="absolute left-[38%] top-[calc(32%-26px)] rounded-[7px] bg-black/68 px-2 py-1 text-[11px] text-white">主体</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!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"
|
||||
@ -4366,8 +4810,23 @@ function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected:
|
||||
);
|
||||
}
|
||||
|
||||
function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
|
||||
function PresetScriptCanvasNode({
|
||||
node,
|
||||
onTextResourceChange,
|
||||
selected,
|
||||
}: {
|
||||
node: CloneNode;
|
||||
onTextResourceChange: (content: string) => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const scriptRole = getDraftString(node, "scriptRole");
|
||||
const contentValues = node.draftValues?.content;
|
||||
const textContent =
|
||||
Array.isArray(contentValues) && typeof contentValues[0] === "string"
|
||||
? contentValues[0]
|
||||
: node.draftValues?.manualEditing
|
||||
? ""
|
||||
: STORY_SCRIPT_TEXT;
|
||||
const rows = [
|
||||
["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"],
|
||||
["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"],
|
||||
@ -4424,9 +4883,14 @@ function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected:
|
||||
编辑
|
||||
</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>
|
||||
<textarea
|
||||
className="h-[calc(100%-44px)] w-full resize-none bg-transparent px-5 py-4 text-[14px] leading-7 outline-none placeholder:text-black/38"
|
||||
onChange={(event) => onTextResourceChange(event.target.value)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
placeholder="输入内容"
|
||||
value={textContent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -5378,6 +5842,35 @@ function SupportPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function ImageNodePreview({ node }: { node: CloneNode }) {
|
||||
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
|
||||
const url = getNodeMediaUrl(node);
|
||||
const rotation = Number(node.draftValues?.rotation || 0);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex h-[420px] items-center justify-center rounded-[8px] border border-white/8 bg-white/5 text-[14px] text-white/42">
|
||||
当前节点暂无可预览素材
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[8px] border border-white/8 bg-[#111] p-3">
|
||||
{mediaType === "video" ? (
|
||||
<video className="max-h-[72vh] w-full rounded-[6px] object-contain" controls playsInline src={url} />
|
||||
) : (
|
||||
<img
|
||||
alt={node.title}
|
||||
className="max-h-[72vh] w-full rounded-[6px] object-contain"
|
||||
src={url}
|
||||
style={{ transform: `rotate(${rotation}deg)` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibModal({
|
||||
children,
|
||||
onClose,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user