@ignix-social/editor 0.1.0 → 0.1.1
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/bin/ignix-editor.mjs +1 -1
- package/package.json +1 -1
- package/skills/ignix-editor/SKILL.md +3 -2
- package/src/audio.mjs +1 -1
- package/src/binaries.mjs +8 -8
- package/src/cli.mjs +74 -45
- package/src/prepare.mjs +8 -1
- package/src/transcribe.mjs +2 -2
- package/src/validate.mjs +8 -8
- package/templates/blank/index.html +7 -10
package/bin/ignix-editor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const major = Number(process.versions.node.split('.')[0]);
|
|
3
3
|
if (major < 22) {
|
|
4
|
-
console.error(`Ignix Editor
|
|
4
|
+
console.error(`Ignix Editor needs Node 22 or newer (this is ${process.versions.node}). Install the LTS from nodejs.org.`);
|
|
5
5
|
process.exit(1);
|
|
6
6
|
}
|
|
7
7
|
const { main } = await import('../src/cli.mjs');
|
package/package.json
CHANGED
|
@@ -11,11 +11,11 @@ Commands (`npx @ignix-social/editor <cmd>` if `ignix-editor` is not on the PATH)
|
|
|
11
11
|
|
|
12
12
|
| Command | What it does |
|
|
13
13
|
|---|---|
|
|
14
|
-
| `ignix-editor doctor` | Node 22
|
|
14
|
+
| `ignix-editor doctor` | Node 22+ and the H.264 encoder; downloads its own ffmpeg and whisper the first time. Run it first. |
|
|
15
15
|
| `ignix-editor init <name>` | Creates `~/ignix-videos/<name>` with a working skeleton, GSAP, local fonts and `ignix-frame.js`. |
|
|
16
16
|
| `ignix-editor transcribe <file> --lang es [--script "…"] -o words.json` | Words with start/end seconds, on this machine. |
|
|
17
17
|
| `ignix-editor check [dir]` | HTML well-formed, no network assets, media rules, layout/contrast/motion audit. |
|
|
18
|
-
| `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. |
|
|
18
|
+
| `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. `[dir]` defaults to the current folder. |
|
|
19
19
|
| `ignix-editor render [dir] [-o out.mp4]` | Renders to `<dir>/renders/<dir>.mp4`, audio normalised to −14 LUFS. |
|
|
20
20
|
| `ignix-editor ffmpeg …` / `ffprobe …` | The editor's own ffmpeg (do not rely on a system one). |
|
|
21
21
|
|
|
@@ -123,6 +123,7 @@ Re-run it whenever the voice or the cuts change; the animation code reads `windo
|
|
|
123
123
|
- Centring with CSS `transform: translate(-50%,-50%)` on an element you also transform from JS fights; centre with flex/inset instead.
|
|
124
124
|
- Emojis render with the operating system's emoji font (different design on other machines).
|
|
125
125
|
- An element that ends past `data-duration` is cut; a timeline shorter than it holds the last frame.
|
|
126
|
+
- `check` also reads HTML comments: never leave example `<video>`/`<audio>` tags inside `<!-- -->` (it reports their files as missing).
|
|
126
127
|
- `check` reports layout overlaps: real ones (a zoomed window under the header) must be fixed; intentional ones get `data-layout-allow-overlap` / `data-layout-allow-overflow` on the element.
|
|
127
128
|
|
|
128
129
|
## 7. Review before showing anything
|
package/src/audio.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
|
|
5
5
|
function ffmpegRun(ffmpeg, args) {
|
|
6
6
|
const r = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
7
|
-
if (r.status !== 0) throw new Error(`ffmpeg
|
|
7
|
+
if (r.status !== 0) throw new Error(`ffmpeg failed: ${(r.stderr || r.error?.message || '').slice(-400)}`);
|
|
8
8
|
return r.stderr; // ffmpeg informa por stderr
|
|
9
9
|
}
|
|
10
10
|
|
package/src/binaries.mjs
CHANGED
|
@@ -28,7 +28,7 @@ const binDir = () => join(HOME, 'bin', PLATFORM, manifest.version);
|
|
|
28
28
|
export function findTool(name) {
|
|
29
29
|
const t = TOOLS[name];
|
|
30
30
|
const fromEnv = process.env[t.env];
|
|
31
|
-
if (fromEnv) return existsSync(fromEnv) ? { path: fromEnv, source: 'env' } : { path: null, source: 'env', error: `${t.env}
|
|
31
|
+
if (fromEnv) return existsSync(fromEnv) ? { path: fromEnv, source: 'env' } : { path: null, source: 'env', error: `${t.env} points to ${fromEnv}, which does not exist` };
|
|
32
32
|
const cached = join(binDir(), t.file);
|
|
33
33
|
if (existsSync(cached)) return { path: cached, source: 'cache' };
|
|
34
34
|
const entry = manifest.platforms[PLATFORM]?.[name];
|
|
@@ -42,15 +42,15 @@ export async function ensureTool(name, log = () => {}) {
|
|
|
42
42
|
if (found.error) throw new Error(found.error);
|
|
43
43
|
const entry = manifest.platforms[PLATFORM]?.[name];
|
|
44
44
|
if (!entry) {
|
|
45
|
-
throw new Error(`${name}
|
|
45
|
+
throw new Error(`${name} is not available for ${PLATFORM} yet. Meanwhile, point ${TOOLS[name].env} to a local ${TOOLS[name].file}.`);
|
|
46
46
|
}
|
|
47
47
|
const url = `${manifest.baseUrl}/${manifest.version}/${PLATFORM}/${entry.file}`;
|
|
48
|
-
log(`
|
|
48
|
+
log(`Downloading ${name} (${(entry.size / 1e6).toFixed(0)} MB)…`);
|
|
49
49
|
const res = await fetch(url);
|
|
50
|
-
if (!res.ok) throw new Error(`
|
|
50
|
+
if (!res.ok) throw new Error(`Could not download ${name}: ${res.status} ${url}`);
|
|
51
51
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
52
52
|
const sha = createHash('sha256').update(buf).digest('hex');
|
|
53
|
-
if (sha !== entry.sha256) throw new Error(`
|
|
53
|
+
if (sha !== entry.sha256) throw new Error(`The downloaded ${name} does not match its SHA-256. Not using it.`);
|
|
54
54
|
const dir = binDir();
|
|
55
55
|
mkdirSync(dir, { recursive: true });
|
|
56
56
|
const dest = join(dir, TOOLS[name].file);
|
|
@@ -69,14 +69,14 @@ export const WHISPER_MODELS = {
|
|
|
69
69
|
|
|
70
70
|
export async function ensureWhisperModel(model = 'medium', log = () => {}) {
|
|
71
71
|
const m = WHISPER_MODELS[model];
|
|
72
|
-
if (!m) throw new Error(`
|
|
72
|
+
if (!m) throw new Error(`Unknown model: ${model}. Options: ${Object.keys(WHISPER_MODELS).join(', ')}`);
|
|
73
73
|
const dir = join(HOME, 'models');
|
|
74
74
|
const dest = join(dir, m.file);
|
|
75
75
|
if (existsSync(dest)) return dest;
|
|
76
76
|
mkdirSync(dir, { recursive: true });
|
|
77
|
-
log(`
|
|
77
|
+
log(`Downloading the ${model} transcription model (${(m.size / 1e9).toFixed(1)} GB, first time only)…`);
|
|
78
78
|
const res = await fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${m.file}`);
|
|
79
|
-
if (!res.ok) throw new Error(`
|
|
79
|
+
if (!res.ok) throw new Error(`Could not download the ${model} model: ${res.status}`);
|
|
80
80
|
await pipeline(Readable.fromWeb(res.body), createWriteStream(`${dest}.part`));
|
|
81
81
|
renameSync(`${dest}.part`, dest);
|
|
82
82
|
return dest;
|
package/src/cli.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// ignix-editor — CLI de Ignix Editor. El motor (HyperFrames, en vendor/) corre siempre por
|
|
2
2
|
// debajo con nuestro entorno: sin telemetría, sin actualizaciones solas, con nuestros binarios.
|
|
3
|
+
// Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
5
7
|
import { homedir } from 'node:os';
|
|
6
8
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
@@ -17,12 +19,21 @@ const ENGINE = join(PKG_DIR, 'vendor', 'hyperframes', 'bin', 'hyperframes.mjs');
|
|
|
17
19
|
const UPSTREAM = JSON.parse(readFileSync(join(PKG_DIR, 'vendor', 'hyperframes', 'UPSTREAM.json'), 'utf8'));
|
|
18
20
|
export const VIDEOS_DIR = join(homedir(), 'ignix-videos');
|
|
19
21
|
|
|
22
|
+
// Las dependencias pueden estar en <paquete>/node_modules o, según cómo instale npm,
|
|
23
|
+
// al lado del paquete (npm install --prefix …): se resuelven como lo haría Node.
|
|
24
|
+
const requireHere = createRequire(import.meta.url);
|
|
25
|
+
const depDir = (name) => dirname(requireHere.resolve(`${name}/package.json`));
|
|
26
|
+
|
|
20
27
|
const log = (...a) => console.error(...a);
|
|
21
28
|
const fail = (msg) => {
|
|
22
29
|
log(`✗ ${msg}`);
|
|
23
30
|
process.exit(1);
|
|
24
31
|
};
|
|
25
32
|
|
|
33
|
+
// Opciones del motor que llevan valor detrás: `snapshot --at 0,1.5` sin carpeta no debe
|
|
34
|
+
// tomar «0,1.5» por la carpeta del proyecto.
|
|
35
|
+
const ENGINE_VALUE_FLAGS = new Set(['--at', '--frames', '--timeout', '--angle', '--zoom', '--zoom-scale', '--against', '--describe', '-w', '--workers', '-q', '--quality', '-f', '--fps', '--format', '--crf', '--video-bitrate', '--resolution', '--variables', '--variables-file', '-c', '--composition', '--port']);
|
|
36
|
+
|
|
26
37
|
// ── argumentos: posicionales, nuestras opciones y el resto pasa al motor tal cual
|
|
27
38
|
function parseArgs(argv, ours) {
|
|
28
39
|
const pos = [];
|
|
@@ -36,9 +47,10 @@ function parseArgs(argv, ours) {
|
|
|
36
47
|
if (ours[key] === 'bool') opts[key] = m?.[2] !== 'false';
|
|
37
48
|
else opts[key] = m?.[2] ?? argv[++i];
|
|
38
49
|
} else if (key && key.startsWith('no-') && key.slice(3) in ours && ours[key.slice(3)] === 'bool') opts[key.slice(3)] = false;
|
|
39
|
-
else if (a.startsWith('-'))
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
else if (a.startsWith('-')) {
|
|
51
|
+
rest.push(a);
|
|
52
|
+
if (ENGINE_VALUE_FLAGS.has(a) && i + 1 < argv.length) rest.push(argv[++i]);
|
|
53
|
+
} else pos.push(a);
|
|
42
54
|
}
|
|
43
55
|
return { pos, opts, rest };
|
|
44
56
|
}
|
|
@@ -80,13 +92,13 @@ function validateProject(dir) {
|
|
|
80
92
|
const issues = projectHtml(dir).flatMap(validateHtml);
|
|
81
93
|
for (const i of issues) log(`${i.level === 'error' ? '✗' : '⚠'} ${i.rule} ${i.file.replace(dir + '/', '')}:${i.line} — ${i.msg}`);
|
|
82
94
|
const errors = issues.filter((i) => i.level === 'error').length;
|
|
83
|
-
log(errors ? `✗ ${errors} error(
|
|
95
|
+
log(errors ? `✗ ${errors} Ignix Editor error(s)` : `✓ Ignix Editor: HTML and assets OK${issues.length ? ` (${issues.length} warning(s))` : ''}`);
|
|
84
96
|
return errors;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
const projectDir = (p) => {
|
|
88
100
|
const dir = resolve(p ?? '.');
|
|
89
|
-
if (!existsSync(join(dir, 'index.html'))) fail(`No
|
|
101
|
+
if (!existsSync(join(dir, 'index.html'))) fail(`No index.html in ${dir}. Is this the project folder? (create one with: ignix-editor init <name>)`);
|
|
90
102
|
return dir;
|
|
91
103
|
};
|
|
92
104
|
|
|
@@ -94,38 +106,53 @@ const projectDir = (p) => {
|
|
|
94
106
|
const commands = {
|
|
95
107
|
async doctor() {
|
|
96
108
|
const major = Number(process.versions.node.split('.')[0]);
|
|
97
|
-
log(`${major >= 22 ? '✓' : '✗'} Node ${process.versions.node}${major >= 22 ? '' : ' —
|
|
98
|
-
log(`✓ Ignix Editor ${PKG.version} ·
|
|
99
|
-
log(`
|
|
109
|
+
log(`${major >= 22 ? '✓' : '✗'} Node ${process.versions.node}${major >= 22 ? '' : ' — Node 22 or newer is required'}`);
|
|
110
|
+
log(`✓ Ignix Editor ${PKG.version} · HyperFrames engine ${UPSTREAM.version} (patches: ${UPSTREAM.patches.join(', ')})`);
|
|
111
|
+
log(` Platform ${PLATFORM} · data in ${HOME}`);
|
|
100
112
|
let ok = major >= 22;
|
|
113
|
+
// doctor deja la máquina lista: descarga lo que falte en vez de solo avisar.
|
|
101
114
|
for (const name of Object.keys(TOOLS)) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
const before = findTool(name);
|
|
116
|
+
if (before.path) {
|
|
117
|
+
log(`✓ ${name}: ${before.path}${before.source === 'env' ? ' (environment variable)' : ''}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const path = await ensureTool(name, log);
|
|
122
|
+
log(`✓ ${name}: ${path} (downloaded)`);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
log(`✗ ${name}: ${e.message}`);
|
|
125
|
+
if (name !== 'whisper') ok = false;
|
|
126
|
+
}
|
|
106
127
|
}
|
|
107
|
-
|
|
108
|
-
|
|
128
|
+
const ff = findTool('ffmpeg').path;
|
|
129
|
+
if (ff) {
|
|
130
|
+
const enc = spawnSync(ff, ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
|
|
109
131
|
const h264 = /\blibx264\b/.test(enc) ? 'libx264' : /\bh264_videotoolbox\b/.test(enc) ? 'VideoToolbox' : null;
|
|
110
|
-
log(`${h264 ? '✓' : '✗'}
|
|
132
|
+
log(`${h264 ? '✓' : '✗'} H.264 encoder: ${h264 ?? 'none (needs libx264 or VideoToolbox)'}`);
|
|
111
133
|
if (!h264) ok = false;
|
|
112
134
|
}
|
|
135
|
+
log(ok ? '✓ Ready.' : '✗ Not ready: see the lines above.');
|
|
113
136
|
process.exit(ok ? 0 : 1);
|
|
114
137
|
},
|
|
115
138
|
|
|
116
139
|
async init(argv) {
|
|
117
140
|
const { pos, opts } = parseArgs(argv, { dir: 'str' });
|
|
118
141
|
const name = pos[0];
|
|
119
|
-
if (!name || !/^[\w.-]+$/.test(name)) fail('
|
|
142
|
+
if (!name || !/^[\w.-]+$/.test(name)) fail('Usage: ignix-editor init <name> [--dir folder] (name without spaces)');
|
|
120
143
|
const dir = resolve(opts.dir ?? join(VIDEOS_DIR, name));
|
|
121
|
-
if (existsSync(dir)) fail(`${dir}
|
|
144
|
+
if (existsSync(dir)) fail(`${dir} already exists. Pick another name or work inside that project.`);
|
|
145
|
+
// Todo se prepara antes de crear la carpeta: si algo falta, no queda un proyecto a medias.
|
|
146
|
+
const gsap = join(depDir('gsap'), 'dist', 'gsap.min.js');
|
|
147
|
+
const fonts = join(depDir('@fontsource/montserrat'), 'files');
|
|
148
|
+
if (!existsSync(gsap) || !existsSync(fonts)) fail('Ignix Editor is missing GSAP or its fonts. Reinstall it (npm install --prefix ~/.ignix-editor/cli @ignix-social/editor).');
|
|
122
149
|
cpSync(join(PKG_DIR, 'templates', 'blank'), dir, { recursive: true });
|
|
123
150
|
mkdirSync(join(dir, 'assets', 'fonts'), { recursive: true });
|
|
124
|
-
cpSync(
|
|
151
|
+
cpSync(gsap, join(dir, 'assets', 'gsap.min.js'));
|
|
125
152
|
for (const w of [700, 800, 900]) {
|
|
126
153
|
for (const sub of ['latin', 'latin-ext']) {
|
|
127
154
|
const f = `montserrat-${sub}-${w}-normal.woff2`;
|
|
128
|
-
cpSync(join(
|
|
155
|
+
cpSync(join(fonts, f), join(dir, 'assets', 'fonts', f));
|
|
129
156
|
}
|
|
130
157
|
}
|
|
131
158
|
cpSync(join(PKG_DIR, 'src', 'runtime', 'ignix-frame.js'), join(dir, 'ignix-frame.js'));
|
|
@@ -139,7 +166,7 @@ const commands = {
|
|
|
139
166
|
const dir = projectDir(pos[0]);
|
|
140
167
|
const errors = validateProject(dir);
|
|
141
168
|
const { build, snapped } = prepare(dir);
|
|
142
|
-
if (snapped) log(`• ${snapped}
|
|
169
|
+
if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
|
|
143
170
|
const { env } = await engineEnv();
|
|
144
171
|
const code = runEngine(['check', build, ...rest], env, dir);
|
|
145
172
|
process.exit(errors ? 1 : code);
|
|
@@ -158,19 +185,19 @@ const commands = {
|
|
|
158
185
|
async render(argv) {
|
|
159
186
|
const { pos, opts, rest } = parseArgs(argv, { output: 'str', lufs: 'str', normalize: 'bool', force: 'bool' });
|
|
160
187
|
const dir = projectDir(pos[0]);
|
|
161
|
-
if (validateProject(dir) && !opts.force) fail('
|
|
188
|
+
if (validateProject(dir) && !opts.force) fail('Fix the errors above before rendering (or --force if you know what you are doing).');
|
|
162
189
|
const { build, snapped } = prepare(dir);
|
|
163
|
-
if (snapped) log(`• ${snapped}
|
|
190
|
+
if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
|
|
164
191
|
const { env, ffmpeg, ffprobe } = await engineEnv();
|
|
165
192
|
const output = resolve(opts.output ?? join(dir, 'renders', `${basename(dir)}.mp4`));
|
|
166
193
|
mkdirSync(dirname(output), { recursive: true });
|
|
167
194
|
const raw = join(build, 'raw.mp4');
|
|
168
195
|
const code = runEngine(['render', build, '-o', raw, ...rest], env, dir);
|
|
169
|
-
if (code !== 0 || !existsSync(raw)) fail('
|
|
196
|
+
if (code !== 0 || !existsSync(raw)) fail('The render failed (see the messages above).');
|
|
170
197
|
if (opts.normalize !== false && hasAudio(ffprobe, raw)) {
|
|
171
198
|
const lufs = Number(opts.lufs ?? -14);
|
|
172
199
|
const r = normalizeLoudness(ffmpeg, raw, output, { lufs });
|
|
173
|
-
log(`✓
|
|
200
|
+
log(`✓ Loudness: ${r.before.toFixed(1)} → ${loudness(ffmpeg, output)?.toFixed(1)} LUFS`);
|
|
174
201
|
} else cpSync(raw, output);
|
|
175
202
|
console.log(output);
|
|
176
203
|
},
|
|
@@ -180,14 +207,14 @@ const commands = {
|
|
|
180
207
|
const dir = projectDir(pos[0]);
|
|
181
208
|
const { build } = prepare(dir);
|
|
182
209
|
const { env } = await engineEnv();
|
|
183
|
-
log('
|
|
210
|
+
log('Previewing the working copy: if you change the project, run this command again.');
|
|
184
211
|
process.exit(runEngine(['preview', build, ...rest], env, dir));
|
|
185
212
|
},
|
|
186
213
|
|
|
187
214
|
async transcribe(argv) {
|
|
188
215
|
const { pos, opts } = parseArgs(argv, { lang: 'str', model: 'str', script: 'str', output: 'str', threads: 'str' });
|
|
189
216
|
const input = pos[0] && resolve(pos[0]);
|
|
190
|
-
if (!input || !existsSync(input)) fail('
|
|
217
|
+
if (!input || !existsSync(input)) fail('Usage: ignix-editor transcribe <audio|video> [--lang es] [--model medium] [--script "what was said"] [-o words.json]');
|
|
191
218
|
const ffmpeg = await ensureTool('ffmpeg', log);
|
|
192
219
|
const ffprobe = await ensureTool('ffprobe', log);
|
|
193
220
|
const whisper = await ensureTool('whisper', log);
|
|
@@ -198,8 +225,10 @@ const commands = {
|
|
|
198
225
|
words = alignToScript(opts.script, words, dur);
|
|
199
226
|
}
|
|
200
227
|
const json = JSON.stringify({ language: opts.lang ?? 'es', words }, null, 1);
|
|
201
|
-
if (opts.output)
|
|
202
|
-
|
|
228
|
+
if (opts.output) {
|
|
229
|
+
mkdirSync(dirname(resolve(opts.output)), { recursive: true });
|
|
230
|
+
writeFileSync(resolve(opts.output), json);
|
|
231
|
+
} else console.log(json);
|
|
203
232
|
},
|
|
204
233
|
|
|
205
234
|
async ffmpeg(argv) {
|
|
@@ -214,38 +243,38 @@ const commands = {
|
|
|
214
243
|
|
|
215
244
|
async skills(argv) {
|
|
216
245
|
const { pos, opts } = parseArgs(argv, { dir: 'str' });
|
|
217
|
-
if (pos[0] !== 'add') fail('
|
|
246
|
+
if (pos[0] !== 'add') fail('Usage: ignix-editor skills add [--dir ~/.claude/skills]');
|
|
218
247
|
const target = resolve(opts.dir ?? join(homedir(), '.claude', 'skills'), 'ignix-editor');
|
|
219
248
|
rmSync(target, { recursive: true, force: true });
|
|
220
249
|
cpSync(join(PKG_DIR, 'skills', 'ignix-editor'), target, { recursive: true });
|
|
221
|
-
log(`✓ Skill
|
|
250
|
+
log(`✓ Skill installed in ${target}`);
|
|
222
251
|
},
|
|
223
252
|
|
|
224
253
|
async version() {
|
|
225
|
-
console.log(`ignix-editor ${PKG.version} (
|
|
254
|
+
console.log(`ignix-editor ${PKG.version} (HyperFrames engine ${UPSTREAM.version})`);
|
|
226
255
|
},
|
|
227
256
|
|
|
228
257
|
async help() {
|
|
229
|
-
console.log(`Ignix Editor ${PKG.version} —
|
|
230
|
-
|
|
231
|
-
ignix-editor doctor
|
|
232
|
-
ignix-editor init <
|
|
233
|
-
ignix-editor check [
|
|
234
|
-
ignix-editor snapshot [
|
|
235
|
-
ignix-editor render [
|
|
236
|
-
ignix-editor preview [
|
|
237
|
-
ignix-editor transcribe <
|
|
238
|
-
ignix-editor ffmpeg|ffprobe <args>
|
|
239
|
-
ignix-editor skills add
|
|
240
|
-
|
|
241
|
-
|
|
258
|
+
console.log(`Ignix Editor ${PKG.version} — video from HTML, rendered on your own computer.
|
|
259
|
+
|
|
260
|
+
ignix-editor doctor checks Node and the encoder, downloads ffmpeg and whisper if missing
|
|
261
|
+
ignix-editor init <name> creates ~/ignix-videos/<name>, ready to work in
|
|
262
|
+
ignix-editor check [folder] HTML, assets, layout, motion and contrast
|
|
263
|
+
ignix-editor snapshot [folder] --at 0,2.5,5 single frames to look at (into <folder>/snapshots)
|
|
264
|
+
ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize]
|
|
265
|
+
ignix-editor preview [folder] live preview in the browser
|
|
266
|
+
ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
|
|
267
|
+
ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
|
|
268
|
+
ignix-editor skills add installs the skill for Claude
|
|
269
|
+
|
|
270
|
+
The folder defaults to the current one. Based on HyperFrames (Apache-2.0) by HeyGen, Inc. See NOTICE.`);
|
|
242
271
|
},
|
|
243
272
|
};
|
|
244
273
|
|
|
245
274
|
export async function main(argv) {
|
|
246
275
|
const [cmd = 'help', ...rest] = argv;
|
|
247
276
|
const fn = commands[cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd];
|
|
248
|
-
if (!fn) fail(`
|
|
277
|
+
if (!fn) fail(`Unknown command: ${cmd}. See: ignix-editor help`);
|
|
249
278
|
try {
|
|
250
279
|
await fn(rest);
|
|
251
280
|
} catch (e) {
|
package/src/prepare.mjs
CHANGED
|
@@ -60,7 +60,7 @@ export function prepare(projectDir) {
|
|
|
60
60
|
mirror(projectDir, build);
|
|
61
61
|
|
|
62
62
|
const root = join(build, 'index.html');
|
|
63
|
-
if (!existsSync(root)) throw new Error(`No
|
|
63
|
+
if (!existsSync(root)) throw new Error(`No index.html in ${projectDir}`);
|
|
64
64
|
const fps = Number(readFileSync(root, 'utf8').match(/data-fps="(\d+(?:\.\d+)?)"/)?.[1] ?? 30);
|
|
65
65
|
|
|
66
66
|
let snapped = 0;
|
|
@@ -69,6 +69,13 @@ export function prepare(projectDir) {
|
|
|
69
69
|
if (changes) writeFileSync(f, html);
|
|
70
70
|
snapped += changes;
|
|
71
71
|
}
|
|
72
|
+
// IgnixFrame.run registra la línea de tiempo al ejecutarse, pero el lint del motor busca
|
|
73
|
+
// el registro LITERAL en el HTML: sin él da «missing_timeline_registry» (un error que,
|
|
74
|
+
// además, apaga las comprobaciones de maquetación y contraste). Se lo damos literal.
|
|
75
|
+
const rootHtml = readFileSync(root, 'utf8');
|
|
76
|
+
if ((/IgnixFrame/.test(rootHtml) || /ignix-frame\.js/.test(rootHtml)) && !/window\.__timelines\s*(=|\[|\.|\?\?=)/.test(rootHtml)) {
|
|
77
|
+
writeFileSync(root, rootHtml.replace(/<\/body>/i, '<script>window.__timelines = window.__timelines || {};</script>\n</body>'));
|
|
78
|
+
}
|
|
72
79
|
cpSync(RUNTIME, join(build, 'ignix-frame.js'));
|
|
73
80
|
return { build, fps, snapped };
|
|
74
81
|
}
|
package/src/transcribe.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
|
|
|
14
14
|
try {
|
|
15
15
|
const wav = join(tmp, 'audio.wav');
|
|
16
16
|
const a = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-i', input, '-vn', '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], { encoding: 'utf8' });
|
|
17
|
-
if (a.status !== 0) throw new Error(`
|
|
17
|
+
if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
|
|
18
18
|
const out = join(tmp, 'out');
|
|
19
19
|
const args = ['-m', model, '-l', language, '-ml', '1', '-sow', '-oj', '-of', out, '-t', String(threads), '-f', wav];
|
|
20
20
|
const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -23,7 +23,7 @@ export function transcribe({ ffmpeg, whisper, model, input, language = 'es', thr
|
|
|
23
23
|
const intelMac = process.platform === 'darwin' && process.arch === 'x64';
|
|
24
24
|
let w = run(intelMac ? ['-ng'] : []);
|
|
25
25
|
if (w.status !== 0 && !intelMac) w = run(['-ng']);
|
|
26
|
-
if (w.status !== 0) throw new Error(`whisper
|
|
26
|
+
if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
|
|
27
27
|
const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
|
|
28
28
|
const words = [];
|
|
29
29
|
for (const seg of j.transcription ?? []) {
|
package/src/validate.mjs
CHANGED
|
@@ -28,10 +28,10 @@ function balance(src) {
|
|
|
28
28
|
if (VOID.has(tag) || m[2] === '/' || ['html', 'head', 'body', 'svg', 'path'].includes(tag)) continue;
|
|
29
29
|
if (m[0].startsWith('</')) {
|
|
30
30
|
if (stack.length && stack[stack.length - 1].tag === tag) stack.pop();
|
|
31
|
-
else issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'html_unmatched_close', msg: `</${tag}>
|
|
31
|
+
else issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'html_unmatched_close', msg: `</${tag}> has no matching opening tag${stack.length ? ` (<${stack[stack.length - 1].tag}> from line ${stack[stack.length - 1].line} is open)` : ''}. Everything after it moves out of place.` });
|
|
32
32
|
} else stack.push({ tag, line: lineOf(src, m.index) });
|
|
33
33
|
}
|
|
34
|
-
for (const open of stack) issues.push({ level: 'error', line: open.line, rule: 'html_unclosed', msg: `<${open.tag}>
|
|
34
|
+
for (const open of stack) issues.push({ level: 'error', line: open.line, rule: 'html_unclosed', msg: `<${open.tag}> is never closed.` });
|
|
35
35
|
return issues;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -42,7 +42,7 @@ export function validateHtml(file) {
|
|
|
42
42
|
parse(src, {
|
|
43
43
|
sourceCodeLocationInfo: true,
|
|
44
44
|
onParseError: (e) => {
|
|
45
|
-
if (!BENIGN.has(e.code)) issues.push({ level: 'error', line: e.startLine, rule: `html_${e.code}`, msg: `HTML
|
|
45
|
+
if (!BENIGN.has(e.code)) issues.push({ level: 'error', line: e.startLine, rule: `html_${e.code}`, msg: `Malformed HTML (${e.code}).` });
|
|
46
46
|
},
|
|
47
47
|
});
|
|
48
48
|
issues.push(...balance(src));
|
|
@@ -50,22 +50,22 @@ export function validateHtml(file) {
|
|
|
50
50
|
for (const s of src.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
51
51
|
const body = s[1];
|
|
52
52
|
if (/createElement\(\s*['"`](video|audio)['"`]/.test(body) || /<(video|audio)\b/.test(body)) {
|
|
53
|
-
issues.push({ level: 'error', line: lineOf(src, s.index), rule: 'media_from_js', msg: '
|
|
53
|
+
issues.push({ level: 'error', line: lineOf(src, s.index), rule: 'media_from_js', msg: '<video>/<audio> created from JavaScript. The engine only sees media written in the HTML: write the HTML beforehand (a build script) instead of creating them at load time.' });
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
for (const m of src.matchAll(/<(script|link|img|video|audio|source)\b[^>]*\b(src|href)\s*=\s*["'](https?:)?\/\/[^"']+["']/gi)) {
|
|
57
57
|
const tag = m[1].toLowerCase();
|
|
58
58
|
const font = /fonts\.(googleapis|gstatic)\.com|use\.typekit|fonts\.bunny/.test(m[0]);
|
|
59
|
-
issues.push({ level: (tag === 'script' || tag === 'link') && !font ? 'warning' : 'error', line: lineOf(src, m.index), rule: 'network_asset', msg: `<${tag}>
|
|
59
|
+
issues.push({ level: (tag === 'script' || tag === 'link') && !font ? 'warning' : 'error', line: lineOf(src, m.index), rule: 'network_asset', msg: `<${tag}> loads something from the internet. Download it into assets/: the render must not depend on the network.` });
|
|
60
60
|
}
|
|
61
61
|
for (const m of src.matchAll(/@import\s+url\(\s*["']?https?:|url\(\s*["']?https?:\/\/fonts\./gi)) {
|
|
62
|
-
issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'network_font', msg: '
|
|
62
|
+
issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'network_font', msg: 'Font loaded from the internet: it arrives late in some frames. Use @font-face with a file in assets/fonts/.' });
|
|
63
63
|
}
|
|
64
64
|
for (const m of src.matchAll(/\bid\s*=\s*["']([^"']+)["']/g)) {
|
|
65
|
-
if (RESERVED_IDS.has(m[1])) issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'reserved_id', msg: `id="${m[1]}"
|
|
65
|
+
if (RESERVED_IDS.has(m[1])) issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'reserved_id', msg: `id="${m[1]}" is special to the engine (it changes the font). Use another name, e.g. "subs".` });
|
|
66
66
|
}
|
|
67
67
|
for (const m of src.matchAll(/<audio\b(?![^>]*\bid\s*=)[^>]*>/gi)) {
|
|
68
|
-
issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'audio_without_id', msg: '<audio>
|
|
68
|
+
issues.push({ level: 'error', line: lineOf(src, m.index), rule: 'audio_without_id', msg: '<audio> without an id: the mixer ignores it and it stays silent.' });
|
|
69
69
|
}
|
|
70
70
|
return issues.map((i) => ({ ...i, file }));
|
|
71
71
|
}
|
|
@@ -1,36 +1,33 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="
|
|
2
|
+
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=1080, height=1920" />
|
|
6
6
|
<script src="assets/gsap.min.js"></script>
|
|
7
7
|
<script src="ignix-frame.js"></script>
|
|
8
8
|
<style>
|
|
9
|
-
/*
|
|
9
|
+
/* Fonts are ALWAYS local (assets/fonts): web fonts arrive late in some frames. */
|
|
10
10
|
@font-face { font-family: 'Montserrat'; font-weight: 700; font-display: block; src: url('assets/fonts/montserrat-latin-700-normal.woff2') format('woff2'); }
|
|
11
11
|
@font-face { font-family: 'Montserrat'; font-weight: 800; font-display: block; src: url('assets/fonts/montserrat-latin-800-normal.woff2') format('woff2'); }
|
|
12
12
|
@font-face { font-family: 'Montserrat'; font-weight: 900; font-display: block; src: url('assets/fonts/montserrat-latin-900-normal.woff2') format('woff2'); }
|
|
13
13
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
14
14
|
html, body { width: 1080px; height: 1920px; overflow: hidden; background: #121313; }
|
|
15
15
|
#root { position: relative; width: 100%; height: 100%; overflow: hidden; background: #121313; font-family: 'Montserrat', sans-serif; color: #f5f5f5; }
|
|
16
|
-
/*
|
|
16
|
+
/* Reel safe area: the central ~820 px column, clear of the top 250 px and the bottom 450 px. */
|
|
17
17
|
#title { position: absolute; left: 130px; width: 820px; top: 760px; text-align: center; font-size: 110px; font-weight: 900; line-height: 1.02; text-transform: uppercase;
|
|
18
18
|
-webkit-text-stroke: 12px #000; paint-order: stroke fill; text-shadow: 0 8px 0 rgba(0,0,0,.5); }
|
|
19
19
|
</style>
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
22
|
-
<!-- data-duration
|
|
22
|
+
<!-- data-duration in seconds: set it to the real length (the voice plus a short tail). -->
|
|
23
23
|
<div id="root" data-composition-id="__ID__" data-width="1080" data-height="1920" data-duration="5" data-fps="30">
|
|
24
|
-
<div id="title">
|
|
25
|
-
<!--
|
|
26
|
-
<video id="clip1" src="assets/toma.mp4" muted playsinline data-start="0" data-duration="3" data-media-start="1.5" data-track-index="0"></video>
|
|
27
|
-
<audio id="voz1" src="assets/voz-1.wav" data-start="0" data-duration="2.4" data-track-index="1" data-volume="1"></audio>
|
|
28
|
-
-->
|
|
24
|
+
<div id="title">Hello</div>
|
|
25
|
+
<!-- Video and audio go here, written in the HTML (never created from JS), each with its own id: see the ignix-editor skill. -->
|
|
29
26
|
</div>
|
|
30
27
|
<script>
|
|
31
28
|
const { run, lerp, spring, ease } = IgnixFrame;
|
|
32
29
|
const title = document.getElementById('title');
|
|
33
|
-
//
|
|
30
|
+
// Everything visible depends ONLY on t (seconds). Never measure the page in here.
|
|
34
31
|
run({ id: '__ID__', duration: 5, fps: 30 }, (t) => {
|
|
35
32
|
const p = spring(t, { stiffness: 220, damping: 14 });
|
|
36
33
|
title.style.transform = `scale(${0.6 + 0.4 * p})`;
|