| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1"> |
| <title>Node Image Processor</title> |
| <script src="https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.js"></script> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script> |
| <style> |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} |
| :root{ |
| --bg:#0d1117;--surface:#161b22;--surface2:#1c2333;--surface3:#222d3d; |
| --border:#30363d;--accent:#ff6b6b;--accent2:#4ecdc4;--text:#e6edf3; |
| --muted:#8b949e;--success:#3fb950;--warning:#d29922;--danger:#f85149; |
| --sidebar-w:clamp(160px,14vw,230px);--toolbar-h:2.5rem;--radius:.5rem; |
| --font:.8125rem;--font-sm:.6875rem;--font-xs:.5625rem |
| } |
| html,body{width:100%;height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:16px} |
| #loader{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.875rem;background:var(--bg);color:var(--text);z-index:9999;transition:opacity .5s} |
| #loader.done{opacity:0;pointer-events:none} |
| .spinner{width:2.25rem;height:2.25rem;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite} |
| @keyframes spin{to{transform:rotate(360deg)}} |
| #ldMsg{font-size:var(--font);font-weight:700} |
| #ldSub{font-size:var(--font-sm);color:var(--muted);max-width:25rem;text-align:center;line-height:1.4} |
| #toast{position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%) translateY(4rem);padding:.5rem 1.375rem;border-radius:var(--radius);font-size:var(--font-sm);font-weight:700;z-index:10000;opacity:0;transition:transform .3s cubic-bezier(.4,0,.2,1),opacity .3s;pointer-events:none;white-space:nowrap} |
| #toast.show{transform:translateX(-50%) translateY(0);opacity:1} |
| #toast.ok{background:#0f2a1a;color:var(--success);border:1px solid #1a4028} |
| #toast.err{background:#2a0f0f;color:var(--danger);border:1px solid #401a1a} |
| #toast.inf{background:#0f1a2a;color:#58a6ff;border:1px solid #1a2840} |
| #app{display:flex;width:100%;height:100%} |
| /* SIDEBAR */ |
| #sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;transition:width .2s} |
| .sb-tabs{display:flex;border-bottom:1px solid var(--border);height:var(--toolbar-h);flex-shrink:0} |
| .sb-tab{flex:1;display:flex;align-items:center;justify-content:center;font-size:var(--font-xs);font-weight:800;cursor:pointer;color:var(--muted);border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase;letter-spacing:.5px;padding:0 .5rem} |
| .sb-tab.on{color:var(--accent);border-color:var(--accent)} |
| .sb-tab:hover:not(.on){color:var(--text)} |
| .sb-content{flex:1;overflow-y:auto;display:none} |
| .sb-content.on{display:flex;flex-direction:column} |
| .sb-title{padding:.625rem .875rem .25rem;font-size:var(--font-xs);text-transform:uppercase;color:var(--muted);letter-spacing:1px;font-weight:800} |
| .sb-item{padding:.375rem .75rem;cursor:grab;border-radius:4px;margin:1px .375rem;display:flex;align-items:center;gap:.5rem;font-size:var(--font-sm);transition:background .1s;user-select:none} |
| .sb-item:hover{background:rgba(255,255,255,.05)} |
| .sb-item:active{cursor:grabbing;opacity:.7} |
| .sb-dot{width:.5rem;height:.5rem;border-radius:2px;flex-shrink:0} |
| .sb-wf-item{padding:.4375rem .75rem;cursor:pointer;border-radius:4px;margin:2px .375rem;display:flex;justify-content:space-between;align-items:center;font-size:var(--font-sm);transition:background .1s} |
| .sb-wf-item:hover{background:rgba(255,255,255,.05)} |
| .sb-wf-del{color:var(--muted);cursor:pointer;font-weight:700;font-size:.8125rem;line-height:1} |
| .sb-wf-del:hover{color:var(--danger)} |
| .sb-empty{padding:1rem .75rem;font-size:var(--font-xs);color:#2a3040;text-align:center;font-style:italic} |
| /* MAIN */ |
| #main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0} |
| #toolbar{height:var(--toolbar-h);background:var(--surface);border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr auto;align-items:center;flex-shrink:0;position:relative} |
| #tabsScroll{overflow-x:auto;overflow-y:hidden;display:flex;align-items:center;gap:1px;padding:0 .5rem;height:100%;scrollbar-width:none} |
| #tabsScroll::-webkit-scrollbar{display:none} |
| .tab{padding:.3125rem .875rem;cursor:pointer;font-size:var(--font-sm);font-weight:700;border-radius:4px 4px 0 0;background:0;border:0;color:var(--muted);transition:all .12s;display:flex;align-items:center;gap:.375rem;white-space:nowrap;flex-shrink:0;height:100%;align-items:center} |
| .tab.on{background:var(--surface2);color:var(--text)} |
| .tab:hover:not(.on){color:var(--text)} |
| .tab-close{font-size:.8125rem;line-height:1;opacity:.4;margin-left:.125rem;transition:opacity .1s} |
| .tab-close:hover{opacity:1;color:var(--danger)} |
| .tab-name{outline:none;border-bottom:1px dashed transparent;padding:0 .125rem;min-width:1.875rem} |
| .tab-name:focus{border-color:var(--accent2)} |
| .tb-right{display:flex;align-items:center;gap:.3125rem;padding:0 .625rem;height:100%;border-left:1px solid var(--border);flex-shrink:0} |
| .tbtn{padding:.25rem .625rem;border-radius:4px;font-size:var(--font-xs);cursor:pointer;border:1px solid var(--border);font-weight:700;background:var(--surface2);color:var(--muted);transition:all .12s;white-space:nowrap} |
| .tbtn:hover{color:var(--text);border-color:#484f58;background:var(--surface3)} |
| .tbtn.run{background:var(--accent);color:#fff;border-color:var(--accent)} |
| .tbtn.run:hover{background:#e55a5a} |
| .tbtn.run:disabled{background:#1a1a1a;color:#444;border-color:#2a2a2a;cursor:not-allowed} |
| #statusWrap{display:flex;align-items:center;gap:.3125rem;font-size:var(--font-xs);color:var(--muted);margin-left:.25rem} |
| .sdot{width:.375rem;height:.375rem;border-radius:50%;background:var(--muted);flex-shrink:0} |
| .sdot.ok{background:var(--success);box-shadow:0 0 6px rgba(63,185,80,.5)} |
| .sdot.load{background:var(--warning);animation:bk 1s infinite} |
| @keyframes bk{0%,100%{opacity:1}50%{opacity:.2}} |
| /* SETTINGS */ |
| #settingsPanel{position:absolute;top:var(--toolbar-h);right:.625rem;background:var(--surface2);border:1px solid var(--border);border-radius:8px;z-index:200;min-width:13.75rem;box-shadow:0 .5rem 2rem rgba(0,0,0,.5);display:none;overflow:hidden} |
| #settingsPanel.open{display:block} |
| .sp-head{padding:.625rem .875rem;font-size:var(--font-sm);font-weight:800;color:var(--accent);border-bottom:1px solid var(--border);background:rgba(255,107,107,.04)} |
| .sp-body{padding:.5rem .875rem .75rem} |
| .sp-row{display:flex;justify-content:space-between;align-items:center;font-size:var(--font-sm);padding:.375rem 0;border-bottom:1px solid rgba(48,54,61,.4)} |
| .sp-row:last-child{border-bottom:none} |
| .sp-row label{color:var(--text);font-size:var(--font-xs)} |
| .sp-row input[type=checkbox]{accent-color:var(--accent);width:.9375rem;height:.9375rem;cursor:pointer} |
| .sp-row select,.sp-row input[type=text],.sp-row input[type=number]{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:.1875rem .375rem;font-size:var(--font-xs);outline:none;cursor:pointer;max-width:8rem} |
| .sp-row .tbtn{padding:.1875rem .625rem} |
| .sp-row .zoom-row{display:flex;align-items:center;gap:.25rem} |
| .sp-row .zoom-row .zbtn{width:1.5rem;height:1.5rem;display:flex;align-items:center;justify-content:center;border-radius:3px;cursor:pointer;color:var(--muted);font-size:.875rem;font-weight:700;background:var(--bg);border:1px solid var(--border);transition:all .1s} |
| .sp-row .zoom-row .zbtn:hover{color:var(--text);background:var(--surface3)} |
| .sp-row .zoom-row .zoom-val{font-size:var(--font-xs);color:var(--text);font-weight:700;min-width:2.5rem;text-align:center;background:var(--bg);border:1px solid var(--border);border-radius:3px;padding:.1875rem .375rem;cursor:pointer} |
| /* WORKSPACE */ |
| #wfPanel{flex:1;position:relative;overflow:hidden} |
| #wfC{width:100%;height:100%;position:relative;overflow:hidden;outline:none;cursor:default} |
| #wfInner{position:absolute;top:0;left:0;width:0;height:0;transform-origin:0 0} |
| #connCvs{position:absolute;inset:0;z-index:5;pointer-events:none} |
| .wf-hint{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#161e2e;font-size:.8125rem;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;pointer-events:none;z-index:0;user-select:none} |
| /* MINIMAP */ |
| #minimap{position:absolute;bottom:.75rem;right:.75rem;z-index:50;width:10.625rem;height:7.1875rem;background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:5px;overflow:hidden;cursor:pointer;backdrop-filter:blur(4px)} |
| #minimap canvas{width:100%;height:100%;display:block} |
| /* POPUPS */ |
| .popup{position:absolute;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:.25rem;z-index:300;min-width:10.625rem;box-shadow:0 .5rem 2rem rgba(0,0,0,.55);display:none} |
| .ctx-item{padding:.375rem .625rem;font-size:var(--font-sm);cursor:pointer;border-radius:4px;display:flex;justify-content:space-between;align-items:center;transition:background .08s} |
| .ctx-item:hover{background:rgba(255,255,255,.06)} |
| .ctx-item.danger{color:var(--danger)} |
| .ctx-sep{height:1px;background:var(--border);margin:.25rem .5rem} |
| .ctx-label{padding:.25rem .625rem;font-size:var(--font-xs);color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:.5px} |
| .ctx-sub{font-size:var(--font-xs);color:var(--muted)} |
| #searchMenu{width:13.75rem;padding:.375rem} |
| #searchInput{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:.4375rem .625rem;border-radius:4px;font-size:var(--font-sm);outline:none} |
| #searchInput:focus{border-color:var(--accent2)} |
| .search-item{padding:.375rem .5rem;font-size:var(--font-sm);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:.4375rem;transition:background .08s} |
| .search-item:hover{background:rgba(255,255,255,.06)} |
| /* NODES */ |
| .node{position:absolute;min-width:11.25rem;max-width:20rem;background:var(--surface2);border:2px solid var(--border);border-radius:8px;user-select:none;z-index:10;box-shadow:0 .25rem 1.5rem rgba(0,0,0,.35);display:flex;flex-direction:column;overflow:visible} |
| .node.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent),0 .25rem 1.5rem rgba(0,0,0,.4)} |
| .node.running{border-color:var(--warning);animation:npulse 1s ease-in-out infinite} |
| .node.done{border-color:var(--success);box-shadow:0 0 12px rgba(63,185,80,.2)} |
| .node.err{border-color:var(--danger);box-shadow:0 0 12px rgba(248,81,73,.2)} |
| .node.disabled{opacity:.45;filter:grayscale(.7)} |
| @keyframes npulse{0%,100%{box-shadow:0 0 6px rgba(210,153,34,.1)}50%{box-shadow:0 0 24px rgba(210,153,34,.4)}} |
| .nhdr{padding:.375rem .625rem;border-radius:6px 6px 0 0;font-size:var(--font-xs);font-weight:800;display:flex;align-items:center;justify-content:space-between;color:#fff;cursor:move;letter-spacing:.3px;flex-shrink:0;gap:.375rem} |
| .nhdr-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| .ndel{cursor:pointer;opacity:0;font-size:.9375rem;line-height:1;transition:opacity .1s;flex-shrink:0;width:1rem;text-align:center} |
| .node:hover .ndel{opacity:.5} |
| .ndel:hover{opacity:1!important} |
| .nbody{padding:.3125rem 0 .4375rem;flex:1;overflow:visible} |
| .npr{display:flex;align-items:center;padding:.125rem 0;font-size:var(--font-xs);color:var(--muted);position:relative} |
| .npr.inp{justify-content:flex-start;padding-left:0} |
| .npr.outp{justify-content:flex-end;padding-right:0} |
| .port{width:.8125rem;height:.8125rem;border-radius:50%;border:2px solid var(--border);background:var(--bg);cursor:crosshair;transition:all .12s;flex-shrink:0;position:relative;z-index:20} |
| .port:hover{transform:scale(1.5);border-color:var(--accent);background:rgba(255,107,107,.25);box-shadow:0 0 8px rgba(255,107,107,.3)} |
| .port.conn{border-color:#5a6a8a;background:#3a4a6a} |
| .npr.inp .port{margin-left:-.4375rem;margin-right:.375rem} |
| .npr.outp .port{margin-right:-.4375rem;margin-left:.375rem} |
| .plbl{font-weight:600;font-size:var(--font-xs)} |
| .nfile{padding:.1875rem .625rem;font-size:var(--font-xs);display:flex;gap:.3125rem;align-items:center;flex-wrap:wrap} |
| .nfile input[type=file]{display:none} |
| .fbtn,.sbtn{display:inline-block;padding:.1875rem .5625rem;border-radius:3px;cursor:pointer;font-size:var(--font-xs);font-weight:700;transition:all .1s} |
| .fbtn{background:var(--border);color:var(--text)}.fbtn:hover{background:#484f58} |
| .sbtn{background:#0f2a2a;color:var(--accent2);border:1px solid rgba(78,205,196,.2)}.sbtn:hover{background:#1a3a3a} |
| .nparam{padding:.125rem .625rem} |
| .nparam label{font-size:var(--font-xs);color:var(--muted);display:flex;justify-content:space-between;margin-bottom:1px} |
| .nparam input[type=range]{width:100%;accent-color:var(--accent);height:.25rem;cursor:pointer} |
| .pv{font-size:var(--font-xs);color:var(--accent);font-weight:700;min-width:1.875rem;text-align:right} |
| .nprev{margin:.25rem .5rem;border-radius:4px;overflow:hidden;background:#060a10;min-height:2rem;max-height:6.25rem;display:flex;align-items:center;justify-content:center} |
| .nprev img{max-width:100%;max-height:5.75rem;display:block;object-fit:contain} |
| .nprev .ph{color:#1a2030;font-size:var(--font-xs);padding:.625rem;text-align:center;font-style:italic} |
| .nval-display{margin:.25rem .5rem;padding:.5rem;border-radius:4px;background:rgba(33,150,243,.1);border:1px solid rgba(33,150,243,.2);text-align:center;font-size:1.25rem;font-weight:800;color:#2196F3} |
| .nres{position:absolute;bottom:0;right:0;width:.875rem;height:.875rem;cursor:nwse-resize;z-index:30;opacity:0;transition:opacity .15s} |
| .node:hover .nres{opacity:.3} |
| .nres:hover{opacity:.8!important} |
| .nres::after{content:"";position:absolute;bottom:3px;right:3px;width:7px;height:7px;border-right:2px solid var(--muted);border-bottom:2px solid var(--muted)} |
| /* RESPONSIVE */ |
| @media(max-width:768px){ |
| :root{--sidebar-w:0px} |
| #sidebar{position:absolute;left:0;top:0;bottom:0;z-index:150;width:0;overflow:hidden;transition:width .2s} |
| #sidebar.open{width:clamp(180px,60vw,260px)} |
| #sidebarToggle{display:flex!important} |
| #minimap{width:7rem;height:4.75rem} |
| } |
| #sidebarToggle{display:none;align-items:center;justify-content:center;width:2rem;height:2rem;cursor:pointer;color:var(--muted);font-size:1.125rem;border:0;background:0;border-radius:4px;transition:all .1s} |
| #sidebarToggle:hover{color:var(--text);background:rgba(255,255,255,.05)} |
| ::-webkit-scrollbar{width:5px} |
| ::-webkit-scrollbar-track{background:transparent} |
| ::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px} |
| ::-webkit-scrollbar-thumb:hover{background:#484f58} |
| </style> |
| </head> |
| <body> |
| <div id="loader"><div class="spinner"></div><div id="ldMsg">Loading Pyodide runtime...</div><div id="ldSub"></div></div> |
| <div id="toast"></div> |
| <div id="app"> |
| <div id="sidebar"> |
| <div class="sb-tabs"> |
| <div class="sb-tab on" data-sb="nodes">Nodes</div> |
| <div class="sb-tab" data-sb="library">Library</div> |
| </div> |
| <div class="sb-content on" id="sbNodes"></div> |
| <div class="sb-content" id="sbLibrary"> |
| <div class="sb-title">Saved Workflows</div> |
| <div id="wfList"><div class="sb-empty">No saved workflows yet</div></div> |
| </div> |
| </div> |
| <div id="main"> |
| <div id="toolbar"> |
| <div style="display:flex;align-items:center"> |
| <div id="sidebarToggle" onclick="document.getElementById('sidebar').classList.toggle('open')">☰</div> |
| <div id="tabsScroll"></div> |
| </div> |
| <div class="tb-right"> |
| <button class="tbtn" id="btnAddTab" title="New tab">+</button> |
| <div style="width:1px;height:1.125rem;background:var(--border)"></div> |
| <button class="tbtn" id="btnSettings" title="Settings">⚙</button> |
| <button class="tbtn" id="btnSave" title="Save">Save</button> |
| <button class="tbtn" id="btnClear" title="Clear">Clear</button> |
| <button class="tbtn run" id="btnRun" disabled>Run</button> |
| <div id="statusWrap"><span class="sdot load" id="sDot"></span><span id="sTxt">Loading...</span></div> |
| </div> |
| <div id="settingsPanel"> |
| <div class="sp-head">Settings</div> |
| <div class="sp-body"> |
| <div class="sp-row"><label>Force run all</label><input type="checkbox" id="forceRunCheck"></div> |
| <div class="sp-row"><label>Grid size</label><select id="gridSizeSel"><option value="15">Tiny</option><option value="25" selected>Small</option><option value="40">Medium</option><option value="60">Large</option></select></div> |
| <div class="sp-row"><label>Snap to grid</label><input type="checkbox" id="snapCheck"></div> |
| <div class="sp-row"><label>Zoom</label> |
| <div class="zoom-row"> |
| <button class="zbtn" onclick="zoomStep(-1)" title="Zoom out">−</button> |
| <span class="zoom-val" id="zoomPct" onclick="this.focus()" onkeydown="if(event.key==='Enter'){setZoomPct(this);event.preventDefault()}" onblur="setZoomPct(this)">100%</span> |
| <button class="zbtn" onclick="zoomStep(1)" title="Zoom in">+</button> |
| </div> |
| </div> |
| <div class="sp-row"><label>Reset view</label><button class="tbtn" onclick="resetZoom()">Reset</button></div> |
| <div class="sp-row"><label>Fit all nodes</label><button class="tbtn" onclick="fitAll()">Fit</button></div> |
| </div> |
| </div> |
| </div> |
| <div id="wfPanel"> |
| <div id="wfC" tabindex="0"> |
| <div id="wfInner"></div> |
| <canvas id="connCvs"></canvas> |
| <div class="wf-hint" id="wfHint">Drag nodes here or double-click to add</div> |
| </div> |
| <div id="minimap" title="Click to navigate"><canvas id="minimapCvs"></canvas></div> |
| <div class="popup" id="ctxMenu"></div> |
| <div class="popup" id="searchMenu"><input type="text" id="searchInput" placeholder="Search nodes..."><div id="searchResults"></div></div> |
| </div> |
| </div> |
| </div> |
| <script> |
| |
| |
| |
| const PY_GRAPH = ` |
| import json |
| NODE_DEFS = { |
| "image_input": {"name": "Image Input", "color": "#4CAF50", "ins": [], "outs": ["image"], "params": [], "file": True, "value_node": False}, |
| "image_output": {"name": "Image Output", "color": "#FF5722", "ins": ["image"], "outs": [], "params": [], "file": False, "value_node": False}, |
| "grayscale": {"name": "Grayscale", "color": "#607D8B", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False}, |
| "gaussian_blur": {"name": "Gaussian Blur", "color": "#FF9800", "ins": ["image"], "outs": ["image"], "params": [{"id":"sigma","label":"Sigma","min":0.1,"max":10,"step":0.1,"def":1.5}], "file": False, "value_node": False}, |
| "sobel": {"name": "Sobel Edge", "color": "#009688", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False}, |
| "canny": {"name": "Canny Edge", "color": "#3F51B5", "ins": ["image"], "outs": ["image"], "params": [{"id":"sigma","label":"Sigma","min":0.1,"max":5,"step":0.1,"def":1.0},{"id":"low_t","label":"Low Thresh","min":0,"max":1,"step":0.01,"def":0.1},{"id":"high_t","label":"High Thresh","min":0,"max":1,"step":0.01,"def":0.3}], "file": False, "value_node": False}, |
| "threshold": {"name": "Otsu Threshold","color": "#795548", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False}, |
| "invert": {"name": "Invert", "color": "#E91E63", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False}, |
| "contour": {"name": "Contour Detect","color": "#9C27B0", "ins": ["image"], "outs": ["image"], "params": [{"id":"level","label":"Level","min":0,"max":1,"step":0.01,"def":0.5}], "file": False, "value_node": False}, |
| "rotate": {"name": "Rotate", "color": "#00BCD4", "ins": ["image"], "outs": ["image"], "params": [{"id":"angle","label":"Angle","min":-180,"max":180,"step":1,"def":90}], "file": False, "value_node": False}, |
| "resize": {"name": "Resize", "color": "#8BC34A", "ins": ["image"], "outs": ["image"], "params": [{"id":"scale","label":"Scale","min":0.1,"max":3,"step":0.1,"def":0.5}], "file": False, "value_node": False}, |
| "int_value": {"name": "Int Value", "color": "#2196F3", "ins": [], "outs": ["value"], "params": [{"id":"value","label":"Value","min":-9999,"max":9999,"step":1,"def":0}], "file": False, "value_node": True}, |
| "float_value": {"name": "Float Value", "color": "#03A9F4", "ins": [], "outs": ["value"], "params": [{"id":"value","label":"Value","min":-9999,"max":9999,"step":0.01,"def":0.0}], "file": False, "value_node": True} |
| } |
| class WorkflowGraph: |
| def __init__(self): |
| self.nodes = {} |
| self.edges = [] |
| def add_node(self, nid, ntype): |
| nid = int(nid) |
| params = {p["id"]: p["def"] for p in NODE_DEFS.get(ntype, {}).get("params", [])} |
| self.nodes[nid] = {"type": ntype, "params": params, "promoted": [], "disabled": False} |
| return True |
| def remove_node(self, nid): |
| nid = int(nid) |
| self.nodes.pop(nid, None) |
| self.edges = [e for e in self.edges if int(e["fn"]) != nid and int(e["tn"]) != nid] |
| def update_param(self, nid, pid, val): |
| nid = int(nid) |
| if nid in self.nodes: self.nodes[nid]["params"][pid] = val |
| def toggle_disable(self, nid): |
| nid = int(nid) |
| if nid in self.nodes: |
| self.nodes[nid]["disabled"] = not self.nodes[nid]["disabled"] |
| return self.nodes[nid]["disabled"] |
| return False |
| def promote_param(self, nid, pid): |
| nid = int(nid) |
| node = self.nodes.get(nid) |
| if node and pid in node.get("params",{}) and pid not in node.get("promoted",[]): |
| node.setdefault("promoted",[]).append(pid) |
| return True |
| return False |
| def demote_param(self, nid, pid): |
| nid = int(nid) |
| node = self.nodes.get(nid) |
| if node and pid in node.get("promoted",[]): |
| node["promoted"].remove(pid) |
| return True |
| return False |
| def get_inputs(self, nid): |
| nid = int(nid) |
| node = self.nodes.get(nid) |
| if not node: return [] |
| base = list(NODE_DEFS.get(node["type"], {}).get("ins", [])) |
| for p in node.get("promoted", []): base.append(p) |
| return base |
| def get_outs(self, nid): |
| nid = int(nid) |
| node = self.nodes.get(nid) |
| if not node: return [] |
| return list(NODE_DEFS.get(node["type"], {}).get("outs", [])) |
| def is_value_node(self, nid): |
| nid = int(nid) |
| node = self.nodes.get(nid) |
| if not node: return False |
| return NODE_DEFS.get(node["type"], {}).get("value_node", False) |
| def add_edge(self, fn, fp, tn, tp): |
| fn, fp, tn, tp = int(fn), int(fp), int(tn), int(tp) |
| if fn == tn: return False |
| if self._would_cycle(fn, tn): return False |
| self.edges = [e for e in self.edges if not (int(e["tn"]) == tn and int(e["tp"]) == tp)] |
| self.edges.append({"fn": fn, "fp": fp, "tn": tn, "tp": tp}) |
| return True |
| def remove_edge(self, fn, fp, tn, tp): |
| fn, fp, tn, tp = int(fn), int(fp), int(tn), int(tp) |
| self.edges = [e for e in self.edges if not (int(e["fn"]) == fn and int(e["fp"]) == fp and int(e["tn"]) == tn and int(e["tp"]) == tp)] |
| def _would_cycle(self, fn, tn): |
| visited, stack = set(), [tn] |
| while stack: |
| cur = stack.pop() |
| if cur == fn: return True |
| if cur in visited: continue |
| visited.add(cur) |
| for e in self.edges: |
| if int(e["fn"]) == cur: stack.append(int(e["tn"])) |
| return False |
| def topological_sort(self): |
| int_nodes = {int(k) for k in self.nodes} |
| in_deg = {nid: 0 for nid in int_nodes} |
| adj = {nid: [] for nid in int_nodes} |
| for e in self.edges: |
| fn, tn = int(e["fn"]), int(e["tn"]) |
| if fn in in_deg and tn in in_deg: |
| in_deg[tn] += 1 |
| adj[fn].append(tn) |
| queue = [nid for nid, d in in_deg.items() if d == 0] |
| order = [] |
| while queue: |
| nid = queue.pop(0) |
| order.append(nid) |
| for nb in adj.get(nid, []): |
| in_deg[nb] -= 1 |
| if in_deg[nb] == 0: queue.append(nb) |
| return order if len(order) == len(int_nodes) else None |
| def get_state(self): |
| return {"nodes": self.nodes, "edges": self.edges} |
| def load_state(self, state): |
| self.nodes = {int(k): v for k, v in state.get("nodes", {}).items()} |
| for v in self.nodes.values(): |
| v.setdefault("promoted", []) |
| v.setdefault("disabled", False) |
| self.edges = state.get("edges", []) |
| def clear(self): |
| self.nodes = {} |
| self.edges = [] |
| graph = WorkflowGraph() |
| `; |
| const PY_PROCESSOR = ` |
| import numpy as np |
| import base64, io, json |
| from PIL import Image |
| def process_image(input_b64, operation, params_json): |
| params = json.loads(params_json) |
| img_data = base64.b64decode(input_b64) |
| img = Image.open(io.BytesIO(img_data)).convert("RGBA") |
| arr = np.array(img, dtype=np.float64) / 255.0 |
| rgb = arr[:, :, :3] |
| if operation == "grayscale": |
| from skimage.color import rgb2gray |
| result = rgb2gray(rgb) |
| elif operation == "gaussian_blur": |
| from skimage.filters import gaussian |
| result = gaussian(rgb, sigma=params.get("sigma", 1.5), channel_axis=-1) |
| elif operation == "sobel": |
| from skimage.filters import sobel; from skimage.color import rgb2gray |
| result = sobel(rgb2gray(rgb)) |
| elif operation == "canny": |
| from skimage.feature import canny; from skimage.color import rgb2gray |
| g = rgb2gray(rgb) |
| result = canny(g, sigma=params.get("sigma",1.0), low_threshold=params.get("low_t",0.1), high_threshold=params.get("high_t",0.3)).astype(np.float64) |
| elif operation == "threshold": |
| from skimage.filters import threshold_otsu; from skimage.color import rgb2gray |
| g = rgb2gray(rgb) |
| result = (g > threshold_otsu(g)).astype(np.float64) |
| elif operation == "invert": |
| result = 1.0 - rgb |
| elif operation == "contour": |
| from skimage.measure import find_contours; from skimage.color import rgb2gray |
| g = rgb2gray(rgb) |
| contours = find_contours(g, level=params.get("level", 0.5)) |
| result = np.zeros_like(g) |
| for c in contours: |
| rr = np.clip(np.round(c[:,0]).astype(int), 0, result.shape[0]-1) |
| cc = np.clip(np.round(c[:,1]).astype(int), 0, result.shape[1]-1) |
| result[rr, cc] = 1.0 |
| elif operation == "rotate": |
| from skimage.transform import rotate as sk_rotate |
| result = sk_rotate(rgb, angle=params.get("angle", 90), resize=True, channel_axis=-1) |
| elif operation == "resize": |
| from skimage.transform import resize as sk_resize |
| s = params.get("scale", 0.5) |
| nh, nw = max(1, int(rgb.shape[0]*s)), max(1, int(rgb.shape[1]*s)) |
| result = sk_resize(rgb, (nh, nw), channel_axis=-1, anti_aliasing=True) |
| else: |
| result = rgb |
| if result.ndim == 2: |
| result = np.stack([result]*3, axis=-1) |
| out = (np.clip(result, 0, 1) * 255).astype(np.uint8) |
| alpha = np.full((*out.shape[:2], 1), 255, dtype=np.uint8) |
| rgba = np.concatenate([out, alpha], axis=-1) |
| buf = io.BytesIO() |
| Image.fromarray(rgba, "RGBA").save(buf, format="PNG") |
| return base64.b64encode(buf.getvalue()).decode("ascii") |
| `; |
| |
| |
| |
| let pyodide = null, pyReady = false; |
| let nextNid = 1, mxZ = 10; |
| let zoom = 1, panX = 0, panY = 0, gridSize = 25; |
| const ZOOM_STEPS = [0.1,0.15,0.25,0.33,0.5,0.67,0.75,1,1.25,1.5,2,2.5,3,4]; |
| let dragInfo = null, connInfo = null, tempEnd = null, panInfo = null; |
| let workflows = [], activeWfId = null, wfCounter = 1; |
| let nodeMeta = {}; |
| const $ = s => document.querySelector(s); |
| const $$ = s => document.querySelectorAll(s); |
| const wfC = $('#wfC'), wfInner = $('#wfInner'); |
| const cvs = $('#connCvs'), cx = cvs.getContext('2d'); |
| const mmCvs = $('#minimapCvs'), mmCx = mmCvs.getContext('2d'); |
| |
| |
| |
| function toast(msg, type='inf') { |
| const t = $('#toast'); t.textContent = msg; |
| t.className = type + ' show'; |
| clearTimeout(t._t); t._t = setTimeout(() => t.className = '', 2800); |
| } |
| function py(code) { return pyReady ? pyodide.runPython(code) : null; } |
| function pyJson(code) { try { return JSON.parse(py(code)); } catch(e) { return null; } } |
| function hashStr(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i) | 0; return h; } |
| function portCenter(el) { |
| const cr = wfC.getBoundingClientRect(), pr = el.getBoundingClientRect(); |
| return { x: pr.left + pr.width/2 - cr.left, y: pr.top + pr.height/2 - cr.top }; |
| } |
| function viewportCenter() { |
| const r = wfC.getBoundingClientRect(); |
| return { x: (r.width/2 - panX) / zoom, y: (r.height/2 - panY) / zoom }; |
| } |
| |
| |
| |
| function resizeCvs() { |
| cvs.width = wfC.clientWidth; cvs.height = wfC.clientHeight; |
| mmCvs.width = 170; mmCvs.height = 115; |
| updateGrid(); drawConns(); drawMinimap(); |
| } |
| new ResizeObserver(resizeCvs).observe(wfC); |
| function updateGrid() { |
| const s = gridSize * zoom; |
| const ox = ((panX % s) + s) % s, oy = ((panY % s) + s) % s; |
| wfC.style.backgroundImage = 'radial-gradient(circle, rgba(48,54,61,0.55) 1px, transparent 1px)'; |
| wfC.style.backgroundSize = s + 'px ' + s + 'px'; |
| wfC.style.backgroundPosition = ox + 'px ' + oy + 'px'; |
| } |
| function applyTransform() { |
| wfInner.style.transform = `translate(${panX}px,${panY}px) scale(${zoom})`; |
| updateGrid(); drawConns(); drawMinimap(); updateZoomPct(); |
| } |
| function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); } |
| function zoomStep(dir) { |
| const idx = ZOOM_STEPS.reduce((best, v, i) => Math.abs(v - zoom) < Math.abs(ZOOM_STEPS[best] - zoom) ? i : best, 0); |
| const ni = Math.max(0, Math.min(ZOOM_STEPS.length - 1, idx + dir)); |
| const nz = ZOOM_STEPS[ni]; |
| const r = wfC.getBoundingClientRect(); |
| const cx = r.width / 2, cy = r.height / 2; |
| panX = cx - (cx - panX) * (nz / zoom); |
| panY = cy - (cy - panY) * (nz / zoom); |
| zoom = nz; applyTransform(); |
| } |
| function updateZoomPct() { $('#zoomPct').textContent = Math.round(zoom * 100) + '%'; } |
| function setZoomPct(el) { |
| const v = parseInt(el.textContent); |
| if (!isNaN(v) && v > 0) { |
| const nz = Math.max(0.1, Math.min(4, v / 100)); |
| const r = wfC.getBoundingClientRect(); |
| const cx = r.width/2, cy = r.height/2; |
| panX = cx - (cx - panX) * (nz / zoom); |
| panY = cy - (cy - panY) * (nz / zoom); |
| zoom = nz; applyTransform(); |
| } else updateZoomPct(); |
| } |
| function fitAll() { |
| const keys = Object.keys(nodeMeta); |
| if (!keys.length) { resetZoom(); return; } |
| let x1=Infinity, y1=Infinity, x2=-Infinity, y2=-Infinity; |
| keys.forEach(k => { const m = nodeMeta[k]; x1=Math.min(x1,m.x); y1=Math.min(y1,m.y); x2=Math.max(x2,m.x+m.w); y2=Math.max(y2,m.y+m.h); }); |
| const pad = 60, cw = x2-x1+pad*2, ch = y2-y1+pad*2; |
| const vw = wfC.clientWidth, vh = wfC.clientHeight; |
| zoom = Math.max(0.15, Math.min(vw/cw, vh/ch, 2)); |
| panX = (vw - cw*zoom)/2 - (x1-pad)*zoom; |
| panY = (vh - ch*zoom)/2 - (y1-pad)*zoom; |
| applyTransform(); |
| } |
| |
| |
| |
| function bezier(x1, y1, x2, y2, col, alpha) { |
| const cp = Math.max(40, Math.abs(x2-x1) * 0.45); |
| cx.save(); |
| cx.globalAlpha = alpha * 0.12; |
| cx.beginPath(); cx.moveTo(x1,y1); cx.bezierCurveTo(x1+cp,y1, x2-cp,y2, x2,y2); |
| cx.strokeStyle = col; cx.lineWidth = 8; cx.stroke(); |
| cx.globalAlpha = alpha * 0.85; |
| cx.beginPath(); cx.moveTo(x1,y1); cx.bezierCurveTo(x1+cp,y1, x2-cp,y2, x2,y2); |
| cx.strokeStyle = col; cx.lineWidth = 2.5; cx.lineCap = 'round'; cx.stroke(); |
| cx.restore(); |
| } |
| function drawConns() { |
| cx.clearRect(0, 0, cvs.width, cvs.height); |
| if (!pyReady) return; |
| const edges = pyJson('json.dumps(graph.edges)'); |
| if (edges) edges.forEach(c => { |
| const fp = wfInner.querySelector(`.port[data-nid="${c.fn}"][data-pt="out"][data-pi="${c.fp}"]`); |
| const tp = wfInner.querySelector(`.port[data-nid="${c.tn}"][data-pt="in"][data-pi="${c.tp}"]`); |
| if (fp && tp) { const a = portCenter(fp), b = portCenter(tp); bezier(a.x, a.y, b.x, b.y, '#ff6b6b', 1); } |
| }); |
| if (connInfo && tempEnd) { |
| const p = portCenter(connInfo.el); |
| if (connInfo.ptype === 'out') bezier(p.x, p.y, tempEnd.x, tempEnd.y, '#ff6b6b', 0.45); |
| else bezier(tempEnd.x, tempEnd.y, p.x, p.y, '#4ecdc4', 0.45); |
| } |
| } |
| function updPortStyles() { |
| if (!pyReady) return; |
| wfInner.querySelectorAll('.port').forEach(p => p.classList.remove('conn')); |
| const edges = pyJson('json.dumps(graph.edges)'); |
| if (edges) edges.forEach(c => { |
| const fp = wfInner.querySelector(`.port[data-nid="${c.fn}"][data-pt="out"][data-pi="${c.fp}"]`); |
| const tp = wfInner.querySelector(`.port[data-nid="${c.tn}"][data-pt="in"][data-pi="${c.tp}"]`); |
| if (fp) fp.classList.add('conn'); |
| if (tp) tp.classList.add('conn'); |
| }); |
| } |
| function updHint() { |
| const n = pyReady ? pyJson('len(graph.nodes)') : 0; |
| $('#wfHint').style.display = n ? 'none' : ''; |
| } |
| |
| |
| |
| let mmData = null; |
| function drawMinimap() { |
| mmCx.fillStyle = '#0d1117'; mmCx.fillRect(0, 0, 170, 115); |
| if (!pyReady) return; |
| const nids = pyJson('list(graph.nodes.keys())'); |
| if (!nids || !nids.length) { mmData = null; return; } |
| let x1=Infinity, y1=Infinity, x2=-Infinity, y2=-Infinity; |
| nids.forEach(nid => { const m = nodeMeta[nid]; if(m){x1=Math.min(x1,m.x);y1=Math.min(y1,m.y);x2=Math.max(x2,m.x+m.w);y2=Math.max(y2,m.y+m.h);} }); |
| const pad = 80; x1-=pad; y1-=pad; x2+=pad; y2+=pad; |
| const w=x2-x1, h=y2-y1, sc=Math.min(170/w, 115/h); |
| const ox=(170-w*sc)/2, oy=(115-h*sc)/2; |
| mmData = {minX:x1, minY:y1, scale:sc, ox, oy}; |
| nids.forEach(nid => { |
| const m = nodeMeta[nid]; if(!m) return; |
| const st = pyJson(`json.dumps(graph.nodes[${nid}])`); |
| const td = pyJson(`json.dumps(NODE_DEFS["${st.type}"])`); |
| mmCx.fillStyle = (td.color||'#666') + '88'; |
| mmCx.fillRect(ox+(m.x-x1)*sc, oy+(m.y-y1)*sc, Math.max(2,m.w*sc), Math.max(2,m.h*sc)); |
| }); |
| const vx=-panX/zoom, vy=-panY/zoom, vw=wfC.clientWidth/zoom, vh=wfC.clientHeight/zoom; |
| mmCx.strokeStyle='#ff6b6baa'; mmCx.lineWidth=1.5; |
| mmCx.strokeRect(ox+(vx-x1)*sc, oy+(vy-y1)*sc, vw*sc, vh*sc); |
| } |
| $('#minimap').addEventListener('click', e => { |
| if (!mmData) return; |
| const rect = e.currentTarget.getBoundingClientRect(); |
| const wx = (e.clientX-rect.left-mmData.ox)/mmData.scale + mmData.minX; |
| const wy = (e.clientY-rect.top-mmData.oy)/mmData.scale + mmData.minY; |
| panX = wfC.clientWidth/2 - wx*zoom; panY = wfC.clientHeight/2 - wy*zoom; |
| applyTransform(); |
| }); |
| |
| |
| |
| wfC.addEventListener('wheel', e => { |
| e.preventDefault(); |
| const nz = Math.max(0.1, Math.min(4, zoom * (e.deltaY > 0 ? 0.92 : 1.08))); |
| const r = wfC.getBoundingClientRect(); |
| const mx = e.clientX-r.left, my = e.clientY-r.top; |
| panX = mx-(mx-panX)*(nz/zoom); panY = my-(my-panY)*(nz/zoom); |
| zoom = nz; applyTransform(); |
| }, { passive: false }); |
| |
| |
| |
| function addNode(type, x, y, nidOverride, nodeState) { |
| const nid = nidOverride != null ? nidOverride : nextNid++; |
| if (nodeState) py(`graph.nodes[${nid}] = ${JSON.stringify(nodeState)}`); |
| else py(`graph.add_node(${nid}, "${type}")`); |
| const td = pyJson(`json.dumps(NODE_DEFS["${type}"])`); |
| const isValue = td.value_node; |
| nodeMeta[nid] = { x, y, w: isValue ? 160 : 190, h: isValue ? 100 : 120 }; |
| const el = document.createElement('div'); |
| el.className = 'node' + ((nodeState && nodeState.disabled) ? ' disabled' : ''); |
| el.dataset.nid = nid; |
| el.style.left = x + 'px'; el.style.top = y + 'px'; |
| if (nodeMeta[nid].w) el.style.width = nodeMeta[nid].w + 'px'; |
| const hdr = document.createElement('div'); |
| hdr.className = 'nhdr'; hdr.style.background = td.color; |
| hdr.innerHTML = `<span class="nhdr-title">${td.name}</span><span class="ndel" title="Delete node">×</span>`; |
| el.appendChild(hdr); |
| const body = document.createElement('div'); body.className = 'nbody'; el.appendChild(body); |
| const res = document.createElement('div'); res.className = 'nres'; el.appendChild(res); |
| wfInner.appendChild(el); |
| renderNodeBody(nid); |
| updHint(); drawMinimap(); |
| return nid; |
| } |
| function renderNodeBody(nid) { |
| const el = wfInner.querySelector(`.node[data-nid="${nid}"]`); |
| if (!el) return; |
| const body = el.querySelector('.nbody'); |
| body.innerHTML = ''; |
| const state = pyJson(`json.dumps(graph.nodes[${nid}])`); |
| const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`); |
| const ins = pyJson(`json.dumps(graph.get_inputs(${nid}))`); |
| const outs = pyJson(`json.dumps(graph.get_outs(${nid}))`); |
| const isValue = td.value_node; |
| |
| const wasSel = el.classList.contains('sel'); |
| el.className = 'node' + (state.disabled ? ' disabled' : '') + (wasSel ? ' sel' : ''); |
| |
| if (td.file) { |
| const fd = document.createElement('div'); fd.className = 'nfile'; |
| const fi = document.createElement('input'); fi.type = 'file'; fi.accept = 'image/*'; |
| fi.addEventListener('change', e => handleUpload(nid, e)); |
| const fb = document.createElement('span'); fb.className = 'fbtn'; fb.textContent = 'Upload'; |
| fb.addEventListener('click', () => fi.click()); |
| const sb = document.createElement('span'); sb.className = 'sbtn'; sb.textContent = 'Sample'; |
| sb.addEventListener('click', () => loadSample(nid)); |
| fd.append(fi, fb, sb); body.appendChild(fd); |
| } |
| |
| ins.forEach((nm, i) => { |
| const row = document.createElement('div'); row.className = 'npr inp'; |
| const pt = document.createElement('div'); pt.className = 'port'; |
| pt.dataset.nid = nid; pt.dataset.pt = 'in'; pt.dataset.pi = i; pt.dataset.pn = nm; |
| const lb = document.createElement('span'); lb.className = 'plbl'; lb.textContent = nm; |
| row.append(pt, lb); body.appendChild(row); |
| }); |
| |
| (td.params || []).forEach(p => { |
| if (state.promoted && state.promoted.includes(p.id)) return; |
| const pd = document.createElement('div'); pd.className = 'nparam'; |
| const lb = document.createElement('label'); |
| const lt = document.createElement('span'); lt.textContent = p.label; |
| const pv = document.createElement('span'); pv.className = 'pv'; |
| const val = state.params[p.id]; |
| pv.textContent = Number.isInteger(val) ? val : parseFloat(val).toFixed(2); |
| lb.append(lt, pv); |
| const inp = document.createElement('input'); inp.type = 'range'; |
| inp.min = p.min; inp.max = p.max; inp.step = p.step; inp.value = val; |
| inp.addEventListener('input', e => { |
| const v = parseFloat(e.target.value); |
| py(`graph.update_param(${nid}, "${p.id}", ${v})`); |
| pv.textContent = Number.isInteger(v) ? v : v.toFixed(2); |
| }); |
| pd.append(lb, inp); body.appendChild(pd); |
| }); |
| |
| if (isValue) { |
| const vd = document.createElement('div'); vd.className = 'nval-display'; vd.id = 'prev-' + nid; |
| const val = state.params.value; |
| vd.textContent = val != null ? (Number.isInteger(val) ? val : parseFloat(val).toFixed(2)) : '0'; |
| body.appendChild(vd); |
| } else { |
| const prev = document.createElement('div'); prev.className = 'nprev'; prev.id = 'prev-' + nid; |
| const m = nodeMeta[nid]; |
| if (m && m.outB64) { |
| const img = document.createElement('img'); img.src = 'data:image/png;base64,' + m.outB64; |
| prev.appendChild(img); |
| } else if (m && m.imgB64) { |
| const img = document.createElement('img'); img.src = 'data:image/png;base64,' + m.imgB64; |
| prev.appendChild(img); |
| } else { |
| prev.innerHTML = '<div class="ph">No image</div>'; |
| } |
| body.appendChild(prev); |
| } |
| |
| outs.forEach((nm, i) => { |
| const row = document.createElement('div'); row.className = 'npr outp'; |
| const lb = document.createElement('span'); lb.className = 'plbl'; lb.textContent = nm; |
| const pt = document.createElement('div'); pt.className = 'port'; |
| pt.dataset.nid = nid; pt.dataset.pt = 'out'; pt.dataset.pi = i; pt.dataset.pn = nm; |
| row.append(lb, pt); body.appendChild(row); |
| }); |
| |
| body.querySelectorAll('.port').forEach(pt => { |
| pt.addEventListener('mousedown', e => { |
| e.preventDefault(); e.stopPropagation(); |
| connInfo = { nid: parseInt(pt.dataset.nid), ptype: pt.dataset.pt, pidx: parseInt(pt.dataset.pi), el: pt }; |
| }); |
| }); |
| } |
| |
| |
| |
| wfInner.addEventListener('click', e => { |
| if (e.target.classList.contains('ndel')) { |
| const nid = parseInt(e.target.closest('.node').dataset.nid); |
| delNode(nid); |
| } |
| }); |
| function delNode(nid) { |
| py(`graph.remove_node(${nid})`); |
| const el = wfInner.querySelector(`.node[data-nid="${nid}"]`); |
| if (el) el.remove(); |
| delete nodeMeta[nid]; |
| updPortStyles(); drawConns(); updHint(); drawMinimap(); |
| } |
| |
| |
| |
| const PAN_THRESHOLD = 4; |
| document.addEventListener('mousemove', e => { |
| if (panInfo) { |
| const dx = e.clientX - panInfo.startX, dy = e.clientY - panInfo.startY; |
| if (!panInfo.moved && Math.abs(dx) + Math.abs(dy) > PAN_THRESHOLD) { panInfo.moved = true; wfC.style.cursor = 'grabbing'; } |
| if (panInfo.moved) { panX = panInfo.startPanX + dx; panY = panInfo.startPanY + dy; applyTransform(); } |
| return; |
| } |
| if (dragInfo) { |
| const dx = (e.clientX - dragInfo.lastX) / zoom, dy = (e.clientY - dragInfo.lastY) / zoom; |
| const m = nodeMeta[dragInfo.nid]; |
| const el = wfInner.querySelector(`.node[data-nid="${dragInfo.nid}"]`); |
| if (dragInfo.resizing) { |
| m.w = Math.max(160, m.w + dx); |
| m.h = Math.max(80, m.h + dy); |
| if (el) { el.style.width = m.w + 'px'; } |
| } else { |
| let nx = m.x + dx, ny = m.y + dy; |
| if ($('#snapCheck').checked) { const gs = gridSize; nx = Math.round(nx/gs)*gs; ny = Math.round(ny/gs)*gs; } |
| m.x = nx; m.y = ny; |
| if (el) { el.style.left = nx + 'px'; el.style.top = ny + 'px'; } |
| } |
| dragInfo.lastX = e.clientX; dragInfo.lastY = e.clientY; |
| drawConns(); drawMinimap(); |
| return; |
| } |
| if (connInfo) { |
| const cr = wfC.getBoundingClientRect(); |
| tempEnd = { x: e.clientX-cr.left, y: e.clientY-cr.top }; |
| drawConns(); |
| } |
| }); |
| document.addEventListener('mouseup', e => { |
| if (panInfo) { |
| wfC.style.cursor = ''; |
| if (!panInfo.moved) { wfInner.querySelectorAll('.node.sel').forEach(n => n.classList.remove('sel')); closeCtxMenu(); closeSearchMenu(); } |
| panInfo = null; return; |
| } |
| if (connInfo) { |
| const target = e.target.closest('.port'); |
| if (target) { |
| const tNid = parseInt(target.dataset.nid), tPt = target.dataset.pt, tPi = parseInt(target.dataset.pi); |
| if (tPt !== connInfo.ptype && tNid !== connInfo.nid) { |
| let fn, fp, tn, tp; |
| if (connInfo.ptype === 'out') { fn=connInfo.nid; fp=connInfo.pidx; tn=tNid; tp=tPi; } |
| else { fn=tNid; fp=tPi; tn=connInfo.nid; tp=connInfo.pidx; } |
| const ok = py(`graph.add_edge(${fn}, ${fp}, ${tn}, ${tp})`); |
| if (ok) updPortStyles(); |
| else toast('Cannot connect: would create a cycle', 'err'); |
| } |
| } |
| connInfo = null; tempEnd = null; drawConns(); |
| } |
| dragInfo = null; |
| }); |
| wfInner.addEventListener('mousedown', e => { |
| const nodeEl = e.target.closest('.node'); |
| if (!nodeEl || e.target.classList.contains('ndel')) return; |
| const nid = parseInt(nodeEl.dataset.nid); |
| mxZ++; nodeEl.style.zIndex = mxZ; |
| if (!e.shiftKey) wfInner.querySelectorAll('.node.sel').forEach(n => n.classList.remove('sel')); |
| nodeEl.classList.add('sel'); |
| if (e.target.classList.contains('nres')) { |
| dragInfo = { nid, resizing: true, lastX: e.clientX, lastY: e.clientY }; |
| e.preventDefault(); return; |
| } |
| if (e.target.closest('.nhdr') && !e.target.classList.contains('ndel')) { |
| dragInfo = { nid, resizing: false, lastX: e.clientX, lastY: e.clientY }; |
| e.preventDefault(); return; |
| } |
| }); |
| wfC.addEventListener('mousedown', e => { |
| if (e.target.closest('.node') || e.target.closest('#minimap') || e.target.closest('.popup')) return; |
| if (e.button === 2) return; |
| panInfo = { startPanX: panX, startPanY: panY, startX: e.clientX, startY: e.clientY, moved: false }; |
| e.preventDefault(); |
| }); |
| wfInner.addEventListener('contextmenu', e => { |
| e.preventDefault(); |
| const nodeEl = e.target.closest('.node'); |
| if (nodeEl) showCtxMenu(parseInt(nodeEl.dataset.nid), e); |
| }); |
| |
| |
| |
| function showCtxMenu(nid, e) { |
| const panel = $('#wfPanel'), pr = panel.getBoundingClientRect(), menu = $('#ctxMenu'); |
| const state = pyJson(`json.dumps(graph.nodes[${nid}])`); |
| const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`); |
| let html = `<div class="ctx-item danger" data-act="del">Delete Node</div>`; |
| html += `<div class="ctx-item" data-act="toggle">${state.disabled ? 'Enable' : 'Disable'} Node</div>`; |
| if (td.params && td.params.length) { |
| html += '<div class="ctx-sep"></div><div class="ctx-label">Parameters</div>'; |
| td.params.forEach(p => { |
| const isProm = state.promoted && state.promoted.includes(p.id); |
| html += `<div class="ctx-item" data-act="${isProm?'demote':'promote'}" data-pid="${p.id}">${p.label} <span class="ctx-sub">${isProm?'Demote':'Promote to input'}</span></div>`; |
| }); |
| } |
| menu.innerHTML = html; |
| menu.style.display = 'block'; |
| let left = e.clientX - pr.left, top = e.clientY - pr.top; |
| if (left + 180 > pr.width) left = pr.width - 185; |
| if (top + menu.offsetHeight > pr.height) top = pr.height - menu.offsetHeight - 5; |
| menu.style.left = Math.max(0, left) + 'px'; menu.style.top = Math.max(0, top) + 'px'; |
| menu.querySelectorAll('.ctx-item').forEach(item => { |
| item.addEventListener('click', () => { |
| const act = item.dataset.act, pid = item.dataset.pid; |
| if (act === 'del') delNode(nid); |
| else if (act === 'toggle') { py(`graph.toggle_disable(${nid})`); renderNodeBody(nid); } |
| else if (act === 'promote') { py(`graph.promote_param(${nid}, "${pid}")`); renderNodeBody(nid); updPortStyles(); drawConns(); } |
| else if (act === 'demote') { |
| const ins = pyJson(`json.dumps(graph.get_inputs(${nid}))`); |
| const pidx = ins.indexOf(pid); |
| if (pidx !== -1) py(`graph.edges = [e for e in graph.edges if not (int(e["tn"]) == ${nid} and int(e["tp"]) == ${pidx})]`); |
| py(`graph.demote_param(${nid}, "${pid}")`); |
| renderNodeBody(nid); updPortStyles(); drawConns(); |
| } |
| closeCtxMenu(); |
| }); |
| }); |
| } |
| function closeCtxMenu() { $('#ctxMenu').style.display = 'none'; } |
| |
| |
| |
| wfC.addEventListener('dblclick', e => { |
| if (e.target.closest('.node') || e.target.closest('#minimap') || e.target.closest('.popup')) return; |
| const pr = $('#wfPanel').getBoundingClientRect(); |
| showSearchMenu(e.clientX - pr.left, e.clientY - pr.top, (e.clientX - pr.left - panX)/zoom - 90, (e.clientY - pr.top - panY)/zoom - 20); |
| }); |
| function showSearchMenu(sx, sy, wx, wy) { |
| const menu = $('#searchMenu'), pr = $('#wfPanel').getBoundingClientRect(); |
| let left = sx, top = sy; |
| if (left + 220 > pr.width) left = pr.width - 225; |
| if (top + 200 > pr.height) top = pr.height - 205; |
| menu.style.left = Math.max(0, left) + 'px'; menu.style.top = Math.max(0, top) + 'px'; |
| menu.style.display = 'block'; menu._wx = wx; menu._wy = wy; |
| const inp = $('#searchInput'); inp.value = ''; inp.focus(); filterSearch(); |
| } |
| function closeSearchMenu() { $('#searchMenu').style.display = 'none'; } |
| $('#searchInput').addEventListener('input', filterSearch); |
| $('#searchInput').addEventListener('keydown', e => { if (e.key === 'Escape') closeSearchMenu(); }); |
| function filterSearch() { |
| const q = $('#searchInput').value.toLowerCase(); |
| const defs = pyReady ? pyJson('json.dumps(NODE_DEFS)') : {}; |
| const res = $('#searchResults'); |
| res.innerHTML = ''; |
| let count = 0; |
| for (const [type, d] of Object.entries(defs)) { |
| if (count >= 8) break; |
| if (d.name.toLowerCase().includes(q)) { |
| const div = document.createElement('div'); div.className = 'search-item'; |
| div.innerHTML = `<div class="sb-dot" style="background:${d.color}"></div>${d.name}`; |
| div.addEventListener('click', () => { |
| const m = $('#searchMenu'); |
| addNode(type, m._wx, m._wy); |
| closeSearchMenu(); |
| }); |
| res.appendChild(div); count++; |
| } |
| } |
| if (!count) res.innerHTML = '<div style="padding:8px;font-size:10px;color:var(--muted)">No results</div>'; |
| } |
| document.addEventListener('mousedown', e => { |
| if (!e.target.closest('#ctxMenu')) closeCtxMenu(); |
| if (!e.target.closest('#searchMenu') && !e.target.closest('#wfC')) closeSearchMenu(); |
| }); |
| |
| |
| |
| const NODE_CATEGORIES = [ |
| { title: 'Input / Output', items: [ |
| { type: 'image_input', color: '#4CAF50', name: 'Image Input' }, |
| { type: 'image_output', color: '#FF5722', name: 'Image Output' } |
| ]}, |
| { title: 'Values', items: [ |
| { type: 'int_value', color: '#2196F3', name: 'Int Value' }, |
| { type: 'float_value', color: '#03A9F4', name: 'Float Value' } |
| ]}, |
| { title: 'Color', items: [ |
| { type: 'grayscale', color: '#607D8B', name: 'Grayscale' }, |
| { type: 'invert', color: '#E91E63', name: 'Invert Colors' } |
| ]}, |
| { title: 'Filters', items: [ |
| { type: 'gaussian_blur', color: '#FF9800', name: 'Gaussian Blur' }, |
| { type: 'threshold', color: '#795548', name: 'Otsu Threshold' } |
| ]}, |
| { title: 'Edge Detection', items: [ |
| { type: 'sobel', color: '#009688', name: 'Sobel Edge' }, |
| { type: 'canny', color: '#3F51B5', name: 'Canny Edge' } |
| ]}, |
| { title: 'Transform', items: [ |
| { type: 'contour', color: '#9C27B0', name: 'Contour Detect' }, |
| { type: 'rotate', color: '#00BCD4', name: 'Rotate' }, |
| { type: 'resize', color: '#8BC34A', name: 'Resize' } |
| ]} |
| ]; |
| function buildSidebar() { |
| const sb = $('#sbNodes'); |
| sb.innerHTML = ''; |
| NODE_CATEGORIES.forEach(cat => { |
| const title = document.createElement('div'); title.className = 'sb-title'; title.textContent = cat.title; |
| sb.appendChild(title); |
| cat.items.forEach(item => { |
| const div = document.createElement('div'); div.className = 'sb-item'; div.draggable = true; |
| div.dataset.type = item.type; |
| div.innerHTML = `<div class="sb-dot" style="background:${item.color}"></div>${item.name}`; |
| div.addEventListener('dragstart', e => { e.dataTransfer.setData('nodeType', item.type); e.dataTransfer.effectAllowed = 'copy'; }); |
| div.addEventListener('dblclick', () => { |
| const c = viewportCenter(); |
| addNode(item.type, c.x - 90, c.y - 20); |
| }); |
| sb.appendChild(div); |
| }); |
| }); |
| } |
| |
| $$('.sb-tab').forEach(btn => btn.addEventListener('click', () => { |
| $$('.sb-tab').forEach(b => b.classList.remove('on')); |
| $$('.sb-content').forEach(b => b.classList.remove('on')); |
| btn.classList.add('on'); |
| $(btn.dataset.sb === 'nodes' ? '#sbNodes' : '#sbLibrary').classList.add('on'); |
| })); |
| |
| wfC.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); |
| wfC.addEventListener('drop', e => { |
| e.preventDefault(); |
| const type = e.dataTransfer.getData('nodeType'); |
| if (!type) return; |
| const cr = wfC.getBoundingClientRect(); |
| addNode(type, (e.clientX-cr.left-panX)/zoom - 90, (e.clientY-cr.top-panY)/zoom - 20); |
| }); |
| |
| |
| |
| function imgToB64(img) { |
| const maxD = 512; |
| let w = img.naturalWidth || img.width, h = img.naturalHeight || img.height; |
| if (w > maxD || h > maxD) { const s = Math.min(maxD/w, maxD/h); w = Math.round(w*s); h = Math.round(h*s); } |
| const c = document.createElement('canvas'); c.width = w; c.height = h; |
| c.getContext('2d').drawImage(img, 0, 0, w, h); |
| return c.toDataURL('image/png').split(',')[1]; |
| } |
| function setPreview(nid, b64, val) { |
| const prev = $(`#prev-${nid}`); |
| if (!prev) return; |
| if (prev.classList.contains('nval-display')) { |
| prev.textContent = val != null ? (Number.isInteger(val) ? val : parseFloat(val).toFixed(2)) : '0'; |
| return; |
| } |
| if (b64) { prev.innerHTML = ''; const img = document.createElement('img'); img.src = 'data:image/png;base64,' + b64; prev.appendChild(img); } |
| else prev.innerHTML = '<div class="ph">No image</div>'; |
| } |
| function handleUpload(nid, e) { |
| const file = e.target.files[0]; if (!file) return; |
| const reader = new FileReader(); |
| reader.onload = ev => { |
| const img = new Image(); |
| img.onload = () => { |
| const b64 = imgToB64(img); |
| const m = nodeMeta[nid]; |
| if (m) { m.imgB64 = b64; m.outB64 = b64; m.lastHash = null; } |
| setPreview(nid, b64); |
| }; |
| img.src = ev.target.result; |
| }; |
| reader.readAsDataURL(file); |
| } |
| function loadSample(nid) { |
| const c = document.createElement('canvas'); c.width = 320; c.height = 240; |
| const ctx = c.getContext('2d'); |
| const g = ctx.createLinearGradient(0,0,320,240); |
| g.addColorStop(0,'#1a2a3a'); g.addColorStop(1,'#2a4a3a'); |
| ctx.fillStyle = g; ctx.fillRect(0,0,320,240); |
| ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc(80,100,50,0,Math.PI*2); ctx.fill(); |
| ctx.fillStyle = '#2ecc71'; ctx.fillRect(160,60,80,80); |
| ctx.fillStyle = '#f39c12'; ctx.beginPath(); ctx.moveTo(280,40); ctx.lineTo(310,120); ctx.lineTo(250,120); ctx.closePath(); ctx.fill(); |
| ctx.fillStyle = '#9b59b6'; ctx.beginPath(); ctx.ellipse(160,180,60,30,0,0,Math.PI*2); ctx.fill(); |
| ctx.strokeStyle = '#ecf0f1'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(10,200); ctx.lineTo(310,200); ctx.stroke(); |
| const b64 = c.toDataURL('image/png').split(',')[1]; |
| const m = nodeMeta[nid]; |
| if (m) { m.imgB64 = b64; m.outB64 = b64; m.lastHash = null; } |
| setPreview(nid, b64); |
| } |
| |
| |
| |
| function initTabs() { |
| const saved = localStorage.getItem('nodeWfTabs'); |
| if (saved) { const s = JSON.parse(saved); workflows = s.workflows || []; wfCounter = s.counter || 1; } |
| if (!workflows.length) workflows.push({ id: wfCounter++, name: 'Workflow 1' }); |
| activeWfId = workflows[0].id; |
| renderTabs(); loadWfLibrary(); |
| loadCurrentWf(); |
| } |
| function renderTabs() { |
| const c = $('#tabsScroll'); c.innerHTML = ''; |
| workflows.forEach(wf => { |
| const tab = document.createElement('div'); tab.className = 'tab' + (wf.id === activeWfId ? ' on' : ''); |
| const nameSpan = document.createElement('span'); nameSpan.className = 'tab-name'; |
| nameSpan.contentEditable = 'true'; nameSpan.spellcheck = false; nameSpan.textContent = wf.name; |
| nameSpan.addEventListener('blur', () => { wf.name = nameSpan.textContent.trim() || 'Untitled'; loadWfLibrary(); }); |
| nameSpan.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); nameSpan.blur(); } }); |
| nameSpan.addEventListener('mousedown', e => e.stopPropagation()); |
| tab.appendChild(nameSpan); |
| if (workflows.length > 1) { |
| const cls = document.createElement('span'); cls.className = 'tab-close'; cls.textContent = '\u00d7'; |
| cls.addEventListener('click', e => { e.stopPropagation(); removeTab(wf.id); }); |
| tab.appendChild(cls); |
| } |
| tab.addEventListener('click', e => { if (!e.target.classList.contains('tab-close')) switchTab(wf.id); }); |
| c.appendChild(tab); |
| }); |
| } |
| function switchTab(id) { |
| saveCurrentWf(); activeWfId = id; |
| clearWfDOM(); py('graph.clear()'); loadCurrentWf(); renderTabs(); |
| } |
| function addTab() { |
| const id = wfCounter++; workflows.push({ id, name: 'Workflow ' + id }); switchTab(id); |
| } |
| function removeTab(id) { |
| saveCurrentWf(); workflows = workflows.filter(w => w.id !== id); |
| if (activeWfId === id) { activeWfId = workflows[0].id; clearWfDOM(); py('graph.clear()'); loadCurrentWf(); } |
| renderTabs(); persistTabs(); |
| } |
| $('#btnAddTab').addEventListener('click', addTab); |
| function clearWfDOM() { |
| wfInner.querySelectorAll('.node').forEach(n => n.remove()); |
| nodeMeta = {}; updHint(); drawConns(); drawMinimap(); |
| } |
| function saveCurrentWf() { |
| if (!pyReady) return; |
| const state = py('json.dumps(graph.get_state())'); |
| const wf = workflows.find(w => w.id === activeWfId); |
| if (wf) { |
| wf.state = state; |
| wf.meta = {}; |
| for (const [k, v] of Object.entries(nodeMeta)) { |
| wf.meta[k] = { x: v.x, y: v.y, w: v.w, h: v.h, outVal: v.outVal }; |
| } |
| } |
| persistTabs(); |
| } |
| function persistTabs() { |
| const lite = workflows.map(w => ({ id: w.id, name: w.name, state: w.state, meta: w.meta })); |
| localStorage.setItem('nodeWfTabs', JSON.stringify({ workflows: lite, counter: wfCounter })); |
| } |
| function loadCurrentWf() { |
| const wf = workflows.find(w => w.id === activeWfId); |
| if (!wf || !wf.state) { updHint(); return; } |
| let state; |
| try { state = typeof wf.state === 'string' ? JSON.parse(wf.state) : wf.state; } catch(e) { console.error('Failed to parse state', e); return; } |
| nodeMeta = {}; |
| if (wf.meta) { for (const [k, v] of Object.entries(wf.meta)) { nodeMeta[k] = { ...v, imgB64: null, outB64: null, lastHash: null }; } } |
| py(`graph.load_state(${JSON.stringify(state)})`); |
| for (const [nid, nState] of Object.entries(state.nodes)) { |
| const m = nodeMeta[nid] || { x: Math.random()*300+50, y: Math.random()*200+50, w: 190, h: 120 }; |
| nodeMeta[nid] = m; |
| const el = document.createElement('div'); |
| el.className = 'node' + (nState.disabled ? ' disabled' : ''); |
| el.dataset.nid = nid; |
| el.style.left = m.x + 'px'; el.style.top = m.y + 'px'; |
| if (m.w) el.style.width = m.w + 'px'; |
| const td = pyJson(`json.dumps(NODE_DEFS["${nState.type}"])`); |
| const hdr = document.createElement('div'); hdr.className = 'nhdr'; hdr.style.background = td.color; |
| hdr.innerHTML = `<span class="nhdr-title">${td.name}</span><span class="ndel" title="Delete">×</span>`; |
| el.appendChild(hdr); |
| const body = document.createElement('div'); body.className = 'nbody'; el.appendChild(body); |
| const res = document.createElement('div'); res.className = 'nres'; el.appendChild(res); |
| wfInner.appendChild(el); |
| renderNodeBody(parseInt(nid)); |
| } |
| const nids = Object.keys(state.nodes).map(Number); |
| if (nids.length) nextNid = Math.max(...nids) + 1; |
| updPortStyles(); drawConns(); updHint(); drawMinimap(); |
| } |
| |
| |
| |
| function loadWfLibrary() { |
| const list = $('#wfList'); |
| const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]'); |
| if (!libs.length) { list.innerHTML = '<div class="sb-empty">No saved workflows yet</div>'; return; } |
| list.innerHTML = ''; |
| libs.forEach((lib, i) => { |
| const div = document.createElement('div'); div.className = 'sb-wf-item'; |
| div.innerHTML = `<span>${lib.name}</span><span class="sb-wf-del">×</span>`; |
| div.querySelector('span:first-child').addEventListener('click', () => loadFromLibrary(i)); |
| div.querySelector('.sb-wf-del').addEventListener('click', e => { e.stopPropagation(); removeFromLibrary(i); }); |
| list.appendChild(div); |
| }); |
| } |
| function saveToLibrary() { |
| saveCurrentWf(); |
| const wf = workflows.find(w => w.id === activeWfId); |
| if (!wf || !wf.state) { toast('Nothing to save', 'err'); return; } |
| const name = prompt('Save workflow as:', wf.name); |
| if (!name) return; |
| const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]'); |
| const liteMeta = {}; |
| for (const [k, v] of Object.entries(nodeMeta)) liteMeta[k] = { x: v.x, y: v.y, w: v.w, h: v.h }; |
| const stateObj = typeof wf.state === 'string' ? JSON.parse(wf.state) : wf.state; |
| libs.push({ name, state: stateObj, meta: liteMeta }); |
| localStorage.setItem('nodeWfLibrary', JSON.stringify(libs)); |
| loadWfLibrary(); toast('Saved to library', 'ok'); |
| } |
| function loadFromLibrary(idx) { |
| const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]'); |
| if (!libs[idx]) return; |
| addTab(); |
| const wf = workflows.find(w => w.id === activeWfId); |
| wf.name = libs[idx].name; |
| wf.state = JSON.stringify(libs[idx].state); |
| wf.meta = libs[idx].meta || {}; |
| loadCurrentWf(); renderTabs(); toast('Workflow loaded', 'inf'); |
| } |
| function removeFromLibrary(idx) { |
| let libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]'); |
| libs.splice(idx, 1); |
| localStorage.setItem('nodeWfLibrary', JSON.stringify(libs)); |
| loadWfLibrary(); |
| } |
| $('#btnSave').addEventListener('click', saveToLibrary); |
| $('#btnClear').addEventListener('click', () => { clearWfDOM(); py('graph.clear()'); updHint(); toast('Canvas cleared', 'inf'); }); |
| |
| |
| |
| $('#btnSettings').addEventListener('click', e => { e.stopPropagation(); $('#settingsPanel').classList.toggle('open'); }); |
| document.addEventListener('click', e => { if (!e.target.closest('#settingsPanel') && !e.target.closest('#btnSettings')) $('#settingsPanel').classList.remove('open'); }); |
| $('#gridSizeSel').addEventListener('change', e => { gridSize = parseInt(e.target.value); updateGrid(); }); |
| |
| |
| |
| async function runWorkflow() { |
| if (!pyReady) { toast('Environment not ready', 'err'); return; } |
| const nodeCount = pyJson('len(graph.nodes)'); |
| if (!nodeCount) { toast('Add some nodes first', 'err'); return; } |
| const btn = $('#btnRun'); |
| btn.disabled = true; btn.textContent = 'Running...'; |
| const forceRun = $('#forceRunCheck').checked; |
| wfInner.querySelectorAll('.node').forEach(n => n.classList.remove('done','running','err')); |
| const order = pyJson('json.dumps(graph.topological_sort())'); |
| if (!order) { toast('Cycle detected!', 'err'); btn.disabled = false; btn.textContent = 'Run'; return; } |
| const edges = pyJson('json.dumps(graph.edges)'); |
| const inMap = {}; |
| order.forEach(nid => inMap[nid] = []); |
| if (edges) edges.forEach(e => inMap[e.tn].push(e)); |
| let hadError = false; |
| for (const nid of order) { |
| const nd = nodeMeta[nid]; if (!nd) continue; |
| const state = pyJson(`json.dumps(graph.nodes[${nid}])`); |
| const el = wfInner.querySelector(`.node[data-nid="${nid}"]`); |
| const isValue = pyJson(`graph.is_value_node(${nid})`); |
| const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`); |
| if (el) { el.classList.remove('done','running','err'); el.classList.add('running'); } |
| try { |
| let inputB64 = null; |
| const baseIns = pyJson(`json.dumps(NODE_DEFS["${state.type}"]["ins"])`); |
| if (inMap[nid]) { |
| inMap[nid].forEach(conn => { |
| const src = nodeMeta[conn.fn]; if (!src) return; |
| if (conn.tp === 0 && baseIns.length > 0 && baseIns[0] === 'image') { |
| inputB64 = src.outB64; |
| } |
| if (conn.tp >= baseIns.length && state.promoted) { |
| const pId = state.promoted[conn.tp - baseIns.length]; |
| if (pId && src.outVal !== undefined && src.outVal !== null) { |
| state.params[pId] = src.outVal; |
| } |
| } |
| }); |
| } |
| const currentHash = hashStr((inputB64||'').slice(0,80) + JSON.stringify(state.params) + state.type + String(state.disabled)); |
| if (state.disabled) { |
| nd.outB64 = inputB64; |
| nd.outVal = isValue ? state.params.value : undefined; |
| nd.lastHash = currentHash; |
| } else if (!forceRun && nd.lastHash === currentHash && (nd.outB64 || isValue)) { |
| |
| } else { |
| if (isValue) { |
| nd.outVal = state.params.value; |
| nd.outB64 = null; |
| } else if (state.type === 'image_input') { |
| nd.outB64 = nd.imgB64 || null; |
| nd.outVal = undefined; |
| } else if (state.type === 'image_output') { |
| nd.outB64 = inputB64; |
| nd.outVal = undefined; |
| } else { |
| if (!inputB64) { |
| nd.outB64 = null; |
| } else { |
| pyodide.globals.set('_in_b64', inputB64); |
| pyodide.globals.set('_op', state.type); |
| pyodide.globals.set('_pjson', JSON.stringify(state.params)); |
| py('output_b64 = process_image(_in_b64, _op, _pjson)'); |
| nd.outB64 = pyodide.globals.get('output_b64'); |
| } |
| nd.outVal = undefined; |
| } |
| nd.lastHash = currentHash; |
| } |
| setPreview(nid, nd.outB64, nd.outVal); |
| if (el) { el.classList.remove('running'); el.classList.add('done'); } |
| } catch (err) { |
| console.error('Node error:', state.type, err); |
| if (el) { el.classList.remove('running'); el.classList.add('err'); } |
| toast('Error in ' + (td.name || state.type) + ': ' + (err.message || String(err)).slice(0, 80), 'err'); |
| hadError = true; break; |
| } |
| await new Promise(r => setTimeout(r, 15)); |
| } |
| if (!hadError) toast('Workflow complete!', 'ok'); |
| btn.disabled = false; btn.textContent = 'Run'; |
| } |
| $('#btnRun').addEventListener('click', runWorkflow); |
| |
| |
| |
| document.addEventListener('keydown', e => { |
| if (e.target.contentEditable === 'true' || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA' || e.target.closest('.CodeMirror')) return; |
| if (e.key === 'Delete' || e.key === 'Backspace') { |
| const sel = wfInner.querySelectorAll('.node.sel'); |
| if (sel.length) { sel.forEach(n => delNode(parseInt(n.dataset.nid))); e.preventDefault(); } |
| } |
| if (e.key === 'Escape') { |
| closeCtxMenu(); closeSearchMenu(); |
| $('#settingsPanel').classList.remove('open'); |
| } |
| if (e.key === ' ') { |
| if (e.target === wfC || wfC.contains(e.target)) { e.preventDefault(); fitAll(); } |
| } |
| }); |
| |
| |
| |
| async function initPyodide() { |
| const ldMsg = $('#ldMsg'), ldSub = $('#ldSub'); |
| try { |
| ldMsg.textContent = 'Loading Pyodide runtime...'; |
| pyodide = await loadPyodide(); |
| ldSub.textContent = 'Loading numpy...'; |
| await pyodide.loadPackage('numpy'); |
| ldSub.textContent = 'Loading Pillow...'; |
| await pyodide.loadPackage('pillow'); |
| ldSub.textContent = 'Loading scikit-image...'; |
| try { |
| await pyodide.loadPackage('scikit-image'); |
| } catch(e) { |
| ldSub.textContent = 'Installing scikit-image via micropip...'; |
| await pyodide.loadPackage('micropip'); |
| const mp = pyodide.pyimport('micropip'); |
| await mp.install('scikit-image'); |
| } |
| ldSub.textContent = 'Initializing engine...'; |
| pyodide.runPython(PY_GRAPH); |
| pyodide.runPython(PY_PROCESSOR); |
| pyReady = true; |
| $('#btnRun').disabled = false; |
| $('#sDot').className = 'sdot ok'; |
| $('#sTxt').textContent = 'Ready'; |
| ldMsg.textContent = 'Ready!'; |
| setTimeout(() => $('#loader').classList.add('done'), 300); |
| buildSidebar(); |
| initTabs(); |
| toast('Ready! Drag nodes or double-click canvas to begin.', 'ok'); |
| } catch(e) { |
| console.error('Init error:', e); |
| ldMsg.textContent = 'Failed to load'; |
| ldSub.textContent = (e.message || String(e)).slice(0, 300); |
| $('.spinner').style.display = 'none'; |
| } |
| } |
| |
| |
| |
| resizeCvs(); |
| initPyodide(); |
| |
| |
| |
| let lastTouchDist = null; |
| wfC.addEventListener('touchstart', e => { |
| if (e.touches.length === 2) { |
| const dx = e.touches[0].clientX - e.touches[1].clientX; |
| const dy = e.touches[0].clientY - e.touches[1].clientY; |
| lastTouchDist = Math.sqrt(dx*dx + dy*dy); |
| } |
| }, { passive: true }); |
| wfC.addEventListener('touchmove', e => { |
| if (e.touches.length === 2 && lastTouchDist !== null) { |
| e.preventDefault(); |
| const dx = e.touches[0].clientX - e.touches[1].clientX; |
| const dy = e.touches[0].clientY - e.touches[1].clientY; |
| const dist = Math.sqrt(dx*dx + dy*dy); |
| const factor = dist / lastTouchDist; |
| const nz = Math.max(0.1, Math.min(4, zoom * factor)); |
| const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2; |
| const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2; |
| const r = wfC.getBoundingClientRect(); |
| const mx = cx - r.left, my = cy - r.top; |
| panX = mx - (mx - panX) * (nz / zoom); |
| panY = my - (my - panY) * (nz / zoom); |
| zoom = nz; |
| applyTransform(); |
| lastTouchDist = dist; |
| } |
| }, { passive: false }); |
| wfC.addEventListener('touchend', () => { lastTouchDist = null; }, { passive: true }); |
| </script> |
| </body> |
| </html> |
|
|