@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignix-social/editor",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Ignix Editor: render HTML video compositions (reels, edits, clips) on your own computer. Based on HyperFrames.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
- - Fades and ducking: a volume lane on the element, e.g. 8 ms fades at each cut so it does not click:
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.
@@ -288,7 +288,8 @@ export function buildProject({ spec, media, dir, ffmpeg, ffprobe }) {
288
288
  const keys = new Set([0, spec.audio.fadeInSec, duration - spec.audio.fadeOutSec, dur]);
289
289
  if (spec.audio.duck) for (const iv of speechIntervals(spec)) for (const x of [iv.start - DUCK_RAMP_SEC, iv.start, iv.end, iv.end + DUCK_RAMP_SEC * 2]) keys.add(x);
290
290
  const points = [...keys].filter((x) => x >= 0 && x <= dur).sort((a, b) => a - b).map((x) => [x, musicLevel(spec, duration, x)]);
291
- body.push(`<audio ${attrs({ id: 'music', src: file, 'data-start': 0, 'data-duration': dur, 'data-volume': 1, 'data-automation': lane(points), 'data-track-index': track++ })}></audio>`);
291
+ // Creator ya decide (spec.audio.duck) y lo lleva en la curva: el ducking automático no la toca.
292
+ body.push(`<audio ${attrs({ id: 'music', src: file, 'data-start': 0, 'data-duration': dur, 'data-volume': 1, 'data-automation': lane(points), 'data-duck': 'off', 'data-track-index': track++ })}></audio>`);
292
293
  }
