diff --git a/browser_tests/assets/workflowInMedia/workflow.ogg b/browser_tests/assets/workflowInMedia/workflow.ogg new file mode 100644 index 00000000000..c3226f6be64 Binary files /dev/null and b/browser_tests/assets/workflowInMedia/workflow.ogg differ diff --git a/browser_tests/fixtures/utils/mimeTypeUtil.ts b/browser_tests/fixtures/utils/mimeTypeUtil.ts index 8ec0b1a8e16..82ea30b1f01 100644 --- a/browser_tests/fixtures/utils/mimeTypeUtil.ts +++ b/browser_tests/fixtures/utils/mimeTypeUtil.ts @@ -12,5 +12,6 @@ export function getMimeType(fileName: string): string { if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg' if (name.endsWith('.json')) return 'application/json' if (name.endsWith('.glb')) return 'model/gltf-binary' + if (name.endsWith('.ogg')) return 'audio/ogg' return 'application/octet-stream' } diff --git a/browser_tests/tests/loadWorkflowInMedia.spec.ts b/browser_tests/tests/loadWorkflowInMedia.spec.ts index 49df32f0ced..350cc578ad3 100644 --- a/browser_tests/tests/loadWorkflowInMedia.spec.ts +++ b/browser_tests/tests/loadWorkflowInMedia.spec.ts @@ -24,7 +24,8 @@ test.describe( 'workflow.mp4', 'workflow.mov', 'workflow.m4v', - 'workflow.svg' + 'workflow.svg', + 'workflow.ogg' // TODO: Re-enable after fixing test asset to use core nodes only // Currently opens missing nodes dialog which is outside scope of AVIF loading functionality // 'workflow.avif' diff --git a/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-ogg-chromium-linux.png b/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-ogg-chromium-linux.png new file mode 100644 index 00000000000..6a4e71db813 Binary files /dev/null and b/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-ogg-chromium-linux.png differ diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts index 19cb7046201..5d5ffd40dc1 100644 --- a/src/scripts/metadata/ogg.test.ts +++ b/src/scripts/metadata/ogg.test.ts @@ -5,9 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { EXPECTED_PROMPT, EXPECTED_PROMPT_NAN_COERCED, - EXPECTED_WORKFLOW, - mockFileReaderAbort, - mockFileReaderError + EXPECTED_WORKFLOW } from './__fixtures__/helpers' import { getOggMetadata } from './ogg' @@ -17,10 +15,146 @@ const nanFixturePath = path.resolve( '__fixtures__/with_nan_metadata.opus' ) +const OGG_HEADER_SIZE = 27 +const OGG_MAX_SEGMENT_SIZE = 255 + +function createOggWithOpusTags(comments: { + [key: string]: string +}): ArrayBuffer { + const vendor = 'ComfyUI' + const vendorBytes = new TextEncoder().encode(vendor) + const commentKeys = Object.keys(comments) + + // Construct OpusTags Packet + let totalPacketSize = 8 + 4 + vendorBytes.length + 4 + const commentEntries: Uint8Array[] = [] + for (const key of commentKeys) { + const entry = `${key}=${comments[key]}` + const entryBytes = new TextEncoder().encode(entry) + commentEntries.push(entryBytes) + totalPacketSize += 4 + entryBytes.length + } + + const packetData = new Uint8Array(totalPacketSize) + const packetView = new DataView(packetData.buffer) + let pos = 0 + packetData.set(new TextEncoder().encode('OpusTags'), pos) + pos += 8 + packetView.setUint32(pos, vendorBytes.length, true) + pos += 4 + packetData.set(vendorBytes, pos) + pos += vendorBytes.length + packetView.setUint32(pos, commentKeys.length, true) + pos += 4 + for (const entryBytes of commentEntries) { + packetView.setUint32(pos, entryBytes.length, true) + pos += 4 + packetData.set(entryBytes, pos) + pos += entryBytes.length + } + + // Construct Ogg Pages for Metadata + const pages: Uint8Array[] = [] + + // Prepend minimal OpusHead as Page 0 (BOS) + const headPageSize = OGG_HEADER_SIZE + 1 + 19 + const headPage = new Uint8Array(headPageSize) + headPage.set(new TextEncoder().encode('OggS'), 0) + headPage[5] = 0x02 // 0x02 indicates Beginning of Stream (BOS) + headPage[26] = 1 // 1 segment + headPage[OGG_HEADER_SIZE] = 19 // segment length + // 'OpusHead' signature (8 bytes) + version (1) + channels (1) + pre-skip (2) + sample rate (4) + gain (2) + mapping (1) + headPage.set(new TextEncoder().encode('OpusHead'), OGG_HEADER_SIZE + 1) + headPage[OGG_HEADER_SIZE + 1 + 8] = 1 // version + headPage[OGG_HEADER_SIZE + 1 + 9] = 2 // channels + pages.push(headPage) + + let packetPos = 0 + let packetEnded = false + + while (!packetEnded) { + const remainingPacketSize = packetData.length - packetPos + let dataSizeInThisPage = 0 + const lacingValues: number[] = [] + + for (let i = 0; i < OGG_MAX_SEGMENT_SIZE; i++) { + const remainingForThisPacket = remainingPacketSize - dataSizeInThisPage + if (remainingForThisPacket >= OGG_MAX_SEGMENT_SIZE) { + lacingValues.push(OGG_MAX_SEGMENT_SIZE) + dataSizeInThisPage += OGG_MAX_SEGMENT_SIZE + } else { + lacingValues.push(remainingForThisPacket) + dataSizeInThisPage += remainingForThisPacket + packetEnded = true + break + } + } + + const numSegments = lacingValues.length + const pageSize = OGG_HEADER_SIZE + numSegments + dataSizeInThisPage + const page = new Uint8Array(pageSize) + + page.set(new TextEncoder().encode('OggS'), 0) + page[5] = packetPos > 0 ? 0x01 : 0x00 // 0x01 indicates continued packet + page[26] = numSegments + + page.set(lacingValues, OGG_HEADER_SIZE) + + if (dataSizeInThisPage > 0) { + page.set( + packetData.subarray(packetPos, packetPos + dataSizeInThisPage), + OGG_HEADER_SIZE + numSegments + ) + } + + pages.push(page) + packetPos += dataSizeInThisPage + } + + // Append Dummy Audio Ogg Pages + let currentTotalSize = pages.reduce((sum, p) => sum + p.length, 0) + const TARGET_SIZE = 65536 // 64KB: ensures the file spans multiple Ogg pages for multi-page parsing tests + + while (currentTotalSize < TARGET_SIZE) { + // Create a dummy audio page (approx 65KB per page) + const numSegments = OGG_MAX_SEGMENT_SIZE + const segmentDataSize = OGG_MAX_SEGMENT_SIZE + const dataSize = numSegments * segmentDataSize + const pageSize = OGG_HEADER_SIZE + numSegments + dataSize + const audioPage = new Uint8Array(pageSize) + + // Ogg Header for dummy audio + audioPage.set(new TextEncoder().encode('OggS'), 0) + audioPage[5] = 0x00 // Normal page + audioPage[26] = numSegments + + // Segment Table (all max size) + audioPage.fill( + segmentDataSize, + OGG_HEADER_SIZE, + OGG_HEADER_SIZE + numSegments + ) + + // The rest of the array remains 0x00, acting as valid empty dummy audio data + pages.push(audioPage) + currentTotalSize += audioPage.length + } + + // Combine all pages into one Buffer + const combined = new Uint8Array(currentTotalSize) + let offset = 0 + for (const p of pages) { + combined.set(p, offset) + offset += p.length + } + + return combined.buffer +} + afterEach(() => vi.restoreAllMocks()) -describe('OGG/Opus metadata', () => { - it('extracts workflow and prompt from an Opus file', async () => { +describe('getOggMetadata', () => { + it('extracts workflow and prompt from a real Opus file fixture', async () => { const bytes = fs.readFileSync(fixturePath) const file = new File([bytes], 'test.opus', { type: 'audio/ogg' }) @@ -40,44 +174,100 @@ describe('OGG/Opus metadata', () => { expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED) }) - it('returns undefined fields for non-OGG data', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const file = new File([new Uint8Array(16)], 'fake.ogg', { + it('resolves undefined fields when the file reading fails', async () => { + const file = new File([new Uint8Array(16)], 'test.ogg', { type: 'audio/ogg' }) + vi.spyOn(file, 'slice').mockImplementation(() => { + return { + arrayBuffer: () => Promise.reject(new Error('File read aborted')) + } as unknown as Blob + }) + const warnSpy = vi.spyOn(console, 'warn') const result = await getOggMetadata(file) - expect(result.workflow).toBeUndefined() - expect(result.prompt).toBeUndefined() - expect(console.error).toHaveBeenCalledWith('Invalid file signature.') + expect(result).toEqual({ prompt: undefined, workflow: undefined }) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Ogg metadata file read failed:'), + expect.any(Error) + ) }) - it('handles files larger than 4096 bytes without RangeError', async () => { - const size = 5000 - const buf = new Uint8Array(size) - const oggs = new TextEncoder().encode('OggS\0') - buf.set(oggs, 0) - buf.set(oggs, 4500) - const file = new File([buf], 'large.ogg', { type: 'audio/ogg' }) + it('resolves undefined when a very large OpusTags packet is truncated at the 2 MB read cap', async () => { + // Build a minimal valid Ogg file whose OpusTags packet data extends well past 2 MB. + // We fake this by making file.slice() return a buffer whose OpusTags segment is cut off + // mid-packet (i.e. the last segment has length 255, signalling an open / incomplete packet). + const headPageSize = OGG_HEADER_SIZE + 1 + 19 + const headPage = new Uint8Array(headPageSize) + headPage.set(new TextEncoder().encode('OggS'), 0) + headPage[5] = 0x02 + headPage[26] = 1 + headPage[OGG_HEADER_SIZE] = 19 + headPage.set(new TextEncoder().encode('OpusHead'), OGG_HEADER_SIZE + 1) + + // Tags page: one segment of exactly OGG_MAX_SEGMENT_SIZE bytes (open packet — never terminates within the cap) + const truncatedTagsPage = new Uint8Array( + OGG_HEADER_SIZE + 1 + OGG_MAX_SEGMENT_SIZE + ) + truncatedTagsPage.set(new TextEncoder().encode('OggS'), 0) + truncatedTagsPage[26] = 1 // 1 segment + truncatedTagsPage[OGG_HEADER_SIZE] = OGG_MAX_SEGMENT_SIZE // segment length == OGG_MAX_SEGMENT_SIZE → packet continues + // Write 'OpusTags' magic at the start of the segment data so the parser recognises the packet + truncatedTagsPage.set( + new TextEncoder().encode('OpusTags'), + OGG_HEADER_SIZE + 1 + ) + + const truncated = new Uint8Array(headPage.length + truncatedTagsPage.length) + truncated.set(headPage, 0) + truncated.set(truncatedTagsPage, headPage.length) + + // Simulate file.slice() returning only the truncated 2 MB view + const file = new File([new Uint8Array(1)], 'huge.opus', { + type: 'audio/ogg' + }) + vi.spyOn(file, 'slice').mockReturnValue( + new Blob([truncated]) as unknown as ReturnType + ) + const warnSpy = vi.spyOn(console, 'warn') const result = await getOggMetadata(file) - expect(result.workflow).toBeUndefined() + // The packet was never closed, so no complete metadata could be extracted expect(result.prompt).toBeUndefined() + expect(result.workflow).toBeUndefined() + // A descriptive warning must be emitted so the truncation is not silent + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'OpusTags packet extends beyond the 2 MB read cap' + ) + ) + }) + + it('should extract prompt and workflow from a valid Ogg file', async () => { + const prompt = { '1': { class_type: 'KSampler' } } + const workflow = { nodes: [{ id: 1 }] } + + const buffer = createOggWithOpusTags({ + prompt: JSON.stringify(prompt), + workflow: JSON.stringify(workflow) + }) + + const file = new File([buffer], 'test.ogg', { type: 'audio/ogg' }) + const result = await getOggMetadata(file) + + expect(result.prompt).toEqual(prompt) + expect(result.workflow).toEqual(workflow) }) it('logs and skips when embedded JSON is malformed', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const metadata = `prompt={not json}\0workflow={also bad}\0` - const oggs = new TextEncoder().encode('OggS\0') - const buf = new Uint8Array(128) - buf.set(oggs, 0) - for (let i = 0; i < metadata.length; i++) { - buf[16 + i] = metadata.charCodeAt(i) - } - buf.set(oggs, 16 + metadata.length + 8) - const file = new File([buf], 'malformed.opus', { type: 'audio/ogg' }) + const buffer = createOggWithOpusTags({ + prompt: '{not json}', + workflow: '{also bad}' + }) + const file = new File([buffer], 'malformed.opus', { type: 'audio/ogg' }) const result = await getOggMetadata(file) @@ -93,23 +283,237 @@ describe('OGG/Opus metadata', () => { ) }) - describe('FileReader failure modes', () => { - const file = new File([new Uint8Array(16)], 'test.ogg') + it('should handle large metadata spanning multiple Ogg pages (over 64KB)', async () => { + // Generate a 70,000 character string (exceeds 255 * 255 = 65,025 bytes to ensure it spans multiple pages) + const hugeString = 'b'.repeat(70000) + const prompt = { data: hugeString } + const workflow = { nodes: [{ id: 'large-node', type: 'AnyType' }] } - it('resolves undefined fields when the FileReader fires error', async () => { - mockFileReaderError('readAsArrayBuffer') + const buffer = createOggWithOpusTags({ + prompt: JSON.stringify(prompt), + workflow: JSON.stringify(workflow) + }) - const result = await getOggMetadata(file) + // Verify: Check if the helper function actually generated a file with multiple pages (2 or more 'OggS' headers) + const view = new Uint8Array(buffer) + const oggMagic = new TextEncoder().encode('OggS') + let oggSCount = 0 + for (let i = 0; i < view.length - 4; i++) { + if ( + view[i] === oggMagic[0] && + view[i + 1] === oggMagic[1] && + view[i + 2] === oggMagic[2] && + view[i + 3] === oggMagic[3] + ) { + oggSCount++ + } + } + expect(oggSCount).toBeGreaterThan(1) - expect(result).toEqual({ prompt: undefined, workflow: undefined }) + // Execute and Assert: Verify that data spanning multiple pages is correctly reconstructed into a single JSON + const file = new File([buffer], 'huge_multi_page.ogg', { + type: 'audio/ogg' }) + const result = await getOggMetadata(file) - it('resolves undefined fields when the FileReader fires abort', async () => { - mockFileReaderAbort('readAsArrayBuffer') + expect(result.prompt).toEqual(prompt) + expect(result.workflow).toEqual(workflow) + }) - const result = await getOggMetadata(file) + describe('structural corruptions and truncations', () => { + // Generate truncated/invalid buffer scenarios + const scenarios: { name: string; createBuffer: () => ArrayBuffer }[] = [ + { + name: 'abruptly truncated segment table', + createBuffer: () => { + const buffer = new Uint8Array(28) + buffer.set(new TextEncoder().encode('OggS'), 0) + buffer[26] = 50 // Declaring 50 segments while only 28 bytes exist + return buffer.buffer + } + }, + { + name: 'segment data exceeds buffer boundary', + createBuffer: () => { + // Page header declares 1 segment of length 200, but only 10 bytes of data follow + const buffer = new Uint8Array(27 + 1 + 10) + buffer.set(new TextEncoder().encode('OggS'), 0) + buffer[26] = 1 // 1 segment + buffer[27] = 200 // segment length = 200 (exceeds remaining 10 bytes) + // Only 10 bytes of data follow; segment exceeds boundary + return buffer.buffer + } + }, + { + name: 'truncated user comment in OpusTags', + createBuffer: () => { + const packet = new Uint8Array(20) + packet.set(new TextEncoder().encode('OpusTags'), 0) + new DataView(packet.buffer).setUint32(8, 0, true) + new DataView(packet.buffer).setUint32(12, 1, true) + new DataView(packet.buffer).setUint32(16, 500, true) // Declared length 500 + const buffer = new Uint8Array(27 + 1 + packet.length) + buffer.set(new TextEncoder().encode('OggS'), 0) + buffer[26] = 1 + buffer[27] = packet.length + buffer.set(packet, 28) + return buffer.buffer + } + }, + { + name: 'file smaller than the Ogg magic number', + createBuffer: () => new Uint8Array([1, 2]).buffer + }, + { + name: 'page has an invalid magic number (stop parsing)', + createBuffer: () => { + const page1 = new Uint8Array(29) + page1.set(new TextEncoder().encode('OggS'), 0) + page1[26] = 1 + page1[27] = 1 + const page2 = new Uint8Array(30) + page2.set(new TextEncoder().encode('FAIL'), 0) + const combined = new Uint8Array(page1.length + page2.length) + combined.set(page1, 0) + combined.set(page2, page1.length) + return combined.buffer + } + }, + { + name: 'OpusTags packet truncated before vendorLength field', + createBuffer: () => { + // Packet is only 10 bytes: 'OpusTags'(8) + 2 extra bytes (not enough for 4-byte vendorLength) + const packet = new Uint8Array(10) + packet.set(new TextEncoder().encode('OpusTags'), 0) + const buffer = new Uint8Array(27 + 1 + packet.length) + buffer.set(new TextEncoder().encode('OggS'), 0) + buffer[26] = 1 + buffer[27] = packet.length + buffer.set(packet, 28) + return buffer.buffer + } + }, + { + name: 'OpusTags packet with oversized vendorLength', + createBuffer: () => { + // Packet: 'OpusTags'(8) + vendorLength=9999(4) + no actual vendor bytes + const packet = new Uint8Array(12) + packet.set(new TextEncoder().encode('OpusTags'), 0) + new DataView(packet.buffer).setUint32(8, 9999, true) // vendor string claims 9999 bytes + const buffer = new Uint8Array(27 + 1 + packet.length) + buffer.set(new TextEncoder().encode('OggS'), 0) + buffer[26] = 1 + buffer[27] = packet.length + buffer.set(packet, 28) + return buffer.buffer + } + }, + { + name: 'OpusTags packet truncated before userCommentListLength', + createBuffer: () => { + // Packet: 'OpusTags'(8) + vendorLength=0(4) + no userCommentListLength + const packet = new Uint8Array(12) // exactly 8 + 4, no bytes for userCommentListLength + packet.set(new TextEncoder().encode('OpusTags'), 0) + new DataView(packet.buffer).setUint32(8, 0, true) // vendorLength=0 + // packet ends here — no room for userCommentListLength (needs 4 more bytes) + const buffer = new Uint8Array(27 + 1 + packet.length) + buffer.set(new TextEncoder().encode('OggS'), 0) + buffer[26] = 1 + buffer[27] = packet.length + buffer.set(packet, 28) + return buffer.buffer + } + }, + { + name: 'OpusTags packet truncated mid-comment-list (missing comment length field)', + createBuffer: () => { + const commentBytes = new TextEncoder().encode('key=value') + // Packet: 'OpusTags'(8) + vendorLength=0(4) + userCommentListLength=2(4) + + // commentLength=9(4) + commentData(9) — second comment length field is missing + const packet = new Uint8Array(8 + 4 + 4 + 4 + commentBytes.length) + const view = new DataView(packet.buffer) + packet.set(new TextEncoder().encode('OpusTags'), 0) + view.setUint32(8, 0, true) // vendorLength = 0 + view.setUint32(12, 2, true) // userCommentListLength = 2 (but only 1 follows) + view.setUint32(16, commentBytes.length, true) // first comment length + packet.set(commentBytes, 20) // first comment data + // second comment's length field is absent — packet ends here + const buffer = new Uint8Array(27 + 1 + packet.length) + buffer.set(new TextEncoder().encode('OggS'), 0) + buffer[26] = 1 + buffer[27] = packet.length + buffer.set(packet, 28) + return buffer.buffer + } + } + ] - expect(result).toEqual({ prompt: undefined, workflow: undefined }) - }) + for (const { name, createBuffer } of scenarios) { + it(`should safely handle: ${name}`, async () => { + const file = new File( + [createBuffer()], + `test_${name.replace(/\s+/g, '_')}.ogg` + ) + const result = await getOggMetadata(file) + expect(result.prompt).toBeUndefined() + expect(result.workflow).toBeUndefined() + }) + } + }) + + describe('invalid or unrelated comment contents', () => { + const scenarios: { + name: string + createBuffer: () => ArrayBuffer + expectedPrompt?: Record + }[] = [ + { + name: 'unrelated Vorbis comments', + createBuffer: () => + createOggWithOpusTags({ + unrelated_key: 'unrelated_value', + prompt: '{"valid": true}' + }), + expectedPrompt: { valid: true } // Workflow is undefined, Prompt is valid + }, + + { + name: 'user comments without an equal sign', + createBuffer: () => { + const commentBytes = new TextEncoder().encode( + 'invalid_comment_no_equal_sign' + ) + const packet = new Uint8Array(16 + 4 + commentBytes.length) + packet.set(new TextEncoder().encode('OpusTags'), 0) + new DataView(packet.buffer).setUint32(8, 0, true) + new DataView(packet.buffer).setUint32(12, 1, true) + new DataView(packet.buffer).setUint32(16, commentBytes.length, true) + packet.set(commentBytes, 20) + + const buffer = new Uint8Array(27 + 1 + packet.length) + buffer.set(new TextEncoder().encode('OggS'), 0) + buffer[26] = 1 + buffer[27] = packet.length + buffer.set(packet, 28) + return buffer.buffer + } + } + ] + + for (const { name, createBuffer, expectedPrompt } of scenarios) { + it(`should appropriately handle: ${name}`, async () => { + const file = new File( + [createBuffer()], + `test_${name.replace(/\s+/g, '_')}.ogg` + ) + const result = await getOggMetadata(file) + if (expectedPrompt !== undefined) { + expect(result.prompt).toEqual(expectedPrompt) + } else { + expect(result.prompt).toBeUndefined() + } + expect(result.workflow).toBeUndefined() + }) + } }) }) diff --git a/src/scripts/metadata/ogg.ts b/src/scripts/metadata/ogg.ts index 858d335f491..306da0010f0 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -1,56 +1,243 @@ +import type { ComfyMetadata } from '@/types/metadataTypes' import type { ComfyApiWorkflow, ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import { parseJsonWithNonFinite } from '@/utils/jsonUtil' -export async function getOggMetadata(file: File) { - const reader = new FileReader() - const read_process = new Promise((r) => { - reader.onload = (event) => r((event?.target?.result as ArrayBuffer) ?? null) - reader.onerror = () => r(null) - reader.onabort = () => r(null) - }) - reader.readAsArrayBuffer(file) - const arrayBuffer = await read_process - if (!arrayBuffer) return { prompt: undefined, workflow: undefined } - const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4)) - if (signature !== 'OggS') console.error('Invalid file signature.') - let oggs = 0 - let header = '' - while (header.length < arrayBuffer.byteLength) { - const page = String.fromCharCode( - ...new Uint8Array( - arrayBuffer, - header.length, - Math.min(4096, arrayBuffer.byteLength - header.length) - ) +const OGG_HEADER_SIZE = 27 // Base size of the Ogg page header (from magic number to just before segment count) +const OGG_PAGE_SEGMENTS_OFFSET = 26 // Offset position where the number of segments in the page is stored +const OGG_MAX_SEGMENT_SIZE = 255 // Ogg spec: the maximum size of a single segment is 255 bytes + +// Magic numbers as byte arrays to avoid per-page TextDecoder allocations +const OGG_S_MAGIC = new TextEncoder().encode('OggS') +const OPUS_TAGS_MAGIC = new TextEncoder().encode('OpusTags') + +/** + * Extracts ComfyUI metadata (prompt and workflow) from an Ogg/Opus audio file. + * + * Reads the file as a binary ArrayBuffer, locates the OpusTags packet by + * tracing Ogg pages, and parses the embedded Vorbis Comments to retrieve + * JSON-encoded prompt and workflow data. + * + * @param file - The Ogg/Opus file to extract metadata from. + * @returns A promise resolving to a {@link ComfyMetadata} object containing + * the parsed `prompt` and `workflow` fields, or `undefined` for each if + * not found or if parsing fails. + */ +export async function getOggMetadata(file: File): Promise { + // Metadata is usually in the first couple of pages. A 2 MB cap covers all realistic sizes. + // If the packet exceeds this, parsing will abort and warn instead of reading the whole file. + const MAX_READ_BYTES = 2 * 1024 * 1024 + let arrayBuffer: ArrayBuffer + try { + arrayBuffer = await file.slice(0, MAX_READ_BYTES).arrayBuffer() + } catch (err) { + console.warn('Ogg metadata file read failed:', err) + return { prompt: undefined, workflow: undefined } + } + + const data = new Uint8Array(arrayBuffer) + const decoder = new TextDecoder('utf-8') + + // Sequentially trace Ogg pages to extract segments of the 'OpusTags' packet containing metadata + const segments = extractOpusTags(data) + if (segments.length === 0) { + console.warn( + 'Ogg metadata parsing failed: No OpusTags found or invalid Ogg file' ) - if (page.match('OggS\u0000')) oggs++ - header += page - if (oggs > 1) break + return { prompt: undefined, workflow: undefined } } - let workflow: ComfyWorkflowJSON | undefined - let prompt: ComfyApiWorkflow | undefined - let prompt_s = header - .match(/prompt=(\{.*?(\}.*?\u0000))/s)?.[1] - ?.match(/\{.*\}/)?.[0] - if (prompt_s) { - try { - prompt = parseJsonWithNonFinite(prompt_s) - } catch (e) { - console.error('Failed to parse Ogg prompt metadata', e) + + // If the last segment is 255 bytes, the packet continues beyond our MAX_READ_BYTES cap. + // We avoid reading further to prevent full-file scans on audio without embedded metadata. + if (segments[segments.length - 1].length === OGG_MAX_SEGMENT_SIZE) { + console.warn( + 'Ogg metadata parsing: OpusTags packet extends beyond the 2 MB read cap ' + + 'and was truncated; prompt/workflow metadata will be unavailable.' + ) + return { prompt: undefined, workflow: undefined } + } + + // Concatenate the extracted segments to reconstruct the complete OpusTags packet (binary data) + const packetData = new Uint8Array( + segments.reduce((sum, seg) => sum + seg.length, 0) + ) + let currentOffset = 0 + for (const seg of segments) { + packetData.set(seg, currentOffset) + currentOffset += seg.length + } + + // Parse the reconstructed packet according to the Vorbis Comments specification to extract JSON + return parseVorbisComments(packetData, decoder) +} + +/** + * Traverses the Ogg page structure to extract all segments belonging to the + * OpusTags packet. + * + * Scans pages sequentially by verifying the `OggS` capture pattern, reads + * the segment table from each page header, and collects contiguous segments + * that form the OpusTags packet. Relies on Ogg lacing rules: a segment + * shorter than 255 bytes signals the end of the current packet. + * + * @param data - Raw binary content of the Ogg file. + * @returns An array of `Uint8Array` segments that together constitute the + * OpusTags packet, or an empty array if the packet is not found. + */ +function extractOpusTags(data: Uint8Array): Uint8Array[] { + const segments: Uint8Array[] = [] + let offset = 0 + let inOpusTags = false + + while (offset + OGG_HEADER_SIZE <= data.length) { + if (!compareMagic(data, offset, OGG_S_MAGIC)) break + + const pageSegmentsCount = data[offset + OGG_PAGE_SEGMENTS_OFFSET] + const lengthsOffset = offset + OGG_HEADER_SIZE + let dataOffset = lengthsOffset + pageSegmentsCount + + if (dataOffset > data.length) break + + let pageProcessingEnded = false + + for (let i = 0; i < pageSegmentsCount; i++) { + const segmentLength = data[lengthsOffset + i] + + // Stop processing if the segment exceeds our available data buffer + if (dataOffset + segmentLength > data.length) { + pageProcessingEnded = true + break + } + + const segment = data.subarray(dataOffset, dataOffset + segmentLength) + dataOffset += segmentLength + + if (!inOpusTags) { + if (segmentLength >= 8 && compareMagic(segment, 0, OPUS_TAGS_MAGIC)) { + inOpusTags = true + } + } + + if (inOpusTags) { + segments.push(segment) + // Ogg lacing spec: If a segment length is less than 255 (OGG_MAX_SEGMENT_SIZE), + // it marks the end of the current packet (data chunk) + if (segmentLength < OGG_MAX_SEGMENT_SIZE) { + pageProcessingEnded = true + break + } + } } + + if (pageProcessingEnded) break + offset = dataOffset } - let workflow_s = header - .match(/workflow=(\{.*?(\}.*?\u0000))/s)?.[1] - ?.match(/\{.*\}/)?.[0] - if (workflow_s) { - try { - workflow = parseJsonWithNonFinite(workflow_s) - } catch (e) { - console.error('Failed to parse Ogg workflow metadata', e) + + return segments +} + +/** + * Parses a reconstructed OpusTags packet according to the Vorbis Comments + * specification and extracts ComfyUI metadata fields. + * + * Skips the 8-byte `OpusTags` magic string, reads the vendor string, then + * iterates over user comments formatted as `KEY=VALUE` pairs. Recognises + * `prompt` and `workflow` keys and JSON-parses their values into the + * returned metadata object. + * + * @param packetData - The complete, concatenated OpusTags packet as a + * `Uint8Array`. + * @param decoder - UTF-8 TextDecoder used to decode comment strings. + * @returns A {@link ComfyMetadata} object with `prompt` and/or `workflow` + * populated from the embedded comments, or `undefined` for missing fields. + */ +function parseVorbisComments( + packetData: Uint8Array, + decoder: TextDecoder +): ComfyMetadata { + let readIndex = 8 // Skip 'OpusTags' magic string (8 bytes) + const packetView = new DataView( + packetData.buffer, + packetData.byteOffset, + packetData.byteLength + ) + + // Bounds check: ensure vendor length field is within packet + if (readIndex + 4 > packetData.length) + return { prompt: undefined, workflow: undefined } + + // Skip vendor string length (4 bytes) + vendor string + const vendorLength = packetView.getUint32(readIndex, true) + readIndex += 4 + + // Bounds check: ensure vendor string is within packet + if (readIndex + vendorLength > packetData.length) + return { prompt: undefined, workflow: undefined } + readIndex += vendorLength + + // Bounds check: ensure user comment list length field is within packet + if (readIndex + 4 > packetData.length) + return { prompt: undefined, workflow: undefined } + + // Get the number of user comments + const userCommentListLength = packetView.getUint32(readIndex, true) + readIndex += 4 + + const result: ComfyMetadata = {} + for (let i = 0; i < userCommentListLength; i++) { + // Bounds check: ensure comment length field is within packet + if (readIndex + 4 > packetData.length) break + + // Vorbis Comments spec: Get comment length (32-bit little-endian) + const commentLength = packetView.getUint32(readIndex, true) + readIndex += 4 + + // Bounds check: ensure comment data is within packet + if (readIndex + commentLength > packetData.length) break + + // Extract and decode the comment string (UTF-8) + const text = decoder.decode( + packetData.subarray(readIndex, readIndex + commentLength) + ) + readIndex += commentLength + + const separatorIndex = text.indexOf('=') + if (separatorIndex !== -1) { + const key = text.substring(0, separatorIndex).toLowerCase() + const value = text.substring(separatorIndex + 1) + if (key === 'prompt') { + try { + result.prompt = parseJsonWithNonFinite(value) + } catch (e) { + console.error('Failed to parse Ogg prompt metadata', e) + } + } else if (key === 'workflow') { + try { + result.workflow = parseJsonWithNonFinite(value) + } catch (e) { + console.error('Failed to parse Ogg workflow metadata', e) + } + } } + + if (result.prompt !== undefined && result.workflow !== undefined) break + } + + return result +} + +/** + * Compares a portion of a Uint8Array with a magic byte sequence. + */ +function compareMagic( + data: Uint8Array, + offset: number, + magic: Uint8Array +): boolean { + for (let i = 0; i < magic.length; i++) { + if (data[offset + i] !== magic[i]) return false } - return { prompt, workflow } + return true }