@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,356 @@
|
|
|
1
|
+
// El material del cliente como un ÍNDICE DE PLANOS, y el montaje que lo usa (ADR-0023 E1):
|
|
2
|
+
// · analyze: cada vídeo partido en sus planos reales (cambios de escena; una toma larga en
|
|
3
|
+
// trozos de ~4 s), cada plano con tres fotogramas, su movimiento, dónde está la acción o la
|
|
4
|
+
// cara, su tamaño de plano y su vector CLIP. Lo ya analizado no se repite.
|
|
5
|
+
// · edit: para cada momento del guion, el plano que ENSEÑA lo que se dice (CLIP imagen↔texto),
|
|
6
|
+
// sin repetir fuente seguida, variando el tamaño de plano, y el trozo del plano que mejor
|
|
7
|
+
// encaja; más planos alternativos para los subplanos.
|
|
8
|
+
// · review: la línea de tiempo terminada, juzgada (¿parece un pase de diapositivas?, ¿varía?).
|
|
9
|
+
// Reglas propias de Ignix (no se copia código de OpenMontage).
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { dot, embedImage, embedTexts, pool } from './clip.mjs';
|
|
13
|
+
import { faces as detectFaces } from './detect.mjs';
|
|
14
|
+
import { frames, probe } from './media.mjs';
|
|
15
|
+
import { cuts } from './sound.mjs';
|
|
16
|
+
|
|
17
|
+
const DIM = 512;
|
|
18
|
+
const SHOT_SIZES = ['close-up', 'medium', 'wide'];
|
|
19
|
+
// Frases fijas con las que CLIP compara cada plano (tamaño de plano sin entrenar nada).
|
|
20
|
+
const SIZE_PROMPTS = {
|
|
21
|
+
'close-up': 'an extreme close-up photo of a detail, a face or hands filling the frame',
|
|
22
|
+
medium: 'a medium shot photo of a person from the waist up',
|
|
23
|
+
wide: 'a wide shot photo of a whole room, place or scene',
|
|
24
|
+
};
|
|
25
|
+
const MAX_WINDOWS = 12;
|
|
26
|
+
const TAG_WEIGHT = 0.25;
|
|
27
|
+
const NEAR_DUPLICATE = 0.92;
|
|
28
|
+
|
|
29
|
+
// ── el índice: index.json + un .f32 por plano con [conjunto, etiqueta, fotogramas…]
|
|
30
|
+
function store(workdir) {
|
|
31
|
+
const dir = join(workdir, 'editor-corpus');
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
const indexPath = join(dir, 'index.json');
|
|
34
|
+
const index = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, 'utf8')) : { clips: {} };
|
|
35
|
+
const vecs = new Map();
|
|
36
|
+
const file = (id) => join(dir, `${id.replace(/[^\w.-]/g, '_')}.f32`);
|
|
37
|
+
return {
|
|
38
|
+
index,
|
|
39
|
+
save: () => writeFileSync(indexPath, JSON.stringify(index)),
|
|
40
|
+
put(id, meta, pooled, tag, frameVecs) {
|
|
41
|
+
const all = new Float32Array(DIM * (2 + frameVecs.length));
|
|
42
|
+
all.set(pooled, 0);
|
|
43
|
+
all.set(tag, DIM);
|
|
44
|
+
frameVecs.forEach((v, i) => all.set(v, DIM * (2 + i)));
|
|
45
|
+
writeFileSync(file(id), Buffer.from(all.buffer));
|
|
46
|
+
index.clips[id] = meta;
|
|
47
|
+
vecs.set(id, all);
|
|
48
|
+
},
|
|
49
|
+
vectors(id) {
|
|
50
|
+
if (!vecs.has(id)) {
|
|
51
|
+
const b = readFileSync(file(id));
|
|
52
|
+
vecs.set(id, new Float32Array(b.buffer, b.byteOffset, b.length / 4));
|
|
53
|
+
}
|
|
54
|
+
const all = vecs.get(id);
|
|
55
|
+
const n = all.length / DIM - 2;
|
|
56
|
+
return {
|
|
57
|
+
pooled: all.subarray(0, DIM),
|
|
58
|
+
tag: all.subarray(DIM, 2 * DIM),
|
|
59
|
+
frames: Array.from({ length: n }, (_, i) => all.subarray(DIM * (2 + i), DIM * (3 + i))),
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Movimiento (diferencia media de grises, 0–255) entre dos fotogramas y dónde ocurre (0..1). */
|
|
66
|
+
export function motionOf(a, b, w, h) {
|
|
67
|
+
if (!a || !b) return { motion: 0, focus: null };
|
|
68
|
+
const cols = new Float64Array(w);
|
|
69
|
+
let sum = 0;
|
|
70
|
+
for (let i = 0; i < w * h; i++) {
|
|
71
|
+
const ga = 0.299 * a[i * 3] + 0.587 * a[i * 3 + 1] + 0.114 * a[i * 3 + 2];
|
|
72
|
+
const gb = 0.299 * b[i * 3] + 0.587 * b[i * 3 + 1] + 0.114 * b[i * 3 + 2];
|
|
73
|
+
const d = Math.abs(ga - gb);
|
|
74
|
+
sum += d;
|
|
75
|
+
cols[i % w] += d;
|
|
76
|
+
}
|
|
77
|
+
const motion = sum / (w * h);
|
|
78
|
+
let focus = null;
|
|
79
|
+
if (motion > 2) {
|
|
80
|
+
let tot = 0;
|
|
81
|
+
let acc = 0;
|
|
82
|
+
for (let x = 0; x < w; x++) {
|
|
83
|
+
tot += cols[x];
|
|
84
|
+
acc += cols[x] * x;
|
|
85
|
+
}
|
|
86
|
+
if (tot > 0) focus = acc / tot / w;
|
|
87
|
+
}
|
|
88
|
+
return { motion, focus };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function analyze({ ffmpeg, ffprobe, workdir, media, log = () => {} }) {
|
|
92
|
+
const db = store(workdir);
|
|
93
|
+
const sizeVecs = await embedTexts(SHOT_SIZES.map((s) => SIZE_PROMPTS[s]), log);
|
|
94
|
+
const shots = [];
|
|
95
|
+
for (const m of media) {
|
|
96
|
+
const { id: asset, kind, path } = m;
|
|
97
|
+
const label = String(m.label ?? '').trim();
|
|
98
|
+
let windows = [];
|
|
99
|
+
let faceSamples = [];
|
|
100
|
+
let info = null;
|
|
101
|
+
try {
|
|
102
|
+
info = probe(ffprobe, path);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
log(`analyze ${asset}: ${e.message}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (kind === 'video') {
|
|
108
|
+
const duration = Number(m.duration) || info.duration;
|
|
109
|
+
// Dónde está la cara (la más grande) dos veces por segundo: encuadre y tamaño de plano.
|
|
110
|
+
await frames(ffmpeg, path, {
|
|
111
|
+
fps: 2,
|
|
112
|
+
max: 640,
|
|
113
|
+
width: info.width,
|
|
114
|
+
height: info.height,
|
|
115
|
+
onFrame: async (f) => {
|
|
116
|
+
const found = await detectFaces(f, log);
|
|
117
|
+
const big = found.sort((a, b) => b.w * b.h - a.w * a.h)[0];
|
|
118
|
+
if (big) faceSamples.push({ t: f.t, x: big.x + big.w / 2, area: big.w * big.h });
|
|
119
|
+
},
|
|
120
|
+
}).catch((e) => log(`faces ${asset}: ${e.message}`));
|
|
121
|
+
const found = await cuts(ffmpeg, path, { minSec: 0.8, maxSec: 24 * 3600 }).catch(() => []);
|
|
122
|
+
const bounds = [0, ...found, duration];
|
|
123
|
+
windows = [];
|
|
124
|
+
for (let i = 0; i + 1 < bounds.length; i++) if (bounds[i + 1] - bounds[i] >= 0.8) windows.push([bounds[i], bounds[i + 1]]);
|
|
125
|
+
if (!windows.length && duration > 0) windows = [[0, duration]];
|
|
126
|
+
// Una toma larga: trozos de ~4 s, para poder elegir cada uno.
|
|
127
|
+
const split = [];
|
|
128
|
+
for (const [a, b] of windows) {
|
|
129
|
+
const n = Math.max(1, Math.floor((b - a) / 4));
|
|
130
|
+
for (let k = 0; k < n; k++) split.push([a + (k * (b - a)) / n, a + ((k + 1) * (b - a)) / n]);
|
|
131
|
+
}
|
|
132
|
+
windows = split.slice(0, MAX_WINDOWS);
|
|
133
|
+
} else windows = [[0, 0]];
|
|
134
|
+
|
|
135
|
+
for (const [k, [a, b]] of windows.entries()) {
|
|
136
|
+
const clip = `${asset}~${k}`;
|
|
137
|
+
const known = db.index.clips[clip];
|
|
138
|
+
if (known) {
|
|
139
|
+
shots.push({ clip, asset, start: a, end: b, motion: known.motion, size: known.size, focus: known.focus });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const grabbed = [];
|
|
143
|
+
if (kind === 'video') {
|
|
144
|
+
await frames(ffmpeg, path, { fps: 3 / Math.max(0.1, b - a), max: 448, width: info.width, height: info.height, start: a, end: b, onFrame: (f) => void grabbed.push(f) }).catch(() => {});
|
|
145
|
+
} else {
|
|
146
|
+
await frames(ffmpeg, path, { still: true, max: 448, width: info.width, height: info.height, onFrame: (f) => void grabbed.push(f) }).catch(() => {});
|
|
147
|
+
}
|
|
148
|
+
if (!grabbed.length) continue;
|
|
149
|
+
const g = grabbed.slice(0, 3);
|
|
150
|
+
let { motion, focus } = kind === 'video' && g.length >= 2 ? motionOf(g[0].rgb, g[Math.floor(g.length / 2)].rgb, g[0].w, g[0].h) : { motion: 0, focus: null };
|
|
151
|
+
let faceSize = null;
|
|
152
|
+
const inside = faceSamples.filter((s) => s.t >= a && s.t <= b);
|
|
153
|
+
if (inside.length) {
|
|
154
|
+
// Una cara en el plano lo encuadra; si no, dónde está la acción.
|
|
155
|
+
const tot = inside.reduce((s, x) => s + x.area, 0);
|
|
156
|
+
focus = inside.reduce((s, x) => s + x.x * x.area, 0) / tot;
|
|
157
|
+
// El tamaño de la cara en el cuadro ES el tamaño de plano (gramática de montaje).
|
|
158
|
+
const side = Math.sqrt(Math.max(...inside.map((x) => x.area)));
|
|
159
|
+
faceSize = side > 0.3 ? 'close-up' : side < 0.1 ? 'wide' : 'medium';
|
|
160
|
+
} else if (kind !== 'video') {
|
|
161
|
+
const f = (await detectFaces(g[0], log)).sort((p, q) => q.w * q.h - p.w * p.h)[0];
|
|
162
|
+
if (f) focus = f.x + f.w / 2;
|
|
163
|
+
}
|
|
164
|
+
const frameVecs = [];
|
|
165
|
+
for (const f of g) frameVecs.push(await embedImage(f, log));
|
|
166
|
+
const pooled = pool(frameVecs);
|
|
167
|
+
const [tag] = await embedTexts([label || (kind === 'video' ? 'a video clip' : 'a photo')], log);
|
|
168
|
+
const size = faceSize ?? SHOT_SIZES[sizeVecs.map((v) => dot(pooled, v)).reduce((bi, v, i, arr) => (v > arr[bi] ? i : bi), 0)];
|
|
169
|
+
const fx = focus === null || focus === undefined ? null : +Math.min(1, Math.max(0, focus)).toFixed(3);
|
|
170
|
+
const times = g.map((f) => +f.t.toFixed(3));
|
|
171
|
+
db.put(clip, { asset, kind, label, start: a, end: b, duration: kind === 'video' ? +(b - a).toFixed(3) : 0, motion, size, focus: fx, times }, pooled, tag, frameVecs);
|
|
172
|
+
shots.push({ clip, asset, start: a, end: b, motion, size, focus: fx });
|
|
173
|
+
}
|
|
174
|
+
db.save();
|
|
175
|
+
}
|
|
176
|
+
db.save();
|
|
177
|
+
return { shots, corpus: Object.keys(db.index.clips).length };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** El trozo del plano cuyos fotogramas se parecen más al momento. */
|
|
181
|
+
function windowFor(shot, meta, frameVecs, seconds, q) {
|
|
182
|
+
const a = Number(shot.start);
|
|
183
|
+
const b = Number(shot.end);
|
|
184
|
+
if (b - a <= seconds + 0.2) return [a, b];
|
|
185
|
+
let bestT = (a + b) / 2;
|
|
186
|
+
if (meta?.times?.length && frameVecs.length) {
|
|
187
|
+
let best = -Infinity;
|
|
188
|
+
frameVecs.forEach((v, i) => {
|
|
189
|
+
const s = dot(v, q);
|
|
190
|
+
if (s > best) {
|
|
191
|
+
best = s;
|
|
192
|
+
bestT = meta.times[i];
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const start = Math.min(Math.max(a + 0.1, bestT - seconds / 2), b - seconds - 0.05);
|
|
197
|
+
return [+start.toFixed(2), +(start + seconds).toFixed(2)];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function edit({ workdir, shots: given = [], beats, allowed = null, preferred = [], minScore = 0.3, minVisual = 0.24, log = () => {} }) {
|
|
201
|
+
const db = store(workdir);
|
|
202
|
+
const shotsById = new Map(given.map((s) => [s.clip, s]));
|
|
203
|
+
const allow = allowed ? new Set(allowed) : null;
|
|
204
|
+
const prefer = new Set(preferred);
|
|
205
|
+
const queries = beats.map((b) => String(b.query ?? '').trim().slice(0, 300) || 'a scene');
|
|
206
|
+
const qvecs = await embedTexts(queries, log);
|
|
207
|
+
const clips = Object.entries(db.index.clips).map(([id, meta]) => ({ id, ...meta }));
|
|
208
|
+
const visual = (id, i) => dot(db.vectors(id).pooled, qvecs[i]);
|
|
209
|
+
|
|
210
|
+
// Para cada momento: los planos ordenados por lo que ENSEÑAN (CLIP) y, menos, por su etiqueta.
|
|
211
|
+
const ranked = beats.map((beat, i) => {
|
|
212
|
+
const fixed = beat.asset;
|
|
213
|
+
const floor = Number(beat.min_visual ?? minVisual);
|
|
214
|
+
return clips
|
|
215
|
+
.filter((c) => (beat.kind === 'video' || beat.kind === 'image' ? c.kind === beat.kind : true))
|
|
216
|
+
.filter((c) => !allow || allow.has(c.asset))
|
|
217
|
+
.filter((c) => !fixed || c.asset === fixed)
|
|
218
|
+
.map((c) => {
|
|
219
|
+
const v = db.vectors(c.id);
|
|
220
|
+
const vis = dot(v.pooled, qvecs[i]);
|
|
221
|
+
return { score: (1 - TAG_WEIGHT) * vis + TAG_WEIGHT * dot(v.tag, qvecs[i]), visual: vis, clip: c };
|
|
222
|
+
})
|
|
223
|
+
.filter((x) => fixed || (x.score >= minScore && x.visual >= floor))
|
|
224
|
+
.sort((p, q) => q.score - p.score);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const used = new Set();
|
|
228
|
+
const picks = new Array(beats.length).fill(null);
|
|
229
|
+
const choose = (i, avoid) => {
|
|
230
|
+
const prev = i > 0 ? picks[i - 1]?.clip : null;
|
|
231
|
+
const next = i + 1 < picks.length ? picks[i + 1]?.clip : null;
|
|
232
|
+
const fixed = beats[i].asset;
|
|
233
|
+
let cands = ranked[i].filter((x) => !used.has(x.clip.id) && !avoid.has(x.clip.id) && (fixed || ((!prev || x.clip.asset !== prev.asset) && (!next || x.clip.asset !== next.asset))));
|
|
234
|
+
if (!cands.length) return null;
|
|
235
|
+
// Lo que adjuntaron para esta pieza antes que el resto de su biblioteca.
|
|
236
|
+
const mine = cands.filter((x) => prefer.has(x.clip.asset));
|
|
237
|
+
if (mine.length) cands = [...mine, ...cands.filter((x) => !prefer.has(x.clip.asset))];
|
|
238
|
+
let best = cands[0];
|
|
239
|
+
// Variar el tamaño de plano cuando hay otro casi igual de bueno.
|
|
240
|
+
if (prev?.size && best.clip.size === prev.size) {
|
|
241
|
+
const alt = cands.find((x) => x.score >= best.score - 0.02 && x.clip.size !== prev.size);
|
|
242
|
+
if (alt) best = alt;
|
|
243
|
+
}
|
|
244
|
+
return best;
|
|
245
|
+
};
|
|
246
|
+
for (let i = 0; i < beats.length; i++) {
|
|
247
|
+
picks[i] = choose(i, new Set());
|
|
248
|
+
if (picks[i]) used.add(picks[i].clip.id);
|
|
249
|
+
}
|
|
250
|
+
// Casi duplicados (dos planos que se ven iguales): el segundo se cambia por otro distinto.
|
|
251
|
+
let swapped = 0;
|
|
252
|
+
const kept = [];
|
|
253
|
+
for (let i = 0; i < picks.length; i++) {
|
|
254
|
+
const p = picks[i];
|
|
255
|
+
if (!p) continue;
|
|
256
|
+
const pv = db.vectors(p.clip.id).pooled;
|
|
257
|
+
const dup = kept.some((id) => dot(db.vectors(id).pooled, pv) > NEAR_DUPLICATE);
|
|
258
|
+
if (dup && !beats[i].asset) {
|
|
259
|
+
used.delete(p.clip.id);
|
|
260
|
+
const alt = choose(i, new Set([p.clip.id, ...kept]));
|
|
261
|
+
picks[i] = alt ?? p;
|
|
262
|
+
used.add(picks[i].clip.id);
|
|
263
|
+
if (alt) swapped += 1;
|
|
264
|
+
}
|
|
265
|
+
kept.push(picks[i].clip.id);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const cut = (i, x, seconds) => {
|
|
269
|
+
const c = x.clip;
|
|
270
|
+
const shot = shotsById.get(c.id) ?? { start: c.start ?? 0, end: c.end ?? c.duration ?? 0 };
|
|
271
|
+
const [a, b] = c.kind === 'video' ? windowFor(shot, c, db.vectors(c.id).frames, seconds, qvecs[i]) : [0, 0];
|
|
272
|
+
return {
|
|
273
|
+
beat: beats[i].beat,
|
|
274
|
+
asset: c.asset,
|
|
275
|
+
clip: c.id,
|
|
276
|
+
kind: c.kind,
|
|
277
|
+
in: a,
|
|
278
|
+
out: b,
|
|
279
|
+
score: +x.score.toFixed(4),
|
|
280
|
+
visual: +visual(c.id, i).toFixed(4),
|
|
281
|
+
size: c.size || null,
|
|
282
|
+
motion: +Number(c.motion || 0).toFixed(2),
|
|
283
|
+
focus: c.focus ?? null,
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
const cutsOut = [];
|
|
287
|
+
const unmatched = [];
|
|
288
|
+
picks.forEach((p, i) => (p ? cutsOut.push(cut(i, p, Number(beats[i].seconds))) : unmatched.push(beats[i].beat)));
|
|
289
|
+
// Planos alternativos (subplanos): fuentes distintas, planos sin usar.
|
|
290
|
+
const alternates = [];
|
|
291
|
+
beats.forEach((beat, i) => {
|
|
292
|
+
const want = Number(beat.alternates ?? 0);
|
|
293
|
+
if (want <= 0 || beat.asset) return;
|
|
294
|
+
const seen = new Set([picks[i]?.clip.asset]);
|
|
295
|
+
const part = Number(beat.seconds) / (want + 1);
|
|
296
|
+
for (const x of ranked[i]) {
|
|
297
|
+
if (alternates.filter((a) => a.beat === beat.beat).length >= want) break;
|
|
298
|
+
if (used.has(x.clip.id) || seen.has(x.clip.asset)) continue;
|
|
299
|
+
used.add(x.clip.id);
|
|
300
|
+
seen.add(x.clip.asset);
|
|
301
|
+
alternates.push(cut(i, x, Math.max(1.2, part)));
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
return { cuts: cutsOut, alternates, unmatched, swapped };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Juicio de la línea de tiempo terminada. Riesgo de «pase de diapositivas» (0..1, más alto =
|
|
309
|
+
* peor) por dimensiones, y variedad con sus fallos concretos.
|
|
310
|
+
*/
|
|
311
|
+
export function review({ scenes }) {
|
|
312
|
+
const n = scenes.length || 1;
|
|
313
|
+
const pairs = Math.max(1, scenes.length - 1);
|
|
314
|
+
const same = (f) => scenes.slice(1).filter((s, i) => f(s) && f(s) === f(scenes[i])).length / pairs;
|
|
315
|
+
const longestRun = (f) => {
|
|
316
|
+
let best = 0;
|
|
317
|
+
let run = 0;
|
|
318
|
+
scenes.forEach((s, i) => {
|
|
319
|
+
run = i && f(s) === f(scenes[i - 1]) ? run + 1 : 1;
|
|
320
|
+
best = Math.max(best, run);
|
|
321
|
+
});
|
|
322
|
+
return best;
|
|
323
|
+
};
|
|
324
|
+
const dimensions = {
|
|
325
|
+
still_share: scenes.filter((s) => (s.type === 'still' || s.type === 'text_card') && s.shot_language?.camera_movement === 'static').length / n,
|
|
326
|
+
text_cards: scenes.filter((s) => s.type === 'text_card').length / n,
|
|
327
|
+
camera_monotony: same((s) => s.shot_language?.camera_movement),
|
|
328
|
+
size_monotony: same((s) => (s.shot_language?.shot_size === 'none' ? null : s.shot_language?.shot_size)),
|
|
329
|
+
type_monotony: longestRun((s) => s.type) / n,
|
|
330
|
+
};
|
|
331
|
+
const values = Object.values(dimensions);
|
|
332
|
+
const average = +(values.reduce((a, b) => a + b, 0) / values.length).toFixed(3);
|
|
333
|
+
const violations = [];
|
|
334
|
+
const runs = (label, f, min) => {
|
|
335
|
+
let run = 1;
|
|
336
|
+
for (let i = 1; i <= scenes.length; i++) {
|
|
337
|
+
if (i < scenes.length && f(scenes[i]) && f(scenes[i]) === f(scenes[i - 1])) run += 1;
|
|
338
|
+
else {
|
|
339
|
+
if (run >= min) violations.push(`${run} consecutive scenes with the same ${label} (${f(scenes[i - 1])}), ending at ${scenes[i - 1].id}`);
|
|
340
|
+
run = 1;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
runs('shot size', (s) => (s.shot_language?.shot_size === 'none' ? null : s.shot_language?.shot_size), 3);
|
|
345
|
+
runs('camera movement', (s) => s.shot_language?.camera_movement, 3);
|
|
346
|
+
runs('type', (s) => (s.type === 'text_card' ? 'text_card' : null), 3);
|
|
347
|
+
const score = +Math.max(0, 1 - violations.length / n).toFixed(3);
|
|
348
|
+
return {
|
|
349
|
+
slideshow_risk: {
|
|
350
|
+
average,
|
|
351
|
+
verdict: average < 0.35 ? 'low' : average < 0.6 ? 'medium' : 'high',
|
|
352
|
+
dimensions: Object.fromEntries(Object.entries(dimensions).map(([k, v]) => [k, +v.toFixed(3)])),
|
|
353
|
+
},
|
|
354
|
+
variation: { score, verdict: violations.length ? 'needs_variety' : 'varied', violations: violations.slice(0, 6), metadata_only: 0 },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
@@ -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
|
+
}
|