fix native and web screenshot rendering
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "node --test tests/**/*.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.0.0",
|
"@xyflow/react": "^12.0.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import FileBrowser from './FileBrowser';
|
|||||||
import * as api from './api';
|
import * as api from './api';
|
||||||
import { toBlob } from 'html-to-image';
|
import { toBlob } from 'html-to-image';
|
||||||
import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
||||||
|
import { serializeWorkflowState } from './workflowSerialization';
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,29 +52,94 @@ function blobToDataUrl(blob) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeWorkflowState(nodes, edges) {
|
async function waitForImageElement(img) {
|
||||||
return {
|
if (img.complete && img.naturalWidth > 0) return;
|
||||||
version: 1,
|
if (typeof img.decode === 'function') {
|
||||||
nodes: nodes.map((node) => ({
|
try {
|
||||||
id: node.id,
|
await img.decode();
|
||||||
type: node.type || 'custom',
|
return;
|
||||||
position: node.position,
|
} catch {
|
||||||
dragHandle: node.dragHandle || '.drag-handle',
|
// Fall back to load/error listeners below.
|
||||||
data: {
|
}
|
||||||
label: node.data?.label || node.data?.className || 'Node',
|
}
|
||||||
className: node.data?.className || '',
|
await new Promise((resolve) => {
|
||||||
widgetValues: node.data?.widgetValues || {},
|
const done = () => {
|
||||||
},
|
img.removeEventListener('load', done);
|
||||||
})),
|
img.removeEventListener('error', done);
|
||||||
edges: edges.map((edge) => ({
|
resolve();
|
||||||
id: edge.id,
|
};
|
||||||
source: edge.source,
|
img.addEventListener('load', done, { once: true });
|
||||||
sourceHandle: edge.sourceHandle,
|
img.addEventListener('error', done, { once: true });
|
||||||
target: edge.target,
|
});
|
||||||
targetHandle: edge.targetHandle,
|
}
|
||||||
style: edge.style,
|
|
||||||
})),
|
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 ───────────────────────
|
// ── Graph serialisation → backend prompt format ───────────────────────
|
||||||
@@ -505,7 +571,7 @@ function Flow() {
|
|||||||
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
||||||
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
||||||
|
|
||||||
const blob = await toBlob(viewportEl, {
|
const blob = await captureViewportBlob(viewportEl, {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
width: imageWidth,
|
width: imageWidth,
|
||||||
height: imageHeight,
|
height: imageHeight,
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ export default function SurfaceView({ meshData }) {
|
|||||||
const width = container.clientWidth;
|
const width = container.clientWidth;
|
||||||
const height = width; // 1:1 aspect
|
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.setSize(width, height);
|
||||||
renderer.setPixelRatio(window.devicePixelRatio);
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
renderer.setClearColor(0x0f172a);
|
renderer.setClearColor(0x0f172a);
|
||||||
|
|||||||
24
frontend/src/workflowSerialization.js
Normal file
24
frontend/src/workflowSerialization.js
Normal file
@@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
109
frontend/tests/pngMetadata.test.mjs
Normal file
109
frontend/tests/pngMetadata.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
91
frontend/tests/workflowSerialization.test.mjs
Normal file
91
frontend/tests/workflowSerialization.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"dev": "npm --prefix frontend run dev",
|
"dev": "npm --prefix frontend run dev",
|
||||||
"build": "npm --prefix frontend run build",
|
"build": "npm --prefix frontend run build",
|
||||||
"preview": "npm --prefix frontend run preview",
|
"preview": "npm --prefix frontend run preview",
|
||||||
|
"test:frontend": "npm --prefix frontend test",
|
||||||
"backend": "python -m backend.main",
|
"backend": "python -m backend.main",
|
||||||
"desktop": "python desktop.py",
|
"desktop": "python desktop.py",
|
||||||
"build:windows": "powershell -ExecutionPolicy Bypass -File scripts\\build-windows.ps1",
|
"build:windows": "powershell -ExecutionPolicy Bypass -File scripts\\build-windows.ps1",
|
||||||
|
|||||||
Reference in New Issue
Block a user