@ignix-social/editor 0.1.15 → 0.1.17

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.15",
3
+ "version": "0.1.17",
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",
@@ -7,14 +7,15 @@ description: Build and render videos with Ignix Editor (HTML + GSAP compositions
7
7
 
8
8
  Ignix Editor turns an HTML page into an MP4. You write `index.html` (layout in HTML/CSS, motion in JS), the editor renders it frame by frame in a headless browser and encodes it with ffmpeg. The Ignix playbooks say WHAT to make; this skill says HOW to write it so that what you write is what renders.
9
9
 
10
- Commands (`npx @ignix-social/editor <cmd>` if `ignix-editor` is not on the PATH):
10
+ Commands. `ignix-editor` means the copy the Ignix playbook installed: `~/.ignix-editor/cli/node_modules/.bin/ignix-editor` (Windows PowerShell: `& "$env:USERPROFILE\.ignix-editor\cli\node_modules\.bin\ignix-editor.cmd"`; PowerShell blocks `.ps1` scripts by default, so use the `.cmd` and `npm.cmd`, and `$env:USERPROFILE` instead of `~`). Do not use `npx`: it may fetch another version.
11
11
 
12
12
  | Command | What it does |
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
16
  | `ignix-editor transcribe <file> --lang es [--script "…"] -o words.json` | Words with start/end seconds, on this machine. |
17
- | `ignix-editor faces <video> -o work/faces.json` | Where the face is over time (macOS, Apple Vision): a smoothed path `{t, cx, cy, size}` (0–1, top-left origin) to frame the person. |
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
+ | `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. |
18
19
  | `ignix-editor check [dir]` | HTML well-formed, no network assets, media rules, layout/contrast/motion audit. |
19
20
  | `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. `[dir]` defaults to the current folder. |
20
21
  | `ignix-editor render [dir] [-o out.mp4]` | Renders to `<dir>/renders/<dir>.mp4`, audio normalised to −14 LUFS. |
package/src/binaries.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // (descarga oficial de Google la primera vez).
5
5
  import { createHash } from 'node:crypto';
6
6
  import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
- import { Readable } from 'node:stream';
7
+ import { Readable, Transform } from 'node:stream';
8
8
  import { pipeline } from 'node:stream/promises';
9
9
  import { homedir } from 'node:os';
10
10
  import { dirname, join } from 'node:path';
@@ -75,9 +75,40 @@ export async function ensureWhisperModel(model = 'medium', log = () => {}) {
75
75
  if (existsSync(dest)) return dest;
76
76
  mkdirSync(dir, { recursive: true });
77
77
  log(`Downloading the ${model} transcription model (${(m.size / 1e9).toFixed(1)} GB, first time only)…`);
78
- const res = await fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${m.file}`);
79
- if (!res.ok) throw new Error(`Could not download the ${model} model: ${res.status}`);
80
- await pipeline(Readable.fromWeb(res.body), createWriteStream(`${dest}.part`));
78
+ // Con avance y con freno (2026-10-07): en las pruebas de Windows esta descarga tardó 52 min
79
+ // sin decir nada, y un agente que no ve avance cree que se ha colgado y la corta. Se avisa
80
+ // cada 10 % y, si en 2 min no llega ni un byte, se para con un error claro.
81
+ const ctrl = new AbortController();
82
+ const STALL_MS = 120_000;
83
+ let stall = setTimeout(() => ctrl.abort(), STALL_MS);
84
+ let got = 0;
85
+ let next = 10;
86
+ const progress = new Transform({
87
+ transform(chunk, _enc, cb) {
88
+ clearTimeout(stall);
89
+ stall = setTimeout(() => ctrl.abort(), STALL_MS);
90
+ got += chunk.length;
91
+ const pct = Math.floor((got / m.size) * 100);
92
+ if (pct >= next && pct < 100) {
93
+ log(` ${pct} % (${Math.round(got / 1e6)} of ${Math.round(m.size / 1e6)} MB)`);
94
+ next = Math.floor(pct / 10) * 10 + 10;
95
+ }
96
+ cb(null, chunk);
97
+ },
98
+ });
99
+ try {
100
+ const res = await fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${m.file}`, { signal: ctrl.signal });
101
+ if (!res.ok) throw new Error(`Could not download the ${model} model: ${res.status}`);
102
+ await pipeline(Readable.fromWeb(res.body), progress, createWriteStream(`${dest}.part`));
103
+ } catch (err) {
104
+ rmSync(`${dest}.part`, { force: true });
105
+ if (ctrl.signal.aborted) {
106
+ throw new Error(`The ${model} transcription model download stalled (no data for 2 minutes). Check the internet connection and run the command again; it starts over.`);
107
+ }
108
+ throw err;
109
+ } finally {
110
+ clearTimeout(stall);
111
+ }
81
112
  // El sha256 es el que publica Hugging Face para ese fichero (su oid de LFS).
