@lumy-pack/scene-sieve 0.1.0 → 0.2.1
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/README.md +128 -42
- package/dist/{commands → cli/commands}/Sieve.d.ts +5 -0
- package/dist/{errors.d.ts → cli/errors/classify-error.d.ts} +5 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/{utils → cli/options}/parse-options.d.ts +11 -2
- package/dist/cli.mjs +1453 -505
- package/dist/constants/package-version.d.ts +2 -0
- package/dist/constants/pipeline-defaults.d.ts +33 -0
- package/dist/core/{analyzer.d.ts → analyzer/analyzer.d.ts} +11 -6
- package/dist/core/{dbscan.d.ts → analyzer/clustering/dbscan.d.ts} +1 -1
- package/dist/core/analyzer/constants/vision-tuning.d.ts +12 -0
- package/dist/core/analyzer/features/feature-diff.d.ts +14 -0
- package/dist/core/analyzer/features/frame-features.d.ts +32 -0
- package/dist/core/analyzer/index.d.ts +3 -0
- package/dist/core/constants/workspace-layout.d.ts +9 -0
- package/dist/core/{extractor.d.ts → extractor/extractor.d.ts} +6 -4
- package/dist/core/extractor/index.d.ts +1 -0
- package/dist/core/index.d.ts +9 -9
- package/dist/core/input-resolver/index.d.ts +1 -0
- package/dist/core/{input-resolver.d.ts → input-resolver/input-resolver.d.ts} +11 -1
- package/dist/core/input-resolver/validation/validate-options.d.ts +8 -0
- package/dist/core/orchestrator/index.d.ts +3 -0
- package/dist/core/orchestrator/orchestrator.d.ts +8 -0
- package/dist/core/{run-in-worker.d.ts → orchestrator/worker/run-in-worker.d.ts} +5 -1
- package/dist/core/pruner/index.d.ts +1 -0
- package/dist/core/{pruner.d.ts → pruner/pruner.d.ts} +2 -2
- package/dist/{utils/math.d.ts → core/pruner/scoring/normalize-scores.d.ts} +4 -0
- package/dist/core/segmenter/index.d.ts +1 -0
- package/dist/core/{segmenter.d.ts → segmenter/segmenter.d.ts} +11 -11
- package/dist/core/utils/metadata/build-edge-metadata.d.ts +11 -0
- package/dist/core/utils/metadata/build-frame-metadata.d.ts +20 -0
- package/dist/core/utils/metadata/build-sieve-metadata.d.ts +14 -0
- package/dist/core/utils/metadata/build-tool-metadata.d.ts +8 -0
- package/dist/core/utils/metadata/build-video-metadata.d.ts +14 -0
- package/dist/core/utils/metadata/change/build-frame-change.d.ts +20 -0
- package/dist/core/utils/metadata/change/select-regions.d.ts +8 -0
- package/dist/core/utils/metadata/change/union-area/y-coverage-tree.d.ts +26 -0
- package/dist/core/utils/metadata/change/union-area.d.ts +7 -0
- package/dist/core/utils/metadata/scale-bounding-box.d.ts +11 -0
- package/dist/core/utils/output/finalize-selection.d.ts +15 -0
- package/dist/core/utils/sheet/build-tile-label-svg.d.ts +8 -0
- package/dist/core/utils/sheet/format-tile-label.d.ts +7 -0
- package/dist/core/utils/sheet/render-contact-sheet.d.ts +19 -0
- package/dist/core/utils/sheet/sample-tile-frames.d.ts +10 -0
- package/dist/core/workspace/index.d.ts +1 -0
- package/dist/core/{workspace.d.ts → workspace/workspace.d.ts} +11 -2
- package/dist/index.cjs +1531 -559
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +1533 -558
- package/dist/pipeline-worker.mjs +1213 -404
- package/dist/types/index.d.ts +173 -0
- package/package.json +14 -11
- package/dist/constants.d.ts +0 -32
- package/dist/core/orchestrator.d.ts +0 -2
- /package/dist/{utils → cli/commands}/command-registry.d.ts +0 -0
- /package/dist/{components → cli/components}/PhaseStep.d.ts +0 -0
- /package/dist/{components → cli/components}/ProgressBar.d.ts +0 -0
- /package/dist/core/{pipeline-worker.d.ts → orchestrator/worker/pipeline-worker.d.ts} +0 -0
- /package/dist/{utils → core/pruner/heap}/min-heap.d.ts +0 -0
- /package/dist/{utils → core/segmenter/scheduling}/concurrency.d.ts +0 -0
- /package/dist/{utils → core/utils/filesystem}/paths.d.ts +0 -0
- /package/dist/{utils → logging}/logger.d.ts +0 -0
package/dist/pipeline-worker.mjs
CHANGED
|
@@ -4,14 +4,15 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { filter, map } from "@winglet/common-utils";
|
|
5
5
|
import pc from "picocolors";
|
|
6
6
|
import sharp from "sharp";
|
|
7
|
-
import {
|
|
8
|
-
import { basename, extname, join, resolve } from "node:path";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
9
8
|
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
9
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
11
|
import { path } from "@ffprobe-installer/ffprobe";
|
|
11
12
|
import { execa } from "execa";
|
|
12
13
|
import ffmpegPath from "ffmpeg-static";
|
|
13
14
|
|
|
14
|
-
//#region src/
|
|
15
|
+
//#region src/logging/logger.ts
|
|
15
16
|
let debugMode = false;
|
|
16
17
|
let jsonMode = false;
|
|
17
18
|
function setDebugMode(enabled) {
|
|
@@ -36,31 +37,32 @@ const logger = {
|
|
|
36
37
|
console.error(`${pc.red("error")} ${message}`);
|
|
37
38
|
},
|
|
38
39
|
debug(message) {
|
|
39
|
-
if (debugMode)
|
|
40
|
-
|
|
40
|
+
if (debugMode) {
|
|
41
|
+
if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
|
|
42
|
+
else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
43
|
+
}
|
|
41
44
|
}
|
|
42
45
|
};
|
|
43
46
|
|
|
44
47
|
//#endregion
|
|
45
|
-
//#region src/constants.ts
|
|
46
|
-
const APP_NAME = "scene-sieve";
|
|
48
|
+
//#region src/constants/pipeline-defaults.ts
|
|
47
49
|
const DEFAULT_THRESHOLD = .5;
|
|
48
|
-
const NORMALIZATION_ALPHA = .4;
|
|
49
|
-
const NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
50
|
-
const WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
51
|
-
const TEMP_BASE_DIR = tmpdir();
|
|
52
|
-
const FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
53
|
-
const FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
54
|
-
const DBSCAN_ALPHA = .03;
|
|
55
50
|
const IOU_THRESHOLD = .9;
|
|
51
|
+
/** Tile-height fraction used for label font size. */
|
|
52
|
+
const SHEET_LABEL_HEIGHT_RATIO = .07;
|
|
53
|
+
/** Fixed contact sheet output name. */
|
|
54
|
+
const SHEET_FILE_NAME = "sheet.jpg";
|
|
55
|
+
/** Fixed metadata document output name. */
|
|
56
|
+
const METADATA_FILE_NAME = ".metadata.json";
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/core/analyzer/constants/vision-tuning.ts
|
|
60
|
+
const DBSCAN_ALPHA = .03;
|
|
56
61
|
const DECAY_LAMBDA = .95;
|
|
57
62
|
const MATCH_DISTANCE_THRESHOLD = .25;
|
|
58
|
-
function getTempWorkspaceDir(sessionId) {
|
|
59
|
-
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
60
|
-
}
|
|
61
63
|
|
|
62
64
|
//#endregion
|
|
63
|
-
//#region src/core/dbscan.ts
|
|
65
|
+
//#region src/core/analyzer/clustering/dbscan.ts
|
|
64
66
|
const UNVISITED = -2;
|
|
65
67
|
const NOISE = -1;
|
|
66
68
|
/**
|
|
@@ -140,7 +142,106 @@ function findNeighbors(points, idx, epsSquared) {
|
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
//#endregion
|
|
143
|
-
//#region src/core/analyzer.ts
|
|
145
|
+
//#region src/core/analyzer/features/feature-diff.ts
|
|
146
|
+
/**
|
|
147
|
+
* Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
|
|
148
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
149
|
+
* @param prev - Previous frame's live features, owned by the caller.
|
|
150
|
+
* @param next - Next frame's live features, owned by the caller.
|
|
151
|
+
* @returns Unmatched next-frame coordinates without changing input ownership.
|
|
152
|
+
* @throws Propagates matching errors after releasing temporary native handles.
|
|
153
|
+
*/
|
|
154
|
+
function computeNewPoints(cvLib, prev, next) {
|
|
155
|
+
let matcher = null;
|
|
156
|
+
let matches = null;
|
|
157
|
+
try {
|
|
158
|
+
const matchedIndices = /* @__PURE__ */ new Set();
|
|
159
|
+
if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
|
|
160
|
+
matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
161
|
+
matches = new cvLib.DMatchVectorVector();
|
|
162
|
+
matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
|
|
163
|
+
for (let i = 0; i < matches.size(); i++) {
|
|
164
|
+
const pair = matches.get(i);
|
|
165
|
+
try {
|
|
166
|
+
if (pair.size() < 2) continue;
|
|
167
|
+
const best = pair.get(0);
|
|
168
|
+
const second = pair.get(1);
|
|
169
|
+
if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
|
|
170
|
+
} finally {
|
|
171
|
+
pair.delete();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
matcher?.delete();
|
|
176
|
+
}
|
|
177
|
+
const points = [];
|
|
178
|
+
for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
|
|
179
|
+
const { x, y } = next.keypoints.get(i).pt;
|
|
180
|
+
points.push({
|
|
181
|
+
x,
|
|
182
|
+
y
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return points;
|
|
186
|
+
} finally {
|
|
187
|
+
matches?.delete();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/core/analyzer/features/frame-features.ts
|
|
193
|
+
/**
|
|
194
|
+
* Detect one frame's features without retaining its image or mask.
|
|
195
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
196
|
+
* @param akaze - Detector owned and released by the caller.
|
|
197
|
+
* @param frame - Grayscale bytes with matching width and height.
|
|
198
|
+
* @returns Feature handles that the caller must delete.
|
|
199
|
+
* @throws Propagates native errors after releasing partial allocations.
|
|
200
|
+
*/
|
|
201
|
+
function computeFrameFeatures(cvLib, akaze, frame) {
|
|
202
|
+
let image = null;
|
|
203
|
+
let mask = null;
|
|
204
|
+
let keypoints = null;
|
|
205
|
+
let descriptors = null;
|
|
206
|
+
try {
|
|
207
|
+
image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
|
|
208
|
+
image.data.set(frame.data);
|
|
209
|
+
mask = new cvLib.Mat();
|
|
210
|
+
keypoints = new cvLib.KeyPointVector();
|
|
211
|
+
descriptors = new cvLib.Mat();
|
|
212
|
+
akaze.detectAndCompute(image, mask, keypoints, descriptors);
|
|
213
|
+
const ownedKeypoints = keypoints;
|
|
214
|
+
const ownedDescriptors = descriptors;
|
|
215
|
+
let deleted = false;
|
|
216
|
+
const features = {
|
|
217
|
+
width: frame.width,
|
|
218
|
+
height: frame.height,
|
|
219
|
+
keypoints: ownedKeypoints,
|
|
220
|
+
descriptors: ownedDescriptors,
|
|
221
|
+
/** Release the transferred handles exactly once. */
|
|
222
|
+
delete() {
|
|
223
|
+
if (deleted) return;
|
|
224
|
+
deleted = true;
|
|
225
|
+
try {
|
|
226
|
+
ownedKeypoints.delete();
|
|
227
|
+
} finally {
|
|
228
|
+
ownedDescriptors.delete();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
keypoints = null;
|
|
233
|
+
descriptors = null;
|
|
234
|
+
return features;
|
|
235
|
+
} finally {
|
|
236
|
+
image?.delete();
|
|
237
|
+
mask?.delete();
|
|
238
|
+
keypoints?.delete();
|
|
239
|
+
descriptors?.delete();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/core/analyzer/analyzer.ts
|
|
144
245
|
const OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
145
246
|
const require = createRequire(import.meta.url);
|
|
146
247
|
let cvReady = null;
|
|
@@ -263,77 +364,6 @@ var IoUTracker = class {
|
|
|
263
364
|
return maxWeight;
|
|
264
365
|
}
|
|
265
366
|
};
|
|
266
|
-
async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
267
|
-
const cv = cvLib;
|
|
268
|
-
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
269
|
-
mat1.data.set(frame1.data);
|
|
270
|
-
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
271
|
-
mat2.data.set(frame2.data);
|
|
272
|
-
const kp1 = new cvLib.KeyPointVector();
|
|
273
|
-
const kp2 = new cvLib.KeyPointVector();
|
|
274
|
-
const desc1 = new cvLib.Mat();
|
|
275
|
-
const desc2 = new cvLib.Mat();
|
|
276
|
-
const mask1 = new cvLib.Mat();
|
|
277
|
-
const mask2 = new cvLib.Mat();
|
|
278
|
-
const akaze = new cvLib.AKAZE();
|
|
279
|
-
let matches = null;
|
|
280
|
-
try {
|
|
281
|
-
akaze.detectAndCompute(mat1, mask1, kp1, desc1);
|
|
282
|
-
akaze.detectAndCompute(mat2, mask2, kp2, desc2);
|
|
283
|
-
const matchedKp1Indices = /* @__PURE__ */ new Set();
|
|
284
|
-
const matchedKp2Indices = /* @__PURE__ */ new Set();
|
|
285
|
-
if (desc1.rows > 0 && desc2.rows > 0) {
|
|
286
|
-
const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
287
|
-
try {
|
|
288
|
-
matches = new cvLib.DMatchVectorVector();
|
|
289
|
-
matcher.knnMatch(desc1, desc2, matches, 2);
|
|
290
|
-
for (let i = 0; i < matches.size(); i++) {
|
|
291
|
-
const pair = matches.get(i);
|
|
292
|
-
if (pair.size() < 2) continue;
|
|
293
|
-
const m0 = pair.get(0);
|
|
294
|
-
const m1 = pair.get(1);
|
|
295
|
-
if (m0.distance < .25 * m1.distance) {
|
|
296
|
-
matchedKp1Indices.add(m0.queryIdx);
|
|
297
|
-
matchedKp2Indices.add(m0.trainIdx);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
} finally {
|
|
301
|
-
matcher.delete();
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
const sNew = [];
|
|
305
|
-
for (let i = 0; i < kp2.size(); i++) if (!matchedKp2Indices.has(i)) {
|
|
306
|
-
const pt = kp2.get(i).pt;
|
|
307
|
-
sNew.push({
|
|
308
|
-
x: pt.x,
|
|
309
|
-
y: pt.y
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
const sLoss = [];
|
|
313
|
-
for (let i = 0; i < kp1.size(); i++) if (!matchedKp1Indices.has(i)) {
|
|
314
|
-
const pt = kp1.get(i).pt;
|
|
315
|
-
sLoss.push({
|
|
316
|
-
x: pt.x,
|
|
317
|
-
y: pt.y
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
return {
|
|
321
|
-
sNew,
|
|
322
|
-
sLoss
|
|
323
|
-
};
|
|
324
|
-
} finally {
|
|
325
|
-
mat1.delete();
|
|
326
|
-
mat2.delete();
|
|
327
|
-
kp1.delete();
|
|
328
|
-
kp2.delete();
|
|
329
|
-
desc1.delete();
|
|
330
|
-
desc2.delete();
|
|
331
|
-
mask1.delete();
|
|
332
|
-
mask2.delete();
|
|
333
|
-
akaze.delete();
|
|
334
|
-
if (matches) matches.delete();
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
367
|
/**
|
|
338
368
|
* Pixel-level difference fallback for AKAZE blind spots.
|
|
339
369
|
*
|
|
@@ -348,17 +378,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
|
348
378
|
* 3. threshold → binary mask of significant changes
|
|
349
379
|
* 4. findContours → bounding rects of changed regions
|
|
350
380
|
* 5. Grid sampling within each bounding rect → Point2D[]
|
|
381
|
+
*
|
|
382
|
+
* @param cvLib - Initialized OpenCV runtime shared by the analyzer.
|
|
383
|
+
* @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
|
|
384
|
+
* @param frame2 - Next grayscale frame, with the same dimensions as frame1.
|
|
385
|
+
* @returns Grid-sampled points from changed regions.
|
|
386
|
+
* @throws Propagates allocation or OpenCV errors after releasing acquired handles.
|
|
351
387
|
*/
|
|
352
388
|
function computePixelDiff(cvLib, frame1, frame2) {
|
|
353
389
|
const cv = cvLib;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
390
|
+
let mat1 = null;
|
|
391
|
+
let mat2 = null;
|
|
392
|
+
let diff = null;
|
|
393
|
+
let blurred = null;
|
|
394
|
+
let binary = null;
|
|
395
|
+
let contours = null;
|
|
396
|
+
let hierarchy = null;
|
|
361
397
|
try {
|
|
398
|
+
mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
399
|
+
mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
400
|
+
diff = new cv.Mat();
|
|
401
|
+
blurred = new cv.Mat();
|
|
402
|
+
binary = new cv.Mat();
|
|
403
|
+
contours = new cv.MatVector();
|
|
404
|
+
hierarchy = new cv.Mat();
|
|
362
405
|
mat1.data.set(frame1.data);
|
|
363
406
|
mat2.data.set(frame2.data);
|
|
364
407
|
cv.absdiff(mat1, mat2, diff);
|
|
@@ -369,22 +412,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
|
|
|
369
412
|
const points = [];
|
|
370
413
|
for (let c = 0; c < contours.size(); c++) {
|
|
371
414
|
const contour = contours.get(c);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
x
|
|
376
|
-
|
|
377
|
-
|
|
415
|
+
try {
|
|
416
|
+
const rect = cv.boundingRect(contour);
|
|
417
|
+
if (rect.width * rect.height < 100) continue;
|
|
418
|
+
for (let y = rect.y; y < rect.y + rect.height; y += 8) for (let x = rect.x; x < rect.x + rect.width; x += 8) points.push({
|
|
419
|
+
x,
|
|
420
|
+
y
|
|
421
|
+
});
|
|
422
|
+
} finally {
|
|
423
|
+
contour.delete();
|
|
424
|
+
}
|
|
378
425
|
}
|
|
379
426
|
return points;
|
|
380
427
|
} finally {
|
|
381
|
-
mat1
|
|
382
|
-
mat2
|
|
383
|
-
diff
|
|
384
|
-
blurred
|
|
385
|
-
binary
|
|
386
|
-
contours
|
|
387
|
-
hierarchy
|
|
428
|
+
mat1?.delete();
|
|
429
|
+
mat2?.delete();
|
|
430
|
+
diff?.delete();
|
|
431
|
+
blurred?.delete();
|
|
432
|
+
binary?.delete();
|
|
433
|
+
contours?.delete();
|
|
434
|
+
hierarchy?.delete();
|
|
388
435
|
}
|
|
389
436
|
}
|
|
390
437
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
@@ -403,47 +450,91 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
|
|
|
403
450
|
}
|
|
404
451
|
return gain;
|
|
405
452
|
}
|
|
406
|
-
|
|
453
|
+
/**
|
|
454
|
+
* Analyze one batch, retaining its boundary frame for the following batch.
|
|
455
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
456
|
+
* @param akaze - Detector owned by analyzeFrames.
|
|
457
|
+
* @param frames - Boundary frame followed by new adjacent frames.
|
|
458
|
+
* @param carry - Boundary bytes and live features transferred from the previous batch.
|
|
459
|
+
* @param scale - Maximum preprocessing width.
|
|
460
|
+
* @param tracker - Stateful animation tracker shared across batches.
|
|
461
|
+
* @param pairOffset - Global position of this batch's first pair.
|
|
462
|
+
* @returns Scores, pair failure count, and ownership of the final frame's features.
|
|
463
|
+
*/
|
|
464
|
+
async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
|
|
407
465
|
const edges = [];
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
466
|
+
let failures = 0;
|
|
467
|
+
let prev = carry?.features ?? null;
|
|
468
|
+
let next = null;
|
|
469
|
+
try {
|
|
470
|
+
const preprocessed = await Promise.all(map(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
|
|
471
|
+
const imageWidth = preprocessed[0]?.width ?? scale;
|
|
472
|
+
const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
|
|
473
|
+
const imageArea = imageWidth * imageHeight;
|
|
474
|
+
for (let i = 0; i < frames.length - 1; i++) {
|
|
475
|
+
const pairIndex = pairOffset + i;
|
|
476
|
+
try {
|
|
477
|
+
prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
|
|
478
|
+
next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
|
|
479
|
+
const sNew = computeNewPoints(cvLib, prev, next);
|
|
480
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
481
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
482
|
+
if (clusters.length === 0) {
|
|
483
|
+
const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
|
|
484
|
+
if (pixelDiffPoints.length > 0) {
|
|
485
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
|
|
486
|
+
dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
|
|
487
|
+
clusters = dbscanResult.boundingBoxes;
|
|
488
|
+
}
|
|
424
489
|
}
|
|
490
|
+
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
491
|
+
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
492
|
+
const animationIndices = tracker.update(clusters, pairIndex);
|
|
493
|
+
const change = {
|
|
494
|
+
regions: filter(clusters, (_, ci) => !animationIndices.has(ci)),
|
|
495
|
+
animatedRegions: filter(clusters, (_, ci) => animationIndices.has(ci))
|
|
496
|
+
};
|
|
497
|
+
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
498
|
+
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
499
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
500
|
+
edges.push({
|
|
501
|
+
sourceId: frames[i].id,
|
|
502
|
+
targetId: frames[i + 1].id,
|
|
503
|
+
score,
|
|
504
|
+
change
|
|
505
|
+
});
|
|
506
|
+
} catch (err) {
|
|
507
|
+
logger.warn(`Frame pair analysis failed: ${String(err)}`);
|
|
508
|
+
failures++;
|
|
509
|
+
edges.push({
|
|
510
|
+
sourceId: frames[i].id,
|
|
511
|
+
targetId: frames[i + 1].id,
|
|
512
|
+
score: 0
|
|
513
|
+
});
|
|
514
|
+
} finally {
|
|
515
|
+
prev?.delete();
|
|
516
|
+
prev = next;
|
|
517
|
+
next = null;
|
|
425
518
|
}
|
|
426
|
-
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
427
|
-
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
428
|
-
const animationIndices = tracker.update(clusters, pairIndex);
|
|
429
|
-
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
430
|
-
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
431
|
-
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
432
|
-
edges.push({
|
|
433
|
-
sourceId: frames[i].id,
|
|
434
|
-
targetId: frames[i + 1].id,
|
|
435
|
-
score
|
|
436
|
-
});
|
|
437
|
-
} catch (err) {
|
|
438
|
-
logger.debug(`Frame pair analysis failed: ${String(err)}`);
|
|
439
|
-
edges.push({
|
|
440
|
-
sourceId: frames[i].id,
|
|
441
|
-
targetId: frames[i + 1].id,
|
|
442
|
-
score: 0
|
|
443
|
-
});
|
|
444
519
|
}
|
|
520
|
+
const result = {
|
|
521
|
+
edges,
|
|
522
|
+
failures,
|
|
523
|
+
analysisResolution: {
|
|
524
|
+
width: imageWidth,
|
|
525
|
+
height: imageHeight
|
|
526
|
+
},
|
|
527
|
+
carry: prev ? {
|
|
528
|
+
preprocessed: preprocessed[preprocessed.length - 1],
|
|
529
|
+
features: prev
|
|
530
|
+
} : null
|
|
531
|
+
};
|
|
532
|
+
prev = null;
|
|
533
|
+
return result;
|
|
534
|
+
} finally {
|
|
535
|
+
prev?.delete();
|
|
536
|
+
next?.delete();
|
|
445
537
|
}
|
|
446
|
-
return edges;
|
|
447
538
|
}
|
|
448
539
|
/**
|
|
449
540
|
* Analyze adjacent frame pairs to compute information gain scores (G(t)).
|
|
@@ -454,35 +545,82 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
454
545
|
* 2. DBSCAN Spatial Clustering
|
|
455
546
|
* 3. Spatio-temporal IoU Tracking
|
|
456
547
|
* 4. G(t) Information Gain Scoring
|
|
548
|
+
* @param ctx - Frames, analysis options, and the progress callback for this run.
|
|
549
|
+
* @returns Adjacent scores and tracked animations in analysis coordinates.
|
|
550
|
+
* @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
|
|
457
551
|
*/
|
|
458
552
|
async function analyzeFrames(ctx) {
|
|
459
553
|
const { frames } = ctx;
|
|
460
554
|
if (frames.length < 2) return {
|
|
461
555
|
edges: [],
|
|
462
|
-
animations: []
|
|
556
|
+
animations: [],
|
|
557
|
+
analysisResolution: {
|
|
558
|
+
width: 0,
|
|
559
|
+
height: 0
|
|
560
|
+
}
|
|
463
561
|
};
|
|
464
562
|
logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
|
|
465
563
|
const cvLib = await ensureOpenCV();
|
|
466
564
|
const edges = [];
|
|
467
|
-
const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
565
|
+
const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
468
566
|
const scale = ctx.options.scale;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
567
|
+
let akaze = null;
|
|
568
|
+
let carry = null;
|
|
569
|
+
let analysisResolution = {
|
|
570
|
+
width: 0,
|
|
571
|
+
height: 0
|
|
572
|
+
};
|
|
573
|
+
let failures = 0;
|
|
574
|
+
try {
|
|
575
|
+
akaze = new cvLib.AKAZE();
|
|
576
|
+
for (let i = 0; i < frames.length - 1; i += 10) {
|
|
577
|
+
const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
|
|
578
|
+
const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
|
|
579
|
+
carry = result.carry;
|
|
580
|
+
failures += result.failures;
|
|
581
|
+
if (i === 0) analysisResolution = result.analysisResolution;
|
|
582
|
+
edges.push(...result.edges);
|
|
583
|
+
const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
|
|
584
|
+
ctx.emitProgress(progress);
|
|
585
|
+
}
|
|
586
|
+
} finally {
|
|
587
|
+
carry?.features.delete();
|
|
588
|
+
akaze?.delete();
|
|
475
589
|
}
|
|
590
|
+
const pairs = frames.length - 1;
|
|
591
|
+
if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
|
|
476
592
|
const animations = tracker.flushAndGetAnimations();
|
|
477
593
|
logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
|
|
478
594
|
return {
|
|
479
595
|
edges,
|
|
480
|
-
animations
|
|
596
|
+
animations,
|
|
597
|
+
analysisResolution
|
|
481
598
|
};
|
|
482
599
|
}
|
|
483
600
|
|
|
484
601
|
//#endregion
|
|
485
|
-
//#region src/
|
|
602
|
+
//#region src/constants/package-version.ts
|
|
603
|
+
/** Source constants are two levels below the manifest; runtime bundles are one level below. */
|
|
604
|
+
const manifestPath = existsSync(new URL("../package.json", import.meta.url)) ? "../package.json" : "../../package.json";
|
|
605
|
+
/** Runtime manifest version shared by CLI responses and metadata documents. */
|
|
606
|
+
const PACKAGE_VERSION = createRequire(import.meta.url)(manifestPath).version;
|
|
607
|
+
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/core/constants/workspace-layout.ts
|
|
610
|
+
/**
|
|
611
|
+
* Temp workspace naming and frame file layout for pipeline runs.
|
|
612
|
+
*/
|
|
613
|
+
const APP_NAME = "scene-sieve";
|
|
614
|
+
const WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
615
|
+
const TEMP_BASE_DIR = tmpdir();
|
|
616
|
+
const FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
617
|
+
const FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
618
|
+
function getTempWorkspaceDir(sessionId) {
|
|
619
|
+
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region src/core/utils/filesystem/paths.ts
|
|
486
624
|
async function ensureDir(dirPath) {
|
|
487
625
|
await mkdir(dirPath, { recursive: true });
|
|
488
626
|
}
|
|
@@ -516,17 +654,579 @@ function resolveAbsolute(p) {
|
|
|
516
654
|
* e.g., /path/to/video.mp4 -> /path/to/video_scenes
|
|
517
655
|
*/
|
|
518
656
|
function deriveOutputPath(inputPath) {
|
|
519
|
-
|
|
657
|
+
const dir = resolve(inputPath, "..");
|
|
658
|
+
const name = basename(inputPath, extname(inputPath));
|
|
659
|
+
return resolve(dir, `${name}_scenes`);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/core/workspace/workspace.ts
|
|
664
|
+
async function createWorkspace(sessionId) {
|
|
665
|
+
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
666
|
+
await ensureDir(join(workspacePath, "frames"));
|
|
667
|
+
await ensureDir(join(workspacePath, "output"));
|
|
668
|
+
return workspacePath;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Write selected JPEGs and the supplied document before replacing the output directory.
|
|
672
|
+
* @param ctx - Workspace, quality and destination settings.
|
|
673
|
+
* @param selectedFrames - Frames paired by position with document.frames.
|
|
674
|
+
* @param document - Complete metadata, including the output file names.
|
|
675
|
+
* @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
|
|
676
|
+
* @returns Selected JPEG paths, optional sheet path and finally the metadata path.
|
|
677
|
+
* @throws Rejects mismatched frame counts and propagates image or filesystem errors.
|
|
678
|
+
*/
|
|
679
|
+
async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
|
|
680
|
+
if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
|
|
681
|
+
const stagingDir = join(ctx.workspacePath, "output");
|
|
682
|
+
const outputPath = ctx.options.outputPath;
|
|
683
|
+
const quality = ctx.options.quality;
|
|
684
|
+
const outputFiles = [];
|
|
685
|
+
for (let i = 0; i < selectedFrames.length; i++) {
|
|
686
|
+
const frame = selectedFrames[i];
|
|
687
|
+
const fileName = document.frames[i].fileName;
|
|
688
|
+
const destPath = join(stagingDir, fileName);
|
|
689
|
+
await sharp(frame.extractPath).jpeg({
|
|
690
|
+
quality,
|
|
691
|
+
mozjpeg: true
|
|
692
|
+
}).toFile(destPath);
|
|
693
|
+
outputFiles.push(join(outputPath, fileName));
|
|
694
|
+
}
|
|
695
|
+
if (sheetBuffer) {
|
|
696
|
+
await writeFile(join(stagingDir, SHEET_FILE_NAME), sheetBuffer);
|
|
697
|
+
outputFiles.push(join(outputPath, SHEET_FILE_NAME));
|
|
698
|
+
}
|
|
699
|
+
const metadataPath = join(stagingDir, METADATA_FILE_NAME);
|
|
700
|
+
await writeFile(metadataPath, JSON.stringify(document, null, 2));
|
|
701
|
+
outputFiles.push(join(outputPath, METADATA_FILE_NAME));
|
|
702
|
+
await ensureDir(join(outputPath, ".."));
|
|
703
|
+
await rm(outputPath, {
|
|
704
|
+
recursive: true,
|
|
705
|
+
force: true
|
|
706
|
+
});
|
|
707
|
+
await rename(stagingDir, outputPath);
|
|
708
|
+
return outputFiles;
|
|
709
|
+
}
|
|
710
|
+
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
711
|
+
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
712
|
+
await ensureDir(join(segmentPath, "frames"));
|
|
713
|
+
return segmentPath;
|
|
714
|
+
}
|
|
715
|
+
async function cleanupWorkspace(workspacePath) {
|
|
716
|
+
if (!workspacePath) return;
|
|
717
|
+
try {
|
|
718
|
+
await rm(workspacePath, {
|
|
719
|
+
recursive: true,
|
|
720
|
+
force: true
|
|
721
|
+
});
|
|
722
|
+
} catch {}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Write a video buffer to a temp file in the workspace and return the path.
|
|
726
|
+
* Used by 'buffer' input mode.
|
|
727
|
+
*/
|
|
728
|
+
async function writeInputBuffer(buffer, workspacePath) {
|
|
729
|
+
const inputDir = join(workspacePath, "input");
|
|
730
|
+
await ensureDir(inputDir);
|
|
731
|
+
const tempPath = join(inputDir, "input.mp4");
|
|
732
|
+
await writeFile(tempPath, buffer);
|
|
733
|
+
return tempPath;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Write an array of frame Buffers as JPG files and return FrameNode[].
|
|
737
|
+
* Used by 'frames' input mode.
|
|
738
|
+
*/
|
|
739
|
+
async function writeInputFrames(frames, workspacePath) {
|
|
740
|
+
const framesDir = join(workspacePath, "frames");
|
|
741
|
+
await ensureDir(framesDir);
|
|
742
|
+
const frameNodes = [];
|
|
743
|
+
for (let i = 0; i < frames.length; i++) {
|
|
744
|
+
const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
|
|
745
|
+
const extractPath = join(framesDir, filename);
|
|
746
|
+
await writeFile(extractPath, frames[i]);
|
|
747
|
+
frameNodes.push({
|
|
748
|
+
id: i,
|
|
749
|
+
timestamp: i,
|
|
750
|
+
extractPath
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return frameNodes;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Read selected FrameNode files as Buffers with JPEG compression.
|
|
757
|
+
* Used to return output buffers in 'buffer' and 'frames' modes.
|
|
758
|
+
*/
|
|
759
|
+
async function readFramesAsBuffers(frameNodes, quality) {
|
|
760
|
+
return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
|
|
761
|
+
quality,
|
|
762
|
+
mozjpeg: true
|
|
763
|
+
}).toBuffer()));
|
|
520
764
|
}
|
|
521
765
|
|
|
522
766
|
//#endregion
|
|
523
|
-
//#region src/core/
|
|
767
|
+
//#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
|
|
768
|
+
/** Internal sweep helper retaining cover counts and lengths between sorted y bounds. */
|
|
769
|
+
var YCoverageTree = class {
|
|
770
|
+
bounds;
|
|
771
|
+
/** Number of whole-node covering intervals, independent of descendants. */
|
|
772
|
+
counts;
|
|
773
|
+
/** Covered geometric length for each node, including partially covered children. */
|
|
774
|
+
lengths;
|
|
775
|
+
/**
|
|
776
|
+
* Allocate linear storage for the elementary intervals between coordinates.
|
|
777
|
+
* @param bounds - At least two sorted unique finite y endpoints.
|
|
778
|
+
*/
|
|
779
|
+
constructor(bounds) {
|
|
780
|
+
this.bounds = bounds;
|
|
781
|
+
this.counts = Array(4 * bounds.length).fill(0);
|
|
782
|
+
this.lengths = Array(4 * bounds.length).fill(0);
|
|
783
|
+
}
|
|
784
|
+
/** Total active covered y length, in the input coordinate system. */
|
|
785
|
+
get coveredLength() {
|
|
786
|
+
return this.lengths[1];
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Adjust a nonempty half-open interval and refresh its ancestors' lengths.
|
|
790
|
+
* @param start - Inclusive endpoint index; 0 <= start < end.
|
|
791
|
+
* @param end - Exclusive endpoint index; end < bounds.length.
|
|
792
|
+
* @param delta - One on entry, minus one for the matching departure.
|
|
793
|
+
* @param node - Internal tree slot; callers use the root default.
|
|
794
|
+
* @param left - Inclusive endpoint index of this node.
|
|
795
|
+
* @param right - Exclusive endpoint index of this node.
|
|
796
|
+
* @returns Nothing; mutates this tree's coverage in O(log n).
|
|
797
|
+
*/
|
|
798
|
+
update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
|
|
799
|
+
if (start <= left && right <= end) this.counts[node] += delta;
|
|
800
|
+
else {
|
|
801
|
+
const middle = Math.floor((left + right) / 2);
|
|
802
|
+
if (start < middle) this.update(start, end, delta, node * 2, left, middle);
|
|
803
|
+
if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
|
|
804
|
+
}
|
|
805
|
+
this.lengths[node] = this.counts[node] > 0 ? this.bounds[right] - this.bounds[left] : right - left === 1 ? 0 : this.lengths[node * 2] + this.lengths[node * 2 + 1];
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
//#endregion
|
|
810
|
+
//#region src/core/utils/metadata/change/union-area.ts
|
|
811
|
+
/**
|
|
812
|
+
* Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
|
|
813
|
+
* @param boxes - Finite rectangles; nonpositive dimensions are ignored.
|
|
814
|
+
* @returns Covered area in the input coordinate system, or zero for empty input.
|
|
815
|
+
*/
|
|
816
|
+
function unionArea(boxes) {
|
|
817
|
+
const events = [];
|
|
818
|
+
const endpoints = [];
|
|
819
|
+
for (const box of boxes) {
|
|
820
|
+
if (!(box.width > 0 && box.height > 0)) continue;
|
|
821
|
+
const end = box.y + box.height;
|
|
822
|
+
if (end === box.y) continue;
|
|
823
|
+
events.push({
|
|
824
|
+
x: box.x,
|
|
825
|
+
start: box.y,
|
|
826
|
+
end,
|
|
827
|
+
delta: 1
|
|
828
|
+
});
|
|
829
|
+
events.push({
|
|
830
|
+
x: box.x + box.width,
|
|
831
|
+
start: box.y,
|
|
832
|
+
end,
|
|
833
|
+
delta: -1
|
|
834
|
+
});
|
|
835
|
+
endpoints.push(box.y, end);
|
|
836
|
+
}
|
|
837
|
+
if (events.length === 0) return 0;
|
|
838
|
+
events.sort((a, b) => a.x - b.x);
|
|
839
|
+
endpoints.sort((a, b) => a - b);
|
|
840
|
+
const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
|
|
841
|
+
const indices = new Map(bounds.map((value, index) => [value, index]));
|
|
842
|
+
const coverage = new YCoverageTree(bounds);
|
|
843
|
+
let area = 0;
|
|
844
|
+
let previousX = events[0].x;
|
|
845
|
+
for (const event of events) {
|
|
846
|
+
area += (event.x - previousX) * coverage.coveredLength;
|
|
847
|
+
coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
|
|
848
|
+
previousX = event.x;
|
|
849
|
+
}
|
|
850
|
+
return area;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region src/core/utils/metadata/build-edge-metadata.ts
|
|
855
|
+
/**
|
|
856
|
+
* Serialize raw candidate edges in graph order.
|
|
857
|
+
* @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
|
|
858
|
+
* @param analysisResolution - Analysis dimensions used for both area fractions.
|
|
859
|
+
* @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
|
|
860
|
+
*/
|
|
861
|
+
function buildEdgeMetadata(graph, analysisResolution) {
|
|
862
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
863
|
+
return map(graph, (edge) => ({
|
|
864
|
+
sourceFrameId: edge.sourceId + 1,
|
|
865
|
+
targetFrameId: edge.targetId + 1,
|
|
866
|
+
score: Math.round(edge.score * 1e6) / 1e6,
|
|
867
|
+
areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
|
|
868
|
+
animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
|
|
869
|
+
}));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/core/utils/metadata/scale-bounding-box.ts
|
|
874
|
+
/**
|
|
875
|
+
* Convert an analysis box to clamped integer output pixels.
|
|
876
|
+
* @param box - Analysis-space rectangle; the input remains unchanged.
|
|
877
|
+
* @param sx - Horizontal output-to-analysis scale.
|
|
878
|
+
* @param sy - Vertical output-to-analysis scale.
|
|
879
|
+
* @param width - Nonnegative output image width.
|
|
880
|
+
* @param height - Nonnegative output image height.
|
|
881
|
+
* @returns A rectangle contained within the output dimensions.
|
|
882
|
+
*/
|
|
883
|
+
function scaleBoundingBox(box, sx, sy, width, height) {
|
|
884
|
+
const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
|
|
885
|
+
const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
|
|
886
|
+
return {
|
|
887
|
+
x,
|
|
888
|
+
y,
|
|
889
|
+
width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
|
|
890
|
+
height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/core/utils/metadata/change/select-regions.ts
|
|
896
|
+
/**
|
|
897
|
+
* Select distinct positive-area boxes with deterministic area and coordinate ties.
|
|
898
|
+
* @param boxes - Already scaled output rectangles; the input is not mutated.
|
|
899
|
+
* @param limit - Nonnegative maximum number of regions.
|
|
900
|
+
* @returns Largest boxes ordered by area descending, then y, x and width ascending.
|
|
901
|
+
*/
|
|
902
|
+
function selectRegions(boxes, limit) {
|
|
903
|
+
return filter(boxes, (box, index) => box.width > 0 && box.height > 0 && boxes.findIndex((other) => other.x === box.x && other.y === box.y && other.width === box.width && other.height === box.height) === index).sort((a, b) => b.width * b.height - a.width * a.height || a.y - b.y || a.x - b.x || a.width - b.width).slice(0, limit);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
//#endregion
|
|
907
|
+
//#region src/core/utils/metadata/change/build-frame-change.ts
|
|
908
|
+
/**
|
|
909
|
+
* Aggregate adjacent-pair evidence across one selected-frame span.
|
|
910
|
+
* @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
|
|
911
|
+
* @returns Rounded raw scores, analysis-space union ratio and output-space regions.
|
|
912
|
+
*/
|
|
913
|
+
function buildFrameChange(input) {
|
|
914
|
+
const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
|
|
915
|
+
const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
|
|
916
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
917
|
+
const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
|
|
918
|
+
const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
|
|
919
|
+
const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
|
|
920
|
+
const regions = area > 0 ? selectRegions(map(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
|
|
921
|
+
return {
|
|
922
|
+
fromFrameId,
|
|
923
|
+
skippedCandidates,
|
|
924
|
+
peakScore: Math.round(peakScore * 1e6) / 1e6,
|
|
925
|
+
sumScore: Math.round(sumScore * 1e6) / 1e6,
|
|
926
|
+
areaRatio: Math.round(areaRatio * 1e4) / 1e4,
|
|
927
|
+
regions
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
//#endregion
|
|
932
|
+
//#region src/core/utils/metadata/build-frame-metadata.ts
|
|
933
|
+
/**
|
|
934
|
+
* Describe selected frames using candidate adjacency rather than synthetic pruning edges.
|
|
935
|
+
* @param input - Chronological candidates and selections, raw graph and output dimensions.
|
|
936
|
+
* @returns One-based frame summaries with rounded timestamps and nonnegative holds.
|
|
937
|
+
*/
|
|
938
|
+
function buildFrameMetadata(input) {
|
|
939
|
+
const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
|
|
940
|
+
const edgesByPair = new Map(map(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
|
|
941
|
+
const candidateIndices = new Map(map(frames, (frame, index) => [frame.id, index]));
|
|
942
|
+
const padding = Math.max(4, String(frames.length).length);
|
|
943
|
+
return map(selected, (frame, index) => {
|
|
944
|
+
const timestampMs = Math.round(frame.timestamp * 1e3);
|
|
945
|
+
const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
|
|
946
|
+
const previous = selected[index - 1];
|
|
947
|
+
const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
|
|
948
|
+
const currentIndex = candidateIndices.get(frame.id);
|
|
949
|
+
const spanEdges = [];
|
|
950
|
+
if (previous) for (let i = previousIndex; i < currentIndex; i++) {
|
|
951
|
+
const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
|
|
952
|
+
if (edge) spanEdges.push(edge);
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
step: index + 1,
|
|
956
|
+
fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
|
|
957
|
+
frameId: frame.id + 1,
|
|
958
|
+
timestampMs,
|
|
959
|
+
holdsMs: Math.max(0, nextTimestampMs - timestampMs),
|
|
960
|
+
change: previous ? buildFrameChange({
|
|
961
|
+
spanEdges,
|
|
962
|
+
fromFrameId: previous.id + 1,
|
|
963
|
+
skippedCandidates: currentIndex - previousIndex - 1,
|
|
964
|
+
analysisResolution,
|
|
965
|
+
outputResolution,
|
|
966
|
+
regionLimit: 5
|
|
967
|
+
}) : null
|
|
968
|
+
};
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/core/utils/metadata/build-tool-metadata.ts
|
|
974
|
+
/**
|
|
975
|
+
* Record the tool and the nine selection and encoding settings in contract order.
|
|
976
|
+
* @param options - Validated pipeline settings; operational options are excluded.
|
|
977
|
+
* @param version - Runtime package version supplied by the I/O boundary.
|
|
978
|
+
* @returns Deterministic tool provenance without paths or execution details.
|
|
979
|
+
*/
|
|
980
|
+
function buildToolMetadata(options, version) {
|
|
981
|
+
return {
|
|
982
|
+
name: "@lumy-pack/scene-sieve",
|
|
983
|
+
version,
|
|
984
|
+
params: {
|
|
985
|
+
fps: options.fps,
|
|
986
|
+
count: options.count,
|
|
987
|
+
threshold: options.threshold,
|
|
988
|
+
scale: options.scale,
|
|
989
|
+
quality: options.quality,
|
|
990
|
+
maxFrames: options.maxFrames,
|
|
991
|
+
iouThreshold: options.iouThreshold,
|
|
992
|
+
animationThreshold: options.animationThreshold,
|
|
993
|
+
maxSegmentDuration: options.maxSegmentDuration
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
//#endregion
|
|
999
|
+
//#region src/core/utils/metadata/build-sieve-metadata.ts
|
|
1000
|
+
/**
|
|
1001
|
+
* Assemble the complete v2 document without I/O or mutation.
|
|
1002
|
+
* @param input - Pipeline state, selections, output-space video and zero-based animations.
|
|
1003
|
+
* @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
|
|
1004
|
+
*/
|
|
1005
|
+
function buildSieveMetadata(input) {
|
|
1006
|
+
const { ctx, selected, video, animations, version, sheet } = input;
|
|
1007
|
+
const analysisResolution = ctx.analysisResolution ?? {
|
|
1008
|
+
width: 0,
|
|
1009
|
+
height: 0
|
|
1010
|
+
};
|
|
1011
|
+
return {
|
|
1012
|
+
metadataVersion: 2,
|
|
1013
|
+
tool: buildToolMetadata(ctx.options, version),
|
|
1014
|
+
video,
|
|
1015
|
+
frames: buildFrameMetadata({
|
|
1016
|
+
frames: ctx.frames,
|
|
1017
|
+
graph: ctx.graph,
|
|
1018
|
+
selected,
|
|
1019
|
+
originalDurationMs: video.originalDurationMs,
|
|
1020
|
+
analysisResolution,
|
|
1021
|
+
outputResolution: video.resolution
|
|
1022
|
+
}),
|
|
1023
|
+
animations: map(animations, (animation) => ({
|
|
1024
|
+
...animation,
|
|
1025
|
+
startFrameId: animation.startFrameId + 1,
|
|
1026
|
+
endFrameId: animation.endFrameId + 1,
|
|
1027
|
+
durationMs: Math.round(animation.durationMs)
|
|
1028
|
+
})),
|
|
1029
|
+
...sheet ? { sheet } : {},
|
|
1030
|
+
...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/core/utils/metadata/build-video-metadata.ts
|
|
1036
|
+
/**
|
|
1037
|
+
* Read output dimensions and build consistent video and animation metadata.
|
|
1038
|
+
* JPEG finalization does not resize, so the source dimensions match the output.
|
|
1039
|
+
* @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
|
|
1040
|
+
* @param selected Selected frames in output order; the first candidate is the fallback.
|
|
1041
|
+
* @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
|
|
1042
|
+
* @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
|
|
1043
|
+
* @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
|
|
1044
|
+
*/
|
|
1045
|
+
async function buildVideoMetadata(ctx, selected, analysisResolution) {
|
|
1046
|
+
const firstFrame = selected[0] ?? ctx.frames[0];
|
|
1047
|
+
const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
|
|
1048
|
+
const width = dimensions?.width ?? 0;
|
|
1049
|
+
const height = dimensions?.height ?? 0;
|
|
1050
|
+
const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
|
|
1051
|
+
const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
|
|
1052
|
+
const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
|
|
1053
|
+
const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
|
|
1054
|
+
return {
|
|
1055
|
+
video: {
|
|
1056
|
+
originalDurationMs: Math.round(duration * 1e3),
|
|
1057
|
+
fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
|
|
1058
|
+
resolution: {
|
|
1059
|
+
width,
|
|
1060
|
+
height
|
|
1061
|
+
},
|
|
1062
|
+
candidatesCount: ctx.frames.length,
|
|
1063
|
+
selectedCount: selected.length,
|
|
1064
|
+
source: {
|
|
1065
|
+
fileName: ctx.options.mode === "file" && ctx.options.inputPath ? basename(ctx.options.inputPath) : null,
|
|
1066
|
+
mode: ctx.options.mode
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
animations: (ctx.animations ?? []).map((animation) => ({
|
|
1070
|
+
...animation,
|
|
1071
|
+
boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
|
|
1072
|
+
}))
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
//#endregion
|
|
1077
|
+
//#region src/core/utils/sheet/build-tile-label-svg.ts
|
|
1078
|
+
/**
|
|
1079
|
+
* Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
|
|
1080
|
+
* @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
|
|
1081
|
+
* @param tileWidth - Positive tile canvas width in pixels.
|
|
1082
|
+
* @param tileHeight - Positive tile canvas height in pixels.
|
|
1083
|
+
* @returns Encoded SVG bytes for a top-left sharp composite overlay.
|
|
1084
|
+
*/
|
|
1085
|
+
function buildTileLabelSvg(text, tileWidth, tileHeight) {
|
|
1086
|
+
const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
|
|
1087
|
+
const padX = Math.round(fontSize * .4);
|
|
1088
|
+
const padY = Math.round(fontSize * .2);
|
|
1089
|
+
const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
|
|
1090
|
+
return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${tileWidth}" height="${tileHeight}"><rect x="0" y="0" width="${badgeWidth}" height="${fontSize + 2 * padY}" fill="black" fill-opacity="0.5"/><text x="${padX}" y="${padY + Math.round(fontSize * .8)}" font-family="sans-serif" font-size="${fontSize}" fill="white">${text}</text></svg>`);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/core/utils/sheet/format-tile-label.ts
|
|
1095
|
+
/**
|
|
1096
|
+
* Format a contact sheet label without rounding into the next tenth of a second.
|
|
1097
|
+
* @param frameId - One-based candidate frame ID.
|
|
1098
|
+
* @param timestampMs - Nonnegative candidate time in milliseconds.
|
|
1099
|
+
* @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
|
|
1100
|
+
*/
|
|
1101
|
+
function formatTileLabel(frameId, timestampMs) {
|
|
1102
|
+
const tenths = Math.floor(timestampMs / 100);
|
|
1103
|
+
return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/core/utils/sheet/sample-tile-frames.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* Sample a sequence uniformly while always retaining both endpoints.
|
|
1110
|
+
* @param items - Items in temporal order.
|
|
1111
|
+
* @param maxTiles - Validated integer limit of at least two.
|
|
1112
|
+
* @returns The original sequence when within the limit, otherwise evenly sampled items.
|
|
1113
|
+
*/
|
|
1114
|
+
function sampleTileFrames(items, maxTiles) {
|
|
1115
|
+
if (items.length <= maxTiles) return {
|
|
1116
|
+
items,
|
|
1117
|
+
sampled: false
|
|
1118
|
+
};
|
|
1119
|
+
return {
|
|
1120
|
+
items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
|
|
1121
|
+
sampled: true
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
//#endregion
|
|
1126
|
+
//#region src/core/utils/sheet/render-contact-sheet.ts
|
|
1127
|
+
/**
|
|
1128
|
+
* Read selected frame images and render a row-major contact sheet.
|
|
1129
|
+
* @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
|
|
1130
|
+
* @returns JPEG bytes and the effective tile layout with one-based frame IDs.
|
|
1131
|
+
* @throws Propagates sharp image reading, compositing or encoding errors.
|
|
1132
|
+
*/
|
|
1133
|
+
async function renderContactSheet(input) {
|
|
1134
|
+
const { selected, resolution, options, quality } = input;
|
|
1135
|
+
const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
|
|
1136
|
+
const columns = Math.min(options.columns, items.length);
|
|
1137
|
+
const rows = Math.ceil(items.length / columns);
|
|
1138
|
+
const tileWidth = options.tileWidth;
|
|
1139
|
+
const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
|
|
1140
|
+
const gap = 4;
|
|
1141
|
+
const tiles = await Promise.all(map(items, async (frame, index) => {
|
|
1142
|
+
const tile = sharp(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
|
|
1143
|
+
if (options.label) {
|
|
1144
|
+
const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
|
|
1145
|
+
tile.composite([{
|
|
1146
|
+
input: buildTileLabelSvg(text, tileWidth, tileHeight),
|
|
1147
|
+
top: 0,
|
|
1148
|
+
left: 0
|
|
1149
|
+
}]);
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
input: await tile.png().toBuffer(),
|
|
1153
|
+
top: gap + Math.floor(index / columns) * (tileHeight + gap),
|
|
1154
|
+
left: gap + index % columns * (tileWidth + gap)
|
|
1155
|
+
};
|
|
1156
|
+
}));
|
|
1157
|
+
return {
|
|
1158
|
+
buffer: await sharp({ create: {
|
|
1159
|
+
width: columns * tileWidth + (columns + 1) * gap,
|
|
1160
|
+
height: rows * tileHeight + (rows + 1) * gap,
|
|
1161
|
+
channels: 3,
|
|
1162
|
+
background: "white"
|
|
1163
|
+
} }).composite(tiles).jpeg({
|
|
1164
|
+
quality,
|
|
1165
|
+
mozjpeg: true
|
|
1166
|
+
}).toBuffer(),
|
|
1167
|
+
metadata: {
|
|
1168
|
+
fileName: SHEET_FILE_NAME,
|
|
1169
|
+
columns,
|
|
1170
|
+
tileWidth,
|
|
1171
|
+
tileHeight,
|
|
1172
|
+
frameIds: map(items, (frame) => frame.id + 1),
|
|
1173
|
+
sampled
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region src/core/utils/output/finalize-selection.ts
|
|
1180
|
+
/**
|
|
1181
|
+
* Read output dimensions once, render optional sheets and finalize the shared v2 document.
|
|
1182
|
+
* @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
|
|
1183
|
+
* @param selected - Selected frames in temporal order.
|
|
1184
|
+
* @returns Mode-specific output, the document and zero-based API animations.
|
|
1185
|
+
* @throws Propagates image, rendering and output I/O errors to the orchestrator.
|
|
1186
|
+
*/
|
|
1187
|
+
async function finalizeSelection(ctx, selected) {
|
|
1188
|
+
const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
|
|
1189
|
+
const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
|
|
1190
|
+
selected,
|
|
1191
|
+
resolution: video.resolution,
|
|
1192
|
+
options: ctx.options.sheet,
|
|
1193
|
+
quality: ctx.options.quality
|
|
1194
|
+
}) : void 0;
|
|
1195
|
+
const document = buildSieveMetadata({
|
|
1196
|
+
ctx,
|
|
1197
|
+
selected,
|
|
1198
|
+
video,
|
|
1199
|
+
animations,
|
|
1200
|
+
version: PACKAGE_VERSION,
|
|
1201
|
+
...sheet ? { sheet: sheet.metadata } : {}
|
|
1202
|
+
});
|
|
1203
|
+
if (ctx.options.mode === "file") return {
|
|
1204
|
+
outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
|
|
1205
|
+
document,
|
|
1206
|
+
animations
|
|
1207
|
+
};
|
|
1208
|
+
return {
|
|
1209
|
+
outputFiles: [],
|
|
1210
|
+
outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
|
|
1211
|
+
document,
|
|
1212
|
+
animations,
|
|
1213
|
+
...sheet ? { sheetBuffer: sheet.buffer } : {}
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
//#endregion
|
|
1218
|
+
//#region src/core/extractor/extractor.ts
|
|
524
1219
|
/**
|
|
525
1220
|
* Extract frames from video/GIF using FFmpeg.
|
|
526
|
-
*
|
|
527
|
-
*
|
|
1221
|
+
* @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
|
|
1222
|
+
* @returns Extracted candidates, or the unchanged input array in frames mode.
|
|
1223
|
+
* @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
|
|
528
1224
|
*/
|
|
529
1225
|
async function extractFrames(ctx) {
|
|
1226
|
+
if (ctx.options.mode === "frames") {
|
|
1227
|
+
ctx.effectiveFps = 1;
|
|
1228
|
+
return ctx.frames;
|
|
1229
|
+
}
|
|
530
1230
|
const framesDir = join(ctx.workspacePath, "frames");
|
|
531
1231
|
const { inputPath, fps, maxFrames, scale } = ctx.options;
|
|
532
1232
|
if (!inputPath) throw new Error("inputPath is required for frame extraction");
|
|
@@ -541,19 +1241,30 @@ async function extractFrames(ctx) {
|
|
|
541
1241
|
if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
|
|
542
1242
|
logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
|
|
543
1243
|
await ensureDir(framesDir);
|
|
1244
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
544
1245
|
let effectiveFps = fps;
|
|
545
1246
|
if (duration > 0) {
|
|
546
|
-
const fpsCap =
|
|
1247
|
+
const fpsCap = frameLimit / duration;
|
|
547
1248
|
effectiveFps = Math.min(fps, fpsCap);
|
|
548
|
-
effectiveFps = Math.max(.5, effectiveFps);
|
|
549
1249
|
logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
|
|
550
1250
|
}
|
|
551
|
-
|
|
1251
|
+
ctx.effectiveFps = effectiveFps;
|
|
1252
|
+
ctx.sourceDurationSec = duration;
|
|
1253
|
+
const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
|
|
552
1254
|
ctx.emitProgress(100);
|
|
553
1255
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
554
1256
|
return frames;
|
|
555
1257
|
}
|
|
556
|
-
|
|
1258
|
+
/**
|
|
1259
|
+
* Write scaled JPEG candidates through the bundled FFmpeg runtime.
|
|
1260
|
+
* @param inputPath Readable video input.
|
|
1261
|
+
* @param outputDir Existing frame directory.
|
|
1262
|
+
* @param fps Positive effective sampling frequency.
|
|
1263
|
+
* @param scale Output image height.
|
|
1264
|
+
* @param frameLimit Maximum number of output frames.
|
|
1265
|
+
* @returns Candidates with local output-grid timestamps; rejects on extraction failure.
|
|
1266
|
+
*/
|
|
1267
|
+
async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
|
|
557
1268
|
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
558
1269
|
await execa(ffmpegPath, [
|
|
559
1270
|
"-i",
|
|
@@ -562,9 +1273,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
|
|
|
562
1273
|
`fps=${fps},scale=-1:${scale}`,
|
|
563
1274
|
"-q:v",
|
|
564
1275
|
"2",
|
|
1276
|
+
"-frames:v",
|
|
1277
|
+
String(frameLimit),
|
|
565
1278
|
outputPattern
|
|
566
1279
|
]);
|
|
567
|
-
return buildFrameList(outputDir,
|
|
1280
|
+
return buildFrameList(outputDir, fps);
|
|
568
1281
|
}
|
|
569
1282
|
async function getVideoMetadata(inputPath) {
|
|
570
1283
|
const { stdout } = await execa(path, [
|
|
@@ -578,12 +1291,19 @@ async function getVideoMetadata(inputPath) {
|
|
|
578
1291
|
]);
|
|
579
1292
|
return JSON.parse(stdout);
|
|
580
1293
|
}
|
|
581
|
-
|
|
582
|
-
|
|
1294
|
+
/**
|
|
1295
|
+
* Read sorted JPEG paths and attach local output-grid times.
|
|
1296
|
+
* @param framesDir Extracted frame directory; filesystem errors propagate.
|
|
1297
|
+
* @param effectiveFps Positive frequency used by the fps filter.
|
|
1298
|
+
* @returns Zero-based candidates without a segment seek offset.
|
|
1299
|
+
*/
|
|
1300
|
+
async function buildFrameList(framesDir, effectiveFps) {
|
|
1301
|
+
const files = await readdir(framesDir);
|
|
1302
|
+
const jpgFiles = filter(files, (f) => f.endsWith(".jpg")).sort();
|
|
583
1303
|
if (jpgFiles.length === 0) return [];
|
|
584
1304
|
return map(jpgFiles, (file, index) => ({
|
|
585
1305
|
id: index,
|
|
586
|
-
timestamp:
|
|
1306
|
+
timestamp: index / effectiveFps,
|
|
587
1307
|
extractPath: join(framesDir, file)
|
|
588
1308
|
}));
|
|
589
1309
|
}
|
|
@@ -597,9 +1317,10 @@ async function buildFrameList(framesDir, duration) {
|
|
|
597
1317
|
* @param scale - Height scale for vision analysis
|
|
598
1318
|
* @param startTime - Start time in seconds
|
|
599
1319
|
* @param duration - Duration in seconds to extract
|
|
1320
|
+
* @param frameLimit - Positive output limit; defaults to the range's grid capacity
|
|
600
1321
|
* @returns Array of FrameNode with segment-local timestamps (starting from 0)
|
|
601
1322
|
*/
|
|
602
|
-
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
|
|
1323
|
+
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
|
|
603
1324
|
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
604
1325
|
await execa(ffmpegPath, [
|
|
605
1326
|
"-ss",
|
|
@@ -612,139 +1333,145 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
|
|
|
612
1333
|
`fps=${fps},scale=-1:${scale}`,
|
|
613
1334
|
"-q:v",
|
|
614
1335
|
"2",
|
|
1336
|
+
"-frames:v",
|
|
1337
|
+
String(frameLimit),
|
|
615
1338
|
outputPattern
|
|
616
1339
|
]);
|
|
617
|
-
return buildFrameList(outputDir,
|
|
1340
|
+
return buildFrameList(outputDir, fps);
|
|
618
1341
|
}
|
|
619
1342
|
|
|
620
1343
|
//#endregion
|
|
621
|
-
//#region src/core/
|
|
622
|
-
async function createWorkspace(sessionId) {
|
|
623
|
-
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
624
|
-
await ensureDir(join(workspacePath, "frames"));
|
|
625
|
-
await ensureDir(join(workspacePath, "output"));
|
|
626
|
-
return workspacePath;
|
|
627
|
-
}
|
|
628
|
-
async function finalizeOutput(ctx, selectedFrames) {
|
|
629
|
-
const stagingDir = join(ctx.workspacePath, "output");
|
|
630
|
-
const outputPath = ctx.options.outputPath;
|
|
631
|
-
const quality = ctx.options.quality;
|
|
632
|
-
const outputFiles = [];
|
|
633
|
-
const framesMetadata = [];
|
|
634
|
-
const totalFramesCount = ctx.frames.length;
|
|
635
|
-
const padding = Math.max(4, String(totalFramesCount).length);
|
|
636
|
-
for (let i = 0; i < selectedFrames.length; i++) {
|
|
637
|
-
const frame = selectedFrames[i];
|
|
638
|
-
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
639
|
-
const destPath = join(stagingDir, fileName);
|
|
640
|
-
await sharp(frame.extractPath).jpeg({
|
|
641
|
-
quality,
|
|
642
|
-
mozjpeg: true
|
|
643
|
-
}).toFile(destPath);
|
|
644
|
-
outputFiles.push(join(outputPath, fileName));
|
|
645
|
-
framesMetadata.push({
|
|
646
|
-
step: i + 1,
|
|
647
|
-
fileName,
|
|
648
|
-
frameId: frame.id + 1,
|
|
649
|
-
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
const metadata = {
|
|
653
|
-
video: {
|
|
654
|
-
originalDurationMs: Math.round((ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3),
|
|
655
|
-
fps: ctx.options.fps,
|
|
656
|
-
resolution: {
|
|
657
|
-
width: ctx.options.scale,
|
|
658
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
659
|
-
}
|
|
660
|
-
},
|
|
661
|
-
frames: framesMetadata,
|
|
662
|
-
animations: map(ctx.animations || [], (anim) => ({
|
|
663
|
-
...anim,
|
|
664
|
-
startFrameId: anim.startFrameId + 1,
|
|
665
|
-
endFrameId: anim.endFrameId + 1,
|
|
666
|
-
durationMs: Math.round(anim.durationMs)
|
|
667
|
-
}))
|
|
668
|
-
};
|
|
669
|
-
await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
|
|
670
|
-
outputFiles.push(join(outputPath, ".metadata.json"));
|
|
671
|
-
await ensureDir(join(outputPath, ".."));
|
|
672
|
-
await rm(outputPath, {
|
|
673
|
-
recursive: true,
|
|
674
|
-
force: true
|
|
675
|
-
});
|
|
676
|
-
await rename(stagingDir, outputPath);
|
|
677
|
-
return outputFiles;
|
|
678
|
-
}
|
|
679
|
-
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
680
|
-
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
681
|
-
await ensureDir(join(segmentPath, "frames"));
|
|
682
|
-
return segmentPath;
|
|
683
|
-
}
|
|
684
|
-
async function cleanupWorkspace(workspacePath) {
|
|
685
|
-
if (!workspacePath) return;
|
|
686
|
-
try {
|
|
687
|
-
await rm(workspacePath, {
|
|
688
|
-
recursive: true,
|
|
689
|
-
force: true
|
|
690
|
-
});
|
|
691
|
-
} catch {}
|
|
692
|
-
}
|
|
693
|
-
/**
|
|
694
|
-
* Write a video buffer to a temp file in the workspace and return the path.
|
|
695
|
-
* Used by 'buffer' input mode.
|
|
696
|
-
*/
|
|
697
|
-
async function writeInputBuffer(buffer, workspacePath) {
|
|
698
|
-
const inputDir = join(workspacePath, "input");
|
|
699
|
-
await ensureDir(inputDir);
|
|
700
|
-
const tempPath = join(inputDir, "input.mp4");
|
|
701
|
-
await writeFile(tempPath, buffer);
|
|
702
|
-
return tempPath;
|
|
703
|
-
}
|
|
1344
|
+
//#region src/core/input-resolver/validation/validate-options.ts
|
|
704
1345
|
/**
|
|
705
|
-
*
|
|
706
|
-
*
|
|
1346
|
+
* Reject invalid numeric options before defaults or pipeline effects are applied.
|
|
1347
|
+
* @param options - Supplied options; omitted numeric fields use pipeline defaults.
|
|
1348
|
+
* @returns Nothing when all supplied numeric fields satisfy their contracts.
|
|
1349
|
+
* @throws An input error naming the invalid option and its received value.
|
|
707
1350
|
*/
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1351
|
+
function validateOptions(options) {
|
|
1352
|
+
for (const [name, min, max, integer, exclusiveMin, requirement] of [
|
|
1353
|
+
[
|
|
1354
|
+
"count",
|
|
1355
|
+
1,
|
|
1356
|
+
Infinity,
|
|
1357
|
+
true,
|
|
1358
|
+
false,
|
|
1359
|
+
"an integer >= 1"
|
|
1360
|
+
],
|
|
1361
|
+
[
|
|
1362
|
+
"threshold",
|
|
1363
|
+
0,
|
|
1364
|
+
1,
|
|
1365
|
+
false,
|
|
1366
|
+
true,
|
|
1367
|
+
"in range (0, 1] and finite"
|
|
1368
|
+
],
|
|
1369
|
+
[
|
|
1370
|
+
"fps",
|
|
1371
|
+
0,
|
|
1372
|
+
Infinity,
|
|
1373
|
+
false,
|
|
1374
|
+
true,
|
|
1375
|
+
"finite and > 0"
|
|
1376
|
+
],
|
|
1377
|
+
[
|
|
1378
|
+
"maxFrames",
|
|
1379
|
+
2,
|
|
1380
|
+
Infinity,
|
|
1381
|
+
true,
|
|
1382
|
+
false,
|
|
1383
|
+
"an integer >= 2"
|
|
1384
|
+
],
|
|
1385
|
+
[
|
|
1386
|
+
"scale",
|
|
1387
|
+
16,
|
|
1388
|
+
Infinity,
|
|
1389
|
+
true,
|
|
1390
|
+
false,
|
|
1391
|
+
"an integer >= 16"
|
|
1392
|
+
],
|
|
1393
|
+
[
|
|
1394
|
+
"quality",
|
|
1395
|
+
1,
|
|
1396
|
+
100,
|
|
1397
|
+
true,
|
|
1398
|
+
false,
|
|
1399
|
+
"an integer in range [1, 100]"
|
|
1400
|
+
],
|
|
1401
|
+
[
|
|
1402
|
+
"iouThreshold",
|
|
1403
|
+
0,
|
|
1404
|
+
1,
|
|
1405
|
+
false,
|
|
1406
|
+
false,
|
|
1407
|
+
"finite and in range [0, 1]"
|
|
1408
|
+
],
|
|
1409
|
+
[
|
|
1410
|
+
"animationThreshold",
|
|
1411
|
+
1,
|
|
1412
|
+
Infinity,
|
|
1413
|
+
true,
|
|
1414
|
+
false,
|
|
1415
|
+
"an integer >= 1"
|
|
1416
|
+
],
|
|
1417
|
+
[
|
|
1418
|
+
"maxSegmentDuration",
|
|
1419
|
+
0,
|
|
1420
|
+
Infinity,
|
|
1421
|
+
false,
|
|
1422
|
+
true,
|
|
1423
|
+
"finite and > 0"
|
|
1424
|
+
],
|
|
1425
|
+
[
|
|
1426
|
+
"concurrency",
|
|
1427
|
+
1,
|
|
1428
|
+
Infinity,
|
|
1429
|
+
true,
|
|
1430
|
+
false,
|
|
1431
|
+
"an integer >= 1"
|
|
1432
|
+
]
|
|
1433
|
+
]) {
|
|
1434
|
+
const value = options[name];
|
|
1435
|
+
if (value === void 0) continue;
|
|
1436
|
+
if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
|
|
720
1437
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1438
|
+
const sheet = options.sheet;
|
|
1439
|
+
if (sheet === void 0 || typeof sheet === "boolean") return;
|
|
1440
|
+
if (sheet === null || typeof sheet !== "object" || Array.isArray(sheet)) throw new Error(`sheet must be a boolean or an object, received: ${Array.isArray(sheet) ? "array" : String(sheet)}`);
|
|
1441
|
+
for (const [name, min] of [
|
|
1442
|
+
["columns", 1],
|
|
1443
|
+
["tileWidth", 16],
|
|
1444
|
+
["maxTiles", 2]
|
|
1445
|
+
]) {
|
|
1446
|
+
const value = sheet[name];
|
|
1447
|
+
if (value === void 0) continue;
|
|
1448
|
+
if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
|
|
1449
|
+
}
|
|
1450
|
+
if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
|
|
732
1451
|
}
|
|
733
1452
|
|
|
734
1453
|
//#endregion
|
|
735
|
-
//#region src/core/input-resolver.ts
|
|
1454
|
+
//#region src/core/input-resolver/input-resolver.ts
|
|
1455
|
+
/**
|
|
1456
|
+
* Validate supplied options and resolve defaults and paths for the pipeline.
|
|
1457
|
+
* @param options - Mode-specific input and optional numeric settings.
|
|
1458
|
+
* @returns Complete pipeline settings with absolute file input paths.
|
|
1459
|
+
* @throws An input error if a supplied numeric setting is invalid.
|
|
1460
|
+
*/
|
|
736
1461
|
function resolveOptions(options) {
|
|
1462
|
+
validateOptions(options);
|
|
737
1463
|
const mode = options.mode;
|
|
738
1464
|
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
739
1465
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
|
|
740
1466
|
const threshold = options.threshold ?? .5;
|
|
741
|
-
|
|
1467
|
+
const pruneMode = "threshold-with-cap";
|
|
1468
|
+
const sheet = typeof options.sheet === "object" ? options.sheet : {};
|
|
742
1469
|
return {
|
|
743
1470
|
mode,
|
|
744
1471
|
inputPath,
|
|
745
1472
|
count: options.count ?? 20,
|
|
746
1473
|
threshold,
|
|
747
|
-
pruneMode
|
|
1474
|
+
pruneMode,
|
|
748
1475
|
outputPath,
|
|
749
1476
|
fps: options.fps ?? 5,
|
|
750
1477
|
maxFrames: options.maxFrames ?? 300,
|
|
@@ -754,7 +1481,14 @@ function resolveOptions(options) {
|
|
|
754
1481
|
animationThreshold: options.animationThreshold ?? 5,
|
|
755
1482
|
debug: options.debug ?? false,
|
|
756
1483
|
maxSegmentDuration: options.maxSegmentDuration ?? 300,
|
|
757
|
-
concurrency: options.concurrency ?? 2
|
|
1484
|
+
concurrency: options.concurrency ?? 2,
|
|
1485
|
+
sheet: options.sheet ? {
|
|
1486
|
+
columns: sheet.columns ?? 4,
|
|
1487
|
+
tileWidth: sheet.tileWidth ?? 320,
|
|
1488
|
+
maxTiles: sheet.maxTiles ?? 40,
|
|
1489
|
+
label: sheet.label ?? true
|
|
1490
|
+
} : null,
|
|
1491
|
+
includeEdges: options.includeEdges ?? false
|
|
758
1492
|
};
|
|
759
1493
|
}
|
|
760
1494
|
/**
|
|
@@ -763,6 +1497,10 @@ function resolveOptions(options) {
|
|
|
763
1497
|
* - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
|
|
764
1498
|
* - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
|
|
765
1499
|
* - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
|
|
1500
|
+
* @param options - Input source; encoded frames must have matching dimensions.
|
|
1501
|
+
* @param workspacePath - Workspace receiving temporary input files.
|
|
1502
|
+
* @returns Frame nodes or a resolved video path for extraction.
|
|
1503
|
+
* @throws Propagates metadata or write errors and rejects mismatched frame sizes.
|
|
766
1504
|
*/
|
|
767
1505
|
async function resolveInput(options, workspacePath) {
|
|
768
1506
|
if (options.mode === "file") return {
|
|
@@ -773,12 +1511,42 @@ async function resolveInput(options, workspacePath) {
|
|
|
773
1511
|
frames: [],
|
|
774
1512
|
resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
|
|
775
1513
|
};
|
|
776
|
-
if (options.mode === "frames")
|
|
1514
|
+
if (options.mode === "frames") {
|
|
1515
|
+
let dimensions;
|
|
1516
|
+
for (const buffer of options.inputFrames) {
|
|
1517
|
+
const { width, height } = await sharp(buffer).metadata();
|
|
1518
|
+
if (dimensions && (width !== dimensions.width || height !== dimensions.height)) throw new Error(`inputFrames must be the same size (${dimensions.width}x${dimensions.height}), received: ${width}x${height}`);
|
|
1519
|
+
dimensions = {
|
|
1520
|
+
width,
|
|
1521
|
+
height
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
|
|
1525
|
+
}
|
|
777
1526
|
throw new Error(`Unsupported input mode: ${options.mode}`);
|
|
778
1527
|
}
|
|
779
1528
|
|
|
780
1529
|
//#endregion
|
|
781
|
-
//#region src/
|
|
1530
|
+
//#region src/core/pruner/scoring/normalize-scores.ts
|
|
1531
|
+
const NORMALIZATION_ALPHA = .4;
|
|
1532
|
+
const NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
1533
|
+
const NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
1534
|
+
/**
|
|
1535
|
+
* Find the first position whose score is at least the requested value.
|
|
1536
|
+
* @param sorted - Finite positive scores sorted in ascending order.
|
|
1537
|
+
* @param value - A finite positive score present in sorted.
|
|
1538
|
+
* @returns The first matching rank, including the first position of any tie.
|
|
1539
|
+
*/
|
|
1540
|
+
function lowerBound(sorted, value) {
|
|
1541
|
+
let low = 0;
|
|
1542
|
+
let high = sorted.length;
|
|
1543
|
+
while (low < high) {
|
|
1544
|
+
const mid = Math.floor((low + high) / 2);
|
|
1545
|
+
if (sorted[mid] < value) low = mid + 1;
|
|
1546
|
+
else high = mid;
|
|
1547
|
+
}
|
|
1548
|
+
return low;
|
|
1549
|
+
}
|
|
782
1550
|
/**
|
|
783
1551
|
* Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
|
|
784
1552
|
*
|
|
@@ -823,13 +1591,13 @@ function normalizeScores(items) {
|
|
|
823
1591
|
});
|
|
824
1592
|
const cdf = map(safeScores, (s) => {
|
|
825
1593
|
if (s <= 0) return 0;
|
|
826
|
-
return sorted
|
|
1594
|
+
return lowerBound(sorted, s) / sorted.length;
|
|
827
1595
|
});
|
|
828
|
-
return map(logisticZ, (z, i) => z *
|
|
1596
|
+
return map(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
|
|
829
1597
|
}
|
|
830
1598
|
|
|
831
1599
|
//#endregion
|
|
832
|
-
//#region src/
|
|
1600
|
+
//#region src/core/pruner/heap/min-heap.ts
|
|
833
1601
|
/**
|
|
834
1602
|
* Generic binary min-heap.
|
|
835
1603
|
*
|
|
@@ -880,7 +1648,7 @@ var MinHeap = class {
|
|
|
880
1648
|
};
|
|
881
1649
|
|
|
882
1650
|
//#endregion
|
|
883
|
-
//#region src/core/pruner.ts
|
|
1651
|
+
//#region src/core/pruner/pruner.ts
|
|
884
1652
|
/**
|
|
885
1653
|
* Edge-aware greedy merge with re-linking — O(N log N).
|
|
886
1654
|
*
|
|
@@ -994,7 +1762,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
|
994
1762
|
return result;
|
|
995
1763
|
}
|
|
996
1764
|
/**
|
|
997
|
-
* Threshold-based pruning with NMS -- O(N).
|
|
1765
|
+
* Threshold-based pruning with NMS -- including normalization, O(N log N).
|
|
998
1766
|
*
|
|
999
1767
|
* 1. Scores are normalized to [0, 1] via percentile normalization.
|
|
1000
1768
|
* 2. Edges with normalized score >= threshold are collected.
|
|
@@ -1059,7 +1827,7 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
1059
1827
|
}
|
|
1060
1828
|
|
|
1061
1829
|
//#endregion
|
|
1062
|
-
//#region src/
|
|
1830
|
+
//#region src/core/segmenter/scheduling/concurrency.ts
|
|
1063
1831
|
/**
|
|
1064
1832
|
* Creates a concurrency limiter that runs at most `limit` tasks in parallel.
|
|
1065
1833
|
* Lightweight replacement for p-limit to avoid external dependency.
|
|
@@ -1081,7 +1849,7 @@ function concurrencyLimit(limit) {
|
|
|
1081
1849
|
}
|
|
1082
1850
|
|
|
1083
1851
|
//#endregion
|
|
1084
|
-
//#region src/core/segmenter.ts
|
|
1852
|
+
//#region src/core/segmenter/segmenter.ts
|
|
1085
1853
|
/**
|
|
1086
1854
|
* Determine whether segmentation should be used.
|
|
1087
1855
|
* Returns false for frames mode and GIF files.
|
|
@@ -1095,53 +1863,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
|
|
|
1095
1863
|
return true;
|
|
1096
1864
|
}
|
|
1097
1865
|
/**
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1866
|
+
* Partition the global extraction grid into nonempty logical segments.
|
|
1867
|
+
* @param totalDuration Positive source duration in seconds.
|
|
1868
|
+
* @param maxSegmentDuration Positive logical segment width in seconds.
|
|
1869
|
+
* @param maxFrames Candidate budget, defensively raised to at least two.
|
|
1870
|
+
* @param fps Positive requested sampling frequency.
|
|
1871
|
+
* @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
|
|
1104
1872
|
*/
|
|
1105
1873
|
function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
|
|
1106
|
-
const
|
|
1874
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
1875
|
+
const effectiveFps = Math.min(fps, frameLimit / totalDuration);
|
|
1107
1876
|
if (totalDuration <= maxSegmentDuration) return [{
|
|
1108
1877
|
index: 0,
|
|
1109
1878
|
startTime: 0,
|
|
1110
1879
|
endTime: totalDuration,
|
|
1111
1880
|
duration: totalDuration,
|
|
1112
|
-
allocatedFrames:
|
|
1881
|
+
allocatedFrames: frameLimit,
|
|
1113
1882
|
effectiveFps,
|
|
1114
1883
|
overlapBefore: 0,
|
|
1115
1884
|
overlapAfter: 0,
|
|
1116
1885
|
extractStartTime: 0,
|
|
1117
1886
|
extractDuration: totalDuration
|
|
1118
1887
|
}];
|
|
1119
|
-
const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
|
|
1120
|
-
const overlapTime = 1 / effectiveFps;
|
|
1121
1888
|
const segments = [];
|
|
1122
|
-
for (let
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
const
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1889
|
+
for (let slot = 0; slot < frameLimit; slot++) {
|
|
1890
|
+
const timestamp = slot / effectiveFps;
|
|
1891
|
+
if (timestamp >= totalDuration) break;
|
|
1892
|
+
const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
|
|
1893
|
+
const previous = segments[segments.length - 1];
|
|
1894
|
+
if (previous?.startTime === startTime) {
|
|
1895
|
+
previous.allocatedFrames++;
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
|
|
1130
1899
|
segments.push({
|
|
1131
|
-
index:
|
|
1900
|
+
index: segments.length,
|
|
1132
1901
|
startTime,
|
|
1133
1902
|
endTime,
|
|
1134
|
-
duration,
|
|
1135
|
-
allocatedFrames:
|
|
1903
|
+
duration: endTime - startTime,
|
|
1904
|
+
allocatedFrames: 1,
|
|
1136
1905
|
effectiveFps,
|
|
1137
|
-
overlapBefore,
|
|
1138
|
-
overlapAfter,
|
|
1139
|
-
extractStartTime,
|
|
1140
|
-
extractDuration
|
|
1906
|
+
overlapBefore: 0,
|
|
1907
|
+
overlapAfter: 0,
|
|
1908
|
+
extractStartTime: timestamp,
|
|
1909
|
+
extractDuration: 0
|
|
1141
1910
|
});
|
|
1142
1911
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1912
|
+
let firstSlot = 0;
|
|
1913
|
+
for (const segment of segments) {
|
|
1914
|
+
const nextSlot = firstSlot + segment.allocatedFrames;
|
|
1915
|
+
segment.overlapBefore = segment.index > 0 ? 1 : 0;
|
|
1916
|
+
segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
|
|
1917
|
+
segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
|
|
1918
|
+
segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
|
|
1919
|
+
segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
|
|
1920
|
+
firstSlot = nextSlot;
|
|
1921
|
+
}
|
|
1145
1922
|
return segments;
|
|
1146
1923
|
}
|
|
1147
1924
|
/**
|
|
@@ -1160,44 +1937,58 @@ function collectAllFrames(segmentResults) {
|
|
|
1160
1937
|
return allFrames;
|
|
1161
1938
|
}
|
|
1162
1939
|
/**
|
|
1163
|
-
* Sort frames
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1940
|
+
* Sort frames in place and alias overlap duplicates to the first survivor.
|
|
1941
|
+
* @param frames Collected entries whose segment index and local ID identify a frame.
|
|
1942
|
+
* @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
|
|
1943
|
+
* @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
|
|
1166
1944
|
*/
|
|
1167
1945
|
function deduplicateFrames(frames, effectiveFps) {
|
|
1168
1946
|
frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
|
|
1169
1947
|
const dupThreshold = 1 / (effectiveFps * 2);
|
|
1170
1948
|
const unique = [];
|
|
1949
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1171
1950
|
for (const entry of frames) {
|
|
1172
1951
|
if (unique.length > 0) {
|
|
1173
1952
|
const last = unique[unique.length - 1];
|
|
1174
|
-
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold)
|
|
1953
|
+
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
|
|
1954
|
+
aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1175
1957
|
}
|
|
1176
1958
|
unique.push(entry);
|
|
1177
1959
|
}
|
|
1178
|
-
return
|
|
1960
|
+
return {
|
|
1961
|
+
unique,
|
|
1962
|
+
aliases
|
|
1963
|
+
};
|
|
1179
1964
|
}
|
|
1180
1965
|
/**
|
|
1181
|
-
* Assign sequential global IDs
|
|
1182
|
-
*
|
|
1966
|
+
* Assign sequential global IDs and retain duplicate local IDs as aliases.
|
|
1967
|
+
* @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
|
|
1968
|
+
* @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
|
|
1969
|
+
* @returns Remapped frames and a global ID lookup covering survivors and duplicates.
|
|
1183
1970
|
*/
|
|
1184
|
-
function remapFrameIds(uniqueFrames) {
|
|
1971
|
+
function remapFrameIds(uniqueFrames, aliases) {
|
|
1185
1972
|
const globalIdMap = /* @__PURE__ */ new Map();
|
|
1973
|
+
const frames = uniqueFrames.map((entry, globalId) => {
|
|
1974
|
+
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1975
|
+
return {
|
|
1976
|
+
id: globalId,
|
|
1977
|
+
timestamp: entry.frame.timestamp,
|
|
1978
|
+
extractPath: entry.frame.extractPath
|
|
1979
|
+
};
|
|
1980
|
+
});
|
|
1981
|
+
for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
|
|
1186
1982
|
return {
|
|
1187
|
-
frames
|
|
1188
|
-
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1189
|
-
return {
|
|
1190
|
-
id: globalId,
|
|
1191
|
-
timestamp: entry.frame.timestamp,
|
|
1192
|
-
extractPath: entry.frame.extractPath
|
|
1193
|
-
};
|
|
1194
|
-
}),
|
|
1983
|
+
frames,
|
|
1195
1984
|
globalIdMap
|
|
1196
1985
|
};
|
|
1197
1986
|
}
|
|
1198
1987
|
/**
|
|
1199
|
-
* Remap
|
|
1200
|
-
*
|
|
1988
|
+
* Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
|
|
1989
|
+
* @param segmentResults Segment-local edges in encounter order.
|
|
1990
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
1991
|
+
* @returns One edge per surviving directed pair without changing its score.
|
|
1201
1992
|
*/
|
|
1202
1993
|
function remapEdges(segmentResults, globalIdMap) {
|
|
1203
1994
|
const edges = [];
|
|
@@ -1206,62 +1997,73 @@ function remapEdges(segmentResults, globalIdMap) {
|
|
|
1206
1997
|
const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
|
|
1207
1998
|
const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
|
|
1208
1999
|
if (newSourceId === void 0 || newTargetId === void 0) continue;
|
|
2000
|
+
if (newSourceId === newTargetId) continue;
|
|
1209
2001
|
const edgeKey = `${newSourceId}-${newTargetId}`;
|
|
1210
2002
|
const existingIdx = edgeMap.get(edgeKey);
|
|
1211
2003
|
if (existingIdx !== void 0) {
|
|
1212
2004
|
if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
|
|
2005
|
+
...edge,
|
|
1213
2006
|
sourceId: newSourceId,
|
|
1214
|
-
targetId: newTargetId
|
|
1215
|
-
score: edge.score
|
|
2007
|
+
targetId: newTargetId
|
|
1216
2008
|
};
|
|
1217
2009
|
} else {
|
|
1218
2010
|
edgeMap.set(edgeKey, edges.length);
|
|
1219
2011
|
edges.push({
|
|
2012
|
+
...edge,
|
|
1220
2013
|
sourceId: newSourceId,
|
|
1221
|
-
targetId: newTargetId
|
|
1222
|
-
score: edge.score
|
|
2014
|
+
targetId: newTargetId
|
|
1223
2015
|
});
|
|
1224
2016
|
}
|
|
1225
2017
|
}
|
|
1226
2018
|
return edges;
|
|
1227
2019
|
}
|
|
1228
2020
|
/**
|
|
1229
|
-
* Remap
|
|
1230
|
-
*
|
|
2021
|
+
* Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
|
|
2022
|
+
* @param segmentResults Segment-local tracker entries in encounter order.
|
|
2023
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
2024
|
+
* @returns One animation per directed pair with its original tracker duration and metadata.
|
|
1231
2025
|
*/
|
|
1232
2026
|
function remapAnimations(segmentResults, globalIdMap) {
|
|
1233
|
-
const animations =
|
|
2027
|
+
const animations = /* @__PURE__ */ new Map();
|
|
1234
2028
|
for (const result of segmentResults) for (const anim of result.animations) {
|
|
1235
2029
|
const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
|
|
1236
2030
|
const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
|
|
1237
2031
|
if (newStartId === void 0 || newEndId === void 0) continue;
|
|
1238
|
-
|
|
2032
|
+
if (newStartId === newEndId) continue;
|
|
2033
|
+
const animationKey = `${newStartId}-${newEndId}`;
|
|
2034
|
+
if (animations.has(animationKey)) continue;
|
|
2035
|
+
animations.set(animationKey, {
|
|
1239
2036
|
...anim,
|
|
1240
2037
|
startFrameId: newStartId,
|
|
1241
2038
|
endFrameId: newEndId
|
|
1242
2039
|
});
|
|
1243
2040
|
}
|
|
1244
|
-
return animations;
|
|
2041
|
+
return [...animations.values()];
|
|
1245
2042
|
}
|
|
1246
2043
|
/**
|
|
1247
2044
|
* Merge multiple segment results into a single unified frame/edge/animation set.
|
|
1248
|
-
*
|
|
1249
|
-
* -
|
|
1250
|
-
*
|
|
1251
|
-
* - Duplicate edges keep higher score
|
|
2045
|
+
* @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
|
|
2046
|
+
* @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
|
|
2047
|
+
* Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
|
|
1252
2048
|
*/
|
|
1253
2049
|
function mergeSegmentFrames(segmentResults) {
|
|
1254
2050
|
if (segmentResults.length === 0) return {
|
|
1255
2051
|
frames: [],
|
|
1256
2052
|
edges: [],
|
|
1257
|
-
animations: []
|
|
2053
|
+
animations: [],
|
|
2054
|
+
analysisResolution: {
|
|
2055
|
+
width: 0,
|
|
2056
|
+
height: 0
|
|
2057
|
+
}
|
|
1258
2058
|
};
|
|
1259
2059
|
const effectiveFps = segmentResults[0].segment.effectiveFps;
|
|
1260
|
-
const {
|
|
2060
|
+
const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
|
|
2061
|
+
const { frames, globalIdMap } = remapFrameIds(unique, aliases);
|
|
1261
2062
|
return {
|
|
1262
2063
|
frames,
|
|
1263
2064
|
edges: remapEdges(segmentResults, globalIdMap),
|
|
1264
|
-
animations: remapAnimations(segmentResults, globalIdMap)
|
|
2065
|
+
animations: remapAnimations(segmentResults, globalIdMap),
|
|
2066
|
+
analysisResolution: segmentResults[0].analysisResolution
|
|
1265
2067
|
};
|
|
1266
2068
|
}
|
|
1267
2069
|
function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
|
|
@@ -1272,6 +2074,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1272
2074
|
maxFrames: segment.allocatedFrames
|
|
1273
2075
|
},
|
|
1274
2076
|
workspacePath: segmentWorkspacePath,
|
|
2077
|
+
effectiveFps: segment.effectiveFps,
|
|
1275
2078
|
frames,
|
|
1276
2079
|
graph: [],
|
|
1277
2080
|
status: "ANALYZING",
|
|
@@ -1283,19 +2086,26 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1283
2086
|
* Each segment uses an isolated workspace directory.
|
|
1284
2087
|
*/
|
|
1285
2088
|
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1286
|
-
const
|
|
2089
|
+
const framesDir = join(workspacePath, "frames");
|
|
2090
|
+
const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
|
|
1287
2091
|
if (frames.length < 2) return {
|
|
1288
2092
|
segment,
|
|
1289
2093
|
frames,
|
|
1290
2094
|
edges: [],
|
|
1291
|
-
animations: []
|
|
2095
|
+
animations: [],
|
|
2096
|
+
analysisResolution: {
|
|
2097
|
+
width: 0,
|
|
2098
|
+
height: 0
|
|
2099
|
+
}
|
|
1292
2100
|
};
|
|
1293
|
-
const
|
|
2101
|
+
const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
|
|
2102
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1294
2103
|
return {
|
|
1295
2104
|
segment,
|
|
1296
2105
|
frames,
|
|
1297
2106
|
edges,
|
|
1298
|
-
animations
|
|
2107
|
+
animations,
|
|
2108
|
+
analysisResolution
|
|
1299
2109
|
};
|
|
1300
2110
|
}
|
|
1301
2111
|
/**
|
|
@@ -1335,7 +2145,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1335
2145
|
});
|
|
1336
2146
|
})));
|
|
1337
2147
|
options.onProgress?.("ANALYZING", 100);
|
|
1338
|
-
const { frames, edges, animations } = mergeSegmentFrames(results);
|
|
2148
|
+
const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
|
|
1339
2149
|
logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
|
|
1340
2150
|
options.onProgress?.("PRUNING", 0);
|
|
1341
2151
|
const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
@@ -1344,6 +2154,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1344
2154
|
options.onProgress?.("FINALIZING", 0);
|
|
1345
2155
|
const ctx = {
|
|
1346
2156
|
options: resolvedOptions,
|
|
2157
|
+
effectiveFps: segments[0]?.effectiveFps,
|
|
2158
|
+
sourceDurationSec: totalDuration,
|
|
2159
|
+
analysisResolution,
|
|
1347
2160
|
workspacePath: mainWorkspace,
|
|
1348
2161
|
frames,
|
|
1349
2162
|
graph: edges,
|
|
@@ -1351,27 +2164,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1351
2164
|
status: "FINALIZING",
|
|
1352
2165
|
emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
|
|
1353
2166
|
};
|
|
1354
|
-
|
|
1355
|
-
let outputBuffers;
|
|
1356
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1357
|
-
else outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
2167
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
1358
2168
|
options.onProgress?.("FINALIZING", 100);
|
|
1359
2169
|
logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
|
|
1360
2170
|
return {
|
|
1361
2171
|
success: true,
|
|
1362
2172
|
originalFramesCount: frames.length,
|
|
1363
2173
|
prunedFramesCount: prunedFrames.length,
|
|
1364
|
-
outputFiles,
|
|
1365
|
-
outputBuffers,
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
width: resolvedOptions.scale,
|
|
1372
|
-
height: Math.round(resolvedOptions.scale * 9 / 16)
|
|
1373
|
-
}
|
|
1374
|
-
},
|
|
2174
|
+
outputFiles: finalized.outputFiles,
|
|
2175
|
+
outputBuffers: finalized.outputBuffers,
|
|
2176
|
+
video: finalized.document.video,
|
|
2177
|
+
animations: finalized.animations,
|
|
2178
|
+
frames: finalized.document.frames,
|
|
2179
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2180
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1375
2181
|
executionTimeMs: Date.now() - pipelineStart
|
|
1376
2182
|
};
|
|
1377
2183
|
} catch (error) {
|
|
@@ -1385,9 +2191,15 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1385
2191
|
}
|
|
1386
2192
|
|
|
1387
2193
|
//#endregion
|
|
1388
|
-
//#region src/core/orchestrator.ts
|
|
2194
|
+
//#region src/core/orchestrator/orchestrator.ts
|
|
2195
|
+
/**
|
|
2196
|
+
* Run the five pipeline stages, delegating segmented inputs to the segmenter.
|
|
2197
|
+
* @param options - Mode-specific input and optional pipeline settings.
|
|
2198
|
+
* @returns Selected outputs with v2 frame metadata and zero-based API animations.
|
|
2199
|
+
* @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
|
|
2200
|
+
*/
|
|
1389
2201
|
async function runPipeline(options) {
|
|
1390
|
-
|
|
2202
|
+
setDebugMode(options.debug ?? false);
|
|
1391
2203
|
const resolvedOptions = resolveOptions(options);
|
|
1392
2204
|
if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
|
|
1393
2205
|
const startTime = Date.now();
|
|
@@ -1407,50 +2219,47 @@ async function runPipeline(options) {
|
|
|
1407
2219
|
logger.debug(`Workspace created: ${ctx.workspacePath}`);
|
|
1408
2220
|
ctx.status = "EXTRACTING";
|
|
1409
2221
|
const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
|
|
1410
|
-
if (resolvedOptions.mode === "frames")
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
2222
|
+
if (resolvedOptions.mode === "frames") {
|
|
2223
|
+
ctx.frames = resolvedFrames;
|
|
2224
|
+
ctx.effectiveFps = 1;
|
|
2225
|
+
} else {
|
|
2226
|
+
const extractCtx = {
|
|
2227
|
+
...ctx,
|
|
2228
|
+
options: {
|
|
2229
|
+
...resolvedOptions,
|
|
2230
|
+
inputPath: resolvedInputPath
|
|
2231
|
+
}
|
|
2232
|
+
};
|
|
2233
|
+
ctx.frames = await extractFrames(extractCtx);
|
|
2234
|
+
ctx.effectiveFps = extractCtx.effectiveFps;
|
|
2235
|
+
ctx.sourceDurationSec = extractCtx.sourceDurationSec;
|
|
2236
|
+
}
|
|
1418
2237
|
ctx.emitProgress(100);
|
|
1419
2238
|
ctx.status = "ANALYZING";
|
|
1420
|
-
const { edges, animations } = await analyzeFrames(ctx);
|
|
2239
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1421
2240
|
ctx.graph = edges;
|
|
1422
2241
|
ctx.animations = animations;
|
|
2242
|
+
ctx.analysisResolution = analysisResolution;
|
|
1423
2243
|
ctx.status = "PRUNING";
|
|
1424
2244
|
const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
1425
2245
|
const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
|
|
1426
2246
|
ctx.emitProgress(100);
|
|
1427
2247
|
ctx.status = "FINALIZING";
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1431
|
-
outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1432
|
-
ctx.emitProgress(100);
|
|
1433
|
-
} else {
|
|
1434
|
-
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1435
|
-
ctx.emitProgress(100);
|
|
1436
|
-
}
|
|
2248
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
2249
|
+
ctx.emitProgress(100);
|
|
1437
2250
|
ctx.status = "SUCCESS";
|
|
1438
2251
|
logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
|
|
1439
2252
|
return {
|
|
1440
2253
|
success: true,
|
|
1441
2254
|
originalFramesCount: ctx.frames.length,
|
|
1442
2255
|
prunedFramesCount: prunedFrames.length,
|
|
1443
|
-
outputFiles,
|
|
1444
|
-
outputBuffers,
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
width: ctx.options.scale,
|
|
1451
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1452
|
-
}
|
|
1453
|
-
},
|
|
2256
|
+
outputFiles: finalized.outputFiles,
|
|
2257
|
+
outputBuffers: finalized.outputBuffers,
|
|
2258
|
+
video: finalized.document.video,
|
|
2259
|
+
animations: finalized.animations,
|
|
2260
|
+
frames: finalized.document.frames,
|
|
2261
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2262
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1454
2263
|
executionTimeMs: Date.now() - startTime
|
|
1455
2264
|
};
|
|
1456
2265
|
} catch (error) {
|
|
@@ -1465,7 +2274,7 @@ async function runPipeline(options) {
|
|
|
1465
2274
|
}
|
|
1466
2275
|
|
|
1467
2276
|
//#endregion
|
|
1468
|
-
//#region src/core/pipeline-worker.ts
|
|
2277
|
+
//#region src/core/orchestrator/worker/pipeline-worker.ts
|
|
1469
2278
|
runPipeline({
|
|
1470
2279
|
...workerData,
|
|
1471
2280
|
onProgress: (phase, percent) => {
|