@ignix-social/editor 0.1.23 → 0.1.25
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 +20 -1
- package/src/cli.mjs +13 -8
- package/src/giphy.mjs +14 -8
- package/src/runtime/blocks/captions.js +138 -0
- package/src/runtime/blocks/overlays.js +176 -0
- package/src/transcribe.mjs +48 -5
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). |
|
|
@@ -222,6 +222,25 @@ const ba = IgnixBeforeAfter.create(document.getElementById('ba'), {
|
|
|
222
222
|
|
|
223
223
|
Every block draws itself with `x.draw(t)` inside `run()`.
|
|
224
224
|
|
|
225
|
+
**`captions`** — word-by-word captions straight from `words.json` (the output of `ignix-editor transcribe`): groups of 2–3 words that never cross the end of a sentence or a pause, the word being said highlighted, numbers and your `emphasis` words in a second colour, hard cuts between groups (a fade leaves an empty frame). Styles: `bold_pop` (uppercase with a thick outline, the current word pops), `karaoke` (words fill with colour as they are said), `clean` (sentence case with a soft shadow), `box` (on a dark box, the current word on a coloured pill). `IgnixCaptions.clip(words, from, to)` takes a clip's words and starts them at 0; `IgnixCaptions.remap(words, cuts)` removes the cut stretches (`[[start, end], …]` in the same time as the words — the same list you cut the video with) and moves the rest earlier. Load the words as a script (`assets/words.js`: `window.WORDS = {…}`), never with `fetch`. The container is the caption band (e.g. `left:90px; right:90px; top:1180px; height:260px` on a reel, under the face); never give it `id="caption"`.
|
|
226
|
+
|
|
227
|
+
```js
|
|
228
|
+
const C = IgnixCaptions;
|
|
229
|
+
const words = C.remap(C.clip(WORDS.words, 452.1, 511.5), [[12.3, 13.1]]);
|
|
230
|
+
const cap = C.create(document.getElementById('subs'), { words, pauses: WORDS.pauses, style: 'bold_pop', accent: '#FF6A00', emphasis: ['YouTube'] });
|
|
231
|
+
run({ id, duration, fps }, (t) => cap.draw(t));
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**`overlays`** — the on-screen labels of an edit, each on its word: `title` (big, `*asterisks*` in the accent colour), `number` (counts up from `from` to `to`, with `prefix`, `suffix`, `label`, `locale`), `list` (items tick in one by one, `step` seconds apart), `strike` (crossed out, with an optional `replace` that pops in under it), `label` (a pill with an arrow: `point: 'down'|'up'|'left'|'right'`, placed with `x`/`y` in %), `emoji`. Place each with `pos: 'top'|'upper'|'center'|'lower'|'bottom'` (bands inside the reel's safe area) or `x`/`y` in %. The container covers the frame (`position:absolute; inset:0`) above the video and under the captions.
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
const O = IgnixOverlays.create(document.getElementById('ov'), { accent: '#FF6A00', items: [
|
|
238
|
+
{ type: 'number', from: 800, to: 1600, suffix: ' €', label: 'per video', at: 3.1, dur: 2.2 },
|
|
239
|
+
{ type: 'strike', text: 'YOUR VIDEO', replace: 'ANOTHER VIDEO', at: 6.0, dur: 2.5 },
|
|
240
|
+
] });
|
|
241
|
+
run({ id, duration, fps }, (t) => O.draw(t));
|
|
242
|
+
```
|
|
243
|
+
|
|
225
244
|
**Mixing them.** Blocks combine with each other and with the normal direction (recreated screens, captions, the camera): one block per stretch (open with a stickman sketch, explain on the whiteboard, then the usual screens), or several at once (the stickman pointing at what the whiteboard draws, a chat inside a scene with captions over it). Give each its own container, show it only during its stretch (toggle `display` from `t`) and change on the word that motivates it, with a transition (a quick slide, wipe or scale) rather than a hard swap.
|
|
226
245
|
|
|
227
246
|
## 3. Recorded video (edits and clips)
|
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
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* captions.js — bloque de Ignix Editor: subtítulos palabra a palabra a partir de words.json
|
|
3
|
+
* (`ignix-editor transcribe`). Agrupa 2-3 palabras sin cruzar el final de una frase ni una
|
|
4
|
+
* pausa, resalta la palabra que suena y destaca números y palabras clave. Función pura del
|
|
5
|
+
* tiempo: el navegador coloca las palabras (CSS) y aquí solo se anima.
|
|
6
|
+
*
|
|
7
|
+
* const C = IgnixCaptions;
|
|
8
|
+
* const data = window.WORDS; // { words, pauses } de words.json
|
|
9
|
+
* const words = C.remap(C.clip(data.words, 452.1, 511.5), [[12.3, 13.1]]); // clip + cortes
|
|
10
|
+
* const cap = C.create(document.getElementById('cap'), {
|
|
11
|
+
* words, pauses: data.pauses, style: 'bold_pop', // bold_pop | karaoke | clean | box
|
|
12
|
+
* accent: '#FF6A00', accent2: '#FFB020', emphasis: ['YouTube', 'gratis'],
|
|
13
|
+
* });
|
|
14
|
+
* run({ id, duration, fps }, (t) => cap.draw(t));
|
|
15
|
+
*
|
|
16
|
+
* Cortes (`remap`): tramos [inicio, fin] QUITADOS, en el tiempo del original; las palabras de
|
|
17
|
+
* dentro desaparecen y las de después se adelantan lo quitado. Usa la misma lista para el vídeo.
|
|
18
|
+
*/
|
|
19
|
+
(function () {
|
|
20
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
21
|
+
const outBack = (x) => 1 + 2.4 * (x - 1) ** 3 + 1.4 * (x - 1) ** 2;
|
|
22
|
+
const norm = (s) => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}]/gu, '');
|
|
23
|
+
const ENDS = /[.!?…]["»”)]?$/;
|
|
24
|
+
|
|
25
|
+
/** Palabras de un tramo [from, to) del original, con el tiempo empezando en 0. */
|
|
26
|
+
function clip(words, from, to = Infinity) {
|
|
27
|
+
return words.filter((w) => w.s >= from - 0.02 && w.s < to).map((w) => ({ ...w, s: +(w.s - from).toFixed(3), e: +(Math.min(w.e, to) - from).toFixed(3) }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Quita los tramos `cuts` ([[s, e], …] en el tiempo de `words`) y adelanta lo que viene después. */
|
|
31
|
+
function remap(words, cuts = []) {
|
|
32
|
+
const cs = [...cuts].map(([a, b]) => [Math.min(a, b), Math.max(a, b)]).sort((x, y) => x[0] - y[0]);
|
|
33
|
+
const map = (x) => {
|
|
34
|
+
let off = 0;
|
|
35
|
+
for (const [a, b] of cs) {
|
|
36
|
+
if (x >= b) off += b - a;
|
|
37
|
+
else if (x > a) return a - off; // dentro de un corte: al borde
|
|
38
|
+
}
|
|
39
|
+
return x - off;
|
|
40
|
+
};
|
|
41
|
+
return words
|
|
42
|
+
.filter((w) => !cs.some(([a, b]) => w.s >= a && w.e <= b))
|
|
43
|
+
.map((w) => ({ ...w, s: +map(w.s).toFixed(3), e: +map(w.e).toFixed(3) }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Grupos de hasta `maxWords` palabras / `maxChars` letras; corta en fin de frase y en pausas. */
|
|
47
|
+
function group(words, { maxWords = 3, maxChars = 18, pauses = [], breakGap = 0.35 } = {}) {
|
|
48
|
+
const out = [];
|
|
49
|
+
let cur = null;
|
|
50
|
+
for (let i = 0; i < words.length; i++) {
|
|
51
|
+
const w = words[i];
|
|
52
|
+
const prev = words[i - 1];
|
|
53
|
+
const chars = cur ? cur.words.reduce((n, x) => n + x.t.length + 1, 0) + w.t.length : 0;
|
|
54
|
+
const pauseBetween = prev && (w.s - prev.e > breakGap || pauses.some(([a, b]) => b - a >= breakGap && a >= prev.s && a < w.s + 0.05));
|
|
55
|
+
if (!cur || cur.words.length >= maxWords || chars > maxChars || ENDS.test(prev.t) || pauseBetween) {
|
|
56
|
+
cur = { words: [], s: w.s, e: w.e };
|
|
57
|
+
out.push(cur);
|
|
58
|
+
}
|
|
59
|
+
cur.words.push(w);
|
|
60
|
+
cur.e = w.e;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Puntuación de final que sobra en pantalla («hola,» → «hola»); ¿ y ! se quedan.
|
|
66
|
+
const tidy = (t) => t.replace(/[.,;:]+$/, '').replace(/^["«“(]+|["»”)]+$/g, '');
|
|
67
|
+
|
|
68
|
+
function create(box, { words = [], pauses = [], style = 'bold_pop', maxWords, maxChars, accent = '#FF6A00', accent2 = '#FFB020', emphasis = [], numbers = true, uppercase, fontSize = 88, font = "'Montserrat', Arial, sans-serif", color = '#FFFFFF', stroke = '#000000', hold = 0.35 } = {}) {
|
|
69
|
+
const st = {
|
|
70
|
+
bold_pop: { up: true, weight: 900, maxW: 3, maxC: 14 },
|
|
71
|
+
karaoke: { up: true, weight: 900, maxW: 4, maxC: 22 },
|
|
72
|
+
clean: { up: false, weight: 800, maxW: 5, maxC: 28 },
|
|
73
|
+
box: { up: false, weight: 800, maxW: 3, maxC: 18 },
|
|
74
|
+
}[style] ?? { up: true, weight: 900, maxW: 3, maxC: 16 };
|
|
75
|
+
const up = uppercase ?? st.up;
|
|
76
|
+
const groups = group(words, { maxWords: maxWords ?? st.maxW, maxChars: maxChars ?? st.maxC, pauses });
|
|
77
|
+
const emph = new Set(emphasis.map(norm));
|
|
78
|
+
box.innerHTML = '';
|
|
79
|
+
// Rejilla de una celda: todos los grupos en el mismo sitio (si dos coinciden un instante,
|
|
80
|
+
// se superponen en vez de repartirse el ancho).
|
|
81
|
+
Object.assign(box.style, { position: box.style.position || 'absolute', display: 'grid', placeItems: 'center', pointerEvents: 'none' });
|
|
82
|
+
const els = groups.map((g, gi) => {
|
|
83
|
+
const row = document.createElement('div');
|
|
84
|
+
// Hueco amplio entre palabras: con contorno grueso, 0,3 em las pega («VÍDEOYOUTUBE»).
|
|
85
|
+
Object.assign(row.style, {
|
|
86
|
+
display: 'none', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'baseline', columnGap: '0.42em', rowGap: '0.06em',
|
|
87
|
+
gridArea: '1 / 1', maxWidth: '100%', textAlign: 'center', fontFamily: font, fontWeight: String(st.weight), fontSize: `${fontSize}px`, lineHeight: '1.08', transformOrigin: '50% 60%',
|
|
88
|
+
});
|
|
89
|
+
if (style === 'box') Object.assign(row.style, { background: 'rgba(14,14,16,.78)', padding: '0.22em 0.5em', borderRadius: '0.32em' });
|
|
90
|
+
const spans = g.words.map((w) => {
|
|
91
|
+
const el = document.createElement('span');
|
|
92
|
+
const text = tidy(w.t);
|
|
93
|
+
el.textContent = up ? text.toUpperCase() : text;
|
|
94
|
+
const key = numbers && /\d/.test(w.t) ? 'num' : emph.has(norm(w.t)) ? 'emph' : '';
|
|
95
|
+
Object.assign(el.style, { display: 'inline-block', color, transformOrigin: '50% 70%', borderRadius: '0.18em', padding: style === 'box' ? '0 0.12em' : '0' });
|
|
96
|
+
if (style === 'bold_pop' || style === 'karaoke') Object.assign(el.style, { WebkitTextStroke: `0.15em ${stroke}`, paintOrder: 'stroke fill' });
|
|
97
|
+
if (style === 'clean') el.style.textShadow = '0 4px 14px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)';
|
|
98
|
+
row.appendChild(el);
|
|
99
|
+
return { el, w, key };
|
|
100
|
+
});
|
|
101
|
+
box.appendChild(row);
|
|
102
|
+
const next = groups[gi + 1];
|
|
103
|
+
// Entra 0,04 s antes de su palabra; el anterior sale justo entonces (sin solaparse).
|
|
104
|
+
return { row, spans, s: g.s, end: next ? Math.min(next.s - 0.04, g.e + hold) : g.e + hold };
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
groups,
|
|
109
|
+
draw(t) {
|
|
110
|
+
for (const g of els) {
|
|
111
|
+
const on = t >= g.s - 0.04 && t < g.end;
|
|
112
|
+
g.row.style.display = on ? 'flex' : 'none';
|
|
113
|
+
if (!on) continue;
|
|
114
|
+
const k = clamp01((t - g.s + 0.04) / 0.14);
|
|
115
|
+
g.row.style.transform = style === 'clean' || style === 'box' ? `translateY(${((1 - k) * 18).toFixed(1)}px)` : `scale(${(0.86 + 0.14 * outBack(k)).toFixed(4)})`;
|
|
116
|
+
g.row.style.opacity = '1'; // corte seco entre grupos: un fundido deja un fotograma vacío
|
|
117
|
+
for (const { el, w, key } of g.spans) {
|
|
118
|
+
const now = t >= w.s && t < Math.max(w.e, w.s + 0.12);
|
|
119
|
+
const said = t >= w.s;
|
|
120
|
+
const base = key === 'num' || key === 'emph' ? accent2 : color;
|
|
121
|
+
if (style === 'box') {
|
|
122
|
+
el.style.background = now ? accent : 'transparent';
|
|
123
|
+
el.style.color = now ? '#111' : base;
|
|
124
|
+
} else if (style === 'karaoke') {
|
|
125
|
+
el.style.color = said ? accent : base;
|
|
126
|
+
} else {
|
|
127
|
+
el.style.color = now ? accent : base;
|
|
128
|
+
}
|
|
129
|
+
const kw = clamp01((t - w.s) / 0.09);
|
|
130
|
+
el.style.transform = now && style === 'bold_pop' ? `scale(${(1 + 0.1 * kw).toFixed(4)})` : 'scale(1)';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
window.IgnixCaptions = { create, group, clip, remap };
|
|
138
|
+
})();
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* overlays.js — bloque de Ignix Editor: rótulos sobre el vídeo, cada uno anclado a su
|
|
3
|
+
* palabra: título, número que cuenta, lista que se va marcando, tachado (con su reemplazo),
|
|
4
|
+
* etiqueta con flecha y emoji. Función pura del tiempo.
|
|
5
|
+
*
|
|
6
|
+
* const O = IgnixOverlays.create(document.getElementById('ov'), { // #ov: inset 0 sobre el vídeo
|
|
7
|
+
* accent: '#FF6A00',
|
|
8
|
+
* items: [
|
|
9
|
+
* { type: 'title', text: 'YouTube ya no te *enseña*', at: 0.2, dur: 2.4, pos: 'top' },
|
|
10
|
+
* { type: 'number', to: 1600, prefix: '', suffix: ' €', at: 3.1, dur: 2.2, from: 800, label: 'por vídeo' },
|
|
11
|
+
* { type: 'list', title: 'Antes de publicar', items: ['Gancho', 'Prueba', 'CTA'], at: 6, step: 0.7, dur: 3.5 },
|
|
12
|
+
* { type: 'strike', text: 'TU VÍDEO', replace: 'OTRO VÍDEO', at: 10, dur: 2.5 },
|
|
13
|
+
* { type: 'label', text: 'Esto', at: 13, dur: 1.5, x: 62, y: 40, point: 'down' },
|
|
14
|
+
* { type: 'emoji', char: '🔥', at: 15, dur: 1.2, x: 78, y: 22 },
|
|
15
|
+
* ],
|
|
16
|
+
* });
|
|
17
|
+
* run({ id, duration, fps }, (t) => O.draw(t));
|
|
18
|
+
*
|
|
19
|
+
* pos: top | upper | center | lower | bottom (franjas dentro de la zona segura del reel) o
|
|
20
|
+
* { x, y } en % del cuadro. Texto con *asteriscos* = esa parte en el color de acento.
|
|
21
|
+
*/
|
|
22
|
+
(function () {
|
|
23
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
24
|
+
const outBack = (x) => 1 + 2.4 * (x - 1) ** 3 + 1.4 * (x - 1) ** 2;
|
|
25
|
+
const outCubic = (x) => 1 - (1 - x) ** 3;
|
|
26
|
+
const POS = { top: 15, upper: 28, center: 46, lower: 60, bottom: 70 };
|
|
27
|
+
|
|
28
|
+
// «Hola *mundo*» → nodos con la parte entre asteriscos en el acento (sin innerHTML).
|
|
29
|
+
function rich(el, text, accent) {
|
|
30
|
+
String(text)
|
|
31
|
+
.split(/(\*[^*]+\*)/)
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.forEach((part) => {
|
|
34
|
+
const s = document.createElement('span');
|
|
35
|
+
const hot = part.startsWith('*') && part.endsWith('*');
|
|
36
|
+
s.textContent = hot ? part.slice(1, -1) : part;
|
|
37
|
+
if (hot) s.style.color = accent;
|
|
38
|
+
el.appendChild(s);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fmt(n, { decimals = 0, locale = 'es-ES' } = {}) {
|
|
43
|
+
return new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }).format(n);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function create(box, { items = [], accent = '#FF6A00', ink = '#FFFFFF', panel = 'rgba(14,14,16,.82)', font = "'Montserrat', Arial, sans-serif", size = 1, locale = 'es-ES' } = {}) {
|
|
47
|
+
box.innerHTML = '';
|
|
48
|
+
Object.assign(box.style, { pointerEvents: 'none', fontFamily: font });
|
|
49
|
+
const base = 1080 * size; // tamaños pensados para 1080 de ancho
|
|
50
|
+
const px = (n) => `${((n * base) / 1080).toFixed(1)}px`;
|
|
51
|
+
|
|
52
|
+
const made = items.map((it) => {
|
|
53
|
+
const el = document.createElement('div');
|
|
54
|
+
const at = it.at ?? 0;
|
|
55
|
+
const dur = it.dur ?? 2;
|
|
56
|
+
const p = typeof it.pos === 'object' && it.pos ? it.pos : { x: it.x ?? 50, y: it.y ?? POS[it.pos ?? (it.type === 'title' ? 'top' : 'upper')] ?? 28 };
|
|
57
|
+
Object.assign(el.style, { position: 'absolute', left: `${p.x}%`, top: `${p.y}%`, transform: 'translate(-50%,-50%)', opacity: '0', color: ink, textAlign: 'center' });
|
|
58
|
+
const o = { it, el, at, end: at + dur };
|
|
59
|
+
|
|
60
|
+
if (it.type === 'title') {
|
|
61
|
+
Object.assign(el.style, { width: '80%', fontWeight: '900', fontSize: px(it.size ?? 92), lineHeight: '1.04', textTransform: 'uppercase', WebkitTextStroke: `${px(10)} #000`, paintOrder: 'stroke fill' });
|
|
62
|
+
rich(el, it.text, accent);
|
|
63
|
+
} else if (it.type === 'number' || it.type === 'counter') {
|
|
64
|
+
Object.assign(el.style, { background: panel, borderRadius: px(36), padding: `${px(26)} ${px(46)}`, fontWeight: '900' });
|
|
65
|
+
const num = document.createElement('div');
|
|
66
|
+
Object.assign(num.style, { fontSize: px(it.size ?? 150), lineHeight: '1', color: accent, fontVariantNumeric: 'tabular-nums' });
|
|
67
|
+
el.appendChild(num);
|
|
68
|
+
if (it.label) {
|
|
69
|
+
const l = document.createElement('div');
|
|
70
|
+
Object.assign(l.style, { fontSize: px(46), fontWeight: '800', marginTop: px(8), textTransform: 'uppercase' });
|
|
71
|
+
l.textContent = it.label;
|
|
72
|
+
el.appendChild(l);
|
|
73
|
+
}
|
|
74
|
+
o.num = num;
|
|
75
|
+
} else if (it.type === 'list') {
|
|
76
|
+
Object.assign(el.style, { width: '76%', background: panel, borderRadius: px(32), padding: `${px(34)} ${px(40)}`, textAlign: 'left' });
|
|
77
|
+
if (it.title) {
|
|
78
|
+
const h = document.createElement('div');
|
|
79
|
+
Object.assign(h.style, { fontSize: px(44), fontWeight: '900', color: accent, textTransform: 'uppercase', marginBottom: px(18) });
|
|
80
|
+
h.textContent = it.title;
|
|
81
|
+
el.appendChild(h);
|
|
82
|
+
}
|
|
83
|
+
o.rows = (it.items ?? []).map((txt, i) => {
|
|
84
|
+
const r = document.createElement('div');
|
|
85
|
+
Object.assign(r.style, { display: 'flex', alignItems: 'center', gap: px(22), fontSize: px(it.size ?? 54), fontWeight: '800', margin: `${px(12)} 0`, opacity: '0' });
|
|
86
|
+
const dot = document.createElement('div');
|
|
87
|
+
Object.assign(dot.style, { flex: '0 0 auto', width: px(62), height: px(62), borderRadius: '50%', background: accent, color: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: px(36), fontWeight: '900' });
|
|
88
|
+
dot.textContent = it.check === false ? String(i + 1) : '✓';
|
|
89
|
+
const tx = document.createElement('div');
|
|
90
|
+
tx.textContent = txt;
|
|
91
|
+
r.append(dot, tx);
|
|
92
|
+
el.appendChild(r);
|
|
93
|
+
return { r, dot, at: at + 0.25 + i * (it.step ?? 0.6) };
|
|
94
|
+
});
|
|
95
|
+
} else if (it.type === 'strike') {
|
|
96
|
+
Object.assign(el.style, { fontWeight: '900', fontSize: px(it.size ?? 96), textTransform: 'uppercase', whiteSpace: 'nowrap' });
|
|
97
|
+
const old = document.createElement('div');
|
|
98
|
+
Object.assign(old.style, { position: 'relative', display: 'inline-block', WebkitTextStroke: `${px(9)} #000`, paintOrder: 'stroke fill' });
|
|
99
|
+
old.textContent = it.text;
|
|
100
|
+
const line = document.createElement('div');
|
|
101
|
+
Object.assign(line.style, { position: 'absolute', left: '-6%', width: '112%', top: '48%', height: px(16), background: it.lineColor ?? '#E0252B', borderRadius: px(8), transformOrigin: '0 50%', transform: 'scaleX(0) rotate(-4deg)' });
|
|
102
|
+
old.appendChild(line);
|
|
103
|
+
el.appendChild(old);
|
|
104
|
+
o.old = old;
|
|
105
|
+
o.line = line;
|
|
106
|
+
if (it.replace) {
|
|
107
|
+
const nw = document.createElement('div');
|
|
108
|
+
Object.assign(nw.style, { color: accent, marginTop: px(10), WebkitTextStroke: `${px(9)} #000`, paintOrder: 'stroke fill', opacity: '0' });
|
|
109
|
+
nw.textContent = it.replace;
|
|
110
|
+
el.appendChild(nw);
|
|
111
|
+
o.nw = nw;
|
|
112
|
+
}
|
|
113
|
+
} else if (it.type === 'label') {
|
|
114
|
+
Object.assign(el.style, { background: accent, color: '#111', fontWeight: '900', fontSize: px(it.size ?? 52), padding: `${px(14)} ${px(30)}`, borderRadius: px(999), whiteSpace: 'nowrap' });
|
|
115
|
+
el.textContent = it.text;
|
|
116
|
+
if (it.point) {
|
|
117
|
+
const a = document.createElement('div');
|
|
118
|
+
const s = px(22);
|
|
119
|
+
const side = { down: { top: '100%', left: '50%', bd: `border-top:${s} solid ${accent}`, tf: 'translateX(-50%)' }, up: { bottom: '100%', left: '50%', bd: `border-bottom:${s} solid ${accent}`, tf: 'translateX(-50%)' }, left: { right: '100%', top: '50%', bd: `border-right:${s} solid ${accent}`, tf: 'translateY(-50%)' }, right: { left: '100%', top: '50%', bd: `border-left:${s} solid ${accent}`, tf: 'translateY(-50%)' } }[it.point];
|
|
120
|
+
if (side) {
|
|
121
|
+
a.style.cssText = `position:absolute;width:0;height:0;border:${s} solid transparent;${side.bd};transform:${side.tf}`;
|
|
122
|
+
for (const k of ['top', 'bottom', 'left', 'right']) if (side[k]) a.style[k] = side[k];
|
|
123
|
+
el.appendChild(a);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else if (it.type === 'emoji') {
|
|
127
|
+
Object.assign(el.style, { fontSize: px(it.size ?? 150), lineHeight: '1' });
|
|
128
|
+
el.textContent = it.char ?? '🔥';
|
|
129
|
+
}
|
|
130
|
+
box.appendChild(el);
|
|
131
|
+
return o;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
draw(t) {
|
|
136
|
+
for (const o of made) {
|
|
137
|
+
const { it, el, at, end } = o;
|
|
138
|
+
const on = t >= at && t < end;
|
|
139
|
+
el.style.display = on ? 'block' : 'none';
|
|
140
|
+
if (!on) continue;
|
|
141
|
+
const kin = clamp01((t - at) / 0.28);
|
|
142
|
+
const kout = clamp01((t - (end - 0.2)) / 0.2);
|
|
143
|
+
el.style.opacity = (clamp01(kin * 1.8) * (1 - kout)).toFixed(3);
|
|
144
|
+
const pop = it.type === 'emoji' ? 0.3 + 0.7 * outBack(kin) : 0.82 + 0.18 * outBack(kin);
|
|
145
|
+
const wob = it.type === 'emoji' ? `rotate(${(Math.sin((t - at) * 6) * 6).toFixed(2)}deg)` : '';
|
|
146
|
+
el.style.transform = `translate(-50%,-50%) translateY(${((1 - outCubic(kin)) * 30 + kout * -20).toFixed(1)}px) scale(${pop.toFixed(4)}) ${wob}`;
|
|
147
|
+
if (o.num) {
|
|
148
|
+
const k = outCubic(clamp01((t - at - 0.15) / (it.count ?? 0.9)));
|
|
149
|
+
const v = (it.from ?? 0) + ((it.to ?? 0) - (it.from ?? 0)) * k;
|
|
150
|
+
o.num.textContent = `${it.prefix ?? ''}${fmt(v, { decimals: it.decimals ?? 0, locale: it.locale ?? locale })}${it.suffix ?? ''}`;
|
|
151
|
+
}
|
|
152
|
+
if (o.rows) {
|
|
153
|
+
for (const r of o.rows) {
|
|
154
|
+
const k = clamp01((t - r.at) / 0.22);
|
|
155
|
+
r.r.style.opacity = k.toFixed(3);
|
|
156
|
+
r.r.style.transform = `translateX(${((1 - outCubic(k)) * -30).toFixed(1)}px)`;
|
|
157
|
+
r.dot.style.transform = `scale(${(0.5 + 0.5 * outBack(k)).toFixed(4)})`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (o.line) {
|
|
161
|
+
const k = outCubic(clamp01((t - at - (it.strikeAt ?? 0.45)) / 0.3));
|
|
162
|
+
o.line.style.transform = `scaleX(${k.toFixed(4)}) rotate(-4deg)`;
|
|
163
|
+
o.old.style.opacity = o.nw ? (1 - 0.45 * clamp01((t - at - 0.8) / 0.3)).toFixed(3) : '1';
|
|
164
|
+
if (o.nw) {
|
|
165
|
+
const kn = clamp01((t - at - (it.replaceAt ?? 0.85)) / 0.25);
|
|
166
|
+
o.nw.style.opacity = kn.toFixed(3);
|
|
167
|
+
o.nw.style.transform = `scale(${(0.7 + 0.3 * outBack(kn)).toFixed(4)})`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
window.IgnixOverlays = { create, format: fmt };
|
|
176
|
+
})();
|
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
|
}
|