diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..7ef5859
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "flow-state",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["--prefix", "flow-state", "run", "dev"],
+ "port": 5180
+ }
+ ]
+}
diff --git a/flow-state/.gitignore b/flow-state/.gitignore
new file mode 100644
index 0000000..e151893
--- /dev/null
+++ b/flow-state/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.vite
+out
diff --git a/flow-state/checks.html b/flow-state/checks.html
new file mode 100644
index 0000000..1d367fc
--- /dev/null
+++ b/flow-state/checks.html
@@ -0,0 +1,42 @@
+
+
+
+
+flow-state · checks
+
+
+
+ flow-state — phase gates
+ starting…
+
+
+
+
diff --git a/flow-state/index.html b/flow-state/index.html
new file mode 100644
index 0000000..e12c4ba
--- /dev/null
+++ b/flow-state/index.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+flow-state
+
+
+
+
+
+
+
+
+
+
flow-state
+
drop an audio file, or click to choose
+
mp3 · flac · wav · ogg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0:00 / 0:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/flow-state/package-lock.json b/flow-state/package-lock.json
new file mode 100644
index 0000000..895e1ec
--- /dev/null
+++ b/flow-state/package-lock.json
@@ -0,0 +1,1132 @@
+{
+ "name": "flow-state",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "flow-state",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "three": "^0.181.1"
+ },
+ "devDependencies": {
+ "vite": "^7.2.2"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
+ "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
+ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.62.4",
+ "@rollup/rollup-android-arm64": "4.62.4",
+ "@rollup/rollup-darwin-arm64": "4.62.4",
+ "@rollup/rollup-darwin-x64": "4.62.4",
+ "@rollup/rollup-freebsd-arm64": "4.62.4",
+ "@rollup/rollup-freebsd-x64": "4.62.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-musl": "4.62.4",
+ "@rollup/rollup-openbsd-x64": "4.62.4",
+ "@rollup/rollup-openharmony-arm64": "4.62.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.181.2",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
+ "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/flow-state/package.json b/flow-state/package.json
new file mode 100644
index 0000000..ccf76c9
--- /dev/null
+++ b/flow-state/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "flow-state",
+ "version": "1.0.0",
+ "description": "Ambient/EDM music video generator with a deterministic render core",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "lint:scenes": "node tools/lint-scenes.js",
+ "test": "node --test test/"
+ },
+ "license": "ISC",
+ "devDependencies": {
+ "vite": "^7.2.2"
+ },
+ "dependencies": {
+ "three": "^0.181.1"
+ }
+}
diff --git a/flow-state/src/checks/framework.js b/flow-state/src/checks/framework.js
new file mode 100644
index 0000000..ec2310b
--- /dev/null
+++ b/flow-state/src/checks/framework.js
@@ -0,0 +1,81 @@
+// Minimal check harness. Every phase gate in PLAN.md is a check registered here
+// and run from checks.html, so "the gate passes" is something you execute rather
+// than something you assert in a commit message.
+
+const registry = [];
+
+export function check(phase, name, fn, options = {}) {
+ registry.push({ phase, name, fn, manual: !!options.manual, slow: !!options.slow });
+}
+
+export function allChecks() {
+ return registry;
+}
+
+export async function runAll({ phases = null, onResult = null, skipSlow = false } = {}) {
+ const results = [];
+ for (const entry of registry) {
+ if (phases && !phases.includes(entry.phase)) continue;
+ if (skipSlow && entry.slow) continue;
+
+ const started = Date.now();
+ let result;
+ try {
+ const r = await entry.fn();
+ result = {
+ phase: entry.phase,
+ name: entry.name,
+ pass: r === true || (r && r.pass !== false),
+ detail: (r && r.detail) || '',
+ manual: entry.manual,
+ ms: Date.now() - started,
+ };
+ } catch (err) {
+ result = {
+ phase: entry.phase,
+ name: entry.name,
+ pass: false,
+ detail: `threw: ${err && err.message ? err.message : String(err)}`,
+ manual: entry.manual,
+ ms: Date.now() - started,
+ };
+ }
+ results.push(result);
+ if (onResult) onResult(result);
+ }
+ return results;
+}
+
+export function summarize(results) {
+ const total = results.length;
+ const failed = results.filter((r) => !r.pass && !r.manual);
+ const manual = results.filter((r) => r.manual);
+ return {
+ total,
+ passed: results.filter((r) => r.pass).length,
+ failed: failed.length,
+ manual: manual.length,
+ ok: failed.length === 0,
+ failures: failed.map((r) => `[P${r.phase}] ${r.name}: ${r.detail}`),
+ };
+}
+
+/** Assertion helpers that produce useful detail strings rather than bare booleans. */
+export function expect(condition, detail) {
+ return { pass: !!condition, detail };
+}
+
+export function expectClose(actual, expected, tolerance, label) {
+ const diff = Math.abs(actual - expected);
+ return {
+ pass: diff <= tolerance,
+ detail: `${label}: ${actual.toFixed(6)} vs ${expected.toFixed(6)} (Δ${diff.toFixed(6)}, tol ${tolerance})`,
+ };
+}
+
+export function expectBelow(actual, limit, label) {
+ return {
+ pass: actual <= limit,
+ detail: `${label}: ${typeof actual === 'number' ? actual.toFixed(6) : actual} (limit ${limit})`,
+ };
+}
diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js
new file mode 100644
index 0000000..e014f71
--- /dev/null
+++ b/flow-state/src/checks/main.js
@@ -0,0 +1,48 @@
+import { runAll, summarize, allChecks } from './framework.js';
+
+// Registering a phase's checks is a side effect of importing it.
+import './phase0.js';
+import './phase1.js';
+import './phase2.js';
+import './phase3.js';
+import './phase4.js';
+import './phase5.js';
+import './phase6.js';
+
+const out = document.getElementById('results');
+const summaryEl = document.getElementById('summary');
+
+function row(result) {
+ const el = document.createElement('div');
+ el.className = 'row ' + (result.pass ? 'pass' : result.manual ? 'manual' : 'fail');
+ el.innerHTML = `
+ ${result.pass ? 'PASS' : 'FAIL'}
+ P${result.phase}
+ ${result.name}
+ ${result.ms}ms
+ ${result.detail || ''}
`;
+ out.appendChild(el);
+}
+
+async function main() {
+ const params = new URLSearchParams(location.search);
+ const phaseArg = params.get('phase');
+ const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
+ const skipSlow = params.get('slow') !== '1';
+
+ summaryEl.textContent = `running ${allChecks().length} checks…`;
+
+ const results = await runAll({ phases, skipSlow, onResult: row });
+ const s = summarize(results);
+
+ summaryEl.textContent =
+ `${s.passed}/${s.total} passed · ${s.failed} failed` + (skipSlow ? ' · slow checks skipped (?slow=1 to include)' : '');
+ summaryEl.className = s.ok ? 'ok' : 'bad';
+
+ // Read by the browser automation that drives these gates.
+ window.__CHECKS__ = { results, summary: s };
+ window.__CHECKS_DONE__ = true;
+ console.log('[checks]', JSON.stringify(s, null, 2));
+}
+
+main();
diff --git a/flow-state/src/checks/phase0.js b/flow-state/src/checks/phase0.js
new file mode 100644
index 0000000..6fa9cdc
--- /dev/null
+++ b/flow-state/src/checks/phase0.js
@@ -0,0 +1,163 @@
+// Phase 0 gate — the determinism spine.
+//
+// These are cheap now and impossible to retrofit later: every phase after this
+// one assumes that frame N is a pure function of (seed, params, frame index).
+
+import { check, expect, expectBelow } from './framework.js';
+import { Engine } from '../engine/Engine.js';
+import { frameDistance, downsample } from '../engine/hash.js';
+import { Rng } from '../engine/rng.js';
+import { nebula } from '../scenes/shader/nebula.js';
+import { defaultValues } from '../params/schema.js';
+
+const TEST_PALETTE = [
+ [0.05, 0.02, 0.15],
+ [0.85, 0.15, 0.55],
+ [0.15, 0.75, 0.95],
+ [0.98, 0.85, 0.35],
+];
+
+function makeEngine(width = 320, height = 180) {
+ const engine = new Engine({ width, height });
+ engine.timeline.setDuration(60);
+ engine.setLayerSpecs([{
+ module: nebula,
+ params: defaultValues(nebula),
+ seed: 12345,
+ opacity: 1,
+ blend: 'normal',
+ palette: TEST_PALETTE,
+ }]);
+ return engine;
+}
+
+check(0, 'repeat run produces identical frames', () => {
+ const engine = makeEngine();
+ try {
+ const a = engine.hashRun(0, 300);
+ const b = engine.hashRun(0, 300);
+ const mismatches = a.filter((h, i) => h !== b[i]).length;
+ return expect(mismatches === 0, `${mismatches}/300 frames differed between runs`);
+ } finally {
+ engine.dispose();
+ }
+});
+
+check(0, 'fresh engine reproduces the same frames', () => {
+ // A second Engine instance must agree with the first: proves nothing is
+ // carried in module-level or GPU state between constructions.
+ const a = makeEngine();
+ const b = makeEngine();
+ try {
+ const ha = a.hashRun(0, 60);
+ const hb = b.hashRun(0, 60);
+ const mismatches = ha.filter((h, i) => h !== hb[i]).length;
+ return expect(mismatches === 0, `${mismatches}/60 frames differed between engines`);
+ } finally {
+ a.dispose(); b.dispose();
+ }
+});
+
+check(0, 'simulated dropped frames change nothing', () => {
+ // Renders the same frame indices but with irregular gaps in between, the way
+ // a stuttering browser would. Because dt is fixed and features are indexed by
+ // frame, output must be byte-identical to the smooth run.
+ const engine = makeEngine();
+ try {
+ const smooth = engine.hashRun(0, 120);
+
+ engine.compositor.reset();
+ const stuttered = [];
+ for (let i = 0; i < 120; i++) {
+ // Burn some GPU work between frames without advancing the timeline.
+ if (i % 7 === 0) engine.renderFrame(i);
+ const target = engine.renderFrame(i);
+ stuttered.push(engine.hashCurrent(target));
+ }
+ const mismatches = smooth.filter((h, i) => h !== stuttered[i]).length;
+ return expect(mismatches === 0, `${mismatches}/120 frames differed under simulated stutter`);
+ } finally {
+ engine.dispose();
+ }
+});
+
+check(0, 'seek equals sequential playback', () => {
+ const engine = makeEngine();
+ try {
+ const sequential = engine.hashRun(0, 100);
+ engine.compositor.reset();
+ const direct = engine.hashCurrent(engine.renderFrame(99));
+ // With feedback off (Phase 0 default) a direct seek must match exactly.
+ return expect(direct === sequential[99],
+ `seek→99 ${direct} vs sequential ${sequential[99]}`);
+ } finally {
+ engine.dispose();
+ }
+});
+
+check(0, 'resolution independence (320x180 vs 1280x720)', () => {
+ const small = new Engine({ width: 320, height: 180 });
+ const large = new Engine({ width: 1280, height: 720 });
+ try {
+ for (const e of [small, large]) {
+ e.timeline.setDuration(60);
+ e.setLayerSpecs([{
+ module: nebula, params: defaultValues(nebula), seed: 12345,
+ opacity: 1, blend: 'normal', palette: TEST_PALETTE,
+ }]);
+ }
+ const frames = [10, 90, 200];
+ let worst = 0;
+ for (const f of frames) {
+ small.compositor.reset(); large.compositor.reset();
+ const st = small.renderFrame(f);
+ const lt = large.renderFrame(f);
+ const sp = Uint8Array.from(small.readPixels(st));
+ const lp = Uint8Array.from(large.readPixels(lt));
+ const reduced = downsample(lp, 1280, 720, 4);
+ worst = Math.max(worst, frameDistance(sp, reduced.pixels));
+ }
+ // 4x downsampling of a noise-bearing shader will never be exact; this
+ // threshold catches genuine pixel-space dependence, not filtering error.
+ return expectBelow(worst, 0.06, 'worst mean channel distance');
+ } finally {
+ small.dispose(); large.dispose();
+ }
+});
+
+check(0, 'seeded rng is reproducible and independent per stream', () => {
+ const a = new Rng(42);
+ const b = new Rng(42);
+ const seqA = Array.from({ length: 500 }, () => a.next());
+ const seqB = Array.from({ length: 500 }, () => b.next());
+ if (seqA.some((v, i) => v !== seqB[i])) return expect(false, 'same seed diverged');
+
+ const parent = new Rng(7);
+ const c1 = parent.fork('layer:0').next();
+ const parent2 = new Rng(7);
+ const c2 = parent2.fork('layer:0').next();
+ const c3 = parent2.fork('layer:1').next();
+ if (c1 !== c2) return expect(false, 'fork(label) not stable');
+ if (c1 === c3) return expect(false, 'different fork labels collided');
+
+ const inRange = seqA.every((v) => v >= 0 && v < 1);
+ return expect(inRange, `500 draws reproducible, forks independent, range ok=${inRange}`);
+});
+
+check(0, 'compositor reset clears all history', () => {
+ const engine = makeEngine();
+ try {
+ engine.compositor.setFeedback({ amount: 0.8, decay: 0.95 });
+ engine.hashRun(0, 30);
+ const afterHistory = engine.hashRun(0, 30); // hashRun resets first
+ const fresh = makeEngine();
+ fresh.compositor.setFeedback({ amount: 0.8, decay: 0.95 });
+ const freshHashes = fresh.hashRun(0, 30);
+ fresh.dispose();
+ const mismatches = afterHistory.filter((h, i) => h !== freshHashes[i]).length;
+ return expect(mismatches === 0,
+ `${mismatches}/30 frames differed after reset — feedback history leaked`);
+ } finally {
+ engine.dispose();
+ }
+});
diff --git a/flow-state/src/checks/phase1.js b/flow-state/src/checks/phase1.js
new file mode 100644
index 0000000..1e59de5
--- /dev/null
+++ b/flow-state/src/checks/phase1.js
@@ -0,0 +1 @@
+// Phase 1 gate — filled in when the phase lands.
diff --git a/flow-state/src/checks/phase2.js b/flow-state/src/checks/phase2.js
new file mode 100644
index 0000000..e27849e
--- /dev/null
+++ b/flow-state/src/checks/phase2.js
@@ -0,0 +1 @@
+// Phase 2 gate — filled in when the phase lands.
diff --git a/flow-state/src/checks/phase3.js b/flow-state/src/checks/phase3.js
new file mode 100644
index 0000000..05432dd
--- /dev/null
+++ b/flow-state/src/checks/phase3.js
@@ -0,0 +1 @@
+// Phase 3 gate — filled in when the phase lands.
diff --git a/flow-state/src/checks/phase4.js b/flow-state/src/checks/phase4.js
new file mode 100644
index 0000000..2652d80
--- /dev/null
+++ b/flow-state/src/checks/phase4.js
@@ -0,0 +1 @@
+// Phase 4 gate — filled in when the phase lands.
diff --git a/flow-state/src/checks/phase5.js b/flow-state/src/checks/phase5.js
new file mode 100644
index 0000000..d30a3fc
--- /dev/null
+++ b/flow-state/src/checks/phase5.js
@@ -0,0 +1 @@
+// Phase 5 gate — filled in when the phase lands.
diff --git a/flow-state/src/checks/phase6.js b/flow-state/src/checks/phase6.js
new file mode 100644
index 0000000..1d617fd
--- /dev/null
+++ b/flow-state/src/checks/phase6.js
@@ -0,0 +1 @@
+// Phase 6 gate — filled in when the phase lands.
diff --git a/flow-state/src/engine/Compositor.js b/flow-state/src/engine/Compositor.js
new file mode 100644
index 0000000..0cc074d
--- /dev/null
+++ b/flow-state/src/engine/Compositor.js
@@ -0,0 +1,267 @@
+import * as THREE from 'three';
+import { makePassMaterial } from './Renderer.js';
+import {
+ BLEND_FRAG, FEEDBACK_FRAG, BRIGHT_FRAG, BLUR_FRAG, COMPOSITE_FRAG, COPY_FRAG,
+ BLEND_MODE_IDS,
+} from './passes.js';
+
+const DEFAULT_POST = {
+ bloom: 0.35,
+ bloomThreshold: 0.6,
+ bloomKnee: 0.3,
+ chroma: 0.15,
+ grain: 0.04,
+ vignette: 0.35,
+ contrast: 1.05,
+ saturation: 1.1,
+ lift: 0.0,
+ exposure: 1.0,
+};
+
+const DEFAULT_FEEDBACK = {
+ amount: 0.0,
+ decay: 0.9,
+ zoom: 0.995,
+ rotate: 0.0,
+};
+
+/**
+ * The layer stack. Layers render into their own target, then blend into an
+ * accumulator; the result goes through feedback and the post chain.
+ *
+ * Every target is explicitly cleared on allocation and on reset, because
+ * inheriting stale GPU memory is exactly the kind of thing that makes an export
+ * differ from a preview.
+ */
+export class Compositor {
+ constructor(renderer, { width, height } = {}) {
+ this.renderer = renderer;
+ this.width = width || renderer.width;
+ this.height = height || renderer.height;
+
+ this.layers = [];
+ this.post = { ...DEFAULT_POST };
+ this.feedback = { ...DEFAULT_FEEDBACK };
+ this.fade = 1;
+ this.soloIndex = -1; // debug: render one layer alone
+ this.postEnabled = true;
+
+ this._buildTargets();
+ this._buildMaterials();
+ }
+
+ _buildTargets() {
+ const r = this.renderer;
+ const w = this.width, h = this.height;
+ const bw = Math.max(1, Math.floor(w / 2));
+ const bh = Math.max(1, Math.floor(h / 2));
+
+ this.layerTarget = r.createTarget(w, h, { depth: true });
+ this.accumA = r.createTarget(w, h);
+ this.accumB = r.createTarget(w, h);
+ this.historyA = r.createTarget(w, h, { float: true });
+ this.historyB = r.createTarget(w, h, { float: true });
+ this.bloomA = r.createTarget(bw, bh);
+ this.bloomB = r.createTarget(bw, bh);
+ this.outputTarget = r.createTarget(w, h);
+ }
+
+ _buildMaterials() {
+ this.blendMaterial = makePassMaterial(BLEND_FRAG, {
+ u_base: { value: null },
+ u_src: { value: null },
+ u_amount: { value: 1 },
+ u_mode: { value: 0 },
+ });
+ this.feedbackMaterial = makePassMaterial(FEEDBACK_FRAG, {
+ u_current: { value: null },
+ u_history: { value: null },
+ u_decay: { value: 0.9 },
+ u_amount: { value: 0 },
+ u_zoom: { value: 0.995 },
+ u_rotate: { value: 0 },
+ u_aspect: { value: 1 },
+ });
+ this.brightMaterial = makePassMaterial(BRIGHT_FRAG, {
+ u_tex: { value: null },
+ u_threshold: { value: 0.6 },
+ u_knee: { value: 0.3 },
+ });
+ this.blurMaterial = makePassMaterial(BLUR_FRAG, {
+ u_tex: { value: null },
+ u_direction: { value: new THREE.Vector2(0, 0) },
+ });
+ this.compositeMaterial = makePassMaterial(COMPOSITE_FRAG, {
+ u_tex: { value: null },
+ u_bloom: { value: null },
+ u_bloomAmount: { value: 0 },
+ u_chroma: { value: 0 },
+ u_grain: { value: 0 },
+ u_vignette: { value: 0 },
+ u_contrast: { value: 1 },
+ u_saturation: { value: 1 },
+ u_lift: { value: 0 },
+ u_exposure: { value: 1 },
+ u_fade: { value: 1 },
+ u_frame: { value: 0 },
+ u_resolution: { value: new THREE.Vector2(1, 1) },
+ });
+ this.copyMaterial = makePassMaterial(COPY_FRAG, { u_tex: { value: null } });
+ }
+
+ setSize(width, height) {
+ if (width === this.width && height === this.height) return;
+ this.width = width;
+ this.height = height;
+ this.disposeTargets();
+ this._buildTargets();
+ }
+
+ setLayers(layers) {
+ this.layers.forEach((l) => { if (!layers.includes(l)) l.dispose(); });
+ this.layers = layers;
+ return this;
+ }
+
+ setPost(post) {
+ this.post = { ...this.post, ...post };
+ return this;
+ }
+
+ setFeedback(feedback) {
+ this.feedback = { ...this.feedback, ...feedback };
+ return this;
+ }
+
+ /**
+ * Wipe all history. Called on seek and before an export run so a render never
+ * depends on what was on screen beforehand.
+ */
+ reset() {
+ const r = this.renderer;
+ [this.accumA, this.accumB, this.historyA, this.historyB,
+ this.bloomA, this.bloomB, this.layerTarget, this.outputTarget]
+ .forEach((t) => r.clear(t));
+ }
+
+ /**
+ * Render one frame. Returns the target holding the finished image, so the
+ * caller decides whether it goes to the canvas or to the encoder.
+ */
+ render(ctx) {
+ const r = this.renderer;
+ const { timeline, features } = ctx;
+
+ r.clear(this.accumA);
+ let accum = this.accumA;
+ let spare = this.accumB;
+
+ const active = this.soloIndex >= 0
+ ? this.layers.slice(this.soloIndex, this.soloIndex + 1)
+ : this.layers;
+
+ for (const layer of active) {
+ if (layer.opacity <= 0.001) continue;
+
+ r.clear(this.layerTarget);
+ layer.render(r, this.layerTarget, {
+ timeline,
+ features,
+ prevTexture: this.historyA.texture,
+ });
+
+ const bu = this.blendMaterial.uniforms;
+ bu.u_base.value = accum.texture;
+ bu.u_src.value = this.layerTarget.texture;
+ bu.u_amount.value = 1.0; // layer opacity already applied in-shader
+ bu.u_mode.value = BLEND_MODE_IDS[layer.blend] ?? 0;
+ r.blit(this.blendMaterial, spare);
+
+ const t = accum; accum = spare; spare = t;
+ }
+
+ // --- feedback -------------------------------------------------------
+ let composited = accum;
+ if (this.feedback.amount > 0.001) {
+ const fu = this.feedbackMaterial.uniforms;
+ fu.u_current.value = accum.texture;
+ fu.u_history.value = this.historyA.texture;
+ fu.u_decay.value = Math.min(0.99, this.feedback.decay);
+ fu.u_amount.value = this.feedback.amount;
+ fu.u_zoom.value = this.feedback.zoom;
+ fu.u_rotate.value = this.feedback.rotate;
+ fu.u_aspect.value = this.width / this.height;
+ r.blit(this.feedbackMaterial, this.historyB);
+ composited = this.historyB;
+ const t = this.historyA; this.historyA = this.historyB; this.historyB = t;
+ } else {
+ // Keep history tracking the image even when feedback is off, so
+ // enabling it mid-track doesn't pop from black.
+ this.copyMaterial.uniforms.u_tex.value = accum.texture;
+ r.blit(this.copyMaterial, this.historyA);
+ }
+
+ if (!this.postEnabled) {
+ this.copyMaterial.uniforms.u_tex.value = composited.texture;
+ r.blit(this.copyMaterial, this.outputTarget);
+ return this.outputTarget;
+ }
+
+ // --- bloom ----------------------------------------------------------
+ const p = this.post;
+ if (p.bloom > 0.001) {
+ this.brightMaterial.uniforms.u_tex.value = composited.texture;
+ this.brightMaterial.uniforms.u_threshold.value = p.bloomThreshold;
+ this.brightMaterial.uniforms.u_knee.value = p.bloomKnee;
+ r.blit(this.brightMaterial, this.bloomA);
+
+ const bw = this.bloomA.width, bh = this.bloomA.height;
+ for (let i = 0; i < 2; i++) {
+ this.blurMaterial.uniforms.u_tex.value = this.bloomA.texture;
+ this.blurMaterial.uniforms.u_direction.value.set((1 + i) / bw, 0);
+ r.blit(this.blurMaterial, this.bloomB);
+
+ this.blurMaterial.uniforms.u_tex.value = this.bloomB.texture;
+ this.blurMaterial.uniforms.u_direction.value.set(0, (1 + i) / bh);
+ r.blit(this.blurMaterial, this.bloomA);
+ }
+ } else {
+ r.clear(this.bloomA);
+ }
+
+ // --- final grade ----------------------------------------------------
+ const cu = this.compositeMaterial.uniforms;
+ cu.u_tex.value = composited.texture;
+ cu.u_bloom.value = this.bloomA.texture;
+ cu.u_bloomAmount.value = p.bloom;
+ cu.u_chroma.value = p.chroma;
+ cu.u_grain.value = p.grain;
+ cu.u_vignette.value = p.vignette;
+ cu.u_contrast.value = p.contrast;
+ cu.u_saturation.value = p.saturation;
+ cu.u_lift.value = p.lift;
+ cu.u_exposure.value = p.exposure;
+ cu.u_fade.value = this.fade;
+ cu.u_frame.value = timeline.frame;
+ cu.u_resolution.value.set(this.width, this.height);
+ r.blit(this.compositeMaterial, this.outputTarget);
+
+ return this.outputTarget;
+ }
+
+ /** Present a finished target to the canvas. */
+ present(target) {
+ this.copyMaterial.uniforms.u_tex.value = target.texture;
+ this.renderer.blit(this.copyMaterial, null);
+ }
+
+ disposeTargets() {
+ [this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB,
+ this.bloomA, this.bloomB, this.outputTarget].forEach((t) => t && t.dispose());
+ }
+
+ dispose() {
+ this.layers.forEach((l) => l.dispose());
+ this.disposeTargets();
+ }
+}
diff --git a/flow-state/src/engine/Engine.js b/flow-state/src/engine/Engine.js
new file mode 100644
index 0000000..41691f8
--- /dev/null
+++ b/flow-state/src/engine/Engine.js
@@ -0,0 +1,127 @@
+import { Renderer } from './Renderer.js';
+import { Compositor } from './Compositor.js';
+import { Timeline, FIXED, REALTIME } from './Timeline.js';
+import { createLayer } from './Layer.js';
+import { hashFrame } from './hash.js';
+
+/** Zeroed features, so the engine runs before any audio is loaded. */
+export const NULL_FEATURES = Object.freeze({
+ loudness: 0, rms: 0,
+ bandSub: 0, bandLow: 0, bandMid: 0, bandHigh: 0, bandAir: 0,
+ flux: 0, centroid: 0.5, flatness: 0, width: 0.5,
+ beat: 0, beatPhase: 0, barPhase: 0, phrasePhase: 0,
+ sectionProgress: 0, sectionEnergy: 0, buildSlope: 0,
+});
+
+/**
+ * Ties the clock, the renderer and the layer stack together. Deliberately the
+ * only place that knows about all three, and deliberately unaware of the DOM
+ * beyond its canvas — the exporter and the check harness drive the exact same
+ * object the preview does, which is what keeps them from diverging.
+ */
+export class Engine {
+ constructor({ width = 1280, height = 720, canvas = null, fps = 60 } = {}) {
+ this.renderer = new Renderer({ width, height, canvas });
+ this.compositor = new Compositor(this.renderer, { width, height });
+ this.timeline = new Timeline({ fps, mode: REALTIME });
+ this.featureProvider = null;
+ this.lastFrameRendered = -1;
+ }
+
+ get width() { return this.renderer.width; }
+ get height() { return this.renderer.height; }
+
+ setSize(width, height) {
+ this.renderer.setSize(width, height);
+ this.compositor.setSize(width, height);
+ }
+
+ setFeatureProvider(provider) {
+ this.featureProvider = provider;
+ return this;
+ }
+
+ featuresAt(frame) {
+ if (!this.featureProvider) return NULL_FEATURES;
+ return this.featureProvider.at(frame) || NULL_FEATURES;
+ }
+
+ /** Replace the stack. `specs` are { module, params, seed, opacity, blend }. */
+ setLayerSpecs(specs) {
+ const layers = specs.map((s) => {
+ const layer = createLayer(s.module, s);
+ if (s.palette) layer.setPalette(s.palette);
+ return layer;
+ });
+ this.compositor.setLayers(layers);
+ return layers;
+ }
+
+ setPalette(colors) {
+ this.compositor.layers.forEach((l) => l.setPalette(colors));
+ }
+
+ /** Render exactly one frame at the timeline's current position. */
+ renderCurrent() {
+ const features = this.featuresAt(this.timeline.frame);
+ const target = this.compositor.render({ timeline: this.timeline, features });
+ this.lastFrameRendered = this.timeline.frame;
+ return target;
+ }
+
+ /** Render a specific frame without warm-up. Used by checks and by export. */
+ renderFrame(frameIndex) {
+ this.timeline.seek(frameIndex);
+ return this.renderCurrent();
+ }
+
+ present(target) {
+ this.compositor.present(target);
+ }
+
+ readPixels(target) {
+ return this.renderer.readPixels(target);
+ }
+
+ hashCurrent(target) {
+ return hashFrame(this.renderer.readPixels(target));
+ }
+
+ /**
+ * Render frames [start, start+count) sequentially from a clean state and
+ * return a hash per frame. Sequential and reset-first, so the result depends
+ * only on the inputs — this is the primitive every determinism check uses.
+ */
+ hashRun(start, count, { reset = true } = {}) {
+ if (reset) this.compositor.reset();
+ const hashes = [];
+ for (let i = 0; i < count; i++) {
+ const target = this.renderFrame(start + i);
+ hashes.push(hashFrame(this.renderer.readPixels(target)));
+ }
+ return hashes;
+ }
+
+ /**
+ * Advance stateful layers up to `frame` without presenting, so a seek lands
+ * on converged feedback state. Section-boundary seeks pass warmup: 0,
+ * because layer state is re-seeded there and is exact by construction.
+ */
+ warmUp(frame, warmupFrames = 120) {
+ const start = Math.max(0, frame - warmupFrames);
+ this.compositor.reset();
+ for (let f = start; f < frame; f++) {
+ this.timeline.seek(f);
+ const features = this.featuresAt(f);
+ this.compositor.render({ timeline: this.timeline, features });
+ }
+ this.timeline.seek(frame);
+ }
+
+ dispose() {
+ this.compositor.dispose();
+ this.renderer.dispose();
+ }
+}
+
+export { FIXED, REALTIME };
diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js
new file mode 100644
index 0000000..4e67b7c
--- /dev/null
+++ b/flow-state/src/engine/Layer.js
@@ -0,0 +1,232 @@
+import * as THREE from 'three';
+import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS } from './shader-contract.js';
+import { clampValue } from '../params/schema.js';
+
+export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
+
+/**
+ * Map a raw feature value through a response curve. `spike` squares the input so
+ * beat-driven params punch rather than wobble; `smooth` rounds the shoulders so
+ * slow features don't step.
+ */
+function applyResponse(value, response) {
+ switch (response) {
+ case 'spike': return value * value;
+ case 'smooth': return value * value * (3 - 2 * value);
+ case 'inverse': return 1 - value;
+ default: return value;
+ }
+}
+
+/** Common surface for shader layers and 3D layers, so Compositor holds one type. */
+export class Layer {
+ constructor({ module, params = {}, seed = 1, opacity = 1, blend = 'normal' }) {
+ this.module = module;
+ this.baseParams = { ...params };
+ this.params = { ...params };
+ this.seed = seed >>> 0;
+ this.opacity = opacity;
+ this.blend = blend;
+ this.palette = [];
+ }
+
+ setParams(params) {
+ this.baseParams = { ...this.baseParams, ...params };
+ return this;
+ }
+
+ setPalette(colors) {
+ this.palette = colors;
+ return this;
+ }
+
+ /** Resolve base params + reactive modulation into the values used this frame. */
+ resolveParams(features) {
+ const defs = this.module.params || {};
+ const reactive = this.module.reactive || {};
+ const out = this.params;
+
+ for (const name of Object.keys(defs)) out[name] = this.baseParams[name];
+
+ if (!features) return out;
+
+ for (const [name, r] of Object.entries(reactive)) {
+ const def = defs[name];
+ if (!def || def.type === 'palette') continue;
+ const raw = features[r.feature];
+ if (raw === undefined) continue;
+
+ const shaped = applyResponse(Math.max(0, Math.min(1, raw)), r.response);
+ const [lo, hi] = def.range || [0, 1];
+ const span = hi - lo;
+ const base = out[name] !== undefined ? out[name] : lo;
+
+ if (def.type === 'vec2') {
+ out[name] = [
+ clampValue(def, [base[0] + shaped * r.amount * span, 0])[0],
+ clampValue(def, [0, base[1] + shaped * r.amount * span])[1],
+ ];
+ } else if (def.type === 'bool') {
+ out[name] = base;
+ } else {
+ out[name] = clampValue(def, base + shaped * r.amount * span);
+ }
+ }
+ return out;
+ }
+
+ render() { throw new Error('Layer.render not implemented'); }
+ dispose() {}
+}
+
+/** A fullscreen fragment-shader scene. The common case. */
+export class ShaderLayer extends Layer {
+ constructor(options) {
+ super(options);
+
+ const uniforms = {
+ u_resolution: { value: new THREE.Vector2(1, 1) },
+ u_aspect: { value: 1 },
+ u_pixelScale: { value: 1 },
+ u_time: { value: 0 },
+ u_frame: { value: 0 },
+ u_progress: { value: 0 },
+ u_seed: { value: (this.seed % 100000) / 1000 },
+ u_opacity: { value: 1 },
+ u_colors: { value: Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1)) },
+ u_colorCount: { value: 1 },
+ u_prev: { value: null },
+ u_hasPrev: { value: 0 },
+ };
+ for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
+
+ for (const [name, def] of Object.entries(this.module.params || {})) {
+ if (!def.uniform || def.type === 'palette') continue;
+ const v = this.baseParams[name];
+ uniforms[def.uniform] = {
+ value: def.type === 'vec2'
+ ? new THREE.Vector2(v ? v[0] : 0, v ? v[1] : 0)
+ : def.type === 'bool' ? (v ? 1 : 0) : (v || 0),
+ };
+ }
+
+ this.uniforms = uniforms;
+ this.material = new THREE.ShaderMaterial({
+ vertexShader: VERTEX_SHADER,
+ fragmentShader: buildFragmentShader(this.module),
+ uniforms,
+ depthTest: false,
+ depthWrite: false,
+ });
+ }
+
+ render(renderer, target, ctx) {
+ const { timeline, features, prevTexture } = ctx;
+ const u = this.uniforms;
+ const w = target ? target.width : renderer.width;
+ const h = target ? target.height : renderer.height;
+
+ u.u_resolution.value.set(w, h);
+ u.u_aspect.value = w / h;
+ u.u_pixelScale.value = h / 1080; // reference height; keeps 720p ≡ 4K
+ u.u_time.value = timeline.time;
+ u.u_frame.value = timeline.frame;
+ u.u_progress.value = timeline.progress;
+ u.u_opacity.value = this.opacity;
+
+ if (features) {
+ for (const name of AUDIO_UNIFORMS) {
+ const key = name.slice(2); // u_bandLow -> bandLow
+ const v = features[key];
+ u[name].value = v === undefined ? 0 : v;
+ }
+ }
+
+ const colors = this.palette || [];
+ u.u_colorCount.value = Math.max(1, Math.min(8, colors.length));
+ for (let i = 0; i < 8; i++) {
+ const c = colors[i % Math.max(1, colors.length)];
+ if (c) u.u_colors.value[i].set(c[0], c[1], c[2]);
+ }
+
+ u.u_prev.value = prevTexture || null;
+ u.u_hasPrev.value = prevTexture ? 1 : 0;
+
+ const resolved = this.resolveParams(features);
+ for (const [name, def] of Object.entries(this.module.params || {})) {
+ if (!def.uniform || def.type === 'palette') continue;
+ const target_u = u[def.uniform];
+ const v = resolved[name];
+ if (v === undefined) continue;
+ if (def.type === 'vec2') target_u.value.set(v[0], v[1]);
+ else if (def.type === 'bool') target_u.value = v ? 1 : 0;
+ else target_u.value = v;
+ }
+
+ renderer.blit(this.material, target);
+ }
+
+ dispose() {
+ this.material.dispose();
+ }
+}
+
+/**
+ * A layer backed by a real three.js scene — particles, geometry, camera motion.
+ * The module supplies build/update hooks; everything determinism-related (seeded
+ * rng, fixed dt, explicit re-seed at section boundaries) is handled here so 3D
+ * modules can't accidentally reintroduce wall-clock or Math.random.
+ */
+export class SceneLayer extends Layer {
+ constructor(options) {
+ super(options);
+ this.scene = new THREE.Scene();
+ this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
+ this.camera.position.set(0, 0, 5);
+ this.instance = this.module.build({
+ scene: this.scene,
+ camera: this.camera,
+ seed: this.seed,
+ params: this.baseParams,
+ THREE,
+ });
+ }
+
+ render(renderer, target, ctx) {
+ const { timeline, features } = ctx;
+ const w = target ? target.width : renderer.width;
+ const h = target ? target.height : renderer.height;
+ if (this.camera.aspect !== w / h) {
+ this.camera.aspect = w / h;
+ this.camera.updateProjectionMatrix();
+ }
+ const resolved = this.resolveParams(features);
+ this.module.update({
+ instance: this.instance,
+ scene: this.scene,
+ camera: this.camera,
+ timeline,
+ features: features || {},
+ params: resolved,
+ palette: this.palette,
+ opacity: this.opacity,
+ THREE,
+ });
+ renderer.renderScene(this.scene, this.camera, target, true);
+ }
+
+ dispose() {
+ this.scene.traverse((obj) => {
+ if (obj.geometry) obj.geometry.dispose();
+ if (obj.material) {
+ const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
+ mats.forEach((m) => m.dispose());
+ }
+ });
+ }
+}
+
+export function createLayer(module, options) {
+ if (module.kind === 'layer3d') return new SceneLayer({ module, ...options });
+ return new ShaderLayer({ module, ...options });
+}
diff --git a/flow-state/src/engine/Renderer.js b/flow-state/src/engine/Renderer.js
new file mode 100644
index 0000000..5d66050
--- /dev/null
+++ b/flow-state/src/engine/Renderer.js
@@ -0,0 +1,119 @@
+import * as THREE from 'three';
+import { VERTEX_SHADER } from './shader-contract.js';
+
+/**
+ * Thin wrapper over WebGLRenderer that provides the two primitives everything
+ * else is built from: allocate a render target, and run a fullscreen shader pass
+ * into one. Keeping this small matters — preview and export share it exactly,
+ * and any state that leaks between frames here would break determinism.
+ */
+export class Renderer {
+ constructor({ width = 1280, height = 720, canvas = null } = {}) {
+ this.gl = new THREE.WebGLRenderer({
+ canvas: canvas || undefined,
+ antialias: false, // we render through targets; MSAA here buys nothing
+ preserveDrawingBuffer: true, // required to read pixels back for hashing/export
+ powerPreference: 'high-performance',
+ });
+ this.gl.autoClear = false;
+ this.gl.setPixelRatio(1); // never device-dependent: output size is explicit
+ this.gl.setSize(width, height, false);
+
+ this.width = width;
+ this.height = height;
+
+ // Fullscreen quad rig, reused for every pass.
+ this.quadScene = new THREE.Scene();
+ this.quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+ this.quadGeometry = new THREE.PlaneGeometry(2, 2);
+ this.quadMesh = new THREE.Mesh(this.quadGeometry, new THREE.MeshBasicMaterial());
+ this.quadMesh.frustumCulled = false;
+ this.quadScene.add(this.quadMesh);
+
+ this._readBuffer = null;
+ }
+
+ get canvas() {
+ return this.gl.domElement;
+ }
+
+ setSize(width, height) {
+ if (width === this.width && height === this.height) return;
+ this.width = width;
+ this.height = height;
+ this.gl.setSize(width, height, false);
+ this._readBuffer = null;
+ }
+
+ createTarget(width = this.width, height = this.height, options = {}) {
+ const target = new THREE.WebGLRenderTarget(width, height, {
+ minFilter: THREE.LinearFilter,
+ magFilter: THREE.LinearFilter,
+ format: THREE.RGBAFormat,
+ type: options.float ? THREE.HalfFloatType : THREE.UnsignedByteType,
+ depthBuffer: options.depth === true,
+ stencilBuffer: false,
+ generateMipmaps: false,
+ });
+ target.texture.wrapS = THREE.ClampToEdgeWrapping;
+ target.texture.wrapT = THREE.ClampToEdgeWrapping;
+ // Deterministic initial contents: never inherit whatever was in GPU memory.
+ this.clear(target);
+ return target;
+ }
+
+ clear(target = null, r = 0, g = 0, b = 0, a = 1) {
+ const prev = this.gl.getClearColor(new THREE.Color());
+ const prevAlpha = this.gl.getClearAlpha();
+ this.gl.setRenderTarget(target);
+ this.gl.setClearColor(new THREE.Color(r, g, b), a);
+ this.gl.clear(true, true, true);
+ this.gl.setClearColor(prev, prevAlpha);
+ this.gl.setRenderTarget(null);
+ }
+
+ /** Run a fullscreen shader pass. target === null renders to the canvas. */
+ blit(material, target = null) {
+ this.quadMesh.material = material;
+ this.gl.setRenderTarget(target);
+ this.gl.clear(true, false, false);
+ this.gl.render(this.quadScene, this.quadCamera);
+ this.gl.setRenderTarget(null);
+ }
+
+ /** Render a real three.js scene (used by 3D layers). */
+ renderScene(scene, camera, target = null, clear = true) {
+ this.gl.setRenderTarget(target);
+ if (clear) this.gl.clear(true, true, true);
+ this.gl.render(scene, camera);
+ this.gl.setRenderTarget(null);
+ }
+
+ readPixels(target) {
+ const w = target ? target.width : this.width;
+ const h = target ? target.height : this.height;
+ const needed = w * h * 4;
+ if (!this._readBuffer || this._readBuffer.length !== needed) {
+ this._readBuffer = new Uint8Array(needed);
+ }
+ this.gl.readRenderTargetPixels(target, 0, 0, w, h, this._readBuffer);
+ return this._readBuffer;
+ }
+
+ dispose() {
+ this.quadGeometry.dispose();
+ this.gl.dispose();
+ }
+}
+
+/** Convenience for building the shader materials used by passes. */
+export function makePassMaterial(fragmentShader, uniforms) {
+ return new THREE.ShaderMaterial({
+ vertexShader: VERTEX_SHADER,
+ fragmentShader,
+ uniforms,
+ depthTest: false,
+ depthWrite: false,
+ transparent: true,
+ });
+}
diff --git a/flow-state/src/engine/Timeline.js b/flow-state/src/engine/Timeline.js
new file mode 100644
index 0000000..2c8fc3e
--- /dev/null
+++ b/flow-state/src/engine/Timeline.js
@@ -0,0 +1,81 @@
+// The only clock the engine has. Scene and layer code never reads wall time —
+// it reads whatever this hands it. That is what lets a realtime preview and an
+// offline export produce identical frames.
+//
+// dt is CONSTANT (1/fps) in both modes. A dropped frame in preview shows as a
+// hitch on screen; it never changes what gets rendered.
+
+export const REALTIME = 'realtime';
+export const FIXED = 'fixed';
+
+export class Timeline {
+ constructor({ fps = 60, duration = 0, mode = REALTIME } = {}) {
+ this.fps = fps;
+ this.duration = duration;
+ this.mode = mode;
+ this.frame = 0;
+ this.playing = false;
+ }
+
+ get dt() {
+ return 1 / this.fps;
+ }
+
+ get time() {
+ return this.frame / this.fps;
+ }
+
+ get frameCount() {
+ return Math.max(1, Math.round(this.duration * this.fps));
+ }
+
+ get progress() {
+ return this.duration > 0 ? Math.min(1, this.time / this.duration) : 0;
+ }
+
+ get finished() {
+ return this.frame >= this.frameCount - 1;
+ }
+
+ setDuration(seconds) {
+ this.duration = seconds;
+ return this;
+ }
+
+ /** Fixed-step advance. Used by the exporter and by preview warm-up. */
+ advance(frames = 1) {
+ this.frame = Math.min(this.frameCount - 1, this.frame + frames);
+ return this.frame;
+ }
+
+ seek(frame) {
+ this.frame = Math.max(0, Math.min(this.frameCount - 1, Math.round(frame)));
+ return this.frame;
+ }
+
+ seekTime(seconds) {
+ return this.seek(seconds * this.fps);
+ }
+
+ /**
+ * Realtime mode: derive the frame index from the audio element's clock.
+ * Audio is the master — the visuals follow it, never the other way round,
+ * so a slow GPU desynchronises nothing.
+ */
+ syncToAudio(currentTime) {
+ this.frame = Math.max(0, Math.min(this.frameCount - 1, Math.floor(currentTime * this.fps)));
+ return this.frame;
+ }
+
+ /** A snapshot handed to layers, so nothing holds a mutable reference to the clock. */
+ snapshot() {
+ return {
+ frame: this.frame,
+ time: this.time,
+ dt: this.dt,
+ fps: this.fps,
+ progress: this.progress,
+ duration: this.duration,
+ };
+ }
+}
diff --git a/flow-state/src/engine/hash.js b/flow-state/src/engine/hash.js
new file mode 100644
index 0000000..6c538fc
--- /dev/null
+++ b/flow-state/src/engine/hash.js
@@ -0,0 +1,97 @@
+// Frame hashing and comparison — the backbone of every determinism check.
+//
+// Determinism guarantee, precisely: on one machine (same browser, GPU, driver)
+// frames are bit-identical, so `hashFrame` is the right test. ACROSS machines,
+// float and derivative differences make bit-exactness unachievable, so the
+// cross-machine test is `frameDistance` against a small threshold. Writing the
+// checks this way keeps the acceptance criteria honest and actually passable.
+
+/** FNV-1a over raw RGBA bytes. Bit-exact test, same-machine. */
+export function hashFrame(pixels) {
+ let h = 0x811c9dc5 >>> 0;
+ for (let i = 0; i < pixels.length; i++) {
+ h ^= pixels[i];
+ h = Math.imul(h, 0x01000193) >>> 0;
+ }
+ return h.toString(16).padStart(8, '0');
+}
+
+/**
+ * Mean absolute per-channel difference, 0..1. Used for the cross-machine and
+ * dual-resolution comparisons where bit-exactness is not a fair ask.
+ */
+export function frameDistance(a, b) {
+ if (a.length !== b.length) return 1;
+ let sum = 0;
+ for (let i = 0; i < a.length; i += 4) {
+ sum += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
+ }
+ return sum / ((a.length / 4) * 3 * 255);
+}
+
+/** Largest single-channel difference. Catches localised breakage a mean would hide. */
+export function frameMaxDelta(a, b) {
+ if (a.length !== b.length) return 255;
+ let max = 0;
+ for (let i = 0; i < a.length; i++) {
+ const d = Math.abs(a[i] - b[i]);
+ if (d > max) max = d;
+ }
+ return max;
+}
+
+/** Mean luminance, 0..1. Used by the range sweep to catch black/white-out frames. */
+export function frameLuminance(pixels) {
+ let sum = 0;
+ const n = pixels.length / 4;
+ for (let i = 0; i < pixels.length; i += 4) {
+ sum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
+ }
+ return sum / n / 255;
+}
+
+/** Per-channel standard deviation, averaged. Near zero means a flat, dead frame. */
+export function frameVariance(pixels) {
+ const n = pixels.length / 4;
+ let mr = 0, mg = 0, mb = 0;
+ for (let i = 0; i < pixels.length; i += 4) { mr += pixels[i]; mg += pixels[i + 1]; mb += pixels[i + 2]; }
+ mr /= n; mg /= n; mb /= n;
+ let vr = 0, vg = 0, vb = 0;
+ for (let i = 0; i < pixels.length; i += 4) {
+ vr += (pixels[i] - mr) ** 2; vg += (pixels[i + 1] - mg) ** 2; vb += (pixels[i + 2] - mb) ** 2;
+ }
+ return (Math.sqrt(vr / n) + Math.sqrt(vg / n) + Math.sqrt(vb / n)) / 3 / 255;
+}
+
+/** True if the frame contains any non-finite pixel artefact of a NaN in the shader. */
+export function frameHasNaN(pixels) {
+ // A NaN in GLSL resolves to 0 or garbage on readback; the practical detector
+ // is a frame that is entirely one value while variance is exactly zero AND
+ // luminance is neither plausible black nor plausible white.
+ return false; // superseded by the luminance/variance checks in sweepScene
+}
+
+/**
+ * Downsample RGBA pixels by integer box filter. Used by the dual-resolution
+ * check so a 4K render can be compared against a 720p one.
+ */
+export function downsample(pixels, width, height, factor) {
+ const ow = Math.floor(width / factor);
+ const oh = Math.floor(height / factor);
+ const out = new Uint8Array(ow * oh * 4);
+ for (let y = 0; y < oh; y++) {
+ for (let x = 0; x < ow; x++) {
+ let r = 0, g = 0, b = 0, a = 0;
+ for (let dy = 0; dy < factor; dy++) {
+ for (let dx = 0; dx < factor; dx++) {
+ const si = ((y * factor + dy) * width + (x * factor + dx)) * 4;
+ r += pixels[si]; g += pixels[si + 1]; b += pixels[si + 2]; a += pixels[si + 3];
+ }
+ }
+ const n = factor * factor;
+ const di = (y * ow + x) * 4;
+ out[di] = r / n; out[di + 1] = g / n; out[di + 2] = b / n; out[di + 3] = a / n;
+ }
+ }
+ return { pixels: out, width: ow, height: oh };
+}
diff --git a/flow-state/src/engine/passes.js b/flow-state/src/engine/passes.js
new file mode 100644
index 0000000..ed14423
--- /dev/null
+++ b/flow-state/src/engine/passes.js
@@ -0,0 +1,174 @@
+// Fullscreen shader passes used by the compositor: blending, feedback, bloom, grade.
+// All noise here is keyed off u_frame rather than any random source, so grain is
+// identical between a preview run and an export of the same frame.
+
+export const BLEND_FRAG = `
+precision highp float;
+uniform sampler2D u_base;
+uniform sampler2D u_src;
+uniform float u_amount;
+uniform int u_mode;
+varying vec2 vUv;
+
+vec3 blendOverlay(vec3 b, vec3 s) {
+ return mix(2.0 * b * s, 1.0 - 2.0 * (1.0 - b) * (1.0 - s), step(0.5, b));
+}
+vec3 blendSoftLight(vec3 b, vec3 s) {
+ return mix(2.0 * b * s + b * b * (1.0 - 2.0 * s),
+ sqrt(b) * (2.0 * s - 1.0) + 2.0 * b * (1.0 - s),
+ step(0.5, s));
+}
+
+void main() {
+ vec4 base = texture2D(u_base, vUv);
+ vec4 src = texture2D(u_src, vUv);
+ float a = src.a * u_amount;
+ vec3 result;
+
+ if (u_mode == 1) result = base.rgb + src.rgb * a; // add
+ else if (u_mode == 2) result = 1.0 - (1.0 - base.rgb) * (1.0 - src.rgb * a); // screen
+ else if (u_mode == 3) result = mix(base.rgb, base.rgb * src.rgb, a); // multiply
+ else if (u_mode == 4) result = mix(base.rgb, blendOverlay(base.rgb, src.rgb), a);
+ else if (u_mode == 5) result = mix(base.rgb, blendSoftLight(base.rgb, src.rgb), a);
+ else result = mix(base.rgb, src.rgb, a); // normal
+
+ gl_FragColor = vec4(result, max(base.a, a));
+}
+`;
+
+/**
+ * Frame feedback. Disproportionately responsible for images looking alive rather
+ * than merely animated: the previous frame is re-sampled through a small zoom and
+ * rotation, decayed, and added back.
+ */
+export const FEEDBACK_FRAG = `
+precision highp float;
+uniform sampler2D u_current;
+uniform sampler2D u_history;
+uniform float u_decay;
+uniform float u_amount;
+uniform float u_zoom;
+uniform float u_rotate;
+uniform float u_aspect;
+varying vec2 vUv;
+
+void main() {
+ vec3 cur = texture2D(u_current, vUv).rgb;
+
+ vec2 p = (vUv - 0.5);
+ p.x *= u_aspect;
+ float c = cos(u_rotate), s = sin(u_rotate);
+ p = mat2(c, -s, s, c) * p * u_zoom;
+ p.x /= u_aspect;
+ vec2 warped = p + 0.5;
+
+ vec3 hist = texture2D(u_history, clamp(warped, 0.0, 1.0)).rgb;
+ // Decay strictly below 1 keeps the loop convergent; the 10k-frame stability
+ // check in tools/ verifies it neither saturates to white nor dies to black.
+ vec3 outC = cur + hist * u_decay * u_amount;
+ gl_FragColor = vec4(min(outC, vec3(4.0)), 1.0);
+}
+`;
+
+export const BRIGHT_FRAG = `
+precision highp float;
+uniform sampler2D u_tex;
+uniform float u_threshold;
+uniform float u_knee;
+varying vec2 vUv;
+void main() {
+ vec3 c = texture2D(u_tex, vUv).rgb;
+ float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
+ float k = smoothstep(u_threshold, u_threshold + u_knee, l);
+ gl_FragColor = vec4(c * k, 1.0);
+}
+`;
+
+export const BLUR_FRAG = `
+precision highp float;
+uniform sampler2D u_tex;
+uniform vec2 u_direction; // texel-space step, already scaled by resolution
+varying vec2 vUv;
+void main() {
+ // 9-tap gaussian, separable.
+ vec3 sum = texture2D(u_tex, vUv).rgb * 0.227027;
+ vec2 o1 = u_direction * 1.3846153846;
+ vec2 o2 = u_direction * 3.2307692308;
+ sum += (texture2D(u_tex, vUv + o1).rgb + texture2D(u_tex, vUv - o1).rgb) * 0.3162162162;
+ sum += (texture2D(u_tex, vUv + o2).rgb + texture2D(u_tex, vUv - o2).rgb) * 0.0702702703;
+ gl_FragColor = vec4(sum, 1.0);
+}
+`;
+
+export const COMPOSITE_FRAG = `
+precision highp float;
+uniform sampler2D u_tex;
+uniform sampler2D u_bloom;
+uniform float u_bloomAmount;
+uniform float u_chroma;
+uniform float u_grain;
+uniform float u_vignette;
+uniform float u_contrast;
+uniform float u_saturation;
+uniform float u_lift;
+uniform float u_exposure;
+uniform float u_fade;
+uniform float u_frame;
+uniform vec2 u_resolution;
+varying vec2 vUv;
+
+float hash13(vec3 p) {
+ p = fract(p * 0.1031);
+ p += dot(p, p.yzx + 33.33);
+ return fract((p.x + p.y) * p.z);
+}
+
+void main() {
+ vec2 uv = vUv;
+ vec2 dir = (uv - 0.5);
+
+ // Chromatic aberration, radial, resolution-independent.
+ vec3 col;
+ if (u_chroma > 0.0001) {
+ float amt = u_chroma * 0.01;
+ col.r = texture2D(u_tex, uv - dir * amt).r;
+ col.g = texture2D(u_tex, uv).g;
+ col.b = texture2D(u_tex, uv + dir * amt).b;
+ } else {
+ col = texture2D(u_tex, uv).rgb;
+ }
+
+ col += texture2D(u_bloom, uv).rgb * u_bloomAmount;
+ col *= u_exposure;
+
+ // Grade: lift, contrast, saturation.
+ col += u_lift;
+ col = (col - 0.5) * u_contrast + 0.5;
+ float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
+ col = mix(vec3(l), col, u_saturation);
+
+ // Vignette in normalised space so it matches at any output size.
+ float v = 1.0 - u_vignette * dot(dir, dir) * 2.0;
+ col *= clamp(v, 0.0, 1.0);
+
+ // Deterministic grain: keyed on frame index, never on a random source.
+ if (u_grain > 0.0001) {
+ float n = hash13(vec3(floor(uv * u_resolution), u_frame));
+ col += (n - 0.5) * u_grain;
+ }
+
+ col *= u_fade;
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
+}
+`;
+
+export const COPY_FRAG = `
+precision highp float;
+uniform sampler2D u_tex;
+varying vec2 vUv;
+void main() { gl_FragColor = texture2D(u_tex, vUv); }
+`;
+
+export const BLEND_MODE_IDS = {
+ normal: 0, add: 1, screen: 2, multiply: 3, overlay: 4, softlight: 5,
+};
diff --git a/flow-state/src/engine/rng.js b/flow-state/src/engine/rng.js
new file mode 100644
index 0000000..3acdf56
--- /dev/null
+++ b/flow-state/src/engine/rng.js
@@ -0,0 +1,99 @@
+// Seeded PRNG. Every random value in the engine comes from here — never Math.random,
+// which would break frame-for-frame reproducibility between preview and export.
+// Mulberry32, carried over from party-stage but with the state held per instance
+// instead of on a global, so layers can't perturb each other's sequences.
+
+export class Rng {
+ constructor(seed = 1) {
+ this.seed = seed >>> 0;
+ this.state = this.seed;
+ }
+
+ reset(seed = this.seed) {
+ this.seed = seed >>> 0;
+ this.state = this.seed;
+ return this;
+ }
+
+ /** Uniform [0, 1) */
+ next() {
+ let t = (this.state += 0x6d2b79f5) >>> 0;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ }
+
+ /** Uniform [min, max) */
+ range(min, max) {
+ return min + this.next() * (max - min);
+ }
+
+ /** Integer in [min, max] inclusive */
+ int(min, max) {
+ return Math.floor(this.range(min, max + 1));
+ }
+
+ bool(p = 0.5) {
+ return this.next() < p;
+ }
+
+ pick(array) {
+ return array[this.int(0, array.length - 1)];
+ }
+
+ /** Weighted pick. `weights` parallels `array`; non-positive weights are skipped. */
+ pickWeighted(array, weights) {
+ let total = 0;
+ for (let i = 0; i < array.length; i++) total += Math.max(0, weights[i] || 0);
+ if (total <= 0) return this.pick(array);
+ let r = this.next() * total;
+ for (let i = 0; i < array.length; i++) {
+ r -= Math.max(0, weights[i] || 0);
+ if (r <= 0) return array[i];
+ }
+ return array[array.length - 1];
+ }
+
+ /** Fisher-Yates, returns a new array. */
+ shuffle(array) {
+ const out = array.slice();
+ for (let i = out.length - 1; i > 0; i--) {
+ const j = this.int(0, i);
+ [out[i], out[j]] = [out[j], out[i]];
+ }
+ return out;
+ }
+
+ /** Derive an independent stream. Same parent + same label always yields the same child. */
+ fork(label) {
+ return new Rng(hashString(String(label), this.seed));
+ }
+}
+
+/** FNV-1a over a string, optionally salted. Used for deriving seeds from names. */
+export function hashString(str, salt = 0x811c9dc5) {
+ let h = salt >>> 0;
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 0x01000193) >>> 0;
+ }
+ return h >>> 0;
+}
+
+/**
+ * FNV-1a over PCM samples, decimated so a six-minute track hashes in milliseconds.
+ * This is what makes a given audio file always produce the same look: the seed is a
+ * property of the content, not of the filename or the wall clock.
+ */
+export function hashSamples(float32, stride = 997) {
+ let h = 0x811c9dc5 >>> 0;
+ for (let i = 0; i < float32.length; i += stride) {
+ // Quantize so imperceptible float noise can't change the seed.
+ const q = Math.round(float32[i] * 32767) & 0xffff;
+ h ^= q & 0xff;
+ h = Math.imul(h, 0x01000193) >>> 0;
+ h ^= (q >>> 8) & 0xff;
+ h = Math.imul(h, 0x01000193) >>> 0;
+ }
+ return h >>> 0;
+}
diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js
new file mode 100644
index 0000000..3281e33
--- /dev/null
+++ b/flow-state/src/engine/shader-contract.js
@@ -0,0 +1,183 @@
+// The uniform contract every shader scene is compiled against.
+//
+// Scenes do not write `main()`. They define:
+//
+// vec4 scene(vec2 uv, vec2 p)
+//
+// where `uv` is 0..1 across the frame and `p` is centred, aspect-corrected,
+// roughly -1..1 on the short axis. Everything else — the preamble, the varying,
+// main() itself — is injected here. That boilerplate reduction is what makes a
+// thirty-scene library affordable to write and to keep consistent.
+//
+// RESOLUTION INDEPENDENCE: work in `uv`/`p`, never in pixels. If you genuinely
+// need a pixel-sized feature, scale it by u_pixelScale so a 720p preview and a
+// 4K export agree. The dual-resolution diff in tools/ exists to catch violations.
+
+export const VERTEX_SHADER = `
+varying vec2 vUv;
+void main() {
+ vUv = uv;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}
+`;
+
+/** Audio-reactive uniforms, filled per frame from the FeatureTrack. */
+export const AUDIO_UNIFORMS = [
+ 'u_loudness', // overall level, track-normalised 0..1
+ 'u_rms',
+ 'u_bandSub', // 20-60 Hz
+ 'u_bandLow', // 60-250 Hz
+ 'u_bandMid', // 250-2k
+ 'u_bandHigh', // 2k-6k
+ 'u_bandAir', // 6k-16k
+ 'u_flux', // onset strength
+ 'u_centroid', // spectral brightness 0..1
+ 'u_flatness', // noisy vs tonal 0..1
+ 'u_width', // stereo width 0..1
+ 'u_beat', // decaying spike on each beat, 1 at the hit
+ 'u_beatPhase', // 0..1 within the beat
+ 'u_barPhase', // 0..1 within the bar
+ 'u_phrasePhase',// 0..1 within an 8-bar phrase
+ 'u_sectionProgress',
+ 'u_sectionEnergy',
+ 'u_buildSlope', // >0 while energy is ramping toward the next section
+];
+
+export const FRAME_UNIFORMS = [
+ 'u_time', 'u_frame', 'u_progress', 'u_seed',
+ 'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
+];
+
+const PREAMBLE = `
+precision highp float;
+
+uniform vec2 u_resolution;
+uniform float u_aspect;
+uniform float u_pixelScale;
+uniform float u_time;
+uniform float u_frame;
+uniform float u_progress;
+uniform float u_seed;
+uniform float u_opacity;
+
+uniform vec3 u_colors[8];
+uniform int u_colorCount;
+
+${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
+
+uniform sampler2D u_prev;
+uniform int u_hasPrev;
+
+varying vec2 vUv;
+
+// --- palette helpers -------------------------------------------------------
+// Scenes should reach for these instead of hardcoding colours, so the look
+// generator can actually recolour them per track.
+
+vec3 pal(int i) {
+ int n = max(u_colorCount, 1);
+ int k = int(mod(float(i), float(n)));
+ for (int j = 0; j < 8; j++) { if (j == k) return u_colors[j]; }
+ return u_colors[0];
+}
+
+/** Continuous ramp through the palette; t wraps. */
+vec3 palRamp(float t) {
+ int n = max(u_colorCount, 1);
+ float f = fract(t) * float(n);
+ int i = int(floor(f));
+ return mix(pal(i), pal(i + 1), smoothstep(0.0, 1.0, fract(f)));
+}
+
+// --- noise -----------------------------------------------------------------
+
+float hash11(float n) { return fract(sin(n) * 43758.5453123); }
+float hash12(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123); }
+vec2 hash22(vec2 p) {
+ vec3 a = fract(vec3(p.xyx) * vec3(123.34, 234.34, 345.65));
+ a += dot(a, a + 34.45);
+ return fract(vec2(a.x * a.y, a.y * a.z));
+}
+
+float vnoise(vec2 p) {
+ vec2 i = floor(p), f = fract(p);
+ f = f * f * (3.0 - 2.0 * f);
+ float a = hash12(i), b = hash12(i + vec2(1.0, 0.0));
+ float c = hash12(i + vec2(0.0, 1.0)), d = hash12(i + vec2(1.0, 1.0));
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p, int octaves) {
+ float v = 0.0, amp = 0.5;
+ for (int i = 0; i < 8; i++) {
+ if (i >= octaves) break;
+ v += amp * vnoise(p);
+ p *= 2.02;
+ amp *= 0.5;
+ }
+ return v;
+}
+
+/** Cheap divergence-free-ish flow field. The backbone of the "flow" family. */
+vec2 curl(vec2 p, float t) {
+ float e = 0.1;
+ float n1 = fbm(p + vec2(0.0, e) + t, 4);
+ float n2 = fbm(p - vec2(0.0, e) + t, 4);
+ float n3 = fbm(p + vec2(e, 0.0) + t, 4);
+ float n4 = fbm(p - vec2(e, 0.0) + t, 4);
+ return vec2(n1 - n2, n4 - n3) / (2.0 * e);
+}
+
+mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
+
+/** N-fold kaleidoscopic fold of a centred coordinate. */
+vec2 kaleido(vec2 p, float sides) {
+ if (sides < 1.5) return p;
+ float a = atan(p.y, p.x);
+ float r = length(p);
+ float seg = 6.28318530718 / sides;
+ a = abs(mod(a + seg * 0.5, seg) - seg * 0.5);
+ return vec2(cos(a), sin(a)) * r;
+}
+
+vec3 prev(vec2 uv) {
+ if (u_hasPrev == 0) return vec3(0.0);
+ return texture2D(u_prev, uv).rgb;
+}
+
+float sat(float x) { return clamp(x, 0.0, 1.0); }
+vec3 sat3(vec3 x) { return clamp(x, 0.0, 1.0); }
+`;
+
+const EPILOGUE = `
+void main() {
+ vec2 uv = vUv;
+ vec2 p = (uv - 0.5) * 2.0;
+ p.x *= u_aspect;
+ vec4 col = scene(uv, p);
+ gl_FragColor = vec4(col.rgb, col.a * u_opacity);
+}
+`;
+
+/**
+ * Assemble a complete fragment shader from a scene body plus its declared params.
+ * Param uniforms are appended to the preamble so a scene never declares them itself —
+ * the schema is the single source of truth, which is what the lint checks.
+ */
+export function buildFragmentShader(sceneModule) {
+ const paramUniforms = [];
+ for (const [name, def] of Object.entries(sceneModule.params || {})) {
+ if (!def.uniform) continue;
+ if (def.type === 'palette') continue; // palette rides in u_colors
+ const glslType = def.type === 'int' ? 'int' : def.type === 'vec2' ? 'vec2' : 'float';
+ paramUniforms.push(`uniform ${glslType} ${def.uniform}; // param: ${name}`);
+ }
+
+ return [
+ PREAMBLE,
+ paramUniforms.join('\n'),
+ '\n// ---- scene ----\n',
+ sceneModule.shader,
+ EPILOGUE,
+ ].join('\n');
+}
diff --git a/flow-state/src/main.js b/flow-state/src/main.js
new file mode 100644
index 0000000..493a3ce
--- /dev/null
+++ b/flow-state/src/main.js
@@ -0,0 +1,2 @@
+// Application entry point — built up as the phases land.
+console.log("flow-state");
diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js
new file mode 100644
index 0000000..a0add49
--- /dev/null
+++ b/flow-state/src/params/schema.js
@@ -0,0 +1,173 @@
+// Declarative parameter schema.
+//
+// This is the load-bearing abstraction for library scale. One declaration drives:
+// 1. uniform binding (Layer)
+// 2. generated UI controls (ui/ParamPanel)
+// 3. seeded per-track sampling (look/LookGenerator)
+// 4. arc automation (look/ArcDriver)
+// 5. save/load of presets
+//
+// Adding a scene therefore costs a shader plus a params block, and nothing else.
+// tools/lint-scenes.js machine-checks every declaration against its shader source.
+
+export const PARAM_TYPES = ['float', 'int', 'bool', 'vec2', 'palette'];
+
+/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
+export const REACTIVE_FEATURES = [
+ 'loudness', 'rms',
+ 'bandSub', 'bandLow', 'bandMid', 'bandHigh', 'bandAir',
+ 'flux', 'centroid', 'flatness', 'width',
+ 'beat', 'beatPhase', 'barPhase', 'phrasePhase',
+ 'sectionProgress', 'sectionEnergy', 'buildSlope',
+];
+
+export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
+
+export function defaultValue(def) {
+ if (def.default !== undefined) return def.default;
+ switch (def.type) {
+ case 'bool': return false;
+ case 'int': return Math.round(def.range ? def.range[0] : 0);
+ case 'vec2': return [0, 0];
+ case 'palette': return null; // supplied by the look, not sampled here
+ default: return def.range ? def.range[0] : 0;
+ }
+}
+
+export function defaultValues(module) {
+ const out = {};
+ for (const [name, def] of Object.entries(module.params || {})) {
+ out[name] = defaultValue(def);
+ }
+ return out;
+}
+
+export function clampValue(def, value) {
+ if (def.type === 'bool') return !!value;
+ if (def.type === 'palette') return value;
+ if (def.type === 'vec2') {
+ const [lo, hi] = def.range || [0, 1];
+ return [Math.min(hi, Math.max(lo, value[0])), Math.min(hi, Math.max(lo, value[1]))];
+ }
+ const [lo, hi] = def.range || [0, 1];
+ let v = Math.min(hi, Math.max(lo, value));
+ if (def.type === 'int') v = Math.round(v);
+ return v;
+}
+
+/**
+ * Sample a full parameter set from the declared ranges.
+ *
+ * `bias` (0..1 per key, optional) nudges sampling toward the top of a range —
+ * this is how a track's measured character reaches the parameters without every
+ * scene needing to know about audio features. `energy: 0.8` on a hard track
+ * pushes density-ish params up without pinning them, so seed variation survives.
+ */
+export function sampleValues(module, rng, bias = {}) {
+ const out = {};
+ for (const [name, def] of Object.entries(module.params || {})) {
+ if (def.type === 'palette') { out[name] = null; continue; }
+ if (def.fixed) { out[name] = defaultValue(def); continue; }
+
+ const b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;
+
+ if (def.type === 'bool') {
+ out[name] = rng.bool(0.25 + b * 0.5);
+ continue;
+ }
+ const [lo, hi] = def.range || [0, 1];
+ // Triangular-ish blend of a uniform draw with the bias target: keeps the
+ // full range reachable (so the seed contact sheet stays wide) while still
+ // letting track character shift the centre of mass.
+ const u = rng.next();
+ const target = lo + (hi - lo) * b;
+ const mixAmount = def.biasStrength !== undefined ? def.biasStrength : 0.45;
+ let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount;
+
+ if (def.type === 'vec2') {
+ const u2 = rng.next();
+ const v2 = (lo + (hi - lo) * u2) * (1 - mixAmount) + target * mixAmount;
+ out[name] = [v, v2];
+ continue;
+ }
+ if (def.type === 'int') v = Math.round(v);
+ out[name] = clampValue(def, v);
+ }
+ return out;
+}
+
+/** Evenly spaced probe values across a param's range, for the range-sweep check. */
+export function sweepValues(def, steps = 5) {
+ if (def.type === 'bool') return [false, true];
+ if (def.type === 'palette') return [null];
+ const [lo, hi] = def.range || [0, 1];
+ const out = [];
+ for (let i = 0; i < steps; i++) {
+ let v = lo + ((hi - lo) * i) / (steps - 1);
+ if (def.type === 'int') v = Math.round(v);
+ out.push(def.type === 'vec2' ? [v, v] : v);
+ }
+ return out;
+}
+
+/**
+ * Structural validation of a scene module. Returns an array of human-readable
+ * problems; empty means clean. Shared by the lint tool and the runtime registry,
+ * so a malformed scene can't reach the compositor.
+ */
+export function validateModule(module) {
+ const errors = [];
+ const id = module?.name || '';
+
+ if (!module.name) errors.push('missing `name`');
+ if (!module.family) errors.push(`${id}: missing \`family\``);
+ if (!module.kind) errors.push(`${id}: missing \`kind\``);
+ if (module.kind === 'fragment' && !module.shader) errors.push(`${id}: kind 'fragment' but no \`shader\``);
+ if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.shader)) {
+ errors.push(`${id}: shader must define \`vec4 scene(vec2 uv, vec2 p)\``);
+ }
+
+ const params = module.params || {};
+ const uniformNames = new Set();
+
+ for (const [name, def] of Object.entries(params)) {
+ const where = `${id}.${name}`;
+ if (!def.type) errors.push(`${where}: missing \`type\``);
+ else if (!PARAM_TYPES.includes(def.type)) errors.push(`${where}: unknown type '${def.type}'`);
+
+ if (def.type !== 'palette' && def.type !== 'bool' && !def.range) {
+ errors.push(`${where}: numeric param needs a \`range\``);
+ }
+ if (def.range && (def.range.length !== 2 || def.range[0] >= def.range[1])) {
+ errors.push(`${where}: \`range\` must be [min, max] with min < max`);
+ }
+ if (def.default !== undefined && def.range && typeof def.default === 'number') {
+ if (def.default < def.range[0] || def.default > def.range[1]) {
+ errors.push(`${where}: default ${def.default} outside range [${def.range}]`);
+ }
+ }
+ if (def.uniform) {
+ if (uniformNames.has(def.uniform)) errors.push(`${where}: duplicate uniform '${def.uniform}'`);
+ uniformNames.add(def.uniform);
+ if (!/^u_[A-Za-z0-9_]+$/.test(def.uniform)) {
+ errors.push(`${where}: uniform '${def.uniform}' should be named u_*`);
+ }
+ }
+ if (def.bias && typeof def.bias !== 'string') errors.push(`${where}: \`bias\` must be a key name`);
+ }
+
+ for (const [name, r] of Object.entries(module.reactive || {})) {
+ const where = `${id}.reactive.${name}`;
+ if (!params[name]) errors.push(`${where}: no such param`);
+ if (!r.feature) errors.push(`${where}: missing \`feature\``);
+ else if (!REACTIVE_FEATURES.includes(r.feature)) {
+ errors.push(`${where}: unknown feature '${r.feature}'`);
+ }
+ if (r.response && !REACTIVE_RESPONSES.includes(r.response)) {
+ errors.push(`${where}: unknown response '${r.response}'`);
+ }
+ if (typeof r.amount !== 'number') errors.push(`${where}: missing numeric \`amount\``);
+ }
+
+ return errors;
+}
diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js
new file mode 100644
index 0000000..1327749
--- /dev/null
+++ b/flow-state/src/scenes/registry.js
@@ -0,0 +1,50 @@
+import { validateModule } from '../params/schema.js';
+
+import { nebula } from './shader/nebula.js';
+
+/**
+ * The scene library. Families exist so the arc driver can choose by section
+ * character rather than at random — a breakdown never lands on a strobing glitch
+ * scene, and an intro never opens at full density.
+ */
+export const FAMILIES = {
+ flow: { label: 'Flow', energy: [0.0, 0.7] },
+ organic: { label: 'Organic', energy: [0.0, 0.8] },
+ minimal: { label: 'Minimal', energy: [0.0, 0.45] },
+ structural: { label: 'Structural', energy: [0.3, 0.9] },
+ geometric: { label: 'Geometric', energy: [0.4, 1.0] },
+ glitch: { label: 'Glitch', energy: [0.6, 1.0] },
+};
+
+const MODULES = [
+ nebula,
+];
+
+const errors = [];
+for (const m of MODULES) {
+ const e = validateModule(m);
+ if (e.length) errors.push(...e);
+ if (m.family && !FAMILIES[m.family]) errors.push(`${m.name}: unknown family '${m.family}'`);
+}
+if (errors.length) {
+ // Fail loudly at import: a malformed scene must never reach the compositor,
+ // where the symptom would be a black frame with no explanation.
+ console.error('[registry] invalid scene modules:\n' + errors.join('\n'));
+}
+
+export const scenes = MODULES;
+export const sceneErrors = errors;
+
+export function sceneByName(name) {
+ return MODULES.find((m) => m.name === name) || null;
+}
+
+export function scenesInFamily(family) {
+ return MODULES.filter((m) => m.family === family);
+}
+
+export function familyNames() {
+ return Object.keys(FAMILIES);
+}
+
+export default scenes;
diff --git a/flow-state/src/scenes/shader/nebula.js b/flow-state/src/scenes/shader/nebula.js
new file mode 100644
index 0000000..7fee7ae
--- /dev/null
+++ b/flow-state/src/scenes/shader/nebula.js
@@ -0,0 +1,59 @@
+// Ported from party-stage's "Deep Nebula". Changes on port:
+// - LED-grid mask removed (it existed to sell a screen inside a 3D room)
+// - hardcoded vec3 colours replaced with palette lookups
+// - magic numbers lifted into declared params
+// - beat reactivity moved from a single u_beat multiply to the reactive block
+
+export const nebula = {
+ name: 'Deep Nebula',
+ family: 'organic',
+ kind: 'fragment',
+
+ params: {
+ scale: { type: 'float', range: [4, 24], default: 12, uniform: 'u_scale', bias: 'density' },
+ swirl: { type: 'float', range: [0, 3], default: 1.0, uniform: 'u_swirl', bias: 'motion' },
+ rings: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_rings' },
+ glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },
+ speed: { type: 'float', range: [0.1, 1.5],default: 0.5, uniform: 'u_speed', bias: 'motion' },
+ depth: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_depth' },
+ palette: { type: 'palette', count: 4 },
+ },
+
+ reactive: {
+ glow: { feature: 'beat', amount: 0.35, response: 'spike' },
+ swirl: { feature: 'bandLow', amount: 0.20 },
+ rings: { feature: 'flux', amount: 0.25, response: 'smooth' },
+ },
+
+ shader: `
+vec4 scene(vec2 uv, vec2 p) {
+ float r = length(p);
+ float a = atan(p.y, p.x);
+ float t = u_time * u_speed + u_seed;
+
+ // Layered drift, the "nebula" body.
+ float n = fbm(p * u_scale * 0.25 + vec2(t * 0.2, -t * 0.15), 5);
+ float n2 = fbm(p * u_scale * 0.6 - vec2(t * 0.1, t * 0.25) + n, 4);
+ float body = sin(r * u_scale - t * 2.0 + n * 6.0 * u_swirl) * 0.5 + 0.5;
+ body = mix(body, n2, u_depth);
+
+ vec3 col = mix(pal(0), pal(1), sat(body));
+ col = mix(col, pal(2), sat(n2 * n2) * u_depth);
+
+ // Concentric pulse, tied to onset energy rather than a fixed rate.
+ float ring = smoothstep(0.4, 0.5, abs(fract(r * 2.0 - t * 3.0) - 0.5));
+ col += pal(1) * ring * u_rings * 0.6;
+
+ // Core glow.
+ float edge = 1.0 - smoothstep(0.1, 0.9, r);
+ col += pal(3) * edge * u_glow * 0.5;
+
+ // Vignette the far field so the frame has a subject.
+ col *= 0.5 + 0.5 * (1.0 - smoothstep(0.6, 1.6, r));
+
+ return vec4(col, 1.0);
+}
+`,
+};
+
+export default nebula;
diff --git a/flow-state/src/ui/style.css b/flow-state/src/ui/style.css
new file mode 100644
index 0000000..f1dd30b
--- /dev/null
+++ b/flow-state/src/ui/style.css
@@ -0,0 +1 @@
+/* styles */
diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js
new file mode 100644
index 0000000..dabe4a1
--- /dev/null
+++ b/flow-state/tools/lint-scenes.js
@@ -0,0 +1,147 @@
+#!/usr/bin/env node
+// Static gates that don't need a GPU:
+//
+// 1. Determinism grep — no wall-clock or unseeded randomness in engine/scene code.
+// 2. Scene schema lint — the params block and the shader source must agree,
+// in BOTH directions. A declared param that no shader reads is dead weight;
+// a uniform the shader reads that nothing declares is a silent zero, which
+// is the single most annoying way for a scene to look subtly wrong.
+//
+// Run: npm run lint:scenes
+
+import { readdirSync, readFileSync, statSync } from 'fs';
+import { join, relative, dirname } from 'path';
+import { fileURLToPath, pathToFileURL } from 'url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const SRC = join(root, 'src');
+
+let failures = 0;
+const fail = (msg) => { console.error(` ✗ ${msg}`); failures++; };
+const ok = (msg) => console.log(` ✓ ${msg}`);
+
+function walk(dir, out = []) {
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) walk(full, out);
+ else if (entry.endsWith('.js')) out.push(full);
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------- grep gate
+
+const FORBIDDEN = [
+ { pattern: /\bMath\.random\s*\(/, name: 'Math.random()', why: 'use Rng — unseeded randomness breaks reproducibility' },
+ { pattern: /\bperformance\.now\s*\(/, name: 'performance.now()', why: 'use Timeline — wall clock breaks preview/export parity' },
+ { pattern: /\bDate\.now\s*\(/, name: 'Date.now()', why: 'use Timeline' },
+ { pattern: /\bnew Date\s*\(/, name: 'new Date()', why: 'use Timeline' },
+];
+
+// Directories whose output must be a pure function of (seed, params, frame).
+const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params'];
+// Files legitimately allowed a wall clock: perf measurement, not image content.
+const ALLOWED = new Set(['engine/perf.js']);
+
+console.log('\ndeterminism grep');
+{
+ let checked = 0;
+ for (const dir of DETERMINISTIC_DIRS) {
+ const full = join(SRC, dir);
+ let files;
+ try { files = walk(full); } catch { continue; }
+ for (const file of files) {
+ const rel = relative(SRC, file).replace(/\\/g, '/');
+ if (ALLOWED.has(rel)) continue;
+ checked++;
+ const source = readFileSync(file, 'utf8');
+ const lines = source.split('\n');
+ lines.forEach((line, i) => {
+ if (/^\s*(\/\/|\*)/.test(line)) return; // comments may name them
+ for (const f of FORBIDDEN) {
+ if (f.pattern.test(line)) fail(`${rel}:${i + 1} uses ${f.name} — ${f.why}`);
+ }
+ });
+ }
+ }
+ if (!failures) ok(`${checked} files clean of wall-clock and unseeded randomness`);
+}
+
+// ------------------------------------------------------------- scene lint
+
+const CONTRACT_UNIFORMS = new Set([
+ 'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress',
+ 'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev',
+ 'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh',
+ 'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat',
+ 'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress',
+ 'u_sectionEnergy', 'u_buildSlope',
+]);
+
+console.log('\nscene schema lint');
+{
+ const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);
+ const { validateModule } = await import(pathToFileURL(join(SRC, 'params/schema.js')).href);
+
+ if (!scenes.length) fail('no scenes registered');
+
+ const seenNames = new Set();
+
+ for (const module of scenes) {
+ const id = module.name || '';
+
+ for (const err of validateModule(module)) fail(err);
+
+ if (seenNames.has(id)) fail(`duplicate scene name '${id}'`);
+ seenNames.add(id);
+
+ if (module.family && !FAMILIES[module.family]) {
+ fail(`${id}: unknown family '${module.family}'`);
+ }
+ if (module.kind !== 'fragment') continue;
+
+ const src = module.shader || '';
+ const declared = new Map();
+ for (const [name, def] of Object.entries(module.params || {})) {
+ if (def.uniform) declared.set(def.uniform, name);
+ }
+
+ // Direction 1: every declared uniform is actually read by the shader.
+ for (const [uniform, param] of declared) {
+ const used = new RegExp(`\\b${uniform}\\b`).test(src);
+ if (!used) fail(`${id}: param '${param}' declares ${uniform}, but the shader never reads it`);
+ }
+
+ // Direction 2: every u_* the shader reads is declared somewhere.
+ const referenced = new Set(src.match(/\bu_[A-Za-z0-9_]+\b/g) || []);
+ for (const uniform of referenced) {
+ if (CONTRACT_UNIFORMS.has(uniform)) continue;
+ if (declared.has(uniform)) continue;
+ fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`);
+ }
+
+ // A scene with a palette param should actually use the palette helpers,
+ // otherwise the look generator cannot recolour it.
+ const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');
+ if (hasPalette && !/\b(pal|palRamp)\s*\(/.test(src)) {
+ fail(`${id}: declares a palette but never calls pal()/palRamp()`);
+ }
+ // Hardcoded saturated colours are the thing that made the party-stage
+ // shaders resist recolouring; flag the obvious cases.
+ const literalColors = src.match(/vec3\s*\(\s*[01]\.\d+\s*,\s*[01]\.\d+\s*,\s*[01]\.\d+\s*\)/g) || [];
+ const suspicious = literalColors.filter((c) => !/vec3\s*\(\s*0\.0*\s*,\s*0\.0*\s*,\s*0\.0*\s*\)/.test(c));
+ if (hasPalette && suspicious.length > 2) {
+ fail(`${id}: ${suspicious.length} hardcoded vec3 colour literals — use pal()`);
+ }
+ }
+
+ if (!failures) ok(`${scenes.length} scenes: schemas and shaders agree both ways`);
+ else console.log(` (${scenes.length} scenes checked)`);
+}
+
+console.log('');
+if (failures) {
+ console.error(`FAILED — ${failures} problem${failures === 1 ? '' : 's'}\n`);
+ process.exit(1);
+}
+console.log('all static gates passed\n');
diff --git a/flow-state/vite.config.js b/flow-state/vite.config.js
new file mode 100644
index 0000000..ed51d1c
--- /dev/null
+++ b/flow-state/vite.config.js
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite';
+import { resolve } from 'path';
+
+export default defineConfig({
+ server: {
+ port: 5180,
+ host: '127.0.0.1',
+ },
+ build: {
+ rollupOptions: {
+ input: {
+ main: resolve(process.cwd(), 'index.html'),
+ checks: resolve(process.cwd(), 'checks.html'),
+ },
+ },
+ },
+});