@ignix-social/editor 0.1.27 → 0.1.29

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/NOTICE CHANGED
@@ -97,3 +97,5 @@ Inter (font, from @fontsource/inter) — SIL Open Font License 1.1, Copyright (c
97
97
 
98
98
  Ignix Creator's composition templates (src/creator) were ported from Ignix Creator's own
99
99
  code (Copyright (c) 2026 Ignix); no Remotion code is included.
100
+
101
+ Reference downloads use official standalone yt-dlp release binaries (https://github.com/yt-dlp/yt-dlp), downloaded separately and checked against release SHA-256 checksums. yt-dlp source is Unlicense; standalone executables include third-party dependencies with their own license notices, available with the official release/source distribution. These executables are not included in this npm package.
package/README.md CHANGED
@@ -2,17 +2,20 @@
2
2
 
3
3
  Render videos from HTML on your own computer: reels made from scratch, edits of your recorded videos and clips from long ones. It is the video engine behind Ignix's reel and edit playbooks for AI assistants (Claude, ChatGPT) and behind Ignix Creator.
4
4
 
5
+ Ignix Editor is for Ignix customers: it needs an active [Ignix plan](https://ignix.social/pricing). Your AI assistant gets a short-lived license (72 h) from the Ignix MCP tool `get_editor_license` and saves it with `ignix-editor license <token>`; the license is checked on your machine, offline.
6
+
5
7
  ```sh
6
- npx @ignix-social/editor doctor # checks Node 22+, ffmpeg, whisper and the H.264 encoder
8
+ npx @ignix-social/editor license <token> # saves the license from the Ignix MCP
9
+ npx @ignix-social/editor doctor # checks Node 22+, ffmpeg, whisper, yt-dlp and the H.264 encoder
7
10
  npx @ignix-social/editor init my-reel # creates ~/ignix-videos/my-reel
8
11
  npx @ignix-social/editor render ~/ignix-videos/my-reel
9
12
  ```
10
13
 
11
- Commands: `doctor`, `init`, `check`, `snapshot`, `render`, `preview`, `transcribe`, `faces`, `ffmpeg`, `ffprobe`, `skills add`, `fetch`, `serve`, `creator-job`, `help`.
14
+ Commands: `license`, `doctor`, `init`, `check`, `snapshot`, `render`, `preview`, `download`, `transcribe`, `faces`, `ffmpeg`, `ffprobe`, `skills add`, `fetch`, `serve`, `creator-job`, `help`.
12
15
 
13
16
  - Everything runs locally; your footage never leaves your machine.
14
17
  - ffmpeg (GPL build, without libx264) and whisper.cpp are downloaded once per machine into `~/.ignix-editor`, checked against their SHA-256. Chrome for rendering is downloaded once from Google's official channel.
15
- - No telemetry and no automatic updates: the version you install is the version that runs.
18
+ - No telemetry and no automatic updates: the version you install is the version that runs. At most once a day it asks ignix.social which version is recommended (a plain request, nothing about you or your projects is sent) and, if yours is older, prints the command to update; it never updates by itself. Turn the check off with `IGNIX_EDITOR_NO_UPDATE_CHECK=1`.
16
19
  - The final audio is normalised to −14 LUFS.
17
20
  - Analysis (`faces`, and `serve`, the local service Ignix Creator talks to: shots, people, faces, burned-in text, voice clean-up, transcription) runs on ONNX Runtime with open models (YOLOX, YuNet, PP-OCR, CLIP), downloaded the first time they are needed and checked against their SHA-256.
18
21
  - `ignix-editor skills add` installs the `ignix-editor` skill for Claude.
@@ -22,3 +25,5 @@ Requires Node.js 22 or newer. Runs on macOS (Intel and Apple Silicon) and Window
22
25
  ## License
23
26
 
24
27
  Apache-2.0. Ignix Editor is based on [HyperFrames](https://github.com/heygen-com/hyperframes) by HeyGen, Inc. (Apache-2.0), included in `vendor/hyperframes` with the modifications listed in `patches/`. See `NOTICE`. Ignix Editor is not affiliated with or endorsed by HeyGen.
28
+
29
+ Public references: `ignix-editor download <URL> -o work/ref.mp4` uses a pinned, SHA-256-checked official yt-dlp binary and bundled FFmpeg/Node, without browser cookies or user downloader configuration. Reuse the transcript supplied by Ignix; run local Whisper if it is absent or needs checking. Inspect frames for visual structure and names. Downloaded source footage is study material, not a render asset.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignix-social/editor",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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,11 +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
+ **License first.** Ignix Editor needs an active Ignix plan. Before the first command of a session, call the Ignix MCP tool `get_editor_license` and run `ignix-editor license <token>` with what it returns. It lasts 72 h; when a command says the license is missing or expired, renew it the same way. If the Ignix MCP says the plan is not active (or `get_editor_license` refuses), STOP: do not render or keep working with Ignix Editor and do not look for workarounds. Tell the user their Ignix plan is not active and that they can reactivate it at https://ignix.social/app/billing.
11
+
10
12
  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
13
 
12
14
  | Command | What it does |
13
15
  |---|---|
14
- | `ignix-editor doctor` | Node 22+ and the H.264 encoder; downloads its own ffmpeg and whisper the first time. Run it first. |
16
+ | `ignix-editor license <token>` | Saves the license from the Ignix MCP (`get_editor_license`). Without a token, shows whether there is a valid one and until when. |
17
+ | `ignix-editor download <URL> -o work/ref.mp4` | Downloads a public Instagram, TikTok or YouTube reference with bundled yt-dlp, FFmpeg and Node. No cookies. Keep it in `work/`, never `assets/`. Reuse the transcript supplied by Ignix; use local Whisper if absent. If downloading fails, use that saved speech transcript and disclose that visuals are unverified. |
18
+ | `ignix-editor doctor` | Node 22+ and the H.264 encoder; downloads its own ffmpeg, whisper and yt-dlp the first time. Run it first. |
15
19
  | `ignix-editor init <name>` | Creates `~/ignix-videos/<name>` with a working skeleton, GSAP, local fonts and `ignix-frame.js`. |
16
20
  | `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
21
  | `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. |
@@ -44,13 +48,13 @@ The engine reads `<video>`/`<audio>` from the HTML file before the page runs. Me
44
48
  ```html
45
49
  <video id="cut1" src="assets/take.mp4" muted playsinline data-start="0" data-duration="2.9667" data-media-start="0.1" data-track-index="0"></video>
46
50
  <audio id="vo1" src="assets/vo/l1.wav" data-start="0" data-duration="3.64" data-track-index="10" data-volume="1"></audio>
47
- <audio id="sfx3" src="assets/sfx/click.mp3" data-start="4.88" data-duration="0.4" data-track-index="23" data-volume="0.45"></audio>
51
+ <audio id="sfx3" src="assets/sfx/click.mp3" data-start="4.88" data-duration="0.4" data-track-index="23" data-volume="0.8"></audio>
48
52
  ```
49
53
 
50
54
  - `data-start` = when it appears in the video; `data-duration` = how long; `data-media-start` = where in the source file it starts (trim in). One `<video>` per kept piece of a recording; the same source file can be used many times.
51
55
  - Every `<video>`/`<audio>` has a unique `id`. An `<audio>` without id is silently dropped from the mix.
52
56
  - Footage you show without its sound: `muted`. A video whose own sound you keep: `data-has-audio="true"` and no `muted`.
53
- - Give each sound effect its own `data-track-index` (overlapping sounds on one track get a warning).
57
+ - Give each sound effect its own `data-track-index` (overlapping sounds on one track get a warning). Effects must be heard: `data-volume` 0.7–1 for the Ignix library effects (already level-matched); below 0.6 a short click or pop disappears under the voice and the music. Never louder than the voice.
54
58
  - Never put `data-start` on BOTH a video and an element that contains it: time the video, and put a plain (untimed) wrapper around it for zoom/crop/position.
55
59
  - Never add `crossorigin` to media. Never call `play()`, `pause()` or set `currentTime`: the engine owns playback.
56
60
  - Music ducks BY ITSELF under the voice at check/render: give the music `<audio>` the id `music` (or `data-role="music"`) and set its `data-volume` to its level when nobody talks; it drops about 13 dB under every voice line (`vo1`, `voice2`… or `data-role="voice"`) and under any `<video>` that keeps its own sound, and comes back up in pauses longer than 0.6 s. Do not draw that by hand. `data-duck="off"` on the music keeps it flat.
@@ -270,6 +274,10 @@ run({ id, duration, fps }, (t) => O.draw(t));
270
274
  - Captions: groups of 2–3 words that never cross the end of a line; current word in the brand accent; font size shrinks for long groups so they fit the ~820 px column. Keep a real gap between words (`gap: 0 0.3em` on the flex row): if the active word grows (`scale`), it eats a smaller gap and «EDITORES DE» reads «EDITORESDE» — grow it at most ~5 %, or only change its colour.
271
275
  - The final mix is normalised to −14 LUFS by `render` (`--lufs` to change, `--no-normalize` to skip).
272
276
 
277
+ ### A reel modelled on a reference
278
+
279
+ When the user brings a reel they want theirs to look like, study it; never use it. Download it first with `ignix-editor download <URL> -o work/ref.mp4`. Read `inspiredBy.transcript` from the Ignix playbook when supplied: the app already obtained it, so the agent never calls APISmith. Reuse that speech evidence; local Whisper is for missing transcripts or recognition errors. If downloading fails, you can still adapt the saved speech argument, but say that visuals are unverified and keep the visual mapping provisional. If neither video nor transcript is available, ask for the file before proposing a script. Verify named recommendations against frames when available and preserve each reason; do not replace a comparison followed by specific recommendations with generic categories. Show the original preview or representative frames, then its sequence and the adaptation mapping before the script. Keep it in `work/` (never `assets/`), tile its frames (`ignix-editor ffmpeg -i work/ref.mp4 -vf "fps=2,scale=240:-1,tile=6x4" work/ref-%02d.jpg`) and transcribe it to read its layout, its beats and their timing, and the moments that carry it. Then rebuild that structure with your own pieces: the layout in CSS (e.g. a split screen with graphics in the top third, the presenter's video below and short captions on the seam), each beat's visual drawn for this brand, the key moment recreated with what you have (a gesture clip, cards fanning out, a reveal). None of it reaches the render: not its footage, music, words or branding. In the review, compare your stills with its contact sheet beat by beat.
280
+
273
281
  ## 5. Generate the HTML from data
274
282
 
275
283
  When there are many clips, lines or cues, keep them in data and let a script write the media tags and the timing file:
package/src/binaries.json CHANGED
@@ -20,6 +20,13 @@
20
20
  "size": 2912480,
21
21
  "sha256": "73fc58436c1e1a9d2cc3ea08bddf18bf78270d8c6940c5fd89a3c9cb31535efe",
22
22
  "build": "whisper.cpp v1.7.6, scripts/build-whisper-macos.sh"
23
+ },
24
+ "ytdlp": {
25
+ "file": "yt-dlp_macos",
26
+ "size": 37146048,
27
+ "sha256": "0f192b7ec147ab6288885d6351d9ab67367640029b4377576ef46dd79cf7b202",
28
+ "url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_macos",
29
+ "build": "yt-dlp 2026.08.19 official standalone; SHA2-256SUMS verified"
23
30
  }
24
31
  },
25
32
  "darwin-arm64": {
@@ -40,6 +47,13 @@
40
47
  "size": 2573144,
41
48
  "sha256": "215d93a2edc77f8929311f127f368727859e13833e925652315ca5ea774595ee",
42
49
  "build": "whisper.cpp v1.7.6, CI binaries.yml (macos-15, run 37375441240)"
50
+ },
51
+ "ytdlp": {
52
+ "file": "yt-dlp_macos",
53
+ "size": 37146048,
54
+ "sha256": "0f192b7ec147ab6288885d6351d9ab67367640029b4377576ef46dd79cf7b202",
55
+ "url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_macos",
56
+ "build": "yt-dlp 2026.08.19 official standalone; SHA2-256SUMS verified"
43
57
  }
44
58
  },
45
59
  "win32-x64": {
@@ -59,7 +73,32 @@
59
73
  "file": "whisper-cli.exe",
60
74
  "size": 1990144,
61
75
  "sha256": "26cef7a9b82544e4076a4977c93635bb1b324bd5ab3b4301d6d06e8224e580ea",
62
- "build": "whisper.cpp v1.7.6, MSVC estático sin OpenMP, CI windows.yml (run 37392626795)"
76
+ "build": "whisper.cpp v1.7.6, MSVC est\u00e1tico sin OpenMP, CI windows.yml (run 37392626795)"
77
+ },
78
+ "ytdlp": {
79
+ "file": "yt-dlp.exe",
80
+ "size": 17840399,
81
+ "sha256": "66674953fe251b89f4d08c5f0e35e0728679bd67ab3d7d05c0562af101dd3e7a",
82
+ "url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp.exe",
83
+ "build": "yt-dlp 2026.08.19 official standalone; SHA2-256SUMS verified"
84
+ }
85
+ },
86
+ "linux-x64": {
87
+ "ytdlp": {
88
+ "file": "yt-dlp_linux",
89
+ "size": 40446224,
90
+ "sha256": "58162f9bfdc27458ea47bfcb311cf47028f17d8154a8bf7d689861d46399230a",
91
+ "url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_linux",
92
+ "build": "yt-dlp 2026.08.19 official standalone; SHA2-256SUMS verified"
93
+ }
94
+ },
95
+ "linux-arm64": {
96
+ "ytdlp": {
97
+ "file": "yt-dlp_linux_aarch64",
98
+ "size": 40167448,
99
+ "sha256": "b16e4dab368a816cd05d477d698a605a6ae87ccee1c8ffd38fa21d7254141fcc",
100
+ "url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_linux_aarch64",
101
+ "build": "yt-dlp 2026.08.19 official standalone; SHA2-256SUMS verified"
63
102
  }
64
103
  }
65
104
  },
