From 3346588a948168c5996449530c3768fdd9a347ff Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Sun, 1 Mar 2026 19:26:49 +0900 Subject: [PATCH 01/11] fix: rewrite Ogg parser for multi-page packets (#9256) The previous implementation used naive regex matching on binary data converted to strings. This approach failed entirely when a large prompt caused the `OpusTags` packet to span multiple Ogg pages. This commit replaces the fragile string search with a robust binary parser that strictly follows the Ogg logical bitstream and Vorbis Comments specifications. It correctly handles Ogg lacing and reassembles segmented packets, ensuring that large JSON metadata is successfully extracted. Also adds unit tests (ogg.test.ts) to verify the correct parsing of both standard and multi-page Ogg files. --- src/scripts/metadata/ogg.test.ts | 305 +++++++++++++++++++++++++++++++ src/scripts/metadata/ogg.ts | 142 +++++++++++--- 2 files changed, 423 insertions(+), 24 deletions(-) create mode 100644 src/scripts/metadata/ogg.test.ts diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts new file mode 100644 index 00000000000..0736abaa68d --- /dev/null +++ b/src/scripts/metadata/ogg.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' +import { getOggMetadata } from './ogg' + +function createOggWithOpusTags(comments: { + [key: string]: string +}): ArrayBuffer { + const OGG_HEADER_SIZE = 27 + 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[] = [] + let packetPos = 0 + let packetEnded = false + + while (!packetEnded) { + const remainingPacketSize = packetData.length - packetPos + let dataSizeInThisPage = 0 + const lacingValues: number[] = [] + + for (let i = 0; i < 255; i++) { + const remainingForThisPacket = remainingPacketSize - dataSizeInThisPage + if (remainingForThisPacket >= 255) { + lacingValues.push(255) + dataSizeInThisPage += 255 + } 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 = 255 + const segmentDataSize = 255 + 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 +} + +describe('getOggMetadata', () => { + 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('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' }] } + + const buffer = createOggWithOpusTags({ + prompt: JSON.stringify(prompt), + workflow: JSON.stringify(workflow) + }) + + // 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) + + // 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) + + expect(result.prompt).toEqual(prompt) + expect(result.workflow).toEqual(workflow) + }) + + 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: '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 + } + } + ] + + for (const { name, createBuffer } of scenarios) { + it(`should safely handle: ${name}`, async () => { + const file = new File( + [createBuffer()], + `test_${name.replace(/\s+/g, '_')}.ogg` + ) + try { + const result = await getOggMetadata(file) + expect(result.prompt).toBeUndefined() + expect(result.workflow).toBeUndefined() + } catch (e) { + // If it throws (e.g. RangeError from DataView out of bounds), it's successfully handled gracefully by the caller + expect(e).toBeInstanceOf(Error) + } + }) + } + }) + + describe('invalid or unrelated comment contents', () => { + const scenarios: { + name: string + createBuffer: () => ArrayBuffer + expectedPrompt?: any + }[] = [ + { + 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` + ) + try { + const result = await getOggMetadata(file) + if (expectedPrompt) { + expect(result.prompt).toEqual(expectedPrompt) // either undefined or { valid: true } + } else { + expect(result.prompt).toBeUndefined() + } + expect(result.workflow).toBeUndefined() + } catch (e) { + // Handled via thrown exception + expect(e).toBeInstanceOf(Error) + } + }) + } + }) +}) diff --git a/src/scripts/metadata/ogg.ts b/src/scripts/metadata/ogg.ts index 5dc49c02b32..5f03d4cfe4a 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -1,30 +1,124 @@ export async function getOggMetadata(file: File) { - const reader = new FileReader() - const read_process = new Promise( - (r) => (reader.onload = (event) => r(event?.target?.result)) + // Read the entire file into memory (Opus files are generally small enough) + const arrayBuffer = await file.arrayBuffer() + 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, decoder) + if (segments.length === 0) { + console.error( + 'Ogg metadata parsing failed: No OpusTags found or invalid Ogg file' + ) + 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) +} + +function extractOpusTags(data: Uint8Array, decoder: TextDecoder): Uint8Array[] { + 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 + + const segments: Uint8Array[] = [] + let offset = 0 + let inOpusTags = false + + while (offset + OGG_HEADER_SIZE < data.length) { + const pageSignature = decoder.decode(data.subarray(offset, offset + 4)) + if (pageSignature !== 'OggS') 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] + const segment = data.subarray(dataOffset, dataOffset + segmentLength) + dataOffset += segmentLength + + if (!inOpusTags) { + const segmentMagic = decoder.decode(segment.subarray(0, 8)) + if (segmentMagic === 'OpusTags') { + 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 + } + + return segments +} + +function parseVorbisComments(packetData: Uint8Array, decoder: TextDecoder) { + let readIndex = 8 // Skip 'OpusTags' magic string (8 bytes) + const packetView = new DataView( + packetData.buffer, + packetData.byteOffset, + packetData.byteLength ) - reader.readAsArrayBuffer(file) - const arrayBuffer = (await read_process) as ArrayBuffer - 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, header.length + 4096) + + // Skip vendor string length (4 bytes) + vendor string + const vendorLength = packetView.getUint32(readIndex, true) + readIndex += 4 + vendorLength + + // Get the number of user comments + const userCommentListLength = packetView.getUint32(readIndex, true) + readIndex += 4 + + let prompt, workflow + for (let i = 0; i < userCommentListLength; i++) { + // Vorbis Comments spec: Get comment length (32-bit little-endian) + const commentLength = packetView.getUint32(readIndex, true) + readIndex += 4 + + // Extract and decode the comment string (UTF-8) + const text = decoder.decode( + packetData.subarray(readIndex, readIndex + commentLength) ) - if (page.match('OggS\u0000')) oggs++ - header += page - if (oggs > 1) break + readIndex += commentLength + + const separatorIndex = text.indexOf('=') + if (separatorIndex !== -1) { + const key = text.substring(0, separatorIndex) + const value = text.substring(separatorIndex + 1) + if (key === 'prompt') { + prompt = JSON.parse(value) + } else if (key === 'workflow') { + workflow = JSON.parse(value) + } + } + + if (prompt !== undefined && workflow !== undefined) break } - let workflow, prompt - let prompt_s = header - .match(/prompt=(\{.*?(\}.*?\u0000))/s)?.[1] - ?.match(/\{.*\}/)?.[0] - if (prompt_s) prompt = JSON.parse(prompt_s) - let workflow_s = header - .match(/workflow=(\{.*?(\}.*?\u0000))/s)?.[1] - ?.match(/\{.*\}/)?.[0] - if (workflow_s) workflow = JSON.parse(workflow_s) + return { prompt, workflow } } From 5f0306be457d56ec9089b98187d719c30ce5ba59 Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Mon, 2 Mar 2026 15:48:24 +0900 Subject: [PATCH 02/11] Apply review suggestions (#9256) --- src/scripts/metadata/ogg.test.ts | 115 ++++++++++++++++++++++++++----- src/scripts/metadata/ogg.ts | 37 +++++++++- 2 files changed, 132 insertions(+), 20 deletions(-) diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts index 0736abaa68d..5015e5cb76b 100644 --- a/src/scripts/metadata/ogg.test.ts +++ b/src/scripts/metadata/ogg.test.ts @@ -187,6 +187,18 @@ describe('getOggMetadata', () => { 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: () => { @@ -207,7 +219,6 @@ describe('getOggMetadata', () => { name: 'file smaller than the Ogg magic number', createBuffer: () => new Uint8Array([1, 2]).buffer }, - { name: 'page has an invalid magic number (stop parsing)', createBuffer: () => { @@ -222,6 +233,73 @@ describe('getOggMetadata', () => { 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 + } } ] @@ -231,14 +309,12 @@ describe('getOggMetadata', () => { [createBuffer()], `test_${name.replace(/\s+/g, '_')}.ogg` ) - try { - const result = await getOggMetadata(file) - expect(result.prompt).toBeUndefined() - expect(result.workflow).toBeUndefined() - } catch (e) { - // If it throws (e.g. RangeError from DataView out of bounds), it's successfully handled gracefully by the caller - expect(e).toBeInstanceOf(Error) - } + await expect( + getOggMetadata(file).then((result) => { + expect(result.prompt).toBeUndefined() + expect(result.workflow).toBeUndefined() + }) + ).resolves.not.toThrow() }) } }) @@ -247,7 +323,7 @@ describe('getOggMetadata', () => { const scenarios: { name: string createBuffer: () => ArrayBuffer - expectedPrompt?: any + expectedPrompt?: Record }[] = [ { name: 'unrelated Vorbis comments', @@ -258,6 +334,14 @@ describe('getOggMetadata', () => { }), expectedPrompt: { valid: true } // Workflow is undefined, Prompt is valid }, + { + name: 'invalid JSON in prompt/workflow comments (plain text)', + createBuffer: () => + createOggWithOpusTags({ + prompt: 'hello world', + workflow: 'plain text workflow' + }) + }, { name: 'user comments without an equal sign', createBuffer: () => { @@ -287,18 +371,15 @@ describe('getOggMetadata', () => { [createBuffer()], `test_${name.replace(/\s+/g, '_')}.ogg` ) - try { - const result = await getOggMetadata(file) + await expect(getOggMetadata(file)).resolves.toSatisfy((result) => { if (expectedPrompt) { - expect(result.prompt).toEqual(expectedPrompt) // either undefined or { valid: true } + expect(result.prompt).toEqual(expectedPrompt) } else { expect(result.prompt).toBeUndefined() } expect(result.workflow).toBeUndefined() - } catch (e) { - // Handled via thrown exception - expect(e).toBeInstanceOf(Error) - } + return true + }) }) } }) diff --git a/src/scripts/metadata/ogg.ts b/src/scripts/metadata/ogg.ts index 5f03d4cfe4a..ac13e0a6466 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -50,6 +50,10 @@ function extractOpusTags(data: Uint8Array, decoder: TextDecoder): Uint8Array[] { for (let i = 0; i < pageSegmentsCount; i++) { const segmentLength = data[lengthsOffset + i] + + // Bounds check: ensure the segment data lies within the available data + if (dataOffset + segmentLength > data.length) break + const segment = data.subarray(dataOffset, dataOffset + segmentLength) dataOffset += segmentLength @@ -86,9 +90,22 @@ function parseVorbisComments(packetData: Uint8Array, decoder: TextDecoder) { 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 + vendorLength + 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) @@ -96,10 +113,16 @@ function parseVorbisComments(packetData: Uint8Array, decoder: TextDecoder) { let prompt, workflow 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) @@ -111,9 +134,17 @@ function parseVorbisComments(packetData: Uint8Array, decoder: TextDecoder) { const key = text.substring(0, separatorIndex) const value = text.substring(separatorIndex + 1) if (key === 'prompt') { - prompt = JSON.parse(value) + try { + prompt = JSON.parse(value) + } catch (e) { + console.warn('Ogg metadata parsing failed for prompt:', e) + } } else if (key === 'workflow') { - workflow = JSON.parse(value) + try { + workflow = JSON.parse(value) + } catch (e) { + console.warn('Ogg metadata parsing failed for workflow:', e) + } } } From 86a006b19941518887b1d811557fdf3b86c940a5 Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Mon, 2 Mar 2026 15:44:37 +0900 Subject: [PATCH 03/11] test: refine ogg metadata assertions based on review feedback --- src/scripts/metadata/ogg.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts index 5015e5cb76b..70608e92a9f 100644 --- a/src/scripts/metadata/ogg.test.ts +++ b/src/scripts/metadata/ogg.test.ts @@ -309,12 +309,9 @@ describe('getOggMetadata', () => { [createBuffer()], `test_${name.replace(/\s+/g, '_')}.ogg` ) - await expect( - getOggMetadata(file).then((result) => { - expect(result.prompt).toBeUndefined() - expect(result.workflow).toBeUndefined() - }) - ).resolves.not.toThrow() + const result = await getOggMetadata(file) + expect(result.prompt).toBeUndefined() + expect(result.workflow).toBeUndefined() }) } }) @@ -372,7 +369,7 @@ describe('getOggMetadata', () => { `test_${name.replace(/\s+/g, '_')}.ogg` ) await expect(getOggMetadata(file)).resolves.toSatisfy((result) => { - if (expectedPrompt) { + if (expectedPrompt !== undefined) { expect(result.prompt).toEqual(expectedPrompt) } else { expect(result.prompt).toBeUndefined() From ce95bf688cdec9a7f075079930943f40ad7023af Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Mon, 2 Mar 2026 16:06:07 +0900 Subject: [PATCH 04/11] refactor: address CodeRabbit nitpicks (explicit types, simpler assertions) --- src/scripts/metadata/ogg.test.ts | 16 +++++++--------- src/scripts/metadata/ogg.ts | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts index 70608e92a9f..4e20dc671fe 100644 --- a/src/scripts/metadata/ogg.test.ts +++ b/src/scripts/metadata/ogg.test.ts @@ -368,15 +368,13 @@ describe('getOggMetadata', () => { [createBuffer()], `test_${name.replace(/\s+/g, '_')}.ogg` ) - await expect(getOggMetadata(file)).resolves.toSatisfy((result) => { - if (expectedPrompt !== undefined) { - expect(result.prompt).toEqual(expectedPrompt) - } else { - expect(result.prompt).toBeUndefined() - } - expect(result.workflow).toBeUndefined() - return true - }) + 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 ac13e0a6466..e196ca2fc70 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -1,4 +1,6 @@ -export async function getOggMetadata(file: File) { +import type { ComfyMetadata } from '@/types/metadataTypes' + +export async function getOggMetadata(file: File): Promise { // Read the entire file into memory (Opus files are generally small enough) const arrayBuffer = await file.arrayBuffer() const data = new Uint8Array(arrayBuffer) @@ -82,7 +84,10 @@ function extractOpusTags(data: Uint8Array, decoder: TextDecoder): Uint8Array[] { return segments } -function parseVorbisComments(packetData: Uint8Array, decoder: TextDecoder) { +function parseVorbisComments( + packetData: Uint8Array, + decoder: TextDecoder +): ComfyMetadata { let readIndex = 8 // Skip 'OpusTags' magic string (8 bytes) const packetView = new DataView( packetData.buffer, @@ -111,7 +116,7 @@ function parseVorbisComments(packetData: Uint8Array, decoder: TextDecoder) { const userCommentListLength = packetView.getUint32(readIndex, true) readIndex += 4 - let prompt, workflow + 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 @@ -135,21 +140,21 @@ function parseVorbisComments(packetData: Uint8Array, decoder: TextDecoder) { const value = text.substring(separatorIndex + 1) if (key === 'prompt') { try { - prompt = JSON.parse(value) + result.prompt = JSON.parse(value) } catch (e) { console.warn('Ogg metadata parsing failed for prompt:', e) } } else if (key === 'workflow') { try { - workflow = JSON.parse(value) + result.workflow = JSON.parse(value) } catch (e) { console.warn('Ogg metadata parsing failed for workflow:', e) } } } - if (prompt !== undefined && workflow !== undefined) break + if (result.prompt !== undefined && result.workflow !== undefined) break } - return { prompt, workflow } + return result } From 531cc00b38c3a371e97777ddd54d71df9039aa34 Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Mon, 2 Mar 2026 16:30:27 +0900 Subject: [PATCH 05/11] fix: allow minimal Ogg pages with exactly 27 header bytes (<= boundary check) --- src/scripts/metadata/ogg.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scripts/metadata/ogg.ts b/src/scripts/metadata/ogg.ts index e196ca2fc70..f49b0847f56 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -38,7 +38,7 @@ function extractOpusTags(data: Uint8Array, decoder: TextDecoder): Uint8Array[] { let offset = 0 let inOpusTags = false - while (offset + OGG_HEADER_SIZE < data.length) { + while (offset + OGG_HEADER_SIZE <= data.length) { const pageSignature = decoder.decode(data.subarray(offset, offset + 4)) if (pageSignature !== 'OggS') break From 1394ce81018b9e42afa92cb57cbb82b77180b88b Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Sun, 8 Mar 2026 01:53:57 +0900 Subject: [PATCH 06/11] docs(ogg): add JSDoc comments to all functions to meet docstring coverage threshold --- src/scripts/metadata/ogg.ts | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/scripts/metadata/ogg.ts b/src/scripts/metadata/ogg.ts index f49b0847f56..51015081990 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -1,5 +1,17 @@ import type { ComfyMetadata } from '@/types/metadataTypes' +/** + * 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 { // Read the entire file into memory (Opus files are generally small enough) const arrayBuffer = await file.arrayBuffer() @@ -29,6 +41,20 @@ export async function getOggMetadata(file: File): Promise { 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. + * @param decoder - UTF-8 TextDecoder used to identify magic strings. + * @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, decoder: TextDecoder): Uint8Array[] { 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 @@ -84,6 +110,21 @@ function extractOpusTags(data: Uint8Array, decoder: TextDecoder): Uint8Array[] { 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 From 79cab5494232a3f4488f1d532240a44b3593472e Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Fri, 13 Mar 2026 22:10:50 +0900 Subject: [PATCH 07/11] refactor(metadata): address review comments for Ogg metadata parser - Limit file reading to 2MB to reduce memory usage - Convert Vorbis Comment keys to lowercase for RFC 7845 compliance - Update test helper to include 'OpusHead' (Page 0) for realistic Ogg structure - Add guard to skip 'OpusTags' magic check on segments shorter than 8 bytes - Downgrade missing 'OpusTags' log from error to warning --- src/scripts/metadata/ogg.test.ts | 14 ++++++++++++++ src/scripts/metadata/ogg.ts | 17 ++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts index 4e20dc671fe..d39f2d9ce14 100644 --- a/src/scripts/metadata/ogg.test.ts +++ b/src/scripts/metadata/ogg.test.ts @@ -39,6 +39,20 @@ function createOggWithOpusTags(comments: { // 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 diff --git a/src/scripts/metadata/ogg.ts b/src/scripts/metadata/ogg.ts index 51015081990..b93869d0401 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -13,15 +13,16 @@ import type { ComfyMetadata } from '@/types/metadataTypes' * not found or if parsing fails. */ export async function getOggMetadata(file: File): Promise { - // Read the entire file into memory (Opus files are generally small enough) - const arrayBuffer = await file.arrayBuffer() + // Read the beginning of the file (Opus files metadata is usually in the first couple of pages) + const MAX_READ_BYTES = 2 * 1024 * 1024 + const arrayBuffer = await file.slice(0, MAX_READ_BYTES).arrayBuffer() 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, decoder) if (segments.length === 0) { - console.error( + console.warn( 'Ogg metadata parsing failed: No OpusTags found or invalid Ogg file' ) return { prompt: undefined, workflow: undefined } @@ -86,9 +87,11 @@ function extractOpusTags(data: Uint8Array, decoder: TextDecoder): Uint8Array[] { dataOffset += segmentLength if (!inOpusTags) { - const segmentMagic = decoder.decode(segment.subarray(0, 8)) - if (segmentMagic === 'OpusTags') { - inOpusTags = true + if (segmentLength >= 8) { + const segmentMagic = decoder.decode(segment.subarray(0, 8)) + if (segmentMagic === 'OpusTags') { + inOpusTags = true + } } } @@ -177,7 +180,7 @@ function parseVorbisComments( const separatorIndex = text.indexOf('=') if (separatorIndex !== -1) { - const key = text.substring(0, separatorIndex) + const key = text.substring(0, separatorIndex).toLowerCase() const value = text.substring(separatorIndex + 1) if (key === 'prompt') { try { From ef4fdf60ed859f5015e828eb6f758cbadb127069 Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Sat, 14 Mar 2026 09:26:27 +0900 Subject: [PATCH 08/11] test: add ogg workflow test and fix mimetype mapping --- .../assets/workflowInMedia/workflow.ogg | Bin 0 -> 1808 bytes .../fixtures/helpers/DragDropHelper.ts | 1 + browser_tests/tests/loadWorkflowInMedia.spec.ts | 3 ++- .../workflow-ogg-chromium-linux.png | Bin 0 -> 49920 bytes 4 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 browser_tests/assets/workflowInMedia/workflow.ogg create mode 100644 browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-ogg-chromium-linux.png diff --git a/browser_tests/assets/workflowInMedia/workflow.ogg b/browser_tests/assets/workflowInMedia/workflow.ogg new file mode 100644 index 0000000000000000000000000000000000000000..c3226f6be6448ec48ca67ba56312e5b9ab446214 GIT binary patch literal 1808 zcmd5-&2G~`5Kd^ti5qa>X8C|XYK`qUj)PR8svtEgB@$_Si6UziuhUf>uUW5?hG2;U zkHMW+K!OY6z$v9jrIx^!WM(uFtsVh>X%3;C@Obp=%or!!pecu=gN0 z5qzA;ow;u554E|@;v|!)dJHl_B;zp{mSJvbdP>GgM1Am5@Hq}t0q^jX#4(L$?EWsrG2^630W&z_m#QZUp1l)67G8tVaXhlYj`)w6p!V$8F#$-(WM9`3(V^x9$ zAMEjQI6LlO;TIW+n4DqEvb{5OsT_!cYhi-X2clb=Iar0-BNM7{pk%mZ4O+}8z-sp} z?Y^Gp`J>HRZOX-281X3x$pSpnEY~*v{_<7D@DM>KEG<+ zcqGqc*>-(2p(hh6BvTyc`ZG8~!-`tSh1R?K94oKT9LAy$NFQQfY!A7hc+f;))lI`{ z8K#c-rz{vznfenFWrd8Z4W+sWo1|12xmFym_H-$)?YTyy*+Lnurql8oYxHf}$nL7X z=I`}IQP=8=RhA^XlzX9W1$E&-xm6oj1Eu(%NPLy&zvv6AF}s*boKoJA_#k5J@^?GQwtF-giK0(t%^emUmZQ4}}de*|8zkRtvy9d0*2)i(g{PiGEgTL1t6 literal 0 HcmV?d00001 diff --git a/browser_tests/fixtures/helpers/DragDropHelper.ts b/browser_tests/fixtures/helpers/DragDropHelper.ts index 181404d8b6d..8a28f02766b 100644 --- a/browser_tests/fixtures/helpers/DragDropHelper.ts +++ b/browser_tests/fixtures/helpers/DragDropHelper.ts @@ -56,6 +56,7 @@ export class DragDropHelper { if (fileName.endsWith('.json')) return 'application/json' if (fileName.endsWith('.glb')) return 'model/gltf-binary' if (fileName.endsWith('.avif')) return 'image/avif' + if (fileName.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 c6011f5a657..4ccedc5a0a8 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 0000000000000000000000000000000000000000..6a4e71db813aefbd1c59e77dcf0f5b5d3022d54a GIT binary patch literal 49920 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

@NW@O}=A&k@r_Zj8&)m35a6+*T7UlIOxz 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 T)w1jwe1aH!Z=|2BdnD*T8E2ET literal 0 HcmV?d00001 From 9364af6c80275df9a150b085d25a65065921cd3d Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Mon, 4 May 2026 18:44:02 +0900 Subject: [PATCH 09/11] refactor(metadata): address Ogg parser review comments Applies optimizations and robustness improvements to the Ogg/Opus metadata parser based on PR feedback: - Use direct byte comparisons for magic strings ('OggS', 'OpusTags') to avoid per-page TextDecoder allocations. - Add explicit bounds checking during segment processing to prevent infinite loops on corrupted files. - Emit a console.warn when the OpusTags packet is truncated at the 2MB read cap instead of failing silently. - Consolidate duplicated constants and clean up comments. --- src/scripts/metadata/ogg.test.ts | 76 ++++++++++++++++++++++++++++---- src/scripts/metadata/ogg.ts | 61 ++++++++++++++++++------- 2 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts index 911779eb9d5..235f1f4152e 100644 --- a/src/scripts/metadata/ogg.test.ts +++ b/src/scripts/metadata/ogg.test.ts @@ -4,12 +4,14 @@ import { describe, expect, it, vi, afterEach } from 'vitest' import { getOggMetadata } from './ogg' import { EXPECTED_PROMPT, EXPECTED_WORKFLOW } from './__fixtures__/helpers' +const OGG_HEADER_SIZE = 27 +const OGG_MAX_SEGMENT_SIZE = 255 + const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.opus') function createOggWithOpusTags(comments: { [key: string]: string }): ArrayBuffer { - const OGG_HEADER_SIZE = 27 const vendor = 'ComfyUI' const vendorBytes = new TextEncoder().encode(vendor) const commentKeys = Object.keys(comments) @@ -66,11 +68,11 @@ function createOggWithOpusTags(comments: { let dataSizeInThisPage = 0 const lacingValues: number[] = [] - for (let i = 0; i < 255; i++) { + for (let i = 0; i < OGG_MAX_SEGMENT_SIZE; i++) { const remainingForThisPacket = remainingPacketSize - dataSizeInThisPage - if (remainingForThisPacket >= 255) { - lacingValues.push(255) - dataSizeInThisPage += 255 + if (remainingForThisPacket >= OGG_MAX_SEGMENT_SIZE) { + lacingValues.push(OGG_MAX_SEGMENT_SIZE) + dataSizeInThisPage += OGG_MAX_SEGMENT_SIZE } else { lacingValues.push(remainingForThisPacket) dataSizeInThisPage += remainingForThisPacket @@ -106,8 +108,8 @@ function createOggWithOpusTags(comments: { while (currentTotalSize < TARGET_SIZE) { // Create a dummy audio page (approx 65KB per page) - const numSegments = 255 - const segmentDataSize = 255 + 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) @@ -154,16 +156,74 @@ describe('getOggMetadata', () => { }) it('resolves undefined fields when the file reading fails', async () => { - const file = new File([new Uint8Array(16)], 'test.ogg', { type: 'audio/ogg' }) + 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).toEqual({ prompt: undefined, workflow: undefined }) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Ogg metadata file read failed:'), + expect.any(Error) + ) + }) + + 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) + + // 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 () => { diff --git a/src/scripts/metadata/ogg.ts b/src/scripts/metadata/ogg.ts index f39df8da337..19b6466c90d 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -1,5 +1,13 @@ import type { ComfyMetadata } from '@/types/metadataTypes' +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. * @@ -13,7 +21,8 @@ import type { ComfyMetadata } from '@/types/metadataTypes' * not found or if parsing fails. */ export async function getOggMetadata(file: File): Promise { - // Read the beginning of the file (Opus files metadata is usually in the first couple of pages) + // 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 { @@ -27,7 +36,7 @@ export async function getOggMetadata(file: File): Promise { const decoder = new TextDecoder('utf-8') // Sequentially trace Ogg pages to extract segments of the 'OpusTags' packet containing metadata - const segments = extractOpusTags(data, decoder) + const segments = extractOpusTags(data) if (segments.length === 0) { console.warn( 'Ogg metadata parsing failed: No OpusTags found or invalid Ogg file' @@ -35,6 +44,16 @@ export async function getOggMetadata(file: File): Promise { return { prompt: undefined, workflow: undefined } } + // 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) @@ -59,22 +78,16 @@ export async function getOggMetadata(file: File): Promise { * shorter than 255 bytes signals the end of the current packet. * * @param data - Raw binary content of the Ogg file. - * @param decoder - UTF-8 TextDecoder used to identify magic strings. * @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, decoder: TextDecoder): Uint8Array[] { - 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 - +function extractOpusTags(data: Uint8Array): Uint8Array[] { const segments: Uint8Array[] = [] let offset = 0 let inOpusTags = false while (offset + OGG_HEADER_SIZE <= data.length) { - const pageSignature = decoder.decode(data.subarray(offset, offset + 4)) - if (pageSignature !== 'OggS') break + if (!compareMagic(data, offset, OGG_S_MAGIC)) break const pageSegmentsCount = data[offset + OGG_PAGE_SEGMENTS_OFFSET] const lengthsOffset = offset + OGG_HEADER_SIZE @@ -87,18 +100,18 @@ function extractOpusTags(data: Uint8Array, decoder: TextDecoder): Uint8Array[] { for (let i = 0; i < pageSegmentsCount; i++) { const segmentLength = data[lengthsOffset + i] - // Bounds check: ensure the segment data lies within the available data - if (dataOffset + segmentLength > data.length) break + // 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) { - const segmentMagic = decoder.decode(segment.subarray(0, 8)) - if (segmentMagic === 'OpusTags') { - inOpusTags = true - } + if (segmentLength >= 8 && compareMagic(segment, 0, OPUS_TAGS_MAGIC)) { + inOpusTags = true } } @@ -209,3 +222,17 @@ function parseVorbisComments( 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 true +} From 93c55af0654d093781ff027bb8686b31ae5bcbdb Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Thu, 14 May 2026 17:13:04 +0900 Subject: [PATCH 10/11] test: remove invalid JSON scenario made redundant by #12217 merge --- src/scripts/metadata/ogg.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts index ada493bb63c..5d5ffd40dc1 100644 --- a/src/scripts/metadata/ogg.test.ts +++ b/src/scripts/metadata/ogg.test.ts @@ -476,14 +476,7 @@ describe('getOggMetadata', () => { }), expectedPrompt: { valid: true } // Workflow is undefined, Prompt is valid }, - { - name: 'invalid JSON in prompt/workflow comments (plain text)', - createBuffer: () => - createOggWithOpusTags({ - prompt: 'hello world', - workflow: 'plain text workflow' - }) - }, + { name: 'user comments without an equal sign', createBuffer: () => { From 91e7b3acd65493bc87079c7393c82b5e9b644086 Mon Sep 17 00:00:00 2001 From: xeinherjer Date: Thu, 14 May 2026 18:15:55 +0900 Subject: [PATCH 11/11] chore: sync ModelHeroSection.vue with upstream Tailwind class order differed from upstream likely because oxfmt pre-commit hook rewrote the class ordering during the first merge commit, which caused subsequent auto-merges to preserve the hook-modified version instead of upstream's. --- apps/website/src/components/models/ModelHeroSection.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/website/src/components/models/ModelHeroSection.vue b/apps/website/src/components/models/ModelHeroSection.vue index 894877af0c3..4ef460d1901 100644 --- a/apps/website/src/components/models/ModelHeroSection.vue +++ b/apps/website/src/components/models/ModelHeroSection.vue @@ -56,16 +56,16 @@ const isPartnerNode = directory === 'partner_nodes' >

{{ eyebrow }}

-

+

{{ displayName }} in ComfyUI

-

+

{{ t('models.hero.workflowCount').replace( '{count}', @@ -122,7 +122,7 @@ const isPartnerNode = directory === 'partner_nodes'

-
+