Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
963b48b67d | ||
|
|
6c6e6b6893 | ||
|
|
e373a1b1e1 | ||
|
|
b545e00555 | ||
|
|
2b2050d369 | ||
|
|
f3b74b572a | ||
|
|
62f39d27e7 | ||
|
|
1ab0054233 | ||
|
|
d91c160138 | ||
|
|
ba78973e51 | ||
|
|
e880350f62 | ||
|
|
851f2b8c6c | ||
|
|
1ba126c92b | ||
|
|
891f6ef7e7 | ||
|
|
5502b2d97a |
+24
@@ -0,0 +1,24 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# Local study notes (full personal README)
|
||||
README.local.md
|
||||
|
||||
# Local-only character snapshot
|
||||
replica/grok-blob-idle.svg
|
||||
|
||||
# Local snap output
|
||||
/tmp/
|
||||
snap-out/
|
||||
|
||||
# Upstream reference checkout for editor/geometry comparison
|
||||
bible-strong-avatar-lab/
|
||||
|
||||
# Never commit unpacked app dumps, unpack scripts, or third-party marks
|
||||
extracted/
|
||||
extract-asar.js
|
||||
assets/
|
||||
.vercel
|
||||
.env*
|
||||
@@ -0,0 +1,26 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Edom
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers only the original engine, playground, and documentation
|
||||
in this repository. It does not grant rights to any third-party character
|
||||
geometry, trademarks, icons, or application source. Those materials are not
|
||||
included here.
|
||||
@@ -1,33 +1,143 @@
|
||||
# Grok Bot 角色复刻(仅供学习参考)
|
||||
# grok-icon-study
|
||||
|
||||
> 素材归 xAI / 相应权利人所有。
|
||||
> 本仓库仅供学习参考,请勿商用或再分发。
|
||||
Unofficial study of a spring-driven character engine.
|
||||
非官方学习项目,与 xAI / Grok 无关。本仓库只开源机芯,不含角色几何、商标或第三方应用源码。
|
||||
|
||||
这是对 **Grok Bot.app v0.18.0** 登录页角色动效的学习复刻,用于研究状态机、弹簧动画、多边形眼睛等实现。
|
||||
One filled shape that morphs between **39 states**, **polygon eyes** that morph
|
||||
on their own playlist, overlays on some states, springs on move / squash / blink /
|
||||
spin. No animation library.
|
||||
|
||||
## 打开
|
||||
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 — not bloub’s measured 14-state engine.
|
||||
|
||||
浏览器打开 [`replica/index.html`](./replica/index.html)。
|
||||
## Running it
|
||||
|
||||
登录页原参数:`sizePx: 64`、`color: "black"`、`shape: "blob"`。
|
||||
`pjn(n) = n%2===0 ? idle : cSe[(n-1)/2]`,间隔 `1200ms`。
|
||||
playground 有意从 curious 起播;点「登录轮换」才按 `pjn(0)=idle` 重来。大预览默认开指针跟随,源码默认关。
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
Then open http://localhost:5191.
|
||||
|
||||
| 路径 | 内容 |
|
||||
```bash
|
||||
pnpm build # vue-tsc --noEmit && vite build
|
||||
```
|
||||
|
||||
Vue 3, Vite, TypeScript, Tailwind 4. No ESLint and no Prettier: `vue-tsc` is the
|
||||
only gate, so run `pnpm build` before you call something done.
|
||||
|
||||
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.
|
||||
|
||||
Do not commit extracted geometry, icons, or a third-party app bundle.
|
||||
|
||||
## What's on the site
|
||||
|
||||
The rail on the left switches between three views. **Customise** offers 8 body
|
||||
shapes, 11 inks and 17 rest expressions, kept between visits, plus a manual pose
|
||||
overlay (drag the body, tune an eye). **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**
|
||||
holds the language (Chinese or English, Chinese by default) and the credits.
|
||||
|
||||
Anything on screen can be exported: the avatar as PNG, SVG or an animated GIF,
|
||||
and a whole timeline as GIF or MP4. The still formats need no library at all,
|
||||
and the video encoder is only fetched the first time you ask for one.
|
||||
|
||||
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.
|
||||
|
||||
Three URLs are worth knowing:
|
||||
|
||||
- [`replica/index.html`](./replica/index.html) — playground + HUD
|
||||
- [`replica/showcase.html`](./replica/showcase.html) — recording layouts
|
||||
- [`replica/embed.html`](./replica/embed.html) — `<grok-bot>`
|
||||
|
||||
## Why this isn't bloub
|
||||
|
||||
bloub measured 14 states off a video and plays exponential ease-outs with no
|
||||
overshoot. This repo studies the spring-driven character: 39 states, polygon
|
||||
eyes, overlays, login wrap. Rounding a spring or swapping a playlist changes
|
||||
the feel, which is the thing this project is trying to keep.
|
||||
|
||||
Geometry is not in the tree on purpose. The engine runs against
|
||||
`window.GROK_GEO`; without it the site still stands.
|
||||
|
||||
## 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"`.
|
||||
|
||||
## How it's put together
|
||||
|
||||
`replica/src/` is framework-free. `GrokCharacter` owns the clock; Vue only
|
||||
mounts it, paints tiles, and (in Customise) overlays the pose editor. Missing
|
||||
geometry keeps the chrome. `GROK.create` / `<grok-bot>` live in
|
||||
`replica/src/api.js`.
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `replica/index.html` | L1 舞台 |
|
||||
| `replica/src/` | 拆开的引擎:character / pose / tricks / eyes / fx / math / tables |
|
||||
| `replica/geometry-data.js` | `u3` 25 眼、`Jo` 18 身形、`snt` 11 色 |
|
||||
| `replica/grok-blob-idle.svg` | 静态 blob + idle `u3[0]` |
|
||||
| `extracted/` | 从主包抽出的学习参考片段 |
|
||||
| `extract-asar.js` | 解包辅助脚本(按需使用) |
|
||||
| `assets/icon.iconset/`、`assets/app-icon-256.png` | Dock / 应用内静态商标(不是角色引擎) |
|
||||
| `src/` | Vue site (bloub-like chrome) |
|
||||
| `src/ui/pose/` | manual pose, head surfaces |
|
||||
| `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` / `<grok-bot>` |
|
||||
|
||||
## 免责声明
|
||||
This repo deliberately does not contain:
|
||||
|
||||
- 本仓库仅用于学习参考。
|
||||
- 所有角色造型、商标、图标、几何数据及从应用包中提取的内容,均归 xAI / 相应权利人所有。
|
||||
- 请勿用于商业用途,请勿再分发。
|
||||
- 请勿将本仓库内容当作自己的商标或原创素材发布。
|
||||
- character geometry (`geometry-data.js`)
|
||||
- official icons / iconset
|
||||
- unpacked app slices
|
||||
- unpack scripts
|
||||
|
||||
## Using the component
|
||||
|
||||
```vue
|
||||
<Hero state="idle" shape="blob" color="black" :size="120" />
|
||||
```
|
||||
|
||||
```html
|
||||
<grok-bot state="idle" shape="blob" color="black" size="64"></grok-bot>
|
||||
```
|
||||
|
||||
```js
|
||||
const { setState, setShape, setColor, setPaused, spin, bounce, destroy } = GROK.create("#host", {
|
||||
sizePx: 64,
|
||||
shape: "blob",
|
||||
color: "black",
|
||||
loginWrap: true,
|
||||
mode: "hold",
|
||||
});
|
||||
```
|
||||
|
||||
`GROK.create` / `GrokCharacter` default to `mode: "hold"` and will not cycle on
|
||||
their own. Pass `mode: "onboarding"` for the login beat. Public methods:
|
||||
`setState` `setShape` `setColor` `setPaused` `spin` `bounce` `destroy`.
|
||||
`<grok-bot>` attributes: `state` `shape` `color` `size` `paused`
|
||||
`follow="true"` `mode` `emphasis`.
|
||||
|
||||
See [`src/components/Hero.vue`](src/components/Hero.vue) and
|
||||
[`replica/src/api.js`](replica/src/api.js) for the details.
|
||||
|
||||
## 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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 707 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// 解析 Electron asar 归档:列出所有文件并提取图标/logo/svg/png 相关资源
|
||||
// 用法: node extract-asar.js <app.asar 路径> <输出目录>
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const asarPath = process.argv[2];
|
||||
const outDir = process.argv[3] || 'extracted';
|
||||
if (!asarPath) { console.error('usage: node extract-asar.js <app.asar> [outdir]'); process.exit(1); }
|
||||
|
||||
const fd = fs.openSync(asarPath, 'r');
|
||||
// 实际布局: [4B: 4][4B: pickleSize][4B: strLen+5][4B: strLen][JSON @16][1B: \0][数据...]
|
||||
const head = Buffer.alloc(16);
|
||||
fs.readSync(fd, head, 0, 16, 0);
|
||||
const pickleSize = head.readUInt32LE(4);
|
||||
const strLen = head.readUInt32LE(12);
|
||||
console.log('pickleSize:', pickleSize, 'strLen:', strLen);
|
||||
|
||||
const jsonBuf = Buffer.alloc(strLen);
|
||||
fs.readSync(fd, jsonBuf, 0, strLen, 16);
|
||||
const header = JSON.parse(jsonBuf.toString('utf8'));
|
||||
const dataStart = 16 + strLen + 1;
|
||||
console.log('dataStart =', dataStart, '\n');
|
||||
|
||||
// 遍历文件树
|
||||
const interesting = [];
|
||||
const allFiles = [];
|
||||
function walk(node, prefix) {
|
||||
for (const [name, info] of Object.entries(node.files || {})) {
|
||||
const p = prefix ? `${prefix}/${name}` : name;
|
||||
if (info.files) { walk(info, p); continue; }
|
||||
allFiles.push({ p, size: info.size, offset: info.offset, unpacked: !!info.unpacked });
|
||||
const lower = p.toLowerCase();
|
||||
if (/(icon|logo|grok|swirl|sparkle|favicon|mark\b|brand|symbol)/.test(lower) && /\.(svg|png|webp|jpg|jpeg|icns|ico)$/.test(lower)) {
|
||||
interesting.push({ p, size: info.size, offset: info.offset, unpacked: !!info.unpacked });
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(header, '');
|
||||
|
||||
console.log(`总文件数: ${allFiles.length}`);
|
||||
console.log(`图标/logo 相关候选: ${interesting.length}\n`);
|
||||
for (const f of interesting) {
|
||||
console.log(` ${String(f.size).padStart(9)} ${f.p}${f.unpacked ? ' [unpacked]' : ''}`);
|
||||
}
|
||||
|
||||
// 提取
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
let n = 0;
|
||||
for (const f of interesting) {
|
||||
const out = path.join(outDir, f.p);
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
if (f.unpacked) {
|
||||
const src = path.join(path.dirname(asarPath) + '.unpacked', f.p);
|
||||
if (fs.existsSync(src)) { fs.copyFileSync(src, out); n++; continue; }
|
||||
}
|
||||
if (f.offset === undefined) continue;
|
||||
const buf = Buffer.alloc(f.size);
|
||||
fs.readSync(fd, buf, 0, f.size, dataStart + parseInt(f.offset));
|
||||
fs.writeFileSync(out, buf);
|
||||
n++;
|
||||
}
|
||||
console.log(`\n已提取 ${n} 个文件到 ${outDir}/`);
|
||||
fs.closeSync(fd);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
cSe=["curious","happy","playful","excited","listening","proud","laughing","shy"],djn=1200,mjn=30,fjn=cSe.length*2*mjn,hjn=T2({name:"onboarding-landing-mood",intervalMs:djn});function pjn(n){return n%2===0?"idle":cSe[(n-1)/2%cSe.length]??"idle"}const VD={root:n=>[rjn,{"--x-transform":`translateY(${n}px)`!=null?`translateY(${n}px)`:void 0}],cta:{kZKoxP:"sand-1vqgdyp",kE3dHu:"sand-d3ty66",kpe85a:"sand-8e5d8q",kOIVth:"sand-1nejdyq",kGuDYH:"sand-osj86m",kLWn49:"sand-ggjnk3",kMwMTN:"sand-5a26a2",kWkgg
|
||||
@@ -1 +0,0 @@
|
||||
const tc=n=>({x:n,v:0,t:n}),xl=(n,e,t,s)=>{n.v+=(-2*t*e*n.v-e*e*(n.x-n.t))*s,n.x+=n.v*s,(!Number.isFinite(n.x)||!Number.isFinite(n.v))&&(n.x=n.t,n.v=0)},T_t=1/120,S_t=n=>Math.max(1,Math.ceil(n/T_t)),$t=(n,e)=>n+Math.random()*(e-n),zn=(n,e,t)=>Math.min(t,Math.max(e,n)),K2=n=>n<.5?4*n*n*n:1-Math.pow(-2*n+2,3)/2,Rc=n=>1-Math.pow(1-n,3),y1e=n=>1+2.70158*Math.pow(n-1,3)+1.70158*Math.pow(n-1,2),Dke=n=>n*n*(3-2*n),x_t=(n,e)=>1-Math.exp(Math.log(1-n)*60*e),k1e=["#f9705c","#5b95f0","#3fbe86","#f5b13f","#9a72ee","#35c3bd"],kie="http://www.w3.org/2000/svg",sae={width:1,smallBoost:1,length:1,lanes:1,hueDrift:1},GBe=5,N_t=.09;
|
||||
@@ -1 +0,0 @@
|
||||
const g_t=[{label:"Lifecycle",states:["sleeping","waking","idle","listening","thinking","searching","working"]},{label:"Reactions",states:["excited","surprised","suspicious","angry","drowsy","happy","curious","confused","bored","proud","shy","sad","laughing","scared","playful","celebrate"]},{label:"Agent morphs",states:["orbit","radar","progress"]},{label:"Product lifecycle",states:["spawning","humming","loading","dictating","writing","sending","receiving","uploading","notifying","alerting","dragging","bouncing","powering-down"]}];g_t.flatMap(n=>n.states);const y_t=new Set(["progress","spawning"]),Fhe={progress:2500,spawning:2e3},k_t=1500,g1e={sleeping:[13,22,4],waking:[13],idle:[0,8],listening:[10,1,19],thinking:[8,16,14,17,5],searching:[15,9,3,20,12,18],working:[7,16,11,10],excited:[2,17,21,3,11],surprised:[3,21],suspicious:[14,5,23],angry:[7,16],drowsy:[4,22,13],happy:[2,11,17,19],curious:[3,21,0,15],confused:[14,5,8],bored:[4,22,0],proud:[15,8,2],shy:[0,24,13],sad:[4,13,22],laughing:[2,11,17],scared:[3,21],playful:[2,17,11,8],celebrate:[2,8,17],orbit:[0,8],radar:[0,8],progress:[0,8],spawning:[3,0],humming:[0,8],loading:[0,8],dictating:[10,1,19],sending:[0,8],receiving:[19,0,8],uploading:[15,9,8],writing:[15,9],notifying:[3,21,0],alerting:[3,21],bouncing:[2,17],dragging:[3,15,0],"powering-down":[13,22]},VBe={sleeping:[6e3,1e4],waking:[800,800],idle:[9e3,16e3],listening:[2800,5e3],thinking:[2e3,3600],searching:[1e3,1800],working:[1800,3200],excited:[1100,2e3],surprised:[2500,4e3],suspicious:[2600,4500],angry:[2200,3800],drowsy:[4e3,8e3],happy:[2500,4500],curious:[1800,3200],confused:[2200,3800],bored:[3500,6e3],proud:[3500,6e3],shy:[3e3,5500],sad:[4e3,7e3],laughing:[1200,2400],scared:[900,1800],playful:[1500,3e3],celebrate:[1400,2600],orbit:[4e3,8e3],radar:[4e3,8e3],progress:[4e3,8e3],spawning:[1200,1200],humming:[5e3,9e3],loading:[6e3,1e4],dictating:[4e3,8e3],sending:[4e3,8e3],receiving:[4e3,8e3],uploading:[4e3,8e3],writing:[4e3,8e3],notifying:[1500,2600],alerting:[2e3,3600],bouncing:[3e3,6e3],dragging:[1600,3e3],"powering-down":[6e3,9e3]},w_t={sleeping:null,waking:null,idle:[6e3,14e3],listening:[3e3,7e3],thinking:[3500,7e3],searching:[1600,4e3],working:[2800,5500],excited:[2e3,4e3],surprised:[1800,3500],suspicious:[4500,8e3],angry:[3500,7e3],drowsy:null,happy:[2500,5e3],curious:[2500,5500],confused:[2800,5500],bored:[4e3,8e3],proud:[3500,7e3],shy:[3e3,6e3],sad:[4e3,8e3],laughing:[2500,5e3],scared:[1200,3e3],playful:[2e3,4500],celebrate:[2200,4500],orbit:null,radar:null,progress:null,spawning:null,humming:[4e3,8e3],loading:null,dictating:null,sending:null,receiving:null,uploading:null,writing:null,notifying:[2e3,4e3],alerting:null,bouncing:null,dragging:[2200,4500],"powering-down":null},v_t=new Set(["happy","excited","proud"]),b_t=new Set(["playful"]),
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hans">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>study — 弹簧角色</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Unofficial study of a spring-driven character engine. Site layout after bloub; motion from the replica engine."
|
||||
/>
|
||||
<meta name="theme-color" content="#f9f9f9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0a0c" media="(prefers-color-scheme: dark)" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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": "[email protected]",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Generated
+1078
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Grok Bot · 最小嵌入</title>
|
||||
<style>
|
||||
:root { --disk: #f3efe6; --ink: #161513; --mute: #6d675d; --paper: #ece7dc; }
|
||||
html, body { margin: 0; min-height: 100%; background: #1a1916; color: var(--paper); }
|
||||
body {
|
||||
font: 14px/1.55 "SF Pro Text", "PingFang SC", sans-serif;
|
||||
padding: 40px 28px 64px;
|
||||
max-width: 640px;
|
||||
}
|
||||
h1 { margin: 0 0 6px; font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
p { margin: 0 0 22px; color: var(--mute); font-size: 13px; }
|
||||
a { color: #e8d7a4; }
|
||||
.disk {
|
||||
width: 96px; height: 96px; border-radius: 50%;
|
||||
background: var(--disk);
|
||||
display: grid; place-items: center;
|
||||
box-shadow: inset 0 -8px 18px rgba(40, 30, 10, 0.08), 0 12px 28px rgba(0,0,0,.3);
|
||||
}
|
||||
grok-bot { color-scheme: light; }
|
||||
pre {
|
||||
margin: 22px 0 0;
|
||||
padding: 14px 16px;
|
||||
background: #22211d;
|
||||
border: 1px solid #2e2c27;
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
font: 12px/1.5 ui-monospace, Menlo, monospace;
|
||||
color: #d8d0c0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>最小嵌入</h1>
|
||||
<p>
|
||||
登录页原参数:64px · blob · black · <code>loginWrap</code>。
|
||||
<a href="index.html">回 playground</a>
|
||||
</p>
|
||||
<div class="disk">
|
||||
<grok-bot state="idle" shape="blob" color="black" size="64" mode="hold"></grok-bot>
|
||||
</div>
|
||||
<pre>
|
||||
<!-- 任意页面 -->
|
||||
<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>
|
||||
</pre>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
# `window.GROK_GEO` 接口
|
||||
|
||||
Playground 只在检测到可用的 `window.GROK_GEO` 时才会创建角色。
|
||||
本仓库不附带这份数据。若要本地看完整外形,自行把 `replica/geometry-data.js` 放到同目录,并保证它在 `src/*.js` 之前执行。
|
||||
|
||||
不要把从商业应用抽出的几何、图标或主包提交到本仓库,也不要再分发。
|
||||
|
||||
## 最小可用对象
|
||||
|
||||
```js
|
||||
window.GROK_GEO = {
|
||||
Re: 100, // 身体逻辑半径,绘制原点
|
||||
G9e: 20, // 眼睛纵向基准
|
||||
VJt: 4, // 非 uniform 眼距预留
|
||||
viewBox: { minX: 0, minY: 0, width: 200, height: 200 },
|
||||
palette: {
|
||||
black: { light: "#111111", dark: "#f4f4f4" },
|
||||
},
|
||||
eyes: [
|
||||
// 下标对应 tables.js 的 playlist。每组 [左眼多边形, 右眼多边形]
|
||||
// 每个多边形是 [[x, y], ...]
|
||||
],
|
||||
shapes: {
|
||||
blob: {
|
||||
label: "Blob",
|
||||
path: "M ... Z", // 你自己的 SVG path d
|
||||
face: { x: 0, y: 0, sx: 1, sy: 1, eye: 1 },
|
||||
radius: 100,
|
||||
tiltScale: 1,
|
||||
top: 0,
|
||||
bottom: 200,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| `Re` | 身体中心 / 变换原点 |
|
||||
| `G9e` `VJt` | 眼睛缩放与间距 |
|
||||
| `viewBox` | 舞台视口;机芯里的 `VIEW` 与此对齐 |
|
||||
| `palette[id].light/dark` | `setColor` 在非 login 墨水下的纯色 |
|
||||
| `eyes[i]` | 第 `i` 号眼形。`tables.js` 的 playlist 用下标引用,默认按 25 组编排 |
|
||||
| `shapes[id].path` | 身形轮廓 |
|
||||
| `shapes[id].face` | 面部相对身体的偏移与缩放,可选 `leftDX` |
|
||||
| `shapes[id].radius/top/bottom/tiltScale` | 眼睛夹持、点头倾斜 |
|
||||
| `solids` | 可选。`bean` / `tablet` / `cloud` 等旋转时的截面带 |
|
||||
|
||||
默认 playground 从 `shapes.blob` 起播。`tables.js` 的 `SHAPE_ZOOM` 给部分 id 配了缩放;其它 id 仍可渲染。
|
||||
`tables.js` 里的 `VIEW` 决定取景框。身形 path 的坐标系最好和它对齐,否则角色会偏出画面。
|
||||
|
||||
## 加载
|
||||
|
||||
`index.html` 会尝试加载 `geometry-data.js`。文件不存在时页面保持说明态,不会创建 `GrokCharacter`。该文件已被 `.gitignore` 忽略。
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-15 -15 259 259" width="256" height="256">
|
||||
<title>Grok Bot blob · idle eyes (source path HJt + u3[0])</title>
|
||||
<path d="M228.541 114.228C228.541 130.133 225.184 145.994 218.738 160.534C212.674 174.217 203.904 186.669 193.065 196.988C155.933 232.34 99.497 238.596 55.5255 212.24C45.097 205.99 35.6851 198.072 27.7451 188.866C19.1926 178.953 12.3686 167.569 7.65781 155.351C2.60712 142.264 0 128.257 0 114.228C0 98.3219 3.35751 82.4611 9.80315 67.9215C15.8672 54.2382 24.6377 41.7862 35.4767 31.4668C72.6081 -3.88483 129.044 -10.1413 173.016 16.2153C183.444 22.4653 192.856 30.3829 200.796 39.5896C209.349 49.5018 216.173 60.8859 220.883 73.1037C225.934 86.1906 228.541 100.198 228.541 114.228Z" fill="#000"/>
|
||||
<path d="M130.36 45.98L132.71 46.19L134.98 46.81L137.11 47.83L138.97 49.28L140.47 51.09L141.68 53.12L142.73 55.23L143.76 57.36L144.78 59.49L145.79 61.62L146.79 63.76L147.76 65.91L148.71 68.07L149.63 70.25L150.52 72.43L151.37 74.63L151.99 76.91L152.10 79.26L151.64 81.57L150.59 83.68L149.04 85.45L147.10 86.78L144.90 87.62L142.56 87.93L140.22 87.71L137.98 86.99L135.93 85.82L134.17 84.24L132.78 82.34L131.69 80.25L130.77 78.08L129.87 75.89L128.94 73.72L128.00 71.56L127.03 69.40L126.05 67.26L125.05 65.12L124.03 62.99L122.93 60.90L121.87 58.79L121.03 56.59L120.72 54.26L121.10 51.93L122.15 49.83L123.75 48.10L125.76 46.89L128.01 46.19Z" fill="#fff"/>
|
||||
<path d="M176.61 37.08L178.72 37.59L180.70 38.48L182.52 39.65L184.20 41.03L185.71 42.59L187.03 44.31L188.20 46.14L189.26 48.03L190.27 49.96L191.26 51.89L192.23 53.84L193.16 55.80L194.05 57.78L194.92 59.77L195.74 61.78L196.53 63.80L197.27 65.84L197.97 67.90L198.47 70.01L198.63 72.18L198.40 74.33L197.58 76.33L195.95 77.72L193.83 78.08L191.71 77.65L189.76 76.69L188.03 75.38L186.53 73.82L185.28 72.05L184.25 70.13L183.40 68.14L182.63 66.11L181.87 64.07L181.07 62.05L180.25 60.04L179.39 58.05L178.49 56.07L177.57 54.10L176.61 52.15L175.62 50.22L174.59 48.31L173.53 46.41L172.54 44.48L171.86 42.42L171.76 40.26L172.62 38.30L174.45 37.19Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
+62
-11
@@ -72,6 +72,17 @@
|
||||
place-items: center;
|
||||
box-shadow: inset 0 -8px 18px rgba(40, 30, 10, 0.08), 0 12px 28px rgba(0,0,0,.3);
|
||||
}
|
||||
.missing {
|
||||
width: min(100%, 280px);
|
||||
margin: 12px auto 0;
|
||||
padding: 22px 18px;
|
||||
border-radius: 16px;
|
||||
background: #2a2823;
|
||||
color: #d8d0c0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.missing strong { display: block; margin-bottom: 8px; color: var(--accent); font-weight: 560; }
|
||||
.missing a { color: var(--accent); }
|
||||
.disk-login svg { width: 64px; height: 64px; overflow: visible; color-scheme: light; --bg: var(--disk); --fg: #111; }
|
||||
.disk-login span {
|
||||
display: block;
|
||||
@@ -80,7 +91,7 @@
|
||||
color: #8d877b;
|
||||
text-align: center;
|
||||
}
|
||||
.readout {
|
||||
.readout, .hud {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -89,7 +100,14 @@
|
||||
color: #b7b1a4;
|
||||
font-size: 12px;
|
||||
}
|
||||
.readout b { color: var(--paper); font-weight: 560; }
|
||||
.hud {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #2e2c27;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
font-size: 11px;
|
||||
}
|
||||
.readout b, .hud b { color: var(--paper); font-weight: 560; }
|
||||
.modes { display: flex; gap: 8px; margin-top: 16px; }
|
||||
button {
|
||||
appearance: none;
|
||||
@@ -159,7 +177,7 @@
|
||||
<body>
|
||||
<header>
|
||||
<h1>Grok Bot Character</h1>
|
||||
<p>从 Grok Bot.app v0.18.0 的 app.asar 抽出 · 个人学习用</p>
|
||||
<p>从 Grok Bot.app v0.18.0 的 app.asar 抽出 · 个人学习用 · <a href="showcase.html" style="color:#e8d7a4">录屏展示</a> · <a href="embed.html" style="color:#e8d7a4">最小嵌入</a></p>
|
||||
</header>
|
||||
<main>
|
||||
<aside class="stage">
|
||||
@@ -174,6 +192,12 @@
|
||||
<span style="display:block;margin-top:8px;font-size:10px;color:#8d877b;text-align:center">登录 sizePx: 64</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missing" id="missing-geo" hidden>
|
||||
<strong>几何未加载</strong>
|
||||
本仓库不包含 <code>geometry-data.js</code>。把符合
|
||||
<a href="geometry.schema.md">geometry.schema.md</a>
|
||||
的数据放到同目录后刷新,即可本地预览。不要提交或再分发从商业应用抽出的造型。
|
||||
</div>
|
||||
<div class="modes">
|
||||
<button id="mode-onboard" aria-pressed="true">登录轮换</button>
|
||||
<button id="mode-hold" aria-pressed="false">锁定状态</button>
|
||||
@@ -192,6 +216,14 @@
|
||||
<div>挤压 <b id="r-squash">1.000</b></div>
|
||||
<div>overlay <b id="r-ov">—</b></div>
|
||||
</div>
|
||||
<div class="hud" id="hud">
|
||||
<div>overlay.x <b id="h-ox">0</b></div>
|
||||
<div>trick <b id="h-trick">—</b></div>
|
||||
<div>shapeSpring <b id="h-shape">1</b></div>
|
||||
<div>notify <b id="h-notify">0</b></div>
|
||||
<div>blink <b id="h-blink">1</b></div>
|
||||
<div>badge <b id="h-badge">—</b></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div>
|
||||
<section>
|
||||
@@ -226,8 +258,9 @@
|
||||
<code>src/</code>:math / tables / pose / tricks / eyes / fx / character。
|
||||
右侧 64px 带 <code>pose.scale ≈ 1.04</code>。thinking / orbit / writing / loading
|
||||
等会切 overlay;celebrate 会 <code>spinWild</code>;换身形走弹簧并轮换一记特技。
|
||||
首屏仍从 curious 起播,点「登录轮换」则按 <code>pjn</code> 从 idle 重来。
|
||||
大预览从 curious 起播;右侧 64px 登录盘按 <code>pjn(0)=idle</code>。点「登录轮换」两颗都从 idle 重来。
|
||||
大预览默认开指针跟随;源码 <code>sd().isFollowingPointer</code> 默认关。
|
||||
别的项目请看 <a href="embed.html"><code>embed.html</code></a> 和 <code>GROK.create</code>。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -240,6 +273,7 @@
|
||||
<script src="src/fx.js"></script>
|
||||
<script src="src/eyes.js"></script>
|
||||
<script src="src/character.js"></script>
|
||||
<script src="src/api.js"></script>
|
||||
<script>
|
||||
const readout = {
|
||||
state: document.getElementById("r-state"),
|
||||
@@ -247,6 +281,12 @@
|
||||
spin: document.getElementById("r-spin"),
|
||||
squash: document.getElementById("r-squash"),
|
||||
ov: document.getElementById("r-ov"),
|
||||
ox: document.getElementById("h-ox"),
|
||||
trick: document.getElementById("h-trick"),
|
||||
shape: document.getElementById("h-shape"),
|
||||
notify: document.getElementById("h-notify"),
|
||||
blink: document.getElementById("h-blink"),
|
||||
badge: document.getElementById("h-badge"),
|
||||
};
|
||||
const stateRoot = document.getElementById("states");
|
||||
function paintReadout(s) {
|
||||
@@ -255,6 +295,13 @@
|
||||
readout.spin.textContent = `${s.spin.toFixed(2)}°`;
|
||||
readout.squash.textContent = s.squash.toFixed(3);
|
||||
if (readout.ov) readout.ov.textContent = s.overlay || "—";
|
||||
if (!readout.ox) return;
|
||||
readout.ox.textContent = (s.overlayX ?? 0).toFixed(3);
|
||||
readout.trick.textContent = s.trick || "—";
|
||||
readout.shape.textContent = (s.shapeSpring ?? 1).toFixed(3);
|
||||
readout.notify.textContent = (s.notify ?? 0).toFixed(3);
|
||||
readout.blink.textContent = (s.blink ?? 1).toFixed(3);
|
||||
readout.badge.textContent = s.badge ? `${s.badge.x.toFixed(1)}, ${s.badge.y.toFixed(1)}` : "—";
|
||||
}
|
||||
function markState(name) {
|
||||
stateRoot.querySelectorAll("button").forEach((b) => {
|
||||
@@ -266,6 +313,10 @@
|
||||
document.getElementById("mode-hold").setAttribute("aria-pressed", mode === "hold");
|
||||
}
|
||||
|
||||
if (!window.GROK_GEO?.shapes || typeof window.GROK?.create !== "function") {
|
||||
document.getElementById("missing-geo").hidden = false;
|
||||
} else {
|
||||
|
||||
GROK_META.groups.forEach((group) => {
|
||||
const h = document.createElement("h2");
|
||||
h.textContent = group.label;
|
||||
@@ -290,19 +341,18 @@
|
||||
});
|
||||
|
||||
const shared = { mode: "onboarding", shape: "blob", color: "black", scheme: "light", loginWrap: true };
|
||||
const bot = new GrokCharacter(document.getElementById("bot"), {
|
||||
const stage = GROK.create(document.getElementById("bot"), {
|
||||
...shared,
|
||||
followPointer: true,
|
||||
onChange(snap) { paintReadout(snap); markState(snap.state); },
|
||||
});
|
||||
const bot64 = new GrokCharacter(document.getElementById("bot64"), { ...shared, sizePx: 64 });
|
||||
const login = GROK.create(document.getElementById("bot64"), { ...shared, sizePx: 64 });
|
||||
const bot = stage.bot;
|
||||
const bot64 = login.bot;
|
||||
const bots = [bot, bot64];
|
||||
window.__bots = bots;
|
||||
// skip the first idle beat so the face is obvious on load
|
||||
bots.forEach((el) => {
|
||||
el.moodN = 1;
|
||||
el.setState("curious", { resetEyes: true });
|
||||
});
|
||||
bot.moodN = 1;
|
||||
bot.setState("curious", { resetEyes: true });
|
||||
setInterval(() => paintReadout(bot.snapshot()), 80);
|
||||
|
||||
const shapeRoot = document.getElementById("shapes");
|
||||
@@ -358,6 +408,7 @@
|
||||
document.getElementById("act-spin").onclick = () => bots.forEach((el) => el.spinOnce(1));
|
||||
document.getElementById("act-bounce").onclick = () => bots.forEach((el) => el.bounceOnce());
|
||||
document.getElementById("act-burst").onclick = () => bots.forEach((el) => el.burstOnce());
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+232
-4
@@ -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: "[email protected]",
|
||||
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));
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/* L2 — public factory + <grok-bot>. 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),
|
||||
setPose: (v) => bot.setPose(v),
|
||||
setManualOffset: (v) => bot.setManualOffset(v),
|
||||
setEyeTune: (v) => bot.setEyeTune(v),
|
||||
setManualHold: (v) => bot.setManualHold(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.shadowRoot || this.attachShadow({ mode: "open" });
|
||||
root.replaceChildren();
|
||||
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);
|
||||
+687
-98
File diff suppressed because it is too large
Load Diff
+217
-11
@@ -36,8 +36,9 @@
|
||||
const {
|
||||
now, polys, morphT, shape, face, faceTune, uniformEyes, eyeScaleProp,
|
||||
blinkX, gazeX, gazeY, winkAt, winkEye, turn, cr, pointer, notifyX,
|
||||
overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint,
|
||||
overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint, manualHold,
|
||||
} = opt;
|
||||
const mix = clamp(opt.manualMix ?? (manualHold ? 1 : 0), 0, 1);
|
||||
const pulse = 1 + 0.07 * Math.sin(morphT * Math.PI);
|
||||
const $i = {
|
||||
x: face.x,
|
||||
@@ -47,6 +48,8 @@
|
||||
eye: face.eye * (faceTune?.size ?? 1),
|
||||
leftDX: face.leftDX ?? 0,
|
||||
};
|
||||
const tune = opt.tune || null;
|
||||
if (tune) $i.sx *= 1 + (clamp(tune.spacing ?? 35, 0, 150) / 35 - 1) * mix;
|
||||
const sX = uniformEyes ? $i.leftDX : 0;
|
||||
const cents = [centroid(polys[0]), centroid(polys[1])];
|
||||
let a1 = 0, o1 = 0;
|
||||
@@ -57,8 +60,8 @@
|
||||
const _ee = a1 + o1 > 0.5 ? clamp((l1 - pre) / (a1 + o1), 0.35, 4) : 4;
|
||||
const Uee = (uniformEyes ? 1 : $i.eye) * clamp(eyeScaleProp, 0.25, 4);
|
||||
const oX = Math.min(clamp(opt.eyeBoostX, 0.2, 2) * Uee, _ee / pulse);
|
||||
const Hee = Math.min(oX * clamp(faceTune?.eyeWidth ?? 1, 0.2, 3), _ee / pulse);
|
||||
const u1 = oX * clamp(faceTune?.eyeHeight ?? 1, 0.2, 3);
|
||||
const Hee0 = Math.min(oX * clamp(faceTune?.eyeWidth ?? 1, 0.2, 3), _ee / pulse);
|
||||
const u10 = oX * clamp(faceTune?.eyeHeight ?? 1, 0.2, 3);
|
||||
const liveSpan = ringHint
|
||||
? (y) => spanPoly(ringHint, y, Re)
|
||||
: spanAt(shape.path, Re);
|
||||
@@ -69,7 +72,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]
|
||||
@@ -80,6 +83,15 @@
|
||||
const [Gn, Ti] = cents[i];
|
||||
eyeEls[i].setAttribute("d", polyPath(poly));
|
||||
const lid = winkLid(blinkX, now, winkAt, winkEye, i);
|
||||
const sideTune = tune?.[i === 0 ? "left" : "right"];
|
||||
const tuneW = sideTune ? 1 + (clamp(sideTune.width ?? 20, 10, 100) / 20 - 1) * mix : 1;
|
||||
const tuneH = sideTune ? 1 + (clamp(sideTune.height ?? 50, 10, 100) / 50 - 1) * mix : 1;
|
||||
const tuneS = sideTune ? 1 + (clamp(sideTune.size ?? 1, 0.35, 2.2) - 1) * mix : 1;
|
||||
const tuneA = sideTune ? (sideTune.angle ?? 0) * Math.PI / 180 * mix : 0;
|
||||
const tuneX = sideTune ? (sideTune.x ?? 0) * mix : 0;
|
||||
const tuneY = sideTune ? (sideTune.y ?? 0) * mix : 0;
|
||||
const Hee = Math.min(Hee0 * tuneW * tuneS, _ee / pulse);
|
||||
const u1 = u10 * tuneH * tuneS;
|
||||
const Ea = Gn + (i === 0 ? sX : 0);
|
||||
let Ca = Re + $i.x;
|
||||
let Wo = (Ea - Re) * $i.sx;
|
||||
@@ -135,9 +147,12 @@
|
||||
Tre = Dke(clamp(Io0 / 0.5, 0, 1));
|
||||
}
|
||||
|
||||
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;
|
||||
let Kj = (1 - mix) * (Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5);
|
||||
let Ko = (1 - mix) * 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 {
|
||||
@@ -167,10 +182,10 @@
|
||||
if (Ia2 - Frp > O2) O2 = Ia2 - Frp;
|
||||
if (li2 - Frp < Xl) Xl = li2 - Frp;
|
||||
}
|
||||
const xre = Ca + Wo + Kj * $i.sx;
|
||||
const xre = Ca + Wo + Kj * $i.sx + tuneX;
|
||||
const lX = O2 <= Xl ? clamp(xre, O2, Xl) : (O2 + Xl) / 2;
|
||||
let dd = lX + (xre - lX) * (1 - Tre);
|
||||
let Yj = vl;
|
||||
let Yj = vl + tuneY;
|
||||
if (notifyX > 0.01) {
|
||||
const xr = 20 * clamp(notifyX, 0, 1.4);
|
||||
const Fr = dd - Yl[0], Ia = Yj - Yl[1];
|
||||
@@ -185,18 +200,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
const rot = tuneA
|
||||
? ` rotate(${(tuneA * 180 / Math.PI).toFixed(2)})`
|
||||
: "";
|
||||
if (use3d) {
|
||||
const FrM = clamp((turn != null ? _c : 1) * Hee * pulse, 0.02, 2.4);
|
||||
const IaM = clamp(lid * u1 * pulse, 0.02, 2.4);
|
||||
const liM = km * FrM, blM = Ree * FrM, IoM = Fee * IaM, uoM = zee * IaM;
|
||||
eyeEls[i].setAttribute(
|
||||
"transform",
|
||||
`translate(${dd.toFixed(2)} ${Yj.toFixed(2)}) matrix(${liM.toFixed(4)} ${blM.toFixed(4)} ${IoM.toFixed(4)} ${uoM.toFixed(4)} 0 0) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})`
|
||||
`translate(${dd.toFixed(2)} ${Yj.toFixed(2)})${rot} matrix(${liM.toFixed(4)} ${blM.toFixed(4)} ${IoM.toFixed(4)} ${uoM.toFixed(4)} 0 0) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})`
|
||||
);
|
||||
} else {
|
||||
eyeEls[i].setAttribute(
|
||||
"transform",
|
||||
`translate(${dd.toFixed(2)} ${Yj.toFixed(2)}) scale(${Vee.toFixed(4)} ${_2.toFixed(4)}) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})`
|
||||
`translate(${dd.toFixed(2)} ${Yj.toFixed(2)})${rot} scale(${Vee.toFixed(4)} ${_2.toFixed(4)}) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -214,5 +232,193 @@
|
||||
}
|
||||
}
|
||||
|
||||
g.GROK_EYES = { queueBlink, consumeBlink, paintEyes, lerpPoly };
|
||||
const EYE_HOME = { turn: 17, tilt: -14, roll: 29 };
|
||||
const DEFAULT_EYE = { width: 20, height: 50, size: 1, angle: 0, x: 0, y: -7 };
|
||||
const DEFAULT_EYES = { left: DEFAULT_EYE, right: DEFAULT_EYE, spacing: 35 };
|
||||
const OVERLAY = 300;
|
||||
const OVERLAY_HALF = OVERLAY / 2;
|
||||
const FACE_R = 120;
|
||||
const FOCAL = 620;
|
||||
const ARC_N = 14;
|
||||
|
||||
const quatMul = (a, b) => [
|
||||
a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
|
||||
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
|
||||
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
|
||||
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0],
|
||||
];
|
||||
const quatAxis = (axis, angle) => {
|
||||
const h = angle / 2;
|
||||
const s = Math.sin(h);
|
||||
return [Math.cos(h), axis[0] * s, axis[1] * s, axis[2] * s];
|
||||
};
|
||||
const quatEuler = (tilt, turn, roll) => {
|
||||
const d = Math.PI / 180;
|
||||
return quatMul(quatMul(quatAxis([0, 0, 1], roll * d), quatAxis([1, 0, 0], tilt * d)), quatAxis([0, 1, 0], turn * d));
|
||||
};
|
||||
const quatRotate = (q, p) => {
|
||||
const tx = 2 * (q[2] * p[2] - q[3] * p[1]);
|
||||
const ty = 2 * (q[3] * p[0] - q[1] * p[2]);
|
||||
const tz = 2 * (q[1] * p[1] - q[2] * p[0]);
|
||||
return [
|
||||
p[0] + q[0] * tx + (q[2] * tz - q[3] * ty),
|
||||
p[1] + q[0] * ty + (q[3] * tx - q[1] * tz),
|
||||
p[2] + q[0] * tz + (q[1] * ty - q[2] * tx),
|
||||
];
|
||||
};
|
||||
const screenOf = (pose, home) => {
|
||||
const h = quatEuler(home.tilt, home.turn, home.roll);
|
||||
const vis = quatMul(quatEuler(pose.tilt, pose.turn, pose.roll), [h[0], -h[1], -h[2], -h[3]]);
|
||||
return [vis[0], -vis[1], vis[2], -vis[3]];
|
||||
};
|
||||
|
||||
function roundedRect(width, height) {
|
||||
const hw = width / 2;
|
||||
const hh = height / 2;
|
||||
const r = Math.min(hw, hh);
|
||||
const pts = [];
|
||||
const line = (a, b) => {
|
||||
const n = Math.max(2, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / 1.5));
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = i / n;
|
||||
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
||||
}
|
||||
};
|
||||
const arc = (cx, cy, start) => {
|
||||
for (let i = 0; i < ARC_N; i++) {
|
||||
const a = start + (i / ARC_N) * Math.PI / 2;
|
||||
pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
|
||||
}
|
||||
};
|
||||
line([-hw + r, -hh], [hw - r, -hh]);
|
||||
arc(hw - r, -hh + r, -Math.PI / 2);
|
||||
line([hw, -hh + r], [hw, hh - r]);
|
||||
arc(hw - r, hh - r, 0);
|
||||
line([hw - r, hh], [-hw + r, hh]);
|
||||
arc(-hw + r, hh - r, Math.PI / 2);
|
||||
line([-hw, hh - r], [-hw, -hh + r]);
|
||||
arc(-hw + r, -hh + r, Math.PI);
|
||||
return pts;
|
||||
}
|
||||
|
||||
function projectEye(pose, home, faceX, faceY, lx, ly, angle, rim) {
|
||||
const a = (angle || 0) * Math.PI / 180;
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
const x = faceX + lx * c - ly * s;
|
||||
const y = faceY + lx * s + ly * c;
|
||||
// Face coords are authored against FACE_R=120 (original lab units) but the
|
||||
// physical head rides `rim` overlay units so the eye sphere's silhouette
|
||||
// lands on the visible head edge. Past the rim points stay raw on the z=0
|
||||
// plane (ellipsoidFrontSample semantics): the eye rides over the edge and
|
||||
// gets sliced by the head clip instead of folding back inward.
|
||||
const headR = rim || 95;
|
||||
const dx = x / FACE_R;
|
||||
const dy = y / FACE_R;
|
||||
const dz = Math.sqrt(Math.max(0, 1 - dx * dx - dy * dy));
|
||||
const rotated = quatRotate(screenOf(pose, home), [dx, dy, dz]);
|
||||
const depth = rotated[2] * headR;
|
||||
const sc = FOCAL / Math.max(FOCAL - depth, 0.0001);
|
||||
return [rotated[0] * headR * sc, rotated[1] * headR * sc, rotated[2]];
|
||||
}
|
||||
|
||||
function overlayToSvg(svg, x, y) {
|
||||
const box = svg.viewBox?.baseVal;
|
||||
const w = box?.width || OVERLAY;
|
||||
const h = box?.height || OVERLAY;
|
||||
const ox = box?.x || 0;
|
||||
const oy = box?.y || 0;
|
||||
return [ox + ((x + OVERLAY_HALF) / OVERLAY) * w, oy + ((y + OVERLAY_HALF) / OVERLAY) * h];
|
||||
}
|
||||
|
||||
function resampleRing(pts, n) {
|
||||
if (!pts?.length) return Array.from({ length: n }, () => [0, 0]);
|
||||
const dist = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
let total = 0;
|
||||
const segs = pts.map((p, i) => {
|
||||
const d = dist(p, pts[(i + 1) % pts.length]) || 1e-6;
|
||||
total += d;
|
||||
return d;
|
||||
});
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let acc = 0;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const target = (k / n) * total;
|
||||
while (acc + segs[i % segs.length] < target && i < pts.length * 2) {
|
||||
acc += segs[i % segs.length];
|
||||
i++;
|
||||
}
|
||||
const a = pts[i % pts.length];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
const sl = segs[i % segs.length];
|
||||
const t = clamp((target - acc) / sl, 0, 1);
|
||||
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sampleEl(el, n) {
|
||||
if (!el || typeof el.getTotalLength !== "function") return null;
|
||||
let len = 0;
|
||||
try {
|
||||
len = el.getTotalLength();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const m = el.transform.baseVal.consolidate()?.matrix;
|
||||
const pts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = el.getPointAtLength(len > 0 ? (i / n) * len : 0);
|
||||
pts.push(m ? [m.a * p.x + m.c * p.y + m.e, m.b * p.x + m.d * p.y + m.f] : [p.x, p.y]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function customEyePolys({ pose, eyes, svg, n = 48, home = EYE_HOME, rim }) {
|
||||
if (!svg || !pose) return null;
|
||||
const src = eyes || DEFAULT_EYES;
|
||||
const sides = ["left", "right"];
|
||||
return sides.map((side) => {
|
||||
const tune = { ...DEFAULT_EYE, ...(src[side] || {}) };
|
||||
const faceX = (side === "left" ? -1 : 1) * clamp(src.spacing ?? 35, 0, 150) / 2 + tune.x;
|
||||
const faceY = tune.y;
|
||||
const local = roundedRect(Math.max(tune.width, 1), Math.max(tune.height, 1));
|
||||
let z = 0;
|
||||
const projected = local.map(([lx, ly]) => {
|
||||
const p = projectEye(pose, home, faceX, faceY, lx, ly, tune.angle, rim);
|
||||
z += p[2];
|
||||
return overlayToSvg(svg, p[0], p[1]);
|
||||
});
|
||||
return { pts: resampleRing(projected, n), visible: z > 0, zAvg: z / Math.max(local.length, 1) };
|
||||
});
|
||||
}
|
||||
|
||||
// Visibility hysteresis: drag jitter around the silhouette boundary would
|
||||
// otherwise pop the eye in and out every few frames.
|
||||
const VIS_HIDE = -0.006;
|
||||
const VIS_SHOW = 0.004;
|
||||
|
||||
function blendCustomEyes({ eyeEls, pose, eyes, svg, mix, n = 48, rim }) {
|
||||
const k = clamp(mix, 0, 1);
|
||||
if (k <= 0.002 || !eyeEls) return;
|
||||
const custom = customEyePolys({ pose, eyes, svg, n, rim });
|
||||
if (!custom) return;
|
||||
const gate = 0.22;
|
||||
const shapeK = k >= gate ? 1 : Dke(k / gate);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const el = eyeEls[i];
|
||||
const expr = sampleEl(el, n);
|
||||
if (!el || !expr || !custom[i]) continue;
|
||||
let shown = el.__eyeShown != null ? el.__eyeShown : custom[i].visible;
|
||||
if (shown && custom[i].zAvg < VIS_HIDE) shown = false;
|
||||
if (!shown && custom[i].zAvg > VIS_SHOW) shown = true;
|
||||
el.__eyeShown = shown;
|
||||
el.setAttribute("d", polyPath(lerpPoly(custom[i].pts, expr, 1 - shapeK)));
|
||||
el.removeAttribute("transform");
|
||||
if (shapeK > 0.55) el.style.display = shown ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
g.GROK_EYES = { queueBlink, consumeBlink, paintEyes, lerpPoly, sampleEl, customEyePolys, blendCustomEyes };
|
||||
})(window);
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
shape: [10, 1],
|
||||
overlayTurn: [14, 1],
|
||||
spinTurn: [6.2, 1],
|
||||
manualMix: [7, 1],
|
||||
};
|
||||
|
||||
const FACE_TUNE = { size: 0.86, gap: 1.18, height: 1, eyeWidth: 0.96, eyeHeight: 0.92 };
|
||||
|
||||
+899
@@ -0,0 +1,899 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import AnimPanel from '@/components/AnimPanel.vue'
|
||||
import Customizer from '@/components/Customizer.vue'
|
||||
import PlaybackBar from '@/components/PlaybackBar.vue'
|
||||
import CycleDialog from '@/components/CycleDialog.vue'
|
||||
import ExportBar from '@/components/ExportBar.vue'
|
||||
import GifDialog from '@/components/GifDialog.vue'
|
||||
import AvatarStage from '@/components/AvatarStage.vue'
|
||||
import PhotoFrame from '@/components/PhotoFrame.vue'
|
||||
import PhotoPanel from '@/components/PhotoPanel.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import SideRail, { type ViewId } from '@/components/SideRail.vue'
|
||||
import Timeline from '@/components/Timeline.vue'
|
||||
import {
|
||||
blockAt,
|
||||
blocksWith,
|
||||
defaultCycle,
|
||||
parseCycles,
|
||||
totalDuration,
|
||||
type Cycle
|
||||
} from '@/editor/cycles'
|
||||
import { nomDeCycle, t } from '@/i18n'
|
||||
import { DEFAULT_COLOR, DEFAULT_EXPRESSION, DEFAULT_SHAPE, estForme } from '@/replica/catalog'
|
||||
import {
|
||||
copie,
|
||||
copieTexte,
|
||||
cycleVersGif,
|
||||
cycleVersMp4,
|
||||
svgAutonome,
|
||||
telecharge,
|
||||
versGifAnime,
|
||||
versPng,
|
||||
versSvgAnime
|
||||
} from '@/ui/capture'
|
||||
import {
|
||||
ACTION_BY_ID,
|
||||
ANIM_IMAGES,
|
||||
ANIM_PAS,
|
||||
BLANC,
|
||||
CYCLE_TAILLE,
|
||||
FOND_GIF_DEFAUT,
|
||||
FORMAT_CYCLE_DEFAUT,
|
||||
GIF_IMAGES,
|
||||
GIF_PAS,
|
||||
Abandon,
|
||||
couleurDeFond,
|
||||
cycleImages,
|
||||
cyclePas,
|
||||
nomFichier,
|
||||
tailleAction,
|
||||
viewBoxExport,
|
||||
type ActionId,
|
||||
type EtatExport,
|
||||
type FondGif,
|
||||
type FormatCycle
|
||||
} from '@/ui/export'
|
||||
import {
|
||||
ANGLE_DEFAUT,
|
||||
COMPOSITION_DEFAUT,
|
||||
svgPhoto,
|
||||
type CompositionPhoto,
|
||||
type FondPhoto
|
||||
} from '@/ui/photo'
|
||||
import { parseManual, persistable, type ManualState } from '@/ui/pose/model'
|
||||
import { ecris, lis } from '@/ui/stockage'
|
||||
|
||||
const view = ref<ViewId>('personnaliser')
|
||||
const preview = ref(false)
|
||||
const ready = ref(false)
|
||||
const savedShape = lis('forme')
|
||||
const shape = ref(savedShape && estForme(savedShape) ? savedShape : DEFAULT_SHAPE)
|
||||
const color = ref(lis('couleur') || DEFAULT_COLOR)
|
||||
const oeil = ref(lis('oeil') || '')
|
||||
const expression = ref(lis('expression') || DEFAULT_EXPRESSION)
|
||||
const intro = ref(true)
|
||||
const hero = ref<{
|
||||
spin: () => void
|
||||
orbitGaze: (ms?: number) => void
|
||||
svg: () => SVGSVGElement | null
|
||||
seekEye: (index: number, opts?: { snap?: boolean }) => void
|
||||
flushPerformance: () => void
|
||||
setPlaylistHold: (on: boolean) => void
|
||||
setPaused: (on: boolean | 'hold-pose') => void
|
||||
holdFrame: (at?: number) => void
|
||||
freezeNow: (opts?: { settle?: boolean }) => unknown
|
||||
playback: () => {
|
||||
state: string
|
||||
eyeIdx: number
|
||||
holdMs: number
|
||||
remainMs: number
|
||||
held: boolean
|
||||
frozen: boolean
|
||||
manualMix: number
|
||||
} | null
|
||||
} | null>(null)
|
||||
const manual = ref<ManualState>(parseManual(lis('manuel')))
|
||||
|
||||
/* ---- mode photo : cadrage, fond et capture de l'avatar fige ---- */
|
||||
const photo = ref(false)
|
||||
const photoOutil = ref<'cadre' | 'pose'>('cadre')
|
||||
const photoComposition = ref<CompositionPhoto>({ ...COMPOSITION_DEFAUT, cornerRadius: 18 })
|
||||
const photoFond = ref<FondPhoto>('transparent')
|
||||
const photoCouleurDe = ref('#F5F7FC')
|
||||
const photoCouleurA = ref('#C9D5FF')
|
||||
const photoAngle = ref(ANGLE_DEFAUT)
|
||||
const photoFormat = ref<'png' | 'svg'>('png')
|
||||
const photoTaille = ref(1024)
|
||||
const photoFlash = ref(0)
|
||||
const photoAvantJoue = ref(true)
|
||||
let minuteurPhoto: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const restored = parseCycles(lis('cycles'))
|
||||
const cycles = ref<Cycle[]>(restored.length ? restored : [defaultCycle()])
|
||||
const activeId = ref(
|
||||
(() => {
|
||||
const v = lis('cycle')
|
||||
return v && cycles.value.some((c) => c.id === v) ? v : cycles.value[0]!.id
|
||||
})()
|
||||
)
|
||||
const block = ref(0)
|
||||
const elapsed = ref(0)
|
||||
const playing = ref(false)
|
||||
let seeking = false
|
||||
|
||||
const cycle = computed(() => cycles.value.find((c) => c.id === activeId.value) ?? cycles.value[0]!)
|
||||
const state = ref(intro.value ? 'idle' : (cycle.value.blocks[block.value]?.state ?? 'idle'))
|
||||
|
||||
const gauche = computed(() => view.value === 'reglages' && !preview.value)
|
||||
const autoTricks = computed(() => view.value === 'animations')
|
||||
const droite = computed(() => view.value !== 'reglages' && !preview.value && !intro.value)
|
||||
const plein = computed(() => preview.value || photo.value)
|
||||
const follow = computed(() => view.value === 'reglages' && !preview.value)
|
||||
const nue = computed(() => intro.value && !preview.value)
|
||||
const playedState = computed(() => {
|
||||
if (photo.value) return expression.value
|
||||
if (view.value === 'animations' || preview.value) return state.value
|
||||
return expression.value
|
||||
})
|
||||
const avatarSize = computed(() => {
|
||||
if (photo.value) return 460
|
||||
if (preview.value) return 560
|
||||
if (view.value === 'personnaliser') return 580
|
||||
return 440
|
||||
})
|
||||
const avatarMax = computed(() => {
|
||||
if (photo.value)
|
||||
return 'max-w-[min(460px,68vw,calc(100dvh_-_14rem))] max-lg:max-w-[calc(54dvh_-_12.5rem)]'
|
||||
if (preview.value) return 'max-w-[min(560px,calc(100dvh_-_6rem))]'
|
||||
if (view.value === 'personnaliser')
|
||||
return 'max-w-[min(600px,calc(100dvh_-_var(--timeline)_-_7rem))]'
|
||||
return 'max-w-[min(460px,calc(100dvh_-_var(--timeline)_-_7rem))]'
|
||||
})
|
||||
|
||||
watch(shape, (id) => {
|
||||
ecris('forme', id)
|
||||
if (manual.value.selected) manual.value = { ...manual.value, selected: null }
|
||||
})
|
||||
watch(color, (id) => ecris('couleur', id))
|
||||
watch(oeil, (v) => ecris('oeil', v))
|
||||
watch(expression, (id) => {
|
||||
ecris('expression', id)
|
||||
if (photo.value) {
|
||||
// en photo, l'avatar est fige : on libere le temps de la transition
|
||||
// spring vers la nouvelle expression, puis on regele.
|
||||
hero.value?.setPaused(false)
|
||||
clearTimeout(minuteurPhoto)
|
||||
minuteurPhoto = setTimeout(() => hero.value?.freezeNow({ settle: true }), 700)
|
||||
return
|
||||
}
|
||||
if (view.value !== 'animations') state.value = id
|
||||
if (view.value !== 'personnaliser') return
|
||||
poseIdx.value = 0
|
||||
poseElapsed.value = 0
|
||||
if (manual.value.on) return
|
||||
posePlay.value = true
|
||||
poseStopped.value = false
|
||||
})
|
||||
watch(
|
||||
manual,
|
||||
(value) => {
|
||||
ecris('manuel', JSON.stringify(persistable(value)))
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
let pending: ReturnType<typeof setTimeout>
|
||||
function enregistreCycles() {
|
||||
clearTimeout(pending)
|
||||
ecris('cycles', JSON.stringify(cycles.value))
|
||||
}
|
||||
watch(cycles, () => {
|
||||
clearTimeout(pending)
|
||||
pending = setTimeout(enregistreCycles, 250)
|
||||
})
|
||||
watch(activeId, (v) => {
|
||||
ecris('cycle', v)
|
||||
block.value = 0
|
||||
elapsed.value = 0
|
||||
state.value = cycle.value.blocks[0]?.state ?? 'idle'
|
||||
})
|
||||
|
||||
watch(block, (i) => {
|
||||
if (seeking) {
|
||||
seeking = false
|
||||
return
|
||||
}
|
||||
elapsed.value = 0
|
||||
state.value = cycle.value.blocks[i]?.state ?? 'idle'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => cycle.value.blocks.length,
|
||||
(n) => {
|
||||
if (block.value >= n) block.value = Math.max(0, n - 1)
|
||||
}
|
||||
)
|
||||
|
||||
function addBlock(id: string) {
|
||||
cycles.value = cycles.value.map((c) =>
|
||||
c.id === cycle.value.id ? { ...c, blocks: blocksWith(c.blocks, id) } : c
|
||||
)
|
||||
}
|
||||
|
||||
function onSeek(t: number) {
|
||||
const { index, elapsed: offset } = blockAt(cycle.value.blocks, t)
|
||||
seeking = true
|
||||
block.value = index
|
||||
elapsed.value = offset
|
||||
state.value = cycle.value.blocks[index]?.state ?? 'idle'
|
||||
}
|
||||
|
||||
const posePlay = ref(!manual.value.on)
|
||||
const poseStopped = ref(false)
|
||||
const poseIdx = ref(0)
|
||||
const poseElapsed = ref(0)
|
||||
const poseHold = ref(2)
|
||||
const poseMix = ref(0)
|
||||
|
||||
watch(
|
||||
() => manual.value.on,
|
||||
(on) => {
|
||||
if (view.value !== 'personnaliser') return
|
||||
if (on) {
|
||||
posePlay.value = false
|
||||
// en photo, l'entree a gele le moteur : le degeler pour que le
|
||||
// ressort manualMix reparte et laisse apparaitre les gizmos.
|
||||
if (photo.value) hero.value?.setPaused(false)
|
||||
pausePose()
|
||||
} else {
|
||||
posePlay.value = true
|
||||
releasePoseFreeze()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function onSeekPose(index: number) {
|
||||
const frozen = !!hero.value?.playback()?.frozen
|
||||
hero.value?.seekEye(index, { snap: frozen })
|
||||
poseIdx.value = index
|
||||
}
|
||||
|
||||
function releasePoseFreeze() {
|
||||
hero.value?.setPaused(false)
|
||||
hero.value?.setPlaylistHold(false)
|
||||
}
|
||||
|
||||
function playPose() {
|
||||
poseStopped.value = false
|
||||
hero.value?.setPaused(false)
|
||||
hero.value?.setPlaylistHold(false)
|
||||
}
|
||||
|
||||
function pausePose(settle = false) {
|
||||
hero.value?.setPlaylistHold(true)
|
||||
if (manual.value.on) {
|
||||
hero.value?.setPaused(true)
|
||||
return
|
||||
}
|
||||
hero.value?.seekEye(poseIdx.value, { snap: true })
|
||||
hero.value?.freezeNow({ settle })
|
||||
}
|
||||
|
||||
function applyPosePlayback() {
|
||||
if (view.value !== 'personnaliser' || preview.value) return
|
||||
if (posePlay.value) playPose()
|
||||
else pausePose(poseStopped.value)
|
||||
}
|
||||
|
||||
function onStopPose() {
|
||||
poseStopped.value = true
|
||||
posePlay.value = false
|
||||
if (view.value === 'personnaliser') pausePose(true)
|
||||
}
|
||||
|
||||
function ouvrePhoto() {
|
||||
if (!hero.value || intro.value || preview.value) return
|
||||
// fige l'instant present : l'expression affichee devient la pose photo.
|
||||
expression.value = playedState.value
|
||||
photoOutil.value = 'cadre'
|
||||
photoAvantJoue.value = posePlay.value
|
||||
posePlay.value = false
|
||||
poseStopped.value = false
|
||||
pausePose(true)
|
||||
photo.value = true
|
||||
}
|
||||
|
||||
function fermePhoto() {
|
||||
photo.value = false
|
||||
clearTimeout(minuteurPhoto)
|
||||
photoFlash.value = 0
|
||||
hero.value?.setPaused(false)
|
||||
posePlay.value = photoAvantJoue.value && !manual.value.on
|
||||
void nextTick(applyPosePlayback)
|
||||
}
|
||||
|
||||
function recadrePhoto() {
|
||||
photoComposition.value = {
|
||||
...COMPOSITION_DEFAUT,
|
||||
cornerRadius: photoComposition.value.cornerRadius
|
||||
}
|
||||
}
|
||||
|
||||
function choisitOutil(outil: 'cadre' | 'pose') {
|
||||
photoOutil.value = outil
|
||||
if (outil === 'pose' && !manual.value.on) {
|
||||
manual.value = { ...manual.value, on: true, selected: manual.value.selected ?? 'body' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choisir une expression quitte le reglage manuel (la pose revient a
|
||||
* l'expression) puis enchaine la transition — meme si l'expression cliquee
|
||||
* est celle d'origine, la grille n'affichant aucune selection en manuel.
|
||||
*/
|
||||
function choisitExpression(id: string) {
|
||||
if (manual.value.on) manual.value = { ...manual.value, on: false, selected: null }
|
||||
if (id === expression.value) {
|
||||
hero.value?.setPaused(false)
|
||||
clearTimeout(minuteurPhoto)
|
||||
minuteurPhoto = setTimeout(() => hero.value?.freezeNow({ settle: true }), 700)
|
||||
return
|
||||
}
|
||||
expression.value = id
|
||||
}
|
||||
|
||||
async function prendPhoto() {
|
||||
const svg = hero.value?.svg()
|
||||
if (!svg || !photo.value || etatExport.value === 'occupe') return
|
||||
clearTimeout(minuteurPhoto)
|
||||
hero.value?.freezeNow({ settle: true })
|
||||
photoFlash.value++
|
||||
clearTimeout(confirmation)
|
||||
etatExport.value = 'occupe'
|
||||
try {
|
||||
// le viewBox vivant du moment : c'est lui que le cadre affiche.
|
||||
const vue = svg.getAttribute('viewBox') || viewBoxExport()
|
||||
const markup = svgAutonome(svg, 300, vue)
|
||||
const complet = svgPhoto(markup, {
|
||||
fond: photoFond.value,
|
||||
couleurDe: photoCouleurDe.value,
|
||||
couleurA: photoCouleurA.value,
|
||||
angle: photoAngle.value,
|
||||
taille: photoTaille.value,
|
||||
composition: photoComposition.value
|
||||
})
|
||||
const nom = nomFichier(shape.value, expression.value, color.value, photoFormat.value, 'photo')
|
||||
const fichier =
|
||||
photoFormat.value === 'svg'
|
||||
? new Blob([complet], { type: 'image/svg+xml' })
|
||||
: await versPng(complet, photoTaille.value)
|
||||
telecharge(fichier, nom)
|
||||
etatExport.value = 'exporte'
|
||||
} catch {
|
||||
etatExport.value = 'erreur'
|
||||
}
|
||||
confirmation = setTimeout(() => (etatExport.value = 'pret'), CONFIRMATION_MS)
|
||||
}
|
||||
|
||||
const gardeAnim = ref(false)
|
||||
|
||||
watch(view, (now, prev) => {
|
||||
intro.value = false
|
||||
if (photo.value) fermePhoto()
|
||||
if (prev === 'personnaliser' && now !== 'personnaliser') releasePoseFreeze()
|
||||
if (now === 'animations') {
|
||||
gardeAnim.value = true
|
||||
playing.value = true
|
||||
state.value = cycle.value.blocks[block.value]?.state ?? 'idle'
|
||||
return
|
||||
}
|
||||
playing.value = now === 'reglages'
|
||||
state.value = expression.value
|
||||
if (now === 'personnaliser') {
|
||||
void nextTick(() => {
|
||||
hero.value?.flushPerformance()
|
||||
applyPosePlayback()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(preview, (on) => {
|
||||
if (on) {
|
||||
playing.value = true
|
||||
releasePoseFreeze()
|
||||
return
|
||||
}
|
||||
if (view.value === 'personnaliser') void nextTick(applyPosePlayback)
|
||||
})
|
||||
|
||||
watch(posePlay, (on) => {
|
||||
if (view.value !== 'personnaliser') return
|
||||
if (on) playPose()
|
||||
else pausePose(poseStopped.value)
|
||||
})
|
||||
|
||||
let raf = 0
|
||||
let last = 0
|
||||
function tick(ms: number) {
|
||||
raf = requestAnimationFrame(tick)
|
||||
const dt = last ? Math.min((ms - last) / 1000, 0.064) : 0
|
||||
last = ms
|
||||
if (view.value === 'personnaliser' && !preview.value) {
|
||||
const snap = hero.value?.playback()
|
||||
if (snap) {
|
||||
poseIdx.value = snap.eyeIdx
|
||||
poseHold.value = snap.holdMs / 1000
|
||||
poseElapsed.value = Math.max(0, (snap.holdMs - snap.remainMs) / 1000)
|
||||
if (typeof snap.manualMix === 'number') poseMix.value = snap.manualMix
|
||||
}
|
||||
}
|
||||
if (!playing.value || (view.value !== 'animations' && !preview.value)) return
|
||||
const blocs = cycle.value.blocks
|
||||
const cur = blocs[block.value]
|
||||
if (!cur) return
|
||||
elapsed.value += dt
|
||||
if (elapsed.value >= cur.duration) {
|
||||
const next = (block.value + 1) % blocs.length
|
||||
seeking = true
|
||||
block.value = next
|
||||
elapsed.value = 0
|
||||
state.value = blocs[next]!.state
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (photo.value) fermePhoto()
|
||||
else preview.value = false
|
||||
}
|
||||
if (e.code !== 'Space') return
|
||||
if (view.value !== 'animations' && !preview.value && !photo.value) return
|
||||
if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return
|
||||
const node = e.target
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.closest('input, textarea, select, [contenteditable="true"], dialog')
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function onPageHide() {
|
||||
clearTimeout(pending)
|
||||
ecris('cycles', JSON.stringify(cycles.value))
|
||||
}
|
||||
|
||||
const RETARD_ARRIVEE = 400
|
||||
const barreCachee = ref(false)
|
||||
let minuteurBarre: ReturnType<typeof setTimeout> | undefined
|
||||
watch(nue, (encore, avant) => {
|
||||
if (!avant || encore) return
|
||||
barreCachee.value = true
|
||||
clearTimeout(minuteurBarre)
|
||||
minuteurBarre = setTimeout(() => (barreCachee.value = false), RETARD_ARRIVEE)
|
||||
})
|
||||
|
||||
const dialogueCycle = ref(false)
|
||||
const formatCycle = ref<FormatCycle>(FORMAT_CYCLE_DEFAUT)
|
||||
const fondCycle = ref<FondGif>(FOND_GIF_DEFAUT)
|
||||
const avancementCycle = ref<number | null>(null)
|
||||
const erreurCycle = ref(false)
|
||||
let abandonCycle: AbortController | null = null
|
||||
|
||||
async function exporteCycle() {
|
||||
if (avancementCycle.value !== null) return
|
||||
erreurCycle.value = false
|
||||
const controle = new AbortController()
|
||||
abandonCycle = controle
|
||||
const blocs = cycle.value.blocks
|
||||
const format = formatCycle.value
|
||||
const images = cycleImages(totalDuration(blocs), format)
|
||||
const pas = cyclePas(format)
|
||||
const taille = CYCLE_TAILLE[format]
|
||||
const reglages = {
|
||||
shape: shape.value,
|
||||
color: color.value,
|
||||
oeil: oeil.value || undefined,
|
||||
expression: expression.value
|
||||
}
|
||||
const suit = (fait: number, total: number) => (avancementCycle.value = fait / total)
|
||||
avancementCycle.value = 0
|
||||
try {
|
||||
const mp4 = format === 'mp4'
|
||||
const fichier = mp4
|
||||
? await cycleVersMp4(reglages, blocs, taille, images, pas, BLANC, suit, controle.signal)
|
||||
: await cycleVersGif(
|
||||
reglages,
|
||||
blocs,
|
||||
taille,
|
||||
images,
|
||||
pas,
|
||||
couleurDeFond(fondCycle.value),
|
||||
suit,
|
||||
controle.signal
|
||||
)
|
||||
telecharge(fichier, nomFichier(nomDeCycle(cycle.value), '', '', mp4 ? 'mp4' : 'gif'))
|
||||
dialogueCycle.value = false
|
||||
} catch (e) {
|
||||
if (!(e instanceof Abandon)) erreurCycle.value = true
|
||||
} finally {
|
||||
avancementCycle.value = null
|
||||
abandonCycle = null
|
||||
}
|
||||
}
|
||||
|
||||
function annuleCycle() {
|
||||
abandonCycle?.abort()
|
||||
}
|
||||
|
||||
watch(dialogueCycle, (ouverte) => {
|
||||
if (ouverte) erreurCycle.value = false
|
||||
else annuleCycle()
|
||||
})
|
||||
|
||||
watch([view, preview], () => {
|
||||
if (manual.value.selected) manual.value = { ...manual.value, selected: null }
|
||||
if (view.value !== 'animations' || preview.value) annuleCycle()
|
||||
})
|
||||
|
||||
const CONFIRMATION_MS = 1800
|
||||
const etatExport = ref<EtatExport>('pret')
|
||||
let confirmation: ReturnType<typeof setTimeout> | undefined
|
||||
const fondGif = ref<FondGif>(FOND_GIF_DEFAUT)
|
||||
const dialogueGif = ref(false)
|
||||
|
||||
async function exporte(id: ActionId, confirme = false) {
|
||||
if (etatExport.value === 'occupe') return
|
||||
if (!confirme && ACTION_BY_ID.get(id)?.mode === 'gif') {
|
||||
dialogueGif.value = true
|
||||
return
|
||||
}
|
||||
const action = ACTION_BY_ID.get(id)
|
||||
const svg = hero.value?.svg()
|
||||
if (!action || !svg) return
|
||||
clearTimeout(confirmation)
|
||||
etatExport.value = 'occupe'
|
||||
const cote = tailleAction(action)
|
||||
const nom = () =>
|
||||
nomFichier(shape.value, expression.value, color.value, action.extension, action.suffixe)
|
||||
try {
|
||||
if (action.mode === 'anime') {
|
||||
const reglages = {
|
||||
shape: shape.value,
|
||||
color: color.value,
|
||||
oeil: oeil.value || undefined,
|
||||
expression: expression.value
|
||||
}
|
||||
telecharge(await versSvgAnime(reglages, cote, ANIM_IMAGES, ANIM_PAS), nom())
|
||||
etatExport.value = 'exporte'
|
||||
} else if (action.mode === 'gif') {
|
||||
const reglages = {
|
||||
shape: shape.value,
|
||||
color: color.value,
|
||||
oeil: oeil.value || undefined,
|
||||
expression: expression.value
|
||||
}
|
||||
telecharge(
|
||||
await versGifAnime(reglages, action.taille, GIF_IMAGES, GIF_PAS, couleurDeFond(fondGif.value)),
|
||||
nom()
|
||||
)
|
||||
etatExport.value = 'exporte'
|
||||
} else {
|
||||
const markup = svgAutonome(svg, cote)
|
||||
if (action.mode === 'copieImage') {
|
||||
await copie(versPng(markup, cote))
|
||||
etatExport.value = 'copie'
|
||||
} else if (action.mode === 'copieTexte') {
|
||||
await copieTexte(markup)
|
||||
etatExport.value = 'copie'
|
||||
} else {
|
||||
const fichier =
|
||||
action.extension === 'svg'
|
||||
? new Blob([markup], { type: 'image/svg+xml' })
|
||||
: await versPng(markup, cote)
|
||||
telecharge(fichier, nom())
|
||||
etatExport.value = 'exporte'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
etatExport.value = 'erreur'
|
||||
}
|
||||
confirmation = setTimeout(() => (etatExport.value = 'pret'), CONFIRMATION_MS)
|
||||
}
|
||||
|
||||
function onHeroReady(ok: boolean) {
|
||||
ready.value = ok
|
||||
if (ok) void nextTick(applyPosePlayback)
|
||||
if (!intro.value) return
|
||||
if (!ok) {
|
||||
intro.value = false
|
||||
return
|
||||
}
|
||||
hero.value?.orbitGaze(1500)
|
||||
window.setTimeout(() => {
|
||||
intro.value = false
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('pagehide', onPageHide)
|
||||
raf = requestAnimationFrame(tick)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('pagehide', onPageHide)
|
||||
cancelAnimationFrame(raf)
|
||||
clearTimeout(pending)
|
||||
clearTimeout(minuteurBarre)
|
||||
clearTimeout(confirmation)
|
||||
clearTimeout(minuteurPhoto)
|
||||
annuleCycle()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 class="sr-only">{{ t('app.name') }}</h1>
|
||||
<SideRail v-if="!preview" v-model="view" class="rail" :inert="nue || undefined" />
|
||||
|
||||
<button
|
||||
v-if="photo"
|
||||
type="button"
|
||||
class="photo-sortie fixed top-4 right-4 z-30 flex cursor-pointer items-center gap-1.5 rounded-full border border-[var(--line)] bg-white/90 px-3 py-1.5 text-xs text-[var(--muted)] shadow-sm backdrop-blur transition hover:text-[var(--ink)] lg:right-[calc(21.25rem+1rem)]"
|
||||
@click="fermePhoto"
|
||||
>
|
||||
{{ t('photo.exit') }}
|
||||
<kbd class="rounded bg-black/5 px-1 py-0.5 text-[10px]">{{ t('preview.key') }}</kbd>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="preview"
|
||||
type="button"
|
||||
class="fixed top-5 right-5 z-30 flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/80 px-2.5 py-1.5 text-xs text-[var(--muted)] shadow-sm backdrop-blur transition hover:text-[var(--ink)]"
|
||||
@click="preview = false"
|
||||
>
|
||||
{{ t('preview.exit') }}
|
||||
<kbd class="rounded bg-black/5 px-1 py-0.5 text-[10px]">{{ t('preview.key') }}</kbd>
|
||||
</button>
|
||||
|
||||
<aside
|
||||
v-if="photo"
|
||||
class="photo-panneau fixed top-0 right-0 z-20 flex h-dvh w-[21.25rem] flex-col gap-3 overflow-y-auto border-l border-[var(--line)] bg-[var(--paper)] p-4 max-lg:inset-x-0 max-lg:top-auto max-lg:bottom-0 max-lg:h-[46dvh] max-lg:w-auto max-lg:rounded-t-2xl max-lg:border-t"
|
||||
>
|
||||
<PhotoPanel
|
||||
v-model:composition="photoComposition"
|
||||
v-model:fond="photoFond"
|
||||
v-model:couleur-de="photoCouleurDe"
|
||||
v-model:couleur-a="photoCouleurA"
|
||||
v-model:angle="photoAngle"
|
||||
:expression="expression"
|
||||
v-model:format="photoFormat"
|
||||
v-model:taille="photoTaille"
|
||||
v-model:shape="shape"
|
||||
v-model:color="color"
|
||||
v-model:manual="manual"
|
||||
:manuel="manual.on"
|
||||
:active="true"
|
||||
@update:expression="choisitExpression"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
class="scene min-h-full items-stretch justify-center p-8 lg:pr-3 max-lg:flex max-lg:flex-col max-lg:gap-10 max-lg:px-5"
|
||||
:class="[
|
||||
!plein && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]',
|
||||
!plein && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]',
|
||||
!preview && !photo && 'max-lg:pt-20',
|
||||
photo && 'max-lg:pt-40 max-lg:pb-[46dvh]',
|
||||
nue || preview ? 'scene--seule' : photo ? 'scene--photo' : view === 'reglages' && 'scene--gauche'
|
||||
]"
|
||||
>
|
||||
<aside
|
||||
v-if="!plein"
|
||||
class="panneau scene__gauche w-full lg:flex lg:h-[calc(100dvh_-_3rem_-_var(--timeline))] lg:w-80 lg:shrink-0 lg:flex-col lg:justify-center lg:self-start lg:-translate-y-12 lg:pl-14"
|
||||
:class="gauche ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
|
||||
>
|
||||
<Settings :ready="ready" />
|
||||
</aside>
|
||||
|
||||
<main
|
||||
class="scene__avatar relative flex flex-1 items-center justify-center max-lg:order-1 max-lg:flex-col max-lg:gap-4 lg:self-start"
|
||||
:class="
|
||||
plein
|
||||
? 'lg:min-h-[calc(100dvh_-_4rem)]'
|
||||
: 'lg:min-h-[calc(100dvh_-_3rem_-_var(--timeline))]'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="avatar flex aspect-square w-full items-center justify-center"
|
||||
:class="[
|
||||
avatarMax,
|
||||
nue && 'avatar--intro',
|
||||
view === 'reglages' && !plein && 'avatar--geant',
|
||||
view === 'personnaliser' && !plein && !nue && 'avatar--bas'
|
||||
]"
|
||||
>
|
||||
<PhotoFrame
|
||||
v-model:composition="photoComposition"
|
||||
:active="photo"
|
||||
:fond="photoFond"
|
||||
:couleur-de="photoCouleurDe"
|
||||
:couleur-a="photoCouleurA"
|
||||
:angle="photoAngle"
|
||||
:outil="photoOutil"
|
||||
:flash="photoFlash"
|
||||
>
|
||||
<AvatarStage
|
||||
ref="hero"
|
||||
class="h-auto max-w-full"
|
||||
:size="avatarSize"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:paper="oeil || '#f9f9f9'"
|
||||
:state="playedState"
|
||||
:follow="follow"
|
||||
:manual="manual"
|
||||
:tools="(view === 'personnaliser' && !preview && !photo) || photo"
|
||||
:echelle="photo ? photoComposition.scale : 1"
|
||||
:playing="posePlay"
|
||||
:mix="poseMix"
|
||||
:auto-tricks="autoTricks"
|
||||
@ready="onHeroReady"
|
||||
@update:manual="manual = $event"
|
||||
/>
|
||||
</PhotoFrame>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="photo"
|
||||
class="photo-barres absolute inset-x-0 bottom-6 z-30 flex flex-col items-center gap-3 max-lg:fixed max-lg:bottom-auto max-lg:top-20 max-lg:left-1/2 max-lg:-translate-x-1/2 max-lg:scale-90"
|
||||
>
|
||||
<div class="photo-outils flex items-center gap-1 rounded-full bg-white/90 p-1 shadow-sm backdrop-blur">
|
||||
<button
|
||||
v-for="outil in ['cadre', 'pose'] as const"
|
||||
:key="outil"
|
||||
type="button"
|
||||
class="cursor-pointer rounded-full px-3 py-1.5 text-xs transition"
|
||||
:class="
|
||||
photoOutil === outil
|
||||
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
|
||||
: 'text-[var(--muted)] hover:text-[var(--ink)]'
|
||||
"
|
||||
:aria-pressed="photoOutil === outil"
|
||||
@click="choisitOutil(outil)"
|
||||
>
|
||||
{{ t(outil === 'cadre' ? 'photo.toolFrame' : 'photo.toolPose') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-full px-3 py-1.5 text-xs text-[var(--muted)] transition hover:text-[var(--ink)]"
|
||||
@click="recadrePhoto"
|
||||
>
|
||||
{{ t('photo.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="photo-declencheur flex cursor-pointer items-center gap-2 rounded-full px-6 py-3 text-sm font-semibold shadow-lg transition"
|
||||
:class="etatExport === 'occupe' && 'occupe'"
|
||||
:disabled="etatExport === 'occupe'"
|
||||
@click="prendPhoto"
|
||||
>
|
||||
<span class="photo-obturateur" aria-hidden="true" />
|
||||
<span>{{
|
||||
etatExport === 'exporte'
|
||||
? t('export.done')
|
||||
: etatExport === 'erreur'
|
||||
? t('export.failed')
|
||||
: t('photo.take')
|
||||
}}</span>
|
||||
<span class="rounded-full bg-black/15 px-1.5 py-0.5 text-[10px] font-medium uppercase">
|
||||
{{ photoFormat }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="view === 'personnaliser' && !plein"
|
||||
class="barre-export flex justify-center gap-2"
|
||||
:class="(nue || barreCachee) && 'barre-export--cachee'"
|
||||
:inert="nue || barreCachee || undefined"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="photo-entree flex cursor-pointer items-center gap-1.5 rounded-xl border border-[var(--line)] bg-white px-3 py-2 text-sm text-[var(--muted)] shadow-sm transition hover:border-[var(--muted)] hover:text-[var(--ink)]"
|
||||
@click="ouvrePhoto"
|
||||
>
|
||||
<span class="photo-obturateur photo-obturateur--petit" aria-hidden="true" />
|
||||
{{ t('photo.open') }}
|
||||
</button>
|
||||
<ExportBar :etat="etatExport" @exporter="exporte" />
|
||||
</div>
|
||||
|
||||
<CycleDialog
|
||||
v-if="view === 'animations' && !plein"
|
||||
v-model:open="dialogueCycle"
|
||||
v-model:format="formatCycle"
|
||||
v-model:fond="fondCycle"
|
||||
:avancement="avancementCycle"
|
||||
:erreur="erreurCycle"
|
||||
@confirm="exporteCycle"
|
||||
@annuler="annuleCycle"
|
||||
/>
|
||||
|
||||
<GifDialog
|
||||
v-if="view === 'personnaliser' && !plein"
|
||||
v-model:open="dialogueGif"
|
||||
v-model:fond="fondGif"
|
||||
@confirm="exporte('gif', true)"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<aside
|
||||
v-if="!plein"
|
||||
class="panneau scene__droite w-full lg:w-[21.25rem] lg:shrink-0"
|
||||
:class="[
|
||||
droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden',
|
||||
view === 'personnaliser' && !intro && 'playback-host'
|
||||
]"
|
||||
>
|
||||
<Customizer
|
||||
v-show="view === 'personnaliser'"
|
||||
class="playback-pad"
|
||||
v-model:shape="shape"
|
||||
v-model:color="color"
|
||||
v-model:oeil="oeil"
|
||||
v-model:expression="expression"
|
||||
v-model:manual="manual"
|
||||
:ready="ready"
|
||||
:active="view === 'personnaliser'"
|
||||
/>
|
||||
<AnimPanel
|
||||
v-if="gardeAnim"
|
||||
v-show="view === 'animations'"
|
||||
v-model="state"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:ready="ready"
|
||||
:active="view === 'animations'"
|
||||
@pick="addBlock"
|
||||
/>
|
||||
<PlaybackBar
|
||||
v-if="view === 'personnaliser' && !intro"
|
||||
v-model:playing="posePlay"
|
||||
:stopped="poseStopped"
|
||||
:expression="expression"
|
||||
:cursor="poseIdx"
|
||||
:elapsed="poseElapsed"
|
||||
:hold="poseHold"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:active="view === 'personnaliser'"
|
||||
:ready="ready"
|
||||
@seek="onSeekPose"
|
||||
@stop="onStopPose"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Timeline
|
||||
v-if="view === 'animations' && !plein"
|
||||
v-model:cycles="cycles"
|
||||
v-model:active-id="activeId"
|
||||
v-model:block="block"
|
||||
v-model:playing="playing"
|
||||
:elapsed="elapsed"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:expression="expression"
|
||||
@seek="onSeek"
|
||||
@preview="preview = true"
|
||||
@exporter="dialogueCycle = true"
|
||||
/>
|
||||
|
||||
<p v-if="view === 'reglages' && !plein" class="wordmark" aria-hidden="true">STUDY</p>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BotTile from '@/components/BotTile.vue'
|
||||
import { stateName, t } from '@/i18n'
|
||||
import { stateGroups } from '@/replica/catalog'
|
||||
|
||||
const emit = defineEmits<{ pick: [state: string] }>()
|
||||
const current = defineModel<string>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
shape: string
|
||||
color: string
|
||||
ready?: boolean
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const groups = computed(() => {
|
||||
void props.ready
|
||||
return stateGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">{{ t('panel.animations') }}</h2>
|
||||
<section v-for="group in groups" :key="group.key" class="mt-4">
|
||||
<h3 class="text-xs font-medium tracking-wide text-[var(--muted)] uppercase">
|
||||
{{
|
||||
group.key === 'lifecycle'
|
||||
? t('panel.group_lifecycle')
|
||||
: group.key === 'reactions'
|
||||
? t('panel.group_reactions')
|
||||
: group.key === 'agent'
|
||||
? t('panel.group_agent')
|
||||
: t('panel.group_product')
|
||||
}}
|
||||
</h3>
|
||||
<div class="mt-2 grid grid-cols-4 gap-1.5">
|
||||
<BotTile
|
||||
v-for="id in group.states"
|
||||
:key="id"
|
||||
:label="stateName(id)"
|
||||
:selected="id === current"
|
||||
:state="id"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:active="active"
|
||||
@click="emit('pick', id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import Hero from '@/components/Hero.vue'
|
||||
import ManualCanvas, { type HeroHandle } from '@/components/ManualCanvas.vue'
|
||||
import { type EyeTune, type ManualPart, type ManualState } from '@/ui/pose/model'
|
||||
import type { HeadPose } from '@/ui/pose/math'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
shape?: string
|
||||
color?: string
|
||||
state?: string
|
||||
follow?: boolean
|
||||
paper?: string
|
||||
manual?: ManualState | null
|
||||
tools?: boolean
|
||||
playing?: boolean
|
||||
mix?: number
|
||||
echelle?: number
|
||||
autoTricks?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 440,
|
||||
shape: 'blob',
|
||||
color: 'black',
|
||||
state: 'idle',
|
||||
follow: false,
|
||||
paper: '#f9f9f9',
|
||||
manual: null,
|
||||
tools: false,
|
||||
playing: false,
|
||||
mix: 0,
|
||||
echelle: 1,
|
||||
autoTricks: true
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [ok: boolean]
|
||||
'update:manual': [ManualState]
|
||||
}>()
|
||||
|
||||
const hero = ref<InstanceType<typeof Hero> | null>(null)
|
||||
const liveHero = computed<HeroHandle | null>(() => hero.value)
|
||||
const active = computed(
|
||||
() => !!props.manual?.on && props.tools && !props.playing && props.mix > 0.985
|
||||
)
|
||||
|
||||
function patch(next: Partial<ManualState>) {
|
||||
if (!props.manual) return
|
||||
emit('update:manual', { ...props.manual, ...next })
|
||||
}
|
||||
|
||||
function applyEngine() {
|
||||
const bot = hero.value
|
||||
const manual = props.manual
|
||||
if (!bot) return
|
||||
if (!manual?.on || !props.tools) {
|
||||
bot.setManualHold(false)
|
||||
return
|
||||
}
|
||||
bot.setPose(manual.pose)
|
||||
bot.setManualOffset(manual.offset)
|
||||
bot.setEyeTune(manual.eyes)
|
||||
bot.setManualHold(!props.playing)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
if (props.manual?.selected != null) emit('update:manual', { ...props.manual, selected: null })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.tools, props.shape],
|
||||
() => clearSelection()
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.manual?.on,
|
||||
props.manual?.pose,
|
||||
props.manual?.offset,
|
||||
props.manual?.eyes,
|
||||
props.tools,
|
||||
props.playing,
|
||||
hero.value
|
||||
],
|
||||
() => applyEngine(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function onReady(ok: boolean) {
|
||||
emit('ready', ok)
|
||||
if (ok) void nextTick(applyEngine)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSelection()
|
||||
hero.value?.setManualHold(false)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
spin: () => hero.value?.spin(),
|
||||
orbitGaze: (ms?: number) => hero.value?.orbitGaze(ms),
|
||||
svg: () => hero.value?.svg() ?? null,
|
||||
seekEye: (index: number, opts?: { snap?: boolean }) => hero.value?.seekEye(index, opts),
|
||||
flushPerformance: () => hero.value?.flushPerformance(),
|
||||
setPlaylistHold: (on: boolean) => hero.value?.setPlaylistHold(on),
|
||||
setPaused: (on: boolean | 'hold-pose') => hero.value?.setPaused(on),
|
||||
holdFrame: (at?: number) => hero.value?.holdFrame(at),
|
||||
freezeNow: (opts?: { settle?: boolean }) => hero.value?.freezeNow(opts) ?? null,
|
||||
playback: () => hero.value?.playback() ?? null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="avatar-stage relative inline-block aspect-square max-w-full"
|
||||
:style="{ width: `${props.size}px` }"
|
||||
>
|
||||
<Hero
|
||||
ref="hero"
|
||||
class="h-auto max-w-full"
|
||||
:size="size"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:state="state"
|
||||
:follow="follow && !active"
|
||||
:paper="paper"
|
||||
:auto-tricks="autoTricks"
|
||||
@ready="onReady"
|
||||
/>
|
||||
<ManualCanvas
|
||||
v-if="active && manual"
|
||||
:manual="manual"
|
||||
:hero="liveHero"
|
||||
:shape="shape"
|
||||
:echelle="echelle"
|
||||
@update:pose="(pose: HeadPose) => patch({ pose })"
|
||||
@update:selected="(selected: ManualPart | null) => patch({ selected })"
|
||||
@update:eyes="(eyes: EyeTune) => patch({ eyes })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BotTile from '@/components/BotTile.vue'
|
||||
import { catalogue, type StateId } from '@/editor/cycles'
|
||||
import { stateName, t } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Carte « + » de la piste et sa palette. Ajouter depuis la piste evite d'aller
|
||||
* jusqu'au panneau de droite quand on monte.
|
||||
*/
|
||||
defineProps<{ shape: string; color: string; expression: string }>()
|
||||
const emit = defineEmits<{ pick: [state: StateId] }>()
|
||||
|
||||
/** Les animations dans l'ordre de la video. */
|
||||
const PALETTE = catalogue()
|
||||
|
||||
/** Largeur de la palette, en pixels — elle sert aussi a la caler. */
|
||||
const WIDTH = 288
|
||||
|
||||
const trigger = ref<HTMLButtonElement | null>(null)
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const position = ref<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* La palette est-elle ouverte ? Pour `aria-expanded`, que le declencheur doit porter comme
|
||||
* ceux de la barre d'export et du menu de cycles.
|
||||
*
|
||||
* Suivi par l'evenement `toggle` du popover et non par notre propre `toggle()` : la
|
||||
* fermeture LEGERE — clic a cote, Echap — est faite par le navigateur, donc un drapeau que
|
||||
* nous tiendrions nous-memes resterait bloque sur « ouvert ».
|
||||
*/
|
||||
const ouvert = ref(false)
|
||||
|
||||
/**
|
||||
* Ouvre la palette au-dessus du « + ». Elle vit dans la couche superieure du
|
||||
* navigateur, donc sa position se calcule ici, en coordonnees d'ecran, et se
|
||||
* borne pour ne pas sortir par la droite quand le bouton est en bout de piste.
|
||||
*/
|
||||
function toggle() {
|
||||
const bouton = trigger.value
|
||||
const boite = panel.value
|
||||
if (!bouton || !boite) return
|
||||
// au clavier, `Entree` ne declenche pas la fermeture legere (qui ecoute le
|
||||
// pointeur) : sans ce garde on rouvrirait une palette deja ouverte, et
|
||||
// `showPopover` leve une exception dans ce cas
|
||||
if (boite.matches(':popover-open')) {
|
||||
boite.hidePopover()
|
||||
return
|
||||
}
|
||||
const r = bouton.getBoundingClientRect()
|
||||
position.value = {
|
||||
position: 'fixed',
|
||||
// les styles par defaut d'un popover posent `inset: 0` : sans remettre le
|
||||
// haut et la droite a `auto`, le calage est sur-contraint et c'est `top: 0`
|
||||
// qui gagne — la palette se colle en haut de l'ecran
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
left: `${Math.max(8, Math.min(r.right - WIDTH, window.innerWidth - WIDTH - 8))}px`,
|
||||
bottom: `${window.innerHeight - r.top + 8}px`
|
||||
}
|
||||
boite.showPopover()
|
||||
}
|
||||
|
||||
function pick(state: StateId) {
|
||||
emit('pick', state)
|
||||
panel.value?.hidePopover()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="trigger"
|
||||
type="button"
|
||||
class="flex h-full w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-[var(--line)] text-lg leading-none text-[var(--muted)] transition hover:border-[var(--muted)] hover:text-[var(--ink)]"
|
||||
:aria-label="t('timeline.addAnimation')"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="ouvert"
|
||||
@click="toggle"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
<!--
|
||||
`popover` promeut la palette dans la couche superieure du navigateur : c'est
|
||||
ce qui la fait echapper au conteneur de la piste, qui rogne verticalement —
|
||||
elle y etait coupee en deux. En prime, le clic a cote et Echap la referment
|
||||
sans code a nous. `m-0` : un popover est centre par une marge auto, comme
|
||||
une modale.
|
||||
-->
|
||||
<div
|
||||
ref="panel"
|
||||
popover
|
||||
@toggle="ouvert = ($event as ToggleEvent).newState === 'open'"
|
||||
class="m-0 w-72 rounded-xl bg-white p-2 shadow-lg ring-1 ring-black/5"
|
||||
:style="position"
|
||||
>
|
||||
<div class="sans-barre grid max-h-80 grid-cols-4 gap-1.5 overflow-y-auto">
|
||||
<BotTile
|
||||
v-for="id in PALETTE"
|
||||
:key="id"
|
||||
:label="stateName(id)"
|
||||
:selected="false"
|
||||
:state="id"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
@click="pick(id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { paintTile } from '@/replica/host'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
selected: boolean
|
||||
state?: string
|
||||
shape?: string
|
||||
color?: string
|
||||
paper?: string
|
||||
size?: number
|
||||
eye?: number
|
||||
interactive?: boolean
|
||||
showLabel?: boolean
|
||||
active?: boolean
|
||||
}>(),
|
||||
{ interactive: true, showLabel: true, active: true }
|
||||
)
|
||||
|
||||
const svg = ref<SVGSVGElement | null>(null)
|
||||
const painted = ref(false)
|
||||
|
||||
async function paint() {
|
||||
const node = svg.value
|
||||
if (!node) return
|
||||
const mount = await paintTile(node, {
|
||||
state: props.state ?? 'idle',
|
||||
shape: props.shape ?? 'blob',
|
||||
color: props.color ?? 'black',
|
||||
follow: false,
|
||||
paper: props.paper ?? '#f9f9f9',
|
||||
size: props.size ?? 60,
|
||||
eye: props.eye
|
||||
})
|
||||
painted.value = !!mount
|
||||
}
|
||||
|
||||
const dirty = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
void paint()
|
||||
})
|
||||
watch(() => [props.state, props.shape, props.color, props.paper, props.eye], () => {
|
||||
if (!props.active) {
|
||||
dirty.value = true
|
||||
return
|
||||
}
|
||||
void paint()
|
||||
})
|
||||
watch(
|
||||
() => props.active,
|
||||
(on) => {
|
||||
if (on && dirty.value) {
|
||||
dirty.value = false
|
||||
void paint()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="interactive ? 'button' : 'div'"
|
||||
:type="interactive ? 'button' : undefined"
|
||||
class="flex flex-col items-center rounded-xl border-2 p-1 transition"
|
||||
:class="[
|
||||
interactive ? 'cursor-pointer' : '',
|
||||
selected ? 'border-[var(--ink)]' : 'border-transparent',
|
||||
interactive && !selected ? 'hover:border-[var(--line)]' : ''
|
||||
]"
|
||||
:aria-label="interactive ? label : undefined"
|
||||
:aria-pressed="interactive ? selected : undefined"
|
||||
>
|
||||
<svg
|
||||
ref="svg"
|
||||
class="shrink-0 overflow-visible"
|
||||
:width="size ?? 60"
|
||||
:height="size ?? 60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
v-if="showLabel"
|
||||
class="text-center text-xs leading-tight text-[var(--muted)]"
|
||||
>{{ label }}</span
|
||||
>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useModalDialog } from '@/ui/useModalDialog'
|
||||
|
||||
/**
|
||||
* Confirmation d'une action destructrice. Le focus s'ouvre sur « Annuler » :
|
||||
* sur une suppression, la touche Entree ne doit pas detruire. L'animation vient
|
||||
* de `styles.css`, le reste du comportement modal de `useModalDialog`.
|
||||
*/
|
||||
const props = defineProps<{ title: string; detail: string; confirmLabel: string }>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const emit = defineEmits<{ confirm: [] }>()
|
||||
|
||||
const boite = useTemplateRef<HTMLDialogElement>('boite')
|
||||
useModalDialog(open, boite)
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="boite"
|
||||
class="dialogue m-auto w-80 rounded-2xl bg-white p-5 text-[var(--ink)] shadow-xl"
|
||||
:aria-label="props.title"
|
||||
@close="open = false"
|
||||
@cancel.prevent="open = false"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm font-semibold">{{ props.title }}</h2>
|
||||
<p class="text-xs text-[var(--muted)]">{{ props.detail }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
autofocus
|
||||
class="h-8 cursor-pointer rounded-lg px-3 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('dialog.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 cursor-pointer rounded-lg bg-[var(--danger)] px-3 text-xs text-white transition hover:opacity-90 active:scale-95"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ props.confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,482 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import BotTile from '@/components/BotTile.vue'
|
||||
import { shapeName, stateName, t } from '@/i18n'
|
||||
import {
|
||||
COLOR_IDS,
|
||||
EXPRESSION_IDS,
|
||||
colorFill,
|
||||
colorHex,
|
||||
encodeFlat,
|
||||
encodeGrad,
|
||||
encodeRadial,
|
||||
liveShapes,
|
||||
parseInk
|
||||
} from '@/replica/catalog'
|
||||
import { angleDepuisPointeur, PAS_CADRAN, tourAngle } from '@/ui/cadran'
|
||||
import { type ManualState } from '@/ui/pose/model'
|
||||
|
||||
const props = defineProps<{ ready?: boolean; active?: boolean }>()
|
||||
const shape = defineModel<string>('shape', { required: true })
|
||||
const color = defineModel<string>('color', { required: true })
|
||||
const oeil = defineModel<string>('oeil', { required: true })
|
||||
const expression = defineModel<string>('expression', { required: true })
|
||||
const manual = defineModel<ManualState>('manual', { required: true })
|
||||
const shapes = computed(() => {
|
||||
void props.ready
|
||||
return liveShapes()
|
||||
})
|
||||
|
||||
const flatHex = ref('#e02135')
|
||||
const gradFrom = ref('#ff8838')
|
||||
const gradTo = ref('#0e74e0')
|
||||
const gradAngle = ref(135)
|
||||
{
|
||||
const spec = parseInk(color.value)
|
||||
if (spec.kind === 'flat') flatHex.value = spec.hex
|
||||
else if (spec.kind === 'grad') {
|
||||
gradFrom.value = spec.from
|
||||
gradTo.value = spec.to
|
||||
gradAngle.value = spec.angle
|
||||
} else if (spec.kind === 'radial') {
|
||||
gradFrom.value = spec.from
|
||||
gradTo.value = spec.to
|
||||
}
|
||||
}
|
||||
|
||||
const OEIL_DEFAUT = '#f9f9f9'
|
||||
const oeilHex = ref(oeil.value || OEIL_DEFAUT)
|
||||
const PERSO_FOND =
|
||||
'conic-gradient(from 0.5turn, #e02135, #e08600, #009957, #1aa8c4, #0e74e0, #804ee0, #e02a88, #e02135)'
|
||||
|
||||
const activeSpec = computed(() => parseInk(color.value))
|
||||
const estPerso = computed(() => {
|
||||
const spec = activeSpec.value
|
||||
if (spec.kind !== 'flat') return false
|
||||
return COLOR_IDS.every((id) => colorHex(id).toLowerCase() !== spec.hex.toLowerCase())
|
||||
})
|
||||
const apercu = computed(() =>
|
||||
activeSpec.value.kind === 'radial'
|
||||
? `radial-gradient(circle at 50% 42%, ${gradFrom.value}, ${gradTo.value})`
|
||||
: `linear-gradient(${gradAngle.value}deg, ${gradFrom.value}, ${gradTo.value})`
|
||||
)
|
||||
|
||||
function appliqueUni() {
|
||||
color.value = encodeFlat(flatHex.value)
|
||||
}
|
||||
|
||||
function appliqueDegrade() {
|
||||
color.value = encodeGrad(gradFrom.value, gradTo.value, gradAngle.value)
|
||||
}
|
||||
|
||||
function appliqueRadial() {
|
||||
color.value = encodeRadial(gradFrom.value, gradTo.value)
|
||||
}
|
||||
|
||||
function appliqueCourant() {
|
||||
if (activeSpec.value.kind === 'radial') appliqueRadial()
|
||||
else appliqueDegrade()
|
||||
}
|
||||
|
||||
function ensemenceSiPresent() {
|
||||
const spec = activeSpec.value
|
||||
if (spec.kind !== 'preset') return
|
||||
const ink = window.GROK_TABLES?.INK?.[spec.id]
|
||||
if (ink?.lightFrom && ink?.lightTo) {
|
||||
gradFrom.value = ink.lightFrom
|
||||
gradTo.value = ink.lightTo
|
||||
gradAngle.value = 135
|
||||
} else {
|
||||
const hex = colorHex(spec.id)
|
||||
gradFrom.value = hex
|
||||
gradTo.value = hex
|
||||
}
|
||||
}
|
||||
|
||||
function choisitUni() {
|
||||
const spec = activeSpec.value
|
||||
if (spec.kind === 'preset') flatHex.value = colorHex(spec.id)
|
||||
else if (spec.kind === 'grad' || spec.kind === 'radial') flatHex.value = gradFrom.value
|
||||
appliqueUni()
|
||||
}
|
||||
|
||||
function choisitNuancier(id: string) {
|
||||
flatHex.value = colorHex(id)
|
||||
appliqueUni()
|
||||
}
|
||||
|
||||
function saisitPerso(e: Event) {
|
||||
flatHex.value = (e.target as HTMLInputElement).value
|
||||
appliqueUni()
|
||||
}
|
||||
|
||||
function estNuancier(id: string) {
|
||||
const spec = activeSpec.value
|
||||
if (spec.kind === 'preset') return spec.id === id
|
||||
if (spec.kind === 'flat') return colorHex(id).toLowerCase() === spec.hex.toLowerCase()
|
||||
return false
|
||||
}
|
||||
|
||||
function choisitDegrade() {
|
||||
ensemenceSiPresent()
|
||||
appliqueDegrade()
|
||||
}
|
||||
|
||||
function choisitRadial() {
|
||||
ensemenceSiPresent()
|
||||
appliqueRadial()
|
||||
}
|
||||
|
||||
function choisitAngle(a: number) {
|
||||
gradAngle.value = tourAngle(a)
|
||||
appliqueDegrade()
|
||||
}
|
||||
|
||||
function cadranPointe(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
choisitAngle(angleDepuisPointeur(el, e))
|
||||
}
|
||||
|
||||
function cadranGlisse(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (!el.hasPointerCapture(e.pointerId)) return
|
||||
choisitAngle(angleDepuisPointeur(el, e))
|
||||
}
|
||||
|
||||
function cadranClavier(e: KeyboardEvent) {
|
||||
const pas = PAS_CADRAN[e.key]
|
||||
if (pas == null) return
|
||||
e.preventDefault()
|
||||
choisitAngle(gradAngle.value + pas)
|
||||
}
|
||||
|
||||
function echangeDegrade() {
|
||||
;[gradFrom.value, gradTo.value] = [gradTo.value, gradFrom.value]
|
||||
appliqueCourant()
|
||||
}
|
||||
|
||||
function saisit(cible: 'from' | 'to' | 'oeil', e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (cible === 'from') {
|
||||
gradFrom.value = v
|
||||
appliqueCourant()
|
||||
} else if (cible === 'to') {
|
||||
gradTo.value = v
|
||||
appliqueCourant()
|
||||
} else {
|
||||
oeilHex.value = v
|
||||
oeil.value = v
|
||||
}
|
||||
}
|
||||
|
||||
function reinitialiseOeil() {
|
||||
oeil.value = ''
|
||||
oeilHex.value = OEIL_DEFAUT
|
||||
}
|
||||
|
||||
function patch(next: Partial<ManualState>) {
|
||||
manual.value = { ...manual.value, ...next }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2
|
||||
class="text-sm font-semibold transition"
|
||||
:class="!manual.on && 'text-[var(--muted)]'"
|
||||
>
|
||||
{{ t('manual.title') }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-5 w-9 shrink-0 cursor-pointer rounded-full transition"
|
||||
:class="manual.on ? 'bg-[var(--ink)]' : 'bg-[var(--line)] hover:bg-[var(--muted)]'"
|
||||
:aria-label="t('manual.title')"
|
||||
:aria-pressed="manual.on"
|
||||
@click="patch({ on: !manual.on, selected: !manual.on ? 'body' : null })"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition"
|
||||
:class="manual.on && 'translate-x-4'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-5 text-sm font-semibold">{{ t('panel.shape') }}</h2>
|
||||
<div class="mt-2 grid grid-cols-4 gap-1.5">
|
||||
<BotTile
|
||||
v-for="id in shapes"
|
||||
:key="id"
|
||||
:label="shapeName(id)"
|
||||
:selected="id === shape"
|
||||
:shape="id"
|
||||
:color="color"
|
||||
:paper="oeil || '#f9f9f9'"
|
||||
:state="expression"
|
||||
:active="active"
|
||||
@click="shape = id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-5 text-sm font-semibold">{{ t('panel.expression') }}</h2>
|
||||
<div class="mt-2 grid grid-cols-4 gap-1.5">
|
||||
<BotTile
|
||||
v-for="id in EXPRESSION_IDS"
|
||||
:key="id"
|
||||
:label="stateName(id)"
|
||||
:selected="id === expression"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:paper="oeil || '#f9f9f9'"
|
||||
:state="id"
|
||||
:active="active"
|
||||
@click="expression = id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-5 text-sm font-semibold">{{ t('panel.color') }}</h2>
|
||||
<div class="mt-2 grid grid-cols-6 gap-1.5">
|
||||
<button
|
||||
v-for="id in COLOR_IDS"
|
||||
:key="id"
|
||||
type="button"
|
||||
class="flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
|
||||
:class="
|
||||
estNuancier(id) ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
|
||||
"
|
||||
:aria-label="id"
|
||||
:aria-pressed="estNuancier(id)"
|
||||
:title="id"
|
||||
@click="choisitNuancier(id)"
|
||||
>
|
||||
<span
|
||||
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
|
||||
:style="{ background: colorFill(id) }"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="relative flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
|
||||
:class="
|
||||
estPerso ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
|
||||
"
|
||||
:title="t('panel.custom')"
|
||||
>
|
||||
<span
|
||||
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
|
||||
:style="{ background: estPerso ? flatHex : PERSO_FOND }"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
:value="flatHex"
|
||||
:aria-label="t('panel.custom')"
|
||||
@input="saisitPerso"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex rounded-full bg-black/[0.04] p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-full px-1 py-1 text-xs whitespace-nowrap transition"
|
||||
:class="
|
||||
activeSpec.kind === 'flat'
|
||||
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
|
||||
: 'text-[var(--muted)] hover:text-[var(--ink)]'
|
||||
"
|
||||
:aria-pressed="activeSpec.kind === 'flat'"
|
||||
@click="choisitUni"
|
||||
>
|
||||
{{ t('panel.flat') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-full px-1 py-1 text-xs whitespace-nowrap transition"
|
||||
:class="
|
||||
activeSpec.kind === 'grad'
|
||||
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
|
||||
: 'text-[var(--muted)] hover:text-[var(--ink)]'
|
||||
"
|
||||
:aria-pressed="activeSpec.kind === 'grad'"
|
||||
@click="choisitDegrade"
|
||||
>
|
||||
{{ t('panel.linear') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-full px-1 py-1 text-xs whitespace-nowrap transition"
|
||||
:class="
|
||||
activeSpec.kind === 'radial'
|
||||
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
|
||||
: 'text-[var(--muted)] hover:text-[var(--ink)]'
|
||||
"
|
||||
:aria-pressed="activeSpec.kind === 'radial'"
|
||||
@click="choisitRadial"
|
||||
>
|
||||
{{ t('panel.radial') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeSpec.kind === 'grad' || activeSpec.kind === 'radial'"
|
||||
class="mt-2 flex items-center gap-2"
|
||||
>
|
||||
<div class="teinte-barre min-w-0 flex-1" :style="{ background: apercu }">
|
||||
<label
|
||||
v-if="activeSpec.kind === 'grad'"
|
||||
class="photo-puce"
|
||||
:title="gradFrom.toUpperCase()"
|
||||
:style="{ background: gradFrom }"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="gradFrom"
|
||||
:aria-label="t('panel.gradFrom')"
|
||||
@input="saisit('from', $event)"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="activeSpec.kind === 'grad'"
|
||||
type="button"
|
||||
class="absolute top-1/2 left-1/2 z-10 grid h-6 w-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-white text-[var(--muted)] shadow-sm ring-1 ring-black/10 transition hover:text-[var(--ink)]"
|
||||
:aria-label="t('panel.swap')"
|
||||
:title="t('panel.swap')"
|
||||
@click="echangeDegrade"
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3 4 7l4 4" />
|
||||
<path d="M4 7h16" />
|
||||
<path d="m16 21 4-4-4-4" />
|
||||
<path d="M20 17H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<label
|
||||
v-if="activeSpec.kind === 'radial'"
|
||||
class="photo-puce photo-puce--coeur"
|
||||
:title="gradFrom.toUpperCase()"
|
||||
:style="{ background: gradFrom }"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="gradFrom"
|
||||
:aria-label="t('panel.gradInner')"
|
||||
@input="saisit('from', $event)"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
class="photo-puce photo-puce--fin"
|
||||
:title="gradTo.toUpperCase()"
|
||||
:style="{ background: gradTo }"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="gradTo"
|
||||
:aria-label="activeSpec.kind === 'radial' ? t('panel.gradOuter') : t('panel.gradTo')"
|
||||
@input="saisit('to', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
v-if="activeSpec.kind === 'grad'"
|
||||
type="button"
|
||||
class="teinte-cadran"
|
||||
:style="{ background: apercu }"
|
||||
:aria-label="t('panel.angle')"
|
||||
:title="`${gradAngle}°`"
|
||||
role="slider"
|
||||
:aria-valuenow="gradAngle"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="359"
|
||||
@pointerdown="cadranPointe"
|
||||
@pointermove="cadranGlisse"
|
||||
@keydown="cadranClavier"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0"
|
||||
:style="{ transform: `rotate(${gradAngle}deg)` }"
|
||||
>
|
||||
<i class="teinte-cadran-aiguille" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="grid h-9 w-9 shrink-0 cursor-pointer place-items-center rounded-full bg-white text-[var(--muted)] shadow-sm ring-1 ring-black/10 transition hover:text-[var(--ink)]"
|
||||
:aria-label="t('panel.swapRadial')"
|
||||
:title="t('panel.swapRadial')"
|
||||
@click="echangeDegrade"
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3 4 7l4 4" />
|
||||
<path d="M4 7h16" />
|
||||
<path d="m16 21 4-4-4-4" />
|
||||
<path d="M20 17H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center gap-1.5">
|
||||
<h2 class="flex-1 text-sm font-semibold">{{ t('panel.eye') }}</h2>
|
||||
<button
|
||||
v-if="oeil"
|
||||
type="button"
|
||||
class="grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded-full text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||
:aria-label="t('panel.eyeReset')"
|
||||
:title="t('panel.eyeReset')"
|
||||
@click="reinitialiseOeil"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
<label
|
||||
class="teinte-oeil"
|
||||
:class="oeil && 'est-teint'"
|
||||
:title="oeilHex.toUpperCase()"
|
||||
>
|
||||
<span class="teinte-oeil-blanc" :style="{ background: oeilHex }" />
|
||||
<span class="teinte-oeil-pupille" />
|
||||
<input
|
||||
type="color"
|
||||
:value="oeilHex"
|
||||
:aria-label="t('panel.eye')"
|
||||
@input="saisit('oeil', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
FONDS_GIF,
|
||||
FORMATS_CYCLE,
|
||||
cycleAccepteTransparence,
|
||||
videoPossible,
|
||||
type FondGif,
|
||||
type FormatCycle
|
||||
} from '@/ui/export'
|
||||
import { useModalDialog } from '@/ui/useModalDialog'
|
||||
|
||||
/**
|
||||
* Choix du format avant d'exporter le montage.
|
||||
*
|
||||
* Le groupe « fond » n'apparait QUE pour le GIF, au lieu d'etre affiche grise :
|
||||
* la video n'a pas de canal alpha du tout, donc il n'y a pas de choix a refuser,
|
||||
* il n'y a pas de choix.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
avancement: number | null
|
||||
/** true = le dernier essai a echoue. La boite le dit et propose de reessayer. */
|
||||
erreur: boolean
|
||||
}>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const format = defineModel<FormatCycle>('format', { required: true })
|
||||
const fond = defineModel<FondGif>('fond', { required: true })
|
||||
const emit = defineEmits<{ confirm: []; annuler: [] }>()
|
||||
|
||||
const boite = useTemplateRef<HTMLDialogElement>('boite')
|
||||
useModalDialog(open, boite)
|
||||
|
||||
/**
|
||||
* La video n'est proposee que la ou le navigateur sait encoder — meme regle que
|
||||
* la copie d'image dans la barre d'export. Sans ce filtre, choisir MP4 sur un
|
||||
* navigateur sans `VideoEncoder` echouait sur l'erreur generique.
|
||||
*
|
||||
* Le garde vient de `export.ts` et SURTOUT PAS de `video.ts` : l'importer d'ici
|
||||
* suffirait a ramener mediabunny dans le chunk d'entree (voir `videoPossible`).
|
||||
*/
|
||||
const formats = FORMATS_CYCLE.filter((f) => f !== 'mp4' || videoPossible())
|
||||
|
||||
/*
|
||||
* Le modele est corrige, pas seulement la liste. Le defaut est `mp4`, donc sur un
|
||||
* navigateur sans `VideoEncoder` la boite n'affichait qu'une radio « GIF » et elle etait
|
||||
* DECOCHEE : l'option etait cachee mais la valeur, elle, gagnait, et l'export partait
|
||||
* quand meme sur l'encodeur video pour echouer aussitot. Cacher un choix ne suffit pas, il
|
||||
* faut aussi ne plus le porter.
|
||||
*/
|
||||
if (!formats.includes(format.value)) format.value = formats[0]!
|
||||
|
||||
const occupe = computed(() => props.avancement !== null)
|
||||
const pourcent = computed(() => Math.round((props.avancement ?? 0) * 100))
|
||||
|
||||
function confirm() {
|
||||
// La boite reste ouverte pendant l'encodage : c'est elle qui porte la
|
||||
// progression, et un cycle de trente secondes ne s'exporte pas instantanement.
|
||||
if (!occupe.value) emit('confirm')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fermer, c'est ABANDONNER quand l'encodage tourne.
|
||||
*
|
||||
* Sans ca, Echap ou le bouton refermaient la boite pendant que l'export continuait
|
||||
* jusqu'au bout et declenchait le telechargement d'un fichier que plus personne
|
||||
* n'attendait.
|
||||
*/
|
||||
function ferme() {
|
||||
if (occupe.value) emit('annuler')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="boite"
|
||||
class="dialogue m-auto w-80 rounded-2xl bg-white p-5 text-[var(--ink)] shadow-xl"
|
||||
:aria-label="t('timeline.export')"
|
||||
@close="open = false"
|
||||
@cancel.prevent="ferme"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="confirm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm font-semibold">{{ t('timeline.export') }}</h2>
|
||||
<p class="text-xs text-[var(--muted)]">{{ t('export.cycleDetail') }}</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="flex flex-col gap-1" :disabled="occupe">
|
||||
<legend class="sr-only">{{ t('export.cycleFormat') }}</legend>
|
||||
<label
|
||||
v-for="(choix, i) in formats"
|
||||
:key="choix"
|
||||
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition hover:bg-black/5"
|
||||
>
|
||||
<input
|
||||
v-model="format"
|
||||
type="radio"
|
||||
name="format"
|
||||
:value="choix"
|
||||
:autofocus="i === 0"
|
||||
class="accent-[var(--ink)]"
|
||||
/>
|
||||
<span class="flex flex-col">
|
||||
{{ t(`export.cycle_${choix}`) }}
|
||||
<span class="text-xs text-[var(--muted)]">{{ t(`export.cycle_${choix}_aide`) }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<!-- seul le GIF a un alpha a offrir : cf. la doc du composant -->
|
||||
<fieldset
|
||||
v-if="cycleAccepteTransparence(format)"
|
||||
class="flex flex-col gap-1"
|
||||
:disabled="occupe"
|
||||
>
|
||||
<legend class="sr-only">{{ t('export.gifBackground') }}</legend>
|
||||
<label
|
||||
v-for="choix in FONDS_GIF"
|
||||
:key="choix"
|
||||
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition hover:bg-black/5"
|
||||
>
|
||||
<input
|
||||
v-model="fond"
|
||||
type="radio"
|
||||
name="fondCycle"
|
||||
:value="choix"
|
||||
class="accent-[var(--ink)]"
|
||||
/>
|
||||
{{ t(`export.fond_${choix}`) }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<!-- la progression remplace les boutons : rien d'autre a faire qu'attendre -->
|
||||
<!-- l'echec s'affiche ICI et pas dans la barre d'export : celle-la n'est rendue
|
||||
que dans la vue Personnaliser, alors que cette boite vit dans les Animations,
|
||||
donc le message partait dans un composant absent de l'ecran -->
|
||||
<p v-if="erreur && !occupe" class="text-xs text-[var(--danger)]" role="alert">
|
||||
{{ t('export.failed') }}
|
||||
</p>
|
||||
|
||||
<div v-if="occupe" class="flex flex-col gap-1.5">
|
||||
<!--
|
||||
Pas de `transition` sur la largeur : une transition sur `width` passe par
|
||||
le layout, donc par le thread principal — que l'encodage sature. La barre
|
||||
restait figee sur sa premiere valeur pendant que le pourcentage, lui,
|
||||
avancait. Et elle n'apporte rien : la valeur change des centaines de fois.
|
||||
-->
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-black/10">
|
||||
<div class="h-full rounded-full bg-[var(--ink)]" :style="{ width: `${pourcent}%` }" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-xs tabular-nums text-[var(--muted)]">
|
||||
{{ t('export.cycleProgress') }} {{ pourcent }} %
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 cursor-pointer rounded-lg px-2 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||
@click="ferme"
|
||||
>
|
||||
{{ t('dialog.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 cursor-pointer rounded-lg px-3 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||
@click="ferme"
|
||||
>
|
||||
{{ t('dialog.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="h-8 cursor-pointer rounded-lg bg-[var(--ink)] px-3 text-xs text-[var(--paper)] transition hover:opacity-90 active:scale-95"
|
||||
>
|
||||
{{ erreur ? t('export.cycleReessayer') : t('export.gifConfirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { MAX_CYCLES, type Cycle } from '@/editor/cycles'
|
||||
import { nomDeCycle, t } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Choix du montage. Un `<select>` natif ne peut pas porter la ligne "Nouveau"
|
||||
* ni la suppression d'une entree, d'ou ce menu — et il s'ouvre vers le haut,
|
||||
* la barre etant en bas de l'ecran.
|
||||
*/
|
||||
const props = defineProps<{ cycles: Cycle[]; current: Cycle }>()
|
||||
const activeId = defineModel<string>('activeId', { required: true })
|
||||
const emit = defineEmits<{ create: []; remove: [id: string]; rename: [id: string] }>()
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
function choose(id: string) {
|
||||
activeId.value = id
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function create() {
|
||||
if (props.cycles.length >= MAX_CYCLES) return
|
||||
open.value = false
|
||||
emit('create')
|
||||
}
|
||||
|
||||
function onOutside(e: PointerEvent) {
|
||||
if (!root.value?.contains(e.target as Node)) open.value = false
|
||||
}
|
||||
|
||||
watch(open, (on) => {
|
||||
if (on) window.addEventListener('pointerdown', onOutside)
|
||||
else window.removeEventListener('pointerdown', onOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('pointerdown', onOutside))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
`text-left` sur le declencheur : les navigateurs centrent le texte des
|
||||
boutons. Ca ne se voyait pas avec l'ancienne troncature, qui remplissait
|
||||
toute la boite ; la troncature au mot laisse une ligne plus courte, et le
|
||||
nom se retrouvait au milieu.
|
||||
-->
|
||||
<div ref="root" class="relative" @keydown.esc="open = false">
|
||||
<button
|
||||
type="button"
|
||||
class="flex max-w-56 cursor-pointer items-center gap-1.5 rounded-lg px-2 py-1 text-left text-sm font-medium transition hover:bg-black/5"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="open"
|
||||
:title="nomDeCycle(current)"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="tronque">{{ nomDeCycle(current) }}</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
aria-hidden="true"
|
||||
class="shrink-0 text-[var(--muted)]"
|
||||
>
|
||||
<path
|
||||
d="M2 3.5 5 6.5l3-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute bottom-full left-0 z-10 mb-2 w-56 rounded-xl bg-white p-1 shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
<div
|
||||
v-for="c in cycles"
|
||||
:key="c.id"
|
||||
class="group/row flex items-center gap-1"
|
||||
>
|
||||
<!-- `min-w-0` sur le bouton : sans lui, un nom long pousse la ligne au
|
||||
lieu d'etre coupe, et deborde du menu avec ses deux actions -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition hover:bg-black/5"
|
||||
:title="nomDeCycle(c)"
|
||||
@click="choose(c.id)"
|
||||
>
|
||||
<span class="w-3 shrink-0 text-[var(--ink)]">{{ c.id === activeId ? '✓' : '' }}</span>
|
||||
<span class="tronque">{{ nomDeCycle(c) }}</span>
|
||||
</button>
|
||||
<!-- renommer et supprimer vivent ici : la barre n'a pas a porter deux
|
||||
boutons de plus pour une action qu'on fait une fois -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-[var(--muted)] opacity-0 transition group-hover/row:opacity-100 hover:bg-black/5 hover:text-[var(--ink)] focus-visible:opacity-100"
|
||||
:aria-label="t('cycles.menuRenameAria', { name: nomDeCycle(c) })"
|
||||
@click="((open = false), emit('rename', c.id))"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path
|
||||
d="M8.2 1.8 10.2 3.8 4.4 9.6 1.8 10.2 2.4 7.6z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.1"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- le dernier montage ne se supprime pas : il n'y aurait plus rien a jouer -->
|
||||
<button
|
||||
v-if="cycles.length > 1"
|
||||
type="button"
|
||||
class="mr-1 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-[var(--muted)] opacity-0 transition group-hover/row:opacity-100 hover:bg-black/5 hover:text-[var(--danger)] focus-visible:opacity-100"
|
||||
:aria-label="t('cycles.menuRemoveAria', { name: nomDeCycle(c) })"
|
||||
@click="((open = false), emit('remove', c.id))"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path
|
||||
d="M2.8 3.9h8.4M5.5 3.9V2.7h3v1.2M4.1 3.9l.5 7.4h4.8l.5-7.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="my-1 h-px bg-[var(--line)]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="cycles.length >= MAX_CYCLES"
|
||||
@click="create"
|
||||
>
|
||||
<span class="w-3 shrink-0 text-[var(--muted)]">+</span>
|
||||
{{ t('cycles.menuNew') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { copiePossible } from '@/ui/capture'
|
||||
import { ACTIONS, ACTION_DEFAUT, type ActionId, type EtatExport } from '@/ui/export'
|
||||
|
||||
/**
|
||||
* Barre d'export de l'avatar : un bouton pour le format courant, et une flèche
|
||||
* accolee pour les autres. Purement presentationnel, comme `Customizer.vue` —
|
||||
* c'est `App.vue` qui possede le SVG a capturer, donc qui fait le travail.
|
||||
*
|
||||
* Le menu s'ouvre vers le HAUT : la barre est en bas de la colonne de l'avatar,
|
||||
* un menu vers le bas sortirait de la fenetre sur un ecran court.
|
||||
*/
|
||||
const props = defineProps<{ etat: EtatExport }>()
|
||||
const emit = defineEmits<{ exporter: [ActionId] }>()
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
/**
|
||||
* La copie d'IMAGE n'est pas proposee la ou le navigateur ne sait pas en ecrire.
|
||||
* La copie du SVG, elle, passe par `writeText` et marche partout.
|
||||
*/
|
||||
const actions = ACTIONS.filter((a) => a.mode !== 'copieImage' || copiePossible())
|
||||
|
||||
const occupe = computed(() => props.etat === 'occupe')
|
||||
|
||||
/** Le bouton principal rend compte de ce qui vient de se passer. */
|
||||
const libelle = computed(() => {
|
||||
if (props.etat === 'exporte') return t('export.done')
|
||||
if (props.etat === 'copie') return t('export.copied')
|
||||
if (props.etat === 'erreur') return t('export.failed')
|
||||
return t('export.action')
|
||||
})
|
||||
|
||||
const confirme = computed(() => props.etat === 'exporte' || props.etat === 'copie')
|
||||
|
||||
function lance(id: ActionId) {
|
||||
open.value = false
|
||||
emit('exporter', id)
|
||||
}
|
||||
|
||||
function onOutside(e: PointerEvent) {
|
||||
if (!root.value?.contains(e.target as Node)) open.value = false
|
||||
}
|
||||
|
||||
watch(open, (on) => {
|
||||
if (on) window.addEventListener('pointerdown', onOutside)
|
||||
else window.removeEventListener('pointerdown', onOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('pointerdown', onOutside))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative" @keydown.esc="open = false">
|
||||
<div
|
||||
class="flex overflow-hidden rounded-xl bg-[var(--ink)] text-[var(--paper)] shadow-sm transition"
|
||||
:class="occupe && 'opacity-60'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-2 py-2.5 pr-3 pl-3.5 text-sm font-medium transition hover:bg-white/10 disabled:cursor-default"
|
||||
:disabled="occupe"
|
||||
@click="lance(ACTION_DEFAUT)"
|
||||
>
|
||||
<!-- solar:check-circle-linear -->
|
||||
<svg
|
||||
v-if="confirme"
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.5 12.5L10.5 14.5L15.5 9.5" />
|
||||
</g>
|
||||
</svg>
|
||||
<!-- solar:download-minimalistic-linear -->
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M3 15C3 17.8284 3 19.2426 3.87868 20.1213C4.75736 21 6.17157 21 9 21H15C17.8284 21 19.2426 21 20.1213 20.1213C21 19.2426 21 17.8284 21 15"
|
||||
/>
|
||||
<path d="M12 3V16M8 11.625L12 16L16 11.625" />
|
||||
</g>
|
||||
</svg>
|
||||
{{ libelle }}
|
||||
</button>
|
||||
|
||||
<!-- pleine hauteur : le conteneur est en `flex`, donc l'etirement suffit,
|
||||
sans marge verticale qui la ferait paraitre flottante -->
|
||||
<div class="w-px self-stretch bg-current opacity-25"></div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center px-2.5 transition hover:bg-white/10 disabled:cursor-default"
|
||||
:disabled="occupe"
|
||||
:aria-label="t('export.more')"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="open"
|
||||
@click="open = !open"
|
||||
>
|
||||
<!-- solar:alt-arrow-down-linear -->
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
class="transition-transform"
|
||||
:class="open && 'rotate-180'"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 9L12 15L5 9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute right-0 bottom-full z-10 mb-2 w-60 rounded-xl border border-[var(--line)] bg-[var(--paper)] p-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.id"
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm transition hover:bg-black/5"
|
||||
:class="action.id === 'copie' && 'mt-1 border-t border-[var(--line)] pt-2.5'"
|
||||
@click="lance(action.id)"
|
||||
>
|
||||
<!-- solar:copy-linear -->
|
||||
<svg
|
||||
v-if="action.mode === 'copieImage' || action.mode === 'copieTexte'"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
class="shrink-0 text-[var(--muted)]"
|
||||
>
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
d="M6 11C6 8.17157 6 6.75736 6.87868 5.87868C7.75736 5 9.17157 5 12 5H15C17.8284 5 19.2426 5 20.1213 5.87868C21 6.75736 21 8.17157 21 11V16C21 18.8284 21 20.2426 20.1213 21.1213C19.2426 22 17.8284 22 15 22H12C9.17157 22 7.75736 22 6.87868 21.1213C6 20.2426 6 18.8284 6 16V11Z"
|
||||
/>
|
||||
<path
|
||||
d="M6 19C4.34315 19 3 17.6569 3 16V10C3 6.22876 3 4.34315 4.17157 3.17157C5.34315 2 7.22876 2 11 2H15C16.6569 2 18 3.34315 18 5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<!-- solar:download-minimalistic-linear — les deux telechargements
|
||||
portent la meme icone : ce qui les distingue est le format, dit par
|
||||
le libelle, pas la nature de l'action -->
|
||||
<svg
|
||||
v-else
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
class="shrink-0 text-[var(--muted)]"
|
||||
>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M3 15C3 17.8284 3 19.2426 3.87868 20.1213C4.75736 21 6.17157 21 9 21H15C17.8284 21 19.2426 21 20.1213 20.1213C21 19.2426 21 17.8284 21 15"
|
||||
/>
|
||||
<path d="M12 3V16M8 11.625L12 16L16 11.625" />
|
||||
</g>
|
||||
</svg>
|
||||
{{ t(`export.${action.id}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { FONDS_GIF, type FondGif } from '@/ui/export'
|
||||
import { useModalDialog } from '@/ui/useModalDialog'
|
||||
|
||||
/**
|
||||
* Choix du fond avant de telecharger le GIF.
|
||||
*
|
||||
* Ce format est le seul a poser la question : sa transparence n'a qu'un bit, donc
|
||||
* son bord transparent est dur et se voit. Le fond plein le lisse, en echange
|
||||
* d'une couleur cuite dans l'image — aucun des deux ne gagne dans tous les cas,
|
||||
* d'ou le choix laisse a l'utilisateur.
|
||||
*
|
||||
* De vrais `<input type="radio">` et non des boutons : le navigateur donne le
|
||||
* groupe, la navigation aux fleches et l'annonce « 1 sur 2 » au lecteur d'ecran.
|
||||
* Le comportement modal vient de `useModalDialog`, l'animation de `styles.css`.
|
||||
*/
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const fond = defineModel<FondGif>('fond', { required: true })
|
||||
const emit = defineEmits<{ confirm: [] }>()
|
||||
|
||||
const boite = useTemplateRef<HTMLDialogElement>('boite')
|
||||
useModalDialog(open, boite)
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="boite"
|
||||
class="dialogue m-auto w-80 rounded-2xl bg-white p-5 text-[var(--ink)] shadow-xl"
|
||||
:aria-label="t('export.gifTitle')"
|
||||
@close="open = false"
|
||||
@cancel.prevent="open = false"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="confirm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm font-semibold">{{ t('export.gifTitle') }}</h2>
|
||||
<p class="text-xs text-[var(--muted)]">{{ t('export.gifDetail') }}</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="flex flex-col gap-1">
|
||||
<legend class="sr-only">{{ t('export.gifBackground') }}</legend>
|
||||
<label
|
||||
v-for="(choix, i) in FONDS_GIF"
|
||||
:key="choix"
|
||||
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition hover:bg-black/5"
|
||||
>
|
||||
<input
|
||||
v-model="fond"
|
||||
type="radio"
|
||||
name="fond"
|
||||
:value="choix"
|
||||
:autofocus="i === 0"
|
||||
class="accent-[var(--ink)]"
|
||||
/>
|
||||
<span class="flex flex-col">
|
||||
{{ t(`export.fond_${choix}`) }}
|
||||
<span class="text-xs text-[var(--muted)]">{{ t(`export.fond_${choix}_aide`) }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 cursor-pointer rounded-lg px-3 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('dialog.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="h-8 cursor-pointer rounded-lg bg-[var(--ink)] px-3 text-xs text-[var(--paper)] transition hover:opacity-90 active:scale-95"
|
||||
>
|
||||
{{ t('export.gifConfirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { mountReplica, type ReplicaMount } from '@/replica/host'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
shape?: string
|
||||
color?: string
|
||||
state?: string
|
||||
follow?: boolean
|
||||
paper?: string
|
||||
autoTricks?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 440,
|
||||
shape: 'blob',
|
||||
color: 'black',
|
||||
state: 'idle',
|
||||
follow: false,
|
||||
paper: '#f9f9f9',
|
||||
autoTricks: true
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ ready: [ok: boolean] }>()
|
||||
|
||||
const svg = ref<SVGSVGElement | null>(null)
|
||||
const live = ref(false)
|
||||
const tried = ref(false)
|
||||
let replica: ReplicaMount | null = null
|
||||
let alive = true
|
||||
|
||||
function input() {
|
||||
return {
|
||||
state: props.state,
|
||||
shape: props.shape,
|
||||
color: props.color,
|
||||
follow: props.follow,
|
||||
paper: props.paper,
|
||||
size: props.size,
|
||||
autoTricks: props.autoTricks
|
||||
}
|
||||
}
|
||||
|
||||
async function attach() {
|
||||
const node = svg.value
|
||||
if (!node) return
|
||||
const mount = await mountReplica(node, input(), true)
|
||||
if (!alive) {
|
||||
mount?.destroy()
|
||||
return
|
||||
}
|
||||
replica = mount
|
||||
live.value = !!replica
|
||||
tried.value = true
|
||||
replica?.apply(input())
|
||||
emit('ready', live.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void attach()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
alive = false
|
||||
replica?.destroy()
|
||||
replica = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.state, props.shape, props.color, props.follow, props.paper, props.size, props.autoTricks],
|
||||
() => replica?.apply(input())
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
spin: () => replica?.spin(),
|
||||
orbitGaze: (ms?: number) => replica?.orbitGaze(ms),
|
||||
svg: () => svg.value,
|
||||
setPose: (pose: { turn?: number; tilt?: number; roll?: number }) => replica?.setPose(pose),
|
||||
setManualOffset: (offset: { tx?: number; ty?: number; spin?: number }) =>
|
||||
replica?.setManualOffset(offset),
|
||||
setEyeTune: (tune: unknown) => replica?.setEyeTune(tune),
|
||||
setManualHold: (on: boolean) => replica?.setManualHold(on),
|
||||
flushPerformance: () => replica?.flushPerformance(),
|
||||
seekEye: (index: number, opts?: { snap?: boolean }) => replica?.seekEye(index, opts),
|
||||
setPlaylistHold: (on: boolean) => replica?.setPlaylistHold(on),
|
||||
setPaused: (on: boolean | 'hold-pose') => replica?.setPaused(on),
|
||||
holdFrame: (at?: number) => replica?.holdFrame(at),
|
||||
freezeNow: (opts?: { settle?: boolean }) => replica?.freezeNow(opts) ?? null,
|
||||
playback: () => replica?.playback() ?? null,
|
||||
parts: () => replica?.parts() ?? null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative inline-block aspect-square max-w-full" :style="{ width: `${props.size}px` }">
|
||||
<svg
|
||||
ref="svg"
|
||||
class="absolute inset-0 h-full w-full max-w-full max-h-full overflow-visible"
|
||||
:width="props.size"
|
||||
:height="props.size"
|
||||
role="img"
|
||||
:aria-label="t('app.botAria')"
|
||||
/>
|
||||
<p
|
||||
v-if="tried && !live"
|
||||
class="absolute inset-6 flex items-center justify-center text-center text-xs text-[var(--muted)]"
|
||||
>
|
||||
{{ t('app.missingGeo') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,860 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
openRingPath,
|
||||
projectEyePoint,
|
||||
projectFacePoint,
|
||||
RADIUS,
|
||||
roundedRectangle,
|
||||
rotatePoseAroundAxis,
|
||||
rotatePoseAroundCamera,
|
||||
rotatePoseWithArcball,
|
||||
rotationRing,
|
||||
splitRingArcs,
|
||||
unitVector,
|
||||
type HeadPose,
|
||||
type Point3
|
||||
} from '@/ui/pose/math'
|
||||
import { surfaceOf, wirePaths } from '@/ui/pose/surface'
|
||||
import {
|
||||
clampEyes,
|
||||
clampPose,
|
||||
DEFAULT_EYE,
|
||||
DEFAULT_POSE,
|
||||
scaleEye,
|
||||
updateEye,
|
||||
type EyeSide,
|
||||
type EyeTune,
|
||||
type ManualPart,
|
||||
type ManualState
|
||||
} from '@/ui/pose/model'
|
||||
import {
|
||||
beginManipulation,
|
||||
finishManipulation,
|
||||
previewManipulation,
|
||||
type ManipulationSession
|
||||
} from '@/ui/pose/session'
|
||||
import { localHeadRadius, overlayToHost, toLocal, type LocalPoint } from '@/ui/pose/space'
|
||||
|
||||
export type HeroHandle = {
|
||||
parts: () => {
|
||||
svg: SVGSVGElement
|
||||
body: SVGPathElement
|
||||
eyes: SVGPathElement[]
|
||||
group: SVGGElement
|
||||
} | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
manual: ManualState
|
||||
hero: HeroHandle | null
|
||||
shape?: string
|
||||
echelle?: number
|
||||
}>(),
|
||||
{ echelle: 1 }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:pose': [HeadPose]
|
||||
'update:selected': [ManualPart | null]
|
||||
'update:eyes': [EyeTune]
|
||||
}>()
|
||||
|
||||
const gizmoSvg = ref<SVGSVGElement | null>(null)
|
||||
const manualLayer = ref<HTMLDivElement | null>(null)
|
||||
const overlayStyle = ref<Record<string, string>>({})
|
||||
const pose = computed(() => props.manual.pose)
|
||||
const selected = computed(() => props.manual.selected)
|
||||
const eyes = computed(() => props.manual.eyes)
|
||||
const headRadius = ref(95)
|
||||
|
||||
const rings = computed(() => {
|
||||
const split = (axis: 'x' | 'y' | 'z') => {
|
||||
const { front, back } = splitRingArcs(rotationRing(pose.value, axis))
|
||||
return {
|
||||
front: front.map(openRingPath).filter(Boolean),
|
||||
back: back.map(openRingPath).filter(Boolean)
|
||||
}
|
||||
}
|
||||
return { x: split('x'), y: split('y'), z: split('z') }
|
||||
})
|
||||
|
||||
const activeAxis = ref<'x' | 'y' | 'z' | 'view' | null>(null)
|
||||
let wireGroup: SVGGElement | null = null
|
||||
const eyeShown: Record<'left' | 'right', boolean | null> = { left: null, right: null }
|
||||
|
||||
type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate'
|
||||
|
||||
type Drag =
|
||||
| { kind: 'arcball'; start: LocalPoint; pose: HeadPose; radius: number }
|
||||
| {
|
||||
kind: 'axis'
|
||||
axis: 'x' | 'y' | 'z'
|
||||
start: LocalPoint
|
||||
tangent: LocalPoint
|
||||
pose: HeadPose
|
||||
}
|
||||
| { kind: 'view'; startAngle: number; pose: HeadPose }
|
||||
| {
|
||||
kind: 'eye'
|
||||
handle: EyeHandle
|
||||
side: 'left' | 'right'
|
||||
start: LocalPoint
|
||||
eyes: EyeTune
|
||||
center: LocalPoint
|
||||
widthAxis: LocalPoint
|
||||
heightAxis: LocalPoint
|
||||
spacingAxis: LocalPoint
|
||||
startAngle: number
|
||||
startDistance: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
}
|
||||
|
||||
const drag = ref<Drag | null>(null)
|
||||
let poseSession: ManipulationSession<HeadPose> | null = null
|
||||
let eyeSession: ManipulationSession<EyeTune> | null = null
|
||||
|
||||
type EyeFrame = {
|
||||
visible: boolean
|
||||
available: boolean
|
||||
center: LocalPoint
|
||||
widthAxis: LocalPoint
|
||||
heightAxis: LocalPoint
|
||||
widthHandle: LocalPoint
|
||||
heightHandle: LocalPoint
|
||||
rotateHandle: LocalPoint
|
||||
sizeHandle: LocalPoint
|
||||
outline: string
|
||||
width: number
|
||||
height: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
plusX: number
|
||||
plusY: number
|
||||
minusY: number
|
||||
faceX: number
|
||||
faceY: number
|
||||
}
|
||||
|
||||
const emptyFrame = (): EyeFrame => ({
|
||||
visible: false,
|
||||
available: false,
|
||||
center: [0, 0],
|
||||
widthAxis: [1, 0],
|
||||
heightAxis: [0, 1],
|
||||
widthHandle: [0, 0],
|
||||
heightHandle: [0, 0],
|
||||
rotateHandle: [0, 0],
|
||||
sizeHandle: [0, 0],
|
||||
outline: '',
|
||||
width: DEFAULT_EYE.width,
|
||||
height: DEFAULT_EYE.height,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
plusX: DEFAULT_EYE.width / 2,
|
||||
plusY: DEFAULT_EYE.height / 2,
|
||||
minusY: -DEFAULT_EYE.height / 2,
|
||||
faceX: 0,
|
||||
faceY: 0
|
||||
})
|
||||
|
||||
const leftFrame = ref<EyeFrame>(emptyFrame())
|
||||
const rightFrame = ref<EyeFrame>(emptyFrame())
|
||||
|
||||
const sceneSvg = () => props.hero?.parts()?.svg ?? null
|
||||
|
||||
const syncOverlay = () => {
|
||||
const host = sceneSvg()
|
||||
const layer = manualLayer.value
|
||||
if (!host || !layer) return
|
||||
/*
|
||||
* Les rects ecran incluent l'echelle photo du sujet ; l'overlay vit
|
||||
* lui-meme dans le sujet mis a l'echelle — diviser par `echelle` rend
|
||||
* sa boite CSS pre-transform, sinon la geometry serait comptee deux fois.
|
||||
*/
|
||||
const hostBox = host.getBoundingClientRect()
|
||||
const layerBox = layer.getBoundingClientRect()
|
||||
overlayStyle.value = {
|
||||
left: `${((hostBox.left - layerBox.left) / props.echelle).toFixed(2)}px`,
|
||||
top: `${((hostBox.top - layerBox.top) / props.echelle).toFixed(2)}px`,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
width: `${(hostBox.width / props.echelle).toFixed(2)}px`,
|
||||
height: `${(hostBox.height / props.echelle).toFixed(2)}px`
|
||||
}
|
||||
}
|
||||
|
||||
const localOf = (event: PointerEvent): LocalPoint | null =>
|
||||
toLocal(sceneSvg(), event.clientX, event.clientY)
|
||||
|
||||
const gizmoOf = (event: PointerEvent): LocalPoint | null => {
|
||||
const svg = gizmoSvg.value
|
||||
if (!svg) return null
|
||||
const rectangle = svg.getBoundingClientRect()
|
||||
if (rectangle.width < 1) return null
|
||||
return [
|
||||
((event.clientX - rectangle.left) / rectangle.width) * 86 - 43,
|
||||
((event.clientY - rectangle.top) / rectangle.height) * 86 - 43
|
||||
]
|
||||
}
|
||||
|
||||
const line = (from: LocalPoint, to: LocalPoint) =>
|
||||
`M${from[0].toFixed(2)} ${from[1].toFixed(2)}L${to[0].toFixed(2)} ${to[1].toFixed(2)}`
|
||||
|
||||
const commitPose = (next: HeadPose) => emit('update:pose', clampPose(next))
|
||||
const commitEyes = (next: EyeTune) => emit('update:eyes', clampEyes(next))
|
||||
|
||||
const xy = (point: Point3): LocalPoint => [point[0], point[1]]
|
||||
|
||||
const overlayPath = (points: Point3[]) => {
|
||||
if (points.length < 2) return ''
|
||||
return `M${points.map((point) => `${point[0].toFixed(2)} ${point[1].toFixed(2)}`).join('L')}Z`
|
||||
}
|
||||
|
||||
const hostPath = (host: SVGSVGElement | null, points: Point3[]) => {
|
||||
const mapped = points
|
||||
.map((point) => overlayToHost(host, point[0], point[1]))
|
||||
.filter((point): point is LocalPoint => !!point)
|
||||
if (mapped.length < 2) return ''
|
||||
return `M${mapped.map((point) => `${point[0].toFixed(2)} ${point[1].toFixed(2)}`).join('L')}Z`
|
||||
}
|
||||
|
||||
const offsetAlong = (origin: LocalPoint, axis: LocalPoint, distance: number): LocalPoint => [
|
||||
origin[0] + axis[0] * distance,
|
||||
origin[1] + axis[1] * distance
|
||||
]
|
||||
|
||||
const readEye = (tune: EyeSide, side: 'left' | 'right'): EyeFrame => {
|
||||
const faceX = (side === 'left' ? -1 : 1) * eyes.value.spacing / 2 + tune.x
|
||||
const faceY = tune.y
|
||||
const width = Math.max(tune.width, 1)
|
||||
const height = Math.max(tune.height, 1)
|
||||
const project = (localX: number, localY: number) =>
|
||||
projectEyePoint(pose.value, faceX, faceY, localX, localY, tune.angle, DEFAULT_POSE)
|
||||
const outline = roundedRectangle(width, height).map(([localX, localY]) =>
|
||||
project(localX, localY)
|
||||
)
|
||||
const center = xy(project(0, 0))
|
||||
// Same visibility hysteresis as the engine eyes: drag jitter around the
|
||||
// silhouette boundary must not pop the editor handles in and out.
|
||||
const zAvg = outline.reduce((sum, point) => sum + point[2], 0) / Math.max(outline.length, 1)
|
||||
let shown = eyeShown[side] ?? zAvg > 0
|
||||
if (shown && zAvg < -0.006) shown = false
|
||||
if (!shown && zAvg > 0.004) shown = true
|
||||
eyeShown[side] = shown
|
||||
const widthHandle = xy(project(width / 2 + 9, 0))
|
||||
const heightHandle = xy(project(0, -height / 2 - 9))
|
||||
const rotateHandle = xy(project(0, -height / 2 - 30))
|
||||
const sizeHandle = xy(project(width / 2 + 11, height / 2 + 11))
|
||||
const plusX = Math.hypot(widthHandle[0] - center[0], widthHandle[1] - center[1])
|
||||
const minusY = -Math.hypot(heightHandle[0] - center[0], heightHandle[1] - center[1])
|
||||
return {
|
||||
visible: shown,
|
||||
available: true,
|
||||
center,
|
||||
widthAxis: unitVector(center, widthHandle),
|
||||
heightAxis: unitVector(center, [
|
||||
center[0] - (heightHandle[0] - center[0]),
|
||||
center[1] - (heightHandle[1] - center[1])
|
||||
]),
|
||||
widthHandle,
|
||||
heightHandle,
|
||||
rotateHandle,
|
||||
sizeHandle,
|
||||
outline: overlayPath(outline),
|
||||
width,
|
||||
height,
|
||||
scaleX: (2 * Math.max(plusX - 9, 0.001)) / width,
|
||||
scaleY: (2 * Math.max(-minusY - 9, 0.001)) / height,
|
||||
plusX,
|
||||
plusY: Math.abs(minusY),
|
||||
minusY,
|
||||
faceX,
|
||||
faceY
|
||||
}
|
||||
}
|
||||
|
||||
const paintStadiums = () => {
|
||||
const parts = props.hero?.parts()
|
||||
const host = sceneSvg()
|
||||
if (!parts || !host) return
|
||||
;(['left', 'right'] as const).forEach((side, index) => {
|
||||
const el = parts.eyes[index]
|
||||
const frame = side === 'left' ? leftFrame.value : rightFrame.value
|
||||
if (!el) return
|
||||
const tune = eyes.value[side]
|
||||
const outline = roundedRectangle(frame.width, frame.height).map(([localX, localY]) =>
|
||||
projectEyePoint(
|
||||
pose.value,
|
||||
frame.faceX,
|
||||
frame.faceY,
|
||||
localX,
|
||||
localY,
|
||||
tune.angle,
|
||||
DEFAULT_POSE
|
||||
)
|
||||
)
|
||||
el.setAttribute('d', hostPath(host, outline))
|
||||
el.removeAttribute('transform')
|
||||
el.style.display = frame.visible ? '' : 'none'
|
||||
})
|
||||
}
|
||||
|
||||
const sampleEyes = () => {
|
||||
const host = sceneSvg()
|
||||
leftFrame.value = readEye(eyes.value.left, 'left')
|
||||
rightFrame.value = readEye(eyes.value.right, 'right')
|
||||
paintStadiums()
|
||||
headRadius.value = localHeadRadius(host, engineRadius())
|
||||
}
|
||||
|
||||
const downOf = (frame: EyeFrame): LocalPoint => unitVector(frame.heightHandle, frame.center)
|
||||
|
||||
const spacingPoint = (left: EyeFrame, right: EyeFrame, selected: EyeFrame): LocalPoint => {
|
||||
const midX = (eyes.value.left.x + eyes.value.right.x) / 2
|
||||
const midY = (eyes.value.left.y + eyes.value.right.y) / 2
|
||||
const point = projectFacePoint(
|
||||
pose.value,
|
||||
midX,
|
||||
midY + selected.height / 2 + 34,
|
||||
RADIUS,
|
||||
[0, 0],
|
||||
DEFAULT_POSE
|
||||
)
|
||||
if (Number.isFinite(point[0]) && Number.isFinite(point[1])) return [point[0], point[1]]
|
||||
return offsetAlong(
|
||||
[(left.center[0] + right.center[0]) / 2, (left.center[1] + right.center[1]) / 2],
|
||||
downOf(selected),
|
||||
selected.height / 2 + 34
|
||||
)
|
||||
}
|
||||
|
||||
const editor = computed(() => {
|
||||
const side: 'left' | 'right' | null =
|
||||
selected.value === 'left' ? 'left' : selected.value === 'right' ? 'right' : null
|
||||
if (!side) return null
|
||||
const frame = side === 'left' ? leftFrame.value : rightFrame.value
|
||||
if (!frame.visible) return null
|
||||
const other = side === 'left' ? rightFrame.value : leftFrame.value
|
||||
const both = other.available
|
||||
const mid: LocalPoint = both
|
||||
? [(frame.center[0] + other.center[0]) / 2, (frame.center[1] + other.center[1]) / 2]
|
||||
: frame.center
|
||||
const spacingHandle = both
|
||||
? spacingPoint(frame, other, frame)
|
||||
: offsetAlong(frame.center, downOf(frame), frame.plusY + 34)
|
||||
return {
|
||||
side,
|
||||
frame,
|
||||
other,
|
||||
both,
|
||||
mid,
|
||||
spacingHandle,
|
||||
widthGuide: line(frame.center, frame.widthHandle),
|
||||
heightGuide: line(frame.center, frame.heightHandle),
|
||||
rotationGuide: line(frame.heightHandle, frame.rotateHandle),
|
||||
spacingGuide: both
|
||||
? `${line(frame.center, other.center)}${line(mid, spacingHandle)}`
|
||||
: line(frame.center, spacingHandle)
|
||||
}
|
||||
})
|
||||
|
||||
const draggingHead = computed(() => !!drag.value && drag.value.kind !== 'eye')
|
||||
const draggingHandle = computed(() => drag.value?.kind === 'eye')
|
||||
const showWire = draggingHead
|
||||
|
||||
const engineRadius = () => window.GROK_GEO?.Re ?? 114.27
|
||||
|
||||
const ensureWires = () => {
|
||||
const parts = props.hero?.parts()
|
||||
if (!parts) return null
|
||||
if (wireGroup?.isConnected) return wireGroup
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
const group = document.createElementNS(ns, 'g')
|
||||
group.setAttribute('class', 'manual-wires')
|
||||
group.setAttribute('aria-hidden', 'true')
|
||||
const clip = parts.svg.querySelector('clipPath')
|
||||
if (clip?.id) group.setAttribute('clip-path', `url(#${clip.id})`)
|
||||
parts.body.insertAdjacentElement('afterend', group)
|
||||
wireGroup = group
|
||||
return group
|
||||
}
|
||||
|
||||
const paintWires = () => {
|
||||
const group = ensureWires()
|
||||
if (!group) return
|
||||
const radius = engineRadius()
|
||||
const paths = showWire.value
|
||||
? wirePaths(pose.value, surfaceOf(props.shape || 'blob'), [radius, radius])
|
||||
: []
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
while (group.childNodes.length > paths.length) group.lastChild?.remove()
|
||||
paths.forEach((d, index) => {
|
||||
let path = group.childNodes[index] as SVGPathElement | undefined
|
||||
if (!path) {
|
||||
path = document.createElementNS(ns, 'path')
|
||||
path.setAttribute('class', 'wire')
|
||||
group.appendChild(path)
|
||||
}
|
||||
path.setAttribute('d', d)
|
||||
})
|
||||
}
|
||||
|
||||
const paintFocus = () => {
|
||||
const parts = props.hero?.parts()
|
||||
if (!parts) return
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
let ring = parts.group.querySelector('.manual-head-outline') as SVGPathElement | null
|
||||
if (draggingHead.value) {
|
||||
if (!ring) {
|
||||
ring = document.createElementNS(ns, 'path')
|
||||
ring.setAttribute('class', 'manual-head-outline')
|
||||
parts.group.appendChild(ring)
|
||||
}
|
||||
ring.setAttribute('d', parts.body.getAttribute('d') || '')
|
||||
} else {
|
||||
ring?.remove()
|
||||
}
|
||||
const left = parts.eyes[0]
|
||||
const right = parts.eyes[1]
|
||||
left?.classList.toggle('cyan-outline', selected.value === 'left')
|
||||
right?.classList.toggle('cyan-outline', selected.value === 'right')
|
||||
}
|
||||
|
||||
const clearDecor = () => {
|
||||
wireGroup?.remove()
|
||||
wireGroup = null
|
||||
const parts = props.hero?.parts()
|
||||
if (parts) parts.body.style.cursor = ''
|
||||
parts?.group.querySelector('.manual-head-outline')?.remove()
|
||||
parts?.eyes.forEach((el) => {
|
||||
el.classList.remove('cyan-outline')
|
||||
el.style.cursor = ''
|
||||
el.style.display = ''
|
||||
el.removeAttribute('transform')
|
||||
})
|
||||
}
|
||||
|
||||
const detachDrag = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', stop)
|
||||
window.removeEventListener('pointercancel', cancel)
|
||||
}
|
||||
|
||||
const attachDrag = (event: PointerEvent) => {
|
||||
event.preventDefault()
|
||||
;(event.currentTarget as Element | null)?.setPointerCapture?.(event.pointerId)
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', stop)
|
||||
window.addEventListener('pointercancel', cancel)
|
||||
}
|
||||
|
||||
const setBodyCursor = (cursor: string) => {
|
||||
const parts = props.hero?.parts()
|
||||
if (!parts) return
|
||||
parts.body.style.cursor = cursor
|
||||
}
|
||||
|
||||
const selectBody = () => {
|
||||
if (selected.value !== 'body') emit('update:selected', 'body')
|
||||
}
|
||||
|
||||
const isEyeTarget = (target: EventTarget | null) => {
|
||||
if (!(target instanceof Element)) return false
|
||||
if (target.closest('.editor-control')) return true
|
||||
const eyes = props.hero?.parts()?.eyes
|
||||
return !!eyes?.some((el) => el === target || el.contains(target))
|
||||
}
|
||||
|
||||
const startArcball = (event: PointerEvent) => {
|
||||
const point = localOf(event)
|
||||
if (!point) return
|
||||
selectBody()
|
||||
poseSession = beginManipulation(pose.value)
|
||||
drag.value = {
|
||||
kind: 'arcball',
|
||||
start: point,
|
||||
pose: pose.value,
|
||||
radius: headRadius.value
|
||||
}
|
||||
setBodyCursor('grabbing')
|
||||
attachDrag(event)
|
||||
}
|
||||
|
||||
const startAxis = (axis: 'x' | 'y' | 'z', event: PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
const point = gizmoOf(event)
|
||||
if (!point) return
|
||||
const ring = rotationRing(pose.value, axis)
|
||||
let closest = 0
|
||||
let best = Infinity
|
||||
ring.slice(0, -1).forEach((ringPoint, index) => {
|
||||
const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1])
|
||||
if (distance < best) {
|
||||
closest = index
|
||||
best = distance
|
||||
}
|
||||
})
|
||||
const previous = ring[(closest - 1 + ring.length - 1) % (ring.length - 1)]!
|
||||
const next = ring[(closest + 1) % (ring.length - 1)]!
|
||||
poseSession = beginManipulation(pose.value)
|
||||
activeAxis.value = axis
|
||||
drag.value = {
|
||||
kind: 'axis',
|
||||
axis,
|
||||
start: point,
|
||||
tangent: unitVector(previous, next),
|
||||
pose: pose.value
|
||||
}
|
||||
attachDrag(event)
|
||||
}
|
||||
|
||||
const startView = (event: PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
const point = gizmoOf(event)
|
||||
if (!point) return
|
||||
poseSession = beginManipulation(pose.value)
|
||||
activeAxis.value = 'view'
|
||||
drag.value = { kind: 'view', startAngle: Math.atan2(point[1], point[0]), pose: pose.value }
|
||||
attachDrag(event)
|
||||
}
|
||||
|
||||
const startHandle = (handle: EyeHandle, event: PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
const live = editor.value
|
||||
if (!live) return
|
||||
const point = localOf(event)
|
||||
if (!point) return
|
||||
eyeSession = beginManipulation(eyes.value)
|
||||
const left = leftFrame.value
|
||||
const right = rightFrame.value
|
||||
const both = left.visible && right.visible
|
||||
const spacingFrom = both ? left.center : live.frame.center
|
||||
const spacingTo = both ? right.center : live.spacingHandle
|
||||
drag.value = {
|
||||
kind: 'eye',
|
||||
handle,
|
||||
side: live.side,
|
||||
start: point,
|
||||
eyes: eyes.value,
|
||||
center: live.frame.center,
|
||||
widthAxis: unitVector(live.frame.center, live.frame.widthHandle),
|
||||
heightAxis: unitVector(live.frame.center, live.frame.heightHandle),
|
||||
spacingAxis: unitVector(spacingFrom, spacingTo),
|
||||
startAngle: Math.atan2(point[1] - live.frame.center[1], point[0] - live.frame.center[0]),
|
||||
startDistance: Math.max(
|
||||
Math.hypot(point[0] - live.frame.center[0], point[1] - live.frame.center[1]),
|
||||
1
|
||||
),
|
||||
scaleX: live.frame.scaleX,
|
||||
scaleY: live.frame.scaleY
|
||||
}
|
||||
attachDrag(event)
|
||||
}
|
||||
|
||||
const move = (event: PointerEvent) => {
|
||||
const interaction = drag.value
|
||||
if (!interaction) return
|
||||
if (interaction.kind === 'arcball') {
|
||||
const point = localOf(event)
|
||||
if (!point) return
|
||||
const next = rotatePoseWithArcball(
|
||||
interaction.pose,
|
||||
interaction.start,
|
||||
point,
|
||||
interaction.radius
|
||||
)
|
||||
if (poseSession) previewManipulation(poseSession, next, commitPose)
|
||||
return
|
||||
}
|
||||
if (interaction.kind === 'eye') {
|
||||
const point = localOf(event)
|
||||
if (!point) return
|
||||
const deltaX = point[0] - interaction.start[0]
|
||||
const deltaY = point[1] - interaction.start[1]
|
||||
const along = (axis: LocalPoint) => deltaX * axis[0] + deltaY * axis[1]
|
||||
const start = interaction.eyes
|
||||
const side = interaction.side
|
||||
const current = start[side]
|
||||
let next = start
|
||||
if (interaction.handle === 'width') {
|
||||
next = updateEye(
|
||||
start,
|
||||
side,
|
||||
{
|
||||
width: Math.max(10, Math.min(100, current.width + along(interaction.widthAxis) * 2))
|
||||
},
|
||||
props.manual.links
|
||||
)
|
||||
} else if (interaction.handle === 'height') {
|
||||
next = updateEye(
|
||||
start,
|
||||
side,
|
||||
{
|
||||
height: Math.max(10, Math.min(100, current.height + along(interaction.heightAxis) * 2))
|
||||
},
|
||||
props.manual.links
|
||||
)
|
||||
} else if (interaction.handle === 'size') {
|
||||
const distance = Math.hypot(point[0] - interaction.center[0], point[1] - interaction.center[1])
|
||||
next = scaleEye(start, side, distance / interaction.startDistance, props.manual.links.size)
|
||||
} else if (interaction.handle === 'spacing') {
|
||||
next = clampEyes({
|
||||
...start,
|
||||
spacing: start.spacing + along(interaction.spacingAxis)
|
||||
})
|
||||
} else {
|
||||
const currentAngle = Math.atan2(point[1] - interaction.center[1], point[0] - interaction.center[0])
|
||||
const delta = Math.atan2(
|
||||
Math.sin(currentAngle - interaction.startAngle),
|
||||
Math.cos(currentAngle - interaction.startAngle)
|
||||
)
|
||||
next = updateEye(
|
||||
start,
|
||||
side,
|
||||
{ angle: current.angle + (delta * 180) / Math.PI },
|
||||
props.manual.links
|
||||
)
|
||||
}
|
||||
if (eyeSession) previewManipulation(eyeSession, next, commitEyes)
|
||||
return
|
||||
}
|
||||
const gizmoPoint = gizmoOf(event)
|
||||
if (!gizmoPoint) return
|
||||
if (interaction.kind === 'view') {
|
||||
const currentAngle = Math.atan2(gizmoPoint[1], gizmoPoint[0])
|
||||
const delta = Math.atan2(
|
||||
Math.sin(currentAngle - interaction.startAngle),
|
||||
Math.cos(currentAngle - interaction.startAngle)
|
||||
)
|
||||
const next = rotatePoseAroundCamera(interaction.pose, delta)
|
||||
if (poseSession) previewManipulation(poseSession, next, commitPose)
|
||||
return
|
||||
}
|
||||
const signed =
|
||||
(gizmoPoint[0] - interaction.start[0]) * interaction.tangent[0] +
|
||||
(gizmoPoint[1] - interaction.start[1]) * interaction.tangent[1]
|
||||
const next = rotatePoseAroundAxis(interaction.pose, interaction.axis, signed * 1.5)
|
||||
if (poseSession) previewManipulation(poseSession, next, commitPose)
|
||||
}
|
||||
|
||||
const finishPose = (outcome: 'commit' | 'cancel') => {
|
||||
if (poseSession)
|
||||
finishManipulation(poseSession, outcome, { preview: commitPose, commit: commitPose })
|
||||
poseSession = null
|
||||
}
|
||||
|
||||
const finishEyes = (outcome: 'commit' | 'cancel') => {
|
||||
if (eyeSession)
|
||||
finishManipulation(eyeSession, outcome, { preview: commitEyes, commit: commitEyes })
|
||||
eyeSession = null
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (drag.value?.kind === 'eye') finishEyes('commit')
|
||||
else finishPose('commit')
|
||||
drag.value = null
|
||||
activeAxis.value = null
|
||||
setBodyCursor('grab')
|
||||
detachDrag()
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (drag.value?.kind === 'eye') finishEyes('cancel')
|
||||
else finishPose('cancel')
|
||||
drag.value = null
|
||||
activeAxis.value = null
|
||||
setBodyCursor('grab')
|
||||
detachDrag()
|
||||
}
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && drag.value) {
|
||||
event.stopPropagation()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
const onBodyDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
startArcball(event)
|
||||
}
|
||||
|
||||
const onEyeDown = (side: 'left' | 'right') => (event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
emit('update:selected', side)
|
||||
}
|
||||
|
||||
const onBlankDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
if (isEyeTarget(event.target)) return
|
||||
if (selected.value === 'left' || selected.value === 'right') selectBody()
|
||||
}
|
||||
|
||||
const bindParts = () => {
|
||||
const parts = props.hero?.parts()
|
||||
if (!parts) return () => {}
|
||||
const left = parts.eyes[0]
|
||||
const right = parts.eyes[1]
|
||||
parts.body.style.cursor = 'grab'
|
||||
if (left) left.style.cursor = 'pointer'
|
||||
if (right) right.style.cursor = 'pointer'
|
||||
const onLeft = onEyeDown('left')
|
||||
const onRight = onEyeDown('right')
|
||||
parts.body.addEventListener('pointerdown', onBodyDown)
|
||||
left?.addEventListener('pointerdown', onLeft)
|
||||
right?.addEventListener('pointerdown', onRight)
|
||||
return () => {
|
||||
parts.body.style.cursor = ''
|
||||
if (left) left.style.cursor = ''
|
||||
if (right) right.style.cursor = ''
|
||||
parts.body.removeEventListener('pointerdown', onBodyDown)
|
||||
left?.removeEventListener('pointerdown', onLeft)
|
||||
right?.removeEventListener('pointerdown', onRight)
|
||||
}
|
||||
}
|
||||
|
||||
let unbind = () => {}
|
||||
let bound = false
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
syncOverlay()
|
||||
sampleEyes()
|
||||
paintWires()
|
||||
paintFocus()
|
||||
if (!bound && props.hero?.parts()) {
|
||||
unbind()
|
||||
unbind = bindParts()
|
||||
bound = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.hero,
|
||||
() => {
|
||||
unbind()
|
||||
bound = false
|
||||
unbind = bindParts()
|
||||
bound = !!props.hero?.parts()
|
||||
sampleEyes()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
window.addEventListener('pointerdown', onBlankDown, true)
|
||||
raf = requestAnimationFrame(tick)
|
||||
syncOverlay()
|
||||
unbind = bindParts()
|
||||
bound = !!props.hero?.parts()
|
||||
sampleEyes()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
window.removeEventListener('pointerdown', onBlankDown, true)
|
||||
cancelAnimationFrame(raf)
|
||||
stop()
|
||||
unbind()
|
||||
clearDecor()
|
||||
})
|
||||
|
||||
const resetHead = () => emit('update:pose', { ...DEFAULT_POSE })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="manualLayer" class="manual-layer">
|
||||
<svg
|
||||
class="manual-overlay"
|
||||
:style="overlayStyle"
|
||||
viewBox="-150 -150 300 300"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g v-if="editor" class="eye-editor">
|
||||
<path v-if="draggingHandle" class="selection-outline" :d="editor.frame.outline" />
|
||||
<path class="editor-guide" :d="editor.widthGuide" />
|
||||
<path class="editor-guide" :d="editor.heightGuide" />
|
||||
<path class="editor-guide" :d="editor.rotationGuide" />
|
||||
<path class="editor-guide" :d="editor.spacingGuide" />
|
||||
<g class="editor-control" data-eye-handle="width" @pointerdown="startHandle('width', $event)">
|
||||
<circle class="editor-handle" :cx="editor.frame.widthHandle[0]" :cy="editor.frame.widthHandle[1]" r="5.5" />
|
||||
<text class="editor-label" :x="editor.frame.widthHandle[0]" :y="editor.frame.widthHandle[1] + 2.6">L</text>
|
||||
</g>
|
||||
<g class="editor-control" data-eye-handle="height" @pointerdown="startHandle('height', $event)">
|
||||
<circle class="editor-handle" :cx="editor.frame.heightHandle[0]" :cy="editor.frame.heightHandle[1]" r="5.5" />
|
||||
<text class="editor-label" :x="editor.frame.heightHandle[0]" :y="editor.frame.heightHandle[1] + 2.6">H</text>
|
||||
</g>
|
||||
<g class="editor-control" data-eye-handle="rotate" @pointerdown="startHandle('rotate', $event)">
|
||||
<circle class="editor-handle" :cx="editor.frame.rotateHandle[0]" :cy="editor.frame.rotateHandle[1]" r="5.5" />
|
||||
<text class="editor-label" :x="editor.frame.rotateHandle[0]" :y="editor.frame.rotateHandle[1] + 2.6">R</text>
|
||||
</g>
|
||||
<g class="editor-control" data-eye-handle="size" @pointerdown="startHandle('size', $event)">
|
||||
<rect
|
||||
class="editor-handle"
|
||||
:x="editor.frame.sizeHandle[0] - 5"
|
||||
:y="editor.frame.sizeHandle[1] - 5"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
/>
|
||||
<text class="editor-label" :x="editor.frame.sizeHandle[0]" :y="editor.frame.sizeHandle[1] + 2.6">S</text>
|
||||
</g>
|
||||
<g class="editor-control" data-eye-handle="spacing" @pointerdown="startHandle('spacing', $event)">
|
||||
<rect
|
||||
class="editor-handle"
|
||||
:x="editor.spacingHandle[0] - 5"
|
||||
:y="editor.spacingHandle[1] - 5"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
/>
|
||||
<text class="editor-label" :x="editor.spacingHandle[0]" :y="editor.spacingHandle[1] + 2.6">E</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="gizmo-cluster">
|
||||
<svg ref="gizmoSvg" class="gizmo" viewBox="-43 -43 86 86" :aria-label="t('manual.gizmo')">
|
||||
<g class="gizmo-back" aria-hidden="true">
|
||||
<path v-for="(d, i) in rings.y.back" :key="'yb' + i" class="gizmo-orbit gizmo-y" :d="d" />
|
||||
<path v-for="(d, i) in rings.x.back" :key="'xb' + i" class="gizmo-orbit gizmo-x" :d="d" />
|
||||
<path v-for="(d, i) in rings.z.back" :key="'zb' + i" class="gizmo-orbit gizmo-z" :d="d" />
|
||||
</g>
|
||||
<circle class="gizmo-hit gizmo-camera-hit" cx="0" cy="0" r="38" @pointerdown="startView" />
|
||||
<circle
|
||||
class="gizmo-orbit gizmo-camera"
|
||||
:class="{ 'is-active': activeAxis === 'view' }"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="38"
|
||||
/>
|
||||
<g class="gizmo-front">
|
||||
<g v-for="(d, i) in rings.y.front" :key="'yf' + i" class="gizmo-arc">
|
||||
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('y', $event)" />
|
||||
<path class="gizmo-orbit gizmo-y" :class="{ 'is-active': activeAxis === 'y' }" :d="d" />
|
||||
</g>
|
||||
<g v-for="(d, i) in rings.x.front" :key="'xf' + i" class="gizmo-arc">
|
||||
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('x', $event)" />
|
||||
<path class="gizmo-orbit gizmo-x" :class="{ 'is-active': activeAxis === 'x' }" :d="d" />
|
||||
</g>
|
||||
<g v-for="(d, i) in rings.z.front" :key="'zf' + i" class="gizmo-arc">
|
||||
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('z', $event)" />
|
||||
<path class="gizmo-orbit gizmo-z" :class="{ 'is-active': activeAxis === 'z' }" :d="d" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<button type="button" class="gizmo-reset" :aria-label="t('manual.reset')" @click="resetHead">
|
||||
↺
|
||||
</button>
|
||||
</div>
|
||||
<div class="axis-key" aria-hidden="true">
|
||||
<i class="x" />X <i class="y" />Y <i class="z" />Z
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useModalDialog } from '@/ui/useModalDialog'
|
||||
|
||||
/**
|
||||
* Boite de dialogue de nommage. Le comportement modal vient de
|
||||
* `useModalDialog` ; l'animation, elle, est dans `styles.css` : elle a besoin
|
||||
* de `@starting-style`, hors de portee des classes.
|
||||
*/
|
||||
const props = defineProps<{ title: string; label: string; submitLabel: string }>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const value = defineModel<string>('value', { required: true })
|
||||
const emit = defineEmits<{ submit: [name: string] }>()
|
||||
|
||||
const boite = useTemplateRef<HTMLDialogElement>('boite')
|
||||
useModalDialog(open, boite)
|
||||
const field = ref<HTMLInputElement | null>(null)
|
||||
const draft = ref('')
|
||||
|
||||
// le nom propose arrive avec l'ouverture, et le champ s'offre deja selectionne
|
||||
watch(open, async (on) => {
|
||||
if (!on) return
|
||||
draft.value = value.value
|
||||
await nextTick()
|
||||
field.value?.select()
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const clean = draft.value.trim()
|
||||
// un nom vide ne veut rien dire : on garde la main plutot que de valider
|
||||
if (!clean) {
|
||||
field.value?.focus()
|
||||
return
|
||||
}
|
||||
emit('submit', clean)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- `m-auto` remet le centrage natif de la modale : le reset de Tailwind
|
||||
passe `margin: 0` sur tout, et c'est cette marge auto qui centre -->
|
||||
<dialog
|
||||
ref="boite"
|
||||
class="dialogue m-auto w-80 rounded-2xl bg-white p-5 text-[var(--ink)] shadow-xl"
|
||||
:aria-label="props.title"
|
||||
@close="open = false"
|
||||
@cancel.prevent="open = false"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<h2 class="text-sm font-semibold">{{ props.title }}</h2>
|
||||
|
||||
<label class="flex flex-col gap-1.5 text-xs text-[var(--muted)]">
|
||||
{{ props.label }}
|
||||
<input
|
||||
ref="field"
|
||||
v-model="draft"
|
||||
class="h-9 rounded-lg bg-black/5 px-2.5 text-sm text-[var(--ink)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--ink)]"
|
||||
type="text"
|
||||
maxlength="40"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 cursor-pointer rounded-lg px-3 text-xs text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('dialog.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="h-8 cursor-pointer rounded-lg bg-[var(--ink)] px-3 text-xs text-[var(--paper)] transition hover:opacity-90 active:scale-95"
|
||||
>
|
||||
{{ props.submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
fondCss,
|
||||
normaliseComposition,
|
||||
ANGLE_DEFAUT,
|
||||
type CompositionPhoto,
|
||||
type FondPhoto
|
||||
} from '@/ui/photo'
|
||||
|
||||
/**
|
||||
* Cadre vivant du mode photo. Reste TOUJOURS monte (display:contents quand
|
||||
* inactif) : le slot contient l'AvatarStage du heros, qui ne doit jamais etre
|
||||
* reparente — un remontage relancerait le moteur replica et perdrait la pose.
|
||||
*
|
||||
* Math WYSIWYG : le cadre fait 300 unites, donc translate(x/3 %) en CSS egale
|
||||
* translate(x) dans le SVG exporte par `svgPhoto`.
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
fond?: FondPhoto
|
||||
couleurDe?: string
|
||||
couleurA?: string
|
||||
angle?: number
|
||||
outil?: 'cadre' | 'pose'
|
||||
flash?: number
|
||||
}>(),
|
||||
{
|
||||
active: false,
|
||||
fond: 'transparent',
|
||||
couleurDe: '#F5F7FC',
|
||||
couleurA: '#C9D5FF',
|
||||
angle: ANGLE_DEFAUT,
|
||||
outil: 'cadre',
|
||||
flash: 0
|
||||
}
|
||||
)
|
||||
|
||||
const composition = defineModel<CompositionPhoto>('composition', { required: true })
|
||||
|
||||
const cadre = ref<HTMLDivElement | null>(null)
|
||||
const glisse = ref<{ clientX: number; clientY: number; x: number; y: number } | null>(null)
|
||||
|
||||
function patch(partiel: Partial<CompositionPhoto>) {
|
||||
composition.value = normaliseComposition({ ...composition.value, ...partiel })
|
||||
}
|
||||
|
||||
function zoome(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (glisse.value) return
|
||||
patch({ scale: composition.value.scale * Math.exp(-e.deltaY * 0.0015) })
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.active, () => props.outil],
|
||||
([actif, outil]) => {
|
||||
const node = cadre.value
|
||||
if (!node) return
|
||||
node.removeEventListener('wheel', zoome)
|
||||
if (actif && outil === 'cadre') node.addEventListener('wheel', zoome, { passive: false })
|
||||
},
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
onBeforeUnmount(() => cadre.value?.removeEventListener('wheel', zoome))
|
||||
|
||||
function debute(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
glisse.value = {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
x: composition.value.x,
|
||||
y: composition.value.y
|
||||
}
|
||||
const cible = e.currentTarget as HTMLElement
|
||||
cible.dataset.dragging = ''
|
||||
cible.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function glit(e: PointerEvent) {
|
||||
if (!glisse.value) return
|
||||
const cible = e.currentTarget as HTMLElement
|
||||
if (!cible.hasPointerCapture(e.pointerId)) return
|
||||
const boite = cible.getBoundingClientRect()
|
||||
const unitesParPixel = 300 / Math.max(boite.width, 1)
|
||||
patch({
|
||||
x: glisse.value.x + (e.clientX - glisse.value.clientX) * unitesParPixel,
|
||||
y: glisse.value.y + (e.clientY - glisse.value.clientY) * unitesParPixel
|
||||
})
|
||||
}
|
||||
|
||||
function arrete(e: PointerEvent) {
|
||||
if (!glisse.value) return
|
||||
glisse.value = null
|
||||
delete (e.currentTarget as HTMLElement).dataset.dragging
|
||||
}
|
||||
|
||||
function pousse(e: KeyboardEvent) {
|
||||
const pas = e.shiftKey ? 10 : 2
|
||||
if (e.key === 'ArrowLeft') patch({ x: composition.value.x - pas })
|
||||
else if (e.key === 'ArrowRight') patch({ x: composition.value.x + pas })
|
||||
else if (e.key === 'ArrowUp') patch({ y: composition.value.y - pas })
|
||||
else if (e.key === 'ArrowDown') patch({ y: composition.value.y + pas })
|
||||
else if (e.key === '+' || e.key === '=') patch({ scale: composition.value.scale + 0.05 })
|
||||
else if (e.key === '-') patch({ scale: composition.value.scale - 0.05 })
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="cadre"
|
||||
class="photo-cadre-hote"
|
||||
:class="[
|
||||
active && 'photo-cadre',
|
||||
active && fond === 'transparent' && 'est-transparent',
|
||||
active && outil === 'cadre' && 'est-cadre'
|
||||
]"
|
||||
:style="
|
||||
active && {
|
||||
'--photo-coins': `${composition.cornerRadius}%`,
|
||||
'--photo-echelle': composition.scale,
|
||||
background: fondCss(fond, couleurDe, couleurA, angle)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="photo-sujet"
|
||||
:style="
|
||||
active && {
|
||||
transform: `translate(${composition.x / 3}%, ${composition.y / 3}%) scale(${composition.scale})`
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="active && outil === 'cadre'"
|
||||
class="photo-interaction"
|
||||
role="application"
|
||||
tabindex="0"
|
||||
:aria-label="t('photo.frameAria')"
|
||||
@pointerdown="debute"
|
||||
@pointermove="glit"
|
||||
@pointerup="arrete"
|
||||
@pointercancel="arrete"
|
||||
@keydown="pousse"
|
||||
/>
|
||||
<div v-if="active && flash > 0" :key="flash" class="photo-flash" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,739 @@
|
||||
<script setup lang="ts">
|
||||
import BotTile from '@/components/BotTile.vue'
|
||||
import { stateName, t, type Cle } from '@/i18n'
|
||||
import { COLOR_IDS, EXPRESSION_IDS, colorFill, colorHex, inkHex } from '@/replica/catalog'
|
||||
import { angleDepuisPointeur, PAS_CADRAN, tourAngle } from '@/ui/cadran'
|
||||
import {
|
||||
fondCss,
|
||||
paletteAleatoire,
|
||||
type CompositionPhoto,
|
||||
type FondPhoto
|
||||
} from '@/ui/photo'
|
||||
import {
|
||||
clampEyes,
|
||||
clampOffset,
|
||||
clampPose,
|
||||
resetAll,
|
||||
updateEye,
|
||||
type BodyOffset,
|
||||
type EyeSide,
|
||||
type EyeTune,
|
||||
type ManualState
|
||||
} from '@/ui/pose/model'
|
||||
import type { HeadPose } from '@/ui/pose/math'
|
||||
|
||||
/**
|
||||
* Reglages du mode photo : pose chiffree, cadrage, fond, expression et
|
||||
* export. Meme rythme que le personnalisateur (titre + contenu, pas de
|
||||
* cartes). Presentationnelle — la logique vit dans `App.vue` et `ui/photo.ts`.
|
||||
*/
|
||||
|
||||
defineProps<{ active?: boolean; manuel?: boolean }>()
|
||||
|
||||
const composition = defineModel<CompositionPhoto>('composition', { required: true })
|
||||
const fond = defineModel<FondPhoto>('fond', { required: true })
|
||||
const couleurDe = defineModel<string>('couleurDe', { required: true })
|
||||
const couleurA = defineModel<string>('couleurA', { required: true })
|
||||
const angle = defineModel<number>('angle', { required: true })
|
||||
const expression = defineModel<string>('expression', { required: true })
|
||||
const format = defineModel<'png' | 'svg'>('format', { required: true })
|
||||
const taille = defineModel<number>('taille', { required: true })
|
||||
const shape = defineModel<string>('shape', { required: true })
|
||||
const color = defineModel<string>('color', { required: true })
|
||||
const manual = defineModel<ManualState>('manual', { required: true })
|
||||
|
||||
type Cote = 'left' | 'right'
|
||||
|
||||
const COTES: Cote[] = ['left', 'right']
|
||||
|
||||
const DIMENSIONS = [
|
||||
{ cle: 'width', libelle: 'manual.width', min: 10, max: 110, pas: 1 },
|
||||
{ cle: 'height', libelle: 'manual.height', min: 10, max: 110, pas: 1 },
|
||||
{ cle: 'size', libelle: 'manual.size', min: 0.35, max: 2.2, pas: 0.05 }
|
||||
] as const
|
||||
|
||||
const FONDS: FondPhoto[] = ['transparent', 'solid', 'linear', 'radial']
|
||||
const TAILLES = [512, 1024, 2048]
|
||||
const PERSO_FOND =
|
||||
'conic-gradient(from 0.5turn, #e02135, #e08600, #009957, #1aa8c4, #0e74e0, #804ee0, #e02a88, #e02135)'
|
||||
|
||||
const LIBELLES_FONDS: Record<FondPhoto, Cle> = {
|
||||
transparent: 'photo.fond_transparent',
|
||||
solid: 'photo.fond_solid',
|
||||
linear: 'photo.fond_linear',
|
||||
radial: 'photo.fond_radial'
|
||||
}
|
||||
|
||||
const num = (v: string) => Number(v)
|
||||
|
||||
function estNuancier(id: string) {
|
||||
return colorHex(id).toLowerCase() === couleurDe.value.toLowerCase()
|
||||
}
|
||||
|
||||
function estPerso() {
|
||||
return COLOR_IDS.every((id) => colorHex(id).toLowerCase() !== couleurDe.value.toLowerCase())
|
||||
}
|
||||
|
||||
function choisitUni(hex: string) {
|
||||
couleurDe.value = hex
|
||||
couleurA.value = hex
|
||||
}
|
||||
|
||||
function choisitNuancier(id: string) {
|
||||
choisitUni(colorHex(id))
|
||||
}
|
||||
|
||||
function saisitPerso(e: Event) {
|
||||
choisitUni((e.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
/** Apercu vivant du bouton de fond : le vrai degrade aux couleurs courantes. */
|
||||
function styleFond(id: FondPhoto) {
|
||||
const css = fondCss(id, couleurDe.value, couleurA.value, angle.value)
|
||||
return css ? { background: css } : undefined
|
||||
}
|
||||
|
||||
function patch(partiel: Partial<CompositionPhoto>) {
|
||||
composition.value = { ...composition.value, ...partiel }
|
||||
}
|
||||
|
||||
function recentre() {
|
||||
patch({ x: 0, y: 0, scale: 1 })
|
||||
}
|
||||
|
||||
/* ---- pose chiffree : applique le reglage manuel, avec bornes ---- */
|
||||
|
||||
function posePatch(partiel: Partial<HeadPose>) {
|
||||
manual.value = { ...manual.value, pose: clampPose({ ...manual.value.pose, ...partiel }) }
|
||||
}
|
||||
|
||||
function offsetPatch(partiel: Partial<BodyOffset>) {
|
||||
manual.value = { ...manual.value, offset: clampOffset({ ...manual.value.offset, ...partiel }) }
|
||||
}
|
||||
|
||||
function yeuxPatch(yeux: EyeTune) {
|
||||
manual.value = { ...manual.value, eyes: clampEyes(yeux) }
|
||||
}
|
||||
|
||||
function oeil(cote: Cote, partiel: Partial<EyeSide>) {
|
||||
yeuxPatch(updateEye(manual.value.eyes, cote, partiel, manual.value.links))
|
||||
}
|
||||
|
||||
function angleOeil(cote: Cote, valeur: number) {
|
||||
const autre: Cote = cote === 'left' ? 'right' : 'left'
|
||||
const yeux = {
|
||||
left: { ...manual.value.eyes.left },
|
||||
right: { ...manual.value.eyes.right },
|
||||
spacing: manual.value.eyes.spacing
|
||||
}
|
||||
yeux[cote].angle = valeur
|
||||
if (manual.value.links.rotation) yeux[autre].angle = -valeur
|
||||
yeuxPatch(yeux)
|
||||
}
|
||||
|
||||
function basculeLien(cle: 'width' | 'height' | 'size' | 'rotation') {
|
||||
manual.value = {
|
||||
...manual.value,
|
||||
links: { ...manual.value.links, [cle]: !manual.value.links[cle] }
|
||||
}
|
||||
}
|
||||
|
||||
function reinitialisePose() {
|
||||
manual.value = resetAll(manual.value)
|
||||
}
|
||||
|
||||
/** Le champ pris met le feu sur la piece concernee, comme les gizmos. */
|
||||
function surligne(partie: 'body' | 'left' | 'right' | null) {
|
||||
if (partie && manual.value.selected !== partie) {
|
||||
manual.value = { ...manual.value, selected: partie }
|
||||
} else if (!partie && manual.value.selected) {
|
||||
manual.value = { ...manual.value, selected: null }
|
||||
}
|
||||
}
|
||||
|
||||
function aleatoire() {
|
||||
const style = fond.value === 'transparent' ? 'solid' : fond.value
|
||||
const palette = paletteAleatoire(style, inkHex(color.value), {
|
||||
couleurDe: couleurDe.value,
|
||||
couleurA: couleurA.value
|
||||
})
|
||||
couleurDe.value = palette.couleurDe
|
||||
couleurA.value = palette.couleurA
|
||||
if (fond.value === 'transparent') fond.value = 'solid'
|
||||
}
|
||||
|
||||
function echangeCouleurs() {
|
||||
;[couleurDe.value, couleurA.value] = [couleurA.value, couleurDe.value]
|
||||
}
|
||||
|
||||
function cadranPointe(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
angle.value = tourAngle(angleDepuisPointeur(el, e))
|
||||
}
|
||||
|
||||
function cadranGlisse(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (!el.hasPointerCapture(e.pointerId)) return
|
||||
angle.value = tourAngle(angleDepuisPointeur(el, e))
|
||||
}
|
||||
|
||||
function cadranClavier(e: KeyboardEvent) {
|
||||
const pas = PAS_CADRAN[e.key]
|
||||
if (pas == null) return
|
||||
e.preventDefault()
|
||||
angle.value = tourAngle(angle.value + pas)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<section v-if="manuel">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">{{ t('photo.poseTitle') }}</h2>
|
||||
<button type="button" class="photo-bouton" @click="reinitialisePose">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{{ t('photo.poseReset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-2.5 text-xs font-medium text-[var(--muted)]">{{ t('manual.head') }}</h3>
|
||||
<div class="mt-1 grid grid-cols-3 gap-1.5">
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.axisTilt') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="manual.pose.tilt"
|
||||
:step="1"
|
||||
@focus="surligne('body')"
|
||||
@blur="surligne(null)"
|
||||
@change="posePatch({ tilt: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.axisTurn') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="manual.pose.turn"
|
||||
:step="1"
|
||||
@focus="surligne('body')"
|
||||
@blur="surligne(null)"
|
||||
@change="posePatch({ turn: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.axisRoll') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="manual.pose.roll"
|
||||
:step="1"
|
||||
@focus="surligne('body')"
|
||||
@blur="surligne(null)"
|
||||
@change="posePatch({ roll: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-2.5 text-xs font-medium text-[var(--muted)]">{{ t('manual.body') }}</h3>
|
||||
<div class="mt-1 grid grid-cols-3 gap-1.5">
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.posH') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="manual.offset.tx"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:step="1"
|
||||
@focus="surligne('body')"
|
||||
@blur="surligne(null)"
|
||||
@change="offsetPatch({ tx: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.posV') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="manual.offset.ty"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:step="1"
|
||||
@focus="surligne('body')"
|
||||
@blur="surligne(null)"
|
||||
@change="offsetPatch({ ty: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('manual.spin') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="manual.offset.spin"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:step="1"
|
||||
@focus="surligne('body')"
|
||||
@blur="surligne(null)"
|
||||
@change="offsetPatch({ spin: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="manuel">
|
||||
<h2 class="text-sm font-semibold">{{ t('manual.eyes') }}</h2>
|
||||
|
||||
<div class="mt-2 grid grid-cols-[2.75rem_auto_1fr_1fr] items-center gap-x-1.5 gap-y-1">
|
||||
<span class="col-span-2" />
|
||||
<span class="photo-etiquette text-center">{{ t('photo.eyeLeft') }}</span>
|
||||
<span class="photo-etiquette text-center">{{ t('photo.eyeRight') }}</span>
|
||||
<template v-for="d in DIMENSIONS" :key="d.cle">
|
||||
<span class="photo-etiquette">{{ t(d.libelle) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="photo-lien"
|
||||
:aria-pressed="manual.links[d.cle]"
|
||||
:aria-label="t('photo.link')"
|
||||
:title="t('photo.link')"
|
||||
@click="basculeLien(d.cle)"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<template v-if="manual.links[d.cle]">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<path d="m13.5 7.5 1.9-1.9a4.24 4.24 0 0 1 6 6l-1.9 1.9" />
|
||||
<path d="m10.5 16.5-1.9 1.9a4.24 4.24 0 0 1-6-6l1.9-1.9" />
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
:value="manual.eyes.left[d.cle]"
|
||||
:min="d.min"
|
||||
:max="d.max"
|
||||
:step="d.pas"
|
||||
:aria-label="`${t('photo.eyeLeft')}·${t(d.libelle)}`"
|
||||
@focus="surligne('left')"
|
||||
@blur="surligne(null)"
|
||||
@change="oeil('left', { [d.cle]: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
:value="manual.eyes.right[d.cle]"
|
||||
:min="d.min"
|
||||
:max="d.max"
|
||||
:step="d.pas"
|
||||
:aria-label="`${t('photo.eyeRight')}·${t(d.libelle)}`"
|
||||
@focus="surligne('right')"
|
||||
@blur="surligne(null)"
|
||||
@change="oeil('right', { [d.cle]: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-2.5 text-xs font-medium text-[var(--muted)]">{{ t('photo.position') }}</h3>
|
||||
<div class="mt-1 grid grid-cols-[2.75rem_1fr_1fr_1fr] items-center gap-x-1.5 gap-y-1">
|
||||
<span />
|
||||
<span class="photo-etiquette text-center">{{ t('photo.posH') }}</span>
|
||||
<span class="photo-etiquette text-center">{{ t('photo.posV') }}</span>
|
||||
<span class="photo-etiquette text-center">{{ t('photo.angle') }}</span>
|
||||
<template v-for="cote in COTES" :key="cote">
|
||||
<span class="photo-etiquette">{{ t(cote === 'left' ? 'photo.eyeLeft' : 'photo.eyeRight') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="manual.eyes[cote].x"
|
||||
:min="-48"
|
||||
:max="48"
|
||||
:step="1"
|
||||
:aria-label="`${t(cote === 'left' ? 'photo.eyeLeft' : 'photo.eyeRight')}·${t('photo.posH')}`"
|
||||
@focus="surligne(cote)"
|
||||
@blur="surligne(null)"
|
||||
@change="oeil(cote, { x: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
:value="manual.eyes[cote].y"
|
||||
:min="-48"
|
||||
:max="48"
|
||||
:step="1"
|
||||
:aria-label="`${t(cote === 'left' ? 'photo.eyeLeft' : 'photo.eyeRight')}·${t('photo.posV')}`"
|
||||
@focus="surligne(cote)"
|
||||
@blur="surligne(null)"
|
||||
@change="oeil(cote, { y: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
:value="manual.eyes[cote].angle"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:step="1"
|
||||
:aria-label="`${t(cote === 'left' ? 'photo.eyeLeft' : 'photo.eyeRight')}·${t('photo.angle')}`"
|
||||
@focus="surligne(cote)"
|
||||
@blur="surligne(null)"
|
||||
@change="angleOeil(cote, num(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</template>
|
||||
<span class="photo-etiquette">{{ t('manual.spacing') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="col-span-3"
|
||||
:value="manual.eyes.spacing"
|
||||
:min="0"
|
||||
:max="150"
|
||||
:step="1"
|
||||
:aria-label="t('manual.spacing')"
|
||||
@change="yeuxPatch({ ...manual.eyes, spacing: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">{{ t('photo.composition') }}</h2>
|
||||
<button type="button" class="photo-bouton" @click="recentre">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
</svg>
|
||||
{{ t('photo.recentre') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.posH') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="composition.x"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:step="1"
|
||||
@change="patch({ x: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.posV') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="composition.y"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:step="1"
|
||||
@change="patch({ y: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.zoom') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="Math.round(composition.scale * 100)"
|
||||
:min="40"
|
||||
:max="300"
|
||||
:step="5"
|
||||
@change="patch({ scale: num(($event.target as HTMLInputElement).value) / 100 })"
|
||||
/>
|
||||
</label>
|
||||
<label class="photo-champ">
|
||||
<span class="photo-etiquette">{{ t('photo.corner') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="mt-0.5"
|
||||
:value="composition.cornerRadius"
|
||||
:min="0"
|
||||
:max="50"
|
||||
:step="1"
|
||||
@change="patch({ cornerRadius: num(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">{{ t('photo.background') }}</h2>
|
||||
<button type="button" class="photo-bouton" @click="aleatoire">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.8-1.1 2-1.7 3.3-1.7H22" />
|
||||
<path d="m18 2 4 4-4 4" />
|
||||
<path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2" />
|
||||
<path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8" />
|
||||
<path d="m18 14 4 4-4 4" />
|
||||
</svg>
|
||||
{{ t('photo.random') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 flex rounded-full bg-black/[0.04] p-0.5">
|
||||
<button
|
||||
v-for="id in FONDS"
|
||||
:key="id"
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-full px-1 py-1 text-xs whitespace-nowrap transition"
|
||||
:class="
|
||||
fond === id
|
||||
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
|
||||
: 'text-[var(--muted)] hover:text-[var(--ink)]'
|
||||
"
|
||||
:aria-pressed="fond === id"
|
||||
@click="fond = id"
|
||||
>
|
||||
{{ t(LIBELLES_FONDS[id]) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="fond === 'solid'" class="mt-2 grid grid-cols-6 gap-1.5">
|
||||
<button
|
||||
v-for="id in COLOR_IDS"
|
||||
:key="id"
|
||||
type="button"
|
||||
class="flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
|
||||
:class="
|
||||
estNuancier(id) ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
|
||||
"
|
||||
:aria-label="id"
|
||||
:aria-pressed="estNuancier(id)"
|
||||
:title="id"
|
||||
@click="choisitNuancier(id)"
|
||||
>
|
||||
<span
|
||||
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
|
||||
:style="{ background: colorFill(id) }"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="relative flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
|
||||
:class="
|
||||
estPerso() ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
|
||||
"
|
||||
:title="t('panel.custom')"
|
||||
>
|
||||
<span
|
||||
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
|
||||
:style="{ background: estPerso() ? couleurDe : PERSO_FOND }"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
:value="couleurDe"
|
||||
:aria-label="t('panel.custom')"
|
||||
@input="saisitPerso"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="fond === 'linear' || fond === 'radial'"
|
||||
class="mt-2 flex items-center gap-2"
|
||||
>
|
||||
<div class="teinte-barre min-w-0 flex-1" :style="styleFond(fond)">
|
||||
<label
|
||||
v-if="fond === 'linear'"
|
||||
class="photo-puce"
|
||||
:title="couleurDe.toUpperCase()"
|
||||
:style="{ background: couleurDe }"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="couleurDe"
|
||||
:aria-label="t('photo.colorStart')"
|
||||
@input="couleurDe = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="fond === 'linear'"
|
||||
type="button"
|
||||
class="absolute top-1/2 left-1/2 z-10 grid h-6 w-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-white text-[var(--muted)] shadow-sm ring-1 ring-black/10 transition hover:text-[var(--ink)]"
|
||||
:aria-label="t('panel.swap')"
|
||||
:title="t('panel.swap')"
|
||||
@click="echangeCouleurs"
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3 4 7l4 4" />
|
||||
<path d="M4 7h16" />
|
||||
<path d="m16 21 4-4-4-4" />
|
||||
<path d="M20 17H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<label
|
||||
v-if="fond === 'radial'"
|
||||
class="photo-puce photo-puce--coeur"
|
||||
:title="couleurDe.toUpperCase()"
|
||||
:style="{ background: couleurDe }"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="couleurDe"
|
||||
:aria-label="t('photo.colorInner')"
|
||||
@input="couleurDe = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
class="photo-puce photo-puce--fin"
|
||||
:title="couleurA.toUpperCase()"
|
||||
:style="{ background: couleurA }"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="couleurA"
|
||||
:aria-label="fond === 'radial' ? t('photo.colorOuter') : t('photo.colorEnd')"
|
||||
@input="couleurA = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
v-if="fond === 'linear'"
|
||||
type="button"
|
||||
class="teinte-cadran"
|
||||
:style="styleFond(fond)"
|
||||
:aria-label="t('photo.direction')"
|
||||
:title="`${angle}°`"
|
||||
role="slider"
|
||||
:aria-valuenow="angle"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="359"
|
||||
@pointerdown="cadranPointe"
|
||||
@pointermove="cadranGlisse"
|
||||
@keydown="cadranClavier"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0"
|
||||
:style="{ transform: `rotate(${angle}deg)` }"
|
||||
>
|
||||
<i class="teinte-cadran-aiguille" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="fond === 'radial'"
|
||||
type="button"
|
||||
class="grid h-9 w-9 shrink-0 cursor-pointer place-items-center rounded-full bg-white text-[var(--muted)] shadow-sm ring-1 ring-black/10 transition hover:text-[var(--ink)]"
|
||||
:aria-label="t('panel.swapRadial')"
|
||||
:title="t('panel.swapRadial')"
|
||||
@click="echangeCouleurs"
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3 4 7l4 4" />
|
||||
<path d="M4 7h16" />
|
||||
<path d="m16 21 4-4-4-4" />
|
||||
<path d="M20 17H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold">{{ t('photo.expression') }}</h2>
|
||||
<div class="mt-2 grid grid-cols-4 gap-1.5">
|
||||
<BotTile
|
||||
v-for="id in EXPRESSION_IDS"
|
||||
:key="id"
|
||||
:label="stateName(id)"
|
||||
:selected="!manuel && id === expression"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:state="id"
|
||||
:active="active"
|
||||
@click="expression = id"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold">{{ t('photo.exportTitle') }}</h2>
|
||||
<div class="mt-2 flex rounded-full bg-black/[0.04] p-0.5">
|
||||
<button
|
||||
v-for="id in ['png', 'svg'] as const"
|
||||
:key="id"
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-full py-1 text-xs uppercase transition"
|
||||
:class="
|
||||
id === format
|
||||
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
|
||||
: 'text-[var(--muted)] hover:text-[var(--ink)]'
|
||||
"
|
||||
:aria-pressed="id === format"
|
||||
@click="format = id"
|
||||
>
|
||||
{{ id }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1.5 flex rounded-full bg-black/[0.04] p-0.5">
|
||||
<button
|
||||
v-for="n in TAILLES"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-full py-1 text-xs transition"
|
||||
:class="
|
||||
n === taille
|
||||
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
|
||||
: 'text-[var(--muted)] hover:text-[var(--ink)]'
|
||||
"
|
||||
:aria-pressed="n === taille"
|
||||
@click="taille = n"
|
||||
>
|
||||
{{ t('photo.pixels', { n }) }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
|
||||
import PlaybackDetails from '@/components/PlaybackDetails.vue'
|
||||
import PlaybackIdentity from '@/components/PlaybackIdentity.vue'
|
||||
import PlaybackStep from '@/components/PlaybackStep.vue'
|
||||
import { playlistOf } from '@/replica/motion'
|
||||
import { stateName, t } from '@/i18n'
|
||||
import { playbackStatus } from '@/ui/playback'
|
||||
import { useSnapSheet } from '@/ui/useSnapSheet'
|
||||
|
||||
const props = defineProps<{
|
||||
expression: string
|
||||
cursor: number
|
||||
elapsed: number
|
||||
hold: number
|
||||
shape: string
|
||||
color: string
|
||||
active?: boolean
|
||||
ready?: boolean
|
||||
stopped?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
seek: [index: number]
|
||||
stop: []
|
||||
}>()
|
||||
|
||||
const playing = defineModel<boolean>('playing', { required: true })
|
||||
|
||||
const details = useTemplateRef<HTMLElement>('details')
|
||||
const dock = useTemplateRef<HTMLElement>('dock')
|
||||
const footer = useTemplateRef<HTMLElement>('footer')
|
||||
const {
|
||||
y,
|
||||
expanded,
|
||||
dragging,
|
||||
opacity,
|
||||
ready,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp
|
||||
} = useSnapSheet(details)
|
||||
|
||||
const poses = computed(() => {
|
||||
void props.ready
|
||||
return playlistOf(props.expression)
|
||||
})
|
||||
const name = computed(() => stateName(props.expression))
|
||||
const status = computed(() => playbackStatus(playing.value, props.stopped))
|
||||
|
||||
function pick(index: number) {
|
||||
emit('seek', index)
|
||||
}
|
||||
|
||||
function publish() {
|
||||
const host = dock.value?.closest('.playback-host')
|
||||
if (!(host instanceof HTMLElement) || !footer.value) return
|
||||
const visible = Math.max(72, footer.value.offsetHeight - y.value)
|
||||
host.style.setProperty('--playback-clearance', `${visible + 6}px`)
|
||||
}
|
||||
|
||||
watch(
|
||||
[y, expanded, ready, () => poses.value.length],
|
||||
() => void nextTick(publish),
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
onMounted(() => void nextTick(publish))
|
||||
|
||||
watch(
|
||||
() => props.cursor,
|
||||
async () => {
|
||||
await nextTick()
|
||||
dock.value
|
||||
?.querySelector<HTMLElement>('.playback-step[aria-pressed="true"]')
|
||||
?.scrollIntoView({ inline: 'nearest', block: 'nearest' })
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const host = dock.value?.closest('.playback-host')
|
||||
if (host instanceof HTMLElement) host.style.removeProperty('--playback-clearance')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="dock" class="playback-dock">
|
||||
<footer
|
||||
ref="footer"
|
||||
class="playback-bar"
|
||||
:class="{
|
||||
'is-expanded': expanded,
|
||||
'is-dragging': dragging,
|
||||
'is-ready': ready
|
||||
}"
|
||||
:style="{ transform: `translateY(${y}px)` }"
|
||||
>
|
||||
<div class="playback-handle-slot">
|
||||
<button
|
||||
type="button"
|
||||
class="playback-handle"
|
||||
:aria-expanded="expanded"
|
||||
:aria-label="expanded ? t('playback.hide') : t('playback.show')"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="playback-strip">
|
||||
<div v-if="poses.length > 1" class="playback-steps">
|
||||
<PlaybackStep
|
||||
v-for="(eye, index) in poses"
|
||||
:key="`${expression}-${eye}-${index}`"
|
||||
:state="expression"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:eye="eye"
|
||||
:current="index === cursor"
|
||||
:elapsed="index === cursor ? elapsed : 0"
|
||||
:duration="hold"
|
||||
:active="active"
|
||||
@pick="pick(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="playback-controls">
|
||||
<PlaybackIdentity :name="name" :status="status" />
|
||||
<button
|
||||
type="button"
|
||||
class="playback-btn"
|
||||
:aria-label="
|
||||
playing ? t('playback.pause', { name }) : t('playback.play', { name })
|
||||
"
|
||||
@click="playing = !playing"
|
||||
>
|
||||
<svg v-if="!playing" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21.4086 9.35258C23.5305 10.5065 23.5305 13.4935 21.4086 14.6474L8.59662 21.6145C6.53435 22.736 4 21.2763 4 18.9671L4 5.0329C4 2.72368 6.53435 1.26402 8.59661 2.38548L21.4086 9.35258Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<g fill="currentColor">
|
||||
<path
|
||||
d="M2 6C2 4.11438 2 3.17157 2.58579 2.58579C3.17157 2 4.11438 2 6 2C7.88562 2 8.82843 2 9.41421 2.58579C10 3.17157 10 4.11438 10 6V18C10 19.8856 10 20.8284 9.41421 21.4142C8.82843 22 7.88562 22 6 22C4.11438 22 3.17157 22 2.58579 21.4142C2 20.8284 2 19.8856 2 18V6Z"
|
||||
/>
|
||||
<path
|
||||
d="M14 6C14 4.11438 14 3.17157 14.5858 2.58579C15.1716 2 16.1144 2 18 2C19.8856 2 20.8284 2 21.4142 2.58579C22 3.17157 22 4.11438 22 6V18C22 19.8856 22 20.8284 21.4142 21.4142C20.8284 22 19.8856 22 18 22C16.1144 22 15.1716 22 14.5858 21.4142C14 20.8284 14 3.17157 14 18V6Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="playback-btn playback-btn--ghost"
|
||||
:aria-label="t('playback.stop', { name })"
|
||||
@click="emit('stop')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6 3C4.11438 3 3.17157 3 2.58579 3.58579C2 4.17157 2 5.11438 2 7V17C2 18.8856 2 19.8284 2.58579 20.4142C3.17157 21 4.11438 21 6 21H18C19.8856 21 20.8284 21 21.4142 20.4142C22 19.8284 22 18.8856 22 17V7C22 5.11438 22 4.17157 21.4142 3.58579C20.8284 3 19.8856 3 18 3H6Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="details"
|
||||
class="playback-details-shell"
|
||||
:style="{ opacity }"
|
||||
:aria-hidden="!expanded"
|
||||
>
|
||||
<PlaybackDetails
|
||||
:expression="expression"
|
||||
:index="cursor"
|
||||
:elapsed="elapsed"
|
||||
:hold="hold"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playback-bar {
|
||||
position: relative;
|
||||
padding: 18px 6px 4px 12px;
|
||||
border-top: 1px solid #d9dce3;
|
||||
background: rgb(250 251 253 / 96%);
|
||||
box-shadow: 0 -10px 30px rgb(24 28 35 / 8%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.playback-bar.is-ready {
|
||||
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.playback-bar.is-dragging {
|
||||
transition: none;
|
||||
}
|
||||
.playback-handle-slot {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.playback-handle {
|
||||
display: grid;
|
||||
width: 64px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: grab;
|
||||
place-items: center;
|
||||
touch-action: none;
|
||||
}
|
||||
.playback-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.playback-handle > span {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: #b9bfca;
|
||||
transition:
|
||||
width 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
.playback-handle:hover > span,
|
||||
.playback-handle:focus-visible > span {
|
||||
width: 44px;
|
||||
background: #5b7fe5;
|
||||
}
|
||||
.playback-strip {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.playback-steps {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 8px 2px 2px;
|
||||
scrollbar-color: #cbd2df transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.playback-controls {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.playback-steps + .playback-controls {
|
||||
padding-left: 14px;
|
||||
border-left: 1px solid #dfe3eb;
|
||||
}
|
||||
.playback-controls :deep(.playback-identity) {
|
||||
max-width: 64px;
|
||||
}
|
||||
.playback-btn {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #edf1ff;
|
||||
color: #4168d5;
|
||||
cursor: pointer;
|
||||
}
|
||||
.playback-btn--ghost {
|
||||
background: transparent;
|
||||
color: #7f8692;
|
||||
}
|
||||
.playback-btn:hover {
|
||||
background: #e0e7ff;
|
||||
}
|
||||
.playback-btn--ghost:hover {
|
||||
background: #f0f1f4;
|
||||
color: #343943;
|
||||
}
|
||||
.playback-details-shell {
|
||||
will-change: opacity;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.playback-bar.is-ready,
|
||||
.playback-handle > span {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FIRST_BLINK_MS, blinkRangeOf, holdRangeOf, playlistOf } from '@/replica/motion'
|
||||
import { secondes, secondesCourtes, stateName, t } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
expression: string
|
||||
index: number
|
||||
elapsed: number
|
||||
hold: number
|
||||
}>()
|
||||
|
||||
const poses = computed(() => playlistOf(props.expression))
|
||||
const hold = computed(() => holdRangeOf(props.expression))
|
||||
const blink = computed(() => blinkRangeOf(props.expression))
|
||||
|
||||
function span(range: [number, number]) {
|
||||
return `${secondesCourtes(range[0] / 1000, 1)}–${secondesCourtes(range[1] / 1000, 1)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="playback-details">
|
||||
<div class="playback-details-header">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t('playback.details') }}</p>
|
||||
<h2>{{ stateName(expression) }}</h2>
|
||||
<p>{{ t('playback.summary', { n: poses.length }) }}</p>
|
||||
</div>
|
||||
<span class="playback-badge">{{ t('playback.mode') }} · {{ t('playback.loop') }}</span>
|
||||
</div>
|
||||
<div class="playback-detail-grid">
|
||||
<div>
|
||||
<span>{{ t('playback.clips') }}</span>
|
||||
<strong>{{ poses.length }}</strong>
|
||||
<small>{{ span(hold) }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('playback.blinkFirst') }}</span>
|
||||
<strong>{{ blink ? span(FIRST_BLINK_MS) : t('playback.blinkOff') }}</strong>
|
||||
<small>{{ t('playback.blinkAfter') }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('playback.blinkGap') }}</span>
|
||||
<strong>{{ blink ? span(blink) : t('playback.blinkOff') }}</strong>
|
||||
<small>{{ t('playback.blinkRand') }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('playback.current') }}</span>
|
||||
<strong>{{ t('playback.stepOf', { n: index + 1, m: poses.length }) }}</strong>
|
||||
<small>
|
||||
{{ secondesCourtes(elapsed, 1) }} / {{ secondes(props.hold) }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playback-details {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px 2px 6px;
|
||||
border-top: 1px solid #dfe3eb;
|
||||
}
|
||||
.playback-details-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.playback-details-header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #7185c4;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.playback-details-header h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.playback-details-header p:last-child {
|
||||
margin: 5px 0 0;
|
||||
color: #7f8692;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.playback-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #edf1ff;
|
||||
color: #4168d5;
|
||||
font-size: 8px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.playback-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 7px;
|
||||
}
|
||||
.playback-detail-grid > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e1e5ec;
|
||||
border-radius: 11px;
|
||||
background: white;
|
||||
}
|
||||
.playback-detail-grid span,
|
||||
.playback-detail-grid small {
|
||||
overflow: hidden;
|
||||
color: #858b96;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.playback-detail-grid span {
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.playback-detail-grid strong {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { Cle } from '@/i18n'
|
||||
import { t } from '@/i18n'
|
||||
import type { PlaybackStatus } from '@/ui/playback'
|
||||
|
||||
defineProps<{ name: string; status: PlaybackStatus }>()
|
||||
|
||||
const LABEL: Record<PlaybackStatus, Cle> = {
|
||||
playing: 'playback.playing',
|
||||
paused: 'playback.paused',
|
||||
stopped: 'playback.stopped'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="playback-identity" :class="'is-' + status">
|
||||
<i />
|
||||
<span>
|
||||
<small>{{ t(LABEL[status]) }}</small>
|
||||
<strong>{{ name }}</strong>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playback-identity {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #33834b;
|
||||
}
|
||||
.playback-identity.is-paused {
|
||||
color: #8a6a24;
|
||||
}
|
||||
.playback-identity.is-stopped {
|
||||
color: #7f8692;
|
||||
}
|
||||
.playback-identity i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
.playback-identity > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
.playback-identity small {
|
||||
color: #9297a1;
|
||||
font-size: 8px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.playback-identity strong {
|
||||
overflow: hidden;
|
||||
color: #343943;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import BotTile from '@/components/BotTile.vue'
|
||||
import { stateName, t } from '@/i18n'
|
||||
import { countdownAngle } from '@/ui/playback'
|
||||
|
||||
const props = defineProps<{
|
||||
state: string
|
||||
shape: string
|
||||
color: string
|
||||
eye: number
|
||||
current: boolean
|
||||
elapsed: number
|
||||
duration: number
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ pick: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="playback-step"
|
||||
:aria-pressed="current"
|
||||
:aria-label="t('timeline.blockAria', { state: stateName(state), duration: duration })"
|
||||
@click="emit('pick')"
|
||||
>
|
||||
<BotTile
|
||||
:label="stateName(state)"
|
||||
:selected="false"
|
||||
:state="state"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:eye="eye"
|
||||
:size="44"
|
||||
:interactive="false"
|
||||
:show-label="false"
|
||||
:active="active"
|
||||
/>
|
||||
<span
|
||||
v-if="current"
|
||||
class="playback-countdown"
|
||||
aria-hidden="true"
|
||||
:style="{ '--countdown': `${countdownAngle(elapsed, duration)}deg` }"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playback-step {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 48px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid #e1e5ec;
|
||||
border-radius: 11px;
|
||||
background: white;
|
||||
box-shadow: 0 3px 10px rgb(24 28 35 / 5%);
|
||||
cursor: pointer;
|
||||
}
|
||||
.playback-step :deep(div) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.playback-step :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.playback-step[aria-pressed='true'] {
|
||||
border-color: #aebff0;
|
||||
background: #edf1ff;
|
||||
box-shadow: 0 0 0 2px rgb(91 127 229 / 16%);
|
||||
}
|
||||
.playback-countdown {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(#4f72d8 var(--countdown), white 0);
|
||||
box-shadow: 0 1px 4px rgb(24 28 35 / 22%);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { langue, LANGUES, t } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
ready: boolean
|
||||
}>()
|
||||
|
||||
function auClavier(event: KeyboardEvent, index: number) {
|
||||
const pas = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key]
|
||||
if (!pas) return
|
||||
event.preventDefault()
|
||||
const cible = LANGUES[(index + pas + LANGUES.length) % LANGUES.length]!
|
||||
langue.value = cible.id
|
||||
const boutons = (event.currentTarget as HTMLElement).parentElement?.children
|
||||
const suivant = boutons?.[LANGUES.indexOf(cible)]
|
||||
if (suivant instanceof HTMLElement) suivant.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">{{ t('settings.language') }}</h2>
|
||||
<div class="mt-2 flex flex-col gap-1" role="radiogroup" :aria-label="t('settings.language')">
|
||||
<button
|
||||
v-for="(l, i) in LANGUES"
|
||||
:key="l.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="l.id === langue"
|
||||
:aria-label="l.nom"
|
||||
:lang="l.tag"
|
||||
:tabindex="l.id === langue ? 0 : -1"
|
||||
class="flex cursor-pointer items-center gap-2.5 rounded-xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="
|
||||
l.id === langue
|
||||
? 'border-[var(--ink)] bg-white font-medium'
|
||||
: 'border-[var(--line)] text-[var(--muted)] hover:border-[var(--muted)] hover:text-[var(--ink)]'
|
||||
"
|
||||
@keydown="auClavier($event, i)"
|
||||
@click="langue = l.id"
|
||||
>
|
||||
<span class="text-base leading-none" aria-hidden="true">{{ l.emoji }}</span>
|
||||
<span class="flex-1">{{ l.nom }}</span>
|
||||
<svg
|
||||
v-if="l.id === langue"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
aria-hidden="true"
|
||||
class="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M2.5 6.4 4.8 8.7 9.5 3.6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-6 text-sm font-semibold">{{ t('settings.about') }}</h2>
|
||||
<a
|
||||
class="mt-2 flex items-center gap-2 rounded-xl border border-[var(--line)] px-3 py-2 text-sm transition hover:border-[var(--muted)]"
|
||||
href="/replica/index.html"
|
||||
:aria-label="t('settings.labAria')"
|
||||
>
|
||||
<span class="flex-1">{{ t('settings.lab') }}</span>
|
||||
</a>
|
||||
<a
|
||||
class="mt-1.5 flex items-center gap-2 rounded-xl border border-[var(--line)] px-3 py-2 text-sm transition hover:border-[var(--muted)]"
|
||||
href="/replica/showcase.html"
|
||||
:aria-label="t('settings.showcaseAria')"
|
||||
>
|
||||
<span class="flex-1">{{ t('settings.showcase') }}</span>
|
||||
</a>
|
||||
<a
|
||||
class="mt-1.5 flex items-center gap-2 rounded-xl border border-[var(--line)] px-3 py-2 text-sm transition hover:border-[var(--muted)]"
|
||||
href="https://github.com/blessonism/grok-icon-study"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="t('settings.githubAria')"
|
||||
>
|
||||
<span class="flex-1">{{ t('settings.github') }}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
aria-hidden="true"
|
||||
class="shrink-0 text-[var(--muted)]"
|
||||
>
|
||||
<path
|
||||
d="M3 9 9 3M9 3H4.5M9 3v4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<p class="mt-4 text-xs text-[var(--muted)]">{{ t('settings.credits') }}</p>
|
||||
<p v-if="!ready" class="mt-2 text-xs text-[var(--muted)]">{{ t('settings.missing') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export type ViewId = 'animations' | 'personnaliser' | 'reglages'
|
||||
|
||||
const view = defineModel<ViewId>({ default: 'personnaliser' })
|
||||
|
||||
/**
|
||||
* `computed` et pas une constante de module : les libelles changent avec la
|
||||
* langue, et un tableau construit a l'import resterait dans celle du demarrage.
|
||||
*/
|
||||
const ITEMS = computed<Array<{ id: ViewId; label: string }>>(() => [
|
||||
{ id: 'personnaliser', label: t('rail.customize') },
|
||||
{ id: 'animations', label: t('rail.animations') },
|
||||
{ id: 'reglages', label: t('rail.settings') }
|
||||
])
|
||||
|
||||
/**
|
||||
* Icone dont l'infobulle est masquee jusqu'a ce que le pointeur reparte : une
|
||||
* fois le clic fait la vue a change, l'infobulle n'a plus rien a annoncer et
|
||||
* rester affichee sous le curseur donne l'impression qu'elle est coincee.
|
||||
* `pointerdown` et pas `click` : au clavier (Entree) rien n'est masque, la
|
||||
* l'infobulle est la seule indication de ce qu'on vient de choisir.
|
||||
*/
|
||||
const muted = ref<ViewId | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
Deux dispositions, et c'est la mise en page de la page qui les impose.
|
||||
|
||||
A partir de 64rem la scene est une grille de trois colonnes bordee de marges
|
||||
larges : le rail flotte dans celle de gauche sans rien recouvrir. En dessous
|
||||
tout s'empile sur la largeur entiere, et ce meme flottement tombait en plein
|
||||
milieu du contenu — mesure a 375 px : la pilule couvrait le titre du panneau
|
||||
des animations et le flanc gauche de l'avatar dans les reglages.
|
||||
|
||||
Il passe donc en BANDE HAUTE, horizontale et centree, et la scene lui reserve
|
||||
sa hauteur (`max-lg:pt-20` dans `App.vue`). En haut et pas en bas : la barre
|
||||
de montage occupe deja le bas de la fenetre dans les animations, et empiler
|
||||
les deux prenait 260 px sur 812.
|
||||
-->
|
||||
<nav
|
||||
class="fixed top-3 left-1/2 z-20 -translate-x-1/2 rounded-2xl border border-[var(--line)] bg-white/85 p-1.5 shadow-sm backdrop-blur lg:top-1/2 lg:left-4 lg:translate-x-0 lg:-translate-y-1/2"
|
||||
:aria-label="t('rail.nav')"
|
||||
>
|
||||
<ul class="flex gap-1 lg:flex-col">
|
||||
<li
|
||||
v-for="item in ITEMS"
|
||||
:key="item.id"
|
||||
class="group relative"
|
||||
@pointerleave="muted = null"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="peer flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl transition"
|
||||
:class="
|
||||
view === item.id
|
||||
? 'bg-[var(--ink)] text-[var(--paper)]'
|
||||
: 'text-[var(--muted)] hover:bg-black/5 hover:text-[var(--ink)]'
|
||||
"
|
||||
:aria-label="item.label"
|
||||
:aria-current="view === item.id ? 'page' : undefined"
|
||||
@pointerdown="muted = item.id"
|
||||
@click="view = item.id"
|
||||
>
|
||||
<!--
|
||||
Les trois icones viennent de bibliotheques, pas d'un trace maison :
|
||||
les formes pleines et arrondies tiennent a 18 px, ce que ne faisaient
|
||||
pas les contours fins d'avant. Les traces sont recopies tels quels
|
||||
depuis les paquets Iconify — pour en changer, prendre le corps du
|
||||
`<symbol>` dans `@iconify-json/<paquet>`, pas le redessiner. Ce sont
|
||||
les seules formes du projet qui ne sont PAS relevees sur la video.
|
||||
|
||||
Animations : le clap de Solar (`solar:clapperboard-play-bold`).
|
||||
-->
|
||||
<svg
|
||||
v-if="item.id === 'animations'"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M12 2C13.8452 2 15.3293 2 16.5401 2.08783L13.0986 7.25002H8.40139L11.9014 2H12Z" />
|
||||
<path d="M3.46447 3.46447C4.71683 2.2121 6.62194 2.03072 10.0957 2.00445L6.59861 7.25002H2.10418C2.25143 5.48593 2.6068 4.32213 3.46447 3.46447Z" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 12C2 10.7633 2 9.68875 2.02644 8.75002H21.9736C22 9.68875 22 10.7633 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM13.014 12.5852C14.338 13.4395 15 13.8666 15 14.5C15 15.1334 14.338 15.5605 13.014 16.4148C11.6719 17.2807 11.0008 17.7137 10.5004 17.3958C10 17.0779 10 16.2186 10 14.5C10 12.7814 10 11.9221 10.5004 11.6042C11.0008 11.2863 11.6719 11.7193 13.014 12.5852Z" />
|
||||
<path d="M21.8958 7.25002C21.7486 5.48593 21.3932 4.32213 20.5355 3.46447C19.9382 2.86714 19.1924 2.51345 18.1987 2.30403L14.9014 7.25002H21.8958Z" />
|
||||
</g>
|
||||
</svg>
|
||||
<!--
|
||||
Reglages : la roue dentee de Solar (`solar:settings-bold`). Elle est
|
||||
centree sur x = 12,5 et non 12 dans sa boite : c'est le trace de la
|
||||
bibliotheque, ne pas le « recentrer », le decalage est invisible a
|
||||
18 px et toucher aux coordonnees deforme les dents.
|
||||
-->
|
||||
<svg
|
||||
v-else-if="item.id === 'reglages'"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.2788 2.15224C13.9085 2 13.439 2 12.5 2C11.561 2 11.0915 2 10.7212 2.15224C10.2274 2.35523 9.83509 2.74458 9.63056 3.23463C9.53719 3.45834 9.50065 3.7185 9.48635 4.09799C9.46534 4.65568 9.17716 5.17189 8.69017 5.45093C8.20318 5.72996 7.60864 5.71954 7.11149 5.45876C6.77318 5.2813 6.52789 5.18262 6.28599 5.15102C5.75609 5.08178 5.22018 5.22429 4.79616 5.5472C4.47814 5.78938 4.24339 6.1929 3.7739 6.99993C3.30441 7.80697 3.06967 8.21048 3.01735 8.60491C2.94758 9.1308 3.09118 9.66266 3.41655 10.0835C3.56506 10.2756 3.77377 10.437 4.0977 10.639C4.57391 10.936 4.88032 11.4419 4.88029 12C4.88026 12.5581 4.57386 13.0639 4.0977 13.3608C3.77372 13.5629 3.56497 13.7244 3.41645 13.9165C3.09108 14.3373 2.94749 14.8691 3.01725 15.395C3.06957 15.7894 3.30432 16.193 3.7738 17C4.24329 17.807 4.47804 18.2106 4.79606 18.4527C5.22008 18.7756 5.75599 18.9181 6.28589 18.8489C6.52778 18.8173 6.77305 18.7186 7.11133 18.5412C7.60852 18.2804 8.2031 18.27 8.69012 18.549C9.17714 18.8281 9.46533 19.3443 9.48635 19.9021C9.50065 20.2815 9.53719 20.5417 9.63056 20.7654C9.83509 21.2554 10.2274 21.6448 10.7212 21.8478C11.0915 22 11.561 22 12.5 22C13.439 22 13.9085 22 14.2788 21.8478C14.7726 21.6448 15.1649 21.2554 15.3694 20.7654C15.4628 20.5417 15.4994 20.2815 15.5137 19.902C15.5347 19.3443 15.8228 18.8281 16.3098 18.549C16.7968 18.2699 17.3914 18.2804 17.8886 18.5412C18.2269 18.7186 18.4721 18.8172 18.714 18.8488C19.2439 18.9181 19.7798 18.7756 20.2038 18.4527C20.5219 18.2105 20.7566 17.807 21.2261 16.9999C21.6956 16.1929 21.9303 15.7894 21.9827 15.395C22.0524 14.8691 21.9088 14.3372 21.5835 13.9164C21.4349 13.7243 21.2262 13.5628 20.9022 13.3608C20.4261 13.0639 20.1197 12.558 20.1197 11.9999C20.1197 11.4418 20.4261 10.9361 20.9022 10.6392C21.2263 10.4371 21.435 10.2757 21.5836 10.0835C21.9089 9.66273 22.0525 9.13087 21.9828 8.60497C21.9304 8.21055 21.6957 7.80703 21.2262 7C20.7567 6.19297 20.522 5.78945 20.2039 5.54727C19.7799 5.22436 19.244 5.08185 18.7141 5.15109C18.4722 5.18269 18.2269 5.28136 17.8887 5.4588C17.3915 5.71959 16.7969 5.73002 16.3099 5.45096C15.8229 5.17191 15.5347 4.65566 15.5136 4.09794C15.4993 3.71848 15.4628 3.45833 15.3694 3.23463C15.1649 2.74458 14.7726 2.35523 14.2788 2.15224ZM12.5 15C14.1695 15 15.5228 13.6569 15.5228 12C15.5228 10.3431 14.1695 9 12.5 9C10.8305 9 9.47716 10.3431 9.47716 12C9.47716 13.6569 10.8305 15 12.5 15Z"
|
||||
/>
|
||||
</svg>
|
||||
<!--
|
||||
Personnaliser : la palette de Remix (`ri:palette-fill`) et pas celle
|
||||
de Solar, qui se lit mal a cette taille. Melange de bibliotheques
|
||||
assume : c'est la seule des trois ou Solar ne convenait pas.
|
||||
-->
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2c5.522 0 10 3.978 10 8.889a5.56 5.56 0 0 1-5.556 5.555h-1.966c-.922 0-1.667.745-1.667 1.667c0 .422.167.811.422 1.1c.267.3.434.689.434 1.122C13.667 21.256 12.9 22 12 22C6.478 22 2 17.522 2 12S6.478 2 12 2M7.5 12a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3m9 0a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3M12 9a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!--
|
||||
Infobulle au survol ET au focus clavier : sinon la barre n'est
|
||||
utilisable qu'a la souris. `peer-focus-visible` et pas `focus-within` :
|
||||
apres un clic souris le bouton garde le focus, et l'infobulle restait
|
||||
affichee alors que le pointeur etait deja parti. Passer par `peer`
|
||||
(frere du bouton) plutot que par `group-has-[:focus-visible]` : dans
|
||||
`:has()`, Chromium ne reevalue pas `:focus-visible` et l'infobulle
|
||||
restait collee a l'ecran.
|
||||
-->
|
||||
<!--
|
||||
L'infobulle suit le rail : sous le bouton quand la barre est en haut, a
|
||||
sa droite quand elle est sur le flanc. Elle ne fait que se FONDRE sur
|
||||
petit ecran, la ou elle glisse a partir de 64rem : le glissement s'ecrit
|
||||
sur `translate-x`, deja pris par le centrage horizontal en bande haute,
|
||||
et deux valeurs sur le meme axe s'ecrasent au lieu de se composer.
|
||||
-->
|
||||
<span
|
||||
class="pointer-events-none absolute top-full left-1/2 z-10 mt-2 -translate-x-1/2 rounded-lg bg-[var(--ink)] px-2.5 py-1.5 text-xs whitespace-nowrap text-[var(--paper)] opacity-0 transition peer-focus-visible:opacity-100 group-hover:opacity-100 lg:top-1/2 lg:left-full lg:mt-0 lg:ml-2 lg:-translate-y-1/2 lg:translate-x-1 lg:peer-focus-visible:translate-x-0 lg:group-hover:translate-x-0"
|
||||
:class="muted === item.id && 'opacity-0! lg:translate-x-1!'"
|
||||
role="tooltip"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import CycleMenu from '@/components/CycleMenu.vue'
|
||||
import NameDialog from '@/components/NameDialog.vue'
|
||||
import TimelineTrack from '@/components/TimelineTrack.vue'
|
||||
import ZoomSlider from '@/components/ZoomSlider.vue'
|
||||
import {
|
||||
blocksWith,
|
||||
makeBlock,
|
||||
MAX_CYCLES,
|
||||
nextCycleId,
|
||||
offsetOf,
|
||||
totalDuration,
|
||||
uniqueName,
|
||||
type Block,
|
||||
type Cycle
|
||||
} from '@/editor/cycles'
|
||||
import type { StateId } from '@/editor/cycles'
|
||||
import { MAX_ZOOM, MIN_ZOOM, mmss } from '@/ui/timeline'
|
||||
import { nomDeCycle, pluriel, t } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Barre de montage : elle tient les cycles (choix, creation, renommage,
|
||||
* suppression) et la lecture. La piste elle-meme est dans `TimelineTrack` —
|
||||
* ici on ne sait rien des gestes, la-bas on ne sait rien des cycles.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** temps ecoule dans le bloc courant, pour le compteur */
|
||||
elapsed: number
|
||||
shape: string
|
||||
color: string
|
||||
expression: string
|
||||
}>()
|
||||
|
||||
/**
|
||||
* `seek` : deplacement de la tete de lecture, seul le lecteur sait recaler le
|
||||
* moteur. `preview` : la page entiere se met en scene, c'est elle qui decide.
|
||||
*/
|
||||
const emit = defineEmits<{ seek: [seconds: number]; preview: []; exporter: [] }>()
|
||||
|
||||
const cycles = defineModel<Cycle[]>('cycles', { required: true })
|
||||
const activeId = defineModel<string>('activeId', { required: true })
|
||||
const block = defineModel<number>('block', { required: true })
|
||||
const playing = defineModel<boolean>('playing', { required: true })
|
||||
|
||||
const zoom = ref(1)
|
||||
|
||||
const cycle = computed(() => cycles.value.find((c) => c.id === activeId.value) ?? cycles.value[0]!)
|
||||
const blocks = computed(() => cycle.value.blocks)
|
||||
const total = computed(() => totalDuration(blocks.value))
|
||||
const at = computed(() => offsetOf(blocks.value, block.value) + props.elapsed)
|
||||
|
||||
/**
|
||||
* Nommage d'un cycle : meme boite pour la creation et le renommage, le second
|
||||
* cas portant l'id vise. La creation n'a lieu qu'a la validation — annuler ne
|
||||
* doit pas laisser un cycle vide derriere.
|
||||
*/
|
||||
const naming = ref<{ mode: 'create' | 'rename'; id?: string } | null>(null)
|
||||
const nameDraft = ref('')
|
||||
const nameOpen = ref(false)
|
||||
|
||||
/** Montage en attente de confirmation de suppression. */
|
||||
const removing = ref<Cycle | null>(null)
|
||||
const confirmOpen = ref(false)
|
||||
const removingDetail = computed(() =>
|
||||
pluriel('dialog.removeDetail', removing.value?.blocks.length ?? 0)
|
||||
)
|
||||
|
||||
/** Remplace le cycle courant : les cycles sont des valeurs, jamais mutees. */
|
||||
function edit(next: Partial<Cycle>) {
|
||||
cycles.value = cycles.value.map((c) => (c.id === cycle.value.id ? { ...c, ...next } : c))
|
||||
}
|
||||
|
||||
function select(id: string) {
|
||||
activeId.value = id
|
||||
block.value = 0
|
||||
}
|
||||
|
||||
function askCreate() {
|
||||
if (cycles.value.length >= MAX_CYCLES) return
|
||||
naming.value = { mode: 'create' }
|
||||
nameDraft.value = uniqueName(t('cycles.newName'), cycles.value)
|
||||
nameOpen.value = true
|
||||
}
|
||||
|
||||
function askRename(id: string) {
|
||||
naming.value = { mode: 'rename', id }
|
||||
const vise = cycles.value.find((c) => c.id === id)
|
||||
// le montage d'amorce n'a pas de nom propre : on part de celui qui s'affiche,
|
||||
// sinon renommer commencerait sur un champ vide
|
||||
nameDraft.value = vise ? nomDeCycle(vise) : ''
|
||||
nameOpen.value = true
|
||||
}
|
||||
|
||||
function onNamed(name: string) {
|
||||
const demande = naming.value
|
||||
naming.value = null
|
||||
if (!demande) return
|
||||
if (demande.mode === 'create') {
|
||||
// jamais de cycle vide : le lecteur aurait un montage sans rien a jouer
|
||||
const neuf: Cycle = {
|
||||
id: nextCycleId(cycles.value),
|
||||
name: uniqueName(name, cycles.value),
|
||||
blocks: [makeBlock('idle')]
|
||||
}
|
||||
cycles.value = [...cycles.value, neuf]
|
||||
select(neuf.id)
|
||||
return
|
||||
}
|
||||
const autres = cycles.value.filter((c) => c.id !== demande.id)
|
||||
const unique = uniqueName(name, autres)
|
||||
cycles.value = cycles.value.map((c) => (c.id === demande.id ? { ...c, name: unique } : c))
|
||||
}
|
||||
|
||||
/** Suppression d'un montage : jamais sans confirmation, c'est irreversible. */
|
||||
function askRemove(id: string) {
|
||||
removing.value = cycles.value.find((c) => c.id === id) ?? null
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
function onRemove() {
|
||||
const cible = removing.value
|
||||
removing.value = null
|
||||
if (!cible) return
|
||||
const reste = cycles.value.filter((c) => c.id !== cible.id)
|
||||
cycles.value = reste
|
||||
if (cible.id === activeId.value) select(reste[0]!.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
Barre de montage : fixee en bas, sans fond ni cadre — elle doit se lire
|
||||
comme une partie de la page, au meme titre que le panneau de droite, dont
|
||||
elle s'arrete avant la colonne (largeur du panneau + gouttiere + marge).
|
||||
La scene lui reserve sa hauteur (`--timeline`) dans cette vue, et la bande de
|
||||
l'avatar la reserve partout ailleurs — sinon l'avatar centre sauterait d'un
|
||||
onglet a l'autre.
|
||||
|
||||
`lg:left-[4.5rem]` repond a `lg:right-[24.5rem]` : la bande couvre EXACTEMENT
|
||||
la colonne de l'avatar, donc le bouton de lecture tombe pile sous la boule.
|
||||
Sans lui la bande partait du bord de la fenetre et son centre etait 20 px a
|
||||
cote.
|
||||
|
||||
4,5rem = la marge de la scene (2rem) + la gouttiere de la grille (2,5rem) : la
|
||||
colonne de gauche a une largeur NULLE en dehors des reglages, mais la grille
|
||||
garde son `column-gap`, donc la colonne de l'avatar commence a 72 px et non a
|
||||
32. C'est la symetrie de 24,5rem = panneau (21,25) + gouttiere (2,5) + marge (0,75).
|
||||
|
||||
Le FOND n'apparait que sous 64rem, et ce n'est pas un choix d'habillage.
|
||||
Au-dessus, la page ne defile pas (`#app { overflow: clip }`) et la scene
|
||||
reserve exactement cette bande : rien ne passe jamais derriere, donc une barre
|
||||
transparente se lit comme une partie de la page. En dessous la page defile
|
||||
pour de vrai, et la palette d'animations remontait mot pour mot au travers de
|
||||
la piste — le curseur de zoom et les etiquettes des vignettes se superposaient.
|
||||
Le filet du haut dit ou la page s'arrete de defiler.
|
||||
-->
|
||||
<div
|
||||
class="fixed inset-x-0 bottom-0 z-30 h-[var(--timeline)] px-6 pt-3 pb-5 max-lg:border-t max-lg:border-[var(--line)] max-lg:bg-[var(--paper)] max-lg:px-5 lg:right-[24.5rem] lg:left-[4.5rem]"
|
||||
>
|
||||
<!-- lecture : flottante au-dessus de la piste, au centre, le temps ecoule a
|
||||
gauche et la duree totale a droite.
|
||||
|
||||
Les deux compteurs disparaissent sous 64rem : ils depassent du haut de la
|
||||
barre, donc du fond qui la rend lisible, et ils tombaient sur la palette
|
||||
qui defile derriere. Le bouton, lui, reste — un disque plein se lit
|
||||
par-dessus n'importe quoi, et il est la commande principale de la vue. La
|
||||
meme paire de valeurs est de toute facon affichee dans la barre d'outils,
|
||||
en bas a gauche. -->
|
||||
<div class="absolute -top-5 left-1/2 flex -translate-x-1/2 items-center gap-3">
|
||||
<span class="text-sm font-medium tabular-nums max-lg:hidden">{{ mmss(at) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 cursor-pointer items-center justify-center rounded-full bg-[var(--ink)] text-[var(--paper)] shadow-sm transition hover:scale-105 active:scale-95"
|
||||
:aria-label="playing ? t('timeline.pause') : t('timeline.play')"
|
||||
@click="playing = !playing"
|
||||
>
|
||||
<!--
|
||||
Lecture / pause : Solar plein (`solar:play-bold`, `solar:pause-bold`),
|
||||
la meme bibliotheque que la barre laterale. Traces recopies tels quels
|
||||
depuis Iconify, comme la-bas — ne pas les redessiner. Meme taille pour
|
||||
les deux : leurs boites font toutes deux 20 unites de haut sur 24, donc
|
||||
les deux glyphes ont la meme hauteur optique. Le triangle est decentre
|
||||
vers la droite dans sa boite (milieu a x = 13,45) et c'est voulu : le
|
||||
centre de masse d'un triangle est a gauche de son cadre, le « recentrer »
|
||||
le ferait paraitre trop a gauche.
|
||||
-->
|
||||
<svg v-if="!playing" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21.4086 9.35258C23.5305 10.5065 23.5305 13.4935 21.4086 14.6474L8.59662 21.6145C6.53435 22.736 4 21.2763 4 18.9671L4 5.0329C4 2.72368 6.53435 1.26402 8.59661 2.38548L21.4086 9.35258Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<g fill="currentColor">
|
||||
<path d="M2 6C2 4.11438 2 3.17157 2.58579 2.58579C3.17157 2 4.11438 2 6 2C7.88562 2 8.82843 2 9.41421 2.58579C10 3.17157 10 4.11438 10 6V18C10 19.8856 10 20.8284 9.41421 21.4142C8.82843 22 7.88562 22 6 22C4.11438 22 3.17157 22 2.58579 21.4142C2 20.8284 2 19.8856 2 18V6Z" />
|
||||
<path d="M14 6C14 4.11438 14 3.17157 14.5858 2.58579C15.1716 2 16.1144 2 18 2C19.8856 2 20.8284 2 21.4142 2.58579C22 3.17157 22 4.11438 22 6V18C22 19.8856 22 20.8284 21.4142 21.4142C20.8284 22 19.8856 22 18 22C16.1144 22 15.1716 22 14.5858 21.4142C14 20.8284 14 19.8856 14 18V6Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-sm tabular-nums text-[var(--muted)] max-lg:hidden">{{ mmss(total) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- rien ne se selectionne dans une barre d'outils : ca ne sert a personne
|
||||
et ca surligne tout des qu'on glisse une carte ou la tete de lecture -->
|
||||
<div class="flex h-full flex-col gap-2 select-none">
|
||||
<!--
|
||||
Le nom du montage a gauche, ce qu'on en fait a droite : l'export est une
|
||||
action sur le CYCLE, comme le renommer ou le supprimer, pas un reglage
|
||||
d'affichage. En bas il voisinait la loupe et l'apercu, qui eux ne changent
|
||||
que la facon de regarder — et il s'y lisait comme une icone de plus.
|
||||
-->
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<CycleMenu
|
||||
v-model:active-id="activeId"
|
||||
:cycles="cycles"
|
||||
:current="cycle"
|
||||
@create="askCreate"
|
||||
@rename="askRename"
|
||||
@remove="askRemove"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 shrink-0 cursor-pointer items-center gap-2 rounded-xl bg-[var(--ink)] pr-3.5 pl-3 text-sm font-medium text-[var(--paper)] shadow-sm transition hover:opacity-90 active:scale-95 max-sm:w-8 max-sm:justify-center max-sm:px-0"
|
||||
@click="emit('exporter')"
|
||||
>
|
||||
<!-- solar:download-minimalistic-linear, la meme que la barre d'export -->
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M3 15C3 17.8284 3 19.2426 3.87868 20.1213C4.75736 21 6.17157 21 9 21H15C17.8284 21 19.2426 21 20.1213 20.1213C21 19.2426 21 17.8284 21 15"
|
||||
/>
|
||||
<path d="M12 3V16M8 11.625L12 16L16 11.625" />
|
||||
</g>
|
||||
</svg>
|
||||
<!-- `sr-only` et pas `hidden` : sous 40rem le bouton se reduit a son
|
||||
icone, mais le libelle reste son NOM accessible — un bouton dont
|
||||
tout le contenu est masque n'est plus annonce du tout, et il
|
||||
n'aurait alors ni `aria-label` ni texte. -->
|
||||
<span class="max-sm:sr-only">{{ t('timeline.export') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TimelineTrack
|
||||
v-model:block="block"
|
||||
v-model:zoom="zoom"
|
||||
:blocks="blocks"
|
||||
:elapsed="elapsed"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:expression="expression"
|
||||
@update:blocks="(b: Block[]) => edit({ blocks: b })"
|
||||
@add="(s: StateId) => edit({ blocks: blocksWith(blocks, s) })"
|
||||
@seek="emit('seek', $event)"
|
||||
/>
|
||||
|
||||
<!-- barre d'outils : le temps a gauche, loupe et apercu a droite -->
|
||||
<div class="flex shrink-0 items-center gap-4 max-sm:gap-2">
|
||||
<p class="mr-auto text-xs tabular-nums text-[var(--muted)]">
|
||||
<span class="text-[var(--ink)]">{{ mmss(at) }}</span> / {{ mmss(total) }}
|
||||
</p>
|
||||
|
||||
<ZoomSlider v-model:zoom="zoom" :min="MIN_ZOOM" :max="MAX_ZOOM" />
|
||||
|
||||
<!-- infobulle au survol ET au focus clavier, comme la barre laterale -->
|
||||
<span class="group relative flex">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
|
||||
:aria-label="t('timeline.preview')"
|
||||
@click="emit('preview')"
|
||||
>
|
||||
<!--
|
||||
Apercu : l'oeil plein de Solar (`solar:eye-bold`), meme
|
||||
bibliotheque que la barre laterale. La paupiere est evidee autour
|
||||
de la pupille (`fill-rule="evenodd"`) : le rond plein qui la
|
||||
remplit est le second trace, ne pas fusionner les deux.
|
||||
-->
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<g fill="currentColor">
|
||||
<path d="M9.75 12C9.75 10.7574 10.7574 9.75 12 9.75C13.2426 9.75 14.25 10.7574 14.25 12C14.25 13.2426 13.2426 14.25 12 14.25C10.7574 14.25 9.75 13.2426 9.75 12Z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2 12C2 13.6394 2.42496 14.1915 3.27489 15.2957C4.97196 17.5004 7.81811 20 12 20C16.1819 20 19.028 17.5004 20.7251 15.2957C21.575 14.1915 22 13.6394 22 12C22 10.3606 21.575 9.80853 20.7251 8.70433C19.028 6.49956 16.1819 4 12 4C7.81811 4 4.97196 6.49956 3.27489 8.70433C2.42496 9.80853 2 10.3606 2 12ZM12 8.25C9.92893 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92893 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 translate-y-1 rounded-lg bg-[var(--ink)] px-2.5 py-1.5 text-xs whitespace-nowrap text-[var(--paper)] opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
|
||||
role="tooltip"
|
||||
>
|
||||
{{ t('timeline.preview') }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NameDialog
|
||||
v-model:open="nameOpen"
|
||||
v-model:value="nameDraft"
|
||||
:title="naming?.mode === 'rename' ? t('dialog.nameRenameTitle') : t('dialog.nameCreateTitle')"
|
||||
:label="t('dialog.nameField')"
|
||||
:submit-label="naming?.mode === 'rename' ? t('dialog.nameRename') : t('dialog.nameCreate')"
|
||||
@submit="onNamed"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="confirmOpen"
|
||||
:title="t('dialog.removeTitle', { name: removing ? nomDeCycle(removing) : '' })"
|
||||
:detail="removingDetail"
|
||||
:confirm-label="t('dialog.removeConfirm')"
|
||||
@confirm="onRemove"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,517 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import BlockPicker from '@/components/BlockPicker.vue'
|
||||
import BotTile from '@/components/BotTile.vue'
|
||||
import {
|
||||
clampDuration,
|
||||
followMove,
|
||||
moveBlock,
|
||||
offsetOf,
|
||||
STEP,
|
||||
totalDuration,
|
||||
type Block,
|
||||
type StateId
|
||||
} from '@/editor/cycles'
|
||||
import { secondes, secondesCourtes, stateName, t } from '@/i18n'
|
||||
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
|
||||
|
||||
/**
|
||||
* La piste : une regle graduee, les cartes du montage, et les gestes qui vont
|
||||
* avec (deplacer, etirer, promener la tete de lecture, zoomer). Elle ne connait
|
||||
* ni les cycles ni le lecteur — elle recoit une suite de blocs et rend celle
|
||||
* qu'on obtient apres le geste.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
blocks: Block[]
|
||||
/** temps ecoule dans le bloc courant, pour la tete de lecture */
|
||||
elapsed: number
|
||||
shape: string
|
||||
color: string
|
||||
expression: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:blocks': [blocks: Block[]]
|
||||
/** date visee sur la regle */
|
||||
seek: [seconds: number]
|
||||
add: [state: StateId]
|
||||
}>()
|
||||
|
||||
/** Curseur de lecture et loupe : la barre les affiche, la piste les manipule. */
|
||||
const block = defineModel<number>('block', { required: true })
|
||||
const zoom = defineModel<number>('zoom', { required: true })
|
||||
|
||||
const scale = computed(() => BASE_SCALE * zoom.value)
|
||||
const total = computed(() => totalDuration(props.blocks))
|
||||
const at = computed(() => offsetOf(props.blocks, block.value) + props.elapsed)
|
||||
const ticks = computed(() => ticksFor(total.value, scale.value))
|
||||
const exact = computed(() => secondes(at.value))
|
||||
|
||||
const track = ref<HTMLElement | null>(null)
|
||||
/** Debordement de la piste, pour n'afficher les degrades que s'ils servent. */
|
||||
const overflow = ref({ left: false, right: false })
|
||||
/**
|
||||
* Defilement de la piste. L'infobulle de temps ne peut pas vivre dedans — le
|
||||
* conteneur rogne ce qui depasse en hauteur, et elle flotte au-dessus de la
|
||||
* regle — donc elle se positionne dehors, et doit retrancher ce defilement.
|
||||
*/
|
||||
const scrolled = ref(0)
|
||||
|
||||
function width(index: number) {
|
||||
return props.blocks[index]!.duration * scale.value
|
||||
}
|
||||
|
||||
function label(index: number) {
|
||||
return stateName(props.blocks[index]!.state)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- defilement, loupe */
|
||||
|
||||
function onScroll() {
|
||||
const el = track.value
|
||||
if (!el) return
|
||||
scrolled.value = el.scrollLeft
|
||||
overflow.value = {
|
||||
left: el.scrollLeft > 4,
|
||||
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 4
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point d'ancrage du prochain changement d'echelle, en coordonnees d'ecran :
|
||||
* la seconde qui s'y trouve doit y rester. Sans ca, zoomer sur une carte
|
||||
* precise la fait fuir hors de l'ecran. `null` = le centre de ce qu'on voit,
|
||||
* c'est le bon compromis quand le zoom vient du curseur de la barre.
|
||||
*/
|
||||
let anchorX: number | null = null
|
||||
|
||||
function setZoom(next: number, clientX?: number) {
|
||||
anchorX = clientX ?? null
|
||||
zoom.value = clampZoom(next)
|
||||
}
|
||||
|
||||
watch(scale, (now, before) => {
|
||||
const el = track.value
|
||||
if (!el) return
|
||||
const x = (anchorX ?? el.getBoundingClientRect().left + el.clientWidth / 2) -
|
||||
el.getBoundingClientRect().left
|
||||
const seconde = (el.scrollLeft + x) / before
|
||||
anchorX = null
|
||||
nextTick(() => {
|
||||
el.scrollLeft = seconde * now - x
|
||||
onScroll()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Molette et trackpad sur la piste :
|
||||
* - pincement du trackpad (le navigateur l'annonce comme une molette + `ctrl`,
|
||||
* c'est la convention) ou `ctrl`/`cmd` + molette → loupe ;
|
||||
* - deux doigts a l'horizontale → defilement, c'est deja `deltaX` ;
|
||||
* - molette de souris, qui n'a pas d'axe horizontal → on renvoie son `deltaY`
|
||||
* sur le defilement de la piste, sinon elle ne servirait a rien ici.
|
||||
* `deltaMode` vaut 1 quand le systeme compte en lignes et pas en pixels (des
|
||||
* souris sous Firefox) : sans le facteur, le geste serait quinze fois trop lent.
|
||||
*/
|
||||
function onWheel(e: WheelEvent) {
|
||||
const el = track.value
|
||||
if (!el) return
|
||||
const unit = e.deltaMode === 1 ? 16 : 1
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
setZoom(zoom.value * Math.exp((-e.deltaY * unit) / 180), e.clientX)
|
||||
return
|
||||
}
|
||||
if (el.scrollWidth <= el.clientWidth) return
|
||||
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
if (!d) return
|
||||
e.preventDefault()
|
||||
el.scrollLeft += d * unit
|
||||
}
|
||||
|
||||
onMounted(onScroll)
|
||||
watch(() => [total.value, props.blocks] as const, () => nextTick(onScroll))
|
||||
|
||||
// La carte courante reste visible quand la piste deborde de la fenetre.
|
||||
watch(block, () => {
|
||||
const el = track.value
|
||||
if (!el) return
|
||||
const x = offsetOf(props.blocks, block.value) * scale.value
|
||||
if (x < el.scrollLeft || x + width(block.value) > el.scrollLeft + el.clientWidth) {
|
||||
el.scrollTo({ left: Math.max(0, x - 24), behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
|
||||
/* --------------------------------------------------------------- montage */
|
||||
|
||||
function removeBlock(index: number) {
|
||||
// la derniere carte ne part pas : un montage vide n'aurait rien a jouer
|
||||
if (props.blocks.length < 2) return
|
||||
emit(
|
||||
'update:blocks',
|
||||
props.blocks.filter((_, i) => i !== index)
|
||||
)
|
||||
// le curseur suit : une carte retiree avant lui le decale d'un cran, et il ne
|
||||
// doit jamais pointer au-dela de la piste
|
||||
if (index < block.value) block.value -= 1
|
||||
else if (block.value >= props.blocks.length - 1) block.value = props.blocks.length - 2
|
||||
}
|
||||
|
||||
function setDuration(index: number, wanted: number) {
|
||||
const b = props.blocks[index]
|
||||
if (!b) return
|
||||
const duration = clampDuration(b.state, wanted)
|
||||
if (duration === b.duration) return
|
||||
emit(
|
||||
'update:blocks',
|
||||
props.blocks.map((old, i) => (i === index ? { ...old, duration } : old))
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ glisser / etirer */
|
||||
|
||||
/**
|
||||
* Glisser-deposer : pendant le geste, le montage n'est PAS modifie. La carte
|
||||
* saisie suit le pointeur et les autres s'ecartent de sa largeur — c'est ce qui
|
||||
* donne la sensation de deplacer un objet. Le montage n'est recompose qu'au
|
||||
* lacher : reordonner en direct ferait sauter la carte d'un emplacement a
|
||||
* l'autre sous le doigt.
|
||||
*/
|
||||
type Drag = { from: number; to: number; startX: number; dx: number; moved: boolean }
|
||||
type Resize = { index: number; startX: number; startDuration: number }
|
||||
|
||||
const drag = ref<Drag | null>(null)
|
||||
const resize = ref<Resize | null>(null)
|
||||
const scrubbing = ref(false)
|
||||
|
||||
/** Decalage a appliquer a une carte pendant qu'on en deplace une autre. */
|
||||
function shiftOf(i: number) {
|
||||
const d = drag.value
|
||||
if (!d?.moved) return 0
|
||||
if (i === d.from) return d.dx
|
||||
const w = width(d.from)
|
||||
if (d.to > d.from && i > d.from && i <= d.to) return -w
|
||||
if (d.to < d.from && i >= d.to && i < d.from) return w
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Vrai pour la carte qu'on est en train de deplacer : elle se souleve. */
|
||||
function lifted(i: number) {
|
||||
return Boolean(drag.value?.moved) && i === drag.value?.from
|
||||
}
|
||||
|
||||
/** Index de la carte sous une position, en secondes depuis le debut de la piste. */
|
||||
function indexAt(t: number) {
|
||||
let acc = 0
|
||||
for (let i = 0; i < props.blocks.length; i++) {
|
||||
acc += props.blocks[i]!.duration
|
||||
if (t < acc) return i
|
||||
}
|
||||
return props.blocks.length - 1
|
||||
}
|
||||
|
||||
function pointerSeconds(e: PointerEvent) {
|
||||
const box = track.value?.getBoundingClientRect()
|
||||
if (!box) return 0
|
||||
return (e.clientX - box.left + (track.value?.scrollLeft ?? 0)) / scale.value
|
||||
}
|
||||
|
||||
function onBlockDown(index: number, e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
drag.value = { from: index, to: index, startX: e.clientX, dx: 0, moved: false }
|
||||
}
|
||||
|
||||
function onBlockMove(e: PointerEvent) {
|
||||
const d = drag.value
|
||||
if (!d) return
|
||||
// quelques pixels de tolerance : un clic tremble toujours un peu
|
||||
if (!d.moved && Math.abs(e.clientX - d.startX) <= 4) return
|
||||
d.moved = true
|
||||
d.dx = e.clientX - d.startX
|
||||
d.to = Math.max(0, indexAt(pointerSeconds(e)))
|
||||
}
|
||||
|
||||
function onBlockUp(index: number) {
|
||||
const d = drag.value
|
||||
drag.value = null
|
||||
if (!d) return
|
||||
// un clic sans deplacement, c'est un saut de la tete de lecture
|
||||
if (!d.moved) {
|
||||
block.value = index
|
||||
return
|
||||
}
|
||||
if (d.to === d.from) return
|
||||
emit('update:blocks', moveBlock(props.blocks, d.from, d.to))
|
||||
block.value = followMove(block.value, d.from, d.to)
|
||||
}
|
||||
|
||||
function onResizeDown(index: number, e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
resize.value = { index, startX: e.clientX, startDuration: props.blocks[index]!.duration }
|
||||
}
|
||||
|
||||
function onResizeMove(e: PointerEvent) {
|
||||
const r = resize.value
|
||||
if (!r) return
|
||||
setDuration(r.index, r.startDuration + (e.clientX - r.startX) / scale.value)
|
||||
}
|
||||
|
||||
/** Le clavier etire aussi : la poignee est un bouton, pas seulement une zone. */
|
||||
function onResizeKey(index: number, delta: number) {
|
||||
setDuration(index, props.blocks[index]!.duration + delta)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- au clavier */
|
||||
|
||||
/**
|
||||
* Reordonner et pointer AU CLAVIER.
|
||||
*
|
||||
* Le glisser-deposer n'avait aucun equivalent : on pouvait ajouter, supprimer et etirer un
|
||||
* bloc — la poignee gere deja les fleches — mais jamais le REORDONNER, et le pointage
|
||||
* precis n'existait qu'au pointeur, sur la regle. C'etait le seul geste de l'editeur
|
||||
* inaccessible.
|
||||
*
|
||||
* `Alt` + fleches deplace la carte, les fleches nues promenent la tete de lecture de `STEP`.
|
||||
* Alt et pas les fleches nues pour le deplacement : une carte est un bouton dans une liste,
|
||||
* et les fleches y servent d'abord a se deplacer.
|
||||
*
|
||||
* Le focus SUIT la carte deplacee, sinon on se retrouve a en pousser une autre au coup
|
||||
* suivant. Il faut attendre le rendu : la liste est recomposee, donc le bouton d'arrivee
|
||||
* n'existe pas encore.
|
||||
*
|
||||
* La carte porte `aria-keyshortcuts` : un raccourci que rien n'annonce n'est pas une
|
||||
* affordance. C'est l'attribut fait pour ca, et il evite d'allonger l'etiquette, qui est
|
||||
* relue a chaque carte de la piste.
|
||||
*/
|
||||
async function onCardKey(index: number, e: KeyboardEvent) {
|
||||
const sens = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0
|
||||
if (!sens) return
|
||||
e.preventDefault()
|
||||
|
||||
if (!e.altKey) {
|
||||
// pointage : la tete de lecture avance d'un pas, dans tout le montage
|
||||
emit('seek', Math.max(0, Math.min(total.value - 0.001, at.value + sens * STEP)))
|
||||
return
|
||||
}
|
||||
|
||||
const cible = index + sens
|
||||
if (cible < 0 || cible >= props.blocks.length) return
|
||||
emit('update:blocks', moveBlock(props.blocks, index, cible))
|
||||
block.value = followMove(block.value, index, cible)
|
||||
await nextTick()
|
||||
const liste = track.value?.querySelectorAll<HTMLButtonElement>('[data-carte]')
|
||||
liste?.[cible]?.focus()
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- scrub */
|
||||
|
||||
function scrubTo(e: PointerEvent) {
|
||||
emit('seek', Math.max(0, Math.min(total.value - 0.001, pointerSeconds(e))))
|
||||
}
|
||||
|
||||
function onRulerDown(e: PointerEvent) {
|
||||
// sans ca, promener la tete de lecture surligne les graduations au passage :
|
||||
// le navigateur demarre une selection de texte sur le `mousedown` induit
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
scrubbing.value = true
|
||||
scrubTo(e)
|
||||
}
|
||||
|
||||
function onRulerMove(e: PointerEvent) {
|
||||
if (scrubbing.value) scrubTo(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1">
|
||||
<div
|
||||
ref="track"
|
||||
class="sans-barre h-full overflow-x-auto overflow-y-hidden"
|
||||
@scroll="onScroll"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<!-- la piste garde de la place pour la carte « + » a sa suite -->
|
||||
<div class="relative flex h-full flex-col" :style="{ width: `${total * scale + 76}px` }">
|
||||
<!--
|
||||
Regle graduee : elle sert aussi de zone de deplacement. On attrape
|
||||
n'importe ou dessus pour promener la tete de lecture, comme sur un
|
||||
montage video — c'est la seule facon d'atteindre un point PRECIS
|
||||
d'une carte, le clic sur une carte ne fait que sauter a son debut.
|
||||
-->
|
||||
<div
|
||||
class="relative h-7 shrink-0 cursor-ew-resize pt-1 select-none"
|
||||
@pointerdown="onRulerDown"
|
||||
@pointermove="onRulerMove"
|
||||
@pointerup="scrubbing = false"
|
||||
@pointercancel="scrubbing = false"
|
||||
>
|
||||
<span
|
||||
v-for="tick in ticks"
|
||||
:key="tick.t"
|
||||
class="absolute bottom-1.5 flex items-end gap-1"
|
||||
:style="{ transform: `translateX(${tick.t * scale}px)` }"
|
||||
>
|
||||
<span class="block w-px bg-[var(--line)]" :class="tick.major ? 'h-3' : 'h-1.5'" />
|
||||
<span v-if="tick.major" class="-mb-0.5 text-xs leading-none text-[var(--muted)]">
|
||||
{{ secondesCourtes(tick.t, Number.isInteger(tick.t) ? 0 : 1) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-1 items-stretch">
|
||||
<!--
|
||||
La largeur du <li> vaut exactement la duree de la carte : la
|
||||
gouttiere est un padding interne, sinon les cartes decaleraient la
|
||||
piste et la tete de lecture ne tomberait plus en face.
|
||||
-->
|
||||
<li
|
||||
v-for="(b, i) in blocks"
|
||||
:key="`${i}-${b.state}`"
|
||||
class="group relative shrink-0 pr-1.5"
|
||||
:class="lifted(i) ? 'z-20' : 'transition-transform duration-150 ease-out'"
|
||||
:style="{
|
||||
width: `${b.duration * scale}px`,
|
||||
transform: shiftOf(i) ? `translateX(${shiftOf(i)}px)` : undefined
|
||||
}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-full cursor-grab flex-col justify-between overflow-hidden rounded-lg px-1.5 py-1 text-left transition select-none active:cursor-grabbing"
|
||||
:class="[
|
||||
i === block
|
||||
? 'bg-white ring-2 ring-[var(--ink)] ring-inset'
|
||||
: 'bg-black/[0.045] hover:bg-black/[0.08]',
|
||||
lifted(i) ? 'scale-[1.02] opacity-75 shadow-lg' : ''
|
||||
]"
|
||||
:aria-label="t('timeline.blockAria', { state: label(i), duration: secondes(b.duration) })"
|
||||
:aria-current="i === block ? 'true' : undefined"
|
||||
@pointerdown="onBlockDown(i, $event)"
|
||||
@pointermove="onBlockMove"
|
||||
@pointerup="onBlockUp(i)"
|
||||
@pointercancel="drag = null"
|
||||
data-carte
|
||||
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight ArrowLeft ArrowRight"
|
||||
@keydown.enter.prevent="block = i"
|
||||
@keydown.left="onCardKey(i, $event)"
|
||||
@keydown.right="onCardKey(i, $event)"
|
||||
>
|
||||
<!-- la miniature EST l'identite de la carte, comme la vignette
|
||||
d'une page : le nom n'apprendrait rien de plus, il ne reste
|
||||
que dans l'etiquette du bouton, pour le lecteur d'ecran -->
|
||||
<span class="flex min-w-0 flex-1 items-center justify-center">
|
||||
<BotTile
|
||||
v-if="width(i) > 44"
|
||||
class="shrink-0 pointer-events-none"
|
||||
:label="label(i)"
|
||||
:selected="false"
|
||||
:interactive="false"
|
||||
:show-label="false"
|
||||
:state="b.state"
|
||||
:size="Math.min(56, Math.max(30, width(i) * 0.5))"
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="width(i) > 50"
|
||||
class="tronque text-center text-xs leading-none font-semibold tabular-nums"
|
||||
:class="i === block ? 'text-[var(--ink)]' : 'text-[var(--muted)]'"
|
||||
>
|
||||
{{ secondes(b.duration) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- poignee de duree : bouton a part entiere, donc utilisable au clavier.
|
||||
`right-px` + gouttiere de 6px : la ligne garde 1px d'air de chaque
|
||||
cote, elle ne touche plus le ring de la carte selectionnee -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-2 right-px w-1 cursor-ew-resize rounded-full bg-[var(--muted)] opacity-0 transition group-hover:opacity-60 hover:opacity-100! focus-visible:opacity-100"
|
||||
:aria-label="
|
||||
t('timeline.blockDurationAria', {
|
||||
state: label(i),
|
||||
duration: secondes(b.duration)
|
||||
})
|
||||
"
|
||||
@pointerdown="onResizeDown(i, $event)"
|
||||
@pointermove="onResizeMove"
|
||||
@pointerup="resize = null"
|
||||
@pointercancel="resize = null"
|
||||
@keydown.left.prevent="onResizeKey(i, -STEP)"
|
||||
@keydown.right.prevent="onResizeKey(i, STEP)"
|
||||
/>
|
||||
<button
|
||||
v-if="blocks.length > 1"
|
||||
type="button"
|
||||
class="absolute top-1 right-2 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/10 text-[var(--ink)] opacity-0 transition group-hover:opacity-100 hover:bg-black/20 focus-visible:opacity-100"
|
||||
:aria-label="t('timeline.blockRemoveAria', { state: label(i) })"
|
||||
@click="removeBlock(i)"
|
||||
>
|
||||
<!-- croix dessinee : le glyphe « × » de la police ne tombe pas au
|
||||
centre optique de la pastille -->
|
||||
<svg width="9" height="9" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<path
|
||||
d="M2.6 2.6 7.4 7.4M7.4 2.6 2.6 7.4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li class="w-[72px] shrink-0 pl-1">
|
||||
<BlockPicker
|
||||
:shape="shape"
|
||||
:color="color"
|
||||
:expression="expression"
|
||||
@pick="emit('add', $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!--
|
||||
Tete de lecture : seule sa transformation change d'une image a l'autre,
|
||||
et elle vit dans la piste, donc elle defile avec elle. Sa poignee est
|
||||
dans la regle, la ou on l'attrape.
|
||||
-->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 w-0.5 rounded-full bg-[var(--ink)]"
|
||||
:style="{ transform: `translateX(${at * scale}px)` }"
|
||||
>
|
||||
<span
|
||||
class="absolute -top-0.5 -left-[5px] h-3 w-3 rounded-full border-2 border-[var(--paper)] bg-[var(--ink)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Temps exact pendant le deplacement : au dixieme, la ou le compteur de la
|
||||
barre arrondit a la seconde. Il flotte au-dessus de la regle, donc hors du
|
||||
conteneur qui defile — d'ou le `scrolled` retranche.
|
||||
-->
|
||||
<div
|
||||
v-if="scrubbing"
|
||||
class="pointer-events-none absolute top-0 left-0 z-10"
|
||||
:style="{ transform: `translate(${at * scale - scrolled}px, -70%)` }"
|
||||
>
|
||||
<span
|
||||
class="block -translate-x-1/2 rounded-md bg-[var(--ink)] px-2 py-1 text-xs tabular-nums text-[var(--paper)] shadow-sm"
|
||||
>
|
||||
{{ exact }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- degrades de debordement : la piste continue par la -->
|
||||
<div
|
||||
v-if="overflow.left"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 w-10 bg-gradient-to-r from-[var(--paper)] to-transparent"
|
||||
/>
|
||||
<div
|
||||
v-if="overflow.right"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-[var(--paper)] to-transparent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { pourcentage, t } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Reglage de la loupe de la piste. Un `<input type="range">` natif plutot qu'un
|
||||
* curseur maison : il apporte le clavier (fleches, Origine/Fin), le pas, et
|
||||
* l'annonce aux lecteurs d'ecran. Seule l'apparence est reprise.
|
||||
*
|
||||
* La valeur n'est pas ecrite directement : le parent la recoit et decide du
|
||||
* point d'ancrage du zoom, pour que la piste ne parte pas ailleurs.
|
||||
*/
|
||||
const props = defineProps<{ zoom: number; min: number; max: number }>()
|
||||
const emit = defineEmits<{ 'update:zoom': [value: number] }>()
|
||||
|
||||
const percent = computed(() => pourcentage(props.zoom))
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:zoom', Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- les deux pastilles disent le sens : petit a gauche, grand a droite -->
|
||||
<span class="h-1 w-1 shrink-0 rounded-full bg-[var(--muted)]" aria-hidden="true" />
|
||||
<input
|
||||
type="range"
|
||||
class="h-1 w-28 cursor-pointer accent-[var(--ink)] max-sm:w-20"
|
||||
:min="props.min"
|
||||
:max="props.max"
|
||||
step="0.01"
|
||||
:value="props.zoom"
|
||||
:aria-label="t('timeline.zoom')"
|
||||
:aria-valuetext="percent"
|
||||
@input="onInput"
|
||||
/>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full bg-[var(--muted)]" aria-hidden="true" />
|
||||
<!-- largeur fixe pour que la barre ne bouge pas quand le nombre change de
|
||||
chiffres, mais aligne a GAUCHE : cale a droite, le pourcentage
|
||||
s'eloignait du curseur des qu'il perdait un chiffre -->
|
||||
<span class="w-11 text-left text-xs tabular-nums text-[var(--muted)]">{{ percent }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
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
|
||||
}
|
||||
|
||||
/** Index of the clip that was current, after `moveBlock(from, to)`. */
|
||||
export function followMove(current: number, from: number, to: number): number {
|
||||
if (current === from) return to
|
||||
if (from < to && current > from && current <= to) return current - 1
|
||||
if (from > to && current >= to && current < from) return current + 1
|
||||
return current
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
const SEPARATEUR = ' | '
|
||||
|
||||
export function interpoler(texte: string, valeurs?: Record<string, string | number>): 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]!
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 en from './locales/en'
|
||||
import zh from './locales/zh'
|
||||
|
||||
export { LANGUES, type Langue } from './langues'
|
||||
|
||||
const dictionnaires: Record<Langue, typeof zh> = { zh, en }
|
||||
|
||||
type Chemins<T, P extends string = ''> = {
|
||||
[K in keyof T & string]: T[K] extends string ? `${P}${K}` : Chemins<T[K], `${P}${K}.`>
|
||||
}[keyof T & string]
|
||||
|
||||
export type Cle = Chemins<typeof zh>
|
||||
|
||||
const courante = ref<Langue>(choisirLangue(lis('langue')))
|
||||
|
||||
export const langue = computed<Langue>({
|
||||
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<unknown>((n, k) => (n as Record<string, unknown>)[k], dictionnaire.value)
|
||||
return noeud as string
|
||||
}
|
||||
|
||||
export function t(cle: Cle, valeurs?: Record<string, string | number>): string {
|
||||
return interpoler(brut(cle), valeurs)
|
||||
}
|
||||
|
||||
export function pluriel(cle: Cle, n: number, valeurs?: Record<string, string | number>): 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<string, Intl.NumberFormat>()
|
||||
|
||||
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<string, string>
|
||||
return dict[id] ?? id
|
||||
}
|
||||
|
||||
export function shapeName(id: string): string {
|
||||
const dict = dictionnaire.value.shapes as Record<string, string>
|
||||
return dict[id] ?? id
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const LANGUES = [
|
||||
{ id: 'zh', tag: 'zh-Hans', emoji: '🇨🇳', nom: '简体中文' },
|
||||
{ id: 'en', tag: 'en', emoji: '🇬🇧', nom: 'English' }
|
||||
] as const
|
||||
|
||||
export type Langue = (typeof LANGUES)[number]['id']
|
||||
|
||||
export const LANGUE_PAR_DEFAUT: Langue = 'zh'
|
||||
|
||||
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): Langue {
|
||||
return estLangue(memorisee) ? memorisee : LANGUE_PAR_DEFAUT
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type zh from './zh'
|
||||
|
||||
const en: typeof zh = {
|
||||
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',
|
||||
custom: 'Custom colour',
|
||||
flat: 'Solid',
|
||||
gradient: 'Gradient',
|
||||
linear: 'Linear gradient',
|
||||
radial: 'Radial gradient',
|
||||
gradFrom: 'Gradient start',
|
||||
gradTo: 'Gradient end',
|
||||
gradInner: 'Inner colour',
|
||||
gradOuter: 'Outer colour',
|
||||
swap: 'Swap gradient ends',
|
||||
swapRadial: 'Swap inner and outer colours',
|
||||
angle: 'Gradient angle',
|
||||
eye: 'Eyes',
|
||||
eyeReset: 'Reset eye 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}'
|
||||
},
|
||||
playback: {
|
||||
details: 'Animation details',
|
||||
show: 'Show animation details',
|
||||
hide: 'Hide animation details',
|
||||
playing: 'Playing',
|
||||
paused: 'Paused',
|
||||
stopped: 'Stopped',
|
||||
play: 'Play {name}',
|
||||
pause: 'Pause {name}',
|
||||
stop: 'Stop {name}',
|
||||
mode: 'Playback mode',
|
||||
loop: 'loop',
|
||||
clips: 'Poses',
|
||||
current: 'Current pose',
|
||||
stepOf: '{n} / {m}',
|
||||
summary: 'The eye poses of this expression, looping.',
|
||||
blinkFirst: 'First blink',
|
||||
blinkAfter: 'after launch',
|
||||
blinkGap: 'Blink interval',
|
||||
blinkRand: 'randomized',
|
||||
blinkOff: 'Off'
|
||||
},
|
||||
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',
|
||||
github: 'GitHub repository',
|
||||
githubAria: 'Open this repository on GitHub',
|
||||
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'
|
||||
},
|
||||
manual: {
|
||||
title: 'Manual adjust',
|
||||
reset: 'Reset',
|
||||
head: 'Head rotation',
|
||||
body: 'Body',
|
||||
spin: 'Spin',
|
||||
eyes: 'Eyes',
|
||||
width: 'W',
|
||||
height: 'H',
|
||||
size: 'S',
|
||||
spacing: 'Gap',
|
||||
gizmo: 'Rotation gizmo',
|
||||
plane: 'Move in the picture plane'
|
||||
},
|
||||
photo: {
|
||||
open: 'Photo mode',
|
||||
exit: 'Exit photo',
|
||||
frameAria: 'Photo frame. Drag to move the character, scroll to zoom, arrow keys to nudge.',
|
||||
toolFrame: 'Frame',
|
||||
toolPose: 'Pose',
|
||||
reset: 'Reset',
|
||||
poseTitle: 'Pose',
|
||||
poseReset: 'Reset',
|
||||
axisTilt: 'Tilt',
|
||||
axisTurn: 'Turn',
|
||||
axisRoll: 'Roll',
|
||||
posH: 'Horizontal',
|
||||
posV: 'Vertical',
|
||||
link: 'Link',
|
||||
eyeLeft: 'Left eye',
|
||||
eyeRight: 'Right eye',
|
||||
angle: 'Angle',
|
||||
position: 'Position and spacing',
|
||||
composition: 'Composition',
|
||||
positionX: 'Position X',
|
||||
positionY: 'Position Y',
|
||||
zoom: 'Zoom',
|
||||
corner: 'Corner radius',
|
||||
recentre: 'Recentre framing',
|
||||
background: 'Background',
|
||||
fond_transparent: 'Transparent',
|
||||
fond_solid: 'Solid',
|
||||
fond_linear: 'Linear gradient',
|
||||
fond_radial: 'Radial gradient',
|
||||
color: 'Colour',
|
||||
colorStart: 'Start',
|
||||
colorEnd: 'End',
|
||||
colorInner: 'Inner',
|
||||
colorOuter: 'Outer',
|
||||
direction: 'Direction',
|
||||
random: 'Random palette',
|
||||
expression: 'Expression',
|
||||
exportTitle: 'Export',
|
||||
pixels: '{n} px',
|
||||
take: 'Take photo'
|
||||
}
|
||||
}
|
||||
|
||||
export default en
|
||||
@@ -0,0 +1,248 @@
|
||||
const zh = {
|
||||
app: {
|
||||
name: 'study',
|
||||
title: 'study — 弹簧角色',
|
||||
botAria: '弹簧驱动的角色',
|
||||
missingGeo: '本地几何不在。页面壳还在,角色不画。见 replica/geometry.schema.md。'
|
||||
},
|
||||
rail: {
|
||||
nav: '分区',
|
||||
customize: '自定义',
|
||||
animations: '动画',
|
||||
settings: '设置'
|
||||
},
|
||||
panel: {
|
||||
animations: '动画',
|
||||
shape: '形体',
|
||||
expression: '表情',
|
||||
color: '颜色',
|
||||
custom: '自定义颜色',
|
||||
flat: '单色',
|
||||
gradient: '渐变',
|
||||
linear: '线性渐变',
|
||||
radial: '径向渐变',
|
||||
gradFrom: '渐变起始色',
|
||||
gradTo: '渐变结束色',
|
||||
gradInner: '渐变内色',
|
||||
gradOuter: '渐变外色',
|
||||
swap: '交换渐变端点',
|
||||
swapRadial: '交换内色与外色',
|
||||
angle: '渐变角度',
|
||||
eye: '眼睛',
|
||||
eyeReset: '恢复默认眼色',
|
||||
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}'
|
||||
},
|
||||
playback: {
|
||||
details: '动画详情',
|
||||
show: '显示动画详情',
|
||||
hide: '隐藏动画详情',
|
||||
playing: '播放中',
|
||||
paused: '已暂停',
|
||||
stopped: '已停止',
|
||||
play: '播放 {name}',
|
||||
pause: '暂停 {name}',
|
||||
stop: '停止 {name}',
|
||||
mode: '播放模式',
|
||||
loop: '循环',
|
||||
clips: '眼位',
|
||||
current: '当前眼位',
|
||||
stepOf: '{n} / {m}',
|
||||
summary: '这个表情的眼位循环。',
|
||||
blinkFirst: '首次眨眼',
|
||||
blinkAfter: '启动后',
|
||||
blinkGap: '眨眼间隔',
|
||||
blinkRand: '随机抽取',
|
||||
blinkOff: '关闭'
|
||||
},
|
||||
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: '打开录屏展示',
|
||||
github: 'GitHub 仓库',
|
||||
githubAria: '在 GitHub 打开本仓库',
|
||||
credits: '非官方学习稿。动效是 replica 机芯,版式参考 bloub。与 xAI 无关。',
|
||||
missing: '没有 geometry-data.js — 只有壳,没有角色。'
|
||||
},
|
||||
units: {
|
||||
seconds: '{n} 秒',
|
||||
secondsShort: '{n}秒'
|
||||
},
|
||||
manual: {
|
||||
title: '手动调节',
|
||||
reset: '重置',
|
||||
head: '头部旋转',
|
||||
body: '身体',
|
||||
spin: '扭转',
|
||||
eyes: '眼睛',
|
||||
width: '宽',
|
||||
height: '高',
|
||||
size: '大小',
|
||||
spacing: '间距',
|
||||
gizmo: '旋转控制器',
|
||||
plane: '在画面上平移'
|
||||
},
|
||||
photo: {
|
||||
open: '拍照模式',
|
||||
exit: '退出拍照',
|
||||
frameAria: '取景框。拖动移动角色,滚轮缩放,方向键微调。',
|
||||
toolFrame: '取景',
|
||||
toolPose: '姿态',
|
||||
reset: '重置',
|
||||
poseTitle: '姿态调节',
|
||||
poseReset: '重置',
|
||||
axisTilt: '俯仰',
|
||||
axisTurn: '转向',
|
||||
axisRoll: '翻滚',
|
||||
posH: '水平',
|
||||
posV: '垂直',
|
||||
link: '联动',
|
||||
eyeLeft: '左眼',
|
||||
eyeRight: '右眼',
|
||||
angle: '旋转',
|
||||
position: '位置与间距',
|
||||
composition: '构图',
|
||||
positionX: '水平位置',
|
||||
positionY: '垂直位置',
|
||||
zoom: '缩放',
|
||||
corner: '圆角',
|
||||
recentre: '居中重置',
|
||||
background: '背景',
|
||||
fond_transparent: '透明',
|
||||
fond_solid: '单色',
|
||||
fond_linear: '线性渐变',
|
||||
fond_radial: '径向渐变',
|
||||
color: '颜色',
|
||||
colorStart: '起点',
|
||||
colorEnd: '终点',
|
||||
colorInner: '内',
|
||||
colorOuter: '外',
|
||||
direction: '方向',
|
||||
random: '随机配色',
|
||||
expression: '表情',
|
||||
exportTitle: '导出',
|
||||
pixels: '{n} px',
|
||||
take: '拍照'
|
||||
}
|
||||
}
|
||||
|
||||
export default zh
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,122 @@
|
||||
/** Load local geometry, then the replica IIFE engine. Missing geometry keeps the chrome. */
|
||||
|
||||
export type ReplicaParts = {
|
||||
svg: SVGSVGElement
|
||||
body: SVGPathElement
|
||||
eyes: SVGPathElement[]
|
||||
group: SVGGElement
|
||||
}
|
||||
|
||||
export type ReplicaBot = {
|
||||
setState: (name: string, opts?: { resetEyes?: boolean }) => void
|
||||
setShape: (name: string) => void
|
||||
setColor: (id: string, scheme?: string) => void
|
||||
setInk: (flat: string | null) => void
|
||||
setInkGrad: (grad: { from: string; to: string; angle: number; radial?: boolean } | null) => void
|
||||
setFollowPointer: (on: boolean) => void
|
||||
setEyeColor: (color: string | null) => void
|
||||
setPaused: (on: boolean | 'hold-pose') => void
|
||||
setPose: (pose: { turn?: number; tilt?: number; roll?: number }) => void
|
||||
setManualOffset: (offset: { tx?: number; ty?: number; spin?: number }) => void
|
||||
setEyeTune: (tune: unknown) => void
|
||||
setManualHold: (on: boolean) => void
|
||||
flushPerformance: () => void
|
||||
parts: () => ReplicaParts
|
||||
spinOnce: (turns?: number) => void
|
||||
bounceOnce: () => void
|
||||
burstOnce: () => void
|
||||
orbitGaze: (ms?: number) => void
|
||||
holdFrame: (at?: number) => void
|
||||
freezeNow: (opts?: { settle?: boolean }) => ReplicaPlayback
|
||||
step: (dt: number) => void
|
||||
setSize: (px: number) => void
|
||||
seekEye: (index: number, opts?: { snap?: boolean }) => void
|
||||
setPlaylistHold: (on: boolean) => void
|
||||
setAutoTricks: (on: boolean) => void
|
||||
playback: () => ReplicaPlayback
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
export type ReplicaPlayback = {
|
||||
state: string
|
||||
eyeIdx: number
|
||||
holdMs: number
|
||||
remainMs: number
|
||||
held: boolean
|
||||
frozen: boolean
|
||||
manualMix: number
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
GROK_GEO?: {
|
||||
Re?: number
|
||||
shapes?: Record<string, unknown>
|
||||
palette?: Record<string, { light: string; dark: string }>
|
||||
}
|
||||
GROK_TABLES?: {
|
||||
GROUPS: Array<{ label: string; states: string[] }>
|
||||
EYE_PLAYLIST?: Record<string, number[]>
|
||||
EYE_HOLD_MS?: Record<string, [number, number]>
|
||||
BLINK_MS?: Record<string, [number, number] | null>
|
||||
INK: Record<string, { lightFrom?: string; lightTo: string; darkFrom?: string; darkTo?: string }>
|
||||
}
|
||||
GrokCharacter?: new (svg: SVGSVGElement, opts: Record<string, unknown>) => ReplicaBot
|
||||
}
|
||||
}
|
||||
|
||||
let pending: Promise<boolean> | null = null
|
||||
|
||||
export function replicaReady(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.GROK_GEO?.shapes && typeof window.GrokCharacter === 'function'
|
||||
}
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[data-replica="${src}"]`)
|
||||
if (existing) {
|
||||
// IIFE already evaluated. replica/src/*.js changes need a full page refresh.
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/** 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<string, string> = {
|
||||
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 type InkSpec =
|
||||
| { kind: 'preset'; id: string }
|
||||
| { kind: 'flat'; hex: string }
|
||||
| { kind: 'grad'; from: string; to: string; angle: number }
|
||||
| { kind: 'radial'; from: string; to: string }
|
||||
|
||||
export type InkGrad = { from: string; to: string; angle: number; radial?: boolean }
|
||||
|
||||
const HEX6 = /^[0-9a-f]{6}$/i
|
||||
|
||||
function hex6(v: string | undefined): string | null {
|
||||
const s = (v ?? '').trim().replace(/^#/, '')
|
||||
return HEX6.test(s) ? `#${s.toLowerCase()}` : null
|
||||
}
|
||||
|
||||
export function parseInk(v: string): InkSpec {
|
||||
if (v.startsWith('g:')) {
|
||||
const [a, b, d] = v.slice(2).split('-')
|
||||
const from = hex6(a)
|
||||
const to = hex6(b)
|
||||
const angle = Math.round(Number(d))
|
||||
if (from && to && Number.isFinite(angle)) {
|
||||
return { kind: 'grad', from, to, angle: Math.min(360, Math.max(0, angle)) }
|
||||
}
|
||||
}
|
||||
if (v.startsWith('r:')) {
|
||||
const [a, b] = v.slice(2).split('-')
|
||||
const from = hex6(a)
|
||||
const to = hex6(b)
|
||||
if (from && to) return { kind: 'radial', from, to }
|
||||
}
|
||||
if (v.startsWith('x:')) {
|
||||
const hex = hex6(v.slice(2))
|
||||
if (hex) return { kind: 'flat', hex }
|
||||
}
|
||||
return { kind: 'preset', id: v }
|
||||
}
|
||||
|
||||
export function encodeFlat(hex: string): string {
|
||||
return `x:${hex6(hex)?.slice(1) ?? '111111'}`
|
||||
}
|
||||
|
||||
export function encodeGrad(from: string, to: string, angle: number): string {
|
||||
const a = hex6(from) ?? '#111111'
|
||||
const b = hex6(to) ?? '#111111'
|
||||
return `g:${a.slice(1)}-${b.slice(1)}-${Math.round(angle)}`
|
||||
}
|
||||
|
||||
export function encodeRadial(from: string, to: string): string {
|
||||
const a = hex6(from) ?? '#111111'
|
||||
const b = hex6(to) ?? '#111111'
|
||||
return `r:${a.slice(1)}-${b.slice(1)}`
|
||||
}
|
||||
|
||||
export function inkSwatch(v: string): string {
|
||||
const spec = parseInk(v)
|
||||
if (spec.kind === 'flat') return spec.hex
|
||||
if (spec.kind === 'grad') return `linear-gradient(${spec.angle}deg, ${spec.from}, ${spec.to})`
|
||||
if (spec.kind === 'radial') return `radial-gradient(circle at 50% 42%, ${spec.from}, ${spec.to})`
|
||||
return colorFill(spec.id)
|
||||
}
|
||||
|
||||
export function inkHex(v: string): string {
|
||||
const spec = parseInk(v)
|
||||
if (spec.kind === 'flat') return spec.hex
|
||||
if (spec.kind === 'grad' || spec.kind === 'radial') return spec.from
|
||||
return colorHex(spec.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 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
loadReplica,
|
||||
replicaReady,
|
||||
type ReplicaBot,
|
||||
type ReplicaParts,
|
||||
type ReplicaPlayback
|
||||
} from './boot'
|
||||
import { parseInk } from './catalog'
|
||||
|
||||
export type ReplicaMount = {
|
||||
apply: (input: ReplicaInput) => void
|
||||
spin: () => void
|
||||
orbitGaze: (ms?: number) => void
|
||||
step: (dt: number) => void
|
||||
setState: (name: string) => void
|
||||
setPose: (pose: { turn?: number; tilt?: number; roll?: number }) => void
|
||||
setManualOffset: (offset: { tx?: number; ty?: number; spin?: number }) => void
|
||||
setEyeTune: (tune: unknown) => void
|
||||
setManualHold: (on: boolean) => void
|
||||
flushPerformance: () => void
|
||||
seekEye: (index: number, opts?: { snap?: boolean }) => void
|
||||
setPlaylistHold: (on: boolean) => void
|
||||
setPaused: (on: boolean | 'hold-pose') => void
|
||||
holdFrame: (at?: number) => void
|
||||
freezeNow: (opts?: { settle?: boolean }) => ReplicaPlayback | null
|
||||
playback: () => ReplicaPlayback | null
|
||||
parts: () => ReplicaParts | null
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
export type ReplicaInput = {
|
||||
state: string
|
||||
shape: string
|
||||
color: string
|
||||
follow: boolean
|
||||
paper: string
|
||||
size: number
|
||||
eye?: number
|
||||
autoTricks?: boolean
|
||||
}
|
||||
|
||||
const TILE_MAX = 6
|
||||
let tileActive = 0
|
||||
const tileWait: Array<() => void> = []
|
||||
|
||||
function appliqueEncre(bot: ReplicaBot, v: string) {
|
||||
const spec = parseInk(v)
|
||||
if (spec.kind === 'preset') {
|
||||
bot.setInk(null)
|
||||
bot.setColor(spec.id)
|
||||
} else if (spec.kind === 'flat') {
|
||||
bot.setInk(spec.hex)
|
||||
} else if (spec.kind === 'radial') {
|
||||
bot.setInkGrad({ from: spec.from, to: spec.to, angle: 0, radial: true })
|
||||
} else {
|
||||
bot.setInkGrad({ from: spec.from, to: spec.to, angle: spec.angle })
|
||||
}
|
||||
}
|
||||
|
||||
function lockTile(): Promise<void> {
|
||||
if (tileActive < TILE_MAX) {
|
||||
tileActive++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
tileWait.push(() => {
|
||||
tileActive++
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function unlockTile() {
|
||||
tileActive--
|
||||
tileWait.shift()?.()
|
||||
}
|
||||
|
||||
export async function paintTile(
|
||||
svg: SVGSVGElement,
|
||||
input: ReplicaInput
|
||||
): Promise<ReplicaMount | null> {
|
||||
await lockTile()
|
||||
try {
|
||||
svg.innerHTML = ''
|
||||
return await mountReplica(svg, input, false)
|
||||
} finally {
|
||||
unlockTile()
|
||||
}
|
||||
}
|
||||
|
||||
export async function mountReplica(
|
||||
svg: SVGSVGElement,
|
||||
input: ReplicaInput,
|
||||
live: boolean,
|
||||
extra?: { driven?: boolean }
|
||||
): Promise<ReplicaMount | null> {
|
||||
const ok = await loadReplica()
|
||||
if (!ok || !window.GrokCharacter) return null
|
||||
const Re = window.GROK_GEO?.Re ?? 114.27
|
||||
const driven = !!extra?.driven
|
||||
const spec = parseInk(input.color)
|
||||
const bot: ReplicaBot = new window.GrokCharacter(svg, {
|
||||
state: input.state,
|
||||
shape: input.shape,
|
||||
color: spec.kind === 'preset' ? spec.id : 'black',
|
||||
inkFlat: spec.kind === 'flat' ? spec.hex : undefined,
|
||||
inkGrad:
|
||||
spec.kind === 'grad'
|
||||
? { from: spec.from, to: spec.to, angle: spec.angle }
|
||||
: spec.kind === 'radial'
|
||||
? { from: spec.from, to: spec.to, angle: 0, radial: true }
|
||||
: undefined,
|
||||
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,
|
||||
eyeIndex: input.eye,
|
||||
autoTricks: input.autoTricks ?? true
|
||||
})
|
||||
const noop = {
|
||||
apply: () => {},
|
||||
spin: () => {},
|
||||
orbitGaze: () => {},
|
||||
step: () => {},
|
||||
setState: () => {},
|
||||
setPose: () => {},
|
||||
setManualOffset: () => {},
|
||||
setEyeTune: () => {},
|
||||
setManualHold: () => {},
|
||||
flushPerformance: () => {},
|
||||
seekEye: () => {},
|
||||
setPlaylistHold: () => {},
|
||||
setPaused: () => {},
|
||||
holdFrame: () => {},
|
||||
freezeNow: () => null,
|
||||
playback: () => null,
|
||||
parts: () => null,
|
||||
destroy: () => {}
|
||||
}
|
||||
if (!live && !driven) {
|
||||
bot.destroy()
|
||||
return noop
|
||||
}
|
||||
|
||||
let last = { ...input }
|
||||
return {
|
||||
apply(next) {
|
||||
if (next.state !== last.state) bot.setState(next.state, { resetEyes: false })
|
||||
if (next.shape !== last.shape) bot.setShape(next.shape)
|
||||
if (next.color !== last.color) appliqueEncre(bot, next.color)
|
||||
if (next.follow !== last.follow) bot.setFollowPointer(next.follow)
|
||||
if (next.paper !== last.paper) bot.setEyeColor(next.paper)
|
||||
if (next.size !== last.size) bot.setSize(next.size)
|
||||
if ((next.autoTricks ?? true) !== (last.autoTricks ?? true))
|
||||
bot.setAutoTricks(next.autoTricks ?? true)
|
||||
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 }
|
||||
},
|
||||
setPose: (pose) => bot.setPose(pose),
|
||||
setManualOffset: (offset) => bot.setManualOffset(offset),
|
||||
setEyeTune: (tune) => bot.setEyeTune(tune),
|
||||
setManualHold: (on) => bot.setManualHold(on),
|
||||
flushPerformance: () => bot.flushPerformance(),
|
||||
seekEye: (index, opts) => bot.seekEye(index, opts),
|
||||
setPlaylistHold: (on) => bot.setPlaylistHold(on),
|
||||
setPaused: (on) => bot.setPaused(on),
|
||||
holdFrame: (at) => bot.holdFrame(at),
|
||||
freezeNow: (opts) => bot.freezeNow(opts),
|
||||
playback: () => bot.playback(),
|
||||
parts: () => bot.parts(),
|
||||
destroy: () => bot.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export { replicaReady }
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Playlist, holds and blinks of a single replica state. Reads live tables. */
|
||||
|
||||
export type HoldRange = [number, number]
|
||||
|
||||
const FALLBACK_HOLD: HoldRange = [2000, 3600]
|
||||
|
||||
export const FIRST_BLINK_MS: HoldRange = [1500, 7000]
|
||||
export const BLINK_DUR_MS = 300
|
||||
|
||||
export function playlistOf(state: string): number[] {
|
||||
const list = window.GROK_TABLES?.EYE_PLAYLIST?.[state]
|
||||
return list?.length ? [...list] : [0]
|
||||
}
|
||||
|
||||
export function holdRangeOf(state: string): HoldRange {
|
||||
const range = window.GROK_TABLES?.EYE_HOLD_MS?.[state]
|
||||
if (!range || range.length < 2) return FALLBACK_HOLD
|
||||
return [range[0]!, range[1]!]
|
||||
}
|
||||
|
||||
export function blinkRangeOf(state: string): HoldRange | null {
|
||||
const range = window.GROK_TABLES?.BLINK_MS?.[state]
|
||||
if (!range || range.length < 2) return null
|
||||
return [range[0]!, range[1]!]
|
||||
}
|
||||
|
||||
export function holdMid(range: HoldRange): number {
|
||||
return (range[0] + range[1]) / 2
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module '../../replica/src/*.js'
|
||||
+1443
File diff suppressed because it is too large
Load Diff
+351
@@ -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(/<g[^>]*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 =
|
||||
'<style>' +
|
||||
// `transform-box`/`transform-origin` ne sont pas decoratifs : sans eux, une
|
||||
// transformation CSS sur un element SVG tourne autour du centre de sa boite
|
||||
// au lieu de l'origine du repere, et l'oeil part a l'autre bout de la boule.
|
||||
`.oeil0,.oeil1{transform-box:view-box;transform-origin:0 0;` +
|
||||
// `alternate` donne une boucle SANS COUTURE : la derive du regard n'est pas
|
||||
// periodique (ses periodes sont premieres entre elles pour ne jamais se
|
||||
// repeter), donc une boucle simple montrerait un saut au raccord. Jouee puis
|
||||
// rejouee a l'envers, elle reboucle exactement sur elle-meme — et un
|
||||
// clignement a l'envers reste un clignement.
|
||||
`animation-duration:${duree}s;animation-iteration-count:infinite;` +
|
||||
`animation-timing-function:linear;animation-direction:alternate}` +
|
||||
Array.from({ length: n }, (_, i) => `.oeil${i}{animation-name:oeil${i}}`).join('') +
|
||||
regles.join('') +
|
||||
'</style>'
|
||||
|
||||
return base.replace(clip[0], clipAnime).replace('</svg>', `${style}</svg>`)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- 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<string, number>()
|
||||
|
||||
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<number, number>
|
||||
/** RGB compacte sur 24 bits -> index, une fois la palette arretee. */
|
||||
index: Map<number, number>
|
||||
/** Resolution des teintes absentes vers la plus proche retenue. */
|
||||
proches: Map<number, number>
|
||||
/** 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<ArrayBuffer> {
|
||||
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<ArrayBuffer> {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Angle CSS : 0 vers le haut, sens horaire, ramene sur [0, 360). */
|
||||
export function tourAngle(a: number) {
|
||||
return ((Math.round(a) % 360) + 360) % 360
|
||||
}
|
||||
|
||||
/** Angle depuis le centre d'un cadran, meme convention que `linear-gradient`. */
|
||||
export function angleDepuisPointeur(el: HTMLElement, e: PointerEvent) {
|
||||
const r = el.getBoundingClientRect()
|
||||
const x = e.clientX - (r.left + r.width / 2)
|
||||
const y = e.clientY - (r.top + r.height / 2)
|
||||
return (Math.atan2(x, -y) * 180) / Math.PI
|
||||
}
|
||||
|
||||
export const PAS_CADRAN: Record<string, number> = {
|
||||
ArrowLeft: -15,
|
||||
ArrowUp: -15,
|
||||
ArrowRight: 15,
|
||||
ArrowDown: 15
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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
|
||||
clone.querySelectorAll('.manual-wires, .manual-head-outline').forEach((el) => el.remove())
|
||||
clone.querySelectorAll('.cyan-outline').forEach((el) => {
|
||||
el.classList.remove('cyan-outline')
|
||||
el.removeAttribute('stroke')
|
||||
el.removeAttribute('stroke-width')
|
||||
})
|
||||
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].filter(
|
||||
(child) => !child.matches('.manual-wires, .manual-head-outline')
|
||||
)
|
||||
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<Blob> {
|
||||
const canvas = document.createElement('canvas')
|
||||
await dessine(markup, taille, canvas)
|
||||
return await new Promise<Blob>((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<Blob>) {
|
||||
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
|
||||
oeil?: 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 ?? reglages.oeil ?? '#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<Blob> {
|
||||
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<Blob> {
|
||||
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<SVGSVGElement>
|
||||
ferme: () => void
|
||||
}
|
||||
|
||||
export async function ouvreCycle(
|
||||
reglages: ReglagesBot,
|
||||
blocs: Block[],
|
||||
taille: number,
|
||||
paper?: string
|
||||
): Promise<LecteurHorsEcran> {
|
||||
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<Blob> {
|
||||
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<Blob> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<string, ActionExport>(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(/<!--[\s\S]*?-->/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()
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Mode photo : composition du cadrage, fonds et assemblage du SVG final.
|
||||
* Pur — aucun DOM. Le cadre fait 300 unites (viewBox -150 -150 300 300) :
|
||||
* en CSS, translate(x/3 %) sur un cadre carre egale translate(x) en SVG,
|
||||
* et scale() d'origine centre egale au couple translate/scale autour de 0.
|
||||
*/
|
||||
|
||||
export type CompositionPhoto = Readonly<{
|
||||
x: number
|
||||
y: number
|
||||
scale: number
|
||||
cornerRadius: number
|
||||
}>
|
||||
|
||||
export const COMPOSITION_DEFAUT: CompositionPhoto = { x: 0, y: 0, scale: 1, cornerRadius: 0 }
|
||||
|
||||
const borne = (valeur: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, Number.isFinite(valeur) ? valeur : min))
|
||||
|
||||
const arrondi = (valeur: number, decimales = 1) => Number(valeur.toFixed(decimales))
|
||||
|
||||
export const normaliseComposition = (c: CompositionPhoto): CompositionPhoto => ({
|
||||
x: arrondi(borne(c.x, -180, 180)),
|
||||
y: arrondi(borne(c.y, -180, 180)),
|
||||
scale: arrondi(borne(c.scale, 0.4, 3), 3),
|
||||
cornerRadius: arrondi(borne(c.cornerRadius, 0, 50))
|
||||
})
|
||||
|
||||
/** Pourcent (0-50) vers unites viewBox du cadre 300. */
|
||||
export const rayonCoins = (cornerRadius: number) => borne(cornerRadius, 0, 50) * 3
|
||||
|
||||
export type FondPhoto = 'transparent' | 'solid' | 'linear' | 'radial'
|
||||
|
||||
/** Direction du linear en degres CSS : 0 = vers le haut, 135 = vers le bas-droite. */
|
||||
export const ANGLE_DEFAUT = 135
|
||||
|
||||
export type OptionsPhoto = {
|
||||
fond: FondPhoto
|
||||
couleurDe: string
|
||||
couleurA: string
|
||||
angle: number
|
||||
taille: number
|
||||
composition: CompositionPhoto
|
||||
}
|
||||
|
||||
/** Fond CSS du cadre vivant — meme geometrie que `degrades`/`rectFond`. */
|
||||
export function fondCss(
|
||||
fond: FondPhoto,
|
||||
couleurDe: string,
|
||||
couleurA: string,
|
||||
angle: number = ANGLE_DEFAUT
|
||||
) {
|
||||
if (fond === 'solid') return couleurDe
|
||||
if (fond === 'linear') return `linear-gradient(${angle}deg, ${couleurDe}, ${couleurA})`
|
||||
if (fond === 'radial') return `radial-gradient(circle at 50% 42%, ${couleurDe}, ${couleurA})`
|
||||
return undefined
|
||||
}
|
||||
|
||||
const degrades = (o: OptionsPhoto) => {
|
||||
if (o.fond === 'linear') {
|
||||
/*
|
||||
* Le repere du cadre est carre : pivoter le vecteur diagonale (0,0)→(1,1)
|
||||
* autour du centre egale exactement l'angle CSS, la diagonale valant 135°.
|
||||
*/
|
||||
const rotation = Math.round(o.angle) - 135
|
||||
const transform = rotation % 360 === 0 ? '' : ` gradientTransform="rotate(${rotation} 0.5 0.5)"`
|
||||
return `<linearGradient id="photo-linear" x1="0" y1="0" x2="1" y2="1"${transform}><stop offset="0" stop-color="${o.couleurDe}"/><stop offset="1" stop-color="${o.couleurA}"/></linearGradient>`
|
||||
}
|
||||
if (o.fond === 'radial') {
|
||||
return `<radialGradient id="photo-radial" cx="50%" cy="42%" r="70%"><stop offset="0" stop-color="${o.couleurDe}"/><stop offset="1" stop-color="${o.couleurA}"/></radialGradient>`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const rectFond = (o: OptionsPhoto) => {
|
||||
if (o.fond === 'transparent') return ''
|
||||
const fill = o.fond === 'solid' ? o.couleurDe : `url(#photo-${o.fond})`
|
||||
return `<rect x="-150" y="-150" width="300" height="300" fill="${fill}"/>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Enveloppe l'avatar autonome (sortie de `svgAutonome` en 300 x 300) dans le
|
||||
* cadre photo : degrade, fond, coins arrondis, translation et echelle.
|
||||
*/
|
||||
export function svgPhoto(markup: string, options: OptionsPhoto) {
|
||||
const o = { ...options, composition: normaliseComposition(options.composition) }
|
||||
const c = o.composition
|
||||
const interne = markup.replace('<svg ', '<svg x="-150" y="-150" ')
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-150 -150 300 300" width="${o.taille}" height="${o.taille}" role="img">
|
||||
<defs>${degrades(o)}<clipPath id="photo-cadre"><rect x="-150" y="-150" width="300" height="300" rx="${rayonCoins(c.cornerRadius)}"/></clipPath></defs>
|
||||
<g clip-path="url(#photo-cadre)">${rectFond(o)}<g transform="translate(${c.x} ${c.y}) scale(${c.scale})">${interne}</g></g>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Palettes aleatoires OKLch, contraste >= 3:1 avec la couleur du corps */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type StylePalette = 'solid' | 'linear' | 'radial'
|
||||
|
||||
export type PalettePhoto = {
|
||||
couleurDe: string
|
||||
couleurA: string
|
||||
}
|
||||
|
||||
type CouleurOklch = {
|
||||
lightness: number
|
||||
chroma: number
|
||||
hue: number
|
||||
}
|
||||
|
||||
const hexVersRgb = (hex: string) => {
|
||||
const normalise = hex.replace('#', '')
|
||||
if (!/^[0-9a-f]{6}$/i.test(normalise)) return null
|
||||
return {
|
||||
red: Number.parseInt(normalise.slice(0, 2), 16),
|
||||
green: Number.parseInt(normalise.slice(2, 4), 16),
|
||||
blue: Number.parseInt(normalise.slice(4, 6), 16)
|
||||
}
|
||||
}
|
||||
|
||||
const luminanceRelative = (hex: string) => {
|
||||
const rgb = hexVersRgb(hex)
|
||||
if (!rgb) return 0
|
||||
const canal = (valeur: number) => {
|
||||
const normalise = valeur / 255
|
||||
return normalise <= 0.04045
|
||||
? normalise / 12.92
|
||||
: ((normalise + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * canal(rgb.red) + 0.7152 * canal(rgb.green) + 0.0722 * canal(rgb.blue)
|
||||
}
|
||||
|
||||
export const contrastePalette = (premiere: string, seconde: string) => {
|
||||
const l1 = luminanceRelative(premiere)
|
||||
const l2 = luminanceRelative(seconde)
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
|
||||
}
|
||||
|
||||
const bornePalette = (valeur: number, min = 0, max = 1) =>
|
||||
Math.min(Math.max(valeur, min), max)
|
||||
|
||||
const lineaireVersSrgb = (valeur: number) => {
|
||||
const normalise = bornePalette(valeur)
|
||||
return normalise <= 0.0031308
|
||||
? normalise * 12.92
|
||||
: 1.055 * normalise ** (1 / 2.4) - 0.055
|
||||
}
|
||||
|
||||
const canalVersHex = (valeur: number) =>
|
||||
Math.round(bornePalette(valeur) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
.toUpperCase()
|
||||
|
||||
const oklchVersHex = ({ lightness, chroma, hue }: CouleurOklch) => {
|
||||
const radians = (hue * Math.PI) / 180
|
||||
const a = chroma * Math.cos(radians)
|
||||
const b = chroma * Math.sin(radians)
|
||||
const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b
|
||||
const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b
|
||||
const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b
|
||||
const l = lPrime ** 3
|
||||
const m = mPrime ** 3
|
||||
const s = sPrime ** 3
|
||||
const red = lineaireVersSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)
|
||||
const green = lineaireVersSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)
|
||||
const blue = lineaireVersSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
|
||||
return `#${canalVersHex(red)}${canalVersHex(green)}${canalVersHex(blue)}`
|
||||
}
|
||||
|
||||
const memePalette = (a: PalettePhoto, b: PalettePhoto) =>
|
||||
a.couleurDe.toLowerCase() === b.couleurDe.toLowerCase() &&
|
||||
a.couleurA.toLowerCase() === b.couleurA.toLowerCase()
|
||||
|
||||
const contrasteMinimal = (palette: PalettePhoto, avantPlan: string) =>
|
||||
Math.min(
|
||||
contrastePalette(palette.couleurDe, avantPlan),
|
||||
contrastePalette(palette.couleurA, avantPlan)
|
||||
)
|
||||
|
||||
const teinteEssai = (alea: () => number, essai: number) =>
|
||||
(alea() * 360 + essai * 137.508) % 360
|
||||
|
||||
const generePalette = (
|
||||
style: StylePalette,
|
||||
fondSombre: boolean,
|
||||
alea: () => number,
|
||||
essai: number
|
||||
): PalettePhoto => {
|
||||
const teinte = teinteEssai(alea, essai)
|
||||
const chroma = 0.065 + alea() * 0.055
|
||||
|
||||
if (style === 'solid') {
|
||||
const lightness = fondSombre ? 0.3 + alea() * 0.14 : 0.78 + alea() * 0.13
|
||||
const couleur = oklchVersHex({ lightness, chroma, hue: teinte })
|
||||
return { couleurDe: couleur, couleurA: couleur }
|
||||
}
|
||||
|
||||
const decalTeinte = 18 + alea() * 38
|
||||
const secondeTeinte = (teinte + (alea() > 0.5 ? decalTeinte : -decalTeinte) + 360) % 360
|
||||
if (style === 'radial') {
|
||||
const lCentre = fondSombre ? 0.36 : 0.94
|
||||
const lBord = fondSombre ? 0.12 : 0.7
|
||||
return {
|
||||
couleurDe: oklchVersHex({ lightness: lCentre, chroma: chroma * 0.82, hue: teinte }),
|
||||
couleurA: oklchVersHex({
|
||||
lightness: lBord,
|
||||
chroma: chroma * (fondSombre ? 0.42 : 0.85),
|
||||
hue: secondeTeinte
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const lDebut = fondSombre ? 0.38 : 0.92
|
||||
const lFin = fondSombre ? 0.2 : 0.74
|
||||
return {
|
||||
couleurDe: oklchVersHex({ lightness: lDebut, chroma, hue: teinte }),
|
||||
couleurA: oklchVersHex({ lightness: lFin, chroma: chroma * 0.9, hue: secondeTeinte })
|
||||
}
|
||||
}
|
||||
|
||||
export const paletteAleatoire = (
|
||||
style: StylePalette,
|
||||
avantPlan: string,
|
||||
actuelle: PalettePhoto,
|
||||
alea: () => number = Math.random
|
||||
): PalettePhoto => {
|
||||
const contrasteSombre = contrastePalette(avantPlan, '#15191F')
|
||||
const contrasteClair = contrastePalette(avantPlan, '#F2E8D4')
|
||||
const fondSombre =
|
||||
contrasteSombre >= 3 && contrasteClair >= 3
|
||||
? alea() >= 0.5
|
||||
: contrasteSombre >= contrasteClair
|
||||
let meilleure: PalettePhoto | null = null
|
||||
let meilleurContraste = 0
|
||||
|
||||
for (let essai = 0; essai < 24; essai += 1) {
|
||||
const palette = generePalette(style, fondSombre, alea, essai)
|
||||
if (memePalette(palette, actuelle)) continue
|
||||
const contraste = contrasteMinimal(palette, avantPlan)
|
||||
if (contraste >= 3) return palette
|
||||
if (contraste > meilleurContraste) {
|
||||
meilleure = palette
|
||||
meilleurContraste = contraste
|
||||
}
|
||||
}
|
||||
|
||||
return meilleure ?? generePalette(style, fondSombre, alea, 25)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
|
||||
|
||||
export function playbackStatus(playing: boolean, stopped = false): PlaybackStatus {
|
||||
if (playing) return 'playing'
|
||||
return stopped ? 'stopped' : 'paused'
|
||||
}
|
||||
|
||||
export function clampSheet(y: number, collapsed: number): number {
|
||||
return Math.min(collapsed, Math.max(0, y))
|
||||
}
|
||||
|
||||
export function snapExpanded(y: number, velocityY: number, collapsed: number): boolean {
|
||||
if (collapsed <= 0) return false
|
||||
return y + velocityY * 0.16 < collapsed / 2
|
||||
}
|
||||
|
||||
export function detailsOpacity(y: number, collapsed: number): number {
|
||||
if (collapsed <= 0) return 1
|
||||
return 1 - Math.min(1, Math.max(0, y / collapsed))
|
||||
}
|
||||
|
||||
export function countdownAngle(elapsed: number, duration: number): number {
|
||||
if (duration <= 0) return 0
|
||||
return (1 - Math.min(1, Math.max(0, elapsed / duration))) * 360
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
export type Quaternion = readonly [number, number, number, number]
|
||||
export type Point3 = readonly [number, number, number]
|
||||
export type HeadPose = { turn: number; tilt: number; roll: number }
|
||||
|
||||
export const RADIUS = 120
|
||||
const RING = 30
|
||||
const FOCAL = 620
|
||||
const PERSPECTIVE = 1
|
||||
const QUARTER_ARC_SAMPLES = 14
|
||||
|
||||
export const clamp = (value: number, min: number, max: number) =>
|
||||
Math.max(min, Math.min(max, value))
|
||||
|
||||
export const radians = (degrees: number) => (degrees * Math.PI) / 180
|
||||
|
||||
export const normalizeQuaternion = ([w, x, y, z]: Quaternion): Quaternion => {
|
||||
const length = Math.hypot(w, x, y, z) || 1
|
||||
return [w / length, x / length, y / length, z / length]
|
||||
}
|
||||
|
||||
export const multiplyQuaternions = (
|
||||
[aw, ax, ay, az]: Quaternion,
|
||||
[bw, bx, by, bz]: Quaternion
|
||||
): Quaternion =>
|
||||
normalizeQuaternion([
|
||||
aw * bw - ax * bx - ay * by - az * bz,
|
||||
aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw
|
||||
])
|
||||
|
||||
export const quaternionFromAxisAngle = ([x, y, z]: Point3, angle: number): Quaternion => {
|
||||
const halfAngle = angle / 2
|
||||
const sine = Math.sin(halfAngle)
|
||||
return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine])
|
||||
}
|
||||
|
||||
export const quaternionFromEuler = (x: number, y: number, z: number): Quaternion => {
|
||||
const xRotation = quaternionFromAxisAngle([1, 0, 0], x)
|
||||
const yRotation = quaternionFromAxisAngle([0, 1, 0], y)
|
||||
const zRotation = quaternionFromAxisAngle([0, 0, 1], z)
|
||||
return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation)
|
||||
}
|
||||
|
||||
export const quaternionFromVectors = (from: Point3, to: Point3): Quaternion => {
|
||||
const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2]
|
||||
const cross: Point3 = [
|
||||
from[1] * to[2] - from[2] * to[1],
|
||||
from[2] * to[0] - from[0] * to[2],
|
||||
from[0] * to[1] - from[1] * to[0]
|
||||
]
|
||||
return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]])
|
||||
}
|
||||
|
||||
export const quaternionToEuler = ([w, x, y, z]: Quaternion): Point3 => {
|
||||
const matrix00 = 1 - 2 * (y * y + z * z)
|
||||
const matrix01 = 2 * (x * y - z * w)
|
||||
const matrix10 = 2 * (x * y + z * w)
|
||||
const matrix11 = 1 - 2 * (x * x + z * z)
|
||||
const matrix20 = 2 * (x * z - y * w)
|
||||
const matrix21 = 2 * (y * z + x * w)
|
||||
const matrix22 = 1 - 2 * (x * x + y * y)
|
||||
const headX = Math.asin(clamp(matrix21, -1, 1))
|
||||
if (Math.abs(Math.cos(headX)) < 0.00001) return [headX, 0, Math.atan2(matrix10, matrix00)]
|
||||
return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)]
|
||||
}
|
||||
|
||||
export const rotateWithQuaternion = ([w, x, y, z]: Quaternion, [px, py, pz]: Point3): Point3 => {
|
||||
const tx = 2 * (y * pz - z * py)
|
||||
const ty = 2 * (z * px - x * pz)
|
||||
const tz = 2 * (x * py - y * px)
|
||||
return [
|
||||
px + w * tx + (y * tz - z * ty),
|
||||
py + w * ty + (z * tx - x * tz),
|
||||
pz + w * tz + (x * ty - y * tx)
|
||||
]
|
||||
}
|
||||
|
||||
const nearestEquivalentAngle = (angle: number, current: number) => {
|
||||
let result = angle
|
||||
while (result - current > 180) result -= 360
|
||||
while (result - current < -180) result += 360
|
||||
return clamp(result, -365, 365)
|
||||
}
|
||||
|
||||
// Engine eyes.js EYE_HOME: the frame where eyes rest centered. Wires, gizmo
|
||||
// and eye projection share it so the grid's central meridian splits the eyes.
|
||||
export const EYE_HOME: HeadPose = { turn: 17, tilt: -14, roll: 29 }
|
||||
|
||||
export const conjugateFlipY = ([w, x, y, z]: Quaternion): Quaternion => [w, -x, y, -z]
|
||||
|
||||
export const orientationOf = (pose: HeadPose): Quaternion =>
|
||||
quaternionFromEuler(radians(pose.tilt), radians(pose.turn), radians(pose.roll))
|
||||
|
||||
export const poseFromOrientation = (pose: HeadPose, orientation: Quaternion): HeadPose => {
|
||||
const [rx, ry, rz] = quaternionToEuler(orientation)
|
||||
return {
|
||||
tilt: nearestEquivalentAngle((rx * 180) / Math.PI, pose.tilt),
|
||||
turn: nearestEquivalentAngle((ry * 180) / Math.PI, pose.turn),
|
||||
roll: nearestEquivalentAngle((rz * 180) / Math.PI, pose.roll)
|
||||
}
|
||||
}
|
||||
|
||||
export const visualOrientation = (pose: HeadPose, home: HeadPose = EYE_HOME): Quaternion => {
|
||||
const [w, x, y, z] = orientationOf(home)
|
||||
return multiplyQuaternions(orientationOf(pose), [w, -x, -y, -z])
|
||||
}
|
||||
|
||||
export const screenOrientation = (pose: HeadPose, home: HeadPose = EYE_HOME): Quaternion =>
|
||||
conjugateFlipY(visualOrientation(pose, home))
|
||||
|
||||
export const poseFromScreen = (
|
||||
pose: HeadPose,
|
||||
screen: Quaternion,
|
||||
home: HeadPose = EYE_HOME
|
||||
): HeadPose =>
|
||||
poseFromOrientation(pose, multiplyQuaternions(conjugateFlipY(screen), orientationOf(home)))
|
||||
|
||||
export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 =>
|
||||
axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]
|
||||
|
||||
export const rotatePoseAroundAxis = (pose: HeadPose, axis: 'x' | 'y' | 'z', deltaDegrees: number) => {
|
||||
const start = screenOrientation(pose)
|
||||
const worldAxis = rotateWithQuaternion(start, axisVector(axis))
|
||||
return poseFromScreen(
|
||||
pose,
|
||||
multiplyQuaternions(quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)), start)
|
||||
)
|
||||
}
|
||||
|
||||
export const rotatePoseAroundCamera = (pose: HeadPose, deltaRadians: number) =>
|
||||
poseFromScreen(
|
||||
pose,
|
||||
multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), screenOrientation(pose))
|
||||
)
|
||||
|
||||
const arcballVector = (
|
||||
[xValue, yValue]: readonly [number, number],
|
||||
radius = RADIUS
|
||||
): Point3 => {
|
||||
const x = xValue / radius
|
||||
const y = yValue / radius
|
||||
const squaredLength = x * x + y * y
|
||||
if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)]
|
||||
const length = Math.sqrt(squaredLength)
|
||||
return [x / length, y / length, 0]
|
||||
}
|
||||
|
||||
export const rotatePoseWithArcball = (
|
||||
pose: HeadPose,
|
||||
startPoint: readonly [number, number],
|
||||
currentPoint: readonly [number, number],
|
||||
radius = RADIUS
|
||||
) => {
|
||||
const start = screenOrientation(pose)
|
||||
const delta = quaternionFromVectors(
|
||||
arcballVector(startPoint, radius),
|
||||
arcballVector(currentPoint, radius)
|
||||
)
|
||||
return poseFromScreen(pose, multiplyQuaternions(delta, start))
|
||||
}
|
||||
|
||||
export const rotationRing = (pose: HeadPose, axis: 'x' | 'y' | 'z', radius = RING): Point3[] =>
|
||||
Array.from({ length: 97 }, (_, index) => {
|
||||
const angle = (index / 96) * Math.PI * 2
|
||||
const cosine = Math.cos(angle)
|
||||
const sine = Math.sin(angle)
|
||||
const point: Point3 =
|
||||
axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
|
||||
const rotated = rotateWithQuaternion(screenOrientation(pose), point)
|
||||
return [rotated[0] * radius, rotated[1] * radius, rotated[2]]
|
||||
})
|
||||
|
||||
export const ringPath = (points: Point3[]) =>
|
||||
`M${points[0]![0]} ${points[0]![1]}${points
|
||||
.slice(1)
|
||||
.map((point) => `L${point[0]} ${point[1]}`)
|
||||
.join('')}Z`
|
||||
|
||||
export const openRingPath = (points: Point3[]) => {
|
||||
if (!points.length) return ''
|
||||
return `M${points[0]![0]} ${points[0]![1]}${points
|
||||
.slice(1)
|
||||
.map((point) => `L${point[0]} ${point[1]}`)
|
||||
.join('')}`
|
||||
}
|
||||
|
||||
export const splitRingArcs = (points: Point3[]): { front: Point3[][]; back: Point3[][] } => {
|
||||
const front: Point3[][] = []
|
||||
const back: Point3[][] = []
|
||||
if (points.length < 2) return { front, back }
|
||||
const cut = (from: Point3, to: Point3): Point3 => {
|
||||
const span = to[2] - from[2]
|
||||
const t = Math.abs(span) < 1e-8 ? 0.5 : from[2] / -span
|
||||
return [
|
||||
from[0] + (to[0] - from[0]) * t,
|
||||
from[1] + (to[1] - from[1]) * t,
|
||||
0
|
||||
]
|
||||
}
|
||||
let current: Point3[] = [points[0]!]
|
||||
let facing = points[0]![2] >= 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const point = points[i]!
|
||||
const nowFront = point[2] >= 0
|
||||
if (nowFront !== facing) {
|
||||
const mid = cut(current[current.length - 1]!, point)
|
||||
current.push(mid)
|
||||
;(facing ? front : back).push(current)
|
||||
current = [mid, point]
|
||||
facing = nowFront
|
||||
} else {
|
||||
current.push(point)
|
||||
}
|
||||
}
|
||||
if (current.length > 1) (facing ? front : back).push(current)
|
||||
return { front, back }
|
||||
}
|
||||
|
||||
export const unitVector = (
|
||||
from: readonly [number, number] | Point3,
|
||||
to: readonly [number, number] | Point3
|
||||
): readonly [number, number] => {
|
||||
const x = to[0] - from[0]
|
||||
const y = to[1] - from[1]
|
||||
const length = Math.hypot(x, y) || 1
|
||||
return [x / length, y / length]
|
||||
}
|
||||
|
||||
export const projectLocalPoint = (
|
||||
pose: HeadPose,
|
||||
local: Point3,
|
||||
origin: readonly [number, number] = [0, 0],
|
||||
home: HeadPose = EYE_HOME
|
||||
): Point3 => {
|
||||
const rotated = rotateWithQuaternion(screenOrientation(pose, home), local)
|
||||
const scale = FOCAL / Math.max(FOCAL - rotated[2] * PERSPECTIVE, 0.0001)
|
||||
return [origin[0] + rotated[0] * scale, origin[1] + rotated[1] * scale, rotated[2]]
|
||||
}
|
||||
|
||||
export const roundedRectangle = (width: number, height: number): (readonly [number, number])[] => {
|
||||
const halfWidth = width / 2
|
||||
const halfHeight = height / 2
|
||||
const cornerRadius = Math.min(halfHeight, halfWidth)
|
||||
const points: (readonly [number, number])[] = []
|
||||
const addLine = (start: readonly [number, number], end: readonly [number, number]) => {
|
||||
const samples = Math.max(2, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / 1.5))
|
||||
for (let index = 0; index < samples; index += 1) {
|
||||
const progress = index / samples
|
||||
points.push([
|
||||
start[0] + (end[0] - start[0]) * progress,
|
||||
start[1] + (end[1] - start[1]) * progress
|
||||
])
|
||||
}
|
||||
}
|
||||
const addArc = (centerX: number, centerY: number, startAngle: number) => {
|
||||
for (let index = 0; index < QUARTER_ARC_SAMPLES; index += 1) {
|
||||
const angle = startAngle + (index / QUARTER_ARC_SAMPLES) * (Math.PI / 2)
|
||||
points.push([
|
||||
centerX + Math.cos(angle) * cornerRadius,
|
||||
centerY + Math.sin(angle) * cornerRadius
|
||||
])
|
||||
}
|
||||
}
|
||||
addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight])
|
||||
addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2)
|
||||
addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius])
|
||||
addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0)
|
||||
addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight])
|
||||
addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2)
|
||||
addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius])
|
||||
addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI)
|
||||
return points
|
||||
}
|
||||
|
||||
export const projectEyePoint = (
|
||||
pose: HeadPose,
|
||||
faceX: number,
|
||||
faceY: number,
|
||||
localX: number,
|
||||
localY: number,
|
||||
angleDeg: number,
|
||||
home: HeadPose = EYE_HOME
|
||||
): Point3 => {
|
||||
const angle = radians(angleDeg)
|
||||
const cosine = Math.cos(angle)
|
||||
const sine = Math.sin(angle)
|
||||
return projectFacePoint(
|
||||
pose,
|
||||
faceX + localX * cosine - localY * sine,
|
||||
faceY + localX * sine + localY * cosine,
|
||||
RADIUS,
|
||||
[0, 0],
|
||||
home
|
||||
)
|
||||
}
|
||||
|
||||
export const projectFacePoint = (
|
||||
pose: HeadPose,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
origin: readonly [number, number] = [radius, radius],
|
||||
home: HeadPose = EYE_HOME
|
||||
): Point3 => {
|
||||
const diskX = x / radius
|
||||
const diskY = y / radius
|
||||
// Face coords are authored against RADIUS (original lab units) but ride the
|
||||
// visible head: rim = RADIUS scaled to the head edge in overlay units, so
|
||||
// the eye sphere silhouette lands on the head outline (engine parity).
|
||||
const rim = radius * (95 / 120)
|
||||
// Past the silhouette points stay raw on the z=0 plane and get sliced by
|
||||
// the head clip instead of folding back inward.
|
||||
const diskZ = Math.sqrt(Math.max(0, 1 - diskX * diskX - diskY * diskY))
|
||||
const local: Point3 = [diskX, diskY, diskZ]
|
||||
const rotated = rotateWithQuaternion(screenOrientation(pose, home), local)
|
||||
const depth = rotated[2] * rim
|
||||
const scale = FOCAL / Math.max(FOCAL - depth * PERSPECTIVE, 0.0001)
|
||||
return [
|
||||
origin[0] + rotated[0] * rim * scale,
|
||||
origin[1] + rotated[1] * rim * scale,
|
||||
rotated[2]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { clamp, type HeadPose } from './math'
|
||||
|
||||
export type EyeSide = {
|
||||
width: number
|
||||
height: number
|
||||
size: number
|
||||
angle: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type EyeTune = {
|
||||
left: EyeSide
|
||||
right: EyeSide
|
||||
spacing: number
|
||||
}
|
||||
|
||||
export type EyeLinks = { width: boolean; height: boolean; size: boolean; rotation: boolean }
|
||||
|
||||
export type BodyOffset = { tx: number; ty: number; spin: number }
|
||||
|
||||
export type ManualPart = 'body' | 'left' | 'right'
|
||||
|
||||
export type ManualState = {
|
||||
on: boolean
|
||||
pose: HeadPose
|
||||
offset: BodyOffset
|
||||
eyes: EyeTune
|
||||
links: EyeLinks
|
||||
selected: ManualPart | null
|
||||
}
|
||||
|
||||
export const DEFAULT_POSE: HeadPose = { turn: 17, tilt: -14, roll: 29 }
|
||||
|
||||
export const DEFAULT_OFFSET: BodyOffset = { tx: 0, ty: 0, spin: 0 }
|
||||
|
||||
export const DEFAULT_EYE: EyeSide = { width: 20, height: 50, size: 1, angle: 0, x: 0, y: -7 }
|
||||
|
||||
export const DEFAULT_EYES: EyeTune = {
|
||||
left: { ...DEFAULT_EYE },
|
||||
right: { ...DEFAULT_EYE },
|
||||
spacing: 35
|
||||
}
|
||||
|
||||
export const DEFAULT_LINKS: EyeLinks = { width: true, height: true, size: true, rotation: true }
|
||||
|
||||
export const defaultManual = (): ManualState => ({
|
||||
on: false,
|
||||
pose: { ...DEFAULT_POSE },
|
||||
offset: { ...DEFAULT_OFFSET },
|
||||
eyes: {
|
||||
left: { ...DEFAULT_EYE },
|
||||
right: { ...DEFAULT_EYE },
|
||||
spacing: 35
|
||||
},
|
||||
links: { ...DEFAULT_LINKS },
|
||||
selected: null
|
||||
})
|
||||
|
||||
const num = (value: unknown, fallback: number) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
|
||||
const arrondi = (value: number, decimales = 1) => Number(value.toFixed(decimales))
|
||||
|
||||
const eyeOf = (value: unknown, fallback: EyeSide): EyeSide => {
|
||||
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
||||
const width = num(raw.width, fallback.width)
|
||||
const height = num(raw.height, fallback.height)
|
||||
return {
|
||||
width: arrondi(clamp(width <= 5 ? width * 20 : width, 10, 110)),
|
||||
height: arrondi(clamp(height <= 5 ? height * 50 : height, 10, 110)),
|
||||
size: arrondi(clamp(num(raw.size, fallback.size), 0.35, 2.2), 2),
|
||||
angle: arrondi(num(raw.angle, fallback.angle)),
|
||||
x: arrondi(clamp(num(raw.x, fallback.x), -48, 48)),
|
||||
y: arrondi(clamp(num(raw.y, fallback.y), -48, 48))
|
||||
}
|
||||
}
|
||||
|
||||
export const clampPose = (pose: HeadPose): HeadPose => ({
|
||||
turn: arrondi(clamp(pose.turn, -365, 365)),
|
||||
tilt: arrondi(clamp(pose.tilt, -365, 365)),
|
||||
roll: arrondi(clamp(pose.roll, -365, 365))
|
||||
})
|
||||
|
||||
export const clampOffset = (offset: BodyOffset): BodyOffset => ({
|
||||
tx: arrondi(clamp(offset.tx, -90, 90)),
|
||||
ty: arrondi(clamp(offset.ty, -90, 90)),
|
||||
spin: arrondi(clamp(offset.spin, -180, 180))
|
||||
})
|
||||
|
||||
export const clampEyes = (eyes: EyeTune): EyeTune => ({
|
||||
left: eyeOf(eyes.left, DEFAULT_EYE),
|
||||
right: eyeOf(eyes.right, DEFAULT_EYE),
|
||||
spacing: arrondi(
|
||||
clamp(
|
||||
(() => {
|
||||
const value = num(eyes.spacing, 35)
|
||||
return value <= 5 ? value * 35 : value
|
||||
})(),
|
||||
0,
|
||||
150
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
export const parseManual = (raw: string | null): ManualState => {
|
||||
const base = defaultManual()
|
||||
if (!raw) return base
|
||||
try {
|
||||
const data = JSON.parse(raw) as Record<string, unknown>
|
||||
const pose = data.pose && typeof data.pose === 'object' ? (data.pose as HeadPose) : base.pose
|
||||
const offset =
|
||||
data.offset && typeof data.offset === 'object' ? (data.offset as BodyOffset) : base.offset
|
||||
const eyes = data.eyes && typeof data.eyes === 'object' ? (data.eyes as EyeTune) : base.eyes
|
||||
const links = data.links && typeof data.links === 'object' ? (data.links as EyeLinks) : base.links
|
||||
const selected =
|
||||
data.selected === 'body' || data.selected === 'left' || data.selected === 'right'
|
||||
? data.selected
|
||||
: null
|
||||
return {
|
||||
on: !!data.on,
|
||||
pose: clampPose({
|
||||
turn: num(pose.turn, DEFAULT_POSE.turn),
|
||||
tilt: num(pose.tilt, DEFAULT_POSE.tilt),
|
||||
roll: num(pose.roll, DEFAULT_POSE.roll)
|
||||
}),
|
||||
offset: clampOffset({
|
||||
tx: num(offset.tx, 0),
|
||||
ty: num(offset.ty, 0),
|
||||
spin: num(offset.spin, 0)
|
||||
}),
|
||||
eyes: clampEyes(eyes),
|
||||
links: {
|
||||
width: links.width !== false,
|
||||
height: links.height !== false,
|
||||
size: links.size !== false,
|
||||
rotation: links.rotation !== false
|
||||
},
|
||||
selected
|
||||
}
|
||||
} catch {
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
export const persistable = (state: ManualState) => ({
|
||||
on: state.on,
|
||||
pose: state.pose,
|
||||
offset: state.offset,
|
||||
eyes: state.eyes,
|
||||
links: state.links
|
||||
})
|
||||
|
||||
export const updateEye = (
|
||||
eyes: EyeTune,
|
||||
side: 'left' | 'right',
|
||||
patch: Partial<EyeSide>,
|
||||
links: EyeLinks
|
||||
): EyeTune => {
|
||||
const next = {
|
||||
left: { ...eyes.left },
|
||||
right: { ...eyes.right },
|
||||
spacing: eyes.spacing
|
||||
}
|
||||
next[side] = { ...next[side], ...patch }
|
||||
const other = side === 'left' ? 'right' : 'left'
|
||||
;(['width', 'height', 'size'] as const).forEach((key) => {
|
||||
if (patch[key] != null && links[key]) next[other][key] = patch[key]!
|
||||
})
|
||||
return clampEyes(next)
|
||||
}
|
||||
|
||||
export const scaleEye = (
|
||||
eyes: EyeTune,
|
||||
side: 'left' | 'right',
|
||||
factor: number,
|
||||
linked: boolean
|
||||
): EyeTune => {
|
||||
const apply = (eye: EyeSide): EyeSide => ({
|
||||
...eye,
|
||||
width: clamp(eye.width * factor, 10, 110),
|
||||
height: clamp(eye.height * factor, 10, 110)
|
||||
})
|
||||
const next = {
|
||||
left: { ...eyes.left },
|
||||
right: { ...eyes.right },
|
||||
spacing: eyes.spacing
|
||||
}
|
||||
next[side] = apply(next[side])
|
||||
if (linked) {
|
||||
const other = side === 'left' ? 'right' : 'left'
|
||||
next[other] = apply(next[other])
|
||||
}
|
||||
return clampEyes(next)
|
||||
}
|
||||
|
||||
export const resetHead = (state: ManualState): ManualState => ({
|
||||
...state,
|
||||
pose: { ...DEFAULT_POSE }
|
||||
})
|
||||
|
||||
export const resetBody = (state: ManualState): ManualState => ({
|
||||
...state,
|
||||
offset: { ...DEFAULT_OFFSET }
|
||||
})
|
||||
|
||||
export const resetEyes = (state: ManualState): ManualState => ({
|
||||
...state,
|
||||
eyes: {
|
||||
left: { ...DEFAULT_EYE },
|
||||
right: { ...DEFAULT_EYE },
|
||||
spacing: 35
|
||||
}
|
||||
})
|
||||
|
||||
export const resetAll = (state: ManualState): ManualState => ({
|
||||
...state,
|
||||
pose: { ...DEFAULT_POSE },
|
||||
offset: { ...DEFAULT_OFFSET },
|
||||
eyes: {
|
||||
left: { ...DEFAULT_EYE },
|
||||
right: { ...DEFAULT_EYE },
|
||||
spacing: 35
|
||||
},
|
||||
selected: null
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
export type ManipulationSession<Value> = {
|
||||
initial: Value
|
||||
latest: Value
|
||||
}
|
||||
|
||||
export const beginManipulation = <Value>(initial: Value): ManipulationSession<Value> => ({
|
||||
initial,
|
||||
latest: initial
|
||||
})
|
||||
|
||||
export const beginManipulationFromRenderedValue = <Value>(
|
||||
readRenderedValue: () => Value
|
||||
): ManipulationSession<Value> => beginManipulation(readRenderedValue())
|
||||
|
||||
export const previewManipulation = <Value>(
|
||||
session: ManipulationSession<Value>,
|
||||
next: Value,
|
||||
preview?: (value: Value) => void
|
||||
) => {
|
||||
session.latest = next
|
||||
preview?.(next)
|
||||
}
|
||||
|
||||
export const finishManipulation = <Value>(
|
||||
session: ManipulationSession<Value>,
|
||||
outcome: 'commit' | 'cancel',
|
||||
handlers: { preview: (value: Value) => void; commit: (value: Value) => void }
|
||||
) => {
|
||||
if (outcome === 'cancel') {
|
||||
handlers.preview(session.initial)
|
||||
return session.initial
|
||||
}
|
||||
handlers.commit(session.latest)
|
||||
return session.latest
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
export type LocalPoint = readonly [number, number]
|
||||
|
||||
export type Frame = {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export const VIEW = 300
|
||||
export const HALF = VIEW / 2
|
||||
export const HEAD_RE = 114.2705
|
||||
|
||||
export const localHeadRadius = (svg: SVGSVGElement | null, radius = HEAD_RE) => {
|
||||
if (!svg) return 95
|
||||
const box = svg.viewBox.baseVal
|
||||
if (!box.width) return 95
|
||||
return (radius / box.width) * VIEW
|
||||
}
|
||||
|
||||
export const toLocal = (
|
||||
svg: SVGSVGElement | null,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
): LocalPoint | null => {
|
||||
if (!svg) return null
|
||||
const rectangle = svg.getBoundingClientRect()
|
||||
if (rectangle.width < 1 || rectangle.height < 1) return null
|
||||
return [
|
||||
((clientX - rectangle.left) / rectangle.width) * VIEW - HALF,
|
||||
((clientY - rectangle.top) / rectangle.height) * VIEW - HALF
|
||||
]
|
||||
}
|
||||
|
||||
export const overlayToHost = (
|
||||
host: SVGSVGElement | null,
|
||||
x: number,
|
||||
y: number
|
||||
): LocalPoint | null => {
|
||||
if (!host) return null
|
||||
const box = host.viewBox.baseVal
|
||||
const w = box.width || VIEW
|
||||
const h = box.height || VIEW
|
||||
return [box.x + ((x + HALF) / VIEW) * w, box.y + ((y + HALF) / VIEW) * h]
|
||||
}
|
||||
|
||||
export const hostToLocal = (
|
||||
host: SVGSVGElement | null,
|
||||
x: number,
|
||||
y: number
|
||||
): LocalPoint | null => {
|
||||
if (!host) return null
|
||||
const matrix = host.getScreenCTM()
|
||||
if (!matrix) return toLocal(host, x, y)
|
||||
const point = host.createSVGPoint()
|
||||
point.x = x
|
||||
point.y = y
|
||||
const screen = point.matrixTransform(matrix)
|
||||
return toLocal(host, screen.x, screen.y)
|
||||
}
|
||||
|
||||
export const mapElementPoint = (
|
||||
el: SVGGraphicsElement,
|
||||
x: number,
|
||||
y: number,
|
||||
host: SVGSVGElement | null
|
||||
): LocalPoint | null => {
|
||||
const svg = el.ownerSVGElement
|
||||
const screenMatrix = el.getScreenCTM()
|
||||
const localMatrix = el.getCTM()
|
||||
const hostMatrix = host?.getScreenCTM()
|
||||
if (!svg || (!screenMatrix && (!localMatrix || !hostMatrix))) return null
|
||||
const point = svg.createSVGPoint()
|
||||
point.x = x
|
||||
point.y = y
|
||||
const screen = screenMatrix
|
||||
? point.matrixTransform(screenMatrix)
|
||||
: point.matrixTransform(localMatrix!).matrixTransform(hostMatrix!)
|
||||
return toLocal(host ?? svg, screen.x, screen.y)
|
||||
}
|
||||
|
||||
export const frameOf = (
|
||||
el: SVGGraphicsElement | null | undefined,
|
||||
overlay: SVGSVGElement | null
|
||||
): Frame | null => {
|
||||
if (!el || !overlay) return null
|
||||
if (el.style.display === 'none') return null
|
||||
const box = el.getBoundingClientRect()
|
||||
const host = overlay.getBoundingClientRect()
|
||||
if (box.width < 0.5 || box.height < 0.5 || host.width < 1) return null
|
||||
const sx = VIEW / host.width
|
||||
const sy = VIEW / host.height
|
||||
return {
|
||||
x: (box.left + box.width / 2 - host.left) * sx - HALF,
|
||||
y: (box.top + box.height / 2 - host.top) * sy - HALF,
|
||||
w: box.width * sx,
|
||||
h: box.height * sy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
projectLocalPoint,
|
||||
radians,
|
||||
rotateWithQuaternion,
|
||||
screenOrientation,
|
||||
type HeadPose,
|
||||
type Point3
|
||||
} from './math'
|
||||
|
||||
export type SurfaceType = 'sphere' | 'ellipsoid' | 'capsule' | 'cube' | 'cone' | 'diamond'
|
||||
|
||||
export type SurfaceConfig = {
|
||||
type: SurfaceType
|
||||
width: number
|
||||
height: number
|
||||
depth: number
|
||||
roundness: number
|
||||
}
|
||||
|
||||
type Kind = { type: SurfaceType; roundness?: number; depthScale?: number }
|
||||
|
||||
const KIND: Record<string, Kind> = {
|
||||
blob: { type: 'sphere' },
|
||||
bean: { type: 'capsule' },
|
||||
egg: { type: 'ellipsoid' },
|
||||
teardrop: { type: 'cone', roundness: 0.7 },
|
||||
cloud: { type: 'ellipsoid', depthScale: 0.58 },
|
||||
leaf: { type: 'diamond', roundness: 0.35 },
|
||||
squircle: { type: 'cube', roundness: 0.55 },
|
||||
capsule: { type: 'capsule' }
|
||||
}
|
||||
|
||||
type Sample = { point: Point3; normal: Point3 }
|
||||
|
||||
const signedPow = (value: number, exp: number) => Math.sign(value) * Math.abs(value) ** exp
|
||||
|
||||
const normalize = ([x, y, z]: Point3): Point3 => {
|
||||
const length = Math.hypot(x, y, z) || 1
|
||||
return [x / length, y / length, z / length]
|
||||
}
|
||||
|
||||
const measure = (id: string): { width: number; height: number } => {
|
||||
const geo = window.GROK_GEO
|
||||
const shape = geo?.shapes?.[id] as
|
||||
| { path?: string; radius?: number; top?: number; bottom?: number }
|
||||
| undefined
|
||||
const Re = geo?.Re ?? 114.27
|
||||
const flatten = (
|
||||
window as unknown as { GROK_MATH?: { flattenPath?: (d: string, step?: number) => number[][] } }
|
||||
).GROK_MATH?.flattenPath
|
||||
if (shape?.path && flatten) {
|
||||
let minX = Infinity
|
||||
let maxX = -Infinity
|
||||
let minY = Infinity
|
||||
let maxY = -Infinity
|
||||
for (const pair of flatten(shape.path, 8)) {
|
||||
const x = pair[0]!
|
||||
const y = pair[1]!
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y
|
||||
if (y > maxY) maxY = y
|
||||
}
|
||||
if (Number.isFinite(minX)) return { width: maxX - minX, height: maxY - minY }
|
||||
}
|
||||
const height =
|
||||
shape?.bottom != null && shape.top != null ? shape.bottom - shape.top : Re * 2
|
||||
return { width: (shape?.radius ?? Re) * 2, height }
|
||||
}
|
||||
|
||||
const surfaceCache = new Map<string, SurfaceConfig>()
|
||||
|
||||
export const surfaceOf = (shape: string): SurfaceConfig => {
|
||||
const hit = surfaceCache.get(shape)
|
||||
if (hit) return hit
|
||||
const kind = KIND[shape] ?? { type: 'ellipsoid' as const }
|
||||
const { width, height } = measure(shape)
|
||||
const depth = width * (kind.depthScale ?? (kind.type === 'cube' ? 0.9 : 1))
|
||||
const surface: SurfaceConfig = {
|
||||
type: kind.type,
|
||||
width,
|
||||
height,
|
||||
depth,
|
||||
roundness: kind.roundness ?? 1
|
||||
}
|
||||
surfaceCache.set(shape, surface)
|
||||
return surface
|
||||
}
|
||||
|
||||
const cubeExp = (roundness: number) => {
|
||||
if (roundness <= 0) return Infinity
|
||||
const power = 0.04 + (Math.min(2, roundness) / 2) * 0.96
|
||||
return 2 / power
|
||||
}
|
||||
|
||||
const diamondExp = (roundness: number) => 1 + Math.min(2, Math.max(0, roundness)) / 2
|
||||
|
||||
const lpPoint = (config: SurfaceConfig, lon: number, lat: number, exp: number): Point3 => {
|
||||
const sx = Math.cos(lat) * Math.sin(lon)
|
||||
const sy = Math.sin(lat)
|
||||
const sz = Math.cos(lat) * Math.cos(lon)
|
||||
const length = Number.isFinite(exp)
|
||||
? (Math.abs(sx) ** exp + Math.abs(sy) ** exp + Math.abs(sz) ** exp) ** (1 / exp) || 1
|
||||
: Math.max(Math.abs(sx), Math.abs(sy), Math.abs(sz)) || 1
|
||||
return [(config.width / 2) * (sx / length), (config.height / 2) * (sy / length), (config.depth / 2) * (sz / length)]
|
||||
}
|
||||
|
||||
const ellipsoid = (config: SurfaceConfig, lon: number, lat: number): Point3 => [
|
||||
(config.width / 2) * Math.cos(lat) * Math.sin(lon),
|
||||
(config.height / 2) * Math.sin(lat),
|
||||
(config.depth / 2) * Math.cos(lat) * Math.cos(lon)
|
||||
]
|
||||
|
||||
const capsule = (config: SurfaceConfig, lon: number, lat: number): Point3 => {
|
||||
const rx = config.width / 2
|
||||
const rz = config.depth / 2
|
||||
const cap = Math.min(rx, config.height / 2)
|
||||
const mid = Math.max(0, config.height / 2 - cap)
|
||||
const meridian = mid * 2 + Math.PI * cap
|
||||
const distance = ((lat + Math.PI / 2) / Math.PI) * meridian
|
||||
let radial = rx
|
||||
let y = 0
|
||||
if (distance < (Math.PI * cap) / 2) {
|
||||
const angle = -Math.PI / 2 + distance / cap
|
||||
radial = rx * Math.cos(angle)
|
||||
y = -mid + cap * Math.sin(angle)
|
||||
} else if (distance <= (Math.PI * cap) / 2 + mid * 2) {
|
||||
y = -mid + distance - (Math.PI * cap) / 2
|
||||
} else {
|
||||
const angle = (distance - (Math.PI * cap) / 2 - mid * 2) / cap
|
||||
radial = rx * Math.cos(angle)
|
||||
y = mid + cap * Math.sin(angle)
|
||||
}
|
||||
return [radial * Math.sin(lon), y, radial * (rx ? rz / rx : 1) * Math.cos(lon)]
|
||||
}
|
||||
|
||||
const cone = (config: SurfaceConfig, lon: number, lat: number): Point3 => {
|
||||
const t = (lat + Math.PI / 2) / Math.PI
|
||||
const tip = 0.16 * config.roundness
|
||||
const base = 0.28 * config.roundness
|
||||
let radiusScale: number
|
||||
let yT = t
|
||||
if (tip > 0 && t < tip) {
|
||||
const a = (t / tip) * (Math.PI / 2)
|
||||
radiusScale = tip * Math.sin(a)
|
||||
yT = tip * (1 - Math.cos(a))
|
||||
} else if (base > 0 && t > 1 - base) {
|
||||
const a = ((t - (1 - base)) / base) * (Math.PI / 2)
|
||||
radiusScale = 1 - base + base * Math.cos(a)
|
||||
yT = 1 - base + base * Math.sin(a)
|
||||
} else {
|
||||
const span = 1 - tip - base
|
||||
const u = span > 0 ? (t - tip) / span : 0
|
||||
radiusScale = tip + (1 - base - tip) * u
|
||||
}
|
||||
return [
|
||||
(config.width / 2) * radiusScale * Math.sin(lon),
|
||||
-config.height / 2 + config.height * yT,
|
||||
(config.depth / 2) * radiusScale * Math.cos(lon)
|
||||
]
|
||||
}
|
||||
|
||||
export const surfacePointAt = (config: SurfaceConfig, lon: number, lat: number): Point3 => {
|
||||
switch (config.type) {
|
||||
case 'sphere':
|
||||
case 'ellipsoid':
|
||||
return ellipsoid(config, lon, lat)
|
||||
case 'capsule':
|
||||
return capsule(config, lon, lat)
|
||||
case 'cube':
|
||||
return lpPoint(config, lon, lat, cubeExp(config.roundness))
|
||||
case 'diamond':
|
||||
return lpPoint(config, lon, lat, diamondExp(config.roundness))
|
||||
case 'cone':
|
||||
return cone(config, lon, lat)
|
||||
}
|
||||
}
|
||||
|
||||
const lpNormal = (config: SurfaceConfig, point: Point3, exp: number): Point3 => {
|
||||
const rx = config.width / 2 || 1
|
||||
const ry = config.height / 2 || 1
|
||||
const rz = config.depth / 2 || 1
|
||||
if (!Number.isFinite(exp)) {
|
||||
const n: Point3 = [point[0] / rx, point[1] / ry, point[2] / rz]
|
||||
const axis = n.reduce((i, v, j, a) => (Math.abs(v) > Math.abs(a[i]!) ? j : i), 0)
|
||||
return normalize([axis === 0 ? Math.sign(n[0]) : 0, axis === 1 ? Math.sign(n[1]) : 0, axis === 2 ? Math.sign(n[2]) : 0])
|
||||
}
|
||||
return normalize([
|
||||
signedPow(point[0] / rx, exp - 1) / rx,
|
||||
signedPow(point[1] / ry, exp - 1) / ry,
|
||||
signedPow(point[2] / rz, exp - 1) / rz
|
||||
])
|
||||
}
|
||||
|
||||
const tangentNormal = (config: SurfaceConfig, lon: number, lat: number): Point3 => {
|
||||
const eps = 0.0006
|
||||
const beforeLon = surfacePointAt(config, lon - eps, lat)
|
||||
const afterLon = surfacePointAt(config, lon + eps, lat)
|
||||
const beforeLat = surfacePointAt(config, lon, Math.max(-Math.PI / 2, lat - eps))
|
||||
const afterLat = surfacePointAt(config, lon, Math.min(Math.PI / 2, lat + eps))
|
||||
const dx: Point3 = [afterLon[0] - beforeLon[0], afterLon[1] - beforeLon[1], afterLon[2] - beforeLon[2]]
|
||||
const dy: Point3 = [afterLat[0] - beforeLat[0], afterLat[1] - beforeLat[1], afterLat[2] - beforeLat[2]]
|
||||
return normalize([
|
||||
dx[1] * dy[2] - dx[2] * dy[1],
|
||||
dx[2] * dy[0] - dx[0] * dy[2],
|
||||
dx[0] * dy[1] - dx[1] * dy[0]
|
||||
])
|
||||
}
|
||||
|
||||
const sampleAt = (config: SurfaceConfig, lon: number, lat: number): Sample => {
|
||||
const point = surfacePointAt(config, lon, lat)
|
||||
if (config.type === 'sphere' || config.type === 'ellipsoid') {
|
||||
const rx = config.width / 2 || 1
|
||||
const ry = config.height / 2 || 1
|
||||
const rz = config.depth / 2 || 1
|
||||
return {
|
||||
point,
|
||||
normal: normalize([point[0] / (rx * rx), point[1] / (ry * ry), point[2] / (rz * rz)])
|
||||
}
|
||||
}
|
||||
if (config.type === 'cube') return { point, normal: lpNormal(config, point, cubeExp(config.roundness)) }
|
||||
if (config.type === 'diamond') return { point, normal: lpNormal(config, point, diamondExp(config.roundness)) }
|
||||
return { point, normal: tangentNormal(config, lon, lat) }
|
||||
}
|
||||
|
||||
const keyOf = (surface: SurfaceConfig) =>
|
||||
`${surface.type}:${surface.width.toFixed(2)}:${surface.height.toFixed(2)}:${surface.depth.toFixed(2)}:${surface.roundness}`
|
||||
|
||||
const cache = new Map<string, Sample[][]>()
|
||||
|
||||
const curvesOf = (surface: SurfaceConfig): Sample[][] => {
|
||||
const key = keyOf(surface)
|
||||
const hit = cache.get(key)
|
||||
if (hit) return hit
|
||||
const parallels = [-60, -30, 0, 30, 60].map((lat) =>
|
||||
Array.from({ length: 73 }, (_, i) => sampleAt(surface, radians(-180 + i * 5), radians(lat)))
|
||||
)
|
||||
const meridians = Array.from({ length: 12 }, (_, i) => -150 + i * 30).map((lon) =>
|
||||
Array.from({ length: 37 }, (_, step) => sampleAt(surface, radians(lon), radians(-90 + step * 5)))
|
||||
)
|
||||
const curves = [...parallels, ...meridians]
|
||||
if (cache.size > 24) cache.delete(cache.keys().next().value!)
|
||||
cache.set(key, curves)
|
||||
return curves
|
||||
}
|
||||
|
||||
const polyline = (points: Point3[]) => {
|
||||
if (points.length < 2) return ''
|
||||
return `M${points[0]![0].toFixed(2)} ${points[0]![1].toFixed(2)}${points
|
||||
.slice(1)
|
||||
.map((point) => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`)
|
||||
.join('')}`
|
||||
}
|
||||
|
||||
const visibleWire = (samples: Sample[], pose: HeadPose, origin: readonly [number, number]) => {
|
||||
const orientation = screenOrientation(pose)
|
||||
const segments: Point3[][] = []
|
||||
let segment: Point3[] = []
|
||||
for (const sample of samples) {
|
||||
const facing = rotateWithQuaternion(orientation, sample.normal)[2] > 0
|
||||
if (facing) segment.push(projectLocalPoint(pose, sample.point, origin))
|
||||
else if (segment.length) {
|
||||
segments.push(segment)
|
||||
segment = []
|
||||
}
|
||||
}
|
||||
if (segment.length) segments.push(segment)
|
||||
return segments
|
||||
.filter((item) => item.length > 1)
|
||||
.map(polyline)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export const wirePaths = (
|
||||
pose: HeadPose,
|
||||
surface: SurfaceConfig,
|
||||
origin: readonly [number, number] = [0, 0]
|
||||
): string[] => curvesOf(surface).map((curve) => visibleWire(curve, pose, origin)).filter(Boolean)
|
||||
@@ -0,0 +1,24 @@
|
||||
const PREFIXE = 'study:'
|
||||
const NOMS = ['cycles', 'cycle', 'forme', 'couleur', 'oeil', 'expression', 'langue', 'manuel'] 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 */
|
||||
}
|
||||
}
|
||||
@@ -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 `<span>` 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
|
||||
@@ -0,0 +1,32 @@
|
||||
import { onBeforeUnmount, watch, type Ref, type ShallowRef } from 'vue'
|
||||
|
||||
/**
|
||||
* Pilote un `<dialog>` 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<boolean>,
|
||||
el: Readonly<ShallowRef<HTMLDialogElement | null>>
|
||||
) {
|
||||
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 `<dialog>` laisse en modal bloquerait la page entiere
|
||||
onBeforeUnmount(() => {
|
||||
if (el.value?.open) el.value.close()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { clampSheet, snapExpanded } from '@/ui/playback'
|
||||
|
||||
const TAP = 6
|
||||
|
||||
export function useSnapSheet(details: Ref<HTMLElement | null>) {
|
||||
const y = ref(0)
|
||||
const expanded = ref(false)
|
||||
const dragging = ref(false)
|
||||
const collapsed = ref(0)
|
||||
const ready = ref(false)
|
||||
|
||||
const opacity = computed(() => {
|
||||
const max = collapsed.value
|
||||
if (max <= 0) return expanded.value ? 1 : 0
|
||||
return 1 - Math.min(1, Math.max(0, y.value / max))
|
||||
})
|
||||
|
||||
let originY = 0
|
||||
let originOffset = 0
|
||||
let lastT = 0
|
||||
let lastClientY = 0
|
||||
let velocity = 0
|
||||
let observer: ResizeObserver | null = null
|
||||
|
||||
function measure() {
|
||||
const h = details.value?.getBoundingClientRect().height ?? 0
|
||||
if (h <= 0) return
|
||||
const first = !ready.value
|
||||
collapsed.value = h
|
||||
if (first) {
|
||||
y.value = expanded.value ? 0 : h
|
||||
ready.value = true
|
||||
return
|
||||
}
|
||||
if (!dragging.value) y.value = expanded.value ? 0 : h
|
||||
}
|
||||
|
||||
function snap(open: boolean) {
|
||||
expanded.value = open
|
||||
y.value = open ? 0 : collapsed.value
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
dragging.value = true
|
||||
originY = e.clientY
|
||||
originOffset = y.value
|
||||
lastT = e.timeStamp
|
||||
lastClientY = e.clientY
|
||||
velocity = 0
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
const dt = e.timeStamp - lastT
|
||||
if (dt > 0) velocity = ((e.clientY - lastClientY) / dt) * 1000
|
||||
lastT = e.timeStamp
|
||||
lastClientY = e.clientY
|
||||
y.value = clampSheet(originOffset + (e.clientY - originY), collapsed.value)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragging.value = false
|
||||
if (Math.abs(e.clientY - originY) < TAP) {
|
||||
snap(!expanded.value)
|
||||
return
|
||||
}
|
||||
snap(snapExpanded(y.value, velocity, collapsed.value))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
measure()
|
||||
if (!details.value) return
|
||||
observer = new ResizeObserver(measure)
|
||||
observer.observe(details.value)
|
||||
})
|
||||
|
||||
watch(details, (el, prev) => {
|
||||
if (prev) observer?.unobserve(prev)
|
||||
if (!el) return
|
||||
observer ??= new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
measure()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
|
||||
return {
|
||||
y,
|
||||
expanded,
|
||||
dragging,
|
||||
collapsed,
|
||||
opacity,
|
||||
ready,
|
||||
snap,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp
|
||||
}
|
||||
}
|
||||
+102
@@ -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<void>,
|
||||
avance?: (fait: number, total: number) => void,
|
||||
/** Abandon demande par l'utilisateur. Cf. `arrete` ci-dessous. */
|
||||
signal?: AbortSignal
|
||||
): Promise<Blob> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"framework": "vite",
|
||||
"buildCommand": "pnpm build",
|
||||
"outputDirectory": "dist"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.md': 'text/markdown; charset=utf-8'
|
||||
}
|
||||
function middleware(
|
||||
req: { url?: string },
|
||||
res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (b?: Buffer) => void },
|
||||
next: () => void
|
||||
) {
|
||||
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))
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'serve-replica',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(middleware)
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(middleware)
|
||||
},
|
||||
writeBundle(options) {
|
||||
const dest = path.resolve(options.dir ?? 'dist', 'replica')
|
||||
fs.cpSync(root, dest, { recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss(), serveReplica()],
|
||||
server: { port: 5191 },
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user