@ignix-social/editor 0.1.26 → 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.26",
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,16 +47,17 @@ 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
- - Fades and ducking: a volume lane on the element, e.g. 8 ms fades at each cut so it does not click:
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.
60
+ - Fades: a volume lane on the element, e.g. 8 ms fades at each cut so it does not click:
57
61
  `data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":0},{"t":0.008,"v":1},{"t":2.95,"v":1},{"t":2.9667,"v":0}]}]}'` (`t` is seconds from the clip's own start).
58
62
  - Times are snapped to the nearest frame by the editor; still write them with enough decimals (frame / fps).
59
63
 
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'));
@@ -35,6 +36,11 @@ const requireHere = createRequire(import.meta.url);
35
36
  const depDir = (name) => dirname(requireHere.resolve(`${name}/package.json`));
36
37
 
37
38
  const log = (...a) => console.error(...a);
39
+ // Qué hizo el ducking automático (duck.mjs): que el asistente lo sepa y no lo duplique a mano.
40
+ function logDuck(duck) {
41
+ for (const d of duck?.ducked ?? []) log(`• Music "${d.id}" ducked under the voice (${d.spans} span${d.spans === 1 ? '' : 's'}). Set data-duck="off" on it to keep it flat.`);
42
+ for (const d of duck?.skipped ?? []) log(`• Music "${d.id}" not ducked: ${d.reason}.`);
43
+ }
38
44
  const fail = (msg) => {
39
45
  log(`✗ ${msg}`);
40
46
  process.exit(1);
@@ -145,8 +151,9 @@ const commands = {
145
151
  const { pos, rest } = parseArgs(argv, {});
146
152
  const dir = projectDir(pos[0]);
147
153
  const errors = validateProject(dir);
148
- const { build, snapped } = prepare(dir);
154
+ const { build, snapped, duck } = prepare(dir);
149
155
  if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
156
+ logDuck(duck);
150
157
  const { env } = await engineEnv(log);
151
158
  const code = runEngine(['check', build, ...rest], env, dir);
152
159
  process.exit(errors ? 1 : code);
@@ -169,6 +176,7 @@ const commands = {
169
176
  const output = resolve(opts.output ?? join(dir, 'renders', `${basename(dir)}.mp4`));
170
177
  const r = await renderProject(dir, output, { lufs: opts.lufs, normalize: opts.normalize, engineArgs: rest, log });
171
178
  if (r.snapped) log(`• ${r.snapped} time(s) snapped to the nearest frame`);
179
+ logDuck(r.duck);
172
180
  if (r.loudness) log(`✓ Loudness: ${r.loudness.before.toFixed(1)} → ${r.loudness.after?.toFixed(1)} LUFS`);
173
181
  // La revisión va SIEMPRE detrás del render (salvo --no-review): un agente con prisa se
174
182
  // salta un paso aparte, pero no puede no ver esto. El MP4 queda hecho igualmente.
@@ -426,6 +434,27 @@ const commands = {
426
434
  }
427
435
  },
428
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
+
429
458
  async version() {
430
459
  console.log(`ignix-editor ${PKG.version} (engine ${UPSTREAM.version})`);
431
460
  },
@@ -450,10 +479,12 @@ const commands = {
450
479
  ignix-editor style measure <video>… [--speech] [--stills dir] [-o style.json] someone's editing style, measured on 2–3 of their videos
451
480
  ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
452
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
453
483
  ignix-editor fetch [--whisper small] [--no-analysis] downloads everything up front (each file SHA-256-checked)
454
484
  ignix-editor serve local service for Ignix Creator (JSON lines on stdin/stdout)
455
485
 
456
- 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.`);
457
488
  },
458
489
  };
459
490
 
@@ -463,10 +494,25 @@ function printReview(rv, out = log) {
463
494
  if (rv.ready && rv.issues.length) out(' (! = worth fixing if it is quick.)');
464
495
  }
465
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
+
466
504
  export async function main(argv) {
467
505
  const [cmd = 'help', ...rest] = argv;
468
- 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;
469
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
+ }
470
516
  // Aviso de versión nueva (src/update-check.mjs): no en `help`/`--version`, que el
471
517
  // agente usa justo para comprobar qué tiene.
472
518
  if (!['help', 'version', '--version', '-v', '--help', '-h'].includes(cmd)) {
@@ -288,7 +288,8 @@ export function buildProject({ spec, media, dir, ffmpeg, ffprobe }) {
288
288
  const keys = new Set([0, spec.audio.fadeInSec, duration - spec.audio.fadeOutSec, dur]);
289
289
  if (spec.audio.duck) for (const iv of speechIntervals(spec)) for (const x of [iv.start - DUCK_RAMP_SEC, iv.start, iv.end, iv.end + DUCK_RAMP_SEC * 2]) keys.add(x);
290
290
  const points = [...keys].filter((x) => x >= 0 && x <= dur).sort((a, b) => a - b).map((x) => [x, musicLevel(spec, duration, x)]);
291
- body.push(`<audio ${attrs({ id: 'music', src: file, 'data-start': 0, 'data-duration': dur, 'data-volume': 1, 'data-automation': lane(points), 'data-track-index': track++ })}></audio>`);
291
+ // Creator ya decide (spec.audio.duck) y lo lleva en la curva: el ducking automático no la toca.
292
+ body.push(`<audio ${attrs({ id: 'music', src: file, 'data-start': 0, 'data-duration': dur, 'data-volume': 1, 'data-automation': lane(points), 'data-duck': 'off', 'data-track-index': track++ })}></audio>`);
292
293
  }
293
294
  spec.voice.forEach((v, i) => {
294
295
  let file = place(v.asset);
package/src/duck.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // Ducking automático: la música baja sola mientras alguien habla y vuelve a subir en las pausas.
2
+ //
3
+ // Creator ya lo hacía (creator/build.mjs, `duck`), pero una composición escrita a mano por un
4
+ // asistente dejaba la música plana: la guía lo mencionaba y nadie lo hacía (2026-10-09, reel
5
+ // «One post. One job.»: música a 0,16 fija bajo once frases). Ahora se aplica en la copia de
6
+ // trabajo (prepare), así que check, snapshot, preview y render lo oyen igual, y el proyecto del
7
+ // usuario no se toca.
8
+ //
9
+ // música <audio> con data-role="music", o id music / bgm / soundtrack / song / bed (music2…)
10
+ // voz <audio> con data-role="voice|speech|narration", o id vo / voice / narration / line (+n);
11
+ // y <video> con su sonido (data-has-audio="true", sin muted), salvo data-role="music|sfx".
12
+ //
13
+ // Se respeta: data-duck="off" (o cualquier valor distinto de "auto"/"on") y una música que ya trae
14
+ // su propia curva de volumen con más de 4 puntos (alguien la dibujó a mano). Las fades sencillas
15
+ // (entrada / salida) se conservan: el ducking se MULTIPLICA por la curva que hubiera.
16
+
17
+ export const DUCKED = 0.22; // ≈ −13 dB bajo la voz (lo mismo que Creator)
18
+ export const DUCK_RAMP_SEC = 0.18; // baja en 0,18 s antes de la frase, sube en 0,36 s después
19
+ const JOIN_GAP_SEC = 0.6; // pausas más cortas no la suben: sin «bombeo» entre frase y frase
20
+
21
+ const TAG = /<(audio|video)\b[^>]*>/gi;
22
+ const ATTR = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
23
+ const MUSIC_ID = /^(music|bgm|soundtrack|song|bed)([-_]?\w*)?$/i;
24
+ const VOICE_ID = /^(vo|voice|narration|narrator|line|speech)([-_]?\d+)?$/i;
25
+
26
+ function attrsOf(tag) {
27
+ const inner = tag.replace(/^<\w+/, '').replace(/\/?>$/, '');
28
+ const out = {};
29
+ for (const m of inner.matchAll(ATTR)) out[m[1].toLowerCase()] = m[2] ?? m[3] ?? true;
30
+ return out;
31
+ }
32
+
33
+ const num = (v, d = NaN) => (v === undefined || v === true || v === '' ? d : Number(v));
34
+
35
+ function isMusic(kind, a) {
36
+ if (kind !== 'audio') return false;
37
+ return a['data-role'] === 'music' || (typeof a.id === 'string' && MUSIC_ID.test(a.id));
38
+ }
39
+
40
+ function isSpeech(kind, a) {
41
+ if (num(a['data-volume'], 1) <= 0) return false;
42
+ const role = a['data-role'];
43
+ if (role === 'voice' || role === 'speech' || role === 'narration') return true;
44
+ if (role === 'music' || role === 'sfx') return false;
45
+ if (kind === 'audio') return typeof a.id === 'string' && VOICE_ID.test(a.id);
46
+ return a['data-has-audio'] === 'true' && a.muted === undefined;
47
+ }
48
+
49
+ /** Tramos [inicio, fin] (tiempo de la composición) en los que habla alguien, ya unidos. */
50
+ export function speechSpans(html) {
51
+ const spans = [];
52
+ for (const m of html.matchAll(TAG)) {
53
+ const a = attrsOf(m[0]);
54
+ if (!isSpeech(m[1].toLowerCase(), a)) continue;
55
+ const s = num(a['data-start']);
56
+ const d = num(a['data-duration']);
57
+ if (Number.isFinite(s) && Number.isFinite(d) && d > 0) spans.push([s, s + d]);
58
+ }
59
+ spans.sort((x, y) => x[0] - y[0]);
60
+ const out = [];
61
+ for (const [s, e] of spans) {
62
+ const last = out[out.length - 1];
63
+ if (last && s - last[1] < JOIN_GAP_SEC) last[1] = Math.max(last[1], e);
64
+ else out.push([s, e]);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ const lin = (t, [a, b], [va, vb]) => (t <= a ? va : t >= b ? vb : va + ((t - a) / (b - a)) * (vb - va));
70
+
71
+ function laneAt(points, t) {
72
+ if (!points?.length) return 1;
73
+ if (t <= points[0].t) return points[0].v;
74
+ for (let i = 1; i < points.length; i++) {
75
+ if (t <= points[i].t) return lin(t, [points[i - 1].t, points[i].t], [points[i - 1].v, points[i].v]);
76
+ }
77
+ return points[points.length - 1].v;
78
+ }
79
+
80
+ function duckAt(spans, t) {
81
+ let g = 1;
82
+ for (const [s, e] of spans) {
83
+ if (t < s - DUCK_RAMP_SEC || t > e + DUCK_RAMP_SEC * 2) continue;
84
+ const down = lin(t, [s - DUCK_RAMP_SEC, s], [1, DUCKED]);
85
+ const up = lin(t, [e, e + DUCK_RAMP_SEC * 2], [DUCKED, 1]);
86
+ g = Math.min(g, t < s ? down : t > e ? up : DUCKED);
87
+ }
88
+ return g;
89
+ }
90
+
91
+ /**
92
+ * Baja la música bajo la voz en un HTML. Devuelve el HTML (igual si no hay nada que hacer) y
93
+ * qué hizo: { ducked: [{ id, spans }], skipped: [{ id, reason }] }.
94
+ */
95
+ export function autoDuck(html) {
96
+ const spans = speechSpans(html);
97
+ const report = { ducked: [], skipped: [] };
98
+ if (spans.length === 0) return { html, ...report };
99
+ const out = html.replace(TAG, (tag, kind) => {
100
+ const a = attrsOf(tag);
101
+ if (!isMusic(kind.toLowerCase(), a)) return tag;
102
+ const id = typeof a.id === 'string' ? a.id : '(music)';
103
+ const mode = a['data-duck'];
104
+ if (mode !== undefined && mode !== 'auto' && mode !== 'on') {
105
+ report.skipped.push({ id, reason: `data-duck="${mode}"` });
106
+ return tag;
107
+ }
108
+ let auto = { version: 1, lanes: [] };
109
+ if (typeof a['data-automation'] === 'string') {
110
+ try {
111
+ auto = JSON.parse(a['data-automation'].replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&'));
112
+ } catch {
113
+ report.skipped.push({ id, reason: 'its data-automation is not valid JSON' });
114
+ return tag;
115
+ }
116
+ }
117
+ const lanes = Array.isArray(auto.lanes) ? auto.lanes : [];
118
+ const vol = lanes.find((l) => l?.target === 'volume');
119
+ const own = Array.isArray(vol?.points) ? vol.points.filter((p) => Number.isFinite(p?.t) && Number.isFinite(p?.v)).sort((x, y) => x.t - y.t) : [];
120
+ if (own.length > 4) {
121
+ report.skipped.push({ id, reason: 'it already has its own volume curve' });
122
+ return tag;
123
+ }
124
+ const start = num(a['data-start'], 0);
125
+ const dur = num(a['data-duration']);
126
+ const end = Number.isFinite(dur) ? dur : Math.max(...spans.map(([, e]) => e - start)) + 1;
127
+ const local = spans.map(([s, e]) => [s - start, e - start]).filter(([s, e]) => e > 0 && s < end);
128
+ if (local.length === 0) return tag;
129
+ const keys = new Set([0, end, ...own.map((p) => p.t)]);
130
+ for (const [s, e] of local) for (const x of [s - DUCK_RAMP_SEC, s, e, e + DUCK_RAMP_SEC * 2]) keys.add(x);
131
+ const points = [...keys]
132
+ .filter((x) => x >= 0 && x <= end)
133
+ .sort((x, y) => x - y)
134
+ .map((t) => ({ t: Number(t.toFixed(4)), v: Number((laneAt(own, t) * duckAt(local, t)).toFixed(4)) }));
135
+ const next = { ...auto, version: auto.version ?? 1, lanes: [...lanes.filter((l) => l !== vol), { ...(vol ?? {}), target: 'volume', points }] };
136
+ report.ducked.push({ id, spans: local.length });
137
+ const json = JSON.stringify(next).replace(/'/g, '&#39;');
138
+ const without = tag.replace(/\sdata-automation\s*=\s*(?:"[^"]*"|'[^']*')/i, '');
139
+ return without.replace(/^<audio\b/i, `<audio data-automation='${json}'`);
140
+ });
141
+ return { html: out, ...report };
142
+ }
package/src/engine.mjs CHANGED
@@ -81,7 +81,7 @@ export function runEngineAsync(args, env, cwd, { onLine = () => {}, signal } = {
81
81
  * y, si hay sonido, la mezcla a `lufs` (−14 por defecto; `normalize: false` lo deja tal cual).
82
82
  */
83
83
  export async function renderProject(dir, output, { lufs = -14, normalize = true, engineArgs = [], quiet = false, onProgress, signal, browser, log = () => {} } = {}) {
84
- const { build, snapped } = prepare(dir);
84
+ const { build, snapped, duck } = prepare(dir);
85
85
  const { env, ffmpeg, ffprobe } = await engineEnv(log);
86
86
  if (browser) env.HYPERFRAMES_BROWSER_PATH = browser;
87
87
  mkdirSync(dirname(output), { recursive: true });
@@ -109,5 +109,5 @@ export async function renderProject(dir, output, { lufs = -14, normalize = true,
109
109
  const r = normalizeLoudness(ffmpeg, raw, output, { lufs: Number(lufs ?? -14) });
110
110
  level = { before: r.before, after: loudness(ffmpeg, output) };
111
111
  } else cpSync(raw, output);
112
- return { output, snapped, loudness: level };
112
+ return { output, snapped, duck, loudness: level };
113
113
  }
@@ -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
+ }
package/src/prepare.mjs CHANGED
@@ -7,6 +7,7 @@ import './win-fs-preload.mjs'; // Windows: fs.cpSync con rutas no ASCII (win-fs.
7
7
  import { copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
8
8
  import { dirname, join } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { autoDuck } from './duck.mjs';
10
11
 
11
12
  const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), 'runtime', 'ignix-frame.js');
12
13
  const SKIP = new Set(['.ignix-build', 'renders', 'snapshots', 'node_modules', '.git', '.DS_Store']);
@@ -88,6 +89,9 @@ export function prepare(projectDir) {
88
89
  if ((/IgnixFrame/.test(rootHtml) || /ignix-frame\.js/.test(rootHtml)) && !/window\.__timelines\s*(=|\[|\.|\?\?=)/.test(rootHtml)) {
89
90
  writeFileSync(root, rootHtml.replace(/<\/body>/i, '<script>window.__timelines = window.__timelines || {};</script>\n</body>'));
90
91
  }
92
+ // La música baja sola bajo la voz (duck.mjs), con los tiempos ya ajustados al fotograma.
93
+ const duck = autoDuck(readFileSync(root, 'utf8'));
94
+ if (duck.ducked.length) writeFileSync(root, duck.html);
91
95
  cpSync(RUNTIME, join(build, 'ignix-frame.js'));
92
- return { build, fps, snapped };
96
+ return { build, fps, snapped, duck: { ducked: duck.ducked, skipped: duck.skipped } };
93
97
  }