mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat: add seedance media params and model visibility
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Home, ImageIcon, Images, List, Menu, MessageSquare, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
|
||||
import { Home, ImageIcon, Images, List, Menu, MessageSquare, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type ViewportTransform,
|
||||
} from "../types";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio } from "@/types/media";
|
||||
|
||||
type CanvasClipboard = {
|
||||
nodes: CanvasNodeData[];
|
||||
@@ -141,7 +142,7 @@ function CanvasRefreshShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: PendingConnectionCreate; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video) => void; onClose: () => void }) {
|
||||
function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: PendingConnectionCreate; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void; onClose: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return (
|
||||
<div
|
||||
@@ -163,6 +164,7 @@ function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: Pending
|
||||
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,7 +485,7 @@ function InfiniteCanvasPage() {
|
||||
);
|
||||
|
||||
const createConnectedNode = useCallback(
|
||||
(type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video, pending: PendingConnectionCreate) => {
|
||||
(type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio, pending: PendingConnectionCreate) => {
|
||||
const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: 3 } : undefined;
|
||||
const newNode = createCanvasNode(type, pending.position, metadata);
|
||||
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
|
||||
@@ -495,7 +497,7 @@ function InfiniteCanvasPage() {
|
||||
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (type !== CanvasNodeType.Text) setDialogNodeId(newNode.id);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
setPendingConnectionCreate(null);
|
||||
setConnecting(null);
|
||||
},
|
||||
@@ -611,7 +613,7 @@ function InfiniteCanvasPage() {
|
||||
setNodes((prev) => [...prev, newNode]);
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (type !== CanvasNodeType.Text) setDialogNodeId(newNode.id);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
},
|
||||
[effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
||||
);
|
||||
@@ -1111,6 +1113,26 @@ function InfiniteCanvasPage() {
|
||||
setDialogNodeId(id);
|
||||
}, []);
|
||||
|
||||
const createAudioFileNode = useCallback(async (file: File, position: Position) => {
|
||||
const audio = await uploadMediaFile(file, "audio");
|
||||
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
|
||||
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
setNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type: CanvasNodeType.Audio,
|
||||
title: file.name,
|
||||
position: { x: position.x - spec.width / 2, y: position.y - spec.height / 2 },
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
metadata: audioMetadata(audio),
|
||||
},
|
||||
]);
|
||||
setSelectedNodeIds(new Set([id]));
|
||||
setSelectedConnectionId(null);
|
||||
}, []);
|
||||
|
||||
const createTextNodeFromClipboard = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
@@ -1324,8 +1346,8 @@ function InfiniteCanvasPage() {
|
||||
}, []);
|
||||
|
||||
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
|
||||
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) || !node.metadata?.content) return;
|
||||
saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : imageExtension(node.metadata.content)}`);
|
||||
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return;
|
||||
saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`);
|
||||
}, []);
|
||||
|
||||
const saveNodeAsset = useCallback(
|
||||
@@ -1453,9 +1475,19 @@ function InfiniteCanvasPage() {
|
||||
async (event: ReactChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
const target = uploadTargetRef.current;
|
||||
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/"))) return;
|
||||
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
|
||||
|
||||
if (target?.nodeId) {
|
||||
if (isAudioFile(file)) {
|
||||
const audio = await uploadMediaFile(file, "audio");
|
||||
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
|
||||
setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Audio, title: file.name, position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { ...node.metadata, ...audioMetadata(audio), errorDetails: undefined } } : node)));
|
||||
setSelectedNodeIds(new Set([target.nodeId]));
|
||||
setSelectedConnectionId(null);
|
||||
uploadTargetRef.current = null;
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
if (file.type.startsWith("video/")) {
|
||||
const video = await uploadMediaFile(file, "video");
|
||||
const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
||||
@@ -1505,25 +1537,25 @@ function InfiniteCanvasPage() {
|
||||
setDialogNodeId(target.nodeId);
|
||||
} else {
|
||||
const position = target?.position || screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
||||
void (file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
|
||||
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
|
||||
}
|
||||
|
||||
uploadTargetRef.current = null;
|
||||
event.target.value = "";
|
||||
},
|
||||
[createImageFileNode, createVideoFileNode, screenToCanvas, size.height, size.width],
|
||||
[createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas, size.height, size.width],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: ReactDragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/") || item.type.startsWith("video/"));
|
||||
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
|
||||
if (!file) return;
|
||||
|
||||
const pos = screenToCanvas(event.clientX, event.clientY);
|
||||
void (file.type.startsWith("video/") ? createVideoFileNode(file, pos) : createImageFileNode(file, pos));
|
||||
void (isAudioFile(file) ? createAudioFileNode(file, pos) : file.type.startsWith("video/") ? createVideoFileNode(file, pos) : createImageFileNode(file, pos));
|
||||
},
|
||||
[createImageFileNode, createVideoFileNode, screenToCanvas],
|
||||
[createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas],
|
||||
);
|
||||
|
||||
const pasteAssistantImage = useCallback(
|
||||
@@ -1749,14 +1781,14 @@ function InfiniteCanvasPage() {
|
||||
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
|
||||
width: isEmptyVideoNode ? sourceNode.width : spec.width,
|
||||
height: isEmptyVideoNode ? sourceNode.height : spec.height,
|
||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationReferenceUrls(generationContext) },
|
||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext) },
|
||||
};
|
||||
pendingChildIds = [videoId];
|
||||
setNodes((prev) => (isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode]));
|
||||
if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]);
|
||||
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos));
|
||||
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos, generationContext.referenceAudios));
|
||||
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
||||
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationReferenceUrls(generationContext) } } : node)));
|
||||
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext) } } : node)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1874,9 +1906,9 @@ function InfiniteCanvasPage() {
|
||||
return;
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, prompt, retryImages, context?.referenceVideos || []));
|
||||
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, prompt, retryImages, context?.referenceVideos || [], context?.referenceAudios || []));
|
||||
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality } } : item)));
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark } } : item)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2196,6 +2228,7 @@ function InfiniteCanvasPage() {
|
||||
showImageInfo={showImageInfo}
|
||||
onAddImage={() => createNode(CanvasNodeType.Image)}
|
||||
onAddVideo={() => createNode(CanvasNodeType.Video)}
|
||||
onAddAudio={() => createNode(CanvasNodeType.Audio)}
|
||||
onAddText={() => createNode(CanvasNodeType.Text)}
|
||||
onAddConfig={() => createNode(CanvasNodeType.Config)}
|
||||
onUndo={undoCanvas}
|
||||
@@ -2235,7 +2268,7 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<input ref={imageInputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleImageInputChange} />
|
||||
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
|
||||
|
||||
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
|
||||
|
||||
@@ -2377,7 +2410,7 @@ function CanvasTopBar({
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入图片", onClick: onImportImage },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
||||
{ type: "divider" },
|
||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||
@@ -2458,7 +2491,7 @@ function CanvasTopBar({
|
||||
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
|
||||
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
|
||||
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
|
||||
<Shortcut keys={["拖入图片"]} value="上传到画布" />
|
||||
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
@@ -2499,12 +2532,20 @@ function imageExtension(dataUrl: string) {
|
||||
return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png";
|
||||
}
|
||||
|
||||
function audioExtension(mimeType?: string) {
|
||||
return mimeType?.includes("wav") ? "wav" : "mp3";
|
||||
}
|
||||
|
||||
function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
|
||||
return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType };
|
||||
}
|
||||
|
||||
function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
|
||||
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4" };
|
||||
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs };
|
||||
}
|
||||
|
||||
function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
|
||||
return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs };
|
||||
}
|
||||
|
||||
function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
|
||||
@@ -2522,10 +2563,11 @@ function referenceUrl(image: ReferenceImage) {
|
||||
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
|
||||
}
|
||||
|
||||
function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }> }) {
|
||||
function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) {
|
||||
return [
|
||||
...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)),
|
||||
...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)),
|
||||
...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2545,7 +2587,7 @@ async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
|
||||
return Promise.all(
|
||||
nodes.map(async (node) => {
|
||||
const content = node.metadata?.content;
|
||||
if (node.type === CanvasNodeType.Video && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
|
||||
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
|
||||
if (node.type !== CanvasNodeType.Image || !content) return node;
|
||||
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } };
|
||||
if (!content.startsWith("data:image/")) return node;
|
||||
@@ -2605,6 +2647,7 @@ function getInputSummary(inputs: NodeGenerationInput[]) {
|
||||
textCount: inputs.filter((input) => input.type === "text").length,
|
||||
imageCount: inputs.filter((input) => input.type === "image").length,
|
||||
videoCount: inputs.filter((input) => input.type === "video").length,
|
||||
audioCount: inputs.filter((input) => input.type === "audio").length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2617,6 +2660,8 @@ function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefine
|
||||
size: node?.metadata?.size || config.size || defaultConfig.size,
|
||||
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
|
||||
count: String(node?.metadata?.count || (mode === "image" ? 3 : config.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
@@ -2652,6 +2697,10 @@ function sourceNodeReferenceImages(node: CanvasNodeData | null) {
|
||||
];
|
||||
}
|
||||
|
||||
function isAudioFile(file: File) {
|
||||
return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
if (!rootId) return false;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Play, Video } from "lucide-react";
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Video } from "lucide-react";
|
||||
import { App, Button, Empty, Input, Modal, Segmented } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
@@ -18,7 +18,7 @@ import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from ".
|
||||
type CanvasConfigNodePanelProps = {
|
||||
node: CanvasNodeData;
|
||||
isRunning: boolean;
|
||||
inputSummary: { textCount: number; imageCount: number; videoCount: number };
|
||||
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
|
||||
inputs: NodeGenerationInput[];
|
||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
|
||||
onTextInputChange: (nodeId: string, content: string) => void;
|
||||
@@ -42,6 +42,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
const textInputs = inputs.filter((input) => input.type === "text");
|
||||
const imageInputs = inputs.filter((input) => input.type === "image");
|
||||
const videoInputs = inputs.filter((input) => input.type === "video");
|
||||
const audioInputs = inputs.filter((input) => input.type === "audio");
|
||||
|
||||
const moveInput = (input: NodeGenerationInput, offset: number) => {
|
||||
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
||||
@@ -114,6 +115,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
|
||||
<Eye className="size-3.5" />
|
||||
预览
|
||||
@@ -123,7 +125,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "text" ? "grid-cols-1" : "grid-cols-[minmax(0,1fr)_148px]"}`} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
|
||||
{mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} />
|
||||
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
) : mode === "image" ? (
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
|
||||
) : null}
|
||||
@@ -132,7 +134,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
|
||||
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount && !inputSummary.videoCount)}
|
||||
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount && !inputSummary.videoCount && !inputSummary.audioCount)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => onGenerate(node.id)}
|
||||
>
|
||||
@@ -182,6 +184,15 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PreviewSection title="参考音频" count={audioInputs.length} empty="暂无参考音频">
|
||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||
{audioInputs.map((input, index) => (
|
||||
<AudioSortCard key={input.nodeId} input={input} audioIndex={index} audioTotal={audioInputs.length} theme={theme} onMove={moveInput} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
|
||||
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
|
||||
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
|
||||
@@ -324,6 +335,36 @@ function VideoSortCard({
|
||||
);
|
||||
}
|
||||
|
||||
function AudioSortCard({
|
||||
input,
|
||||
audioIndex,
|
||||
audioTotal,
|
||||
theme,
|
||||
onMove,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
audioIndex: number;
|
||||
audioTotal: number;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
}) {
|
||||
if (!input.audio) return null;
|
||||
return (
|
||||
<div className="w-48 shrink-0 rounded-lg border p-2" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 text-[11px] opacity-70">
|
||||
<Music2 className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{input.title}</span>
|
||||
</div>
|
||||
<audio src={input.audio.url} controls className="h-8 w-full" preload="metadata" />
|
||||
<div className="mt-1 flex justify-between">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowLeft className="size-3" />} disabled={audioIndex <= 0} onClick={() => onMove(input, -1)} />
|
||||
<span className="text-[10px] opacity-45">{audioIndex + 1}</span>
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowRight className="size-3" />} disabled={audioIndex >= audioTotal - 1} onClick={() => onMove(input, 1)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
||||
return (
|
||||
<>
|
||||
@@ -360,6 +401,15 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
|
||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
|
||||
function videoConfigPatch(key: keyof AiConfig, value: string) {
|
||||
if (key === "videoSeconds") return { seconds: value };
|
||||
if (key === "videoGenerateAudio") return { generateAudio: value };
|
||||
if (key === "videoWatermark") return { watermark: value };
|
||||
return { [key]: value };
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
|
||||
>
|
||||
{nodes.map((node) => {
|
||||
const pos = toMinimap(node.position.x, node.position.y);
|
||||
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
|
||||
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import type { ChatCompletionMessage } from "@/services/api/image";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceVideo } from "@/types/media";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
|
||||
|
||||
export type NodeGenerationContext = {
|
||||
prompt: string;
|
||||
referenceImages: ReferenceImage[];
|
||||
referenceVideos: ReferenceVideo[];
|
||||
referenceAudios: ReferenceAudio[];
|
||||
textCount: number;
|
||||
imageCount: number;
|
||||
videoCount: number;
|
||||
audioCount: number;
|
||||
};
|
||||
|
||||
export type NodeGenerationInput = {
|
||||
nodeId: string;
|
||||
type: "text" | "image" | "video";
|
||||
type: "text" | "image" | "video" | "audio";
|
||||
title: string;
|
||||
text?: string;
|
||||
image?: ReferenceImage;
|
||||
video?: ReferenceVideo;
|
||||
audio?: ReferenceAudio;
|
||||
};
|
||||
|
||||
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
|
||||
@@ -29,14 +32,17 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
|
||||
.join("\n\n");
|
||||
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
||||
const referenceVideos = inputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
|
||||
const referenceAudios = inputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
|
||||
|
||||
return {
|
||||
prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt,
|
||||
referenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
textCount: inputs.filter((input) => input.type === "text").length,
|
||||
imageCount: referenceImages.length,
|
||||
videoCount: referenceVideos.length,
|
||||
audioCount: referenceAudios.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +52,8 @@ export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[
|
||||
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
|
||||
const video = readReferenceVideo(node);
|
||||
if (video) return [{ nodeId: node.id, type: "video" as const, title: node.title, video }];
|
||||
const audio = readReferenceAudio(node);
|
||||
if (audio) return [{ nodeId: node.id, type: "audio" as const, title: node.title, audio }];
|
||||
const text = readNodeTextInput(node);
|
||||
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
|
||||
return [];
|
||||
@@ -97,6 +105,18 @@ function readReferenceVideo(node: CanvasNodeData): ReferenceVideo | null {
|
||||
};
|
||||
}
|
||||
|
||||
function readReferenceAudio(node: CanvasNodeData): ReferenceAudio | null {
|
||||
if (node.type !== CanvasNodeType.Audio || !node.metadata?.content) return null;
|
||||
return {
|
||||
id: node.id,
|
||||
name: `${node.title || node.id}.mp3`,
|
||||
type: node.metadata.mimeType || "audio/mpeg",
|
||||
url: node.metadata.content,
|
||||
storageKey: node.metadata.storageKey,
|
||||
durationMs: node.metadata.durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const target = nodes.find((node) => node.id === nodeId);
|
||||
const upstreamNodes = connections
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Modal, Segmented, Tooltip } from "antd";
|
||||
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, Maximize2, MessageSquare, Minus, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, Maximize2, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
@@ -58,13 +58,15 @@ export function CanvasNodeHoverToolbar({
|
||||
const top = viewport.y + node.position.y * viewport.k - 14;
|
||||
const isImage = node.type === CanvasNodeType.Image;
|
||||
const isVideo = node.type === CanvasNodeType.Video;
|
||||
const isAudio = node.type === CanvasNodeType.Audio;
|
||||
const hasImage = isImage && Boolean(node.metadata?.content);
|
||||
const hasVideo = isVideo && Boolean(node.metadata?.content);
|
||||
const hasAudio = isAudio && Boolean(node.metadata?.content);
|
||||
const isText = node.type === CanvasNodeType.Text;
|
||||
const isConfig = node.type === CanvasNodeType.Config;
|
||||
const canOpenDialog = isText || hasImage || isVideo;
|
||||
const canRetry = node.metadata?.status === "error";
|
||||
const hasSpecificTools = canRetry || isText || isImage || isVideo || isConfig;
|
||||
const hasSpecificTools = canRetry || isText || isImage || isVideo || isAudio || isConfig;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -80,7 +82,7 @@ export function CanvasNodeHoverToolbar({
|
||||
{hasSpecificTools ? <ToolbarDivider /> : null}
|
||||
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
|
||||
{hasImage || hasVideo || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
|
||||
{hasImage || hasVideo ? <IconAction title={hasVideo ? "下载视频" : "下载图片"} icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
|
||||
{hasImage || hasVideo || hasAudio ? <IconAction title={hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片"} icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
|
||||
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
|
||||
@@ -89,6 +91,7 @@ export function CanvasNodeHoverToolbar({
|
||||
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
|
||||
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{isVideo ? <ToolbarAction title={hasVideo ? "替换视频" : "上传视频"} label={hasVideo ? "替换视频" : "上传视频"} icon={<Video className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{isAudio ? <ToolbarAction title={hasAudio ? "替换音频" : "上传音频"} label={hasAudio ? "替换音频" : "上传音频"} icon={<Music2 className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{hasImage ? (
|
||||
<ToolbarAction
|
||||
title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"}
|
||||
@@ -151,7 +154,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
{view === "info" ? (
|
||||
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
||||
<InfoRow label="ID" value={node.id} />
|
||||
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : "生成配置"} />
|
||||
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "生成配置"} />
|
||||
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
|
||||
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
|
||||
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
|
||||
|
||||
@@ -93,7 +93,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
) : mode === "video" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} />
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
@@ -132,6 +132,15 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
|
||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
|
||||
function videoConfigPatch(key: keyof AiConfig, value: string) {
|
||||
if (key === "videoSeconds") return { seconds: value };
|
||||
if (key === "videoGenerateAudio") return { generateAudio: value };
|
||||
if (key === "videoWatermark") return { watermark: value };
|
||||
return { [key]: value };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronRight, Image as ImageIcon, RefreshCw, Star, Video } from "lucide-react";
|
||||
import { ChevronRight, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
@@ -99,6 +99,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
||||
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
|
||||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
|
||||
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||||
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||||
@@ -301,7 +302,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
|
||||
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
|
||||
|
||||
{!hasImageContent && !hasVideoContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
||||
{!hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
||||
|
||||
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
||||
@@ -332,6 +333,7 @@ const nodeContentRenderers = {
|
||||
[CanvasNodeType.Image]: ImageNodeContent,
|
||||
[CanvasNodeType.Config]: EmptyImageContent,
|
||||
[CanvasNodeType.Video]: VideoNodeContent,
|
||||
[CanvasNodeType.Audio]: AudioNodeContent,
|
||||
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
|
||||
|
||||
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||
@@ -472,6 +474,25 @@ function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
|
||||
return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
|
||||
}
|
||||
|
||||
function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
|
||||
if (!node.metadata?.content)
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2" style={{ color: theme.node.placeholder }}>
|
||||
<Music2 className="size-7 opacity-35" />
|
||||
<span className="text-sm">空音频节点</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
|
||||
<Music2 className="size-4 shrink-0" />
|
||||
<span className="truncate">{node.title || "音频"}</span>
|
||||
</div>
|
||||
<audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageContent({
|
||||
node,
|
||||
isBatchRoot,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Button, Segmented, Switch } from "antd";
|
||||
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Library, Moon, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
||||
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Library, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -15,6 +15,7 @@ export function CanvasToolbar({
|
||||
showImageInfo,
|
||||
onAddImage,
|
||||
onAddVideo,
|
||||
onAddAudio,
|
||||
onAddText,
|
||||
onAddConfig,
|
||||
onUndo,
|
||||
@@ -35,6 +36,7 @@ export function CanvasToolbar({
|
||||
showImageInfo: boolean;
|
||||
onAddImage: () => void;
|
||||
onAddVideo: () => void;
|
||||
onAddAudio: () => void;
|
||||
onAddText: () => void;
|
||||
onAddConfig: () => void;
|
||||
onUndo: () => void;
|
||||
@@ -84,10 +86,13 @@ export function CanvasToolbar({
|
||||
<ToolbarButton id="tool-video" label="视频" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddVideo}>
|
||||
<Video className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-audio" label="音频" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddAudio}>
|
||||
<Music2 className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
|
||||
<Settings2 className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-upload" label="上传图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
||||
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
||||
<Upload className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<Divider theme={theme} />
|
||||
@@ -279,8 +284,9 @@ function toolLabel(id: string) {
|
||||
if (id === "tool-text") return "文本";
|
||||
if (id === "tool-image") return "图片";
|
||||
if (id === "tool-video") return "视频";
|
||||
if (id === "tool-audio") return "音频";
|
||||
if (id === "tool-config") return "生成配置";
|
||||
if (id === "tool-upload") return "上传图片";
|
||||
if (id === "tool-upload") return "上传素材";
|
||||
if (id === "tool-library") return "素材库";
|
||||
if (id === "tool-assets") return "我的素材";
|
||||
if (id === "tool-style") return "画布外观";
|
||||
|
||||
@@ -13,6 +13,7 @@ export const NODE_DEFAULT_SIZE = {
|
||||
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
|
||||
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
|
||||
[CanvasNodeType.Video]: { width: 420, height: 236, title: "Video" },
|
||||
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
|
||||
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
||||
|
||||
export const NODE_SPECS = {
|
||||
@@ -32,6 +33,10 @@ export const NODE_SPECS = {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Video],
|
||||
metadata: { content: "", status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.Audio]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Audio],
|
||||
metadata: { content: "", status: "idle" },
|
||||
},
|
||||
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
||||
|
||||
export function getNodeSpec(type: CanvasNodeType) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum CanvasNodeType {
|
||||
Text = "text",
|
||||
Config = "config",
|
||||
Video = "video",
|
||||
Audio = "audio",
|
||||
}
|
||||
|
||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||
@@ -34,6 +35,8 @@ export type CanvasNodeMetadata = {
|
||||
count?: number;
|
||||
seconds?: string;
|
||||
vquality?: string;
|
||||
generateAudio?: string;
|
||||
watermark?: string;
|
||||
references?: string[];
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
@@ -48,6 +51,7 @@ export type CanvasNodeMetadata = {
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export type CanvasNodeData = {
|
||||
|
||||
Reference in New Issue
Block a user