Add runnable graph editor controls
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
399
graphfx-ui/package-lock.json
generated
399
graphfx-ui/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
253
graphfx-ui/src/lib/graphRuntime.ts
Normal file
253
graphfx-ui/src/lib/graphRuntime.ts
Normal 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?.()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
23638
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user