diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cbfa37d..89a2bea 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 { 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, diff --git a/frontend/src/workflowCapture.js b/frontend/src/workflowCapture.js new file mode 100644 index 0000000..e5bd537 --- /dev/null +++ b/frontend/src/workflowCapture.js @@ -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()); + } +} diff --git a/frontend/tests/workflowCapture.test.mjs b/frontend/tests/workflowCapture.test.mjs new file mode 100644 index 0000000..09f160a --- /dev/null +++ b/frontend/tests/workflowCapture.test.mjs @@ -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); +});