feat: batch audio/video file drops into single undo entry

This commit is contained in:
bymyself
2026-03-16 00:58:03 +00:00
parent 75022f302b
commit 4768e90165
4 changed files with 77 additions and 33 deletions

View File

@@ -287,20 +287,21 @@ describe('pasteAudioNodes', () => {
const file2 = createAudioFile('file2.wav', 'audio/wav')
const result = await pasteAudioNodes(mockCanvas, [file1, file2])
await result.completion
expect(createNode).toHaveBeenCalledTimes(2)
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadAudio')
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadAudio')
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
expect(result).toEqual([mockNode1, mockNode2])
expect(result.nodes).toEqual([mockNode1, mockNode2])
})
it('should handle empty file list', async () => {
const result = await pasteAudioNodes(mockCanvas, [])
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
expect(result.nodes).toEqual([])
})
it('should handle single audio file', async () => {
@@ -311,7 +312,7 @@ describe('pasteAudioNodes', () => {
const result = await pasteAudioNodes(mockCanvas, [file])
expect(createNode).toHaveBeenCalledTimes(1)
expect(result).toEqual([mockNode])
expect(result.nodes).toEqual([mockNode])
})
})
@@ -383,20 +384,21 @@ describe('pasteVideoNodes', () => {
const file2 = createVideoFile('file2.webm', 'video/webm')
const result = await pasteVideoNodes(mockCanvas, [file1, file2])
await result.completion
expect(createNode).toHaveBeenCalledTimes(2)
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadVideo')
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadVideo')
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
expect(result).toEqual([mockNode1, mockNode2])
expect(result.nodes).toEqual([mockNode1, mockNode2])
})
it('should handle empty file list', async () => {
const result = await pasteVideoNodes(mockCanvas, [])
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
expect(result.nodes).toEqual([])
})
it('should handle single video file', async () => {
@@ -407,7 +409,7 @@ describe('pasteVideoNodes', () => {
const result = await pasteVideoNodes(mockCanvas, [file])
expect(createNode).toHaveBeenCalledTimes(1)
expect(result).toEqual([mockNode])
expect(result.nodes).toEqual([mockNode])
})
})

View File

