fix native and web screenshot rendering

This commit is contained in:
2026-03-23 23:05:08 -07:00
parent 29107bc141
commit 0d47228782
7 changed files with 322 additions and 26 deletions

View File

@@ -0,0 +1,109 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { embedWorkflow, extractWorkflow } from '../src/pngMetadata.js';
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=';
function makePngBlob() {
return new Blob([Buffer.from(PNG_BASE64, 'base64')], { type: 'image/png' });
}
function crc32(bytes) {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c;
}
let crc = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
crc = table[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function buildChunk(type, payload) {
const typeBytes = new TextEncoder().encode(type);
const crcInput = new Uint8Array(4 + payload.length);
crcInput.set(typeBytes, 0);
crcInput.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(crcInput));
return chunk;
}
async function insertTextChunk(blob, workflow) {
const png = new Uint8Array(await blob.arrayBuffer());
const encoder = new TextEncoder();
const key = encoder.encode('workflow');
const text = encoder.encode(JSON.stringify(workflow));
const payload = new Uint8Array(key.length + 1 + text.length);
payload.set(key, 0);
payload.set(text, key.length + 1);
const chunk = buildChunk('tEXt', payload);
let pos = 8;
while (pos < png.length) {
const len = new DataView(png.buffer, pos, 4).getUint32(0);
const type = String.fromCharCode(png[pos + 4], png[pos + 5], png[pos + 6], png[pos + 7]);
if (type === 'IEND') break;
pos += 12 + len;
}
const out = new Uint8Array(png.length + chunk.length);
out.set(png.subarray(0, pos), 0);
out.set(chunk, pos);
out.set(png.subarray(pos), pos + chunk.length);
return new Blob([out], { type: 'image/png' });
}
test('embedWorkflow roundtrips workflow data through an iTXt chunk', async () => {
const workflow = {
version: 1,
nodes: [
{
id: '1',
type: 'custom',
position: { x: 12, y: 34 },
data: { className: 'DemoNode', widgetValues: { title: 'naïve café', sigma: 'β' } },
},
],
edges: [],
};
const embedded = await embedWorkflow(makePngBlob(), workflow);
const extracted = await extractWorkflow(embedded);
const bytes = new Uint8Array(await embedded.arrayBuffer());
assert.deepEqual(extracted, workflow);
assert.match(Buffer.from(bytes).toString('latin1'), /iTXt/);
});
test('extractWorkflow still supports legacy tEXt metadata chunks', async () => {
const workflow = { version: 1, legacy: true, nodes: [], edges: [] };
const legacyBlob = await insertTextChunk(makePngBlob(), workflow);
const extracted = await extractWorkflow(legacyBlob);
assert.deepEqual(extracted, workflow);
});
test('extractWorkflow returns the last workflow chunk when an image is re-saved', async () => {
const first = { version: 1, name: 'old', nodes: [], edges: [] };
const second = { version: 1, name: 'new', nodes: [], edges: [] };
const once = await embedWorkflow(makePngBlob(), first);
const twice = await embedWorkflow(once, second);
const extracted = await extractWorkflow(twice);
assert.deepEqual(extracted, second);
});

View File

@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { serializeWorkflowState } from '../src/workflowSerialization.js';
test('serializeWorkflowState keeps only stable workflow fields needed for reload', () => {
const nodes = [
{
id: '1',
type: 'custom',
position: { x: 100, y: 200 },
dragHandle: '.node-header',
selected: true,
width: 320,
data: {
label: 'Demo Label',
className: 'DemoNode',
widgetValues: { threshold: 0.42, mode: 'fast' },
definition: { input: { required: { threshold: ['FLOAT', { default: 0.5 }] } } },
previewImage: 'data:image/png;base64,abc',
tableRows: [{ a: 1 }],
meshData: { vertices: [1, 2, 3] },
overlay: { active: true },
},
},
{
id: '2',
position: { x: 10, y: 20 },
data: {
className: 'NoLabelNode',
},
},
];
const edges = [
{
id: 'e1-2',
source: '1',
sourceHandle: 'output::0::IMAGE',
target: '2',
targetHandle: 'input::image::IMAGE',
style: { stroke: '#fff', strokeWidth: 2 },
selected: true,
animated: true,
},
];
const serialized = serializeWorkflowState(nodes, edges);
assert.deepEqual(serialized, {
version: 1,
nodes: [
{
id: '1',
type: 'custom',
position: { x: 100, y: 200 },
dragHandle: '.node-header',
data: {
label: 'Demo Label',
className: 'DemoNode',
widgetValues: { threshold: 0.42, mode: 'fast' },
},
},
{
id: '2',
type: 'custom',
position: { x: 10, y: 20 },
dragHandle: '.drag-handle',
data: {
label: 'NoLabelNode',
className: 'NoLabelNode',
widgetValues: {},
},
},
],
edges: [
{
id: 'e1-2',
source: '1',
sourceHandle: 'output::0::IMAGE',
target: '2',
targetHandle: 'input::image::IMAGE',
style: { stroke: '#fff', strokeWidth: 2 },
},
],
});
assert.equal('definition' in serialized.nodes[0].data, false);
assert.equal('previewImage' in serialized.nodes[0].data, false);
assert.equal('selected' in serialized.edges[0], false);
});