Compare commits

15 Commits
Author SHA1 Message Date
Edom 963b48b67d feat:关于区加上 GitHub 仓库链接 2026-08-24 21:31:16 +08:00
Edom 6c6e6b6893 fix:调节数值取整避免精度漂移 2026-08-24 21:31:10 +08:00
Edom e373a1b1e1 feat:自动小动作仅在动画分区开启 2026-08-24 21:31:04 +08:00
EdomandClaude Sonnet 5 b545e00555 chore:提交几何数据,线上角色可渲染
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-24 19:08:48 +08:00
EdomandClaude Sonnet 5 2b2050d369 chore:接入 Vercel 部署配置
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-24 18:54:59 +08:00
Edom f3b74b572a feat:加上拍照模式与自定义颜色渐变 2026-08-22 22:56:46 +08:00
Edom 62f39d27e7 feat:手动调节与表情播放弹簧衔接
打开手动、播放、暂停都走弹簧过渡,中间形态保持用户自定义眼睛,不再冻帧硬切。
2026-08-21 05:20:47 +08:00
Edom 1ab0054233 fix:进场动画不显示手动调节圆环
自定义页进入动画期间藏掉右上角旋转环,手动调节开着也不带出来。
2026-08-21 01:26:05 +08:00
Edom d91c160138 feat:语言只留中英并默认中文
去掉法语,首次打开用中文。README 按 bloub 的段落重写,补上嵌入用法。
2026-08-21 01:26:05 +08:00
Edom ba78973e51 feat:按形体画导线并点空白取消选眼
导线从球体抽成独立曲面模块,按 blob/bean/egg 等形体采样。
点空白处取消眼睛选中并回到身体。
2026-08-20 21:29:26 +08:00
Edom e880350f62 feat:加上手动调节与表情回放 2026-08-20 00:17:43 +08:00
Edom 851f2b8c6c fix:修导出时钟与切页卡顿
导出与播放共用模拟时钟;换循环、重排播放头、<grok-bot> 重挂和构建产物对齐。
自定义/动画面板留下不重画,角色高度对齐,表情走弹簧。
2026-08-19 02:01:29 +08:00
Edom 1ba126c92b feat:加上 Vue 学习站与可嵌入机芯
站点按 bloub 三栏做自定义、动画时间线和设置,支持序列导出。
形体只留常见几种并配中文名;动画页右栏拉满时间线旁空位,滚动条隐藏。
机芯补 GROK.create / <grok-bot>,以及暂停冻结。
2026-08-19 01:29:13 +08:00
Edom 891f6ef7e7 chore:忽略抽出目录以免误提交
防止本地解包产物被加进公开仓。
2026-08-15 22:45:35 +08:00
Edom 5502b2d97a docs:发布不含几何的角色机芯学习仓
以 kivo@9337f7e 的 replica 机芯为基准,单独开源弹簧/状态机/眼睛编排。
不包含 geometry-data、官方图标和抽出的应用主包。
2026-08-15 22:45:18 +08:00
87 changed files with 15617 additions and 763 deletions
+24
View File
@@ -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*
+26
View File
@@ -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.
+133 -23
View File
@@ -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

-64
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+27
View File
@@ -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"
}
}
+1078
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -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>
&lt;!-- 任意页面 --&gt;
&lt;script src="geometry-data.js"&gt;&lt;/script&gt;
&lt;script src="src/math.js"&gt;&lt;/script&gt;
&lt;script src="src/tables.js"&gt;&lt;/script&gt;
&lt;script src="src/pose.js"&gt;&lt;/script&gt;
&lt;script src="src/tricks.js"&gt;&lt;/script&gt;
&lt;script src="src/fx.js"&gt;&lt;/script&gt;
&lt;script src="src/eyes.js"&gt;&lt;/script&gt;
&lt;script src="src/character.js"&gt;&lt;/script&gt;
&lt;script src="src/api.js"&gt;&lt;/script&gt;
&lt;grok-bot state="idle" shape="blob" color="black" size="64"&gt;&lt;/grok-bot&gt;
&lt;script&gt;
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] },
});
&lt;/script&gt;
</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>
+56
View File
@@ -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` 忽略。
-7
View File
@@ -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
View File
@@ -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
View File
@@ -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));
+141
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+217 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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>
+53
View File
@@ -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>
+144
View File
@@ -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>
+110
View File
@@ -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>
+89
View File
@@ -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>
+57
View File
@@ -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>
+482
View File
@@ -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>
+183
View File
@@ -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>
+148
View File
@@ -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>
+191
View File
@@ -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>
+85
View File
@@ -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>
+113
View File
@@ -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>
+860
View File
@@ -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>
+84
View File
@@ -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>
+156
View File
@@ -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>
+739
View File
@@ -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>
+306
View File
@@ -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>
+139
View File
@@ -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>
+68
View File
@@ -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>
+90
View File
@@ -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>
+107
View File
@@ -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>
+151
View File
@@ -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>
+326
View File
@@ -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>
+517
View File
@@ -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>
+44
View File
@@ -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>
+157
View File
@@ -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
}
+17
View File
@@ -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]!
}
+96
View File
@@ -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
}
+20
View File
@@ -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
}
+252
View File
@@ -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
+248
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'
createApp(App).mount('#app')
+122
View File
@@ -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
}
+228
View File
@@ -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 }
]
}
+187
View File
@@ -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 }
+29
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
declare module '../../replica/src/*.js'
+1443
View File
File diff suppressed because it is too large Load Diff
+351
View File
@@ -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)
}
+19
View File
@@ -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
}
+330
View File
@@ -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()
}
}
+122
View File
@@ -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
View File
@@ -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)
}
+25
View File
@@ -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
}
+324
View File
@@ -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]
]
}
+226
View File
@@ -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
})
+35
View File
@@ -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
}
+99
View File
@@ -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
}
}
+278
View File
@@ -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)
+24
View File
@@ -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 */
}
}
+77
View File
@@ -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
+32
View File
@@ -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()
})
}
+104
View File
@@ -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
View File
@@ -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
}
}
+7
View File
@@ -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
}
+27
View File
@@ -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"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"framework": "vite",
"buildCommand": "pnpm build",
"outputDirectory": "dist"
}
+57
View File
@@ -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)) }
}
})