82
113
  const h = createHash('sha256');
83
114
  await pipeline(createReadStream(`${dest}.part`), h);
package/src/cli.mjs CHANGED
@@ -19,6 +19,7 @@ import { creatorJob } from './creator/job.mjs';
19
19
  import { fetchAll } from './fetch.mjs';
20
20
  import { measureStyle } from './analysis/style.mjs';
21
21
  import { reviewVideo } from './analysis/review.mjs';
22
+ import { pickAudio } from './pick-audio.mjs';
22
23
 
23
24
  const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
24
25
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
@@ -223,6 +224,33 @@ const commands = {
223
224
  } else console.log(json);
224
225
  },
225
226
 
227
+ // Música o efectos de Pixabay elegidos por la persona (src/pick-audio.mjs): abre la
228
+ // búsqueda en su navegador, espera su descarga y la copia al proyecto.
229
+ async 'pick-audio'(argv) {
230
+ const { pos, opts } = parseArgs(argv, { kind: 'str', query: 'str', order: 'str', count: 'str', timeout: 'str', downloads: 'str', open: 'bool', json: 'bool' });
231
+ const project = resolve(pos[0] ?? '.');
232
+ const kind = opts.kind === 'sfx' ? 'sfx' : 'music';
233
+ if (!opts.query || !existsSync(project)) {
234
+ fail('Usage: ignix-editor pick-audio [folder] --kind music|sfx --query "inspiring" [--order trending|latest] [--count 1] [--timeout 600] [--no-open] [--downloads <folder>] [--json]');
235
+ }
236
+ const r = await pickAudio({
237
+ project,
238
+ kind,
239
+ query: opts.query,
240
+ order: opts.order,
241
+ count: Math.max(1, Math.min(10, Number(opts.count ?? 1) || 1)),
242
+ timeoutSec: Math.max(30, Number(opts.timeout ?? 600) || 600),
243
+ downloads: opts.downloads,
244
+ open: opts.open !== false,
245
+ log,
246
+ });
247
+ if (opts.json) console.log(JSON.stringify(r, null, 1));
248
+ if (r.timedOut) {
249
+ log(r.picked.length ? `Only ${r.picked.length} downloaded before the time ran out.` : 'Nothing was downloaded. Ask the person whether they found something, then run this again.');
250
+ process.exitCode = 2;
251
+ }
252
+ },
253
+
226
254
  // El estilo de edición de alguien, medido en 2–3 vídeos suyos (ADR-0023 E3).
