├── .github └── workflows │ ├── 01.basic-qa.yml │ ├── 50.codeql-analysis.yml │ └── 99.publishing.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── pre-commit └── pre-push ├── .nvmrc ├── .vscode ├── .gitignore ├── extensions.json ├── launch.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __mocks__ └── vscode.ts ├── architecture.drawio.png ├── biome.json ├── commitlint.config.js ├── demo-project ├── .vscode │ ├── extensions.json │ └── settings.json ├── README.md ├── fiha.json ├── layers │ ├── canvas │ │ ├── canvas-basics-animation.mjs │ │ ├── canvas-basics-setup.mjs │ │ ├── canvas.d.ts │ │ └── jsconfig.json │ └── threejs │ │ ├── basics-animation.mjs │ │ ├── basics-setup.mjs │ │ ├── jsconfig.json │ │ └── threejs.d.ts └── worker │ ├── jsconfig.json │ ├── worker-animation.mjs │ ├── worker-setup.mjs │ └── worker.d.ts ├── media ├── control.png ├── favicon.png ├── main.css ├── reset.css ├── vf.icon.alt.png ├── vf.icon.png ├── vf.icon.svg └── vscode.css ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── scripts-dts ├── assetTools.ts ├── canvas.ts ├── mathTools.ts ├── miscTools.ts ├── scriptRunner.ts ├── threejs.ts └── worker.ts ├── src ├── capture │ ├── audio.ts │ ├── index.tsx │ ├── midi.ts │ └── tsconfig.json ├── display │ ├── Display.ts │ ├── Display.worker.ts │ ├── DisplayWorker.test.ts │ ├── DisplayWorker.ts │ ├── displayState.ts │ ├── index.html │ ├── index.ts │ └── tsconfig.json ├── extension │ ├── VFExtension.ts │ ├── WebServer.ts │ ├── commands │ │ ├── addLayer.ts │ │ ├── createLayer.ts │ │ ├── index.ts │ │ ├── openEditor.ts │ │ ├── removeLayer.ts │ │ ├── resetData.ts │ │ ├── scaffoldProject.test.ts │ │ ├── scaffoldProject.ts │ │ ├── setBPM.ts │ │ ├── setStageSize.ts │ │ └── toggleLayer.ts │ ├── configuration.ts │ ├── extension.ts │ ├── getNonce.ts │ ├── getWebviewOptions.ts │ ├── getWorkspaceFolder.ts │ ├── isAppState.ts │ ├── isLayer.ts │ ├── isLayerType.ts │ ├── isScriptingData.ts │ ├── readLayerScripts.ts │ ├── readScripts.ts │ ├── readWorkspaceRC.test.ts │ ├── readWorkspaceRC.ts │ ├── reducerSpy.ts │ ├── scriptUri.ts │ ├── store.ts │ ├── textDocumentScriptInfo.ts │ ├── tsconfig.json │ ├── views │ │ ├── AudioViewProvider.ts │ │ ├── ControlViewProvider.ts │ │ ├── DisplaysViewProvider.ts │ │ ├── ScriptsViewProvider.ts │ │ ├── SettingsViewProvider.ts │ │ └── TimelineViewProvider.ts │ ├── webviews-media.ts │ ├── workspaceFileExists._test.ts │ ├── workspaceFileExists.ts │ └── writeWorkspaceRC.ts ├── layers │ ├── Canvas2D │ │ ├── Canvas2DLayer.dom.test.ts │ │ ├── Canvas2DLayer.ts │ │ └── canvasTools.ts │ ├── Layer.dom.test.ts │ ├── Layer.ts │ └── ThreeJS │ │ ├── ThreeJSLayer.skippedspec.ts │ │ └── ThreeJSLayer.ts ├── types.ts ├── utils │ ├── ScriptRunner.dom.test.ts │ ├── ScriptRunner.ts │ ├── Scriptable.ts │ ├── VFS.ts │ ├── assetTools.ts │ ├── blob2DataURI.ts │ ├── blobURI2DataURI.ts │ ├── com.dom.test.ts │ ├── com.ts │ ├── dataURI2Blob.ts │ ├── deprecate.ts │ ├── mathTools.ts │ └── miscTools.ts └── webviews │ ├── audioView.tsx │ ├── components │ ├── AppInfo.tsx │ ├── Audio.tsx │ ├── ControlDisplay.tsx │ ├── Display.tsx │ ├── DisplaysList.tsx │ ├── Providers.tsx │ └── StoreControl.tsx │ ├── contexts │ ├── ComContext.tsx │ └── DataContext.tsx │ ├── controlView.tsx │ ├── displaysView.tsx │ ├── index.html │ ├── store.ts │ ├── timelineView.tsx │ ├── tsconfig.json │ └── vscode.ts ├── tsconfig.base.json ├── types └── mic.d.ts └── vitest.config.ts /.github/workflows/01.basic-qa.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Basic Quality Assurance 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: 19 | - 20.x 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Install pnpm 30 | uses: pnpm/action-setup@v3 31 | with: 32 | version: 10 33 | run_install: false 34 | 35 | - name: Get pnpm store directory 36 | shell: bash 37 | run: | 38 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 39 | 40 | - uses: actions/cache@v4 41 | name: Setup pnpm cache 42 | with: 43 | path: ${{ env.STORE_PATH }} 44 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 45 | restore-keys: | 46 | ${{ runner.os }}-pnpm-store- 47 | 48 | - name: Install dependencies 49 | run: pnpm install --frozen-lockfile 50 | 51 | - name: Run tests 52 | run: pnpm test 53 | -------------------------------------------------------------------------------- /.github/workflows/50.codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Analysis" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | branches: [ main ] 19 | schedule: 20 | - cron: '30 1 * * 0' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | runs-on: ubuntu-latest 26 | permissions: 27 | security-events: write 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: [ 'javascript' ] 33 | 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v4 37 | 38 | - name: Initialize CodeQL 39 | uses: github/codeql-action/init@v3 40 | with: 41 | languages: ${{ matrix.language }} 42 | 43 | - name: Use Node.js 20.x 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: 20.x 47 | 48 | - name: Install pnpm 49 | uses: pnpm/action-setup@v3 50 | with: 51 | version: 10 52 | run_install: false 53 | 54 | - name: Get pnpm store directory 55 | shell: bash 56 | run: | 57 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 58 | 59 | - uses: actions/cache@v4 60 | name: Setup pnpm cache 61 | with: 62 | path: ${{ env.STORE_PATH }} 63 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 64 | restore-keys: | 65 | ${{ runner.os }}-pnpm-store- 66 | 67 | - name: Install dependencies 68 | run: pnpm install --frozen-lockfile 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/99.publishing.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Publishing 5 | 6 | on: 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: Either a SemVer or patch, minor, major 11 | required: false 12 | default: patch 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: 21 | - 20.x 22 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Use Node.js ${{ matrix.node-version }} 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | 34 | - name: Install pnpm 35 | uses: pnpm/action-setup@v3 36 | with: 37 | version: 10 38 | run_install: false 39 | 40 | - name: Get pnpm store directory 41 | shell: bash 42 | run: | 43 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 44 | 45 | - uses: actions/cache@v4 46 | name: Setup pnpm cache 47 | with: 48 | path: ${{ env.STORE_PATH }} 49 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 50 | restore-keys: | 51 | ${{ runner.os }}-pnpm-store- 52 | 53 | - name: Install dependencies 54 | run: pnpm install --frozen-lockfile 55 | 56 | - name: Conventional Changelog Action 57 | uses: TriPSs/conventional-changelog-action@v5 58 | with: 59 | github-token: ${{ secrets.GH_TOKEN }} 60 | git-message: 'chore(release): {version}' 61 | output-file: 'CHANGELOG.md' 62 | git-user-name: 'Valentin Vago as CI' 63 | git-user-email: 'zeropaper+vf-ci@irata.ch' 64 | 65 | - run: git status 66 | 67 | - name: Publish to VS Marketplace 68 | run: pnpm exec vsce publish -p ${{ secrets.VS_MARKETPLACE_TOKEN }} 69 | 70 | - run: git push 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | .eslintcache 4 | coverage 5 | *.vsix 6 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # allows `fixup!` subject for commits (not push) 5 | X_COMMITLINT_DEFAULT_IGNORES=1 npx --no-install commitlint --edit 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | COMMITLINT_ORIGIN_BRANCH=$(git branch --show-current) 5 | npx --no-install commitlint --from "origin/$COMMITLINT_ORIGIN_BRANCH" --to HEAD --verbose 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.19.1 -------------------------------------------------------------------------------- /.vscode/.gitignore: -------------------------------------------------------------------------------- 1 | settings.json -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "connor4312.esbuild-problem-matchers", 7 | "biomejs.biome" 8 | ] 9 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": ["--extensionDevelopmentPath=${workspaceRoot}"], 14 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 15 | "preLaunchTask": "extension: watch", 16 | "env": { 17 | "VSCODE_DEBUG_MODE": "true" 18 | } 19 | }, 20 | { 21 | "name": "Debug Jest Tests", 22 | "type": "node", 23 | "request": "launch", 24 | "runtimeArgs": [ 25 | "--inspect-brk", 26 | "${workspaceRoot}/node_modules/.bin/jest", 27 | "--runInBand", 28 | "--coverage", 29 | "false" 30 | ], 31 | "console": "integratedTerminal", 32 | "internalConsoleOptions": "neverOpen" 33 | }, 34 | { 35 | "name": "Debug Current Jest Tests", 36 | "type": "node", 37 | "request": "launch", 38 | "runtimeArgs": [ 39 | "--inspect-brk", 40 | "${workspaceRoot}/node_modules/.bin/jest", 41 | "--runInBand", 42 | "${fileBasenameNoExtension}" 43 | ], 44 | "console": "integratedTerminal", 45 | "internalConsoleOptions": "neverOpen" 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "shell", 8 | "command": "pnpm watch:extension", 9 | "problemMatcher": "$esbuild-watch", 10 | "isBackground": true, 11 | "group": "build", 12 | "label": "extension: watch", 13 | "dependsOn": [ 14 | "webviews:controlView: watch", 15 | "webviews:audioView: watch", 16 | "webviews:displaysView: watch", 17 | "webviews:timelineView: watch", 18 | "display worker: watch", 19 | "capture: watch", 20 | "display: watch" 21 | ] 22 | }, 23 | { 24 | "type": "shell", 25 | "command": "pnpm watch:webviews:controlView", 26 | "problemMatcher": "$esbuild-watch", 27 | "isBackground": true, 28 | "group": "build", 29 | "label": "webviews:controlView: watch" 30 | }, 31 | { 32 | "type": "shell", 33 | "command": "pnpm watch:webviews:audioView", 34 | "problemMatcher": "$esbuild-watch", 35 | "isBackground": true, 36 | "group": "build", 37 | "label": "webviews:audioView: watch" 38 | }, 39 | { 40 | "type": "shell", 41 | "command": "pnpm watch:webviews:displaysView", 42 | "problemMatcher": "$esbuild-watch", 43 | "isBackground": true, 44 | "group": "build", 45 | "label": "webviews:displaysView: watch" 46 | }, 47 | { 48 | "type": "shell", 49 | "command": "pnpm watch:webviews:timelineView", 50 | "problemMatcher": "$esbuild-watch", 51 | "isBackground": true, 52 | "group": "build", 53 | "label": "webviews:timelineView: watch" 54 | }, 55 | { 56 | "type": "shell", 57 | "command": "pnpm watch:display", 58 | "problemMatcher": "$esbuild-watch", 59 | "isBackground": true, 60 | "group": "build", 61 | "label": "display: watch" 62 | }, 63 | { 64 | "type": "shell", 65 | "command": "pnpm watch:capture", 66 | "problemMatcher": "$esbuild-watch", 67 | "isBackground": true, 68 | "group": "build", 69 | "label": "capture: watch" 70 | }, 71 | { 72 | "type": "shell", 73 | "command": "pnpm watch:displayworker", 74 | "problemMatcher": "$esbuild-watch", 75 | "isBackground": true, 76 | "group": "build", 77 | "label": "display worker: watch" 78 | } 79 | ] 80 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github 2 | .husky 3 | coverage 4 | src 5 | # out 6 | node_modules 7 | .eslintignore 8 | .eslintcache 9 | .eslintrs.js 10 | jest.config.ts 11 | .vscode 12 | .nvmrc 13 | tsconfig.* 14 | renovate.json 15 | .gitignore 16 | __mocks__ 17 | scripts-dts 18 | commitlint.config.js 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [3.3.0](https://github.com/zeropaper/visual-fiha/compare/v3.2.0...v3.3.0) (2021-10-17) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * correct runtime state implementation and usage ([9cbb8a1](https://github.com/zeropaper/visual-fiha/commit/9cbb8a17556a14549d3a4b253f5ebf863b8cab62)) 7 | * hack around TS block scoped variable error ([35b42cf](https://github.com/zeropaper/visual-fiha/commit/35b42cf6cac770b94c4351bd115c6532a5713040)) 8 | 9 | 10 | ### Features 11 | 12 | * **canvas:** improve centererGrid typing ([a62ea31](https://github.com/zeropaper/visual-fiha/commit/a62ea31b8d446ec8737c6f00baddc4a343a04f85)) 13 | * **demo:** allow command when ext. not activated ([f94a0cf](https://github.com/zeropaper/visual-fiha/commit/f94a0cf5dcc37c12de5361b178b553cd3cbd13ce)) 14 | 15 | 16 | 17 | # [3.2.0](https://github.com/zeropaper/visual-fiha/compare/v3.1.0...v3.2.0) (2021-09-21) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * allow release as scope for commits ([87306da](https://github.com/zeropaper/visual-fiha/commit/87306da9199f67b93eea2f06307d8984df349d54)) 23 | * **displayworker:** set state as a public variable ([7c5c4ab](https://github.com/zeropaper/visual-fiha/commit/7c5c4abc142e7d96a30beb878e3d5ce85d70776b)) 24 | * handle error type declaration ([e8f6e90](https://github.com/zeropaper/visual-fiha/commit/e8f6e9073417639e0bda867af30ebe1f8fdd3b63)) 25 | 26 | 27 | ### Features 28 | 29 | * **demo:** add basic demo scaffolding ([610c32f](https://github.com/zeropaper/visual-fiha/commit/610c32f3df100d8e01ec18261ae2d36057e9e1db)) 30 | 31 | 32 | 33 | # [3.1.0](https://github.com/zeropaper/visual-fiha/compare/v3.0.11...v3.1.0) (2021-09-18) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * **canvas2d:** cast OffscreenCanvas as HTMLCanvasElement ([2c9209a](https://github.com/zeropaper/visual-fiha/commit/2c9209a0f9d423bd6a286d0db71f8c49289eea0f)) 39 | * **displayworker:** correct resizeLayer usage ([33b75ab](https://github.com/zeropaper/visual-fiha/commit/33b75ab3d673468ca0d25ec565db546cedd74381)) 40 | * **server:** hhandle error response ([6607dbf](https://github.com/zeropaper/visual-fiha/commit/6607dbf88fc1c01b91e9553aae6acf497f7d23f0)) 41 | 42 | 43 | ### Features 44 | 45 | * **config:** allow human readable JSON5 ([aa0eed5](https://github.com/zeropaper/visual-fiha/commit/aa0eed5bc78a4f16887c05f98dd4fa6cd41e884d)) 46 | * **extension:** add workspaceFileExists ([10eeeb7](https://github.com/zeropaper/visual-fiha/commit/10eeeb7b718e1134fcc07cffa32d9b5f117b0e8c)) 47 | 48 | 49 | 50 | ## [3.0.11](https://github.com/zeropaper/visual-fiha/compare/v3.0.10...v3.0.11) (2021-09-07) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * add missing icon in package.json ([2b12c05](https://github.com/zeropaper/visual-fiha/commit/2b12c050c05b01310cabcc6085bb611648cf7dc6)) 56 | 57 | 58 | 59 | ## [3.0.10](https://github.com/zeropaper/visual-fiha/compare/v3.0.9...v3.0.10) (2021-09-07) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * **build:** ensure DisplayWorker compilation is run ([7920e39](https://github.com/zeropaper/visual-fiha/commit/7920e39559396d1bc3a301e8a12dc51945210a97)) 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Valentin Vago 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Visual Fiha — Visual Studio Code Extension 2 | 3 | [![Basic QA](https://github.com/zeropaper/visual-fiha/actions/workflows/01.basic-qa.yml/badge.svg)](https://github.com/zeropaper/visual-fiha/actions/workflows/01.basic-qa.yml) 4 | 5 | ## Disclaimer 6 | 7 | This is a personal project and it is highly experimental and is not ready for production use. 8 | 9 | ## Description 10 | 11 | The Visual Fiha extension for VS Code is the third version of an application 12 | aimed to provide a live-coding VJing environment using web technologies. 13 | 14 | ![image](https://user-images.githubusercontent.com/65971/128530419-14828850-778d-427e-bd6f-221fed02fc46.png) 15 | 16 | ## Features 17 | 18 | - Live coding 19 | - Multi-display 20 | - Audio analysis 21 | - Custom, function based, 2D canvas rendering context API 22 | - ThreeJS based 3D rendering 23 | - Assets support 24 | 25 | ## Installation 26 | 27 | The extension on the [Visual Studio Code marketplace](https://marketplace.visualstudio.com/items?itemName=visual-fiha.visual-fiha) and can be installed from the extension panel of Visual Studio Code too. 28 | 29 | ## Getting started 30 | 31 | Select **Visual Fiha: Scaffold project** from the Command Palette (`Ctrl+Shift+P`). 32 | 33 | ## Project Tasks 34 | 35 | https://github.com/zeropaper/visual-fiha/projects/2 36 | 37 | ## Key Concepts 38 | 39 | ### Display 40 | 41 | A display is a web page that can is used to render. 42 | Typically, this is the page that you want to show to your audience with a projector. 43 | 44 | Tip: You can double-click the display to enter full screen mode. 45 | 46 | ### Layers 47 | 48 | Like many other graphical softwares, Visual Fiha has a layer system that allows compositing of images. 49 | 50 | Layers can be sorted, rendered or only activated (invisible as layer but re-usable in others, e.g. 3D texture). 51 | 52 | ### Scriptables 53 | 54 | The live-coding uses scriptable context that have a `setup` and an `animation` scripts. 55 | The `setup` script allows to define initial `cache` and `data` values that can be then used by the `animation` script. 56 | 57 | ### Cache 58 | 59 | Each scriptables have an object `cache` that can hold any values used for the scriptable instance scripts. 60 | You can read and write the cache values from the `setup` and `animation` scripts. 61 | 62 | ### Data 63 | 64 | The `data` is an object that is serializable and shared between the worker scripts and the layers scripts. 65 | 66 | You can define values in the `data` object by returning an object from the `setup` script. 67 | 68 | ### Worker 69 | 70 | Each display spawns a worker that is responsible for rendering the layers and send back the result to the display thread. 71 | 72 | ### Capture 73 | 74 | The capture page is aimed to react to different inputs (audio, MIDI, mouse, keyboard, touch, etc.) and forwards 75 | these inputs to the extension (that, in turn, broadcasts them to the display workers). 76 | 77 | #### Audio 78 | 79 | Note: In order for the audio capture to work properly, you should keep the capture page open in a separate window 80 | that has no other tabs open. 81 | 82 | ### Webview - Control 83 | 84 | Gives the VJ some controls over the layers and the project settings. 85 | 86 | ## Architecture 87 | 88 | The above "key concepts", graphically arranged. 89 | 90 | ![architecture](./architecture.drawio.png) 91 | 92 | ## Development 93 | 94 | Obviously, Visual Studio Code is required. 95 | 96 | 1. Clone the repository 97 | 2. Ensure that you are using the right NodeJS version with [NVM](https://github.com/nvm-sh/nvm/blob/master/README.md): `nvm use` 98 | 3. Install the dependencies with `npm install` 99 | 4. Press the F5 key to start the VS Code extension development 100 | 101 | ## License 102 | 103 | See [LICENSE](./LICENSE) 104 | -------------------------------------------------------------------------------- /__mocks__/vscode.ts: -------------------------------------------------------------------------------- 1 | let workspacePath = "/absolute/fictive"; 2 | 3 | const workspace = { 4 | workspaceFolders: ["fictive-a", "fictive-b"], 5 | }; 6 | 7 | export function __setWorkspace( 8 | absPath: string, 9 | info: { worksapceFolders: string[] }, 10 | ) { 11 | workspace.workspaceFolders = info.worksapceFolders; 12 | workspacePath = absPath; 13 | } 14 | 15 | export const Uri = { 16 | parse: jest.fn(), 17 | joinPath: jest.fn((_, filepath: string) => { 18 | // console.warn('[vscode mock] Uri.joinPath', workspacePath, filepath); 19 | return { 20 | fsPath: `${workspacePath}/${filepath}`, 21 | }; 22 | }), 23 | }; 24 | 25 | export { workspace }; 26 | -------------------------------------------------------------------------------- /architecture.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeropaper/visual-fiha/5c16fd09f07339f36fd64ae7a84bde9a53a9312b/architecture.drawio.png -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.6.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "complexity": { 11 | "noForEach": "off" 12 | }, 13 | "correctness": { 14 | "noSwitchDeclarations": "off" 15 | }, 16 | "performance": { 17 | "noAccumulatingSpread": "warn" 18 | }, 19 | "suspicious": { 20 | "noExplicitAny": "off" 21 | }, 22 | "style": { 23 | "noUnusedTemplateLiteral": "off", 24 | "noNonNullAssertion": "off" 25 | } 26 | } 27 | }, 28 | "formatter": { 29 | "enabled": true, 30 | "indentStyle": "space" 31 | }, 32 | "files": { 33 | "ignoreUnknown": true, 34 | "include": ["__mocks__/**", "demo-project/**", "script-dts/**", "src/**"] 35 | }, 36 | "vcs": { 37 | "enabled": true, 38 | "clientKind": "git", 39 | "useIgnoreFile": true, 40 | "defaultBranch": "main" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import("@commitlint/types").UserConfig} */ 4 | module.exports = { 5 | // Ideally, this should be true for commits and false for pushu 6 | defaultIgnores: !!process.env.X_COMMITLINT_DEFAULT_IGNORES, 7 | extends: ['@commitlint/config-conventional'], 8 | // https://commitlint.js.org/#/reference-rules 9 | rules: { 10 | 'scope-enum': [2, 'always', [ 11 | 'release', 12 | 'deps', 13 | 'extension', 14 | 'webserver', 15 | 'display', 16 | 'displayworker', 17 | 'worker', 18 | 'audio', 19 | 'midi', 20 | 'controls', 21 | 'com', 22 | 'demo', 23 | 'canvas', 24 | 'three', 25 | ]], 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /demo-project/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["visual-fiha.visual-fiha"] 3 | } 4 | -------------------------------------------------------------------------------- /demo-project/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/*.d.ts": true, 4 | "**/jsconfig.json": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /demo-project/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeropaper/visual-fiha/5c16fd09f07339f36fd64ae7a84bde9a53a9312b/demo-project/README.md -------------------------------------------------------------------------------- /demo-project/fiha.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "visual-fiha-demo", 3 | "layers": [ 4 | { 5 | "active": true, 6 | "id": "canvas-basics", 7 | "type": "canvas" 8 | }, 9 | { 10 | "active": true, 11 | "id": "threejs-basics", 12 | "type": "threejs" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /demo-project/layers/canvas/canvas-basics-animation.mjs: -------------------------------------------------------------------------------- 1 | clear(); 2 | const now = read("now", 0); 3 | 4 | const beatP = beatPrct(now, read("bpm", 120) * (1 / 2)); 5 | const beatNum = read("beatNum", 1); 6 | const frq = read("frequency", []); 7 | const vol = read("volume", []); 8 | const frqAvg = arrayAvg(frq); 9 | 10 | // if (frqAvg > 80) { 11 | // cache.generate(); 12 | // } 13 | lineCap("round"); 14 | lineCap("square"); 15 | (cache.lines || []).forEach((line) => line.render(now)); 16 | -------------------------------------------------------------------------------- /demo-project/layers/canvas/canvas-basics-setup.mjs: -------------------------------------------------------------------------------- 1 | class Line { 2 | constructor(index) { 3 | this.x = width(2) - width() * random(); 4 | this.y = height() * random(); 5 | this.length = random() * width(0.75); 6 | this.index = index; 7 | this.velocity = random() - 0.5; 8 | this.width = random(); 9 | 10 | const v = random(); 11 | this.color = rgba(v, v, v, 1); 12 | } 13 | 14 | render(now, beatNum) { 15 | const lwidth = vmin(this.width * 20); 16 | lineWidth(lwidth); 17 | strokeStyle(this.color); 18 | beginPath(); 19 | 20 | const distance = this.length + width() + lwidth; 21 | const relative = abs(now * this.velocity) % distance; 22 | 23 | let x = relative - this.length; 24 | 25 | if (this.velocity < 0) { 26 | x = width() - relative; 27 | } 28 | 29 | moveTo(x, this.y); 30 | lineTo(x + this.length, this.y); 31 | stroke(); 32 | } 33 | } 34 | 35 | cache.lines = []; 36 | cache.generate = () => { 37 | repeat(50, (i) => { 38 | cache.lines.push(new Line(i)); 39 | }); 40 | }; 41 | cache.generate(); 42 | -------------------------------------------------------------------------------- /demo-project/layers/canvas/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "noLib": true, 5 | "checkJs": true, 6 | "typeRoots": ["."] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /demo-project/layers/threejs/basics-animation.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // TODO 4 | 5 | -------------------------------------------------------------------------------- /demo-project/layers/threejs/basics-setup.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // TODO 4 | -------------------------------------------------------------------------------- /demo-project/layers/threejs/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "noLib": true, 5 | "checkJs": true, 6 | "typeRoots": ["."] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /demo-project/worker/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "noLib": true, 5 | "checkJs": true, 6 | "typeRoots": ["."] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /demo-project/worker/worker-animation.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // TODO 4 | -------------------------------------------------------------------------------- /demo-project/worker/worker-setup.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // TODO 4 | -------------------------------------------------------------------------------- /media/control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeropaper/visual-fiha/5c16fd09f07339f36fd64ae7a84bde9a53a9312b/media/control.png -------------------------------------------------------------------------------- /media/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeropaper/visual-fiha/5c16fd09f07339f36fd64ae7a84bde9a53a9312b/media/favicon.png -------------------------------------------------------------------------------- /media/main.css: -------------------------------------------------------------------------------- 1 | /* https://code.visualstudio.com/api/references/theme-color */ 2 | :root { 3 | --spacing: 4px; 4 | } 5 | 6 | html, body { 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | body { 12 | background-color: var(--vscode-editorWidget-background); 13 | } 14 | 15 | body > * { 16 | margin: 0; 17 | padding: 0; 18 | } 19 | 20 | #app { 21 | display: flex; 22 | align-items: flex-start; 23 | overflow: auto; 24 | } 25 | 26 | #controls { 27 | flex-grow: 1; 28 | margin-left: 1vw; 29 | } 30 | 31 | @media (orientation: portrait) { 32 | #app { 33 | flex-direction: column; 34 | } 35 | #controls { 36 | flex-grow: 1; 37 | margin-left: 0; 38 | margin-top: 1vh; 39 | width: 100%; 40 | display: flex; 41 | flex-wrap: wrap; 42 | } 43 | #controls > section { 44 | width: 50%; 45 | } 46 | } 47 | 48 | a.button { 49 | background-color: var(--vscode-button-background); 50 | color: var(--vscode-button-foreground); 51 | display: inline-block; 52 | padding: 0.5em 1em; 53 | /* padding: var(--input-padding-vertical) var(--input-padding-horizontal); */ 54 | margin: 0; 55 | text-decoration: none; 56 | border-radius: 0; 57 | border: 1px solid var(--vscode-button-border); 58 | cursor: pointer; 59 | } 60 | 61 | .button-wrapper { 62 | display: flex; 63 | flex-direction: column; 64 | justify-content: center; 65 | align-items: center; 66 | } 67 | 68 | /** 69 | * Control view 70 | */ 71 | #control-view { 72 | margin: 0; 73 | padding: 0; 74 | width: 100vw; 75 | height: 100vh; 76 | overflow: hidden; 77 | display: flex; 78 | align-items: center; 79 | } 80 | 81 | #control-view .control-display { 82 | display: block; 83 | z-index: 1; 84 | margin: 0 auto; 85 | padding: 0; 86 | border: 0; 87 | max-width: 100%; 88 | max-height: 100%; 89 | flex-grow: 1; 90 | } 91 | 92 | /** 93 | * Audio view 94 | */ 95 | #audio-view { 96 | padding: var(--spacing); 97 | display: flex; 98 | flex-direction: column; 99 | align-items: stretch; 100 | gap: var(--spacing); 101 | height: 100%; 102 | } 103 | 104 | #audio-view .bpm { 105 | display: flex; 106 | justify-content: space-around; 107 | align-items: center; 108 | } 109 | 110 | #audio-view .visualizer-wrapper { 111 | overflow: hidden; 112 | display: flex; 113 | justify-content: center; 114 | align-items: center; 115 | flex-grow: 1; 116 | } 117 | 118 | #audio-view .visualizer { 119 | background-color: var(--vscode-editor-background); 120 | width: 100%; 121 | height: 100%; 122 | max-width: 100%; 123 | min-height: 120px; 124 | } 125 | 126 | /** 127 | * Displays view 128 | */ 129 | #displays-view { 130 | padding: var(--spacing); 131 | display: flex; 132 | flex-direction: column; 133 | gap: var(--spacing); 134 | height: 100%; 135 | } 136 | 137 | .displays-wrapper { 138 | background-color: rgba(122, 122, 122, 0.5); 139 | display: flex; 140 | justify-content: center; 141 | align-items: center; 142 | min-height: 20vmin; 143 | flex-direction: column; 144 | overflow: hidden; 145 | flex-grow: 1; 146 | 147 | position: relative; 148 | justify-content: space-around; 149 | align-items: center; 150 | } 151 | 152 | .display { 153 | border: 1px solid currentColor; 154 | display: flex; 155 | flex-direction: column; 156 | align-items: center; 157 | justify-content: center; 158 | margin: var(--spacing); 159 | } 160 | 161 | /** 162 | * Timeline view 163 | */ 164 | #timeline-view { 165 | padding: var(--spacing); 166 | } -------------------------------------------------------------------------------- /media/reset.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | font-size: 13px; 4 | } 5 | 6 | *, 7 | *:before, 8 | *:after { 9 | box-sizing: inherit; 10 | } 11 | 12 | body, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | ol, 21 | ul { 22 | margin: 0; 23 | padding: 0; 24 | font-weight: normal; 25 | } 26 | 27 | img { 28 | max-width: 100%; 29 | height: auto; 30 | } 31 | -------------------------------------------------------------------------------- /media/vf.icon.alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeropaper/visual-fiha/5c16fd09f07339f36fd64ae7a84bde9a53a9312b/media/vf.icon.alt.png -------------------------------------------------------------------------------- /media/vf.icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeropaper/visual-fiha/5c16fd09f07339f36fd64ae7a84bde9a53a9312b/media/vf.icon.png -------------------------------------------------------------------------------- /media/vf.icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /media/vscode.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --container-paddding: 20px; 3 | --input-padding-vertical: 6px; 4 | --input-padding-horizontal: 4px; 5 | --input-margin-vertical: 4px; 6 | --input-margin-horizontal: 0; 7 | } 8 | 9 | body { 10 | padding: 0 var(--container-paddding); 11 | color: var(--vscode-foreground); 12 | font-size: var(--vscode-font-size); 13 | font-weight: var(--vscode-font-weight); 14 | font-family: var(--vscode-font-family); 15 | background-color: var(--vscode-editor-background); 16 | } 17 | 18 | ol, 19 | ul { 20 | padding-left: var(--container-paddding); 21 | } 22 | 23 | body > *, 24 | form > * { 25 | margin-block-start: var(--input-margin-vertical); 26 | margin-block-end: var(--input-margin-vertical); 27 | } 28 | 29 | *:focus { 30 | outline-color: var(--vscode-focusBorder) !important; 31 | } 32 | 33 | a { 34 | color: var(--vscode-textLink-foreground); 35 | } 36 | 37 | a:hover, 38 | a:active { 39 | color: var(--vscode-textLink-activeForeground); 40 | } 41 | 42 | code { 43 | font-size: var(--vscode-editor-font-size); 44 | font-family: var(--vscode-editor-font-family); 45 | } 46 | 47 | button { 48 | border: none; 49 | padding: var(--input-padding-vertical) var(--input-padding-horizontal); 50 | width: 100%; 51 | text-align: center; 52 | outline: 1px solid transparent; 53 | outline-offset: 2px !important; 54 | color: var(--vscode-button-foreground); 55 | background: var(--vscode-button-background); 56 | } 57 | 58 | button:hover { 59 | cursor: pointer; 60 | background: var(--vscode-button-hoverBackground); 61 | } 62 | 63 | button:focus { 64 | outline-color: var(--vscode-focusBorder); 65 | } 66 | 67 | button.secondary { 68 | color: var(--vscode-button-secondaryForeground); 69 | background: var(--vscode-button-secondaryBackground); 70 | } 71 | 72 | button.secondary:hover { 73 | background: var(--vscode-button-secondaryHoverBackground); 74 | } 75 | 76 | input:not([type='checkbox']), 77 | textarea { 78 | display: block; 79 | width: 100%; 80 | border: none; 81 | font-family: var(--vscode-font-family); 82 | padding: var(--input-padding-vertical) var(--input-padding-horizontal); 83 | color: var(--vscode-input-foreground); 84 | outline-color: var(--vscode-input-border); 85 | background-color: var(--vscode-input-background); 86 | } 87 | 88 | input::placeholder, 89 | textarea::placeholder { 90 | color: var(--vscode-input-placeholderForeground); 91 | } 92 | 93 | /* custom */ 94 | html { 95 | height: 100vh; 96 | width: 100vw; 97 | } 98 | 99 | body { 100 | height: 100%; 101 | } 102 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /scripts-dts/assetTools.ts: -------------------------------------------------------------------------------- 1 | import type * as assetTools from '../src/utils/assetTools' 2 | 3 | declare global { 4 | const clearFetchedAssets: typeof assetTools.clearFetchedAssets 5 | const loadImage: typeof assetTools.loadImage 6 | const loadVideo: typeof assetTools.loadVideo 7 | const asset: typeof assetTools.asset 8 | } 9 | 10 | export { } 11 | -------------------------------------------------------------------------------- /scripts-dts/canvas.ts: -------------------------------------------------------------------------------- 1 | import type { Canvas2DAPI } from '../src/layers/Canvas2D/canvasTools' 2 | 3 | import './scriptRunner' 4 | import './mathTools' 5 | import './miscTools' 6 | import './assetTools' 7 | 8 | declare global { 9 | const width: Canvas2DAPI['width'] 10 | const height: Canvas2DAPI['height'] 11 | const vmin: Canvas2DAPI['vmin'] 12 | const vmax: Canvas2DAPI['vmax'] 13 | const vh: Canvas2DAPI['vh'] 14 | const vw: Canvas2DAPI['vw'] 15 | const textLines: Canvas2DAPI['textLines'] 16 | const mirror: Canvas2DAPI['mirror'] 17 | const mediaType: Canvas2DAPI['mediaType'] 18 | const clear: Canvas2DAPI['clear'] 19 | const copy: Canvas2DAPI['copy'] 20 | const pasteImage: Canvas2DAPI['pasteImage'] 21 | const pasteContain: Canvas2DAPI['pasteContain'] 22 | const pasteCover: Canvas2DAPI['pasteCover'] 23 | const fontSize: Canvas2DAPI['fontSize'] 24 | const fontFamily: Canvas2DAPI['fontFamily'] 25 | const fontWeight: Canvas2DAPI['fontWeight'] 26 | const plot: Canvas2DAPI['plot'] 27 | const circle: Canvas2DAPI['circle'] 28 | const polygon: Canvas2DAPI['polygon'] 29 | const grid: Canvas2DAPI['grid'] 30 | const centeredGrid: Canvas2DAPI['centeredGrid'] 31 | 32 | const clip: Canvas2DAPI['clip'] 33 | const createImageData: Canvas2DAPI['createImageData'] 34 | const createLinearGradient: Canvas2DAPI['createLinearGradient'] 35 | const createPattern: Canvas2DAPI['createPattern'] 36 | const createRadialGradient: Canvas2DAPI['createRadialGradient'] 37 | const drawImage: Canvas2DAPI['drawImage'] 38 | const fill: Canvas2DAPI['fill'] 39 | const fillText: Canvas2DAPI['fillText'] 40 | const getImageData: Canvas2DAPI['getImageData'] 41 | const getLineDash: Canvas2DAPI['getLineDash'] 42 | const getTransform: Canvas2DAPI['getTransform'] 43 | const isPointInPath: Canvas2DAPI['isPointInPath'] 44 | const isPointInStroke: Canvas2DAPI['isPointInStroke'] 45 | const measureText: Canvas2DAPI['measureText'] 46 | const putImageData: Canvas2DAPI['putImageData'] 47 | const scale: Canvas2DAPI['scale'] 48 | const setLineDash: Canvas2DAPI['setLineDash'] 49 | const setTransform: Canvas2DAPI['setTransform'] 50 | const stroke: Canvas2DAPI['stroke'] 51 | const strokeText: Canvas2DAPI['strokeText'] 52 | const transform: Canvas2DAPI['transform'] 53 | const translate: Canvas2DAPI['translate'] 54 | const arc: Canvas2DAPI['arc'] 55 | const arcTo: Canvas2DAPI['arcTo'] 56 | const beginPath: Canvas2DAPI['beginPath'] 57 | const bezierCurveTo: Canvas2DAPI['bezierCurveTo'] 58 | const clearRect: Canvas2DAPI['clearRect'] 59 | const closePath: Canvas2DAPI['closePath'] 60 | const ellipse: Canvas2DAPI['ellipse'] 61 | const fillRect: Canvas2DAPI['fillRect'] 62 | const lineTo: Canvas2DAPI['lineTo'] 63 | 64 | // @ts-expect-error 65 | const moveTo: Canvas2DAPI['moveTo'] 66 | const quadraticCurveTo: Canvas2DAPI['quadraticCurveTo'] 67 | const rect: Canvas2DAPI['rect'] 68 | const resetTransform: Canvas2DAPI['resetTransform'] 69 | const restore: Canvas2DAPI['restore'] 70 | const rotate: Canvas2DAPI['rotate'] 71 | const save: Canvas2DAPI['save'] 72 | const strokeRect: Canvas2DAPI['strokeRect'] 73 | const globalAlpha: Canvas2DAPI['globalAlpha'] 74 | const globalCompositeOperation: Canvas2DAPI['globalCompositeOperation'] 75 | const filter: Canvas2DAPI['filter'] 76 | const imageSmoothingEnabled: Canvas2DAPI['imageSmoothingEnabled'] 77 | const imageSmoothingQuality: Canvas2DAPI['imageSmoothingQuality'] 78 | const strokeStyle: Canvas2DAPI['strokeStyle'] 79 | const fillStyle: Canvas2DAPI['fillStyle'] 80 | const shadowOffsetX: Canvas2DAPI['shadowOffsetX'] 81 | const shadowOffsetY: Canvas2DAPI['shadowOffsetY'] 82 | const shadowBlur: Canvas2DAPI['shadowBlur'] 83 | const shadowColor: Canvas2DAPI['shadowColor'] 84 | const lineWidth: Canvas2DAPI['lineWidth'] 85 | const lineCap: Canvas2DAPI['lineCap'] 86 | const lineJoin: Canvas2DAPI['lineJoin'] 87 | const miterLimit: Canvas2DAPI['miterLimit'] 88 | const lineDashOffset: Canvas2DAPI['lineDashOffset'] 89 | const font: Canvas2DAPI['font'] 90 | const textAlign: Canvas2DAPI['textAlign'] 91 | const textBaseline: Canvas2DAPI['textBaseline'] 92 | const direction: Canvas2DAPI['direction'] 93 | } 94 | 95 | export { } 96 | -------------------------------------------------------------------------------- /scripts-dts/mathTools.ts: -------------------------------------------------------------------------------- 1 | import type * as mathTools from '../src/utils/mathTools' 2 | 3 | declare global { 4 | const abs: typeof Math.abs 5 | const acos: typeof Math.acos 6 | const acosh: typeof Math.acosh 7 | const asin: typeof Math.asin 8 | const asinh: typeof Math.asinh 9 | const atan: typeof Math.atan 10 | const atanh: typeof Math.atanh 11 | const atan2: typeof Math.atan2 12 | const ceil: typeof Math.ceil 13 | const cbrt: typeof Math.cbrt 14 | const expm1: typeof Math.expm1 15 | const clz32: typeof Math.clz32 16 | const cos: typeof Math.cos 17 | const cosh: typeof Math.cosh 18 | const exp: typeof Math.exp 19 | const floor: typeof Math.floor 20 | const fround: typeof Math.fround 21 | const hypot: typeof Math.hypot 22 | const imul: typeof Math.imul 23 | const log: typeof Math.log 24 | const log1p: typeof Math.log1p 25 | const log2: typeof Math.log2 26 | const log10: typeof Math.log10 27 | const max: typeof Math.max 28 | const min: typeof Math.min 29 | const pow: typeof Math.pow 30 | const random: typeof Math.random 31 | const round: typeof Math.round 32 | const sign: typeof Math.sign 33 | const sin: typeof Math.sin 34 | const sinh: typeof Math.sinh 35 | const sqrt: typeof Math.sqrt 36 | const tan: typeof Math.tan 37 | const tanh: typeof Math.tanh 38 | const trunc: typeof Math.trunc 39 | const E: typeof Math.E 40 | const LN10: typeof Math.LN10 41 | const LN2: typeof Math.LN2 42 | const LOG10E: typeof Math.LOG10E 43 | const LOG2E: typeof Math.LOG2E 44 | const PI: typeof Math.PI 45 | const SQRT1_2: typeof Math.SQRT1_2 46 | const SQRT2: typeof Math.SQRT2 47 | 48 | const PI2: typeof mathTools.PI2 49 | const GR: typeof mathTools.GR 50 | const sDiv: typeof mathTools.sDiv 51 | const arrayMax: typeof mathTools.arrayMax 52 | const arrayMin: typeof mathTools.arrayMin 53 | const arraySum: typeof mathTools.arraySum 54 | const arrayDiff: typeof mathTools.arrayDiff 55 | const arrayAvg: typeof mathTools.arrayAvg 56 | const arrayMirror: typeof mathTools.arrayMirror 57 | const arrayDownsample: typeof mathTools.arrayDownsample 58 | const arraySmooth: typeof mathTools.arraySmooth 59 | const deg2rad: typeof mathTools.deg2rad 60 | const rad2deg: typeof mathTools.rad2deg 61 | const cap: typeof mathTools.cap 62 | const between: typeof mathTools.between 63 | const beatPrct: typeof mathTools.beatPrct 64 | const beat: typeof mathTools.beat 65 | 66 | // @ts-expect-error 67 | const orientation: typeof mathTools.orientation 68 | const objOrientation: typeof mathTools.objOrientation 69 | const containBox: typeof mathTools.containBox 70 | const coverBox: typeof mathTools.coverBox 71 | } 72 | 73 | export { } 74 | -------------------------------------------------------------------------------- /scripts-dts/miscTools.ts: -------------------------------------------------------------------------------- 1 | import type * as miscTools from '../src/utils/miscTools' 2 | 3 | declare global { 4 | const noop: typeof miscTools.noop 5 | const rgba: typeof miscTools.rgba 6 | const hsla: typeof miscTools.hsla 7 | const repeat: typeof miscTools.repeat 8 | const assetDataURI: typeof miscTools.assetDataURI 9 | const isFunction: typeof miscTools.isFunction 10 | const toggled: typeof miscTools.toggled 11 | const prevToggle: typeof miscTools.prevToggle 12 | const toggle: typeof miscTools.toggle 13 | const inOut: typeof miscTools.inOut 14 | const steps: typeof miscTools.steps 15 | const prevStepVals: typeof miscTools.prevStepVals 16 | const stepper: typeof miscTools.stepper 17 | const merge: typeof miscTools.merge 18 | } 19 | 20 | export { } 21 | -------------------------------------------------------------------------------- /scripts-dts/scriptRunner.ts: -------------------------------------------------------------------------------- 1 | import type { ReadInterface, Cache } from '../src/utils/Scriptable' 2 | import type { ScriptLog } from '../src/utils/ScriptRunner' 3 | 4 | declare global { 5 | const read: ReadInterface 6 | const cache: Cache 7 | const scriptLog: ScriptLog 8 | } 9 | -------------------------------------------------------------------------------- /scripts-dts/threejs.ts: -------------------------------------------------------------------------------- 1 | 2 | import type * as THREE from 'three' 3 | 4 | import './scriptRunner' 5 | import './mathTools' 6 | import './miscTools' 7 | import './assetTools' 8 | 9 | declare global { 10 | const THREE: typeof THREE 11 | } 12 | 13 | export { } 14 | -------------------------------------------------------------------------------- /scripts-dts/worker.ts: -------------------------------------------------------------------------------- 1 | import './scriptRunner' 2 | import './mathTools' 3 | import './miscTools' 4 | 5 | export { } 6 | -------------------------------------------------------------------------------- /src/capture/audio.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io-client"; 2 | import type { DefaultEventsMap } from "socket.io/dist/typed-events"; 3 | import type { ChannelPost } from "../utils/com"; 4 | 5 | const canvasCtx = document.getElementsByTagName("canvas")[0].getContext("2d")!; 6 | 7 | let audioCtx: AudioContext; 8 | 9 | const audioConfig = { 10 | minDecibels: -120, 11 | maxDecibels: 80, 12 | smoothingTimeConstant: 0.85, 13 | fftSize: 1024, 14 | }; 15 | 16 | let analyser: AnalyserNode; 17 | 18 | function drawValues( 19 | values: number[], 20 | color: string, 21 | transform: (val: number) => number, 22 | ) { 23 | if (canvasCtx == null || !values || !values.length) return; 24 | 25 | const { 26 | canvas: { width: w }, 27 | } = canvasCtx; 28 | const wi = w / values.length; 29 | 30 | canvasCtx.strokeStyle = color; 31 | canvasCtx.beginPath(); 32 | values.forEach((val: number, i: number) => { 33 | const vh = transform(val); 34 | if (i === 0) { 35 | canvasCtx.moveTo(0, vh); 36 | return; 37 | } 38 | canvasCtx.lineTo(wi * i, vh); 39 | }); 40 | canvasCtx.stroke(); 41 | } 42 | 43 | export default function audioCapture( 44 | post: ChannelPost, 45 | socket: Socket, 46 | ) { 47 | // let s = 0; 48 | function render() { 49 | if (!canvasCtx || !analyser) { 50 | requestAnimationFrame(render); 51 | return; 52 | } 53 | 54 | const freqArray = new Uint8Array(analyser.frequencyBinCount); 55 | const timeDomainArray = new Uint8Array(analyser.frequencyBinCount); 56 | // const freqFloat = new Float32Array(audioConfig.fftSize); 57 | // const timeDomainFloat = new Float32Array(audioConfig.fftSize); 58 | // s += 1; 59 | 60 | analyser.getByteFrequencyData(freqArray); 61 | analyser.getByteTimeDomainData(timeDomainArray); 62 | // analyser.getFloatFrequencyData(freqFloat); 63 | // analyser.getFloatTimeDomainData(timeDomainFloat); 64 | 65 | const payload = { 66 | frequency: Array.from(freqArray), 67 | volume: Array.from(timeDomainArray), 68 | // frequencyFloat: Array.from(freqFloat), 69 | // volumeFloat: Array.from(timeDomainFloat), 70 | }; 71 | socket.emit("audioupdate", payload); 72 | // if (s % 600 === 0) console.info("audio", payload); 73 | 74 | const { 75 | canvas: { width: w, height: h }, 76 | } = canvasCtx; 77 | canvasCtx.clearRect(0, 0, w, h); 78 | 79 | drawValues(payload.frequency, "red", (val) => (val / 255) * h); 80 | // drawValues(payload.frequencyFloat, "orange", (val) => val * h + 50); 81 | drawValues(payload.volume, "lime", (val) => val); 82 | // drawValues(payload.volumeFloat, "lightblue", (val) => val * h + 150); 83 | 84 | requestAnimationFrame(render); 85 | } 86 | 87 | navigator.mediaDevices 88 | .getUserMedia({ 89 | video: false, 90 | audio: true, 91 | }) 92 | .then((stream: MediaStream) => { 93 | console.log("getUserMedia success", stream); 94 | 95 | audioCtx = audioCtx || new AudioContext(); 96 | if (!analyser) { 97 | analyser = audioCtx.createAnalyser(); 98 | const source = audioCtx.createMediaStreamSource(stream); 99 | source.connect(analyser); 100 | } 101 | 102 | try { 103 | analyser.minDecibels = audioConfig.minDecibels; 104 | analyser.maxDecibels = audioConfig.maxDecibels; 105 | analyser.smoothingTimeConstant = audioConfig.smoothingTimeConstant; 106 | analyser.fftSize = audioConfig.fftSize; 107 | } catch (err) { 108 | console.warn(err); 109 | } 110 | 111 | render(); 112 | 113 | // -------------------------------------------------------------------- 114 | 115 | // const processor = audioCtx.createScriptProcessor(2048, 2, 2); 116 | // processor.addEventListener('audioprocess', audioProcessListener); 117 | // const source = audioCtx.createMediaStreamSource(stream); 118 | // const splitter = audioCtx.createChannelSplitter(2); 119 | // source.connect(splitter); 120 | // source.connect(processor); 121 | // processor.connect(audioCtx.destination); 122 | // render(); 123 | }) 124 | .catch((e: any) => { 125 | console.error(e); 126 | }); 127 | } 128 | 129 | export const comHandlers = { 130 | audiosettingsupdate: (data: Partial) => { 131 | if (!analyser) return; 132 | try { 133 | const merged = { 134 | ...audioConfig, 135 | ...data, 136 | }; 137 | analyser.minDecibels = merged.minDecibels; 138 | analyser.maxDecibels = merged.maxDecibels; 139 | analyser.smoothingTimeConstant = merged.smoothingTimeConstant; 140 | analyser.fftSize = merged.fftSize; 141 | } catch (err) { 142 | console.warn(err); 143 | } 144 | }, 145 | }; 146 | -------------------------------------------------------------------------------- /src/capture/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | 4 | import { io } from "socket.io-client"; 5 | 6 | import { type ComEventData, autoBind } from "../utils/com"; 7 | import audioCapture from "./audio"; 8 | import midiCapture from "./midi"; 9 | 10 | const socket = io(); 11 | 12 | const { post, listener } = autoBind( 13 | { 14 | postMessage: (message: ComEventData) => { 15 | socket.emit("message", message); 16 | }, 17 | }, 18 | "capture-socket", 19 | {}, 20 | ); 21 | 22 | socket.on("message", (message: ComEventData) => { 23 | listener({ data: message }); 24 | }); 25 | 26 | socket.emit("registercapture"); 27 | 28 | audioCapture(post, socket); 29 | void midiCapture(post, socket); 30 | 31 | const root = createRoot(document.getElementById("capture-view")!); 32 | root.render(
Tada
); 33 | -------------------------------------------------------------------------------- /src/capture/midi.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io-client"; 2 | import type { DefaultEventsMap } from "socket.io/dist/typed-events"; 3 | import type { ChannelPost } from "../utils/com"; 4 | 5 | let midi: MIDIAccess; 6 | 7 | const dataKeys = ["unknown", "id", "value"]; 8 | type DataObject = Record<(typeof dataKeys)[number], number>; 9 | 10 | function dataToObject(data: Uint8Array): DataObject { 11 | return data.reduce((acc, val, i) => { 12 | acc[dataKeys[i]] = val; 13 | return acc; 14 | }, {} as any) as DataObject; 15 | } 16 | 17 | function midiMessageListener(evt: any) { 18 | if (!(evt instanceof MIDIMessageEvent)) return; 19 | console.info("MIDI Message", dataToObject(evt.data)); 20 | } 21 | 22 | function processState() { 23 | console.info("MIDI State", midi); 24 | if (!midi?.inputs) return; 25 | 26 | midi.inputs.forEach((input) => { 27 | console.info("MIDI Input", input); 28 | input.removeEventListener("midimessage", midiMessageListener, false); 29 | input.addEventListener("midimessage", midiMessageListener, false); 30 | }); 31 | } 32 | 33 | export default async function midiCapture( 34 | post: ChannelPost, 35 | socket: Socket, 36 | ) { 37 | midi = await navigator.requestMIDIAccess(); 38 | console.info("MIDI Access", midi); 39 | midi.onstatechange = processState; 40 | processState(); 41 | } 42 | -------------------------------------------------------------------------------- /src/capture/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "..", 5 | "jsx": "react" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/display/Display.ts: -------------------------------------------------------------------------------- 1 | import type Canvas2DLayer from "../layers/Canvas2D/Canvas2DLayer"; 2 | import type ThreeJSLayer from "../layers/ThreeJS/ThreeJSLayer"; 3 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 4 | import type { AppState } from "../types"; 5 | import { 6 | type ChannelPost, 7 | type ComActionHandlers, 8 | type ComMessageEventListener, 9 | autoBind, 10 | } from "../utils/com"; 11 | 12 | interface OffscreenCanvas extends HTMLCanvasElement {} 13 | 14 | export interface DisplayOptions { 15 | id?: string; 16 | canvas?: HTMLCanvasElement; 17 | } 18 | 19 | export interface DisplayState 20 | extends Omit, "displays"> { 21 | id: string; 22 | readonly control: boolean; 23 | width: number; 24 | height: number; 25 | layers?: Array; 26 | } 27 | 28 | const handlers: ComActionHandlers = {}; 29 | 30 | export default class Display { 31 | static ensureCanvas = (id = "canvas"): HTMLCanvasElement => { 32 | let el = document.querySelector(`body>#${id}`) as HTMLCanvasElement; 33 | if (!el) { 34 | el = document.createElement("canvas"); 35 | el.id = id; 36 | document.body.appendChild(el); 37 | document.body.style.margin = "0"; 38 | document.body.style.padding = "0"; 39 | document.body.style.overflow = "hidden"; 40 | } 41 | 42 | const { style: parentStyle } = el.parentElement as HTMLElement; 43 | parentStyle.position = "relative"; 44 | parentStyle.background = "black"; 45 | return el; 46 | }; 47 | 48 | static checkSupport = () => { 49 | if (typeof OffscreenCanvas === "undefined") return false; 50 | try { 51 | const el = document.createElement("canvas"); 52 | el.transferControlToOffscreen(); 53 | } catch (e) { 54 | return false; 55 | } 56 | return true; 57 | }; 58 | 59 | constructor(options?: DisplayOptions) { 60 | const { id, canvas = Display.ensureCanvas() } = options ?? {}; 61 | 62 | this.#id = id || `display${(Math.random() * 10000).toFixed()}`; 63 | 64 | canvas.width = canvas.parentElement?.clientWidth ?? canvas.width; 65 | canvas.height = canvas.parentElement?.clientHeight ?? canvas.height; 66 | this.#canvas = canvas; 67 | 68 | this.#worker = new Worker(`/Display.worker.js#${this.#id}`); 69 | const { post, listener } = autoBind( 70 | this.#worker, 71 | `display-${this.#id}-browser`, 72 | handlers, 73 | ); 74 | this.#post = post; 75 | this.#listener = listener; 76 | this.#worker.addEventListener("message", this.#listener); 77 | 78 | // @ts-expect-error 79 | this.#offscreen = canvas.transferControlToOffscreen(); 80 | this.#worker.postMessage( 81 | { 82 | type: "offscreencanvas", 83 | payload: { canvas: this.#offscreen }, 84 | }, 85 | // @ts-expect-error 86 | [this.#offscreen], 87 | ); 88 | requestAnimationFrame(() => this.resize()); 89 | } 90 | 91 | #offscreen: OffscreenCanvas; 92 | 93 | #worker: Worker; 94 | 95 | #post: ChannelPost; 96 | 97 | #id: string; 98 | 99 | #canvas: HTMLCanvasElement; 100 | 101 | #listener: ComMessageEventListener; 102 | 103 | get canvas() { 104 | return this.#canvas; 105 | } 106 | 107 | get state(): Partial { 108 | return { 109 | id: this.#id, 110 | width: this.#canvas.width, 111 | height: this.#canvas.height, 112 | }; 113 | } 114 | 115 | get post(): ChannelPost { 116 | return this.#post; 117 | } 118 | 119 | resize = () => 120 | requestAnimationFrame(() => { 121 | const { canvas } = this; 122 | const size = { 123 | width: canvas.parentElement?.clientWidth ?? this.#canvas.width, 124 | height: canvas.parentElement?.clientHeight ?? this.#canvas.height, 125 | }; 126 | console.info("[display] resize", !!canvas.parentElement, size); 127 | void this.post("resize", size); 128 | }); 129 | } 130 | -------------------------------------------------------------------------------- /src/display/Display.worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env worker */ 2 | 3 | import VFWorker, { 4 | isDisplayState, 5 | type OffscreenCanvas, 6 | } from "./DisplayWorker"; 7 | 8 | import type { 9 | AppState, 10 | ReadFunction, 11 | ScriptInfo, 12 | ScriptingData, 13 | } from "../types"; 14 | 15 | import Canvas2DLayer from "../layers/Canvas2D/Canvas2DLayer"; 16 | import ThreeJSLayer from "../layers/ThreeJS/ThreeJSLayer"; 17 | import type { ScriptRunnerEventListener } from "../utils/ScriptRunner"; 18 | import type { ComActionHandlers } from "../utils/com"; 19 | 20 | type LayerType = Canvas2DLayer | ThreeJSLayer; 21 | 22 | interface WebWorker extends Worker { 23 | location: Location; 24 | } 25 | 26 | // scripting 27 | 28 | const data: ScriptingData = { 29 | iterationCount: 0, 30 | now: 0, 31 | deltaNow: 0, 32 | frequency: [], 33 | volume: [], 34 | }; 35 | 36 | const onExecutionError: ScriptRunnerEventListener = (err: any) => { 37 | console.error("onExecutionError", err); 38 | return false; 39 | }; 40 | const onCompilationError: ScriptRunnerEventListener = (err: any) => { 41 | console.error("onCompilationError", err); 42 | return false; 43 | }; 44 | 45 | const worker: WebWorker = self as any; 46 | 47 | const read: ReadFunction = (key, defaultVal) => 48 | typeof data[key] !== "undefined" ? data[key] : defaultVal; 49 | 50 | const idFromWorkerHash = worker.location.hash.replace("#", ""); 51 | if (!idFromWorkerHash) throw new Error("[worker] worker is not ready"); 52 | 53 | const socketHandlers = (vfWorker: VFWorker): ComActionHandlers => ({ 54 | scriptchange: async ( 55 | payload: ScriptInfo & { 56 | script: string; 57 | }, 58 | ) => { 59 | const { id, type, role, script } = payload; 60 | 61 | if (type === "worker") { 62 | vfWorker.scriptable[role].code = script; 63 | if (role === "setup") { 64 | // data = { ...data, ...((await scriptable.execSetup()) || {}) }; 65 | Object.assign(data, (await vfWorker.scriptable.execSetup()) || {}); 66 | } 67 | } else { 68 | void vfWorker.workerCom.post("scriptchange", payload); 69 | if (type === "layer") { 70 | const found = vfWorker.findStateLayer(id); 71 | if (found != null) { 72 | found[role].code = script; 73 | 74 | if (role === "setup") { 75 | void found.execSetup(); 76 | } 77 | } else { 78 | console.error("scriptchange layer not found", id); 79 | } 80 | } 81 | } 82 | }, 83 | updatestate: (update: Partial) => { 84 | const { scriptable, state } = vfWorker; 85 | 86 | const layers = Array.isArray(update.layers) 87 | ? update.layers 88 | .map((options) => { 89 | const found = vfWorker.findStateLayer(options.id); 90 | if (found != null) { 91 | found.active = !!options.active; 92 | return found; 93 | } 94 | const completeOptions = { 95 | ...options, 96 | read, 97 | onCompilationError, 98 | onExecutionError, 99 | }; 100 | switch (options.type) { 101 | // case 'canvas2d': 102 | case "canvas": 103 | return new Canvas2DLayer(completeOptions); 104 | case "threejs": 105 | return new ThreeJSLayer(completeOptions); 106 | default: 107 | return null; 108 | } 109 | }) 110 | .filter(Boolean) 111 | .map((layer) => vfWorker.resizeLayer(layer as LayerType)) 112 | : state.layers; 113 | const updated = { 114 | ...state, 115 | ...update, 116 | layers, 117 | }; 118 | if (!isDisplayState(updated)) { 119 | throw new Error("updatestate: invalid state"); 120 | } 121 | vfWorker.state = updated; 122 | if ( 123 | typeof update.worker?.setup !== "undefined" && 124 | update.worker.setup !== scriptable.setup.code 125 | ) { 126 | scriptable.setup.code = update.worker.setup || scriptable.setup.code; 127 | state.worker.setup = scriptable.setup.code; 128 | 129 | scriptable.execSetup(); 130 | } 131 | if ( 132 | typeof update.worker?.animation !== "undefined" && 133 | update.worker.animation !== scriptable.animation.code 134 | ) { 135 | scriptable.animation.code = 136 | update.worker.animation || scriptable.animation.code; 137 | state.worker.animation = scriptable.animation.code; 138 | } 139 | }, 140 | updatedata: (payload: typeof data) => { 141 | Object.assign(data, payload); 142 | // workerCom.post('updatedata', data); 143 | }, 144 | }); 145 | 146 | const messageHandlers = (vfWorker: VFWorker): ComActionHandlers => ({ 147 | offscreencanvas: ({ canvas: onscreen }: { canvas: OffscreenCanvas }) => { 148 | vfWorker.onScreenCanvas = onscreen; 149 | 150 | // TODO: use autoBind 151 | vfWorker.registerDisplay(); 152 | }, 153 | resize: ({ width, height }: { width: number; height: number }) => { 154 | const { canvas, socketCom, state } = vfWorker; 155 | vfWorker.state = { 156 | ...state, 157 | width: width || state.width, 158 | height: height || state.height, 159 | }; 160 | canvas.width = state.width; 161 | canvas.height = state.height; 162 | state.layers?.forEach((l) => vfWorker.resizeLayer(l)); 163 | 164 | if (!state.control) { 165 | socketCom.post("resizedisplay", { 166 | id: idFromWorkerHash, 167 | width: state.width, 168 | height: state.height, 169 | }); 170 | } 171 | }, 172 | }); 173 | 174 | const displayWorker = new VFWorker(worker, socketHandlers, messageHandlers); 175 | 176 | console.info("displayWorker", displayWorker); 177 | -------------------------------------------------------------------------------- /src/display/DisplayWorker.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | describe("DisplayWorker", () => { 4 | it.todo("is comprehensively tested (haha)"); 5 | }); 6 | -------------------------------------------------------------------------------- /src/display/DisplayWorker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env worker */ 2 | 3 | import { io } from "socket.io-client"; 4 | 5 | import type { Socket } from "node:dgram"; 6 | import type { ScriptingData } from "../types"; 7 | import { autoBind } from "../utils/com"; 8 | import type { 9 | ChannelBindings, 10 | ComActionHandlers, 11 | ComEventData, 12 | } from "../utils/com"; 13 | 14 | import type { DisplayState } from "./Display"; 15 | 16 | import type Canvas2DLayer from "../layers/Canvas2D/Canvas2DLayer"; 17 | import canvasTools, { type Canvas2DAPI } from "../layers/Canvas2D/canvasTools"; 18 | import type ThreeJSLayer from "../layers/ThreeJS/ThreeJSLayer"; 19 | import Scriptable, { type ScriptableOptions } from "../utils/Scriptable"; 20 | import * as mathTools from "../utils/mathTools"; 21 | 22 | export interface OffscreenCanvas extends HTMLCanvasElement {} 23 | interface OffscreenCanvasRenderingContext2D extends CanvasRenderingContext2D {} 24 | 25 | interface WebWorker extends Worker { 26 | location: Location; 27 | } 28 | 29 | // scripting 30 | 31 | const defaultStage = { 32 | width: 600, 33 | height: 400, 34 | autoScale: true, 35 | }; 36 | 37 | const data: ScriptingData = { 38 | iterationCount: 0, 39 | now: 0, 40 | deltaNow: 0, 41 | frequency: [], 42 | volume: [], 43 | }; 44 | 45 | const worker: WebWorker = self as any; 46 | 47 | const read = (/* Worker read */ key: string, defaultVal?: any) => 48 | typeof data[key] !== "undefined" ? data[key] : defaultVal; 49 | const makeErrorHandler = 50 | (type: string) => 51 | (event: any): any => { 52 | console.warn("[worker]", type, event); 53 | }; 54 | const scriptableOptions: ScriptableOptions = { 55 | id: "worker", 56 | api: { ...mathTools, read }, 57 | read, 58 | onCompilationError: makeErrorHandler("compilation"), 59 | onExecutionError: makeErrorHandler("execution"), 60 | }; 61 | 62 | const idFromWorkerHash = worker.location.hash.replace("#", ""); 63 | if (!idFromWorkerHash) throw new Error("[worker] worker is not ready"); 64 | 65 | export function isDisplayState(data: any): data is DisplayState { 66 | return ( 67 | data && 68 | typeof data === "object" && 69 | "layers" in data && 70 | Array.isArray(data.layers) && 71 | "stage" in data && 72 | typeof data.stage === "object" && 73 | "width" in data.stage && 74 | "height" in data.stage 75 | ); 76 | } 77 | 78 | export default class VFWorker { 79 | constructor( 80 | workerSelf: WebWorker, 81 | socketHandlers: (instance: VFWorker) => ComActionHandlers, 82 | messageHandlers: (instance: VFWorker) => ComActionHandlers, 83 | ) { 84 | this.#worker = workerSelf; 85 | 86 | this.state = { 87 | bpm: { count: 120, start: Date.now() }, 88 | server: { host: "localhost", port: 9999 }, 89 | control: !!this.#worker.location.hash?.startsWith("#control"), 90 | id: idFromWorkerHash, 91 | width: defaultStage.width, 92 | height: defaultStage.height, 93 | layers: [], 94 | stage: { ...defaultStage }, 95 | worker: { 96 | setup: "", 97 | animation: "", 98 | }, 99 | }; 100 | 101 | this.scriptable = new Scriptable(scriptableOptions); 102 | 103 | // @ts-ignore 104 | this.canvas = new OffscreenCanvas(this.state.width, this.state.height); 105 | this.#context = this.canvas.getContext( 106 | "2d", 107 | ) as OffscreenCanvasRenderingContext2D; 108 | 109 | this.#tools = canvasTools(this.#context); 110 | 111 | this.#socket = io() as unknown as Socket; 112 | 113 | this.socketCom = autoBind( 114 | { 115 | postMessage: (message: any) => { 116 | this.#socket.emit("message", message); 117 | }, 118 | }, 119 | `display-${idFromWorkerHash}-socket`, 120 | socketHandlers(this), 121 | ); 122 | 123 | this.#socket.on("message", (message: ComEventData) => { 124 | // if (message.type === 'updatestate') { 125 | // console.info('[worker] updatestate', message) 126 | // } 127 | const before = isDisplayState(this.state); 128 | this.socketCom.listener({ data: message }); 129 | const after = isDisplayState(this.state); 130 | if (before !== after) { 131 | console.error("[worker] state is not a DisplayState", message); 132 | throw new Error("state is not a DisplayState"); 133 | } 134 | }); 135 | 136 | this.#socket.on("reconnect", (attempt: number) => { 137 | console.info("[worker] reconnect", attempt); 138 | this.registerDisplay(); 139 | }); 140 | 141 | this.workerCom = autoBind( 142 | this.#worker, 143 | `display-${idFromWorkerHash}-worker`, 144 | messageHandlers(this), 145 | ); 146 | worker.addEventListener("message", this.workerCom.listener); 147 | 148 | try { 149 | this.scriptable 150 | .execSetup() 151 | .then(() => { 152 | this.render(); 153 | }) 154 | .catch(() => { 155 | console.error("Cannot run worker initial setup"); 156 | }); 157 | } catch (e) { 158 | console.error(e); 159 | } 160 | } 161 | 162 | #worker: WebWorker; 163 | 164 | #socket: Socket; 165 | 166 | socketCom: ChannelBindings; 167 | 168 | workerCom: ChannelBindings; 169 | 170 | scriptable: Scriptable; 171 | 172 | canvas: OffscreenCanvas; 173 | 174 | onScreenCanvas: OffscreenCanvas | null = null; 175 | 176 | #context: OffscreenCanvasRenderingContext2D; 177 | 178 | #tools: Canvas2DAPI; 179 | 180 | state: DisplayState; 181 | 182 | registerDisplay() { 183 | if (this.onScreenCanvas == null) return; 184 | this.#socket.emit("registerdisplay", { 185 | id: idFromWorkerHash, 186 | width: this.onScreenCanvas.width, 187 | height: this.onScreenCanvas.height, 188 | }); 189 | } 190 | 191 | resizeLayer(layer: Canvas2DLayer | ThreeJSLayer) { 192 | layer.width = this.canvas.width; 193 | 194 | layer.height = this.canvas.height; 195 | 196 | layer.execSetup().catch((err) => { 197 | console.error("resizeLayer execSetup error", err); 198 | }); 199 | return layer; 200 | } 201 | 202 | findStateLayer(id: string) { 203 | return this.state.layers?.find((layer) => id === layer.id); 204 | } 205 | 206 | renderLayers = () => { 207 | const { canvas } = this; 208 | const context = this.#context; 209 | if (!context) return; 210 | context.clearRect(0, 0, canvas.width, canvas.height); 211 | if (!Array.isArray(this.state.layers)) { 212 | // console.error('DisplayWorker.state.layers is not an array', this.state.layers) 213 | return; 214 | } 215 | this.state.layers?.forEach((layer) => { 216 | if (!layer.active) return; 217 | layer.execAnimation(); 218 | this.#tools.pasteContain(layer.canvas as any); 219 | }); 220 | }; 221 | 222 | render() { 223 | Object.assign(data, this.scriptable.execAnimation() || {}); 224 | 225 | if (this.#context && this.onScreenCanvas != null) { 226 | // console.info('[worker] render isDisplayState', isDisplayState(this.state)) 227 | this.renderLayers(); 228 | 229 | this.onScreenCanvas.height = this.canvas.height; 230 | this.onScreenCanvas.width = this.canvas.width; 231 | const ctx = this.onScreenCanvas.getContext( 232 | "2d", 233 | ) as OffscreenCanvasRenderingContext2D; 234 | ctx.drawImage( 235 | this.canvas, 236 | 0, 237 | 0, 238 | this.onScreenCanvas.width, 239 | this.onScreenCanvas.height, 240 | 0, 241 | 0, 242 | this.canvas.width, 243 | this.canvas.height, 244 | ); 245 | } 246 | 247 | requestAnimationFrame(() => { 248 | this.render(); 249 | }); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/display/displayState.ts: -------------------------------------------------------------------------------- 1 | import type { LayerInfo } from "../types"; 2 | 3 | export interface DisplayState { 4 | meta: { 5 | displayId: string; 6 | connected: boolean; 7 | socketId?: string; 8 | }; 9 | data: object; 10 | worker: { 11 | setup: string; 12 | animate: string; 13 | }; 14 | layers: LayerInfo[]; 15 | } 16 | 17 | const defaultState: DisplayState = { 18 | meta: { 19 | displayId: `display${(Math.random() * 10000).toFixed()}`, 20 | connected: false, 21 | }, 22 | data: {}, 23 | worker: { 24 | setup: "", 25 | animate: "", 26 | }, 27 | layers: [], 28 | }; 29 | 30 | export default defaultState; 31 | -------------------------------------------------------------------------------- /src/display/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeropaper/visual-fiha/5c16fd09f07339f36fd64ae7a84bde9a53a9312b/src/display/index.html -------------------------------------------------------------------------------- /src/display/index.ts: -------------------------------------------------------------------------------- 1 | import Display from "./Display"; 2 | 3 | if (Display.checkSupport()) { 4 | const display = new Display({ 5 | id: window.location.hash.slice(1), 6 | }); 7 | 8 | window.addEventListener("resize", () => { 9 | display.resize(); 10 | }); 11 | 12 | window.addEventListener("beforeunload", () => { 13 | display.post("unregister"); 14 | }); 15 | 16 | // when double clicking on the window its body goes fullscreen 17 | window.addEventListener("dblclick", () => { 18 | if (document.body.requestFullscreen) { 19 | document.body.requestFullscreen(); 20 | } 21 | }); 22 | } else { 23 | document.body.innerHTML = 24 | '
Unsupported browser, please use Google Chrome.
'; 25 | } 26 | -------------------------------------------------------------------------------- /src/display/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": ".." 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/extension/VFExtension.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import * as vscode from "vscode"; 3 | import type { ScriptingData } from "../types"; 4 | import VFServer from "./WebServer"; 5 | import commands from "./commands"; 6 | import readLayerScripts from "./readLayerScripts"; 7 | import readScripts from "./readScripts"; 8 | import readWorkspaceRC from "./readWorkspaceRC"; 9 | import store, { type StoreAction } from "./store"; 10 | import textDocumentScriptInfo from "./textDocumentScriptInfo"; 11 | import AudioViewProvider from "./views/AudioViewProvider"; 12 | import ControlViewProvider from "./views/ControlViewProvider"; 13 | import DisplaysViewProvider from "./views/DisplaysViewProvider"; 14 | import ScriptsViewProvider from "./views/ScriptsViewProvider"; 15 | import SettingsViewProvider from "./views/SettingsViewProvider"; 16 | import TimelineViewProvider from "./views/TimelineViewProvider"; 17 | 18 | export default class VFExtension { 19 | constructor() { 20 | this.#refreshInterval = null; 21 | this.#store = store; 22 | this.#webServer = new VFServer(() => this.state); 23 | 24 | this.#controlProvider = null; 25 | this.#audioProvider = null; 26 | this.#displaysProvider = null; 27 | this.#timelineProvider = null; 28 | } 29 | 30 | #refreshInterval: NodeJS.Timer | null; 31 | 32 | #webServer: VFServer; 33 | 34 | #store: typeof store; 35 | 36 | #data: ScriptingData = { 37 | started: 0, 38 | iterationCount: 0, 39 | now: 0, 40 | deltaNow: 0, 41 | frequency: [], 42 | volume: [], 43 | }; 44 | 45 | #controlProvider: ControlViewProvider | null; 46 | 47 | #audioProvider: AudioViewProvider | null; 48 | 49 | #displaysProvider: DisplaysViewProvider | null; 50 | 51 | #timelineProvider: TimelineViewProvider | null; 52 | 53 | resetData() { 54 | this.#data = { 55 | started: 0, 56 | iterationCount: 0, 57 | now: 0, 58 | deltaNow: 0, 59 | frequency: [], 60 | volume: [], 61 | }; 62 | } 63 | 64 | #refreshData() { 65 | const { state } = this; 66 | const now = Date.now(); 67 | const started = this.#data.started || now; 68 | const bpm = state.bpm.count || this.#data.bpm || 120; 69 | const timeSinceBPMSet = now - (state.bpm.start || started); 70 | const oneMinute = 60000; 71 | 72 | this.#data = { 73 | ...this.#data, 74 | bpm, 75 | timeSinceBPMSet, 76 | started, 77 | iterationCount: this.#data.iterationCount + 1, 78 | now: now - started, 79 | deltaNow: this.#data.now ? now - this.#data.now : 0, 80 | }; 81 | 82 | const beatLength = oneMinute / bpm; 83 | this.#data.beatPrct = (timeSinceBPMSet % beatLength) / beatLength; 84 | this.#data.beatNum = Math.floor(this.#data.now / (oneMinute / bpm)); 85 | 86 | // if (this.#data.iterationCount % 100 === 0) { 87 | // console.info('[ext] this.#data refreshed', 88 | // this.#data.iterationCount, 89 | // now - started, 90 | // this.#data.deltaNow, 91 | // this.#data.beatPrct, 92 | // this.#data.beatNum); 93 | // } 94 | 95 | this.#webServer.broadcastData(this.#data); 96 | } 97 | 98 | get state() { 99 | return this.#store.getState(); 100 | } 101 | 102 | get subscribe() { 103 | return this.#store.subscribe; 104 | } 105 | 106 | dispatch(action: StoreAction) { 107 | return this.#store.dispatch(action); 108 | } 109 | 110 | updateState() { 111 | const { state } = this; 112 | this.#webServer.broadcastState(state); 113 | } 114 | 115 | async propagate() { 116 | console.info("[ext] propagate"); 117 | 118 | try { 119 | const fiharc = await readWorkspaceRC(); 120 | 121 | const { state: current } = this; 122 | this.dispatch({ 123 | type: "replaceState", 124 | payload: { 125 | ...fiharc, 126 | ...current, 127 | id: fiharc.id || current.id, 128 | layers: await Promise.all( 129 | fiharc.layers.map(readLayerScripts("layer")), 130 | ), 131 | worker: await readScripts("worker", "worker", "worker"), 132 | }, 133 | }); 134 | } catch (err) { 135 | console.warn("[ext] fiharc", (err as Error).message); 136 | } 137 | } 138 | 139 | makeDisposableStoreListener(): vscode.Disposable { 140 | const unsubscribe = store.subscribe(() => { 141 | this.updateState(); 142 | }); 143 | return { 144 | dispose: unsubscribe, 145 | }; 146 | } 147 | 148 | #prepareViews(context: vscode.ExtensionContext) { 149 | this.#controlProvider = new ControlViewProvider(context.extensionUri); 150 | this.#audioProvider = new AudioViewProvider(context.extensionUri); 151 | this.#displaysProvider = new DisplaysViewProvider(context.extensionUri); 152 | this.#timelineProvider = new TimelineViewProvider(context.extensionUri); 153 | } 154 | 155 | async activate(context: vscode.ExtensionContext) { 156 | try { 157 | this.#prepareViews(context); 158 | console.info("[ext] start refreshing data"); 159 | this.#refreshInterval = setInterval(() => { 160 | this.#refreshData(); 161 | this.#audioProvider?.updateData(this.#data); 162 | }, 2); 163 | 164 | void this.propagate(); 165 | } catch (err) { 166 | const msg = `Could not read fiha.json: "${(err as Error).message}"`; 167 | void vscode.window.showWarningMessage(msg); 168 | } 169 | 170 | context.subscriptions.push( 171 | vscode.window.registerWebviewViewProvider( 172 | ControlViewProvider.viewType, 173 | this.#controlProvider!, 174 | ), 175 | 176 | vscode.window.registerWebviewViewProvider( 177 | AudioViewProvider.viewType, 178 | this.#audioProvider!, 179 | ), 180 | 181 | vscode.window.registerWebviewViewProvider( 182 | DisplaysViewProvider.viewType, 183 | this.#displaysProvider!, 184 | ), 185 | 186 | vscode.window.registerWebviewViewProvider( 187 | TimelineViewProvider.viewType, 188 | this.#timelineProvider!, 189 | ), 190 | 191 | this.#webServer.activate(context), 192 | 193 | this.makeDisposableStoreListener(), 194 | 195 | this.#webServer.onDisplaysChange((displays) => { 196 | const { state } = this; 197 | this.dispatch({ 198 | type: "setStage", 199 | payload: { 200 | ...state.stage, 201 | ...this.#webServer.displaysMaxSize, 202 | }, 203 | }); 204 | 205 | // this.#webServer.broadcastState(this.#runtimeState); 206 | }), 207 | 208 | this.#webServer.onSocketConnection((socket) => { 209 | socket.emit("message", { 210 | type: "updatestate", 211 | payload: this.state, 212 | }); 213 | 214 | socket.on( 215 | "audioupdate", 216 | (audio: { frequency: number[]; volume: number[] }) => { 217 | this.#data = { 218 | ...this.#data, 219 | ...audio, 220 | }; 221 | }, 222 | ); 223 | }), 224 | 225 | ...Object.keys(commands).map((name) => 226 | vscode.commands.registerCommand( 227 | `visualFiha.${name}`, 228 | commands[name](context, this), 229 | ), 230 | ), 231 | 232 | vscode.workspace.onDidChangeTextDocument((event) => { 233 | // if (!event.contentChanges.length) return; 234 | const { document: doc } = event; 235 | if (doc.isUntitled || doc.isClosed || doc.languageId !== "javascript") { 236 | return; 237 | } 238 | 239 | const info = textDocumentScriptInfo(doc); 240 | const script = doc.getText(); 241 | this.#webServer.broadcastScript(info, script); 242 | 243 | const { state } = this; 244 | 245 | // console.info("[ext] onDidChangeTextDocument", info, state); 246 | 247 | const layerIndex = state.layers.findIndex( 248 | (layer) => layer.id === info.id, 249 | ); 250 | if (layerIndex < 0) { 251 | // TODO: check info.type 252 | state.worker[info.role] = script; 253 | return; 254 | } 255 | 256 | state.layers[layerIndex][info.role] = script; 257 | }), 258 | 259 | vscode.workspace.onDidSaveTextDocument((event) => { 260 | if (!event.fileName.endsWith("fiha.json")) { 261 | return; 262 | } 263 | this.propagate().catch((err) => { 264 | console.info("propagate error", err); 265 | }); 266 | }), 267 | ); 268 | 269 | new ScriptsViewProvider(context, this); 270 | 271 | new SettingsViewProvider(context); 272 | } 273 | 274 | deactivate() { 275 | if (this.#refreshInterval != null) { 276 | clearInterval(this.#refreshInterval); 277 | } 278 | this.#webServer.deactivate(); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/extension/commands/addLayer.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import type { LayerInfo, VFCommand } from "../../types"; 3 | import { isLayerType } from "../isLayerType"; 4 | 5 | async function collectLayerInfo(): Promise { 6 | function validateId(value: string) { 7 | if (!value) return "The value is required"; 8 | 9 | const exp = /^[a-z0-9-_]+$/i; 10 | const found = value.match(exp); 11 | if (found != null) return; 12 | return "The value contains invalid characters"; 13 | } 14 | 15 | return { 16 | id: await vscode.window 17 | .showInputBox({ 18 | title: "Layer ID", 19 | prompt: "What ID should be used for the layer (/^[a-z0-9-_]+$/i) ?", 20 | validateInput: validateId, 21 | }) 22 | .then((id?: string) => { 23 | if (!id) { 24 | throw new Error("The value is required"); 25 | } 26 | return id; 27 | }), 28 | type: await vscode.window 29 | .showQuickPick( 30 | [ 31 | { 32 | label: "canvas", 33 | }, 34 | { 35 | label: "threejs", 36 | }, 37 | ], 38 | { 39 | title: "Layer type", 40 | canPickMany: false, 41 | }, 42 | ) 43 | .then((type?: { label: string }) => { 44 | if (!type || !isLayerType(type?.label)) { 45 | throw new Error("Invalid layer type"); 46 | } 47 | return type.label; 48 | }), 49 | active: await vscode.window 50 | .showQuickPick( 51 | [ 52 | { 53 | label: "yes", 54 | }, 55 | { 56 | label: "no", 57 | }, 58 | ], 59 | { 60 | title: "Set active", 61 | canPickMany: false, 62 | }, 63 | ) 64 | .then((active?: { label: string }) => { 65 | if (!active) { 66 | throw new Error("Invalid layer active value"); 67 | } 68 | return active.label === "yes"; 69 | }), 70 | }; 71 | } 72 | 73 | async function writeNewLayer(layer: LayerInfo) { 74 | const folder = vscode.workspace.workspaceFolders?.[0]; 75 | if (!folder) { 76 | throw new Error("No workspace folder found"); 77 | } 78 | 79 | const filepath = vscode.Uri.joinPath(folder.uri, "fiha.json").fsPath; 80 | const fiha = JSON.parse( 81 | (await vscode.workspace.fs.readFile(vscode.Uri.file(filepath))).toString(), 82 | ); 83 | fiha.layers.push(layer); 84 | await vscode.workspace.fs.writeFile( 85 | vscode.Uri.file(filepath), 86 | Buffer.from(JSON.stringify(fiha, null, 2)), 87 | ); 88 | return layer; 89 | } 90 | 91 | const addLayer: VFCommand = 92 | (context, extension) => async (layer?: LayerInfo) => { 93 | console.info("[command] addLayer", layer); 94 | 95 | if (!layer) { 96 | const newLayer = await collectLayerInfo(); 97 | await writeNewLayer(newLayer); 98 | extension.dispatch({ 99 | type: "addLayer", 100 | payload: newLayer, 101 | }); 102 | return; 103 | } 104 | 105 | await writeNewLayer(layer); 106 | extension.dispatch({ 107 | type: "addLayer", 108 | payload: layer, 109 | }); 110 | }; 111 | 112 | export default addLayer; 113 | -------------------------------------------------------------------------------- /src/extension/commands/createLayer.ts: -------------------------------------------------------------------------------- 1 | export default function createLayer() { 2 | return () => {}; 3 | } 4 | -------------------------------------------------------------------------------- /src/extension/commands/index.ts: -------------------------------------------------------------------------------- 1 | import type { VFCommand } from "../../types"; 2 | import addLayer from "./addLayer"; 3 | import createLayer from "./createLayer"; 4 | import openEditor from "./openEditor"; 5 | import removeLayer from "./removeLayer"; 6 | import resetData from "./resetData"; 7 | import scaffoldProject from "./scaffoldProject"; 8 | import setBPM from "./setBPM"; 9 | import setStageSize from "./setStageSize"; 10 | import toggleLayer from "./toggleLayer"; 11 | 12 | export type Commands = Record; 13 | 14 | const commands: Commands = { 15 | openEditor, 16 | setBPM, 17 | createLayer, 18 | removeLayer, 19 | toggleLayer, 20 | addLayer, 21 | setStageSize, 22 | resetData, 23 | scaffoldProject, 24 | }; 25 | 26 | export default commands; 27 | -------------------------------------------------------------------------------- /src/extension/commands/openEditor.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs"; 2 | import * as vscode from "vscode"; 3 | 4 | import type { ComEventDataMeta } from "../../utils/com"; 5 | import getWorkspaceFolder from "../getWorkspaceFolder"; 6 | 7 | export type OpenCommandOptions = 8 | | string 9 | | { 10 | relativePath: string; 11 | viewColumn?: number; 12 | preserveFocus?: boolean; 13 | preview?: boolean; 14 | selection?: vscode.Range; 15 | createIfMissing?: boolean; 16 | }; 17 | 18 | export default function openEditor() { 19 | return (options: OpenCommandOptions, meta?: ComEventDataMeta) => { 20 | const { 21 | commands: { executeCommand }, 22 | workspace: { fs: wfs }, 23 | // window: { showErrorMessage }, 24 | } = vscode; 25 | const { uri } = getWorkspaceFolder(); 26 | 27 | const filepath = vscode.Uri.joinPath( 28 | uri, 29 | typeof options === "string" ? options : options.relativePath, 30 | ); 31 | const { 32 | viewColumn = vscode.ViewColumn.Active, 33 | preserveFocus = true, 34 | preview = false, 35 | selection = undefined, 36 | // createIfMissing = false, 37 | } = typeof options === "string" ? {} : options; 38 | 39 | const resolve = () => { 40 | // if (!meta?.operationId) return; 41 | // VFPanel.currentPanel?.postMessage({ 42 | // type: "com/reply", 43 | // meta: { 44 | // ...meta, 45 | // originalType: "open", 46 | // processed: Date.now(), 47 | // }, 48 | // }); 49 | }; 50 | 51 | const reject = (error: unknown) => { 52 | console.warn("[VFPanel] open error", error); 53 | // if (!meta?.operationId) { 54 | // void showErrorMessage(`Could not open: ${filepath.toString()}`); 55 | // return; 56 | // } 57 | 58 | // VFPanel.currentPanel?.postMessage({ 59 | // type: "com/reply", 60 | // meta: { 61 | // ...meta, 62 | // error, 63 | // originalType: "open", 64 | // processed: Date.now(), 65 | // }, 66 | // }); 67 | }; 68 | 69 | const create = async () => { 70 | await new Promise((res, rej) => { 71 | fs.writeFile(filepath.fsPath, "", "utf8", (err) => { 72 | if (err != null) { 73 | rej(err); 74 | return; 75 | } 76 | res(); 77 | }); 78 | }); 79 | }; 80 | const doOpen = () => 81 | executeCommand("vscode.open", filepath, { 82 | viewColumn, 83 | preserveFocus, 84 | preview, 85 | selection, 86 | }).then(resolve, reject); 87 | 88 | wfs.stat(filepath).then(doOpen, async () => { 89 | await create().then(doOpen).catch(reject); 90 | }); 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/extension/commands/removeLayer.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import type { LayerInfo, VFCommand } from "../../types"; 3 | 4 | async function writeRemovedLayer(id: LayerInfo["id"]): Promise { 5 | const folder = vscode.workspace.workspaceFolders?.[0]; 6 | if (!folder) { 7 | throw new Error("No workspace folder found"); 8 | } 9 | 10 | const filepath = vscode.Uri.joinPath(folder.uri, "fiha.json").fsPath; 11 | const fiha = JSON.parse( 12 | (await vscode.workspace.fs.readFile(vscode.Uri.file(filepath))).toString(), 13 | ); 14 | fiha.layers = fiha.layers.filter((layer: LayerInfo) => layer.id !== id); 15 | await vscode.workspace.fs.writeFile( 16 | vscode.Uri.file(filepath), 17 | Buffer.from(JSON.stringify(fiha, null, 2)), 18 | ); 19 | return fiha.layers; 20 | } 21 | 22 | const removeLayer: VFCommand = 23 | (context, extension) => async (layer: LayerInfo | LayerInfo["id"]) => { 24 | const id = typeof layer === "string" ? layer : layer.id; 25 | const confirmed = await vscode.window 26 | .showQuickPick( 27 | [ 28 | { 29 | label: "yes", 30 | }, 31 | { 32 | label: "no", 33 | }, 34 | ], 35 | { 36 | title: "Are you sure?", 37 | canPickMany: false, 38 | placeHolder: "Are you sure you want to remove this layer?", 39 | }, 40 | ) 41 | .then((value) => value && value.label === "yes"); 42 | if (confirmed) { 43 | extension.dispatch({ 44 | type: "setLayers", 45 | payload: await writeRemovedLayer(id), 46 | }); 47 | } 48 | }; 49 | 50 | export default removeLayer; 51 | -------------------------------------------------------------------------------- /src/extension/commands/resetData.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export default function resetData( 4 | context: vscode.ExtensionContext, 5 | extension: any, 6 | ) { 7 | return () => { 8 | vscode.window.showWarningMessage("Reseting visuals data"); 9 | extension.resetData(); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/extension/commands/scaffoldProject.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | // import scaffoldProject, { 4 | // resolveValue, 5 | // promptName, 6 | // promptDirectory, 7 | // } from './scaffoldProject'; 8 | 9 | describe("resolveValue", () => { 10 | it.todo("do some stuff with the fs path and homedir"); 11 | }); 12 | 13 | describe("promptName", () => { 14 | it.todo("prompts user"); 15 | }); 16 | 17 | describe("promptDirectory", () => { 18 | it.todo("prompts user"); 19 | }); 20 | 21 | describe("scaffoldProject", () => { 22 | describe("filepath argument", () => { 23 | it.todo("must point to a writeable directory"); 24 | }); 25 | 26 | describe("created result", () => { 27 | it.todo("contains a directories structure"); 28 | it.todo("contains type claration files"); 29 | it.todo("contains a fiha.json file"); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/extension/commands/scaffoldProject.ts: -------------------------------------------------------------------------------- 1 | import { stat } from "node:fs/promises"; 2 | import { homedir } from "node:os"; 3 | import { join, resolve } from "node:path"; 4 | import { copySync, existsSync, outputFileSync, readFileSync } from "fs-extra"; 5 | import * as vscode from "vscode"; 6 | import configuration from "../configuration"; 7 | 8 | type OnValue = (value: T) => any; 9 | 10 | type OnAbort = (reason?: any) => void; 11 | 12 | export function promptName(onValue: OnValue, onAbort?: OnAbort) { 13 | vscode.window 14 | .showInputBox({ 15 | title: "Visual Fiha project name", 16 | prompt: "What name should be used for the project (/^[a-z0-9-_]+$/i) ?", 17 | validateInput: (value: string) => { 18 | const exp = /^[a-z0-9-_]+$/i; 19 | const found = value.match(exp); 20 | if (found != null) return; 21 | 22 | return "The value contains invalid characters"; 23 | }, 24 | }) 25 | .then( 26 | (value?: string) => { 27 | if (!value) { 28 | if (typeof onAbort === "function") { 29 | onAbort(new Error("No name provided")); 30 | } 31 | return; 32 | } 33 | 34 | return onValue(value); 35 | }, 36 | (err: any) => { 37 | console.warn("[command] scaffoldProject promptName err", err); 38 | if (typeof onAbort === "function") onAbort(err); 39 | }, 40 | ); 41 | } 42 | 43 | const exp = /^~\//; 44 | export function resolveValue(value: string) { 45 | if (value.match(exp) == null) { 46 | return resolve(value); 47 | } 48 | 49 | const hd = homedir(); 50 | const final = join(hd, value.replace(exp, "")); 51 | return final; 52 | } 53 | 54 | export function promptDirectory(onValue: OnValue, onAbort?: OnAbort) { 55 | const configPath = configuration("projectsPath") as string; 56 | 57 | vscode.window 58 | .showInputBox({ 59 | title: "Visual Fiha project scaffolding", 60 | prompt: "Where should the project be created?", 61 | placeHolder: configPath, 62 | validateInput: (value: string) => { 63 | try { 64 | const resolved = resolveValue(value); 65 | if (existsSync(resolved)) return; 66 | 67 | return `That path "${resolved}" does not exists`; 68 | } catch (err: unknown) { 69 | return (err as Error).message; 70 | } 71 | }, 72 | }) 73 | .then( 74 | (value?: string) => { 75 | // esc pressed, abort... 76 | if (typeof value === "undefined") { 77 | if (typeof onAbort === "function") onAbort(); 78 | return; 79 | } 80 | 81 | // use default value 82 | if (!value) { 83 | onValue(resolveValue(configPath)); 84 | return; 85 | } 86 | 87 | // use value provided 88 | onValue(resolveValue(value)); 89 | }, 90 | (err: any) => { 91 | if (typeof onAbort === "function") onAbort(err); 92 | }, 93 | ); 94 | } 95 | 96 | export default function scaffoldProject( 97 | context: vscode.ExtensionContext, 98 | { propagate }: { propagate: () => Promise }, 99 | ) { 100 | const demoProjectPath = context.asAbsolutePath("out/demo-project"); 101 | 102 | const [currentWorkspaceFolder] = vscode.workspace.workspaceFolders ?? []; 103 | const wsUri = currentWorkspaceFolder ? currentWorkspaceFolder.uri : null; 104 | 105 | function scaffold(projectId: string, projectPath: string) { 106 | copySync(demoProjectPath, projectPath, { 107 | overwrite: false, 108 | errorOnExist: false, 109 | }); 110 | 111 | const demoProjectJsonPath = join(demoProjectPath, "fiha.json"); 112 | const originalJson = JSON.parse(readFileSync(demoProjectJsonPath, "utf8")); 113 | const content = JSON.stringify( 114 | { 115 | ...originalJson, 116 | id: projectId, 117 | }, 118 | null, 119 | 2, 120 | ); 121 | outputFileSync(join(projectPath, "fiha.json"), content, "utf8"); 122 | 123 | return projectPath; 124 | } 125 | 126 | return async () => 127 | await new Promise((resolve, reject) => { 128 | function onAbort(reason?: any) { 129 | const err = 130 | reason instanceof Error 131 | ? reason 132 | : new Error(reason?.message || reason); 133 | console.warn("[command] project scaffolding aborted", err.stack); 134 | reject(reason); 135 | } 136 | 137 | function proceed(projectId: string, projectPath: string) { 138 | try { 139 | scaffold(projectId, projectPath); 140 | 141 | if (projectPath === wsUri?.fsPath) { 142 | propagate().then(resolve).catch(reject); 143 | return; 144 | } 145 | 146 | const uri = vscode.Uri.parse(projectPath); 147 | console.info("opening folder", uri, "forceNewWindow: false"); 148 | vscode.commands 149 | .executeCommand("vscode.openFolder", uri, { 150 | forceNewWindow: false, 151 | }) 152 | .then(() => { 153 | resolve(projectPath); 154 | }, onAbort); 155 | } catch (err: any) { 156 | console.error((err as Error).message); 157 | onAbort(err); 158 | } 159 | } 160 | 161 | function promptInfo() { 162 | console.info("promptInfo", "wsUri", wsUri?.fsPath); 163 | promptName((projectId: string) => { 164 | promptDirectory((wantedProjectPath: string) => { 165 | proceed(projectId, join(wantedProjectPath, projectId)); 166 | }, onAbort); 167 | }, onAbort); 168 | } 169 | 170 | if (wsUri == null) { 171 | promptInfo(); 172 | return; 173 | } 174 | 175 | stat(vscode.Uri.joinPath(wsUri, "fiha.json").fsPath) 176 | .then(() => { 177 | promptInfo(); 178 | }) 179 | .catch(() => { 180 | promptName((projectId) => { 181 | proceed(projectId, wsUri.fsPath); 182 | }); 183 | }); 184 | }); 185 | } 186 | -------------------------------------------------------------------------------- /src/extension/commands/setBPM.ts: -------------------------------------------------------------------------------- 1 | import type { ComEventDataMeta } from "../../utils/com"; 2 | import store from "../store"; 3 | 4 | export default function setBPM() { 5 | return (newBPM: number, meta: ComEventDataMeta) => { 6 | // console.info('[ext] set BPM', newBPM); 7 | store.dispatch({ type: "setBPM", payload: newBPM, meta }); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/extension/commands/setStageSize.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import type { ComEventDataMeta } from "../../utils/com"; 3 | import store from "../store"; 4 | 5 | interface Size { 6 | width: number; 7 | height: number; 8 | } 9 | 10 | const sizes = { 11 | "4k": { 12 | width: 3840, 13 | height: 2160, 14 | }, 15 | "1080p": { 16 | width: 1920, 17 | height: 1080, 18 | }, 19 | "720p": { 20 | width: 1280, 21 | height: 720, 22 | }, 23 | } as const; 24 | 25 | async function promptForSize(): Promise { 26 | const choice = await vscode.window.showQuickPick( 27 | [ 28 | ...Object.keys(sizes).map((label) => ({ 29 | label, 30 | })), 31 | { 32 | label: "Custom", 33 | }, 34 | ], 35 | { 36 | placeHolder: "Select a size", 37 | title: "Set Stage Size", 38 | }, 39 | ); 40 | 41 | if (!choice) { 42 | return sizes["720p"]; 43 | } 44 | 45 | if (choice?.label === "Custom") { 46 | const width = await vscode.window.showInputBox({ 47 | placeHolder: "800", 48 | title: "Set Stage Width", 49 | }); 50 | const height = await vscode.window.showInputBox({ 51 | placeHolder: "600", 52 | title: "Set Stage Height", 53 | }); 54 | 55 | return { 56 | width: Number(width), 57 | height: Number(height), 58 | }; 59 | } 60 | 61 | return sizes[choice.label as keyof typeof sizes]; 62 | } 63 | 64 | export default function setStageSize() { 65 | return async (size?: Size, meta?: ComEventDataMeta) => { 66 | store.dispatch({ 67 | type: "setStageSize", 68 | payload: size ?? (await promptForSize()), 69 | meta, 70 | }); 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/extension/commands/toggleLayer.ts: -------------------------------------------------------------------------------- 1 | import type { VFCommand } from "../../types"; 2 | 3 | const toggleLayer: VFCommand = 4 | (context, extension) => (layerId: string | { id: string }) => { 5 | console.info("[command] toggleLayer", layerId); 6 | 7 | // const { state: { layers } } = extension; 8 | // const layerIndex = layers.findIndex((layer) => layer.id === layerId); 9 | // if (layerIndex < 0) return; 10 | 11 | extension.dispatch({ 12 | type: "toggleLayer", 13 | payload: typeof layerId === "string" ? layerId : layerId.id, 14 | }); 15 | }; 16 | 17 | export default toggleLayer; 18 | -------------------------------------------------------------------------------- /src/extension/configuration.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from "vscode"; 2 | 3 | export default function configuration( 4 | key?: string, 5 | value?: string | boolean | number | null, 6 | ) { 7 | const conf = workspace.getConfiguration("visualFiha.settings"); 8 | if (!key) { 9 | return conf; 10 | } 11 | 12 | if (typeof value === "undefined") { 13 | return conf.get(key); 14 | } 15 | 16 | return conf.update(key, value); 17 | } 18 | -------------------------------------------------------------------------------- /src/extension/extension.ts: -------------------------------------------------------------------------------- 1 | import type * as vscode from "vscode"; 2 | 3 | import VFExtension from "./VFExtension"; 4 | 5 | const extension = new VFExtension(); 6 | 7 | export async function activate(context: vscode.ExtensionContext) { 8 | await extension.activate(context); 9 | } 10 | 11 | export function deactivate() { 12 | extension.deactivate(); 13 | } 14 | -------------------------------------------------------------------------------- /src/extension/getNonce.ts: -------------------------------------------------------------------------------- 1 | export default function getNonce() { 2 | let text = ""; 3 | const possible = 4 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 5 | for (let i = 0; i < 32; i += 1) { 6 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 7 | } 8 | return text; 9 | } 10 | -------------------------------------------------------------------------------- /src/extension/getWebviewOptions.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export default function getWebviewOptions( 4 | extensionUri: vscode.Uri, 5 | ): vscode.WebviewOptions { 6 | return { 7 | // Enable javascript in the webview 8 | enableScripts: true, 9 | 10 | // And restrict the webview to only loading content from our extension's `media` directory. 11 | localResourceRoots: [ 12 | vscode.Uri.joinPath(extensionUri, "media"), 13 | vscode.Uri.joinPath(extensionUri, "out"), 14 | ], 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/extension/getWorkspaceFolder.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export default function getWorkspaceFolder( 4 | folderIndex = 0, 5 | ): vscode.WorkspaceFolder { 6 | const { workspaceFolders: folders } = vscode.workspace; 7 | 8 | if (!folders?.length) { 9 | throw new Error("Workspace has no folder"); 10 | } 11 | 12 | if (!folders[folderIndex]) { 13 | throw new Error( 14 | `Workspace has no folder with index ${folderIndex} (${folders.length})`, 15 | ); 16 | } 17 | 18 | return folders[folderIndex]; 19 | } 20 | -------------------------------------------------------------------------------- /src/extension/isAppState.ts: -------------------------------------------------------------------------------- 1 | import type { AppState } from "../types"; 2 | 3 | export function isAppState(value: any): value is AppState { 4 | return ( 5 | value != null && typeof value === "object" && typeof value.id === "string" 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/extension/isLayer.ts: -------------------------------------------------------------------------------- 1 | import type { Layer } from "../types"; 2 | import { isLayerType } from "./isLayerType"; 3 | 4 | export function isLayer(layer: unknown): layer is Layer { 5 | return ( 6 | (layer as Layer).active !== undefined && 7 | (layer as Layer).id !== undefined && 8 | isLayerType((layer as Layer).type) 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/extension/isLayerType.ts: -------------------------------------------------------------------------------- 1 | import type { LayerInfo } from "../types"; 2 | 3 | export function isLayerType(type: any): type is LayerInfo["type"] { 4 | return [ 5 | "canvas", 6 | "threejs", 7 | // , 'canvas2d', 'webgl', 'webgl2' 8 | ].includes(type); 9 | } 10 | -------------------------------------------------------------------------------- /src/extension/isScriptingData.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ScriptingData, 3 | // DisplayBase, 4 | // Layer, 5 | // StageInfo, 6 | } from "../types"; 7 | 8 | export function isScriptingData(value: any): value is ScriptingData { 9 | return ( 10 | value != null && 11 | typeof value === "object" && 12 | typeof value.iterationCount === "number" 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/extension/readLayerScripts.ts: -------------------------------------------------------------------------------- 1 | import type { LayerInfo, TypeDirectory } from "../types"; 2 | import readScripts from "./readScripts"; 3 | 4 | export default function readLayerScripts(type: keyof typeof TypeDirectory) { 5 | return async (info: LayerInfo): Promise => ({ 6 | ...info, 7 | ...(await readScripts(type, info.type, info.id)), 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/extension/readScripts.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import type { TypeDirectory } from "../types"; 3 | import scriptUri from "./scriptUri"; 4 | 5 | export default async function readScripts( 6 | type: keyof typeof TypeDirectory, 7 | runnerType: string, 8 | id: string, 9 | ) { 10 | const setupFSPath = scriptUri(type, runnerType, id, "setup").path; 11 | const animationFSPath = scriptUri(type, runnerType, id, "animation").path; 12 | 13 | let setup = ""; 14 | let animation = ""; 15 | 16 | try { 17 | setup = await readFile(setupFSPath, "utf8"); 18 | } catch (e) { 19 | /* */ 20 | } 21 | try { 22 | animation = await readFile(animationFSPath, "utf8"); 23 | } catch (e) { 24 | /* */ 25 | } 26 | 27 | return { 28 | setup, 29 | animation, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/extension/readWorkspaceRC.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | // import readWorkspaceRC from './readWorkspaceRC'; 4 | 5 | describe("readWorkspaceRC", () => { 6 | it.todo("can read from fiha.json"); 7 | it.todo("can read from package.json"); 8 | }); 9 | -------------------------------------------------------------------------------- /src/extension/readWorkspaceRC.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import * as JSON5 from "json5"; 3 | import * as vscode from "vscode"; 4 | import type { FihaRC } from "../types"; 5 | import getWorkspaceFolder from "./getWorkspaceFolder"; 6 | 7 | export default async function readWorkspaceRC( 8 | folderIndex = 0, 9 | ): Promise { 10 | const folder = getWorkspaceFolder(folderIndex); 11 | 12 | const filepath = vscode.Uri.joinPath(folder.uri, "fiha.json").fsPath; 13 | const content = await readFile(filepath, "utf8"); 14 | return await JSON5.parse(content); 15 | } 16 | -------------------------------------------------------------------------------- /src/extension/reducerSpy.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAction } from "redux"; 2 | 3 | export function reducerSpy( 4 | reducer: (state: any, action: any) => any, 5 | name: string, 6 | ) { 7 | return (state: unknown, action: AnyAction) => { 8 | const newState = reducer(state, action); 9 | console.info("[store] %s: %s", name, action.type, { 10 | state, 11 | payload: action.payload, 12 | newState, 13 | }); 14 | return newState; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/extension/scriptUri.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { type ScriptRole, TypeDirectory } from "../types"; 3 | import getWorkspaceFolder from "./getWorkspaceFolder"; 4 | 5 | export default function scriptUri( 6 | type: keyof typeof TypeDirectory, 7 | runnerType: string, 8 | id: string, 9 | role: ScriptRole, 10 | ) { 11 | const folder = getWorkspaceFolder(); 12 | if (id === "worker") { 13 | return vscode.Uri.joinPath(folder.uri, `worker/${role}.mjs`); 14 | } 15 | return vscode.Uri.joinPath( 16 | folder.uri, 17 | TypeDirectory[type], 18 | runnerType, 19 | `${id}-${role}.mjs`, 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/extension/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Action, 3 | type AnyAction, 4 | type Reducer, 5 | combineReducers, 6 | legacy_createStore as createStore, 7 | } from "redux"; 8 | 9 | import type { 10 | AppState, 11 | DisplayBase, 12 | DisplayServerInfo, 13 | LayerInfo, 14 | StageInfo, 15 | } from "../types"; 16 | 17 | type SetIdAction = Action<"setId"> & { 18 | payload: string; 19 | }; 20 | const id = (state: string, action: AnyAction | SetIdAction) => { 21 | if (action.type !== "setId") return state || null; 22 | return action.payload || state; 23 | }; 24 | 25 | type SetBPMAction = Action<"setBPM"> & { 26 | payload: number; 27 | }; 28 | const defaultBpmInfo = { count: 120, start: 0 }; 29 | const bpm = (state: any, action: AnyAction | SetBPMAction) => { 30 | if (action.type !== "setBPM") return state || defaultBpmInfo; 31 | return { 32 | count: action.payload, 33 | start: Date.now(), 34 | }; 35 | }; 36 | 37 | type SetStageAction = Action<"setStage"> & { 38 | payload: StageInfo; 39 | }; 40 | type SetStageSizeAction = Action<"setStageSize"> & { 41 | payload: { width: number; height: number }; 42 | }; 43 | type StageAction = SetStageAction | SetStageSizeAction; 44 | const defaultStageInfo = { width: 600, height: 400, autoScale: true }; 45 | const stage = (state: StageInfo, action: AnyAction | StageAction) => { 46 | switch (action.type) { 47 | case "setStage": 48 | return action.payload; 49 | case "setStageSize": 50 | return { 51 | ...state, 52 | ...action.payload, 53 | }; 54 | default: 55 | return state || defaultStageInfo; 56 | } 57 | }; 58 | 59 | type SetServerAction = Action<"setDisplayServer"> & { 60 | payload: DisplayServerInfo; 61 | }; 62 | export const server = ( 63 | state: DisplayServerInfo, 64 | action: AnyAction | SetServerAction, 65 | ) => { 66 | if (action.type !== "setDisplayServer") return state || null; 67 | return { 68 | ...state, 69 | ...action.payload, 70 | }; 71 | }; 72 | 73 | type SetWorkerScriptAction = Action<"setWorkerScript"> & { 74 | payload: { setup?: string; animation?: string }; 75 | }; 76 | const defaultWorkerScripts = { setup: "", animation: "" }; 77 | export const worker = ( 78 | state: any, 79 | action: AnyAction | SetWorkerScriptAction, 80 | ) => { 81 | if (action.type !== "setWorkerScript") return state || defaultWorkerScripts; 82 | return { 83 | ...state, 84 | ...action.payload, 85 | }; 86 | }; 87 | 88 | type AddLayerAction = Action<"addLayer"> & { 89 | payload: LayerInfo; 90 | }; 91 | type SetLayersAction = Action<"setLayers"> & { 92 | payload: LayerInfo[]; 93 | }; 94 | type RemoveLayerAction = Action<"removeLayer"> & { 95 | payload: string; 96 | }; 97 | type ToggleLayerAction = Action<"toggleLayer"> & { 98 | payload: string; 99 | }; 100 | type LayerAction = 101 | | AddLayerAction 102 | | SetLayersAction 103 | | RemoveLayerAction 104 | | ToggleLayerAction; 105 | const layers = (state: LayerInfo[], action: AnyAction | LayerAction) => { 106 | switch (action.type) { 107 | case "addLayer": 108 | return [...state, action.payload]; 109 | 110 | case "setLayers": 111 | return action.payload; 112 | 113 | case "toggleLayer": 114 | return state.map((layer) => { 115 | if (layer.id === action.payload) { 116 | return { ...layer, active: !layer.active }; 117 | } 118 | return layer; 119 | }); 120 | 121 | default: 122 | return [...(Array.isArray(state) ? state : [])]; 123 | } 124 | }; 125 | 126 | type SetDisplaysAction = Action<"setDisplays"> & { 127 | payload: DisplayBase[]; 128 | }; 129 | const displays = ( 130 | state: DisplayBase[], 131 | action: AnyAction | SetDisplaysAction, 132 | ) => { 133 | if (action.type !== "setDisplays") return state || []; 134 | return action.payload; 135 | }; 136 | 137 | export const reducers = { 138 | id, 139 | bpm, 140 | stage, 141 | server, 142 | worker, 143 | layers, 144 | displays, 145 | } as const; 146 | 147 | const topReducer = combineReducers(reducers); 148 | 149 | export type CombinedState = Parameters[0]; 150 | const defaultState: CombinedState = { 151 | id: null, 152 | bpm: { count: 0, start: 0 }, 153 | stage: { 154 | autoScale: true, 155 | height: 600, 156 | width: 800, 157 | }, 158 | displays: [], 159 | layers: [], 160 | worker: {}, 161 | server: { 162 | host: "localhost", 163 | port: 9999, 164 | }, 165 | }; 166 | 167 | type ReplaceStateAction = Action<"replaceState"> & { 168 | payload: AppState; 169 | }; 170 | 171 | export type StoreAction = 172 | | SetIdAction 173 | | SetBPMAction 174 | | StageAction 175 | | ReplaceStateAction 176 | | SetServerAction 177 | | SetWorkerScriptAction 178 | | SetDisplaysAction 179 | | LayerAction; 180 | const reducer: Reducer = (state, action) => { 181 | switch (action.type) { 182 | case "replaceState": 183 | return action.payload; 184 | default: 185 | return topReducer(state as CombinedState, action); 186 | } 187 | }; 188 | const store = createStore(reducer); 189 | 190 | export const messageHandlers = { 191 | updatestate: (newState: AppState) => { 192 | const localState = store.getState(); 193 | 194 | if (localState.id !== newState.id) { 195 | store.dispatch({ type: "setId", payload: newState.id }); 196 | } 197 | if (localState.server !== newState.server) { 198 | store.dispatch({ type: "setDisplayServer", payload: newState.server }); 199 | } 200 | }, 201 | }; 202 | 203 | export default store; 204 | -------------------------------------------------------------------------------- /src/extension/textDocumentScriptInfo.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { 3 | DirectoryTypes, 4 | type ScriptInfo, 5 | type ScriptRole, 6 | type ScriptType, 7 | } from "../types"; 8 | 9 | export default function textDocumentScriptInfo( 10 | doc: vscode.TextDocument, 11 | ): ScriptInfo { 12 | const workspacePath: string = 13 | (vscode.workspace.workspaceFolders != null && 14 | vscode.workspace.workspaceFolders.length > 0 && 15 | vscode.workspace.workspaceFolders[0].uri.path) || 16 | ""; 17 | 18 | const relativePath = doc.uri.path.replace(workspacePath, ""); 19 | const match = relativePath.match( 20 | /\/([^/]+)\/([^/]+)\/([^/]+)-(setup|animation)\./, 21 | ); 22 | if (!match) 23 | throw new Error(`Cannot determine script info for ${doc.uri.path}`); 24 | 25 | const [, directory, , id, role] = match as [ 26 | any, 27 | keyof typeof DirectoryTypes, 28 | any, 29 | string, 30 | ScriptRole, 31 | ]; 32 | 33 | if (!directory || !id || !role) 34 | throw new Error(`Cannot determine script info for ${doc.uri.path}`); 35 | return { 36 | id: directory === "worker" ? "worker" : id, 37 | relativePath, 38 | path: doc.uri.path, 39 | type: DirectoryTypes[directory] as ScriptType, 40 | role, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "..", 4 | "outDir": "../../out" 5 | }, 6 | "extends": "../../tsconfig.base.json", 7 | "exclude": ["node_modules", "layers"] 8 | } 9 | -------------------------------------------------------------------------------- /src/extension/views/AudioViewProvider.ts: -------------------------------------------------------------------------------- 1 | import type * as vscode from "vscode"; 2 | import getNonce from "../getNonce"; 3 | import { getMediaUri } from "../webviews-media"; 4 | 5 | export default class AudioViewProvider implements vscode.WebviewViewProvider { 6 | public static readonly viewType = "visualFiha.audioView"; 7 | 8 | private _view?: vscode.WebviewView; 9 | 10 | constructor(private readonly _extensionUri: vscode.Uri) {} 11 | 12 | public resolveWebviewView( 13 | webviewView: vscode.WebviewView, 14 | context: vscode.WebviewViewResolveContext, 15 | _token: vscode.CancellationToken, 16 | ) { 17 | this._view = webviewView; 18 | 19 | webviewView.webview.options = { 20 | // Allow scripts in the webview 21 | enableScripts: true, 22 | 23 | localResourceRoots: [this._extensionUri], 24 | }; 25 | 26 | webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); 27 | 28 | webviewView.webview.onDidReceiveMessage((data) => { 29 | console.info("[AudioViewProvider] onDidReceiveMessage", data); 30 | }); 31 | } 32 | 33 | public updateData(data: any) { 34 | void this._view?.webview.postMessage({ 35 | type: "updatedata", 36 | payload: data, 37 | }); 38 | } 39 | 40 | private _getHtmlForWebview(webview: vscode.Webview) { 41 | const assets = getMediaUri("audioView", webview, this._extensionUri); 42 | // Use a nonce to only allow a specific script to be run. 43 | const nonce = getNonce(); 44 | 45 | return ` 46 | 47 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | Audio 64 | 65 | 66 |
67 | 68 | 69 | `; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/extension/views/ControlViewProvider.ts: -------------------------------------------------------------------------------- 1 | import type * as vscode from "vscode"; 2 | import getNonce from "../getNonce"; 3 | import { getMediaUri } from "../webviews-media"; 4 | 5 | export default class ControlViewProvider implements vscode.WebviewViewProvider { 6 | public static readonly viewType = "visualFiha.controlView"; 7 | 8 | private _view?: vscode.WebviewView; 9 | 10 | constructor(private readonly _extensionUri: vscode.Uri) {} 11 | 12 | public resolveWebviewView( 13 | webviewView: vscode.WebviewView, 14 | context: vscode.WebviewViewResolveContext, 15 | _token: vscode.CancellationToken, 16 | ) { 17 | this._view = webviewView; 18 | 19 | webviewView.webview.options = { 20 | // Allow scripts in the webview 21 | enableScripts: true, 22 | 23 | localResourceRoots: [this._extensionUri], 24 | }; 25 | 26 | webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); 27 | 28 | webviewView.webview.onDidReceiveMessage((data) => { 29 | console.info("[ControlViewProvider] onDidReceiveMessage", data); 30 | }); 31 | } 32 | 33 | private _getHtmlForWebview(webview: vscode.Webview) { 34 | const assets = getMediaUri("controlView", webview, this._extensionUri); 35 | // Use a nonce to only allow a specific script to be run. 36 | const nonce = getNonce(); 37 | 38 | return ` 39 | 40 | 41 | 42 | 43 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Control 57 | 58 | 59 |
60 | 61 | 62 | `; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/extension/views/DisplaysViewProvider.ts: -------------------------------------------------------------------------------- 1 | import type * as vscode from "vscode"; 2 | import getNonce from "../getNonce"; 3 | import { getMediaUri } from "../webviews-media"; 4 | 5 | export default class DisplaysViewProvider 6 | implements vscode.WebviewViewProvider 7 | { 8 | public static readonly viewType = "visualFiha.displaysView"; 9 | 10 | private _view?: vscode.WebviewView; 11 | 12 | constructor(private readonly _extensionUri: vscode.Uri) {} 13 | 14 | public resolveWebviewView( 15 | webviewView: vscode.WebviewView, 16 | context: vscode.WebviewViewResolveContext, 17 | _token: vscode.CancellationToken, 18 | ) { 19 | this._view = webviewView; 20 | 21 | webviewView.webview.options = { 22 | // Allow scripts in the webview 23 | enableScripts: true, 24 | 25 | localResourceRoots: [this._extensionUri], 26 | }; 27 | 28 | webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); 29 | 30 | webviewView.webview.onDidReceiveMessage((data) => { 31 | console.info("[DisplaysViewProvider] onDidReceiveMessage", data); 32 | }); 33 | } 34 | 35 | private _getHtmlForWebview(webview: vscode.Webview) { 36 | const assets = getMediaUri("displaysView", webview, this._extensionUri); 37 | // Use a nonce to only allow a specific script to be run. 38 | const nonce = getNonce(); 39 | 40 | return ` 41 | 42 | 43 | 44 | 45 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | Displays 59 | 60 | 61 |
62 | 63 | 64 | `; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/extension/views/ScriptsViewProvider.ts: -------------------------------------------------------------------------------- 1 | import type { Unsubscribe } from "redux"; 2 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 3 | import * as vscode from "vscode"; 4 | import type { LayerInfo } from "../../types"; 5 | import type VFExtension from "../VFExtension"; 6 | 7 | interface Node extends Pick { 8 | type: string; 9 | parent?: Node; 10 | } 11 | 12 | class ScriptsViewProvider implements vscode.TreeDataProvider { 13 | private readonly _onDidChangeTreeData = new vscode.EventEmitter< 14 | Node | undefined 15 | >(); 16 | 17 | readonly onDidChangeTreeData = this._onDidChangeTreeData.event; 18 | 19 | constructor(context: vscode.ExtensionContext, extension: VFExtension) { 20 | this.#context = context; 21 | this.#extension = extension; 22 | this.#unsubscribe = this.#extension.subscribe(this.refresh.bind(this)); 23 | } 24 | 25 | #unsubscribe: Unsubscribe; 26 | 27 | #context: vscode.ExtensionContext; 28 | 29 | #extension: VFExtension; 30 | 31 | dispose() { 32 | this.#unsubscribe(); 33 | } 34 | 35 | refresh() { 36 | this._onDidChangeTreeData.fire(undefined); 37 | } 38 | 39 | private getScriptChildren(element: Node) { 40 | return [ 41 | { 42 | id: `${element.id}-setup}`, 43 | type: "setup", 44 | weight: 0, 45 | active: true, 46 | parent: element, 47 | }, 48 | { 49 | id: `${element.id}-animation`, 50 | type: "animation", 51 | weight: 1, 52 | active: true, 53 | parent: element, 54 | }, 55 | ]; 56 | } 57 | 58 | private getLayers() { 59 | return this.#extension.state.layers 60 | .map((layer) => ({ 61 | id: layer.id, 62 | type: layer.type, 63 | weight: layer.weight, 64 | active: layer.active, 65 | })) 66 | .sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0)); 67 | } 68 | 69 | getChildren(element?: Node) { 70 | if (element) { 71 | return this.getScriptChildren(element); 72 | } 73 | return [ 74 | { 75 | id: "worker", 76 | type: "worker", 77 | active: true, 78 | }, 79 | ...this.getLayers(), 80 | ] as Node[]; 81 | } 82 | 83 | private getScriptTreeItem(element: Node): vscode.TreeItem { 84 | const scriptId = element.parent!.id; 85 | const scriptType = element.parent!.type; 86 | const scriptRole = element.type; 87 | 88 | const title = `Open ${scriptId} ${scriptRole} script`; 89 | 90 | const scriptRelativePath = 91 | scriptId === "worker" 92 | ? `worker/worker-${scriptRole}.mjs` 93 | : `layers/${scriptType}/${scriptId}-${scriptRole}.mjs`; 94 | 95 | return { 96 | id: element.id, 97 | label: scriptRole, 98 | collapsibleState: vscode.TreeItemCollapsibleState.None, 99 | tooltip: title, 100 | command: { 101 | command: "visualFiha.openEditor", 102 | title, 103 | arguments: [scriptRelativePath], 104 | }, 105 | }; 106 | } 107 | 108 | getTreeItem(element: Node): vscode.TreeItem | Thenable { 109 | if (["setup", "animation"].includes(element.type)) { 110 | return this.getScriptTreeItem(element); 111 | } 112 | return { 113 | id: element.id, 114 | label: element.id, 115 | description: `${element.type} ${element.active ? "✓" : ""}`, 116 | collapsibleState: vscode.TreeItemCollapsibleState.Expanded, 117 | contextValue: element.id === "worker" ? "worker" : "layer", 118 | }; 119 | } 120 | } 121 | 122 | export default class ScriptsView { 123 | constructor(context: vscode.ExtensionContext, extension: VFExtension) { 124 | this.#provider = new ScriptsViewProvider(context, extension); 125 | 126 | this.#view = vscode.window.createTreeView("visualFiha.scriptsView", { 127 | treeDataProvider: this.#provider, 128 | showCollapseAll: true, 129 | canSelectMany: true, 130 | }); 131 | 132 | context.subscriptions.push(this.#view); 133 | } 134 | 135 | #view: vscode.TreeView; 136 | 137 | #provider: vscode.TreeDataProvider; 138 | } 139 | -------------------------------------------------------------------------------- /src/extension/views/SettingsViewProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export default class SettingsView { 4 | constructor(context: vscode.ExtensionContext) { 5 | const view = vscode.window.createTreeView("visualFiha.settingsView", { 6 | treeDataProvider: aNodeWithIdTreeDataProvider(), 7 | showCollapseAll: true, 8 | }); 9 | context.subscriptions.push(view); 10 | vscode.commands.registerCommand("settingsView.reveal", async () => { 11 | const key = await vscode.window.showInputBox({ 12 | placeHolder: "Type the label of the item to reveal", 13 | }); 14 | if (key) { 15 | await view.reveal( 16 | { key }, 17 | { focus: true, select: false, expand: true }, 18 | ); 19 | } 20 | }); 21 | vscode.commands.registerCommand("settingsView.changeTitle", async () => { 22 | const title = await vscode.window.showInputBox({ 23 | prompt: "Type the new title for the Test View", 24 | placeHolder: view.title, 25 | }); 26 | if (title) { 27 | view.title = title; 28 | } 29 | }); 30 | } 31 | } 32 | 33 | const tree: any = { 34 | a: { 35 | aa: { 36 | aaa: { 37 | aaaa: { 38 | aaaaa: { 39 | aaaaaa: {}, 40 | }, 41 | }, 42 | }, 43 | }, 44 | ab: {}, 45 | }, 46 | b: { 47 | ba: {}, 48 | bb: {}, 49 | }, 50 | }; 51 | const nodes: any = {}; 52 | 53 | function aNodeWithIdTreeDataProvider(): vscode.TreeDataProvider<{ 54 | key: string; 55 | }> { 56 | return { 57 | getChildren: (element: { key: string }): Array<{ key: string }> => { 58 | return getChildren(element ? element.key : undefined).map((key) => 59 | getNode(key), 60 | ); 61 | }, 62 | getTreeItem: (element: { key: string }): vscode.TreeItem => { 63 | const treeItem = getTreeItem(element.key); 64 | treeItem.id = element.key; 65 | return treeItem; 66 | }, 67 | getParent: ({ key }: { key: string }): { key: string } | undefined => { 68 | const parentKey = key.substring(0, key.length - 1); 69 | return parentKey ? new Key(parentKey) : undefined; 70 | }, 71 | }; 72 | } 73 | 74 | function getChildren(key: string | undefined): string[] { 75 | if (!key) { 76 | return Object.keys(tree); 77 | } 78 | const treeElement = getTreeElement(key); 79 | if (treeElement) { 80 | return Object.keys(treeElement); 81 | } 82 | return []; 83 | } 84 | 85 | function getTreeItem(key: string): vscode.TreeItem { 86 | const treeElement = getTreeElement(key); 87 | // An example of how to use codicons in a MarkdownString in a tree item tooltip. 88 | const tooltip = new vscode.MarkdownString(`$(zap) Tooltip for ${key}`, true); 89 | return { 90 | label: /** vscode.TreeItemLabel**/ { 91 | label: key, 92 | highlights: 93 | key.length > 1 ? [[key.length - 2, key.length - 1]] : undefined, 94 | } as any, 95 | tooltip, 96 | collapsibleState: 97 | treeElement && Object.keys(treeElement).length 98 | ? vscode.TreeItemCollapsibleState.Collapsed 99 | : vscode.TreeItemCollapsibleState.None, 100 | }; 101 | } 102 | 103 | function getTreeElement(element: string): any { 104 | let parent = tree; 105 | for (let i = 0; i < element.length; i++) { 106 | parent = parent[element.substring(0, i + 1)]; 107 | if (!parent) { 108 | return null; 109 | } 110 | } 111 | return parent; 112 | } 113 | 114 | function getNode(key: string): { key: string } { 115 | if (!nodes[key]) { 116 | nodes[key] = new Key(key); 117 | } 118 | return nodes[key]; 119 | } 120 | 121 | class Key { 122 | constructor(readonly key: string) {} 123 | } 124 | -------------------------------------------------------------------------------- /src/extension/views/TimelineViewProvider.ts: -------------------------------------------------------------------------------- 1 | import type * as vscode from "vscode"; 2 | import getNonce from "../getNonce"; 3 | import { getMediaUri } from "../webviews-media"; 4 | 5 | export default class TimelineViewProvider 6 | implements vscode.WebviewViewProvider 7 | { 8 | public static readonly viewType = "visualFiha.timelineView"; 9 | 10 | private _view?: vscode.WebviewView; 11 | 12 | constructor(private readonly _extensionUri: vscode.Uri) {} 13 | 14 | public resolveWebviewView( 15 | webviewView: vscode.WebviewView, 16 | context: vscode.WebviewViewResolveContext, 17 | _token: vscode.CancellationToken, 18 | ) { 19 | this._view = webviewView; 20 | 21 | webviewView.webview.options = { 22 | // Allow scripts in the webview 23 | enableScripts: true, 24 | 25 | localResourceRoots: [this._extensionUri], 26 | }; 27 | 28 | webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); 29 | 30 | webviewView.webview.onDidReceiveMessage((data) => { 31 | console.info("[TimelineViewProvider] onDidReceiveMessage", data); 32 | }); 33 | } 34 | 35 | private _getHtmlForWebview(webview: vscode.Webview) { 36 | const assets = getMediaUri("timelineView", webview, this._extensionUri); 37 | // Use a nonce to only allow a specific script to be run. 38 | const nonce = getNonce(); 39 | 40 | return ` 41 | 42 | 43 | 44 | 45 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | Timeline 59 | 60 | 61 |
62 | 63 | 64 | `; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/extension/webviews-media.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export function getMediaUri( 4 | scriptname: string, 5 | webview: vscode.Webview, 6 | extensionUri: vscode.Uri, 7 | ) { 8 | const script = webview 9 | .asWebviewUri( 10 | vscode.Uri.joinPath(extensionUri, "out", "webviews", `${scriptname}.js`), 11 | ) 12 | .toString(); 13 | const styleReset = webview 14 | .asWebviewUri(vscode.Uri.joinPath(extensionUri, "media", "reset.css")) 15 | .toString(); 16 | 17 | const styleVSCode = webview 18 | .asWebviewUri(vscode.Uri.joinPath(extensionUri, "media", "vscode.css")) 19 | .toString(); 20 | 21 | const styleMain = webview 22 | .asWebviewUri(vscode.Uri.joinPath(extensionUri, "media", "main.css")) 23 | .toString(); 24 | 25 | const icon = webview 26 | .asWebviewUri(vscode.Uri.joinPath(extensionUri, "media", "favicon.png")) 27 | .toString(); 28 | 29 | return { 30 | script, 31 | styleReset, 32 | styleVSCode, 33 | styleMain, 34 | icon, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/extension/workspaceFileExists._test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, it } from "vitest"; 2 | 3 | import { type DirectoryResult, dir } from "tmp-promise"; 4 | // Mock function to simulate setting up a workspace 5 | const __setWorkspace = ( 6 | path: string, 7 | options: { worksapceFolders: string[] }, 8 | ) => { 9 | // Simulate workspace setup logic here 10 | }; 11 | 12 | import workspaceFileExists from "./workspaceFileExists"; 13 | 14 | let tmpDir: DirectoryResult; 15 | beforeAll(async () => { 16 | tmpDir = await dir(); 17 | }); 18 | 19 | afterAll(async () => { 20 | await tmpDir.cleanup(); 21 | }); 22 | 23 | describe("workspaceFileExists", () => { 24 | it("returns false when the relative path does not exists", async () => { 25 | await expect(workspaceFileExists("whatever")).resolves.toBe(false); 26 | }); 27 | 28 | it("returns true when the relative path exists", async () => { 29 | __setWorkspace(tmpDir.path, { 30 | worksapceFolders: ["folder-a"], 31 | }); 32 | await expect(workspaceFileExists("whatever")).resolves.toBe(false); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/extension/workspaceFileExists.ts: -------------------------------------------------------------------------------- 1 | import { access } from "node:fs"; 2 | import * as vscode from "vscode"; 3 | 4 | import getWorkspaceFolder from "./getWorkspaceFolder"; 5 | 6 | export default async function workspaceFileExists( 7 | relativePath: string, 8 | folderIndex = 0, 9 | ): Promise { 10 | const folder = getWorkspaceFolder(folderIndex); 11 | const filepath = vscode.Uri.joinPath(folder.uri, relativePath).fsPath; 12 | return await new Promise((resolve) => { 13 | access(filepath, (err) => { 14 | if (err != null) { 15 | resolve(false); 16 | return; 17 | } 18 | resolve(true); 19 | }); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/extension/writeWorkspaceRC.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs/promises"; 2 | import * as vscode from "vscode"; 3 | import type { AppState, FihaRC } from "../types"; 4 | import getWorkspaceFolder from "./getWorkspaceFolder"; 5 | 6 | export default async function writeWorkspaceRC( 7 | content: AppState, 8 | folderIndex = 0, 9 | ): Promise { 10 | const folder = getWorkspaceFolder(folderIndex); 11 | 12 | const filepath = vscode.Uri.joinPath(folder.uri, "fiha.json").fsPath; 13 | await writeFile( 14 | filepath, 15 | JSON.stringify( 16 | { 17 | id: content.id, 18 | layers: content.layers, 19 | assets: [], 20 | } satisfies FihaRC, 21 | null, 22 | 2, 23 | ), 24 | "utf8", 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/layers/Canvas2D/Canvas2DLayer.dom.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import Canvas2DLayer, { type Canvas2DLayerOptions } from "./Canvas2DLayer"; 3 | 4 | const setupScript = 'console.info("hello"); return { newData: "set" };'; 5 | 6 | let layer: Canvas2DLayer; 7 | 8 | const options: Canvas2DLayerOptions = { 9 | id: "layerId", 10 | canvas: document.createElement("canvas"), 11 | }; 12 | 13 | const compilationErrorListener = vi.fn((err) => { 14 | console.info(err.builderStr); 15 | }); 16 | 17 | describe("instanciation", () => { 18 | it("takes some options", () => { 19 | layer = new Canvas2DLayer(options); 20 | expect(layer).toBeTruthy(); 21 | expect(layer).toHaveProperty("setup.isAsync", false); 22 | expect(layer).toHaveProperty("setup.version", 2); 23 | expect(layer).toHaveProperty("id", options.id); 24 | }); 25 | 26 | it("throws an error if no id is provided", () => { 27 | expect( 28 | () => 29 | // @ts-expect-error 30 | new Canvas2DLayer({ 31 | canvas: document.createElement("canvas"), 32 | }), 33 | ).toThrowError(); 34 | }); 35 | 36 | it("has a cache", () => { 37 | expect(layer).toHaveProperty("cache", {}); 38 | }); 39 | }); 40 | 41 | describe("setup script", () => { 42 | it("is empty by default", () => { 43 | expect(layer).toHaveProperty("setup.code", ""); 44 | }); 45 | 46 | it("has a version number", () => { 47 | expect(layer).toHaveProperty("setup.version", 2); 48 | }); 49 | 50 | it("can be set", () => { 51 | layer.setup.addEventListener( 52 | "compilationerror", 53 | compilationErrorListener as any, 54 | ); 55 | expect(() => { 56 | layer.setup.code = setupScript; 57 | }).not.toThrowError(); 58 | expect(compilationErrorListener).not.toHaveBeenCalled(); 59 | expect(layer).toHaveProperty("setup.version", 3); 60 | expect(layer).toHaveProperty("setup.code", setupScript); 61 | }); 62 | 63 | it("always executes asynchronimously", async () => { 64 | layer.setup.code = 65 | 'return await (new Promise((res) => res({ newData: "set" })))'; 66 | expect(layer).toHaveProperty("setup.isAsync", true); 67 | const promise = layer.execSetup(); 68 | await expect(promise).resolves.toStrictEqual({ newData: "set" }); 69 | }); 70 | 71 | it("can be used to set the scripts cache", () => { 72 | expect(layer).toHaveProperty("cache", { newData: "set" }); 73 | }); 74 | }); 75 | 76 | describe("animation script", () => { 77 | it("is empty by default", () => { 78 | expect(layer).toHaveProperty("animation.code", ""); 79 | }); 80 | 81 | it("can use the script cache", () => { 82 | const logListener = vi.fn(); 83 | const code = 'cache.added = true; scriptLog("cache", cache);'; 84 | layer.animation.code = code; 85 | layer.animation.addEventListener("log", logListener); 86 | layer.animation.addEventListener("executionerror", ((err) => { 87 | console.info(err); 88 | }) as any); 89 | expect(layer).toHaveProperty("animation.code", code); 90 | expect(layer.cache).toHaveProperty("newData", "set"); 91 | expect(layer.execAnimation).not.toThrow(); 92 | expect(layer.cache).toHaveProperty("newData", "set"); 93 | expect(layer.cache).toHaveProperty("added", true); 94 | expect(logListener).toHaveBeenCalledWith({ 95 | data: [["cache", { newData: "set", added: true }]], 96 | type: "log", 97 | }); 98 | layer.animation.removeEventListener("log", logListener); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/layers/Canvas2D/Canvas2DLayer.ts: -------------------------------------------------------------------------------- 1 | import * as assetTools from "../../utils/assetTools"; 2 | import * as mathTools from "../../utils/mathTools"; 3 | import miscTools from "../../utils/miscTools"; 4 | import Layer, { type LayerOptions } from "../Layer"; 5 | import canvasTools, { type CTX } from "./canvasTools"; 6 | 7 | export interface Canvas2DLayerOptions extends LayerOptions {} 8 | 9 | export default class Canvas2DLayer extends Layer { 10 | constructor(options: Canvas2DLayerOptions) { 11 | super(options); 12 | this.#ctx = this.canvas.getContext("2d") as CTX; 13 | this.api = { 14 | ...mathTools, 15 | ...miscTools, 16 | ...assetTools, 17 | ...canvasTools(this.#ctx), 18 | ...super.api, 19 | }; 20 | } 21 | 22 | #ctx: CTX; 23 | 24 | get context() { 25 | return this.#ctx; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/layers/Layer.dom.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import Layer, { type LayerOptions } from "./Layer"; 3 | 4 | const setupScript = 'console.info("hello"); return { newData: "set" };'; 5 | 6 | let layer: Layer; 7 | 8 | const options: LayerOptions = { 9 | id: "layerId", 10 | canvas: document.createElement("canvas"), 11 | }; 12 | 13 | const compilationErrorListener = vi.fn((err) => { 14 | console.info(err.builderStr); 15 | }); 16 | 17 | describe("instanciation", () => { 18 | it("takes some options", () => { 19 | layer = new Layer(options); 20 | expect(layer).toBeTruthy(); 21 | expect(layer).toHaveProperty("setup.isAsync", false); 22 | expect(layer).toHaveProperty("setup.version", 1); 23 | expect(layer).toHaveProperty("id", options.id); 24 | }); 25 | 26 | it("throws an error if no id is provided", () => { 27 | expect( 28 | () => 29 | // @ts-expect-error 30 | new Layer({ 31 | canvas: document.createElement("canvas"), 32 | }), 33 | ).toThrowError(); 34 | }); 35 | 36 | it("has a cache", () => { 37 | expect(layer).toHaveProperty("cache", {}); 38 | }); 39 | }); 40 | 41 | describe("setup script", () => { 42 | it("is empty by default", () => { 43 | expect(layer).toHaveProperty("setup.code", ""); 44 | }); 45 | 46 | it("has a version number", () => { 47 | expect(layer).toHaveProperty("setup.version", 1); 48 | }); 49 | 50 | it("can be set", () => { 51 | layer.setup.addEventListener( 52 | "compilationerror", 53 | compilationErrorListener as any, 54 | ); 55 | expect(() => { 56 | layer.setup.code = setupScript; 57 | }).not.toThrowError(); 58 | expect(compilationErrorListener).not.toHaveBeenCalled(); 59 | expect(layer).toHaveProperty("setup.version", 2); 60 | expect(layer).toHaveProperty("setup.code", setupScript); 61 | }); 62 | 63 | it("always executes asynchronimously", async () => { 64 | layer.setup.code = 65 | 'return await (new Promise((res) => res({ newData: "set" })))'; 66 | expect(layer).toHaveProperty("setup.isAsync", true); 67 | const promise = layer.execSetup(); 68 | await expect(promise).resolves.toStrictEqual({ newData: "set" }); 69 | }); 70 | 71 | it("can be used to set the scripts cache", () => { 72 | expect(layer).toHaveProperty("cache", { newData: "set" }); 73 | }); 74 | }); 75 | 76 | describe("animation script", () => { 77 | it("is empty by default", () => { 78 | expect(layer).toHaveProperty("animation.code", ""); 79 | }); 80 | 81 | it("can use the script cache", () => { 82 | const logListener = vi.fn(); 83 | const code = 'cache.added = true; scriptLog("cache", cache);'; 84 | layer.animation.code = code; 85 | layer.animation.addEventListener("log", logListener); 86 | layer.animation.addEventListener("executionerror", ((err) => { 87 | console.info(err); 88 | }) as any); 89 | expect(layer).toHaveProperty("animation.code", code); 90 | expect(layer.cache).toHaveProperty("newData", "set"); 91 | expect(layer.execAnimation).not.toThrow(); 92 | expect(layer.cache).toHaveProperty("newData", "set"); 93 | expect(layer.cache).toHaveProperty("added", true); 94 | expect(logListener).toHaveBeenCalledWith({ 95 | data: [["cache", { newData: "set", added: true }]], 96 | type: "log", 97 | }); 98 | layer.animation.removeEventListener("log", logListener); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/layers/Layer.ts: -------------------------------------------------------------------------------- 1 | import Scriptable, { type ScriptableOptions } from "../utils/Scriptable"; 2 | 3 | interface OffscreenCanvas extends HTMLCanvasElement {} 4 | 5 | export interface LayerOptions extends Omit { 6 | id: string; 7 | canvas?: HTMLCanvasElement | OffscreenCanvas; 8 | active?: boolean; 9 | } 10 | 11 | export default class Layer extends Scriptable { 12 | constructor(options: LayerOptions) { 13 | super(options); 14 | this.active = typeof options.active !== "undefined" ? options.active : true; 15 | if (!options.id) throw new Error("Missing id option"); 16 | this.#canvas = ( 17 | options.canvas != null ? options.canvas : new OffscreenCanvas(600, 400) 18 | ) as OffscreenCanvas; 19 | } 20 | 21 | active = true; 22 | 23 | #canvas: HTMLCanvasElement | OffscreenCanvas; 24 | 25 | get canvas() { 26 | return this.#canvas; 27 | } 28 | 29 | get width() { 30 | return this.#canvas.width; 31 | } 32 | 33 | set width(val) { 34 | const canvas = this.#canvas; 35 | canvas.width = val; 36 | } 37 | 38 | get height() { 39 | return this.#canvas.height; 40 | } 41 | 42 | set height(val) { 43 | const canvas = this.#canvas; 44 | canvas.height = val; 45 | } 46 | 47 | execAnimation = () => { 48 | if (!this.active) return; 49 | this.animation.exec(); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/layers/ThreeJS/ThreeJSLayer.skippedspec.ts: -------------------------------------------------------------------------------- 1 | import ThreeJSLayer, { type ThreeJSLayerOptions } from "./ThreeJSLayer"; 2 | 3 | const setupScript = 'console.info("hello"); return { newData: "set" };'; 4 | 5 | let layer: ThreeJSLayer; 6 | 7 | const options = { 8 | id: "layerId", 9 | canvas: document.createElement("canvas"), 10 | } as ThreeJSLayerOptions; 11 | 12 | const compilationErrorListener = jest.fn((err) => { 13 | console.info(err.builderStr); 14 | }); 15 | 16 | describe.skip("instanciation", () => { 17 | it("takes some options", () => { 18 | layer = new ThreeJSLayer(options); 19 | expect(layer).toBeTruthy(); 20 | expect(layer).toHaveProperty("setup.isAsync", false); 21 | expect(layer).toHaveProperty("setup.version", 2); 22 | expect(layer).toHaveProperty("id", options.id); 23 | }); 24 | 25 | it("throws an error if no id is provided", () => { 26 | expect( 27 | () => 28 | new ThreeJSLayer({ 29 | canvas: document.createElement("canvas"), 30 | } as ThreeJSLayerOptions), 31 | ).toThrowError(); 32 | }); 33 | 34 | it("has a cache", () => { 35 | expect(layer).toHaveProperty("cache", {}); 36 | }); 37 | }); 38 | 39 | describe.skip("setup script", () => { 40 | it("is empty by default", () => { 41 | expect(layer).toHaveProperty("setup.code", ""); 42 | }); 43 | 44 | it("has a version number", () => { 45 | expect(layer).toHaveProperty("setup.version", 2); 46 | }); 47 | 48 | it("can be set", () => { 49 | layer.setup.addEventListener("compilationerror", compilationErrorListener); 50 | expect(() => { 51 | layer.setup.code = setupScript; 52 | }).not.toThrowError(); 53 | expect(compilationErrorListener).not.toHaveBeenCalled(); 54 | expect(layer).toHaveProperty("setup.version", 3); 55 | expect(layer).toHaveProperty("setup.code", setupScript); 56 | }); 57 | 58 | it("always executes asynchronimously", async () => { 59 | layer.setup.code = 60 | 'return await (new Promise((res) => res({ newData: "set" })))'; 61 | expect(layer).toHaveProperty("setup.isAsync", true); 62 | const promise = layer.execSetup(); 63 | await expect(promise).resolves.toStrictEqual({ newData: "set" }); 64 | }); 65 | 66 | it("can be used to set the scripts cache", () => { 67 | expect(layer).toHaveProperty("cache", { newData: "set" }); 68 | }); 69 | }); 70 | 71 | describe.skip("animation script", () => { 72 | it("is empty by default", () => { 73 | expect(layer).toHaveProperty("animation.code", ""); 74 | }); 75 | 76 | it("can use the script cache", () => { 77 | const logListener = jest.fn(); 78 | const code = 'cache.added = true; scriptLog("cache", cache);'; 79 | layer.animation.code = code; 80 | layer.animation.addEventListener("log", logListener); 81 | layer.animation.addEventListener("executionerror", (err) => { 82 | console.info(err); 83 | }); 84 | expect(layer).toHaveProperty("animation.code", code); 85 | expect(layer.cache).toHaveProperty("newData", "set"); 86 | expect(layer.execAnimation).not.toThrow(); 87 | expect(layer.cache).toHaveProperty("newData", "set"); 88 | expect(layer.cache).toHaveProperty("added", true); 89 | expect(logListener).toHaveBeenCalledWith({ 90 | data: [["cache", { newData: "set", added: true }]], 91 | type: "log", 92 | }); 93 | layer.animation.removeEventListener("log", logListener); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/layers/ThreeJS/ThreeJSLayer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/strict-boolean-expressions */ 2 | import * as THREE from "three"; 3 | import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; 4 | 5 | import * as mathTools from "../../utils/mathTools"; 6 | import miscTools from "../../utils/miscTools"; 7 | import Layer, { type LayerOptions } from "../Layer"; 8 | 9 | export interface ThreeJSLayerOptions extends LayerOptions {} 10 | 11 | export default class ThreeJSLayer extends Layer { 12 | constructor(options: ThreeJSLayerOptions) { 13 | super(options); 14 | 15 | const { 16 | canvas, 17 | canvas: { width, height }, 18 | } = this; 19 | 20 | this.renderer = new THREE.WebGLRenderer({ 21 | alpha: true, 22 | canvas, 23 | }); 24 | this.scene = new THREE.Scene(); 25 | this.camera = new THREE.PerspectiveCamera(20, width / height, 1, 1000); 26 | 27 | const { renderer, camera, scene } = this; 28 | 29 | renderer.setClearColor(0x000000, 0); 30 | 31 | camera.position.z = 400; 32 | camera.position.x = 400; 33 | camera.position.y = 100; 34 | camera.lookAt(0, 0, 0); 35 | 36 | this.api = { 37 | ...mathTools, 38 | ...miscTools, 39 | ...super.api, 40 | THREE, 41 | camera, 42 | scene, 43 | renderer, 44 | GLTFLoader, 45 | clear: this.#clearScene, 46 | }; 47 | } 48 | 49 | renderer: THREE.WebGLRenderer; 50 | 51 | camera: THREE.PerspectiveCamera; 52 | 53 | scene: THREE.Scene; 54 | 55 | #clearScene = (): void => { 56 | this.scene.children.forEach((child) => { 57 | console.info("[ThreeJS] clear scene child", child.name || "no name"); 58 | this.scene.remove(child); 59 | }); 60 | }; 61 | 62 | #update = (): void => { 63 | // if (!this.camera) return 64 | try { 65 | this.camera.aspect = this.width / this.height; 66 | } catch (e) { 67 | console.info("[ThreeJS] cannot set aspect", (e as Error).message); 68 | } 69 | }; 70 | 71 | get width(): number { 72 | return this.canvas.width; 73 | } 74 | 75 | set width(width: number) { 76 | this.canvas.width = width; 77 | this.renderer.setSize(width, this.canvas.height, false); 78 | this.#update(); 79 | } 80 | 81 | get height(): number { 82 | return this.canvas.height; 83 | } 84 | 85 | set height(height: number) { 86 | this.canvas.height = height; 87 | this.renderer.setSize(this.canvas.width, height, false); 88 | this.#update(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io"; 2 | import type * as vscode from "vscode"; 3 | 4 | import type VFExtension from "./extension/VFExtension"; 5 | import type { ScriptableOptions } from "./utils/Scriptable"; 6 | 7 | export interface StageInfo { 8 | width: number; 9 | height: number; 10 | autoScale: boolean; 11 | } 12 | 13 | export interface DisplayBase { 14 | id: string; 15 | width?: number; 16 | height?: number; 17 | resolution?: number; 18 | readonly control: boolean; 19 | } 20 | 21 | export interface AppDisplay extends DisplayBase { 22 | app: { 23 | id: string; 24 | stage: StageInfo; 25 | }; 26 | } 27 | 28 | export interface ServerDisplay extends Omit { 29 | socket: Socket; 30 | } 31 | 32 | export type LayerInfo = Omit & { 33 | type: "canvas" | "threejs"; // | 'canvas2d' | 'webgl' | 'webgl2' 34 | id: string; 35 | weight?: number; 36 | active?: boolean; 37 | setup?: string; 38 | animation?: string; 39 | }; 40 | 41 | export interface DisplayServerInfo { 42 | host: string; 43 | port: number; 44 | } 45 | 46 | export interface AppState { 47 | id: string; 48 | bpm: { 49 | count: number; 50 | start: number; 51 | }; 52 | worker: { 53 | setup: string; 54 | animation: string; 55 | }; 56 | server: DisplayServerInfo; 57 | displays: DisplayBase[]; 58 | layers: LayerInfo[]; 59 | stage: StageInfo; 60 | } 61 | 62 | export interface FihaRC { 63 | id: string; 64 | // bpm?: number; 65 | layers: LayerInfo[]; 66 | assets?: Array<{ name: string }>; 67 | } 68 | 69 | export type ScriptableAPIReference = Record< 70 | string, 71 | { 72 | category?: string; 73 | type?: string; 74 | description?: string; 75 | snippet?: string; 76 | link?: string; 77 | } 78 | >; 79 | 80 | export interface ScriptingData { 81 | iterationCount: number; 82 | now: number; 83 | deltaNow: number; 84 | frequency: number[]; 85 | volume: number[]; 86 | [k: string]: any; 87 | } 88 | 89 | export type ScriptRole = "setup" | "animation"; 90 | export type ScriptType = "layer" | "signal" | "worker" | "server"; 91 | export interface ScriptInfo { 92 | relativePath: string; 93 | path: string; 94 | id: string; 95 | type: ScriptType; 96 | role: ScriptRole; 97 | } 98 | export enum DirectoryTypes { 99 | layers = "layer", 100 | signals = "signal", 101 | worker = "worker", 102 | } 103 | export enum TypeDirectory { 104 | layer = "layers", 105 | signal = "signals", 106 | worker = "worker", 107 | } 108 | 109 | export type VFCommand = ( 110 | context: vscode.ExtensionContext, 111 | extension: VFExtension, 112 | ) => (...args: any[]) => any; 113 | 114 | export type ReadFunction = (key: string, defaultVal?: any) => any; 115 | -------------------------------------------------------------------------------- /src/utils/ScriptRunner.dom.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import ScriptRunner from "./ScriptRunner"; 3 | 4 | const api = { 5 | val1: 1, 6 | val2: 2, 7 | fnA: () => {}, 8 | }; 9 | 10 | const scope = { stuff: true }; 11 | 12 | const syncCode = "return val1 + val2;"; 13 | 14 | const asyncCode = ` 15 | const a = await (new Promise((res) => { 16 | res(val1); 17 | })); 18 | const b = await (new Promise((res) => { 19 | res(val2); 20 | })); 21 | return a + b; 22 | `; 23 | 24 | const compilationBrokenCode = ` 25 | breaks % line 6; 26 | `; 27 | 28 | const executionBrokenCode = ` 29 | a.substr(0, 1); 30 | `; 31 | 32 | describe("object ScriptRunner", () => { 33 | describe("compilation", () => { 34 | describe("with sync code", () => { 35 | let runner: ScriptRunner; 36 | 37 | it("instanciates", () => { 38 | expect(() => { 39 | runner = new ScriptRunner(); 40 | runner.code = syncCode; 41 | }).not.toThrow(); 42 | expect(runner).toBeInstanceOf(ScriptRunner); 43 | expect(runner.isAsync).toBe(false); 44 | }); 45 | }); 46 | 47 | describe("with async code", () => { 48 | let runner: ScriptRunner; 49 | 50 | it("instanciates", () => { 51 | expect(() => { 52 | runner = new ScriptRunner(scope); 53 | runner.api = api; 54 | runner.code = asyncCode; 55 | }).not.toThrow(); 56 | 57 | expect(runner.isAsync).toBe(true); 58 | 59 | expect(runner.version).toBe(2); 60 | 61 | expect(runner).toBeInstanceOf(ScriptRunner); 62 | 63 | expect(runner.isAsync).toBe(true); 64 | }); 65 | 66 | it("returns a promise when executed", async () => { 67 | const promise = runner.exec(); 68 | expect(promise).toBeInstanceOf(Promise); 69 | await expect(promise).resolves.toBeTruthy(); 70 | }); 71 | 72 | it("executes", async () => { 73 | const result = await runner.exec(); 74 | expect(result).toBe(3); 75 | }); 76 | }); 77 | }); 78 | 79 | describe("execution", () => { 80 | describe("with sync code", () => { 81 | it("does not return a promise when executed", async () => { 82 | const runner = new ScriptRunner(scope); 83 | runner.code = syncCode; 84 | runner.api = api; 85 | 86 | let result: any; 87 | await expect( 88 | (async () => { 89 | result = await runner.exec(); 90 | })(), 91 | ).resolves.toBeUndefined(); 92 | expect(result).not.toBeInstanceOf(Promise); 93 | 94 | expect(result).toBe(3); 95 | }); 96 | }); 97 | 98 | describe("with async code", () => { 99 | it("does not return a promise when executed", async () => { 100 | const runner = new ScriptRunner(scope); 101 | runner.code = asyncCode; 102 | runner.api = api; 103 | const promise = runner.exec(); 104 | 105 | expect(promise).toBeInstanceOf(Promise); 106 | 107 | expect(await promise).toBe(3); 108 | }); 109 | }); 110 | 111 | describe("scope", () => { 112 | it.each(["global", "parent", "window", "document", "self", "globalThis"])( 113 | "prevents access to %s", 114 | async (name) => { 115 | const runner = new ScriptRunner(scope); 116 | runner.code = "return ${name}"; 117 | console.info("console", runner.code, scope, runner); 118 | let result: any; 119 | await expect( 120 | (async () => { 121 | result = await runner.exec(); 122 | })(), 123 | ).resolves.toBeUndefined(); 124 | 125 | expect(result).toBeUndefined(); 126 | }, 127 | ); 128 | 129 | it("can be set", async () => { 130 | const runner = new ScriptRunner(scope); 131 | runner.code = ` 132 | return this.stuff 133 | `; 134 | 135 | let result: any; 136 | await expect( 137 | (async () => { 138 | result = await runner.exec(); 139 | })(), 140 | ).resolves.toBeUndefined(); 141 | 142 | expect(result).toBe(true); 143 | }); 144 | }); 145 | 146 | describe("scriptLog()", () => { 147 | it("logs", async () => { 148 | const runner = new ScriptRunner(scope); 149 | const compilationErrorListener = vi.fn(); 150 | const executionErrorListener = vi.fn(); 151 | const logListener = vi.fn(); 152 | runner.addEventListener("compilationerror", compilationErrorListener); 153 | runner.addEventListener("executionerror", executionErrorListener); 154 | runner.addEventListener("log", logListener); 155 | runner.api = api; 156 | runner.code = ` 157 | scriptLog("script logged", this, typeof self, typeof window, val1, val2); 158 | return this.stuff 159 | `; 160 | 161 | let result: any; 162 | await expect( 163 | (async () => { 164 | result = await runner.exec(); 165 | })(), 166 | ).resolves.toBeUndefined(); 167 | 168 | expect(compilationErrorListener).not.toHaveBeenCalled(); 169 | expect(executionErrorListener).not.toHaveBeenCalled(); 170 | expect(logListener).toHaveBeenCalledTimes(1); 171 | 172 | expect(runner.log).toHaveLength(1); 173 | expect(runner.log[0]).toHaveLength(6); 174 | expect(runner.log[0][0]).toBe("script logged"); 175 | expect(runner.log[0][1]).toStrictEqual(scope); 176 | // we use typeof in the script, so we need to assert on the string "undefined" 177 | expect(runner.log[0][2]).toBe("undefined"); 178 | expect(runner.log[0][3]).toBe("undefined"); 179 | expect(runner.log[0][4]).toBe(api.val1); 180 | expect(runner.log[0][5]).toBe(api.val2); 181 | 182 | expect(result).toBe(true); 183 | }); 184 | }); 185 | }); 186 | 187 | describe("events", () => { 188 | describe('"compilationerror" type', () => { 189 | it("is triggered when code cannot be compiled", () => { 190 | const listener = vi.fn(); 191 | 192 | const runner = new ScriptRunner(scope); 193 | runner.addEventListener("compilationerror", listener); 194 | runner.code = compilationBrokenCode; 195 | 196 | expect(listener).toHaveBeenCalledTimes(1); 197 | }); 198 | }); 199 | 200 | describe('"executionerror" type', () => { 201 | it("is triggered when code execution fails", async () => { 202 | const compilationErrorListener = vi.fn(); 203 | const executionErrorListener = vi.fn(); 204 | const runner = new ScriptRunner(scope); 205 | 206 | runner.addEventListener("compilationerror", compilationErrorListener); 207 | runner.addEventListener("executionerror", executionErrorListener); 208 | 209 | runner.code = executionBrokenCode; 210 | 211 | try { 212 | await runner.exec(); 213 | } catch (e) { 214 | /* */ 215 | } 216 | 217 | expect(executionErrorListener).toHaveBeenCalledTimes(1); 218 | expect(compilationErrorListener).toHaveBeenCalledTimes(0); 219 | }); 220 | }); 221 | 222 | describe('"log" type', () => { 223 | it("is triggered after execution of code", async () => { 224 | const listener = vi.fn(); 225 | 226 | const runner = new ScriptRunner(scope); 227 | runner.code = 'scriptLog("log");'; 228 | runner.addEventListener("log", listener); 229 | 230 | try { 231 | await runner.exec(); 232 | } catch (e) { 233 | /* */ 234 | } 235 | 236 | expect(listener).toHaveBeenCalledTimes(1); 237 | }); 238 | 239 | it("is triggered only if log() has been called within the code", async () => { 240 | const listener = vi.fn(); 241 | 242 | const runner = new ScriptRunner(scope); 243 | runner.addEventListener("log", listener); 244 | 245 | try { 246 | await runner.exec(); 247 | } catch (e) { 248 | /* */ 249 | } 250 | 251 | expect(listener).toHaveBeenCalledTimes(0); 252 | }); 253 | }); 254 | 255 | describe(".removeEventListener()", () => { 256 | it("can be used to remove event listeners", async () => { 257 | const listener = vi.fn(); 258 | 259 | const runner = new ScriptRunner(scope); 260 | runner.code = 'scriptLog("log");'; 261 | runner.addEventListener("log", listener); 262 | 263 | try { 264 | await runner.exec(); 265 | } catch (e) { 266 | /* */ 267 | } 268 | 269 | runner.removeEventListener("log", listener); 270 | 271 | try { 272 | await runner.exec(); 273 | } catch (e) { 274 | /* */ 275 | } 276 | 277 | expect(listener).toHaveBeenCalledTimes(1); 278 | }); 279 | }); 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /src/utils/ScriptRunner.ts: -------------------------------------------------------------------------------- 1 | import { camelCase } from "lodash"; 2 | 3 | const asyncNoop = async () => {}; 4 | 5 | export type ScriptLog = (...args: any[]) => void; 6 | 7 | export type ScriptRunnerEventTypes = 8 | | "compilationerror" 9 | | "executionerror" 10 | | "log"; 11 | 12 | export interface ScriptRunnerCodeError extends Error { 13 | lineNumber?: number; 14 | columnNumber?: number; 15 | details?: object[]; 16 | } 17 | 18 | export interface ScriptRunnerEvent { 19 | defaultPrevented?: boolean; 20 | readonly type: ScriptRunnerEventTypes; 21 | } 22 | 23 | export interface ScriptRunnerErrorEvent extends ScriptRunnerEvent { 24 | error: ScriptRunnerCodeError | ScriptRunnerLintingError; 25 | readonly type: "compilationerror" | "executionerror"; 26 | builderStr?: string; 27 | code?: string; 28 | lineNumber?: number; 29 | columnNumber?: number; 30 | } 31 | 32 | export interface ScriptRunnerLogEvent extends ScriptRunnerEvent { 33 | data: any; 34 | readonly type: "log"; 35 | } 36 | 37 | export type ScriptRunnerEventListener = ( 38 | event: ScriptRunnerErrorEvent | ScriptRunnerLogEvent, 39 | ) => boolean | undefined; 40 | 41 | export type API = Record; 42 | 43 | export const removeExportCrutch = (str: string) => 44 | str.replace(/export\s+{\s?};?/g, ""); 45 | 46 | class EmptyScope {} 47 | 48 | const forbidden = new Set([ 49 | ...Object.keys(typeof window !== "undefined" ? window : {}), 50 | ...Object.keys(typeof global !== "undefined" ? global : {}), 51 | ...Object.keys(typeof self !== "undefined" ? self : {}), 52 | ...Object.keys(typeof globalThis !== "undefined" ? globalThis : {}), 53 | ]); 54 | 55 | class ScriptRunnerLintingError extends Error { 56 | constructor(details: object[]) { 57 | super("ScriptRunnerLintingError"); 58 | this.details = details; 59 | } 60 | 61 | details: object[] = []; 62 | } 63 | 64 | class ScriptRunner { 65 | constructor(scope: any = null, name = `sr${Date.now()}`) { 66 | this.#scope = scope; 67 | this.#name = camelCase(name); 68 | } 69 | 70 | #name: string; 71 | 72 | #listeners: Record = {}; 73 | 74 | #errors: { 75 | compilation?: Error | null; 76 | execution?: Error | null; 77 | } = { 78 | compilation: null, 79 | execution: null, 80 | }; 81 | 82 | #version = 0; 83 | 84 | #code = ""; 85 | 86 | #scope: any = new EmptyScope(); 87 | 88 | // biome-ignore lint/complexity/noBannedTypes: 89 | #fn: Function = asyncNoop; 90 | 91 | #logs: any[] = []; 92 | 93 | #log = (...whtvr: any[]) => { 94 | this.#logs.push(whtvr); 95 | }; 96 | 97 | #api: API = {}; 98 | 99 | get version() { 100 | return this.#version; 101 | } 102 | 103 | get scope() { 104 | return this.#scope; 105 | } 106 | 107 | set scope(newScope: any) { 108 | this.#scope = newScope; 109 | } 110 | 111 | get api(): API & { scriptLog: (...args: any[]) => void } { 112 | return { 113 | ...this.#api, 114 | scriptLog: this.#log, 115 | }; 116 | } 117 | 118 | set api({ scriptLog, ...api }: API) { 119 | this.#api = api; 120 | this.code = this.#code; 121 | } 122 | 123 | get log() { 124 | return this.#logs; 125 | } 126 | 127 | get isAsync() { 128 | return this.#code.includes("await"); 129 | } 130 | 131 | get code() { 132 | return this.#code; 133 | } 134 | 135 | set code(code: string) { 136 | this.#errors.compilation = null; 137 | this.#errors.execution = null; 138 | 139 | const paramNames = Object.keys(this.api); 140 | const paramsStr = paramNames.join(", "); 141 | 142 | const forbiddenStr = Array.from(forbidden) 143 | .filter((value) => !paramNames.includes(value)) 144 | .join(", "); 145 | 146 | const sync = code.includes("await") ? "async" : ""; 147 | 148 | const builderStr = ` 149 | return ${sync} function ${this.#name}_${this.#version + 1}(${paramsStr}) { 150 | ${forbiddenStr ? `let ${forbiddenStr};` : ""} 151 | ${removeExportCrutch(code || "// empty")} 152 | };`.trim(); 153 | 154 | try { 155 | const builder = new Function(builderStr); 156 | 157 | const fn = builder(); 158 | if (fn.toString() !== this.#fn.toString()) { 159 | this.#fn = fn; 160 | this.#code = code; 161 | this.#version += 1; 162 | } 163 | } catch (error) { 164 | const err = error as ScriptRunnerCodeError; 165 | this.#errors.compilation = err; 166 | this.dispatchEvent({ 167 | type: "compilationerror", 168 | error: err, 169 | lineNumber: err.lineNumber ?? 0, 170 | columnNumber: err.columnNumber ?? 0, 171 | code, 172 | builderStr, 173 | } satisfies ScriptRunnerErrorEvent); 174 | } 175 | } 176 | 177 | addEventListener( 178 | type: ScriptRunnerEventTypes, 179 | callback: ScriptRunnerEventListener, 180 | ) { 181 | /* istanbul ignore next */ 182 | if (!this.#listeners[type]) this.#listeners[type] = []; 183 | 184 | this.#listeners[type].push(callback); 185 | } 186 | 187 | removeEventListener( 188 | type: ScriptRunnerEventTypes, 189 | callback: ScriptRunnerEventListener, 190 | ) { 191 | /* istanbul ignore next */ 192 | if (!this.#listeners[type]) return; 193 | 194 | const stack = this.#listeners[type]; 195 | for (let i = 0, l = stack.length; i < l; i += 1) { 196 | if (stack[i] === callback) { 197 | stack.splice(i, 1); 198 | return; 199 | } 200 | } 201 | } 202 | 203 | dispatchEvent(event: ScriptRunnerErrorEvent | ScriptRunnerLogEvent) { 204 | if (!this.#listeners[event.type]) return undefined; 205 | 206 | const stack = this.#listeners[event.type].slice(); 207 | 208 | for (let i = 0, l = stack.length; i < l; i += 1) { 209 | stack[i].call(this, event); 210 | } 211 | 212 | return !event.defaultPrevented; 213 | } 214 | 215 | exec() { 216 | this.#logs = []; 217 | try { 218 | this.#errors.execution = null; 219 | 220 | const args = Object.values(this.api); 221 | const result = this.#fn.call(this.#scope, ...args); 222 | if (this.#logs.length > 0) { 223 | this.dispatchEvent({ 224 | type: "log", 225 | data: this.#logs, 226 | } satisfies ScriptRunnerLogEvent); 227 | } 228 | 229 | /* istanbul ignore next */ 230 | return result instanceof EmptyScope ? undefined : result; 231 | } catch (error) { 232 | const err = error as ScriptRunnerCodeError; 233 | this.#errors.execution = err; 234 | this.dispatchEvent({ 235 | type: "executionerror", 236 | error: err, 237 | } satisfies ScriptRunnerErrorEvent); 238 | // console.info('[%s] execution error', this.#name, error.message || error.stack); 239 | return undefined; 240 | } 241 | } 242 | } 243 | 244 | export default ScriptRunner; 245 | -------------------------------------------------------------------------------- /src/utils/Scriptable.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type, no-debugger */ 2 | import ScriptRunner, { 3 | type ScriptRunnerEventListener, 4 | type API, 5 | } from "./ScriptRunner"; 6 | 7 | export type Cache = Record; 8 | export type ReadInterface = (name: string, defaultValue?: any) => any; 9 | 10 | export type WriteInterface = (data: Record) => void; 11 | 12 | export interface ScriptableOptions { 13 | onCompilationError?: ScriptRunnerEventListener; 14 | onExecutionError?: ScriptRunnerEventListener; 15 | animation?: string; 16 | setup?: string; 17 | read?: ReadInterface; 18 | api?: API; 19 | scope?: any; 20 | id: string; 21 | } 22 | 23 | export default class Scriptable { 24 | constructor(options: ScriptableOptions = { id: `scriptable${Date.now()}` }) { 25 | this.read = typeof options.read === "function" ? options.read : this.read; 26 | this.#id = options.id; 27 | this.#runners = { 28 | setup: new ScriptRunner(options.scope, `${this.#id}_S`), 29 | animation: new ScriptRunner(options.scope, `${this.#id}_A`), 30 | }; 31 | this.api = { 32 | debug: this.#debug, 33 | read: this.read, 34 | cache: this.cache, 35 | 36 | ...(options.api || {}), 37 | }; 38 | this.initialize(options); 39 | } 40 | 41 | #id: string; 42 | 43 | #runners: { 44 | setup: ScriptRunner; 45 | animation: ScriptRunner; 46 | }; 47 | 48 | // TODO: make it private? 49 | cache: Cache = {}; 50 | 51 | read: ReadInterface = (key, fb) => { 52 | /* Scriptable read */ 53 | console.info("[Scriptable] read", key, fb, this.cache[key], this.cache); 54 | return typeof this.cache[key] === "undefined" ? fb : this.cache[key]; 55 | }; 56 | 57 | #debug = (...args: any[]) => { 58 | console.info("[script] debug %s", this.#id, ...args); 59 | }; 60 | 61 | get id() { 62 | return this.#id; 63 | } 64 | 65 | get api(): API & { cache: Cache } { 66 | return { 67 | debug: this.#debug, 68 | read: this.read, 69 | cache: this.cache, 70 | ...this.#runners.animation.api, 71 | }; 72 | } 73 | 74 | set api(api: API) { 75 | this.#runners.setup.api = api; 76 | this.#runners.animation.api = api; 77 | } 78 | 79 | get setup() { 80 | return this.#runners.setup; 81 | } 82 | 83 | set setup(sr: ScriptRunner) { 84 | this.#runners.setup = sr; 85 | } 86 | 87 | get animation() { 88 | return this.#runners.animation; 89 | } 90 | 91 | set animation(sr: ScriptRunner) { 92 | this.#runners.animation = sr; 93 | } 94 | 95 | initialize = ({ 96 | setup, 97 | animation, 98 | onCompilationError, 99 | onExecutionError, 100 | }: ScriptableOptions) => { 101 | // console.info( 102 | // "[Scriptable] initialize", 103 | // onCompilationError, 104 | // onExecutionError 105 | // ); 106 | if (onCompilationError != null) { 107 | this.setup.addEventListener("compilationerror", onCompilationError); 108 | this.animation.addEventListener("compilationerror", onCompilationError); 109 | } 110 | if (onExecutionError != null) { 111 | this.setup.addEventListener("executionerror", onExecutionError); 112 | this.animation.addEventListener("executionerror", onExecutionError); 113 | } 114 | if (this.setup.code !== setup && setup != null) this.setup.code = setup; 115 | if (this.animation.code !== animation && animation) 116 | this.animation.code = animation; 117 | }; 118 | 119 | execSetup = async () => { 120 | const result = await this.setup.exec(); 121 | Object.assign(this.cache, result || {}); 122 | return result; 123 | }; 124 | 125 | execAnimation = () => this.animation.exec(); 126 | } 127 | -------------------------------------------------------------------------------- /src/utils/VFS.ts: -------------------------------------------------------------------------------- 1 | async function writeFile(vfsPath: string, content: string) { 2 | // 3 | } 4 | 5 | export default class VFS { 6 | #tmppath = ""; 7 | 8 | #files: Record; 9 | 10 | add(vfsPath: string, content: string) { 11 | this.#files[vfsPath] = this.#files[vfsPath] 12 | ? `${this.#files[vfsPath]}${content}` 13 | : content; 14 | } 15 | 16 | async write() { 17 | return await Promise.all( 18 | Object.keys(this.#files).reduce((promises: Array>, key) => { 19 | promises.push(writeFile(key, this.#files[key])); 20 | return promises; 21 | }, []), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/assetTools.ts: -------------------------------------------------------------------------------- 1 | // TODO: explore WebStream usage 2 | // https://developer.mozilla.org/en-US/docs/Web/API/Streams_API 3 | // https://nodejs.org/en/blog/release/v16.5.0/ 4 | 5 | let fetchedURLBlobs: any = {}; 6 | 7 | async function fetchBlob(url: string, force?: boolean): Promise { 8 | fetchedURLBlobs[url] = force 9 | ? fetch(url).then(async (r) => await r.blob()) 10 | : fetchedURLBlobs[url] || fetch(url).then(async (r) => await r.blob()); 11 | return fetchedURLBlobs[url]; 12 | } 13 | 14 | export function clearFetchedAssets(url?: string) { 15 | if (!url) { 16 | fetchedURLBlobs = {}; 17 | } else { 18 | delete fetchedURLBlobs[url]; 19 | } 20 | } 21 | 22 | export async function loadImage(url: string) { 23 | const blob = await fetchBlob(url); 24 | const img = await createImageBitmap(blob); 25 | return img; 26 | } 27 | 28 | export async function loadVideo(url: string) { 29 | const blob = await fetchBlob(url); 30 | const img = await createImageBitmap(blob); 31 | return img; 32 | } 33 | 34 | export async function asset(url: string) { 35 | const blob = await fetchBlob(url); 36 | if (blob.type.startsWith("image/")) { 37 | return await loadImage(url); 38 | } 39 | if (blob.type.startsWith("video/")) { 40 | return await loadVideo(url); 41 | } 42 | return blob; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/blob2DataURI.ts: -------------------------------------------------------------------------------- 1 | export default async function blob2DataURI(blob: Blob) { 2 | const fileReader = new FileReader(); 3 | return await new Promise((resolve, reject) => { 4 | fileReader.onerror = () => { 5 | reject(new Error("FileReader error")); 6 | }; 7 | fileReader.onload = (evt: ProgressEvent) => { 8 | resolve(evt.target?.result); 9 | }; 10 | fileReader.readAsDataURL(blob); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/blobURI2DataURI.ts: -------------------------------------------------------------------------------- 1 | import blob2DataURI from "./blob2DataURI"; 2 | 3 | export default async function blobURI2DataURI(blobURI: string) { 4 | return await fetch(blobURI) 5 | .then(async (res) => await res.blob()) 6 | .then(async (blob) => await blob2DataURI(blob)); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/com.dom.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import * as com from "./com"; 3 | 4 | // const expectedError = new Error('Expected'); 5 | 6 | const handlers = { 7 | actionA: vi.fn(() => {}), 8 | actionB: vi.fn(async () => "B"), 9 | actionC: vi.fn(async () => { 10 | throw new Error("Expected"); 11 | }), 12 | }; 13 | 14 | describe("com.messenger", () => { 15 | it("properly use the posting function", () => { 16 | const post = vi.fn(); 17 | const messenger = com.makeChannelPost(post, "side-1"); 18 | void messenger("actionA"); 19 | 20 | expect(post).toHaveBeenCalledTimes(1); 21 | 22 | const [args] = post.mock.calls; 23 | expect(args).toHaveLength(1); 24 | expect(args[0]).toHaveProperty("type", "actionA"); 25 | expect(args[0]).toHaveProperty("payload"); 26 | expect(args[0]).toHaveProperty("meta.source", "side-1"); 27 | expect(args[0]).toHaveProperty("meta.sent"); 28 | expect(args[0]).not.toHaveProperty("meta.operationId"); 29 | }); 30 | 31 | it("can use async", async () => { 32 | const post = vi.fn(); 33 | const messenger = com.makeChannelPost(post, "side-1"); 34 | 35 | const promise = messenger("actionB", {}, true); 36 | expect(post).toHaveBeenCalledTimes(1); 37 | expect(promise).toHaveProperty("then"); 38 | 39 | const [args] = post.mock.calls; 40 | 41 | expect(args).toHaveLength(1); 42 | expect(args[0]).toHaveProperty("type", "actionB"); 43 | expect(args[0]).toHaveProperty("payload"); 44 | expect(args[0]).toHaveProperty("meta.source", "side-1"); 45 | expect(args[0]).toHaveProperty("meta.sent"); 46 | expect(args[0]).toHaveProperty("meta.operationId"); 47 | 48 | const postBack = vi.fn(); 49 | const listener2 = com.makeChannelListener(postBack, handlers); 50 | 51 | const listener1 = com.makeChannelListener(post, {}); 52 | 53 | listener2({ 54 | data: { ...args[0] }, 55 | } as MessageEvent); 56 | 57 | await new Promise((res) => setTimeout(res, 1)); 58 | expect(postBack).toHaveBeenCalledTimes(1); 59 | 60 | listener1({ 61 | data: postBack.mock.calls[0][0], 62 | } as MessageEvent); 63 | 64 | await new Promise((res) => setTimeout(res, 1)); 65 | await expect(promise).resolves.toBe("B"); 66 | }); 67 | 68 | it("handles async error", async () => { 69 | const post = vi.fn(); 70 | const messenger = com.makeChannelPost(post, "side-1"); 71 | 72 | const listener1 = com.makeChannelListener(post, {}); 73 | 74 | const postBack = vi.fn(); 75 | const listener2 = com.makeChannelListener(postBack, handlers); 76 | const promise: Promise = messenger("actionC", {}, true); 77 | 78 | const [args] = post.mock.calls; 79 | 80 | listener2({ 81 | data: { ...args[0] }, 82 | } as MessageEvent); 83 | 84 | expect(post).toHaveBeenCalledTimes(1); 85 | expect(promise).toHaveProperty("then"); 86 | 87 | await new Promise((res) => setTimeout(res, 1)); 88 | expect(postBack).toHaveBeenCalledTimes(1); 89 | 90 | expect(postBack.mock.calls[0][0]).toHaveProperty("meta.error", "Expected"); 91 | const postBackEvent = { 92 | data: postBack.mock.calls[0][0], 93 | } as MessageEvent; 94 | 95 | listener1(postBackEvent); 96 | 97 | await expect(promise).rejects.toThrow("Expected"); 98 | }); 99 | }); 100 | 101 | describe("com.autoBind", () => { 102 | it("creates the post and listener", async () => { 103 | let post: com.ChannelPost; 104 | let listener: com.ComMessageEventListener; 105 | const obj = { 106 | postMessage: vi.fn(), 107 | }; 108 | 109 | expect(() => { 110 | const api = com.autoBind(obj, "some-name", handlers); 111 | post = api.post; 112 | listener = api.listener; 113 | 114 | expect(typeof post).toBe("function"); 115 | expect(typeof listener).toBe("function"); 116 | 117 | void post("actionA"); 118 | }).not.toThrow(); 119 | 120 | expect(obj.postMessage).toHaveBeenCalledTimes(1); 121 | 122 | // const postPromise = post('actionB', null, true); 123 | // const [[response]] = obj.postMessage.mock.calls; 124 | // listener({ 125 | // data: { 126 | // ...response, 127 | // }, 128 | // } as MessageEvent); 129 | // await expect(postPromise).resolves.toBe('B'); 130 | 131 | // expect(obj.postMessage).toHaveBeenCalledTimes(2); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/utils/com.ts: -------------------------------------------------------------------------------- 1 | export interface ComEventDataMeta { 2 | [custom: string]: any; 3 | operationId?: string; 4 | sent?: number; 5 | received?: number; 6 | processed?: number; 7 | answered?: number; 8 | source?: string; 9 | error?: string; 10 | } 11 | export interface ComEventData { 12 | type: string; 13 | payload?: any; 14 | meta?: ComEventDataMeta; 15 | } 16 | 17 | export type ComActionHandler = ( 18 | payload?: any, 19 | meta?: ComEventDataMeta, 20 | ) => Promise | any; 21 | 22 | export type ComActionHandlers = Record; 23 | 24 | export type ComAnswerer = (type: ComEventData) => any; 25 | 26 | const promises: Record void> = {}; 27 | 28 | export type Poster = (message: ComEventData) => any; 29 | 30 | export type ChannelPost = ( 31 | type: string, 32 | payload?: any, 33 | originalMeta?: ComEventDataMeta | true, 34 | ) => Promise; 35 | 36 | export type ChannelPostMaker = (poster: Poster, source: string) => ChannelPost; 37 | 38 | export interface ComMessageEvent { 39 | data: ComEventData; 40 | } 41 | 42 | export type ComMessageEventListener = (event: ComMessageEvent) => void; 43 | 44 | export type ChannelListenerMaker = ( 45 | postBack: ComAnswerer, 46 | handlers: ComActionHandlers, 47 | ) => ComMessageEventListener; 48 | 49 | export const makeChannelPost: ChannelPostMaker = 50 | (poster, source) => async (type, payload, originalMeta) => { 51 | const addiontions = 52 | originalMeta === true 53 | ? { 54 | operationId: `${Date.now()}-${source}-${( 55 | Math.random() * 1000000 56 | ).toFixed()}`, 57 | } 58 | : originalMeta != null 59 | ? originalMeta 60 | : {}; 61 | const meta: ComEventDataMeta = { 62 | ...addiontions, 63 | sent: Date.now(), 64 | source, 65 | }; 66 | 67 | if (meta.operationId) { 68 | const { operationId } = meta; 69 | const promise = new Promise((res, rej) => { 70 | if (promises[operationId]) throw new Error("Promise already exists"); 71 | promises[operationId] = (err, result) => { 72 | if (err) { 73 | rej(err); 74 | return; 75 | } 76 | res(result); 77 | }; 78 | poster({ type, payload, meta }); 79 | }); 80 | return await promise; 81 | } 82 | 83 | poster({ 84 | type, 85 | payload, 86 | meta, 87 | }); 88 | await Promise.resolve(); 89 | }; 90 | 91 | const handleComReply = (payload: any, meta: ComEventDataMeta) => { 92 | if (!meta.operationId) { 93 | return; 94 | } 95 | 96 | if (typeof promises[meta.operationId] !== "function") { 97 | throw new Error("No promise found"); 98 | } 99 | 100 | const cb = promises[meta.operationId]; 101 | if (meta.error) { 102 | cb(new Error(meta.error)); 103 | } else { 104 | cb(null, payload); 105 | } 106 | }; 107 | 108 | const replyError = ( 109 | postBack: ComAnswerer, 110 | err: Error | string, 111 | type: string, 112 | meta: ComEventDataMeta, 113 | ) => { 114 | const error = 115 | typeof err === "string" ? err : err.message || "Unexpected error"; 116 | postBack({ 117 | type: "com/reply", 118 | meta: { 119 | ...meta, 120 | originalType: type, 121 | processed: Date.now(), 122 | error, 123 | }, 124 | }); 125 | }; 126 | 127 | export const makeChannelListener: ChannelListenerMaker = 128 | (postBack, handlers) => (event) => { 129 | const { type, payload, meta: originalMeta } = event.data; 130 | const meta: ComEventDataMeta = { 131 | ...originalMeta, 132 | received: Date.now(), 133 | }; 134 | 135 | if (type === "com/reply" && meta.operationId) { 136 | handleComReply(payload, meta); 137 | return; 138 | } 139 | 140 | const { [event.data.type]: handler } = handlers; 141 | if (!handler) { 142 | const err = `Unexepected ${type} action type`; 143 | 144 | if (!meta.operationId) return; 145 | 146 | replyError(postBack, err, type, meta); 147 | return; 148 | } 149 | 150 | if (!meta.operationId) { 151 | handler(payload, meta); 152 | return; 153 | } 154 | 155 | handler(payload, meta) 156 | .then((result: any) => 157 | postBack({ 158 | type: "com/reply", 159 | payload: result, 160 | meta: { 161 | ...meta, 162 | originalType: type, 163 | processed: Date.now(), 164 | }, 165 | }), 166 | ) 167 | .catch((err: Error) => { 168 | replyError(postBack, err, type, meta); 169 | }); 170 | }; 171 | 172 | export interface ComMessageChannel { 173 | postMessage: Poster; 174 | addEventListener?: ( 175 | eventName: string, 176 | listener: (event: any) => void, 177 | ) => void; 178 | } 179 | 180 | export type ComChannel = ComMessageChannel; 181 | 182 | export interface ChannelBindings { 183 | post: ChannelPost; 184 | listener: (event: ComMessageEvent) => void; 185 | } 186 | 187 | export const autoBind = ( 188 | obj: ComChannel, 189 | source: string, 190 | handlers: ComActionHandlers, 191 | ) => { 192 | const originalPost = obj.postMessage.bind(obj); 193 | return { 194 | post: makeChannelPost(originalPost, source), 195 | listener: makeChannelListener(originalPost, handlers), 196 | }; 197 | }; 198 | -------------------------------------------------------------------------------- /src/utils/dataURI2Blob.ts: -------------------------------------------------------------------------------- 1 | // borrowed from https://stackoverflow.com/questions/12168909/blob-from-dataurl 2 | export default function dataURI2Blob(dataURI, mimeType) { 3 | // const binary = atob(dataURI.split(',')[1]); 4 | // const array = []; 5 | // for (let i = 0; i < binary.length; i += 1) array.push(binary.charCodeAt(i)); 6 | // return new Blob([new Uint8Array(array)], { type: mimeType }); 7 | 8 | // convert base64 to raw binary data held in a string 9 | // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this 10 | const byteString = atob(dataURI.split(",")[1]); 11 | 12 | // separate out the mime component 13 | const mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0]; 14 | 15 | // write the bytes of the string to an ArrayBuffer 16 | const ab = new ArrayBuffer(byteString.length); 17 | 18 | // create a view into the buffer 19 | const ia = new Uint8Array(ab); 20 | 21 | // set the bytes of the buffer to the correct values 22 | for (let i = 0; i < byteString.length; i += 1) { 23 | ia[i] = byteString.charCodeAt(i); 24 | } 25 | 26 | // write the ArrayBuffer to a blob, and you're done 27 | const blob = new Blob([ab], { type: mimeType || mimeString }); 28 | return blob; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/deprecate.ts: -------------------------------------------------------------------------------- 1 | const warned: string[] = []; 2 | export default function deprecate(fn: any, message: string) { 3 | const { name, displayName } = fn; 4 | return (...args: any[]) => { 5 | const cached = name || displayName || "anonymous"; 6 | if (!warned.includes(cached)) { 7 | warned.push(cached); 8 | console.warn("[deprecated] %s: %s", cached, message); 9 | } 10 | return fn.apply(null, ...args); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/mathTools.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-properties */ 2 | /* eslint-disable @typescript-eslint/naming-convention */ 3 | interface Box { 4 | width: number; 5 | height: number; 6 | } 7 | 8 | const { 9 | abs, 10 | acos, 11 | acosh, 12 | asin, 13 | asinh, 14 | atan, 15 | atanh, 16 | atan2, 17 | ceil, 18 | cbrt, 19 | expm1, 20 | clz32, 21 | cos, 22 | cosh, 23 | exp, 24 | floor, 25 | fround, 26 | hypot, 27 | imul, 28 | log, 29 | log1p, 30 | log2, 31 | log10, 32 | max, 33 | min, 34 | pow, 35 | random, 36 | round, 37 | sign, 38 | sin, 39 | sinh, 40 | sqrt, 41 | tan, 42 | tanh, 43 | trunc, 44 | E, 45 | LN10, 46 | LN2, 47 | LOG10E, 48 | LOG2E, 49 | PI, 50 | SQRT1_2, 51 | SQRT2, 52 | } = Math; 53 | export { 54 | abs, 55 | acos, 56 | acosh, 57 | asin, 58 | asinh, 59 | atan, 60 | atanh, 61 | atan2, 62 | ceil, 63 | cbrt, 64 | expm1, 65 | clz32, 66 | cos, 67 | cosh, 68 | exp, 69 | floor, 70 | fround, 71 | hypot, 72 | imul, 73 | log, 74 | log1p, 75 | log2, 76 | log10, 77 | max, 78 | min, 79 | pow, 80 | random, 81 | round, 82 | sign, 83 | sin, 84 | sinh, 85 | sqrt, 86 | tan, 87 | tanh, 88 | trunc, 89 | E, 90 | LN10, 91 | LN2, 92 | LOG10E, 93 | LOG2E, 94 | PI, 95 | SQRT1_2, 96 | SQRT2, 97 | }; 98 | 99 | export const PI2 = Math.PI * 2; 100 | 101 | export const GR = 1.618033988; 102 | 103 | export const sDiv = (val: number, div: number): number => val * (1 / div); 104 | 105 | export const arrayMax = (arr: number[]) => 106 | arr.reduce((val, prev) => Math.max(val, prev), 0); 107 | 108 | export const arrayMin = (arr: number[]) => 109 | arr.reduce((val, prev) => Math.min(val, prev), Number.POSITIVE_INFINITY); 110 | 111 | export const arraySum = (arr: number[]) => 112 | arr.reduce((val, prev) => val + prev, 0); 113 | 114 | export const arrayDiff = (arr: number[]) => 115 | Math.abs(arrayMax(arr) - arrayMin(arr)); 116 | 117 | export const arrayAvg = (arr: number[]) => sDiv(arraySum(arr), arr.length); 118 | 119 | export const arrayMirror = (arr: number[]) => [...arr, ...arr.reverse()]; 120 | 121 | export const arrayDownsample = (arr: number[], samples = 2) => { 122 | const result: number[] = []; 123 | arr.forEach((item, i) => { 124 | if (i % samples === 0) result.push(item); 125 | }); 126 | return result; 127 | }; 128 | 129 | export const arraySmooth = (arr: number[], factor = 2) => 130 | arr.reduce((acc: number[], val: number, index: number) => { 131 | acc.push(arrayAvg(arr.slice(index, index + factor))); 132 | return acc; 133 | }, []); 134 | 135 | export const deg2rad = (deg: number) => (PI2 / 360) * deg; 136 | 137 | export const rad2deg = (rad: number) => (360 / PI2) * rad; 138 | 139 | export const cap = (val: number, minVal = 0, maxVal = 127) => 140 | Math.min(Math.max(val, minVal), maxVal); 141 | 142 | export const between = (val: number, minVal = 0, maxVal = 127) => 143 | val < maxVal && val > minVal; 144 | 145 | export const beatPrct = (now: number, bpm = 120) => { 146 | const timeBetweenBeats = sDiv(60, bpm) * 1000; 147 | return cap(sDiv(now % timeBetweenBeats, timeBetweenBeats), 0, 1); 148 | }; 149 | 150 | export const beat = (now: number, bpm = 120) => { 151 | console.log("[DERECATED]: beat(), use beatPrct() instead"); 152 | return beatPrct(now, bpm); 153 | }; 154 | 155 | export const orientation = (width: number, height: number) => 156 | width >= height ? "landscape" : "portrait"; 157 | 158 | export const objOrientation = (obj: Box) => orientation(obj.width, obj.height); 159 | 160 | export const containBox = (box1: Box, box2: Box) => { 161 | const { width, height } = box1; 162 | const { width: box2Width, height: box2Height } = box2; 163 | const { width: box1Width, height: box1Height } = box1; 164 | const x = (box2Width / box1Width) * width; 165 | const y = (box2Height / box1Height) * height; 166 | return { width: x, height: y }; 167 | }; 168 | 169 | export const coverBox = (box1: Box, box2: Box) => { 170 | const { width, height } = box1; 171 | const { width: box2Width, height: box2Height } = box2; 172 | const { width: box1Width, height: box1Height } = box1; 173 | const x = (box1Width / box2Width) * width; 174 | const y = (box1Height / box2Height) * height; 175 | return { width: x, height: y }; 176 | }; 177 | -------------------------------------------------------------------------------- /src/utils/miscTools.ts: -------------------------------------------------------------------------------- 1 | import blobURI2DataURI from "./blobURI2DataURI"; 2 | 3 | type ReadInterface = (name: string, defaultValue?: any) => any; 4 | 5 | export const noop = (...args: any[]): any => {}; 6 | 7 | export const rgba = (r = 0.5, g = 0.5, b = 0.5, a = 1) => 8 | `rgba(${(r * 255).toFixed()}, ${(g * 255).toFixed()}, ${( 9 | b * 255 10 | ).toFixed()}, ${a.toFixed(3)})`; 11 | 12 | export const hsla = (h = 0.5, s = 0.5, l = 0.5, a = 1) => 13 | `hsla(${(h * 360).toFixed()}, ${(s * 100).toFixed()}%, ${( 14 | l * 100 15 | ).toFixed()}%, ${a.toFixed(3)})`; 16 | 17 | export const repeat = (times = 1, func = noop) => { 18 | for (let t = 0; t < times; t += 1) { 19 | func(t, times); 20 | } 21 | }; 22 | 23 | export const assetDataURI = async (asset: any) => { 24 | const uri = await blobURI2DataURI(asset.src); 25 | return uri; 26 | }; 27 | 28 | export const isFunction = (what: any) => typeof what === "function"; 29 | 30 | export const toggled: Record = {}; 31 | export const prevToggle: Record = {}; 32 | export const toggle = 33 | (read: ReadInterface, name: string) => (on: any, off: any) => { 34 | const val = read(name); 35 | if (prevToggle[name] !== val && val) toggled[name] = !toggled[name]; 36 | if (toggled[name] && isFunction(on)) on(); 37 | if (!toggled[name] && isFunction(off)) off(); 38 | prevToggle[name] = val; 39 | return toggled[name]; 40 | }; 41 | 42 | export const inOut = 43 | (read: ReadInterface, name: string) => (on: any, off: any) => { 44 | const val = read(name); 45 | if (val && isFunction(on)) on(); 46 | if (!val && isFunction(off)) off(); 47 | return val; 48 | }; 49 | 50 | export const steps: Record = {}; 51 | export const prevStepVals: Record = {}; 52 | export const stepper = (read: ReadInterface, name: string, distance = 1) => { 53 | const val = read(name, 0); 54 | steps[name] = steps[name] || 0; 55 | if (!prevStepVals[name] && val) steps[name] += distance; 56 | prevStepVals[name] = val; 57 | return steps[name]; 58 | }; 59 | 60 | export const merge = (...objs: Array>) => { 61 | const result: Record = {}; 62 | objs.forEach((obj) => { 63 | Object.keys(obj).forEach((key) => { 64 | result[key] = obj[key]; 65 | }); 66 | }); 67 | return result; 68 | }; 69 | 70 | const tools = { 71 | rgba, 72 | hsla, 73 | repeat, 74 | noop, 75 | assetDataURI, 76 | isFunction, 77 | toggle, 78 | inOut, 79 | stepper, 80 | merge, 81 | }; 82 | 83 | // export const apiReference = reference; 84 | 85 | export default tools; 86 | -------------------------------------------------------------------------------- /src/webviews/audioView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import Audio from "./components/Audio"; 4 | import Providers from "./components/Providers"; 5 | 6 | const root = createRoot(document.getElementById("audio-view")!); 7 | root.render( 8 | 9 | , 11 | ); 12 | -------------------------------------------------------------------------------- /src/webviews/components/AppInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import { useVSCOpen } from "../contexts/ComContext"; 4 | import type { WebviewAppState } from "../store"; 5 | 6 | const AppInfo = () => { 7 | const { 8 | id, 9 | stage, 10 | // bpm: { count: bpm }, 11 | // server, 12 | } = useSelector((state: WebviewAppState) => state); 13 | const open = useVSCOpen(); 14 | 15 | // const serverURL = `http://${server.host}:${server.port}`; 16 | 17 | const handleRCOpen = () => { 18 | console.info("open fiha.json", open); 19 | open("fiha.json"); 20 | }; 21 | 22 | return ( 23 |
24 |
25 |

{`# ${id}`}

26 | {/* biome-ignore lint/a11y/useValidAnchor: */} 27 | 28 | {"open "} 29 | fiha.json 30 | 31 |
32 |
33 |
34 | {/*
BPM:
35 |
{bpm}
*/} 36 | 37 |
Stage:
38 |
{`${stage.width}x${stage.height}`}
39 | 40 | {/*
Display:
41 |
{`${serverURL}/display/`}
*/} 42 | 43 | {/*
Capture:
44 |
{`${serverURL}/capture/`}
*/} 45 |
46 |
47 |
48 | ); 49 | }; 50 | 51 | export default AppInfo; 52 | -------------------------------------------------------------------------------- /src/webviews/components/Audio.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import * as React from "react"; 3 | import { useSelector } from "react-redux"; 4 | import { useSetBPM } from "../contexts/ComContext"; 5 | import { useRead } from "../contexts/DataContext"; 6 | import type { WebviewAppState } from "../store"; 7 | 8 | const defaultState = { 9 | lastMeasure: 0, 10 | measuresCount: 0, 11 | seriesStart: 0, 12 | }; 13 | 14 | const useRequestAnimationFrame = (fn: (elsapsed: number) => void) => { 15 | const requestRef = React.useRef(); 16 | const previousTimeRef = React.useRef(); 17 | 18 | const animate = (time: number) => { 19 | if (previousTimeRef.current) { 20 | fn(time - (previousTimeRef.current || 0)); 21 | } 22 | previousTimeRef.current = time; 23 | requestRef.current = requestAnimationFrame(animate); 24 | }; 25 | 26 | // biome-ignore lint/correctness/useExhaustiveDependencies: 27 | React.useEffect(() => { 28 | requestRef.current = requestAnimationFrame(animate); 29 | return () => { 30 | if (requestRef.current) cancelAnimationFrame(requestRef.current); 31 | }; 32 | }, []); 33 | }; 34 | 35 | function drawValues( 36 | canvasCtx: CanvasRenderingContext2D, 37 | values: Uint8Array | Float32Array, 38 | color: string, 39 | transform: (val: number) => number, 40 | ) { 41 | if (!values?.length) return; 42 | 43 | const { 44 | canvas: { width: w }, 45 | } = canvasCtx; 46 | const wi = w / values.length; 47 | 48 | canvasCtx.strokeStyle = color; 49 | canvasCtx.beginPath(); 50 | values.forEach((val: number, i: number) => { 51 | const vh = transform(val); 52 | if (i === 0) { 53 | canvasCtx.moveTo(0, vh); 54 | return; 55 | } 56 | canvasCtx.lineTo(wi * i, vh); 57 | }); 58 | canvasCtx.stroke(); 59 | } 60 | 61 | function Visualizer() { 62 | const canvasRef = React.useRef(null); 63 | const read = useRead(); 64 | 65 | useRequestAnimationFrame(() => { 66 | const canvas = canvasRef.current; 67 | const rect = canvas?.getBoundingClientRect(); 68 | const ctx = canvas?.getContext("2d"); 69 | if (!ctx) return; 70 | canvas!.width = rect!.width; 71 | canvas!.height = rect!.height; 72 | const h = rect!.height; 73 | const hh = h * 0.5; 74 | drawValues(ctx, read("frequency"), "red", (val) => h - (val / 255) * h); 75 | drawValues( 76 | ctx, 77 | read("volume"), 78 | "lime", 79 | (val) => h - (hh + ((val - 127) / hh) * h), 80 | ); 81 | }); 82 | 83 | return ( 84 |
85 | 86 |
87 | ); 88 | } 89 | 90 | const Audio = () => { 91 | const { 92 | bpm: { count: bpm }, 93 | server, 94 | } = useSelector((state: WebviewAppState) => state); 95 | const setBPM = useSetBPM(); 96 | 97 | const [{ lastMeasure, measuresCount, seriesStart }, setState] = 98 | React.useState(defaultState); 99 | const serverURL = `http://${server.host}:${server.port}`; 100 | 101 | const newBPM = Math.round( 102 | 60000 / ((Date.now() - seriesStart) * (1 / measuresCount)), 103 | ); 104 | 105 | const handleBPMClick = () => { 106 | const now = Date.now(); 107 | if (now - lastMeasure > 2000) { 108 | setState({ 109 | lastMeasure: now, 110 | measuresCount: 0, 111 | seriesStart: now, 112 | }); 113 | return; 114 | } 115 | 116 | if (measuresCount > 2) { 117 | void setBPM(newBPM); 118 | } 119 | 120 | setState({ 121 | lastMeasure: now, 122 | measuresCount: measuresCount + 1, 123 | seriesStart: seriesStart || now, 124 | }); 125 | }; 126 | 127 | return ( 128 | <> 129 | 135 | 136 |
137 | {`BPM: ${bpm}`} 138 | 139 | 151 |
152 | 153 | ); 154 | }; 155 | 156 | export default Audio; 157 | -------------------------------------------------------------------------------- /src/webviews/components/ControlDisplay.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import type { AppState } from "../../types"; 4 | 5 | const ControlDisplay = () => { 6 | const info = useSelector((state: AppState) => ({ 7 | ...state.server, 8 | ...state.stage, 9 | })); 10 | const src = `http://${info.host}:${info.port}/display/#control`; 11 | return ( 12 |