@ignix-social/editor 0.1.19 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignix-social/editor",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Ignix Editor: render HTML video compositions (reels, edits, clips) on your own computer. Based on HyperFrames.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
@@ -1,8 +1,9 @@
1
1
  // ignix-editor — CLI de Ignix Editor. El motor (HyperFrames, en vendor/) corre siempre por
2
2
  // debajo con nuestro entorno: sin telemetría, sin actualizaciones solas, con nuestros binarios.
3
3
  // Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
4
+ import './win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
4
5
  import { spawnSync } from 'node:child_process';
5
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
7
  import { createRequire } from 'node:module';
7
8
  import { homedir } from 'node:os';
8
9
  import { basename, dirname, join, resolve } from 'node:path';
@@ -21,6 +22,7 @@ import { measureStyle } from './analysis/style.mjs';
21
22
  import { reviewVideo } from './analysis/review.mjs';
22
23
  import { pickAudio } from './pick-audio.mjs';
23
24
  import { importGiphy, openGiphySearch } from './giphy.mjs';
25
+ import { warnIfOutdated } from './update-check.mjs';
24
26
 
25
27
  const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
26
28
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
@@ -213,7 +215,7 @@ const commands = {
213
215
  const ffprobe = await ensureTool('ffprobe', log);
214
216
  const whisper = await ensureTool('whisper', log);
215
217
  const model = await ensureWhisperModel(opts.model ?? 'medium', log);
216
- let words = transcribe({ ffmpeg, whisper, model, input, language: opts.lang ?? 'es', threads: Number(opts.threads ?? 8) });
218
+ let words = transcribe({ ffmpeg, whisper, model, input, language: opts.lang ?? 'es', threads: opts.threads });
217
219
  if (opts.script) {
218
220
  const dur = Number(spawnSync(ffprobe, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', input], { encoding: 'utf8' }).stdout.trim());
219
221
  words = alignToScript(opts.script, words, dur);
@@ -254,6 +256,24 @@ const commands = {
254
256
 
255
257
  // GIFs, stickers y clips de GIPHY elegidos por la persona (src/giphy.mjs): abre la
256
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
+
257
277
  async giphy(argv) {
258
278
  const { pos, opts } = parseArgs(argv, { search: 'str', kind: 'str', open: 'bool', json: 'bool' });
259
279
  const usage = 'Usage: ignix-editor giphy --search "thumbs up" [--kind gifs|stickers|clips] or ignix-editor giphy <link> [folder]';
@@ -419,6 +439,7 @@ const commands = {
419
439
  ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
420
440
  ignix-editor pick-audio [folder] --kind music|sfx --query "inspiring" [--order trending]
421
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…)
422
443
  ignix-editor giphy --search "thumbs up" [--kind gifs|stickers|clips] opens the GIPHY search in the browser
423
444
  ignix-editor giphy <link> [folder] brings the GIF (mp4), sticker (transparent frame sheet) or clip (mp4 with audio) into assets/
424
445
  ignix-editor style measure <video>… [--speech] [--stills dir] [-o style.json] someone's editing style, measured on 2–3 of their videos
@@ -441,6 +462,11 @@ export async function main(argv) {
441
462
  const [cmd = 'help', ...rest] = argv;
442
463
  const fn = commands[cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd];
443
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
+ }
444
470
  try {
445
471
  await fn(rest);
446
472
  } catch (e) {
@@ -2,6 +2,7 @@
2
2
  // listo para renderizar: los medios en assets/ con sus tiempos escritos en el HTML (vídeos,
3
3
  // audios, imágenes), las fuentes, la tabla de anchos de carácter para ajustar textos y el
4
4
  // dibujante de las plantillas (runtime/creator.js). ADR-0023 E2.
5
+ import '../win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
5
6
  import { spawnSync } from 'node:child_process';
6
7
  import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
7
8
  import { dirname, extname, join } from 'node:path';
package/src/engine.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  // El motor (HyperFrames, en vendor/) con nuestro entorno: sin telemetría, sin actualizaciones
2
2
  // solas, con nuestros binarios; y el render completo de un proyecto (copia de trabajo, motor,
3
3
  // volumen a −14 LUFS). Lo usan el CLI y el servicio para Creator.
4
+ import './win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
4
5
  import { spawn, spawnSync } from 'node:child_process';
5
6
  import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
6
7
  import { dirname, join } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
9
  import { HOME, PLATFORM, TOOLS, ensureTool, findTool } from './binaries.mjs';
9
10
  import { linkMedia, prepare } from './prepare.mjs';
10
11
  import { hasAudio, loudness, normalizeLoudness } from './audio.mjs';
@@ -22,6 +23,12 @@ export async function engineEnv(log = () => {}) {
22
23
  HYPERFRAMES_SKIP_SKILLS: '1',
23
24
  HYPERFRAMES_FONT_CACHE_DIR: join(HOME, 'cache', 'fonts'),
24
25
  };
26
+ // En Windows, el motor y todo proceso de Node que lance cargan antes el arreglo de
27
+ // fs.cpSync para rutas no ASCII (win-fs.mjs).
28
+ if (process.platform === 'win32') {
29
+ const preload = pathToFileURL(join(PKG_DIR, 'src', 'win-fs-preload.mjs')).href;
30
+ env.NODE_OPTIONS = `${env.NODE_OPTIONS ?? ''} --import="${preload}"`.trim();
31
+ }
25
32
  const ffmpeg = await ensureTool('ffmpeg', log);
26
33
  const ffprobe = await ensureTool('ffprobe', log);
27
34
  env.HYPERFRAMES_FFMPEG_PATH = ffmpeg;
package/src/giphy.mjs CHANGED
@@ -72,7 +72,6 @@ export function giphyRenditions(kind) {
72
72
  return [['giphy.mp4', 'mp4'], ['giphy.gif', 'gif']];
73
73
  }
74
74
 
75
- /** Trae el elegido a `<project>/assets/` y apunta su origen en `work/media-sources.json`. */
76
75
  /**
77
76
  * Un GIF con transparencia → una tira PNG (cols × rows fotogramas) + sus medidas.
78
77
  * `ffmpeg`/`ffprobe` son los del Editor.
@@ -92,10 +91,30 @@ export function gifToSheet({ ffmpeg, ffprobe, gif, out }) {
92
91
  return { sheet: out, frames, cols, rows, fps, durationSec: Math.round(durationSec * 100) / 100, frameWidth: Number(st.width), frameHeight: Number(st.height) };
93
92
  }
94
93
 
94
+ /**
95
+ * El tipo cuando el enlace no lo dice (el del ARCHIVO, …/giphy.gif): con él un clip se traía
96
+ * como GIF, sin audio. Solo los clips tienen giphy480p.mp4, y la página dice si es sticker.
97
+ */
98
+ export async function resolveGiphyKind(id, fromLink) {
99
+ if (fromLink) return fromLink;
100
+ if (!ID.test(id)) return 'gif';
101
+ const clip = await fetch(`https://media.giphy.com/media/${id}/giphy480p.mp4`, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
102
+ .then((r) => r.ok)
103
+ .catch(() => false);
104
+ if (clip) return 'clip';
105
+ const page = await fetch(`https://giphy.com/gifs/${id}`, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; IgnixEditor)' }, signal: AbortSignal.timeout(8000) })
106
+ .then((r) => (r.ok ? r.text() : ''))
107
+ .catch(() => '');
108
+ const html = page.replace(/\\"/g, '"');
109
+ const at = html.indexOf(`"id":"${id}"`);
110
+ return at >= 0 && /"is_sticker":true/.test(html.slice(at, at + 8000)) ? 'sticker' : 'gif';
111
+ }
112
+
113
+ /** Trae el elegido a `<project>/assets/` y apunta su origen en `work/media-sources.json`. */
95
114
  export async function importGiphy({ link, project, kind: forced, ffmpeg, ffprobe }) {
96
115
  const parsed = parseGiphyLink(link);
97
116
  if (!parsed) throw new Error('That is not a GIPHY link. Copy the link of the GIF, sticker or clip from giphy.com.');
98
- const kind = forced ?? parsed.kind ?? 'gif';
117
+ const kind = forced ?? (await resolveGiphyKind(parsed.id, parsed.kind));
99
118
  const assets = join(project, 'assets');
100
119
  mkdirSync(assets, { recursive: true });
101
120
  let lastErr = null;
package/src/prepare.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // con 4 decimales, 97/30 = 3.2333 daba el fotograma 96 en vez del 97);
4
4
  // · ignix-frame.js de esta versión del editor, siempre al día.
5
5
  // El proyecto del usuario no se toca.
6
+ import './win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.mjs)
6
7
  import { copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
7
8
  import { dirname, join } from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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
+ })();
@@ -4,7 +4,7 @@
4
4
  // y a veces se come el final de una frase.
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
7
+ import { availableParallelism, tmpdir } from 'node:os';
8
8
  import { basename, dirname, join, relative } from 'node:path';
9
9
 
10
10
  /**
@@ -27,9 +27,38 @@ function whisperWorkspace(model) {
27
27
  return { tmp: mkdtempSync(join(tmpdir(), 'ignix-editor-asr-')), cwd: undefined, modelArg: model, arg: (p) => p };
28
28
  }
29
29
 
30
+ /**
31
+ * Hilos para whisper: nunca más que núcleos. Con más hilos que núcleos (p. ej. -t 8 en un
32
+ * portátil de 2 o 4) el whisper.cpp que publicamos (sin OpenMP) se queda bloqueado PARA
33
+ * SIEMPRE, sin error (Windows en CI y en una VM, 2026-10-08).
34
+ */
35
+ export function whisperThreads(requested) {
36
+ const cores = Math.max(1, availableParallelism());
37
+ const n = Number(requested);
38
+ return Math.max(1, Math.min(Number.isFinite(n) && n > 0 ? n : 8, cores));
39
+ }
40
+
41
+ /** Tiempo máximo de whisper: generoso (un PC lento va a ~1× tiempo real), pero nunca infinito. */
42
+ function whisperTimeoutMs(wavPath) {
43
+ let seconds = 0;
44
+ try {
45
+ seconds = Math.max(0, (statSync(wavPath).size - 44) / 32000);
46
+ } catch {
47
+ /* sin tamaño: el mínimo */
48
+ }
49
+ return Math.max(10 * 60_000, seconds * 6 * 1000);
50
+ }
51
+
52
+ function whisperFailure(w, timeoutMs) {
53
+ if (w.error?.code === 'ETIMEDOUT') {
54
+ return new Error(`whisper did not finish in ${Math.round(timeoutMs / 60_000)} min and was stopped. Close other heavy programs and run the command again, or use a smaller model (--model small).`);
55
+ }
56
+ return new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
57
+ }
58
+
30
59
  const norm = (s) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}]/gu, '');
31
60
 
32
- export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads = 8 }) {
61
+ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads }) {
33
62
  const ws = whisperWorkspace(model);
34
63
  const tmp = ws.tmp;
35
64
  try {
@@ -37,14 +66,15 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
37
66
  const a = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-i', input, '-vn', '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], { encoding: 'utf8' });
38
67
  if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
39
68
  const out = join(tmp, 'out');
40
- const args = ['-m', ws.modelArg, '-l', language, '-ml', '1', '-sow', '-oj', '-of', ws.arg(out), '-t', String(threads), '-f', ws.arg(wav)];
41
- const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd });
69
+ const args = ['-m', ws.modelArg, '-l', language, '-ml', '1', '-sow', '-oj', '-of', ws.arg(out), '-t', String(whisperThreads(threads)), '-f', ws.arg(wav)];
70
+ const timeout = whisperTimeoutMs(wav);
71
+ const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd, timeout });
42
72
  // En Mac Intel la GPU (Metal) se cuelga con whisper: CPU directamente. En el resto, GPU y,
