@ignix-social/editor 0.1.22 → 0.1.24
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 +5 -5
- package/src/cli.mjs +13 -8
- package/src/giphy.mjs +14 -8
- package/src/runtime/blocks/stickman.js +13 -6
- package/src/runtime/blocks/whiteboard.js +26 -6
- package/src/transcribe.mjs +53 -7
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ Commands. `ignix-editor` means the copy the Ignix playbook installed: `~/.ignix-
|
|
|
13
13
|
|---|---|
|
|
14
14
|
| `ignix-editor doctor` | Node 22+ and the H.264 encoder; downloads its own ffmpeg and whisper the first time. Run it first. |
|
|
15
15
|
| `ignix-editor init <name>` | Creates `~/ignix-videos/<name>` with a working skeleton, GSAP, local fonts and `ignix-frame.js`. |
|
|
16
|
-
| `ignix-editor transcribe <file> --lang es [--script "…"] -o words.json` | Words with start/end seconds, on this machine. |
|
|
16
|
+
| `ignix-editor transcribe <file> --lang es [--script "…"] -o words.json` | Words with start/end seconds, plus `pauses` ([start, end] of every silence ≥0.35 s, for cutting silences), on this machine. Over 2 min it prints whisper's progress; without a GPU it takes about as long as the audio. |
|
|
17
17
|
| `ignix-editor faces <video> -o work/faces.json` | Where the face is over time (macOS and Windows; the first run downloads the face detector): a smoothed path `{t, cx, cy, size}` (0–1, top-left origin) to frame the person. |
|
|
18
18
|
| `ignix-editor pick-audio [dir] --kind music\|sfx --query "inspiring" [--order trending]` | Music (or a specific sound effect) from Pixabay, chosen by the USER: opens Pixabay's search in their browser, waits for their download, copies it into `assets/` and notes its source in `work/audio-sources.json`. Tell the user first that you opened Pixabay for them to pick one. Never fetch pixabay.com yourself: it blocks robots. |
|
|
19
19
|
| `ignix-editor giphy --search "thumbs up" [--kind gifs\|stickers\|clips]` · `ignix-editor giphy <link> [dir]` | GIFs, stickers and clips from GIPHY, chosen by the USER: the first opens GIPHY's search in their browser; they copy the link of the one they like (Share → Copy link) and the second brings it into `assets/`: a GIF as MP4, a clip as MP4 with its audio, a sticker as a transparent frame sheet (see section 2, GIPHY stickers). |
|
|
@@ -114,7 +114,7 @@ Decide the zones once per video (e.g. header y 270–440, stage y 460–1300, ca
|
|
|
114
114
|
|
|
115
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
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).
|
|
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; pass `theme` in the state to `draw()` to switch it per frame, e.g. dark scene → white whiteboard, with the same figure).
|
|
118
118
|
|
|
119
119
|
```html
|
|
120
120
|
<svg id="stage" viewBox="0 0 1080 1920" style="position:absolute;inset:0;width:1080px;height:1920px"></svg>
|
|
@@ -159,7 +159,7 @@ const chat = IgnixChat.create(document.getElementById('screen'), {
|
|
|
159
159
|
run({ id, duration, fps }, (t) => chat.draw(t));
|
|
160
160
|
```
|
|
161
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.
|
|
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. `create(target, { paper })` also draws the board itself (`paper: '#F7F5F0'` or `{ color, x, y, w, h, radius, frame }`); `target` may be a `<g>` inside the `<svg>`, so scaling/translating that `<g>` moves a camera over the board. 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
163
|
|
|
164
164
|
```js
|
|
165
165
|
const B = IgnixBoard;
|
|
@@ -259,7 +259,7 @@ work/ transcripts, edit decisions (JSON)
|
|
|
259
259
|
build.mjs reads work/ + durations (ignix-editor ffprobe) → writes assets/timing.js and the <video>/<audio> tags between markers in index.html
|
|
260
260
|
```
|
|
261
261
|
|
|
262
|
-
Re-run it whenever the voice or the cuts change; the animation code reads `window.R` from `assets/timing.js` and never hardcodes seconds.
|
|
262
|
+
Re-run it whenever the voice or the cuts change; the animation code reads `window.R` from `assets/timing.js` and never hardcodes seconds. Keep any HTML template the script reads in `work/`, never next to `index.html`: a second root HTML with `data-composition-id` fails `check` (`multiple_root_compositions`).
|
|
263
263
|
|
|
264
264
|
## 6. Traps that do not error
|
|
265
265
|
|
|
@@ -279,5 +279,5 @@ Re-run it whenever the voice or the cuts change; the animation code reads `windo
|
|
|
279
279
|
Only if the app offers no way, give the user ONE line to paste into Terminal, e.g. `cd ~/ignix-videos/<name> && ~/.ignix-editor/cli/node_modules/.bin/ignix-editor render`, never the contents of a script file (a `#!` line breaks zsh), then continue when they say it finished.
|
|
280
280
|
|
|
281
281
|
1. `ignix-editor check` → 0 errors.
|
|
282
|
-
2. `ignix-editor snapshot --at <every ~0.5 s and every cue>` and LOOK at the frames
|
|
282
|
+
2. `ignix-editor snapshot --at <every ~0.5 s and every cue>` and LOOK at the frames — it also writes them tiled as `snapshots/contact-sheet*.jpg`, the quickest to read. Check: hook readable at frame 0, nothing still for >1.5 s, every named thing pointed at on its word, captions never over what is pointed at, nothing outside the safe zone.
|
|
283
283
|
3. `ignix-editor render` → it reviews the MP4 by itself and prints `✓ Review: ready to show` or `✗ Review: NOT READY` with what to fix and where (stretches where nothing on the stage moves, a mostly empty frame, silences between voice lines, no change of scene). NOT READY means it is not finished: fix every ✗, render again, and only then show it. The `!` lines are worth fixing too when it is quick. (`ignix-editor review <mp4>` runs the same check on any video.) Then open the MP4's first frame and a few cues again, and deliver as the playbook says.
|
package/src/cli.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { HOME, PLATFORM, TOOLS, ensureTool, ensureWhisperModel, findTool } from
|
|
|
12
12
|
import { linkMedia, prepare, projectHtml } from './prepare.mjs';
|
|
13
13
|
import { validateHtml } from './validate.mjs';
|
|
14
14
|
import { engineEnv, renderProject, runEngine } from './engine.mjs';
|
|
15
|
-
import { alignToScript, transcribe } from './transcribe.mjs';
|
|
15
|
+
import { alignToScript, fitToPauses, transcribe } from './transcribe.mjs';
|
|
16
16
|
import { faces as detectFaces } from './analysis/detect.mjs';
|
|
17
17
|
import { frames, probe } from './analysis/media.mjs';
|
|
18
18
|
import { serve } from './analysis/serve.mjs';
|
|
@@ -215,16 +215,21 @@ const commands = {
|
|
|
215
215
|
const ffprobe = await ensureTool('ffprobe', log);
|
|
216
216
|
const whisper = await ensureTool('whisper', log);
|
|
217
217
|
const model = await ensureWhisperModel(opts.model ?? 'medium', log);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
const
|
|
218
|
+
const dur = Number(spawnSync(ffprobe, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', input], { encoding: 'utf8' }).stdout.trim()) || 0;
|
|
219
|
+
const mmss = (x) => `${Math.floor(x / 60)}:${String(Math.round(x % 60)).padStart(2, '0')}`;
|
|
220
|
+
const t0 = Date.now();
|
|
221
|
+
// Aviso de duración ANTES de empezar: sin GPU, una charla larga tarda más o menos lo que dura.
|
|
222
|
+
if (dur > 120) log(`Transcribing ${mmss(dur)} of audio with the ${basename(model).replace(/^ggml-|\.bin$/g, '')} model. Without a GPU this can take about as long as the audio itself; whisper prints its progress below.`);
|
|
223
|
+
const r = transcribe({ ffmpeg, whisper, model, input, language: opts.lang ?? 'es', threads: opts.threads, progress: dur > 120 });
|
|
224
|
+
let words = r.words;
|
|
225
|
+
if (opts.script) words = alignToScript(opts.script, words, dur);
|
|
226
|
+
words = fitToPauses(words, r.pauses);
|
|
227
|
+
const json = JSON.stringify({ language: opts.lang ?? 'es', duration: +dur.toFixed(3), words, pauses: r.pauses }, null, 1);
|
|
224
228
|
if (opts.output) {
|
|
225
229
|
mkdirSync(dirname(resolve(opts.output)), { recursive: true });
|
|
226
230
|
writeFileSync(resolve(opts.output), json);
|
|
227
231
|
} else console.log(json);
|
|
232
|
+
log(`✓ ${words.length} words and ${r.pauses.length} pauses (≥0.35 s) in ${mmss((Date.now() - t0) / 1000)}${opts.output ? ` → ${opts.output}` : ''}`);
|
|
228
233
|
},
|
|
229
234
|
|
|
230
235
|
// Música o efectos de Pixabay elegidos por la persona (src/pick-audio.mjs): abre la
|
|
@@ -296,7 +301,7 @@ const commands = {
|
|
|
296
301
|
if (r.sheet) {
|
|
297
302
|
const sh = r.sheet;
|
|
298
303
|
log(`✓ sticker → ${r.file}: ${sh.frames} frames in a ${sh.cols}×${sh.rows} transparent sheet, ${sh.frameWidth}×${sh.frameHeight} each, ${sh.fps} fps (${sh.durationSec} s loop). Animate it from t as the skill shows (GIPHY section).`);
|
|
299
|
-
} else log(`✓ ${r.kind} → ${r.file}${r.hasAudio ? ' (with its audio)' : ''}`);
|
|
304
|
+
} else log(`✓ ${r.kind} → ${r.file}${r.kind === 'clip' ? (r.hasAudio ? ' (with its audio)' : ' (this clip has no sound: treat it as a muted clip)') : ''}`);
|
|
300
305
|
if (opts.json) console.log(JSON.stringify(r));
|
|
301
306
|
},
|
|
302
307
|
|
package/src/giphy.mjs
CHANGED
|
@@ -67,7 +67,8 @@ export function giphySearchUrl(query, kind = 'gifs') {
|
|
|
67
67
|
|
|
68
68
|
/** Qué fichero traer de media.giphy.com para cada tipo (y el de reserva). */
|
|
69
69
|
export function giphyRenditions(kind) {
|
|
70
|
-
|
|
70
|
+
// El de 360p es el que lleva el audio; giphy480p.mp4 dejó de servirse (404, 2026-10-08).
|
|
71
|
+
if (kind === 'clip') return [['giphy360p.mp4', 'mp4'], ['giphy.mp4', 'mp4']];
|
|
71
72
|
if (kind === 'sticker') return [['giphy.gif', 'gif']];
|
|
72
73
|
return [['giphy.mp4', 'mp4'], ['giphy.gif', 'gif']];
|
|
73
74
|
}
|
|
@@ -93,21 +94,21 @@ export function gifToSheet({ ffmpeg, ffprobe, gif, out }) {
|
|
|
93
94
|
|
|
94
95
|
/**
|
|
95
96
|
* 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.
|
|
97
|
+
* como GIF, sin audio. La página lo dice: los clips traen su bloque "video" y los stickers
|
|
98
|
+
* is_sticker.
|
|
97
99
|
*/
|
|
98
100
|
export async function resolveGiphyKind(id, fromLink) {
|
|
99
101
|
if (fromLink) return fromLink;
|
|
100
102
|
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
103
|
const page = await fetch(`https://giphy.com/gifs/${id}`, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; IgnixEditor)' }, signal: AbortSignal.timeout(8000) })
|
|
106
104
|
.then((r) => (r.ok ? r.text() : ''))
|
|
107
105
|
.catch(() => '');
|
|
108
106
|
const html = page.replace(/\\"/g, '"');
|
|
109
107
|
const at = html.indexOf(`"id":"${id}"`);
|
|
110
|
-
|
|
108
|
+
if (at < 0) return 'gif';
|
|
109
|
+
const next = html.slice(at + 10).search(/"id":"[A-Za-z0-9]{8,40}"/);
|
|
110
|
+
const own = html.slice(at, next < 0 ? at + 8000 : at + 10 + next);
|
|
111
|
+
return /"video":\{/.test(own) ? 'clip' : /"is_sticker":true/.test(own) ? 'sticker' : 'gif';
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/** Trae el elegido a `<project>/assets/` y apunta su origen en `work/media-sources.json`. */
|
|
@@ -143,7 +144,12 @@ export async function importGiphy({ link, project, kind: forced, ffmpeg, ffprobe
|
|
|
143
144
|
/* fichero roto: se empieza de nuevo */
|
|
144
145
|
}
|
|
145
146
|
writeFileSync(log, JSON.stringify([...prev, { source: 'giphy', kind, id: parsed.id, link: String(link), file: dest, at: new Date().toISOString() }], null, 2));
|
|
146
|
-
|
|
147
|
+
// Audio de verdad, no supuesto: hay clips mudos («Take My Money»).
|
|
148
|
+
const hasAudio =
|
|
149
|
+
kind === 'clip' && ffprobe
|
|
150
|
+
? spawnSync(ffprobe, ['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', dest], { encoding: 'utf8' }).stdout.trim() !== ''
|
|
151
|
+
: false;
|
|
152
|
+
return { id: parsed.id, kind, file: dest, hasAudio, ...(sheet ? { sheet } : {}) };
|
|
147
153
|
}
|
|
148
154
|
throw new Error(`Could not download that ${kind} from GIPHY${lastErr ? `: ${lastErr.message}` : ''}.`);
|
|
149
155
|
}
|
|
@@ -182,10 +182,16 @@
|
|
|
182
182
|
// look: { color, hair: short|spiky|curly|long|bun|ponytail|none, hairColor, accessory: none|glasses|cap|headphones|bow|tie, theme: 'light'|'dark' }
|
|
183
183
|
function create(svg, look = {}) {
|
|
184
184
|
const L = { color: '#D24D1E', hair: 'short', hairColor: '#22201E', accessory: 'none', theme: 'light', ...look };
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
// Colores según el tema; `draw` puede cambiar de tema en cada fotograma (escena oscura →
|
|
186
|
+
// pizarra blanca) sin crear otro monigote.
|
|
187
|
+
let ink, headFill, hairFill;
|
|
188
|
+
function palette(theme) {
|
|
189
|
+
const dark = theme === 'dark';
|
|
190
|
+
ink = dark ? '#F3F0EA' : '#1F1D1B';
|
|
191
|
+
headFill = dark ? '#2A3038' : '#FFFFFF';
|
|
192
|
+
hairFill = dark && lum(L.hairColor) <= 0.25 ? '#D9D4CC' : L.hairColor;
|
|
193
|
+
}
|
|
194
|
+
palette(L.theme);
|
|
189
195
|
const tie = lum(L.color) > 0.2 ? '#2B2F3A' : '#E8E2D8';
|
|
190
196
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
191
197
|
svg.appendChild(g);
|
|
@@ -263,9 +269,10 @@
|
|
|
263
269
|
return s;
|
|
264
270
|
}
|
|
265
271
|
|
|
266
|
-
// state: { x, ground, scale, facing, joints, face, mouth, eyesOpen, alpha, pop, lift }
|
|
272
|
+
// state: { x, ground, scale, facing, joints, face, mouth, eyesOpen, alpha, pop, lift, theme }
|
|
267
273
|
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 };
|
|
274
|
+
const st = { scale: 1, facing: 1, mouth: 0, eyesOpen: 1, alpha: 1, pop: 1, lift: 0, face: 'neutral', joints: 'idle', theme: L.theme, ...state };
|
|
275
|
+
palette(st.theme);
|
|
269
276
|
const joints = typeof st.joints === 'string' ? POSES[st.joints] ?? POSES.idle : { ...DEFAULT, ...st.joints };
|
|
270
277
|
const face = typeof st.face === 'string' ? EXPRESSIONS[st.face] ?? EXPRESSIONS.neutral : { ...EXPRESSIONS.neutral, ...st.face };
|
|
271
278
|
const p = forward(joints);
|
|
@@ -57,8 +57,19 @@
|
|
|
57
57
|
const underline = (x, y, w, seed = seedN++) => line(x, y, x + w, y + 6, 5, seed);
|
|
58
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
59
|
|
|
60
|
-
|
|
60
|
+
// `svg` puede ser el <svg> o un <g> dentro de él (para mover una «cámara» sobre la
|
|
61
|
+
// pizarra: escala/traslada el <g>). `paper` dibuja la pizarra en sí: un color, o
|
|
62
|
+
// { color, x, y, w, h, radius, frame } (por defecto 0,0,1080,1920 sin marco).
|
|
63
|
+
function create(svg, { ink = '#1F1D1B', width = 9, marker = true, markerColor = '#2B6CB0', paper = null } = {}) {
|
|
61
64
|
const items = [];
|
|
65
|
+
if (paper) {
|
|
66
|
+
const P = typeof paper === 'string' ? { color: paper } : paper;
|
|
67
|
+
const bg = document.createElementNS(NS, 'rect');
|
|
68
|
+
const attrs = { x: P.x ?? 0, y: P.y ?? 0, width: P.w ?? 1080, height: P.h ?? 1920, rx: P.radius ?? 0, fill: P.color ?? '#F7F5F0' };
|
|
69
|
+
if (P.frame) Object.assign(attrs, { stroke: P.frame === true ? '#C9C4BA' : P.frame, 'stroke-width': P.frameWidth ?? 14 });
|
|
70
|
+
for (const [k, v] of Object.entries(attrs)) bg.setAttribute(k, String(v));
|
|
71
|
+
svg.appendChild(bg);
|
|
72
|
+
}
|
|
62
73
|
const layer = document.createElementNS(NS, 'g');
|
|
63
74
|
svg.appendChild(layer);
|
|
64
75
|
let pen = null;
|
|
@@ -109,11 +120,12 @@
|
|
|
109
120
|
tx.style.font = font;
|
|
110
121
|
tx.style.fontSize = `${size}px`;
|
|
111
122
|
layer.appendChild(tx);
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
123
|
+
// El ancho se MIDE en cada fotograma (getComputedTextLength: geometría del texto con
|
|
124
|
+
// la fuente ya cargada, igual en todos los fotogramas del render) para que el
|
|
125
|
+
// rotulador acabe donde acaba la palabra. Si aún no se puede medir, estimación por lo
|
|
126
|
+
// alto (~0,8 em por carácter): la máscara solo descubre y al acabar se quita.
|
|
127
|
+
const est = text.length * size * 0.8;
|
|
128
|
+
items.push({ kind: 'text', el: tx, rect: r, at, dur, x, anchor, est, y: y - size, h: size * 1.4 });
|
|
117
129
|
return tx;
|
|
118
130
|
}
|
|
119
131
|
|
|
@@ -127,6 +139,14 @@
|
|
|
127
139
|
if (it.fillEl) it.fillEl.style.opacity = String(clamp01((t - it.at - it.dur) / 0.3));
|
|
128
140
|
if (k > 0 && k < 1) tip = it.el.getPointAtLength(it.len * k);
|
|
129
141
|
} else {
|
|
142
|
+
let w = it.est;
|
|
143
|
+
try {
|
|
144
|
+
w = it.el.getComputedTextLength() || it.est;
|
|
145
|
+
} catch {
|
|
146
|
+
/* sin medida: estimación */
|
|
147
|
+
}
|
|
148
|
+
it.w = w;
|
|
149
|
+
it.x0 = it.anchor === 'middle' ? it.x - w / 2 : it.anchor === 'end' ? it.x - w : it.x;
|
|
130
150
|
it.rect.setAttribute('y', String(it.y));
|
|
131
151
|
it.rect.setAttribute('height', String(it.h));
|
|
132
152
|
// Terminado, sin máscara: que ninguna letra se quede fuera por la estimación.
|
package/src/transcribe.mjs
CHANGED
|
@@ -46,19 +46,60 @@ function whisperTimeoutMs(wavPath) {
|
|
|
46
46
|
} catch {
|
|
47
47
|
/* sin tamaño: el mínimo */
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
// ENTERO: spawnSync rechaza un timeout con decimales («The value of "timeout" is out of
|
|
50
|
+
// range… Received 13158197.625») y eso rompía cualquier audio de más de 100 s (2026-10-08).
|
|
51
|
+
return Math.ceil(Math.max(10 * 60_000, seconds * 6 * 1000));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pausas del audio (ffmpeg silencedetect): [[inicio, fin], …] en segundos. Sirven para el
|
|
56
|
+
* paso de silencios y para que ninguna palabra se estire sobre una pausa: whisper con -ml 1
|
|
57
|
+
* hace que cada palabra acabe donde empieza la siguiente («claro.» de 0,76 s, «1.600,» de 1,6 s).
|
|
58
|
+
*/
|
|
59
|
+
export function detectPauses(ffmpeg, input, { noiseDb = -35, minSec = 0.35 } = {}) {
|
|
60
|
+
const r = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', '-i', input, '-vn', '-af', `silencedetect=noise=${noiseDb}dB:d=${minSec}`, '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
61
|
+
const out = [];
|
|
62
|
+
let start = null;
|
|
63
|
+
for (const line of (r.stderr || '').split('\n')) {
|
|
64
|
+
const a = line.match(/silence_start: (-?[\d.]+)/);
|
|
65
|
+
if (a) start = Math.max(0, Number(a[1]));
|
|
66
|
+
const b = line.match(/silence_end: ([\d.]+)/);
|
|
67
|
+
if (b && start !== null) {
|
|
68
|
+
out.push([+start.toFixed(3), +Number(b[1]).toFixed(3)]);
|
|
69
|
+
start = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Recorta cada palabra a la voz: ni empieza ni acaba dentro de una pausa (si le queda voz). */
|
|
76
|
+
export function fitToPauses(words, pauses) {
|
|
77
|
+
if (!pauses?.length) return words;
|
|
78
|
+
return words.map((w) => {
|
|
79
|
+
let { s, e } = w;
|
|
80
|
+
for (const [ps, pe] of pauses) {
|
|
81
|
+
if (ps >= e || pe <= s) continue;
|
|
82
|
+
if (ps > s && ps - s >= 0.08) e = Math.min(e, ps); // se estiraba sobre la pausa
|
|
83
|
+
else if (pe < e && e - pe >= 0.08) s = Math.max(s, pe); // empezaba dentro de la pausa
|
|
84
|
+
}
|
|
85
|
+
return { ...w, s: +s.toFixed(3), e: +e.toFixed(3) };
|
|
86
|
+
});
|
|
50
87
|
}
|
|
51
88
|
|
|
52
89
|
function whisperFailure(w, timeoutMs) {
|
|
53
90
|
if (w.error?.code === 'ETIMEDOUT') {
|
|
54
91
|
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
92
|
}
|
|
56
|
-
return new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
|
|
93
|
+
return new Error(`whisper failed (exit ${w.status ?? w.error?.code ?? '?'}): ${(w.stderr || '').slice(-400)}`);
|
|
57
94
|
}
|
|
58
95
|
|
|
59
96
|
const norm = (s) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}]/gu, '');
|
|
60
97
|
|
|
61
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Palabras y pausas. Con `progress`, whisper escribe su avance («progress = 40%») en la
|
|
100
|
+
* consola: en una transcripción larga, sin eso nadie distingue «va lento» de «se ha colgado».
|
|
101
|
+
*/
|
|
102
|
+
export function transcribe({ ffmpeg, whisper, model, input, language = 'es', threads, progress = false }) {
|
|
62
103
|
const ws = whisperWorkspace(model);
|
|
63
104
|
const tmp = ws.tmp;
|
|
64
105
|
try {
|
|
@@ -67,8 +108,10 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
|
|
|
67
108
|
if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
|
|
68
109
|
const out = join(tmp, 'out');
|
|
69
110
|
const args = ['-m', ws.modelArg, '-l', language, '-ml', '1', '-sow', '-oj', '-of', ws.arg(out), '-t', String(whisperThreads(threads)), '-f', ws.arg(wav)];
|
|
111
|
+
if (progress) args.unshift('-np', '-pp');
|
|
70
112
|
const timeout = whisperTimeoutMs(wav);
|
|
71
|
-
const
|
|
113
|
+
const stdio = progress ? ['ignore', 'pipe', 'inherit'] : undefined;
|
|
114
|
+
const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, cwd: ws.cwd, timeout, stdio });
|
|
72
115
|
// En Mac Intel la GPU (Metal) se cuelga con whisper: CPU directamente. En el resto, GPU y,
|
|
73
116
|
// si falla, otra vez por CPU.
|
|
74
117
|
const intelMac = process.platform === 'darwin' && process.arch === 'x64';
|
|
@@ -82,7 +125,7 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
|
|
|
82
125
|
if (!t || /^\[.*\]$/.test(t)) continue; // [MÚSICA], [MUSIC]…
|
|
83
126
|
words.push({ t, s: seg.offsets.from / 1000, e: seg.offsets.to / 1000 });
|
|
84
127
|
}
|
|
85
|
-
return words;
|
|
128
|
+
return { words, pauses: detectPauses(ffmpeg, wav) };
|
|
86
129
|
} finally {
|
|
87
130
|
rmSync(tmp, { recursive: true, force: true });
|
|
88
131
|
}
|
|
@@ -95,7 +138,7 @@ export function alignToScript(script, tokens, end) {
|
|
|
95
138
|
let i = 0;
|
|
96
139
|
for (let j = 0; j < tokens.length && i < sw.length; j++) {
|
|
97
140
|
const tk = tokens[j];
|
|
98
|
-
const tEnd = j + 1 < tokens.length ? tokens[j + 1].s :
|
|
141
|
+
const tEnd = j + 1 < tokens.length ? tokens[j + 1].s : end > 0 ? end : tk.e;
|
|
99
142
|
// whisper juntó dos palabras en una («a DM» → «ADM»)
|
|
100
143
|
if (i + 1 < sw.length && norm(sw[i] + sw[i + 1]) === norm(tk.t) && norm(sw[i]) !== norm(tk.t)) {
|
|
101
144
|
const mid = tk.s + (tEnd - tk.s) * (sw[i].length / (sw[i].length + sw[i + 1].length));
|
|
@@ -118,7 +161,10 @@ export function alignToScript(script, tokens, end) {
|
|
|
118
161
|
s = e;
|
|
119
162
|
}
|
|
120
163
|
}
|
|
121
|
-
|
|
164
|
+
// whisper trabaja en ventanas de 30 s y a veces pone el final de la última palabra más
|
|
165
|
+
// allá del audio (una frase de 2,76 s con «Ignix» acabando en 3,6 s): nada pasa del final.
|
|
166
|
+
const cap = end > 0 ? (x) => Math.min(x, end) : (x) => x;
|
|
167
|
+
return out.map((w) => ({ t: w.t, s: +cap(w.s).toFixed(3), e: +cap(w.e).toFixed(3) }));
|
|
122
168
|
}
|
|
123
169
|
|
|
124
170
|
/**
|