fix snapshot for the umpteenth time, now with custom line widgets
This commit is contained in:
@@ -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 { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
||||
import { hydrateWorkflowState } from './workflowHydration';
|
||||
import { serializeWorkflowState } from './workflowSerialization';
|
||||
|
||||
@@ -867,7 +868,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 captureViewportBlob(viewportEl, {
|
||||
const blob = await captureWorkflowViewportBlob(viewportEl, {
|
||||
backgroundColor: '#1a1a1a',
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
|
||||
192
frontend/src/workflowCapture.js
Normal file
192
frontend/src/workflowCapture.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import { toBlob } from 'html-to-image';
|
||||
|
||||
export const OVERLAY_CAPTURE_SELECTORS = [
|
||||
'.lineplot-overlay',
|
||||
'.cs-overlay',
|
||||
'.crop-overlay',
|
||||
];
|
||||
|
||||
function encodeBase64(bytes) {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
if (!blob) return null;
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
return `data:${blob.type || 'image/png'};base64,${encodeBase64(bytes)}`;
|
||||
}
|
||||
|
||||
function getElementSize(el) {
|
||||
const rect = el.getBoundingClientRect?.() ?? { width: 0, height: 0 };
|
||||
return {
|
||||
width: Math.max(1, Math.round(el.clientWidth || rect.width || 0)),
|
||||
height: Math.max(1, Math.round(el.clientHeight || rect.height || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
export 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 });
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCaptureImageDataUrl(img) {
|
||||
const src = img.currentSrc || img.src;
|
||||
if (!src) return null;
|
||||
if (!src.startsWith('data:')) return src;
|
||||
|
||||
const { width, height } = getElementSize(img);
|
||||
const scale = Math.min(2, globalThis.window?.devicePixelRatio || 1);
|
||||
|
||||
const canvas = globalThis.document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(width * scale));
|
||||
canvas.height = Math.max(1, Math.round(height * scale));
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return src;
|
||||
|
||||
try {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCapturePlaceholder(
|
||||
el,
|
||||
dataUrl,
|
||||
{ stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) } = {},
|
||||
) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyleFn(el);
|
||||
const placeholder = documentRef.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 = stretch ? '100% 100%' : 'contain';
|
||||
placeholder.style.flexShrink = style.flexShrink;
|
||||
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
async function renderCanvasToDataUrl(canvas) {
|
||||
try {
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderElementToDataUrl(el, toBlobImpl) {
|
||||
const { width, height } = getElementSize(el);
|
||||
const blob = await toBlobImpl(el, {
|
||||
width,
|
||||
height,
|
||||
backgroundColor: '#0f172a',
|
||||
style: {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: 'none',
|
||||
},
|
||||
});
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
async function replaceElementsWithPlaceholders(elements, renderDataUrl, createPlaceholderFn, stretch) {
|
||||
const restorers = [];
|
||||
|
||||
for (const el of elements) {
|
||||
if (!el?.parentNode) continue;
|
||||
const dataUrl = await renderDataUrl(el);
|
||||
if (!dataUrl) continue;
|
||||
|
||||
const placeholder = createPlaceholderFn(el, dataUrl, { stretch });
|
||||
el.parentNode.replaceChild(placeholder, el);
|
||||
restorers.push(() => {
|
||||
if (placeholder.parentNode) {
|
||||
placeholder.parentNode.replaceChild(el, placeholder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return restorers;
|
||||
}
|
||||
|
||||
function defaultQueryAll(root, selector) {
|
||||
return Array.from(root.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
function defaultNextFrame() {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
export async function captureViewportBlob(viewportEl, options, deps = {}) {
|
||||
const queryAll = deps.queryAll ?? defaultQueryAll;
|
||||
const toBlobImpl = deps.toBlobImpl ?? toBlob;
|
||||
const waitForImage = deps.waitForImageElement ?? waitForImageElement;
|
||||
const renderImage = deps.renderImageToDataUrl ?? getCaptureImageDataUrl;
|
||||
const renderCanvas = deps.renderCanvasToDataUrl ?? renderCanvasToDataUrl;
|
||||
const renderOverlay = deps.renderOverlayToDataUrl ?? ((el) => renderElementToDataUrl(el, toBlobImpl));
|
||||
const createPlaceholderFn = deps.createPlaceholder ?? createCapturePlaceholder;
|
||||
const nextFrame = deps.nextFrame ?? defaultNextFrame;
|
||||
const overlaySelectors = deps.overlaySelectors ?? OVERLAY_CAPTURE_SELECTORS;
|
||||
const restorers = [];
|
||||
|
||||
const overlays = [];
|
||||
const seen = new Set();
|
||||
for (const selector of overlaySelectors) {
|
||||
for (const el of queryAll(viewportEl, selector)) {
|
||||
if (seen.has(el)) continue;
|
||||
seen.add(el);
|
||||
overlays.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
const images = queryAll(viewportEl, 'img');
|
||||
await Promise.all(images.map(waitForImage));
|
||||
|
||||
try {
|
||||
restorers.push(...await replaceElementsWithPlaceholders(overlays, renderOverlay, createPlaceholderFn, true));
|
||||
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage, createPlaceholderFn, false));
|
||||
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas, createPlaceholderFn, true));
|
||||
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
return await toBlobImpl(viewportEl, options);
|
||||
} finally {
|
||||
restorers.reverse().forEach((restore) => restore());
|
||||
}
|
||||
}
|
||||
136
frontend/tests/workflowCapture.test.mjs
Normal file
136
frontend/tests/workflowCapture.test.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { OVERLAY_CAPTURE_SELECTORS, captureViewportBlob } from '../src/workflowCapture.js';
|
||||
|
||||
function makeElement(name, { tagName = 'DIV', width = 160, height = 90, src = '' } = {}) {
|
||||
return {
|
||||
name,
|
||||
tagName,
|
||||
src,
|
||||
currentSrc: src,
|
||||
complete: true,
|
||||
naturalWidth: 1,
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
parentNode: null,
|
||||
getBoundingClientRect() {
|
||||
return { width, height };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeParent(initialChild) {
|
||||
return {
|
||||
currentChild: initialChild,
|
||||
replaceChild(nextChild, prevChild) {
|
||||
assert.equal(this.currentChild, prevChild);
|
||||
this.currentChild = nextChild;
|
||||
nextChild.parentNode = this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeViewport({ overlay, image, canvas }) {
|
||||
return {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.lineplot-overlay') {
|
||||
return overlay.parentNode?.currentChild === overlay ? [overlay] : [];
|
||||
}
|
||||
if (selector === '.cs-overlay' || selector === '.crop-overlay') {
|
||||
return [];
|
||||
}
|
||||
if (selector === 'img') {
|
||||
return image.parentNode?.currentChild === image ? [image] : [];
|
||||
}
|
||||
if (selector === 'canvas') {
|
||||
return canvas.parentNode?.currentChild === canvas ? [canvas] : [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('captureViewportBlob rasterizes custom overlays before the final workflow snapshot', async () => {
|
||||
const overlay = makeElement('overlay', { width: 320, height: 220 });
|
||||
const image = makeElement('image', { tagName: 'IMG', width: 180, height: 120, src: 'data:image/png;base64,abc' });
|
||||
const canvas = makeElement('canvas', { tagName: 'CANVAS', width: 128, height: 128 });
|
||||
canvas.toDataURL = () => 'data:image/png;base64,canvas';
|
||||
|
||||
const overlayParent = makeParent(overlay);
|
||||
const imageParent = makeParent(image);
|
||||
const canvasParent = makeParent(canvas);
|
||||
overlay.parentNode = overlayParent;
|
||||
image.parentNode = imageParent;
|
||||
canvas.parentNode = canvasParent;
|
||||
|
||||
const selectorsSeen = [];
|
||||
const viewport = makeViewport({ overlay, image, canvas });
|
||||
const blob = await captureViewportBlob(viewport, { width: 800, height: 600 }, {
|
||||
queryAll(root, selector) {
|
||||
selectorsSeen.push(selector);
|
||||
return root.querySelectorAll(selector);
|
||||
},
|
||||
waitForImageElement: async () => {},
|
||||
renderOverlayToDataUrl: async (el) => `data:image/png;base64,overlay-${el.name}`,
|
||||
renderImageToDataUrl: async (el) => `data:image/png;base64,image-${el.name}`,
|
||||
renderCanvasToDataUrl: async (el) => `data:image/png;base64,canvas-${el.name}`,
|
||||
createPlaceholder: (el, dataUrl) => ({ placeholderFor: el.name, dataUrl, parentNode: null }),
|
||||
nextFrame: async () => {},
|
||||
toBlobImpl: async (target) => {
|
||||
if (target === viewport) {
|
||||
assert.notEqual(overlayParent.currentChild, overlay);
|
||||
assert.notEqual(imageParent.currentChild, image);
|
||||
assert.notEqual(canvasParent.currentChild, canvas);
|
||||
return new Blob(['viewport'], { type: 'image/png' });
|
||||
}
|
||||
throw new Error('Unexpected element capture path');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await blob.text(), 'viewport');
|
||||
assert.equal(overlayParent.currentChild, overlay);
|
||||
assert.equal(imageParent.currentChild, image);
|
||||
assert.equal(canvasParent.currentChild, canvas);
|
||||
assert.deepEqual(
|
||||
selectorsSeen.filter((selector) => OVERLAY_CAPTURE_SELECTORS.includes(selector)),
|
||||
OVERLAY_CAPTURE_SELECTORS,
|
||||
);
|
||||
});
|
||||
|
||||
test('captureViewportBlob restores live elements when final capture fails', async () => {
|
||||
const overlay = makeElement('overlay', { width: 320, height: 220 });
|
||||
const image = makeElement('image', { tagName: 'IMG', width: 180, height: 120, src: 'data:image/png;base64,abc' });
|
||||
const canvas = makeElement('canvas', { tagName: 'CANVAS', width: 128, height: 128 });
|
||||
canvas.toDataURL = () => 'data:image/png;base64,canvas';
|
||||
|
||||
const overlayParent = makeParent(overlay);
|
||||
const imageParent = makeParent(image);
|
||||
const canvasParent = makeParent(canvas);
|
||||
overlay.parentNode = overlayParent;
|
||||
image.parentNode = imageParent;
|
||||
canvas.parentNode = canvasParent;
|
||||
|
||||
const viewport = makeViewport({ overlay, image, canvas });
|
||||
|
||||
await assert.rejects(
|
||||
captureViewportBlob(viewport, { width: 800, height: 600 }, {
|
||||
queryAll: (root, selector) => root.querySelectorAll(selector),
|
||||
waitForImageElement: async () => {},
|
||||
renderOverlayToDataUrl: async () => 'data:image/png;base64,overlay',
|
||||
renderImageToDataUrl: async () => 'data:image/png;base64,image',
|
||||
renderCanvasToDataUrl: async () => 'data:image/png;base64,canvas',
|
||||
createPlaceholder: (el) => ({ placeholderFor: el.name, parentNode: null }),
|
||||
nextFrame: async () => {},
|
||||
toBlobImpl: async (target) => {
|
||||
if (target === viewport) throw new Error('capture failed');
|
||||
return new Blob(['unexpected'], { type: 'image/png' });
|
||||
},
|
||||
}),
|
||||
/capture failed/,
|
||||
);
|
||||
|
||||
assert.equal(overlayParent.currentChild, overlay);
|
||||
assert.equal(imageParent.currentChild, image);
|
||||
assert.equal(canvasParent.currentChild, canvas);
|
||||
});
|
||||
Reference in New Issue
Block a user