@@ -139,20 +139,25 @@ export async function pasteAudioNode(
export async function pasteAudioNodes(
canvas: LGraphCanvas,
fileList: File[]
): Promise<LGraphNode[]> {
): Promise<PasteNodesResult> {
const nodes: LGraphNode[] = []
const uploads: Promise<void>[] = []
for (const file of fileList) {
const node = await createNode(canvas, 'LoadAudio')
if (!node) continue
nodes.push(node)
const transfer = new DataTransfer()
transfer.items.add(file)
const node = await pasteAudioNode(canvas, transfer.items)
if (node) {
nodes.push(node)
}
uploads.push(pasteItemsOnNode(transfer.items, node, 'audio'))
}
return nodes
return {
nodes,
completion: Promise.all(uploads).then(() => {})
}
}
export async function pasteVideoNode(
@@ -170,20 +175,25 @@ export async function pasteVideoNode(
export async function pasteVideoNodes(
canvas: LGraphCanvas,
fileList: File[]
): Promise<LGraphNode[]> {
): Promise<PasteNodesResult> {
const nodes: LGraphNode[] = []
const uploads: Promise<void>[] = []
for (const file of fileList) {
const node = await createNode(canvas, 'LoadVideo')
if (!node) continue
nodes.push(node)
const transfer = new DataTransfer()
transfer.items.add(file)
const node = await pasteVideoNode(canvas, transfer.items)
if (node) {
nodes.push(node)
}
uploads.push(pasteItemsOnNode(transfer.items, node, 'video'))
}
return nodes
return {
nodes,
completion: Promise.all(uploads).then(() => {})
}
}
/**

View File

@@ -153,7 +153,10 @@ describe('ComfyApp', () => {
it('should create audio nodes and select them', async () => {
const mockNode1 = createMockNode({ id: 1, type: 'LoadAudio' })
const mockNode2 = createMockNode({ id: 2, type: 'LoadAudio' })
vi.mocked(pasteAudioNodes).mockResolvedValue([mockNode1, mockNode2])
vi.mocked(pasteAudioNodes).mockResolvedValue({
nodes: [mockNode1, mockNode2],
completion: Promise.resolve()
})
const file1 = createTestFile('test1.mp3', 'audio/mpeg')
const file2 = createTestFile('test2.wav', 'audio/wav')
@@ -165,14 +168,21 @@ describe('ComfyApp', () => {
mockNode1,
mockNode2
])
expect(mockCanvas.emitBeforeChange).toHaveBeenCalled()
expect(mockCanvas.emitAfterChange).toHaveBeenCalled()
})
it('should not select when no nodes created', async () => {
vi.mocked(pasteAudioNodes).mockResolvedValue([])
vi.mocked(pasteAudioNodes).mockResolvedValue({
nodes: [],
completion: Promise.resolve()
})
await app.handleAudioFileList([createTestFile('test.mp3', 'audio/mpeg')])
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
expect(mockCanvas.emitBeforeChange).toHaveBeenCalled()
expect(mockCanvas.emitAfterChange).toHaveBeenCalled()
})
})
@@ -180,7 +190,10 @@ describe('ComfyApp', () => {
it('should create video nodes and select them', async () => {
const mockNode1 = createMockNode({ id: 1, type: 'LoadVideo' })
const mockNode2 = createMockNode({ id: 2, type: 'LoadVideo' })
vi.mocked(pasteVideoNodes).mockResolvedValue([mockNode1, mockNode2])
vi.mocked(pasteVideoNodes).mockResolvedValue({
nodes: [mockNode1, mockNode2],
completion: Promise.resolve()
})
const file1 = createTestFile('test1.mp4', 'video/mp4')
const file2 = createTestFile('test2.webm', 'video/webm')
@@ -192,14 +205,21 @@ describe('ComfyApp', () => {
mockNode1,
mockNode2
])
expect(mockCanvas.emitBeforeChange).toHaveBeenCalled()
expect(mockCanvas.emitAfterChange).toHaveBeenCalled()
})
it('should not select when no nodes created', async () => {
vi.mocked(pasteVideoNodes).mockResolvedValue([])
vi.mocked(pasteVideoNodes).mockResolvedValue({
nodes: [],
completion: Promise.resolve()
})
await app.handleVideoFileList([createTestFile('test.mp4', 'video/mp4')])
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
expect(mockCanvas.emitBeforeChange).toHaveBeenCalled()
expect(mockCanvas.emitAfterChange).toHaveBeenCalled()
})
})

View File

@@ -1746,19 +1746,31 @@ export class ComfyApp {
}
async handleAudioFileList(fileList: File[]) {
const audioNodes = await pasteAudioNodes(this.canvas, fileList)
if (audioNodes.length === 0) return
this.canvas.emitBeforeChange()
try {
const { nodes, completion } = await pasteAudioNodes(this.canvas, fileList)
if (nodes.length === 0) return
this.positionNodes(audioNodes)
this.canvas.selectItems(audioNodes)
this.positionNodes(nodes)
this.canvas.selectItems(nodes)
await completion
} finally {
this.canvas.emitAfterChange()
}
}
async handleVideoFileList(fileList: File[]) {
const videoNodes = await pasteVideoNodes(this.canvas, fileList)
if (videoNodes.length === 0) return
this.canvas.emitBeforeChange()
try {
const { nodes, completion } = await pasteVideoNodes(this.canvas, fileList)
if (nodes.length === 0) return
this.positionNodes(videoNodes)
this.canvas.selectItems(videoNodes)
this.positionNodes(nodes)
this.canvas.selectItems(nodes)
await completion
} finally {
this.canvas.emitAfterChange()
}
}
/**
@@ -1769,7 +1781,7 @@ export class ComfyApp {
positionNodes(nodes: LGraphNode[]): void {
if (nodes.length <= 1) return
const [x, y] = nodes[0].getBounding()
const [x, y] = nodes[0].pos
const nodeHeight = 150
nodes.forEach((node, index) => {