snapshot working
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* PNG tEXt chunk utilities for embedding/extracting workflow metadata.
|
||||
* PNG text chunk utilities for embedding/extracting workflow metadata.
|
||||
*
|
||||
* PNG files are composed of chunks: [4-byte length][4-byte type][data][4-byte CRC].
|
||||
* We add a tEXt chunk with key "workflow" containing the JSON-serialised graph,
|
||||
* inserted just before the IEND chunk.
|
||||
* We add an iTXt chunk with key "workflow" containing the JSON-serialised graph,
|
||||
* inserted just before the IEND chunk. We still read legacy tEXt chunks.
|
||||
*/
|
||||
|
||||
// ── CRC32 (PNG uses CRC-32/ISO 3309) ────────────────────────────────
|
||||
@@ -43,10 +43,64 @@ function chunkType(data, offset) {
|
||||
);
|
||||
}
|
||||
|
||||
function readUint32(data, offset) {
|
||||
return new DataView(data.buffer, data.byteOffset + offset, 4).getUint32(0);
|
||||
}
|
||||
|
||||
function buildChunk(type, payload) {
|
||||
const encoder = new TextEncoder();
|
||||
const typeBytes = encoder.encode(type);
|
||||
const forCrc = new Uint8Array(4 + payload.length);
|
||||
forCrc.set(typeBytes, 0);
|
||||
forCrc.set(payload, 4);
|
||||
|
||||
const chunk = new Uint8Array(12 + payload.length);
|
||||
const view = new DataView(chunk.buffer);
|
||||
view.setUint32(0, payload.length);
|
||||
chunk.set(typeBytes, 4);
|
||||
chunk.set(payload, 8);
|
||||
view.setUint32(8 + payload.length, crc32(forCrc));
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parseTextChunk(type, chunkData) {
|
||||
const decoder = new TextDecoder();
|
||||
const keywordEnd = chunkData.indexOf(0);
|
||||
if (keywordEnd === -1) return null;
|
||||
|
||||
const keyword = decoder.decode(chunkData.subarray(0, keywordEnd));
|
||||
if (keyword !== 'workflow') return null;
|
||||
|
||||
if (type === 'tEXt') {
|
||||
return JSON.parse(decoder.decode(chunkData.subarray(keywordEnd + 1)));
|
||||
}
|
||||
|
||||
if (type !== 'iTXt') return null;
|
||||
|
||||
const compressionFlagIdx = keywordEnd + 1;
|
||||
const compressionMethodIdx = keywordEnd + 2;
|
||||
if (compressionMethodIdx >= chunkData.length) return null;
|
||||
|
||||
const compressionFlag = chunkData[compressionFlagIdx];
|
||||
if (compressionFlag !== 0) {
|
||||
throw new Error('Compressed PNG workflow metadata is not supported');
|
||||
}
|
||||
|
||||
let offset = compressionMethodIdx + 1;
|
||||
const languageEnd = chunkData.indexOf(0, offset);
|
||||
if (languageEnd === -1) return null;
|
||||
|
||||
offset = languageEnd + 1;
|
||||
const translatedEnd = chunkData.indexOf(0, offset);
|
||||
if (translatedEnd === -1) return null;
|
||||
|
||||
return JSON.parse(decoder.decode(chunkData.subarray(translatedEnd + 1)));
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Embed a workflow object into a PNG blob as a tEXt chunk.
|
||||
* Embed a workflow object into a PNG blob as an iTXt chunk.
|
||||
* Returns a new Blob with the metadata inserted before IEND.
|
||||
*/
|
||||
export async function embedWorkflow(pngBlob, workflow) {
|
||||
@@ -55,33 +109,22 @@ export async function embedWorkflow(pngBlob, workflow) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Build tEXt payload: keyword \0 text
|
||||
// Build iTXt payload:
|
||||
// keyword \0 compression-flag compression-method language-tag \0 translated-keyword \0 text
|
||||
const key = encoder.encode('workflow');
|
||||
const val = encoder.encode(JSON.stringify(workflow));
|
||||
const payload = new Uint8Array(key.length + 1 + val.length);
|
||||
const payload = new Uint8Array(key.length + 5 + val.length);
|
||||
payload.set(key, 0);
|
||||
// payload[key.length] is already 0 (null separator)
|
||||
payload.set(val, key.length + 1);
|
||||
|
||||
// CRC covers type + payload
|
||||
const typeBytes = encoder.encode('tEXt');
|
||||
const forCrc = new Uint8Array(4 + payload.length);
|
||||
forCrc.set(typeBytes, 0);
|
||||
forCrc.set(payload, 4);
|
||||
|
||||
// Assemble chunk: length(4) + type(4) + payload + crc(4)
|
||||
const chunk = new Uint8Array(12 + payload.length);
|
||||
const view = new DataView(chunk.buffer);
|
||||
view.setUint32(0, payload.length);
|
||||
chunk.set(typeBytes, 4);
|
||||
chunk.set(payload, 8);
|
||||
view.setUint32(8 + payload.length, crc32(forCrc));
|
||||
payload.set(val, key.length + 5);
|
||||
const chunk = buildChunk('iTXt', payload);
|
||||
|
||||
// Locate IEND
|
||||
let pos = 8;
|
||||
let iendPos = data.length;
|
||||
while (pos < data.length) {
|
||||
const len = new DataView(data.buffer, pos, 4).getUint32(0);
|
||||
if (pos + 8 > data.length) break;
|
||||
const len = readUint32(data, pos);
|
||||
if (pos + 12 + len > data.length) break;
|
||||
if (chunkType(data, pos) === 'IEND') { iendPos = pos; break; }
|
||||
pos += 12 + len;
|
||||
}
|
||||
@@ -96,34 +139,30 @@ export async function embedWorkflow(pngBlob, workflow) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the workflow object from a PNG blob's tEXt chunks.
|
||||
* Extract the workflow object from a PNG blob's iTXt/tEXt chunks.
|
||||
* Returns the parsed object, or null if no "workflow" key is found.
|
||||
*/
|
||||
export async function extractWorkflow(pngBlob) {
|
||||
const data = new Uint8Array(await pngBlob.arrayBuffer());
|
||||
if (!isPng(data)) return null;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let pos = 8;
|
||||
let found = null;
|
||||
|
||||
while (pos + 8 <= data.length) {
|
||||
const len = new DataView(data.buffer, pos, 4).getUint32(0);
|
||||
const len = readUint32(data, pos);
|
||||
if (pos + 12 + len > data.length) break;
|
||||
const type = chunkType(data, pos);
|
||||
|
||||
if (type === 'tEXt' && pos + 8 + len <= data.length) {
|
||||
if (type === 'tEXt' || type === 'iTXt') {
|
||||
const chunkData = data.subarray(pos + 8, pos + 8 + len);
|
||||
const nullIdx = chunkData.indexOf(0);
|
||||
if (nullIdx !== -1) {
|
||||
const k = decoder.decode(chunkData.subarray(0, nullIdx));
|
||||
if (k === 'workflow') {
|
||||
return JSON.parse(decoder.decode(chunkData.subarray(nullIdx + 1)));
|
||||
}
|
||||
}
|
||||
const parsed = parseTextChunk(type, chunkData);
|
||||
if (parsed) found = parsed;
|
||||
}
|
||||
|
||||
if (type === 'IEND') break;
|
||||
pos += 12 + len;
|
||||
}
|
||||
|
||||
return null;
|
||||
return found;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user