Files
ComfyUI_frontend/src/scripts/metadata/avif.test.ts
pythongosssss ccd19d8695 test: add metadata parser coverage (#11307)
## Summary
Adds tests for metadata parsers

## Changes

- **What**: 
- add test file generation script
- identified & fixed bug in webp exif parsing over-reading
- identified & fix bug in mp3/ogg parser where it would read from a
fixed position instead of relative, causing incorrect reads throwing
RangeError
- added catch in latent + json parsing to resolve errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11307-test-add-metadata-parser-coverage-3446d73d36508108ac36dddcec0a54d4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-02 13:18:45 -07:00

401 lines
11 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
} from './__fixtures__/helpers'
import { getFromAvifFile } from './avif'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.avif')
afterEach(() => vi.restoreAllMocks())
describe('AVIF metadata', () => {
it('extracts workflow and prompt from EXIF data in ISOBMFF boxes', async () => {
const bytes = fs.readFileSync(fixturePath)
const file = new File([bytes], 'test.avif', { type: 'image/avif' })
const result = await getFromAvifFile(file)
expect(JSON.parse(result.workflow)).toEqual(EXPECTED_WORKFLOW)
expect(JSON.parse(result.prompt)).toEqual(EXPECTED_PROMPT)
})
it('returns empty for non-AVIF data', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const file = new File([new Uint8Array(16)], 'fake.avif')
const result = await getFromAvifFile(file)
expect(result).toEqual({})
expect(console.error).toHaveBeenCalledWith('Not a valid AVIF file')
})
it('returns empty when AVIF has valid ftyp but corrupt internal boxes', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const buf = new Uint8Array(40)
const dv = new DataView(buf.buffer)
dv.setUint32(0, 16)
buf.set(new TextEncoder().encode('ftypavif'), 4)
dv.setUint32(16, 24)
buf.set(new TextEncoder().encode('meta'), 20)
const file = new File([buf], 'corrupt.avif', { type: 'image/avif' })
const result = await getFromAvifFile(file)
expect(result).toEqual({})
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('Error parsing AVIF metadata'),
expect.anything()
)
})
describe('FileReader failure modes', () => {
const file = new File([new Uint8Array(16)], 'test.avif')
it('resolves empty when the FileReader fires error', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockFileReaderError('readAsArrayBuffer')
expect(await getFromAvifFile(file)).toEqual({})
})
it('resolves empty when the FileReader fires abort', async () => {
mockFileReaderAbort('readAsArrayBuffer')
expect(await getFromAvifFile(file)).toEqual({})
})
})
})
const setU32BE = (dv: DataView, off: number, val: number) =>
dv.setUint32(off, val, false)
const setU16BE = (dv: DataView, off: number, val: number) =>
dv.setUint16(off, val, false)
const buildExifBlob = (
asciiEntries: string[],
endian: 'II' | 'MM' = 'II'
): Uint8Array => {
const isLE = endian === 'II'
const headerSize = 8
const ifdSize = 2 + asciiEntries.length * 12 + 4
const entryDataSizes = asciiEntries.map((s) => s.length + 1)
const entryDataTotal = entryDataSizes.reduce((a, b) => a + b, 0)
const buf = new Uint8Array(headerSize + ifdSize + entryDataTotal)
const dv = new DataView(buf.buffer)
buf[0] = endian === 'II' ? 0x49 : 0x4d
buf[1] = buf[0]
dv.setUint16(2, 0x002a, isLE)
dv.setUint32(4, 8, isLE)
let p = 8
dv.setUint16(p, asciiEntries.length, isLE)
p += 2
let dataOffset = headerSize + ifdSize
for (let i = 0; i < asciiEntries.length; i++) {
const dataLen = entryDataSizes[i]
const tag = 0x9286 + i
dv.setUint16(p, tag, isLE)
p += 2
dv.setUint16(p, 2, isLE)
p += 2
dv.setUint32(p, dataLen, isLE)
p += 4
dv.setUint32(p, dataOffset, isLE)
p += 4
const enc = new TextEncoder().encode(asciiEntries[i])
buf.set(enc, dataOffset)
buf[dataOffset + enc.length] = 0
dataOffset += dataLen
}
dv.setUint32(p, 0, isLE)
return buf
}
const buildInfeBox = (
itemId: number,
itemType: string,
version = 2
): Uint8Array => {
const bodySize = 4 + 2 + 2 + 4 + 1 + 1
const totalSize = 8 + bodySize
const buf = new Uint8Array(totalSize)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, totalSize)
buf.set(new TextEncoder().encode('infe'), 4)
buf[8] = version
if (version >= 2) {
setU16BE(dv, 12, itemId)
setU16BE(dv, 14, 0)
buf.set(new TextEncoder().encode(itemType.padEnd(4).slice(0, 4)), 16)
}
return buf
}
const buildIinfBox = (infeBoxes: Uint8Array[]): Uint8Array => {
const bodySize = 4 + 2 + infeBoxes.reduce((s, b) => s + b.length, 0)
const totalSize = 8 + bodySize
const buf = new Uint8Array(totalSize)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, totalSize)
buf.set(new TextEncoder().encode('iinf'), 4)
setU16BE(dv, 12, infeBoxes.length)
let off = 14
for (const ib of infeBoxes) {
buf.set(ib, off)
off += ib.length
}
return buf
}
const buildIlocBox = (
items: { itemId: number; extentOffset: number; extentLength: number }[]
): Uint8Array => {
const perItemSize = 2 + 2 + 0 + 2 + (4 + 4)
const bodySize = 4 + 1 + 1 + 2 + items.length * perItemSize
const totalSize = 8 + bodySize
const buf = new Uint8Array(totalSize)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, totalSize)
buf.set(new TextEncoder().encode('iloc'), 4)
buf[12] = 0x44
buf[13] = 0x00
setU16BE(dv, 14, items.length)
let p = 16
for (const it of items) {
setU16BE(dv, p, it.itemId)
p += 2
setU16BE(dv, p, 0)
p += 2
setU16BE(dv, p, 1)
p += 2
setU32BE(dv, p, it.extentOffset)
p += 4
setU32BE(dv, p, it.extentLength)
p += 4
}
return buf
}
const buildMetaBox = (boxes: Uint8Array[]): Uint8Array => {
const bodySize = 4 + boxes.reduce((s, b) => s + b.length, 0)
const totalSize = 8 + bodySize
const buf = new Uint8Array(totalSize)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, totalSize)
buf.set(new TextEncoder().encode('meta'), 4)
let p = 12
for (const b of boxes) {
buf.set(b, p)
p += b.length
}
return buf
}
const buildFtypBox = (majorBrand = 'avif'): Uint8Array => {
const buf = new Uint8Array(16)
const dv = new DataView(buf.buffer)
setU32BE(dv, 0, 16)
buf.set(new TextEncoder().encode('ftyp'), 4)
buf.set(new TextEncoder().encode(majorBrand.padEnd(4).slice(0, 4)), 8)
setU32BE(dv, 12, 0)
return buf
}
interface BuildAvifOpts {
exifEntries?: string[]
endian?: 'II' | 'MM'
itemType?: string
ftypBrand?: string
omitMeta?: boolean
omitIloc?: boolean
infeVersion?: number
}
const buildAvifFile = (opts: BuildAvifOpts = {}): ArrayBuffer => {
const {
exifEntries = [],
endian = 'II',
itemType = 'Exif',
ftypBrand = 'avif',
omitMeta = false,
omitIloc = false,
infeVersion = 2
} = opts
const ftyp = buildFtypBox(ftypBrand)
if (omitMeta) {
return ftyp.slice().buffer as ArrayBuffer
}
const exifData = buildExifBlob(exifEntries, endian)
const infe = buildInfeBox(1, itemType, infeVersion)
const iinf = buildIinfBox([infe])
const realIloc = buildIlocBox([
{ itemId: 1, extentOffset: 0, extentLength: exifData.length }
])
const metaSize = 8 + 4 + iinf.length + (omitIloc ? 0 : realIloc.length)
const exifOffset = ftyp.length + metaSize
const finalIloc = buildIlocBox([
{ itemId: 1, extentOffset: exifOffset, extentLength: exifData.length }
])
const finalInner = omitIloc ? [iinf] : [iinf, finalIloc]
const meta = buildMetaBox(finalInner)
const total = ftyp.length + meta.length + exifData.length
const buf = new Uint8Array(total)
let p = 0
buf.set(ftyp, p)
p += ftyp.length
buf.set(meta, p)
p += meta.length
buf.set(exifData, p)
return buf.slice().buffer as ArrayBuffer
}
const fileFromBuffer = (buffer: ArrayBuffer, name = 'test.avif'): File =>
new File([buffer], name, { type: 'image/avif' })
describe('getFromAvifFile', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
vi.spyOn(console, 'log').mockImplementation(() => undefined)
})
it('extracts workflow JSON from EXIF when AVIF has an Exif item', async () => {
const workflow = '{"nodes":[],"version":1}'
const file = fileFromBuffer(
buildAvifFile({ exifEntries: [`workflow:${workflow}`] })
)
const result = await getFromAvifFile(file)
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
})
it('extracts prompt JSON from EXIF', async () => {
const prompt = '{"1":{"class_type":"KSampler"}}'
const file = fileFromBuffer(
buildAvifFile({ exifEntries: [`prompt:${prompt}`] })
)
const result = await getFromAvifFile(file)
expect(result.prompt).toBe(JSON.stringify(JSON.parse(prompt)))
})
it('parses big-endian (MM) EXIF data', async () => {
const workflow = '{"endian":"big"}'
const file = fileFromBuffer(
buildAvifFile({ exifEntries: [`workflow:${workflow}`], endian: 'MM' })
)
const result = await getFromAvifFile(file)
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
})
it('returns {} when AVIF major brand is not "avif"', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['workflow:{}'], ftypBrand: 'heic' })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when meta box is missing', async () => {
const file = fileFromBuffer(buildAvifFile({ omitMeta: true }))
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when iinf has no Exif item', async () => {
const file = fileFromBuffer(
buildAvifFile({
exifEntries: ['workflow:{}'],
itemType: 'mime'
})
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when EXIF entry uses an unrecognized key', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['random:thing'] })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when EXIF entry has malformed JSON', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['workflow:{notjson'] })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} (and does not throw) when infe version is unsupported', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['workflow:{}'], infeVersion: 1 })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when iloc box is missing while iinf has an Exif item', async () => {
const file = fileFromBuffer(
buildAvifFile({ exifEntries: ['workflow:{}'], omitIloc: true })
)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('returns {} when buffer is too short to contain a valid header', async () => {
const file = fileFromBuffer(new Uint8Array(4).buffer)
const result = await getFromAvifFile(file)
expect(result).toEqual({})
})
it('extracts both prompt and workflow when present in separate EXIF entries', async () => {
const prompt = '{"node":1}'
const workflow = '{"nodes":[1]}'
const file = fileFromBuffer(
buildAvifFile({
exifEntries: [`prompt:${prompt}`, `workflow:${workflow}`]
})
)
const result = await getFromAvifFile(file)
expect(result.prompt).toBe(JSON.stringify(JSON.parse(prompt)))
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
})
})