diff --git a/frontend/package.json b/frontend/package.json index 2082038..ab03857 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test tests/**/*.test.mjs" }, "dependencies": { "@xyflow/react": "^12.0.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ced7656..8c0abd7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import FileBrowser from './FileBrowser'; import * as api from './api'; import { toBlob } from 'html-to-image'; import { embedWorkflow, extractWorkflow } from './pngMetadata'; +import { serializeWorkflowState } from './workflowSerialization'; // ── Constants ───────────────────────────────────────────────────────── @@ -51,29 +52,94 @@ function blobToDataUrl(blob) { }); } -function serializeWorkflowState(nodes, edges) { - return { - version: 1, - nodes: nodes.map((node) => ({ - id: node.id, - type: node.type || 'custom', - position: node.position, - dragHandle: node.dragHandle || '.drag-handle', - data: { - label: node.data?.label || node.data?.className || 'Node', - className: node.data?.className || '', - widgetValues: node.data?.widgetValues || {}, - }, - })), - edges: edges.map((edge) => ({ - id: edge.id, - source: edge.source, - sourceHandle: edge.sourceHandle, - target: edge.target, - targetHandle: edge.targetHandle, - style: edge.style, - })), - }; +async function waitForImageElement(img) { + if (img.complete && img.naturalWidth > 0) return; + if (typeof img.decode === 'function') { + try { + await img.decode(); + return; + } catch { + // Fall back to load/error listeners below. + } + } + await new Promise((resolve) => { + const done = () => { + img.removeEventListener('load', done); + img.removeEventListener('error', done); + resolve(); + }; + img.addEventListener('load', done, { once: true }); + img.addEventListener('error', done, { once: true }); + }); +} + +function createCapturePlaceholder(el, dataUrl) { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + const placeholder = document.createElement('div'); + + placeholder.style.display = style.display === 'inline' ? 'inline-block' : style.display; + placeholder.style.width = `${el.clientWidth || rect.width}px`; + placeholder.style.height = `${el.clientHeight || rect.height}px`; + placeholder.style.maxWidth = style.maxWidth; + placeholder.style.maxHeight = style.maxHeight; + placeholder.style.minWidth = style.minWidth; + placeholder.style.minHeight = style.minHeight; + placeholder.style.borderRadius = style.borderRadius; + placeholder.style.backgroundImage = `url("${dataUrl}")`; + placeholder.style.backgroundRepeat = 'no-repeat'; + placeholder.style.backgroundPosition = 'center'; + placeholder.style.backgroundSize = el.tagName === 'CANVAS' ? '100% 100%' : 'contain'; + placeholder.style.flexShrink = style.flexShrink; + + return placeholder; +} + +async function captureViewportBlob(viewportEl, options) { + const restorers = []; + const images = Array.from(viewportEl.querySelectorAll('img')); + await Promise.all(images.map(waitForImageElement)); + + for (const img of images) { + const dataUrl = img.currentSrc || img.src; + if (!dataUrl || !img.parentNode) continue; + const placeholder = createCapturePlaceholder(img, dataUrl); + img.parentNode.replaceChild(placeholder, img); + restorers.push(() => { + if (placeholder.parentNode) { + placeholder.parentNode.replaceChild(img, placeholder); + } + }); + } + + const canvases = Array.from(viewportEl.querySelectorAll('canvas')); + for (const canvas of canvases) { + if (!canvas.parentNode) continue; + let dataUrl = 'data:,'; + try { + dataUrl = canvas.toDataURL('image/png'); + } catch { + dataUrl = 'data:,'; + } + if (dataUrl === 'data:,') continue; + + const placeholder = createCapturePlaceholder(canvas, dataUrl); + canvas.parentNode.replaceChild(placeholder, canvas); + restorers.push(() => { + if (placeholder.parentNode) { + placeholder.parentNode.replaceChild(canvas, placeholder); + } + }); + } + + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + + try { + return await toBlob(viewportEl, options); + } finally { + restorers.reverse().forEach((restore) => restore()); + } } // ── Graph serialisation → backend prompt format ─────────────────────── @@ -505,7 +571,7 @@ function Flow() { const imageHeight = Math.ceil(bounds.height * (1 + pad * 2)); const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad); - const blob = await toBlob(viewportEl, { + const blob = await captureViewportBlob(viewportEl, { backgroundColor: '#1a1a1a', width: imageWidth, height: imageHeight, diff --git a/frontend/src/SurfaceView.jsx b/frontend/src/SurfaceView.jsx index 2a3d5bb..8247ed7 100644 --- a/frontend/src/SurfaceView.jsx +++ b/frontend/src/SurfaceView.jsx @@ -28,7 +28,11 @@ export default function SurfaceView({ meshData }) { const width = container.clientWidth; const height = width; // 1:1 aspect - const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + preserveDrawingBuffer: true, + }); renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x0f172a); diff --git a/frontend/src/workflowSerialization.js b/frontend/src/workflowSerialization.js new file mode 100644 index 0000000..ebab63f --- /dev/null +++ b/frontend/src/workflowSerialization.js @@ -0,0 +1,24 @@ +export function serializeWorkflowState(nodes, edges) { + return { + version: 1, + nodes: nodes.map((node) => ({ + id: node.id, + type: node.type || 'custom', + position: node.position, + dragHandle: node.dragHandle || '.drag-handle', + data: { + label: node.data?.label || node.data?.className || 'Node', + className: node.data?.className || '', + widgetValues: node.data?.widgetValues || {}, + }, + })), + edges: edges.map((edge) => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle, + target: edge.target, + targetHandle: edge.targetHandle, + style: edge.style, + })), + }; +} diff --git a/frontend/tests/pngMetadata.test.mjs b/frontend/tests/pngMetadata.test.mjs new file mode 100644 index 0000000..16831fe --- /dev/null +++ b/frontend/tests/pngMetadata.test.mjs @@ -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); +}); diff --git a/frontend/tests/workflowSerialization.test.mjs b/frontend/tests/workflowSerialization.test.mjs new file mode 100644 index 0000000..8c05dda --- /dev/null +++ b/frontend/tests/workflowSerialization.test.mjs @@ -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); +}); diff --git a/package.json b/package.json index 5fe014e..0618d35 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "npm --prefix frontend run dev", "build": "npm --prefix frontend run build", "preview": "npm --prefix frontend run preview", + "test:frontend": "npm --prefix frontend test", "backend": "python -m backend.main", "desktop": "python desktop.py", "build:windows": "powershell -ExecutionPolicy Bypass -File scripts\\build-windows.ps1",