293
294
  spec.voice.forEach((v, i) => {
294
295
  let file = place(v.asset);
package/src/duck.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // Ducking automático: la música baja sola mientras alguien habla y vuelve a subir en las pausas.
2
+ //
3
+ // Creator ya lo hacía (creator/build.mjs, `duck`), pero una composición escrita a mano por un
4
+ // asistente dejaba la música plana: la guía lo mencionaba y nadie lo hacía (2026-10-09, reel
5
+ // «One post. One job.»: música a 0,16 fija bajo once frases). Ahora se aplica en la copia de
6
+ // trabajo (prepare), así que check, snapshot, preview y render lo oyen igual, y el proyecto del
7
+ // usuario no se toca.
8
+ //
9
+ // música <audio> con data-role="music", o id music / bgm / soundtrack / song / bed (music2…)
10
+ // voz <audio> con data-role="voice|speech|narration", o id vo / voice / narration / line (+n);
11
+ // y <video> con su sonido (data-has-audio="true", sin muted), salvo data-role="music|sfx".
12
+ //
13
+ // Se respeta: data-duck="off" (o cualquier valor distinto de "auto"/"on") y una música que ya trae
14
+ // su propia curva de volumen con más de 4 puntos (alguien la dibujó a mano). Las fades sencillas
15
+ // (entrada / salida) se conservan: el ducking se MULTIPLICA por la curva que hubiera.
16
+
17
+ export const DUCKED = 0.22; // ≈ −13 dB bajo la voz (lo mismo que Creator)
18
+ export const DUCK_RAMP_SEC = 0.18; // baja en 0,18 s antes de la frase, sube en 0,36 s después
19
+ const JOIN_GAP_SEC = 0.6; // pausas más cortas no la suben: sin «bombeo» entre frase y frase
20
+
21
+ const TAG = /<(audio|video)\b[^>]*>/gi;
22
+ const ATTR = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
23
+ const MUSIC_ID = /^(music|bgm|soundtrack|song|bed)([-_]?\w*)?$/i;
24
+ const VOICE_ID = /^(vo|voice|narration|narrator|line|speech)([-_]?\d+)?$/i;
25
+
26
+ function attrsOf(tag) {
27
+ const inner = tag.replace(/^<\w+/, '').replace(/\/?>$/, '');
28
+ const out = {};
29
+ for (const m of inner.matchAll(ATTR)) out[m[1].toLowerCase()] = m[2] ?? m[3] ?? true;
30
+ return out;
31
+ }
32
+
33
+ const num = (v, d = NaN) => (v === undefined || v === true || v === '' ? d : Number(v));
34
+
35
+ function isMusic(kind, a) {
36
+ if (kind !== 'audio') return false;
37
+ return a['data-role'] === 'music' || (typeof a.id === 'string' && MUSIC_ID.test(a.id));
38
+ }
39
+
40
+ function isSpeech(kind, a) {
41
+ if (num(a['data-volume'], 1) <= 0) return false;
42
+ const role = a['data-role'];
43
+ if (role === 'voice' || role === 'speech' || role === 'narration') return true;
44
+ if (role === 'music' || role === 'sfx') return false;
45
+ if (kind === 'audio') return typeof a.id === 'string' && VOICE_ID.test(a.id);
46
+ return a['data-has-audio'] === 'true' && a.muted === undefined;
47
+ }
48
+
49
+ /** Tramos [inicio, fin] (tiempo de la composición) en los que habla alguien, ya unidos. */
50
+ export function speechSpans(html) {
51
+ const spans = [];
52
+ for (const m of html.matchAll(TAG)) {
53
+ const a = attrsOf(m[0]);
54
+ if (!isSpeech(m[1].toLowerCase(), a)) continue;
55
+ const s = num(a['data-start']);
56
+ const d = num(a['data-duration']);
57
+ if (Number.isFinite(s) && Number.isFinite(d) && d > 0) spans.push([s, s + d]);
58
+ }
59
+ spans.sort((x, y) => x[0] - y[0]);
60
+ const out = [];
61
+ for (const [s, e] of spans) {
62
+ const last = out[out.length - 1];
63
+ if (last && s - last[1] < JOIN_GAP_SEC) last[1] = Math.max(last[1], e);
64
+ else out.push([s, e]);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ const lin = (t, [a, b], [va, vb]) => (t <= a ? va : t >= b ? vb : va + ((t - a) / (b - a)) * (vb - va));
70
+
71
+ function laneAt(points, t) {
72
+ if (!points?.length) return 1;
73
+ if (t <= points[0].t) return points[0].v;
74
+ for (let i = 1; i < points.length; i++) {
75
+ if (t <= points[i].t) return lin(t, [points[i - 1].t, points[i].t], [points[i - 1].v, points[i].v]);
76
+ }
77
+ return points[points.length - 1].v;
78
+ }
79
+
80
+ function duckAt(spans, t) {
81
+ let g = 1;
82
+ for (const [s, e] of spans) {
83
+ if (t < s - DUCK_RAMP_SEC || t > e + DUCK_RAMP_SEC * 2) continue;
84
+ const down = lin(t, [s - DUCK_RAMP_SEC, s], [1, DUCKED]);
85
+ const up = lin(t, [e, e + DUCK_RAMP_SEC * 2], [DUCKED, 1]);
86
+ g = Math.min(g, t < s ? down : t > e ? up : DUCKED);
87
+ }
88
+ return g;
89
+ }
90
+
91
+ /**
92
+ * Baja la música bajo la voz en un HTML. Devuelve el HTML (igual si no hay nada que hacer) y
93
+ * qué hizo: { ducked: [{ id, spans }], skipped: [{ id, reason }] }.
94
+ */
95
+ export function autoDuck(html) {
96
+ const spans = speechSpans(html);
97
+ const report = { ducked: [], skipped: [] };
98
+ if (spans.length === 0) return { html, ...report };
99
+ const out = html.replace(TAG, (tag, kind) => {
100
+ const a = attrsOf(tag);
101
+ if (!isMusic(kind.toLowerCase(), a)) return tag;
102
+ const id = typeof a.id === 'string' ? a.id : '(music)';
103
+ const mode = a['data-duck'];
104
+ if (mode !== undefined && mode !== 'auto' && mode !== 'on') {
105
+ report.skipped.push({ id, reason: `data-duck="${mode}"` });
106
+ return tag;
107
+ }
108
+ let auto = { version: 1, lanes: [] };
109
+ if (typeof a['data-automation'] === 'string') {
110
+ try {
111
+ auto = JSON.parse(a['data-automation'].replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&'));
112
+ } catch {
113
+ report.skipped.push({ id, reason: 'its data-automation is not valid JSON' });
114
+ return tag;
115
+ }
116
+ }
117
+ const lanes = Array.isArray(auto.lanes) ? auto.lanes : [];
118
+ const vol = lanes.find((l) => l?.target === 'volume');
119
+ const own = Array.isArray(vol?.points) ? vol.points.filter((p) => Number.isFinite(p?.t) && Number.isFinite(p?.v)).sort((x, y) => x.t - y.t) : [];
120
+ if (own.length > 4) {
121
+ report.skipped.push({ id, reason: 'it already has its own volume curve' });
122
+ return tag;
123
+ }
124
+ const start = num(a['data-start'], 0);
125
+ const dur = num(a['data-duration']);
126
+ const end = Number.isFinite(dur) ? dur : Math.max(...spans.map(([, e]) => e - start)) + 1;
127
+ const local = spans.map(([s, e]) => [s - start, e - start]).filter(([s, e]) => e > 0 && s < end);
128
+ if (local.length === 0) return tag;
129
+ const keys = new Set([0, end, ...own.map((p) => p.t)]);
130
+ for (const [s, e] of local) for (const x of [s - DUCK_RAMP_SEC, s, e, e + DUCK_RAMP_SEC * 2]) keys.add(x);
131
+ const points = [...keys]
132
+ .filter((x) => x >= 0 && x <= end)
133
+ .sort((x, y) => x - y)
134
+ .map((t) => ({ t: Number(t.toFixed(4)), v: Number((laneAt(own, t) * duckAt(local, t)).toFixed(4)) }));
135
+ const next = { ...auto, version: auto.version ?? 1, lanes: [...lanes.filter((l) => l !== vol), { ...(vol ?? {}), target: 'volume', points }] };
136
+ report.ducked.push({ id, spans: local.length });
137
+ const json = JSON.stringify(next).replace(/'/g, '&#39;');
138
+ const without = tag.replace(/\sdata-automation\s*=\s*(?:"[^"]*"|'[^']*')/i, '');
139
+ return without.replace(/^<audio\b/i, `<audio data-automation='${json}'`);
140
+ });
141
+ return { html: out, ...report };
142
+ }
package/src/engine.mjs CHANGED
@@ -81,7 +81,7 @@ export function runEngineAsync(args, env, cwd, { onLine = () => {}, signal } = {
81
81
  * y, si hay sonido, la mezcla a `lufs` (−14 por defecto; `normalize: false` lo deja tal cual).
82
82
  */
83
83
  export async function renderProject(dir, output, { lufs = -14, normalize = true, engineArgs = [], quiet = false, onProgress, signal, browser, log = () => {} } = {}) {
84
- const { build, snapped } = prepare(dir);
84
+ const { build, snapped, duck } = prepare(dir);
85
85
  const { env, ffmpeg, ffprobe } = await engineEnv(log);
86
86
  if (browser) env.HYPERFRAMES_BROWSER_PATH = browser;
87
87
  mkdirSync(dirname(output), { recursive: true });
@@ -109,5 +109,5 @@ export async function renderProject(dir, output, { lufs = -14, normalize = true,
109
109
  const r = normalizeLoudness(ffmpeg, raw, output, { lufs: Number(lufs ?? -14) });
110
110
  level = { before: r.before, after: loudness(ffmpeg, output) };
111
111
  } else cpSync(raw, output);
112
- return { output, snapped, loudness: level };
112
+ return { output, snapped, duck, loudness: level };
113
113
  }
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
  }