@ignix-social/editor 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +35 -4
- package/README.md +4 -3
- package/licenses/ffmpeg-GPL-2.0.txt +339 -0
- package/licenses/onnxruntime-MIT.txt +21 -0
- package/licenses/yunet-MIT.txt +21 -0
- package/package.json +3 -2
- package/patches/index.mjs +38 -0
- package/scripts/build-analysis-runtime.mjs +45 -0
- package/scripts/build-ffmpeg-macos.sh +10 -4
- package/scripts/build-ffmpeg-windows.sh +44 -0
- package/skills/ignix-editor/SKILL.md +3 -4
- package/src/analysis/clip.mjs +142 -0
- package/src/analysis/detect.mjs +185 -0
- package/src/analysis/image.mjs +123 -0
- package/src/analysis/media.mjs +152 -0
- package/src/analysis/montage.mjs +356 -0
- package/src/analysis/runtime.mjs +115 -0
- package/src/analysis/serve.mjs +149 -0
- package/src/analysis/sound.mjs +99 -0
- package/src/binaries.json +120 -14
- package/src/cli.mjs +35 -24
- package/src/prepare.mjs +13 -2
- package/src/transcribe.mjs +65 -1
- package/vendor/hyperframes/UPSTREAM.json +1 -0
- package/vendor/hyperframes/dist/chunk-K2FTXJYH.js +16 -2
- package/vendor/hyperframes/dist/chunk-TVCRUG2S.js +1 -1
- package/vendor/hyperframes/dist/render-LNO62LEL.js +1 -1
- package/src/native/faces.swift +0 -60
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Sonido y ritmo con el ffmpeg del Editor:
|
|
2
|
+
// · cleanVoice: la voz de un clip, más limpia (medido en Creator, ADR-0022 O1): primero se
|
|
3
|
+
// MIDE (suelo de ruido = el 10 % más bajo de ventanas de 50 ms; voz = el 10 % más alto) y
|
|
4
|
+
// luego se limpia con esos números: sin graves de rumor, el ruido constante abajo a su nivel
|
|
5
|
+
// real, una puerta suave justo encima del suelo y UNA ganancia fija hasta el nivel de voz de
|
|
6
|
+
// redes, con limitador. Ni compresor ni loudness dinámico: suben las pausas y el ruido con
|
|
7
|
+
// ellas (medido: −6 a −11 dB de relación voz/ruido con ventilador, zumbido y siseo).
|
|
8
|
+
// · structure: el ritmo de un vídeo de REFERENCIA (dónde corta y cuánto suena cada medio
|
|
9
|
+
// segundo). Solo salen números: ni fotogramas ni palabras.
|
|
10
|
+
// · cuts: dónde cambia de plano un vídeo (detector de cambio de escena de ffmpeg).
|
|
11
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
12
|
+
import { levels, probe } from './media.mjs';
|
|
13
|
+
|
|
14
|
+
export const MAX_VOICE_SEC = 3600;
|
|
15
|
+
const SPEECH_TARGET_DB = -14;
|
|
16
|
+
const CLEAN_ENOUGH_DB = 32;
|
|
17
|
+
|
|
18
|
+
const percentile = (xs, p) => {
|
|
19
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
20
|
+
return s[Math.min(s.length - 1, Math.max(0, Math.round((p / 100) * (s.length - 1))))];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function cleanVoice({ ffmpeg, ffprobe, path, out, start = null, end = null }) {
|
|
24
|
+
if (!/\.wav$/i.test(out)) throw new Error('out must be a .wav');
|
|
25
|
+
let span = null;
|
|
26
|
+
if (start !== null || end !== null) {
|
|
27
|
+
const a = Math.max(0, Number(start ?? 0));
|
|
28
|
+
const b = Number(end);
|
|
29
|
+
if (!(b > a) || b - a > MAX_VOICE_SEC) throw new Error('bad span');
|
|
30
|
+
span = [a, b];
|
|
31
|
+
}
|
|
32
|
+
const db = await levels(ffmpeg, path, { windowSec: 0.05, start: span?.[0] ?? 0, end: span?.[1] ?? null, maxSec: MAX_VOICE_SEC });
|
|
33
|
+
if (!db) throw new Error('no audio');
|
|
34
|
+
if (db.length < 10) throw new Error('too short');
|
|
35
|
+
const floor = percentile(db, 10);
|
|
36
|
+
const speech = percentile(db, 90);
|
|
37
|
+
const chain = ['highpass=f=80'];
|
|
38
|
+
const denoised = speech - floor < CLEAN_ENOUGH_DB;
|
|
39
|
+
if (denoised) {
|
|
40
|
+
chain.push(`afftdn=nr=15:nf=${Math.round(Math.max(-80, Math.min(-20, floor)))}:tn=1`);
|
|
41
|
+
const threshold = 10 ** ((floor + 8) / 20);
|
|
42
|
+
chain.push(`agate=mode=downward:range=0.2:threshold=${threshold.toFixed(5)}:ratio=3:attack=5:release=250:knee=4:detection=rms`);
|
|
43
|
+
}
|
|
44
|
+
const gain = Math.max(-12, Math.min(18, SPEECH_TARGET_DB - speech));
|
|
45
|
+
chain.push(`volume=${gain.toFixed(1)}dB`, 'alimiter=limit=0.84:level=false', 'aresample=48000');
|
|
46
|
+
const args = ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y'];
|
|
47
|
+
if (span) args.push('-ss', span[0].toFixed(3));
|
|
48
|
+
args.push('-i', path);
|
|
49
|
+
if (span) args.push('-t', (span[1] - span[0]).toFixed(3));
|
|
50
|
+
args.push('-vn', '-af', chain.join(','), '-ac', '1', '-c:a', 'pcm_s16le', out);
|
|
51
|
+
const r = spawnSync(ffmpeg, args, { encoding: 'utf8' });
|
|
52
|
+
if (r.status !== 0) throw new Error(`clean_voice: ${(r.stderr || '').slice(-300)}`);
|
|
53
|
+
const d = probe(ffprobe, out).duration;
|
|
54
|
+
return {
|
|
55
|
+
durationSec: +d.toFixed(3),
|
|
56
|
+
floorDb: +floor.toFixed(1),
|
|
57
|
+
speechDb: +speech.toFixed(1),
|
|
58
|
+
denoised,
|
|
59
|
+
gainDb: +gain.toFixed(1),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Cambios de plano (segundos). Umbral del detector de ffmpeg (0–100, diferencia media entre
|
|
65
|
+
* fotogramas) y duración mínima de un plano.
|
|
66
|
+
*/
|
|
67
|
+
export function cuts(ffmpeg, path, { threshold = SCENE_THRESHOLD, minSec = 0.3, maxSec = 600 } = {}) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const p = spawn(ffmpeg, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-t', String(maxSec), '-i', path, '-an', '-vf', `scale=320:-2,scdet=threshold=${threshold}:sc_pass=1,metadata=print:file=-`, '-f', 'null', '-'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
70
|
+
let out = '';
|
|
71
|
+
let err = '';
|
|
72
|
+
p.stdout.on('data', (d) => (out += d));
|
|
73
|
+
p.stderr.on('data', (d) => (err = (err + d).slice(-400)));
|
|
74
|
+
p.on('error', reject);
|
|
75
|
+
p.on('close', (code) => {
|
|
76
|
+
if (code !== 0) return reject(new Error(`scene detection: ${err.trim()}`));
|
|
77
|
+
const times = [...out.matchAll(/lavfi\.scd\.time=([\d.]+)/g)].map((m) => Number(m[1]));
|
|
78
|
+
const kept = [];
|
|
79
|
+
for (const t of times.sort((a, b) => a - b)) {
|
|
80
|
+
if (t < 0.05) continue;
|
|
81
|
+
if (!kept.length || t - kept[kept.length - 1] >= minSec) kept.push(+t.toFixed(2));
|
|
82
|
+
}
|
|
83
|
+
resolve(kept);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export const SCENE_THRESHOLD = 10;
|
|
88
|
+
|
|
89
|
+
export const MAX_REFERENCE_SEC = 600;
|
|
90
|
+
|
|
91
|
+
export async function structure({ ffmpeg, path }) {
|
|
92
|
+
const c = await cuts(ffmpeg, path, { minSec: 0.3, maxSec: MAX_REFERENCE_SEC });
|
|
93
|
+
// Cuánto suena cada medio segundo (dBFS del RMS), para la curva de energía.
|
|
94
|
+
const db = await levels(ffmpeg, path, { windowSec: 0.5, maxSec: MAX_REFERENCE_SEC + 1 }).catch((e) => {
|
|
95
|
+
if (/too long/.test(e.message)) throw e;
|
|
96
|
+
return null;
|
|
97
|
+
});
|
|
98
|
+
return { cuts: c.slice(0, 600), loudness: (db ?? []).map((v) => +v.toFixed(1)).slice(0, MAX_REFERENCE_SEC * 2) };
|
|
99
|
+
}
|
package/src/binaries.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "
|
|
2
|
+
"version": "2",
|
|
3
3
|
"baseUrl": "https://media.ignix.social/editor/bin",
|
|
4
4
|
"platforms": {
|
|
5
5
|
"darwin-x64": {
|
|
6
6
|
"ffmpeg": {
|
|
7
7
|
"file": "ffmpeg",
|
|
8
|
-
"size":
|
|
9
|
-
"sha256": "
|
|
10
|
-
"build": "ffmpeg 7.1.2
|
|
8
|
+
"size": 25454144,
|
|
9
|
+
"sha256": "593bf505c3f4e67c3b24c8b24008577f217178e6b96ffa452f0d63d55f557d41",
|
|
10
|
+
"build": "ffmpeg 7.1.2 GPL (sin libx264 ni non-free) + VideoToolbox, scripts/build-ffmpeg-macos.sh en Mac Intel"
|
|
11
11
|
},
|
|
12
12
|
"ffprobe": {
|
|
13
13
|
"file": "ffprobe",
|
|
14
|
-
"size":
|
|
15
|
-
"sha256": "
|
|
16
|
-
"build": "ffmpeg 7.1.2
|
|
14
|
+
"size": 25367080,
|
|
15
|
+
"sha256": "97dae7a58f71a3fa54d11be06c810896504fb70f062396cd466c37f66fe16b1e",
|
|
16
|
+
"build": "ffmpeg 7.1.2 GPL, scripts/build-ffmpeg-macos.sh en Mac Intel"
|
|
17
17
|
},
|
|
18
18
|
"whisper": {
|
|
19
19
|
"file": "whisper-cli",
|
|
@@ -25,21 +25,127 @@
|
|
|
25
25
|
"darwin-arm64": {
|
|
26
26
|
"ffmpeg": {
|
|
27
27
|
"file": "ffmpeg",
|
|
28
|
-
"size":
|
|
29
|
-
"sha256": "
|
|
30
|
-
"build": "ffmpeg 7.1.2
|
|
28
|
+
"size": 21133592,
|
|
29
|
+
"sha256": "47430a1ac61ff5e461c0fd70db9c5feb74557018ede93773bdf98b09297dcd49",
|
|
30
|
+
"build": "ffmpeg 7.1.2 GPL (sin libx264 ni non-free) + VideoToolbox, CI binaries.yml (macos-15, run 37375441240)"
|
|
31
31
|
},
|
|
32
32
|
"ffprobe": {
|
|
33
33
|
"file": "ffprobe",
|
|
34
|
-
"size":
|
|
35
|
-
"sha256": "
|
|
36
|
-
"build": "ffmpeg 7.1.2
|
|
34
|
+
"size": 21074760,
|
|
35
|
+
"sha256": "10c338cd1c804222a6c90c24c7c07f73bb07ee0f2d506eff565ca8f5e9fa9a40",
|
|
36
|
+
"build": "ffmpeg 7.1.2 GPL, CI binaries.yml (macos-15, run 37375441240)"
|
|
37
37
|
},
|
|
38
38
|
"whisper": {
|
|
39
39
|
"file": "whisper-cli",
|
|
40
40
|
"size": 2573144,
|
|
41
41
|
"sha256": "215d93a2edc77f8929311f127f368727859e13833e925652315ca5ea774595ee",
|
|
42
|
-
"build": "whisper.cpp v1.7.6, CI binaries.yml (macos-15, run
|
|
42
|
+
"build": "whisper.cpp v1.7.6, CI binaries.yml (macos-15, run 37375441240)"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"win32-x64": {
|
|
46
|
+
"ffmpeg": {
|
|
47
|
+
"file": "ffmpeg.exe",
|
|
48
|
+
"size": 23648768,
|
|
49
|
+
"sha256": "44afa6a014429b21ae647a09f31cb13f66af628e9f5dd66942f61a88a7f1ebfc",
|
|
50
|
+
"build": "ffmpeg 7.1.2 GPL (sin libx264 ni non-free) + Media Foundation, cruzado con MinGW, CI windows.yml (run 37392626795)"
|
|
51
|
+
},
|
|
52
|
+
"ffprobe": {
|
|
53
|
+
"file": "ffprobe.exe",
|
|
54
|
+
"size": 23516160,
|
|
55
|
+
"sha256": "86ddaff3fac842fa833102cdae338cda5f9862b71c1a54682ddc4fd336f3964f",
|
|
56
|
+
"build": "ffmpeg 7.1.2 GPL, cruzado con MinGW, CI windows.yml (run 37392626795)"
|
|
57
|
+
},
|
|
58
|
+
"whisper": {
|
|
59
|
+
"file": "whisper-cli.exe",
|
|
60
|
+
"size": 1990144,
|
|
61
|
+
"sha256": "26cef7a9b82544e4076a4977c93635bb1b324bd5ab3b4301d6d06e8224e580ea",
|
|
62
|
+
"build": "whisper.cpp v1.7.6, MSVC estático sin OpenMP, CI windows.yml (run 37392626795)"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"analysis": {
|
|
67
|
+
"version": "1",
|
|
68
|
+
"baseUrl": "https://media.ignix.social/editor/analysis",
|
|
69
|
+
"runtimeVersion": "1.23.2",
|
|
70
|
+
"runtime": {
|
|
71
|
+
"darwin-x64": {
|
|
72
|
+
"file": "onnxruntime-1.23.2-darwin-x64.zip",
|
|
73
|
+
"size": 11733354,
|
|
74
|
+
"sha256": "ff1d13c08e73f238f385205c265fb2d9bc49cab928b2f93263e3699d392742e9",
|
|
75
|
+
"build": "onnxruntime-node 1.23.2 (npm, MIT), scripts/build-analysis-runtime.mjs"
|
|
76
|
+
},
|
|
77
|
+
"darwin-arm64": {
|
|
78
|
+
"file": "onnxruntime-1.23.2-darwin-arm64.zip",
|
|
79
|
+
"size": 10537536,
|
|
80
|
+
"sha256": "dec3b9fddb103048c35a8dbc075a6c9d0c7f94c2ae188e1bb555d9128aee2f22",
|
|
81
|
+
"build": "onnxruntime-node 1.23.2 (npm, MIT), scripts/build-analysis-runtime.mjs"
|
|
82
|
+
},
|
|
83
|
+
"win32-x64": {
|
|
84
|
+
"file": "onnxruntime-1.23.2-win32-x64.zip",
|
|
85
|
+
"size": 27455571,
|
|
86
|
+
"sha256": "1666158dab17be85ce480b0f397c68c0f11c9ae82e40366344b03900b6e70e96",
|
|
87
|
+
"build": "onnxruntime-node 1.23.2 (npm, MIT), scripts/build-analysis-runtime.mjs"
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"models": {
|
|
91
|
+
"yolox": {
|
|
92
|
+
"file": "yolox_s.onnx",
|
|
93
|
+
"size": 35858002,
|
|
94
|
+
"sha256": "c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063",
|
|
95
|
+
"label": "the person detector",
|
|
96
|
+
"source": "opencv/object_detection_yolox@78c368f (Apache-2.0)"
|
|
97
|
+
},
|
|
98
|
+
"yunet": {
|
|
99
|
+
"file": "face_detection_yunet_2023mar.onnx",
|
|
100
|
+
"size": 232589,
|
|
101
|
+
"sha256": "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4",
|
|
102
|
+
"label": "the face detector",
|
|
103
|
+
"source": "opencv/face_detection_yunet@3cc26e7 (MIT)"
|
|
104
|
+
},
|
|
105
|
+
"ppocr": {
|
|
106
|
+
"file": "ppocr_det.onnx",
|
|
107
|
+
"size": 2423490,
|
|
108
|
+
"sha256": "03f550c6b406fda8bf54bd8327815f6c7e2edd98cea02348c93d879254366587",
|
|
109
|
+
"label": "the text detector",
|
|
110
|
+
"source": "opencv/text_detection_ppocr@c299e81 (Apache-2.0)"
|
|
111
|
+
},
|
|
112
|
+
"clip_vision": {
|
|
113
|
+
"file": "clip-vit-b32-vision.onnx",
|
|
114
|
+
"size": 351685709,
|
|
115
|
+
"sha256": "fd6e1402a588279d1723c7534d4bcba5bc0b14b47dfab0e46f8c47b8270d7d40",
|
|
116
|
+
"label": "the image matcher",
|
|
117
|
+
"source": "Xenova/clip-vit-base-patch32@d15189d onnx/vision_model.onnx (MIT, openai/clip-vit-base-patch32)",
|
|
118
|
+
"parts": [
|
|
119
|
+
{
|
|
120
|
+
"file": "clip-vit-b32-vision.onnx.partaa",
|
|
121
|
+
"size": 200000000,
|
|
122
|
+
"sha256": "d34cb7664dd8897e6acb806703f937b307ce20aa5bbff9209b8911028318e2ea"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"file": "clip-vit-b32-vision.onnx.partab",
|
|
126
|
+
"size": 151685709,
|
|
127
|
+
"sha256": "f046802f8bb8fd4e50584a0295809cb386167cb56dfb76cb163911540f0e2e62"
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"clip_text": {
|
|
132
|
+
"file": "clip-vit-b32-text.onnx",
|
|
133
|
+
"size": 254058553,
|
|
134
|
+
"sha256": "3f6571f5bad13a97c469c1622e1cfc4d9aef78b79fdbfcff804ca357bfada8cc",
|
|
135
|
+
"label": "the text matcher",
|
|
136
|
+
"source": "Xenova/clip-vit-base-patch32@d15189d onnx/text_model.onnx (MIT)"
|
|
137
|
+
},
|
|
138
|
+
"clip_vocab": {
|
|
139
|
+
"file": "clip-vocab.json",
|
|
140
|
+
"size": 862328,
|
|
141
|
+
"sha256": "5047b556ce86ccaf6aa22b3ffccfc52d391ea4accdab9c2f2407da5b742d4363",
|
|
142
|
+
"source": "Xenova/clip-vit-base-patch32@d15189d (MIT)"
|
|
143
|
+
},
|
|
144
|
+
"clip_merges": {
|
|
145
|
+
"file": "clip-merges.txt",
|
|
146
|
+
"size": 524619,
|
|
147
|
+
"sha256": "9fd691f7c8039210e0fced15865466c65820d09b63988b0174bfe25de299051a",
|
|
148
|
+
"source": "Xenova/clip-vit-base-patch32@d15189d (MIT)"
|
|
43
149
|
}
|
|
44
150
|
}
|
|
45
151
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
// debajo con nuestro entorno: sin telemetría, sin actualizaciones solas, con nuestros binarios.
|
|
3
3
|
// Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import {
|
|
6
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
6
|
import { createRequire } from 'node:module';
|
|
8
7
|
import { homedir } from 'node:os';
|
|
9
8
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
10
9
|
import { fileURLToPath } from 'node:url';
|
|
11
10
|
import { HOME, PLATFORM, TOOLS, ensureTool, ensureWhisperModel, findTool } from './binaries.mjs';
|
|
12
|
-
import { prepare, projectHtml } from './prepare.mjs';
|
|
11
|
+
import { linkMedia, prepare, projectHtml } from './prepare.mjs';
|
|
13
12
|
import { validateHtml } from './validate.mjs';
|
|
14
13
|
import { loudness, hasAudio, normalizeLoudness } from './audio.mjs';
|
|
15
14
|
import { alignToScript, transcribe } from './transcribe.mjs';
|
|
15
|
+
import { faces as detectFaces } from './analysis/detect.mjs';
|
|
16
|
+
import { frames, probe } from './analysis/media.mjs';
|
|
17
|
+
import { serve } from './analysis/serve.mjs';
|
|
16
18
|
|
|
17
19
|
const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
20
|
const PKG = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
|
|
@@ -77,8 +79,8 @@ async function engineEnv() {
|
|
|
77
79
|
const shim = join(HOME, 'shim', PLATFORM);
|
|
78
80
|
rmSync(shim, { recursive: true, force: true });
|
|
79
81
|
mkdirSync(shim, { recursive: true });
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
linkMedia(ffmpeg, join(shim, TOOLS.ffmpeg.file));
|
|
83
|
+
linkMedia(ffprobe, join(shim, TOOLS.ffprobe.file));
|
|
82
84
|
env.PATH = `${shim}${process.platform === 'win32' ? ';' : ':'}${env.PATH ?? ''}`;
|
|
83
85
|
return { env, ffmpeg, ffprobe };
|
|
84
86
|
}
|
|
@@ -129,8 +131,10 @@ const commands = {
|
|
|
129
131
|
const ff = findTool('ffmpeg').path;
|
|
130
132
|
if (ff) {
|
|
131
133
|
const enc = spawnSync(ff, ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
// Lo mismo que acepta el motor (con el parche mf-encoder): libx264, VideoToolbox (Mac) o
|
|
135
|
+
// Media Foundation (Windows); NVIDIA/Intel/AMD si el ffmpeg los trae.
|
|
136
|
+
const h264 = /\blibx264\b/.test(enc) ? 'libx264' : /\bh264_videotoolbox\b/.test(enc) ? 'VideoToolbox' : /\bh264_mf\b/.test(enc) ? 'Media Foundation' : /\bh264_(nvenc|qsv|amf)\b/.test(enc) ? 'GPU' : null;
|
|
137
|
+
log(`${h264 ? '✓' : '✗'} H.264 encoder: ${h264 ?? 'none (needs libx264, VideoToolbox or Media Foundation)'}`);
|
|
134
138
|
if (!h264) ok = false;
|
|
135
139
|
}
|
|
136
140
|
log(ok ? '✓ Ready.' : '✗ Not ready: see the lines above.');
|
|
@@ -232,27 +236,28 @@ const commands = {
|
|
|
232
236
|
} else console.log(json);
|
|
233
237
|
},
|
|
234
238
|
|
|
235
|
-
// Dónde está la cara a lo largo del vídeo, para encuadrar en vertical siguiéndola.
|
|
236
|
-
//
|
|
239
|
+
// Dónde está la cara a lo largo del vídeo, para encuadrar en vertical siguiéndola. YuNet sobre
|
|
240
|
+
// onnxruntime (igual en Mac y en Windows); el motor y el modelo se descargan la primera vez.
|
|
237
241
|
async faces(argv) {
|
|
238
242
|
const { pos, opts } = parseArgs(argv, { every: 'str', output: 'str', smooth: 'str' });
|
|
239
243
|
const input = pos[0] && resolve(pos[0]);
|
|
240
244
|
if (!input || !existsSync(input)) fail('Usage: ignix-editor faces <video> [--every 0.2] [--smooth 1] [-o work/faces.json]');
|
|
241
|
-
|
|
242
|
-
const
|
|
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
|
-
}
|
|
245
|
+
const ffmpeg = await ensureTool('ffmpeg', log);
|
|
246
|
+
const ffprobe = await ensureTool('ffprobe', log);
|
|
251
247
|
const every = Math.max(0.05, Number(opts.every ?? 0.2));
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
248
|
+
const info = probe(ffprobe, input);
|
|
249
|
+
const S = [];
|
|
250
|
+
await frames(ffmpeg, input, {
|
|
251
|
+
fps: 1 / every,
|
|
252
|
+
max: 640,
|
|
253
|
+
width: info.width,
|
|
254
|
+
height: info.height,
|
|
255
|
+
onFrame: async (f) => {
|
|
256
|
+
const big = (await detectFaces(f, log)).sort((a, b) => b.w * b.h - a.w * a.h)[0];
|
|
257
|
+
S.push(big ? { t: +f.t.toFixed(3), x: big.x, y: big.y, w: big.w, h: big.h } : { t: +f.t.toFixed(3), x: 0, y: 0, w: 0, h: 0 });
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
const raw = { width: info.width, height: info.height, duration: info.duration };
|
|
256
261
|
// Huecos sin cara: la posición conocida más cercana; si no hay ninguna, el centro.
|
|
257
262
|
const known = S.map((x, i) => (x.w ? i : -1)).filter((i) => i >= 0);
|
|
258
263
|
const filled = S.map((x, i) => {
|
|
@@ -279,6 +284,11 @@ const commands = {
|
|
|
279
284
|
} else console.log(json);
|
|
280
285
|
},
|
|
281
286
|
|
|
287
|
+
// Servicio local para Ignix Creator: líneas JSON por stdin/stdout (ADR-0023).
|
|
288
|
+
async serve() {
|
|
289
|
+
await serve({ version: PKG.version, engine: UPSTREAM.version });
|
|
290
|
+
},
|
|
291
|
+
|
|
282
292
|
async ffmpeg(argv) {
|
|
283
293
|
const r = spawnSync(await ensureTool('ffmpeg', log), argv, { stdio: 'inherit' });
|
|
284
294
|
process.exit(r.status ?? 1);
|
|
@@ -312,9 +322,10 @@ const commands = {
|
|
|
312
322
|
ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize]
|
|
313
323
|
ignix-editor preview [folder] live preview in the browser
|
|
314
324
|
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
|
|
325
|
+
ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed
|
|
316
326
|
ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
|
|
317
327
|
ignix-editor skills add installs the skill for Claude
|
|
328
|
+
ignix-editor serve local service for Ignix Creator (JSON lines on stdin/stdout)
|
|
318
329
|
|
|
319
330
|
The folder defaults to the current one. Based on HyperFrames (Apache-2.0) by HeyGen, Inc. See NOTICE.`);
|
|
320
331
|
},
|
package/src/prepare.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// con 4 decimales, 97/30 = 3.2333 daba el fotograma 96 en vez del 97);
|
|
4
4
|
// · ignix-frame.js de esta versión del editor, siempre al día.
|
|
5
5
|
// El proyecto del usuario no se toca.
|
|
6
|
-
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
|
|
@@ -12,6 +12,17 @@ const SKIP = new Set(['.ignix-build', 'renders', 'snapshots', 'node_modules', '.
|
|
|
12
12
|
// Vídeo y audio no se copian (pueden pesar gigas): enlace a la ruta absoluta del original.
|
|
13
13
|
const MEDIA = /\.(mp4|mov|m4v|webm|mkv|avi|wav|mp3|m4a|aac|ogg|flac)$/i;
|
|
14
14
|
|
|
15
|
+
// macOS/Linux: enlace simbólico. Windows: los simbólicos piden permisos de administrador o el
|
|
16
|
+
// modo desarrollador, así que enlace duro (mismo disco) y, si no se puede, una copia.
|
|
17
|
+
export function linkMedia(target, to) {
|
|
18
|
+
if (process.platform !== 'win32') return symlinkSync(target, to);
|
|
19
|
+
try {
|
|
20
|
+
linkSync(target, to);
|
|
21
|
+
} catch {
|
|
22
|
+
copyFileSync(target, to);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
function mirror(src, dest) {
|
|
16
27
|
mkdirSync(dest, { recursive: true });
|
|
17
28
|
for (const name of readdirSync(src)) {
|
|
@@ -20,7 +31,7 @@ function mirror(src, dest) {
|
|
|
20
31
|
const to = join(dest, name);
|
|
21
32
|
const st = statSync(from);
|
|
22
33
|
if (st.isDirectory()) mirror(from, to);
|
|
23
|
-
else if (MEDIA.test(name))
|
|
34
|
+
else if (MEDIA.test(name)) linkMedia(realpathSync(from), to);
|
|
24
35
|
else copyFileSync(from, to);
|
|
25
36
|
}
|
|
26
37
|
}
|
package/src/transcribe.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// el guion en las palabras y whisper en los tiempos: whisper confunde palabras («ADM», «tiene»)
|
|
4
4
|
// y a veces se come el final de una frase.
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
|
|
@@ -69,3 +69,67 @@ export function alignToScript(script, tokens, end) {
|
|
|
69
69
|
}
|
|
70
70
|
return out.map((w) => ({ t: w.t, s: +w.s.toFixed(3), e: +w.e.toFixed(3) }));
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Transcripción completa para Creator: idioma detectado (si no se da), cada palabra con su
|
|
75
|
+
* confianza (media de sus tokens) y las frases (cortadas en un punto o en una pausa).
|
|
76
|
+
*/
|
|
77
|
+
export function transcribeDetailed({ ffmpeg, whisper, model, input, language = null, threads = 8, maxWords = 6000, maxSegments = 800 }) {
|
|
78
|
+
const tmp = mkdtempSync(join(tmpdir(), 'ignix-editor-asr-'));
|
|
79
|
+
try {
|
|
80
|
+
const wav = join(tmp, 'audio.wav');
|
|
81
|
+
const a = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-i', input, '-vn', '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], { encoding: 'utf8' });
|
|
82
|
+
if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
|
|
83
|
+
const duration = statSync(wav).size > 44 ? (statSync(wav).size - 44) / 32000 : 0;
|
|
84
|
+
const out = join(tmp, 'out');
|
|
85
|
+
const args = ['-m', model, '-l', language || 'auto', '-ml', '1', '-sow', '-ojf', '-of', out, '-t', String(threads), '-f', wav];
|
|
86
|
+
const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
87
|
+
const intelMac = process.platform === 'darwin' && process.arch === 'x64';
|
|
88
|
+
let w = run(intelMac ? ['-ng'] : []);
|
|
89
|
+
let device = intelMac ? 'cpu' : 'gpu';
|
|
90
|
+
if (w.status !== 0 && !intelMac) {
|
|
91
|
+
w = run(['-ng']);
|
|
92
|
+
device = 'cpu';
|
|
93
|
+
}
|
|
94
|
+
if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
|
|
95
|
+
const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
|
|
96
|
+
const words = [];
|
|
97
|
+
let aside = false; // dentro de una acotación: [MÚSICA], (upbeat music)…
|
|
98
|
+
for (const seg of j.transcription ?? []) {
|
|
99
|
+
const text = seg.text.trim();
|
|
100
|
+
if (!text) continue;
|
|
101
|
+
if (aside || /^[[(]/.test(text)) {
|
|
102
|
+
aside = !/[\])]$/.test(text);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const toks = (seg.tokens ?? []).filter((t) => !/^\[_|^<\|/.test(t.text) && t.text.trim());
|
|
106
|
+
const p = toks.length ? toks.reduce((s, t) => s + (t.p ?? 0), 0) / toks.length : 0;
|
|
107
|
+
words.push({ text: text.slice(0, 60), start: seg.offsets.from / 1000, end: seg.offsets.to / 1000, p: +Math.min(1, Math.max(0, p)).toFixed(3) });
|
|
108
|
+
if (words.length >= maxWords) break;
|
|
109
|
+
}
|
|
110
|
+
// Frases: hasta un punto final o una pausa de más de 0,7 s.
|
|
111
|
+
const segments = [];
|
|
112
|
+
let cur = null;
|
|
113
|
+
for (const wd of words) {
|
|
114
|
+
if (cur && wd.start - cur.end > 0.7) {
|
|
115
|
+
segments.push(cur);
|
|
116
|
+
cur = null;
|
|
117
|
+
}
|
|
118
|
+
cur = cur ? { start: cur.start, end: wd.end, text: `${cur.text} ${wd.text}` } : { start: wd.start, end: wd.end, text: wd.text };
|
|
119
|
+
if (/[.!?…。]$/.test(wd.text)) {
|
|
120
|
+
segments.push(cur);
|
|
121
|
+
cur = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (cur) segments.push(cur);
|
|
125
|
+
return {
|
|
126
|
+
language: j.result?.language ?? language ?? null,
|
|
127
|
+
duration: +duration.toFixed(3),
|
|
128
|
+
device,
|
|
129
|
+
words,
|
|
130
|
+
segments: segments.slice(0, maxSegments).map((s) => ({ ...s, text: s.text.slice(0, 500) })),
|
|
131
|
+
};
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -7405,14 +7405,16 @@ var GPU_ENCODER_CANDIDATES = [
|
|
|
7405
7405
|
"videotoolbox",
|
|
7406
7406
|
"vaapi",
|
|
7407
7407
|
"qsv",
|
|
7408
|
-
"amf"
|
|
7408
|
+
"amf",
|
|
7409
|
+
"mf" // ignix-editor: Media Foundation (Windows)
|
|
7409
7410
|
];
|
|
7410
7411
|
var H264_ENCODER_BY_GPU = {
|
|
7411
7412
|
nvenc: "h264_nvenc",
|
|
7412
7413
|
videotoolbox: "h264_videotoolbox",
|
|
7413
7414
|
vaapi: "h264_vaapi",
|
|
7414
7415
|
qsv: "h264_qsv",
|
|
7415
|
-
amf: "h264_amf"
|
|
7416
|
+
amf: "h264_amf",
|
|
7417
|
+
mf: "h264_mf"
|
|
7416
7418
|
};
|
|
7417
7419
|
var GPU_PROBE_TIMEOUT_MS = 2e3;
|
|
7418
7420
|
var GPU_PROBE_KILL_GRACE_MS = 1e3;
|
|
@@ -7476,6 +7478,8 @@ function getGpuEncoderName(encoder, codec) {
|
|
|
7476
7478
|
return codec === "h264" ? "h264_qsv" : "hevc_qsv";
|
|
7477
7479
|
case "amf":
|
|
7478
7480
|
return codec === "h264" ? "h264_amf" : "hevc_amf";
|
|
7481
|
+
case "mf":
|
|
7482
|
+
return codec === "h264" ? "h264_mf" : "hevc_mf";
|
|
7479
7483
|
default:
|
|
7480
7484
|
return codec === "h264" ? "libx264" : "libx265";
|
|
7481
7485
|
}
|
|
@@ -8595,6 +8599,11 @@ function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = null) {
|
|
|
8595
8599
|
if (bitrate) args.push("-b:v", bitrate);
|
|
8596
8600
|
else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
|
|
8597
8601
|
break;
|
|
8602
|
+
case "mf": {
|
|
8603
|
+
const vt = buildVideoToolboxRateControlArgs({ bitrate, width: options.width, height: options.height, fps: fpsToNumber(fps), quality });
|
|
8604
|
+
args.push("-b:v", vt[1], "-rate_control", "u_vbr");
|
|
8605
|
+
break;
|
|
8606
|
+
}
|
|
8598
8607
|
}
|
|
8599
8608
|
if (codec === "h264" && (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi" || gpuEncoder === "amf")) {
|
|
8600
8609
|
args.push("-bf", "0");
|
|
@@ -9248,6 +9257,11 @@ function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
|
|
|
9248
9257
|
if (bitrate) args.push("-b:v", bitrate);
|
|
9249
9258
|
else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
|
|
9250
9259
|
break;
|
|
9260
|
+
case "mf": {
|
|
9261
|
+
const vt = buildVideoToolboxRateControlArgs({ bitrate, width: options.width, height: options.height, fps: fpsToNumber(fps), quality });
|
|
9262
|
+
args.push("-b:v", vt[1], "-rate_control", "u_vbr");
|
|
9263
|
+
break;
|
|
9264
|
+
}
|
|
9251
9265
|
}
|
|
9252
9266
|
if (codec === "h264" && (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi" || gpuEncoder === "amf")) {
|
|
9253
9267
|
args.push("-bf", "0");
|
|
@@ -195,7 +195,7 @@ var H264EncoderUnavailableError = class extends Error {
|
|
|
195
195
|
function resolveH264EncoderMode(ffmpegEncodersOutput, gpuRequested) {
|
|
196
196
|
if (gpuRequested) return "gpu";
|
|
197
197
|
if (/\blibx264\b/.test(ffmpegEncodersOutput)) return "software";
|
|
198
|
-
if (/\
|
|
198
|
+
if (/\bh264_(videotoolbox|mf)\b/.test(ffmpegEncodersOutput)) return "gpu";
|
|
199
199
|
throw new H264EncoderUnavailableError();
|
|
200
200
|
}
|
|
201
201
|
function detectH264EncoderMode(ffmpegPath, gpuRequested) {
|
|
@@ -1770,7 +1770,7 @@ async function executeLocalRender(projectDir, outputPath, options, cancellation)
|
|
|
1770
1770
|
}
|
|
1771
1771
|
if (encoderMode === "gpu") {
|
|
1772
1772
|
console.warn(
|
|
1773
|
-
c.warn(" FFmpeg does not include libx264;
|
|
1773
|
+
c.warn(" FFmpeg does not include libx264; using the system's hardware H.264 encoder (VideoToolbox on macOS, Media Foundation on Windows).")
|
|
1774
1774
|
);
|
|
1775
1775
|
options = { ...options, gpu: true };
|
|
1776
1776
|
}
|
package/src/native/faces.swift
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
}
|