@ignix-social/editor 0.1.16 → 0.1.18
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 +17 -0
- package/src/cli.mjs +62 -1
- package/src/giphy.mjs +136 -0
- package/src/pick-audio.mjs +152 -0
package/package.json
CHANGED
|
@@ -15,6 +15,8 @@ Commands. `ignix-editor` means the copy the Ignix playbook installed: `~/.ignix-
|
|
|
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
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. |
|
|
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). |
|
|
18
20
|
| `ignix-editor check [dir]` | HTML well-formed, no network assets, media rules, layout/contrast/motion audit. |
|
|
19
21
|
| `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. `[dir]` defaults to the current folder. |
|
|
20
22
|
| `ignix-editor render [dir] [-o out.mp4]` | Renders to `<dir>/renders/<dir>.mp4`, audio normalised to −14 LUFS. |
|
|
@@ -75,6 +77,21 @@ The renderer asks for frames independently (in parallel, out of order). If frame
|
|
|
75
77
|
- Use `transform` (translate/scale/rotate) and `opacity` for motion; animating `left/top/width/height/font-size` snaps to whole pixels and stutters in slow moves.
|
|
76
78
|
- Toggling `display`, `textContent` or a class from `t` is fine (typed text: `KEY.slice(0, n(t))`).
|
|
77
79
|
|
|
80
|
+
### GIPHY stickers (transparent, animated)
|
|
81
|
+
|
|
82
|
+
Never put an animated GIF/WebP in an `<img>`: it plays on the browser's clock, not the render's, and comes out jumpy. `ignix-editor giphy <sticker link>` gives a transparent sheet with every frame and prints `frames`, `cols`, `rows`, `fps` and the frame size. Show one cell at a time, chosen from `t`:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
// e.g. 51 frames, 8×7 sheet, 25 fps, 256×256 each; the element is 256×256 (scale it with transform)
|
|
86
|
+
const S = { frames: 51, cols: 8, rows: 7, fps: 25 };
|
|
87
|
+
sticker.style.background = `url(assets/giphy-sticker-ID-sheet.png) 0 0 / ${S.cols * 100}% ${S.rows * 100}% no-repeat`;
|
|
88
|
+
// inside run(): loops while it is on screen
|
|
89
|
+
const i = Math.floor(Math.max(0, t - start) * S.fps) % S.frames;
|
|
90
|
+
sticker.style.backgroundPosition = `${((i % S.cols) / (S.cols - 1)) * 100}% ${(Math.floor(i / S.cols) / (S.rows - 1)) * 100}%`;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
GIFs and clips come as MP4: use them like any `<video>` (a clip keeps its audio; mute it if the voice-over runs over it).
|
|
94
|
+
|
|
78
95
|
### Never measure the page while rendering
|
|
79
96
|
|
|
80
97
|
Do not call `getBoundingClientRect()`/`offsetLeft` to aim arrows, cursors or highlight boxes: fonts may not be ready and layout may differ per frame; arrows pointed 300 px off and vanished in a test because of this. Instead, give the recreated UI fixed coordinates and keep a table:
|
package/src/cli.mjs
CHANGED
|
@@ -19,6 +19,8 @@ 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';
|
|
23
|
+
import { importGiphy, openGiphySearch } from './giphy.mjs';
|
|
22
24
|
|
|
23
25
|
const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
26
|
const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
|
|
@@ -223,6 +225,61 @@ const commands = {
|
|
|
223
225
|
} else console.log(json);
|
|
224
226
|
},
|
|
225
227
|
|
|
228
|
+
// Música o efectos de Pixabay elegidos por la persona (src/pick-audio.mjs): abre la
|
|
229
|
+
// búsqueda en su navegador, espera su descarga y la copia al proyecto.
|
|
230
|
+
async 'pick-audio'(argv) {
|
|
231
|
+
const { pos, opts } = parseArgs(argv, { kind: 'str', query: 'str', order: 'str', count: 'str', timeout: 'str', downloads: 'str', open: 'bool', json: 'bool' });
|
|
232
|
+
const project = resolve(pos[0] ?? '.');
|
|
233
|
+
const kind = opts.kind === 'sfx' ? 'sfx' : 'music';
|
|
234
|
+
if (!opts.query || !existsSync(project)) {
|
|
235
|
+
fail('Usage: ignix-editor pick-audio [folder] --kind music|sfx --query "inspiring" [--order trending|latest] [--count 1] [--timeout 600] [--no-open] [--downloads <folder>] [--json]');
|
|
236
|
+
}
|
|
237
|
+
const r = await pickAudio({
|
|
238
|
+
project,
|
|
239
|
+
kind,
|
|
240
|
+
query: opts.query,
|
|
241
|
+
order: opts.order,
|
|
242
|
+
count: Math.max(1, Math.min(10, Number(opts.count ?? 1) || 1)),
|
|
243
|
+
timeoutSec: Math.max(30, Number(opts.timeout ?? 600) || 600),
|
|
244
|
+
downloads: opts.downloads,
|
|
245
|
+
open: opts.open !== false,
|
|
246
|
+
log,
|
|
247
|
+
});
|
|
248
|
+
if (opts.json) console.log(JSON.stringify(r, null, 1));
|
|
249
|
+
if (r.timedOut) {
|
|
250
|
+
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.');
|
|
251
|
+
process.exitCode = 2;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
// GIFs, stickers y clips de GIPHY elegidos por la persona (src/giphy.mjs): abre la
|
|
256
|
+
// búsqueda en su navegador, o trae el enlace que ha copiado.
|
|
257
|
+
async giphy(argv) {
|
|
258
|
+
const { pos, opts } = parseArgs(argv, { search: 'str', kind: 'str', open: 'bool', json: 'bool' });
|
|
259
|
+
const usage = 'Usage: ignix-editor giphy --search "thumbs up" [--kind gifs|stickers|clips] or ignix-editor giphy <link> [folder]';
|
|
260
|
+
if (opts.search) {
|
|
261
|
+
const kind = ['stickers', 'clips'].includes(opts.kind) ? opts.kind : 'gifs';
|
|
262
|
+
const r = opts.open === false ? { url: (await import('./giphy.mjs')).giphySearchUrl(opts.search, kind), opened: false } : openGiphySearch(opts.search, kind);
|
|
263
|
+
log(r.opened ? `Opened in the browser: ${r.url}` : `Open this in the browser: ${r.url}`);
|
|
264
|
+
log('Ask the person to copy the link of the one they like (Share → Copy link) and paste it here; then run: ignix-editor giphy <link> <folder>');
|
|
265
|
+
if (opts.json) console.log(JSON.stringify(r));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const link = pos[0];
|
|
269
|
+
if (!link) fail(usage);
|
|
270
|
+
const project = resolve(pos[1] ?? '.');
|
|
271
|
+
if (!existsSync(project)) fail(`Folder not found: ${project}`);
|
|
272
|
+
const forced = { gifs: 'gif', gif: 'gif', stickers: 'sticker', sticker: 'sticker', clips: 'clip', clip: 'clip' }[opts.kind];
|
|
273
|
+
const ffmpeg = await ensureTool('ffmpeg', log);
|
|
274
|
+
const ffprobe = await ensureTool('ffprobe', log);
|
|
275
|
+
const r = await importGiphy({ link, project, kind: forced, ffmpeg, ffprobe }).catch((e) => fail(e.message));
|
|
276
|
+
if (r.sheet) {
|
|
277
|
+
const sh = r.sheet;
|
|
278
|
+
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).`);
|
|
279
|
+
} else log(`✓ ${r.kind} → ${r.file}${r.hasAudio ? ' (with its audio)' : ''}`);
|
|
280
|
+
if (opts.json) console.log(JSON.stringify(r));
|
|
281
|
+
},
|
|
282
|
+
|
|
226
283
|
// El estilo de edición de alguien, medido en 2–3 vídeos suyos (ADR-0023 E3).
|
|
227
284
|
async style(argv) {
|
|
228
285
|
const [sub, ...rest] = argv;
|
|
@@ -356,10 +413,14 @@ const commands = {
|
|
|
356
413
|
ignix-editor check [folder] HTML, assets, layout, motion and contrast
|
|
357
414
|
ignix-editor snapshot [folder] --at 0,2.5,5 single frames to look at (into <folder>/snapshots)
|
|
358
415
|
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
|
|
416
|
+
ignix-editor review <video.mp4> [--json] is it ready to show? stillness, empty frame, voice gaps, cuts
|
|
360
417
|
ignix-editor preview [folder] live preview in the browser
|
|
361
418
|
ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
|
|
362
419
|
ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
|
|
420
|
+
ignix-editor pick-audio [folder] --kind music|sfx --query "inspiring" [--order trending]
|
|
421
|
+
opens the Pixabay search in the browser, waits for the person's download, copies it into assets/
|
|
422
|
+
ignix-editor giphy --search "thumbs up" [--kind gifs|stickers|clips] opens the GIPHY search in the browser
|
|
423
|
+
ignix-editor giphy <link> [folder] brings the GIF (mp4), sticker (transparent frame sheet) or clip (mp4 with audio) into assets/
|
|
363
424
|
ignix-editor style measure <video>… [--speech] [--stills dir] [-o style.json] someone's editing style, measured on 2–3 of their videos
|
|
364
425
|
ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
|
|
365
426
|
ignix-editor skills add installs the skill for Claude
|
package/src/giphy.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { openInBrowser } from './pick-audio.mjs';
|
|
7
|
+
|
|
8
|
+
// GIFs, STICKERS Y CLIPS DE GIPHY, ELEGIDOS POR LA PERSONA (2026-10-08, Fran).
|
|
9
|
+
//
|
|
10
|
+
// Sin la API de GIPHY (pide clave y revisión): la persona busca en giphy.com (esto le
|
|
11
|
+
// abre la búsqueda), copia el enlace del que le gusta y el agente lo trae con
|
|
12
|
+
// `ignix-editor giphy <enlace>`. Con el id del enlace, GIPHY sirve cada formato en
|
|
13
|
+
// media.giphy.com: el GIF como MP4 (mejor para vídeo), el sticker como WebP con
|
|
14
|
+
// transparencia (para superponerlo) y el clip como MP4 con su audio.
|
|
15
|
+
//
|
|
16
|
+
// El sticker NO va como imagen animada: un GIF/WebP en un <img> corre con el reloj del
|
|
17
|
+
// navegador, no con el del render (cada fotograma se pide suelto y fuera de orden), así
|
|
18
|
+
// que saldría a saltos. Va como TIRA de fotogramas en un PNG con transparencia
|
|
19
|
+
// (ffmpeg, filtro tile) que el vídeo recorre desde `t` (ver la guía, sección GIPHY).
|
|
20
|
+
|
|
21
|
+
const ID = /^[A-Za-z0-9]{6,40}$/;
|
|
22
|
+
|
|
23
|
+
/** El id y el tipo de un enlace de GIPHY (página, media, i.giphy o embed). null si no lo es. */
|
|
24
|
+
export function parseGiphyLink(raw) {
|
|
25
|
+
let u;
|
|
26
|
+
try {
|
|
27
|
+
u = new URL(String(raw ?? '').trim());
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const host = u.hostname.replace(/^www\./, '');
|
|
32
|
+
if (!/(^|\.)giphy\.com$/.test(host)) return null;
|
|
33
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
34
|
+
// giphy.com/gifs/<slug>-<id>, /stickers/<slug>-<id>, /clips/<slug>-<id>, /embed/<id>
|
|
35
|
+
if (host === 'giphy.com' && parts.length >= 2) {
|
|
36
|
+
const kind = { gifs: 'gif', stickers: 'sticker', clips: 'clip', embed: 'gif' }[parts[0]];
|
|
37
|
+
const last = parts[1];
|
|
38
|
+
const id = last.includes('-') ? last.slice(last.lastIndexOf('-') + 1) : last;
|
|
39
|
+
return kind && ID.test(id) ? { id, kind } : null;
|
|
40
|
+
}
|
|
41
|
+
// media*.giphy.com/media/(v1.xxx/)?<id>/<fichero>
|
|
42
|
+
const m = parts.indexOf('media');
|
|
43
|
+
if (m >= 0) {
|
|
44
|
+
const rest = parts.slice(m + 1);
|
|
45
|
+
const id = rest[0]?.startsWith('v1.') ? rest[1] : rest[0];
|
|
46
|
+
return id && ID.test(id) ? { id, kind: null } : null;
|
|
47
|
+
}
|
|
48
|
+
// i.giphy.com/<id>.webp
|
|
49
|
+
if (host === 'i.giphy.com' && parts[0]) {
|
|
50
|
+
const id = parts[0].replace(/\.[a-z0-9]+$/i, '');
|
|
51
|
+
return ID.test(id) ? { id, kind: null } : null;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** La búsqueda de GIPHY como la usa una persona en su web. */
|
|
57
|
+
export function giphySearchUrl(query, kind = 'gifs') {
|
|
58
|
+
const slug = String(query ?? '')
|
|
59
|
+
.trim()
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
62
|
+
.replace(/^-|-$/g, '')
|
|
63
|
+
.slice(0, 80);
|
|
64
|
+
const suffix = kind === 'stickers' ? '-stickers' : kind === 'clips' ? '-clips' : '';
|
|
65
|
+
return `https://giphy.com/search/${encodeURIComponent(slug).replace(/%2D/gi, '-')}${suffix}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Qué fichero traer de media.giphy.com para cada tipo (y el de reserva). */
|
|
69
|
+
export function giphyRenditions(kind) {
|
|
70
|
+
if (kind === 'clip') return [['giphy480p.mp4', 'mp4'], ['giphy.mp4', 'mp4']];
|
|
71
|
+
if (kind === 'sticker') return [['giphy.gif', 'gif']];
|
|
72
|
+
return [['giphy.mp4', 'mp4'], ['giphy.gif', 'gif']];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Trae el elegido a `<project>/assets/` y apunta su origen en `work/media-sources.json`. */
|
|
76
|
+
/**
|
|
77
|
+
* Un GIF con transparencia → una tira PNG (cols × rows fotogramas) + sus medidas.
|
|
78
|
+
* `ffmpeg`/`ffprobe` son los del Editor.
|
|
79
|
+
*/
|
|
80
|
+
export function gifToSheet({ ffmpeg, ffprobe, gif, out }) {
|
|
81
|
+
const pr = spawnSync(ffprobe, ['-v', 'error', '-count_frames', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,nb_read_frames,r_frame_rate:format=duration', '-of', 'json', gif], { encoding: 'utf8' });
|
|
82
|
+
const j = JSON.parse(pr.stdout || '{}');
|
|
83
|
+
const st = j.streams?.[0] ?? {};
|
|
84
|
+
const frames = Math.max(1, Number(st.nb_read_frames) || 1);
|
|
85
|
+
const durationSec = Number(j.format?.duration) || frames / 15;
|
|
86
|
+
const fps = Math.round((frames / durationSec) * 100) / 100;
|
|
87
|
+
// Rejilla casi cuadrada: una tira larga pasaría del tamaño máximo de textura de Chrome.
|
|
88
|
+
const cols = Math.ceil(Math.sqrt(frames));
|
|
89
|
+
const rows = Math.ceil(frames / cols);
|
|
90
|
+
const r = spawnSync(ffmpeg, ['-nostdin', '-y', '-v', 'error', '-i', gif, '-vf', `format=rgba,tile=${cols}x${rows}`, '-frames:v', '1', out], { encoding: 'utf8' });
|
|
91
|
+
if (r.status !== 0) throw new Error(`Could not turn the sticker into frames: ${(r.stderr || '').slice(-300)}`);
|
|
92
|
+
return { sheet: out, frames, cols, rows, fps, durationSec: Math.round(durationSec * 100) / 100, frameWidth: Number(st.width), frameHeight: Number(st.height) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function importGiphy({ link, project, kind: forced, ffmpeg, ffprobe }) {
|
|
96
|
+
const parsed = parseGiphyLink(link);
|
|
97
|
+
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';
|
|
99
|
+
const assets = join(project, 'assets');
|
|
100
|
+
mkdirSync(assets, { recursive: true });
|
|
101
|
+
let lastErr = null;
|
|
102
|
+
for (const [file, ext] of giphyRenditions(kind)) {
|
|
103
|
+
const url = `https://media.giphy.com/media/${parsed.id}/${file}`;
|
|
104
|
+
const res = await fetch(url).catch((e) => {
|
|
105
|
+
lastErr = e;
|
|
106
|
+
return null;
|
|
107
|
+
});
|
|
108
|
+
if (!res?.ok || !res.body) continue;
|
|
109
|
+
let dest = join(assets, `giphy-${kind}-${parsed.id}.${ext}`);
|
|
110
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
|
|
111
|
+
let sheet = null;
|
|
112
|
+
if (kind === 'sticker' && ffmpeg && ffprobe) {
|
|
113
|
+
sheet = gifToSheet({ ffmpeg, ffprobe, gif: dest, out: join(assets, `giphy-sticker-${parsed.id}-sheet.png`) });
|
|
114
|
+
rmSync(dest, { force: true });
|
|
115
|
+
dest = sheet.sheet;
|
|
116
|
+
}
|
|
117
|
+
const work = join(project, 'work');
|
|
118
|
+
mkdirSync(work, { recursive: true });
|
|
119
|
+
const log = join(work, 'media-sources.json');
|
|
120
|
+
let prev = [];
|
|
121
|
+
try {
|
|
122
|
+
prev = existsSync(log) ? JSON.parse(readFileSync(log, 'utf8')) : [];
|
|
123
|
+
} catch {
|
|
124
|
+
/* fichero roto: se empieza de nuevo */
|
|
125
|
+
}
|
|
126
|
+
writeFileSync(log, JSON.stringify([...prev, { source: 'giphy', kind, id: parsed.id, link: String(link), file: dest, at: new Date().toISOString() }], null, 2));
|
|
127
|
+
return { id: parsed.id, kind, file: dest, hasAudio: kind === 'clip' && file.includes('480p'), ...(sheet ? { sheet } : {}) };
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Could not download that ${kind} from GIPHY${lastErr ? `: ${lastErr.message}` : ''}.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Abre la búsqueda en el navegador de la persona. */
|
|
133
|
+
export function openGiphySearch(query, kind) {
|
|
134
|
+
const url = giphySearchUrl(query, kind);
|
|
135
|
+
return { url, opened: openInBrowser(url) };
|
|
136
|
+
}
|
|
@@ -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
|
+
}
|