43
73
  // si falla, otra vez por CPU.
44
74
  const intelMac = process.platform === 'darwin' && process.arch === 'x64';
45
75
  let w = run(intelMac ? ['-ng'] : []);
46
- if (w.status !== 0 && !intelMac) w = run(['-ng']);
47
- if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
76
+ if (w.status !== 0 && !intelMac && w.error?.code !== 'ETIMEDOUT') w = run(['-ng']);
77
+ if (w.status !== 0) throw whisperFailure(w, timeout);
48
78
  const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
49
79
  const words = [];
50
80
  for (const seg of j.transcription ?? []) {
@@ -95,7 +125,7 @@ export function alignToScript(script, tokens, end) {
95
125
  * Transcripción completa para Creator: idioma detectado (si no se da), cada palabra con su
96
126
  * confianza (media de sus tokens) y las frases (cortadas en un punto o en una pausa).
97
127
  */
98
- export function transcribeDetailed({ ffmpeg, whisper, model, input, language = null, threads = 8, maxWords = 6000, maxSegments = 800 }) {
128
+ export function transcribeDetailed({ ffmpeg, whisper, model, input, language = null, threads, maxWords = 6000, maxSegments = 800 }) {
99
129
  const ws = whisperWorkspace(model);
100
130
  const tmp = ws.tmp;
101
131
  try {
@@ -104,16 +134,17 @@ export function transcribeDetailed({ ffmpeg, whisper, model, input, language = n
104
134
  if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
105
135
  const duration = statSync(wav).size > 44 ? (statSync(wav).size - 44) / 32000 : 0;
106
136
  const out = join(tmp, 'out');
107
- const args = ['-m', ws.modelArg, '-l', language || 'auto', '-ml', '1', '-sow', '-ojf', '-of', ws.arg(out), '-t', String(threads), '-f', ws.arg(wav)];
108
- const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd });
137
+ const args = ['-m', ws.modelArg, '-l', language || 'auto', '-ml', '1', '-sow', '-ojf', '-of', ws.arg(out), '-t', String(whisperThreads(threads)), '-f', ws.arg(wav)];
138
+ const timeout = whisperTimeoutMs(wav);
139
+ const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd, timeout });
109
140
  const intelMac = process.platform === 'darwin' && process.arch === 'x64';
110
141
  let w = run(intelMac ? ['-ng'] : []);
111
142
  let device = intelMac ? 'cpu' : 'gpu';
112
- if (w.status !== 0 && !intelMac) {
143
+ if (w.status !== 0 && !intelMac && w.error?.code !== 'ETIMEDOUT') {
113
144
  w = run(['-ng']);
114
145
  device = 'cpu';
115
146
  }
116
- if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
147
+ if (w.status !== 0) throw whisperFailure(w, timeout);
117
148
  const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
118
149
  const words = [];
119
150
  let aside = false; // dentro de una acotación: [MÚSICA], (upbeat music)…
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ // Se carga con `node --import` en el proceso del motor (ver win-fs.mjs).
2
+ import { installWindowsFsPatch } from './win-fs.mjs';
3
+
4
+ installWindowsFsPatch();
package/src/win-fs.mjs ADDED
@@ -0,0 +1,40 @@
1
+ // fs.cpSync de Node 22 en Windows (su versión nativa en C++) rompe con rutas fuera del
2
+ // ASCII: con «C:\Users\José María» el proceso muere en seco (0xC0000409) o «copia» a una
3
+ // ruta mal convertida y el fichero no aparece. Le pasa a `init`, a `skill` y al motor al
4
+ // compilar el proyecto para renderizar. En Windows se cambia por una copia en JavaScript
5
+ // (fichero a fichero, que sí respeta Unicode) para el Editor y, vía --import, para el motor.
6
+ import fs from 'node:fs';
7
+ import { syncBuiltinESMExports } from 'node:module';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ /** cpSync en JavaScript: recursive, dereference, filter(src, dest) y force (por defecto sí). */
11
+ export function safeCpSync(src, dest, opts = {}) {
12
+ const { recursive = false, dereference = false, filter, force = true } = opts;
13
+ if (filter && !filter(String(src), String(dest))) return;
14
+ const st = dereference ? fs.statSync(src) : fs.lstatSync(src);
15
+ if (st.isDirectory()) {
16
+ if (!recursive) throw Object.assign(new Error(`Recursive option is required to copy a directory: ${src}`), { code: 'ERR_FS_EISDIR' });
17
+ fs.mkdirSync(dest, { recursive: true });
18
+ for (const name of fs.readdirSync(src)) safeCpSync(join(String(src), name), join(String(dest), name), opts);
19
+ return;
20
+ }
21
+ if (st.isSymbolicLink()) {
22
+ if (!force && fs.existsSync(dest)) return;
23
+ fs.rmSync(dest, { force: true });
24
+ fs.mkdirSync(dirname(String(dest)), { recursive: true });
25
+ fs.symlinkSync(fs.readlinkSync(src), dest);
26
+ return;
27
+ }
28
+ if (!force && fs.existsSync(dest)) return;
29
+ fs.mkdirSync(dirname(String(dest)), { recursive: true });
30
+ fs.copyFileSync(src, dest);
31
+ }
32
+
33
+ let installed = false;
34
+ /** En Windows, cambia fs.cpSync por safeCpSync también para quien lo importó por nombre. */
35
+ export function installWindowsFsPatch() {
36
+ if (installed || process.platform !== 'win32') return;
37
+ installed = true;
38
+ fs.cpSync = safeCpSync;
39
+ syncBuiltinESMExports();
40
+ }