@ignix-social/editor 0.1.0 → 0.1.2
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 +17 -5
- package/src/audio.mjs +1 -1
- package/src/binaries.mjs +8 -8
- package/src/cli.mjs +123 -45
- package/src/native/faces.swift +60 -0
- 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,12 @@ 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
|
+
| `ignix-editor faces <video> -o work/faces.json` | Where the face is over time (macOS, Apple Vision): a smoothed path `{t, cx, cy, size}` (0–1, top-left origin) to frame the person. |
|
|
17
18
|
| `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. |
|
|
19
|
+
| `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. `[dir]` defaults to the current folder. |
|
|
19
20
|
| `ignix-editor render [dir] [-o out.mp4]` | Renders to `<dir>/renders/<dir>.mp4`, audio normalised to −14 LUFS. |
|
|
20
21
|
| `ignix-editor ffmpeg …` / `ffprobe …` | The editor's own ffmpeg (do not rely on a system one). |
|
|
21
22
|
|
|
@@ -94,16 +95,26 @@ Decide the zones once per video (e.g. header y 270–440, stage y 460–1300, ca
|
|
|
94
95
|
- Transcribe on the machine: `ignix-editor transcribe assets/take.mp4 --lang es -o work/words.json`. Show the user the text and fix obvious mishearings (names, brands) without rewriting what they said.
|
|
95
96
|
- Cuts: one `<video>` per kept range, edge to edge (`data-start` of the next = previous start + duration). Build a map from source time to edited time and pass every word time, zoom, overlay and sound through it.
|
|
96
97
|
- Voice clean-up before building: `ignix-editor ffmpeg -i take.mp4 -vn -ac 1 -ar 48000 -af "highpass=f=80,acompressor=threshold=-22dB:ratio=3:attack=5:release=120:makeup=2" assets/voice.wav`, then one `<audio>` per kept range with `data-media-start` and 8 ms fade lanes, and the videos `muted`.
|
|
97
|
-
- Reframing a horizontal source to 9:16 and following the face: a fixed card/frame element with `overflow:hidden`; inside it a wrapper sized like the card, `transform-origin:0 0`, moved with `translate(-x·z, -y·z) scale(z)` per frame from
|
|
98
|
+
- Reframing a horizontal source to 9:16 and following the face: `ignix-editor faces assets/take.mp4 -o work/faces.json` gives a smoothed path (`cx`, `cy` = centre of the face, `size` = its height, all 0–1). Map it through your cut map like the words. Then a fixed card/frame element with `overflow:hidden`; inside it a wrapper sized like the card, `transform-origin:0 0`, moved with `translate(-x·z, -y·z) scale(z)` per frame from that path (interpolate between samples). Zooms multiply `z`.
|
|
99
|
+
- The cover is the first frame: the face AND the hook. If the person is not in shot yet in the first second (sitting down, walking in), use a still of their face from later (`ignix-editor ffmpeg -ss <t> -i take.mp4 -frames:v 1 assets/cover.jpg`) under the hook for ~1 s, with a slow push, then cut to the footage.
|
|
100
|
+
- The same file in two places at once (main shot + a small picture-in-picture window): give the second one its own copy (`cp take.mp4 take-pip.mp4`); `check` flags two simultaneous clips of one file.
|
|
98
101
|
- Blurred background of the same footage: a second set of the same `<video>` elements behind, in a wrapper with `filter: blur(60px) brightness(.32)` and `transform: scale(1.25)`. (Heavy without a GPU: on a machine without one, prefer a pre-blurred copy made with ffmpeg.)
|
|
99
102
|
- Long sources (lives, podcasts) are fine: the editor seeks straight to `data-media-start`.
|
|
100
103
|
|
|
104
|
+
### The bundled ffmpeg (LGPL)
|
|
105
|
+
|
|
106
|
+
`ignix-editor ffmpeg` is a licence-clean build: everything you need for audio and simple video prep, but no GPL parts.
|
|
107
|
+
- Encode H.264 with `-c:v h264_videotoolbox -b:v 10M -allow_sw 1` (there is no `libx264` and no `-crf`). AAC audio: `-c:a aac -b:a 192k`.
|
|
108
|
+
- Present: `scale`, `crop`, `pad`, `overlay`, `trim`/`atrim`, `concat`, `gblur`, `avgblur`, `curves`, `lutyuv`, `colorbalance`, `colorchannelmixer`, `fade`, `afade`, `highpass`, `lowpass`, `acompressor`, `loudnorm`, `ebur128`, `silencedetect`, `amix`, `volume`.
|
|
109
|
+
- Missing (GPL): `eq` (use `curves`/`lutyuv` for contrast and warmth), `boxblur` (use `gblur`), `delogo` (cover a burned-in label with a cropped, `gblur`red patch of the same frame placed by `overlay`, or with a card in the HTML).
|
|
110
|
+
- Grading and blur are usually easier in the HTML anyway (CSS `filter: contrast() saturate() blur()`).
|
|
111
|
+
|
|
101
112
|
## 4. Audio and captions for reels from scratch
|
|
102
113
|
|
|
103
114
|
- One `<audio>` per voice line (Ignix `generate_voiceover`), placed back to back with ~0.25 s of air: the scenes follow the voice.
|
|
104
115
|
- Word times: `ignix-editor transcribe assets/vo/l1.wav --lang es --script "<the exact line>" -o work/l1.json`. With `--script` the words are the script's and the times are whisper's (whisper mishears and sometimes drops the last words; the script wins).
|
|
105
116
|
- Anchor every gesture (click, pop, highlight, cut) to a word time: `cue = line.start + word.s`. Sound effects start on their gesture.
|
|
106
|
-
- 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.
|
|
117
|
+
- 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.
|
|
107
118
|
- The final mix is normalised to −14 LUFS by `render` (`--lufs` to change, `--no-normalize` to skip).
|
|
108
119
|
|
|
109
120
|
## 5. Generate the HTML from data
|
|
@@ -123,7 +134,8 @@ Re-run it whenever the voice or the cuts change; the animation code reads `windo
|
|
|
123
134
|
- Centring with CSS `transform: translate(-50%,-50%)` on an element you also transform from JS fights; centre with flex/inset instead.
|
|
124
135
|
- Emojis render with the operating system's emoji font (different design on other machines).
|
|
125
136
|
- An element that ends past `data-duration` is cut; a timeline shorter than it holds the last frame.
|
|
126
|
-
- `check`
|
|
137
|
+
- `check` also reads HTML comments: never leave example `<video>`/`<audio>` tags inside `<!-- -->` (it reports their files as missing).
|
|
138
|
+
- `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. A title split in lines (one element per line) is reported line against line: put `data-layout-allow-overlap` on EACH line, not only on the container.
|
|
127
139
|
|
|
128
140
|
## 7. Review before showing anything
|
|
129
141
|
|
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,10 @@
|
|
|
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';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
4
6
|
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
5
8
|
import { homedir } from 'node:os';
|
|
6
9
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
10
|
import { fileURLToPath } from 'node:url';
|
|
@@ -17,12 +20,21 @@ const ENGINE = join(PKG_DIR, 'vendor', 'hyperframes', 'bin', 'hyperframes.mjs');
|
|
|
17
20
|
const UPSTREAM = JSON.parse(readFileSync(join(PKG_DIR, 'vendor', 'hyperframes', 'UPSTREAM.json'), 'utf8'));
|
|
18
21
|
export const VIDEOS_DIR = join(homedir(), 'ignix-videos');
|
|
19
22
|
|
|
23
|
+
// Las dependencias pueden estar en <paquete>/node_modules o, según cómo instale npm,
|
|
24
|
+
// al lado del paquete (npm install --prefix …): se resuelven como lo haría Node.
|
|
25
|
+
const requireHere = createRequire(import.meta.url);
|
|
26
|
+
const depDir = (name) => dirname(requireHere.resolve(`${name}/package.json`));
|
|
27
|
+
|
|
20
28
|
const log = (...a) => console.error(...a);
|
|
21
29
|
const fail = (msg) => {
|
|
22
30
|
log(`✗ ${msg}`);
|
|
23
31
|
process.exit(1);
|
|
24
32
|
};
|
|
25
33
|
|
|
34
|
+
// Opciones del motor que llevan valor detrás: `snapshot --at 0,1.5` sin carpeta no debe
|
|
35
|
+
// tomar «0,1.5» por la carpeta del proyecto.
|
|
36
|
+
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']);
|
|
37
|
+
|
|
26
38
|
// ── argumentos: posicionales, nuestras opciones y el resto pasa al motor tal cual
|
|
27
39
|
function parseArgs(argv, ours) {
|
|
28
40
|
const pos = [];
|
|
@@ -36,9 +48,10 @@ function parseArgs(argv, ours) {
|
|
|
36
48
|
if (ours[key] === 'bool') opts[key] = m?.[2] !== 'false';
|
|
37
49
|
else opts[key] = m?.[2] ?? argv[++i];
|
|
38
50
|
} 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
|
-
|
|
51
|
+
else if (a.startsWith('-')) {
|
|
52
|
+
rest.push(a);
|
|
53
|
+
if (ENGINE_VALUE_FLAGS.has(a) && i + 1 < argv.length) rest.push(argv[++i]);
|
|
54
|
+
} else pos.push(a);
|
|
42
55
|
}
|
|
43
56
|
return { pos, opts, rest };
|
|
44
57
|
}
|
|
@@ -80,13 +93,13 @@ function validateProject(dir) {
|
|
|
80
93
|
const issues = projectHtml(dir).flatMap(validateHtml);
|
|
81
94
|
for (const i of issues) log(`${i.level === 'error' ? '✗' : '⚠'} ${i.rule} ${i.file.replace(dir + '/', '')}:${i.line} — ${i.msg}`);
|
|
82
95
|
const errors = issues.filter((i) => i.level === 'error').length;
|
|
83
|
-
log(errors ? `✗ ${errors} error(
|
|
96
|
+
log(errors ? `✗ ${errors} Ignix Editor error(s)` : `✓ Ignix Editor: HTML and assets OK${issues.length ? ` (${issues.length} warning(s))` : ''}`);
|
|
84
97
|
return errors;
|
|
85
98
|
}
|
|
86
99
|
|
|
87
100
|
const projectDir = (p) => {
|
|
88
101
|
const dir = resolve(p ?? '.');
|
|
89
|
-
if (!existsSync(join(dir, 'index.html'))) fail(`No
|
|
102
|
+
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
103
|
return dir;
|
|
91
104
|
};
|
|
92
105
|
|
|
@@ -94,38 +107,53 @@ const projectDir = (p) => {
|
|
|
94
107
|
const commands = {
|
|
95
108
|
async doctor() {
|
|
96
109
|
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(`
|
|
110
|
+
log(`${major >= 22 ? '✓' : '✗'} Node ${process.versions.node}${major >= 22 ? '' : ' — Node 22 or newer is required'}`);
|
|
111
|
+
log(`✓ Ignix Editor ${PKG.version} · HyperFrames engine ${UPSTREAM.version} (patches: ${UPSTREAM.patches.join(', ')})`);
|
|
112
|
+
log(` Platform ${PLATFORM} · data in ${HOME}`);
|
|
100
113
|
let ok = major >= 22;
|
|
114
|
+
// doctor deja la máquina lista: descarga lo que falte en vez de solo avisar.
|
|
101
115
|
for (const name of Object.keys(TOOLS)) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
const before = findTool(name);
|
|
117
|
+
if (before.path) {
|
|
118
|
+
log(`✓ ${name}: ${before.path}${before.source === 'env' ? ' (environment variable)' : ''}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const path = await ensureTool(name, log);
|
|
123
|
+
log(`✓ ${name}: ${path} (downloaded)`);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
log(`✗ ${name}: ${e.message}`);
|
|
126
|
+
if (name !== 'whisper') ok = false;
|
|
127
|
+
}
|
|
106
128
|
}
|
|
107
|
-
|
|
108
|
-
|
|
129
|
+
const ff = findTool('ffmpeg').path;
|
|
130
|
+
if (ff) {
|
|
131
|
+
const enc = spawnSync(ff, ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
|
|
109
132
|
const h264 = /\blibx264\b/.test(enc) ? 'libx264' : /\bh264_videotoolbox\b/.test(enc) ? 'VideoToolbox' : null;
|
|
110
|
-
log(`${h264 ? '✓' : '✗'}
|
|
133
|
+
log(`${h264 ? '✓' : '✗'} H.264 encoder: ${h264 ?? 'none (needs libx264 or VideoToolbox)'}`);
|
|
111
134
|
if (!h264) ok = false;
|
|
112
135
|
}
|
|
136
|
+
log(ok ? '✓ Ready.' : '✗ Not ready: see the lines above.');
|
|
113
137
|
process.exit(ok ? 0 : 1);
|
|
114
138
|
},
|
|
115
139
|
|
|
116
140
|
async init(argv) {
|
|
117
141
|
const { pos, opts } = parseArgs(argv, { dir: 'str' });
|
|
118
142
|
const name = pos[0];
|
|
119
|
-
if (!name || !/^[\w.-]+$/.test(name)) fail('
|
|
143
|
+
if (!name || !/^[\w.-]+$/.test(name)) fail('Usage: ignix-editor init <name> [--dir folder] (name without spaces)');
|
|
120
144
|
const dir = resolve(opts.dir ?? join(VIDEOS_DIR, name));
|
|
121
|
-
if (existsSync(dir)) fail(`${dir}
|
|
145
|
+
if (existsSync(dir)) fail(`${dir} already exists. Pick another name or work inside that project.`);
|
|
146
|
+
// Todo se prepara antes de crear la carpeta: si algo falta, no queda un proyecto a medias.
|
|
147
|
+
const gsap = join(depDir('gsap'), 'dist', 'gsap.min.js');
|
|
148
|
+
const fonts = join(depDir('@fontsource/montserrat'), 'files');
|
|
149
|
+
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
150
|
cpSync(join(PKG_DIR, 'templates', 'blank'), dir, { recursive: true });
|
|
123
151
|
mkdirSync(join(dir, 'assets', 'fonts'), { recursive: true });
|
|
124
|
-
cpSync(
|
|
152
|
+
cpSync(gsap, join(dir, 'assets', 'gsap.min.js'));
|
|
125
153
|
for (const w of [700, 800, 900]) {
|
|
126
154
|
for (const sub of ['latin', 'latin-ext']) {
|
|
127
155
|
const f = `montserrat-${sub}-${w}-normal.woff2`;
|
|
128
|
-
cpSync(join(
|
|
156
|
+
cpSync(join(fonts, f), join(dir, 'assets', 'fonts', f));
|
|
129
157
|
}
|
|
130
158
|
}
|
|
131
159
|
cpSync(join(PKG_DIR, 'src', 'runtime', 'ignix-frame.js'), join(dir, 'ignix-frame.js'));
|
|
@@ -139,7 +167,7 @@ const commands = {
|
|
|
139
167
|
const dir = projectDir(pos[0]);
|
|
140
168
|
const errors = validateProject(dir);
|
|
141
169
|
const { build, snapped } = prepare(dir);
|
|
142
|
-
if (snapped) log(`• ${snapped}
|
|
170
|
+
if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
|
|
143
171
|
const { env } = await engineEnv();
|
|
144
172
|
const code = runEngine(['check', build, ...rest], env, dir);
|
|
145
173
|
process.exit(errors ? 1 : code);
|
|
@@ -158,19 +186,19 @@ const commands = {
|
|
|
158
186
|
async render(argv) {
|
|
159
187
|
const { pos, opts, rest } = parseArgs(argv, { output: 'str', lufs: 'str', normalize: 'bool', force: 'bool' });
|
|
160
188
|
const dir = projectDir(pos[0]);
|
|
161
|
-
if (validateProject(dir) && !opts.force) fail('
|
|
189
|
+
if (validateProject(dir) && !opts.force) fail('Fix the errors above before rendering (or --force if you know what you are doing).');
|
|
162
190
|
const { build, snapped } = prepare(dir);
|
|
163
|
-
if (snapped) log(`• ${snapped}
|
|
191
|
+
if (snapped) log(`• ${snapped} time(s) snapped to the nearest frame`);
|
|
164
192
|
const { env, ffmpeg, ffprobe } = await engineEnv();
|
|
165
193
|
const output = resolve(opts.output ?? join(dir, 'renders', `${basename(dir)}.mp4`));
|
|
166
194
|
mkdirSync(dirname(output), { recursive: true });
|
|
167
195
|
const raw = join(build, 'raw.mp4');
|
|
168
196
|
const code = runEngine(['render', build, '-o', raw, ...rest], env, dir);
|
|
169
|
-
if (code !== 0 || !existsSync(raw)) fail('
|
|
197
|
+
if (code !== 0 || !existsSync(raw)) fail('The render failed (see the messages above).');
|
|
170
198
|
if (opts.normalize !== false && hasAudio(ffprobe, raw)) {
|
|
171
199
|
const lufs = Number(opts.lufs ?? -14);
|
|
172
200
|
const r = normalizeLoudness(ffmpeg, raw, output, { lufs });
|
|
173
|
-
log(`✓
|
|
201
|
+
log(`✓ Loudness: ${r.before.toFixed(1)} → ${loudness(ffmpeg, output)?.toFixed(1)} LUFS`);
|
|
174
202
|
} else cpSync(raw, output);
|
|
175
203
|
console.log(output);
|
|
176
204
|
},
|
|
@@ -180,14 +208,14 @@ const commands = {
|
|
|
180
208
|
const dir = projectDir(pos[0]);
|
|
181
209
|
const { build } = prepare(dir);
|
|
182
210
|
const { env } = await engineEnv();
|
|
183
|
-
log('
|
|
211
|
+
log('Previewing the working copy: if you change the project, run this command again.');
|
|
184
212
|
process.exit(runEngine(['preview', build, ...rest], env, dir));
|
|
185
213
|
},
|
|
186
214
|
|
|
187
215
|
async transcribe(argv) {
|
|
188
216
|
const { pos, opts } = parseArgs(argv, { lang: 'str', model: 'str', script: 'str', output: 'str', threads: 'str' });
|
|
189
217
|
const input = pos[0] && resolve(pos[0]);
|
|
190
|
-
if (!input || !existsSync(input)) fail('
|
|
218
|
+
if (!input || !existsSync(input)) fail('Usage: ignix-editor transcribe <audio|video> [--lang es] [--model medium] [--script "what was said"] [-o words.json]');
|
|
191
219
|
const ffmpeg = await ensureTool('ffmpeg', log);
|
|
192
220
|
const ffprobe = await ensureTool('ffprobe', log);
|
|
193
221
|
const whisper = await ensureTool('whisper', log);
|
|
@@ -198,8 +226,57 @@ const commands = {
|
|
|
198
226
|
words = alignToScript(opts.script, words, dur);
|
|
199
227
|
}
|
|
200
228
|
const json = JSON.stringify({ language: opts.lang ?? 'es', words }, null, 1);
|
|
201
|
-
if (opts.output)
|
|
202
|
-
|
|
229
|
+
if (opts.output) {
|
|
230
|
+
mkdirSync(dirname(resolve(opts.output)), { recursive: true });
|
|
231
|
+
writeFileSync(resolve(opts.output), json);
|
|
232
|
+
} else console.log(json);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// Dónde está la cara a lo largo del vídeo, para encuadrar en vertical siguiéndola.
|
|
236
|
+
// macOS: Apple Vision desde un Swift propio (src/native/faces.swift), compilado una vez.
|
|
237
|
+
async faces(argv) {
|
|
238
|
+
const { pos, opts } = parseArgs(argv, { every: 'str', output: 'str', smooth: 'str' });
|
|
239
|
+
const input = pos[0] && resolve(pos[0]);
|
|
240
|
+
if (!input || !existsSync(input)) fail('Usage: ignix-editor faces <video> [--every 0.2] [--smooth 1] [-o work/faces.json]');
|
|
241
|
+
if (process.platform !== 'darwin') fail('faces needs macOS for now. Elsewhere, frame the person by hand from stills (snapshot) or keep a centred crop.');
|
|
242
|
+
const src = join(PKG_DIR, 'src', 'native', 'faces.swift');
|
|
243
|
+
const hash = createHash('sha256').update(readFileSync(src)).digest('hex').slice(0, 12);
|
|
244
|
+
const bin = join(HOME, 'bin', PLATFORM, `faces-${hash}`);
|
|
245
|
+
if (!existsSync(bin)) {
|
|
246
|
+
mkdirSync(dirname(bin), { recursive: true });
|
|
247
|
+
log('Compiling the face tracker (first time only)…');
|
|
248
|
+
const c = spawnSync('xcrun', ['swiftc', '-O', '-parse-as-library', src, '-o', bin], { encoding: 'utf8' });
|
|
249
|
+
if (c.status !== 0) fail(`Could not compile the face tracker. It needs Apple's Command Line Tools (xcode-select --install).\n${(c.stderr || c.error?.message || '').slice(-400)}`);
|
|
250
|
+
}
|
|
251
|
+
const every = Math.max(0.05, Number(opts.every ?? 0.2));
|
|
252
|
+
const r = spawnSync(bin, [input, String(every)], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
253
|
+
if (r.status !== 0) fail(`Face tracking failed: ${(r.stderr || '').slice(-400)}`);
|
|
254
|
+
const raw = JSON.parse(r.stdout);
|
|
255
|
+
const S = raw.samples;
|
|
256
|
+
// Huecos sin cara: la posición conocida más cercana; si no hay ninguna, el centro.
|
|
257
|
+
const known = S.map((x, i) => (x.w ? i : -1)).filter((i) => i >= 0);
|
|
258
|
+
const filled = S.map((x, i) => {
|
|
259
|
+
if (x.w) return x;
|
|
260
|
+
if (!known.length) return { t: x.t, x: 0.4, y: 0.3, w: 0.2, h: 0.2 };
|
|
261
|
+
const k = known.reduce((a, b) => (Math.abs(b - i) < Math.abs(a - i) ? b : a));
|
|
262
|
+
return { ...S[k], t: x.t };
|
|
263
|
+
});
|
|
264
|
+
// Suavizado: media móvil de ~1 s (configurable) sobre el centro y el tamaño.
|
|
265
|
+
const half = Math.max(1, Math.round(Number(opts.smooth ?? 1) / every / 2));
|
|
266
|
+
const avg = (i, f) => {
|
|
267
|
+
let sum = 0, n = 0;
|
|
268
|
+
for (let j = Math.max(0, i - half); j <= Math.min(filled.length - 1, i + half); j++) (sum += f(filled[j])), n++;
|
|
269
|
+
return +(sum / n).toFixed(4);
|
|
270
|
+
};
|
|
271
|
+
const path = filled.map((x, i) => ({ t: x.t, cx: avg(i, (y) => y.x + y.w / 2), cy: avg(i, (y) => y.y + y.h / 2), size: avg(i, (y) => y.h) }));
|
|
272
|
+
const out = { source: input, width: raw.width, height: raw.height, duration: raw.duration, every, found: known.length, samples: S.length, path };
|
|
273
|
+
const json = JSON.stringify(out);
|
|
274
|
+
log(`✓ Face found in ${known.length}/${S.length} samples${known.length < S.length ? ' (gaps filled with the nearest position)' : ''}`);
|
|
275
|
+
if (opts.output) {
|
|
276
|
+
mkdirSync(dirname(resolve(opts.output)), { recursive: true });
|
|
277
|
+
writeFileSync(resolve(opts.output), json);
|
|
278
|
+
console.log(resolve(opts.output));
|
|
279
|
+
} else console.log(json);
|
|
203
280
|
},
|
|
204
281
|
|
|
205
282
|
async ffmpeg(argv) {
|
|
@@ -214,38 +291,39 @@ const commands = {
|
|
|
214
291
|
|
|
215
292
|
async skills(argv) {
|
|
216
293
|
const { pos, opts } = parseArgs(argv, { dir: 'str' });
|
|
217
|
-
if (pos[0] !== 'add') fail('
|
|
294
|
+
if (pos[0] !== 'add') fail('Usage: ignix-editor skills add [--dir ~/.claude/skills]');
|
|
218
295
|
const target = resolve(opts.dir ?? join(homedir(), '.claude', 'skills'), 'ignix-editor');
|
|
219
296
|
rmSync(target, { recursive: true, force: true });
|
|
220
297
|
cpSync(join(PKG_DIR, 'skills', 'ignix-editor'), target, { recursive: true });
|
|
221
|
-
log(`✓ Skill
|
|
298
|
+
log(`✓ Skill installed in ${target}`);
|
|
222
299
|
},
|
|
223
300
|
|
|
224
301
|
async version() {
|
|
225
|
-
console.log(`ignix-editor ${PKG.version} (
|
|
302
|
+
console.log(`ignix-editor ${PKG.version} (HyperFrames engine ${UPSTREAM.version})`);
|
|
226
303
|
},
|
|
227
304
|
|
|
228
305
|
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
|
|
239
|
-
ignix-editor
|
|
240
|
-
|
|
241
|
-
|
|
306
|
+
console.log(`Ignix Editor ${PKG.version} — video from HTML, rendered on your own computer.
|
|
307
|
+
|
|
308
|
+
ignix-editor doctor checks Node and the encoder, downloads ffmpeg and whisper if missing
|
|
309
|
+
ignix-editor init <name> creates ~/ignix-videos/<name>, ready to work in
|
|
310
|
+
ignix-editor check [folder] HTML, assets, layout, motion and contrast
|
|
311
|
+
ignix-editor snapshot [folder] --at 0,2.5,5 single frames to look at (into <folder>/snapshots)
|
|
312
|
+
ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize]
|
|
313
|
+
ignix-editor preview [folder] live preview in the browser
|
|
314
|
+
ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
|
|
315
|
+
ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed (macOS)
|
|
316
|
+
ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
|
|
317
|
+
ignix-editor skills add installs the skill for Claude
|
|
318
|
+
|
|
319
|
+
The folder defaults to the current one. Based on HyperFrames (Apache-2.0) by HeyGen, Inc. See NOTICE.`);
|
|
242
320
|
},
|
|
243
321
|
};
|
|
244
322
|
|
|
245
323
|
export async function main(argv) {
|
|
246
324
|
const [cmd = 'help', ...rest] = argv;
|
|
247
325
|
const fn = commands[cmd === '--version' || cmd === '-v' ? 'version' : cmd === '--help' || cmd === '-h' ? 'help' : cmd];
|
|
248
|
-
if (!fn) fail(`
|
|
326
|
+
if (!fn) fail(`Unknown command: ${cmd}. See: ignix-editor help`);
|
|
249
327
|
try {
|
|
250
328
|
await fn(rest);
|
|
251
329
|
} catch (e) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// faces — dónde está la cara en cada momento de un vídeo (Apple Vision, macOS 13+).
|
|
2
|
+
// Uso: faces <vídeo> <cada_segundos> → JSON por stdout:
|
|
3
|
+
// { "duration": s, "width": px, "height": px, "samples": [ { "t": s, "x":…, "y":…, "w":…, "h":… } | { "t": s } ] }
|
|
4
|
+
// x, y, w, h normalizados 0–1 con origen ARRIBA a la izquierda (como CSS). Sin cara → solo "t".
|
|
5
|
+
import AVFoundation
|
|
6
|
+
import Foundation
|
|
7
|
+
import Vision
|
|
8
|
+
|
|
9
|
+
@main
|
|
10
|
+
struct Faces {
|
|
11
|
+
static func main() async {
|
|
12
|
+
let args = CommandLine.arguments
|
|
13
|
+
guard args.count >= 2 else {
|
|
14
|
+
FileHandle.standardError.write("usage: faces <video> [every_seconds]\n".data(using: .utf8)!)
|
|
15
|
+
exit(2)
|
|
16
|
+
}
|
|
17
|
+
let url = URL(fileURLWithPath: args[1])
|
|
18
|
+
let every = args.count >= 3 ? (Double(args[2]) ?? 0.2) : 0.2
|
|
19
|
+
let asset = AVURLAsset(url: url)
|
|
20
|
+
do {
|
|
21
|
+
let duration = try await asset.load(.duration).seconds
|
|
22
|
+
guard let track = try await asset.loadTracks(withMediaType: .video).first else {
|
|
23
|
+
FileHandle.standardError.write("no video track\n".data(using: .utf8)!)
|
|
24
|
+
exit(1)
|
|
25
|
+
}
|
|
26
|
+
let size = try await track.load(.naturalSize).applying(try await track.load(.preferredTransform))
|
|
27
|
+
let gen = AVAssetImageGenerator(asset: asset)
|
|
28
|
+
gen.appliesPreferredTrackTransform = true
|
|
29
|
+
gen.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 30)
|
|
30
|
+
gen.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
|
|
31
|
+
gen.maximumSize = CGSize(width: 960, height: 960)
|
|
32
|
+
|
|
33
|
+
var samples: [[String: Double]] = []
|
|
34
|
+
var t = 0.0
|
|
35
|
+
while t < duration {
|
|
36
|
+
var sample: [String: Double] = ["t": (t * 1000).rounded() / 1000]
|
|
37
|
+
if let (image, _) = try? await gen.image(at: CMTime(seconds: t, preferredTimescale: 600)) {
|
|
38
|
+
let req = VNDetectFaceRectanglesRequest()
|
|
39
|
+
try? VNImageRequestHandler(cgImage: image, options: [:]).perform([req])
|
|
40
|
+
// La cara más grande: la de quien habla a cámara.
|
|
41
|
+
if let f = (req.results ?? []).max(by: { $0.boundingBox.width * $0.boundingBox.height < $1.boundingBox.width * $1.boundingBox.height }) {
|
|
42
|
+
let b = f.boundingBox // Vision: origen ABAJO a la izquierda
|
|
43
|
+
sample["x"] = b.minX
|
|
44
|
+
sample["y"] = 1 - b.maxY
|
|
45
|
+
sample["w"] = b.width
|
|
46
|
+
sample["h"] = b.height
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
samples.append(sample)
|
|
50
|
+
t += every
|
|
51
|
+
}
|
|
52
|
+
let out: [String: Any] = ["duration": duration, "width": abs(size.width), "height": abs(size.height), "samples": samples]
|
|
53
|
+
let data = try JSONSerialization.data(withJSONObject: out)
|
|
54
|
+
FileHandle.standardOutput.write(data)
|
|
55
|
+
} catch {
|
|
56
|
+
FileHandle.standardError.write("faces failed: \(error)\n".data(using: .utf8)!)
|
|
57
|
+
exit(1)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
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})`;
|