package/src/binaries.mjs CHANGED
@@ -17,6 +17,7 @@ const EXE = process.platform === 'win32' ? '.exe' : '';
17
17
  const manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'binaries.json'), 'utf8'));
18
18
 
19
19
  export const TOOLS = {
20
+ ytdlp: { env: 'IGNIX_EDITOR_YTDLP', file: `yt-dlp${EXE}` },
20
21
  ffmpeg: { env: 'IGNIX_EDITOR_FFMPEG', file: `ffmpeg${EXE}` },
21
22
  ffprobe: { env: 'IGNIX_EDITOR_FFPROBE', file: `ffprobe${EXE}` },
22
23
  whisper: { env: 'IGNIX_EDITOR_WHISPER', file: `whisper-cli${EXE}` },
@@ -44,7 +45,7 @@ export async function ensureTool(name, log = () => {}) {
44
45
  if (!entry) {
45
46
  throw new Error(`${name} is not available for ${PLATFORM} yet. Meanwhile, point ${TOOLS[name].env} to a local ${TOOLS[name].file}.`);
46
47
  }
47
- const url = `${manifest.baseUrl}/${manifest.version}/${PLATFORM}/${entry.file}`;
48
+ const url = entry.url ?? `${manifest.baseUrl}/${manifest.version}/${PLATFORM}/${entry.file}`;
48
49
  log(`Downloading ${name} (${(entry.size / 1e6).toFixed(0)} MB)…`);
49
50
  const res = await fetch(url);
50
51
  if (!res.ok) throw new Error(`Could not download ${name}: ${res.status} ${url}`);
package/src/cli.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { downloadReference } from './reference-download.mjs';
1
2
  // ignix-editor — CLI de Ignix Editor. El motor (HyperFrames, en vendor/) corre siempre por
2
3
  // debajo con nuestro entorno: sin telemetría, sin actualizaciones solas, con nuestros binarios.
3
4
  // Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
@@ -23,6 +24,7 @@ import { reviewVideo } from './analysis/review.mjs';
23
24
  import { pickAudio } from './pick-audio.mjs';
24
25
  import { importGiphy, openGiphySearch } from './giphy.mjs';
25
26
  import { warnIfOutdated } from './update-check.mjs';
27
+ import { LICENSE_ENV, LICENSE_FILE, licenseMessage, licenseStatus, requireLicense, saveLicense } from './license.mjs';
26
28
 
27
29
  const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
28
30
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
@@ -214,6 +216,14 @@ const commands = {
214
216
  process.exit(runEngine(['preview', build, ...rest], env, dir));
215
217
  },
216
218
 
219
+ async download(argv) {
220
+ const { pos, opts } = parseArgs(argv, { output: 'str' });
221
+ if (pos.length !== 1 || !opts.output) fail('Usage: ignix-editor download <Instagram|TikTok|YouTube URL> -o work/ref.mp4');
222
+ const ytdlp = await ensureTool('ytdlp', log);
223
+ const ffmpeg = await ensureTool('ffmpeg', log);
224
+ console.log(downloadReference({ url: pos[0], output: opts.output, ytdlp, ffmpeg }));
225
+ },
226
+
217
227
  async transcribe(argv) {
218
228
  const { pos, opts } = parseArgs(argv, { lang: 'str', model: 'str', script: 'str', output: 'str', threads: 'str' });
219
229
  const input = pos[0] && resolve(pos[0]);
@@ -433,6 +443,27 @@ const commands = {
433
443
  }
434
444
  },
435
445
 
446
+ // Licencia de Ignix (src/license.mjs). Con token: la comprueba y la guarda. Sin nada: dice
447
+ // si hay una válida y hasta cuándo (código 1 si no la hay, para que el agente lo vea).
448
+ async license(argv) {
449
+ const { pos } = parseArgs(argv, {});
450
+ const when = (exp) => new Date(exp * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
451
+ if (pos[0]) {
452
+ const r = saveLicense(pos[0]);
453
+ console.log(`✓ License valid until ${when(r.exp)}`);
454
+ if (process.env[LICENSE_ENV]) log(`• ${LICENSE_ENV} is set in this environment and takes precedence over the saved license.`);
455
+ return;
456
+ }
457
+ const st = licenseStatus();
458
+ if (st.ok) {
459
+ console.log(`✓ License valid until ${when(st.exp)} (${st.source === 'env' ? LICENSE_ENV : LICENSE_FILE})`);
460
+ return;
461
+ }
462
+ if (st.reason === 'expired') log(`✗ The license expired on ${when(st.exp)}.`);
463
+ log(`✗ ${licenseMessage(st.reason)}`);
464
+ process.exitCode = 1;
465
+ },
466
+
436
467
  async version() {
437
468
  console.log(`ignix-editor ${PKG.version} (engine ${UPSTREAM.version})`);
438
469
  },
@@ -440,13 +471,14 @@ const commands = {
440
471
  async help() {
441
472
  console.log(`Ignix Editor ${PKG.version} — video from HTML, rendered on your own computer.
442
473
 
443
- ignix-editor doctor checks Node and the encoder, downloads ffmpeg and whisper if missing
474
+ ignix-editor doctor checks Node and the encoder, downloads ffmpeg, whisper and yt-dlp if missing
444
475
  ignix-editor init <name> creates ~/ignix-videos/<name>, ready to work in
445
476
  ignix-editor check [folder] HTML, assets, layout, motion and contrast
446
477
  ignix-editor snapshot [folder] --at 0,2.5,5 single frames to look at (into <folder>/snapshots)
447
478
  ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize] [--no-review]
448
479
  ignix-editor review <video.mp4> [--json] is it ready to show? stillness, empty frame, voice gaps, cuts
449
480
  ignix-editor preview [folder] live preview in the browser
481
+ ignix-editor download <URL> -o work/ref.mp4 downloads a public reference with yt-dlp (no cookies)
450
482
  ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
451
483
  ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
452
484
  ignix-editor pick-audio [folder] --kind music|sfx --query "inspiring" [--order trending]
@@ -457,10 +489,12 @@ const commands = {
457
489
  ignix-editor style measure <video>… [--speech] [--stills dir] [-o style.json] someone's editing style, measured on 2–3 of their videos
458
490
  ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
459
491
  ignix-editor skills add installs the skill for Claude
492
+ ignix-editor license [<token>] saves the license from the Ignix MCP (get_editor_license), or shows it
460
493
  ignix-editor fetch [--whisper small] [--no-analysis] downloads everything up front (each file SHA-256-checked)
461
494
  ignix-editor serve local service for Ignix Creator (JSON lines on stdin/stdout)
462
495
 
463
- The folder defaults to the current one. Open source (Apache-2.0); credits in NOTICE.`);
496
+ The folder defaults to the current one. Requires an active Ignix plan (license from the Ignix MCP).
497
+ Open source (Apache-2.0); credits in NOTICE.`);
464
498
  },
465
499
  };
466
500
 
@@ -470,10 +504,25 @@ function printReview(rv, out = log) {
470
504
  if (rv.ready && rv.issues.length) out(' (! = worth fixing if it is quick.)');
471
505
  }
472
506
 
507
+ // Comandos que funcionan SIN licencia de Ignix; todos los demás (y los que se añadan) la piden.
508
+ // - help, version, license, skills add: para ponerse en marcha y pedir/guardar la licencia.
509
+ // - doctor, fetch: solo comprueban la máquina y descargan binarios/modelos una vez.
510
+ // - serve, creator-job: los usa Ignix Creator, que lleva este paquete dentro y comprueba el
511
+ // plan él mismo (ADR-0023); aquí no se piden para no romper sus renders ni su análisis.
512
+ const NO_LICENSE = new Set(['help', 'version', 'license', 'skills', 'doctor', 'fetch', 'serve', 'creator-job']);
513
+
473
514
  export async function main(argv) {
474
515
  const [cmd = 'help', ...rest] = argv;
475
- const fn = commands[cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd];
516
+ const name = cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd;
517
+ const fn = Object.hasOwn(commands, name) ? commands[name] : null;
476
518
  if (!fn) fail(`Unknown command: ${cmd}. See: ignix-editor help`);
519
+ if (!NO_LICENSE.has(name)) {
520
+ try {
521
+ requireLicense();
522
+ } catch (e) {
523
+ fail(e.message);
524
+ }
525
+ }
477
526
  // Aviso de versión nueva (src/update-check.mjs): no en `help`/`--version`, que el
478
527
  // agente usa justo para comprobar qué tiene.
479
528
  if (!['help', 'version', '--version', '-v', '--help', '-h'].includes(cmd)) {
package/src/fetch.mjs CHANGED
@@ -11,7 +11,7 @@ export async function fetchAll({ whisper = 'small', analysis = true, json = fals
11
11
  const emit = (e) => json && process.stdout.write(`${JSON.stringify(e)}\n`);
12
12
  if (!WHISPER_MODELS[whisper]) throw Object.assign(new Error(`Unknown model: ${whisper}`), { code: 'invalid_args' });
13
13
  const todo = [];
14
- for (const name of ['ffmpeg', 'ffprobe', 'whisper']) {
14
+ for (const name of ['ffmpeg', 'ffprobe', 'whisper', 'ytdlp']) {
15
15
  if (!findTool(name).path) todo.push({ item: name, size: toolSize(name), run: () => ensureTool(name, log) });
16
16
  }
17
17
  if (!existsSync(whisperModelPath(whisper))) todo.push({ item: `whisper-${whisper}`, size: WHISPER_MODELS[whisper].size, run: () => ensureWhisperModel(whisper, log) });
@@ -0,0 +1,126 @@
1
+ // LICENCIA DE USO (2026-10-09, Fran: Ignix Editor solo para clientes de Ignix con plan activo).
2
+ //
3
+ // El asistente pide una licencia corta (72 h) a la herramienta del MCP de Ignix
4
+ // `get_editor_license`, que solo la emite con el plan activo, y la guarda con
5
+ // `ignix-editor license <token>`. Aquí solo se COMPRUEBA, sin red: la firma Ed25519 de
6
+ // Ignix y la caducidad. Renovarla es volver a pedirla al MCP.
7
+ //
8
+ // Token: `<payloadB64url>.<firmaB64url>`. El payload es JSON UTF-8
9
+ // {"v":1,"sub":"<hash opaco de la org>","iat":<s unix>,"exp":<s unix>} y la firma va sobre
10
+ // los bytes EXACTOS del payload decodificado.
11
+ //
12
+ // Dónde: `IGNIX_EDITOR_LICENSE` (variable de entorno) manda sobre el fichero
13
+ // ~/.ignix-editor/license (la misma carpeta base que los binarios, src/binaries.mjs).
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { createPublicKey, verify } from 'node:crypto';
16
+ import { join } from 'node:path';
17
+ import { HOME } from './binaries.mjs';
18
+
19
+ // Clave pública de Ignix (SPKI DER en base64). La privada vive solo en el servidor de Ignix.
20
+ const PUBLIC_KEY_SPKI = 'MCowBQYDK2VwAyEAFwuR7qOsial9svJLSIIzeAKCy/y7gWr4MRSP8UKlAg4=';
21
+
22
+ export const LICENSE_FILE = join(HOME, 'license');
23
+ export const LICENSE_ENV = 'IGNIX_EDITOR_LICENSE';
24
+
25
+ export const MSG_NEEDS_PLAN =
26
+ 'Ignix Editor needs an active Ignix plan. Ask your assistant to get a license with the Ignix MCP tool get_editor_license and run: ignix-editor license <token>. Plans: https://ignix.social/pricing';
27
+ export const MSG_INVALID = 'That license is not valid. Get a new one with the Ignix MCP tool get_editor_license.';
28
+
29
+ const toKey = (k) => (typeof k === 'string' ? createPublicKey({ key: Buffer.from(k, 'base64'), format: 'der', type: 'spki' }) : k);
30
+
31
+ // Solo para los tests: `node --test` pone NODE_TEST_CONTEXT en el proceso de cada fichero de
32
+ // test (y lo heredan los procesos que lanza), y entonces IGNIX_EDITOR_LICENSE_PUBKEY_TEST
33
+ // (SPKI DER en base64 de una clave generada en el propio test) sustituye a la de Ignix, para
34
+ // poder probar la CLI con licencias firmadas allí. Fuera de `node --test` se ignora.
35
+ function defaultKey() {
36
+ const testKey = process.env.NODE_TEST_CONTEXT && process.env.IGNIX_EDITOR_LICENSE_PUBKEY_TEST;
37
+ return toKey(testKey || PUBLIC_KEY_SPKI);
38
+ }
39
+
40
+ const B64URL = /^[A-Za-z0-9_-]+$/;
41
+
42
+ /** Separa el token. null si no tiene la forma `<payload>.<firma>` con un payload JSON. */
43
+ export function parseToken(token) {
44
+ const parts = String(token ?? '').trim().split('.');
45
+ if (parts.length !== 2 || !parts.every((p) => B64URL.test(p))) return null;
46
+ const payloadBytes = Buffer.from(parts[0], 'base64url');
47
+ const sig = Buffer.from(parts[1], 'base64url');
48
+ let claims;
49
+ try {
50
+ claims = JSON.parse(payloadBytes.toString('utf8'));
51
+ } catch {
52
+ return null;
53
+ }
54
+ if (!claims || typeof claims !== 'object' || Array.isArray(claims)) return null;
55
+ return { payloadBytes, sig, claims };
56
+ }
57
+
58
+ /**
59
+ * ¿Vale la licencia? `now` en milisegundos (Date.now()); `publicKey` = KeyObject o SPKI DER en
60
+ * base64 (por defecto, la de Ignix). → {ok:true, exp} o {ok:false, reason}.
61
+ */
62
+ export function verifyToken(token, now = Date.now(), publicKey = defaultKey()) {
63
+ if (!token || !String(token).trim()) return { ok: false, reason: 'missing' };
64
+ const parsed = parseToken(token);
65
+ if (!parsed) return { ok: false, reason: 'malformed' };
66
+ let signed = false;
67
+ try {
68
+ signed = verify(null, parsed.payloadBytes, toKey(publicKey), parsed.sig);
69
+ } catch {
70
+ signed = false;
71
+ }
72
+ if (!signed) return { ok: false, reason: 'bad_signature' };
73
+ const { v, exp } = parsed.claims;
74
+ if (v !== 1) return { ok: false, reason: 'unsupported' };
75
+ if (!Number.isFinite(exp)) return { ok: false, reason: 'malformed' };
76
+ if (exp * 1000 <= now) return { ok: false, reason: 'expired', exp };
77
+ return { ok: true, exp };
78
+ }
79
+
80
+ /** La licencia guardada: primero la variable de entorno, después el fichero. */
81
+ export function readLicense() {
82
+ const fromEnv = process.env[LICENSE_ENV];
83
+ if (fromEnv && fromEnv.trim()) return { token: fromEnv.trim(), source: 'env' };
84
+ if (existsSync(LICENSE_FILE)) {
85
+ const token = readFileSync(LICENSE_FILE, 'utf8').trim();
86
+ if (token) return { token, source: 'file' };
87
+ }
88
+ return { token: null, source: null };
89
+ }
90
+
91
+ /** Estado de la licencia guardada, con su origen ('env' | 'file' | null). */
92
+ export function licenseStatus(now = Date.now()) {
93
+ const { token, source } = readLicense();
94
+ return { ...verifyToken(token, now), source };
95
+ }
96
+
97
+ /** El texto para la persona/el agente según el motivo del rechazo. */
98
+ export function licenseMessage(reason) {
99
+ return reason === 'malformed' || reason === 'bad_signature' ? MSG_INVALID : MSG_NEEDS_PLAN;
100
+ }
101
+
102
+ /** Comprueba y guarda la licencia (solo legible por el usuario). Lanza si no vale. */
103
+ export function saveLicense(token, now = Date.now()) {
104
+ const clean = String(token ?? '').trim();
105
+ const r = verifyToken(clean, now);
106
+ if (!r.ok) {
107
+ const e = new Error(licenseMessage(r.reason));
108
+ e.code = 'license';
109
+ e.reason = r.reason;
110
+ throw e;
111
+ }
112
+ mkdirSync(HOME, { recursive: true });
113
+ writeFileSync(LICENSE_FILE, `${clean}\n`, { mode: 0o600 });
114
+ chmodSync(LICENSE_FILE, 0o600); // `mode` solo cuenta al crear: si ya existía, se ajusta aquí
115
+ return r;
116
+ }
117
+
118
+ /** Corta con un error claro si no hay una licencia válida. Devuelve {ok, exp, source}. */
119
+ export function requireLicense(now = Date.now()) {
120
+ const st = licenseStatus(now);
121
+ if (st.ok) return st;
122
+ const e = new Error(licenseMessage(st.reason));
123
+ e.code = 'license';
124
+ e.reason = st.reason;
125
+ throw e;
126
+ }
@@ -0,0 +1,42 @@
1
+ // Reference study only: these files belong in work/, never in render assets.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+
6
+ export function referenceUrl(raw) {
7
+ let u;
8
+ try { u = new URL(raw); } catch { throw new Error('Expected an Instagram, TikTok or YouTube video URL.'); }
9
+ const host = u.hostname.toLowerCase();
10
+ const allowed = ['instagram.com', 'www.instagram.com', 'youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', 'tiktok.com', 'www.tiktok.com', 'vm.tiktok.com', 'vt.tiktok.com'];
11
+ if (u.protocol !== 'https:' || !allowed.includes(host) || u.username || u.password || u.port) throw new Error('Expected an HTTPS Instagram, TikTok or YouTube video URL.');
12
+ return u.href;
13
+ }
14
+
15
+ export function downloadArgs({ url, output, ffmpeg, node = process.execPath }) {
16
+ return [
17
+ '--ignore-config', '--no-simulate', '--no-cache-dir', '--no-playlist', '--no-overwrites',
18
+ '--socket-timeout', '20', '--retries', '1', '--fragment-retries', '1',
19
+ '--max-filesize', '500M', '--match-filter', 'duration <=? 1800',
20
+ '--js-runtimes', `node:${node}`, '--ffmpeg-location', dirname(ffmpeg),
21
+ '--format', 'bv[height<=720][ext=mp4][vcodec^=avc1]+ba[ext=m4a]/b[height<=?720][ext=mp4]/b[height<=?720]',
22
+ '--merge-output-format', 'mp4', '--remux-video', 'mp4',
23
+ '--print', 'after_move:filepath', '--output', output.replace(/\.mp4$/i, '') + '.%(ext)s',
24
+ '--', referenceUrl(url),
25
+ ];
26
+ }
27
+
28
+ export function downloadReference({ url, output, ytdlp, ffmpeg, run = spawnSync }) {
29
+ const dest = resolve(output);
30
+ if (!/\.mp4$/i.test(dest)) throw new Error('Reference output must end in .mp4.');
31
+ referenceUrl(url);
32
+ if (existsSync(dest)) return dest;
33
+ mkdirSync(dirname(dest), { recursive: true });
34
+ const r = run(ytdlp, downloadArgs({ url, output: dest, ffmpeg }), {
35
+ encoding: 'utf8', timeout: 600_000, maxBuffer: 4 * 1024 * 1024,
36
+ env: { ...process.env, PATH: `${dirname(ffmpeg)}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}` },
37
+ });
38
+ if (r.error || r.status !== 0 || !existsSync(dest)) {
39
+ throw new Error(`Reference download failed. Use the transcript already supplied by Ignix if available; do not claim to have seen the visuals. Otherwise request the original file. ${r.error?.message ?? (r.stderr ?? '').slice(-1200)}`);
40
+ }
41
+ return dest;
42
+ }