diff --git a/.gitignore b/.gitignore index 4bf613b..774daba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ .DS_Store *.log +node_modules +dist + +# Local study notes (full personal README) +README.local.md # Local-only character data. Do not commit extracted or third-party geometry. replica/geometry-data.js @@ -9,5 +14,7 @@ replica/grok-blob-idle.svg /tmp/ snap-out/ -# Never commit unpacked app dumps +# Never commit unpacked app dumps, unpack scripts, or third-party marks extracted/ +extract-asar.js +assets/ diff --git a/README.md b/README.md index 3ff78c2..644ab75 100644 --- a/README.md +++ b/README.md @@ -3,53 +3,69 @@ Unofficial study of a spring-driven character engine. 非官方学习项目,与 xAI / Grok 无关。本仓库只开源机芯,不含角色几何、商标或第三方应用源码。 -登录页那种会眨眼、换情绪、被弹簧拉着走的角色,不是 GIF / Lottie,而是状态机 + 弹簧 + 多边形眼睛。这里把机芯拆开,方便阅读和改。 +The **site** follows [bloub](https://github.com/jeremy-prt/bloub)’s presentation: a left rail, one character in the middle, Customise / Animations / Settings. The **motion** is ours — springs, 39 states, polygon eyes, overlays, login wrap. Not bloub’s measured 14-state engine. -## 打开 +## Running it -用浏览器打开 [`replica/index.html`](./replica/index.html)。 +```bash +pnpm install +pnpm dev +``` -克隆下来默认**看不到完整角色外形**。没有 `replica/geometry-data.js` 时,页面会停在说明态,不会报错崩掉。 +Then open http://localhost:5191. -自备几何的接口见 [`replica/geometry.schema.md`](./replica/geometry.schema.md)。把符合接口的文件放到 `replica/geometry-data.js` 即可本地预览。该路径已被 git 忽略。 +Clone the repo and the chrome still loads. Without `replica/geometry-data.js` the character stays off; the layout does not fall over. Put a file that matches [`replica/geometry.schema.md`](./replica/geometry.schema.md) at `replica/geometry-data.js` (gitignored) to see the bot. -不要把从商业应用抽出的几何、图标或主包提交到本仓库,也不要再分发。 +The original lab is still there: -## 机芯在做什么 +- [`replica/index.html`](./replica/index.html) — playground + HUD +- [`replica/showcase.html`](./replica/showcase.html) — recording layouts +- [`replica/embed.html`](./replica/embed.html) — `` -- 39 个情绪 / 生命周期状态,每态有眼睛 playlist、眨眼间隔、注视节奏 -- 弹簧驱动位移、挤压、旋转、眨眼、换形 -- 眼睛是多边形 morph,不是贴图 -- 部分状态会切 overlay(thinking / orbit / writing / loading 等) -- 登录包装带 `pose.scale`、指针跟随、onboarding 轮换 +Do not commit extracted geometry, icons, or a third-party app bundle. -登录页常见参数:`sizePx: 64`,`color: "black"`,`shape: "blob"`。 -`pjn(n) = n % 2 === 0 ? idle : onboarding[(n-1)/2]`,间隔 1200ms。 -playground 有几何时从 `curious` 起播;点「登录轮换」才按 `pjn(0)=idle` 重来。 +## What's on the site -## 目录 +The rail switches three views. **Customise** is 18 body shapes, 11 inks and the rest-face playlist. **Animations** is bloub’s timeline editor on our 39 states: drag to reorder, pull a card to set duration, save named cycles in `localStorage`. The right-hand tiles append a block. **Settings** is language (French, English, Chinese) and credits. GIF/MP4 export is not in this tree. -| 路径 | 内容 | +The centre avatar is always `GrokCharacter` in `hold` mode. Tiles paint one frame and stop. Nothing from bloub’s `src/bot/` is in this tree. + +## What the engine does + +- 39 mood / lifecycle states, each with an eye playlist, blink cadence, gaze +- Springs on move, squash, spin, blink, shape change +- Eyes are polygon morphs, not sprites +- Some states switch overlays (thinking / orbit / writing / loading / …) +- Login wrap carries `pose.scale`, pointer follow, onboarding + +Login-disk defaults: `sizePx: 64`, `color: "black"`, `shape: "blob"`. + +## Layout + +| Path | What | |---|---| -| `replica/index.html` | playground。无几何时显示说明 | -| `replica/geometry.schema.md` | `window.GROK_GEO` 接口,不含数据 | -| `replica/src/math.js` | 弹簧、插值、多边形 | -| `replica/src/tables.js` | 状态表、playlist、弹簧参数 | -| `replica/src/pose.js` | 姿态 / 注视 | -| `replica/src/tricks.js` | 转圈、跳跃等特技 | -| `replica/src/fx.js` | overlay 与粒子 | -| `replica/src/eyes.js` | 眨眼、wink、眼睛绘制 | -| `replica/src/character.js` | `GrokCharacter` 编排 | -| `replica/snap.mjs` | 可选的 headless 快照脚本 | +| `src/` | Vue site (bloub-like chrome) | +| `replica/index.html` | playground | +| `replica/geometry.schema.md` | `window.GROK_GEO` contract, no data | +| `replica/src/math.js` | springs, lerp, polygons | +| `replica/src/tables.js` | states, playlists, spring constants | +| `replica/src/pose.js` | pose / gaze | +| `replica/src/tricks.js` | spin, hop | +| `replica/src/fx.js` | overlays and particles | +| `replica/src/eyes.js` | blink, wink, eye paint | +| `replica/src/character.js` | `GrokCharacter` | +| `replica/src/api.js` | `GROK.create` / `` | -本仓库故意不含: +This repo deliberately does not contain: -- 角色几何(`geometry-data.js`) -- 官方图标 / iconset -- 从应用包抽出的主包或切片 -- 解包脚本 +- character geometry (`geometry-data.js`) +- official icons / iconset +- unpacked app slices +- unpack scripts -## 许可 +## License -[MIT](./LICENSE) 只覆盖本仓库里的原创机芯、页面和文档。 -它不授权任何第三方角色造型、商标或应用源码;那些东西也不在这个仓库里。 +[MIT](./LICENSE) covers the original engine, site and docs in this repository. +It does not grant rights to third-party character art, trademarks or application source; those are not in this repository. + +Site chrome is arranged after bloub’s three-column scene. bloub is MIT; “Grok” and “x.ai” belong to their owners. Not affiliated with either. diff --git a/index.html b/index.html new file mode 100644 index 0000000..5679b7f --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + study — spring character + + + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..de95106 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "grok-icon-study", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Study site for a spring-driven character engine. Bloub-like presentation, replica motion.", + "license": "MIT", + "packageManager": "pnpm@11.17.0", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "mediabunny": "^1.55.1", + "vue": "^3.5.41" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.0.0", + "@vitejs/plugin-vue": "^6.0.8", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.3", + "vite": "^8.2.1", + "vue-tsc": "^3.3.9" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..824602b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1078 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + mediabunny: + specifier: ^1.55.1 + version: 1.55.1 + vue: + specifier: ^3.5.41 + version: 3.5.41(typescript@5.9.3) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)) + '@types/node': + specifier: ^26.0.0 + version: 26.2.0 + '@vitejs/plugin-vue': + specifier: ^6.0.8 + version: 6.0.8(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0))(vue@3.5.41(typescript@5.9.3)) + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@26.2.0)(jiti@2.7.0) + vue-tsc: + specifier: ^3.3.9 + version: 3.3.10(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/dom-mediacapture-transform@0.1.12': + resolution: {integrity: sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==} + + '@types/dom-webcodecs@0.1.13': + resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/language-core@3.3.10': + resolution: {integrity: sha512-CR7ByBbgPHqhxrioKPOcZBqttaozzLNwtkCzXQ+uF8gLPHnUe03srPnGpdtHD3zp+bq5iyVkZ1WNx7W564RPwg==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mediabunny@1.55.1: + resolution: {integrity: sha512-JDdTEUOw9g6Ey8eWc2Ei63GmRGtGTJoE56SE7CHikwzS8i9xpsWcve4bEiUw47d4j49L3r7MXDFsH/5oZIhjBw==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.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 + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-tsc@3.3.10: + resolution: {integrity: sha512-YaDVxcW+CGtaOt3pZahMG5jYPx0hsUTxyEoPOTSMebcGUXP9lIBabQ14vfKMORb2CqqK5CxsNo/d1d+4IQwiKg==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@oxc-project/types@0.144.0': {} + + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0) + + '@types/dom-mediacapture-transform@0.1.12': + dependencies: + '@types/dom-webcodecs': 0.1.13 + + '@types/dom-webcodecs@0.1.13': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0))(vue@3.5.41(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0) + vue: 3.5.41(typescript@5.9.3) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/language-core@3.3.10': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.5 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + alien-signals@3.2.1: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@7.0.1: {} + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + jiti@2.7.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mediabunny@1.55.1: + dependencies: + '@types/dom-mediacapture-transform': 0.1.12 + '@types/dom-webcodecs': 0.1.13 + + muggle-string@0.4.1: {} + + nanoid@3.3.18: {} + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.4: + dependencies: + '@oxc-project/types': 0.144.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 + + source-map-js@1.2.1: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + typescript@5.9.3: {} + + undici-types@8.3.0: {} + + vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + fsevents: 2.3.3 + jiti: 2.7.0 + + vscode-uri@3.1.0: {} + + vue-tsc@3.3.10(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.10 + typescript: 5.9.3 + + vue@3.5.41(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: 5.9.3 diff --git a/replica/embed.html b/replica/embed.html new file mode 100644 index 0000000..1b04840 --- /dev/null +++ b/replica/embed.html @@ -0,0 +1,88 @@ + + + + + +Grok Bot · 最小嵌入 + + + +

最小嵌入

+

+ 登录页原参数:64px · blob · black · loginWrap。 + 回 playground +

+
+ +
+
+<!-- 任意页面 -->
+<script src="geometry-data.js"></script>
+<script src="src/math.js"></script>
+<script src="src/tables.js"></script>
+<script src="src/pose.js"></script>
+<script src="src/tricks.js"></script>
+<script src="src/fx.js"></script>
+<script src="src/eyes.js"></script>
+<script src="src/character.js"></script>
+<script src="src/api.js"></script>
+
+<grok-bot state="idle" shape="blob" color="black" size="64"></grok-bot>
+
+<script>
+  const el = document.querySelector("grok-bot");
+  el.setState("playful");
+  el.spin(1);
+
+  // 或命令式
+  const { setState, spin, destroy } = GROK.create("#host", {
+    sizePx: 64, shape: "blob", color: "black", loginWrap: true, mode: "hold",
+  });
+
+  // 换皮:只覆盖你有的部分
+  GROK.create("#other", {
+    eyes: MY_EYES,
+    shapes: { blob: MY_BLOB },
+    playlist: { idle: [0], playful: [1] },
+  });
+</script>
+
+ + + + + + + + + + + diff --git a/replica/index.html b/replica/index.html index f11fdf0..f3ec1b2 100644 --- a/replica/index.html +++ b/replica/index.html @@ -3,7 +3,7 @@ -Character Engine Playground +Grok Bot 角色 · 源码复刻 + + +
+
+
+
+
+
+
+
+ + + + +
+
+
+
+

GROK

+

一小团会眨眼的墨

