Improve LibTV video canvas replica
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-18 21:02:39 +08:00
parent 3278776e7f
commit 7bc4e768b6
4 changed files with 10564 additions and 10007 deletions

File diff suppressed because it is too large Load Diff

View 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

View File

@ -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,