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 = {
|
type LiblibVideoCanvasReplicaProps = {
|
||||||
canvasId: string;
|
canvasId: string;
|
||||||
isLoading?: boolean;
|
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_VIEWPORT = { x: 144, y: 100, zoom: 0.77 };
|
||||||
const PRESET_WORKFLOW_CENTER = { x: 1024, y: 512 };
|
const PRESET_WORKFLOW_CENTER = { x: 1024, y: 512 };
|
||||||
const CANVAS_EXTENT = 20000;
|
const CANVAS_EXTENT = 20000;
|
||||||
|
const OFFICIAL_NODE_GAP = 100;
|
||||||
|
const OFFICIAL_TEXT_RESOURCE_HEIGHT = 232;
|
||||||
|
const OFFICIAL_TEXT_RESOURCE_WIDTH = 350;
|
||||||
|
|
||||||
const TARGET_AVATAR =
|
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";
|
"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;
|
const nodes = (canvasData as { nodes?: unknown[] } | null)?.nodes;
|
||||||
if (!Array.isArray(nodes)) return [];
|
if (!Array.isArray(nodes)) return [];
|
||||||
return nodes
|
return nodes
|
||||||
.map((item) => {
|
.map((item, index) => {
|
||||||
const metadata = ((item as any)?.data?.metadata || {}) as Record<string, unknown>;
|
const metadata = ((item as any)?.data?.metadata || {}) as Record<string, unknown>;
|
||||||
const clone = metadata.liblibCloneNode as CloneNode | undefined;
|
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[];
|
.filter(Boolean) as CloneNode[];
|
||||||
}
|
}
|
||||||
@ -724,7 +746,7 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index
|
|||||||
return {
|
return {
|
||||||
id: makeId("script"),
|
id: makeId("script"),
|
||||||
kind: "script",
|
kind: "script",
|
||||||
title: "脚本生成器",
|
title: `脚本 ${index}`,
|
||||||
subtitle: "脚本生成",
|
subtitle: "脚本生成",
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
x,
|
x,
|
||||||
@ -915,9 +937,9 @@ function createMediaResourceNode({
|
|||||||
|
|
||||||
function createTextResourceNode({
|
function createTextResourceNode({
|
||||||
content,
|
content,
|
||||||
height = 430,
|
height = OFFICIAL_TEXT_RESOURCE_HEIGHT,
|
||||||
title,
|
title,
|
||||||
width = 560,
|
width = OFFICIAL_TEXT_RESOURCE_WIDTH,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
}: {
|
}: {
|
||||||
@ -954,6 +976,13 @@ function getDraftString(node: CloneNode | null | undefined, key: string) {
|
|||||||
return typeof value === "string" ? value : "";
|
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 {
|
function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
|
||||||
const value = getDraftString(node, "targetPreset");
|
const value = getDraftString(node, "targetPreset");
|
||||||
if (
|
if (
|
||||||
@ -986,6 +1015,109 @@ function getTargetModuleKind(node: CloneNode): AddModuleKind | null {
|
|||||||
return 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({
|
function createPresetMediaNode({
|
||||||
action,
|
action,
|
||||||
generated = false,
|
generated = false,
|
||||||
@ -1223,6 +1355,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
const [canvasCommand, setCanvasCommand] = useState<CanvasCommandState>(null);
|
const [canvasCommand, setCanvasCommand] = useState<CanvasCommandState>(null);
|
||||||
const [nodeContextMenu, setNodeContextMenu] = useState<NodeContextState>(null);
|
const [nodeContextMenu, setNodeContextMenu] = useState<NodeContextState>(null);
|
||||||
const [connectionMenu, setConnectionMenu] = useState<ConnectionMenuState>(null);
|
const [connectionMenu, setConnectionMenu] = useState<ConnectionMenuState>(null);
|
||||||
|
const [previewNode, setPreviewNode] = useState<CloneNode | null>(null);
|
||||||
|
|
||||||
const selectedNode = useMemo(
|
const selectedNode = useMemo(
|
||||||
() => nodes.find((node) => node.id === selectedNodeId) || null,
|
() => 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);
|
const anchor = nodes.find((node) => node.id === connectionMenu.nodeId);
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
|
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
|
||||||
const gap = 120;
|
const gap = OFFICIAL_NODE_GAP;
|
||||||
const node =
|
const node =
|
||||||
connectionMenu.side === "source"
|
connectionMenu.side === "source"
|
||||||
? {
|
? {
|
||||||
@ -1476,8 +1609,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
const anchor = nodes.find((node) => node.id === nodeId);
|
const anchor = nodes.find((node) => node.id === nodeId);
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
|
|
||||||
const leftX = Math.round(anchor.x - 560 - 120);
|
const leftOfAnchor = (width: number) => Math.round(anchor.x - width - OFFICIAL_NODE_GAP);
|
||||||
const rightX = Math.round(anchor.x + anchor.width + 120);
|
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 centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2);
|
||||||
const nextNodes: CloneNode[] = [];
|
const nextNodes: CloneNode[] = [];
|
||||||
const nextEdges: CloneEdge[] = [];
|
const nextEdges: CloneEdge[] = [];
|
||||||
@ -1512,7 +1645,22 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
|
|
||||||
switch (actionLabel) {
|
switch (actionLabel) {
|
||||||
case "自己编写内容": {
|
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("已切换为文本编写节点");
|
toast.success("已切换为文本编写节点");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -1528,7 +1676,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
title: "图片",
|
title: "图片",
|
||||||
width: 420,
|
width: 420,
|
||||||
height: 280,
|
height: 280,
|
||||||
x: leftX,
|
x: leftOfAnchor(420),
|
||||||
y: centerY(280),
|
y: centerY(280),
|
||||||
});
|
});
|
||||||
draftUpdates = {
|
draftUpdates = {
|
||||||
@ -1609,7 +1757,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
width: 430,
|
width: 430,
|
||||||
height: 252,
|
height: 252,
|
||||||
x: anchor.x,
|
x: anchor.x,
|
||||||
y: Math.round(anchor.y + 170 + 120),
|
y: Math.round(anchor.y + 170 + OFFICIAL_NODE_GAP),
|
||||||
});
|
});
|
||||||
nextNodes.push(video, image);
|
nextNodes.push(video, image);
|
||||||
nextEdges.push(
|
nextEdges.push(
|
||||||
@ -1628,7 +1776,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
title: "首帧",
|
title: "首帧",
|
||||||
width: 420,
|
width: 420,
|
||||||
height: 280,
|
height: 280,
|
||||||
x: leftX,
|
x: leftOfAnchor(420),
|
||||||
y: centerY(280),
|
y: centerY(280),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -1643,7 +1791,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
title: "首帧",
|
title: "首帧",
|
||||||
width: 420,
|
width: 420,
|
||||||
height: 240,
|
height: 240,
|
||||||
x: leftX,
|
x: leftOfAnchor(420),
|
||||||
y: Math.round(anchor.y + 18),
|
y: Math.round(anchor.y + 18),
|
||||||
});
|
});
|
||||||
const last = createMediaResourceNode({
|
const last = createMediaResourceNode({
|
||||||
@ -1652,7 +1800,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
title: "尾帧",
|
title: "尾帧",
|
||||||
width: 420,
|
width: 420,
|
||||||
height: 240,
|
height: 240,
|
||||||
x: leftX,
|
x: leftOfAnchor(420),
|
||||||
y: Math.round(anchor.y + 286),
|
y: Math.round(anchor.y + 286),
|
||||||
});
|
});
|
||||||
connectToAnchor(first);
|
connectToAnchor(first);
|
||||||
@ -1660,8 +1808,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
selectedId = anchor.id;
|
selectedId = anchor.id;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "脚本生成分镜脚本": {
|
case "剧本生成分镜脚本": {
|
||||||
connectToAnchor(createTextResourceNode({ content: STORY_SCRIPT_TEXT, title: "文本", x: leftX, y: centerY(430) }));
|
connectToAnchor(
|
||||||
|
createTextResourceNode({
|
||||||
|
content: STORY_SCRIPT_TEXT,
|
||||||
|
title: "文本",
|
||||||
|
x: leftOfAnchor(OFFICIAL_TEXT_RESOURCE_WIDTH),
|
||||||
|
y: centerY(OFFICIAL_TEXT_RESOURCE_HEIGHT),
|
||||||
|
})
|
||||||
|
);
|
||||||
selectedId = anchor.id;
|
selectedId = anchor.id;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -1676,7 +1831,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
title: "视频",
|
title: "视频",
|
||||||
width: 560,
|
width: 560,
|
||||||
height: 315,
|
height: 315,
|
||||||
x: leftX,
|
x: leftOfAnchor(560),
|
||||||
y: centerY(315),
|
y: centerY(315),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -1691,7 +1846,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
title: "角色 1",
|
title: "角色 1",
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 300,
|
height: 300,
|
||||||
x: leftX,
|
x: leftOfAnchor(300),
|
||||||
y: Math.round(anchor.y + 10),
|
y: Math.round(anchor.y + 10),
|
||||||
});
|
});
|
||||||
const second = createMediaResourceNode({
|
const second = createMediaResourceNode({
|
||||||
@ -1700,7 +1855,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
title: "角色 2",
|
title: "角色 2",
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 300,
|
height: 300,
|
||||||
x: leftX,
|
x: leftOfAnchor(300),
|
||||||
y: Math.round(anchor.y + 330),
|
y: Math.round(anchor.y + 330),
|
||||||
});
|
});
|
||||||
connectToAnchor(first);
|
connectToAnchor(first);
|
||||||
@ -1740,6 +1895,217 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
[nodes]
|
[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(
|
const createFreeNode = useCallback(
|
||||||
(kind: CanvasCreateKind) => {
|
(kind: CanvasCreateKind) => {
|
||||||
if (!canvasCommand) return;
|
if (!canvasCommand) return;
|
||||||
@ -2034,6 +2400,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
|
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
|
||||||
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
|
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
|
||||||
onPointerDown={(event) => startNodeDrag(event, node)}
|
onPointerDown={(event) => startNodeDrag(event, node)}
|
||||||
|
onTextResourceChange={(content) => updateTextResourceContent(node.id, content)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -2116,8 +2483,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedPresetMode && selectedPresetAnchor ? (
|
{selectedPresetMode && selectedPresetAnchor && selectedPresetControlNode ? (
|
||||||
<PresetWorkflowControls anchor={selectedPresetAnchor} mode={selectedPresetMode} nodes={nodes} />
|
<PresetWorkflowControls
|
||||||
|
anchor={selectedPresetAnchor}
|
||||||
|
mode={selectedPresetMode}
|
||||||
|
nodes={nodes}
|
||||||
|
onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)}
|
||||||
|
onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)}
|
||||||
|
selectedNode={selectedPresetControlNode}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<TopBar
|
<TopBar
|
||||||
@ -2272,6 +2646,12 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
<AccountPopover onClose={() => setActivePanel(null)} />
|
<AccountPopover onClose={() => setActivePanel(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{previewNode ? (
|
||||||
|
<LibModal onClose={() => setPreviewNode(null)} title={previewNode.title || "预览"} width={920}>
|
||||||
|
<ImageNodePreview node={previewNode} />
|
||||||
|
</LibModal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
<div className="absolute inset-0 z-[120] flex items-center justify-center bg-[#151515]">
|
<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">
|
<div className="flex flex-col items-center gap-3 text-white/72">
|
||||||
@ -2656,16 +3036,27 @@ function PresetWorkflowControls({
|
|||||||
anchor,
|
anchor,
|
||||||
mode,
|
mode,
|
||||||
nodes,
|
nodes,
|
||||||
|
onComposerSubmit,
|
||||||
|
onImageToolAction,
|
||||||
|
selectedNode,
|
||||||
}: {
|
}: {
|
||||||
anchor: PresetControlAnchor;
|
anchor: PresetControlAnchor;
|
||||||
mode: Exclude<ActiveStarterScene, null>;
|
mode: Exclude<ActiveStarterScene, null>;
|
||||||
nodes: CloneNode[];
|
nodes: CloneNode[];
|
||||||
|
onComposerSubmit: (payload: ComposerSubmitPayload) => void;
|
||||||
|
onImageToolAction: (action: ImageToolbarAction, option?: string) => void;
|
||||||
|
selectedNode: CloneNode;
|
||||||
}) {
|
}) {
|
||||||
if (mode === "character-three-view") {
|
if (mode === "character-three-view") {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||||||
<CharacterPresetToolbar anchor={anchor} />
|
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
|
||||||
<PromptComposer anchor={anchor} nodes={nodes} />
|
<PromptComposer
|
||||||
|
anchor={anchor}
|
||||||
|
nodes={nodes}
|
||||||
|
onSubmit={onComposerSubmit}
|
||||||
|
referenceThumbnailUrl={getNodeMediaUrl(selectedNode) || CHARACTER_IMAGE_URL}
|
||||||
|
/>
|
||||||
</div>
|
</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 [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 (
|
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 === "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")} />
|
<SceneTool active={toolbarMenu === "crop"} kind="crop" label="宫格切分" dropdown onClick={() => setToolbarMenu((current) => current === "crop" ? null : "crop")} />
|
||||||
<div className="h-9 w-px bg-white/8" />
|
<div className="h-9 w-px bg-white/8" />
|
||||||
{["⌁", "↔", "↓", "↗"].map((item, index) => (
|
{iconActions.map((item) => (
|
||||||
<button
|
<button
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-[28px] text-white/84 hover:bg-white/8"
|
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-[28px] text-white/84 hover:bg-white/8"
|
||||||
key={item}
|
key={item.action}
|
||||||
onClick={() => toast.info(["绘制编辑待接入。", "对比预览待接入。", "下载素材待接入。", "全屏预览待接入。"][index])}
|
onClick={() => onToolAction(item.action)}
|
||||||
|
title={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{item}
|
{item.icon}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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,
|
anchor,
|
||||||
menu,
|
menu,
|
||||||
onClose,
|
onClose,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
anchor?: PresetControlAnchor;
|
anchor?: PresetControlAnchor;
|
||||||
menu: Exclude<SceneToolbarMenu, null>;
|
menu: Exclude<SceneToolbarMenu, null>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSelect?: (option?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [lightingPrompt, setLightingPrompt] = useState("");
|
||||||
const quickOptions: Record<Exclude<SceneToolbarMenu, "lighting" | null>, string[]> = {
|
const quickOptions: Record<Exclude<SceneToolbarMenu, "lighting" | null>, string[]> = {
|
||||||
crop: ["4 宫格切分", "9 宫格切分", "16 宫格切分", "25 宫格切分"],
|
crop: ["4 宫格切分", "9 宫格切分", "16 宫格切分", "25 宫格切分"],
|
||||||
grid: ["2 x 2", "3 x 3", "4 x 4", "自定义宫格"],
|
grid: ["2 x 2", "3 x 3", "4 x 4", "自定义宫格"],
|
||||||
@ -3247,11 +3665,13 @@ function SceneToolbarPopover({
|
|||||||
</div>
|
</div>
|
||||||
<textarea
|
<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"
|
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="简单描述你想实现的打光效果,或者情绪风格"
|
placeholder="简单描述你想实现的打光效果,或者情绪风格"
|
||||||
|
value={lightingPrompt}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="mt-3 h-10 w-full rounded-[10px] bg-white font-semibold text-black hover:bg-white/90"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
应用打光
|
应用打光
|
||||||
@ -3274,7 +3694,7 @@ function SceneToolbarPopover({
|
|||||||
<button
|
<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"
|
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}
|
key={option}
|
||||||
onClick={() => toast.info(`${option} 接口待接入。`)}
|
onClick={() => onSelect?.(option)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
@ -3359,9 +3779,13 @@ function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: stri
|
|||||||
function PromptComposer({
|
function PromptComposer({
|
||||||
anchor,
|
anchor,
|
||||||
nodes = [],
|
nodes = [],
|
||||||
|
onSubmit,
|
||||||
|
referenceThumbnailUrl = CHARACTER_IMAGE_URL,
|
||||||
}: {
|
}: {
|
||||||
anchor?: PresetControlAnchor;
|
anchor?: PresetControlAnchor;
|
||||||
nodes?: CloneNode[];
|
nodes?: CloneNode[];
|
||||||
|
onSubmit?: (payload: ComposerSubmitPayload) => void;
|
||||||
|
referenceThumbnailUrl?: string;
|
||||||
}) {
|
}) {
|
||||||
const [menu, setMenu] = useState<ComposerMenu>(null);
|
const [menu, setMenu] = useState<ComposerMenu>(null);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
@ -3443,7 +3867,7 @@ function PromptComposer({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="relative h-[70px] w-[70px] overflow-hidden rounded-[10px]">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ -3509,7 +3933,16 @@ function PromptComposer({
|
|||||||
<ComposerControl label="14" icon="⚡" muted onClick={() => toast.info("积分估算接口待接入。")} />
|
<ComposerControl label="14" icon="⚡" muted onClick={() => toast.info("积分估算接口待接入。")} />
|
||||||
<button
|
<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"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
↑
|
↑
|
||||||
@ -3962,6 +4395,7 @@ function CloneCanvasNode({
|
|||||||
onModuleAction,
|
onModuleAction,
|
||||||
onOpenConnectionMenu,
|
onOpenConnectionMenu,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
|
onTextResourceChange,
|
||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
node: CloneNode;
|
node: CloneNode;
|
||||||
@ -3969,6 +4403,7 @@ function CloneCanvasNode({
|
|||||||
onModuleAction: (actionLabel: TargetModuleActionLabel) => void;
|
onModuleAction: (actionLabel: TargetModuleActionLabel) => void;
|
||||||
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
|
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
|
||||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
onTextResourceChange: (content: string) => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}) {
|
}) {
|
||||||
const targetModuleKind = getTargetModuleKind(node);
|
const targetModuleKind = getTargetModuleKind(node);
|
||||||
@ -3991,7 +4426,7 @@ function CloneCanvasNode({
|
|||||||
if (node.kind === "script") {
|
if (node.kind === "script") {
|
||||||
return (
|
return (
|
||||||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
|
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
|
||||||
<PresetScriptCanvasNode node={node} selected={selected} />
|
<PresetScriptCanvasNode node={node} onTextResourceChange={onTextResourceChange} selected={selected} />
|
||||||
</CanvasNodeShell>
|
</CanvasNodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -4074,7 +4509,7 @@ function TargetModuleCanvasNode({
|
|||||||
{ icon: "hd", label: "图片高清" },
|
{ icon: "hd", label: "图片高清" },
|
||||||
],
|
],
|
||||||
script: [
|
script: [
|
||||||
{ icon: FileText, label: "脚本生成分镜脚本" },
|
{ icon: FileText, label: "剧本生成分镜脚本" },
|
||||||
{ icon: Video, label: "视频参考生成分镜脚本" },
|
{ icon: Video, label: "视频参考生成分镜脚本" },
|
||||||
{ icon: UserRound, label: "角色生成分镜脚本" },
|
{ icon: UserRound, label: "角色生成分镜脚本" },
|
||||||
],
|
],
|
||||||
@ -4263,8 +4698,10 @@ function CanvasNodeShell({
|
|||||||
|
|
||||||
function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
|
function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
|
||||||
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
|
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
|
||||||
const mediaUrl = getDraftString(node, "mediaUrl");
|
const mediaUrl = getNodeMediaUrl(node);
|
||||||
const generated = Boolean(node.draftValues?.generated);
|
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;
|
const Icon = mediaType === "audio" ? Music2 : mediaType === "video" ? Video : ImageIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -4347,10 +4784,17 @@ function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected:
|
|||||||
alt={node.title}
|
alt={node.title}
|
||||||
className={cn("h-full w-full", node.title === "角色三视图" ? "object-contain" : "object-cover")}
|
className={cn("h-full w-full", node.title === "角色三视图" ? "object-contain" : "object-cover")}
|
||||||
src={mediaUrl}
|
src={mediaUrl}
|
||||||
|
style={{ transform: `rotate(${rotation}deg)` }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-[13px] text-black/40">等待图片</div>
|
<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 ? (
|
{!generated ? (
|
||||||
<button
|
<button
|
||||||
className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-[8px] bg-black/56 text-white/90"
|
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 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 = [
|
const rows = [
|
||||||
["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"],
|
["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"],
|
||||||
["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"],
|
["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"],
|
||||||
@ -4424,9 +4883,14 @@ function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected:
|
|||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[calc(100%-44px)] overflow-y-auto whitespace-pre-wrap px-5 py-4 text-[14px] leading-7">
|
<textarea
|
||||||
{STORY_SCRIPT_TEXT}
|
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"
|
||||||
</div>
|
onChange={(event) => onTextResourceChange(event.target.value)}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onWheel={(event) => event.stopPropagation()}
|
||||||
|
placeholder="输入内容"
|
||||||
|
value={textContent}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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({
|
function LibModal({
|
||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user