@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/cli.mjs
CHANGED
|
@@ -6,13 +6,13 @@ import { Box, Text, render, useApp } from "ink";
|
|
|
6
6
|
import React, { useEffect, useState } from "react";
|
|
7
7
|
import Spinner from "ink-spinner";
|
|
8
8
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
-
import { homedir, tmpdir } from "node:os";
|
|
10
|
-
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
11
9
|
import { randomUUID } from "node:crypto";
|
|
12
10
|
import { filter, map } from "@winglet/common-utils";
|
|
13
11
|
import pc from "picocolors";
|
|
14
12
|
import sharp from "sharp";
|
|
15
13
|
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
14
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
15
|
+
import { homedir, tmpdir } from "node:os";
|
|
16
16
|
import { path } from "@ffprobe-installer/ffprobe";
|
|
17
17
|
import { execa } from "execa";
|
|
18
18
|
import ffmpegPath from "ffmpeg-static";
|
|
@@ -84,7 +84,7 @@ function respondError(command, code, message, startTime, version, details) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
//#endregion
|
|
87
|
-
//#region src/components/ProgressBar.tsx
|
|
87
|
+
//#region src/cli/components/ProgressBar.tsx
|
|
88
88
|
const ProgressBar = ({ percent, width = 30 }) => {
|
|
89
89
|
const clamped = Math.max(0, Math.min(100, percent));
|
|
90
90
|
const filled = Math.round(width * (clamped / 100));
|
|
@@ -107,7 +107,7 @@ const ProgressBar = ({ percent, width = 30 }) => {
|
|
|
107
107
|
};
|
|
108
108
|
|
|
109
109
|
//#endregion
|
|
110
|
-
//#region src/components/PhaseStep.tsx
|
|
110
|
+
//#region src/cli/components/PhaseStep.tsx
|
|
111
111
|
const PhaseStep = ({ phase }) => {
|
|
112
112
|
const icon = (() => {
|
|
113
113
|
switch (phase.status) {
|
|
@@ -146,28 +146,18 @@ const PhaseStep = ({ phase }) => {
|
|
|
146
146
|
};
|
|
147
147
|
|
|
148
148
|
//#endregion
|
|
149
|
-
//#region src/constants.ts
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
var APP_NAME, DEFAULT_THRESHOLD, NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN, DBSCAN_ALPHA, IOU_THRESHOLD, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
|
|
154
|
-
var init_constants = __esmMin((() => {
|
|
155
|
-
APP_NAME = "scene-sieve";
|
|
149
|
+
//#region src/constants/pipeline-defaults.ts
|
|
150
|
+
var DEFAULT_THRESHOLD, IOU_THRESHOLD, SHEET_LABEL_HEIGHT_RATIO, SHEET_FILE_NAME, METADATA_FILE_NAME;
|
|
151
|
+
var init_pipeline_defaults = __esmMin((() => {
|
|
156
152
|
DEFAULT_THRESHOLD = .5;
|
|
157
|
-
NORMALIZATION_ALPHA = .4;
|
|
158
|
-
NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
159
|
-
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
160
|
-
TEMP_BASE_DIR = tmpdir();
|
|
161
|
-
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
162
|
-
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
163
|
-
DBSCAN_ALPHA = .03;
|
|
164
153
|
IOU_THRESHOLD = .9;
|
|
165
|
-
|
|
166
|
-
|
|
154
|
+
SHEET_LABEL_HEIGHT_RATIO = .07;
|
|
155
|
+
SHEET_FILE_NAME = "sheet.jpg";
|
|
156
|
+
METADATA_FILE_NAME = ".metadata.json";
|
|
167
157
|
}));
|
|
168
158
|
|
|
169
159
|
//#endregion
|
|
170
|
-
//#region src/
|
|
160
|
+
//#region src/logging/logger.ts
|
|
171
161
|
function setDebugMode(enabled) {
|
|
172
162
|
debugMode = enabled;
|
|
173
163
|
}
|
|
@@ -197,14 +187,25 @@ var init_logger = __esmMin((() => {
|
|
|
197
187
|
console.error(`${pc.red("error")} ${message}`);
|
|
198
188
|
},
|
|
199
189
|
debug(message) {
|
|
200
|
-
if (debugMode)
|
|
201
|
-
|
|
190
|
+
if (debugMode) {
|
|
191
|
+
if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
|
|
192
|
+
else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
193
|
+
}
|
|
202
194
|
}
|
|
203
195
|
};
|
|
204
196
|
}));
|
|
205
197
|
|
|
206
198
|
//#endregion
|
|
207
|
-
//#region src/core/
|
|
199
|
+
//#region src/core/analyzer/constants/vision-tuning.ts
|
|
200
|
+
var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
|
|
201
|
+
var init_vision_tuning = __esmMin((() => {
|
|
202
|
+
DBSCAN_ALPHA = .03;
|
|
203
|
+
DECAY_LAMBDA = .95;
|
|
204
|
+
MATCH_DISTANCE_THRESHOLD = .25;
|
|
205
|
+
}));
|
|
206
|
+
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/core/analyzer/clustering/dbscan.ts
|
|
208
209
|
/**
|
|
209
210
|
* DBSCAN clustering with resolution-independent eps.
|
|
210
211
|
* eps = alpha * sqrt(width^2 + height^2)
|
|
@@ -282,13 +283,116 @@ function findNeighbors(points, idx, epsSquared) {
|
|
|
282
283
|
}
|
|
283
284
|
var UNVISITED, NOISE;
|
|
284
285
|
var init_dbscan = __esmMin((() => {
|
|
285
|
-
|
|
286
|
+
init_vision_tuning();
|
|
286
287
|
UNVISITED = -2;
|
|
287
288
|
NOISE = -1;
|
|
288
289
|
}));
|
|
289
290
|
|
|
290
291
|
//#endregion
|
|
291
|
-
//#region src/core/analyzer.ts
|
|
292
|
+
//#region src/core/analyzer/features/feature-diff.ts
|
|
293
|
+
/**
|
|
294
|
+
* Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
|
|
295
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
296
|
+
* @param prev - Previous frame's live features, owned by the caller.
|
|
297
|
+
* @param next - Next frame's live features, owned by the caller.
|
|
298
|
+
* @returns Unmatched next-frame coordinates without changing input ownership.
|
|
299
|
+
* @throws Propagates matching errors after releasing temporary native handles.
|
|
300
|
+
*/
|
|
301
|
+
function computeNewPoints(cvLib, prev, next) {
|
|
302
|
+
let matcher = null;
|
|
303
|
+
let matches = null;
|
|
304
|
+
try {
|
|
305
|
+
const matchedIndices = /* @__PURE__ */ new Set();
|
|
306
|
+
if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
|
|
307
|
+
matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
308
|
+
matches = new cvLib.DMatchVectorVector();
|
|
309
|
+
matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
|
|
310
|
+
for (let i = 0; i < matches.size(); i++) {
|
|
311
|
+
const pair = matches.get(i);
|
|
312
|
+
try {
|
|
313
|
+
if (pair.size() < 2) continue;
|
|
314
|
+
const best = pair.get(0);
|
|
315
|
+
const second = pair.get(1);
|
|
316
|
+
if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
|
|
317
|
+
} finally {
|
|
318
|
+
pair.delete();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
matcher?.delete();
|
|
323
|
+
}
|
|
324
|
+
const points = [];
|
|
325
|
+
for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
|
|
326
|
+
const { x, y } = next.keypoints.get(i).pt;
|
|
327
|
+
points.push({
|
|
328
|
+
x,
|
|
329
|
+
y
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return points;
|
|
333
|
+
} finally {
|
|
334
|
+
matches?.delete();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
var init_feature_diff = __esmMin((() => {
|
|
338
|
+
init_vision_tuning();
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/core/analyzer/features/frame-features.ts
|
|
343
|
+
/**
|
|
344
|
+
* Detect one frame's features without retaining its image or mask.
|
|
345
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
346
|
+
* @param akaze - Detector owned and released by the caller.
|
|
347
|
+
* @param frame - Grayscale bytes with matching width and height.
|
|
348
|
+
* @returns Feature handles that the caller must delete.
|
|
349
|
+
* @throws Propagates native errors after releasing partial allocations.
|
|
350
|
+
*/
|
|
351
|
+
function computeFrameFeatures(cvLib, akaze, frame) {
|
|
352
|
+
let image = null;
|
|
353
|
+
let mask = null;
|
|
354
|
+
let keypoints = null;
|
|
355
|
+
let descriptors = null;
|
|
356
|
+
try {
|
|
357
|
+
image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
|
|
358
|
+
image.data.set(frame.data);
|
|
359
|
+
mask = new cvLib.Mat();
|
|
360
|
+
keypoints = new cvLib.KeyPointVector();
|
|
361
|
+
descriptors = new cvLib.Mat();
|
|
362
|
+
akaze.detectAndCompute(image, mask, keypoints, descriptors);
|
|
363
|
+
const ownedKeypoints = keypoints;
|
|
364
|
+
const ownedDescriptors = descriptors;
|
|
365
|
+
let deleted = false;
|
|
366
|
+
const features = {
|
|
367
|
+
width: frame.width,
|
|
368
|
+
height: frame.height,
|
|
369
|
+
keypoints: ownedKeypoints,
|
|
370
|
+
descriptors: ownedDescriptors,
|
|
371
|
+
/** Release the transferred handles exactly once. */
|
|
372
|
+
delete() {
|
|
373
|
+
if (deleted) return;
|
|
374
|
+
deleted = true;
|
|
375
|
+
try {
|
|
376
|
+
ownedKeypoints.delete();
|
|
377
|
+
} finally {
|
|
378
|
+
ownedDescriptors.delete();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
keypoints = null;
|
|
383
|
+
descriptors = null;
|
|
384
|
+
return features;
|
|
385
|
+
} finally {
|
|
386
|
+
image?.delete();
|
|
387
|
+
mask?.delete();
|
|
388
|
+
keypoints?.delete();
|
|
389
|
+
descriptors?.delete();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
var init_frame_features = __esmMin((() => {}));
|
|
393
|
+
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/core/analyzer/analyzer.ts
|
|
292
396
|
async function ensureOpenCV() {
|
|
293
397
|
if (!cvReady) cvReady = (async () => {
|
|
294
398
|
const cvObj = require("@techstark/opencv-js");
|
|
@@ -327,77 +431,6 @@ function computeIoU(a, b) {
|
|
|
327
431
|
const union = a.width * a.height + b.width * b.height - intersection;
|
|
328
432
|
return union === 0 ? 0 : intersection / union;
|
|
329
433
|
}
|
|
330
|
-
async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
331
|
-
const cv = cvLib;
|
|
332
|
-
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
333
|
-
mat1.data.set(frame1.data);
|
|
334
|
-
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
335
|
-
mat2.data.set(frame2.data);
|
|
336
|
-
const kp1 = new cvLib.KeyPointVector();
|
|
337
|
-
const kp2 = new cvLib.KeyPointVector();
|
|
338
|
-
const desc1 = new cvLib.Mat();
|
|
339
|
-
const desc2 = new cvLib.Mat();
|
|
340
|
-
const mask1 = new cvLib.Mat();
|
|
341
|
-
const mask2 = new cvLib.Mat();
|
|
342
|
-
const akaze = new cvLib.AKAZE();
|
|
343
|
-
let matches = null;
|
|
344
|
-
try {
|
|
345
|
-
akaze.detectAndCompute(mat1, mask1, kp1, desc1);
|
|
346
|
-
akaze.detectAndCompute(mat2, mask2, kp2, desc2);
|
|
347
|
-
const matchedKp1Indices = /* @__PURE__ */ new Set();
|
|
348
|
-
const matchedKp2Indices = /* @__PURE__ */ new Set();
|
|
349
|
-
if (desc1.rows > 0 && desc2.rows > 0) {
|
|
350
|
-
const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
351
|
-
try {
|
|
352
|
-
matches = new cvLib.DMatchVectorVector();
|
|
353
|
-
matcher.knnMatch(desc1, desc2, matches, 2);
|
|
354
|
-
for (let i = 0; i < matches.size(); i++) {
|
|
355
|
-
const pair = matches.get(i);
|
|
356
|
-
if (pair.size() < 2) continue;
|
|
357
|
-
const m0 = pair.get(0);
|
|
358
|
-
const m1 = pair.get(1);
|
|
359
|
-
if (m0.distance < .25 * m1.distance) {
|
|
360
|
-
matchedKp1Indices.add(m0.queryIdx);
|
|
361
|
-
matchedKp2Indices.add(m0.trainIdx);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
} finally {
|
|
365
|
-
matcher.delete();
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
const sNew = [];
|
|
369
|
-
for (let i = 0; i < kp2.size(); i++) if (!matchedKp2Indices.has(i)) {
|
|
370
|
-
const pt = kp2.get(i).pt;
|
|
371
|
-
sNew.push({
|
|
372
|
-
x: pt.x,
|
|
373
|
-
y: pt.y
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
const sLoss = [];
|
|
377
|
-
for (let i = 0; i < kp1.size(); i++) if (!matchedKp1Indices.has(i)) {
|
|
378
|
-
const pt = kp1.get(i).pt;
|
|
379
|
-
sLoss.push({
|
|
380
|
-
x: pt.x,
|
|
381
|
-
y: pt.y
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
return {
|
|
385
|
-
sNew,
|
|
386
|
-
sLoss
|
|
387
|
-
};
|
|
388
|
-
} finally {
|
|
389
|
-
mat1.delete();
|
|
390
|
-
mat2.delete();
|
|
391
|
-
kp1.delete();
|
|
392
|
-
kp2.delete();
|
|
393
|
-
desc1.delete();
|
|
394
|
-
desc2.delete();
|
|
395
|
-
mask1.delete();
|
|
396
|
-
mask2.delete();
|
|
397
|
-
akaze.delete();
|
|
398
|
-
if (matches) matches.delete();
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
434
|
/**
|
|
402
435
|
* Pixel-level difference fallback for AKAZE blind spots.
|
|
403
436
|
*
|
|
@@ -412,17 +445,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
|
412
445
|
* 3. threshold → binary mask of significant changes
|
|
413
446
|
* 4. findContours → bounding rects of changed regions
|
|
414
447
|
* 5. Grid sampling within each bounding rect → Point2D[]
|
|
448
|
+
*
|
|
449
|
+
* @param cvLib - Initialized OpenCV runtime shared by the analyzer.
|
|
450
|
+
* @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
|
|
451
|
+
* @param frame2 - Next grayscale frame, with the same dimensions as frame1.
|
|
452
|
+
* @returns Grid-sampled points from changed regions.
|
|
453
|
+
* @throws Propagates allocation or OpenCV errors after releasing acquired handles.
|
|
415
454
|
*/
|
|
416
455
|
function computePixelDiff(cvLib, frame1, frame2) {
|
|
417
456
|
const cv = cvLib;
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
457
|
+
let mat1 = null;
|
|
458
|
+
let mat2 = null;
|
|
459
|
+
let diff = null;
|
|
460
|
+
let blurred = null;
|
|
461
|
+
let binary = null;
|
|
462
|
+
let contours = null;
|
|
463
|
+
let hierarchy = null;
|
|
425
464
|
try {
|
|
465
|
+
mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
466
|
+
mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
467
|
+
diff = new cv.Mat();
|
|
468
|
+
blurred = new cv.Mat();
|
|
469
|
+
binary = new cv.Mat();
|
|
470
|
+
contours = new cv.MatVector();
|
|
471
|
+
hierarchy = new cv.Mat();
|
|
426
472
|
mat1.data.set(frame1.data);
|
|
427
473
|
mat2.data.set(frame2.data);
|
|
428
474
|
cv.absdiff(mat1, mat2, diff);
|
|
@@ -433,22 +479,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
|
|
|
433
479
|
const points = [];
|
|
434
480
|
for (let c = 0; c < contours.size(); c++) {
|
|
435
481
|
const contour = contours.get(c);
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
x
|
|
440
|
-
|
|
441
|
-
|
|
482
|
+
try {
|
|
483
|
+
const rect = cv.boundingRect(contour);
|
|
484
|
+
if (rect.width * rect.height < 100) continue;
|
|
485
|
+
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({
|
|
486
|
+
x,
|
|
487
|
+
y
|
|
488
|
+
});
|
|
489
|
+
} finally {
|
|
490
|
+
contour.delete();
|
|
491
|
+
}
|
|
442
492
|
}
|
|
443
493
|
return points;
|
|
444
494
|
} finally {
|
|
445
|
-
mat1
|
|
446
|
-
mat2
|
|
447
|
-
diff
|
|
448
|
-
blurred
|
|
449
|
-
binary
|
|
450
|
-
contours
|
|
451
|
-
hierarchy
|
|
495
|
+
mat1?.delete();
|
|
496
|
+
mat2?.delete();
|
|
497
|
+
diff?.delete();
|
|
498
|
+
blurred?.delete();
|
|
499
|
+
binary?.delete();
|
|
500
|
+
contours?.delete();
|
|
501
|
+
hierarchy?.delete();
|
|
452
502
|
}
|
|
453
503
|
}
|
|
454
504
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
@@ -467,47 +517,91 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
|
|
|
467
517
|
}
|
|
468
518
|
return gain;
|
|
469
519
|
}
|
|
470
|
-
|
|
520
|
+
/**
|
|
521
|
+
* Analyze one batch, retaining its boundary frame for the following batch.
|
|
522
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
523
|
+
* @param akaze - Detector owned by analyzeFrames.
|
|
524
|
+
* @param frames - Boundary frame followed by new adjacent frames.
|
|
525
|
+
* @param carry - Boundary bytes and live features transferred from the previous batch.
|
|
526
|
+
* @param scale - Maximum preprocessing width.
|
|
527
|
+
* @param tracker - Stateful animation tracker shared across batches.
|
|
528
|
+
* @param pairOffset - Global position of this batch's first pair.
|
|
529
|
+
* @returns Scores, pair failure count, and ownership of the final frame's features.
|
|
530
|
+
*/
|
|
531
|
+
async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
|
|
471
532
|
const edges = [];
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
533
|
+
let failures = 0;
|
|
534
|
+
let prev = carry?.features ?? null;
|
|
535
|
+
let next = null;
|
|
536
|
+
try {
|
|
537
|
+
const preprocessed = await Promise.all(map(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
|
|
538
|
+
const imageWidth = preprocessed[0]?.width ?? scale;
|
|
539
|
+
const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
|
|
540
|
+
const imageArea = imageWidth * imageHeight;
|
|
541
|
+
for (let i = 0; i < frames.length - 1; i++) {
|
|
542
|
+
const pairIndex = pairOffset + i;
|
|
543
|
+
try {
|
|
544
|
+
prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
|
|
545
|
+
next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
|
|
546
|
+
const sNew = computeNewPoints(cvLib, prev, next);
|
|
547
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
548
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
549
|
+
if (clusters.length === 0) {
|
|
550
|
+
const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
|
|
551
|
+
if (pixelDiffPoints.length > 0) {
|
|
552
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
|
|
553
|
+
dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
|
|
554
|
+
clusters = dbscanResult.boundingBoxes;
|
|
555
|
+
}
|
|
488
556
|
}
|
|
557
|
+
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
558
|
+
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
559
|
+
const animationIndices = tracker.update(clusters, pairIndex);
|
|
560
|
+
const change = {
|
|
561
|
+
regions: filter(clusters, (_, ci) => !animationIndices.has(ci)),
|
|
562
|
+
animatedRegions: filter(clusters, (_, ci) => animationIndices.has(ci))
|
|
563
|
+
};
|
|
564
|
+
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
565
|
+
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
566
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
567
|
+
edges.push({
|
|
568
|
+
sourceId: frames[i].id,
|
|
569
|
+
targetId: frames[i + 1].id,
|
|
570
|
+
score,
|
|
571
|
+
change
|
|
572
|
+
});
|
|
573
|
+
} catch (err) {
|
|
574
|
+
logger.warn(`Frame pair analysis failed: ${String(err)}`);
|
|
575
|
+
failures++;
|
|
576
|
+
edges.push({
|
|
577
|
+
sourceId: frames[i].id,
|
|
578
|
+
targetId: frames[i + 1].id,
|
|
579
|
+
score: 0
|
|
580
|
+
});
|
|
581
|
+
} finally {
|
|
582
|
+
prev?.delete();
|
|
583
|
+
prev = next;
|
|
584
|
+
next = null;
|
|
489
585
|
}
|
|
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 animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
494
|
-
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
495
|
-
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
496
|
-
edges.push({
|
|
497
|
-
sourceId: frames[i].id,
|
|
498
|
-
targetId: frames[i + 1].id,
|
|
499
|
-
score
|
|
500
|
-
});
|
|
501
|
-
} catch (err) {
|
|
502
|
-
logger.debug(`Frame pair analysis failed: ${String(err)}`);
|
|
503
|
-
edges.push({
|
|
504
|
-
sourceId: frames[i].id,
|
|
505
|
-
targetId: frames[i + 1].id,
|
|
506
|
-
score: 0
|
|
507
|
-
});
|
|
508
586
|
}
|
|
587
|
+
const result = {
|
|
588
|
+
edges,
|
|
589
|
+
failures,
|
|
590
|
+
analysisResolution: {
|
|
591
|
+
width: imageWidth,
|
|
592
|
+
height: imageHeight
|
|
593
|
+
},
|
|
594
|
+
carry: prev ? {
|
|
595
|
+
preprocessed: preprocessed[preprocessed.length - 1],
|
|
596
|
+
features: prev
|
|
597
|
+
} : null
|
|
598
|
+
};
|
|
599
|
+
prev = null;
|
|
600
|
+
return result;
|
|
601
|
+
} finally {
|
|
602
|
+
prev?.delete();
|
|
603
|
+
next?.delete();
|
|
509
604
|
}
|
|
510
|
-
return edges;
|
|
511
605
|
}
|
|
512
606
|
/**
|
|
513
607
|
* Analyze adjacent frame pairs to compute information gain scores (G(t)).
|
|
@@ -518,37 +612,66 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
518
612
|
* 2. DBSCAN Spatial Clustering
|
|
519
613
|
* 3. Spatio-temporal IoU Tracking
|
|
520
614
|
* 4. G(t) Information Gain Scoring
|
|
615
|
+
* @param ctx - Frames, analysis options, and the progress callback for this run.
|
|
616
|
+
* @returns Adjacent scores and tracked animations in analysis coordinates.
|
|
617
|
+
* @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
|
|
521
618
|
*/
|
|
522
619
|
async function analyzeFrames(ctx) {
|
|
523
620
|
const { frames } = ctx;
|
|
524
621
|
if (frames.length < 2) return {
|
|
525
622
|
edges: [],
|
|
526
|
-
animations: []
|
|
623
|
+
animations: [],
|
|
624
|
+
analysisResolution: {
|
|
625
|
+
width: 0,
|
|
626
|
+
height: 0
|
|
627
|
+
}
|
|
527
628
|
};
|
|
528
629
|
logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
|
|
529
630
|
const cvLib = await ensureOpenCV();
|
|
530
631
|
const edges = [];
|
|
531
|
-
const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
632
|
+
const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
532
633
|
const scale = ctx.options.scale;
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
634
|
+
let akaze = null;
|
|
635
|
+
let carry = null;
|
|
636
|
+
let analysisResolution = {
|
|
637
|
+
width: 0,
|
|
638
|
+
height: 0
|
|
639
|
+
};
|
|
640
|
+
let failures = 0;
|
|
641
|
+
try {
|
|
642
|
+
akaze = new cvLib.AKAZE();
|
|
643
|
+
for (let i = 0; i < frames.length - 1; i += 10) {
|
|
644
|
+
const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
|
|
645
|
+
const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
|
|
646
|
+
carry = result.carry;
|
|
647
|
+
failures += result.failures;
|
|
648
|
+
if (i === 0) analysisResolution = result.analysisResolution;
|
|
649
|
+
edges.push(...result.edges);
|
|
650
|
+
const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
|
|
651
|
+
ctx.emitProgress(progress);
|
|
652
|
+
}
|
|
653
|
+
} finally {
|
|
654
|
+
carry?.features.delete();
|
|
655
|
+
akaze?.delete();
|
|
539
656
|
}
|
|
657
|
+
const pairs = frames.length - 1;
|
|
658
|
+
if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
|
|
540
659
|
const animations = tracker.flushAndGetAnimations();
|
|
541
660
|
logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
|
|
542
661
|
return {
|
|
543
662
|
edges,
|
|
544
|
-
animations
|
|
663
|
+
animations,
|
|
664
|
+
analysisResolution
|
|
545
665
|
};
|
|
546
666
|
}
|
|
547
667
|
var OPENCV_INIT_TIMEOUT_MS, require, cvReady, IoUTracker;
|
|
548
|
-
var init_analyzer = __esmMin((() => {
|
|
549
|
-
|
|
668
|
+
var init_analyzer$1 = __esmMin((() => {
|
|
669
|
+
init_pipeline_defaults();
|
|
670
|
+
init_vision_tuning();
|
|
550
671
|
init_logger();
|
|
551
672
|
init_dbscan();
|
|
673
|
+
init_feature_diff();
|
|
674
|
+
init_frame_features();
|
|
552
675
|
OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
553
676
|
require = createRequire(import.meta.url);
|
|
554
677
|
cvReady = null;
|
|
@@ -630,58 +753,728 @@ var init_analyzer = __esmMin((() => {
|
|
|
630
753
|
for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
|
|
631
754
|
if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
|
|
632
755
|
}
|
|
633
|
-
return maxWeight;
|
|
634
|
-
}
|
|
756
|
+
return maxWeight;
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}));
|
|
760
|
+
|
|
761
|
+
//#endregion
|
|
762
|
+
//#region src/core/analyzer/index.ts
|
|
763
|
+
var init_analyzer = __esmMin((() => {
|
|
764
|
+
init_analyzer$1();
|
|
765
|
+
init_dbscan();
|
|
766
|
+
}));
|
|
767
|
+
|
|
768
|
+
//#endregion
|
|
769
|
+
//#region src/constants/package-version.ts
|
|
770
|
+
var manifestPath, PACKAGE_VERSION;
|
|
771
|
+
var init_package_version = __esmMin((() => {
|
|
772
|
+
manifestPath = existsSync(new URL("../package.json", import.meta.url)) ? "../package.json" : "../../package.json";
|
|
773
|
+
PACKAGE_VERSION = createRequire(import.meta.url)(manifestPath).version;
|
|
774
|
+
}));
|
|
775
|
+
|
|
776
|
+
//#endregion
|
|
777
|
+
//#region src/core/constants/workspace-layout.ts
|
|
778
|
+
function getTempWorkspaceDir(sessionId) {
|
|
779
|
+
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
780
|
+
}
|
|
781
|
+
var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
|
|
782
|
+
var init_workspace_layout = __esmMin((() => {
|
|
783
|
+
APP_NAME = "scene-sieve";
|
|
784
|
+
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
785
|
+
TEMP_BASE_DIR = tmpdir();
|
|
786
|
+
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
787
|
+
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
788
|
+
}));
|
|
789
|
+
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/core/utils/filesystem/paths.ts
|
|
792
|
+
async function ensureDir(dirPath) {
|
|
793
|
+
await mkdir(dirPath, { recursive: true });
|
|
794
|
+
}
|
|
795
|
+
async function fileExists(filePath) {
|
|
796
|
+
try {
|
|
797
|
+
await stat(filePath);
|
|
798
|
+
return true;
|
|
799
|
+
} catch {
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Expand leading ~ to homedir. Node's path.resolve() does not expand ~,
|
|
805
|
+
* so paths like ~/Desktop/foo depend on process.cwd() and can produce
|
|
806
|
+
* different results when run from different directories.
|
|
807
|
+
*/
|
|
808
|
+
function expandTilde(p) {
|
|
809
|
+
if (p === "~") return homedir();
|
|
810
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return resolve(homedir(), p.slice(2));
|
|
811
|
+
return p;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Resolve path to absolute. Expands ~ to homedir first so that the result
|
|
815
|
+
* does not depend on process.cwd().
|
|
816
|
+
*/
|
|
817
|
+
function resolveAbsolute(p) {
|
|
818
|
+
return resolve(expandTilde(p));
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Derive default output directory name from input file path.
|
|
822
|
+
* e.g., /path/to/video.mp4 -> /path/to/video_scenes
|
|
823
|
+
*/
|
|
824
|
+
function deriveOutputPath(inputPath) {
|
|
825
|
+
const dir = resolve(inputPath, "..");
|
|
826
|
+
const name = basename(inputPath, extname(inputPath));
|
|
827
|
+
return resolve(dir, `${name}_scenes`);
|
|
828
|
+
}
|
|
829
|
+
var init_paths = __esmMin((() => {}));
|
|
830
|
+
|
|
831
|
+
//#endregion
|
|
832
|
+
//#region src/core/workspace/workspace.ts
|
|
833
|
+
async function createWorkspace(sessionId) {
|
|
834
|
+
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
835
|
+
await ensureDir(join(workspacePath, "frames"));
|
|
836
|
+
await ensureDir(join(workspacePath, "output"));
|
|
837
|
+
return workspacePath;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Write selected JPEGs and the supplied document before replacing the output directory.
|
|
841
|
+
* @param ctx - Workspace, quality and destination settings.
|
|
842
|
+
* @param selectedFrames - Frames paired by position with document.frames.
|
|
843
|
+
* @param document - Complete metadata, including the output file names.
|
|
844
|
+
* @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
|
|
845
|
+
* @returns Selected JPEG paths, optional sheet path and finally the metadata path.
|
|
846
|
+
* @throws Rejects mismatched frame counts and propagates image or filesystem errors.
|
|
847
|
+
*/
|
|
848
|
+
async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
|
|
849
|
+
if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
|
|
850
|
+
const stagingDir = join(ctx.workspacePath, "output");
|
|
851
|
+
const outputPath = ctx.options.outputPath;
|
|
852
|
+
const quality = ctx.options.quality;
|
|
853
|
+
const outputFiles = [];
|
|
854
|
+
for (let i = 0; i < selectedFrames.length; i++) {
|
|
855
|
+
const frame = selectedFrames[i];
|
|
856
|
+
const fileName = document.frames[i].fileName;
|
|
857
|
+
const destPath = join(stagingDir, fileName);
|
|
858
|
+
await sharp(frame.extractPath).jpeg({
|
|
859
|
+
quality,
|
|
860
|
+
mozjpeg: true
|
|
861
|
+
}).toFile(destPath);
|
|
862
|
+
outputFiles.push(join(outputPath, fileName));
|
|
863
|
+
}
|
|
864
|
+
if (sheetBuffer) {
|
|
865
|
+
await writeFile(join(stagingDir, SHEET_FILE_NAME), sheetBuffer);
|
|
866
|
+
outputFiles.push(join(outputPath, SHEET_FILE_NAME));
|
|
867
|
+
}
|
|
868
|
+
const metadataPath = join(stagingDir, METADATA_FILE_NAME);
|
|
869
|
+
await writeFile(metadataPath, JSON.stringify(document, null, 2));
|
|
870
|
+
outputFiles.push(join(outputPath, METADATA_FILE_NAME));
|
|
871
|
+
await ensureDir(join(outputPath, ".."));
|
|
872
|
+
await rm(outputPath, {
|
|
873
|
+
recursive: true,
|
|
874
|
+
force: true
|
|
875
|
+
});
|
|
876
|
+
await rename(stagingDir, outputPath);
|
|
877
|
+
return outputFiles;
|
|
878
|
+
}
|
|
879
|
+
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
880
|
+
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
881
|
+
await ensureDir(join(segmentPath, "frames"));
|
|
882
|
+
return segmentPath;
|
|
883
|
+
}
|
|
884
|
+
async function cleanupWorkspace(workspacePath) {
|
|
885
|
+
if (!workspacePath) return;
|
|
886
|
+
try {
|
|
887
|
+
await rm(workspacePath, {
|
|
888
|
+
recursive: true,
|
|
889
|
+
force: true
|
|
890
|
+
});
|
|
891
|
+
} catch {}
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Remove stale workspace directories left by previous interrupted runs.
|
|
895
|
+
* Only deletes directories older than 1 hour to avoid removing active workspaces.
|
|
896
|
+
*/
|
|
897
|
+
async function cleanupStaleWorkspaces() {
|
|
898
|
+
const entries = await readdir(TEMP_BASE_DIR);
|
|
899
|
+
const now = Date.now();
|
|
900
|
+
for (const entry of entries) {
|
|
901
|
+
if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
|
|
902
|
+
const fullPath = join(TEMP_BASE_DIR, entry);
|
|
903
|
+
try {
|
|
904
|
+
const info = await stat(fullPath);
|
|
905
|
+
if (info.isDirectory() && now - info.mtimeMs > STALE_THRESHOLD_MS) await rm(fullPath, {
|
|
906
|
+
recursive: true,
|
|
907
|
+
force: true
|
|
908
|
+
});
|
|
909
|
+
} catch {}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Write a video buffer to a temp file in the workspace and return the path.
|
|
914
|
+
* Used by 'buffer' input mode.
|
|
915
|
+
*/
|
|
916
|
+
async function writeInputBuffer(buffer, workspacePath) {
|
|
917
|
+
const inputDir = join(workspacePath, "input");
|
|
918
|
+
await ensureDir(inputDir);
|
|
919
|
+
const tempPath = join(inputDir, "input.mp4");
|
|
920
|
+
await writeFile(tempPath, buffer);
|
|
921
|
+
return tempPath;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Write an array of frame Buffers as JPG files and return FrameNode[].
|
|
925
|
+
* Used by 'frames' input mode.
|
|
926
|
+
*/
|
|
927
|
+
async function writeInputFrames(frames, workspacePath) {
|
|
928
|
+
const framesDir = join(workspacePath, "frames");
|
|
929
|
+
await ensureDir(framesDir);
|
|
930
|
+
const frameNodes = [];
|
|
931
|
+
for (let i = 0; i < frames.length; i++) {
|
|
932
|
+
const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
|
|
933
|
+
const extractPath = join(framesDir, filename);
|
|
934
|
+
await writeFile(extractPath, frames[i]);
|
|
935
|
+
frameNodes.push({
|
|
936
|
+
id: i,
|
|
937
|
+
timestamp: i,
|
|
938
|
+
extractPath
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
return frameNodes;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Read selected FrameNode files as Buffers with JPEG compression.
|
|
945
|
+
* Used to return output buffers in 'buffer' and 'frames' modes.
|
|
946
|
+
*/
|
|
947
|
+
async function readFramesAsBuffers(frameNodes, quality) {
|
|
948
|
+
return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
|
|
949
|
+
quality,
|
|
950
|
+
mozjpeg: true
|
|
951
|
+
}).toBuffer()));
|
|
952
|
+
}
|
|
953
|
+
var STALE_THRESHOLD_MS;
|
|
954
|
+
var init_workspace$1 = __esmMin((() => {
|
|
955
|
+
init_workspace_layout();
|
|
956
|
+
init_pipeline_defaults();
|
|
957
|
+
init_paths();
|
|
958
|
+
STALE_THRESHOLD_MS = 36e5;
|
|
959
|
+
}));
|
|
960
|
+
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/core/workspace/index.ts
|
|
963
|
+
var init_workspace = __esmMin((() => {
|
|
964
|
+
init_workspace$1();
|
|
965
|
+
}));
|
|
966
|
+
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
|
|
969
|
+
var YCoverageTree;
|
|
970
|
+
var init_y_coverage_tree = __esmMin((() => {
|
|
971
|
+
YCoverageTree = class {
|
|
972
|
+
bounds;
|
|
973
|
+
/** Number of whole-node covering intervals, independent of descendants. */
|
|
974
|
+
counts;
|
|
975
|
+
/** Covered geometric length for each node, including partially covered children. */
|
|
976
|
+
lengths;
|
|
977
|
+
/**
|
|
978
|
+
* Allocate linear storage for the elementary intervals between coordinates.
|
|
979
|
+
* @param bounds - At least two sorted unique finite y endpoints.
|
|
980
|
+
*/
|
|
981
|
+
constructor(bounds) {
|
|
982
|
+
this.bounds = bounds;
|
|
983
|
+
this.counts = Array(4 * bounds.length).fill(0);
|
|
984
|
+
this.lengths = Array(4 * bounds.length).fill(0);
|
|
985
|
+
}
|
|
986
|
+
/** Total active covered y length, in the input coordinate system. */
|
|
987
|
+
get coveredLength() {
|
|
988
|
+
return this.lengths[1];
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Adjust a nonempty half-open interval and refresh its ancestors' lengths.
|
|
992
|
+
* @param start - Inclusive endpoint index; 0 <= start < end.
|
|
993
|
+
* @param end - Exclusive endpoint index; end < bounds.length.
|
|
994
|
+
* @param delta - One on entry, minus one for the matching departure.
|
|
995
|
+
* @param node - Internal tree slot; callers use the root default.
|
|
996
|
+
* @param left - Inclusive endpoint index of this node.
|
|
997
|
+
* @param right - Exclusive endpoint index of this node.
|
|
998
|
+
* @returns Nothing; mutates this tree's coverage in O(log n).
|
|
999
|
+
*/
|
|
1000
|
+
update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
|
|
1001
|
+
if (start <= left && right <= end) this.counts[node] += delta;
|
|
1002
|
+
else {
|
|
1003
|
+
const middle = Math.floor((left + right) / 2);
|
|
1004
|
+
if (start < middle) this.update(start, end, delta, node * 2, left, middle);
|
|
1005
|
+
if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
|
|
1006
|
+
}
|
|
1007
|
+
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];
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
}));
|
|
1011
|
+
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/core/utils/metadata/change/union-area.ts
|
|
1014
|
+
/**
|
|
1015
|
+
* Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
|
|
1016
|
+
* @param boxes - Finite rectangles; nonpositive dimensions are ignored.
|
|
1017
|
+
* @returns Covered area in the input coordinate system, or zero for empty input.
|
|
1018
|
+
*/
|
|
1019
|
+
function unionArea(boxes) {
|
|
1020
|
+
const events = [];
|
|
1021
|
+
const endpoints = [];
|
|
1022
|
+
for (const box of boxes) {
|
|
1023
|
+
if (!(box.width > 0 && box.height > 0)) continue;
|
|
1024
|
+
const end = box.y + box.height;
|
|
1025
|
+
if (end === box.y) continue;
|
|
1026
|
+
events.push({
|
|
1027
|
+
x: box.x,
|
|
1028
|
+
start: box.y,
|
|
1029
|
+
end,
|
|
1030
|
+
delta: 1
|
|
1031
|
+
});
|
|
1032
|
+
events.push({
|
|
1033
|
+
x: box.x + box.width,
|
|
1034
|
+
start: box.y,
|
|
1035
|
+
end,
|
|
1036
|
+
delta: -1
|
|
1037
|
+
});
|
|
1038
|
+
endpoints.push(box.y, end);
|
|
1039
|
+
}
|
|
1040
|
+
if (events.length === 0) return 0;
|
|
1041
|
+
events.sort((a, b) => a.x - b.x);
|
|
1042
|
+
endpoints.sort((a, b) => a - b);
|
|
1043
|
+
const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
|
|
1044
|
+
const indices = new Map(bounds.map((value, index) => [value, index]));
|
|
1045
|
+
const coverage = new YCoverageTree(bounds);
|
|
1046
|
+
let area = 0;
|
|
1047
|
+
let previousX = events[0].x;
|
|
1048
|
+
for (const event of events) {
|
|
1049
|
+
area += (event.x - previousX) * coverage.coveredLength;
|
|
1050
|
+
coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
|
|
1051
|
+
previousX = event.x;
|
|
1052
|
+
}
|
|
1053
|
+
return area;
|
|
1054
|
+
}
|
|
1055
|
+
var init_union_area = __esmMin((() => {
|
|
1056
|
+
init_y_coverage_tree();
|
|
1057
|
+
}));
|
|
1058
|
+
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/core/utils/metadata/build-edge-metadata.ts
|
|
1061
|
+
/**
|
|
1062
|
+
* Serialize raw candidate edges in graph order.
|
|
1063
|
+
* @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
|
|
1064
|
+
* @param analysisResolution - Analysis dimensions used for both area fractions.
|
|
1065
|
+
* @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
|
|
1066
|
+
*/
|
|
1067
|
+
function buildEdgeMetadata(graph, analysisResolution) {
|
|
1068
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
1069
|
+
return map(graph, (edge) => ({
|
|
1070
|
+
sourceFrameId: edge.sourceId + 1,
|
|
1071
|
+
targetFrameId: edge.targetId + 1,
|
|
1072
|
+
score: Math.round(edge.score * 1e6) / 1e6,
|
|
1073
|
+
areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
|
|
1074
|
+
animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
|
|
1075
|
+
}));
|
|
1076
|
+
}
|
|
1077
|
+
var init_build_edge_metadata = __esmMin((() => {
|
|
1078
|
+
init_union_area();
|
|
1079
|
+
}));
|
|
1080
|
+
|
|
1081
|
+
//#endregion
|
|
1082
|
+
//#region src/core/utils/metadata/scale-bounding-box.ts
|
|
1083
|
+
/**
|
|
1084
|
+
* Convert an analysis box to clamped integer output pixels.
|
|
1085
|
+
* @param box - Analysis-space rectangle; the input remains unchanged.
|
|
1086
|
+
* @param sx - Horizontal output-to-analysis scale.
|
|
1087
|
+
* @param sy - Vertical output-to-analysis scale.
|
|
1088
|
+
* @param width - Nonnegative output image width.
|
|
1089
|
+
* @param height - Nonnegative output image height.
|
|
1090
|
+
* @returns A rectangle contained within the output dimensions.
|
|
1091
|
+
*/
|
|
1092
|
+
function scaleBoundingBox(box, sx, sy, width, height) {
|
|
1093
|
+
const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
|
|
1094
|
+
const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
|
|
1095
|
+
return {
|
|
1096
|
+
x,
|
|
1097
|
+
y,
|
|
1098
|
+
width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
|
|
1099
|
+
height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
var init_scale_bounding_box = __esmMin((() => {}));
|
|
1103
|
+
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/core/utils/metadata/change/select-regions.ts
|
|
1106
|
+
/**
|
|
1107
|
+
* Select distinct positive-area boxes with deterministic area and coordinate ties.
|
|
1108
|
+
* @param boxes - Already scaled output rectangles; the input is not mutated.
|
|
1109
|
+
* @param limit - Nonnegative maximum number of regions.
|
|
1110
|
+
* @returns Largest boxes ordered by area descending, then y, x and width ascending.
|
|
1111
|
+
*/
|
|
1112
|
+
function selectRegions(boxes, limit) {
|
|
1113
|
+
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);
|
|
1114
|
+
}
|
|
1115
|
+
var init_select_regions = __esmMin((() => {}));
|
|
1116
|
+
|
|
1117
|
+
//#endregion
|
|
1118
|
+
//#region src/core/utils/metadata/change/build-frame-change.ts
|
|
1119
|
+
/**
|
|
1120
|
+
* Aggregate adjacent-pair evidence across one selected-frame span.
|
|
1121
|
+
* @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
|
|
1122
|
+
* @returns Rounded raw scores, analysis-space union ratio and output-space regions.
|
|
1123
|
+
*/
|
|
1124
|
+
function buildFrameChange(input) {
|
|
1125
|
+
const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
|
|
1126
|
+
const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
|
|
1127
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
1128
|
+
const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
|
|
1129
|
+
const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
|
|
1130
|
+
const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
|
|
1131
|
+
const regions = area > 0 ? selectRegions(map(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
|
|
1132
|
+
return {
|
|
1133
|
+
fromFrameId,
|
|
1134
|
+
skippedCandidates,
|
|
1135
|
+
peakScore: Math.round(peakScore * 1e6) / 1e6,
|
|
1136
|
+
sumScore: Math.round(sumScore * 1e6) / 1e6,
|
|
1137
|
+
areaRatio: Math.round(areaRatio * 1e4) / 1e4,
|
|
1138
|
+
regions
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
var init_build_frame_change = __esmMin((() => {
|
|
1142
|
+
init_scale_bounding_box();
|
|
1143
|
+
init_select_regions();
|
|
1144
|
+
init_union_area();
|
|
1145
|
+
}));
|
|
1146
|
+
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/core/utils/metadata/build-frame-metadata.ts
|
|
1149
|
+
/**
|
|
1150
|
+
* Describe selected frames using candidate adjacency rather than synthetic pruning edges.
|
|
1151
|
+
* @param input - Chronological candidates and selections, raw graph and output dimensions.
|
|
1152
|
+
* @returns One-based frame summaries with rounded timestamps and nonnegative holds.
|
|
1153
|
+
*/
|
|
1154
|
+
function buildFrameMetadata(input) {
|
|
1155
|
+
const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
|
|
1156
|
+
const edgesByPair = new Map(map(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
|
|
1157
|
+
const candidateIndices = new Map(map(frames, (frame, index) => [frame.id, index]));
|
|
1158
|
+
const padding = Math.max(4, String(frames.length).length);
|
|
1159
|
+
return map(selected, (frame, index) => {
|
|
1160
|
+
const timestampMs = Math.round(frame.timestamp * 1e3);
|
|
1161
|
+
const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
|
|
1162
|
+
const previous = selected[index - 1];
|
|
1163
|
+
const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
|
|
1164
|
+
const currentIndex = candidateIndices.get(frame.id);
|
|
1165
|
+
const spanEdges = [];
|
|
1166
|
+
if (previous) for (let i = previousIndex; i < currentIndex; i++) {
|
|
1167
|
+
const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
|
|
1168
|
+
if (edge) spanEdges.push(edge);
|
|
1169
|
+
}
|
|
1170
|
+
return {
|
|
1171
|
+
step: index + 1,
|
|
1172
|
+
fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
|
|
1173
|
+
frameId: frame.id + 1,
|
|
1174
|
+
timestampMs,
|
|
1175
|
+
holdsMs: Math.max(0, nextTimestampMs - timestampMs),
|
|
1176
|
+
change: previous ? buildFrameChange({
|
|
1177
|
+
spanEdges,
|
|
1178
|
+
fromFrameId: previous.id + 1,
|
|
1179
|
+
skippedCandidates: currentIndex - previousIndex - 1,
|
|
1180
|
+
analysisResolution,
|
|
1181
|
+
outputResolution,
|
|
1182
|
+
regionLimit: 5
|
|
1183
|
+
}) : null
|
|
1184
|
+
};
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
var init_build_frame_metadata = __esmMin((() => {
|
|
1188
|
+
init_pipeline_defaults();
|
|
1189
|
+
init_build_frame_change();
|
|
1190
|
+
}));
|
|
1191
|
+
|
|
1192
|
+
//#endregion
|
|
1193
|
+
//#region src/core/utils/metadata/build-tool-metadata.ts
|
|
1194
|
+
/**
|
|
1195
|
+
* Record the tool and the nine selection and encoding settings in contract order.
|
|
1196
|
+
* @param options - Validated pipeline settings; operational options are excluded.
|
|
1197
|
+
* @param version - Runtime package version supplied by the I/O boundary.
|
|
1198
|
+
* @returns Deterministic tool provenance without paths or execution details.
|
|
1199
|
+
*/
|
|
1200
|
+
function buildToolMetadata(options, version) {
|
|
1201
|
+
return {
|
|
1202
|
+
name: "@lumy-pack/scene-sieve",
|
|
1203
|
+
version,
|
|
1204
|
+
params: {
|
|
1205
|
+
fps: options.fps,
|
|
1206
|
+
count: options.count,
|
|
1207
|
+
threshold: options.threshold,
|
|
1208
|
+
scale: options.scale,
|
|
1209
|
+
quality: options.quality,
|
|
1210
|
+
maxFrames: options.maxFrames,
|
|
1211
|
+
iouThreshold: options.iouThreshold,
|
|
1212
|
+
animationThreshold: options.animationThreshold,
|
|
1213
|
+
maxSegmentDuration: options.maxSegmentDuration
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
var init_build_tool_metadata = __esmMin((() => {}));
|
|
1218
|
+
|
|
1219
|
+
//#endregion
|
|
1220
|
+
//#region src/core/utils/metadata/build-sieve-metadata.ts
|
|
1221
|
+
/**
|
|
1222
|
+
* Assemble the complete v2 document without I/O or mutation.
|
|
1223
|
+
* @param input - Pipeline state, selections, output-space video and zero-based animations.
|
|
1224
|
+
* @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
|
|
1225
|
+
*/
|
|
1226
|
+
function buildSieveMetadata(input) {
|
|
1227
|
+
const { ctx, selected, video, animations, version, sheet } = input;
|
|
1228
|
+
const analysisResolution = ctx.analysisResolution ?? {
|
|
1229
|
+
width: 0,
|
|
1230
|
+
height: 0
|
|
1231
|
+
};
|
|
1232
|
+
return {
|
|
1233
|
+
metadataVersion: 2,
|
|
1234
|
+
tool: buildToolMetadata(ctx.options, version),
|
|
1235
|
+
video,
|
|
1236
|
+
frames: buildFrameMetadata({
|
|
1237
|
+
frames: ctx.frames,
|
|
1238
|
+
graph: ctx.graph,
|
|
1239
|
+
selected,
|
|
1240
|
+
originalDurationMs: video.originalDurationMs,
|
|
1241
|
+
analysisResolution,
|
|
1242
|
+
outputResolution: video.resolution
|
|
1243
|
+
}),
|
|
1244
|
+
animations: map(animations, (animation) => ({
|
|
1245
|
+
...animation,
|
|
1246
|
+
startFrameId: animation.startFrameId + 1,
|
|
1247
|
+
endFrameId: animation.endFrameId + 1,
|
|
1248
|
+
durationMs: Math.round(animation.durationMs)
|
|
1249
|
+
})),
|
|
1250
|
+
...sheet ? { sheet } : {},
|
|
1251
|
+
...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
var init_build_sieve_metadata = __esmMin((() => {
|
|
1255
|
+
init_pipeline_defaults();
|
|
1256
|
+
init_build_edge_metadata();
|
|
1257
|
+
init_build_frame_metadata();
|
|
1258
|
+
init_build_tool_metadata();
|
|
1259
|
+
}));
|
|
1260
|
+
|
|
1261
|
+
//#endregion
|
|
1262
|
+
//#region src/core/utils/metadata/build-video-metadata.ts
|
|
1263
|
+
/**
|
|
1264
|
+
* Read output dimensions and build consistent video and animation metadata.
|
|
1265
|
+
* JPEG finalization does not resize, so the source dimensions match the output.
|
|
1266
|
+
* @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
|
|
1267
|
+
* @param selected Selected frames in output order; the first candidate is the fallback.
|
|
1268
|
+
* @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
|
|
1269
|
+
* @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
|
|
1270
|
+
* @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
|
|
1271
|
+
*/
|
|
1272
|
+
async function buildVideoMetadata(ctx, selected, analysisResolution) {
|
|
1273
|
+
const firstFrame = selected[0] ?? ctx.frames[0];
|
|
1274
|
+
const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
|
|
1275
|
+
const width = dimensions?.width ?? 0;
|
|
1276
|
+
const height = dimensions?.height ?? 0;
|
|
1277
|
+
const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
|
|
1278
|
+
const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
|
|
1279
|
+
const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
|
|
1280
|
+
const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
|
|
1281
|
+
return {
|
|
1282
|
+
video: {
|
|
1283
|
+
originalDurationMs: Math.round(duration * 1e3),
|
|
1284
|
+
fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
|
|
1285
|
+
resolution: {
|
|
1286
|
+
width,
|
|
1287
|
+
height
|
|
1288
|
+
},
|
|
1289
|
+
candidatesCount: ctx.frames.length,
|
|
1290
|
+
selectedCount: selected.length,
|
|
1291
|
+
source: {
|
|
1292
|
+
fileName: ctx.options.mode === "file" && ctx.options.inputPath ? basename(ctx.options.inputPath) : null,
|
|
1293
|
+
mode: ctx.options.mode
|
|
1294
|
+
}
|
|
1295
|
+
},
|
|
1296
|
+
animations: (ctx.animations ?? []).map((animation) => ({
|
|
1297
|
+
...animation,
|
|
1298
|
+
boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
|
|
1299
|
+
}))
|
|
635
1300
|
};
|
|
1301
|
+
}
|
|
1302
|
+
var init_build_video_metadata = __esmMin((() => {
|
|
1303
|
+
init_scale_bounding_box();
|
|
636
1304
|
}));
|
|
637
1305
|
|
|
638
1306
|
//#endregion
|
|
639
|
-
//#region src/utils/
|
|
640
|
-
|
|
641
|
-
|
|
1307
|
+
//#region src/core/utils/sheet/build-tile-label-svg.ts
|
|
1308
|
+
/**
|
|
1309
|
+
* Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
|
|
1310
|
+
* @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
|
|
1311
|
+
* @param tileWidth - Positive tile canvas width in pixels.
|
|
1312
|
+
* @param tileHeight - Positive tile canvas height in pixels.
|
|
1313
|
+
* @returns Encoded SVG bytes for a top-left sharp composite overlay.
|
|
1314
|
+
*/
|
|
1315
|
+
function buildTileLabelSvg(text, tileWidth, tileHeight) {
|
|
1316
|
+
const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
|
|
1317
|
+
const padX = Math.round(fontSize * .4);
|
|
1318
|
+
const padY = Math.round(fontSize * .2);
|
|
1319
|
+
const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
|
|
1320
|
+
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>`);
|
|
642
1321
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1322
|
+
var init_build_tile_label_svg = __esmMin((() => {
|
|
1323
|
+
init_pipeline_defaults();
|
|
1324
|
+
}));
|
|
1325
|
+
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region src/core/utils/sheet/format-tile-label.ts
|
|
1328
|
+
/**
|
|
1329
|
+
* Format a contact sheet label without rounding into the next tenth of a second.
|
|
1330
|
+
* @param frameId - One-based candidate frame ID.
|
|
1331
|
+
* @param timestampMs - Nonnegative candidate time in milliseconds.
|
|
1332
|
+
* @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
|
|
1333
|
+
*/
|
|
1334
|
+
function formatTileLabel(frameId, timestampMs) {
|
|
1335
|
+
const tenths = Math.floor(timestampMs / 100);
|
|
1336
|
+
return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
|
|
650
1337
|
}
|
|
1338
|
+
var init_format_tile_label = __esmMin((() => {}));
|
|
1339
|
+
|
|
1340
|
+
//#endregion
|
|
1341
|
+
//#region src/core/utils/sheet/sample-tile-frames.ts
|
|
651
1342
|
/**
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
1343
|
+
* Sample a sequence uniformly while always retaining both endpoints.
|
|
1344
|
+
* @param items - Items in temporal order.
|
|
1345
|
+
* @param maxTiles - Validated integer limit of at least two.
|
|
1346
|
+
* @returns The original sequence when within the limit, otherwise evenly sampled items.
|
|
655
1347
|
*/
|
|
656
|
-
function
|
|
657
|
-
if (
|
|
658
|
-
|
|
659
|
-
|
|
1348
|
+
function sampleTileFrames(items, maxTiles) {
|
|
1349
|
+
if (items.length <= maxTiles) return {
|
|
1350
|
+
items,
|
|
1351
|
+
sampled: false
|
|
1352
|
+
};
|
|
1353
|
+
return {
|
|
1354
|
+
items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
|
|
1355
|
+
sampled: true
|
|
1356
|
+
};
|
|
660
1357
|
}
|
|
1358
|
+
var init_sample_tile_frames = __esmMin((() => {}));
|
|
1359
|
+
|
|
1360
|
+
//#endregion
|
|
1361
|
+
//#region src/core/utils/sheet/render-contact-sheet.ts
|
|
661
1362
|
/**
|
|
662
|
-
*
|
|
663
|
-
*
|
|
1363
|
+
* Read selected frame images and render a row-major contact sheet.
|
|
1364
|
+
* @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
|
|
1365
|
+
* @returns JPEG bytes and the effective tile layout with one-based frame IDs.
|
|
1366
|
+
* @throws Propagates sharp image reading, compositing or encoding errors.
|
|
664
1367
|
*/
|
|
665
|
-
function
|
|
666
|
-
|
|
1368
|
+
async function renderContactSheet(input) {
|
|
1369
|
+
const { selected, resolution, options, quality } = input;
|
|
1370
|
+
const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
|
|
1371
|
+
const columns = Math.min(options.columns, items.length);
|
|
1372
|
+
const rows = Math.ceil(items.length / columns);
|
|
1373
|
+
const tileWidth = options.tileWidth;
|
|
1374
|
+
const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
|
|
1375
|
+
const gap = 4;
|
|
1376
|
+
const tiles = await Promise.all(map(items, async (frame, index) => {
|
|
1377
|
+
const tile = sharp(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
|
|
1378
|
+
if (options.label) {
|
|
1379
|
+
const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
|
|
1380
|
+
tile.composite([{
|
|
1381
|
+
input: buildTileLabelSvg(text, tileWidth, tileHeight),
|
|
1382
|
+
top: 0,
|
|
1383
|
+
left: 0
|
|
1384
|
+
}]);
|
|
1385
|
+
}
|
|
1386
|
+
return {
|
|
1387
|
+
input: await tile.png().toBuffer(),
|
|
1388
|
+
top: gap + Math.floor(index / columns) * (tileHeight + gap),
|
|
1389
|
+
left: gap + index % columns * (tileWidth + gap)
|
|
1390
|
+
};
|
|
1391
|
+
}));
|
|
1392
|
+
return {
|
|
1393
|
+
buffer: await sharp({ create: {
|
|
1394
|
+
width: columns * tileWidth + (columns + 1) * gap,
|
|
1395
|
+
height: rows * tileHeight + (rows + 1) * gap,
|
|
1396
|
+
channels: 3,
|
|
1397
|
+
background: "white"
|
|
1398
|
+
} }).composite(tiles).jpeg({
|
|
1399
|
+
quality,
|
|
1400
|
+
mozjpeg: true
|
|
1401
|
+
}).toBuffer(),
|
|
1402
|
+
metadata: {
|
|
1403
|
+
fileName: SHEET_FILE_NAME,
|
|
1404
|
+
columns,
|
|
1405
|
+
tileWidth,
|
|
1406
|
+
tileHeight,
|
|
1407
|
+
frameIds: map(items, (frame) => frame.id + 1),
|
|
1408
|
+
sampled
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
667
1411
|
}
|
|
1412
|
+
var init_render_contact_sheet = __esmMin((() => {
|
|
1413
|
+
init_pipeline_defaults();
|
|
1414
|
+
init_build_tile_label_svg();
|
|
1415
|
+
init_format_tile_label();
|
|
1416
|
+
init_sample_tile_frames();
|
|
1417
|
+
}));
|
|
1418
|
+
|
|
1419
|
+
//#endregion
|
|
1420
|
+
//#region src/core/utils/output/finalize-selection.ts
|
|
668
1421
|
/**
|
|
669
|
-
*
|
|
670
|
-
*
|
|
1422
|
+
* Read output dimensions once, render optional sheets and finalize the shared v2 document.
|
|
1423
|
+
* @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
|
|
1424
|
+
* @param selected - Selected frames in temporal order.
|
|
1425
|
+
* @returns Mode-specific output, the document and zero-based API animations.
|
|
1426
|
+
* @throws Propagates image, rendering and output I/O errors to the orchestrator.
|
|
671
1427
|
*/
|
|
672
|
-
function
|
|
673
|
-
|
|
1428
|
+
async function finalizeSelection(ctx, selected) {
|
|
1429
|
+
const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
|
|
1430
|
+
const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
|
|
1431
|
+
selected,
|
|
1432
|
+
resolution: video.resolution,
|
|
1433
|
+
options: ctx.options.sheet,
|
|
1434
|
+
quality: ctx.options.quality
|
|
1435
|
+
}) : void 0;
|
|
1436
|
+
const document = buildSieveMetadata({
|
|
1437
|
+
ctx,
|
|
1438
|
+
selected,
|
|
1439
|
+
video,
|
|
1440
|
+
animations,
|
|
1441
|
+
version: PACKAGE_VERSION,
|
|
1442
|
+
...sheet ? { sheet: sheet.metadata } : {}
|
|
1443
|
+
});
|
|
1444
|
+
if (ctx.options.mode === "file") return {
|
|
1445
|
+
outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
|
|
1446
|
+
document,
|
|
1447
|
+
animations
|
|
1448
|
+
};
|
|
1449
|
+
return {
|
|
1450
|
+
outputFiles: [],
|
|
1451
|
+
outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
|
|
1452
|
+
document,
|
|
1453
|
+
animations,
|
|
1454
|
+
...sheet ? { sheetBuffer: sheet.buffer } : {}
|
|
1455
|
+
};
|
|
674
1456
|
}
|
|
675
|
-
var
|
|
1457
|
+
var init_finalize_selection = __esmMin((() => {
|
|
1458
|
+
init_package_version();
|
|
1459
|
+
init_workspace();
|
|
1460
|
+
init_build_sieve_metadata();
|
|
1461
|
+
init_build_video_metadata();
|
|
1462
|
+
init_render_contact_sheet();
|
|
1463
|
+
}));
|
|
676
1464
|
|
|
677
1465
|
//#endregion
|
|
678
|
-
//#region src/core/extractor.ts
|
|
1466
|
+
//#region src/core/extractor/extractor.ts
|
|
679
1467
|
/**
|
|
680
1468
|
* Extract frames from video/GIF using FFmpeg.
|
|
681
|
-
*
|
|
682
|
-
*
|
|
1469
|
+
* @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
|
|
1470
|
+
* @returns Extracted candidates, or the unchanged input array in frames mode.
|
|
1471
|
+
* @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
|
|
683
1472
|
*/
|
|
684
1473
|
async function extractFrames(ctx) {
|
|
1474
|
+
if (ctx.options.mode === "frames") {
|
|
1475
|
+
ctx.effectiveFps = 1;
|
|
1476
|
+
return ctx.frames;
|
|
1477
|
+
}
|
|
685
1478
|
const framesDir = join(ctx.workspacePath, "frames");
|
|
686
1479
|
const { inputPath, fps, maxFrames, scale } = ctx.options;
|
|
687
1480
|
if (!inputPath) throw new Error("inputPath is required for frame extraction");
|
|
@@ -696,19 +1489,30 @@ async function extractFrames(ctx) {
|
|
|
696
1489
|
if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
|
|
697
1490
|
logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
|
|
698
1491
|
await ensureDir(framesDir);
|
|
1492
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
699
1493
|
let effectiveFps = fps;
|
|
700
1494
|
if (duration > 0) {
|
|
701
|
-
const fpsCap =
|
|
1495
|
+
const fpsCap = frameLimit / duration;
|
|
702
1496
|
effectiveFps = Math.min(fps, fpsCap);
|
|
703
|
-
effectiveFps = Math.max(.5, effectiveFps);
|
|
704
1497
|
logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
|
|
705
1498
|
}
|
|
706
|
-
|
|
1499
|
+
ctx.effectiveFps = effectiveFps;
|
|
1500
|
+
ctx.sourceDurationSec = duration;
|
|
1501
|
+
const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
|
|
707
1502
|
ctx.emitProgress(100);
|
|
708
1503
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
709
1504
|
return frames;
|
|
710
1505
|
}
|
|
711
|
-
|
|
1506
|
+
/**
|
|
1507
|
+
* Write scaled JPEG candidates through the bundled FFmpeg runtime.
|
|
1508
|
+
* @param inputPath Readable video input.
|
|
1509
|
+
* @param outputDir Existing frame directory.
|
|
1510
|
+
* @param fps Positive effective sampling frequency.
|
|
1511
|
+
* @param scale Output image height.
|
|
1512
|
+
* @param frameLimit Maximum number of output frames.
|
|
1513
|
+
* @returns Candidates with local output-grid timestamps; rejects on extraction failure.
|
|
1514
|
+
*/
|
|
1515
|
+
async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
|
|
712
1516
|
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
713
1517
|
await execa(ffmpegPath, [
|
|
714
1518
|
"-i",
|
|
@@ -717,9 +1521,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
|
|
|
717
1521
|
`fps=${fps},scale=-1:${scale}`,
|
|
718
1522
|
"-q:v",
|
|
719
1523
|
"2",
|
|
1524
|
+
"-frames:v",
|
|
1525
|
+
String(frameLimit),
|
|
720
1526
|
outputPattern
|
|
721
1527
|
]);
|
|
722
|
-
return buildFrameList(outputDir,
|
|
1528
|
+
return buildFrameList(outputDir, fps);
|
|
723
1529
|
}
|
|
724
1530
|
async function getVideoMetadata(inputPath) {
|
|
725
1531
|
const { stdout } = await execa(path, [
|
|
@@ -733,12 +1539,19 @@ async function getVideoMetadata(inputPath) {
|
|
|
733
1539
|
]);
|
|
734
1540
|
return JSON.parse(stdout);
|
|
735
1541
|
}
|
|
736
|
-
|
|
737
|
-
|
|
1542
|
+
/**
|
|
1543
|
+
* Read sorted JPEG paths and attach local output-grid times.
|
|
1544
|
+
* @param framesDir Extracted frame directory; filesystem errors propagate.
|
|
1545
|
+
* @param effectiveFps Positive frequency used by the fps filter.
|
|
1546
|
+
* @returns Zero-based candidates without a segment seek offset.
|
|
1547
|
+
*/
|
|
1548
|
+
async function buildFrameList(framesDir, effectiveFps) {
|
|
1549
|
+
const files = await readdir(framesDir);
|
|
1550
|
+
const jpgFiles = filter(files, (f) => f.endsWith(".jpg")).sort();
|
|
738
1551
|
if (jpgFiles.length === 0) return [];
|
|
739
1552
|
return map(jpgFiles, (file, index) => ({
|
|
740
1553
|
id: index,
|
|
741
|
-
timestamp:
|
|
1554
|
+
timestamp: index / effectiveFps,
|
|
742
1555
|
extractPath: join(framesDir, file)
|
|
743
1556
|
}));
|
|
744
1557
|
}
|
|
@@ -752,9 +1565,10 @@ async function buildFrameList(framesDir, duration) {
|
|
|
752
1565
|
* @param scale - Height scale for vision analysis
|
|
753
1566
|
* @param startTime - Start time in seconds
|
|
754
1567
|
* @param duration - Duration in seconds to extract
|
|
1568
|
+
* @param frameLimit - Positive output limit; defaults to the range's grid capacity
|
|
755
1569
|
* @returns Array of FrameNode with segment-local timestamps (starting from 0)
|
|
756
1570
|
*/
|
|
757
|
-
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
|
|
1571
|
+
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
|
|
758
1572
|
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
759
1573
|
await execa(ffmpegPath, [
|
|
760
1574
|
"-ss",
|
|
@@ -767,169 +1581,157 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
|
|
|
767
1581
|
`fps=${fps},scale=-1:${scale}`,
|
|
768
1582
|
"-q:v",
|
|
769
1583
|
"2",
|
|
1584
|
+
"-frames:v",
|
|
1585
|
+
String(frameLimit),
|
|
770
1586
|
outputPattern
|
|
771
1587
|
]);
|
|
772
|
-
return buildFrameList(outputDir,
|
|
1588
|
+
return buildFrameList(outputDir, fps);
|
|
773
1589
|
}
|
|
774
|
-
var init_extractor = __esmMin((() => {
|
|
775
|
-
|
|
1590
|
+
var init_extractor$1 = __esmMin((() => {
|
|
1591
|
+
init_workspace_layout();
|
|
776
1592
|
init_logger();
|
|
777
1593
|
init_paths();
|
|
778
1594
|
}));
|
|
779
1595
|
|
|
780
1596
|
//#endregion
|
|
781
|
-
//#region src/core/
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
async function finalizeOutput(ctx, selectedFrames) {
|
|
789
|
-
const stagingDir = join(ctx.workspacePath, "output");
|
|
790
|
-
const outputPath = ctx.options.outputPath;
|
|
791
|
-
const quality = ctx.options.quality;
|
|
792
|
-
const outputFiles = [];
|
|
793
|
-
const framesMetadata = [];
|
|
794
|
-
const totalFramesCount = ctx.frames.length;
|
|
795
|
-
const padding = Math.max(4, String(totalFramesCount).length);
|
|
796
|
-
for (let i = 0; i < selectedFrames.length; i++) {
|
|
797
|
-
const frame = selectedFrames[i];
|
|
798
|
-
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
799
|
-
const destPath = join(stagingDir, fileName);
|
|
800
|
-
await sharp(frame.extractPath).jpeg({
|
|
801
|
-
quality,
|
|
802
|
-
mozjpeg: true
|
|
803
|
-
}).toFile(destPath);
|
|
804
|
-
outputFiles.push(join(outputPath, fileName));
|
|
805
|
-
framesMetadata.push({
|
|
806
|
-
step: i + 1,
|
|
807
|
-
fileName,
|
|
808
|
-
frameId: frame.id + 1,
|
|
809
|
-
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
810
|
-
});
|
|
811
|
-
}
|
|
812
|
-
const metadata = {
|
|
813
|
-
video: {
|
|
814
|
-
originalDurationMs: Math.round((ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3),
|
|
815
|
-
fps: ctx.options.fps,
|
|
816
|
-
resolution: {
|
|
817
|
-
width: ctx.options.scale,
|
|
818
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
819
|
-
}
|
|
820
|
-
},
|
|
821
|
-
frames: framesMetadata,
|
|
822
|
-
animations: map(ctx.animations || [], (anim) => ({
|
|
823
|
-
...anim,
|
|
824
|
-
startFrameId: anim.startFrameId + 1,
|
|
825
|
-
endFrameId: anim.endFrameId + 1,
|
|
826
|
-
durationMs: Math.round(anim.durationMs)
|
|
827
|
-
}))
|
|
828
|
-
};
|
|
829
|
-
await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
|
|
830
|
-
outputFiles.push(join(outputPath, ".metadata.json"));
|
|
831
|
-
await ensureDir(join(outputPath, ".."));
|
|
832
|
-
await rm(outputPath, {
|
|
833
|
-
recursive: true,
|
|
834
|
-
force: true
|
|
835
|
-
});
|
|
836
|
-
await rename(stagingDir, outputPath);
|
|
837
|
-
return outputFiles;
|
|
838
|
-
}
|
|
839
|
-
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
840
|
-
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
841
|
-
await ensureDir(join(segmentPath, "frames"));
|
|
842
|
-
return segmentPath;
|
|
843
|
-
}
|
|
844
|
-
async function cleanupWorkspace(workspacePath) {
|
|
845
|
-
if (!workspacePath) return;
|
|
846
|
-
try {
|
|
847
|
-
await rm(workspacePath, {
|
|
848
|
-
recursive: true,
|
|
849
|
-
force: true
|
|
850
|
-
});
|
|
851
|
-
} catch {}
|
|
852
|
-
}
|
|
1597
|
+
//#region src/core/extractor/index.ts
|
|
1598
|
+
var init_extractor = __esmMin((() => {
|
|
1599
|
+
init_extractor$1();
|
|
1600
|
+
}));
|
|
1601
|
+
|
|
1602
|
+
//#endregion
|
|
1603
|
+
//#region src/core/input-resolver/validation/validate-options.ts
|
|
853
1604
|
/**
|
|
854
|
-
*
|
|
855
|
-
*
|
|
1605
|
+
* Reject invalid numeric options before defaults or pipeline effects are applied.
|
|
1606
|
+
* @param options - Supplied options; omitted numeric fields use pipeline defaults.
|
|
1607
|
+
* @returns Nothing when all supplied numeric fields satisfy their contracts.
|
|
1608
|
+
* @throws An input error naming the invalid option and its received value.
|
|
856
1609
|
*/
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1610
|
+
function validateOptions(options) {
|
|
1611
|
+
for (const [name, min, max, integer, exclusiveMin, requirement] of [
|
|
1612
|
+
[
|
|
1613
|
+
"count",
|
|
1614
|
+
1,
|
|
1615
|
+
Infinity,
|
|
1616
|
+
true,
|
|
1617
|
+
false,
|
|
1618
|
+
"an integer >= 1"
|
|
1619
|
+
],
|
|
1620
|
+
[
|
|
1621
|
+
"threshold",
|
|
1622
|
+
0,
|
|
1623
|
+
1,
|
|
1624
|
+
false,
|
|
1625
|
+
true,
|
|
1626
|
+
"in range (0, 1] and finite"
|
|
1627
|
+
],
|
|
1628
|
+
[
|
|
1629
|
+
"fps",
|
|
1630
|
+
0,
|
|
1631
|
+
Infinity,
|
|
1632
|
+
false,
|
|
1633
|
+
true,
|
|
1634
|
+
"finite and > 0"
|
|
1635
|
+
],
|
|
1636
|
+
[
|
|
1637
|
+
"maxFrames",
|
|
1638
|
+
2,
|
|
1639
|
+
Infinity,
|
|
1640
|
+
true,
|
|
1641
|
+
false,
|
|
1642
|
+
"an integer >= 2"
|
|
1643
|
+
],
|
|
1644
|
+
[
|
|
1645
|
+
"scale",
|
|
1646
|
+
16,
|
|
1647
|
+
Infinity,
|
|
1648
|
+
true,
|
|
1649
|
+
false,
|
|
1650
|
+
"an integer >= 16"
|
|
1651
|
+
],
|
|
1652
|
+
[
|
|
1653
|
+
"quality",
|
|
1654
|
+
1,
|
|
1655
|
+
100,
|
|
1656
|
+
true,
|
|
1657
|
+
false,
|
|
1658
|
+
"an integer in range [1, 100]"
|
|
1659
|
+
],
|
|
1660
|
+
[
|
|
1661
|
+
"iouThreshold",
|
|
1662
|
+
0,
|
|
1663
|
+
1,
|
|
1664
|
+
false,
|
|
1665
|
+
false,
|
|
1666
|
+
"finite and in range [0, 1]"
|
|
1667
|
+
],
|
|
1668
|
+
[
|
|
1669
|
+
"animationThreshold",
|
|
1670
|
+
1,
|
|
1671
|
+
Infinity,
|
|
1672
|
+
true,
|
|
1673
|
+
false,
|
|
1674
|
+
"an integer >= 1"
|
|
1675
|
+
],
|
|
1676
|
+
[
|
|
1677
|
+
"maxSegmentDuration",
|
|
1678
|
+
0,
|
|
1679
|
+
Infinity,
|
|
1680
|
+
false,
|
|
1681
|
+
true,
|
|
1682
|
+
"finite and > 0"
|
|
1683
|
+
],
|
|
1684
|
+
[
|
|
1685
|
+
"concurrency",
|
|
1686
|
+
1,
|
|
1687
|
+
Infinity,
|
|
1688
|
+
true,
|
|
1689
|
+
false,
|
|
1690
|
+
"an integer >= 1"
|
|
1691
|
+
]
|
|
1692
|
+
]) {
|
|
1693
|
+
const value = options[name];
|
|
1694
|
+
if (value === void 0) continue;
|
|
1695
|
+
if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
|
|
870
1696
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
883
|
-
/**
|
|
884
|
-
* Write an array of frame Buffers as JPG files and return FrameNode[].
|
|
885
|
-
* Used by 'frames' input mode.
|
|
886
|
-
*/
|
|
887
|
-
async function writeInputFrames(frames, workspacePath) {
|
|
888
|
-
const framesDir = join(workspacePath, "frames");
|
|
889
|
-
await ensureDir(framesDir);
|
|
890
|
-
const frameNodes = [];
|
|
891
|
-
for (let i = 0; i < frames.length; i++) {
|
|
892
|
-
const extractPath = join(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
|
|
893
|
-
await writeFile(extractPath, frames[i]);
|
|
894
|
-
frameNodes.push({
|
|
895
|
-
id: i,
|
|
896
|
-
timestamp: i,
|
|
897
|
-
extractPath
|
|
898
|
-
});
|
|
1697
|
+
const sheet = options.sheet;
|
|
1698
|
+
if (sheet === void 0 || typeof sheet === "boolean") return;
|
|
1699
|
+
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)}`);
|
|
1700
|
+
for (const [name, min] of [
|
|
1701
|
+
["columns", 1],
|
|
1702
|
+
["tileWidth", 16],
|
|
1703
|
+
["maxTiles", 2]
|
|
1704
|
+
]) {
|
|
1705
|
+
const value = sheet[name];
|
|
1706
|
+
if (value === void 0) continue;
|
|
1707
|
+
if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
|
|
899
1708
|
}
|
|
900
|
-
|
|
901
|
-
}
|
|
902
|
-
/**
|
|
903
|
-
* Read selected FrameNode files as Buffers with JPEG compression.
|
|
904
|
-
* Used to return output buffers in 'buffer' and 'frames' modes.
|
|
905
|
-
*/
|
|
906
|
-
async function readFramesAsBuffers(frameNodes, quality) {
|
|
907
|
-
return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
|
|
908
|
-
quality,
|
|
909
|
-
mozjpeg: true
|
|
910
|
-
}).toBuffer()));
|
|
1709
|
+
if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
|
|
911
1710
|
}
|
|
912
|
-
var
|
|
913
|
-
var init_workspace = __esmMin((() => {
|
|
914
|
-
init_constants();
|
|
915
|
-
init_paths();
|
|
916
|
-
STALE_THRESHOLD_MS = 3600 * 1e3;
|
|
917
|
-
}));
|
|
1711
|
+
var init_validate_options = __esmMin((() => {}));
|
|
918
1712
|
|
|
919
1713
|
//#endregion
|
|
920
|
-
//#region src/core/input-resolver.ts
|
|
1714
|
+
//#region src/core/input-resolver/input-resolver.ts
|
|
1715
|
+
/**
|
|
1716
|
+
* Validate supplied options and resolve defaults and paths for the pipeline.
|
|
1717
|
+
* @param options - Mode-specific input and optional numeric settings.
|
|
1718
|
+
* @returns Complete pipeline settings with absolute file input paths.
|
|
1719
|
+
* @throws An input error if a supplied numeric setting is invalid.
|
|
1720
|
+
*/
|
|
921
1721
|
function resolveOptions(options) {
|
|
1722
|
+
validateOptions(options);
|
|
922
1723
|
const mode = options.mode;
|
|
923
1724
|
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
924
1725
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
|
|
925
1726
|
const threshold = options.threshold ?? .5;
|
|
926
|
-
|
|
1727
|
+
const pruneMode = "threshold-with-cap";
|
|
1728
|
+
const sheet = typeof options.sheet === "object" ? options.sheet : {};
|
|
927
1729
|
return {
|
|
928
1730
|
mode,
|
|
929
1731
|
inputPath,
|
|
930
1732
|
count: options.count ?? 20,
|
|
931
1733
|
threshold,
|
|
932
|
-
pruneMode
|
|
1734
|
+
pruneMode,
|
|
933
1735
|
outputPath,
|
|
934
1736
|
fps: options.fps ?? 5,
|
|
935
1737
|
maxFrames: options.maxFrames ?? 300,
|
|
@@ -939,7 +1741,14 @@ function resolveOptions(options) {
|
|
|
939
1741
|
animationThreshold: options.animationThreshold ?? 5,
|
|
940
1742
|
debug: options.debug ?? false,
|
|
941
1743
|
maxSegmentDuration: options.maxSegmentDuration ?? 300,
|
|
942
|
-
concurrency: options.concurrency ?? 2
|
|
1744
|
+
concurrency: options.concurrency ?? 2,
|
|
1745
|
+
sheet: options.sheet ? {
|
|
1746
|
+
columns: sheet.columns ?? 4,
|
|
1747
|
+
tileWidth: sheet.tileWidth ?? 320,
|
|
1748
|
+
maxTiles: sheet.maxTiles ?? 40,
|
|
1749
|
+
label: sheet.label ?? true
|
|
1750
|
+
} : null,
|
|
1751
|
+
includeEdges: options.includeEdges ?? false
|
|
943
1752
|
};
|
|
944
1753
|
}
|
|
945
1754
|
/**
|
|
@@ -948,6 +1757,10 @@ function resolveOptions(options) {
|
|
|
948
1757
|
* - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
|
|
949
1758
|
* - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
|
|
950
1759
|
* - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
|
|
1760
|
+
* @param options - Input source; encoded frames must have matching dimensions.
|
|
1761
|
+
* @param workspacePath - Workspace receiving temporary input files.
|
|
1762
|
+
* @returns Frame nodes or a resolved video path for extraction.
|
|
1763
|
+
* @throws Propagates metadata or write errors and rejects mismatched frame sizes.
|
|
951
1764
|
*/
|
|
952
1765
|
async function resolveInput(options, workspacePath) {
|
|
953
1766
|
if (options.mode === "file") return {
|
|
@@ -958,17 +1771,51 @@ async function resolveInput(options, workspacePath) {
|
|
|
958
1771
|
frames: [],
|
|
959
1772
|
resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
|
|
960
1773
|
};
|
|
961
|
-
if (options.mode === "frames")
|
|
1774
|
+
if (options.mode === "frames") {
|
|
1775
|
+
let dimensions;
|
|
1776
|
+
for (const buffer of options.inputFrames) {
|
|
1777
|
+
const { width, height } = await sharp(buffer).metadata();
|
|
1778
|
+
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}`);
|
|
1779
|
+
dimensions = {
|
|
1780
|
+
width,
|
|
1781
|
+
height
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
|
|
1785
|
+
}
|
|
962
1786
|
throw new Error(`Unsupported input mode: ${options.mode}`);
|
|
963
1787
|
}
|
|
964
|
-
var init_input_resolver = __esmMin((() => {
|
|
965
|
-
|
|
1788
|
+
var init_input_resolver$1 = __esmMin((() => {
|
|
1789
|
+
init_pipeline_defaults();
|
|
966
1790
|
init_paths();
|
|
1791
|
+
init_validate_options();
|
|
967
1792
|
init_workspace();
|
|
968
1793
|
}));
|
|
969
1794
|
|
|
970
1795
|
//#endregion
|
|
971
|
-
//#region src/
|
|
1796
|
+
//#region src/core/input-resolver/index.ts
|
|
1797
|
+
var init_input_resolver = __esmMin((() => {
|
|
1798
|
+
init_input_resolver$1();
|
|
1799
|
+
}));
|
|
1800
|
+
|
|
1801
|
+
//#endregion
|
|
1802
|
+
//#region src/core/pruner/scoring/normalize-scores.ts
|
|
1803
|
+
/**
|
|
1804
|
+
* Find the first position whose score is at least the requested value.
|
|
1805
|
+
* @param sorted - Finite positive scores sorted in ascending order.
|
|
1806
|
+
* @param value - A finite positive score present in sorted.
|
|
1807
|
+
* @returns The first matching rank, including the first position of any tie.
|
|
1808
|
+
*/
|
|
1809
|
+
function lowerBound(sorted, value) {
|
|
1810
|
+
let low = 0;
|
|
1811
|
+
let high = sorted.length;
|
|
1812
|
+
while (low < high) {
|
|
1813
|
+
const mid = Math.floor((low + high) / 2);
|
|
1814
|
+
if (sorted[mid] < value) low = mid + 1;
|
|
1815
|
+
else high = mid;
|
|
1816
|
+
}
|
|
1817
|
+
return low;
|
|
1818
|
+
}
|
|
972
1819
|
/**
|
|
973
1820
|
* Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
|
|
974
1821
|
*
|
|
@@ -1013,16 +1860,19 @@ function normalizeScores(items) {
|
|
|
1013
1860
|
});
|
|
1014
1861
|
const cdf = map(safeScores, (s) => {
|
|
1015
1862
|
if (s <= 0) return 0;
|
|
1016
|
-
return sorted
|
|
1863
|
+
return lowerBound(sorted, s) / sorted.length;
|
|
1017
1864
|
});
|
|
1018
|
-
return map(logisticZ, (z, i) => z *
|
|
1865
|
+
return map(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
|
|
1019
1866
|
}
|
|
1020
|
-
var
|
|
1021
|
-
|
|
1867
|
+
var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
|
|
1868
|
+
var init_normalize_scores = __esmMin((() => {
|
|
1869
|
+
NORMALIZATION_ALPHA = .4;
|
|
1870
|
+
NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
1871
|
+
NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
1022
1872
|
}));
|
|
1023
1873
|
|
|
1024
1874
|
//#endregion
|
|
1025
|
-
//#region src/
|
|
1875
|
+
//#region src/core/pruner/heap/min-heap.ts
|
|
1026
1876
|
var MinHeap;
|
|
1027
1877
|
var init_min_heap = __esmMin((() => {
|
|
1028
1878
|
MinHeap = class {
|
|
@@ -1070,7 +1920,7 @@ var init_min_heap = __esmMin((() => {
|
|
|
1070
1920
|
}));
|
|
1071
1921
|
|
|
1072
1922
|
//#endregion
|
|
1073
|
-
//#region src/core/pruner.ts
|
|
1923
|
+
//#region src/core/pruner/pruner.ts
|
|
1074
1924
|
/**
|
|
1075
1925
|
* Edge-aware greedy merge with re-linking — O(N log N).
|
|
1076
1926
|
*
|
|
@@ -1184,7 +2034,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
|
1184
2034
|
return result;
|
|
1185
2035
|
}
|
|
1186
2036
|
/**
|
|
1187
|
-
* Threshold-based pruning with NMS -- O(N).
|
|
2037
|
+
* Threshold-based pruning with NMS -- including normalization, O(N log N).
|
|
1188
2038
|
*
|
|
1189
2039
|
* 1. Scores are normalized to [0, 1] via percentile normalization.
|
|
1190
2040
|
* 2. Edges with normalized score >= threshold are collected.
|
|
@@ -1247,13 +2097,19 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
1247
2097
|
}
|
|
1248
2098
|
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1249
2099
|
}
|
|
1250
|
-
var init_pruner = __esmMin((() => {
|
|
1251
|
-
|
|
2100
|
+
var init_pruner$1 = __esmMin((() => {
|
|
2101
|
+
init_normalize_scores();
|
|
1252
2102
|
init_min_heap();
|
|
1253
2103
|
}));
|
|
1254
2104
|
|
|
1255
2105
|
//#endregion
|
|
1256
|
-
//#region src/
|
|
2106
|
+
//#region src/core/pruner/index.ts
|
|
2107
|
+
var init_pruner = __esmMin((() => {
|
|
2108
|
+
init_pruner$1();
|
|
2109
|
+
}));
|
|
2110
|
+
|
|
2111
|
+
//#endregion
|
|
2112
|
+
//#region src/core/segmenter/scheduling/concurrency.ts
|
|
1257
2113
|
/**
|
|
1258
2114
|
* Creates a concurrency limiter that runs at most `limit` tasks in parallel.
|
|
1259
2115
|
* Lightweight replacement for p-limit to avoid external dependency.
|
|
@@ -1276,7 +2132,7 @@ function concurrencyLimit(limit) {
|
|
|
1276
2132
|
var init_concurrency = __esmMin((() => {}));
|
|
1277
2133
|
|
|
1278
2134
|
//#endregion
|
|
1279
|
-
//#region src/core/segmenter.ts
|
|
2135
|
+
//#region src/core/segmenter/segmenter.ts
|
|
1280
2136
|
/**
|
|
1281
2137
|
* Determine whether segmentation should be used.
|
|
1282
2138
|
* Returns false for frames mode and GIF files.
|
|
@@ -1290,53 +2146,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
|
|
|
1290
2146
|
return true;
|
|
1291
2147
|
}
|
|
1292
2148
|
/**
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
2149
|
+
* Partition the global extraction grid into nonempty logical segments.
|
|
2150
|
+
* @param totalDuration Positive source duration in seconds.
|
|
2151
|
+
* @param maxSegmentDuration Positive logical segment width in seconds.
|
|
2152
|
+
* @param maxFrames Candidate budget, defensively raised to at least two.
|
|
2153
|
+
* @param fps Positive requested sampling frequency.
|
|
2154
|
+
* @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
|
|
1299
2155
|
*/
|
|
1300
2156
|
function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
|
|
1301
|
-
const
|
|
2157
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
2158
|
+
const effectiveFps = Math.min(fps, frameLimit / totalDuration);
|
|
1302
2159
|
if (totalDuration <= maxSegmentDuration) return [{
|
|
1303
2160
|
index: 0,
|
|
1304
2161
|
startTime: 0,
|
|
1305
2162
|
endTime: totalDuration,
|
|
1306
2163
|
duration: totalDuration,
|
|
1307
|
-
allocatedFrames:
|
|
2164
|
+
allocatedFrames: frameLimit,
|
|
1308
2165
|
effectiveFps,
|
|
1309
2166
|
overlapBefore: 0,
|
|
1310
2167
|
overlapAfter: 0,
|
|
1311
2168
|
extractStartTime: 0,
|
|
1312
2169
|
extractDuration: totalDuration
|
|
1313
2170
|
}];
|
|
1314
|
-
const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
|
|
1315
|
-
const overlapTime = 1 / effectiveFps;
|
|
1316
2171
|
const segments = [];
|
|
1317
|
-
for (let
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
const
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
2172
|
+
for (let slot = 0; slot < frameLimit; slot++) {
|
|
2173
|
+
const timestamp = slot / effectiveFps;
|
|
2174
|
+
if (timestamp >= totalDuration) break;
|
|
2175
|
+
const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
|
|
2176
|
+
const previous = segments[segments.length - 1];
|
|
2177
|
+
if (previous?.startTime === startTime) {
|
|
2178
|
+
previous.allocatedFrames++;
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
|
|
1325
2182
|
segments.push({
|
|
1326
|
-
index:
|
|
2183
|
+
index: segments.length,
|
|
1327
2184
|
startTime,
|
|
1328
2185
|
endTime,
|
|
1329
|
-
duration,
|
|
1330
|
-
allocatedFrames:
|
|
2186
|
+
duration: endTime - startTime,
|
|
2187
|
+
allocatedFrames: 1,
|
|
1331
2188
|
effectiveFps,
|
|
1332
|
-
overlapBefore,
|
|
1333
|
-
overlapAfter,
|
|
1334
|
-
extractStartTime,
|
|
1335
|
-
extractDuration
|
|
2189
|
+
overlapBefore: 0,
|
|
2190
|
+
overlapAfter: 0,
|
|
2191
|
+
extractStartTime: timestamp,
|
|
2192
|
+
extractDuration: 0
|
|
1336
2193
|
});
|
|
1337
2194
|
}
|
|
1338
|
-
|
|
1339
|
-
|
|
2195
|
+
let firstSlot = 0;
|
|
2196
|
+
for (const segment of segments) {
|
|
2197
|
+
const nextSlot = firstSlot + segment.allocatedFrames;
|
|
2198
|
+
segment.overlapBefore = segment.index > 0 ? 1 : 0;
|
|
2199
|
+
segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
|
|
2200
|
+
segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
|
|
2201
|
+
segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
|
|
2202
|
+
segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
|
|
2203
|
+
firstSlot = nextSlot;
|
|
2204
|
+
}
|
|
1340
2205
|
return segments;
|
|
1341
2206
|
}
|
|
1342
2207
|
/**
|
|
@@ -1355,44 +2220,58 @@ function collectAllFrames(segmentResults) {
|
|
|
1355
2220
|
return allFrames;
|
|
1356
2221
|
}
|
|
1357
2222
|
/**
|
|
1358
|
-
* Sort frames
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
2223
|
+
* Sort frames in place and alias overlap duplicates to the first survivor.
|
|
2224
|
+
* @param frames Collected entries whose segment index and local ID identify a frame.
|
|
2225
|
+
* @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
|
|
2226
|
+
* @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
|
|
1361
2227
|
*/
|
|
1362
2228
|
function deduplicateFrames(frames, effectiveFps) {
|
|
1363
2229
|
frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
|
|
1364
2230
|
const dupThreshold = 1 / (effectiveFps * 2);
|
|
1365
2231
|
const unique = [];
|
|
2232
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1366
2233
|
for (const entry of frames) {
|
|
1367
2234
|
if (unique.length > 0) {
|
|
1368
2235
|
const last = unique[unique.length - 1];
|
|
1369
|
-
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold)
|
|
2236
|
+
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
|
|
2237
|
+
aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
|
|
2238
|
+
continue;
|
|
2239
|
+
}
|
|
1370
2240
|
}
|
|
1371
2241
|
unique.push(entry);
|
|
1372
2242
|
}
|
|
1373
|
-
return
|
|
2243
|
+
return {
|
|
2244
|
+
unique,
|
|
2245
|
+
aliases
|
|
2246
|
+
};
|
|
1374
2247
|
}
|
|
1375
2248
|
/**
|
|
1376
|
-
* Assign sequential global IDs
|
|
1377
|
-
*
|
|
2249
|
+
* Assign sequential global IDs and retain duplicate local IDs as aliases.
|
|
2250
|
+
* @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
|
|
2251
|
+
* @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
|
|
2252
|
+
* @returns Remapped frames and a global ID lookup covering survivors and duplicates.
|
|
1378
2253
|
*/
|
|
1379
|
-
function remapFrameIds(uniqueFrames) {
|
|
2254
|
+
function remapFrameIds(uniqueFrames, aliases) {
|
|
1380
2255
|
const globalIdMap = /* @__PURE__ */ new Map();
|
|
2256
|
+
const frames = uniqueFrames.map((entry, globalId) => {
|
|
2257
|
+
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
2258
|
+
return {
|
|
2259
|
+
id: globalId,
|
|
2260
|
+
timestamp: entry.frame.timestamp,
|
|
2261
|
+
extractPath: entry.frame.extractPath
|
|
2262
|
+
};
|
|
2263
|
+
});
|
|
2264
|
+
for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
|
|
1381
2265
|
return {
|
|
1382
|
-
frames
|
|
1383
|
-
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1384
|
-
return {
|
|
1385
|
-
id: globalId,
|
|
1386
|
-
timestamp: entry.frame.timestamp,
|
|
1387
|
-
extractPath: entry.frame.extractPath
|
|
1388
|
-
};
|
|
1389
|
-
}),
|
|
2266
|
+
frames,
|
|
1390
2267
|
globalIdMap
|
|
1391
2268
|
};
|
|
1392
2269
|
}
|
|
1393
2270
|
/**
|
|
1394
|
-
* Remap
|
|
1395
|
-
*
|
|
2271
|
+
* Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
|
|
2272
|
+
* @param segmentResults Segment-local edges in encounter order.
|
|
2273
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
2274
|
+
* @returns One edge per surviving directed pair without changing its score.
|
|
1396
2275
|
*/
|
|
1397
2276
|
function remapEdges(segmentResults, globalIdMap) {
|
|
1398
2277
|
const edges = [];
|
|
@@ -1401,62 +2280,73 @@ function remapEdges(segmentResults, globalIdMap) {
|
|
|
1401
2280
|
const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
|
|
1402
2281
|
const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
|
|
1403
2282
|
if (newSourceId === void 0 || newTargetId === void 0) continue;
|
|
2283
|
+
if (newSourceId === newTargetId) continue;
|
|
1404
2284
|
const edgeKey = `${newSourceId}-${newTargetId}`;
|
|
1405
2285
|
const existingIdx = edgeMap.get(edgeKey);
|
|
1406
2286
|
if (existingIdx !== void 0) {
|
|
1407
2287
|
if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
|
|
2288
|
+
...edge,
|
|
1408
2289
|
sourceId: newSourceId,
|
|
1409
|
-
targetId: newTargetId
|
|
1410
|
-
score: edge.score
|
|
2290
|
+
targetId: newTargetId
|
|
1411
2291
|
};
|
|
1412
2292
|
} else {
|
|
1413
2293
|
edgeMap.set(edgeKey, edges.length);
|
|
1414
2294
|
edges.push({
|
|
2295
|
+
...edge,
|
|
1415
2296
|
sourceId: newSourceId,
|
|
1416
|
-
targetId: newTargetId
|
|
1417
|
-
score: edge.score
|
|
2297
|
+
targetId: newTargetId
|
|
1418
2298
|
});
|
|
1419
2299
|
}
|
|
1420
2300
|
}
|
|
1421
2301
|
return edges;
|
|
1422
2302
|
}
|
|
1423
2303
|
/**
|
|
1424
|
-
* Remap
|
|
1425
|
-
*
|
|
2304
|
+
* Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
|
|
2305
|
+
* @param segmentResults Segment-local tracker entries in encounter order.
|
|
2306
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
2307
|
+
* @returns One animation per directed pair with its original tracker duration and metadata.
|
|
1426
2308
|
*/
|
|
1427
2309
|
function remapAnimations(segmentResults, globalIdMap) {
|
|
1428
|
-
const animations =
|
|
2310
|
+
const animations = /* @__PURE__ */ new Map();
|
|
1429
2311
|
for (const result of segmentResults) for (const anim of result.animations) {
|
|
1430
2312
|
const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
|
|
1431
2313
|
const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
|
|
1432
2314
|
if (newStartId === void 0 || newEndId === void 0) continue;
|
|
1433
|
-
|
|
2315
|
+
if (newStartId === newEndId) continue;
|
|
2316
|
+
const animationKey = `${newStartId}-${newEndId}`;
|
|
2317
|
+
if (animations.has(animationKey)) continue;
|
|
2318
|
+
animations.set(animationKey, {
|
|
1434
2319
|
...anim,
|
|
1435
2320
|
startFrameId: newStartId,
|
|
1436
2321
|
endFrameId: newEndId
|
|
1437
2322
|
});
|
|
1438
2323
|
}
|
|
1439
|
-
return animations;
|
|
2324
|
+
return [...animations.values()];
|
|
1440
2325
|
}
|
|
1441
2326
|
/**
|
|
1442
2327
|
* Merge multiple segment results into a single unified frame/edge/animation set.
|
|
1443
|
-
*
|
|
1444
|
-
* -
|
|
1445
|
-
*
|
|
1446
|
-
* - Duplicate edges keep higher score
|
|
2328
|
+
* @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
|
|
2329
|
+
* @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
|
|
2330
|
+
* Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
|
|
1447
2331
|
*/
|
|
1448
2332
|
function mergeSegmentFrames(segmentResults) {
|
|
1449
2333
|
if (segmentResults.length === 0) return {
|
|
1450
2334
|
frames: [],
|
|
1451
2335
|
edges: [],
|
|
1452
|
-
animations: []
|
|
2336
|
+
animations: [],
|
|
2337
|
+
analysisResolution: {
|
|
2338
|
+
width: 0,
|
|
2339
|
+
height: 0
|
|
2340
|
+
}
|
|
1453
2341
|
};
|
|
1454
2342
|
const effectiveFps = segmentResults[0].segment.effectiveFps;
|
|
1455
|
-
const {
|
|
2343
|
+
const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
|
|
2344
|
+
const { frames, globalIdMap } = remapFrameIds(unique, aliases);
|
|
1456
2345
|
return {
|
|
1457
2346
|
frames,
|
|
1458
2347
|
edges: remapEdges(segmentResults, globalIdMap),
|
|
1459
|
-
animations: remapAnimations(segmentResults, globalIdMap)
|
|
2348
|
+
animations: remapAnimations(segmentResults, globalIdMap),
|
|
2349
|
+
analysisResolution: segmentResults[0].analysisResolution
|
|
1460
2350
|
};
|
|
1461
2351
|
}
|
|
1462
2352
|
function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
|
|
@@ -1467,6 +2357,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1467
2357
|
maxFrames: segment.allocatedFrames
|
|
1468
2358
|
},
|
|
1469
2359
|
workspacePath: segmentWorkspacePath,
|
|
2360
|
+
effectiveFps: segment.effectiveFps,
|
|
1470
2361
|
frames,
|
|
1471
2362
|
graph: [],
|
|
1472
2363
|
status: "ANALYZING",
|
|
@@ -1478,19 +2369,26 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1478
2369
|
* Each segment uses an isolated workspace directory.
|
|
1479
2370
|
*/
|
|
1480
2371
|
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1481
|
-
const
|
|
2372
|
+
const framesDir = join(workspacePath, "frames");
|
|
2373
|
+
const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
|
|
1482
2374
|
if (frames.length < 2) return {
|
|
1483
2375
|
segment,
|
|
1484
2376
|
frames,
|
|
1485
2377
|
edges: [],
|
|
1486
|
-
animations: []
|
|
2378
|
+
animations: [],
|
|
2379
|
+
analysisResolution: {
|
|
2380
|
+
width: 0,
|
|
2381
|
+
height: 0
|
|
2382
|
+
}
|
|
1487
2383
|
};
|
|
1488
|
-
const
|
|
2384
|
+
const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
|
|
2385
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1489
2386
|
return {
|
|
1490
2387
|
segment,
|
|
1491
2388
|
frames,
|
|
1492
2389
|
edges,
|
|
1493
|
-
animations
|
|
2390
|
+
animations,
|
|
2391
|
+
analysisResolution
|
|
1494
2392
|
};
|
|
1495
2393
|
}
|
|
1496
2394
|
/**
|
|
@@ -1530,7 +2428,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1530
2428
|
});
|
|
1531
2429
|
})));
|
|
1532
2430
|
options.onProgress?.("ANALYZING", 100);
|
|
1533
|
-
const { frames, edges, animations } = mergeSegmentFrames(results);
|
|
2431
|
+
const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
|
|
1534
2432
|
logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
|
|
1535
2433
|
options.onProgress?.("PRUNING", 0);
|
|
1536
2434
|
const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
@@ -1539,6 +2437,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1539
2437
|
options.onProgress?.("FINALIZING", 0);
|
|
1540
2438
|
const ctx = {
|
|
1541
2439
|
options: resolvedOptions,
|
|
2440
|
+
effectiveFps: segments[0]?.effectiveFps,
|
|
2441
|
+
sourceDurationSec: totalDuration,
|
|
2442
|
+
analysisResolution,
|
|
1542
2443
|
workspacePath: mainWorkspace,
|
|
1543
2444
|
frames,
|
|
1544
2445
|
graph: edges,
|
|
@@ -1546,27 +2447,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1546
2447
|
status: "FINALIZING",
|
|
1547
2448
|
emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
|
|
1548
2449
|
};
|
|
1549
|
-
|
|
1550
|
-
let outputBuffers;
|
|
1551
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1552
|
-
else outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
2450
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
1553
2451
|
options.onProgress?.("FINALIZING", 100);
|
|
1554
2452
|
logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
|
|
1555
2453
|
return {
|
|
1556
2454
|
success: true,
|
|
1557
2455
|
originalFramesCount: frames.length,
|
|
1558
2456
|
prunedFramesCount: prunedFrames.length,
|
|
1559
|
-
outputFiles,
|
|
1560
|
-
outputBuffers,
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
width: resolvedOptions.scale,
|
|
1567
|
-
height: Math.round(resolvedOptions.scale * 9 / 16)
|
|
1568
|
-
}
|
|
1569
|
-
},
|
|
2457
|
+
outputFiles: finalized.outputFiles,
|
|
2458
|
+
outputBuffers: finalized.outputBuffers,
|
|
2459
|
+
video: finalized.document.video,
|
|
2460
|
+
animations: finalized.animations,
|
|
2461
|
+
frames: finalized.document.frames,
|
|
2462
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2463
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1570
2464
|
executionTimeMs: Date.now() - pipelineStart
|
|
1571
2465
|
};
|
|
1572
2466
|
} catch (error) {
|
|
@@ -1578,10 +2472,11 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1578
2472
|
else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
|
|
1579
2473
|
}
|
|
1580
2474
|
}
|
|
1581
|
-
var init_segmenter = __esmMin((() => {
|
|
2475
|
+
var init_segmenter$1 = __esmMin((() => {
|
|
1582
2476
|
init_concurrency();
|
|
1583
2477
|
init_logger();
|
|
1584
2478
|
init_analyzer();
|
|
2479
|
+
init_finalize_selection();
|
|
1585
2480
|
init_extractor();
|
|
1586
2481
|
init_input_resolver();
|
|
1587
2482
|
init_pruner();
|
|
@@ -1589,10 +2484,22 @@ var init_segmenter = __esmMin((() => {
|
|
|
1589
2484
|
}));
|
|
1590
2485
|
|
|
1591
2486
|
//#endregion
|
|
1592
|
-
//#region src/core/
|
|
2487
|
+
//#region src/core/segmenter/index.ts
|
|
2488
|
+
var init_segmenter = __esmMin((() => {
|
|
2489
|
+
init_segmenter$1();
|
|
2490
|
+
}));
|
|
2491
|
+
|
|
2492
|
+
//#endregion
|
|
2493
|
+
//#region src/core/orchestrator/orchestrator.ts
|
|
1593
2494
|
var orchestrator_exports = /* @__PURE__ */ __exportAll({ runPipeline: () => runPipeline });
|
|
2495
|
+
/**
|
|
2496
|
+
* Run the five pipeline stages, delegating segmented inputs to the segmenter.
|
|
2497
|
+
* @param options - Mode-specific input and optional pipeline settings.
|
|
2498
|
+
* @returns Selected outputs with v2 frame metadata and zero-based API animations.
|
|
2499
|
+
* @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
|
|
2500
|
+
*/
|
|
1594
2501
|
async function runPipeline(options) {
|
|
1595
|
-
|
|
2502
|
+
setDebugMode(options.debug ?? false);
|
|
1596
2503
|
const resolvedOptions = resolveOptions(options);
|
|
1597
2504
|
if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
|
|
1598
2505
|
const startTime = Date.now();
|
|
@@ -1612,50 +2519,47 @@ async function runPipeline(options) {
|
|
|
1612
2519
|
logger.debug(`Workspace created: ${ctx.workspacePath}`);
|
|
1613
2520
|
ctx.status = "EXTRACTING";
|
|
1614
2521
|
const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
|
|
1615
|
-
if (resolvedOptions.mode === "frames")
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
2522
|
+
if (resolvedOptions.mode === "frames") {
|
|
2523
|
+
ctx.frames = resolvedFrames;
|
|
2524
|
+
ctx.effectiveFps = 1;
|
|
2525
|
+
} else {
|
|
2526
|
+
const extractCtx = {
|
|
2527
|
+
...ctx,
|
|
2528
|
+
options: {
|
|
2529
|
+
...resolvedOptions,
|
|
2530
|
+
inputPath: resolvedInputPath
|
|
2531
|
+
}
|
|
2532
|
+
};
|
|
2533
|
+
ctx.frames = await extractFrames(extractCtx);
|
|
2534
|
+
ctx.effectiveFps = extractCtx.effectiveFps;
|
|
2535
|
+
ctx.sourceDurationSec = extractCtx.sourceDurationSec;
|
|
2536
|
+
}
|
|
1623
2537
|
ctx.emitProgress(100);
|
|
1624
2538
|
ctx.status = "ANALYZING";
|
|
1625
|
-
const { edges, animations } = await analyzeFrames(ctx);
|
|
2539
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1626
2540
|
ctx.graph = edges;
|
|
1627
2541
|
ctx.animations = animations;
|
|
2542
|
+
ctx.analysisResolution = analysisResolution;
|
|
1628
2543
|
ctx.status = "PRUNING";
|
|
1629
2544
|
const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
1630
2545
|
const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
|
|
1631
2546
|
ctx.emitProgress(100);
|
|
1632
2547
|
ctx.status = "FINALIZING";
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1636
|
-
outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1637
|
-
ctx.emitProgress(100);
|
|
1638
|
-
} else {
|
|
1639
|
-
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1640
|
-
ctx.emitProgress(100);
|
|
1641
|
-
}
|
|
2548
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
2549
|
+
ctx.emitProgress(100);
|
|
1642
2550
|
ctx.status = "SUCCESS";
|
|
1643
2551
|
logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
|
|
1644
2552
|
return {
|
|
1645
2553
|
success: true,
|
|
1646
2554
|
originalFramesCount: ctx.frames.length,
|
|
1647
2555
|
prunedFramesCount: prunedFrames.length,
|
|
1648
|
-
outputFiles,
|
|
1649
|
-
outputBuffers,
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
width: ctx.options.scale,
|
|
1656
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1657
|
-
}
|
|
1658
|
-
},
|
|
2556
|
+
outputFiles: finalized.outputFiles,
|
|
2557
|
+
outputBuffers: finalized.outputBuffers,
|
|
2558
|
+
video: finalized.document.video,
|
|
2559
|
+
animations: finalized.animations,
|
|
2560
|
+
frames: finalized.document.frames,
|
|
2561
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2562
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1659
2563
|
executionTimeMs: Date.now() - startTime
|
|
1660
2564
|
};
|
|
1661
2565
|
} catch (error) {
|
|
@@ -1671,6 +2575,7 @@ async function runPipeline(options) {
|
|
|
1671
2575
|
var init_orchestrator = __esmMin((() => {
|
|
1672
2576
|
init_logger();
|
|
1673
2577
|
init_analyzer();
|
|
2578
|
+
init_finalize_selection();
|
|
1674
2579
|
init_extractor();
|
|
1675
2580
|
init_input_resolver();
|
|
1676
2581
|
init_pruner();
|
|
@@ -1679,13 +2584,16 @@ var init_orchestrator = __esmMin((() => {
|
|
|
1679
2584
|
}));
|
|
1680
2585
|
|
|
1681
2586
|
//#endregion
|
|
1682
|
-
//#region src/core/run-in-worker.ts
|
|
1683
|
-
init_orchestrator();
|
|
2587
|
+
//#region src/core/orchestrator/worker/run-in-worker.ts
|
|
1684
2588
|
/**
|
|
1685
2589
|
* Run the pipeline, choosing the best execution strategy:
|
|
1686
2590
|
*
|
|
1687
2591
|
* - Production (bundled .mjs): Worker thread — spinner never freezes
|
|
1688
2592
|
* - Dev mode (tsx .ts): Main thread — simpler, spinner may stutter during CPU work
|
|
2593
|
+
* @param options - Serializable input and pipeline settings for this run.
|
|
2594
|
+
* @param onProgress - Receives worker progress updates.
|
|
2595
|
+
* @returns The pipeline result; settlement is unchanged by later exit events.
|
|
2596
|
+
* @throws Rejects worker errors or any worker exit before a result is received.
|
|
1689
2597
|
*/
|
|
1690
2598
|
async function runPipelineInWorker(options, onProgress) {
|
|
1691
2599
|
const currentFile = fileURLToPath(import.meta.url);
|
|
@@ -1711,13 +2619,13 @@ async function runPipelineInWorker(options, onProgress) {
|
|
|
1711
2619
|
});
|
|
1712
2620
|
worker.on("error", reject);
|
|
1713
2621
|
worker.on("exit", (code) => {
|
|
1714
|
-
|
|
2622
|
+
reject(/* @__PURE__ */ new Error(`Worker exited with code ${code} without a result`));
|
|
1715
2623
|
});
|
|
1716
2624
|
});
|
|
1717
2625
|
}
|
|
1718
2626
|
|
|
1719
2627
|
//#endregion
|
|
1720
|
-
//#region src/errors.ts
|
|
2628
|
+
//#region src/cli/errors/classify-error.ts
|
|
1721
2629
|
const SieveErrorCode = {
|
|
1722
2630
|
INVALID_INPUT: "INVALID_INPUT",
|
|
1723
2631
|
FILE_NOT_FOUND: "FILE_NOT_FOUND",
|
|
@@ -1726,16 +2634,22 @@ const SieveErrorCode = {
|
|
|
1726
2634
|
WORKER_ERROR: "WORKER_ERROR",
|
|
1727
2635
|
UNKNOWN: "UNKNOWN"
|
|
1728
2636
|
};
|
|
2637
|
+
/**
|
|
2638
|
+
* Classify pipeline errors for structured CLI responses.
|
|
2639
|
+
* @param error - Failure with a diagnostic message and optional filesystem code.
|
|
2640
|
+
* @returns The existing error code matching the failure.
|
|
2641
|
+
*/
|
|
1729
2642
|
function classifyError(error) {
|
|
1730
2643
|
const msg = error.message.toLowerCase();
|
|
1731
|
-
if (
|
|
2644
|
+
if (msg.includes("must be")) return SieveErrorCode.INVALID_INPUT;
|
|
2645
|
+
else if (error.code === "ENOENT" || msg.includes("not found")) return SieveErrorCode.FILE_NOT_FOUND;
|
|
1732
2646
|
else if (msg.includes("no video stream") || msg.includes("invalid format")) return SieveErrorCode.INVALID_FORMAT;
|
|
1733
2647
|
else if (msg.includes("worker")) return SieveErrorCode.WORKER_ERROR;
|
|
1734
2648
|
else return SieveErrorCode.PIPELINE_ERROR;
|
|
1735
2649
|
}
|
|
1736
2650
|
|
|
1737
2651
|
//#endregion
|
|
1738
|
-
//#region src/
|
|
2652
|
+
//#region src/cli/commands/command-registry.ts
|
|
1739
2653
|
const SIEVE_COMMAND = {
|
|
1740
2654
|
name: "scene-sieve",
|
|
1741
2655
|
description: "Extract key frames from video and GIF files",
|
|
@@ -1746,6 +2660,16 @@ const SIEVE_COMMAND = {
|
|
|
1746
2660
|
required: true
|
|
1747
2661
|
}],
|
|
1748
2662
|
options: [
|
|
2663
|
+
{
|
|
2664
|
+
flag: "--sheet",
|
|
2665
|
+
description: "Generate a contact sheet of selected frames",
|
|
2666
|
+
type: "boolean"
|
|
2667
|
+
},
|
|
2668
|
+
{
|
|
2669
|
+
flag: "--include-edges",
|
|
2670
|
+
description: "Include candidate edge diagnostics in metadata",
|
|
2671
|
+
type: "boolean"
|
|
2672
|
+
},
|
|
1749
2673
|
{
|
|
1750
2674
|
flag: "-n, --count <number>",
|
|
1751
2675
|
description: "Max number of frames to keep (default: 20)",
|
|
@@ -1831,28 +2755,46 @@ const SIEVE_COMMAND = {
|
|
|
1831
2755
|
};
|
|
1832
2756
|
|
|
1833
2757
|
//#endregion
|
|
1834
|
-
//#region src/
|
|
2758
|
+
//#region src/cli/options/parse-options.ts
|
|
2759
|
+
/**
|
|
2760
|
+
* Parse one complete decimal string without truncating fractional values.
|
|
2761
|
+
* @param value - Decimal CLI argument, optionally using an exponent.
|
|
2762
|
+
* @param integer - Whether the result must be an integer.
|
|
2763
|
+
* @returns The finite parsed number, or NaN for invalid input.
|
|
2764
|
+
*/
|
|
2765
|
+
function parseNumberStrict(value, integer = false) {
|
|
2766
|
+
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return NaN;
|
|
2767
|
+
const number = Number(value);
|
|
2768
|
+
return Number.isFinite(number) && (!integer || Number.isInteger(number)) ? number : NaN;
|
|
2769
|
+
}
|
|
2770
|
+
/**
|
|
2771
|
+
* Convert CLI strings to pipeline values for subsequent range validation.
|
|
2772
|
+
* @param opts - Commander options with raw numeric strings.
|
|
2773
|
+
* @returns Typed settings, preserving invalid numeric input as NaN.
|
|
2774
|
+
*/
|
|
1835
2775
|
function parsePipelineOptions(opts) {
|
|
1836
2776
|
return {
|
|
1837
|
-
...opts.threshold !== void 0 ? { threshold:
|
|
1838
|
-
...opts.count !== void 0 ? { count:
|
|
2777
|
+
...opts.threshold !== void 0 ? { threshold: parseNumberStrict(opts.threshold) } : {},
|
|
2778
|
+
...opts.count !== void 0 ? { count: parseNumberStrict(opts.count, true) } : {},
|
|
1839
2779
|
outputPath: opts.output,
|
|
1840
|
-
fps:
|
|
1841
|
-
maxFrames:
|
|
1842
|
-
scale:
|
|
1843
|
-
quality:
|
|
1844
|
-
iouThreshold: opts.iouThreshold !== void 0 ?
|
|
1845
|
-
animationThreshold: opts.animThreshold !== void 0 ?
|
|
1846
|
-
maxSegmentDuration: opts.maxSegmentDuration !== void 0 ?
|
|
1847
|
-
concurrency: opts.concurrency !== void 0 ?
|
|
1848
|
-
debug: opts.debug ?? false
|
|
2780
|
+
fps: parseNumberStrict(opts.fps),
|
|
2781
|
+
maxFrames: parseNumberStrict(opts.maxFrames, true),
|
|
2782
|
+
scale: parseNumberStrict(opts.scale, true),
|
|
2783
|
+
quality: parseNumberStrict(opts.quality, true),
|
|
2784
|
+
iouThreshold: opts.iouThreshold !== void 0 ? parseNumberStrict(opts.iouThreshold) : void 0,
|
|
2785
|
+
animationThreshold: opts.animThreshold !== void 0 ? parseNumberStrict(opts.animThreshold, true) : void 0,
|
|
2786
|
+
maxSegmentDuration: opts.maxSegmentDuration !== void 0 ? parseNumberStrict(opts.maxSegmentDuration) : void 0,
|
|
2787
|
+
concurrency: opts.concurrency !== void 0 ? parseNumberStrict(opts.concurrency, true) : void 0,
|
|
2788
|
+
debug: opts.debug ?? false,
|
|
2789
|
+
...opts.sheet !== void 0 ? { sheet: opts.sheet } : {},
|
|
2790
|
+
...opts.includeEdges !== void 0 ? { includeEdges: opts.includeEdges } : {}
|
|
1849
2791
|
};
|
|
1850
2792
|
}
|
|
1851
2793
|
|
|
1852
2794
|
//#endregion
|
|
1853
|
-
//#region src/commands/Sieve.tsx
|
|
1854
|
-
|
|
1855
|
-
init_workspace();
|
|
2795
|
+
//#region src/cli/commands/Sieve.tsx
|
|
2796
|
+
init_pipeline_defaults();
|
|
2797
|
+
init_orchestrator(), init_workspace$1();
|
|
1856
2798
|
init_logger();
|
|
1857
2799
|
const PHASE_DEFS = [
|
|
1858
2800
|
{
|
|
@@ -1894,7 +2836,7 @@ function phaseKeyToIndex(phase) {
|
|
|
1894
2836
|
}
|
|
1895
2837
|
function registerSieveCommand(program, version) {
|
|
1896
2838
|
const cmd = SIEVE_COMMAND;
|
|
1897
|
-
program.argument("<input>", cmd.arguments[0].description).option("-n, --count <number>", cmd.options.find((o) => o.flag.includes("--count")).description).option("-t, --threshold <number>", cmd.options.find((o) => o.flag.includes("--threshold")).description).option("-o, --output <path>", cmd.options.find((o) => o.flag.includes("--output")).description).option("--fps <number>", cmd.options.find((o) => o.flag.includes("--fps")).description, String(5)).option("-mf, --max-frames <number>", cmd.options.find((o) => o.flag.includes("--max-frames")).description, String(300)).option("-s, --scale <number>", cmd.options.find((o) => o.flag.includes("--scale")).description, String(720)).option("-q, --quality <number>", cmd.options.find((o) => o.flag.includes("--quality")).description, String(80)).option("-it, --iou-threshold <number>", cmd.options.find((o) => o.flag.includes("--iou-threshold")).description).option("-at, --anim-threshold <number>", cmd.options.find((o) => o.flag.includes("--anim-threshold")).description).option("--max-segment-duration <number>", cmd.options.find((o) => o.flag.includes("--max-segment-duration")).description).option("--concurrency <number>", cmd.options.find((o) => o.flag.includes("--concurrency")).description).option("--debug", cmd.options.find((o) => o.flag.includes("--debug")).description).option("--json", cmd.options.find((o) => o.flag.includes("--json")).description).option("--describe", cmd.options.find((o) => o.flag.includes("--describe")).description).action(async (input, opts) => {
|
|
2839
|
+
program.argument("<input>", cmd.arguments[0].description).option("-n, --count <number>", cmd.options.find((o) => o.flag.includes("--count")).description).option("-t, --threshold <number>", cmd.options.find((o) => o.flag.includes("--threshold")).description).option("-o, --output <path>", cmd.options.find((o) => o.flag.includes("--output")).description).option("--fps <number>", cmd.options.find((o) => o.flag.includes("--fps")).description, String(5)).option("-mf, --max-frames <number>", cmd.options.find((o) => o.flag.includes("--max-frames")).description, String(300)).option("-s, --scale <number>", cmd.options.find((o) => o.flag.includes("--scale")).description, String(720)).option("-q, --quality <number>", cmd.options.find((o) => o.flag.includes("--quality")).description, String(80)).option("-it, --iou-threshold <number>", cmd.options.find((o) => o.flag.includes("--iou-threshold")).description).option("-at, --anim-threshold <number>", cmd.options.find((o) => o.flag.includes("--anim-threshold")).description).option("--max-segment-duration <number>", cmd.options.find((o) => o.flag.includes("--max-segment-duration")).description).option("--concurrency <number>", cmd.options.find((o) => o.flag.includes("--concurrency")).description).option("--debug", cmd.options.find((o) => o.flag.includes("--debug")).description).option("--sheet", cmd.options.find((o) => o.flag === "--sheet").description).option("--include-edges", cmd.options.find((o) => o.flag === "--include-edges").description).option("--json", cmd.options.find((o) => o.flag.includes("--json")).description).option("--describe", cmd.options.find((o) => o.flag.includes("--describe")).description).action(async (input, opts) => {
|
|
1898
2840
|
const parsed = parsePipelineOptions(opts);
|
|
1899
2841
|
if (opts.json) {
|
|
1900
2842
|
setJsonMode(true);
|
|
@@ -1915,14 +2857,17 @@ function registerSieveCommand(program, version) {
|
|
|
1915
2857
|
}) + "\n");
|
|
1916
2858
|
}
|
|
1917
2859
|
});
|
|
1918
|
-
|
|
2860
|
+
const data = {
|
|
1919
2861
|
success: result.success,
|
|
1920
2862
|
originalFrames: result.originalFramesCount,
|
|
1921
2863
|
selectedFrames: result.prunedFramesCount,
|
|
1922
2864
|
outputFiles: result.outputFiles,
|
|
1923
2865
|
animations: result.animations ?? [],
|
|
1924
|
-
video: result.video ?? null
|
|
1925
|
-
|
|
2866
|
+
video: result.video ?? null,
|
|
2867
|
+
frames: result.frames ?? [],
|
|
2868
|
+
sheet: result.sheet ?? null
|
|
2869
|
+
};
|
|
2870
|
+
respond("extract", data, startTime, version);
|
|
1926
2871
|
} catch (error) {
|
|
1927
2872
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
1928
2873
|
respondError("extract", classifyError(err), err.message, startTime, version);
|
|
@@ -1972,7 +2917,9 @@ const SieveView = (props) => {
|
|
|
1972
2917
|
animationThreshold: props.animationThreshold,
|
|
1973
2918
|
maxSegmentDuration: props.maxSegmentDuration,
|
|
1974
2919
|
concurrency: props.concurrency,
|
|
1975
|
-
debug: props.debug
|
|
2920
|
+
debug: props.debug,
|
|
2921
|
+
...props.sheet !== void 0 ? { sheet: props.sheet } : {},
|
|
2922
|
+
includeEdges: props.includeEdges
|
|
1976
2923
|
}, (phase, percent) => {
|
|
1977
2924
|
const phaseIdx = phaseKeyToIndex(phase);
|
|
1978
2925
|
if (phaseIdx < 0) return;
|
|
@@ -2029,8 +2976,9 @@ const SieveView = (props) => {
|
|
|
2029
2976
|
};
|
|
2030
2977
|
return next;
|
|
2031
2978
|
});
|
|
2032
|
-
|
|
2033
|
-
|
|
2979
|
+
const failure = err instanceof Error ? err : new Error(String(err));
|
|
2980
|
+
setError(failure.message);
|
|
2981
|
+
setTimeout(() => exit(failure), 100);
|
|
2034
2982
|
}
|
|
2035
2983
|
})();
|
|
2036
2984
|
}, []);
|
|
@@ -2105,23 +3053,23 @@ const SieveView = (props) => {
|
|
|
2105
3053
|
|
|
2106
3054
|
//#endregion
|
|
2107
3055
|
//#region src/cli.ts
|
|
2108
|
-
|
|
3056
|
+
init_package_version();
|
|
2109
3057
|
const program = new Command();
|
|
2110
|
-
program.name("scene-sieve").description("Extract key frames from video and GIF files").version(
|
|
2111
|
-
registerSieveCommand(program,
|
|
3058
|
+
program.name("scene-sieve").description("Extract key frames from video and GIF files").version(PACKAGE_VERSION);
|
|
3059
|
+
registerSieveCommand(program, PACKAGE_VERSION);
|
|
2112
3060
|
if (process.argv.includes("--describe")) {
|
|
2113
3061
|
const startTime = Date.now();
|
|
2114
3062
|
respond("describe", {
|
|
2115
3063
|
name: SIEVE_COMMAND.name,
|
|
2116
|
-
version,
|
|
3064
|
+
version: PACKAGE_VERSION,
|
|
2117
3065
|
description: SIEVE_COMMAND.description,
|
|
2118
3066
|
arguments: SIEVE_COMMAND.arguments,
|
|
2119
3067
|
options: SIEVE_COMMAND.options
|
|
2120
|
-
}, startTime,
|
|
3068
|
+
}, startTime, PACKAGE_VERSION);
|
|
2121
3069
|
process.exit(0);
|
|
2122
3070
|
}
|
|
2123
3071
|
program.parseAsync(process.argv).catch((error) => {
|
|
2124
|
-
if (process.argv.includes("--json")) respondError("extract", SieveErrorCode.UNKNOWN, error.message, Date.now(),
|
|
3072
|
+
if (process.argv.includes("--json")) respondError("extract", SieveErrorCode.UNKNOWN, error.message, Date.now(), PACKAGE_VERSION);
|
|
2125
3073
|
else console.error("Fatal error:", error.message);
|
|
2126
3074
|
process.exit(1);
|
|
2127
3075
|
});
|