Add runnable graph editor controls

This commit is contained in:
2026-06-19 00:58:21 +02:00
parent 952f0b425a
commit 1068f7e659
10 changed files with 14932 additions and 10160 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
graphfx-ui/node_modules
graphfx-ui/dist
graphfx-ui/dist-ssr
.git
.gitignore
npm-debug.log*
.DS_Store

View File

@@ -4,14 +4,21 @@ FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npm ci --legacy-peer-deps
WORKDIR /app/graphfx-ui
COPY graphfx-ui/package*.json ./
RUN npm ci --legacy-peer-deps
WORKDIR /app
COPY . .
WORKDIR /app/graphfx-ui
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
COPY graphfx-ui/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/graphfx-ui/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -1,7 +1,8 @@
services:
graphfx-ui:
build:
context: .
context: ..
dockerfile: graphfx-ui/Dockerfile
image: graphfx-ui:local
container_name: graphfx-ui
restart: unless-stopped

View File

@@ -9,9 +9,22 @@
"version": "0.0.0",
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@mediapipe/face_detection": "^0.4.1646425229",
"@mediapipe/selfie_segmentation": "^0.1.1675465747",
"@tensorflow-models/body-segmentation": "^1.0.2",
"@tensorflow-models/face-detection": "^1.0.3",
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@tensorflow/tfjs-converter": "^4.22.0",
"@tensorflow/tfjs-core": "^4.22.0",
"@types/lodash": "^4.17.24",
"@types/uuid": "^10.0.0",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@zxing/browser": "^0.2.0",
"@zxing/library": "^0.22.0",
"lodash": "^4.18.1",
"uuid": "^14.0.0",
"vue": "^3.5.34"
},
"devDependencies": {
@@ -124,6 +137,18 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@mediapipe/face_detection": {
"version": "0.4.1646425229",
"resolved": "https://registry.npmjs.org/@mediapipe/face_detection/-/face_detection-0.4.1646425229.tgz",
"integrity": "sha512-aeCN+fRAojv9ch3NXorP6r5tcGVLR3/gC1HmtqB0WEZBRXrdP6/3W/sGR0dHr1iT6ueiK95G9PVjbzFosf/hrg==",
"license": "Apache-2.0"
},
"node_modules/@mediapipe/selfie_segmentation": {
"version": "0.1.1675465747",
"resolved": "https://registry.npmjs.org/@mediapipe/selfie_segmentation/-/selfie_segmentation-0.1.1675465747.tgz",
"integrity": "sha512-IxYxNhwE5VwOm52L1yoFWYLP7q9Pd+NJjzOC5tlepfvEGaY3o9hslhUrx9BgseqdfZtKSDtd/4NfCSMjNzQalA==",
"license": "Apache-2.0"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -435,6 +460,110 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tensorflow-models/body-segmentation": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@tensorflow-models/body-segmentation/-/body-segmentation-1.0.2.tgz",
"integrity": "sha512-sbPiL8wpqfKqh01of6qguZzU9yLWOQDTwiPEIHFPA/EAjz5T51LKHKIySll+mmteRo/TxNED8pxd9VJ6q2r7kg==",
"license": "Apache-2.0",
"dependencies": {
"rimraf": "^3.0.2"
},
"peerDependencies": {
"@mediapipe/selfie_segmentation": "~0.1.0",
"@tensorflow/tfjs-backend-webgl": "^4.9.0",
"@tensorflow/tfjs-converter": "^4.9.0",
"@tensorflow/tfjs-core": "^4.9.0"
}
},
"node_modules/@tensorflow-models/face-detection": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tensorflow-models/face-detection/-/face-detection-1.0.3.tgz",
"integrity": "sha512-4Ld/vFF8MrdFdrMWhlLKZD4hMW0PNY9OkYeqoCPNZ+LwFyenxAqVaNaWrR8JKp37vw9Nuzp4ILbkal5zPUnA0g==",
"license": "Apache-2.0",
"dependencies": {
"rimraf": "^3.0.2",
"tslib": "2.4.0"
},
"peerDependencies": {
"@mediapipe/face_detection": "~0.4.0",
"@tensorflow/tfjs-backend-webgl": "^4.21.0",
"@tensorflow/tfjs-converter": "^4.21.0",
"@tensorflow/tfjs-core": "^4.21.0"
}
},
"node_modules/@tensorflow-models/face-detection/node_modules/tslib": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"license": "0BSD"
},
"node_modules/@tensorflow/tfjs-backend-cpu": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz",
"integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==",
"license": "Apache-2.0",
"dependencies": {
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-backend-webgl": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz",
"integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==",
"license": "Apache-2.0",
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@types/offscreencanvas": "~2019.3.0",
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-converter": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz",
"integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==",
"license": "Apache-2.0",
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-core": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz",
"integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==",
"license": "Apache-2.0",
"dependencies": {
"@types/long": "^4.0.1",
"@types/offscreencanvas": "~2019.7.0",
"@types/seedrandom": "^2.4.28",
"@webgpu/types": "0.1.38",
"long": "4.0.0",
"node-fetch": "~2.6.1",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
}
},
"node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -446,6 +575,18 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
@@ -456,6 +597,24 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.3.0",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==",
"license": "MIT"
},
"node_modules/@types/seedrandom": {
"version": "2.4.34",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz",
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
@@ -767,6 +926,46 @@
}
}
},
"node_modules/@webgpu/types": {
"version": "0.1.38",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz",
"integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==",
"license": "BSD-3-Clause"
},
"node_modules/@zxing/browser": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz",
"integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.22.0"
}
},
"node_modules/@zxing/library": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz",
"integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==",
"license": "Apache-2.0",
"dependencies": {
"ts-custom-error": "^3.3.1"
},
"engines": {
"node": ">= 24.0.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/alien-signals": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
@@ -774,6 +973,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -931,6 +1152,12 @@
}
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -946,6 +1173,44 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1219,6 +1484,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1228,6 +1505,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1253,6 +1542,35 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1260,6 +1578,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1307,6 +1634,22 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
@@ -1341,6 +1684,12 @@
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1367,6 +1716,21 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1396,6 +1760,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
@@ -1518,6 +1895,28 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

