@ignix-social/editor 0.1.27 → 0.1.28

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/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
8
+ npx @ignix-social/editor license <token> # saves the license from the Ignix MCP
6
9
  npx @ignix-social/editor doctor # checks Node 22+, ffmpeg, whisper 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`, `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.
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.28",
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,10 +7,13 @@ 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
  |---|---|
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. |
14
17
  | `ignix-editor doctor` | Node 22+ and the H.264 encoder; downloads its own ffmpeg and whisper the first time. Run it first. |
15
18
  | `ignix-editor init <name>` | Creates `~/ignix-videos/<name>` with a working skeleton, GSAP, local fonts and `ignix-frame.js`. |
16
19
  | `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. |
@@ -44,13 +47,13 @@ The engine reads `<video>`/`<audio>` from the HTML file before the page runs. Me
44
47
  ```html
45
48
  <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
49
  <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>
50
+ <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
51
  ```
49
52
 
50
53
  - `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
54
  - Every `<video>`/`<audio>` has a unique `id`. An `<audio>` without id is silently dropped from the mix.
52
55
  - 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).
56
+ - 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
57
  - 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
58
  - Never add `crossorigin` to media. Never call `play()`, `pause()` or set `currentTime`: the engine owns playback.
56
59
  - 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.
package/src/cli.mjs CHANGED
@@ -23,6 +23,7 @@ import { reviewVideo } from './analysis/review.mjs';
23
23
  import { pickAudio } from './pick-audio.mjs';
24
24
  import { importGiphy, openGiphySearch } from './giphy.mjs';
25
25
  import { warnIfOutdated } from './update-check.mjs';
26
+ import { LICENSE_ENV, LICENSE_FILE, licenseMessage, licenseStatus, requireLicense, saveLicense } from './license.mjs';
26
27
 
27
28
  const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
28
29
  const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
@@ -433,6 +434,27 @@ const commands = {
433
434
  }
434
435
  },
435
436
 
437
+ // Licencia de Ignix (src/license.mjs). Con token: la comprueba y la guarda. Sin nada: dice
438
+ // si hay una válida y hasta cuándo (código 1 si no la hay, para que el agente lo vea).
439
+ async license(argv) {
440
+ const { pos } = parseArgs(argv, {});
441
+ const when = (exp) => new Date(exp * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
442
+ if (pos[0]) {
443
+ const r = saveLicense(pos[0]);
444
+ console.log(`✓ License valid until ${when(r.exp)}`);
445
+ if (process.env[LICENSE_ENV]) log(`• ${LICENSE_ENV} is set in this environment and takes precedence over the saved license.`);
446
+ return;
447
+ }
448
+ const st = licenseStatus();
449
+ if (st.ok) {
450
+ console.log(`✓ License valid until ${when(st.exp)} (${st.source === 'env' ? LICENSE_ENV : LICENSE_FILE})`);
451
+ return;
452
+ }
453
+ if (st.reason === 'expired') log(`✗ The license expired on ${when(st.exp)}.`);
454
+ log(`✗ ${licenseMessage(st.reason)}`);
455
+ process.exitCode = 1;
456
+ },
457
+
436
458
  async version() {
437
459
  console.log(`ignix-editor ${PKG.version} (engine ${UPSTREAM.version})`);
438
460
  },
@@ -457,10 +479,12 @@ const commands = {
457
479
  ignix-editor style measure <video>… [--speech] [--stills dir] [-o style.json] someone's editing style, measured on 2–3 of their videos
458
480
  ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
459
481
  ignix-editor skills add installs the skill for Claude
482
+ ignix-editor license [<token>] saves the license from the Ignix MCP (get_editor_license), or shows it
460
483
  ignix-editor fetch [--whisper small] [--no-analysis] downloads everything up front (each file SHA-256-checked)
461
484
  ignix-editor serve local service for Ignix Creator (JSON lines on stdin/stdout)
462
485
 
463
- The folder defaults to the current one. Open source (Apache-2.0); credits in NOTICE.`);
486
+ The folder defaults to the current one. Requires an active Ignix plan (license from the Ignix MCP).
487
+ Open source (Apache-2.0); credits in NOTICE.`);
464
488
  },
465
489
  };
466
490
 
@@ -470,10 +494,25 @@ function printReview(rv, out = log) {
470
494
  if (rv.ready && rv.issues.length) out(' (! = worth fixing if it is quick.)');
471
495
  }
472
496
 
497
+ // Comandos que funcionan SIN licencia de Ignix; todos los demás (y los que se añadan) la piden.
498
+ // - help, version, license, skills add: para ponerse en marcha y pedir/guardar la licencia.
499
+ // - doctor, fetch: solo comprueban la máquina y descargan binarios/modelos una vez.
500
+ // - serve, creator-job: los usa Ignix Creator, que lleva este paquete dentro y comprueba el
501
+ // plan él mismo (ADR-0023); aquí no se piden para no romper sus renders ni su análisis.
502
+ const NO_LICENSE = new Set(['help', 'version', 'license', 'skills', 'doctor', 'fetch', 'serve', 'creator-job']);
503
+
473
504
  export async function main(argv) {
474
505
  const [cmd = 'help', ...rest] = argv;
475
- const fn = commands[cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd];
506
+ const name = cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd;
507
+ const fn = Object.hasOwn(commands, name) ? commands[name] : null;
476
508
  if (!fn) fail(`Unknown command: ${cmd}. See: ignix-editor help`);
509
+ if (!NO_LICENSE.has(name)) {
510
+ try {
511
+ requireLicense();
512
+ } catch (e) {
513
+ fail(e.message);
514
+ }
515
+ }
477
516
  // Aviso de versión nueva (src/update-check.mjs): no en `help`/`--version`, que el
478
517
  // agente usa justo para comprobar qué tiene.
479
518
  if (!['help', 'version', '--version', '-v', '--help', '-h'].includes(cmd)) {
@@ -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
+ }