From f035cbcbd5de253bd12b045472fecaf10ca79469 Mon Sep 17 00:00:00 2001 From: bymyself Date: Mon, 16 Jun 2025 15:11:34 -0700 Subject: [PATCH] [bugfix] Fix PNG loading with NaN values in metadata Restore lazy evaluation for metadata parsing to handle PNG files with NaN values in prompt field. Uses the original if-else loading logic to prevent parsing errors when workflow is valid but prompt contains invalid JSON. Fixes #4199 --- src/scripts/app.ts | 203 +++--------- src/utils/fileHandlers.ts | 334 ++++++++++++++++++++ tests-ui/tests/utils/fileHandlers.test.ts | 360 ++++++++++++++++++++++ 3 files changed, 739 insertions(+), 158 deletions(-) create mode 100644 src/utils/fileHandlers.ts create mode 100644 tests-ui/tests/utils/fileHandlers.test.ts diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 7744ae757..5858a9a70 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -30,12 +30,6 @@ import { isComboInputSpecV1, isComboInputSpecV2 } from '@/schemas/nodeDefSchema' -import { getFromWebmFile } from '@/scripts/metadata/ebml' -import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf' -import { getFromIsobmffFile } from '@/scripts/metadata/isobmff' -import { getMp3Metadata } from '@/scripts/metadata/mp3' -import { getOggMetadata } from '@/scripts/metadata/ogg' -import { getSvgMetadata } from '@/scripts/metadata/svg' import { useDialogService } from '@/services/dialogService' import { useExtensionService } from '@/services/extensionService' import { useLitegraphService } from '@/services/litegraphService' @@ -60,6 +54,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy' import { ExtensionManager } from '@/types/extensionTypes' import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil' import { graphToPrompt } from '@/utils/executionUtil' +import { getFileHandler } from '@/utils/fileHandlers' import { executeWidgetsCallback, fixLinkInputSlots, @@ -74,13 +69,7 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard' import { type ComfyApi, PromptExecutionError, api } from './api' import { defaultGraph } from './defaultGraph' -import { - getFlacMetadata, - getLatentMetadata, - getPngMetadata, - getWebpMetadata, - importA1111 -} from './pnginfo' +import { importA1111 } from './pnginfo' import { $el, ComfyUI } from './ui' import { ComfyAppMenu } from './ui/menu/index' import { clone } from './utils' @@ -1332,160 +1321,58 @@ export class ComfyApp { return f.substring(0, p) } const fileName = removeExt(file.name) - if (file.type === 'image/png') { - const pngInfo = await getPngMetadata(file) - if (pngInfo?.workflow) { + + const handler = getFileHandler(file) + if (!handler) { + this.showErrorOnFileLoad(file) + return + } + + const metadata = await handler(file) + + // Special handling for JSON files + if (metadata.jsonTemplateData) { + const jsonContent = metadata.jsonTemplateData() + if ( + jsonContent && + typeof jsonContent === 'object' && + 'templates' in jsonContent + ) { + this.loadTemplateData(jsonContent as any) + return + } else if (this.isApiJson(jsonContent)) { + this.loadApiJson(jsonContent as ComfyApiWorkflow, fileName) + return + } else { + // Regular workflow JSON await this.loadGraphData( - JSON.parse(pngInfo.workflow), + jsonContent as ComfyWorkflowJSON, true, true, fileName ) - } else if (pngInfo?.prompt) { - this.loadApiJson(JSON.parse(pngInfo.prompt), fileName) - } else if (pngInfo?.parameters) { - // Note: Not putting this in `importA1111` as it is mostly not used - // by external callers, and `importA1111` has no access to `app`. - useWorkflowService().beforeLoadNewGraph() - importA1111(this.graph, pngInfo.parameters) - useWorkflowService().afterLoadNewGraph( - fileName, - this.graph.serialize() as unknown as ComfyWorkflowJSON - ) - } else { - this.showErrorOnFileLoad(file) + return } - } else if (file.type === 'image/webp') { - const pngInfo = await getWebpMetadata(file) - // Support loading workflows from that webp custom node. - const workflow = pngInfo?.workflow || pngInfo?.Workflow - const prompt = pngInfo?.prompt || pngInfo?.Prompt + } - if (workflow) { - this.loadGraphData(JSON.parse(workflow), true, true, fileName) - } else if (prompt) { - this.loadApiJson(JSON.parse(prompt), fileName) - } else { - this.showErrorOnFileLoad(file) + if (metadata.workflow) { + const workflowData = metadata.workflow() + if (workflowData) { + await this.loadGraphData(workflowData, true, true, fileName) } - } else if (file.type === 'audio/mpeg') { - const { workflow, prompt } = await getMp3Metadata(file) - if (workflow) { - this.loadGraphData(workflow, true, true, fileName) - } else if (prompt) { - this.loadApiJson(prompt, fileName) - } else { - this.showErrorOnFileLoad(file) - } - } else if (file.type === 'audio/ogg') { - const { workflow, prompt } = await getOggMetadata(file) - if (workflow) { - this.loadGraphData(workflow, true, true, fileName) - } else if (prompt) { - this.loadApiJson(prompt, fileName) - } else { - this.showErrorOnFileLoad(file) - } - } else if (file.type === 'audio/flac' || file.type === 'audio/x-flac') { - const pngInfo = await getFlacMetadata(file) - const workflow = pngInfo?.workflow || pngInfo?.Workflow - const prompt = pngInfo?.prompt || pngInfo?.Prompt - - if (workflow) { - this.loadGraphData(JSON.parse(workflow), true, true, fileName) - } else if (prompt) { - this.loadApiJson(JSON.parse(prompt), fileName) - } else { - this.showErrorOnFileLoad(file) - } - } else if (file.type === 'video/webm') { - const webmInfo = await getFromWebmFile(file) - if (webmInfo.workflow) { - this.loadGraphData(webmInfo.workflow, true, true, fileName) - } else if (webmInfo.prompt) { - this.loadApiJson(webmInfo.prompt, fileName) - } else { - this.showErrorOnFileLoad(file) - } - } else if ( - file.type === 'video/mp4' || - file.name?.endsWith('.mp4') || - file.name?.endsWith('.mov') || - file.name?.endsWith('.m4v') || - file.type === 'video/quicktime' || - file.type === 'video/x-m4v' - ) { - const mp4Info = await getFromIsobmffFile(file) - if (mp4Info.workflow) { - this.loadGraphData(mp4Info.workflow, true, true, fileName) - } else if (mp4Info.prompt) { - this.loadApiJson(mp4Info.prompt, fileName) - } - } else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) { - const svgInfo = await getSvgMetadata(file) - if (svgInfo.workflow) { - this.loadGraphData(svgInfo.workflow, true, true, fileName) - } else if (svgInfo.prompt) { - this.loadApiJson(svgInfo.prompt, fileName) - } else { - this.showErrorOnFileLoad(file) - } - } else if ( - file.type === 'model/gltf-binary' || - file.name?.endsWith('.glb') - ) { - const gltfInfo = await getGltfBinaryMetadata(file) - if (gltfInfo.workflow) { - this.loadGraphData(gltfInfo.workflow, true, true, fileName) - } else if (gltfInfo.prompt) { - this.loadApiJson(gltfInfo.prompt, fileName) - } else { - this.showErrorOnFileLoad(file) - } - } else if ( - file.type === 'application/json' || - file.name?.endsWith('.json') - ) { - const reader = new FileReader() - reader.onload = async () => { - const readerResult = reader.result as string - const jsonContent = JSON.parse(readerResult) - if (jsonContent?.templates) { - this.loadTemplateData(jsonContent) - } else if (this.isApiJson(jsonContent)) { - this.loadApiJson(jsonContent, fileName) - } else { - await this.loadGraphData( - JSON.parse(readerResult), - true, - true, - fileName - ) - } - } - reader.readAsText(file) - } else if ( - file.name?.endsWith('.latent') || - file.name?.endsWith('.safetensors') - ) { - const info = await getLatentMetadata(file) - // TODO define schema to LatentMetadata - // @ts-expect-error - if (info.workflow) { - await this.loadGraphData( - // @ts-expect-error - JSON.parse(info.workflow), - true, - true, - fileName - ) - // @ts-expect-error - } else if (info.prompt) { - // @ts-expect-error - this.loadApiJson(JSON.parse(info.prompt)) - } else { - this.showErrorOnFileLoad(file) + } else if (metadata.prompt) { + const promptData = metadata.prompt() + if (promptData) { + this.loadApiJson(promptData, fileName) } + } else if (metadata.parameters) { + // A1111 import + useWorkflowService().beforeLoadNewGraph() + importA1111(this.graph, metadata.parameters) + useWorkflowService().afterLoadNewGraph( + fileName, + this.graph.serialize() as unknown as ComfyWorkflowJSON + ) } else { this.showErrorOnFileLoad(file) } diff --git a/src/utils/fileHandlers.ts b/src/utils/fileHandlers.ts new file mode 100644 index 000000000..3e7b293d2 --- /dev/null +++ b/src/utils/fileHandlers.ts @@ -0,0 +1,334 @@ +/** + * Maps MIME types and file extensions to handler functions for extracting + * workflow data from various file formats. Uses supportedWorkflowFormats.ts + * as the source of truth for supported formats. + */ +import { + AUDIO_WORKFLOW_FORMATS, + DATA_WORKFLOW_FORMATS, + IMAGE_WORKFLOW_FORMATS, + MODEL_WORKFLOW_FORMATS, + VIDEO_WORKFLOW_FORMATS +} from '@/constants/supportedWorkflowFormats' +import type { + ComfyApiWorkflow, + ComfyWorkflowJSON +} from '@/schemas/comfyWorkflowSchema' +import { getFromWebmFile } from '@/scripts/metadata/ebml' +import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf' +import { getFromIsobmffFile } from '@/scripts/metadata/isobmff' +import { getMp3Metadata } from '@/scripts/metadata/mp3' +import { getOggMetadata } from '@/scripts/metadata/ogg' +import { getSvgMetadata } from '@/scripts/metadata/svg' +import { + getFlacMetadata, + getLatentMetadata, + getPngMetadata, + getWebpMetadata +} from '@/scripts/pnginfo' + +/** + * Type for the raw file metadata with lazy parsing + */ +export type WorkflowFileMetadata = { + workflow?: () => ComfyWorkflowJSON | undefined + prompt?: () => ComfyApiWorkflow | undefined + parameters?: string + jsonTemplateData?: () => unknown +} + +/** + * Type for the file handler function + */ +export type WorkflowFileHandler = (file: File) => Promise + +/** + * Maps MIME types to file handlers for loading workflows from different file formats + */ +export const mimeTypeHandlers = new Map() + +/** + * Maps file extensions to file handlers for loading workflows + * Used as a fallback when MIME type detection fails + */ +export const extensionHandlers = new Map() + +/** + * Handler for PNG files + */ +const handlePngFile: WorkflowFileHandler = async (file) => { + const pngInfo = await getPngMetadata(file) + return { + workflow: () => + pngInfo?.workflow ? JSON.parse(pngInfo.workflow) : undefined, + prompt: () => (pngInfo?.prompt ? JSON.parse(pngInfo.prompt) : undefined), + parameters: pngInfo?.parameters + } +} + +/** + * Handler for WebP files + */ +const handleWebpFile: WorkflowFileHandler = async (file) => { + const pngInfo = await getWebpMetadata(file) + const workflow = pngInfo?.workflow || pngInfo?.Workflow + const prompt = pngInfo?.prompt || pngInfo?.Prompt + + return { + workflow: () => (workflow ? JSON.parse(workflow) : undefined), + prompt: () => (prompt ? JSON.parse(prompt) : undefined) + } +} + +/** + * Handler for SVG files + */ +const handleSvgFile: WorkflowFileHandler = async (file) => { + const svgInfo = await getSvgMetadata(file) + return { + workflow: () => svgInfo.workflow, + prompt: () => svgInfo.prompt + } +} + +/** + * Handler for MP3 files + */ +const handleMp3File: WorkflowFileHandler = async (file) => { + const { workflow, prompt } = await getMp3Metadata(file) + return { + workflow: () => workflow, + prompt: () => prompt + } +} + +/** + * Handler for OGG files + */ +const handleOggFile: WorkflowFileHandler = async (file) => { + const { workflow, prompt } = await getOggMetadata(file) + return { + workflow: () => workflow, + prompt: () => prompt + } +} + +/** + * Handler for FLAC files + */ +const handleFlacFile: WorkflowFileHandler = async (file) => { + const pngInfo = await getFlacMetadata(file) + const workflow = pngInfo?.workflow || pngInfo?.Workflow + const prompt = pngInfo?.prompt || pngInfo?.Prompt + + return { + workflow: () => (workflow ? JSON.parse(workflow) : undefined), + prompt: () => (prompt ? JSON.parse(prompt) : undefined) + } +} + +/** + * Handler for WebM files + */ +const handleWebmFile: WorkflowFileHandler = async (file) => { + const webmInfo = await getFromWebmFile(file) + return { + workflow: () => webmInfo.workflow, + prompt: () => webmInfo.prompt + } +} + +/** + * Handler for MP4/MOV/M4V files + */ +const handleMp4File: WorkflowFileHandler = async (file) => { + const mp4Info = await getFromIsobmffFile(file) + return { + workflow: () => mp4Info.workflow, + prompt: () => mp4Info.prompt + } +} + +/** + * Handler for GLB files + */ +const handleGlbFile: WorkflowFileHandler = async (file) => { + const gltfInfo = await getGltfBinaryMetadata(file) + return { + workflow: () => gltfInfo.workflow, + prompt: () => gltfInfo.prompt + } +} + +/** + * Handler for JSON files + */ +const handleJsonFile: WorkflowFileHandler = async (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + try { + const readerResult = reader.result as string + resolve({ + workflow: () => JSON.parse(readerResult), + prompt: () => JSON.parse(readerResult), + jsonTemplateData: () => JSON.parse(readerResult) + }) + } catch (error) { + reject(error) + } + } + reader.onerror = () => reject(reader.error) + reader.readAsText(file) + }) +} + +/** + * Handler for .latent and .safetensors files + */ +const handleLatentFile: WorkflowFileHandler = async (file) => { + const info = await getLatentMetadata(file) + + return { + workflow: () => { + if ( + info && + typeof info === 'object' && + 'workflow' in info && + info.workflow + ) { + return JSON.parse(info.workflow as string) + } + return undefined + }, + prompt: () => { + if (info && typeof info === 'object' && 'prompt' in info && info.prompt) { + return JSON.parse(info.prompt as string) + } + return undefined + } + } +} + +/** + * Helper function to determine if a JSON object is in the API JSON format + */ +export function isApiJson(data: unknown) { + return ( + typeof data === 'object' && + data !== null && + Object.keys(data).length > 0 && + Object.values(data as Record).every((v) => v.class_type) + ) +} + +// Register image format handlers +IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + if (mimeType === 'image/png') { + mimeTypeHandlers.set(mimeType, handlePngFile) + } else if (mimeType === 'image/webp') { + mimeTypeHandlers.set(mimeType, handleWebpFile) + } else if (mimeType === 'image/svg+xml') { + mimeTypeHandlers.set(mimeType, handleSvgFile) + } +}) + +IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => { + if (ext === '.png') { + extensionHandlers.set(ext, handlePngFile) + } else if (ext === '.webp') { + extensionHandlers.set(ext, handleWebpFile) + } else if (ext === '.svg') { + extensionHandlers.set(ext, handleSvgFile) + } +}) + +// Register audio format handlers +AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + if (mimeType === 'audio/mpeg') { + mimeTypeHandlers.set(mimeType, handleMp3File) + } else if (mimeType === 'audio/ogg') { + mimeTypeHandlers.set(mimeType, handleOggFile) + } else if (mimeType === 'audio/flac' || mimeType === 'audio/x-flac') { + mimeTypeHandlers.set(mimeType, handleFlacFile) + } +}) + +AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => { + if (ext === '.mp3') { + extensionHandlers.set(ext, handleMp3File) + } else if (ext === '.ogg') { + extensionHandlers.set(ext, handleOggFile) + } else if (ext === '.flac') { + extensionHandlers.set(ext, handleFlacFile) + } +}) + +// Register video format handlers +VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + if (mimeType === 'video/webm') { + mimeTypeHandlers.set(mimeType, handleWebmFile) + } else if ( + mimeType === 'video/mp4' || + mimeType === 'video/quicktime' || + mimeType === 'video/x-m4v' + ) { + mimeTypeHandlers.set(mimeType, handleMp4File) + } +}) + +VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => { + if (ext === '.webm') { + extensionHandlers.set(ext, handleWebmFile) + } else if (ext === '.mp4' || ext === '.mov' || ext === '.m4v') { + extensionHandlers.set(ext, handleMp4File) + } +}) + +// Register 3D model format handlers +MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + if (mimeType === 'model/gltf-binary') { + mimeTypeHandlers.set(mimeType, handleGlbFile) + } +}) + +MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => { + if (ext === '.glb') { + extensionHandlers.set(ext, handleGlbFile) + } +}) + +// Register data format handlers +DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + if (mimeType === 'application/json') { + mimeTypeHandlers.set(mimeType, handleJsonFile) + } +}) + +DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => { + if (ext === '.json') { + extensionHandlers.set(ext, handleJsonFile) + } else if (ext === '.latent' || ext === '.safetensors') { + extensionHandlers.set(ext, handleLatentFile) + } +}) + +/** + * Gets the appropriate file handler for a given file based on mime type or extension + */ +export function getFileHandler(file: File): WorkflowFileHandler | null { + // First try to match by MIME type + if (file.type && mimeTypeHandlers.has(file.type)) { + return mimeTypeHandlers.get(file.type) || null + } + + // If no MIME type match, try to match by file extension + if (file.name) { + const extension = '.' + file.name.split('.').pop()?.toLowerCase() + if (extension && extensionHandlers.has(extension)) { + return extensionHandlers.get(extension) || null + } + } + + return null +} diff --git a/tests-ui/tests/utils/fileHandlers.test.ts b/tests-ui/tests/utils/fileHandlers.test.ts new file mode 100644 index 000000000..56a9f0bb3 --- /dev/null +++ b/tests-ui/tests/utils/fileHandlers.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + AUDIO_WORKFLOW_FORMATS, + DATA_WORKFLOW_FORMATS, + IMAGE_WORKFLOW_FORMATS, + MODEL_WORKFLOW_FORMATS, + VIDEO_WORKFLOW_FORMATS +} from '../../../src/constants/supportedWorkflowFormats' +import { + extensionHandlers, + getFileHandler, + isApiJson, + mimeTypeHandlers +} from '../../../src/utils/fileHandlers' + +// Mock the metadata functions +vi.mock('../../../src/scripts/pnginfo', () => ({ + getPngMetadata: vi.fn().mockResolvedValue({ + workflow: '{"test": "workflow"}', + prompt: '{"test": "prompt"}' + }), + getWebpMetadata: vi + .fn() + .mockResolvedValue({ workflow: '{"test": "workflow"}' }), + getFlacMetadata: vi + .fn() + .mockResolvedValue({ workflow: '{"test": "workflow"}' }), + getLatentMetadata: vi + .fn() + .mockResolvedValue({ workflow: '{"test": "workflow"}' }) +})) + +vi.mock('../../../src/scripts/metadata/svg', () => ({ + getSvgMetadata: vi.fn().mockResolvedValue({ workflow: { test: 'workflow' } }) +})) + +vi.mock('../../../src/scripts/metadata/mp3', () => ({ + getMp3Metadata: vi.fn().mockResolvedValue({ workflow: { test: 'workflow' } }) +})) + +vi.mock('../../../src/scripts/metadata/ogg', () => ({ + getOggMetadata: vi.fn().mockResolvedValue({ workflow: { test: 'workflow' } }) +})) + +vi.mock('../../../src/scripts/metadata/ebml', () => ({ + getFromWebmFile: vi.fn().mockResolvedValue({ workflow: { test: 'workflow' } }) +})) + +vi.mock('../../../src/scripts/metadata/isobmff', () => ({ + getFromIsobmffFile: vi + .fn() + .mockResolvedValue({ workflow: { test: 'workflow' } }) +})) + +vi.mock('../../../src/scripts/metadata/gltf', () => ({ + getGltfBinaryMetadata: vi + .fn() + .mockResolvedValue({ workflow: { test: 'workflow' } }) +})) + +describe('fileHandlers', () => { + describe('handler registrations', () => { + it('should register handlers for all image MIME types', () => { + IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + expect(mimeTypeHandlers.has(mimeType)).toBe(true) + expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all image extensions', () => { + IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => { + expect(extensionHandlers.has(ext)).toBe(true) + expect(extensionHandlers.get(ext)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all audio MIME types', () => { + AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + expect(mimeTypeHandlers.has(mimeType)).toBe(true) + expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all audio extensions', () => { + AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => { + expect(extensionHandlers.has(ext)).toBe(true) + expect(extensionHandlers.get(ext)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all video MIME types', () => { + VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + expect(mimeTypeHandlers.has(mimeType)).toBe(true) + expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all video extensions', () => { + VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => { + expect(extensionHandlers.has(ext)).toBe(true) + expect(extensionHandlers.get(ext)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all model MIME types', () => { + MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + expect(mimeTypeHandlers.has(mimeType)).toBe(true) + expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all model extensions', () => { + MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => { + expect(extensionHandlers.has(ext)).toBe(true) + expect(extensionHandlers.get(ext)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all data MIME types', () => { + DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => { + expect(mimeTypeHandlers.has(mimeType)).toBe(true) + expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function') + }) + }) + + it('should register handlers for all data extensions', () => { + DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => { + expect(extensionHandlers.has(ext)).toBe(true) + expect(extensionHandlers.get(ext)).toBeTypeOf('function') + }) + }) + }) + + describe('getFileHandler', () => { + it('should return handler based on MIME type', () => { + const file = new File([''], 'test.png', { type: 'image/png' }) + const handler = getFileHandler(file) + expect(handler).toBeTruthy() + expect(handler).toBeTypeOf('function') + }) + + it('should return handler based on file extension when MIME type is not available', () => { + const file = new File([''], 'test.png', { type: '' }) + const handler = getFileHandler(file) + expect(handler).toBeTruthy() + expect(handler).toBeTypeOf('function') + }) + + it('should return null for unsupported file types', () => { + const file = new File([''], 'test.xyz', { type: 'application/unknown' }) + const handler = getFileHandler(file) + expect(handler).toBeNull() + }) + + it('should prioritize MIME type over file extension', () => { + const file = new File([''], 'test.txt', { type: 'image/png' }) + const handler = getFileHandler(file) + expect(handler).toBe(mimeTypeHandlers.get('image/png')) + }) + }) + + describe('isApiJson', () => { + it('should return true for valid API JSON', () => { + const apiJson = { + '1': { class_type: 'Node1', inputs: {} }, + '2': { class_type: 'Node2', inputs: {} } + } + expect(isApiJson(apiJson)).toBe(true) + }) + + it('should return false for non-API JSON', () => { + expect(isApiJson({})).toBe(false) + expect(isApiJson({ nodes: [] })).toBe(false) + expect(isApiJson(null)).toBe(false) + expect(isApiJson('string')).toBe(false) + }) + }) + + describe('PNG handler with NaN values', () => { + it('should return lazy functions that parse on demand', async () => { + const { getPngMetadata } = await import('../../../src/scripts/pnginfo') + vi.mocked(getPngMetadata).mockResolvedValueOnce({ + workflow: '{"valid": "json"}', + prompt: '{"invalid": NaN}' + }) + + const file = new File([''], 'test.png', { type: 'image/png' }) + const handler = getFileHandler(file) + const result = await handler!(file) + + expect(result.workflow).toBeTypeOf('function') + expect(result.prompt).toBeTypeOf('function') + + // workflow should parse successfully + expect(result.workflow!()).toEqual({ valid: 'json' }) + + // prompt should throw on parse due to NaN + expect(() => result.prompt!()).toThrow() + }) + + it('should handle valid workflow with invalid prompt JSON', async () => { + const { getPngMetadata } = await import('../../../src/scripts/pnginfo') + vi.mocked(getPngMetadata).mockResolvedValueOnce({ + workflow: '{"nodes": [{"id": 1, "type": "TestNode"}]}', + prompt: + '{"1": {"inputs": {"seed": 123}, "class_type": "TestNode"}, "error": NaN}' + }) + + const file = new File([''], 'test.png', { type: 'image/png' }) + const handler = getFileHandler(file) + const result = await handler!(file) + + // workflow should parse successfully + const workflowData = result.workflow!() + expect(workflowData).toEqual({ + nodes: [{ id: 1, type: 'TestNode' }] + }) + + // prompt should throw due to invalid JSON + expect(() => result.prompt!()).toThrow('Unexpected token') + }) + + it('should handle both fields containing NaN', async () => { + const { getPngMetadata } = await import('../../../src/scripts/pnginfo') + vi.mocked(getPngMetadata).mockResolvedValueOnce({ + workflow: '{"value": NaN}', + prompt: '{"error": NaN}' + }) + + const file = new File([''], 'test.png', { type: 'image/png' }) + const handler = getFileHandler(file) + const result = await handler!(file) + + // Both should throw when called + expect(() => result.workflow!()).toThrow() + expect(() => result.prompt!()).toThrow() + }) + + it('should handle missing metadata gracefully', async () => { + const { getPngMetadata } = await import('../../../src/scripts/pnginfo') + vi.mocked(getPngMetadata).mockResolvedValueOnce({}) + + const file = new File([''], 'test.png', { type: 'image/png' }) + const handler = getFileHandler(file) + const result = await handler!(file) + + expect(result.workflow!()).toBeUndefined() + expect(result.prompt!()).toBeUndefined() + }) + + it('should handle undefined metadata fields', async () => { + const { getPngMetadata } = await import('../../../src/scripts/pnginfo') + vi.mocked(getPngMetadata).mockResolvedValueOnce({ + workflow: undefined as any, + prompt: undefined as any, + parameters: 'some parameters' + }) + + const file = new File([''], 'test.png', { type: 'image/png' }) + const handler = getFileHandler(file) + const result = await handler!(file) + + expect(result.workflow!()).toBeUndefined() + expect(result.prompt!()).toBeUndefined() + expect(result.parameters).toBe('some parameters') + }) + }) + + describe('WebP handler variations', () => { + it('should handle case-sensitive workflow field names', async () => { + const { getWebpMetadata } = await import('../../../src/scripts/pnginfo') + vi.mocked(getWebpMetadata).mockResolvedValueOnce({ + Workflow: '{"uppercase": true}', + Prompt: '{"uppercase": true}' + }) + + const file = new File([''], 'test.webp', { type: 'image/webp' }) + const handler = getFileHandler(file) + const result = await handler!(file) + + expect(result.workflow!()).toEqual({ uppercase: true }) + expect(result.prompt!()).toEqual({ uppercase: true }) + }) + }) + + describe('JSON handler edge cases', () => { + it('should handle empty JSON file', async () => { + const file = new File(['{}'], 'empty.json', { type: 'application/json' }) + const handler = getFileHandler(file) + const result = await handler!(file) + + expect(result.jsonTemplateData!()).toEqual({}) + }) + + it('should handle malformed JSON', async () => { + const file = new File(['{invalid json}'], 'bad.json', { + type: 'application/json' + }) + const handler = getFileHandler(file) + const result = await handler!(file) + + // Should throw when calling the lazy functions + expect(() => result.jsonTemplateData!()).toThrow() + expect(() => result.workflow!()).toThrow() + expect(() => result.prompt!()).toThrow() + }) + }) + + describe('Lazy evaluation behavior', () => { + it('should not parse until functions are called', async () => { + const { getPngMetadata } = await import('../../../src/scripts/pnginfo') + const originalParse = JSON.parse + let parseCallCount = 0 + JSON.parse = vi.fn((text: string) => { + parseCallCount++ + return originalParse(text) + }) + + vi.mocked(getPngMetadata).mockResolvedValueOnce({ + workflow: '{"test": "data"}', + prompt: '{"test": "prompt"}' + }) + + const file = new File([''], 'test.png', { type: 'image/png' }) + const handler = getFileHandler(file) + const result = await handler!(file) + + // JSON.parse should not have been called yet + expect(parseCallCount).toBe(0) + + // Call workflow function + result.workflow!() + expect(parseCallCount).toBe(1) + + // Call prompt function + result.prompt!() + expect(parseCallCount).toBe(2) + + // Restore original JSON.parse + JSON.parse = originalParse + }) + }) + + describe('File extension fallback', () => { + it('should use file extension when MIME type is empty', () => { + const file = new File([''], 'test.png', { type: '' }) + const handler = getFileHandler(file) + expect(handler).toBe(extensionHandlers.get('.png')) + }) + + it('should use file extension when MIME type is application/octet-stream', () => { + const file = new File([''], 'test.mp3', { + type: 'application/octet-stream' + }) + const handler = getFileHandler(file) + expect(handler).toBe(extensionHandlers.get('.mp3')) + }) + }) +})