View File

@@ -5,14 +5,28 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "vite build",
"type-check": "vue-tsc -b",
"preview": "vite preview"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@mediapipe/face_detection": "^0.4.1646425229",
"@mediapipe/selfie_segmentation": "^0.1.1675465747",
"@tensorflow-models/body-segmentation": "^1.0.2",
"@tensorflow-models/face-detection": "^1.0.3",
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@tensorflow/tfjs-converter": "^4.22.0",
"@tensorflow/tfjs-core": "^4.22.0",
"@types/lodash": "^4.17.24",
"@types/uuid": "^10.0.0",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@zxing/browser": "^0.2.0",
"@zxing/library": "^0.22.0",
"lodash": "^4.18.1",
"uuid": "^14.0.0",
"vue": "^3.5.34"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { VueFlow, useVueFlow, type Edge, type Node } from '@vue-flow/core'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { VueFlow, useVueFlow, type Connection, type Edge, type Node } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
@@ -11,6 +11,15 @@ import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFl
import { layoutGraph } from './lib/layout'
import { sampleGraph } from './lib/sampleGraph'
import apiExampleJson from '../examples/api.json?raw'
import {
createRuntimeGraph,
destroyRuntimeGraph,
setImageInput,
subscribeImageOutput,
type LabeledImageInput,
type LabeledImageOutput,
type RuntimeState,
} from './lib/graphRuntime'
const editorText = ref(JSON.stringify(sampleGraph, null, 2))
const parseError = ref<string | null>(null)
@@ -18,12 +27,29 @@ const graph = ref<GraphFxSerializedGraph>(sampleGraph)
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
const warnings = ref<GraphFxFlowWarning[]>([])
const layoutDirection = ref<'LR' | 'TB'>('LR')
const preferSavedPositions = ref(true)
const useSavedPositions = ref(true)
const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT') || 'My first graph')
const fileInput = ref<HTMLInputElement | null>(null)
const sidebarCollapsed = ref(false)
const activeTab = ref<'control' | 'data'>('control')
const { fitView } = useVueFlow()
const runtimeState = ref<RuntimeState | null>(null)
const runtimeStatus = ref('Graph not running')
const runtimeError = ref<string | null>(null)
const selectedOutputKey = ref('')
const outputPreviewUrl = ref<string | null>(null)
let unsubscribeOutput: (() => void) | null = null
const webcamVideo = ref<HTMLVideoElement | null>(null)
const webcamStream = ref<MediaStream | null>(null)
const webcamError = ref<string | null>(null)
const liveInputKeys = ref(new Set<string>())
const liveFrames = new Map<string, number>()
const labeledImageInputs = computed(() => runtimeState.value?.inputs || [])
const labeledImageOutputs = computed(() => runtimeState.value?.outputs || [])
const stats = computed(() => ({
nodes: nodes.value.length,
edges: edges.value.length,
@@ -37,8 +63,8 @@ function refreshFlow() {
const model = graphFxToVueFlow(parsed)
graph.value = parsed
nodes.value = layoutGraph(model.nodes, model.edges, {
direction: layoutDirection.value,
useSavedPositions: preferSavedPositions.value,
direction: 'LR',
useSavedPositions: useSavedPositions.value,
})
edges.value = model.edges
warnings.value = model.warnings
@@ -49,8 +75,57 @@ function refreshFlow() {
}
}
function syncEditorFromGraph() {
editorText.value = JSON.stringify(graph.value, null, 2)
}
function parseOutputHandle(handle?: string | null) {
const match = handle?.match(/^(.*):out:(.*)$/)
return match ? { nodeId: match[1], outputName: match[2] } : null
}
function parseInputHandle(handle?: string | null) {
const match = handle?.match(/^(.*):in:(.*)$/)
return match ? { nodeId: match[1], inputName: match[2] } : null
}
function updateInputConnection(targetNodeId: string, inputName: string, outputId: string | null) {
const target = graph.value.find((item) => item.node.id === targetNodeId)
const input = target?.node.options?.in?.[inputName]
if (!input) return
input.output = outputId
if (outputId) input.value = null
syncEditorFromGraph()
refreshFlow()
}
function onConnect(connection: Connection) {
const source = parseOutputHandle(connection.sourceHandle)
const target = parseInputHandle(connection.targetHandle)
if (!source || !target) return
updateInputConnection(target.nodeId, target.inputName, `${source.nodeId}-out-${source.outputName}`)
}
function onNodeDragStop(payload: unknown, maybeNode?: Node) {
const moved = (maybeNode || (payload as { node?: Node })?.node || payload) as Node | undefined
if (!moved?.id || !moved.position) return
const item = graph.value.find((entry) => entry.node.id === moved.id)
if (!item) return
item.x = Math.round(moved.position.x)
item.y = Math.round(moved.position.y)
syncEditorFromGraph()
}
function onEdgeDoubleClick(payload: unknown, maybeEdge?: Edge) {
const edge = (maybeEdge || (payload as { edge?: Edge })?.edge || payload) as Edge | undefined
const target = parseInputHandle(edge?.targetHandle)
if (!target) return
updateInputConnection(target.nodeId, target.inputName, null)
}
function loadSample() {
editorText.value = JSON.stringify(sampleGraph, null, 2)
selectedSaveSlot.value = 'sample'
refreshFlow()
}
@@ -99,104 +174,262 @@ async function importGraph(event: Event) {
const file = input.files?.[0]
if (!file) return
editorText.value = await file.text()
selectedSaveSlot.value = file.name.replace(/\.json$/i, '')
input.value = ''
refreshFlow()
}
function relayout() {
const model = graphFxToVueFlow(graph.value)
nodes.value = layoutGraph(model.nodes, model.edges, {
direction: layoutDirection.value,
useSavedPositions: preferSavedPositions.value,
})
edges.value = model.edges
warnings.value = model.warnings
nextTick(() => fitView({ padding: 0.18, duration: 250 }))
function loadJsonFromEditor() {
refreshFlow()
if (!parseError.value) activeTab.value = 'control'
}
watch([layoutDirection, preferSavedPositions], relayout)
function stopAllLiveInputs() {
for (const frame of liveFrames.values()) cancelAnimationFrame(frame)
liveFrames.clear()
liveInputKeys.value = new Set()
}
function stopLiveInput(key: string) {
const frame = liveFrames.get(key)
if (frame) cancelAnimationFrame(frame)
liveFrames.delete(key)
const next = new Set(liveInputKeys.value)
next.delete(key)
liveInputKeys.value = next
}
async function runGraph() {
refreshFlow()
if (parseError.value) return
stopAllLiveInputs()
unsubscribeOutput?.()
unsubscribeOutput = null
outputPreviewUrl.value = null
destroyRuntimeGraph(runtimeState.value)
runtimeState.value = null
runtimeError.value = null
runtimeStatus.value = 'Starting graph…'
try {
runtimeState.value = await createRuntimeGraph(graph.value)
runtimeStatus.value = `Running: ${runtimeState.value.inputs.length} labeled image inputs, ${runtimeState.value.outputs.length} labeled image outputs`
selectedOutputKey.value = runtimeState.value.outputs[0]?.key || ''
subscribeSelectedOutput()
} catch (error) {
runtimeError.value = error instanceof Error ? error.message : String(error)
runtimeStatus.value = 'Graph failed to start'
}
}
async function openWebcam() {
if (webcamStream.value) return
webcamError.value = null
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
webcamStream.value = stream
if (webcamVideo.value) {
webcamVideo.value.srcObject = stream
await webcamVideo.value.play()
}
} catch (error) {
webcamError.value = error instanceof Error ? error.message : String(error)
}
}
function closeWebcam() {
stopAllLiveInputs()
webcamStream.value?.getTracks().forEach((track) => track.stop())
webcamStream.value = null
if (webcamVideo.value) webcamVideo.value.srcObject = null
}
function outputByKey(key: string): LabeledImageOutput | undefined {
return labeledImageOutputs.value.find((output) => output.key === key)
}
function pushWebcamFrame(input: LabeledImageInput) {
if (!runtimeState.value?.graph || !webcamVideo.value) return
if (!webcamVideo.value.videoWidth || !webcamVideo.value.videoHeight) return
setImageInput(runtimeState.value.graph, input, webcamVideo.value)
}
async function snapshotInput(input: LabeledImageInput) {
await openWebcam()
stopLiveInput(input.key)
pushWebcamFrame(input)
}
async function toggleLiveInput(input: LabeledImageInput) {
if (liveInputKeys.value.has(input.key)) {
stopLiveInput(input.key)
return
}
await openWebcam()
const next = new Set(liveInputKeys.value)
next.add(input.key)
liveInputKeys.value = next
const tick = () => {
if (!liveInputKeys.value.has(input.key)) return
pushWebcamFrame(input)
liveFrames.set(input.key, requestAnimationFrame(tick))
}
tick()
}
function subscribeSelectedOutput() {
unsubscribeOutput?.()
unsubscribeOutput = null
outputPreviewUrl.value = null
if (!runtimeState.value?.graph || !selectedOutputKey.value) return
const output = outputByKey(selectedOutputKey.value)
if (!output) return
unsubscribeOutput = subscribeImageOutput(runtimeState.value.graph, output, (url) => {
outputPreviewUrl.value = url
})
}
watch(useSavedPositions, refreshFlow)
watch(selectedOutputKey, subscribeSelectedOutput)
onBeforeUnmount(() => {
stopAllLiveInputs()
closeWebcam()
unsubscribeOutput?.()
destroyRuntimeGraph(runtimeState.value)
})
refreshFlow()
</script>
<template>
<main class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand__mark">GFX</div>
<div>
<p>graphfx-ui</p>
<h1>GraphFX Pipeline Viewer</h1>
</div>
</div>
<p class="sidebar__intro">
Paste the JSON returned by <code>graph.serialize()</code>. If nodes do not have <code>x/y</code>, the UI lays them out automatically.
</p>
<div class="toolbar">
<button type="button" @click="refreshFlow">Render JSON</button>
<button type="button" class="secondary" @click="loadSample">Load sample</button>
<button type="button" class="secondary" @click="loadApiExample">Load api.json</button>
</div>
<div class="savebar">
<input v-model="selectedSaveSlot" type="text" aria-label="Save slot name" />
<main class="app-shell" :class="{ 'app-shell--collapsed': sidebarCollapsed }">
<aside class="sidebar" :aria-hidden="sidebarCollapsed">
<div class="savebar savebar--primary">
<input v-model="selectedSaveSlot" type="text" aria-label="Graph name" placeholder="Graph name" />
<button type="button" class="secondary" @click="loadGraph">Load</button>
<button type="button" class="secondary" @click="saveGraph">Save</button>
<button type="button" class="secondary" @click="fileInput?.click()">Import</button>
<button type="button" class="secondary" @click="exportGraph">Export</button>
<input ref="fileInput" class="visually-hidden" type="file" accept="application/json,.json" @change="importGraph" />
<button type="button" @click="saveGraph">Save</button>
</div>
<div class="options">
<label>
Layout
<select v-model="layoutDirection">
<option value="LR">Left right</option>
<option value="TB">Top bottom</option>
<div class="tabs" role="tablist" aria-label="Sidebar sections">
<button type="button" :class="{ active: activeTab === 'control' }" @click="activeTab = 'control'">Control</button>
<button type="button" :class="{ active: activeTab === 'data' }" @click="activeTab = 'data'">Data</button>
</div>
<section v-if="activeTab === 'control'" class="tab-panel">
<div class="panel-card">
<div class="control-row">
<span>Saved layout</span>
<button type="button" class="toggle" :class="{ active: useSavedPositions }" @click="useSavedPositions = !useSavedPositions">
{{ useSavedPositions ? 'On' : 'Off' }}
</button>
</div>
<div class="toolbar toolbar--examples">
<button type="button" class="secondary" @click="loadSample">Sample</button>
<button type="button" class="secondary" @click="loadApiExample">api.json</button>
</div>
<dl class="stats">
<div><dt>Nodes</dt><dd>{{ stats.nodes }}</dd></div>
<div><dt>Edges</dt><dd>{{ stats.edges }}</dd></div>
<div><dt>Saved</dt><dd>{{ stats.savedPositions }}</dd></div>
<div><dt>Warn</dt><dd>{{ stats.warnings }}</dd></div>
</dl>
</div>
<div class="panel-card">
<button type="button" class="run-button" @click="runGraph">Run graph</button>
<p class="status-line">{{ runtimeStatus }}</p>
<p v-if="runtimeError" class="error">{{ runtimeError }}</p>
</div>
<div class="panel-card">
<div class="section-heading">
<h2>Webcam input</h2>
<button type="button" class="secondary" @click="webcamStream ? closeWebcam() : openWebcam()">
{{ webcamStream ? 'Close webcam' : 'Open webcam' }}
</button>
</div>
<video ref="webcamVideo" class="webcam-preview" muted playsinline />
<p v-if="webcamError" class="error">{{ webcamError }}</p>
<p v-if="!runtimeState" class="muted">Run the graph to discover labeled image inputs.</p>
<div v-else-if="!labeledImageInputs.length" class="muted">No labeled image inputs found.</div>
<div v-else class="io-actions">
<div v-for="input in labeledImageInputs" :key="input.key" class="io-action-row">
<div>
<strong>{{ input.label }}</strong>
<small>{{ input.nodeName }}.{{ input.ioName }}</small>
</div>
<button type="button" class="secondary" :class="{ active: liveInputKeys.has(input.key) }" @click="toggleLiveInput(input)">
{{ liveInputKeys.has(input.key) ? 'Stop live' : 'Live' }}
</button>
<button type="button" class="secondary" @click="snapshotInput(input)">Snapshot</button>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-heading">
<h2>Output</h2>
</div>
<select v-model="selectedOutputKey" :disabled="!labeledImageOutputs.length">
<option value="">Select labeled image output</option>
<option v-for="output in labeledImageOutputs" :key="output.key" :value="output.key">
{{ output.label }} {{ output.nodeName }}.{{ output.ioName }}
</option>
</select>
<div class="output-preview">
<img v-if="outputPreviewUrl" :src="outputPreviewUrl" alt="Selected GraphFX output preview" />
<span v-else>No image yet</span>
</div>
</div>
</section>
<section v-else class="tab-panel">
<div class="data-actions">
<button type="button" @click="loadJsonFromEditor">Load JSON</button>
<button type="button" class="secondary" @click="fileInput?.click()">Import</button>
<button type="button" class="secondary" @click="exportGraph">Export</button>
<input ref="fileInput" class="visually-hidden" type="file" accept="application/json,.json" @change="importGraph" />
</div>
<p v-if="parseError" class="error">{{ parseError }}</p>
<div v-if="warnings.length" class="warnings">
<p>Diagnostics</p>
<ul>
<li v-for="warning in warnings" :key="`${warning.type}-${warning.nodeId}-${warning.ioName}-${warning.outputId}`">
{{ warning.message }}
</li>
</ul>
</div>
<label class="json-editor">
JSON
<textarea v-model="editorText" spellcheck="false" />
</label>
<label class="checkbox-row">
<input v-model="preferSavedPositions" type="checkbox" />
Use saved x/y when every node has coordinates
</label>
</div>
<dl class="stats">
<div><dt>Nodes</dt><dd>{{ stats.nodes }}</dd></div>
<div><dt>Edges</dt><dd>{{ stats.edges }}</dd></div>
<div><dt>Saved positions</dt><dd>{{ stats.savedPositions }}</dd></div>
<div><dt>Warnings</dt><dd>{{ stats.warnings }}</dd></div>
</dl>
<p v-if="parseError" class="error">{{ parseError }}</p>
<div v-if="warnings.length" class="warnings">
<p>Diagnostics</p>
<ul>
<li v-for="warning in warnings" :key="`${warning.type}-${warning.nodeId}-${warning.ioName}-${warning.outputId}`">
{{ warning.message }}
</li>
</ul>
</div>
<label class="json-editor">
Serialized graph
<textarea v-model="editorText" spellcheck="false" />
</label>
</section>
</aside>
<button type="button" class="sidebar-toggle" :aria-expanded="!sidebarCollapsed" @click="sidebarCollapsed = !sidebarCollapsed">
{{ sidebarCollapsed ? '' : '' }}
</button>
<section class="flow-panel" aria-label="GraphFX graph visualization">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:default-edge-options="{ type: 'smoothstep' }"
:nodes-draggable="true"
:nodes-connectable="false"
:nodes-connectable="true"
:elements-selectable="true"
:min-zoom="0.15"
:max-zoom="1.6"
fit-view-on-init
@connect="onConnect"
@node-drag-stop="onNodeDragStop"
@edge-double-click="onEdgeDoubleClick"
>
<template #node-graphfx="nodeProps">
<GraphFxNode v-bind="nodeProps" />

View File

@@ -0,0 +1,253 @@
import type { GraphFxSerializedGraph } from './graphfxToVueFlow'
type RuntimeGraph = {
nodes: Array<{ node: RuntimeNode }>
destroy?: () => void
}
type RuntimeNode = {
id: string
name: string
in: RuntimeIoSet<RuntimeInput>
out: RuntimeIoSet<RuntimeOutput>
}
type RuntimeIoSet<T> = {
variables: Record<string, unknown>
[key: string]: unknown
} & Iterable<T>
type RuntimeInput = RuntimeIo & {
output?: RuntimeOutput | null
value: unknown
}
type RuntimeOutput = RuntimeIo & {
value: unknown
onchange: (listener: (value: unknown) => void) => void
offchange: (listener: (value: unknown) => void) => void
}
type RuntimeIo = {
name: string
label?: string | null
type: string
}
export type LabeledImageInput = {
key: string
nodeId: string
nodeName: string
ioName: string
label: string
connectedOutput?: string | null
}
export type LabeledImageOutput = {
key: string
nodeId: string
nodeName: string
ioName: string
label: string
}
export type RuntimeState = {
graph: RuntimeGraph | null
inputs: LabeledImageInput[]
outputs: LabeledImageOutput[]
}
type NodeConstructor = new (options?: unknown) => RuntimeNode & {
deserialize: (data: unknown) => Promise<void>
reconnect: (data: unknown, outputs: Record<string, RuntimeOutput>) => Promise<void>
destroy?: () => void
}
const nodeLoaders: Record<string, () => Promise<{ default: NodeConstructor }>> = {
Resize: () => import('../../../src/nodes/Canvas2d/Resize'),
Compose: () => import('../../../src/nodes/Canvas2d/Compose'),
Fill: () => import('../../../src/nodes/Canvas2d/Fill'),
Flip: () => import('../../../src/nodes/Canvas2d/Flip'),
EmptySpace: () => import('../../../src/nodes/Canvas2d/EmptySpace'),
Text: () => import('../../../src/nodes/Canvas2d/Text'),
Rotate: () => import('../../../src/nodes/Canvas2d/Rotate'),
Webcam: () => import('../../../src/nodes/Webcam'),
Api: () => import('../../../src/nodes/Api'),
ApiJson: () => import('../../../src/nodes/ApiJson'),
Eval: () => import('../../../src/nodes/Eval'),
QRCodeDetector: () => import('../../../src/nodes/QRCodeDetector'),
MarkerDetector: () => import('../../../src/nodes/MarkerDetector'),
WaitForAll: () => import('../../../src/nodes/WaitForAll'),
QRCodeGenerator: () => import('../../../src/nodes/QRCodeGenerator'),
Base64ToImg: () => import('../../../src/nodes/Base64ToImg'),
ImgToBase64: () => import('../../../src/nodes/ImgToBase64'),
Font: () => import('../../../src/nodes/Font'),
Disable: () => import('../../../src/nodes/Disable'),
Numbers: () => import('../../../src/nodes/Math/Numbers'),
NumberBinaryOperation: () => import('../../../src/nodes/Math/NumberBinaryOperation'),
BrightnessContrast: () => import('../../../src/nodes/WebGL/BrightnessContrast'),
HSV: () => import('../../../src/nodes/WebGL/HSV'),
Sepia: () => import('../../../src/nodes/WebGL/Sepia'),
GreenScreen: () => import('../../../src/nodes/WebGL/Greenscreen'),
Channels: () => import('../../../src/nodes/WebGL/Channels'),
GradientMap: () => import('../../../src/nodes/WebGL/GradientMap'),
ColorLookupTable: () => import('../../../src/nodes/WebGL/ColorLookupTable'),
Vignette: () => import('../../../src/nodes/WebGL/Vignette'),
Blur: () => import('../../../src/nodes/WebGL/Blur'),
SoftEdge: () => import('../../../src/nodes/WebGL/SoftEdge'),
Edge: () => import('../../../src/nodes/WebGL/Edge'),
KernelPreset: () => import('../../../src/nodes/WebGL/KernelPreset'),
Levels: () => import('../../../src/nodes/WebGL/Levels'),
LevelsAuto: () => import('../../../src/nodes/WebGL/LevelsAuto'),
}
function ioKey(nodeId: string, ioName: string) {
return `${nodeId}:${ioName}`
}
function runtimeInputAt(graph: RuntimeGraph, input: LabeledImageInput): RuntimeInput | null {
const node = graph.nodes.find(({ node }) => node.id === input.nodeId)?.node
return (node?.in?.[input.ioName] as RuntimeInput | undefined) || null
}
function runtimeOutputAt(graph: RuntimeGraph, output: LabeledImageOutput): RuntimeOutput | null {
const node = graph.nodes.find(({ node }) => node.id === output.nodeId)?.node
return (node?.out?.[output.ioName] as RuntimeOutput | undefined) || null
}
export async function createRuntimeGraph(serializedGraph: GraphFxSerializedGraph): Promise<RuntimeState> {
const nodeEntries = await Promise.all(serializedGraph.map(async (entry) => {
const loader = nodeLoaders[entry.node.name]
if (!loader) throw new Error(`Runtime does not support node type "${entry.node.name}" yet.`)
const NodeClass = (await loader()).default
const node = new NodeClass(entry.node.options)
await node.deserialize({ id: entry.node.id, options: entry.node.options })
return { node, entry }
}))
const outputsById = nodeEntries
.flatMap(({ node }) => Object.values((node.out as RuntimeIoSet<RuntimeOutput>).__values as Record<string, RuntimeOutput> || {}))
.reduce<Record<string, RuntimeOutput>>((acc, output) => {
acc[(output as RuntimeOutput & { id: string }).id] = output
return acc
}, {})
await Promise.all(nodeEntries.map(({ node, entry }) => node.reconnect(entry.node, outputsById)))
const graph: RuntimeGraph = {
nodes: nodeEntries.map(({ node }) => ({ node })),
destroy() {
for (const { node } of this.nodes) node.destroy?.()
},
}
const inputs: LabeledImageInput[] = []
const outputs: LabeledImageOutput[] = []
for (const { node } of graph.nodes) {
for (const ioName of Object.keys(node.in.variables || {})) {
const input = node.in[ioName] as RuntimeInput | undefined
if (input?.type === 'Image' && input.label) {
inputs.push({
key: ioKey(node.id, ioName),
nodeId: node.id,
nodeName: node.name,
ioName,
label: input.label,
connectedOutput: input.output ? `${input.output.name}` : null,
})
}
}
for (const ioName of Object.keys(node.out.variables || {})) {
const output = node.out[ioName] as RuntimeOutput | undefined
if (output?.type === 'Image' && output.label) {
outputs.push({
key: ioKey(node.id, ioName),
nodeId: node.id,
nodeName: node.name,
ioName,
label: output.label,
})
}
}
}
return { graph, inputs, outputs }
}
function mediaSize(media: unknown) {
if (media instanceof HTMLCanvasElement || media instanceof OffscreenCanvas || media instanceof ImageBitmap) {
return { width: media.width, height: media.height }
}
if (media instanceof HTMLImageElement) {
return { width: media.naturalWidth || media.width, height: media.naturalHeight || media.height }
}
if (media instanceof HTMLVideoElement) {
return { width: media.videoWidth || media.width, height: media.videoHeight || media.height }
}
if (media instanceof ImageData) {
return { width: media.width, height: media.height }
}
return { width: 0, height: 0 }
}
export function drawMediaToCanvas(media: CanvasImageSource | ImageData, target?: HTMLCanvasElement) {
const size = mediaSize(media)
const canvas = target || document.createElement('canvas')
if (!size.width || !size.height) return canvas
canvas.width = size.width
canvas.height = size.height
const ctx = canvas.getContext('2d')
if (!ctx) return canvas
if (media instanceof ImageData) {
ctx.putImageData(media, 0, 0)
} else {
ctx.drawImage(media, 0, 0, size.width, size.height)
}
return canvas
}
export function mediaToDataUrl(media: unknown): string | null {
if (!media) return null
if (media instanceof HTMLImageElement && media.src) return media.src
if (
media instanceof HTMLCanvasElement ||
media instanceof HTMLVideoElement ||
media instanceof ImageBitmap ||
media instanceof ImageData
) {
const canvas = drawMediaToCanvas(media as CanvasImageSource | ImageData)
return canvas.width && canvas.height ? canvas.toDataURL('image/png') : null
}
if (media instanceof OffscreenCanvas) {
const canvas = document.createElement('canvas')
canvas.width = media.width
canvas.height = media.height
canvas.getContext('2d')?.drawImage(media, 0, 0)
return canvas.toDataURL('image/png')
}
return null
}
export function setImageInput(graph: RuntimeGraph, input: LabeledImageInput, media: CanvasImageSource | ImageData) {
const runtimeInput = runtimeInputAt(graph, input)
if (!runtimeInput) throw new Error(`Input ${input.label} no longer exists.`)
runtimeInput.value = drawMediaToCanvas(media)
}
export function subscribeImageOutput(graph: RuntimeGraph, output: LabeledImageOutput, listener: (url: string | null) => void) {
const runtimeOutput = runtimeOutputAt(graph, output)
if (!runtimeOutput) return () => undefined
const handler = (value: unknown) => listener(mediaToDataUrl(value))
runtimeOutput.onchange(handler)
listener(mediaToDataUrl(runtimeOutput.value))
return () => runtimeOutput.offchange(handler)
}
export function destroyRuntimeGraph(state: RuntimeState | null) {
state?.graph?.destroy?.()
}

View File

@@ -56,83 +56,73 @@ code {
radial-gradient(circle at 15% 10%, rgb(84 101 255 / 18%), transparent 32rem),
radial-gradient(circle at 88% 18%, rgb(55 213 255 / 14%), transparent 28rem),
#080b12;
transition: grid-template-columns 180ms ease;
}
.app-shell--collapsed {
grid-template-columns: 0 1fr;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
gap: 0.9rem;
min-width: 0;
padding: 0.9rem;
border-right: 1px solid rgb(255 255 255 / 8%);
background: rgb(10 14 24 / 92%);
background: rgb(10 14 24 / 94%);
box-shadow: 18px 0 44px rgb(0 0 0 / 28%);
overflow: auto;
transition: opacity 160ms ease;
}
.brand {
display: flex;
align-items: center;
gap: 0.9rem;
.app-shell--collapsed .sidebar {
opacity: 0;
pointer-events: none;
}
.brand__mark {
.sidebar-toggle {
position: absolute;
z-index: 20;
top: 0.85rem;
left: calc(430px - 0.9rem);
display: grid;
place-items: center;
width: 3.4rem;
height: 3.4rem;
border-radius: 18px;
background: linear-gradient(135deg, #8090ff, #33e1ba);
color: #08101b;
font-weight: 950;
letter-spacing: -0.08em;
width: 1.8rem;
height: 3rem;
border-radius: 999px;
padding: 0;
background: #172033;
color: #dce7ff;
box-shadow: 0 12px 28px rgb(0 0 0 / 30%), inset 0 0 0 1px rgb(255 255 255 / 10%);
transition: left 180ms ease;
}
.brand p, .brand h1 { margin: 0; }
.brand p {
color: #8fa4c7;
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.15em;
}
.brand h1 {
font-size: 1.2rem;
line-height: 1.1;
}
.sidebar__intro {
margin: 0;
color: #9eb0cf;
font-size: 0.92rem;
}
.toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.toolbar button {
padding-inline: 0.68rem;
white-space: nowrap;
}
.toolbar button:first-child {
grid-column: 1 / -1;
.app-shell--collapsed .sidebar-toggle {
left: 0.45rem;
}
.savebar {
display: grid;
grid-template-columns: 1fr repeat(4, auto);
grid-template-columns: 1fr auto auto;
gap: 0.45rem;
padding: 0.62rem;
padding: 0.58rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 16px;
background: rgb(255 255 255 / 4%);
}
.savebar input[type="text"] {
.savebar--primary {
position: sticky;
top: 0;
z-index: 5;
backdrop-filter: blur(14px);
}
.savebar input[type="text"],
.panel-card select {
min-width: 0;
width: 100%;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 10px;
padding: 0.55rem 0.68rem;
@@ -141,10 +131,206 @@ code {
}
.savebar button {
padding: 0.55rem 0.62rem;
padding: 0.55rem 0.72rem;
font-size: 0.78rem;
}
.tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
padding: 0.3rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 14px;
background: rgb(255 255 255 / 4%);
}
.tabs button {
padding: 0.58rem 0.7rem;
background: transparent;
color: #9eb0cf;
box-shadow: none;
}
.tabs button.active {
background: linear-gradient(135deg, #6d7cff, #37d5ff);
color: #07101d;
}
.tab-panel {
display: flex;
flex-direction: column;
gap: 0.85rem;
min-height: 0;
}
.panel-card {
display: grid;
gap: 0.7rem;
padding: 0.85rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 18px;
background: rgb(255 255 255 / 4%);
}
.control-row,
.section-heading,
.io-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.control-row span,
.section-heading h2 {
margin: 0;
color: #e8eefc;
font-size: 0.9rem;
font-weight: 900;
}
.toggle {
min-width: 4.4rem;
padding: 0.45rem 0.65rem;
background: #172033;
color: #9eb0cf;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 9%);
}
.toggle.active,
.io-action-row button.active {
background: linear-gradient(135deg, #33e1ba, #37d5ff);
color: #07101d;
}
.toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.toolbar button,
.data-actions button,
.io-action-row button {
padding-inline: 0.68rem;
white-space: nowrap;
}
.toolbar--examples button:first-child {
grid-column: auto;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.45rem;
margin: 0;
}
.stats div {
padding: 0.58rem;
border-radius: 14px;
background: rgb(255 255 255 / 5%);
border: 1px solid rgb(255 255 255 / 7%);
}
.stats dt {
margin-bottom: 0.12rem;
color: #8fa4c7;
font-size: 0.62rem;
font-weight: 900;
text-transform: uppercase;
}
.stats dd {
margin: 0;
color: #f5f8ff;
font-size: 1.05rem;
font-weight: 900;
}
.run-button {
width: 100%;
}
.status-line,
.muted {
margin: 0;
color: #9eb0cf;
font-size: 0.84rem;
}
.webcam-preview {
width: 100%;
max-height: 150px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 14px;
background: #050913;
object-fit: contain;
}
.io-actions {
display: grid;
gap: 0.45rem;
}
.io-action-row {
padding: 0.52rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 13px;
background: rgb(5 9 19 / 58%);
}
.io-action-row div {
min-width: 0;
display: grid;
}
.io-action-row strong,
.io-action-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.io-action-row strong {
color: #f8fbff;
font-size: 0.84rem;
}
.io-action-row small {
color: #8193b3;
font-size: 0.68rem;
}
.output-preview {
display: grid;
place-items: center;
min-height: 190px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 16px;
background:
linear-gradient(45deg, rgb(255 255 255 / 3%) 25%, transparent 25%),
linear-gradient(-45deg, rgb(255 255 255 / 3%) 25%, transparent 25%),
#050913;
background-size: 18px 18px;
color: #61708a;
}
.output-preview img {
max-width: 100%;
max-height: 320px;
object-fit: contain;
}
.data-actions {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.55rem;
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -154,72 +340,6 @@ code {
white-space: nowrap;
}
.options {
display: grid;
gap: 0.72rem;
padding: 0.9rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 18px;
background: rgb(255 255 255 / 4%);
}
.options label,
.json-editor {
display: grid;
gap: 0.42rem;
color: #b8c7e4;
font-size: 0.85rem;
font-weight: 800;
}
.options select {
width: 100%;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 10px;
padding: 0.55rem 0.7rem;
color: #f5f8ff;
background: #101827;
}
.checkbox-row {
display: flex !important;
grid-template-columns: none;
align-items: center;
gap: 0.55rem;
font-weight: 650 !important;
}
.checkbox-row input { width: 1rem; height: 1rem; }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin: 0;
}
.stats div {
padding: 0.68rem;
border-radius: 16px;
background: rgb(255 255 255 / 5%);
border: 1px solid rgb(255 255 255 / 7%);
}
.stats dt {
margin-bottom: 0.14rem;
color: #8fa4c7;
font-size: 0.68rem;
font-weight: 900;
text-transform: uppercase;
}
.stats dd {
margin: 0;
color: #f5f8ff;
font-size: 1.2rem;
font-weight: 900;
}
.error, .warnings {
margin: 0;
border-radius: 14px;
@@ -243,13 +363,18 @@ code {
.warnings ul { margin: 0; padding-left: 1.1rem; }
.json-editor {
min-height: 22rem;
display: grid;
gap: 0.42rem;
min-height: 30rem;
flex: 1;
color: #b8c7e4;
font-size: 0.85rem;
font-weight: 800;
}
.json-editor textarea {
flex: 1;
min-height: 22rem;
min-height: 30rem;
resize: vertical;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 18px;

View File

@@ -1,7 +1,17 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
graphfx: fileURLToPath(new URL('..', import.meta.url)),
},
},
server: {
fs: {
allow: ['..'],
},
},
})

23638
package-lock.json generated

File diff suppressed because it is too large Load Diff