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