227
255
  async style(argv) {
228
256
  const [sub, ...rest] = argv;
@@ -356,10 +384,12 @@ const commands = {
356
384
  ignix-editor check [folder] HTML, assets, layout, motion and contrast
357
385
  ignix-editor snapshot [folder] --at 0,2.5,5 single frames to look at (into <folder>/snapshots)
358
386
  ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize] [--no-review]
359
- ignix-editor review <video.mp4> [--json] is it ready to show? stillness, empty frame, voice gaps, cuts, sound effects
387
+ ignix-editor review <video.mp4> [--json] is it ready to show? stillness, empty frame, voice gaps, cuts
360
388
  ignix-editor preview [folder] live preview in the browser
361
389
  ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
362
390
  ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
391
+ ignix-editor pick-audio [folder] --kind music|sfx --query "inspiring" [--order trending]
392
+ opens the Pixabay search in the browser, waits for the person's download, copies it into assets/
363
393
  ignix-editor style measure <video>… [--speech] [--stills dir] [-o style.json] someone's editing style, measured on 2–3 of their videos
364
394
  ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
365
395
  ignix-editor skills add installs the skill for Claude
@@ -0,0 +1,152 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { homedir, platform } from 'node:os';
4
+ import { basename, extname, join } from 'node:path';
5
+
6
+ // MÚSICA Y EFECTOS DE PIXABAY, ELEGIDOS POR LA PERSONA (2026-10-08, idea de Fran).
7
+ //
8
+ // Pixabay no tiene API de audio y su web no deja entrar a robots (Cloudflare devuelve 403
9
+ // a todo lo que no sea un navegador con una persona). Así que el agente NO busca ni
10
+ // descarga: abre la búsqueda en el navegador de la persona, ella escucha los avances y
11
+ // pulsa «Descargar» en el que le gusta, y esto lo recoge de su carpeta de Descargas y lo
12
+ // copia al proyecto. La licencia de Pixabay permite usarlo en redes; guardamos de dónde
13
+ // salió (nombre del fichero y búsqueda) porque es lo que Pixabay pide para responder a
14
+ // una reclamación de Content ID en YouTube.
15
+
16
+ const AUDIO = new Set(['.mp3', '.wav', '.m4a', '.ogg', '.aac', '.flac']);
17
+ /** Descargas a medias de Chrome, Firefox, Safari y Edge. */
18
+ const PARTIAL = /\.(crdownload|part|download|partial|tmp)$/i;
19
+
20
+ /** La búsqueda de Pixabay, como la usa una persona en su web. */
21
+ export function pixabayAudioUrl(kind, query, order) {
22
+ const base = kind === 'sfx' ? 'https://pixabay.com/sound-effects/search/' : 'https://pixabay.com/music/search/';
23
+ const q = encodeURIComponent(String(query ?? '').trim().replace(/\s+/g, ' ').slice(0, 80));
24
+ const o = order && ['trending', 'latest', 'ec'].includes(order) ? `?order=${order}` : '';
25
+ return `${base}${q}/${o}`;
26
+ }
27
+
28
+ /** La carpeta de Descargas de esta persona (la normal; `--downloads` para otra). */
29
+ export function downloadsDir(override) {
30
+ if (override) return override;
31
+ return join(homedir(), 'Downloads');
32
+ }
33
+
34
+ /** Abre la URL en el navegador de la persona. false si no se pudo (sandbox, sin escritorio). */
35
+ export function openInBrowser(url) {
36
+ try {
37
+ const os = platform();
38
+ const [cmd, args] =
39
+ os === 'darwin' ? ['open', [url]] : os === 'win32' ? ['cmd', ['/c', 'start', '""', url.replace(/&/g, '^&')]] : ['xdg-open', [url]];
40
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
41
+ child.on('error', () => {});
42
+ child.unref();
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /** Ficheros de audio terminados que han aparecido en `dir` después de `since` (ms). */
50
+ export function newAudioFiles(dir, since, seen = new Set()) {
51
+ let names = [];
52
+ try {
53
+ names = readdirSync(dir);
54
+ } catch {
55
+ return [];
56
+ }
57
+ const out = [];
58
+ for (const name of names) {
59
+ if (seen.has(name) || PARTIAL.test(name) || !AUDIO.has(extname(name).toLowerCase())) continue;
60
+ let st;
61
+ try {
62
+ st = statSync(join(dir, name));
63
+ } catch {
64
+ continue;
65
+ }
66
+ // birthtime no existe en todos los sistemas de ficheros: vale la última escritura.
67
+ const t = Math.max(st.mtimeMs, st.birthtimeMs || 0);
68
+ if (st.isFile() && st.size > 0 && t >= since) out.push({ name, path: join(dir, name), size: st.size });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
74
+
75
+ /** Espera a que el fichero deje de crecer (la descarga terminó de escribirse). */
76
+ async function settled(path) {
77
+ let last = -1;
78
+ for (let i = 0; i < 20; i++) {
79
+ let size = -1;
80
+ try {
81
+ size = statSync(path).size;
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (size > 0 && size === last) return true;
86
+ last = size;
87
+ await sleep(500);
88
+ }
89
+ return true;
90
+ }
91
+
92
+ const slug = (s) =>
93
+ s
94
+ .normalize('NFKD')
95
+ .replace(/[̀-ͯ]/g, '')
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, '-')
98
+ .replace(/^-|-$/g, '')
99
+ .slice(0, 60) || 'audio';
100
+
101
+ /**
102
+ * Abre la búsqueda y espera a que la persona descargue `count` ficheros; los copia a
103
+ * `<project>/assets/` y apunta su origen en `<project>/work/audio-sources.json`.
104
+ */
105
+ export async function pickAudio({ project, kind, query, order, count = 1, timeoutSec = 600, downloads, open = true, log = () => {} }) {
106
+ const url = pixabayAudioUrl(kind, query, order);
107
+ const dir = downloadsDir(downloads);
108
+ if (!existsSync(dir)) throw new Error(`Downloads folder not found: ${dir}. Pass --downloads <folder>.`);
109
+ const since = Date.now() - 1000;
110
+ const opened = open ? openInBrowser(url) : false;
111
+ log(opened ? `Opened in the browser: ${url}` : `Open this in the browser: ${url}`);
112
+ log(`Waiting for ${count === 1 ? 'a download' : `${count} downloads`} in ${dir} (up to ${Math.round(timeoutSec / 60)} min)…`);
113
+
114
+ const seen = new Set();
115
+ const picked = [];
116
+ const deadline = Date.now() + timeoutSec * 1000;
117
+ while (picked.length < count && Date.now() < deadline) {
118
+ for (const f of newAudioFiles(dir, since, seen)) {
119
+ seen.add(f.name);
120
+ if (!(await settled(f.path))) continue;
121
+ const assets = join(project, 'assets');
122
+ mkdirSync(assets, { recursive: true });
123
+ const ext = extname(f.name).toLowerCase();
124
+ let dest = join(assets, `${kind === 'sfx' ? 'sfx' : 'music'}-${slug(basename(f.name, ext))}${ext}`);
125
+ for (let n = 2; existsSync(dest); n++) dest = join(assets, `${kind === 'sfx' ? 'sfx' : 'music'}-${slug(basename(f.name, ext))}-${n}${ext}`);
126
+ copyFileSync(f.path, dest);
127
+ picked.push({ file: dest, originalName: f.name, bytes: f.size });
128
+ log(`✓ Got ${f.name} → ${dest}`);
129
+ if (picked.length >= count) break;
130
+ }
131
+ if (picked.length < count) await sleep(1000);
132
+ }
133
+
134
+ // De dónde salió cada uno: Pixabay pide conservarlo para disputar un Content ID.
135
+ if (picked.length) {
136
+ const work = join(project, 'work');
137
+ mkdirSync(work, { recursive: true });
138
+ const file = join(work, 'audio-sources.json');
139
+ let prev = [];
140
+ try {
141
+ prev = JSON.parse(readFileSync(file, 'utf8'));
142
+ } catch {
143
+ /* primera vez */
144
+ }
145
+ const now = new Date().toISOString();
146
+ writeFileSync(
147
+ file,
148
+ JSON.stringify([...prev, ...picked.map((p) => ({ source: 'pixabay', kind, search: url, originalName: p.originalName, file: p.file, downloadedAt: now }))], null, 2),
149
+ );
150
+ }
151
+ return { url, opened, picked, timedOut: picked.length < count };
152
+ }