+
+
+
+
+
+ Space 暂停 · ←→ 换镜 · P 节目 · A 画幅 · T 底色 · M 字幕 · F 全屏 · C 控制台 · H 隐藏 +
+
+ + + +
+
苏醒
+
+ +
+ + + +
+ + + +
+
+ + + + + + + + diff --git a/replica/snap.mjs b/replica/snap.mjs old mode 100755 new mode 100644 index 982d435..2fdbba5 --- a/replica/snap.mjs +++ b/replica/snap.mjs @@ -146,14 +146,21 @@ async function main() { if (ready) break; await sleep(100); } + const report = []; + report.push(await cdp.eval(`(() => { + const [stage, login] = window.__bots || []; + return { + label: "boot-modes", + stage: { state: stage?.state, mode: stage?.mode, moodN: stage?.moodN }, + login: { state: login?.state, mode: login?.mode, moodN: login?.moodN }, + }; + })()`)); await click(cdp, "#mode-hold"); await click(cdp, "#opt-follow"); // default on → off for stable transforms await sleep(80); - - const report = []; const states = [ - "playful", "laughing", "sleeping", "idle", "celebrate", - "thinking", "writing", "orbit", "humming", "loading", + "playful", "laughing", "sleeping", "waking", "idle", "celebrate", + "thinking", "writing", "orbit", "humming", "loading", "notifying", ]; for (const name of states) { await click(cdp, `button[data-state="${name}"]`); @@ -201,6 +208,227 @@ async function main() { await shot(cdp, `${shape}-spin`); } + // Gr() Lt/Mt: locals default to 1 each frame (not prev) + await cdp.eval(`(() => { + const b = window.__bots[0]; + b.setMode("hold"); + b.setState("laughing", { resetEyes: false }); + b.poseOut.lid = 0.7; + b.poseOut.eyeBoost = 1.12; + return true; + })()`); + await sleep(80); + await click(cdp, 'button[data-state="loading"]'); + await sleep(80); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + return { + label: "loading-after-laughing", + lid: b.poseOut.lid, + eyeBoost: b.poseOut.eyeBoost, + trick: b.trick?.kind || null, + spinTurn: !!b.spinTurn, + }; + })()`)); + + await click(cdp, 'button[data-state="excited"]'); + await sleep(80); + await click(cdp, 'button[data-state="waking"]'); + await sleep(1600); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + return { + label: "waking@1.6s", + dt: (performance.now() - b.stateAt) / 1000, + lid: b.poseOut.lid, + eyeBoost: b.poseOut.eyeBoost, + ty: b.poseOut.ty, + squash: b.poseOut.squash, + }; + })()`)); + + await click(cdp, 'button[data-state="dragging"]'); + await sleep(50); + const drag = await cdp.eval(`(() => { + const b = window.__bots[0]; + const now = performance.now(); + const dt = (now - b.stateAt) / 1000; + const a = window.GROK_POSE.applyPose("dragging", 0, 0.05, now, b.ctx, { prev: { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1.06 } }); + const mid = window.GROK_POSE.applyPose("dragging", 0, 1.2, now, b.ctx, { prev: { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1 } }); + const late = window.GROK_POSE.applyPose("dragging", 0, 2.5, now, b.ctx, { prev: { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1.06 } }); + return { label: "dragging-Lt", dt, a: a.eyeBoost, mid: mid.eyeBoost, late: late.eyeBoost }; + })()`); + report.push(drag); + + // hold 切态 must not tear rn/ys + await click(cdp, 'button[data-state="idle"]'); + await sleep(60); + await click(cdp, "#act-spin"); + await sleep(40); + const beforeHold = await cdp.eval(`({ spinTurn: !!window.__bots[0].spinTurn, trick: window.__bots[0].trick?.kind || null })`); + await click(cdp, 'button[data-state="thinking"]'); + await sleep(40); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + return { + label: "hold-keeps-pn", + before: ${JSON.stringify(beforeHold)}, + state: b.state, + spinTurn: !!b.spinTurn, + trick: b.trick?.kind || null, + }; + })()`)); + + // notifying badge = rest ring 7/8 + await click(cdp, 'button[data-state="notifying"]'); + await sleep(400); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + const R = b.geo.Re; + const ring = window.GROK_FX.shapeRing(b.geo.shapes[b.shapeName].path, R); + const Yl = ring[Math.round(ring.length * 7 / 8) % ring.length]; + const badge = b.badge; + const vis = badge && badge.style.display !== "none"; + const cx = vis ? +badge.getAttribute("cx") : null; + const cy = vis ? +badge.getAttribute("cy") : null; + return { + label: "notifying-Yl", + vis, + cx, cy, + Yl, + dx: vis ? Math.abs(cx - Yl[0]) : null, + dy: vis ? Math.abs(cy - Yl[1]) : null, + notifyX: b.notify.x, + }; + })()`)); + + // excited pn must survive 1200ms onboard cut to idle + await cdp.eval(`(() => { + const b = window.__bots[0]; + b.setMode("hold"); + b.moodN = 7; + b.setState("excited", { resetEyes: false }); + b._pn(1); + b.mode = "onboarding"; + b.stateAt = performance.now() - 1190; + return true; + })()`); + await sleep(80); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + return { + label: "onboard-excited-to-idle", + state: b.state, + moodN: b.moodN, + spinTurn: !!b.spinTurn, + trick: b.trick?.kind || null, + }; + })()`)); + + await click(cdp, 'button[data-state="idle"]'); + await sleep(40); + await cdp.eval(`(() => { + const b = window.__bots[0]; + b.trick = null; + b.spinOnce(1); + return true; + })()`); + await sleep(50); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + const rn = b.spinTurn; + const turn = b.extras.turn; + return { + label: "pn-post-spring", + hasRn: !!rn, + turn, + rnX: rn ? rn.x : null, + match: rn ? Math.abs(turn - rn.x) < 1e-9 : turn == null, + }; + })()`)); + + await cdp.eval(`(() => { + const b = window.__bots[0]; + b.setFollowPointer(true); + b.pointerRaw = { x: 0, y: 0 }; + b.pointer.tx = 12; + b.pointer.ty = 8; + b.pointer.x = 12; + b.pointer.y = 8; + return true; + })()`); + await sleep(30); + await cdp.eval(`(() => { + const b = window.__bots[0]; + b.pointerRaw = null; + return true; + })()`); + await sleep(80); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + return { + label: "pointer-leave-decay", + tracking: !!(b.gazeTarget || (b.followPointer && b.pointerRaw)), + x: b.pointer.x, + tx: b.pointer.tx, + decaying: b.pointer.tx === 0 && b.pointer.x > 0.05 && b.pointer.x < 12, + }; + })()`)); + + await click(cdp, 'button[data-state="writing"]'); + await sleep(400); + await cdp.eval(`(() => { + const b = window.__bots[0]; + b.fx.ink = [[10, 10], [20, 20]]; + b.fx.recvTick = 3; + b.fx.recvDir = 1.2; + b.setState("writing", { resetEyes: false }); + return true; + })()`); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + return { + label: "setState-resets-ink-recv", + ink: b.fx.ink.length, + recvTick: b.fx.recvTick, + recvDir: b.fx.recvDir, + }; + })()`)); + + await cdp.eval(`(() => { + const b = window.__bots[0]; + b.setMode("hold"); + b.trick = null; + b.spinTurn = null; + b.hopAt = -1; + b.particles.clear(); + b.overlay.x = 0; b.overlay.v = 0; b.overlay.t = 0; + b.overlayTurn.x = 0; b.overlayTurn.v = 0; b.overlayTurn.t = 0; + b.overlayMix.x = 1; b.overlayMix.v = 0; b.overlayMix.t = 1; + b.shapeSpring.x = 1; b.shapeSpring.v = 0; b.shapeSpring.t = 1; + b.eyeMorph.x = 1; b.eyeMorph.v = 0; b.eyeMorph.t = 1; + b.ovOn = false; + b.ovKind = null; + b.ovTarget = null; + b.ovPrev = null; + b.setState("sleeping", { resetEyes: false }); + b.setPaused(true); + return true; + })()`); + await sleep(200); + report.push(await cdp.eval(`(() => { + const b = window.__bots[0]; + return { + label: "pause-settle-snap", + raf: b._raf, + eyeFrom: b.eyeFrom, + eyeTo: b.eyeTo, + poseOut: b.poseOut, + fromPolys: b._fromPolys, + }; + })()`)); + await cdp.eval(`window.__bots[0].setPaused(false)`); + await writeFile(join(OUT, "report.json"), JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2)); diff --git a/replica/src/api.js b/replica/src/api.js new file mode 100644 index 0000000..181690b --- /dev/null +++ b/replica/src/api.js @@ -0,0 +1,136 @@ +/* L2 — public factory + . Skin: eyes / shapes / playlist / palette. */ +(function (g) { + const ATTRS = ["state", "shape", "color", "size", "paused", "follow", "mode", "emphasis"]; + + function mergeSkin(opts = {}) { + const base = opts.geo || g.GROK_GEO; + return { + ...base, + eyes: opts.eyes || base.eyes, + shapes: opts.shapes ? { ...base.shapes, ...opts.shapes } : base.shapes, + palette: opts.palette ? { ...base.palette, ...opts.palette } : base.palette, + solids: opts.solids || base.solids, + }; + } + + function resolveSvg(target) { + if (!target) throw new Error("GROK.create: missing target"); + if (typeof target === "string") { + const el = document.querySelector(target); + if (!el) throw new Error("GROK.create: no match for " + target); + target = el; + } + if (target.tagName === "SVG" || target instanceof SVGSVGElement) return target; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + target.appendChild(svg); + return svg; + } + + function createCharacter(target, opts = {}) { + const svg = resolveSvg(target); + const geo = mergeSkin(opts); + const playlist = opts.playlist + ? { ...g.GROK_TABLES.EYE_PLAYLIST, ...opts.playlist } + : g.GROK_TABLES.EYE_PLAYLIST; + const bot = new g.GrokCharacter(svg, { mode: "hold", ...opts, geo, playlist }); + return { + bot, + svg, + setState: (name, o) => bot.setState(name, o), + setShape: (name) => bot.setShape(name), + setColor: (id, scheme) => bot.setColor(id, scheme), + setPaused: (v) => bot.setPaused(v), + setMode: (m) => bot.setMode(m), + setFollowPointer: (v) => bot.setFollowPointer(v), + setEmphasis: (v) => bot.setEmphasis(v), + spin: (n) => bot.spinOnce(n), + bounce: () => bot.bounceOnce(), + burst: () => bot.burstOnce(), + destroy: () => bot.destroy(), + snapshot: () => bot.snapshot(), + }; + } + + class GrokBotElement extends HTMLElement { + static get observedAttributes() { return ATTRS; } + + connectedCallback() { + if (this._handle) return; + const root = this.attachShadow({ mode: "open" }); + const box = document.createElement("div"); + box.setAttribute("part", "disk"); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", this.getAttribute("label") || "character"); + box.appendChild(svg); + const style = document.createElement("style"); + style.textContent = `:host{display:inline-block;line-height:0}div{width:100%;height:100%}svg{width:100%;height:100%;overflow:visible}`; + root.append(style, box); + this._applySize(); + this._handle = createCharacter(svg, this._opts()); + this.bot = this._handle.bot; + } + + disconnectedCallback() { + this._handle?.destroy(); + this._handle = null; + this.bot = null; + } + + attributeChangedCallback(name, prev, next) { + if (!this._handle || prev === next) return; + this._applySize(); + if (name === "state") this._handle.setState(next || "idle"); + else if (name === "shape") this._handle.setShape(next || "blob"); + else if (name === "color") this._handle.setColor(next || "black"); + else if (name === "paused") this._handle.setPaused(this.hasAttribute("paused")); + else if (name === "follow") this._handle.setFollowPointer(next === "true"); + else if (name === "emphasis") this._handle.setEmphasis(this.hasAttribute("emphasis")); + else if (name === "mode") this._handle.setMode(next || "hold"); + } + + _applySize() { + const n = Number(this.getAttribute("size")); + if (n > 0) { + this.style.width = `${n}px`; + this.style.height = `${n}px`; + } + } + + _opts() { + const size = Number(this.getAttribute("size")); + return { + state: this.getAttribute("state") || "idle", + shape: this.getAttribute("shape") || "blob", + color: this.getAttribute("color") || "black", + sizePx: size > 0 ? size : null, + paused: this.hasAttribute("paused"), + followPointer: this.getAttribute("follow") === "true", + emphasis: this.hasAttribute("emphasis"), + mode: this.getAttribute("mode") || "hold", + loginWrap: this.getAttribute("login") !== "false", + }; + } + + setState(name) { this._handle?.setState(name); } + setShape(name) { this._handle?.setShape(name); } + setColor(id) { this._handle?.setColor(id); } + setPaused(v) { this._handle?.setPaused(v); } + spin(n) { this._handle?.spin(n); } + bounce() { this._handle?.bounce(); } + destroy() { this._handle?.destroy(); } + } + + function define(name = "grok-bot") { + if (!customElements.get(name)) customElements.define(name, GrokBotElement); + return name; + } + + define(); + g.GROK = { + create: createCharacter, + define, + mergeSkin, + Character: g.GrokCharacter, + }; +})(window); diff --git a/replica/src/character.js b/replica/src/character.js index ae3b580..393d874 100644 --- a/replica/src/character.js +++ b/replica/src/character.js @@ -17,15 +17,31 @@ VIEW, VIEW_HALF, VIEW_MID, inkFg, inkCss, EYE_BG, } = T; + const snapSpring = (s, v) => { + s.x = s.t = v; + s.v = 0; + }; + + const HOLD_GAZE = { + curious: { x: 12, y: -6 }, sad: { x: 2, y: 8 }, shy: { x: 10, y: 7 }, + proud: { x: 0, y: -6 }, bored: { x: 12, y: 6 }, suspicious: { x: -15, y: 3 }, + confused: { x: 12, y: 3 }, thinking: { x: 12, y: -7 }, angry: { x: 0, y: 2 }, + drowsy: { x: 4, y: 7 }, searching: { x: 14, y: -4 }, working: { x: 3, y: 7 }, + notifying: { x: 7, y: -3 }, playful: { x: 10, y: -4 }, happy: { x: -6, y: -4 }, + laughing: { x: 4, y: -4 }, listening: { x: 5, y: -2 }, scared: { x: 12, y: 2 }, + }; + class GrokCharacter { constructor(svg, opts = {}) { this.svg = svg; this.shapeName = opts.shape || "blob"; this.colorId = opts.color || "black"; this.scheme = opts.scheme || "light"; - this.mode = opts.mode || "onboarding"; + this.mode = opts.mode || "hold"; this.state = opts.state || "idle"; this.onChange = opts.onChange || (() => {}); + this.frameHalf = opts.frameHalf || VIEW_HALF; + this.fitBox = !!opts.fitBox; this.loginWrap = opts.loginWrap !== false; this.eyeTopology = opts.eyeTopology ?? this.loginWrap; this.faceTune = opts.faceTune ?? (this.loginWrap ? FACE_TUNE : null); @@ -36,12 +52,16 @@ this.emphasis = !!opts.emphasis; this.followPointer = !!opts.followPointer; this.gazeTarget = opts.gazeTarget || null; + this.frozen = opts.paused === "hold-pose"; + this.driven = !!opts.driven; this.paused = !!opts.paused; this.reduceMotion = opts.reduceMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); this.badgeColor = opts.badgeColor || "var(--gb-badge, #1d9bf0)"; this.sizePx = opts.sizePx || null; this.eyeColor = opts.eyeColor || null; this.inkFlat = opts.inkFlat || null; + this.geo = opts.geo || g.GROK_GEO; + this.playlists = opts.playlist || EYE_PLAYLIST; this.spin = spring(0); this.tx = spring(0); @@ -102,19 +122,27 @@ this.ovTarget = null; this.ovRest = false; this.ovRestAt = 0; - this.pxW = 190; + this.pxW = opts.sizePx || 190; this.pxAt = 0; this.partScale = 1; this.celebrateAt = -1; + this.gazeSpin = 0; this.extras = { turn: null, Kr: 0, yi: 0, ki: 0, Yr: 0, Zr: 0, wi: 0, hop: 0 }; + this.poseOut = { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1 }; this._build(); this.setColor(this.colorId, this.scheme); this._applyPoseScale(); this.setState(this.state, { resetEyes: true }); - this._bindPointer(); - this._paint(this.t0); - this._raf = requestAnimationFrame((t) => this._tick(t)); + if (!this.driven) this._bindPointer(); + if (this.frozen) { + this.holdFrame(); + } else if (this.driven) { + this._paint(this.t0); + } else { + this._paint(this.t0); + this._armTick(); + } } destroy() { @@ -125,7 +153,7 @@ _freshCtx(now) { return { - nodUntil: now + 1800, + nodUntil: now + rand(1200, 2200), nodEnd: 0, angryShakeUntil: 0, impulseAt: now + rand(500, 1200), @@ -156,7 +184,28 @@ } setPaused(v) { + if (v === "hold-pose") { + this.holdFrame(); + return; + } + this.frozen = false; this.paused = !!v; + if (!this.paused) this._armTick(); + } + + _armTick() { + if (this.frozen || this.driven || this._raf) return; + this.last = performance.now(); + this._raf = requestAnimationFrame((t) => this._tick(t)); + } + + _wake() { + if (this.frozen || this.driven) return; + if (this.paused) this._armTick(); + } + + step(dt) { + this._tick(this.last + Math.max(0, dt) * 1000); } setEmphasis(v) { @@ -176,10 +225,10 @@ } setShape(name) { - if (!g.GROK_GEO.shapes[name] || name === this.shapeName) return; - const R = g.GROK_GEO.Re; + if (!this.geo.shapes[name] || name === this.shapeName) return; + const R = this.geo.Re; const k = K2(clamp(this.shapeSpring.x, 0, 1)); - const rest = FX.shapeMetrics(g.GROK_GEO.shapes[this.shapeName], R); + const rest = FX.shapeMetrics(this.geo.shapes[this.shapeName], R); if (k >= 1 || !this.prevFace || !this.prevRing) { this.prevFace = rest.face; this.prevRing = rest.ring; @@ -199,6 +248,7 @@ if (this.loginWrap) this.eyeScaleProp = shapeEyeScale(name); this._applyPoseScale(); this._cycleShapeTrick(); + this._wake(); } setColor(id, scheme) { @@ -209,7 +259,7 @@ } else if (this.loginWrap) { this.svg.style.setProperty("--fg", inkFg(id)); } else { - const pal = g.GROK_GEO.palette[id] || g.GROK_GEO.palette.black; + const pal = this.geo.palette[id] || this.geo.palette.black; this.svg.style.setProperty("--fg", this.scheme === "dark" ? pal.dark : pal.light); } this.svg.style.setProperty("--ink", inkCss(id)); @@ -227,10 +277,10 @@ } setState(name, { resetEyes = false } = {}) { - if (!EYE_PLAYLIST[name]) return; + if (!this.playlists[name]) return; this.state = name; this.stateAt = performance.now(); - const list = EYE_PLAYLIST[name]; + const list = this.playlists[name]; this.eyeIdx = 0; if (resetEyes) { this.eyeFrom = list[0]; @@ -255,14 +305,12 @@ : rand(6000, 10000) ); this.celebrateAt = name === "celebrate" ? this.stateAt + 140 : -1; - this.trick = null; - this.spinTurn = null; - this.hopAt = -1; - this.wildWide = false; - if (name !== "writing") this.fx?.resetInk(); + this.fx?.resetInk(); + this.fx?.resetRecv(); if (name !== "waking" && name !== "sleeping" && name !== "drowsy") { EY.queueBlink(this.blinkQueue, this.stateAt); } + this._wake(); try { this.onChange(this.snapshot()); } catch (_) { /* host UI may not be ready */ } @@ -283,17 +331,80 @@ squash: this.squash.x, blink: this.blink.x, overlay: this.ovKind, + overlayX: this.overlay.x, + notify: this.notify.x, + trick: this.trick?.kind || (this.spinTurn ? "pn" : null), + shapeSpring: this.shapeSpring.x, + badge: this.badge && this.badge.style.display !== "none" + ? { x: +this.badge.getAttribute("cx"), y: +this.badge.getAttribute("cy") } + : null, }; } spinOnce(turns = 1) { + this._wake(); this._pn(turns); } bounceOnce() { + this._wake(); this._hop(performance.now()); } burstOnce() { - if (!this.reduceMotion) this.particles.burst(22, 1.1, 0.3); + if (!this.reduceMotion && !this.paused) this.particles.burst(22, 1.1, 0.3); + } + + orbitGaze(ms = 1500) { + if (this.reduceMotion || this.frozen) return; + this.gazeOrbit = { at: performance.now(), dur: Math.max(ms, 1), from: Math.PI * 2 }; + this._armTick(); + } + + holdFrame(at = 900) { + this.frozen = true; + this.paused = true; + if (this._raf) { + cancelAnimationFrame(this._raf); + this._raf = 0; + } + const now = this.t0 + at; + const mt = at / 1000; + const pose = applyPose(this.state, mt, mt, now, this.ctx, { + eyeTo: this.eyeTo, + eyeMorphX: 1, + blinkX: this.blink.x, + playlist: this.playlists, + prev: this.poseOut, + }); + this.ctx.wantPn = null; + this.ctx.wantBurst = null; + this.ctx.tyKick = 0; + this.ctx.spinKick = 0; + this.poseOut = pose; + snapSpring(this.spin, pose.spin); + snapSpring(this.tx, pose.tx); + snapSpring(this.ty, pose.ty); + snapSpring(this.squash, pose.squash); + snapSpring(this.eyeScale, pose.eyeBoost); + snapSpring(this.blink, pose.lid); + this._stepOverlay(now); + if (this.ovKind) { + snapSpring(this.overlay, this.overlay.t); + snapSpring(this.overlayMix, 1); + snapSpring(this.overlayTurn, this.overlayTurn.t); + this.fx.overlayAt = this.t0; + } + snapSpring(this.notify, this.state === "notifying" ? 1 : 0); + snapSpring(this.humDots, this.state === "humming" ? 1 : 0); + if (this.state === "humming" || this.state === "loading") this.ovSpin = 2.1; + const gz = HOLD_GAZE[this.state] || { x: 0, y: 0 }; + snapSpring(this.gazeX, gz.x); + snapSpring(this.gazeY, gz.y); + this.eyeMorph.x = 1; + this.eyeMorph.v = 0; + this.eyeMorph.t = 1; + this._fromPolys = null; + this._paint(now); + return this.snapshot(); } _applyPoseScale() { @@ -303,7 +414,8 @@ this.svg.style.width = `${this.sizePx}px`; this.svg.style.height = `${this.sizePx}px`; } - if (Math.abs(sc - 1) > 0.001) { + const boxed = this.fitBox || (this.sizePx && this.sizePx < 120); + if (!boxed && Math.abs(sc - 1) > 0.001) { this.svg.style.transform = `scale(${sc})`; this.svg.style.transformOrigin = "50% 50%"; } else { @@ -329,7 +441,7 @@ } _build() { - const geo = g.GROK_GEO; + const geo = this.geo; const vb = geo.viewBox; this.svg.setAttribute("viewBox", `${vb.minX} ${vb.minY} ${vb.width} ${vb.height}`); this.svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); @@ -400,7 +512,7 @@ } _currentPolys(t) { - const eyes = g.GROK_GEO.eyes; + const eyes = this.geo.eyes; const from = this._fromPolys || eyes[this.eyeFrom]; const to = eyes[this.eyeTo]; return [lerpPoly(from[0], to[0], t), lerpPoly(from[1], to[1], t)]; @@ -412,7 +524,8 @@ } _hop(now) { - if (this.hopAt < 0) this.hopAt = now; + if (this.reduceMotion || this.paused || this.hopAt >= 0) return; + this.hopAt = now; } _cycleShapeTrick() { @@ -423,9 +536,11 @@ else if (this.trickCycle === 1) { this.wildWide = true; this._pn(2); - } else if (this.trickCycle === 2) this.trick = TR.startTrick("spinBounce", this.reduceMotion); - else if (this.trickCycle === 3) this.trick = TR.startTrick("spinDizzy", this.reduceMotion); - else { + } else if (this.trickCycle === 2) { + if (!this.trick) this.trick = TR.startTrick("spinBounce", this.reduceMotion); + } else if (this.trickCycle === 3) { + if (!this.trick) this.trick = TR.startTrick("spinDizzy", this.reduceMotion); + } else { this._pn(1); this.particles.burst(16, 0.95, 0.3); } @@ -499,21 +614,17 @@ this.pointer.tx = 0; this.pointer.ty = 0; } - const z = Rn(0.16); - this.pointer.x += (this.pointer.tx - this.pointer.x) * z; - this.pointer.y += (this.pointer.ty - this.pointer.y) * z; } _tick(now) { + if (this.frozen) { + this._raf = 0; + return; + } const dt = Math.min((now - this.last) / 1000, 0.1); this.last = now; - if (this.paused) { - this._raf = requestAnimationFrame((t) => this._tick(t)); - return; - } - - if (this.mode === "onboarding" && now - this.stateAt >= ONBOARDING_MS) { + if (!this.paused && this.mode === "onboarding" && now - this.stateAt >= ONBOARDING_MS) { this.moodN += 1; this.setState(onboardMood(this.moodN)); } @@ -524,7 +635,10 @@ eyeTo: this.eyeTo, eyeMorphX: this.eyeMorph.x, blinkX: this.blink.x, + playlist: this.playlists, + prev: this.poseOut, }); + this.poseOut = pose; this.spin.t = pose.spin; this.tx.t = pose.tx; this.ty.t = pose.ty; @@ -567,8 +681,8 @@ this._stepOverlay(now); - if (this.celebrateAt > 0 && now >= this.celebrateAt && !this.trick && !this.spinTurn) { - this.trick = TR.startTrick("spinWild", this.reduceMotion); + if (this.celebrateAt > 0 && now >= this.celebrateAt && !this.trick) { + this.trick = TR.startTrick("spinWild", this.reduceMotion || this.paused); this.celebrateAt = now + 6200; } @@ -577,10 +691,10 @@ const z = Math.random(); if (V_T.has(this.state)) { if (z < 0.55) this._pn(1); - else this.trick = TR.startTrick("spinBounce", this.reduceMotion); - } else if (z < 0.34) this.trick = TR.startTrick("spinBounce", this.reduceMotion); + else this.trick = TR.startTrick("spinBounce", this.reduceMotion || this.paused); + } else if (z < 0.34) this.trick = TR.startTrick("spinBounce", this.reduceMotion || this.paused); else if (z < 0.62) this._hop(now); - else if (z < 0.86) this.trick = TR.startTrick("spinDizzy", this.reduceMotion); + else if (z < 0.86) this.trick = TR.startTrick("spinDizzy", this.reduceMotion || this.paused); else this._pn(1); } this.trickAt = now + rand(9000, 18000); @@ -594,17 +708,12 @@ this.hopAt = -1; hop = 0; } - let turn = tf.turn; - if (this.spinTurn) { - turn = (turn ?? 0) + this.spinTurn.x; - if (TR.spinTurnSettled(this.spinTurn)) this.spinTurn = null; - } - this.extras = { ...tf, turn, hop }; + this.extras = { ...tf, turn: tf.turn, hop }; if (this.extras.eyeBoost != null) this.eyeScale.t = this.extras.eyeBoost; if (this.state !== "waking" && this.state !== "sleeping" && now >= this.eyeUntil) { - const list = EYE_PLAYLIST[this.state]; + const list = this.playlists[this.state]; this.eyeIdx = (this.eyeIdx + 1 + Math.floor(rand(0, list.length - 1))) % list.length; const stiff = this.state === "searching" || this.state === "excited" ? 10 : 6; this._morphEyes(list[this.eyeIdx], stiff); @@ -649,7 +758,7 @@ } if (this.reduceMotion) { - this._morphEyes(EYE_PLAYLIST[this.state][0]); + this._morphEyes(this.playlists[this.state][0]); this.spin.t = 0; this.tx.t = 0; this.ty.t = 0; this.squash.t = 1; this.blink.t = 1; this.eyeScale.t = 1; } @@ -679,6 +788,25 @@ this.overlayTurn.x = this.overlayTurn.t; this.overlay.x = this.overlay.t; } + if (this.spinTurn) { + if (TR.spinTurnSettled(this.spinTurn)) { + this.spinTurn = null; + this.wildWide = false; + } else { + this.extras = { ...this.extras, turn: (this.extras.turn ?? 0) + this.spinTurn.x }; + } + } + if (this.gazeOrbit) { + const u = clamp((now - this.gazeOrbit.at) / this.gazeOrbit.dur, 0, 1); + const ease = u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2; + this.gazeSpin = this.gazeOrbit.from * (1 - ease); + if (u >= 1) { + this.gazeOrbit = null; + this.gazeSpin = 0; + } + } else { + this.gazeSpin = 0; + } this.notify.t = this.state === "notifying" ? 1 : 0; this.humDots.t = this.state === "humming" ? 1 : 0; @@ -703,11 +831,52 @@ this._updatePointer(now); this._paint(now); + + if (this.driven) { + this._raf = 0; + return; + } + + if ( + this.paused + && !this.trick + && !this.spinTurn + && this.hopAt < 0 + && !this.particles.hasLife() + && Math.abs(this.overlay.x - this.overlay.t) < 0.001 + && Math.abs(this.overlayTurn.t - this.overlayTurn.x) < 0.01 + && this.overlayMix.x > 0.996 + && Math.abs(this.shapeSpring.x - this.shapeSpring.t) < 0.001 + && Math.abs(this.eyeMorph.x - this.eyeMorph.t) < 0.001 + ) { + for (const s of [this.spin, this.tx, this.ty, this.gazeX, this.gazeY]) { + s.x = 0; s.v = 0; s.t = 0; + } + for (const s of [this.squash, this.blink, this.eyeScale]) { + s.x = 1; s.v = 0; s.t = 1; + } + this.shapeSpring.x = this.shapeSpring.t; + this.shapeSpring.v = 0; + this.blinkQueue = []; + this.winkAt = -1e9; + const home = this.playlists[this.state]?.[0] ?? 0; + this.eyeFrom = home; + this.eyeTo = home; + this._fromPolys = null; + this.eyeMorph.x = 1; + this.eyeMorph.v = 0; + this.eyeMorph.t = 1; + this.poseOut = { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1 }; + this._paint(0); + this._raf = 0; + return; + } + this._raf = requestAnimationFrame((t) => this._tick(t)); } _paint(now) { - const geo = g.GROK_GEO; + const geo = this.geo; const R = geo.Re; const shape = geo.shapes[this.shapeName]; const morphK = K2(clamp(this.shapeSpring.x, 0, 1)); @@ -744,7 +913,7 @@ : FX.shapeRing(shape.path, R); let liveRing = restRing; let turned = false; - const turnAt = !morphing && spinning ? FX.turnAtOf(this.shapeName, shape.path, R) : null; + const turnAt = !morphing && spinning ? FX.turnAtOf(this.shapeName, shape.path, R, geo.solids) : null; if (turnAt) { liveRing = turnAt(spinAmt); turned = true; @@ -778,7 +947,7 @@ const zCur = overlayViewZoom(this.ovKind, pScale); const zPrev = overlayViewZoom(this.ovPrev, pScale); const zoom = 1 + (zCur * mix + zPrev * (1 - mix) - 1) * yl * shrink; - const half = VIEW_HALF / zoom; + const half = this.frameHalf / zoom; this.svg.setAttribute("viewBox", `${(VIEW_MID - half).toFixed(2)} ${(VIEW_MID - half).toFixed(2)} ${(half * 2).toFixed(2)} ${(half * 2).toFixed(2)}`); const morphT = clamp(this.eyeMorph.x, 0, 1); @@ -787,8 +956,9 @@ const overlayLive = yl > 0.001 || Math.abs(this.overlayTurn.t - this.overlayTurn.x) > 0.01; let cyl = overlayLive ? this.overlayTurn.x : null; if (ex.turn != null) cyl = (cyl ?? 0) + ex.turn; + if (this.gazeSpin) cyl = (cyl ?? 0) + this.gazeSpin; const ringHint = morphing || turned ? liveRing : null; - const hasPtr = !!(this.gazeTarget || (this.followPointer && this.pointerRaw)); + const tracking = !!(this.gazeTarget || (this.followPointer && this.pointerRaw)); EY.paintEyes({ now, polys, @@ -806,7 +976,8 @@ winkEye: this.winkEye, turn: cyl, cr, - pointer: hasPtr ? this.pointer : null, + pointer: this.pointer, + tracking, notifyX: this.notify.x, overlayX: this.overlay.x, eyeEls: this.eyeEls, diff --git a/replica/src/eyes.js b/replica/src/eyes.js index 3a105a1..1d1808c 100644 --- a/replica/src/eyes.js +++ b/replica/src/eyes.js @@ -69,7 +69,7 @@ const midY = (cents[0][1] + cents[1][1]) / 2; const pullX = (Re - midX) * 0.42 * Vn; const pullY = (Re - midY) * 0.42 * Vn; - const gazeW = pointer ? 0.2 : 1; + const gazeW = opt.tracking ? 0.2 : 1; const badgeRing = opt.badgeRing || ringHint; const Yl = badgeRing ? badgeRing[Math.round(badgeRing.length * 7 / 8) % badgeRing.length] @@ -138,6 +138,9 @@ let Kj = Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5; let Ko = Math.sin(now * 58e-5 + i) * 0.9; if (pointer) { + const Zl = Rn(0.16); + pointer.x += (pointer.tx - pointer.x) * Zl; + pointer.y += (pointer.ty - pointer.y) * Zl; Kj += pointer.x * (1 - 0.6 * Vn) + pullX; Ko += pointer.y * (1 - 0.6 * Vn) + pullY; } else { diff --git a/replica/src/fx.js b/replica/src/fx.js index 8af2abd..b6ba095 100644 --- a/replica/src/fx.js +++ b/replica/src/fx.js @@ -838,12 +838,13 @@ } resetInk() { this.ink = []; } + resetRecv() { this.recvTick = -1; this.recvDir = -0.7; } } const turnAtCache = new Map(); - function turnAtOf(name, path, R) { + function turnAtOf(name, path, R, solids) { if (turnAtCache.has(name)) return turnAtCache.get(name); - const solid = global.GROK_GEO.solids?.[name]; + const solid = (solids || global.GROK_GEO.solids)?.[name]; if (!solid || !global.GROK_MATH?.makeTurnAt) { turnAtCache.set(name, null); return null; diff --git a/replica/src/pose.js b/replica/src/pose.js index 9bc5874..6170ede 100644 --- a/replica/src/pose.js +++ b/replica/src/pose.js @@ -5,9 +5,10 @@ function applyPose(state, mt, dtState, now, ctx, extra = {}) { const pt = 0; + const prev = extra.prev || { spin: pt, tx: 0, ty: 0, squash: 1 }; + let { spin, tx, ty, squash } = prev; let lid = 1; let eyeBoost = 1; - let spin = pt, tx = 0, ty = 0, squash = 1; switch (state) { case "sleeping": { @@ -17,7 +18,8 @@ tx = -2 * En; ty = 8 * En + Math.sin(mt * 0.55) * 3 - Zt * 5; squash = 1 + Math.sin(mt * 0.55) * 0.016 + Zt * 0.05; - if (EYE_PLAYLIST.sleeping.includes(extra.eyeTo)) { + const sleepEyes = extra.playlist?.sleeping || EYE_PLAYLIST.sleeping; + if (sleepEyes.includes(extra.eyeTo)) { lid = extra.eyeMorphX > 0.85 ? 1 : 0.08; } else if (dtState < 1.2) { const dn = Math.min(1, dtState / 1); diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..5c2899b --- /dev/null +++ b/src/App.vue @@ -0,0 +1,463 @@ + + + diff --git a/src/components/AnimPanel.vue b/src/components/AnimPanel.vue new file mode 100644 index 0000000..182154c --- /dev/null +++ b/src/components/AnimPanel.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/BlockPicker.vue b/src/components/BlockPicker.vue new file mode 100644 index 0000000..6e5bcd3 --- /dev/null +++ b/src/components/BlockPicker.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/components/BotTile.vue b/src/components/BotTile.vue new file mode 100644 index 0000000..89e9441 --- /dev/null +++ b/src/components/BotTile.vue @@ -0,0 +1,71 @@ + + + diff --git a/src/components/ConfirmDialog.vue b/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..bd89390 --- /dev/null +++ b/src/components/ConfirmDialog.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/components/Customizer.vue b/src/components/Customizer.vue new file mode 100644 index 0000000..80ae272 --- /dev/null +++ b/src/components/Customizer.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/components/CycleDialog.vue b/src/components/CycleDialog.vue new file mode 100644 index 0000000..7eca8df --- /dev/null +++ b/src/components/CycleDialog.vue @@ -0,0 +1,183 @@ + + + diff --git a/src/components/CycleMenu.vue b/src/components/CycleMenu.vue new file mode 100644 index 0000000..f766c89 --- /dev/null +++ b/src/components/CycleMenu.vue @@ -0,0 +1,146 @@ + + + diff --git a/src/components/ExportBar.vue b/src/components/ExportBar.vue new file mode 100644 index 0000000..14814ae --- /dev/null +++ b/src/components/ExportBar.vue @@ -0,0 +1,191 @@ + + + diff --git a/src/components/GifDialog.vue b/src/components/GifDialog.vue new file mode 100644 index 0000000..33b4787 --- /dev/null +++ b/src/components/GifDialog.vue @@ -0,0 +1,85 @@ + + + diff --git a/src/components/Hero.vue b/src/components/Hero.vue new file mode 100644 index 0000000..2069f42 --- /dev/null +++ b/src/components/Hero.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/components/NameDialog.vue b/src/components/NameDialog.vue new file mode 100644 index 0000000..afd5ff9 --- /dev/null +++ b/src/components/NameDialog.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/components/Settings.vue b/src/components/Settings.vue new file mode 100644 index 0000000..5427236 --- /dev/null +++ b/src/components/Settings.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/components/SideRail.vue b/src/components/SideRail.vue new file mode 100644 index 0000000..f04e231 --- /dev/null +++ b/src/components/SideRail.vue @@ -0,0 +1,151 @@ + + + diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue new file mode 100644 index 0000000..5cfb4f7 --- /dev/null +++ b/src/components/Timeline.vue @@ -0,0 +1,324 @@ + + + diff --git a/src/components/TimelineBar.vue b/src/components/TimelineBar.vue new file mode 100644 index 0000000..9ec2e41 --- /dev/null +++ b/src/components/TimelineBar.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/components/TimelineTrack.vue b/src/components/TimelineTrack.vue new file mode 100644 index 0000000..155b777 --- /dev/null +++ b/src/components/TimelineTrack.vue @@ -0,0 +1,517 @@ + + + diff --git a/src/components/ZoomSlider.vue b/src/components/ZoomSlider.vue new file mode 100644 index 0000000..c983e15 --- /dev/null +++ b/src/components/ZoomSlider.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/editor/cycles.ts b/src/editor/cycles.ts new file mode 100644 index 0000000..1317c16 --- /dev/null +++ b/src/editor/cycles.ts @@ -0,0 +1,149 @@ +import { defaultCycle as seedBlocks, stateGroups } from '@/replica/catalog' + +export type StateId = string + +export interface Block { + state: StateId + duration: number +} + +export interface Cycle { + id: string + name: string + blocks: Block[] +} + +export const MIN_BLOCK = 0.6 +export const MAX_BLOCK = 10 +export const MAX_BLOCS = 200 +export const MAX_CYCLES = 50 +export const STEP = 0.1 + +const DEFAULT_CYCLE_ID = 'defaut' + +export function catalogue(): StateId[] { + return stateGroups().flatMap((g) => g.states) +} + +const KNOWN = new Set(catalogue()) + +export function isState(id: string): id is StateId { + return KNOWN.has(id) +} + +export function minDurationOf(_state: StateId): number { + return MIN_BLOCK +} + +export function clampDuration(state: StateId, seconds: number): number { + const snapped = Math.round(seconds / STEP) * STEP + const bounded = Math.min(MAX_BLOCK, Math.max(minDurationOf(state), snapped)) + return Math.round(bounded * 100) / 100 +} + +export function makeBlock(state: StateId): Block { + const seed = seedBlocks().find((b) => b.state === state) + return { state, duration: clampDuration(state, seed?.duration ?? 2.4) } +} + +export function defaultCycle(): Cycle { + return { + name: '', + id: DEFAULT_CYCLE_ID, + blocks: seedBlocks().map((b) => ({ + state: b.state, + duration: clampDuration(b.state, b.duration) + })) + } +} + +export function totalDuration(blocks: Block[]): number { + return blocks.reduce((sum, b) => sum + b.duration, 0) +} + +export function offsetOf(blocks: Block[], index: number): number { + let acc = 0 + for (let i = 0; i < index && i < blocks.length; i++) acc += blocks[i]!.duration + return acc +} + +export function blockAt(blocks: Block[], t: number): { index: number; elapsed: number } { + const total = totalDuration(blocks) + if (!blocks.length || total <= 0) return { index: 0, elapsed: 0 } + const wrapped = t >= 0 && t < total ? t : ((t % total) + total) % total + let acc = 0 + for (let i = 0; i < blocks.length; i++) { + const end = acc + blocks[i]!.duration + if (wrapped < end) return { index: i, elapsed: wrapped - acc } + acc = end + } + return { index: blocks.length - 1, elapsed: 0 } +} + +export function blocksWith(blocks: Block[], state: StateId): Block[] { + if (blocks.length >= MAX_BLOCS) return blocks + return [...blocks, makeBlock(state)] +} + +export function moveBlock(blocks: Block[], from: number, to: number): Block[] { + const next = blocks.slice() + const [moved] = next.splice(from, 1) + if (!moved) return blocks + next.splice(Math.min(Math.max(to, 0), next.length), 0, moved) + return next +} + +export function uniqueName(base: string, cycles: Cycle[]): string { + const taken = new Set(cycles.map((c) => c.name)) + if (!taken.has(base)) return base + let n = 2 + while (taken.has(`${base} ${n}`)) n++ + return `${base} ${n}` +} + +export function nextCycleId(cycles: Cycle[]): string { + const taken = new Set(cycles.map((c) => c.id)) + let n = 1 + while (taken.has(`c${n}`)) n++ + return `c${n}` +} + +function parseBlock(raw: unknown): Block | null { + if (typeof raw !== 'object' || raw === null) return null + const { state, duration } = raw as { state?: unknown; duration?: unknown } + if (typeof state !== 'string' || !isState(state)) return null + if (typeof duration !== 'number' || !Number.isFinite(duration)) return null + return { state, duration: clampDuration(state, duration) } +} + +function parseCycle(raw: unknown, seen: Cycle[]): Cycle | null { + if (typeof raw !== 'object' || raw === null) return null + const { id, name, blocks } = raw as { id?: unknown; name?: unknown; blocks?: unknown } + if (typeof id !== 'string' || !id) return null + if (typeof name !== 'string') return null + if (!Array.isArray(blocks)) return null + const kept = blocks + .slice(0, MAX_BLOCS) + .map(parseBlock) + .filter((b): b is Block => b !== null) + if (!kept.length) return null + if (seen.some((c) => c.id === id)) return null + return { id, name, blocks: kept } +} + +export function parseCycles(raw: string | null): Cycle[] { + if (!raw) return [] + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(data)) return [] + const out: Cycle[] = [] + for (const item of data.slice(0, MAX_CYCLES)) { + const cycle = parseCycle(item, out) + if (cycle) out.push(cycle) + } + return out +} diff --git a/src/i18n/format.ts b/src/i18n/format.ts new file mode 100644 index 0000000..20c9af4 --- /dev/null +++ b/src/i18n/format.ts @@ -0,0 +1,17 @@ +const SEPARATEUR = ' | ' + +export function interpoler(texte: string, valeurs?: Record): string { + if (!valeurs) return texte + let sortie = texte + for (const [nom, valeur] of Object.entries(valeurs)) { + sortie = sortie.replaceAll(`{${nom}}`, String(valeur)) + } + return sortie +} + +export function formePlurielle(gabarit: string, n: number, tag: string): string { + const formes = gabarit.split(SEPARATEUR) + if (formes.length < 2) return formes[0]! + const index = new Intl.PluralRules(tag).select(n) === 'one' ? 0 : 1 + return formes[index] ?? formes[0]! +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..e5ce52e --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,99 @@ +import { computed, ref, watchEffect } from 'vue' +import { ecris, lis } from '@/ui/stockage' +import { formePlurielle, interpoler } from './format' +import { choisirLangue, estLangue, type Langue, tagDe } from './langues' +import fr from './locales/fr' +import en from './locales/en' +import zh from './locales/zh' + +export { LANGUES, type Langue } from './langues' + +const dictionnaires: Record = { fr, en, zh } + +type Chemins = { + [K in keyof T & string]: T[K] extends string ? `${P}${K}` : Chemins +}[keyof T & string] + +export type Cle = Chemins + +const courante = ref( + choisirLangue(lis('langue'), typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language])) +) + +export const langue = computed({ + get: () => courante.value, + set: (valeur) => { + if (!estLangue(valeur)) return + courante.value = valeur + ecris('langue', valeur) + } +}) + +const dictionnaire = computed(() => dictionnaires[courante.value]) +const tag = computed(() => tagDe(courante.value)) + +watchEffect(() => { + if (typeof document === 'undefined') return + document.documentElement.lang = tag.value + document.title = t('app.title') +}) + +function brut(cle: Cle): string { + const noeud = cle + .split('.') + .reduce((n, k) => (n as Record)[k], dictionnaire.value) + return noeud as string +} + +export function t(cle: Cle, valeurs?: Record): string { + return interpoler(brut(cle), valeurs) +} + +export function pluriel(cle: Cle, n: number, valeurs?: Record): string { + return interpoler(formePlurielle(brut(cle), n, tag.value), { n, ...valeurs }) +} + +export function nomDeCycle(cycle: { name: string }): string { + return cycle.name || t('cycles.defaultName') +} + +const formateurs = new Map() + +function formateur(cle: string, options: Intl.NumberFormatOptions): Intl.NumberFormat { + const memo = `${tag.value}:${cle}` + let f = formateurs.get(memo) + if (!f) { + f = new Intl.NumberFormat(tag.value, options) + formateurs.set(memo, f) + } + return f +} + +export function nombre(valeur: number, decimales = 0): string { + return formateur(`n${decimales}`, { + minimumFractionDigits: decimales, + maximumFractionDigits: decimales + }).format(valeur) +} + +export function pourcentage(fraction: number): string { + return formateur('%', { style: 'percent', maximumFractionDigits: 0 }).format(fraction) +} + +export function secondes(valeur: number): string { + return t('units.seconds', { n: nombre(valeur, 1) }) +} + +export function secondesCourtes(valeur: number, decimales: number): string { + return t('units.secondsShort', { n: nombre(valeur, decimales) }) +} + +export function stateName(id: string): string { + const dict = dictionnaire.value.states as Record + return dict[id] ?? id +} + +export function shapeName(id: string): string { + const dict = dictionnaire.value.shapes as Record + return dict[id] ?? id +} diff --git a/src/i18n/langues.ts b/src/i18n/langues.ts new file mode 100644 index 0000000..5a86ad7 --- /dev/null +++ b/src/i18n/langues.ts @@ -0,0 +1,31 @@ +export const LANGUES = [ + { id: 'fr', tag: 'fr', emoji: '🇫🇷', nom: 'Français' }, + { id: 'en', tag: 'en', emoji: '🇬🇧', nom: 'English' }, + { id: 'zh', tag: 'zh-Hans', emoji: '🇨🇳', nom: '简体中文' } +] as const + +export type Langue = (typeof LANGUES)[number]['id'] + +export const LANGUE_PAR_DEFAUT: Langue = 'en' + +export function estLangue(valeur: string | null | undefined): valeur is Langue { + return LANGUES.some((l) => l.id === valeur) +} + +export function tagDe(langue: Langue): string { + return LANGUES.find((l) => l.id === langue)!.tag +} + +export function choisirLangue(memorisee: string | null, preferences: readonly string[]): Langue { + if (estLangue(memorisee)) return memorisee + for (const tag of preferences) { + let base: string + try { + base = new Intl.Locale(tag).language + } catch { + continue + } + if (estLangue(base)) return base + } + return LANGUE_PAR_DEFAUT +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts new file mode 100644 index 0000000..3a87f07 --- /dev/null +++ b/src/i18n/locales/en.ts @@ -0,0 +1,158 @@ +import type fr from './fr' + +const en: typeof fr = { + app: { + name: 'study', + title: 'study — spring character', + botAria: 'Spring-driven character', + missingGeo: 'Local geometry is missing. The chrome still works; the character stays off. See replica/geometry.schema.md.' + }, + rail: { + nav: 'Sections', + customize: 'Customise', + animations: 'Animations', + settings: 'Settings' + }, + panel: { + animations: 'Animation', + shape: 'Shape', + expression: 'Expression', + color: 'Colour', + group_lifecycle: 'Lifecycle', + group_reactions: 'Reactions', + group_agent: 'Agent', + group_product: 'Product' + }, + shapes: { + blob: 'Blob', + bean: 'Bean', + egg: 'Egg', + teardrop: 'Drop', + cloud: 'Cloud', + leaf: 'Leaf', + squircle: 'Cube', + capsule: 'Capsule' + }, + export: { + action: 'Export PNG', + more: 'Other formats', + png: 'Download PNG', + svg: 'Download SVG', + anime: 'Download animated SVG', + gif: 'Download animated GIF', + cycleDetail: 'The video is lighter and smoother; the GIF plays anywhere.', + cycleFormat: 'Format', + cycle_mp4: 'MP4 video', + cycle_mp4_aide: 'Light and smooth, needs a background', + cycle_gif: 'Animated GIF', + cycle_gif_aide: 'Plays anywhere, heavier', + cycleProgress: 'Exporting…', + cycleReessayer: 'Try again', + gifTitle: 'Download animated GIF', + gifDetail: + 'GIF transparency is all-or-nothing: with no background, the ball’s edge comes out a little hard.', + gifBackground: 'Background', + fond_blanc: 'White background', + fond_blanc_aide: 'Smooth edge, for light surfaces', + fond_transparent: 'Transparent background', + fond_transparent_aide: 'Fits any background, edge a little hard', + gifConfirm: 'Download', + copie: 'Copy image', + copieSvg: 'Copy SVG', + done: 'Exported', + copied: 'Copied', + failed: 'Export failed' + }, + preview: { + exit: 'Exit preview', + open: 'Preview', + key: 'Esc' + }, + timeline: { + play: 'Start playback', + pause: 'Stop playback', + addAnimation: 'Add an animation', + preview: 'Preview', + export: 'Export animation sequence', + zoom: 'Track zoom', + blockAria: '{state}, {duration}', + blockDurationAria: 'Duration of {state}, {duration}', + blockRemoveAria: 'Remove {state}' + }, + dialog: { + cancel: 'Cancel', + nameCreateTitle: 'New cycle', + nameRenameTitle: 'Rename cycle', + nameField: 'Cycle name', + nameCreate: 'Create', + nameRename: 'Rename', + removeTitle: 'Delete “{name}”?', + removeDetail: + 'This sequence will be lost, along with its animation. | This sequence will be lost, along with its {n} animations.', + removeConfirm: 'Delete' + }, + cycles: { + defaultName: 'Default cycle', + newName: 'My cycle', + menuNew: 'New cycle', + menuRenameAria: 'Rename {name}', + menuRemoveAria: 'Delete {name}' + }, + states: { + sleeping: 'Sleeping', + waking: 'Waking', + idle: 'Idle', + listening: 'Listening', + thinking: 'Thinking', + searching: 'Searching', + working: 'Working', + excited: 'Excited', + surprised: 'Surprised', + suspicious: 'Suspicious', + angry: 'Angry', + drowsy: 'Drowsy', + happy: 'Happy', + curious: 'Curious', + confused: 'Confused', + bored: 'Bored', + proud: 'Proud', + shy: 'Shy', + sad: 'Sad', + laughing: 'Laughing', + scared: 'Scared', + playful: 'Playful', + celebrate: 'Celebrate', + orbit: 'Orbit', + radar: 'Radar', + progress: 'Progress', + spawning: 'Spawning', + humming: 'Humming', + loading: 'Loading', + dictating: 'Dictating', + writing: 'Writing', + sending: 'Sending', + receiving: 'Receiving', + uploading: 'Uploading', + notifying: 'Notifying', + alerting: 'Alerting', + dragging: 'Dragging', + bouncing: 'Bouncing', + 'powering-down': 'Powering down' + }, + settings: { + language: 'Language', + about: 'About', + lab: 'Engine lab', + labAria: 'Open the original playground', + showcase: 'Showcase', + showcaseAria: 'Open the recording showcase', + credits: 'Unofficial study. Motion from the replica engine; layout after bloub. Not affiliated with xAI.', + missing: 'No geometry-data.js — stock chrome, no character.' + }, + units: { + seconds: '{n} s', + secondsShort: '{n}s' + } +} + +export default en diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts new file mode 100644 index 0000000..6287e12 --- /dev/null +++ b/src/i18n/locales/fr.ts @@ -0,0 +1,158 @@ +const fr = { + app: { + name: 'study', + title: 'study — personnage à ressorts', + botAria: 'Personnage animé par ressorts', + missingGeo: + 'Géométrie locale absente. L’interface reste là ; le personnage non. Voir replica/geometry.schema.md.' + }, + rail: { + nav: 'Sections', + customize: 'Personnaliser', + animations: 'Animations', + settings: 'Réglages' + }, + panel: { + animations: 'Animation', + shape: 'Forme', + expression: 'Expression', + color: 'Couleur', + group_lifecycle: 'Cycle', + group_reactions: 'Réactions', + group_agent: 'Agent', + group_product: 'Produit' + }, + shapes: { + blob: 'Blob', + bean: 'Haricot', + egg: 'Œuf', + teardrop: 'Goutte', + cloud: 'Nuage', + leaf: 'Feuille', + squircle: 'Cube', + capsule: 'Capsule' + }, + export: { + action: 'Exporter en PNG', + more: 'Autres formats', + png: 'Télécharger le PNG', + svg: 'Télécharger le SVG', + anime: "Télécharger l'animation SVG", + gif: 'Télécharger le GIF animé', + cycleDetail: 'La vidéo est plus légère et plus fluide ; le GIF passe partout.', + cycleFormat: 'Format', + cycle_mp4: 'Vidéo MP4', + cycle_mp4_aide: 'Léger et fluide, fond obligatoire', + cycle_gif: 'GIF animé', + cycle_gif_aide: 'Lu partout, plus lourd', + cycleProgress: 'Export en cours…', + cycleReessayer: 'Réessayer', + gifTitle: 'Télécharger le GIF animé', + gifDetail: + "Le GIF ne gère la transparence qu'en tout ou rien : sans fond, le contour de la boule est un peu dur.", + gifBackground: 'Fond', + fond_blanc: 'Fond blanc', + fond_blanc_aide: 'Contour lisse, à poser sur du clair', + fond_transparent: 'Fond transparent', + fond_transparent_aide: "S'adapte à tout fond, contour un peu dur", + gifConfirm: 'Télécharger', + copie: "Copier l'image", + copieSvg: 'Copier le SVG', + done: 'Exporté', + copied: 'Copié', + failed: "Échec de l'export" + }, + preview: { + exit: 'Quitter l’aperçu', + open: 'Aperçu', + key: 'Échap' + }, + timeline: { + play: 'Lancer la lecture', + pause: 'Arrêter la lecture', + addAnimation: 'Ajouter une animation', + preview: 'Aperçu', + export: "Exporter la séquence d'animation", + zoom: 'Zoom de la piste', + blockAria: '{state}, {duration}', + blockDurationAria: 'Durée de {state}, {duration}', + blockRemoveAria: 'Retirer {state}' + }, + dialog: { + cancel: 'Annuler', + nameCreateTitle: 'Nouveau cycle', + nameRenameTitle: 'Renommer le cycle', + nameField: 'Nom du cycle', + nameCreate: 'Créer', + nameRename: 'Renommer', + removeTitle: 'Supprimer « {name} » ?', + removeDetail: + 'Ce montage sera perdu, avec son animation. | Ce montage sera perdu, avec ses {n} animations.', + removeConfirm: 'Supprimer' + }, + cycles: { + defaultName: 'Cycle par défaut', + newName: 'Mon cycle', + menuNew: 'Nouveau cycle', + menuRenameAria: 'Renommer {name}', + menuRemoveAria: 'Supprimer {name}' + }, + states: { + sleeping: 'Sommeil', + waking: 'Réveil', + idle: 'Repos', + listening: 'Écoute', + thinking: 'Réflexion', + searching: 'Recherche', + working: 'Travail', + excited: 'Excité', + surprised: 'Surpris', + suspicious: 'Méfiant', + angry: 'En colère', + drowsy: 'Somnolent', + happy: 'Heureux', + curious: 'Curieux', + confused: 'Confus', + bored: 'Blasé', + proud: 'Fier', + shy: 'Timide', + sad: 'Triste', + laughing: 'Rire', + scared: 'Effrayé', + playful: 'Joueur', + celebrate: 'Célébration', + orbit: 'Orbite', + radar: 'Radar', + progress: 'Progrès', + spawning: 'Apparition', + humming: 'Bourdonnement', + loading: 'Chargement', + dictating: 'Dictée', + writing: 'Écriture', + sending: 'Envoi', + receiving: 'Réception', + uploading: 'Téléversement', + notifying: 'Notification', + alerting: 'Alerte', + dragging: 'Glisser', + bouncing: 'Rebond', + 'powering-down': 'Extinction' + }, + settings: { + language: 'Langue', + about: 'À propos', + lab: 'Laboratoire', + labAria: 'Ouvrir le playground d’origine', + showcase: 'Showcase', + showcaseAria: 'Ouvrir le showcase d’enregistrement', + credits: + 'Étude non officielle. Le mouvement vient du moteur replica ; la mise en page, de bloub. Sans lien avec xAI.', + missing: 'Pas de geometry-data.js — chrome seul, pas de personnage.' + }, + units: { + seconds: '{n} s', + secondsShort: '{n}s' + } +} + +export default fr diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts new file mode 100644 index 0000000..83219a5 --- /dev/null +++ b/src/i18n/locales/zh.ts @@ -0,0 +1,156 @@ +import type fr from './fr' + +const zh: typeof fr = { + app: { + name: 'study', + title: 'study — 弹簧角色', + botAria: '弹簧驱动的角色', + missingGeo: '本地几何不在。页面壳还在,角色不画。见 replica/geometry.schema.md。' + }, + rail: { + nav: '分区', + customize: '自定义', + animations: '动画', + settings: '设置' + }, + panel: { + animations: '动画', + shape: '形体', + expression: '表情', + color: '颜色', + group_lifecycle: '生命周期', + group_reactions: '反应', + group_agent: '代理', + group_product: '产品' + }, + shapes: { + blob: '团子', + bean: '豆', + egg: '蛋', + teardrop: '水滴', + cloud: '云', + leaf: '叶子', + squircle: '正方体', + capsule: '胶囊' + }, + export: { + action: '导出 PNG', + more: '其他格式', + png: '下载 PNG', + svg: '下载 SVG', + anime: '下载 SVG 动图', + gif: '下载 GIF 动图', + cycleDetail: '视频更轻更流畅;GIF 到处都能播放。', + cycleFormat: '格式', + cycle_mp4: 'MP4 视频', + cycle_mp4_aide: '轻巧流畅,必须有背景', + cycle_gif: 'GIF 动图', + cycle_gif_aide: '到处可播,体积更大', + cycleProgress: '正在导出…', + cycleReessayer: '重试', + gifTitle: '下载 GIF 动图', + gifDetail: 'GIF 的透明只有全有或全无:不加背景时,球体边缘会略显生硬。', + gifBackground: '背景', + fond_blanc: '白色背景', + fond_blanc_aide: '边缘平滑,适合浅色底', + fond_transparent: '透明背景', + fond_transparent_aide: '适配任何背景,边缘略硬', + gifConfirm: '下载', + copie: '复制图片', + copieSvg: '复制 SVG', + done: '已导出', + copied: '已复制', + failed: '导出失败' + }, + preview: { + exit: '退出预览', + open: '预览', + key: 'Esc' + }, + timeline: { + play: '开始播放', + pause: '停止播放', + addAnimation: '添加动画', + preview: '预览', + export: '导出动画序列', + zoom: '轨道缩放', + blockAria: '{state},{duration}', + blockDurationAria: '{state} 的时长,{duration}', + blockRemoveAria: '移除 {state}' + }, + dialog: { + cancel: '取消', + nameCreateTitle: '新建循环', + nameRenameTitle: '重命名循环', + nameField: '循环名称', + nameCreate: '创建', + nameRename: '重命名', + removeTitle: '删除「{name}」?', + removeDetail: '这段蒙太奇会连同动画一起丢掉。 | 这段蒙太奇会连同它的 {n} 段动画一起丢掉。', + removeConfirm: '删除' + }, + cycles: { + defaultName: '默认循环', + newName: '我的循环', + menuNew: '新建循环', + menuRenameAria: '重命名 {name}', + menuRemoveAria: '删除 {name}' + }, + states: { + sleeping: '睡着', + waking: '醒来', + idle: '休息', + listening: '倾听', + thinking: '思考', + searching: '搜索', + working: '工作', + excited: '兴奋', + surprised: '惊讶', + suspicious: '怀疑', + angry: '生气', + drowsy: '困倦', + happy: '高兴', + curious: '好奇', + confused: '困惑', + bored: '无聊', + proud: '得意', + shy: '害羞', + sad: '难过', + laughing: '大笑', + scared: '害怕', + playful: '调皮', + celebrate: '庆祝', + orbit: '环绕', + radar: '雷达', + progress: '进度', + spawning: '生成', + humming: '嗡鸣', + loading: '加载', + dictating: '口述', + writing: '书写', + sending: '发送', + receiving: '接收', + uploading: '上传', + notifying: '通知', + alerting: '警报', + dragging: '拖拽', + bouncing: '弹跳', + 'powering-down': '关机' + }, + settings: { + language: '语言', + about: '关于', + lab: '机芯实验室', + labAria: '打开原来的 playground', + showcase: '展示页', + showcaseAria: '打开录屏展示', + credits: '非官方学习稿。动效是 replica 机芯,版式参考 bloub。与 xAI 无关。', + missing: '没有 geometry-data.js — 只有壳,没有角色。' + }, + units: { + seconds: '{n} 秒', + secondsShort: '{n}秒' + } +} + +export default zh diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..fdbdce5 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles.css' + +createApp(App).mount('#app') diff --git a/src/replica/boot.ts b/src/replica/boot.ts new file mode 100644 index 0000000..079f7ed --- /dev/null +++ b/src/replica/boot.ts @@ -0,0 +1,87 @@ +/** Load local geometry, then the replica IIFE engine. Missing geometry keeps the chrome. */ + +export type ReplicaBot = { + setState: (name: string, opts?: { resetEyes?: boolean }) => void + setShape: (name: string) => void + setColor: (id: string, scheme?: string) => void + setFollowPointer: (on: boolean) => void + setEyeColor: (color: string | null) => void + setPaused: (on: boolean | 'hold-pose') => void + spinOnce: (turns?: number) => void + bounceOnce: () => void + burstOnce: () => void + orbitGaze: (ms?: number) => void + holdFrame: (at?: number) => void + step: (dt: number) => void + destroy: () => void +} + +declare global { + interface Window { + GROK_GEO?: { + Re?: number + shapes?: Record + palette?: Record + } + GROK_TABLES?: { + GROUPS: Array<{ label: string; states: string[] }> + INK: Record + } + GrokCharacter?: new (svg: SVGSVGElement, opts: Record) => ReplicaBot + } +} + +let pending: Promise | null = null + +export function replicaReady(): boolean { + return typeof window !== 'undefined' && !!window.GROK_GEO?.shapes && typeof window.GrokCharacter === 'function' +} + +function loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const existing = document.querySelector(`script[data-replica="${src}"]`) + if (existing) { + resolve() + return + } + const el = document.createElement('script') + el.src = src + el.dataset.replica = src + el.onload = () => resolve() + el.onerror = () => reject(new Error(`replica: failed to load ${src}`)) + document.head.appendChild(el) + }) +} + +async function loadOptional(src: string): Promise { + try { + const res = await fetch(src) + if (!res.ok) return false + if (document.querySelector(`script[data-replica="${src}"]`)) return true + const el = document.createElement('script') + el.dataset.replica = src + el.textContent = await res.text() + document.head.appendChild(el) + return true + } catch { + return false + } +} + +export function loadReplica(): Promise { + if (replicaReady()) return Promise.resolve(true) + if (pending) return pending + pending = (async () => { + try { + const geo = await loadOptional('/replica/geometry-data.js') + if (!geo || !window.GROK_GEO) return false + for (const name of ['math', 'tables', 'pose', 'tricks', 'fx', 'eyes', 'character']) { + await loadScript(`/replica/src/${name}.js`) + } + return replicaReady() + } catch { + return false + } + })() + return pending +} diff --git a/src/replica/catalog.ts b/src/replica/catalog.ts new file mode 100644 index 0000000..6d5bce9 --- /dev/null +++ b/src/replica/catalog.ts @@ -0,0 +1,159 @@ +/** Replica catalogues. Ids stay English; the chrome never uses Bloub's 14-state set. */ + +export const SHAPE_IDS = [ + 'blob', + 'bean', + 'egg', + 'teardrop', + 'cloud', + 'leaf', + 'squircle', + 'capsule' +] as const + +export const COLOR_IDS = [ + 'black', + 'brown', + 'red', + 'orange', + 'yellow', + 'green', + 'cyan', + 'blue', + 'violet', + 'magenta', + 'gray' +] as const + +export const EXPRESSION_IDS = [ + 'idle', + 'listening', + 'excited', + 'surprised', + 'suspicious', + 'angry', + 'drowsy', + 'happy', + 'curious', + 'confused', + 'bored', + 'proud', + 'shy', + 'sad', + 'laughing', + 'scared', + 'playful' +] as const + +export const GROUP_KEYS = ['lifecycle', 'reactions', 'agent', 'product'] as const + +export type ShapeId = (typeof SHAPE_IDS)[number] +export type ColorId = (typeof COLOR_IDS)[number] +export type ExpressionId = (typeof EXPRESSION_IDS)[number] + +export const DEFAULT_SHAPE: ShapeId = 'blob' +export const DEFAULT_COLOR: ColorId = 'black' +export const DEFAULT_EXPRESSION: ExpressionId = 'curious' + +const FALLBACK_HEX: Record = { + black: '#111111', + brown: '#855C36', + red: '#E02135', + orange: '#E05B00', + yellow: '#E08600', + green: '#009957', + cyan: '#1aa8c4', + blue: '#0E74E0', + violet: '#804EE0', + magenta: '#E02A88', + gray: '#696969' +} + +export function colorHex(id: string): string { + const ink = window.GROK_TABLES?.INK?.[id] + if (ink?.lightTo) return ink.lightTo + return FALLBACK_HEX[id] ?? '#111111' +} + +export function colorFill(id: string): string { + const ink = window.GROK_TABLES?.INK?.[id] + if (ink?.lightFrom && ink?.lightTo) { + return `linear-gradient(135deg, ${ink.lightFrom}, ${ink.lightTo})` + } + return colorHex(id) +} + +export function estForme(id: string): id is ShapeId { + return (SHAPE_IDS as readonly string[]).includes(id) +} + +export function liveShapes(): string[] { + const geo = window.GROK_GEO?.shapes + if (!geo) return [...SHAPE_IDS] + return SHAPE_IDS.filter((id) => id in geo) +} + +export function stateGroups(): Array<{ key: (typeof GROUP_KEYS)[number]; states: string[] }> { + const groups = window.GROK_TABLES?.GROUPS + if (groups?.length) { + return groups.map((g, i) => ({ + key: GROUP_KEYS[i] ?? 'lifecycle', + states: g.states + })) + } + return [ + { key: 'lifecycle', states: ['sleeping', 'waking', 'idle', 'listening', 'thinking', 'searching', 'working'] }, + { + key: 'reactions', + states: [ + 'excited', + 'surprised', + 'suspicious', + 'angry', + 'drowsy', + 'happy', + 'curious', + 'confused', + 'bored', + 'proud', + 'shy', + 'sad', + 'laughing', + 'scared', + 'playful', + 'celebrate' + ] + }, + { key: 'agent', states: ['orbit', 'radar', 'progress'] }, + { + key: 'product', + states: [ + 'spawning', + 'humming', + 'loading', + 'dictating', + 'writing', + 'sending', + 'receiving', + 'uploading', + 'notifying', + 'alerting', + 'dragging', + 'bouncing', + 'powering-down' + ] + } + ] +} + +export function defaultCycle(): Array<{ state: string; duration: number }> { + return [ + { state: 'idle', duration: 2.4 }, + { state: 'curious', duration: 2.2 }, + { state: 'thinking', duration: 2.6 }, + { state: 'orbit', duration: 3.2 }, + { state: 'writing', duration: 2.8 }, + { state: 'celebrate', duration: 2.6 }, + { state: 'sending', duration: 2.4 } + ] +} diff --git a/src/replica/host.ts b/src/replica/host.ts new file mode 100644 index 0000000..46835de --- /dev/null +++ b/src/replica/host.ts @@ -0,0 +1,92 @@ +import { loadReplica, replicaReady, type ReplicaBot } from './boot' + +export type ReplicaMount = { + apply: (input: ReplicaInput) => void + spin: () => void + orbitGaze: (ms?: number) => void + step: (dt: number) => void + setState: (name: string) => void + destroy: () => void +} + +export type ReplicaInput = { + state: string + shape: string + color: string + follow: boolean + paper: string + size: number +} + +let tileQueue: Promise = Promise.resolve() + +/** One-frame tiles share a queue so first paint does not spawn 30 engines at once. */ +export function paintTile(svg: SVGSVGElement, input: ReplicaInput): Promise { + const job = tileQueue.then(() => mountReplica(svg, input, false)) + tileQueue = job.then( + () => undefined, + () => undefined + ) + return job +} + +export async function mountReplica( + svg: SVGSVGElement, + input: ReplicaInput, + live: boolean, + extra?: { driven?: boolean } +): Promise { + const ok = await loadReplica() + if (!ok || !window.GrokCharacter) return null + const Re = window.GROK_GEO?.Re ?? 114.27 + const driven = !!extra?.driven + const bot: ReplicaBot = new window.GrokCharacter(svg, { + state: input.state, + shape: input.shape, + color: input.color, + sizePx: input.size, + mode: 'hold', + loginWrap: true, + fitBox: true, + frameHalf: Re * 1.58, + followPointer: live && input.follow && !driven, + eyeColor: input.paper, + paused: driven ? false : live ? false : 'hold-pose', + driven + }) + const noop = { + apply: () => {}, + spin: () => {}, + orbitGaze: () => {}, + step: () => {}, + setState: () => {}, + destroy: () => {} + } + if (!live && !driven) { + bot.destroy() + return noop + } + + let last = { ...input } + return { + apply(next) { + if (next.state !== last.state) bot.setState(next.state, { resetEyes: true }) + if (next.shape !== last.shape) bot.setShape(next.shape) + if (next.color !== last.color) bot.setColor(next.color) + if (next.follow !== last.follow) bot.setFollowPointer(next.follow) + if (next.paper !== last.paper) bot.setEyeColor(next.paper) + last = { ...next } + }, + spin: () => bot.spinOnce(1), + orbitGaze: (ms) => bot.orbitGaze(ms), + step: (dt) => bot.step(dt), + setState: (name) => { + if (name === last.state) return + bot.setState(name, { resetEyes: true }) + last = { ...last, state: name } + }, + destroy: () => bot.destroy() + } +} + +export { replicaReady } diff --git a/src/replica/shims.d.ts b/src/replica/shims.d.ts new file mode 100644 index 0000000..d909b26 --- /dev/null +++ b/src/replica/shims.d.ts @@ -0,0 +1 @@ +declare module '../../replica/src/*.js' diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..27b275a --- /dev/null +++ b/src/styles.css @@ -0,0 +1,628 @@ +@import 'tailwindcss'; + +:root { + /* Fond releve au pixel sur la video de reference. */ + --paper: #f9f9f9; + /* + * Couleur principale de l'INTERFACE : un bleu nuit choisi, pas releve. Le + * noir de la video, lui, n'a pas bouge — c'est celui du bot, dans skins.ts + * (`encre`, #0a0a0c). Les deux ne doivent pas etre confondus : changer + * celle-ci retouche le decor, changer l'autre casse la ressemblance. + */ + --ink: #17203a; + --line: #e3e5ea; + --muted: #6e7382; + /* Rouge d'interface pour les actions destructrices — choisi, comme --ink. */ + --danger: #d63c34; + + /** + * Hauteur de la barre de montage. Elle est fixee en bas, donc c'est la scene + * qui lui reserve sa place — dans les DEUX vues, sinon l'avatar centre change + * de position en passant a la personnalisation. + */ + --timeline: 236px; + + /** + * Duree et courbe du passage d'une vue a l'autre : le glissement de la scene et + * la croissance de la boule. Le tour du regard, lui, a sa propre duree + * (`TURN_TIME` de `src/ui/gaze.ts`), volontairement un peu plus longue — les + * yeux finissent de se poser apres que la boule a fini de grandir, ce qui fait + * lire les deux comme un seul mouvement qui se termine. Ease-out sans + * depassement, comme tout le reste du projet. + */ + --bascule: 1s cubic-bezier(0.22, 1, 0.36, 1); +} + +/* + * La meme barre, sur une fenetre etroite : 236 px, c'est 29 % d'un ecran de + * telephone, et la hauteur qu'elle prend est retranchee de tout ce qui reste. + * 200 px laissent la regle (28 px) et des cartes de 64 px, soit la vignette a sa + * taille maximale — en dessous les cartes ne montrent plus rien. + * + * La variable est lue par la reserve de la scene et par le plafond de l'avatar, + * qui valent a toutes les largeurs. Les calages fins de `.barre-export` et des + * colonnes, eux, sont dans la requete `>= 64rem` : ils ne voient jamais cette + * valeur. + */ +@media (width < 64rem) { + :root { + --timeline: 200px; + } +} + +html, +body, +#app { + height: 100%; +} + +html { + scrollbar-width: none; +} +html::-webkit-scrollbar { + display: none; +} + +/* + * La boule des reglages sort volontairement par la droite. Sans ce rognage, son + * debordement deviendrait une zone defilable et la page partirait en largeur — + * y compris pendant l'animation de RETOUR, ou la boule redescend alors que la + * vue a deja change. D'ou un rognage permanent, et pas seulement sur cette vue. + * + * Sur `#app` et non sur `body` : l'`overflow` du corps est PROPAGE a la fenetre + * quand la racine est en `visible`, donc l'y poser ne rogne rien du tout. `#app` + * est un conteneur ordinaire, lui rogne pour de bon. + * + * `clip` et pas `hidden` : `hidden` en ferait un conteneur de defilement, donc + * rendrait le debordement navigable au lieu de le supprimer. Ici sur le seul axe + * x, parce que sous 64rem la mise en page s'empile et doit defiler ; sur grand + * ecran une seconde regle rogne aussi la hauteur (voir plus bas). + */ +#app { + overflow-x: clip; + /* repere du grand mot du pied de page, qui s'y cale en `absolute` */ + position: relative; +} + +/* + * Troncature d'une ligne. Volontairement pas `truncate` (text-overflow) : celui + * -la coupe au milieu d'un mot et garde l'espace avant les points de suspension + * quand la coupe tombe entre deux mots. `line-clamp` coupe au mot et colle les + * points au texte, ce qui est la seule facon lisible d'ecrire « Mon cycle… ». + */ +@utility sans-barre { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +@utility tronque { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; +} + +/* + * Ouverture de la boite de dialogue. Une transition sur un element qui passe de + * `display: none` a visible a besoin de `@starting-style` pour avoir un etat de + * depart, et de `allow-discrete` pour que `display` et `overlay` attendent la + * fin de l'animation a la fermeture. C'est pour ca que ce n'est pas ecrit en + * classes utilitaires. + */ +.dialogue, +.dialogue::backdrop { + opacity: 0; + transition: + opacity 0.18s ease-out, + transform 0.18s ease-out, + overlay 0.18s ease-out allow-discrete, + display 0.18s ease-out allow-discrete; +} + +.dialogue { + transform: translateY(8px) scale(0.98); +} + +.dialogue[open], +.dialogue[open]::backdrop { + opacity: 1; +} + +.dialogue[open] { + transform: none; +} + +.dialogue::backdrop { + background: rgb(10 12 20 / 0.4); +} + +@starting-style { + .dialogue[open], + .dialogue[open]::backdrop { + opacity: 0; + } + + .dialogue[open] { + transform: translateY(8px) scale(0.98); + } +} + +/* + * Le nom du projet en grand, au pied des reglages. Reprise de la landing page de + * Yuzu (`yuzu-releases/index.html`), transposee sur nos couleurs. + * + * Le fondu est un DEGRADE DE COULEUR revele au travers des lettres + * (`background-clip: text`), et non un `mask-image` : le texte n'est jamais peint, + * c'est le fond qu'on voit a travers lui. Il part deja faible (22 %) et s'eteint + * completement a 88 % de la hauteur, donc le bas des lettres est invisible avant + * meme d'etre rogne. + * + * `line-height` sous 1 fait deborder les glyphes de leur boite : c'est ce qui les + * fait rogner par le panneau et donne l'impression que le mot sort de la page. + * D'ou `pointer-events: none` — sans lui, les lettres qui remontent volent le clic + * des liens du dessus, piege deja tombe sur la landing page. + * + * `-webkit-text-fill-color` en plus de `color` : Safari ne rend pas le texte + * transparent avec le seul `color`. Tailwind n'a pas d'utilitaire pour cette + * propriete, d'ou cette regle ecrite a la main plutot qu'en classes. + */ +.wordmark { + /* + * `absolute` dans `#app`, et non `fixed`. `#app` fait exactement la hauteur de + * la fenetre, donc le bas est le meme — mais un element fixe est SORTI du + * document : il ne suit pas le rebond elastique quand on tente de defiler une + * page qui ne defile pas, alors que le mot de la landing page de Yuzu, lui, est + * dans le flux et accompagne le geste. En `absolute` il accompagne la page. + */ + position: absolute; + /* degage le rail, qui flotte (`fixed left-4`) et ne reserve rien dans la scene */ + left: 6rem; + /* legerement sous le bord : c'est ce qui fait mordre les lettres par l'ecran */ + bottom: -0.1em; + pointer-events: none; + user-select: none; + line-height: 0.78; + /* + * Dimensionne d'apres la place REELLEMENT disponible, et non en `vw` seul : le + * mot part a 6rem du bord gauche, donc une taille proportionnelle a la fenetre + * finissait par pousser le dernier B au-dela du bord droit. 3,05 est la largeur + * de « BLOUB » en cadratins a ce poids et cet interlettrage — mesuree, pas + * estimee — et le reste est la marge qu'on lui laisse a droite. + */ + font-size: clamp(80px, calc((100vw - 7rem) / 3.05), 268px); + font-weight: 900; + letter-spacing: -0.045em; + /* + * Marge a droite DANS la boite, et non un espacement autour. + * + * `background-clip: text` peint le degrade sur la boite de l'element et le + * revele au travers des lettres : tout glyphe qui depasse cette boite n'a donc + * aucun degrade a montrer, et `-webkit-text-fill-color: transparent` le rend + * purement invisible. Or l'interlettrage negatif retranche 0,045 cadratin apres + * le DERNIER glyphe aussi, et la panse droite d'un B depasse deja son avance : + * le B final se coupait net, quelle que soit la largeur de la fenetre. C'est le + * padding qui elargit la boite du degrade, pas la taille du texte. + */ + padding-right: 0.1em; + background: linear-gradient( + 180deg, + rgb(23 32 58 / 0.22) 0%, + rgb(23 32 58 / 0.1) 48%, + rgb(23 32 58 / 0) 88% + ); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + /* il arrive avec le panneau, pas avant : meme retard que `.panneau--ouvert` */ + opacity: 1; + transition: opacity 0.35s ease-out 0.2s; +} + +@starting-style { + .wordmark { + opacity: 0; + } +} + +/* + * Le mot sous 64rem : DANS LE FLUX, en bas de la page et centre. + * + * Le calage sorti du flux ne tient que tant que `#app` fait exactement la hauteur + * de la fenetre, ce qui est vrai au-dessus de 64rem — rien n'y defile. En dessous + * la page defile pour de vrai, et le mot restait a 812 px du haut du DOCUMENT : + * par-dessus les credits au repos (mesure a 375 px : panneau jusqu'a 780, mot des + * 753), puis en plein milieu de l'ecran une fois la page deroulee. Le remettre + * dans le flux le rend a la page : il vient apres la scene, donc en dernier, et + * rien ne peut plus lui passer dessus puisqu'il n'y a plus de recouvrement. + * + * Centre et non cale a gauche : les 6rem de marge degageaient le rail, qui est + * passe en bande HAUTE a cette largeur. Il n'y a plus rien a contourner, et un mot + * pleine largeur pose sous une colonne centree se lit de travers s'il ne l'est + * pas. La marge droite de 0,1em disparait avec le calage : le centrage laisse du + * jeu des deux cotes, donc le dernier B n'a plus de bord ou se couper. + * + * Le plafond descend a 200 px : sur une fenetre de tablette, 268 px de decoration + * mangeaient un quart de la page. Et le diviseur perd les 6rem de marge gauche, + * qui n'existent plus ici — le mot occupe la largeur entiere moins les gouttieres + * de la scene. + */ +@media (width < 64rem) { + .wordmark { + position: static; + text-align: center; + padding-right: 0; + font-size: clamp(80px, calc((100vw - 2.5rem) / 3.05), 200px); + /* + * Les lettres touchent le bord bas de la page. + * + * Une marge negative n'y suffit pas : la zone defilable est l'union des BOITES + * des descendants, les marges n'y entrent pas, et le talon de la fonte — + * l'espace des jambages, vide pour BLOUB qui n'en a aucun — depassait la boite + * de 21 px que la page comptait quand meme. D'ou 21 px de blanc sous le mot. + * + * On rogne donc a la LIGNE DE BASE : plus rien ne deborde, la page s'arrete ou + * les lettres s'arretent. C'est ce que fait le rognage de `#app` au-dessus de + * 64rem, ou le mot est simplement pose sous le bord. + */ + height: 0.69em; + overflow: hidden; + /* + * Le degrade est peint sur la BOITE et revele au travers des lettres : rogner + * la boite l'aurait eteint plus haut (il s'annule a 88 % de sa hauteur). On lui + * rend donc la hauteur de la boite pleine, independamment du rognage. + */ + background-size: 100% 0.78em; + } +} + +/* + * Deplacement de l'avatar d'une vue a l'autre. + * + * La scene est une grille de trois colonnes : panneau gauche, avatar, panneau + * droit. Une seule des deux colonnes de panneau a une largeur a la fois, et + * c'est l'INTERPOLATION DE CES LARGEURS qui deplace l'avatar — les pistes de + * grille s'animent, alors que `order` et `flex-direction` non. D'ou cette + * grille plutot qu'une inversion de sens du conteneur, et d'ou son ecriture + * ici : les deux etats doivent etre des valeurs litterales pour que la + * transition ait de quoi interpoler. + * + * Les gouttieres restent constantes dans les deux etats : la colonne de + * l'avatar garde donc exactement la meme largeur, et son centre se decale de la + * largeur d'un panneau, ni plus ni moins. + * + * En dessous de 64rem la grille n'existe pas : tout s'empile, et il n'y a plus + * de glissement a animer. + */ + +/* + * La TRANSITION de l'avatar est declaree ici, hors de la requete de largeur, la + * ou ses positions sont dedans. Deux raisons : l'arrivee sur le site fait + * apparaitre la boule a toutes les tailles, et une transition redeclaree plus bas + * en chasserait l'opacite — une propriete absente de la liste ne transitionne + * plus du tout. + * + * L'opacite prend son temps (0,8 s) : c'est une boule qui parait, pas un element + * qui se charge. Elle reste plus courte que la bascule pour que la boule soit + * entierement opaque avant d'avoir fini de s'installer — l'inverse donnerait un + * fondu qui traine sur une forme deja immobile. + */ +.avatar { + transition: + transform var(--bascule), + opacity 0.8s ease-out; +} + +/* + * Arrivee de la barre d'export : elle monte en se revelant, APRES que l'avatar a + * pris sa place. + * + * C'est une TRANSITION et pas une animation, exactement comme `.panneau` — et + * c'est ce qui la fait se taire quand il le faut. Une transition ne joue pas sur + * le premier style calcule d'un element : au rechargement, ou en revenant de la + * vue Animations, la barre nait deja visible et rien ne bouge. Une animation, + * elle, rejouait a chaque montage, donc a chaque changement de vue. + * + * D'ou aussi le fait que la barre soit montee PENDANT l'arrivee, juste masquee : + * sans etat de depart present a l'ecran, il n'y aurait rien a interpoler. + * + * Le RETARD, lui, n'est pas ici mais dans `App.vue` (`RETARD_ARRIVEE`), et il ne + * sert QU'A la fin de l'arrivee : le temps que l'avatar rejoigne sa place. En + * venant d'une autre vue il n'y a rien a attendre, la barre etant posee sur la + * fenetre elle nait deja a sa place. En CSS il aurait fallu une valeur unique + * pour les deux cas, donc fausse dans l'un des deux. + * + * C'est `transform` et non `translate` qui porte la montee : le centrage + * horizontal occupe deja `translate`, et les deux proprietes se composent au lieu + * de s'ecraser. + */ +.barre-export { + transition: + opacity 0.6s ease-out, + transform 0.6s ease-out; +} + +.barre-export--cachee { + opacity: 0; + transform: translateY(0.85rem); +} + +@media (width >= 64rem) { + /* + * Sur grand ecran la mise en page est calee sur la fenetre : rien ne doit faire + * defiler la PAGE. Le seul debordement vertical vient de la boule geante, dont + * la boite grandit avec elle (2,5 fois 460 px) alors que seule la boule est + * peinte — du vide qui allongeait la page de deux cents pixels. + * + * `clip` sur les deux axes ici, et non `hidden` : `hidden` ferait de `#app` un + * conteneur de defilement, donc rendrait ce debordement navigable au lieu de le + * supprimer. En dessous de 64rem la regle ne s'applique pas — la mise en page + * s'y empile et doit defiler pour de vrai. + */ + #app { + overflow: clip; + } + + .scene { + display: grid; + grid-template-columns: 0 minmax(0, 1fr) 20rem; + column-gap: 2.5rem; + transition: grid-template-columns var(--bascule); + /* + * Bornee a la fenetre. C'est ce qui donne aux panneaux une hauteur CONTRAINTE, + * sans quoi leur `overflow-y: auto` ne sert a rien : une piste de grille sans + * plafond prend la hauteur de son contenu, la boite ne deborde jamais d' + * elle-meme, et le panneau se retrouvait coupe par le rognage de `#app` sans + * pouvoir defiler. Sur une fenetre basse, c'est le panneau qui defile. + */ + max-height: 100%; + /* + * Piste unique DEFINIE, et non automatique. Une piste automatique prend la + * hauteur de son contenu et ignore le plafond du conteneur : le panneau + * gardait ses 773 px dans une fenetre de 620, se faisait rogner par `#app` et + * n'avait rien a defiler puisque sa boite ne debordait pas d'elle-meme. + */ + grid-template-rows: minmax(0, 1fr); + } + + .scene--gauche { + grid-template-columns: 20rem minmax(0, 1fr) 0; + } + + /* + * La boule SEULE en scene : aucune colonne de panneau. Deux cas, et il fallait + * bien que ce soit la meme regle — + * + * - l'arrivee sur le site, ou c'est le RETOUR a `.scene` (la colonne de droite + * qui reprend ses 20rem) qui fait glisser la boule a sa place ; + * - l'apercu, ou les panneaux sont demontes. Sans cette classe la grille leur + * reserve quand meme leur piste — une colonne de 20rem plus sa gouttiere — + * et l'avatar se retrouve centre dans une colonne decalee, donc a 180 px a + * gauche du centre de l'ecran. Demonter un panneau ne retire pas sa piste. + * + * Les deux gouttieres subsistent et sont egales, donc la colonne du milieu + * reste centree au pixel. + */ + .scene--seule { + grid-template-columns: 0 minmax(0, 1fr) 0; + } + + /* + * La boule dans les reglages : elle grandit jusqu'a sortir de l'ecran par la + * droite, et descend assez pour degager le haut de la page : sans cet air + * au-dessus d'elle, la vue se lit comme une image tronquee et non comme un + * cadrage. + * + * Un `transform` et pas la taille du SVG : la mise en page ne bouge pas, donc + * rien ne se remet en page a chaque image, et le trace restant vectoriel il + * reste net a n'importe quelle echelle. + * + * Ici, QUE la taille et la place. Le tour sur soi-meme, lui, n'est pas un + * miroir CSS : il est joue par le regard, les yeux faisant reellement le tour + * de la sphere (`src/ui/gaze.ts`). Retourner l'image par une echelle negative + * aurait couche les yeux en `//` au lieu de `\\` et aplati la boule au passage + * par zero — deux choses que le projet refuse. + * + * Les deux etats declarent la meme suite de fonctions, sinon le navigateur n'a + * rien a interpoler et le changement se ferait d'un coup. + */ + .avatar { + transform: translate(0, 0) scale(1); + } + + .avatar--geant { + transform: translate(30vw, 14vh) scale(2.5); + } + + .scene__gauche { + grid-column: 1; + } + + .scene__avatar { + grid-column: 2; + } + + .scene__droite { + grid-column: 3; + } + + /* + * La barre de montage s'arrete avant cette colonne. La scene reserve quand + * meme `--timeline` pour que l'avatar reste centre au-dessus, ce qui + * laissait un vide sous le panneau, a cote de la piste. On reprend cette + * reserve ici : le panneau redescend a la meme marge basse que partout + * ailleurs (les 2 rem de `.scene`). + */ + .scene--timeline .scene__droite { + margin-bottom: calc(1rem - var(--timeline)); + } + + /* + * Un panneau dans une colonne de largeur nulle doit disparaitre sans elargir sa + * piste : `min-width: 0` l'empeche de reclamer la largeur de son contenu, et le + * rognage coupe ce qui depasse pendant le glissement. + */ + .panneau { + min-width: 0; + /* + * `hidden` en largeur pour que la colonne qui se referme ecrase son contenu, + * `auto` en hauteur pour que ce soit le PANNEAU qui defile et non la page : + * la grille des vignettes du personnalisateur est plus haute qu'une fenetre + * basse, et la page ne defile plus (voir `#app` ci-dessus). + */ + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; + opacity: 0; + transition: opacity 0.3s ease-out; + } + + .panneau::-webkit-scrollbar { + display: none; + } + + .panneau--ouvert { + opacity: 1; + /* le contenu n'apparait qu'une fois la place faite, sinon il se lit pendant + qu'il est encore ecrase */ + transition: opacity 0.35s ease-out 0.2s; + } + + /* + * Barre d'export : calee sur le bas de la BOITE de l'avatar, pas sur le bas de + * la colonne. + * + * La boite est carree et centree dans la colonne, mais la silhouette n'occupe + * qu'environ 65 % de son cote — le viewBox du bot garde de quoi loger les + * anneaux des etats animes, qui n'existent pas au repos. Viser le bas de la + * colonne laissait donc 176 px de vide mesures entre la boule et le bouton, et + * la barre se lisait comme detachee de l'avatar. + * + * Le `min()` est exactement celui du `max-w` de `.avatar` : c'est la meme + * boite, donc la barre suit son retrecissement sur une fenetre basse au lieu de + * lui passer dessus. + * + * Rien n'est reserve ici : `--timeline` est deja soustrait de la hauteur de la + * colonne dans les deux vues, donc la bande du bas est libre et l'avatar ne + * bouge pas d'un pixel. + */ + /* + * Calee comme la barre de MONTAGE, et pas dans la colonne de l'avatar. + * + * C'est ce qui lui evite de bouger. Ancree dans la colonne, elle suivait + * l'interpolation de la grille : en venant des reglages, le panneau de gauche se + * referme et le centre de la colonne se deplace de 320 px — la barre glissait + * donc jusqu'a sa place au lieu d'y etre. La barre de montage, elle, ne bouge + * jamais parce qu'elle est posee sur la FENETRE. + * + * `left`/`right` sont exactement les siens : la bande couvre la colonne de + * l'avatar (4,5rem = marge + gouttiere ; 24,5rem = panneau + gouttiere + marge) + * et centre son contenu. La vue Personnaliser n'a qu'une seule disposition de + * grille, donc cette position est constante. + */ + .barre-export { + position: fixed; + left: 4.5rem; + right: 24.5rem; + display: flex; + justify-content: center; + /* + * Juste sous la boite de l'avatar : haut de la bande + demi-bande (le centre + * de la boite, qui y est centree) + demi-boite. Le `min()` est celui du + * `max-w` de `.avatar`, donc la barre suit son retrecissement sur une fenetre + * basse. Le demi-rem final la fait mordre dans la marge morte du bas de la + * boite, ou la silhouette ne va pas. + */ + top: calc( + 2rem + (100dvh - 3rem - var(--timeline)) / 2 + + min(460px, 100dvh - var(--timeline) - 7rem) / 2 - 0.5rem + ); + } +} + +/* + * Arrivee sur le site (voir `src/ui/intro.ts` pour le montage et la regle). + * + * La boule parait seule au centre d'une page vide, a peine plus grande que sa + * taille de croisiere, prend le temps de s'installer, puis redescend a l'echelle 1 + * en glissant vers sa place. Le glissement, lui, n'est pas ici : il vient des + * colonnes de `.scene--seule` qui se rouvrent, comme un changement de vue. + * + * Les ecarts sont volontairement FAIBLES — 0,9 a l'apparition, 1,06 en attente — + * la ou un surgissement ample (0,4 puis 1,22) se lisait comme un pop. La boule ne + * doit pas jaillir : elle doit PARAITRE, et ce qu'on regarde ensuite est le tour + * du regard puis le clignement qui pose l'interface, pas l'echelle. + * + * Ecrit en `@starting-style` plutot qu'en `@keyframes` : Vue monte toute la page + * en JavaScript, donc l'element est bel et bien INSERE, et une transition part + * alors de l'etat declare ici. Meme mecanisme que le grand mot du pied de page, + * et il a l'avantage de ne rien laisser tourner apres coup. + * + * Ecrit APRES la requete de largeur, et non dedans : le `transform` doit primer + * sur celui que `.avatar` y declare, et l'apparition vaut a toutes les tailles — + * sur petit ecran il n'y a pas de colonnes a rouvrir, mais la boule parait quand + * meme. + */ +.avatar--intro { + transform: translate(0, 0) scale(1.06); +} + +@starting-style { + .avatar--intro { + opacity: 0; + /* meme suite de fonctions que l'etat d'arrivee, sinon rien a interpoler */ + transform: translate(0, 0) scale(0.9); + } +} + +/* + * La barre laterale pendant l'arrivee. Elle reste montee — elle est `fixed`, la + * demonter ne libererait aucune place — mais `inert`, ce qui la sort de l'ordre + * de tabulation en meme temps que du clic. L'attribut sert donc de selecteur : + * une seule source de verite pour « absente », au lieu d'une classe a tenir en + * plus. Elle revient avec le meme retard que `.panneau--ouvert`, pour que tout + * le decor se pose d'un seul mouvement. + */ +.rail { + transition: opacity 0.35s ease-out 0.2s; +} + +.rail[inert] { + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .scene, + .panneau, + .panneau--ouvert, + .avatar { + transition: none; + } + + .barre-export { + transition: none; + } + + /* + * Les boites modales manquaient a cette liste : `.dialogue` anime un + * `translateY(8px) scale(0.98)`, donc elles glissaient encore sous « mouvement + * reduit » alors que tout le reste etait neutralise. `.rail` et `.wordmark`, eux, ne + * jouent que sur l'opacite et n'ont rien a faire ici. + */ + .dialogue, + .dialogue::backdrop { + transition: none; + animation: none; + } +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: + ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} diff --git a/src/ui/anime.ts b/src/ui/anime.ts new file mode 100644 index 0000000..dfc0656 --- /dev/null +++ b/src/ui/anime.ts @@ -0,0 +1,351 @@ +/** + * Assemblage de l'animation exportee : un SVG dont les yeux sont animes en CSS. + * + * Tout est pur — de la chaine vers de la chaine — donc testable en `node` comme le + * reste de `src/ui/`. C'est la capture des images cles qui a besoin du DOM, et elle + * vit dans `capture.ts`. + */ + +/** Ecrit un entier non signe sur `n` octets, petit-boutiste. */ +function le(valeur: number, n: number): number[] { + const out: number[] = [] + for (let i = 0; i < n; i++) out.push((valeur / 2 ** (8 * i)) & 0xff) + return out +} + +const ascii = (s: string) => Array.from(s, (c) => c.charCodeAt(0)) + +/** + * Injecte l'animation des yeux dans un SVG deja rendu. + * + * On ne reconstruit PAS le dessin : on part du SVG que `BloubBot` a produit pour + * la premiere image et on remplace le `transform` de chaque oeil par une classe + * animee. Une seule source de dessin, donc aucune derive possible — c'est la + * meme regle que pour l'export fixe. + * + * Seuls les yeux bougent, et c'est mesure : au repos la silhouette ne se deplace + * que de 1,17 unite sur un rayon de 100 en trois secondes, soit environ un pixel + * et demi a la taille d'export. Le corps reste donc tel quel, ce qui rend le + * fichier minuscule — tout le poids d'une animation du bot est dans son regard. + * + * L'interpolation est faite par le NAVIGATEUR : c'est ce qui rend l'animation + * lisse a la frequence de l'ecran au lieu de sauter d'image en image comme un + * feuilletage. C'est tout l'interet par rapport a un WebP ou un GIF. + */ +export function svgAnime(base: string, matrices: string[][], duree: number): string { + if (matrices.length < 2) throw new Error('il faut au moins deux images cles') + + const clip = base.match(/]*clip-path="[^"]+"[^>]*>[\s\S]*?<\/g>/) + if (!clip) throw new Error('yeux introuvables') + + let n = 0 + const clipAnime = clip[0].replace(/\stransform="[^"]*"/g, () => ` class="oeil${n++}"`) + if (n === 0) throw new Error('aucun oeil a animer') + + const parImage = matrices[0]!.length + if (parImage !== n) throw new Error(`${n} yeux dans le clip, ${parImage} par image cle`) + + const pas = 100 / (matrices.length - 1) + const regles = Array.from({ length: n }, (_, oeil) => { + const etapes = matrices + .map((m, i) => `${+(i * pas).toFixed(3)}%{transform:${m[oeil]}}`) + .join('') + return `@keyframes oeil${oeil}{${etapes}}` + }) + + const style = + '' + + return base.replace(clip[0], clipAnime).replace('', `${style}`) +} + +/* ------------------------------------------------------------------- gif */ + +/** + * Compression LZW du GIF : codes de longueur VARIABLE, ecrits bit a bit en + * commencant par le bit de poids faible, et regroupes en sous-blocs de 255 octets + * au plus. + */ +function lzw(indices: Uint8Array, bitsParPixel: number): number[] { + const clear = 1 << bitsParPixel + const fin = clear + 1 + let taille = bitsParPixel + 1 + let suivant = fin + 1 + let dico = new Map() + + const octets: number[] = [] + let reserve = 0 + let bits = 0 + const ecris = (code: number) => { + reserve |= code << bits + bits += taille + while (bits >= 8) { + octets.push(reserve & 0xff) + reserve >>= 8 + bits -= 8 + } + } + + ecris(clear) + let prefixe = String(indices[0]) + for (let i = 1; i < indices.length; i++) { + const c = indices[i]! + const cle = `${prefixe},${c}` + const connu = dico.get(cle) + if (connu !== undefined) { + prefixe = String(connu) + continue + } + ecris(Number(prefixe)) + if (suivant < 4096) { + dico.set(cle, suivant++) + // La largeur du code grandit un cran APRES que le dictionnaire ait + // depasse ce qu'elle adresse, et ce decalage est le piege du format : + // l'encodeur inscrit son entree juste apres avoir emis un code, alors que + // le decodeur n'inscrit la sienne qu'en lisant le code SUIVANT. Il est donc + // toujours en retard d'une entree, et l'encodeur doit elargir en retard + // aussi. Ecrire `=== (1 << taille)` desynchronise les deux et le flux + // devient illisible — verifie par un test d'aller-retour. + if (suivant > 1 << taille && taille < 12) taille++ + } else { + // Dictionnaire plein : on le repart a zero, le decodeur fait pareil en + // voyant le code de remise a zero. + ecris(clear) + dico = new Map() + taille = bitsParPixel + 1 + suivant = fin + 1 + } + prefixe = String(c) + } + ecris(Number(prefixe)) + ecris(fin) + if (bits > 0) octets.push(reserve & 0xff) + + // Decoupage en sous-blocs : chacun est precede de sa longueur, et un octet nul + // termine la serie. + const sortie: number[] = [bitsParPixel] + for (let i = 0; i < octets.length; i += 255) { + const bloc = octets.slice(i, i + 255) + sortie.push(bloc.length, ...bloc) + } + sortie.push(0) + return sortie +} + +/** + * Palette commune a toutes les images, index 0 reserve au transparent. + * + * Elle se construit en DEUX temps — recenser les couleurs, puis indexer les + * images — parce qu'un GIF exige une palette commune : il faut avoir vu toutes + * les images avant d'en encoder une seule. Sur un cycle de trente secondes, les + * garder en pixels bruts couterait 255 Mo ; recensees puis indexees, elles ne + * pesent plus qu'un octet par pixel. + */ +export interface PaletteGif { + /** Combien de pixels par teinte, pendant le recensement. */ + vues: Map + /** RGB compacte sur 24 bits -> index, une fois la palette arretee. */ + index: Map + /** Resolution des teintes absentes vers la plus proche retenue. */ + proches: Map + /** Au moins un pixel transparent a ete vu. */ + transparence: boolean +} + +export const nouvellePalette = (): PaletteGif => ({ + vues: new Map(), + index: new Map(), + proches: new Map(), + transparence: false +}) + +/** + * Recense les couleurs d'une image, en COMPTANT les pixels. + * + * Le comptage n'est pas un detail : une palette remplie au premier arrive se + * serait gorgee des nuances d'anticrenelage des premieres images, et les teintes + * des anneaux — qui n'apparaissent qu'au milieu du cycle — seraient toutes tombees + * sur la meme case. Mesure : un cycle depasse les 255 couleurs, donc il FAUT + * choisir lesquelles garder. + */ +export function recense(palette: PaletteGif, px: Uint8ClampedArray) { + for (let p = 0; p < px.length; p += 4) { + if (px[p + 3]! < 128) { + palette.transparence = true + continue + } + const cle = (px[p]! << 16) | (px[p + 1]! << 8) | px[p + 2]! + palette.vues.set(cle, (palette.vues.get(cle) ?? 0) + 1) + } +} + +/** Arrete la palette sur les 255 teintes les plus presentes. */ +function arrete(palette: PaletteGif) { + if (palette.index.size) return + const tries = [...palette.vues.entries()].sort((a, b) => b[1] - a[1]).slice(0, 255) + for (const [cle] of tries) palette.index.set(cle, palette.index.size + 1) +} + +/** Index de la teinte la plus proche parmi celles retenues, mis en cache. */ +function plusProche(palette: PaletteGif, cle: number) { + const connu = palette.proches.get(cle) + if (connu !== undefined) return connu + const r = (cle >> 16) & 0xff + const v = (cle >> 8) & 0xff + const b = cle & 0xff + let meilleur = 1 + let ecart = Infinity + for (const [autre, i] of palette.index) { + const dr = r - ((autre >> 16) & 0xff) + const dv = v - ((autre >> 8) & 0xff) + const db = b - (autre & 0xff) + const d = dr * dr + dv * dv + db * db + if (d < ecart) { + ecart = d + meilleur = i + } + } + // Le cache borne le cout : chaque teinte absente n'est resolue qu'une fois, + // meme si elle revient sur des centaines d'images. + palette.proches.set(cle, meilleur) + return meilleur +} + +/** Traduit une image en index de palette, un octet par pixel. */ +export function indexe(palette: PaletteGif, px: Uint8ClampedArray): Uint8Array { + arrete(palette) + const out = new Uint8Array(px.length / 4) + for (let i = 0, p = 0; i < out.length; i++, p += 4) { + // 0 = transparent, et c'est deja la valeur par defaut du tableau + if (px[p + 3]! < 128) continue + const cle = (px[p]! << 16) | (px[p + 1]! << 8) | px[p + 2]! + out[i] = palette.index.get(cle) ?? plusProche(palette, cle) + } + return out +} + +/** + * Assemble des images en GIF anime. + * + * La transparence du GIF est sur UN bit : un pixel est opaque ou invisible, rien + * entre les deux. Sur un fond transparent, le bord antialiase de la boule est donc + * tranche a 50 % d'opacite et ressort en marches d'escalier. Ce n'est pas un defaut + * a corriger, c'est le format — d'ou le choix laisse a l'utilisateur entre ce bord + * dur et un fond plein, ou le bord reste lisse parce qu'il a de quoi se fondre. + * + * La transparence est DEDUITE des images, pas demandee : des images deja aplaties + * sur un fond n'ont pas a declarer d'index transparent, et surtout pas a etre + * eliminees « retour au fond » entre deux — ce qui ferait clignoter le fond. + * + * Le GIF n'existe que pour les endroits qui refusent le SVG anime, comme les + * avatars animes de Discord ou de Slack. Partout ailleurs le SVG est meilleur. + */ +export function gifAnime( + images: Uint8ClampedArray[], + largeur: number, + hauteur: number, + delaiMs: number +): Uint8Array { + if (!images.length) throw new Error('aucune image a emballer') + const palette = nouvellePalette() + for (const px of images) recense(palette, px) + return gifIndexe( + palette, + images.map((px) => indexe(palette, px)), + largeur, + hauteur, + delaiMs + ) +} + +/** + * Meme chose, mais sur des images DEJA indexees — la voie a memoire bornee, pour + * les longues sequences. Cf. `PaletteGif`. + */ +export function gifIndexe( + palette: PaletteGif, + images: Uint8Array[], + largeur: number, + hauteur: number, + delaiMs: number +): Uint8Array { + if (!images.length) throw new Error('aucune image a emballer') + + arrete(palette) + const transparence = palette.transparence + const couleurs = palette.index + // Une palette GIF a une taille en puissance de deux, et il faut au moins deux + // bits par pixel pour que le code de remise a zero tienne. + const bits = Math.max(2, Math.ceil(Math.log2(couleurs.size + 1))) + const tailleTable = 1 << bits + + const table: number[] = [0, 0, 0] // index 0 : le transparent, couleur sans importance + for (const cle of couleurs.keys()) table.push((cle >> 16) & 0xff, (cle >> 8) & 0xff, cle & 0xff) + while (table.length < tailleTable * 3) table.push(0) + + const out: number[] = [ + ...ascii('GIF89a'), + ...le(largeur, 2), + ...le(hauteur, 2), + 0x80 | (bits - 1), // table globale presente, et sa taille + 0, // index de fond : le transparent + 0, // pas de rapport d'aspect + ...table, + // Boucle sans fin, par l'extension Netscape — rien d'autre ne sait le dire. + 0x21, + 0xff, + 0x0b, + ...ascii('NETSCAPE2.0'), + 0x03, + 0x01, + ...le(0, 2), + 0x00 + ] + + // Le delai du GIF se compte en CENTIEMES de seconde : c'est ce qui limite sa + // cadence, un delai de 0 ou 1 etant traite differemment selon les lecteurs. + const delai = Math.max(2, Math.round(delaiMs / 10)) + + for (const indices of images) { + out.push( + 0x21, + 0xf9, + 0x04, + // Sur fond transparent : elimination « retour au fond » (2) et index + // transparent declare, sinon les zones vides laissent voir l'image + // precedente et la boule traine derriere elle. + // + // Sur fond plein : « laisser en place » (1), et surtout PAS de retour au + // fond — chaque image couvre toute la toile, donc effacer entre deux ne + // sert qu'a faire clignoter le fond. + transparence ? (2 << 2) | 0x01 : 1 << 2, + ...le(delai, 2), + 0, // index transparent + 0x00, + 0x2c, + ...le(0, 2), + ...le(0, 2), + ...le(largeur, 2), + ...le(hauteur, 2), + 0, // pas de table locale, pas d'entrelacement + ...lzw(indices, bits) + ) + } + + out.push(0x3b) + return new Uint8Array(out) +} diff --git a/src/ui/capture.ts b/src/ui/capture.ts new file mode 100644 index 0000000..1f55547 --- /dev/null +++ b/src/ui/capture.ts @@ -0,0 +1,321 @@ +/** + * Capture replica : serialise le SVG vivant (aplatit les var CSS) et rejoue + * hors ecran via GrokCharacter.step, sans rAF. + */ + +import type { Block } from '@/editor/cycles' +import { mountReplica, type ReplicaInput } from '@/replica/host' +import { gifAnime, gifIndexe, indexe, nouvellePalette, recense, svgAnime } from './anime' +import { arrete, demiEcran, sansCommentaires, viewBoxExport } from './export' + +export function svgAutonome(svg: SVGSVGElement, taille: number, viewBox = viewBoxExport()) { + const clone = flattenSvg(svg) + clone.removeAttribute('class') + clone.removeAttribute('style') + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + clone.setAttribute('viewBox', viewBox) + clone.setAttribute('width', String(taille)) + clone.setAttribute('height', String(taille)) + return sansCommentaires(new XMLSerializer().serializeToString(clone)) +} + +function flattenSvg(root: SVGSVGElement) { + const clone = root.cloneNode(true) as SVGSVGElement + const walk = (src: Element, dst: Element) => { + const cs = getComputedStyle(src) + for (const attr of ['fill', 'stroke'] as const) { + const raw = src.getAttribute(attr) + if (raw?.includes('var(')) dst.setAttribute(attr, cs[attr]) + } + const style = src.getAttribute('style') + if (style?.includes('var(')) { + let next = style + if (/fill\s*:/.test(style)) next = next.replace(/fill\s*:\s*var\([^)]+\)/g, `fill:${cs.fill}`) + if (/stroke\s*:/.test(style)) { + next = next.replace(/stroke\s*:\s*var\([^)]+\)/g, `stroke:${cs.stroke}`) + } + dst.setAttribute('style', next) + } + const kids = [...src.children] + const copies = [...dst.children] + for (let i = 0; i < kids.length; i++) walk(kids[i]!, copies[i]!) + } + walk(root, clone) + return clone +} + +async function dessine( + markup: string, + taille: number, + canvas: HTMLCanvasElement, + fond: string | null = null +) { + const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' })) + try { + const img = new Image() + img.src = url + await img.decode() + canvas.width = taille + canvas.height = taille + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('canvas indisponible') + ctx.clearRect(0, 0, taille, taille) + if (fond) { + ctx.fillStyle = fond + ctx.fillRect(0, 0, taille, taille) + } + ctx.drawImage(img, 0, 0, taille, taille) + return ctx + } finally { + URL.revokeObjectURL(url) + } +} + +export async function versPng(markup: string, taille: number): Promise { + const canvas = document.createElement('canvas') + await dessine(markup, taille, canvas) + return await new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error('encodage png impossible'))), + 'image/png' + ) + }) +} + +export function telecharge(blob: Blob, nom: string) { + const url = URL.createObjectURL(blob) + try { + const a = document.createElement('a') + a.href = url + a.download = nom + a.click() + } finally { + setTimeout(() => URL.revokeObjectURL(url), 10_000) + } +} + +export function copiePossible() { + return ( + typeof ClipboardItem !== 'undefined' && + !!navigator.clipboard?.write && + (ClipboardItem.supports?.('image/png') ?? true) + ) +} + +export async function copie(blob: Promise) { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) +} + +export async function copieTexte(texte: string) { + await navigator.clipboard.writeText(texte) +} + +export type Avancement = (fait: number, total: number) => void + +export interface ReglagesBot { + shape: string + color: string + expression: string + state?: string +} + +function inputOf(reglages: ReglagesBot, taille: number, paper?: string): ReplicaInput { + return { + state: reglages.state ?? reglages.expression, + shape: reglages.shape, + color: reglages.color, + follow: false, + paper: paper ?? '#f9f9f9', + size: taille + } +} + +async function ouvreBot(reglages: ReglagesBot, taille: number, paper?: string) { + const hote = document.createElement('div') + hote.style.cssText = 'position:fixed;left:-99999px;top:0;width:1px;height:1px;overflow:hidden' + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + hote.appendChild(svg) + document.body.appendChild(hote) + const mount = await mountReplica(svg, inputOf(reglages, taille, paper), false, { driven: true }) + if (!mount) { + hote.remove() + throw new Error('replica hors ecran indisponible') + } + return { hote, svg, mount } +} + +export async function cycleVersMp4( + reglages: ReglagesBot, + blocs: Block[], + taille: number, + images: number, + pas: number, + fond: string, + avance?: Avancement, + signal?: AbortSignal +): Promise { + const { versMp4 } = await import('./video') + const canvas = document.createElement('canvas') + const lecteur = await ouvreCycle(reglages, blocs, taille, fond) + try { + return await versMp4( + canvas, + images, + Math.round(1 / pas), + async (i) => { + const svg = await lecteur.rendre(i * pas) + await dessine(svgAutonome(svg, taille, viewBoxExport(demiEcran())), taille, canvas, fond) + }, + avance, + signal + ) + } finally { + lecteur.ferme() + } +} + +export async function cycleVersGif( + reglages: ReglagesBot, + blocs: Block[], + taille: number, + images: number, + pas: number, + fond: string | null, + avance?: Avancement, + signal?: AbortSignal +): Promise { + const canvas = document.createElement('canvas') + const vue = viewBoxExport(demiEcran()) + const lecteur = await ouvreCycle(reglages, blocs, taille, fond ?? undefined) + + const passe = async (lis: (index: number, pixels: Uint8ClampedArray) => void) => { + for (let i = 0; i < images; i++) { + arrete(signal) + const svg = await lecteur.rendre(i * pas) + const ctx = await dessine(svgAutonome(svg, taille, vue), taille, canvas, fond) + lis(i, ctx.getImageData(0, 0, taille, taille).data) + } + } + + try { + const palette = nouvellePalette() + await passe((i, pixels) => { + recense(palette, pixels) + avance?.(i + 1, images * 2) + }) + const morceaux: Uint8Array[] = [] + await passe((i, pixels) => { + morceaux.push(indexe(palette, pixels)) + avance?.(images + i + 1, images * 2) + }) + return new Blob([gifIndexe(palette, morceaux, taille, taille, Math.round(pas * 1000))], { + type: 'image/gif' + }) + } finally { + lecteur.ferme() + } +} + +export interface LecteurHorsEcran { + rendre: (t: number) => Promise + ferme: () => void +} + +export async function ouvreCycle( + reglages: ReglagesBot, + blocs: Block[], + taille: number, + paper?: string +): Promise { + const { blockAt } = await import('@/editor/cycles') + let session = await ouvreBot({ ...reglages, state: blocs[0]?.state ?? 'idle' }, taille, paper) + let clock = 0 + + const reset = async () => { + session.mount.destroy() + session.hote.remove() + session = await ouvreBot({ ...reglages, state: blocs[0]?.state ?? 'idle' }, taille, paper) + clock = 0 + } + + return { + rendre: async (t) => { + if (t < clock - 1e-6) await reset() + const pas = 1 / 30 + while (clock < t - 1e-9) { + const next = Math.min(t, clock + pas) + const { index } = blockAt(blocs, next) + const want = blocs[index]?.state ?? 'idle' + session.mount.setState(want) + session.mount.step(next - clock) + clock = next + } + return session.svg + }, + ferme: () => { + session.mount.destroy() + session.hote.remove() + } + } +} + +function cssTransform(svgTransform: string) { + return svgTransform.replace(/([a-z]+)\(([^)]+)\)/gi, (_m, fn: string, args: string) => { + return `${fn}(${args.trim().split(/[\s,]+/).join(', ')})` + }) +} + +function matricesDesYeux(svg: SVGSVGElement) { + return [...svg.querySelectorAll('g[clip-path] path')].map((e) => + cssTransform(e.getAttribute('transform') || 'translate(0 0)') + ) +} + +export async function versSvgAnime( + reglages: ReglagesBot, + taille: number, + nombre: number, + pas: number +): Promise { + const { hote, svg, mount } = await ouvreBot(reglages, taille) + try { + let base = '' + const matrices: string[][] = [] + for (let i = 0; i < nombre; i++) { + if (i > 0) mount.step(pas) + if (i === 0) base = svgAutonome(svg, taille) + matrices.push(matricesDesYeux(svg)) + } + return new Blob([svgAnime(base, matrices, +((nombre - 1) * pas).toFixed(3))], { + type: 'image/svg+xml' + }) + } finally { + mount.destroy() + hote.remove() + } +} + +export async function versGifAnime( + reglages: ReglagesBot, + taille: number, + nombre: number, + pas: number, + fond: string | null +): Promise { + const { hote, svg, mount } = await ouvreBot(reglages, taille, fond ?? undefined) + const canvas = document.createElement('canvas') + try { + const images: Uint8ClampedArray[] = [] + for (let i = 0; i < nombre; i++) { + if (i > 0) mount.step(pas) + const ctx = await dessine(svgAutonome(svg, taille), taille, canvas, fond) + images.push(ctx.getImageData(0, 0, taille, taille).data) + } + return new Blob([gifAnime(images, taille, taille, Math.round(pas * 1000))], { + type: 'image/gif' + }) + } finally { + mount.destroy() + hote.remove() + } +} diff --git a/src/ui/export.ts b/src/ui/export.ts new file mode 100644 index 0000000..fd60b00 --- /dev/null +++ b/src/ui/export.ts @@ -0,0 +1,122 @@ +/** + * Cadrage et nommage des images exportees. Pur : aucun DOM. + * Le cadre serre (avatar au repos) et le cadre ecran (cycle, anneaux) suivent + * Bloub : 1,15 × 1,08 autour du rayon, et 1,58 pour loger les overlay. + */ + +const MARGE = 1.08 +const RAYON_MAX = 1.15 +const ECRAN = 1.58 + +export function rayonMoteur() { + return window.GROK_GEO?.Re ?? 114.27 +} + +export function demiCadre() { + return Math.ceil(rayonMoteur() * RAYON_MAX * MARGE) +} + +export function demiEcran() { + return rayonMoteur() * ECRAN +} + +export function viewBoxExport(demi = demiCadre()) { + const mid = rayonMoteur() + return `${(mid - demi).toFixed(2)} ${(mid - demi).toFixed(2)} ${(demi * 2).toFixed(2)} ${(demi * 2).toFixed(2)}` +} + +export type ActionId = 'png' | 'svg' | 'anime' | 'gif' | 'copie' | 'copieSvg' +export type ModeExport = 'telecharge' | 'anime' | 'gif' | 'copieImage' | 'copieTexte' + +export interface ActionExport { + id: ActionId + mode: ModeExport + taille: number + extension: 'png' | 'svg' | 'gif' + suffixe?: string +} + +export const ANIM_CLES_PAR_SEC = 30 +export const ANIM_SECONDES = 3 +export const ANIM_IMAGES = ANIM_CLES_PAR_SEC * ANIM_SECONDES +export const ANIM_PAS = 1 / ANIM_CLES_PAR_SEC + +export const GIF_FPS = 20 +export const GIF_IMAGES = GIF_FPS * ANIM_SECONDES +export const GIF_PAS = 1 / GIF_FPS +export const GIF_TAILLE = 320 + +export type FormatCycle = 'mp4' | 'gif' +export const FORMATS_CYCLE: FormatCycle[] = ['mp4', 'gif'] +export const FORMAT_CYCLE_DEFAUT: FormatCycle = 'mp4' + +export const CYCLE_FPS = { gif: 20, mp4: 30 } as const +export const CYCLE_TAILLE = { gif: 320, mp4: 1024 } as const +export const cyclePas = (format: FormatCycle) => 1 / CYCLE_FPS[format] +export const cycleImages = (duree: number, format: FormatCycle) => + Math.max(1, Math.round(duree * CYCLE_FPS[format])) +export const cycleAccepteTransparence = (format: FormatCycle) => format === 'gif' + +export type FondGif = 'blanc' | 'transparent' +export const FONDS_GIF: FondGif[] = ['blanc', 'transparent'] +export const FOND_GIF_DEFAUT: FondGif = 'blanc' +export const BLANC = '#ffffff' +export const couleurDeFond = (fond: FondGif) => (fond === 'blanc' ? BLANC : null) + +export const ACTIONS: ActionExport[] = [ + { id: 'png', mode: 'telecharge', taille: 1024, extension: 'png' }, + { id: 'svg', mode: 'telecharge', taille: 0, extension: 'svg' }, + { id: 'anime', mode: 'anime', taille: 0, extension: 'svg', suffixe: 'anime' }, + { id: 'gif', mode: 'gif', taille: GIF_TAILLE, extension: 'gif' }, + { id: 'copie', mode: 'copieImage', taille: 1024, extension: 'png' }, + { id: 'copieSvg', mode: 'copieTexte', taille: 0, extension: 'svg' } +] + +export const ACTION_BY_ID = new Map(ACTIONS.map((a) => [a.id, a])) +export const ACTION_DEFAUT: ActionId = 'png' + +export function tailleAction(action: ActionExport) { + return action.taille || demiCadre() * 2 +} + +export type EtatExport = 'pret' | 'occupe' | 'exporte' | 'copie' | 'erreur' + +export function sansCommentaires(markup: string) { + return markup.replace(//g, '') +} + +export function nomFichier( + forme: string, + expression: string, + couleur: string, + extension: string, + suffixe = '' +) { + const propre = (v: string) => + v + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + const morceaux = [propre(forme), propre(expression), propre(couleur), propre(suffixe)].filter( + Boolean + ) + return `study${morceaux.map((m) => `-${m}`).join('')}.${extension}` +} + +export function videoPossible() { + return typeof VideoEncoder !== 'undefined' +} + +export class Abandon extends Error { + constructor() { + super('export abandonne') + this.name = 'Abandon' + } +} + +export function arrete(signal: AbortSignal | undefined) { + if (signal?.aborted) throw new Abandon() +} diff --git a/src/ui/stockage.ts b/src/ui/stockage.ts new file mode 100644 index 0000000..4621d25 --- /dev/null +++ b/src/ui/stockage.ts @@ -0,0 +1,24 @@ +const PREFIXE = 'study:' +const NOMS = ['cycles', 'cycle', 'forme', 'couleur', 'expression', 'langue'] as const + +export type NomStocke = (typeof NOMS)[number] + +export function cle(nom: NomStocke): string { + return `${PREFIXE}${nom}` +} + +export function lis(nom: NomStocke): string | null { + try { + return localStorage.getItem(cle(nom)) + } catch { + return null + } +} + +export function ecris(nom: NomStocke, valeur: string): void { + try { + localStorage.setItem(cle(nom), valeur) + } catch { + /* persist is optional */ + } +} diff --git a/src/ui/timeline.ts b/src/ui/timeline.ts new file mode 100644 index 0000000..ef0d703 --- /dev/null +++ b/src/ui/timeline.ts @@ -0,0 +1,77 @@ +/** + * Reglages et mises en forme de la piste de montage. Tout est pur : la piste + * n'a besoin d'aucun DOM pour savoir ou tomber, ce qui rend ses graduations + * testables — c'est la meme discipline que `src/bot/`, appliquee a l'affichage. + */ + +/** + * Largeur d'une carte, en pixels par seconde : la duree se lit donc directement + * dans la piste. Le zoom multiplie cette echelle — il ne change jamais le + * montage, seulement la loupe qu'on pose dessus. + */ +export const BASE_SCALE = 44 + +/** + * Bornes de la loupe : au plus petit, le montage releve sur la video tient + * entier dans la piste ; au plus grand, une carte reste manipulable sans + * devenir un mur. + */ +export const MIN_ZOOM = 0.45 +export const MAX_ZOOM = 2.4 + +/** Ecart minimal entre deux reperes chiffres de la regle, en pixels. */ +const TICK_SPACING = 52 + +/** Pas possibles de la regle, du plus fin au plus large. */ +const STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60] + +export function clampZoom(v: number) { + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v)) +} + +/** `0:04` — les dixiemes changeraient trop vite pour etre lisibles. */ +export function mmss(t: number) { + const s = Math.max(0, Math.floor(t)) + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` +} + +/* + * Les durees en secondes ne sont PAS mises en forme ici : le separateur decimal + * change avec la langue (virgule en francais, point en anglais) et l'unite se + * traduit. Elles passent donc par `secondes` / `secondesCourtes` de `@/i18n`, + * qui a la langue courante — ce module, lui, reste pur. + * + * `mmss` reste : le format mm:ss n'a ni unite ni separateur decimal. + */ + +/** + * Reperes de la regle pour une duree et une echelle donnees. Le pas chiffre est + * le premier de `STEPS` qui laisse `TICK_SPACING` entre deux nombres : c'est ce + * qui fait qu'en dezoomant on passe de 1 s a 5 s puis a 10 s au lieu d'empiler + * des chiffres illisibles. Les reperes intermediaires n'apparaissent que s'ils + * ne se collent pas les uns aux autres. + */ +export function ticksFor(total: number, scale: number): Array<{ t: number; major: boolean }> { + const major = STEPS.find((s) => s * scale >= TICK_SPACING) ?? STEPS[STEPS.length - 1]! + const step = (major / 5) * scale >= 7 ? major / 5 : major + const out: Array<{ t: number; major: boolean }> = [] + for (let i = 0; i * step <= total + 1e-6 && out.length < MAX_TICKS; i++) { + const t = i * step + out.push({ t, major: Math.abs(t / major - Math.round(t / major)) < 1e-6 }) + } + return out +} + +/** + * Plafond du nombre de graduations. Garde-fou et non reglage. + * + * `parseCycles` borne deja la taille d'un montage relu, mais cette fonction ne doit pas + * dependre de ce garde-la pour rester bornee : elle rend un objet par graduation et le + * composant un `` par objet, donc une duree aberrante — un montage bricole a la + * main, un zoom extreme — se paie en centaines de milliers de noeuds et l'onglet fige. + * + * Deux mille couvre trente minutes de montage au zoom le plus fin, soit bien plus que ce + * que `MAX_BLOCS` autorise a relire. La regle est de toute facon plus large que l'ecran : + * la piste defile, elle n'affiche jamais tout. + */ +const MAX_TICKS = 2000 diff --git a/src/ui/useModalDialog.ts b/src/ui/useModalDialog.ts new file mode 100644 index 0000000..a3326db --- /dev/null +++ b/src/ui/useModalDialog.ts @@ -0,0 +1,32 @@ +import { onBeforeUnmount, watch, type Ref, type ShallowRef } from 'vue' + +/** + * Pilote un `` natif ouvert en modal depuis un booleen. + * + * On passe par l'element natif plutot que par une div : le navigateur fournit + * alors le piege a focus, la fermeture par Echap, le retour du focus au + * declencheur, l'inertie du reste de la page et le fond assombri — tout ce + * qu'on reecrirait moins bien a la main. + * + * L'element vient de l'appelant (`useTemplateRef`) plutot que d'ici : c'est lui + * qui nomme la ref dans son template. Cote template, il reste a renvoyer la + * fermeture native vers le booleen : + * `@close="open = false" @cancel.prevent="open = false"`. + */ +export function useModalDialog( + open: Ref, + el: Readonly> +) { + watch(open, (on) => { + const dialog = el.value + if (!dialog) return + if (on) dialog.showModal() + else if (dialog.open) dialog.close() + }) + + // le composant peut disparaitre alors que la boite est ouverte (changement de + // vue) : un `` laisse en modal bloquerait la page entiere + onBeforeUnmount(() => { + if (el.value?.open) el.value.close() + }) +} diff --git a/src/ui/video.ts b/src/ui/video.ts new file mode 100644 index 0000000..e582e2e --- /dev/null +++ b/src/ui/video.ts @@ -0,0 +1,102 @@ +/** + * Encodage video de l'animation, en MP4. + * + * Seul module du projet a dependre d'autre chose que Vue, et il le fait en import + * DYNAMIQUE : mediabunny pese 43 ko gzip en import statique, soit plus que les + * 34 ko qui avaient fait ecarter `vue-i18n` au profit d'une couche maison. Charge + * a la demande, il ne coute que 0,7 ko au chargement du site et n'arrive que le + * jour ou quelqu'un exporte une video. + * + * Ecrire le conteneur a la main comme pour le GIF et le WebP n'etait pas tenable + * ici : un MP4 demande tout l'arbre de boites ISO BMFF et ses tables + * d'echantillons, la ou un GIF tient en quelques centaines de lignes. + * + * La video est forcement OPAQUE. Verifie sur place : `VideoEncoder` refuse + * `alpha: 'keep'`, en H.264 comme en VP9. C'est pour ca que l'export video impose + * un fond, la ou le GIF laisse le choix. + */ + +import { arrete } from './export' + +/** + * QUANTISEUR explicite, et pas un niveau `QUALITY_*`. + * + * Ce n'est pas un detail de reglage, c'est la clef de la qualite ici. Les niveaux + * nommes de mediabunny ne fixent PAS de debit : quand le navigateur sait encoder + * a quantiseur fixe — Chrome 117 et au-dela pour tous ces codecs — ils se + * traduisent en `bitrateMode: 'quantizer'` et le debit calcule ne sert qu'a + * choisir le niveau AVC de la chaine de codec. `QUALITY_HIGH` posait donc un QP de + * 22, et augmenter un debit n'y aurait rien change. + * + * Mesure sur une image du bot, erreur au bord contre la source : + * QP 22 → 3,06 · QP 16 → 1,75 · QP 10 → 0,58. On prend 12, presque transparent, + * pour environ 50 % d'octets de plus qu'a 22 — un export offline n'a pas de + * contrainte de taille, et c'est le bord des formes qui fait toute l'impression + * de proprete. + * + * Le debit accompagne le quantiseur comme REPLI : si un navigateur ne sait pas + * encoder a quantiseur fixe, mediabunny bascule dessus au lieu d'echouer. Six + * megabits pour du 1024 a 30 images est genereux sur des aplats. + */ +const QP = 12 +const DEBIT_REPLI = 6_000_000 + +/** + * Encode une suite d'images en MP4. + * + * `rend` dessine l'image `i` DANS le canvas fourni, puis rend la main. Les images + * ne sont jamais accumulees : chacune est encodee et jetee avant la suivante, + * sans quoi un cycle de trente secondes tiendrait 255 Mo de pixels bruts en + * memoire. + */ +export async function versMp4( + canvas: HTMLCanvasElement, + images: number, + fps: number, + rend: (index: number) => void | Promise, + avance?: (fait: number, total: number) => void, + /** Abandon demande par l'utilisateur. Cf. `arrete` ci-dessous. */ + signal?: AbortSignal +): Promise { + const { BufferTarget, CanvasSource, Mp4OutputFormat, Output, Quality } = await import( + 'mediabunny' + ) + + const sortie = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() }) + const source = new CanvasSource(canvas, { + codec: 'avc', + quality: new Quality({ quantizer: QP, bitrate: DEBIT_REPLI }) + }) + sortie.addVideoTrack(source, { frameRate: fps }) + await sortie.start() + + /* + * A partir d'ici l'encodeur est OUVERT, donc tout chemin de sortie doit passer par + * `cancel`. Sans ca, une image qui echoue a se rendre laissait le `VideoEncoder` + * derriere elle : Chrome plafonne le nombre d'encodeurs materiels simultanes, si bien + * qu'apres quelques echecs les exports suivants tombaient a `start()` pour une raison + * qui n'avait plus rien a voir avec la cause reelle. Le lecteur hors ecran de + * `capture.ts` est ferme dans un `finally` pour la meme raison ; l'encodeur n'avait pas + * son equivalent. + */ + try { + const duree = 1 / fps + for (let i = 0; i < images; i++) { + arrete(signal) + await rend(i) + // `await` sur chaque image et non en lot : c'est ce qui applique la + // contre-pression de l'encodeur, donc ce qui borne la memoire. + await source.add(i * duree, duree) + avance?.(i + 1, images) + } + arrete(signal) + + await sortie.finalize() + const buffer = sortie.target.buffer + if (!buffer) throw new Error('encodage mp4 vide') + return new Blob([buffer], { type: 'video/mp4' }) + } catch (e) { + await sortie.cancel() + throw e + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..65c7311 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a14e956 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "allowJs": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..914a88c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath, URL } from 'node:url' +import tailwindcss from '@tailwindcss/vite' +import vue from '@vitejs/plugin-vue' +import { defineConfig, type Plugin } from 'vite' + +function serveReplica(): Plugin { + const root = fileURLToPath(new URL('./replica', import.meta.url)) + const types: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.svg': 'image/svg+xml', + '.md': 'text/markdown; charset=utf-8' + } + return { + name: 'serve-replica', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith('/replica/')) { + next() + return + } + const rel = decodeURIComponent(req.url.slice('/replica/'.length).split('?')[0] || '') + const file = path.resolve(root, rel) + if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { + res.statusCode = 404 + res.end() + return + } + res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream') + res.end(fs.readFileSync(file)) + }) + } + } +} + +export default defineConfig({ + plugins: [vue(), tailwindcss(), serveReplica()], + server: { port: 5191 }, + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } + } +})