@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 +2 -0
- package/README.md +8 -3
- package/package.json +1 -1
- package/skills/ignix-editor/SKILL.md +11 -3
- package/src/binaries.json +40 -1
- package/src/binaries.mjs +2 -1
- package/src/cli.mjs +52 -3
- package/src/fetch.mjs +1 -1
- package/src/license.mjs +126 -0
- package/src/reference-download.mjs +42 -0
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
|
|
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
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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) });
|
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
|
+
}
|
|
@@ -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
|
+
}
|