diff --git a/.coverage b/.coverage index 43e750b..a53ebb9 100644 Binary files a/.coverage and b/.coverage differ diff --git a/frontend/coverage/lcov-report/angleMeasureGeometry.js.html b/frontend/coverage/lcov-report/angleMeasureGeometry.js.html new file mode 100644 index 0000000..b3d1cd4 --- /dev/null +++ b/frontend/coverage/lcov-report/angleMeasureGeometry.js.html @@ -0,0 +1,316 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 | 18x +18x +18x +1x +1x +12x +12x +1x +1x +2x +2x +2x +2x +2x +2x + + +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + | function clamp01(value) {
+ return Math.max(0, Math.min(1, Number(value) || 0));
+}
+
+export function round3(value) {
+ return Number.parseFloat(Number(value).toFixed(3));
+}
+
+export function getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2) {
+ const va = { x: Number(x1) - Number(xm), y: Number(y1) - Number(ym) };
+ const vb = { x: Number(x2) - Number(xm), y: Number(y2) - Number(ym) };
+ const lenA = Math.hypot(va.x, va.y);
+ const lenB = Math.hypot(vb.x, vb.y);
+
+ if (lenA <= 1e-6 || lenB <= 1e-6) {
+ return { x: clamp01(xm), y: clamp01(Number(ym) - 0.14) };
+ }
+
+ const unit = {
+ x: (va.x / lenA) + (vb.x / lenB),
+ y: (va.y / lenA) + (vb.y / lenB),
+ };
+ const unitLength = Math.hypot(unit.x, unit.y);
+ const bisector = unitLength <= 1e-6
+ ? { x: 0, y: -1 }
+ : { x: unit.x / unitLength, y: unit.y / unitLength };
+
+ return {
+ x: clamp01(Number(xm) + bisector.x * 0.14),
+ y: clamp01(Number(ym) + bisector.y * 0.14),
+ };
+}
+
+export function getAngleLabelPosition(points, labelDx = 0, labelDy = 0) {
+ const base = getAngleLabelBasePosition(points.x1, points.y1, points.xm, points.ym, points.x2, points.y2);
+ return {
+ x: clamp01(base.x + (Number(labelDx) || 0)),
+ y: clamp01(base.y + (Number(labelDy) || 0)),
+ };
+}
+
+export function moveAngleWidget(points, dx, dy) {
+ const nextDx = Number(dx) || 0;
+ const nextDy = Number(dy) || 0;
+ const xs = [points.x1, points.xm, points.x2];
+ const ys = [points.y1, points.ym, points.y2];
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const minY = Math.min(...ys);
+ const maxY = Math.max(...ys);
+ const clampedDx = Math.max(-minX, Math.min(1 - maxX, nextDx));
+ const clampedDy = Math.max(-minY, Math.min(1 - maxY, nextDy));
+
+ return {
+ x1: round3(clamp01(points.x1 + clampedDx)),
+ y1: round3(clamp01(points.y1 + clampedDy)),
+ xm: round3(clamp01(points.xm + clampedDx)),
+ ym: round3(clamp01(points.ym + clampedDy)),
+ x2: round3(clamp01(points.x2 + clampedDx)),
+ y2: round3(clamp01(points.y2 + clampedDy)),
+ };
+}
+
+export function measureAngleDegrees(x1, y1, xm, ym, x2, y2) {
+ const ax = Number(x1) - Number(xm);
+ const ay = Number(y1) - Number(ym);
+ const bx = Number(x2) - Number(xm);
+ const by = Number(y2) - Number(ym);
+ const lenA = Math.hypot(ax, ay);
+ const lenB = Math.hypot(bx, by);
+
+ if (lenA <= 1e-12 || lenB <= 1e-12) return 0;
+
+ const cosTheta = ((ax * bx) + (ay * by)) / (lenA * lenB);
+ const clamped = Math.max(-1, Math.min(1, cosTheta));
+ return Math.acos(clamped) * (180 / Math.PI);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 | 1x +1x +1x +42x +42x +42x +42x + + + +42x +1x +17x +17x +17x +1x +25x +25x +25x +25x +1x +1x +9x +9x +6x +9x +1x +1x +3x +3x +3x + + +2x +3x +1x +1x +5x +5x +5x +2x +2x +2x +5x +1x +1x +3x +3x +3x + | const EXCLUDED_CANVAS_TARGETS = '.context-menu, .react-flow__node, .react-flow__edge, .react-flow__controls, .react-flow__minimap, .surface-view-container';
+const CANVAS_AREA_TARGETS = '.react-flow, .react-flow__renderer, .react-flow__viewport, .react-flow__pane, .react-flow__background, .react-flow__selectionpane';
+
+function getTargetElement(target) {
+ if (!target) return null;
+ if (typeof target.closest === 'function') return target;
+ if (target.parentElement && typeof target.parentElement.closest === 'function') {
+ return target.parentElement;
+ }
+ return null;
+}
+
+function supportsClosest(target) {
+ return !!getTargetElement(target);
+}
+
+function matchesClosest(target, selector) {
+ const element = getTargetElement(target);
+ return !!element && element.closest(selector) !== null;
+}
+
+export function isEditableInteractionTarget(target) {
+ if (!supportsClosest(target)) return false;
+ if (matchesClosest(target, 'input, textarea, select')) return true;
+ return matchesClosest(target, '[contenteditable="true"]');
+}
+
+export function canStartCanvasRightDragZoomTarget(target) {
+ if (!supportsClosest(target)) return false;
+ if (isEditableInteractionTarget(target)) return false;
+ if (matchesClosest(target, EXCLUDED_CANVAS_TARGETS)) {
+ return false;
+ }
+ return matchesClosest(target, CANVAS_AREA_TARGETS);
+}
+
+export function canOpenCanvasContextMenuTarget(target) {
+ if (!supportsClosest(target)) return false;
+ if (isEditableInteractionTarget(target)) return false;
+ if (matchesClosest(target, EXCLUDED_CANVAS_TARGETS)) {
+ return false;
+ }
+ return matchesClosest(target, CANVAS_AREA_TARGETS);
+}
+
+export function isSecondaryCanvasContextEvent(event) {
+ if (!event || typeof event.button !== 'number') return false;
+ return event.button === 2 || (event.button === 0 && !!event.ctrlKey);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 | 1x +1x +1x +1x +1x +1x +1x +1x +20x +20x +1x +1x +12x +12x +1x +1x +5x +5x +1x +1x +11x +11x +1x +1x +12x +12x +12x +1x +1x +12x +1x +1x +33x +33x +9x +33x +7x +7x +7x +7x +7x +7x +33x +1x +1x +11x +11x +11x +1x +1x +10x +10x +10x +10x +10x +10x +10x +1x +1x +14x +14x +10x +10x +14x +14x +14x +1x +1x +1x +1x +17x +7x +7x +10x +17x +3x +3x +3x +8x +17x +17x +3x +3x +17x +17x +1x +1x +8x +3x +3x +8x +1x +1x +8x +1x +1x +8x +1x +1x +2x +8x +1x +1x +1x +1x +1x +1x +8x +8x +8x +8x +8x +3x +3x +8x +8x +8x +8x +8x +8x +8x +8x + | // ── Connection utility functions ───────────────────────────────────────
+// Pure functions extracted from App.jsx so they can be independently tested.
+
+import { socketSpecAcceptsType } from './constants.js';
+
+// ── Handle ID helpers ─────────────────────────────────────────────────
+
+export function getHandleType(handleId) {
+ return handleId.split('::')[2];
+}
+
+export function getInputName(handleId) {
+ return handleId.split('::')[1];
+}
+
+export function getOutputSlot(handleId) {
+ return parseInt(handleId.split('::')[1], 10);
+}
+
+export function encodeProxyHandleRef(handleId) {
+ return encodeURIComponent(String(handleId || ''));
+}
+
+export function decodeProxyHandleRef(encoded) {
+ try {
+ return decodeURIComponent(String(encoded || ''));
+ } catch {
+ return String(encoded || '');
+ }
+}
+
+export function parseGroupProxyHandle(handleId) {
+ const text = String(handleId || '');
+ if (!text.startsWith('group-proxy::')) return null;
+ const parts = text.split('::');
+ if (parts.length < 5) return null;
+ return {
+ direction: parts[1],
+ nodeId: parts[2],
+ type: parts[3],
+ realHandle: decodeProxyHandleRef(parts.slice(4).join('::')),
+ };
+}
+
+export function getConnectionHandleType(handleId) {
+ const proxy = parseGroupProxyHandle(handleId);
+ return proxy?.type || getHandleType(handleId);
+}
+
+export function getResolvedHandleRef(nodeId, handleId) {
+ const proxy = parseGroupProxyHandle(handleId);
+ return {
+ nodeId: proxy?.nodeId || nodeId,
+ handleId: proxy?.realHandle || handleId,
+ type: proxy?.type || getHandleType(handleId),
+ };
+}
+
+export function getNodeInputSpecForHandle(node, handleId) {
+ const definition = node?.data?.definition;
+ if (!definition?.input) return null;
+ const inputName = getInputName(handleId);
+ return definition.input.required?.[inputName]
+ || definition.input.optional?.[inputName]
+ || null;
+}
+
+// ── Type compatibility ────────────────────────────────────────────────
+
+export function outputTypeCanConnectToTarget(outputType, targetSpecOrType, outputAcceptedTypes = []) {
+ if (socketSpecAcceptsType(outputType, targetSpecOrType)) {
+ return true;
+ }
+ // Polymorphic output: the output socket declares it can also produce the target type
+ if (outputAcceptedTypes.length > 0) {
+ const targetType = Array.isArray(targetSpecOrType) ? targetSpecOrType[0] : targetSpecOrType;
+ if (outputAcceptedTypes.includes(targetType)) return true;
+ }
+ return outputType === 'ANNOTATION_SOURCE'
+ && !socketSpecAcceptsType('ANNOTATION_SOURCE', targetSpecOrType)
+ && (
+ socketSpecAcceptsType('DATA_FIELD', targetSpecOrType)
+ || socketSpecAcceptsType('IMAGE', targetSpecOrType)
+ );
+}
+
+export function resolveOutputTypeForTarget(outputType, targetSpecOrType) {
+ if (outputType !== 'ANNOTATION_SOURCE') {
+ return outputType;
+ }
+ if (socketSpecAcceptsType('ANNOTATION_SOURCE', targetSpecOrType)) {
+ return 'ANNOTATION_SOURCE';
+ }
+ if (socketSpecAcceptsType('DATA_FIELD', targetSpecOrType)) {
+ return 'DATA_FIELD';
+ }
+ if (socketSpecAcceptsType('IMAGE', targetSpecOrType)) {
+ return 'IMAGE';
+ }
+ return 'ANNOTATION_SOURCE';
+}
+
+// ── Pure connection validation ────────────────────────────────────────
+// Extracted from the isValidConnection useCallback so it can be unit-tested
+// without a ReactFlow context. Pass a `getNodeFn` that mirrors reactFlow.getNode.
+
+export function checkConnectionValid(connection, getNodeFn) {
+ const srcType = getConnectionHandleType(connection.sourceHandle);
+ const resolvedTarget = getResolvedHandleRef(connection.target, connection.targetHandle);
+ const targetNode = getNodeFn(resolvedTarget.nodeId);
+ const targetSpec = getNodeInputSpecForHandle(targetNode, resolvedTarget.handleId) || resolvedTarget.type;
+ if (socketSpecAcceptsType(srcType, targetSpec)) return true;
+ // Polymorphic output: check if the source output declares it can produce the target type
+ const srcProxy = parseGroupProxyHandle(connection.sourceHandle);
+ const srcNodeId = srcProxy ? srcProxy.nodeId : connection.source;
+ const srcHandleId = srcProxy ? srcProxy.realHandle : connection.sourceHandle;
+ const srcNode = getNodeFn(srcNodeId);
+ const srcSlot = getOutputSlot(srcHandleId);
+ const srcAcceptedTypes = srcNode?.data?.definition?.output_accepted_types?.[srcSlot] || [];
+ const targetType = Array.isArray(targetSpec) ? targetSpec[0] : targetSpec;
+ return Array.isArray(srcAcceptedTypes) && srcAcceptedTypes.includes(targetType);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 | 5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +60x +60x +60x + +60x +5x +5x +23x +23x +5x +5x +23x +23x +23x +5x +5x +50x +50x +50x +50x + + +50x +50x +50x +50x +3x +3x +3x +3x +50x +50x +50x +17x +17x +17x +17x +50x +50x +50x +5x +5x +49x +49x +49x +5x +5x +5x +5x +5x +5x +5x + | // ── Shared type & color constants ─────────────────────────────────────
+
+export const DATA_TYPES = new Set([
+ 'DATA_FIELD', 'IMAGE', 'LINE', 'RECORD_TABLE', 'DATA_TABLE',
+ 'COORD', 'ANNOTATION_SOURCE', 'COLORMAP',
+ 'MESH_MODEL', 'FONT', 'FILE_PATH', 'DIRECTORY', 'COORDPAIR',
+]);
+
+export const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']);
+
+export const TYPE_COLORS = {
+ DATA_FIELD: '#3a7abf',
+ IMAGE: '#00ff08a0',
+ LINE: '#ffbe5c',
+ RECORD_TABLE: '#35e2fd',
+ DATA_TABLE: '#ff7474',
+ COORD: '#e91ed1',
+ COORDPAIR: '#5cb861',
+ FLOAT: '#ab3197',
+ INT: '#ffffff',
+ ANNOTATION_SOURCE: '#06b6d4',
+ COLORMAP: '#f472b6',
+ MESH_MODEL: '#14b8a6',
+ FONT: '#fb7185',
+ FILE_PATH: '#f59e0b',
+ DIRECTORY: '#f97316',
+};
+
+export const CAT_COLORS = {
+ Input: '#37474f',
+ Display: '#212121',
+ Overlay: '#0f766e',
+ Geometry: '#0d9488',
+ Filter: '#1a237e',
+ Spectral: '#4c1d95',
+ 'Level & Correct': '#1b5e20',
+ Measure: '#4a148c',
+ Mask: '#7c2d12',
+ Grains: '#bf360c',
+};
+
+export const SOCKET_COMPATIBILITY = {
+ FLOAT: new Set(['INT']),
+ INT: new Set(['FLOAT']),
+ LINE: new Set(['COORDPAIR']),
+};
+
+const EMPTY_SOCKET_TYPE_SET = new Set();
+
+export function getSpecTypeAndOptions(spec) {
+ if (Array.isArray(spec)) {
+ return [spec[0], spec[1] || {}];
+ }
+ return [spec, {}];
+}
+
+export function isDataSocketType(type) {
+ return typeof type === 'string' && DATA_TYPES.has(type);
+}
+
+export function isDataSocketSpec(spec) {
+ const [type] = getSpecTypeAndOptions(spec);
+ return isDataSocketType(type);
+}
+
+export function getAcceptedSocketTypes(specOrType) {
+ const [type, opts] = Array.isArray(specOrType)
+ ? getSpecTypeAndOptions(specOrType)
+ : [specOrType, {}];
+ if (typeof type !== 'string') {
+ return EMPTY_SOCKET_TYPE_SET;
+ }
+
+ const accepted = new Set([type]);
+ const explicitAccepted = Array.isArray(opts?.accepted_types) ? opts.accepted_types : [];
+ for (const acceptedType of explicitAccepted) {
+ if (typeof acceptedType === 'string' && acceptedType) {
+ accepted.add(acceptedType);
+ }
+ }
+
+ const fallbackAccepted = SOCKET_COMPATIBILITY[type];
+ if (fallbackAccepted) {
+ for (const acceptedType of fallbackAccepted) {
+ accepted.add(acceptedType);
+ }
+ }
+
+ return accepted;
+}
+
+export function socketSpecAcceptsType(sourceType, targetSpecOrType) {
+ if (typeof sourceType !== 'string' || !sourceType) return false;
+ return getAcceptedSocketTypes(targetSpecOrType).has(sourceType);
+}
+
+// Colors used in Canvas 2D / toBlob contexts where CSS var() is unavailable.
+export const CANVAS_COLORS = {
+ bgDeep: '#0f172a',
+ maskStroke: '#ffffff',
+ maskOverlay: 'rgba(255, 59, 59, 0.16)',
+};
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 | 1x +1x +1x +1x +1x +1x +1x +11x +11x +11x +11x +11x +2x +2x +9x +11x +11x +11x +11x +5x + + +4x +11x +2x +1x +1x +2x + + +2x +2x +11x +1x +11x + + +1x +11x +1x +1x +6x +6x +6x +6x +11x +11x +2x +2x +2x +2x +2x +2x +11x +4x +6x + | import { extractWorkflow } from './pngMetadata.js';
+
+const DEFAULT_WORKFLOW_CANDIDATES = [
+ { path: '/default-workflow.json', type: 'json' },
+ { path: '/default-workflow.png', type: 'png' },
+];
+
+async function loadCandidate(candidate, fetchImpl, extractWorkflowFn) {
+ let response;
+ try {
+ response = await fetchImpl(candidate.path, { cache: 'no-store' });
+ } catch {
+ return null;
+ }
+
+ const contentType = response.headers?.get?.('content-type') || '';
+ const isHtmlFallback = typeof contentType === 'string' && contentType.toLowerCase().includes('text/html');
+
+ if (!response.ok) {
+ if (response.status === 404 || response.status === 0) return null;
+ throw new Error(`Failed to load ${candidate.path} (${response.status})`);
+ }
+
+ if (candidate.type === 'json') {
+ if (isHtmlFallback) return null;
+ try {
+ return await response.json();
+ } catch {
+ throw new Error(`${candidate.path} is not valid JSON`);
+ }
+ }
+
+ if (isHtmlFallback) return null;
+ const workflow = await extractWorkflowFn(await response.blob());
+ if (!workflow) {
+ throw new Error(`${candidate.path} does not contain embedded workflow metadata`);
+ }
+ return workflow;
+}
+
+export async function loadDefaultWorkflowAsset({
+ fetchImpl = fetch,
+ extractWorkflowFn = extractWorkflow,
+} = {}) {
+ for (const candidate of DEFAULT_WORKFLOW_CANDIDATES) {
+ const workflow = await loadCandidate(candidate, fetchImpl, extractWorkflowFn);
+ if (workflow) {
+ return {
+ source: candidate.path,
+ format: candidate.type,
+ workflow,
+ };
+ }
+ }
+ return null;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +9x +9x +9x +1x +5x +5x +5x +1x +22x +22x +22x +3x +3x +22x +22x +22x +22x +22x +22x +1x +1x +11x +11x +7x +7x +7x +7x +11x +11x +1x +11x +11x +11x +1x +9x +9x +5x +5x +9x +4x +4x + +9x +1x +11x +11x +11x +11x +11x +25x +25x +6x +6x +25x +11x +11x +11x +1x +1x +7x +7x +7x +7x +16x +13x +13x +16x +16x +16x +13x +13x +16x +16x +16x +16x +16x +16x +16x +16x +18x +12x +18x +18x +7x +7x +7x +18x +13x +13x +13x +13x +16x +5x +5x +5x +5x +13x +13x +13x +7x +7x +7x +1x +1x +4x +4x +4x +1x +1x +5x +5x +5x +5x +5x +7x +7x +7x +7x +7x +7x +1x +1x +1x +1x +7x +7x +7x +7x +6x +7x +2x +1x +1x +7x +4x +4x +3x +3x +3x +4x +7x +7x +3x +3x +5x + | import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.js';
+
+const OMITTED_WIDGET_INPUTS_BY_CLASS = {
+ View3D: new Set([
+ 'camera_azimuth',
+ 'camera_polar',
+ 'camera_distance',
+ 'camera_target_x',
+ 'camera_target_y',
+ 'camera_target_z',
+ ]),
+};
+
+function getInputName(handleId) {
+ return handleId.split('::')[1];
+}
+
+function getOutputSlot(handleId) {
+ return parseInt(handleId.split('::')[1], 10);
+}
+
+function resolveExecutionEdge(edge) {
+ const original = edge?.data?.groupProxyOriginal;
+ if (!original) return edge;
+ return {
+ ...edge,
+ source: original.source || edge.source,
+ sourceHandle: original.sourceHandle || edge.sourceHandle,
+ target: original.target || edge.target,
+ targetHandle: original.targetHandle || edge.targetHandle,
+ };
+}
+
+export function getConnectedNodeIds(edges) {
+ const connectedNodeIds = new Set();
+ for (const edge of edges) {
+ const resolved = resolveExecutionEdge(edge);
+ connectedNodeIds.add(resolved.source);
+ connectedNodeIds.add(resolved.target);
+ }
+ return connectedNodeIds;
+}
+
+function isPreviewLoadNode(node) {
+ return ['Image', 'ImageDemo'].includes(node?.data?.className);
+}
+
+function hasPreviewLoadSelection(node) {
+ if (node?.data?.className === 'Image') {
+ return !!String(node.data?.widgetValues?.filename || '').trim();
+ }
+ if (node?.data?.className === 'ImageDemo') {
+ return !!String(node.data?.widgetValues?.name || '').trim();
+ }
+ return false;
+}
+
+function getRunnableNodeIds(nodes, edges) {
+ const connectedNodeIds = getConnectedNodeIds(edges);
+
+ const runnableNodeIds = new Set(connectedNodeIds);
+ for (const node of nodes) {
+ if (connectedNodeIds.has(node.id)) continue;
+ if (isPreviewLoadNode(node) && hasPreviewLoadSelection(node)) {
+ runnableNodeIds.add(node.id);
+ }
+ }
+
+ return runnableNodeIds;
+}
+
+export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = false } = {}) {
+ const runnableNodeIds = getRunnableNodeIds(nodes, edges);
+ const prompt = {};
+
+ for (const node of nodes) {
+ if (!runnableNodeIds.has(node.id)) continue;
+
+ const { className, definition, widgetValues, runtimeValues } = node.data;
+ if (className === 'Group') continue;
+ if (!definition) continue;
+ if (excludeManualTrigger && definition.manual_trigger) continue;
+
+ const inputs = {};
+ const valueBag = { ...(widgetValues || {}), ...(runtimeValues || {}) };
+ const omittedInputs = OMITTED_WIDGET_INPUTS_BY_CLASS[className] || null;
+
+ const allWidgets = {
+ ...(definition.input.required || {}),
+ ...(definition.input.optional || {}),
+ };
+ for (const [name, spec] of Object.entries(allWidgets)) {
+ if (omittedInputs?.has(name)) continue;
+ const [type] = getSpecTypeAndOptions(spec);
+ if (isDataSocketSpec(spec)) continue;
+ if (type === 'BUTTON') continue;
+ if (valueBag[name] !== undefined) {
+ inputs[name] = valueBag[name];
+ }
+ }
+
+ const incoming = edges
+ .map(resolveExecutionEdge)
+ .filter((edge) => edge.target === node.id);
+ for (const edge of incoming) {
+ const inputName = getInputName(edge.targetHandle);
+ const outputSlot = getOutputSlot(edge.sourceHandle);
+ inputs[inputName] = [edge.source, outputSlot];
+ }
+
+ prompt[node.id] = { class_type: className, inputs };
+ }
+
+ return prompt;
+}
+
+export function getAutoRunnableNodes(nodes, edges) {
+ const runnableNodeIds = getRunnableNodeIds(nodes, edges);
+ return nodes.filter((node) => runnableNodeIds.has(node.id));
+}
+
+export function hasBlockingAutoRunInput(node, edges) {
+ const def = node.data?.definition;
+ if (!def || def.manual_trigger) return false;
+
+ const required = def.input.required || {};
+ for (const [name, spec] of Object.entries(required)) {
+ const [type, opts] = getSpecTypeAndOptions(spec);
+ const hiddenByConnectedInput = (() => {
+ const raw = opts?.hide_when_input_connected;
+ if (!raw) return false;
+ const inputs = Array.isArray(raw) ? raw : [raw];
+ return inputs.some((inputName) => edges.some(
+ (edge) => {
+ const resolved = resolveExecutionEdge(edge);
+ return resolved.target === node.id && getInputName(resolved.targetHandle) === String(inputName);
+ }
+ ));
+ })();
+
+ if (hiddenByConnectedInput) continue;
+
+ if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
+ if (!node.data.widgetValues?.[name]) return true;
+ continue;
+ }
+ if (!isDataSocketSpec(spec)) continue;
+ const hasEdge = edges.some(
+ (edge) => {
+ const resolved = resolveExecutionEdge(edge);
+ return resolved.target === node.id && getInputName(resolved.targetHandle) === name;
+ }
+ );
+ if (!hasEdge) return true;
+ }
+
+ return false;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 | 1x +1x +1x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +1x +1x +2x +2x + | export const GROUP_DRAG_RELEASE_DISTANCE = 18;
+
+export function getPointDistanceOutsideRect(rect, point) {
+ if (!rect || !point) return Infinity;
+
+ const dx = point.x < rect.left
+ ? rect.left - point.x
+ : (point.x > rect.right ? point.x - rect.right : 0);
+ const dy = point.y < rect.top
+ ? rect.top - point.y
+ : (point.y > rect.bottom ? point.y - rect.bottom : 0);
+
+ return Math.hypot(dx, dy);
+}
+
+export function shouldReleaseFromGroup(rect, point, threshold = GROUP_DRAG_RELEASE_DISTANCE) {
+ return getPointDistanceOutsideRect(rect, point) >= threshold;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 | 1x +1x +1x +4x +4x +4x +4x +4x +4x +4x +4x + +4x +1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + | const DEFAULT_CHILD_WIDTH = 200;
+const DEFAULT_CHILD_HEIGHT = 120;
+
+function getNodeSize(node, axis) {
+ const fallback = axis === 'width' ? DEFAULT_CHILD_WIDTH : DEFAULT_CHILD_HEIGHT;
+ const measured = Number(node?.measured?.[axis]);
+ if (Number.isFinite(measured) && measured > 0) return measured;
+ const direct = Number(node?.[axis]);
+ if (Number.isFinite(direct) && direct > 0) return direct;
+ const styled = Number(node?.style?.[axis]);
+ if (Number.isFinite(styled) && styled > 0) return styled;
+ return fallback;
+}
+
+export function getGroupMinimumSize(memberNodes, {
+ minWidth = 260,
+ minHeight = 180,
+ paddingX = 24,
+ paddingY = 24,
+} = {}) {
+ let maxRight = 0;
+ let maxBottom = 0;
+
+ for (const node of memberNodes || []) {
+ const x = Number(node?.position?.x) || 0;
+ const y = Number(node?.position?.y) || 0;
+ maxRight = Math.max(maxRight, x + getNodeSize(node, 'width'));
+ maxBottom = Math.max(maxBottom, y + getNodeSize(node, 'height'));
+ }
+
+ return {
+ width: Math.max(minWidth, Math.ceil(maxRight + paddingX)),
+ height: Math.max(minHeight, Math.ceil(maxBottom + paddingY)),
+ };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| angleMeasureGeometry.js | +
+
+ |
+ 97.4% | +75/77 | +53.33% | +8/15 | +100% | +6/6 | +97.4% | +75/77 | +
| canvasInteractionTargets.js | +
+
+ |
+ 89.79% | +44/49 | +68% | +17/25 | +100% | +7/7 | +89.79% | +44/49 | +
| connectionUtils.js | +
+
+ |
+ 100% | +122/122 | +93.93% | +62/66 | +100% | +12/12 | +100% | +122/122 | +
| constants.js | +
+
+ |
+ 97.05% | +99/102 | +76.47% | +13/17 | +100% | +5/5 | +97.05% | +99/102 | +
| defaultWorkflow.js | +
+
+ |
+ 89.28% | +50/56 | +86.36% | +19/22 | +100% | +2/2 | +89.28% | +50/56 | +
| executionGraph.js | +
+
+ |
+ 99.36% | +157/158 | +79.48% | +62/78 | +100% | +10/10 | +99.36% | +157/158 | +
| groupDrag.js | +
+
+ |
+ 100% | +18/18 | +55.55% | +5/9 | +100% | +2/2 | +100% | +18/18 | +
| groupSizing.js | +
+
+ |
+ 97.14% | +34/35 | +35.71% | +5/14 | +100% | +2/2 | +97.14% | +34/35 | +
| loadNodeOutputs.js | +
+
+ |
+ 90% | +27/30 | +66.66% | +10/15 | +100% | +3/3 | +90% | +27/30 | +
| markupShapeGeometry.js | +
+
+ |
+ 76.53% | +75/98 | +44.44% | +8/18 | +83.33% | +5/6 | +76.53% | +75/98 | +
| nodeClipboard.js | +
+
+ |
+ 94.37% | +302/320 | +59.67% | +74/124 | +100% | +17/17 | +94.37% | +302/320 | +
| nodeHierarchy.js | +
+
+ |
+ 100% | +28/28 | +86.66% | +13/15 | +100% | +2/2 | +100% | +28/28 | +
| nodeWidgetDefaults.js | +
+
+ |
+ 92.3% | +24/26 | +69.23% | +9/13 | +100% | +2/2 | +92.3% | +24/26 | +
| nodeWidgetLayout.js | +
+
+ |
+ 100% | +49/49 | +73.07% | +19/26 | +100% | +4/4 | +100% | +49/49 | +
| pngMetadata.js | +
+
+ |
+ 98.78% | +162/164 | +61.11% | +22/36 | +100% | +8/8 | +98.78% | +162/164 | +
| runtimeValuePersistence.js | +
+
+ |
+ 90% | +9/10 | +87.5% | +7/8 | +100% | +1/1 | +90% | +9/10 | +
| valueFormatting.js | +
+
+ |
+ 80.35% | +180/224 | +65.57% | +40/61 | +83.33% | +10/12 | +80.35% | +180/224 | +
| workflowCapture.js | +
+
+ |
+ 40.93% | +79/193 | +36.84% | +7/19 | +16.66% | +2/12 | +40.93% | +79/193 | +
| workflowHydration.js | +
+
+ |
+ 100% | +82/82 | +73.52% | +25/34 | +100% | +5/5 | +100% | +82/82 | +
| workflowSerialization.js | +
+
+ |
+ 100% | +65/65 | +64.7% | +22/34 | +100% | +4/4 | +100% | +65/65 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 | 1x +3x +3x +3x +3x +3x +3x +1x +1x +3x + + +3x +1x +1x +1x +1x +1x + +3x +1x +1x +2x +2x +2x +2x +1x +1x +2x +2x + | export function resolveLoadNodeChannelPath({
+ explicitPath = null,
+ resolvedPathInput = null,
+ className = '',
+ widgetValues = {},
+} = {}) {
+ if (typeof explicitPath === 'string' && explicitPath) {
+ return explicitPath;
+ }
+ if (typeof resolvedPathInput === 'string' && resolvedPathInput) {
+ return resolvedPathInput;
+ }
+ if (className === 'Image') {
+ return String(widgetValues?.filename || '');
+ }
+ if (className === 'ImageDemo') {
+ return String(widgetValues?.name || '');
+ }
+ return '';
+}
+
+export function beginTrackedNodeRequest(requestVersions, nodeId) {
+ const nextVersion = (requestVersions.get(nodeId) || 0) + 1;
+ requestVersions.set(nodeId, nextVersion);
+ return nextVersion;
+}
+
+export function isTrackedNodeRequestCurrent(requestVersions, nodeId, version) {
+ return requestVersions.get(nodeId) === version;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 | 1x +1x +1x +1x +4x +4x +4x +4x +4x +1x +1x +2x +1x +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x +2x +2x +2x +2x + | export const MARKUP_DEFAULT_SHAPE = 'arrow';
+export const MARKUP_DEFAULT_COLOR = '#ff0000';
+export const MARKUP_PREVIEW_REFERENCE_DIM = 512;
+
+function clampFraction(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return 0;
+ return Math.max(0, Math.min(1, numeric));
+}
+
+export function sanitizeMarkupColor(color, fallback = MARKUP_DEFAULT_COLOR) {
+ if (typeof color !== 'string') return fallback;
+ const value = color.trim();
+ return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
+}
+
+export function sanitizeMarkupShape(
+ shape,
+ fallbackShape = MARKUP_DEFAULT_SHAPE,
+ fallbackColor = MARKUP_DEFAULT_COLOR,
+ fallbackWidth = 3,
+) {
+ if (!shape || typeof shape !== 'object') return null;
+ const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape;
+ const x1 = clampFraction(shape.x1);
+ const y1 = clampFraction(shape.y1);
+ const x2 = clampFraction(shape.x2);
+ const y2 = clampFraction(shape.y2);
+ const width = Math.max(1, Math.min(64, Math.round(Number(shape.width) || fallbackWidth || 1)));
+ return {
+ kind,
+ x1: Number(x1.toFixed(4)),
+ y1: Number(y1.toFixed(4)),
+ x2: Number(x2.toFixed(4)),
+ y2: Number(y2.toFixed(4)),
+ width,
+ color: sanitizeMarkupColor(shape.color, fallbackColor),
+ };
+}
+
+export function parseMarkupShapes(
+ markupShapes,
+ fallbackShape = MARKUP_DEFAULT_SHAPE,
+ fallbackColor = MARKUP_DEFAULT_COLOR,
+ fallbackWidth = 3,
+) {
+ if (Array.isArray(markupShapes)) {
+ return markupShapes
+ .map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
+ .filter(Boolean);
+ }
+
+ if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
+
+ try {
+ const parsed = JSON.parse(markupShapes);
+ if (!Array.isArray(parsed)) return [];
+ return parsed
+ .map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
+ .filter(Boolean);
+ } catch {
+ return [];
+ }
+}
+
+export function getArrowGeometry(shape, imageWidth, imageHeight) {
+ const x1 = shape.x1 * imageWidth;
+ const y1 = shape.y1 * imageHeight;
+ const x2 = shape.x2 * imageWidth;
+ const y2 = shape.y2 * imageHeight;
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ const length = Math.hypot(dx, dy) || 1;
+ const ux = dx / length;
+ const uy = dy / length;
+ const strokeWidth = Math.max(1, shape.width);
+ const headLength = Math.max(10, strokeWidth * 4);
+ const headWidth = Math.max(8, strokeWidth * 3);
+ const shaftX = x2 - ux * headLength;
+ const shaftY = y2 - uy * headLength;
+ const px = -uy;
+ const py = ux;
+ const leftX = shaftX + px * headWidth * 0.5;
+ const leftY = shaftY + py * headWidth * 0.5;
+ const rightX = shaftX - px * headWidth * 0.5;
+ const rightY = shaftY - py * headWidth * 0.5;
+ return {
+ line: `${x1},${y1} ${shaftX},${shaftY}`,
+ head: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`,
+ };
+}
+
+export function getMarkupPreviewStrokeWidth(width, imageWidth, imageHeight) {
+ const normalizedWidth = Math.max(1, Math.round(Number(width) || 1));
+ const longestDim = Math.max(1, Number(imageWidth) || 0, Number(imageHeight) || 0);
+ const scale = Math.max(1, longestDim / MARKUP_PREVIEW_REFERENCE_DIM);
+ return Math.max(1, Math.round(normalizedWidth * scale));
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 | 2x +2x +2x +2x +2x +26x +26x +26x +26x +26x +26x + + +26x + +26x +2x +39x +39x +39x +39x +2x +2x +2x +2x +2x +2x +2x +2x +2x + + +2x +2x +9x +9x +9x +2x +9x +2x +2x +2x +2x +2x +2x +9x +2x +6x +6x +6x +2x +5x +5x +5x +5x +2x +9x +9x +9x +2x +9x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x +8x +8x +8x +1x +1x +8x + + +8x +8x +2x +1x +1x +1x +1x +1x + + +1x +1x +1x +1x +1x +1x +1x +1x +1x + + +1x +1x +1x +1x +1x +1x +1x +2x +4x +4x +4x +4x +4x +4x +4x +4x +8x +8x +8x + + + +8x +4x +4x +4x +2x +5x +5x +5x +5x +15x +15x +15x +15x +15x +15x +15x +15x +15x +15x +15x +15x +5x +5x +5x +2x +2x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +2x +2x +2x +4x +4x +4x +4x +4x +4x +4x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +4x +4x +2x +2x +2x +2x +2x +2x +2x +4x +4x +4x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x + + +1x +2x +2x +5x +5x +5x +5x +5x +5x +5x + + +5x +5x +5x +5x +5x +8x +5x +5x +5x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +5x +5x +5x +5x +3x +3x +5x +5x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +5x +5x +5x +5x +5x +5x +5x +5x + | import { sortNodesForParentOrder } from './nodeHierarchy.js';
+
+export const NODE_CLIPBOARD_KIND = 'argonode/node-selection';
+export const NODE_CLIPBOARD_MIME = 'application/x-argonode-node-selection';
+
+function cloneValue(value) {
+ if (value == null) return value;
+ if (typeof structuredClone === 'function') {
+ try {
+ return structuredClone(value);
+ } catch {
+ // Fall through to JSON clone for simple plain data.
+ }
+ }
+ return JSON.parse(JSON.stringify(value));
+}
+
+function clonePlainObject(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+ return cloneValue(value) || {};
+}
+
+function encodeProxyHandleRef(handleId) {
+ return encodeURIComponent(String(handleId || ''));
+}
+
+function decodeProxyHandleRef(encoded) {
+ try {
+ return decodeURIComponent(String(encoded || ''));
+ } catch {
+ return String(encoded || '');
+ }
+}
+
+function parseGroupProxyHandle(handleId) {
+ const text = String(handleId || '');
+ if (!text.startsWith('group-proxy::')) return null;
+ const parts = text.split('::');
+ if (parts.length < 5) return null;
+ return {
+ direction: parts[1],
+ nodeId: parts[2],
+ type: parts[3],
+ realHandle: decodeProxyHandleRef(parts.slice(4).join('::')),
+ };
+}
+
+function hasOwn(obj, key) {
+ return Object.prototype.hasOwnProperty.call(obj, key);
+}
+
+function remapNodeId(value, idMap) {
+ if (value == null) return value;
+ return idMap.get(String(value)) || String(value);
+}
+
+function remapGroupProxyHandle(handleId, idMap) {
+ const proxy = parseGroupProxyHandle(handleId);
+ if (!proxy) return handleId;
+ return `group-proxy::${proxy.direction}::${remapNodeId(proxy.nodeId, idMap)}::${proxy.type}::${encodeProxyHandleRef(proxy.realHandle)}`;
+}
+
+function remapGroupProxyDescriptors(items, idMap) {
+ if (!Array.isArray(items)) return items;
+ return items.map((item) => {
+ if (!item || typeof item !== 'object') return item;
+ const nextItem = { ...item };
+ if (typeof nextItem.key === 'string') {
+ const separator = nextItem.key.indexOf('::');
+ if (separator !== -1) {
+ const handleId = nextItem.key.slice(separator + 2);
+ nextItem.key = `${remapNodeId(nextItem.key.slice(0, separator), idMap)}::${remapGroupProxyHandle(handleId, idMap)}`;
+ }
+ }
+ if (typeof nextItem.handleId === 'string') {
+ nextItem.handleId = remapGroupProxyHandle(nextItem.handleId, idMap);
+ }
+ return nextItem;
+ });
+}
+
+function remapClipboardExtraData(extraData, idMap) {
+ const nextExtraData = clonePlainObject(extraData);
+ if (Array.isArray(nextExtraData.proxyInputs)) {
+ nextExtraData.proxyInputs = remapGroupProxyDescriptors(nextExtraData.proxyInputs, idMap);
+ }
+ if (Array.isArray(nextExtraData.proxyOutputs)) {
+ nextExtraData.proxyOutputs = remapGroupProxyDescriptors(nextExtraData.proxyOutputs, idMap);
+ }
+ return nextExtraData;
+}
+
+function remapClipboardEdgeData(data, idMap) {
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return cloneValue(data);
+
+ const nextData = cloneValue(data);
+ if (hasOwn(nextData, 'groupInternalHiddenBy')) {
+ nextData.groupInternalHiddenBy = remapNodeId(nextData.groupInternalHiddenBy, idMap);
+ }
+ if (hasOwn(nextData, 'groupProxyOwner')) {
+ nextData.groupProxyOwner = remapNodeId(nextData.groupProxyOwner, idMap);
+ }
+
+ const original = nextData.groupProxyOriginal;
+ if (original && typeof original === 'object' && !Array.isArray(original)) {
+ if (hasOwn(original, 'source')) original.source = remapNodeId(original.source, idMap);
+ if (hasOwn(original, 'target')) original.target = remapNodeId(original.target, idMap);
+ if (hasOwn(original, 'sourceHandle')) {
+ original.sourceHandle = remapGroupProxyHandle(original.sourceHandle, idMap);
+ }
+ if (hasOwn(original, 'targetHandle')) {
+ original.targetHandle = remapGroupProxyHandle(original.targetHandle, idMap);
+ }
+ }
+
+ return nextData;
+}
+
+function collectSelectedNodeIds(nodes, nodeIds) {
+ const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
+ if (selectedIdSet.size === 0) return selectedIdSet;
+
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const node of Array.isArray(nodes) ? nodes : []) {
+ const parentId = node?.parentId ? String(node.parentId) : null;
+ const nodeId = String(node?.id);
+ if (parentId && selectedIdSet.has(parentId) && !selectedIdSet.has(nodeId)) {
+ selectedIdSet.add(nodeId);
+ changed = true;
+ }
+ }
+ }
+ return selectedIdSet;
+}
+
+function extractExtraData(data) {
+ const source = data || {};
+ return Object.fromEntries(
+ Object.entries(source).filter(([key]) => ![
+ 'label',
+ 'className',
+ 'widgetValues',
+ 'runtimeValues',
+ 'definition',
+ 'previewImage',
+ 'tableRows',
+ 'meshData',
+ 'overlay',
+ 'scalarValue',
+ 'processingTimeMs',
+ 'warning',
+ ].includes(key)),
+ );
+}
+
+export function buildNodeClipboardPayloadForIds(
+ nodes,
+ edges,
+ nodeIds,
+ { includeIncomingExternalEdges = false } = {},
+) {
+ const selectedIdSet = collectSelectedNodeIds(nodes, nodeIds);
+ const selectedNodes = Array.isArray(nodes)
+ ? nodes.filter((node) => selectedIdSet.has(String(node.id)))
+ : [];
+ if (selectedNodes.length === 0) return null;
+
+ const capturedEdges = Array.isArray(edges)
+ ? edges.filter((edge) => (
+ selectedIdSet.has(String(edge.target))
+ && (
+ selectedIdSet.has(String(edge.source))
+ || (includeIncomingExternalEdges && !selectedIdSet.has(String(edge.source)))
+ )
+ ))
+ : [];
+
+ return {
+ kind: NODE_CLIPBOARD_KIND,
+ version: 1,
+ nodes: selectedNodes.map((node) => ({
+ id: String(node.id),
+ type: node.type || 'custom',
+ position: {
+ x: Number(node.position?.x) || 0,
+ y: Number(node.position?.y) || 0,
+ },
+ ...(node.className ? { className: node.className } : {}),
+ ...(node.parentId ? { parentId: String(node.parentId) } : {}),
+ ...(node.extent ? { extent: node.extent } : {}),
+ ...(node.hidden ? { hidden: true } : {}),
+ ...(node.style ? { style: cloneValue(node.style) } : {}),
+ dragHandle: node.dragHandle || '.drag-handle',
+ data: {
+ label: node.data?.label || node.data?.className || 'Node',
+ className: node.data?.className || '',
+ widgetValues: clonePlainObject(node.data?.widgetValues),
+ runtimeValues: clonePlainObject(node.data?.runtimeValues),
+ extraData: clonePlainObject(extractExtraData(node.data)),
+ },
+ })),
+ edges: capturedEdges.map((edge) => ({
+ source: String(edge.source),
+ sourceHandle: edge.sourceHandle,
+ target: String(edge.target),
+ targetHandle: edge.targetHandle,
+ ...(edge.style ? { style: { ...edge.style } } : {}),
+ ...(edge.hidden ? { hidden: true } : {}),
+ ...(edge.data ? { data: cloneValue(edge.data) } : {}),
+ })),
+ };
+}
+
+export function buildNodeClipboardPayload(nodes, edges) {
+ const selectedNodes = Array.isArray(nodes)
+ ? nodes.filter((node) => node?.selected)
+ : [];
+ const selectedIds = selectedNodes.map((node) => String(node.id));
+ const includeIncomingExternalEdges = selectedNodes.some((node) => node?.data?.className === 'Group');
+ return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds, { includeIncomingExternalEdges });
+}
+
+export function parseNodeClipboardPayload(text) {
+ if (typeof text !== 'string' || !text.trim()) return null;
+
+ try {
+ const parsed = JSON.parse(text);
+ if (parsed?.kind !== NODE_CLIPBOARD_KIND) return null;
+ if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export function instantiateNodeClipboardPayload(
+ payload,
+ defs = {},
+ nextNodeId = 1,
+ offset = { x: 40, y: 40 },
+ { keepExternalSources = false } = {},
+) {
+ if (!payload || !Array.isArray(payload.nodes) || payload.nodes.length === 0) {
+ return { nodes: [], edges: [], nextNodeId };
+ }
+
+ const idMap = new Map();
+ let currentId = Number(nextNodeId) || 1;
+
+ payload.nodes.forEach((node) => {
+ idMap.set(String(node.id), String(currentId++));
+ });
+
+ const nodes = sortNodesForParentOrder(payload.nodes.map((node) => {
+ const newId = idMap.get(String(node.id));
+ const className = node.data?.className || '';
+ const definition = className ? defs[className] || null : null;
+ const extraData = remapClipboardExtraData(node.data?.extraData, idMap);
+
+ return {
+ id: newId,
+ type: node.type || 'custom',
+ className: node.className,
+ position: {
+ x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 0),
+ y: (Number(node.position?.y) || 0) + (Number(offset?.y) || 0),
+ },
+ ...(node.parentId ? { parentId: idMap.get(String(node.parentId)) || String(node.parentId) } : {}),
+ ...(node.extent ? { extent: node.extent } : {}),
+ ...(node.hidden ? { hidden: true } : {}),
+ ...(node.style ? { style: cloneValue(node.style) } : {}),
+ dragHandle: node.dragHandle || '.drag-handle',
+ selected: true,
+ data: {
+ label: node.data?.label || className || 'Node',
+ className,
+ widgetValues: clonePlainObject(node.data?.widgetValues),
+ runtimeValues: clonePlainObject(node.data?.runtimeValues),
+ ...extraData,
+ definition,
+ previewImage: null,
+ tableRows: null,
+ meshData: null,
+ overlay: null,
+ scalarValue: null,
+ processingTimeMs: null,
+ warning: null,
+ },
+ };
+ }));
+
+ const edges = payload.edges
+ .filter((edge) => (
+ idMap.has(String(edge.target))
+ && (idMap.has(String(edge.source)) || keepExternalSources)
+ ))
+ .map((edge, index) => {
+ const source = idMap.get(String(edge.source)) || String(edge.source);
+ const target = idMap.get(String(edge.target));
+ return {
+ id: `e${source}-${target}-${index}`,
+ source,
+ sourceHandle: remapGroupProxyHandle(edge.sourceHandle, idMap),
+ target,
+ targetHandle: remapGroupProxyHandle(edge.targetHandle, idMap),
+ selected: false,
+ ...(edge.style ? { style: { ...edge.style } } : {}),
+ ...(edge.hidden ? { hidden: true } : {}),
+ ...(edge.data ? { data: remapClipboardEdgeData(edge.data, idMap) } : {}),
+ };
+ });
+
+ return {
+ nodes,
+ edges,
+ nextNodeId: currentId,
+ };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 | 3x +12x +12x +12x +12x +12x +12x +12x +12x +24x +24x +24x +19x +19x +19x +24x +24x +5x +5x +19x +19x +19x +19x +24x +12x +12x +12x +12x + | export function sortNodesForParentOrder(nodes) {
+ const list = Array.isArray(nodes) ? nodes.filter(Boolean) : [];
+ const entries = list.map((node) => ({ id: String(node.id), node }));
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
+ const visiting = new Set();
+ const visited = new Set();
+ const ordered = [];
+
+ function visit(entry) {
+ if (!entry) return;
+ const { id, node } = entry;
+ if (visited.has(id) || visiting.has(id)) return;
+
+ visiting.add(id);
+
+ const parentId = node?.parentId ? String(node.parentId) : null;
+ if (parentId) {
+ visit(byId.get(parentId));
+ }
+
+ visiting.delete(id);
+ visited.add(id);
+ ordered.push(node);
+ }
+
+ entries.forEach((entry) => visit(entry));
+ return ordered;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 | 1x +1x +1x +6x +6x +6x +6x +2x +2x +2x + + +6x +6x +1x +1x +1x +1x +1x +5x +5x +3x +3x +5x +1x +1x + | import { getSpecTypeAndOptions, isDataSocketSpec } from './constants.js';
+
+export function getDefaultWidgetValue(spec) {
+ const [type, opts] = getSpecTypeAndOptions(spec);
+ if (isDataSocketSpec(spec)) return undefined;
+ if (type === 'BUTTON') return undefined;
+ if (Array.isArray(type)) {
+ if (typeof opts?.default === 'string' && type.includes(opts.default)) {
+ return opts.default;
+ }
+ return type[0];
+ }
+ return opts?.default ?? '';
+}
+
+export function buildDefaultWidgetValues(definition) {
+ const widgetValues = {};
+ const required = definition?.input?.required || {};
+ for (const [name, spec] of Object.entries(required)) {
+ const value = getDefaultWidgetValue(spec);
+ if (value !== undefined) {
+ widgetValues[name] = value;
+ }
+ }
+ return widgetValues;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 | 1x +4x +4x +4x +4x +4x +4x +1x +5x +5x +5x +5x +5x +5x +1x +1x +3x +3x +1x +1x +2x +3x +3x +2x +3x +2x +2x +2x +2x +1x +1x +2x +1x +1x +3x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | export function formatUiLabel(text) {
+ return String(text ?? '')
+ .replace(/_/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .toLowerCase();
+}
+
+function normalizeInputNames(raw) {
+ if (!raw) return [];
+ return (Array.isArray(raw) ? raw : [raw])
+ .map((value) => String(value))
+ .filter((value) => value.length > 0);
+}
+
+export function getWidgetCombinedInputName(widget, dataInputByName) {
+ const explicitInputName = normalizeInputNames(widget?.opts?.top_socket_input)[0];
+ if (explicitInputName && dataInputByName?.has(explicitInputName)) {
+ return explicitInputName;
+ }
+
+ const widgetLabel = formatUiLabel(widget?.opts?.label || widget?.name);
+ if (!widgetLabel) return null;
+
+ for (const inputName of normalizeInputNames(widget?.opts?.hide_when_input_connected)) {
+ const input = dataInputByName?.get(inputName);
+ if (!input) continue;
+ const inputLabel = formatUiLabel(input.label || input.name);
+ if (inputLabel === widgetLabel) {
+ return input.name;
+ }
+ }
+
+ return null;
+}
+
+export function buildCombinedInputNameByWidgetName(widgets, dataInputs) {
+ const dataInputByName = new Map((dataInputs || []).map((input) => [input.name, input]));
+ const combinedInputNameByWidgetName = new Map();
+
+ for (const widget of widgets || []) {
+ const combinedInputName = getWidgetCombinedInputName(widget, dataInputByName);
+ if (combinedInputName) {
+ combinedInputNameByWidgetName.set(widget.name, combinedInputName);
+ }
+ }
+
+ return combinedInputNameByWidgetName;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 | 2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +512x +512x +4096x +4096x +512x +512x +2x +4x +4x +4x +382x +382x +4x +4x +2x +2x +2x +2x +2x +7x +7x +7x +56x +56x +7x +7x +2x +26x +26x +26x +26x +26x +2x +26x +26x +26x +2x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +2x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x + + +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +2x +2x +2x +2x +2x +2x +2x +2x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +13x +13x +13x +13x +9x +9x +4x +4x +4x +4x +4x +4x +4x +4x +4x +2x +2x +2x +2x +2x +2x +3x +3x +3x +3x +3x +3x +3x +13x +13x +13x +13x +13x +4x +4x +4x +4x +13x +13x +10x +10x +3x +3x +3x + | /**
+ * PNG text chunk utilities for embedding/extracting workflow metadata.
+ *
+ * PNG files are composed of chunks: [4-byte length][4-byte type][data][4-byte CRC].
+ * We add an iTXt chunk with key "workflow" containing the JSON-serialised graph,
+ * inserted just before the IEND chunk.
+ */
+
+// ── CRC32 (PNG uses CRC-32/ISO 3309) ────────────────────────────────
+
+const crcTable = 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);
+ }
+ crcTable[i] = c;
+}
+
+function crc32(bytes) {
+ let crc = 0xFFFFFFFF;
+ for (let i = 0; i < bytes.length; i++) {
+ crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
+ }
+ return (crc ^ 0xFFFFFFFF) >>> 0;
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+const PNG_SIG = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
+
+function isPng(data) {
+ if (data.length < 8) return false;
+ for (let i = 0; i < 8; i++) {
+ if (data[i] !== PNG_SIG[i]) return false;
+ }
+ return true;
+}
+
+function chunkType(data, offset) {
+ return String.fromCharCode(
+ data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
+ );
+}
+
+function readUint32(data, offset) {
+ return new DataView(data.buffer, data.byteOffset + offset, 4).getUint32(0);
+}
+
+function buildChunk(type, payload) {
+ const encoder = new TextEncoder();
+ const typeBytes = encoder.encode(type);
+ const forCrc = new Uint8Array(4 + payload.length);
+ forCrc.set(typeBytes, 0);
+ forCrc.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(forCrc));
+ return chunk;
+}
+
+function parseTextChunk(type, chunkData) {
+ const decoder = new TextDecoder();
+ const keywordEnd = chunkData.indexOf(0);
+ if (keywordEnd === -1) return null;
+
+ const keyword = decoder.decode(chunkData.subarray(0, keywordEnd));
+ if (keyword !== 'workflow') return null;
+
+ if (type !== 'iTXt') return null;
+
+ const compressionFlagIdx = keywordEnd + 1;
+ const compressionMethodIdx = keywordEnd + 2;
+ if (compressionMethodIdx >= chunkData.length) return null;
+
+ const compressionFlag = chunkData[compressionFlagIdx];
+ if (compressionFlag !== 0) {
+ throw new Error('Compressed PNG workflow metadata is not supported');
+ }
+
+ let offset = compressionMethodIdx + 1;
+ const languageEnd = chunkData.indexOf(0, offset);
+ if (languageEnd === -1) return null;
+
+ offset = languageEnd + 1;
+ const translatedEnd = chunkData.indexOf(0, offset);
+ if (translatedEnd === -1) return null;
+
+ return JSON.parse(decoder.decode(chunkData.subarray(translatedEnd + 1)));
+}
+
+// ── Public API ───────────────────────────────────────────────────────
+
+/**
+ * Embed a workflow object into a PNG blob as an iTXt chunk.
+ * Returns a new Blob with the metadata inserted before IEND.
+ */
+export async function embedWorkflow(pngBlob, workflow) {
+ const data = new Uint8Array(await pngBlob.arrayBuffer());
+ if (!isPng(data)) throw new Error('Not a valid PNG file');
+
+ const encoder = new TextEncoder();
+
+ // Build iTXt payload:
+ // keyword \0 compression-flag compression-method language-tag \0 translated-keyword \0 text
+ const key = encoder.encode('workflow');
+ const val = encoder.encode(JSON.stringify(workflow));
+ const payload = new Uint8Array(key.length + 5 + val.length);
+ payload.set(key, 0);
+ payload.set(val, key.length + 5);
+ const chunk = buildChunk('iTXt', payload);
+
+ // Locate IEND
+ let pos = 8;
+ let iendPos = data.length;
+ while (pos < data.length) {
+ if (pos + 8 > data.length) break;
+ const len = readUint32(data, pos);
+ if (pos + 12 + len > data.length) break;
+ if (chunkType(data, pos) === 'IEND') { iendPos = pos; break; }
+ pos += 12 + len;
+ }
+
+ // Splice: [before IEND] + [tEXt chunk] + [IEND]
+ const result = new Uint8Array(data.length + chunk.length);
+ result.set(data.subarray(0, iendPos), 0);
+ result.set(chunk, iendPos);
+ result.set(data.subarray(iendPos), iendPos + chunk.length);
+
+ return new Blob([result], { type: 'image/png' });
+}
+
+/**
+ * Extract the workflow object from a PNG blob's iTXt chunks.
+ * Returns the parsed object, or null if no "workflow" key is found.
+ */
+export async function extractWorkflow(pngBlob) {
+ const data = new Uint8Array(await pngBlob.arrayBuffer());
+ if (!isPng(data)) return null;
+
+ let pos = 8;
+ let found = null;
+
+ while (pos + 8 <= data.length) {
+ const len = readUint32(data, pos);
+ if (pos + 12 + len > data.length) break;
+ const type = chunkType(data, pos);
+
+ if (type === 'iTXt') {
+ const chunkData = data.subarray(pos + 8, pos + 8 + len);
+ const parsed = parseTextChunk(type, chunkData);
+ if (parsed) found = parsed;
+ }
+
+ if (type === 'IEND') break;
+ pos += 12 + len;
+ }
+
+ return found;
+}
+ |