From 7e31c19d6e72a9c7588023f3fa969d69465a1758 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Wed, 5 Aug 2026 10:59:30 +0200 Subject: [PATCH] Phase 0: determinism spine Engine core: Timeline (fixed dt, audio-mastered in realtime), seeded Rng, Renderer, Layer/ShaderLayer/SceneLayer, Compositor with blend modes, feedback and post chain. Shader scenes are compiled against a fixed uniform contract and define only `vec4 scene(vec2 uv, vec2 p)`, so adding a scene costs a shader plus a params block. Deep Nebula ported from party-stage as the first one. Gate passes, 7/7 in checks.html: - 300 frames rendered twice are bit-identical - a fresh Engine reproduces the same frames - simulated dropped frames change nothing (proves dt is fixed) - seek matches sequential playback - 320x180 vs 1280x720 agree within 0.010 (limit 0.06) - seeded rng reproducible, forked streams independent - compositor reset clears feedback history Static gates: no wall-clock or unseeded randomness in deterministic directories; scene schemas and shader sources agree in both directions. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 11 + flow-state/.gitignore | 4 + flow-state/checks.html | 42 + flow-state/index.html | 73 ++ flow-state/package-lock.json | 1132 ++++++++++++++++++++++ flow-state/package.json | 20 + flow-state/src/checks/framework.js | 81 ++ flow-state/src/checks/main.js | 48 + flow-state/src/checks/phase0.js | 163 ++++ flow-state/src/checks/phase1.js | 1 + flow-state/src/checks/phase2.js | 1 + flow-state/src/checks/phase3.js | 1 + flow-state/src/checks/phase4.js | 1 + flow-state/src/checks/phase5.js | 1 + flow-state/src/checks/phase6.js | 1 + flow-state/src/engine/Compositor.js | 267 +++++ flow-state/src/engine/Engine.js | 127 +++ flow-state/src/engine/Layer.js | 232 +++++ flow-state/src/engine/Renderer.js | 119 +++ flow-state/src/engine/Timeline.js | 81 ++ flow-state/src/engine/hash.js | 97 ++ flow-state/src/engine/passes.js | 174 ++++ flow-state/src/engine/rng.js | 99 ++ flow-state/src/engine/shader-contract.js | 183 ++++ flow-state/src/main.js | 2 + flow-state/src/params/schema.js | 173 ++++ flow-state/src/scenes/registry.js | 50 + flow-state/src/scenes/shader/nebula.js | 59 ++ flow-state/src/ui/style.css | 1 + flow-state/tools/lint-scenes.js | 147 +++ flow-state/vite.config.js | 17 + 31 files changed, 3408 insertions(+) create mode 100644 .claude/launch.json create mode 100644 flow-state/.gitignore create mode 100644 flow-state/checks.html create mode 100644 flow-state/index.html create mode 100644 flow-state/package-lock.json create mode 100644 flow-state/package.json create mode 100644 flow-state/src/checks/framework.js create mode 100644 flow-state/src/checks/main.js create mode 100644 flow-state/src/checks/phase0.js create mode 100644 flow-state/src/checks/phase1.js create mode 100644 flow-state/src/checks/phase2.js create mode 100644 flow-state/src/checks/phase3.js create mode 100644 flow-state/src/checks/phase4.js create mode 100644 flow-state/src/checks/phase5.js create mode 100644 flow-state/src/checks/phase6.js create mode 100644 flow-state/src/engine/Compositor.js create mode 100644 flow-state/src/engine/Engine.js create mode 100644 flow-state/src/engine/Layer.js create mode 100644 flow-state/src/engine/Renderer.js create mode 100644 flow-state/src/engine/Timeline.js create mode 100644 flow-state/src/engine/hash.js create mode 100644 flow-state/src/engine/passes.js create mode 100644 flow-state/src/engine/rng.js create mode 100644 flow-state/src/engine/shader-contract.js create mode 100644 flow-state/src/main.js create mode 100644 flow-state/src/params/schema.js create mode 100644 flow-state/src/scenes/registry.js create mode 100644 flow-state/src/scenes/shader/nebula.js create mode 100644 flow-state/src/ui/style.css create mode 100644 flow-state/tools/lint-scenes.js create mode 100644 flow-state/vite.config.js 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'), + }, + }, + }, +});