@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 +5 -2
- package/package.json +1 -1
- package/skills/ignix-editor/SKILL.md +5 -2
- package/src/cli.mjs +41 -2
- package/src/license.mjs +126 -0
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
|
@@ -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.
|
|
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.
|
|
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
|
|
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)) {
|
package/src/license.mjs
ADDED
|
@@ -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
|
+
}
|