feat: support video file drag-drop and paste (#9154)

This commit is contained in:
Dante
2026-02-25 07:59:26 +09:00
committed by GitHub
parent 2ff14fadc2
commit 9108b7535a
6 changed files with 359 additions and 25 deletions

View File

@@ -7,7 +7,14 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { ComfyApp } from './app'
import { createNode } from '@/utils/litegraphUtil'
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
import {
pasteAudioNode,
pasteAudioNodes,
pasteImageNode,
pasteImageNodes,
pasteVideoNode,
pasteVideoNodes
} from '@/composables/usePaste'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
vi.mock('@/utils/litegraphUtil', () => ({
@@ -20,8 +27,12 @@ vi.mock('@/utils/litegraphUtil', () => ({
}))
vi.mock('@/composables/usePaste', () => ({
pasteAudioNode: vi.fn(),
pasteAudioNodes: vi.fn(),
pasteImageNode: vi.fn(),
pasteImageNodes: vi.fn()
pasteImageNodes: vi.fn(),
pasteVideoNode: vi.fn(),
pasteVideoNodes: vi.fn()
}))
vi.mock('@/scripts/metadata/parser', () => ({
@@ -130,6 +141,60 @@ describe('ComfyApp', () => {
})
})
describe('handleAudioFileList', () => {
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])
const file1 = createTestFile('test1.mp3', 'audio/mpeg')
const file2 = createTestFile('test2.wav', 'audio/wav')
await app.handleAudioFileList([file1, file2])
expect(pasteAudioNodes).toHaveBeenCalledWith(mockCanvas, [file1, file2])
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
mockNode1,
mockNode2
])
})
it('should not select when no nodes created', async () => {
vi.mocked(pasteAudioNodes).mockResolvedValue([])
await app.handleAudioFileList([createTestFile('test.mp3', 'audio/mpeg')])
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
})
})
describe('handleVideoFileList', () => {
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])
const file1 = createTestFile('test1.mp4', 'video/mp4')
const file2 = createTestFile('test2.webm', 'video/webm')
await app.handleVideoFileList([file1, file2])
expect(pasteVideoNodes).toHaveBeenCalledWith(mockCanvas, [file1, file2])
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
mockNode1,
mockNode2
])
})
it('should not select when no nodes created', async () => {
vi.mocked(pasteVideoNodes).mockResolvedValue([])
await app.handleVideoFileList([createTestFile('test.mp4', 'video/mp4')])
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
})
})
describe('positionBatchNodes', () => {
it('should position batch node to the right of first node', () => {
const mockNode1 = createMockNode({
@@ -191,6 +256,42 @@ describe('ComfyApp', () => {
)
})
it('should handle audio files by creating LoadAudio node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
const mockNode = createMockNode({ type: 'LoadAudio' })
vi.mocked(createNode).mockResolvedValue(mockNode)
const audioFile = createTestFile('test.mp3', 'audio/mpeg')
await app.handleFile(audioFile)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadAudio')
expect(pasteAudioNode).toHaveBeenCalledWith(
mockCanvas,
expect.any(DataTransferItemList),
mockNode
)
})
it('should handle video files by creating LoadVideo node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
const mockNode = createMockNode({ type: 'LoadVideo' })
vi.mocked(createNode).mockResolvedValue(mockNode)
const videoFile = createTestFile('test.mp4', 'video/mp4')
await app.handleFile(videoFile)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadVideo')
expect(pasteVideoNode).toHaveBeenCalledWith(
mockCanvas,
expect.any(DataTransferItemList),
mockNode
)
})
it('should handle image files with non-workflow metadata by creating LoadImage node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({
Software: 'gnome-screenshot'

View File

@@ -114,14 +114,18 @@ import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/
import {
extractFilesFromDragEvent,
hasAudioType,
hasImageType
hasImageType,
hasVideoType,
isMediaFile
} from '@/utils/eventUtils'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import {
pasteAudioNode,
pasteAudioNodes,
pasteImageNode,
pasteImageNodes
pasteImageNodes,
pasteVideoNode,
pasteVideoNodes
} from '@/composables/usePaste'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -571,7 +575,9 @@ export class ComfyApp {
workspace.spinner = true
const imageFiles = files.filter(hasImageType)
const audioFiles = files.filter(hasAudioType)
const totalMedia = imageFiles.length + audioFiles.length
const videoFiles = files.filter(hasVideoType)
const totalMedia =
imageFiles.length + audioFiles.length + videoFiles.length
const hasMultipleMedia = totalMedia > 1
if (hasMultipleMedia) {
@@ -581,8 +587,10 @@ export class ComfyApp {
if (audioFiles.length > 0) {
await this.handleAudioFileList(audioFiles)
}
const handled = new Set([...imageFiles, ...audioFiles])
for (const file of files.filter((f) => !handled.has(f))) {
if (videoFiles.length > 0) {
await this.handleVideoFileList(videoFiles)
}
for (const file of files.filter((f) => !isMediaFile(f))) {
await this.handleFile(file, 'file_drop', {
deferWarnings: true
})
@@ -1581,17 +1589,21 @@ export class ComfyApp {
const { workflow, prompt, parameters, templates } = workflowData ?? {}
if (!(workflow || prompt || parameters || templates)) {
if (file.type.startsWith('image')) {
const mediaNodeTypes: Record<string, [string, typeof pasteImageNode]> = {
image: ['LoadImage', pasteImageNode],
audio: ['LoadAudio', pasteAudioNode],
video: ['LoadVideo', pasteVideoNode]
}
const mediaType = Object.keys(mediaNodeTypes).find((t) =>
file.type.startsWith(t)
)
if (mediaType) {
const [nodeType, pasteFn] = mediaNodeTypes[mediaType]
const transfer = new DataTransfer()
transfer.items.add(file)
const imageNode = await createNode(this.canvas, 'LoadImage')
await pasteImageNode(this.canvas, transfer.items, imageNode)
return
} else if (file.type.startsWith('audio')) {
const transfer = new DataTransfer()
transfer.items.add(file)
const audioNode = await createNode(this.canvas, 'LoadAudio')
await pasteAudioNode(this.canvas, transfer.items, audioNode)
const node = await createNode(this.canvas, nodeType)
await pasteFn(this.canvas, transfer.items, node)
return
}
@@ -1703,6 +1715,14 @@ export class ComfyApp {
this.canvas.selectItems(audioNodes)
}
async handleVideoFileList(fileList: File[]) {
const videoNodes = await pasteVideoNodes(this.canvas, fileList)
if (videoNodes.length === 0) return
this.positionNodes(videoNodes)
this.canvas.selectItems(videoNodes)
}
/**
* Positions batched nodes in drag and drop
* @param nodes