@ignix-social/editor 0.1.3 → 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 +30 -3
- package/README.md +3 -2
- package/licenses/onnxruntime-MIT.txt +21 -0
- package/licenses/yunet-MIT.txt +21 -0
- package/package.json +1 -1
- package/patches/index.mjs +38 -0
- package/scripts/build-analysis-runtime.mjs +45 -0
- package/scripts/build-ffmpeg-windows.sh +44 -0
- 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 +142 -6
- 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,115 @@
|
|
|
1
|
+
// Lo que el análisis necesita aparte de ffmpeg: onnxruntime (MIT, 1.23.2: la última con
|
|
2
|
+
// binario para Mac Intel) y los modelos (YOLOX, YuNet, PP-OCR, CLIP). Se descargan la primera
|
|
3
|
+
// vez que hacen falta, por separado de la instalación con npx: quien solo renderiza vídeos con
|
|
4
|
+
// Claude nunca los baja. Mismo patrón que los binarios: media.ignix.social + sha256.
|
|
5
|
+
//
|
|
6
|
+
// Orden de búsqueda: variable de entorno (desarrollo) → caché en ~/.ignix-editor → descarga.
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { Readable } from 'node:stream';
|
|
12
|
+
import { pipeline } from 'node:stream/promises';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import AdmZip from 'adm-zip';
|
|
15
|
+
import { HOME, PLATFORM } from '../binaries.mjs';
|
|
16
|
+
|
|
17
|
+
const manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'binaries.json'), 'utf8')).analysis;
|
|
18
|
+
|
|
19
|
+
async function sha256Of(path) {
|
|
20
|
+
const h = createHash('sha256');
|
|
21
|
+
await pipeline(createReadStream(path), h);
|
|
22
|
+
return h.digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Descarga `url` a `dest` comprobando tamaño y sha256 (sin dejar nada a medias). */
|
|
26
|
+
async function fetchChecked(url, dest, entry, log) {
|
|
27
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
28
|
+
log(`Downloading ${entry.label ?? entry.file} (${(entry.size / 1e6).toFixed(0)} MB, first time only)…`);
|
|
29
|
+
const res = await fetch(url);
|
|
30
|
+
if (!res.ok || !res.body) throw new Error(`Could not download ${entry.file}: ${res.status} ${url}`);
|
|
31
|
+
const part = `${dest}.part`;
|
|
32
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(part));
|
|
33
|
+
if ((await sha256Of(part)) !== entry.sha256) {
|
|
34
|
+
rmSync(part, { force: true });
|
|
35
|
+
throw new Error(`The downloaded ${entry.file} does not match its SHA-256. Not using it.`);
|
|
36
|
+
}
|
|
37
|
+
renameSync(part, dest);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let ort = null;
|
|
41
|
+
|
|
42
|
+
/** onnxruntime-node para esta plataforma (cargado una vez por proceso). */
|
|
43
|
+
export async function loadOrt(log = () => {}) {
|
|
44
|
+
if (ort) return ort;
|
|
45
|
+
let dir = process.env.IGNIX_EDITOR_ORT;
|
|
46
|
+
if (!dir) {
|
|
47
|
+
const entry = manifest.runtime[PLATFORM];
|
|
48
|
+
if (!entry) throw new Error(`The analysis runtime is not available for ${PLATFORM} yet.`);
|
|
49
|
+
dir = join(HOME, 'runtime', `onnxruntime-${manifest.runtimeVersion}`, PLATFORM);
|
|
50
|
+
if (!existsSync(join(dir, 'onnxruntime-node', 'package.json'))) {
|
|
51
|
+
const zip = join(HOME, 'runtime', `${entry.file}`);
|
|
52
|
+
await fetchChecked(`${manifest.baseUrl}/${manifest.version}/runtime/${PLATFORM}/${entry.file}`, zip, { ...entry, label: 'the analysis runtime' }, log);
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
new AdmZip(zip).extractAllTo(dir, true);
|
|
55
|
+
rmSync(zip, { force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
ort = createRequire(join(dir, 'onnxruntime-node', 'package.json'))('./');
|
|
59
|
+
return ort;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Ruta local de un modelo de análisis, descargándolo la primera vez. */
|
|
63
|
+
export async function ensureModel(name, log = () => {}) {
|
|
64
|
+
const entry = manifest.models[name];
|
|
65
|
+
if (!entry) throw new Error(`Unknown analysis model: ${name}`);
|
|
66
|
+
const local = process.env.IGNIX_EDITOR_MODELS;
|
|
67
|
+
if (local) {
|
|
68
|
+
const p = join(local, entry.file);
|
|
69
|
+
if (!existsSync(p)) throw new Error(`IGNIX_EDITOR_MODELS has no ${entry.file}`);
|
|
70
|
+
return p;
|
|
71
|
+
}
|
|
72
|
+
const dest = join(HOME, 'models', 'analysis', manifest.version, entry.file);
|
|
73
|
+
if (existsSync(dest)) return dest;
|
|
74
|
+
const base = `${manifest.baseUrl}/${manifest.version}/models`;
|
|
75
|
+
if (!entry.parts) {
|
|
76
|
+
await fetchChecked(`${base}/${entry.file}`, dest, entry, log);
|
|
77
|
+
return dest;
|
|
78
|
+
}
|
|
79
|
+
// Un modelo de más de 300 MB va en partes (límite de subida a R2): cada una con su sha256 y,
|
|
80
|
+
// juntas, el sha256 del modelo entero.
|
|
81
|
+
const pieces = [];
|
|
82
|
+
for (const [i, part] of entry.parts.entries()) {
|
|
83
|
+
const p = `${dest}.${i}`;
|
|
84
|
+
await fetchChecked(`${base}/${part.file}`, p, { ...part, label: `${entry.label ?? entry.file} (${i + 1}/${entry.parts.length})` }, log);
|
|
85
|
+
pieces.push(p);
|
|
86
|
+
}
|
|
87
|
+
const whole = `${dest}.part`;
|
|
88
|
+
const w = createWriteStream(whole);
|
|
89
|
+
for (const p of pieces) await pipeline(createReadStream(p), w, { end: false });
|
|
90
|
+
await new Promise((resolve, reject) => w.end((e) => (e ? reject(e) : resolve())));
|
|
91
|
+
for (const p of pieces) rmSync(p, { force: true });
|
|
92
|
+
if ((await sha256Of(whole)) !== entry.sha256) {
|
|
93
|
+
rmSync(whole, { force: true });
|
|
94
|
+
throw new Error(`The downloaded ${entry.file} does not match its SHA-256. Not using it.`);
|
|
95
|
+
}
|
|
96
|
+
renameSync(whole, dest);
|
|
97
|
+
return dest;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sessions = new Map();
|
|
101
|
+
|
|
102
|
+
/** Sesión de onnxruntime para un modelo (una por proceso y modelo). */
|
|
103
|
+
export async function session(name, log = () => {}) {
|
|
104
|
+
if (!sessions.has(name)) {
|
|
105
|
+
const p = (async () => {
|
|
106
|
+
const o = await loadOrt(log);
|
|
107
|
+
return o.InferenceSession.create(await ensureModel(name, log), { graphOptimizationLevel: 'all' });
|
|
108
|
+
})();
|
|
109
|
+
sessions.set(name, p);
|
|
110
|
+
p.catch(() => sessions.delete(name));
|
|
111
|
+
}
|
|
112
|
+
return sessions.get(name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const RUNTIME_VERSION = manifest.runtimeVersion;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `ignix-editor serve`: el Editor como servicio local para Ignix Creator (ADR-0023). Habla
|
|
2
|
+
// líneas JSON por stdin/stdout, una petición y una respuesta por línea:
|
|
3
|
+
//
|
|
4
|
+
// -> {"id": 1, "op": "hello"}
|
|
5
|
+
// <- {"id": 1, "ok": true, "result": {...}}
|
|
6
|
+
//
|
|
7
|
+
// Las operaciones y sus respuestas son las que Creator ya usaba con su caja de OpenMontage, así
|
|
8
|
+
// que cambiar de motor no toca su lógica. Todo ocurre en el ordenador: nada sale a la red salvo
|
|
9
|
+
// la primera descarga de lo que falte (binarios, motor de análisis y modelos, con su sha256).
|
|
10
|
+
// Las peticiones se atienden de una en una (el análisis ya usa todos los núcleos).
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
import { ensureTool, ensureWhisperModel } from '../binaries.mjs';
|
|
13
|
+
import { transcribeDetailed } from '../transcribe.mjs';
|
|
14
|
+
import { faces as detectFaces, people as detectPeople, text as detectText } from './detect.mjs';
|
|
15
|
+
import { frames, probe } from './media.mjs';
|
|
16
|
+
import { analyze, edit, review } from './montage.mjs';
|
|
17
|
+
import { RUNTIME_VERSION, loadOrt } from './runtime.mjs';
|
|
18
|
+
import { cleanVoice, structure } from './sound.mjs';
|
|
19
|
+
|
|
20
|
+
const MAX_SAMPLES = 2400;
|
|
21
|
+
const log = (...a) => console.error(...a);
|
|
22
|
+
|
|
23
|
+
async function tools() {
|
|
24
|
+
return { ffmpeg: await ensureTool('ffmpeg', log), ffprobe: await ensureTool('ffprobe', log) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createOps({ version, engine }) {
|
|
28
|
+
return {
|
|
29
|
+
async hello() {
|
|
30
|
+
const { ffmpeg, ffprobe } = await tools();
|
|
31
|
+
const ort = await loadOrt(log);
|
|
32
|
+
return { runner: 'ignix-editor', version, engine, onnxruntime: ort.env?.versions?.common ?? RUNTIME_VERSION, platform: `${process.platform}-${process.arch}`, ffmpeg: !!ffmpeg, ffprobe: !!ffprobe };
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async transcribe(req) {
|
|
36
|
+
const { ffmpeg } = await tools();
|
|
37
|
+
const whisper = await ensureTool('whisper', log);
|
|
38
|
+
const model = await ensureWhisperModel(req.model ?? 'small', log);
|
|
39
|
+
return transcribeDetailed({ ffmpeg, whisper, model, input: req.path, language: req.language ?? null });
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Dónde está la persona a lo largo del clip (la cara más grande de cada muestra).
|
|
43
|
+
async faces(req) {
|
|
44
|
+
const { ffmpeg, ffprobe } = await tools();
|
|
45
|
+
const info = probe(ffprobe, req.path);
|
|
46
|
+
const found = [];
|
|
47
|
+
let sampled = 0;
|
|
48
|
+
await frames(ffmpeg, req.path, {
|
|
49
|
+
fps: Number(req.fps ?? 3),
|
|
50
|
+
max: 640,
|
|
51
|
+
width: info.width,
|
|
52
|
+
height: info.height,
|
|
53
|
+
onFrame: async (f) => {
|
|
54
|
+
sampled += 1;
|
|
55
|
+
const all = await detectFaces(f, log);
|
|
56
|
+
const big = all.sort((a, b) => b.w * b.h - a.w * a.h)[0];
|
|
57
|
+
if (big) found.push({ t: +f.t.toFixed(3), x: big.x, y: big.y, w: big.w, h: big.h });
|
|
58
|
+
return found.length < MAX_SAMPLES;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return { faces: found, sampled };
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// Cada persona (su cuerpo), las caras vistas DE FRENTE y el texto quemado (una vez por segundo).
|
|
65
|
+
async people(req) {
|
|
66
|
+
const { ffmpeg, ffprobe } = await tools();
|
|
67
|
+
const info = probe(ffprobe, req.path);
|
|
68
|
+
const fps = Number(req.fps ?? 3);
|
|
69
|
+
const every = Math.max(1, Math.round(fps));
|
|
70
|
+
const people = [];
|
|
71
|
+
const frontal = [];
|
|
72
|
+
const text = [];
|
|
73
|
+
let sampled = 0;
|
|
74
|
+
await frames(ffmpeg, req.path, {
|
|
75
|
+
fps,
|
|
76
|
+
max: 960,
|
|
77
|
+
width: info.width,
|
|
78
|
+
height: info.height,
|
|
79
|
+
onFrame: async (f) => {
|
|
80
|
+
const t = +f.t.toFixed(3);
|
|
81
|
+
const scale = info.height / f.h;
|
|
82
|
+
for (const b of await detectPeople(f, log)) people.push({ t, x: b.x, y: b.y, w: b.w, h: b.h });
|
|
83
|
+
for (const c of (await detectFaces({ ...f, scale }, log)).slice(0, 12)) if (c.frontal) frontal.push({ t, x: c.x, y: c.y, w: c.w, h: c.h });
|
|
84
|
+
if (sampled % every === 0) for (const b of await detectText(f, log)) text.push({ t, ...b });
|
|
85
|
+
sampled += 1;
|
|
86
|
+
return people.length < MAX_SAMPLES;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
return { people: people.slice(0, MAX_SAMPLES), faces: frontal.slice(0, MAX_SAMPLES), text: text.slice(0, MAX_SAMPLES), textChecked: true, sampled };
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async clean_voice(req) {
|
|
93
|
+
const { ffmpeg, ffprobe } = await tools();
|
|
94
|
+
return cleanVoice({ ffmpeg, ffprobe, path: req.path, out: req.out, start: req.start ?? null, end: req.end ?? null });
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async structure(req) {
|
|
98
|
+
const { ffmpeg } = await tools();
|
|
99
|
+
return structure({ ffmpeg, path: req.path });
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async analyze(req) {
|
|
103
|
+
const { ffmpeg, ffprobe } = await tools();
|
|
104
|
+
return analyze({ ffmpeg, ffprobe, workdir: req.workdir, media: req.media ?? [], log });
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async edit(req) {
|
|
108
|
+
return edit({
|
|
109
|
+
workdir: req.workdir,
|
|
110
|
+
shots: req.shots ?? [],
|
|
111
|
+
beats: req.beats ?? [],
|
|
112
|
+
allowed: req.allowed ?? null,
|
|
113
|
+
preferred: req.preferred ?? [],
|
|
114
|
+
minScore: Number(req.min_score ?? 0.3),
|
|
115
|
+
minVisual: Number(req.min_visual ?? 0.24),
|
|
116
|
+
log,
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async review(req) {
|
|
121
|
+
return review({ scenes: req.scenes ?? [] });
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function serve({ version, engine }) {
|
|
127
|
+
const ops = createOps({ version, engine });
|
|
128
|
+
const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
129
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
130
|
+
let queue = Promise.resolve();
|
|
131
|
+
lines.on('line', (line) => {
|
|
132
|
+
if (!line.trim()) return;
|
|
133
|
+
queue = queue.then(async () => {
|
|
134
|
+
let id = null;
|
|
135
|
+
try {
|
|
136
|
+
const msg = JSON.parse(line);
|
|
137
|
+
id = msg.id ?? null;
|
|
138
|
+
const op = ops[msg.op];
|
|
139
|
+
if (!op) throw new Error('unknown op');
|
|
140
|
+
send({ id, ok: true, result: await op(msg) });
|
|
141
|
+
} catch (e) {
|
|
142
|
+
log(e?.stack ?? String(e));
|
|
143
|
+
send({ id, ok: false, error: `${e?.name ?? 'Error'}: ${String(e?.message ?? e).slice(0, 400)}` });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
await new Promise((resolve) => lines.on('close', resolve));
|
|
148
|
+
await queue;
|
|
149
|
+
}
|
|
@@ -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
|
@@ -3,14 +3,150 @@
|
|
|
3
3
|
"baseUrl": "https://media.ignix.social/editor/bin",
|
|
4
4
|
"platforms": {
|
|
5
5
|
"darwin-x64": {
|
|
6
|
-
"ffmpeg": {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"ffmpeg": {
|
|
7
|
+
"file": "ffmpeg",
|
|
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
|
+
},
|
|
12
|
+
"ffprobe": {
|
|
13
|
+
"file": "ffprobe",
|
|
14
|
+
"size": 25367080,
|
|
15
|
+
"sha256": "97dae7a58f71a3fa54d11be06c810896504fb70f062396cd466c37f66fe16b1e",
|
|
16
|
+
"build": "ffmpeg 7.1.2 GPL, scripts/build-ffmpeg-macos.sh en Mac Intel"
|
|
17
|
+
},
|
|
18
|
+
"whisper": {
|
|
19
|
+
"file": "whisper-cli",
|
|
20
|
+
"size": 2912480,
|
|
21
|
+
"sha256": "73fc58436c1e1a9d2cc3ea08bddf18bf78270d8c6940c5fd89a3c9cb31535efe",
|
|
22
|
+
"build": "whisper.cpp v1.7.6, scripts/build-whisper-macos.sh"
|
|
23
|
+
}
|
|
9
24
|
},
|
|
10
25
|
"darwin-arm64": {
|
|
11
|
-
"ffmpeg": {
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
"ffmpeg": {
|
|
27
|
+
"file": "ffmpeg",
|
|
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
|
+
},
|
|
32
|
+
"ffprobe": {
|
|
33
|
+
"file": "ffprobe",
|
|
34
|
+
"size": 21074760,
|
|
35
|
+
"sha256": "10c338cd1c804222a6c90c24c7c07f73bb07ee0f2d506eff565ca8f5e9fa9a40",
|
|
36
|
+
"build": "ffmpeg 7.1.2 GPL, CI binaries.yml (macos-15, run 37375441240)"
|
|
37
|
+
},
|
|
38
|
+
"whisper": {
|
|
39
|
+
"file": "whisper-cli",
|
|
40
|
+
"size": 2573144,
|
|
41
|
+
"sha256": "215d93a2edc77f8929311f127f368727859e13833e925652315ca5ea774595ee",
|
|
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)"
|
|
149
|
+
}
|
|
14
150
|
}
|
|
15
151
|
}
|
|
16
152
|
}
|
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
|
},
|