@ignix-social/editor 0.1.20 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/ignix-editor/SKILL.md +62 -0
- package/src/cli.mjs +26 -1
- package/src/runtime/blocks/chat.js +86 -0
- package/src/runtime/blocks/kinetic.js +78 -0
- package/src/runtime/blocks/stickman.js +310 -0
- package/src/runtime/blocks/whiteboard.js +147 -0
- package/src/update-check.mjs +81 -0
package/package.json
CHANGED
|
@@ -24,6 +24,8 @@ Commands. `ignix-editor` means the copy the Ignix playbook installed: `~/.ignix-
|
|
|
24
24
|
|
|
25
25
|
One project = one folder with `index.html` at its root and everything it uses inside `assets/`. Never work inside the user's code repository.
|
|
26
26
|
|
|
27
|
+
**Version first.** If any `ignix-editor` command prints `⬆ Ignix Editor … is available`, stop and run the two update commands it prints (the user approves them), then read this guide again before going on: an old version can hang or lack fixes the guide counts on.
|
|
28
|
+
|
|
27
29
|
## 1. The composition contract
|
|
28
30
|
|
|
29
31
|
```html
|
|
@@ -108,6 +110,66 @@ Position the elements with the SAME numbers in CSS. Cursor paths, click ripples
|
|
|
108
110
|
|
|
109
111
|
Decide the zones once per video (e.g. header y 270–440, stage y 460–1300, captions y 1335–1465 on a 1080×1920 reel) and keep them. Put zoomable content (an app window, a phone) inside a stage element with `overflow:hidden`, and move a camera by transforming the content inside it: zooming in then never covers the header or the captions. A soft mask on the stage edges hides the crop.
|
|
110
112
|
|
|
113
|
+
### Optional blocks (only when they fit the script)
|
|
114
|
+
|
|
115
|
+
Ready-made pieces that draw themselves from `t`, like everything else. They are options, not a format: use one when the idea is better told that way, never because it exists. `ignix-editor block` lists them; `ignix-editor block <name> <project>` copies `<name>.js` into the project; load it right after `ignix-frame.js`.
|
|
116
|
+
|
|
117
|
+
**`stickman`** — stick-figure characters that walk, gesture, talk and react (explainers, "you vs. the smart way" sketches, a two-person dialogue, a story told by a character). Poses: `idle talk wave point think shrug cheer arms_crossed present sad sit jump_crouch jump_air`. Faces: `neutral happy sad surprised thinking angry`. Look: `color` (use the brand colour), `hair` (`short spiky curly long bun ponytail none`), `hairColor`, `accessory` (`none glasses cap headphones bow tie`), `theme` (`light`, or `dark` on a dark background).
|
|
118
|
+
|
|
119
|
+
```html
|
|
120
|
+
<svg id="stage" viewBox="0 0 1080 1920" style="position:absolute;inset:0;width:1080px;height:1920px"></svg>
|
|
121
|
+
```
|
|
122
|
+
```js
|
|
123
|
+
const S = IgnixStickman;
|
|
124
|
+
const ana = S.create(document.getElementById('stage'), { color: '#D24D1E', hair: 'ponytail' });
|
|
125
|
+
run({ id, duration, fps }, (t) => {
|
|
126
|
+
const w = S.walk(t, [0, -150], [1.6, 380]); // walks in from the left, then stops
|
|
127
|
+
const at = ana.draw({
|
|
128
|
+
x: w.x, ground: 1300, scale: 1.5, facing: 1, lift: w.lift ?? 0,
|
|
129
|
+
joints: w.walking ? w.joints : S.poseAt(t, [[0, 'idle'], [1.7, 'wave'], [3, 'talk']]),
|
|
130
|
+
face: S.faceAt(t, [[0, 'neutral'], [1.7, 'happy']]),
|
|
131
|
+
mouth: S.talkAt(t, [[1.8, 2.9]]), // the spans where THIS character's voice line plays
|
|
132
|
+
eyesOpen: S.blinkAt(t, 1), // a different seed per character
|
|
133
|
+
});
|
|
134
|
+
// `at.rightHand`, `at.leftHand`, `at.headTop` are scene coordinates: hang a prop or a speech bubble there.
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- Big: `scale` 1.4–1.6 on a 1080×1920 reel (a figure about 800 px tall); small figures leave the frame empty. Two characters face each other (`facing` 1 and −1).
|
|
139
|
+
- The mouth moves only while its own voice line plays (`talkAt` with that line's start and end); everyone else keeps their mouth shut.
|
|
140
|
+
- Change pose or face on the word that motivates it, as with any overlay. Put the brand's world around them (a desk, a phone, a chart) as simple shapes in the same SVG, drawn first so the characters stand in front.
|
|
141
|
+
|
|
142
|
+
**`kinetic`** — kinetic typography: the words of a line pop in on the exact moment they are said (a hook, a punchline, a key promise). You decide the line breaks (no measuring); each word has its `at` (from the voice timings), and optionally `color`, `size` (relative, e.g. 1.4) or `box` (a filled label behind it). Styles: `pop` (default), `slide`, `punch` (a stamp from big to normal), `type` (typed letter by letter).
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
const kt = IgnixKinetic.create(document.getElementById('kt'), {
|
|
146
|
+
lines: [[{ text: 'Stop', at: 0.2 }, { text: 'losing', at: 0.45 }], [{ text: 'bookings', at: 0.8, color: '#FF5A1F' }]],
|
|
147
|
+
style: 'pop', font: "900 130px 'Montserrat'", stroke: '#000', strokeWidth: 12,
|
|
148
|
+
});
|
|
149
|
+
run({ id, duration, fps }, (t) => kt.draw(t, { out: 3.0 })); // out: when the whole block leaves
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**`chat`** — a story told as a chat on a phone (a customer asking, the business answering, a booking made). Messages appear at their `at`; the other side shows "typing…" just before each of theirs. Themes: `imessage`, `whatsapp`, `instagram` (or your own `colors`). The container is the phone SCREEN; size it and draw the phone frame around it in CSS. Messages may carry an `image` (a local file in `assets/`). Invented conversations are examples: label them as such if they could pass for a real customer.
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
const chat = IgnixChat.create(document.getElementById('screen'), {
|
|
156
|
+
theme: 'whatsapp', title: 'Laura', subtitle: 'online', fontSize: 46,
|
|
157
|
+
messages: [{ from: 'them', text: 'Are you open today?', at: 0.6 }, { from: 'me', text: 'Until 8 pm 😊', at: 1.8 }],
|
|
158
|
+
});
|
|
159
|
+
run({ id, duration, fps }, (t) => chat.draw(t));
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**`whiteboard`** — a whiteboard explanation: shapes and words drawn by hand while a marker follows the stroke. Hand-drawn helpers (same wobble every frame): `IgnixBoard.line`, `rect`, `circle`, `arrow`, `underline`, `strike`; or any SVG path `d`. Each `shape(d, at, dur, { color, stroke, fill })` draws itself from `at` over `dur`; `write(text, x, y, at, dur, { size })` writes text left to right. One thing at a time, timed to the words, and big: on a 1080×1920 reel words of 70–110 px and shapes that use most of the width.
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
const B = IgnixBoard;
|
|
166
|
+
const board = B.create(document.getElementById('board'), { ink: '#1F1D1B', marker: true });
|
|
167
|
+
board.write('Comments', 300, 640, 0.4, 0.6, { size: 90 });
|
|
168
|
+
board.shape(B.circle(300, 610, 260, 120), 1.0, 0.7);
|
|
169
|
+
board.shape(B.arrow(420, 780, 660, 1040), 1.8, 0.5, { color: '#E0452B' });
|
|
170
|
+
run({ id, duration, fps }, (t) => board.draw(t));
|
|
171
|
+
```
|
|
172
|
+
|
|
111
173
|
## 3. Recorded video (edits and clips)
|
|
112
174
|
|
|
113
175
|
- Transcribe on the machine: `ignix-editor transcribe assets/take.mp4 --lang es -o work/words.json`. Show the user the text and fix obvious mishearings (names, brands) without rewriting what they said.
|
package/src/cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
|
|
4
4
|
import './win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
@@ -22,6 +22,7 @@ import { measureStyle } from './analysis/style.mjs';
|
|
|
22
22
|
import { reviewVideo } from './analysis/review.mjs';
|
|
23
23
|
import { pickAudio } from './pick-audio.mjs';
|
|
24
24
|
import { importGiphy, openGiphySearch } from './giphy.mjs';
|
|
25
|
+
import { warnIfOutdated } from './update-check.mjs';
|
|
25
26
|
|
|
26
27
|
const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
28
|
const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
|
|
@@ -255,6 +256,24 @@ const commands = {
|
|
|
255
256
|
|
|
256
257
|
// GIFs, stickers y clips de GIPHY elegidos por la persona (src/giphy.mjs): abre la
|
|
257
258
|
// búsqueda en su navegador, o trae el enlace que ha copiado.
|
|
259
|
+
// Bloques opcionales (src/runtime/blocks): plantillas sencillas que se copian al proyecto
|
|
260
|
+
// y se usan desde run(), como IgnixFrame. Sin nombre, lista los que hay.
|
|
261
|
+
async block(argv) {
|
|
262
|
+
const { pos } = parseArgs(argv, {});
|
|
263
|
+
const dir = join(PKG_DIR, 'src', 'runtime', 'blocks');
|
|
264
|
+
const all = readdirSync(dir).filter((f) => f.endsWith('.js')).map((f) => f.slice(0, -3)).sort();
|
|
265
|
+
const name = pos[0];
|
|
266
|
+
if (!name) {
|
|
267
|
+
console.log(all.join('\n'));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!all.includes(name)) fail(`Unknown block "${name}". Blocks: ${all.join(', ')}`);
|
|
271
|
+
const project = resolve(pos[1] ?? '.');
|
|
272
|
+
if (!existsSync(join(project, 'index.html'))) fail(`${project} is not an Ignix Editor project (no index.html). Run it inside the project or pass its folder.`);
|
|
273
|
+
copyFileSync(join(dir, `${name}.js`), join(project, `${name}.js`));
|
|
274
|
+
console.log(`✓ ${name}.js copied to ${project}. Load it after ignix-frame.js: <script src="${name}.js"></script>`);
|
|
275
|
+
},
|
|
276
|
+
|
|
258
277
|
async giphy(argv) {
|
|
259
278
|
const { pos, opts } = parseArgs(argv, { search: 'str', kind: 'str', open: 'bool', json: 'bool' });
|
|
260
279
|
const usage = 'Usage: ignix-editor giphy --search "thumbs up" [--kind gifs|stickers|clips] or ignix-editor giphy <link> [folder]';
|
|
@@ -420,6 +439,7 @@ const commands = {
|
|
|
420
439
|
ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
|
|
421
440
|
ignix-editor pick-audio [folder] --kind music|sfx --query "inspiring" [--order trending]
|
|
422
441
|
opens the Pixabay search in the browser, waits for the person's download, copies it into assets/
|
|
442
|
+
ignix-editor block [name] [folder] lists the optional blocks, or copies one into the project (stickman…)
|
|
423
443
|
ignix-editor giphy --search "thumbs up" [--kind gifs|stickers|clips] opens the GIPHY search in the browser
|
|
424
444
|
ignix-editor giphy <link> [folder] brings the GIF (mp4), sticker (transparent frame sheet) or clip (mp4 with audio) into assets/
|
|
425
445
|
ignix-editor style measure <video>… [--speech] [--stills dir] [-o style.json] someone's editing style, measured on 2–3 of their videos
|
|
@@ -442,6 +462,11 @@ export async function main(argv) {
|
|
|
442
462
|
const [cmd = 'help', ...rest] = argv;
|
|
443
463
|
const fn = commands[cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd];
|
|
444
464
|
if (!fn) fail(`Unknown command: ${cmd}. See: ignix-editor help`);
|
|
465
|
+
// Aviso de versión nueva (src/update-check.mjs): no en `help`/`--version`, que el
|
|
466
|
+
// agente usa justo para comprobar qué tiene.
|
|
467
|
+
if (!['help', 'version', '--version', '-v', '--help', '-h'].includes(cmd)) {
|
|
468
|
+
await warnIfOutdated({ current: PKG.version, pkgDir: PKG_DIR }).catch(() => {});
|
|
469
|
+
}
|
|
445
470
|
try {
|
|
446
471
|
await fn(rest);
|
|
447
472
|
} catch (e) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* chat.js — bloque de Ignix Editor: una historia contada como conversación de chat en un
|
|
3
|
+
* móvil. Los mensajes aparecen a su hora, con «escribiendo…» antes de los del otro lado.
|
|
4
|
+
* Función pura del tiempo; el navegador apila los mensajes (los nuevos empujan arriba a
|
|
5
|
+
* los viejos) y aquí solo se decide cuáles se ven y cómo entran: nunca se mide la página.
|
|
6
|
+
*
|
|
7
|
+
* const C = IgnixChat;
|
|
8
|
+
* const chat = C.create(document.getElementById('phone'), {
|
|
9
|
+
* theme: 'imessage', // imessage | whatsapp | instagram
|
|
10
|
+
* title: 'Laura', subtitle: 'online',
|
|
11
|
+
* messages: [
|
|
12
|
+
* { from: 'them', text: 'Are you still open today?', at: 0.6 },
|
|
13
|
+
* { from: 'me', text: 'Yes! Until 8 pm 😊', at: 2.0 },
|
|
14
|
+
* { from: 'them', text: 'Perfect, booking now', at: 3.6 },
|
|
15
|
+
* ],
|
|
16
|
+
* });
|
|
17
|
+
* run({ id, duration, fps }, (t) => chat.draw(t));
|
|
18
|
+
*
|
|
19
|
+
* El contenedor es la PANTALLA del móvil (p. ej. 820×1500): su tamaño lo pones tú en CSS.
|
|
20
|
+
*/
|
|
21
|
+
(function () {
|
|
22
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
23
|
+
const outBack = (x) => 1 + 2.4 * (x - 1) ** 3 + 1.4 * (x - 1) ** 2;
|
|
24
|
+
const THEMES = {
|
|
25
|
+
imessage: { bg: '#FFFFFF', head: '#F6F6F6', ink: '#111111', me: '#0A84FF', meInk: '#FFFFFF', them: '#E9E9EB', themInk: '#111111', font: "-apple-system, 'Helvetica Neue', Arial, sans-serif", radius: 38 },
|
|
26
|
+
whatsapp: { bg: '#EFE7DE', head: '#075E54', ink: '#FFFFFF', me: '#DCF8C6', meInk: '#111111', them: '#FFFFFF', themInk: '#111111', font: "'Helvetica Neue', Arial, sans-serif", radius: 22 },
|
|
27
|
+
instagram: { bg: '#FFFFFF', head: '#FFFFFF', ink: '#111111', me: 'linear-gradient(135deg,#7B2FF7,#F107A3)', meInk: '#FFFFFF', them: '#EFEFEF', themInk: '#111111', font: "-apple-system, 'Helvetica Neue', Arial, sans-serif", radius: 40 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function create(screen, { theme = 'imessage', title = '', subtitle = '', messages = [], fontSize = 46, typing = 0.9, colors = {} } = {}) {
|
|
31
|
+
const T = { ...THEMES[theme], ...colors };
|
|
32
|
+
screen.innerHTML = '';
|
|
33
|
+
Object.assign(screen.style, { background: T.bg, display: 'flex', flexDirection: 'column', overflow: 'hidden', fontFamily: T.font });
|
|
34
|
+
const head = document.createElement('div');
|
|
35
|
+
Object.assign(head.style, { background: T.head, color: T.ink, padding: `${fontSize * 0.9}px ${fontSize}px ${fontSize * 0.6}px`, textAlign: 'center', borderBottom: '1px solid rgba(0,0,0,.08)', flex: '0 0 auto' });
|
|
36
|
+
head.innerHTML = `<div style="font-weight:700;font-size:${fontSize * 1.05}px">${esc(title)}</div>${subtitle ? `<div style="opacity:.6;font-size:${fontSize * 0.62}px;margin-top:4px">${esc(subtitle)}</div>` : ''}`;
|
|
37
|
+
screen.appendChild(head);
|
|
38
|
+
const list = document.createElement('div');
|
|
39
|
+
Object.assign(list.style, { flex: '1 1 auto', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', gap: `${fontSize * 0.35}px`, padding: `${fontSize * 0.6}px ${fontSize * 0.55}px`, overflow: 'hidden' });
|
|
40
|
+
screen.appendChild(list);
|
|
41
|
+
|
|
42
|
+
const items = [];
|
|
43
|
+
for (const m of messages) {
|
|
44
|
+
const mine = m.from === 'me';
|
|
45
|
+
if (!mine && typing > 0) {
|
|
46
|
+
const dots = document.createElement('div');
|
|
47
|
+
Object.assign(dots.style, { alignSelf: 'flex-start', background: T.them, borderRadius: `${T.radius}px`, padding: `${fontSize * 0.42}px ${fontSize * 0.6}px`, display: 'none', gap: `${fontSize * 0.18}px` });
|
|
48
|
+
dots.innerHTML = [0, 1, 2].map(() => `<span style="display:inline-block;width:${fontSize * 0.3}px;height:${fontSize * 0.3}px;border-radius:50%;background:#8E8E93;margin:0 ${fontSize * 0.07}px"></span>`).join('');
|
|
49
|
+
list.appendChild(dots);
|
|
50
|
+
items.push({ kind: 'typing', el: dots, from: Math.max(0, m.at - typing), to: m.at });
|
|
51
|
+
}
|
|
52
|
+
const b = document.createElement('div');
|
|
53
|
+
Object.assign(b.style, {
|
|
54
|
+
alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '78%', background: mine ? T.me : T.them, color: mine ? T.meInk : T.themInk,
|
|
55
|
+
borderRadius: `${T.radius}px`, padding: `${fontSize * 0.42}px ${fontSize * 0.62}px`, fontSize: `${fontSize}px`, lineHeight: '1.25',
|
|
56
|
+
transformOrigin: mine ? '100% 100%' : '0% 100%', display: 'none', whiteSpace: 'pre-wrap',
|
|
57
|
+
});
|
|
58
|
+
if (m.image) b.innerHTML = `<img src="${m.image}" style="display:block;width:100%;border-radius:${T.radius * 0.6}px;margin-bottom:${m.text ? 10 : 0}px">${esc(m.text ?? '')}`;
|
|
59
|
+
else b.textContent = m.text;
|
|
60
|
+
list.appendChild(b);
|
|
61
|
+
items.push({ kind: 'msg', el: b, at: m.at });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function draw(t) {
|
|
65
|
+
for (const it of items) {
|
|
66
|
+
if (it.kind === 'typing') {
|
|
67
|
+
const on = t >= it.from && t < it.to;
|
|
68
|
+
it.el.style.display = on ? 'flex' : 'none';
|
|
69
|
+
if (on) [...it.el.children].forEach((d, i) => (d.style.opacity = (0.35 + 0.65 * Math.max(0, Math.sin((t - it.from) * 9 - i * 0.9))).toFixed(3)));
|
|
70
|
+
} else {
|
|
71
|
+
const k = clamp01((t - it.at) / 0.28);
|
|
72
|
+
it.el.style.display = t >= it.at ? 'block' : 'none';
|
|
73
|
+
it.el.style.transform = `scale(${(0.6 + 0.4 * outBack(k)).toFixed(4)})`;
|
|
74
|
+
it.el.style.opacity = clamp01(k * 3).toFixed(3);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { draw };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function esc(s) {
|
|
82
|
+
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
window.IgnixChat = { create };
|
|
86
|
+
})();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* kinetic.js — bloque de Ignix Editor: texto cinético. Las palabras entran cada una en
|
|
3
|
+
* su momento (el de la voz) y con su estilo; todo es función pura del tiempo.
|
|
4
|
+
*
|
|
5
|
+
* El navegador coloca las palabras (CSS, una línea por grupo que tú decides) y aquí solo
|
|
6
|
+
* se animan con transform/opacity: nunca se mide la página.
|
|
7
|
+
*
|
|
8
|
+
* const K = IgnixKinetic;
|
|
9
|
+
* const title = K.create(document.getElementById('kt'), {
|
|
10
|
+
* lines: [
|
|
11
|
+
* [{ text: 'STOP', at: 0.2 }, { text: 'SCROLLING', at: 0.5, color: '#FF5A1F' }],
|
|
12
|
+
* [{ text: 'READ', at: 1.0 }, { text: 'THIS', at: 1.2, size: 1.4 }],
|
|
13
|
+
* ],
|
|
14
|
+
* style: 'pop', // pop | slide | punch | type
|
|
15
|
+
* font: "900 120px 'Montserrat'", color: '#fff', stroke: '#000',
|
|
16
|
+
* });
|
|
17
|
+
* run({ id, duration, fps }, (t) => title.draw(t, { out: 4.2 })); // out: cuándo sale todo
|
|
18
|
+
*/
|
|
19
|
+
(function () {
|
|
20
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
21
|
+
const outBack = (x) => 1 + 2.7 * (x - 1) ** 3 + 1.7 * (x - 1) ** 2;
|
|
22
|
+
const outCubic = (x) => 1 - (1 - x) ** 3;
|
|
23
|
+
|
|
24
|
+
function create(box, { lines, style = 'pop', font = "900 110px 'Montserrat'", color = '#FFFFFF', stroke = '#000000', strokeWidth = 12, gap = 0.28, lineHeight = 1.02, align = 'center', uppercase = true } = {}) {
|
|
25
|
+
box.innerHTML = '';
|
|
26
|
+
Object.assign(box.style, { display: 'flex', flexDirection: 'column', alignItems: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center', gap: '0px' });
|
|
27
|
+
const words = [];
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const row = document.createElement('div');
|
|
30
|
+
Object.assign(row.style, { display: 'flex', flexWrap: 'nowrap', justifyContent: 'center', alignItems: 'baseline', columnGap: `${gap}em`, lineHeight: String(lineHeight), font, whiteSpace: 'nowrap' });
|
|
31
|
+
for (const w of line) {
|
|
32
|
+
const el = document.createElement('span');
|
|
33
|
+
el.textContent = uppercase ? w.text.toUpperCase() : w.text;
|
|
34
|
+
Object.assign(el.style, {
|
|
35
|
+
display: 'inline-block', color: w.color ?? color, fontSize: w.size ? `${w.size}em` : undefined,
|
|
36
|
+
WebkitTextStroke: stroke ? `${strokeWidth}px ${stroke}` : undefined, paintOrder: 'stroke fill',
|
|
37
|
+
transformOrigin: '50% 80%', willChange: 'transform, opacity', opacity: '0',
|
|
38
|
+
});
|
|
39
|
+
if (w.box) Object.assign(el.style, { background: w.box, padding: '0 0.18em', borderRadius: '0.12em', WebkitTextStroke: '0' });
|
|
40
|
+
row.appendChild(el);
|
|
41
|
+
words.push({ el, at: w.at, text: el.textContent });
|
|
42
|
+
}
|
|
43
|
+
box.appendChild(row);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// opts.out: momento en que todo sale (fundido y un poco hacia arriba).
|
|
47
|
+
function draw(t, opts = {}) {
|
|
48
|
+
const outK = opts.out != null ? clamp01((t - opts.out) / 0.25) : 0;
|
|
49
|
+
for (const w of words) {
|
|
50
|
+
const k = clamp01((t - w.at) / (style === 'punch' ? 0.18 : 0.32));
|
|
51
|
+
let tr = '', op = k > 0 ? 1 : 0;
|
|
52
|
+
if (style === 'pop') {
|
|
53
|
+
tr = `scale(${(0.4 + 0.6 * outBack(k)).toFixed(4)})`;
|
|
54
|
+
op = clamp01(k * 3);
|
|
55
|
+
} else if (style === 'slide') {
|
|
56
|
+
tr = `translateY(${((1 - outCubic(k)) * 0.6).toFixed(4)}em)`;
|
|
57
|
+
op = clamp01(k * 2);
|
|
58
|
+
} else if (style === 'punch') {
|
|
59
|
+
tr = `scale(${(1.8 - 0.8 * outCubic(k)).toFixed(4)})`;
|
|
60
|
+
op = clamp01(k * 4);
|
|
61
|
+
} else if (style === 'type') {
|
|
62
|
+
const n = Math.round(clamp01((t - w.at) / Math.max(0.05, w.text.length * 0.045)) * w.text.length);
|
|
63
|
+
w.el.textContent = w.text.slice(0, n) || '';
|
|
64
|
+
op = t >= w.at ? 1 : 0;
|
|
65
|
+
}
|
|
66
|
+
if (outK > 0) {
|
|
67
|
+
tr += ` translateY(${(-0.3 * outK).toFixed(4)}em)`;
|
|
68
|
+
op *= 1 - outK;
|
|
69
|
+
}
|
|
70
|
+
w.el.style.transform = tr;
|
|
71
|
+
w.el.style.opacity = op.toFixed(3);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { draw, words };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
window.IgnixKinetic = { create };
|
|
78
|
+
})();
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* stickman.js — bloque de Ignix Editor: un muñeco de palitos que habla, gesticula y anda,
|
|
3
|
+
* dibujado en SVG como función pura del tiempo (igual que IgnixFrame).
|
|
4
|
+
*
|
|
5
|
+
* Portado de dos proyectos MIT, cuyos avisos se conservan:
|
|
6
|
+
* - stickman-explainers, Copyright (c) 2026 21cabbagee (esqueleto, poses como ángulos).
|
|
7
|
+
* - stickman-ia, Copyright (c) 2026 David García (IA Que Trabaja) y colaboradores
|
|
8
|
+
* (poses think/shrug/cheer/arms_crossed/present/sad, caras paramétricas, peinados,
|
|
9
|
+
* accesorios, ciclo de andar).
|
|
10
|
+
* MIT License: se permite usar, copiar, modificar y distribuir con este aviso incluido.
|
|
11
|
+
*
|
|
12
|
+
* Uso (en el run() de IgnixFrame):
|
|
13
|
+
* const S = IgnixStickman;
|
|
14
|
+
* const hero = S.create(document.getElementById('stage-svg'), { color: '#D24D1E', hair: 'short' });
|
|
15
|
+
* run({ id, duration, fps }, (t) => {
|
|
16
|
+
* hero.draw({
|
|
17
|
+
* x: 540, ground: 1500, scale: 1.1, facing: 1,
|
|
18
|
+
* joints: S.poseAt(t, [[0, 'idle'], [1.2, 'wave'], [3, 'point']]),
|
|
19
|
+
* face: S.faceAt(t, [[0, 'neutral'], [1.2, 'happy']]),
|
|
20
|
+
* mouth: S.talkAt(t, [[0.4, 2.6], [3.1, 5.0]]), // tramos en los que habla (su voz)
|
|
21
|
+
* eyesOpen: S.blinkAt(t),
|
|
22
|
+
* });
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
(function () {
|
|
26
|
+
const DEG = Math.PI / 180;
|
|
27
|
+
const BONES = { torso: 178, neck: 20, head_r: 60, upper_arm: 98, forearm: 92, thigh: 118, shin: 112, shoulder_drop: 16, foot: 26, shoulder_w: 12 };
|
|
28
|
+
const LINE = 11;
|
|
29
|
+
const DEFAULT = { spine: -90, neck: 0, l_sh: 104, l_el: 8, r_sh: 76, r_el: -8, l_hip: 97, l_knee: 0, r_hip: 83, r_knee: 0 };
|
|
30
|
+
const JOINT_KEYS = Object.keys(DEFAULT);
|
|
31
|
+
|
|
32
|
+
const proj = (o, a, len) => [o[0] + Math.cos(a * DEG) * len, o[1] + Math.sin(a * DEG) * len];
|
|
33
|
+
|
|
34
|
+
// Cinemática directa: cadera en (0,0), mirando a la derecha. Ángulos en grados (0 = derecha, 90 = abajo).
|
|
35
|
+
function forward(j) {
|
|
36
|
+
const g = (k) => (j[k] ?? DEFAULT[k]);
|
|
37
|
+
const hip = [0, 0];
|
|
38
|
+
const spine = g('spine');
|
|
39
|
+
const chest = proj(hip, spine, BONES.torso);
|
|
40
|
+
const shoulder = proj(hip, spine, BONES.torso - BONES.shoulder_drop);
|
|
41
|
+
const neckA = spine + g('neck');
|
|
42
|
+
const headBase = proj(chest, neckA, BONES.neck);
|
|
43
|
+
const head = proj(headBase, neckA, BONES.head_r);
|
|
44
|
+
const p = { hip, chest, shoulder, head_base: headBase, head, head_tilt: neckA + 90 };
|
|
45
|
+
const ux = Math.cos(spine * DEG), uy = Math.sin(spine * DEG);
|
|
46
|
+
for (const [side, sgn] of [['l', -1], ['r', 1]]) {
|
|
47
|
+
const sp = [shoulder[0] - uy * BONES.shoulder_w * sgn, shoulder[1] + ux * BONES.shoulder_w * sgn];
|
|
48
|
+
const sh = g(`${side}_sh`);
|
|
49
|
+
const el = proj(sp, sh, BONES.upper_arm);
|
|
50
|
+
const wr = proj(el, sh + g(`${side}_el`), BONES.forearm);
|
|
51
|
+
const hp = g(`${side}_hip`);
|
|
52
|
+
const kn = proj(hip, hp, BONES.thigh);
|
|
53
|
+
const an = proj(kn, hp + g(`${side}_knee`), BONES.shin);
|
|
54
|
+
Object.assign(p, { [`${side}_shoulder`]: sp, [`${side}_elbow`]: el, [`${side}_wrist`]: wr, [`${side}_knee`]: kn, [`${side}_ankle`]: an });
|
|
55
|
+
}
|
|
56
|
+
return p;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// IK analítica de dos huesos: [ángulo del hombro, ángulo relativo del codo].
|
|
60
|
+
function ik(target, l1, l2, bend) {
|
|
61
|
+
const [tx, ty] = target;
|
|
62
|
+
let d = Math.hypot(tx, ty);
|
|
63
|
+
d = Math.max(1e-6, Math.min(d, l1 + l2 - 1e-6, Math.max(Math.abs(l1 - l2) + 1e-6, d)));
|
|
64
|
+
const base = Math.atan2(ty, tx);
|
|
65
|
+
const a = Math.acos(Math.max(-1, Math.min(1, (l1 * l1 + d * d - l2 * l2) / (2 * l1 * d))));
|
|
66
|
+
const sh = base - bend * a;
|
|
67
|
+
const el = [Math.cos(sh) * l1, Math.sin(sh) * l1];
|
|
68
|
+
const fa = Math.atan2(ty - el[1], tx - el[0]);
|
|
69
|
+
let rel = (fa - sh) / DEG;
|
|
70
|
+
rel = ((rel + 180) % 360 + 360) % 360 - 180;
|
|
71
|
+
return [sh / DEG, rel];
|
|
72
|
+
}
|
|
73
|
+
function arm(side, target, elbow) {
|
|
74
|
+
let best = null;
|
|
75
|
+
for (const bend of [1, -1]) {
|
|
76
|
+
const [sh, el] = ik(target, BONES.upper_arm, BONES.forearm, bend);
|
|
77
|
+
const ex = Math.cos(sh * DEG) * BONES.upper_arm, ey = Math.sin(sh * DEG) * BONES.upper_arm;
|
|
78
|
+
const score = elbow === 'down' ? ey : side === 'r' ? ex : -ex;
|
|
79
|
+
if (!best || score > best[0]) best = [score, sh, el];
|
|
80
|
+
}
|
|
81
|
+
return { [`${side}_sh`]: best[1], [`${side}_el`]: best[2] };
|
|
82
|
+
}
|
|
83
|
+
const pose = (kw) => ({ ...DEFAULT, ...kw });
|
|
84
|
+
|
|
85
|
+
// El brazo/pierna «r» es el de delante cuando mira a la derecha.
|
|
86
|
+
const POSES = {
|
|
87
|
+
idle: pose({}),
|
|
88
|
+
talk: pose({ r_sh: 62, r_el: -58, l_sh: 108, l_el: 14 }),
|
|
89
|
+
wave: pose({ r_sh: -28, r_el: -62, l_sh: 106, l_el: 10, neck: -4 }),
|
|
90
|
+
point: pose({ r_sh: -12, r_el: -4, l_sh: 108, l_el: 12 }),
|
|
91
|
+
think: pose({ neck: 6, ...arm('r', [36, -30], 'down'), ...arm('l', [34, 70], 'out') }),
|
|
92
|
+
shrug: pose({ neck: -7, l_sh: 148, l_el: 78, r_sh: 32, r_el: -78 }),
|
|
93
|
+
cheer: pose({ l_sh: -134, l_el: 14, r_sh: -46, r_el: -14, spine: -91 }),
|
|
94
|
+
arms_crossed: pose({ ...arm('r', [-40, 52], 'out'), ...arm('l', [44, 60], 'out') }),
|
|
95
|
+
present: pose({ r_sh: 18, r_el: -48, l_sh: 110, l_el: 12 }),
|
|
96
|
+
sad: pose({ spine: -88, neck: 20, l_sh: 96, l_el: 2, r_sh: 84, r_el: -2, l_hip: 95, r_hip: 85 }),
|
|
97
|
+
sit: pose({ spine: -94, l_sh: 74, l_el: -30, r_sh: 64, r_el: -40, l_hip: -6, l_knee: 92, r_hip: 4, r_knee: 84 }),
|
|
98
|
+
jump_crouch: pose({ spine: -80, l_sh: 128, l_el: -16, r_sh: 52, r_el: 18, l_hip: 62, l_knee: 78, r_hip: 48, r_knee: 80 }),
|
|
99
|
+
jump_air: pose({ spine: -92, l_sh: -118, l_el: 12, r_sh: -62, r_el: -12, l_hip: 112, l_knee: 26, r_hip: 70, r_knee: 34 }),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Caras paramétricas (interpolables). eye: escala de ojos; brow_in: + preocupado, − enfado;
|
|
103
|
+
// curve: + sonrisa; mw: ancho de boca; mo: apertura; px/py: mirada; blush; brow_tilt: ceja levantada.
|
|
104
|
+
const EXPRESSIONS = {
|
|
105
|
+
neutral: { eye: 1, brow_in: 0, brow_y: 0, curve: 0.25, mw: 22, mo: 0, mdx: 0, px: 0, py: 0, blush: 0, brow_tilt: 0 },
|
|
106
|
+
happy: { eye: 0.92, brow_in: 6, brow_y: -3, curve: 1, mw: 28, mo: 0.18, mdx: 0, px: 0, py: 0, blush: 1, brow_tilt: 0 },
|
|
107
|
+
sad: { eye: 0.9, brow_in: 22, brow_y: 2, curve: -0.85, mw: 20, mo: 0, mdx: 0, px: 0, py: 0.35, blush: 0, brow_tilt: 0 },
|
|
108
|
+
surprised: { eye: 1.28, brow_in: 4, brow_y: -9, curve: 0, mw: 13, mo: 0.55, mdx: 0, px: 0, py: -0.1, blush: 0, brow_tilt: 0 },
|
|
109
|
+
thinking: { eye: 0.95, brow_in: 0, brow_y: -2, curve: -0.15, mw: 16, mo: 0, mdx: 6, px: 0.55, py: -0.75, blush: 0, brow_tilt: 9 },
|
|
110
|
+
angry: { eye: 0.95, brow_in: -26, brow_y: 4, curve: -0.6, mw: 22, mo: 0, mdx: 0, px: 0, py: 0, blush: 0, brow_tilt: 0 },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
114
|
+
const easeInOut = (x) => (x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2);
|
|
115
|
+
const mix = (a, b, k) => {
|
|
116
|
+
const o = {};
|
|
117
|
+
for (const key of Object.keys(a)) o[key] = a[key] + ((b[key] ?? a[key]) - a[key]) * k;
|
|
118
|
+
return o;
|
|
119
|
+
};
|
|
120
|
+
const resolve = (table, v) => (typeof v === 'string' ? table[v] ?? Object.values(table)[0] : { ...Object.values(table)[0], ...v });
|
|
121
|
+
|
|
122
|
+
// Valor por tramos con mezcla suave: keys = [[t, 'pose' | {ángulos}], …]; pasa a cada una en `dur` s.
|
|
123
|
+
function keyedMix(table, t, keys, dur) {
|
|
124
|
+
let cur = resolve(table, keys[0][1]);
|
|
125
|
+
for (let i = 1; i < keys.length; i++) {
|
|
126
|
+
const [tk, v] = keys[i];
|
|
127
|
+
if (t < tk) break;
|
|
128
|
+
cur = mix(cur, resolve(table, v), easeInOut(clamp01((t - tk) / dur)));
|
|
129
|
+
}
|
|
130
|
+
return cur;
|
|
131
|
+
}
|
|
132
|
+
const poseAt = (t, keys, dur = 0.35) => {
|
|
133
|
+
const j = keyedMix(POSES, t, keys, dur);
|
|
134
|
+
// respiración: un vaivén mínimo para que nunca esté congelado del todo
|
|
135
|
+
return { ...j, spine: j.spine + Math.sin(t * 2.1) * 0.8, l_sh: j.l_sh + Math.sin(t * 2.1 + 1) * 1.2, r_sh: j.r_sh - Math.sin(t * 2.1 + 1) * 1.2 };
|
|
136
|
+
};
|
|
137
|
+
const faceAt = (t, keys, dur = 0.25) => keyedMix(EXPRESSIONS, t, keys, dur);
|
|
138
|
+
|
|
139
|
+
// Boca al hablar: dentro de cada tramo [inicio, fin] se abre y cierra a ritmo de sílabas
|
|
140
|
+
// (determinista: solo depende de t). 0 = cerrada, 1 = abierta del todo.
|
|
141
|
+
function talkAt(t, spans) {
|
|
142
|
+
for (const [a, b] of spans) {
|
|
143
|
+
if (t < a || t > b) continue;
|
|
144
|
+
const edge = Math.min(1, (t - a) / 0.08, (b - t) / 0.08);
|
|
145
|
+
const s = Math.abs(Math.sin(t * 13.7)) * 0.65 + Math.abs(Math.sin(t * 7.3 + 1.3)) * 0.35;
|
|
146
|
+
return Math.max(0, edge) * (0.25 + 0.6 * s);
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
// Parpadeo cada ~3,4 s (la semilla separa a varios personajes).
|
|
151
|
+
function blinkAt(t, seed = 0) {
|
|
152
|
+
const period = 3.4 + (seed % 5) * 0.37;
|
|
153
|
+
const p = ((t + seed * 1.13) % period + period) % period;
|
|
154
|
+
return p < 0.07 ? 1 - p / 0.07 : p < 0.14 ? (p - 0.07) / 0.07 : 1;
|
|
155
|
+
}
|
|
156
|
+
// Andar de x0 (en t0) a x1 (en t1): devuelve { x, facing, joints } para mezclar con la pose.
|
|
157
|
+
function walk(t, [t0, x0], [t1, x1], base = 'idle') {
|
|
158
|
+
const k = clamp01((t - t0) / (t1 - t0));
|
|
159
|
+
const x = x0 + (x1 - x0) * k;
|
|
160
|
+
const facing = x1 >= x0 ? 1 : -1;
|
|
161
|
+
if (k <= 0 || k >= 1) return { x, facing, joints: POSES[base] ?? POSES.idle, walking: false };
|
|
162
|
+
const ph = (t - t0) * 9;
|
|
163
|
+
const s = Math.sin(ph), c = Math.cos(ph);
|
|
164
|
+
const j = {
|
|
165
|
+
...(POSES[base] ?? POSES.idle),
|
|
166
|
+
l_hip: 90 + 26 * s, r_hip: 90 - 26 * s,
|
|
167
|
+
l_knee: Math.max(0, -c) * 38 * (s < 0.3 ? 1 : 0.6),
|
|
168
|
+
r_knee: Math.max(0, c) * 38 * (s > -0.3 ? 1 : 0.6),
|
|
169
|
+
l_sh: 90 - 24 * s, r_sh: 90 + 24 * s, l_el: -18, r_el: -18, spine: -87,
|
|
170
|
+
};
|
|
171
|
+
return { x, facing, joints: j, walking: true, lift: Math.abs(Math.sin(ph)) * 4 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const f1 = (n) => Math.round(n * 10) / 10;
|
|
175
|
+
const pt = (p) => `${f1(p[0])},${f1(p[1])}`;
|
|
176
|
+
const lum = (h) => {
|
|
177
|
+
const x = h.replace('#', '');
|
|
178
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(x.slice(i, i + 2), 16) / 255);
|
|
179
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// look: { color, hair: short|spiky|curly|long|bun|ponytail|none, hairColor, accessory: none|glasses|cap|headphones|bow|tie, theme: 'light'|'dark' }
|
|
183
|
+
function create(svg, look = {}) {
|
|
184
|
+
const L = { color: '#D24D1E', hair: 'short', hairColor: '#22201E', accessory: 'none', theme: 'light', ...look };
|
|
185
|
+
const dark = L.theme === 'dark';
|
|
186
|
+
const ink = dark ? '#F3F0EA' : '#1F1D1B';
|
|
187
|
+
const headFill = dark ? '#2A3038' : '#FFFFFF';
|
|
188
|
+
const hairFill = dark && lum(L.hairColor) <= 0.25 ? '#D9D4CC' : L.hairColor;
|
|
189
|
+
const tie = lum(L.color) > 0.2 ? '#2B2F3A' : '#E8E2D8';
|
|
190
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
191
|
+
svg.appendChild(g);
|
|
192
|
+
const R = BONES.head_r;
|
|
193
|
+
|
|
194
|
+
function hairBack() {
|
|
195
|
+
if (L.hair === 'long') return `<rect x="${-R - 9}" y="${-R - 6}" width="${2 * R + 18}" height="${R * 2.45 + 6}" rx="${R}" ry="${R * 0.6}" fill="${hairFill}"/>`;
|
|
196
|
+
if (L.hair === 'bun') return `<circle cx="${-R * 0.25}" cy="${-R * 1.22}" r="${R * 0.38}" fill="${hairFill}"/>`;
|
|
197
|
+
if (L.hair === 'ponytail') return `<g transform="translate(${-R * 0.95},${-R * 0.15}) rotate(25)"><ellipse cx="0" cy="${R * 0.375}" rx="${R * 0.32}" ry="${R * 0.575}" fill="${hairFill}"/></g>`;
|
|
198
|
+
return '';
|
|
199
|
+
}
|
|
200
|
+
function hairFront() {
|
|
201
|
+
const h = L.hair;
|
|
202
|
+
if (h === 'none' || (L.accessory === 'cap' && ['short', 'spiky', 'curly'].includes(h))) return '';
|
|
203
|
+
if (h === 'curly') {
|
|
204
|
+
let s = '';
|
|
205
|
+
for (let i = 0; i < 9; i++) {
|
|
206
|
+
const a = (192 + i * 19.5) * DEG;
|
|
207
|
+
s += `<circle cx="${f1(Math.cos(a) * R * 0.9)}" cy="${f1(Math.sin(a) * R * 0.9 - 2)}" r="${f1(R * 0.3)}" fill="${hairFill}"/>`;
|
|
208
|
+
}
|
|
209
|
+
return s;
|
|
210
|
+
}
|
|
211
|
+
const r = R + 5;
|
|
212
|
+
const a0 = 188 * DEG, a1 = 352 * DEG;
|
|
213
|
+
let s = `<path d="M${f1(Math.cos(a0) * r)},${f1(Math.sin(a0) * r)} A${r},${r} 0 0 1 ${f1(Math.cos(a1) * r)},${f1(Math.sin(a1) * r)} C${R * 0.55},${-R * 0.62} ${R * 0.05},${-R * 0.38} ${-R * 0.42},${-R * 0.52} Q${-R * 0.8},${-R * 0.45} ${-R - 4},${-R * 0.12} Z" fill="${hairFill}"/>`;
|
|
214
|
+
if (h === 'spiky') {
|
|
215
|
+
for (let i = 0; i < 5; i++) {
|
|
216
|
+
const b0 = (205 + i * 27) * DEG, bm = (218 + i * 27) * DEG, b1 = (232 + i * 27) * DEG;
|
|
217
|
+
s += `<path d="M${f1(Math.cos(b0) * (R + 2))},${f1(Math.sin(b0) * (R + 2))} L${f1(Math.cos(bm) * (R + 24))},${f1(Math.sin(bm) * (R + 24))} L${f1(Math.cos(b1) * (R + 2))},${f1(Math.sin(b1) * (R + 2))} Z" fill="${hairFill}"/>`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return s;
|
|
221
|
+
}
|
|
222
|
+
function mouth(mx, my, f, talk) {
|
|
223
|
+
const curve = f.curve, o = Math.min(1, f.mo + talk);
|
|
224
|
+
const w = f.mw * (1 - 0.25 * Math.min(1, talk)), hw = w / 2, cy = curve * 11;
|
|
225
|
+
const st = `stroke="${ink}" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"`;
|
|
226
|
+
if (o < 0.06) return `<path d="M${f1(mx - hw)},${f1(my - cy * 0.5)} Q${f1(mx)},${f1(my + cy)} ${f1(mx + hw)},${f1(my - cy * 0.5)}" fill="none" ${st}/>`;
|
|
227
|
+
const depth = 6 + o * 26;
|
|
228
|
+
return `<path d="M${f1(mx - hw)},${f1(my - cy * 0.5)} Q${f1(mx)},${f1(my + cy * 0.55)} ${f1(mx + hw)},${f1(my - cy * 0.5)} C${f1(mx + hw)},${f1(my + depth * 0.9)} ${f1(mx - hw)},${f1(my + depth * 0.9)} ${f1(mx - hw)},${f1(my - cy * 0.5)} Z" fill="${ink}" ${st}/>`;
|
|
229
|
+
}
|
|
230
|
+
function head(f, eyesOpen, talk) {
|
|
231
|
+
const fx = 9, ex = 19, ey = -2;
|
|
232
|
+
const thin = `stroke="${ink}" stroke-width="5" fill="none" stroke-linecap="round"`;
|
|
233
|
+
let s = `<circle r="${R}" fill="${headFill}" stroke="${ink}" stroke-width="${LINE}"/>` + hairFront();
|
|
234
|
+
const open = Math.max(0.08, eyesOpen);
|
|
235
|
+
for (const sx of [-1, 1]) {
|
|
236
|
+
const cx = fx + sx * ex;
|
|
237
|
+
s += `<ellipse cx="${cx}" cy="${ey}" rx="${f1(6.6 * f.eye)}" ry="${f1(8.8 * f.eye * open)}" fill="${ink}"/>`;
|
|
238
|
+
if (open > 0.5) s += `<circle cx="${f1(cx + 2.2 + f.px * 1.5)}" cy="${f1(ey - 3 + f.py * 1.5)}" r="${f1(2.3 * f.eye)}" fill="#FFFFFF"/>`;
|
|
239
|
+
}
|
|
240
|
+
const by = -23 + f.brow_y;
|
|
241
|
+
for (const sx of [-1, 1]) {
|
|
242
|
+
const cx = fx + sx * ex;
|
|
243
|
+
const a = (f.brow_in + (sx === 1 ? f.brow_tilt : 0)) * DEG;
|
|
244
|
+
const dy = Math.sin(a) * 9;
|
|
245
|
+
s += `<line x1="${f1(cx + sx * 9)}" y1="${f1(by + dy)}" x2="${f1(cx - sx * 9)}" y2="${f1(by - dy - (sx === 1 ? f.brow_tilt * 0.35 : 0))}" stroke="${ink}" stroke-width="6.5" stroke-linecap="round"/>`;
|
|
246
|
+
}
|
|
247
|
+
if (f.blush > 0.02) for (const sx of [-1, 1]) s += `<ellipse cx="${fx + sx * 31}" cy="13.5" rx="8" ry="3.5" fill="#F08F7A" opacity="${f1(0.4 * Math.min(1, f.blush) * 100) / 100}"/>`;
|
|
248
|
+
s += mouth(fx + f.mdx, 25, f, talk);
|
|
249
|
+
if (L.accessory === 'glasses') {
|
|
250
|
+
for (const sx of [-1, 1]) s += `<circle cx="${fx + sx * ex}" cy="${ey}" r="13.5" ${thin}/>`;
|
|
251
|
+
s += `<line x1="${fx - ex + 13.5}" y1="${ey - 1}" x2="${fx + ex - 13.5}" y2="${ey - 1}" ${thin}/><line x1="${fx + ex + 13.5}" y1="${ey - 3}" x2="${R - 4}" y2="${ey - 6}" ${thin}/>`;
|
|
252
|
+
} else if (L.accessory === 'cap') {
|
|
253
|
+
const r = R + 4;
|
|
254
|
+
s += `<path d="M${f1(Math.cos(182 * DEG) * r)},${f1(Math.sin(182 * DEG) * r - 2)} A${r},${r} 0 0 1 ${f1(Math.cos(358 * DEG) * r)},${f1(Math.sin(358 * DEG) * r - 2)} Q0,${-R * 0.42} ${-R - 3},${-R * 0.24} Z" fill="${L.color}" ${thin.replace('fill="none"', '')}/>`;
|
|
255
|
+
s += `<path d="M${R * 0.2},${-R * 0.36} Q${R * 1.1},${-R * 0.42} ${R * 1.42},${-R * 0.2} Q${R},${-R * 0.12} ${R * 0.2},${-R * 0.2} Z" fill="${L.color}" stroke="${ink}" stroke-width="5"/>`;
|
|
256
|
+
} else if (L.accessory === 'headphones') {
|
|
257
|
+
const r = R + 9;
|
|
258
|
+
s += `<path d="M${f1(Math.cos(190 * DEG) * r)},${f1(Math.sin(190 * DEG) * r - 2)} A${r},${r} 0 0 1 ${f1(Math.cos(350 * DEG) * r)},${f1(Math.sin(350 * DEG) * r - 2)}" fill="none" stroke="${ink}" stroke-width="9" stroke-linecap="round"/>`;
|
|
259
|
+
for (const sx of [-1, 1]) s += `<rect x="${sx * (R + 2) - 10}" y="-20" width="20" height="38" rx="8" fill="${L.color}" stroke="${ink}" stroke-width="5"/>`;
|
|
260
|
+
} else if (L.accessory === 'bow') {
|
|
261
|
+
s += `<g transform="translate(${-R * 0.45},${-R * 0.9}) rotate(-20)"><path d="M0,0 L-20,-12 L-20,12 Z M0,0 L20,-12 L20,12 Z" fill="${L.color}" stroke="${ink}" stroke-width="5" stroke-linejoin="round"/><circle r="6" fill="${L.color}" stroke="${ink}" stroke-width="5"/></g>`;
|
|
262
|
+
}
|
|
263
|
+
return s;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// state: { x, ground, scale, facing, joints, face, mouth, eyesOpen, alpha, pop, lift }
|
|
267
|
+
function draw(state) {
|
|
268
|
+
const st = { scale: 1, facing: 1, mouth: 0, eyesOpen: 1, alpha: 1, pop: 1, lift: 0, face: 'neutral', joints: 'idle', ...state };
|
|
269
|
+
const joints = typeof st.joints === 'string' ? POSES[st.joints] ?? POSES.idle : { ...DEFAULT, ...st.joints };
|
|
270
|
+
const face = typeof st.face === 'string' ? EXPRESSIONS[st.face] ?? EXPRESSIONS.neutral : { ...EXPRESSIONS.neutral, ...st.face };
|
|
271
|
+
const p = forward(joints);
|
|
272
|
+
const dy = -Math.max(p.l_ankle[1], p.r_ankle[1]) - st.lift;
|
|
273
|
+
const s = st.scale * st.pop;
|
|
274
|
+
const line = `stroke="${ink}" stroke-width="${LINE}" stroke-linecap="round" stroke-linejoin="round" fill="none"`;
|
|
275
|
+
const shW = 70 * st.scale * Math.max(0.45, 1 - st.lift / 260);
|
|
276
|
+
let out = `<ellipse cx="${f1(st.x)}" cy="${f1(st.ground)}" rx="${f1(shW)}" ry="${f1(9 * st.scale)}" fill="#000" opacity="0.1"/>`;
|
|
277
|
+
out += `<g transform="translate(${f1(st.x)},${f1(st.ground)}) scale(${f1(s * st.facing * 1000) / 1000},${f1(s * 1000) / 1000}) translate(0,${f1(dy)})">`;
|
|
278
|
+
for (const side of ['l', 'r']) {
|
|
279
|
+
const a = p[`${side}_ankle`];
|
|
280
|
+
out += `<polyline points="${pt(p.hip)} ${pt(p[`${side}_knee`])} ${pt(a)}" ${line}/><line x1="${f1(a[0])}" y1="${f1(a[1])}" x2="${f1(a[0] + BONES.foot)}" y2="${f1(a[1] + 1)}" ${line}/>`;
|
|
281
|
+
}
|
|
282
|
+
out += `<g transform="translate(${pt(p.head)}) rotate(${f1(p.head_tilt)})">${hairBack()}</g>`;
|
|
283
|
+
const v = [p.chest[0] - p.hip[0], p.chest[1] - p.hip[1]];
|
|
284
|
+
const n = Math.hypot(v[0], v[1]) || 1;
|
|
285
|
+
const u = [v[0] / n, v[1] / n];
|
|
286
|
+
out += `<line x1="${f1(p.chest[0])}" y1="${f1(p.chest[1])}" x2="${f1(p.head_base[0])}" y2="${f1(p.head_base[1])}" ${line}/>`;
|
|
287
|
+
out += `<line x1="${f1(p.hip[0] + u[0] * 4)}" y1="${f1(p.hip[1] + u[1] * 4)}" x2="${f1(p.chest[0] - u[0] * 12)}" y2="${f1(p.chest[1] - u[1] * 12)}" stroke="${L.color}" stroke-width="44" stroke-linecap="round"/>`;
|
|
288
|
+
if (L.accessory === 'tie') {
|
|
289
|
+
const q = [-u[1], u[0]];
|
|
290
|
+
const top = [p.chest[0] - u[0] * 22, p.chest[1] - u[1] * 22];
|
|
291
|
+
const P = (k, w) => `${f1(top[0] - u[0] * k + q[0] * w)},${f1(top[1] - u[1] * k + q[1] * w)}`;
|
|
292
|
+
out += `<polygon points="${P(0, 6)} ${P(0, -6)} ${P(62, -9)} ${P(74, 0)} ${P(62, 9)}" fill="${tie}"/>`;
|
|
293
|
+
}
|
|
294
|
+
out += `<g transform="translate(${pt(p.head)}) rotate(${f1(p.head_tilt)})">${head(face, st.eyesOpen, st.mouth)}</g>`;
|
|
295
|
+
for (const side of ['l', 'r']) {
|
|
296
|
+
const w = p[`${side}_wrist`];
|
|
297
|
+
out += `<polyline points="${pt(p[`${side}_shoulder`])} ${pt(p[`${side}_elbow`])} ${pt(w)}" ${line}/><circle cx="${f1(w[0])}" cy="${f1(w[1])}" r="9.5" fill="${ink}"/>`;
|
|
298
|
+
}
|
|
299
|
+
out += '</g>';
|
|
300
|
+
g.setAttribute('opacity', String(f1(st.alpha * 1000) / 1000));
|
|
301
|
+
g.innerHTML = out;
|
|
302
|
+
// Dónde quedan las manos y la cabeza en la escena (para colgarles un objeto o un bocadillo).
|
|
303
|
+
const toScene = (q) => [st.x + st.facing * s * q[0], st.ground + s * (q[1] + dy)];
|
|
304
|
+
return { leftHand: toScene(p.l_wrist), rightHand: toScene(p.r_wrist), head: toScene(p.head), headTop: toScene([p.head[0], p.head[1] - R]) };
|
|
305
|
+
}
|
|
306
|
+
return { draw, look: L };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
window.IgnixStickman = { create, poseAt, faceAt, talkAt, blinkAt, walk, forward, POSES, EXPRESSIONS, JOINT_KEYS };
|
|
310
|
+
})();
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* whiteboard.js — bloque de Ignix Editor: pizarra blanca. Trazos que se dibujan solos con
|
|
3
|
+
* un rotulador que los sigue, formas «a mano» (rectángulo, círculo, flecha, subrayado,
|
|
4
|
+
* tachado) y texto que se escribe. Función pura del tiempo.
|
|
5
|
+
*
|
|
6
|
+
* Las formas a mano llevan un temblor con semilla fija: el mismo dibujo en cada fotograma.
|
|
7
|
+
* La longitud de cada trazo es geometría del SVG (getTotalLength), no medida de la página.
|
|
8
|
+
*
|
|
9
|
+
* const B = IgnixBoard;
|
|
10
|
+
* const board = B.create(document.getElementById('board'), { ink: '#1F1D1B', marker: true });
|
|
11
|
+
* board.shape(B.circle(540, 700, 180), 0.5, 0.8); // (path, empieza, dura)
|
|
12
|
+
* board.shape(B.arrow(200, 1100, 470, 860), 1.4, 0.5, { color: '#E0452B' });
|
|
13
|
+
* board.write('2× more bookings', 540, 1180, 1.9, 1.0, { size: 84 });
|
|
14
|
+
* run({ id, duration, fps }, (t) => board.draw(t));
|
|
15
|
+
*/
|
|
16
|
+
(function () {
|
|
17
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
18
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
19
|
+
const easeInOut = (x) => (x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2);
|
|
20
|
+
// Ruido con semilla: el mismo «pulso» de mano en cada fotograma.
|
|
21
|
+
function rng(seed) {
|
|
22
|
+
let s = seed >>> 0 || 1;
|
|
23
|
+
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296) * 2 - 1;
|
|
24
|
+
}
|
|
25
|
+
let seedN = 7;
|
|
26
|
+
const f1 = (n) => Math.round(n * 10) / 10;
|
|
27
|
+
|
|
28
|
+
// Una línea a mano: ligera curva y temblor.
|
|
29
|
+
function line(x1, y1, x2, y2, wobble = 4, seed = seedN++) {
|
|
30
|
+
const r = rng(seed);
|
|
31
|
+
const mx = (x1 + x2) / 2 + r() * wobble * 2, my = (y1 + y2) / 2 + r() * wobble * 2;
|
|
32
|
+
return `M${f1(x1 + r() * wobble)},${f1(y1 + r() * wobble)} Q${f1(mx)},${f1(my)} ${f1(x2 + r() * wobble)},${f1(y2 + r() * wobble)}`;
|
|
33
|
+
}
|
|
34
|
+
function rect(x, y, w, h, wobble = 5, seed = seedN++) {
|
|
35
|
+
const r = rng(seed);
|
|
36
|
+
const j = () => r() * wobble;
|
|
37
|
+
// Un solo trazo continuo que se pasa un poco al cerrar, como a mano.
|
|
38
|
+
return `M${f1(x + j())},${f1(y + j())} L${f1(x + w + j())},${f1(y + j())} L${f1(x + w + j())},${f1(y + h + j())} L${f1(x + j())},${f1(y + h + j())} L${f1(x + j())},${f1(y - h * 0.04 + j())} L${f1(x + w * 0.12 + j())},${f1(y + j())}`;
|
|
39
|
+
}
|
|
40
|
+
function circle(cx, cy, rx, ry = rx, wobble = 0.05, seed = seedN++) {
|
|
41
|
+
const r = rng(seed);
|
|
42
|
+
const pts = [];
|
|
43
|
+
const n = 28, a0 = -Math.PI / 2 + r() * 0.3;
|
|
44
|
+
for (let i = 0; i <= n + 3; i++) {
|
|
45
|
+
const a = a0 + (i / n) * Math.PI * 2;
|
|
46
|
+
const k = 1 + r() * wobble;
|
|
47
|
+
pts.push([cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k]);
|
|
48
|
+
}
|
|
49
|
+
return 'M' + pts.map((p) => `${f1(p[0])},${f1(p[1])}`).join(' L');
|
|
50
|
+
}
|
|
51
|
+
function arrow(x1, y1, x2, y2, head = 46, seed = seedN++) {
|
|
52
|
+
const a = Math.atan2(y2 - y1, x2 - x1);
|
|
53
|
+
const h1 = [x2 - Math.cos(a - 0.45) * head, y2 - Math.sin(a - 0.45) * head];
|
|
54
|
+
const h2 = [x2 - Math.cos(a + 0.45) * head, y2 - Math.sin(a + 0.45) * head];
|
|
55
|
+
return `${line(x1, y1, x2, y2, 4, seed)} M${f1(h1[0])},${f1(h1[1])} L${f1(x2)},${f1(y2)} L${f1(h2[0])},${f1(h2[1])}`;
|
|
56
|
+
}
|
|
57
|
+
const underline = (x, y, w, seed = seedN++) => line(x, y, x + w, y + 6, 5, seed);
|
|
58
|
+
const strike = (x, y, w, seed = seedN++) => `${line(x, y, x + w, y - 10, 4, seed)} ${line(x + w * 0.05, y + 14, x + w * 1.02, y + 2, 4, seed + 1)}`;
|
|
59
|
+
|
|
60
|
+
function create(svg, { ink = '#1F1D1B', width = 9, marker = true, markerColor = '#2B6CB0' } = {}) {
|
|
61
|
+
const items = [];
|
|
62
|
+
const layer = document.createElementNS(NS, 'g');
|
|
63
|
+
svg.appendChild(layer);
|
|
64
|
+
let pen = null;
|
|
65
|
+
if (marker) {
|
|
66
|
+
pen = document.createElementNS(NS, 'g');
|
|
67
|
+
pen.innerHTML = `<g transform="rotate(-35)"><rect x="-10" y="-150" width="20" height="120" rx="6" fill="${markerColor}"/><rect x="-12" y="-38" width="24" height="26" rx="4" fill="#E8E8E8"/><path d="M-8,-12 L0,6 L8,-12 Z" fill="${ink}"/></g>`;
|
|
68
|
+
pen.style.display = 'none';
|
|
69
|
+
svg.appendChild(pen);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Un trazo que se dibuja de `at` a `at + dur`.
|
|
73
|
+
function shape(d, at, dur, { color = ink, stroke = width, fill = 'none' } = {}) {
|
|
74
|
+
const p = document.createElementNS(NS, 'path');
|
|
75
|
+
p.setAttribute('d', d);
|
|
76
|
+
p.setAttribute('fill', 'none');
|
|
77
|
+
p.setAttribute('stroke', color);
|
|
78
|
+
p.setAttribute('stroke-width', String(stroke));
|
|
79
|
+
p.setAttribute('stroke-linecap', 'round');
|
|
80
|
+
p.setAttribute('stroke-linejoin', 'round');
|
|
81
|
+
layer.appendChild(p);
|
|
82
|
+
const len = p.getTotalLength();
|
|
83
|
+
p.style.strokeDasharray = `${len} ${len}`;
|
|
84
|
+
let fillEl = null;
|
|
85
|
+
if (fill !== 'none') {
|
|
86
|
+
fillEl = p.cloneNode();
|
|
87
|
+
fillEl.setAttribute('fill', fill);
|
|
88
|
+
fillEl.setAttribute('stroke', 'none');
|
|
89
|
+
layer.insertBefore(fillEl, p);
|
|
90
|
+
}
|
|
91
|
+
items.push({ kind: 'path', el: p, fillEl, at, dur, len });
|
|
92
|
+
return p;
|
|
93
|
+
}
|
|
94
|
+
// Texto que se escribe de izquierda a derecha (se descubre con una máscara).
|
|
95
|
+
function write(text, x, y, at, dur, { size = 80, color = ink, font = "800 1em 'Montserrat'", anchor = 'middle' } = {}) {
|
|
96
|
+
const id = `wb-clip-${items.length}-${Math.random().toString(36).slice(2, 7)}`;
|
|
97
|
+
const clip = document.createElementNS(NS, 'clipPath');
|
|
98
|
+
clip.setAttribute('id', id);
|
|
99
|
+
const r = document.createElementNS(NS, 'rect');
|
|
100
|
+
clip.appendChild(r);
|
|
101
|
+
svg.appendChild(clip);
|
|
102
|
+
const tx = document.createElementNS(NS, 'text');
|
|
103
|
+
tx.textContent = text;
|
|
104
|
+
tx.setAttribute('x', String(x));
|
|
105
|
+
tx.setAttribute('y', String(y));
|
|
106
|
+
tx.setAttribute('fill', color);
|
|
107
|
+
tx.setAttribute('text-anchor', anchor);
|
|
108
|
+
tx.setAttribute('clip-path', `url(#${id})`);
|
|
109
|
+
tx.style.font = font;
|
|
110
|
+
tx.style.fontSize = `${size}px`;
|
|
111
|
+
layer.appendChild(tx);
|
|
112
|
+
// Ancho estimado sin medir, por lo alto (~0,8 em por carácter en una negrita): la
|
|
113
|
+
// máscara solo descubre, así que pasarse no corta nada; al acabar se quita.
|
|
114
|
+
const w = text.length * size * 0.8;
|
|
115
|
+
const x0 = anchor === 'middle' ? x - w / 2 : anchor === 'end' ? x - w : x;
|
|
116
|
+
items.push({ kind: 'text', el: tx, rect: r, at, dur, x0, w, y: y - size, h: size * 1.4 });
|
|
117
|
+
return tx;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function draw(t) {
|
|
121
|
+
let tip = null;
|
|
122
|
+
for (const it of items) {
|
|
123
|
+
const k = easeInOut(clamp01((t - it.at) / it.dur));
|
|
124
|
+
if (it.kind === 'path') {
|
|
125
|
+
it.el.style.strokeDashoffset = String(it.len * (1 - k));
|
|
126
|
+
it.el.style.opacity = t >= it.at ? '1' : '0';
|
|
127
|
+
if (it.fillEl) it.fillEl.style.opacity = String(clamp01((t - it.at - it.dur) / 0.3));
|
|
128
|
+
if (k > 0 && k < 1) tip = it.el.getPointAtLength(it.len * k);
|
|
129
|
+
} else {
|
|
130
|
+
it.rect.setAttribute('y', String(it.y));
|
|
131
|
+
it.rect.setAttribute('height', String(it.h));
|
|
132
|
+
// Terminado, sin máscara: que ninguna letra se quede fuera por la estimación.
|
|
133
|
+
it.rect.setAttribute('x', String(k >= 1 ? -1e5 : it.x0 - 10));
|
|
134
|
+
it.rect.setAttribute('width', String(k >= 1 ? 2e5 : Math.max(0, (it.w + 20) * k)));
|
|
135
|
+
if (k > 0 && k < 1) tip = { x: it.x0 + it.w * k, y: it.y + it.h * 0.75 };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (pen) {
|
|
139
|
+
pen.style.display = tip ? 'block' : 'none';
|
|
140
|
+
if (tip) pen.setAttribute('transform', `translate(${f1(tip.x)},${f1(tip.y)})`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { shape, write, draw };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
window.IgnixBoard = { create, line, rect, circle, arrow, underline, strike };
|
|
147
|
+
})();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// AVISO DE VERSIÓN NUEVA (2026-10-08, Fran: aviso, NO actualización automática).
|
|
2
|
+
//
|
|
3
|
+
// Ignix publica la versión que recomienda (la misma que fija su MCP, ya probada en CI):
|
|
4
|
+
// https://ignix.social/api/editor/version. Como mucho una vez al día se consulta (2 s
|
|
5
|
+
// de tope, sin red no pasa nada) y, si la instalada es más vieja, cada comando dice en
|
|
6
|
+
// stderr cuál hay y el comando EXACTO para actualizar. Lo ejecuta el agente con el OK
|
|
7
|
+
// de la persona; nunca se actualiza solo a mitad de un proyecto.
|
|
8
|
+
//
|
|
9
|
+
// Existe porque un agente puede usar la guía instalada (`skills add`) sin pasar por el
|
|
10
|
+
// MCP, y entonces nadie comprobaba la versión (prueba en Windows, 2026-10-08).
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { HOME } from './binaries.mjs';
|
|
14
|
+
|
|
15
|
+
const URL_VERSION = process.env.IGNIX_EDITOR_VERSION_URL || 'https://ignix.social/api/editor/version';
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/** a > b en versiones x.y.z. */
|
|
19
|
+
export function newer(a, b) {
|
|
20
|
+
const pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number);
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0);
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** La carpeta `--prefix` de esta instalación, si está instalada con npm (no en el repo). */
|
|
28
|
+
export function installPrefix(pkgDir) {
|
|
29
|
+
const scope = dirname(pkgDir);
|
|
30
|
+
const nm = dirname(scope);
|
|
31
|
+
if (basename(scope) !== '@ignix-social' || basename(nm) !== 'node_modules') return null;
|
|
32
|
+
return dirname(nm);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Los comandos para actualizar en este sistema. */
|
|
36
|
+
export function updateCommands(prefix, version, platform = process.platform) {
|
|
37
|
+
const pkg = `@ignix-social/editor@${version}`;
|
|
38
|
+
if (platform === 'win32') {
|
|
39
|
+
const bin = join(prefix, 'node_modules', '.bin', 'ignix-editor.cmd');
|
|
40
|
+
return [`npm.cmd install --prefix "${prefix}" ${pkg}`, `& "${bin}" skills add`];
|
|
41
|
+
}
|
|
42
|
+
const bin = join(prefix, 'node_modules', '.bin', 'ignix-editor');
|
|
43
|
+
return [`npm install --prefix "${prefix}" ${pkg}`, `"${bin}" skills add`];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function recommended() {
|
|
47
|
+
const cache = join(HOME, 'update-check.json');
|
|
48
|
+
try {
|
|
49
|
+
const c = JSON.parse(readFileSync(cache, 'utf8'));
|
|
50
|
+
if (Date.now() - c.checkedAt < DAY_MS && c.version) return c.version;
|
|
51
|
+
} catch {
|
|
52
|
+
/* sin caché */
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const r = await fetch(URL_VERSION, { signal: AbortSignal.timeout(2000) });
|
|
56
|
+
if (!r.ok) return null;
|
|
57
|
+
const { version } = await r.json();
|
|
58
|
+
if (!/^\d+\.\d+\.\d+$/.test(String(version))) return null;
|
|
59
|
+
mkdirSync(HOME, { recursive: true });
|
|
60
|
+
writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), version }));
|
|
61
|
+
return version;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Avisa (stderr) si Ignix recomienda una versión más nueva. Nunca lanza ni bloquea más de 2 s. */
|
|
68
|
+
export async function warnIfOutdated({ current, pkgDir, write = (s) => process.stderr.write(s) }) {
|
|
69
|
+
if (process.env.IGNIX_EDITOR_NO_UPDATE_CHECK === '1' || process.env.CI) return null;
|
|
70
|
+
const prefix = installPrefix(resolve(pkgDir));
|
|
71
|
+
if (!prefix || !existsSync(prefix)) return null;
|
|
72
|
+
const latest = await recommended();
|
|
73
|
+
if (!latest || !newer(latest, current)) return null;
|
|
74
|
+
const [install, skills] = updateCommands(prefix, latest);
|
|
75
|
+
write(
|
|
76
|
+
`⬆ Ignix Editor ${latest} is available (this is ${current}). Update before going on — ask the user to approve these two commands:\n` +
|
|
77
|
+
` ${install}\n ${skills}\n` +
|
|
78
|
+
` Then read the guide again (it may have changed).\n`,
|
|
79
|
+
);
|
|
80
|
+
return latest;
|
|
81
|
+
}
|