@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/index.cjs
CHANGED
|
@@ -6,6 +6,14 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __getProtoOf = Object.getPrototypeOf;
|
|
8
8
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __esmMin = (fn, res, err) => () => {
|
|
10
|
+
if (err) throw err[0];
|
|
11
|
+
try {
|
|
12
|
+
return fn && (res = fn(fn = 0)), res;
|
|
13
|
+
} catch (e) {
|
|
14
|
+
throw err = [e], e;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
9
17
|
var __copyProps = (to, from, except, desc) => {
|
|
10
18
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
19
|
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
@@ -20,7 +28,7 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
20
28
|
}
|
|
21
29
|
return to;
|
|
22
30
|
};
|
|
23
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
31
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
24
32
|
value: mod,
|
|
25
33
|
enumerable: true
|
|
26
34
|
}) : target, mod));
|
|
@@ -33,66 +41,72 @@ picocolors = __toESM(picocolors, 1);
|
|
|
33
41
|
let node_module = require("node:module");
|
|
34
42
|
let sharp = require("sharp");
|
|
35
43
|
sharp = __toESM(sharp, 1);
|
|
36
|
-
let
|
|
37
|
-
let node_path = require("node:path");
|
|
44
|
+
let node_fs = require("node:fs");
|
|
38
45
|
let node_fs_promises = require("node:fs/promises");
|
|
46
|
+
let node_path = require("node:path");
|
|
47
|
+
let node_os = require("node:os");
|
|
39
48
|
let _ffprobe_installer_ffprobe = require("@ffprobe-installer/ffprobe");
|
|
40
49
|
let execa = require("execa");
|
|
41
50
|
let ffmpeg_static = require("ffmpeg-static");
|
|
42
51
|
ffmpeg_static = __toESM(ffmpeg_static, 1);
|
|
43
52
|
|
|
44
|
-
//#region src/
|
|
45
|
-
let debugMode = false;
|
|
46
|
-
let jsonMode = false;
|
|
53
|
+
//#region src/logging/logger.ts
|
|
47
54
|
function setDebugMode(enabled) {
|
|
48
55
|
debugMode = enabled;
|
|
49
56
|
}
|
|
50
57
|
function timestamp() {
|
|
51
58
|
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
52
59
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
var debugMode, jsonMode, logger;
|
|
61
|
+
var init_logger = __esmMin((() => {
|
|
62
|
+
debugMode = false;
|
|
63
|
+
jsonMode = false;
|
|
64
|
+
logger = {
|
|
65
|
+
info(message) {
|
|
66
|
+
if (jsonMode) process.stderr.write(`${picocolors.default.blue("info")} ${message}\n`);
|
|
67
|
+
else console.log(`${picocolors.default.blue("info")} ${message}`);
|
|
68
|
+
},
|
|
69
|
+
success(message) {
|
|
70
|
+
if (jsonMode) process.stderr.write(`${picocolors.default.green("done")} ${message}\n`);
|
|
71
|
+
else console.log(`\n${picocolors.default.green("done")} ${message}`);
|
|
72
|
+
},
|
|
73
|
+
warn(message) {
|
|
74
|
+
console.warn(`${picocolors.default.yellow("warn")} ${message}`);
|
|
75
|
+
},
|
|
76
|
+
error(message) {
|
|
77
|
+
console.error(`${picocolors.default.red("error")} ${message}`);
|
|
78
|
+
},
|
|
79
|
+
debug(message) {
|
|
80
|
+
if (debugMode) {
|
|
81
|
+
if (jsonMode) process.stderr.write(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}\n`);
|
|
82
|
+
else console.log(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}));
|
|
73
87
|
|
|
74
88
|
//#endregion
|
|
75
|
-
//#region src/constants.ts
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
//#region src/constants/pipeline-defaults.ts
|
|
90
|
+
var DEFAULT_THRESHOLD, IOU_THRESHOLD, SHEET_LABEL_HEIGHT_RATIO, SHEET_FILE_NAME, METADATA_FILE_NAME;
|
|
91
|
+
var init_pipeline_defaults = __esmMin((() => {
|
|
92
|
+
DEFAULT_THRESHOLD = .5;
|
|
93
|
+
IOU_THRESHOLD = .9;
|
|
94
|
+
SHEET_LABEL_HEIGHT_RATIO = .07;
|
|
95
|
+
SHEET_FILE_NAME = "sheet.jpg";
|
|
96
|
+
METADATA_FILE_NAME = ".metadata.json";
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/core/analyzer/constants/vision-tuning.ts
|
|
101
|
+
var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
|
|
102
|
+
var init_vision_tuning = __esmMin((() => {
|
|
103
|
+
DBSCAN_ALPHA = .03;
|
|
104
|
+
DECAY_LAMBDA = .95;
|
|
105
|
+
MATCH_DISTANCE_THRESHOLD = .25;
|
|
106
|
+
}));
|
|
91
107
|
|
|
92
108
|
//#endregion
|
|
93
|
-
//#region src/core/dbscan.ts
|
|
94
|
-
const UNVISITED = -2;
|
|
95
|
-
const NOISE = -1;
|
|
109
|
+
//#region src/core/analyzer/clustering/dbscan.ts
|
|
96
110
|
/**
|
|
97
111
|
* DBSCAN clustering with resolution-independent eps.
|
|
98
112
|
* eps = alpha * sqrt(width^2 + height^2)
|
|
@@ -168,12 +182,118 @@ function findNeighbors(points, idx, epsSquared) {
|
|
|
168
182
|
}
|
|
169
183
|
return neighbors;
|
|
170
184
|
}
|
|
185
|
+
var UNVISITED, NOISE;
|
|
186
|
+
var init_dbscan = __esmMin((() => {
|
|
187
|
+
init_vision_tuning();
|
|
188
|
+
UNVISITED = -2;
|
|
189
|
+
NOISE = -1;
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/core/analyzer/features/feature-diff.ts
|
|
194
|
+
/**
|
|
195
|
+
* Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
|
|
196
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
197
|
+
* @param prev - Previous frame's live features, owned by the caller.
|
|
198
|
+
* @param next - Next frame's live features, owned by the caller.
|
|
199
|
+
* @returns Unmatched next-frame coordinates without changing input ownership.
|
|
200
|
+
* @throws Propagates matching errors after releasing temporary native handles.
|
|
201
|
+
*/
|
|
202
|
+
function computeNewPoints(cvLib, prev, next) {
|
|
203
|
+
let matcher = null;
|
|
204
|
+
let matches = null;
|
|
205
|
+
try {
|
|
206
|
+
const matchedIndices = /* @__PURE__ */ new Set();
|
|
207
|
+
if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
|
|
208
|
+
matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
209
|
+
matches = new cvLib.DMatchVectorVector();
|
|
210
|
+
matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
|
|
211
|
+
for (let i = 0; i < matches.size(); i++) {
|
|
212
|
+
const pair = matches.get(i);
|
|
213
|
+
try {
|
|
214
|
+
if (pair.size() < 2) continue;
|
|
215
|
+
const best = pair.get(0);
|
|
216
|
+
const second = pair.get(1);
|
|
217
|
+
if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
|
|
218
|
+
} finally {
|
|
219
|
+
pair.delete();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
matcher?.delete();
|
|
224
|
+
}
|
|
225
|
+
const points = [];
|
|
226
|
+
for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
|
|
227
|
+
const { x, y } = next.keypoints.get(i).pt;
|
|
228
|
+
points.push({
|
|
229
|
+
x,
|
|
230
|
+
y
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return points;
|
|
234
|
+
} finally {
|
|
235
|
+
matches?.delete();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
var init_feature_diff = __esmMin((() => {
|
|
239
|
+
init_vision_tuning();
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/core/analyzer/features/frame-features.ts
|
|
244
|
+
/**
|
|
245
|
+
* Detect one frame's features without retaining its image or mask.
|
|
246
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
247
|
+
* @param akaze - Detector owned and released by the caller.
|
|
248
|
+
* @param frame - Grayscale bytes with matching width and height.
|
|
249
|
+
* @returns Feature handles that the caller must delete.
|
|
250
|
+
* @throws Propagates native errors after releasing partial allocations.
|
|
251
|
+
*/
|
|
252
|
+
function computeFrameFeatures(cvLib, akaze, frame) {
|
|
253
|
+
let image = null;
|
|
254
|
+
let mask = null;
|
|
255
|
+
let keypoints = null;
|
|
256
|
+
let descriptors = null;
|
|
257
|
+
try {
|
|
258
|
+
image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
|
|
259
|
+
image.data.set(frame.data);
|
|
260
|
+
mask = new cvLib.Mat();
|
|
261
|
+
keypoints = new cvLib.KeyPointVector();
|
|
262
|
+
descriptors = new cvLib.Mat();
|
|
263
|
+
akaze.detectAndCompute(image, mask, keypoints, descriptors);
|
|
264
|
+
const ownedKeypoints = keypoints;
|
|
265
|
+
const ownedDescriptors = descriptors;
|
|
266
|
+
let deleted = false;
|
|
267
|
+
const features = {
|
|
268
|
+
width: frame.width,
|
|
269
|
+
height: frame.height,
|
|
270
|
+
keypoints: ownedKeypoints,
|
|
271
|
+
descriptors: ownedDescriptors,
|
|
272
|
+
/** Release the transferred handles exactly once. */
|
|
273
|
+
delete() {
|
|
274
|
+
if (deleted) return;
|
|
275
|
+
deleted = true;
|
|
276
|
+
try {
|
|
277
|
+
ownedKeypoints.delete();
|
|
278
|
+
} finally {
|
|
279
|
+
ownedDescriptors.delete();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
keypoints = null;
|
|
284
|
+
descriptors = null;
|
|
285
|
+
return features;
|
|
286
|
+
} finally {
|
|
287
|
+
image?.delete();
|
|
288
|
+
mask?.delete();
|
|
289
|
+
keypoints?.delete();
|
|
290
|
+
descriptors?.delete();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
var init_frame_features = __esmMin((() => {}));
|
|
171
294
|
|
|
172
295
|
//#endregion
|
|
173
|
-
//#region src/core/analyzer.ts
|
|
174
|
-
const OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
175
|
-
const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
|
|
176
|
-
let cvReady = null;
|
|
296
|
+
//#region src/core/analyzer/analyzer.ts
|
|
177
297
|
async function ensureOpenCV() {
|
|
178
298
|
if (!cvReady) cvReady = (async () => {
|
|
179
299
|
const cvObj = require$1("@techstark/opencv-js");
|
|
@@ -212,158 +332,6 @@ function computeIoU(a, b) {
|
|
|
212
332
|
const union = a.width * a.height + b.width * b.height - intersection;
|
|
213
333
|
return union === 0 ? 0 : intersection / union;
|
|
214
334
|
}
|
|
215
|
-
var IoUTracker = class {
|
|
216
|
-
fps;
|
|
217
|
-
iouThreshold;
|
|
218
|
-
animationThreshold;
|
|
219
|
-
regions = [];
|
|
220
|
-
extractedAnimations = [];
|
|
221
|
-
constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
|
|
222
|
-
this.fps = fps;
|
|
223
|
-
this.iouThreshold = iouThreshold;
|
|
224
|
-
this.animationThreshold = animationThreshold;
|
|
225
|
-
}
|
|
226
|
-
update(boxes, pairIndex) {
|
|
227
|
-
const animationIndices = /* @__PURE__ */ new Set();
|
|
228
|
-
const matched = /* @__PURE__ */ new Set();
|
|
229
|
-
for (let bi = 0; bi < boxes.length; bi++) {
|
|
230
|
-
const box = boxes[bi];
|
|
231
|
-
let bestIoU = 0;
|
|
232
|
-
let bestRegionIdx = -1;
|
|
233
|
-
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
234
|
-
if (matched.has(ri)) continue;
|
|
235
|
-
const iou = computeIoU(box, this.regions[ri].box);
|
|
236
|
-
if (iou > bestIoU) {
|
|
237
|
-
bestIoU = iou;
|
|
238
|
-
bestRegionIdx = ri;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
242
|
-
const region = this.regions[bestRegionIdx];
|
|
243
|
-
const gap = pairIndex - region.lastSeen;
|
|
244
|
-
region.box = box;
|
|
245
|
-
region.consecutiveCount++;
|
|
246
|
-
region.lastSeen = pairIndex;
|
|
247
|
-
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
248
|
-
matched.add(bestRegionIdx);
|
|
249
|
-
if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
|
|
250
|
-
} else this.regions.push({
|
|
251
|
-
box,
|
|
252
|
-
consecutiveCount: 1,
|
|
253
|
-
firstSeen: pairIndex,
|
|
254
|
-
lastSeen: pairIndex,
|
|
255
|
-
weight: 1
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
|
|
259
|
-
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
260
|
-
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
261
|
-
}
|
|
262
|
-
for (let i = 0; i < this.regions.length; i++) {
|
|
263
|
-
const region = this.regions[i];
|
|
264
|
-
if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
|
|
265
|
-
}
|
|
266
|
-
this.regions = (0, _winglet_common_utils.filter)(this.regions, (r, i) => r.weight > .01 || matched.has(i));
|
|
267
|
-
return animationIndices;
|
|
268
|
-
}
|
|
269
|
-
collectAnimation(region) {
|
|
270
|
-
if (region.consecutiveCount >= this.animationThreshold) {
|
|
271
|
-
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
272
|
-
this.extractedAnimations.push({
|
|
273
|
-
type: "loading_spinner",
|
|
274
|
-
boundingBox: region.box,
|
|
275
|
-
startFrameId: region.firstSeen,
|
|
276
|
-
endFrameId: region.lastSeen,
|
|
277
|
-
durationMs
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
flushAndGetAnimations() {
|
|
282
|
-
for (const region of this.regions) this.collectAnimation(region);
|
|
283
|
-
this.regions = [];
|
|
284
|
-
return this.extractedAnimations;
|
|
285
|
-
}
|
|
286
|
-
getAnimationWeight(boxIndex, boxes) {
|
|
287
|
-
if (boxIndex >= boxes.length) return 0;
|
|
288
|
-
const box = boxes[boxIndex];
|
|
289
|
-
let maxWeight = 0;
|
|
290
|
-
for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
|
|
291
|
-
if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
|
|
292
|
-
}
|
|
293
|
-
return maxWeight;
|
|
294
|
-
}
|
|
295
|
-
};
|
|
296
|
-
async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
297
|
-
const cv = cvLib;
|
|
298
|
-
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
299
|
-
mat1.data.set(frame1.data);
|
|
300
|
-
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
301
|
-
mat2.data.set(frame2.data);
|
|
302
|
-
const kp1 = new cvLib.KeyPointVector();
|
|
303
|
-
const kp2 = new cvLib.KeyPointVector();
|
|
304
|
-
const desc1 = new cvLib.Mat();
|
|
305
|
-
const desc2 = new cvLib.Mat();
|
|
306
|
-
const mask1 = new cvLib.Mat();
|
|
307
|
-
const mask2 = new cvLib.Mat();
|
|
308
|
-
const akaze = new cvLib.AKAZE();
|
|
309
|
-
let matches = null;
|
|
310
|
-
try {
|
|
311
|
-
akaze.detectAndCompute(mat1, mask1, kp1, desc1);
|
|
312
|
-
akaze.detectAndCompute(mat2, mask2, kp2, desc2);
|
|
313
|
-
const matchedKp1Indices = /* @__PURE__ */ new Set();
|
|
314
|
-
const matchedKp2Indices = /* @__PURE__ */ new Set();
|
|
315
|
-
if (desc1.rows > 0 && desc2.rows > 0) {
|
|
316
|
-
const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
317
|
-
try {
|
|
318
|
-
matches = new cvLib.DMatchVectorVector();
|
|
319
|
-
matcher.knnMatch(desc1, desc2, matches, 2);
|
|
320
|
-
for (let i = 0; i < matches.size(); i++) {
|
|
321
|
-
const pair = matches.get(i);
|
|
322
|
-
if (pair.size() < 2) continue;
|
|
323
|
-
const m0 = pair.get(0);
|
|
324
|
-
const m1 = pair.get(1);
|
|
325
|
-
if (m0.distance < .25 * m1.distance) {
|
|
326
|
-
matchedKp1Indices.add(m0.queryIdx);
|
|
327
|
-
matchedKp2Indices.add(m0.trainIdx);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
} finally {
|
|
331
|
-
matcher.delete();
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
const sNew = [];
|
|
335
|
-
for (let i = 0; i < kp2.size(); i++) if (!matchedKp2Indices.has(i)) {
|
|
336
|
-
const pt = kp2.get(i).pt;
|
|
337
|
-
sNew.push({
|
|
338
|
-
x: pt.x,
|
|
339
|
-
y: pt.y
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
const sLoss = [];
|
|
343
|
-
for (let i = 0; i < kp1.size(); i++) if (!matchedKp1Indices.has(i)) {
|
|
344
|
-
const pt = kp1.get(i).pt;
|
|
345
|
-
sLoss.push({
|
|
346
|
-
x: pt.x,
|
|
347
|
-
y: pt.y
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
return {
|
|
351
|
-
sNew,
|
|
352
|
-
sLoss
|
|
353
|
-
};
|
|
354
|
-
} finally {
|
|
355
|
-
mat1.delete();
|
|
356
|
-
mat2.delete();
|
|
357
|
-
kp1.delete();
|
|
358
|
-
kp2.delete();
|
|
359
|
-
desc1.delete();
|
|
360
|
-
desc2.delete();
|
|
361
|
-
mask1.delete();
|
|
362
|
-
mask2.delete();
|
|
363
|
-
akaze.delete();
|
|
364
|
-
if (matches) matches.delete();
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
335
|
/**
|
|
368
336
|
* Pixel-level difference fallback for AKAZE blind spots.
|
|
369
337
|
*
|
|
@@ -378,17 +346,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
|
378
346
|
* 3. threshold → binary mask of significant changes
|
|
379
347
|
* 4. findContours → bounding rects of changed regions
|
|
380
348
|
* 5. Grid sampling within each bounding rect → Point2D[]
|
|
349
|
+
*
|
|
350
|
+
* @param cvLib - Initialized OpenCV runtime shared by the analyzer.
|
|
351
|
+
* @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
|
|
352
|
+
* @param frame2 - Next grayscale frame, with the same dimensions as frame1.
|
|
353
|
+
* @returns Grid-sampled points from changed regions.
|
|
354
|
+
* @throws Propagates allocation or OpenCV errors after releasing acquired handles.
|
|
381
355
|
*/
|
|
382
356
|
function computePixelDiff(cvLib, frame1, frame2) {
|
|
383
357
|
const cv = cvLib;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
358
|
+
let mat1 = null;
|
|
359
|
+
let mat2 = null;
|
|
360
|
+
let diff = null;
|
|
361
|
+
let blurred = null;
|
|
362
|
+
let binary = null;
|
|
363
|
+
let contours = null;
|
|
364
|
+
let hierarchy = null;
|
|
391
365
|
try {
|
|
366
|
+
mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
367
|
+
mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
368
|
+
diff = new cv.Mat();
|
|
369
|
+
blurred = new cv.Mat();
|
|
370
|
+
binary = new cv.Mat();
|
|
371
|
+
contours = new cv.MatVector();
|
|
372
|
+
hierarchy = new cv.Mat();
|
|
392
373
|
mat1.data.set(frame1.data);
|
|
393
374
|
mat2.data.set(frame2.data);
|
|
394
375
|
cv.absdiff(mat1, mat2, diff);
|
|
@@ -399,22 +380,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
|
|
|
399
380
|
const points = [];
|
|
400
381
|
for (let c = 0; c < contours.size(); c++) {
|
|
401
382
|
const contour = contours.get(c);
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
x
|
|
406
|
-
|
|
407
|
-
|
|
383
|
+
try {
|
|
384
|
+
const rect = cv.boundingRect(contour);
|
|
385
|
+
if (rect.width * rect.height < 100) continue;
|
|
386
|
+
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({
|
|
387
|
+
x,
|
|
388
|
+
y
|
|
389
|
+
});
|
|
390
|
+
} finally {
|
|
391
|
+
contour.delete();
|
|
392
|
+
}
|
|
408
393
|
}
|
|
409
394
|
return points;
|
|
410
395
|
} finally {
|
|
411
|
-
mat1
|
|
412
|
-
mat2
|
|
413
|
-
diff
|
|
414
|
-
blurred
|
|
415
|
-
binary
|
|
416
|
-
contours
|
|
417
|
-
hierarchy
|
|
396
|
+
mat1?.delete();
|
|
397
|
+
mat2?.delete();
|
|
398
|
+
diff?.delete();
|
|
399
|
+
blurred?.delete();
|
|
400
|
+
binary?.delete();
|
|
401
|
+
contours?.delete();
|
|
402
|
+
hierarchy?.delete();
|
|
418
403
|
}
|
|
419
404
|
}
|
|
420
405
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
@@ -433,47 +418,91 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
|
|
|
433
418
|
}
|
|
434
419
|
return gain;
|
|
435
420
|
}
|
|
436
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Analyze one batch, retaining its boundary frame for the following batch.
|
|
423
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
424
|
+
* @param akaze - Detector owned by analyzeFrames.
|
|
425
|
+
* @param frames - Boundary frame followed by new adjacent frames.
|
|
426
|
+
* @param carry - Boundary bytes and live features transferred from the previous batch.
|
|
427
|
+
* @param scale - Maximum preprocessing width.
|
|
428
|
+
* @param tracker - Stateful animation tracker shared across batches.
|
|
429
|
+
* @param pairOffset - Global position of this batch's first pair.
|
|
430
|
+
* @returns Scores, pair failure count, and ownership of the final frame's features.
|
|
431
|
+
*/
|
|
432
|
+
async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
|
|
437
433
|
const edges = [];
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
434
|
+
let failures = 0;
|
|
435
|
+
let prev = carry?.features ?? null;
|
|
436
|
+
let next = null;
|
|
437
|
+
try {
|
|
438
|
+
const preprocessed = await Promise.all((0, _winglet_common_utils.map)(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
|
|
439
|
+
const imageWidth = preprocessed[0]?.width ?? scale;
|
|
440
|
+
const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
|
|
441
|
+
const imageArea = imageWidth * imageHeight;
|
|
442
|
+
for (let i = 0; i < frames.length - 1; i++) {
|
|
443
|
+
const pairIndex = pairOffset + i;
|
|
444
|
+
try {
|
|
445
|
+
prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
|
|
446
|
+
next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
|
|
447
|
+
const sNew = computeNewPoints(cvLib, prev, next);
|
|
448
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
449
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
450
|
+
if (clusters.length === 0) {
|
|
451
|
+
const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
|
|
452
|
+
if (pixelDiffPoints.length > 0) {
|
|
453
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
|
|
454
|
+
dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
|
|
455
|
+
clusters = dbscanResult.boundingBoxes;
|
|
456
|
+
}
|
|
454
457
|
}
|
|
458
|
+
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
459
|
+
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
460
|
+
const animationIndices = tracker.update(clusters, pairIndex);
|
|
461
|
+
const change = {
|
|
462
|
+
regions: (0, _winglet_common_utils.filter)(clusters, (_, ci) => !animationIndices.has(ci)),
|
|
463
|
+
animatedRegions: (0, _winglet_common_utils.filter)(clusters, (_, ci) => animationIndices.has(ci))
|
|
464
|
+
};
|
|
465
|
+
const animationWeights = (0, _winglet_common_utils.map)(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
466
|
+
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
467
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
468
|
+
edges.push({
|
|
469
|
+
sourceId: frames[i].id,
|
|
470
|
+
targetId: frames[i + 1].id,
|
|
471
|
+
score,
|
|
472
|
+
change
|
|
473
|
+
});
|
|
474
|
+
} catch (err) {
|
|
475
|
+
logger.warn(`Frame pair analysis failed: ${String(err)}`);
|
|
476
|
+
failures++;
|
|
477
|
+
edges.push({
|
|
478
|
+
sourceId: frames[i].id,
|
|
479
|
+
targetId: frames[i + 1].id,
|
|
480
|
+
score: 0
|
|
481
|
+
});
|
|
482
|
+
} finally {
|
|
483
|
+
prev?.delete();
|
|
484
|
+
prev = next;
|
|
485
|
+
next = null;
|
|
455
486
|
}
|
|
456
|
-
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
457
|
-
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
458
|
-
const animationIndices = tracker.update(clusters, pairIndex);
|
|
459
|
-
const animationWeights = (0, _winglet_common_utils.map)(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
460
|
-
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
461
|
-
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
462
|
-
edges.push({
|
|
463
|
-
sourceId: frames[i].id,
|
|
464
|
-
targetId: frames[i + 1].id,
|
|
465
|
-
score
|
|
466
|
-
});
|
|
467
|
-
} catch (err) {
|
|
468
|
-
logger.debug(`Frame pair analysis failed: ${String(err)}`);
|
|
469
|
-
edges.push({
|
|
470
|
-
sourceId: frames[i].id,
|
|
471
|
-
targetId: frames[i + 1].id,
|
|
472
|
-
score: 0
|
|
473
|
-
});
|
|
474
487
|
}
|
|
488
|
+
const result = {
|
|
489
|
+
edges,
|
|
490
|
+
failures,
|
|
491
|
+
analysisResolution: {
|
|
492
|
+
width: imageWidth,
|
|
493
|
+
height: imageHeight
|
|
494
|
+
},
|
|
495
|
+
carry: prev ? {
|
|
496
|
+
preprocessed: preprocessed[preprocessed.length - 1],
|
|
497
|
+
features: prev
|
|
498
|
+
} : null
|
|
499
|
+
};
|
|
500
|
+
prev = null;
|
|
501
|
+
return result;
|
|
502
|
+
} finally {
|
|
503
|
+
prev?.delete();
|
|
504
|
+
next?.delete();
|
|
475
505
|
}
|
|
476
|
-
return edges;
|
|
477
506
|
}
|
|
478
507
|
/**
|
|
479
508
|
* Analyze adjacent frame pairs to compute information gain scores (G(t)).
|
|
@@ -484,35 +513,183 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
484
513
|
* 2. DBSCAN Spatial Clustering
|
|
485
514
|
* 3. Spatio-temporal IoU Tracking
|
|
486
515
|
* 4. G(t) Information Gain Scoring
|
|
516
|
+
* @param ctx - Frames, analysis options, and the progress callback for this run.
|
|
517
|
+
* @returns Adjacent scores and tracked animations in analysis coordinates.
|
|
518
|
+
* @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
|
|
487
519
|
*/
|
|
488
520
|
async function analyzeFrames(ctx) {
|
|
489
521
|
const { frames } = ctx;
|
|
490
522
|
if (frames.length < 2) return {
|
|
491
523
|
edges: [],
|
|
492
|
-
animations: []
|
|
524
|
+
animations: [],
|
|
525
|
+
analysisResolution: {
|
|
526
|
+
width: 0,
|
|
527
|
+
height: 0
|
|
528
|
+
}
|
|
493
529
|
};
|
|
494
530
|
logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
|
|
495
531
|
const cvLib = await ensureOpenCV();
|
|
496
532
|
const edges = [];
|
|
497
|
-
const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
533
|
+
const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
498
534
|
const scale = ctx.options.scale;
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
535
|
+
let akaze = null;
|
|
536
|
+
let carry = null;
|
|
537
|
+
let analysisResolution = {
|
|
538
|
+
width: 0,
|
|
539
|
+
height: 0
|
|
540
|
+
};
|
|
541
|
+
let failures = 0;
|
|
542
|
+
try {
|
|
543
|
+
akaze = new cvLib.AKAZE();
|
|
544
|
+
for (let i = 0; i < frames.length - 1; i += 10) {
|
|
545
|
+
const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
|
|
546
|
+
const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
|
|
547
|
+
carry = result.carry;
|
|
548
|
+
failures += result.failures;
|
|
549
|
+
if (i === 0) analysisResolution = result.analysisResolution;
|
|
550
|
+
edges.push(...result.edges);
|
|
551
|
+
const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
|
|
552
|
+
ctx.emitProgress(progress);
|
|
553
|
+
}
|
|
554
|
+
} finally {
|
|
555
|
+
carry?.features.delete();
|
|
556
|
+
akaze?.delete();
|
|
505
557
|
}
|
|
558
|
+
const pairs = frames.length - 1;
|
|
559
|
+
if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
|
|
506
560
|
const animations = tracker.flushAndGetAnimations();
|
|
507
561
|
logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
|
|
508
562
|
return {
|
|
509
563
|
edges,
|
|
510
|
-
animations
|
|
564
|
+
animations,
|
|
565
|
+
analysisResolution
|
|
511
566
|
};
|
|
512
567
|
}
|
|
568
|
+
var OPENCV_INIT_TIMEOUT_MS, require$1, cvReady, IoUTracker;
|
|
569
|
+
var init_analyzer$1 = __esmMin((() => {
|
|
570
|
+
init_pipeline_defaults();
|
|
571
|
+
init_vision_tuning();
|
|
572
|
+
init_logger();
|
|
573
|
+
init_dbscan();
|
|
574
|
+
init_feature_diff();
|
|
575
|
+
init_frame_features();
|
|
576
|
+
OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
577
|
+
require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
|
|
578
|
+
cvReady = null;
|
|
579
|
+
IoUTracker = class {
|
|
580
|
+
fps;
|
|
581
|
+
iouThreshold;
|
|
582
|
+
animationThreshold;
|
|
583
|
+
regions = [];
|
|
584
|
+
extractedAnimations = [];
|
|
585
|
+
constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
|
|
586
|
+
this.fps = fps;
|
|
587
|
+
this.iouThreshold = iouThreshold;
|
|
588
|
+
this.animationThreshold = animationThreshold;
|
|
589
|
+
}
|
|
590
|
+
update(boxes, pairIndex) {
|
|
591
|
+
const animationIndices = /* @__PURE__ */ new Set();
|
|
592
|
+
const matched = /* @__PURE__ */ new Set();
|
|
593
|
+
for (let bi = 0; bi < boxes.length; bi++) {
|
|
594
|
+
const box = boxes[bi];
|
|
595
|
+
let bestIoU = 0;
|
|
596
|
+
let bestRegionIdx = -1;
|
|
597
|
+
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
598
|
+
if (matched.has(ri)) continue;
|
|
599
|
+
const iou = computeIoU(box, this.regions[ri].box);
|
|
600
|
+
if (iou > bestIoU) {
|
|
601
|
+
bestIoU = iou;
|
|
602
|
+
bestRegionIdx = ri;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
606
|
+
const region = this.regions[bestRegionIdx];
|
|
607
|
+
const gap = pairIndex - region.lastSeen;
|
|
608
|
+
region.box = box;
|
|
609
|
+
region.consecutiveCount++;
|
|
610
|
+
region.lastSeen = pairIndex;
|
|
611
|
+
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
612
|
+
matched.add(bestRegionIdx);
|
|
613
|
+
if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
|
|
614
|
+
} else this.regions.push({
|
|
615
|
+
box,
|
|
616
|
+
consecutiveCount: 1,
|
|
617
|
+
firstSeen: pairIndex,
|
|
618
|
+
lastSeen: pairIndex,
|
|
619
|
+
weight: 1
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
|
|
623
|
+
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
624
|
+
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
625
|
+
}
|
|
626
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
627
|
+
const region = this.regions[i];
|
|
628
|
+
if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
|
|
629
|
+
}
|
|
630
|
+
this.regions = (0, _winglet_common_utils.filter)(this.regions, (r, i) => r.weight > .01 || matched.has(i));
|
|
631
|
+
return animationIndices;
|
|
632
|
+
}
|
|
633
|
+
collectAnimation(region) {
|
|
634
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
635
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
636
|
+
this.extractedAnimations.push({
|
|
637
|
+
type: "loading_spinner",
|
|
638
|
+
boundingBox: region.box,
|
|
639
|
+
startFrameId: region.firstSeen,
|
|
640
|
+
endFrameId: region.lastSeen,
|
|
641
|
+
durationMs
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
flushAndGetAnimations() {
|
|
646
|
+
for (const region of this.regions) this.collectAnimation(region);
|
|
647
|
+
this.regions = [];
|
|
648
|
+
return this.extractedAnimations;
|
|
649
|
+
}
|
|
650
|
+
getAnimationWeight(boxIndex, boxes) {
|
|
651
|
+
if (boxIndex >= boxes.length) return 0;
|
|
652
|
+
const box = boxes[boxIndex];
|
|
653
|
+
let maxWeight = 0;
|
|
654
|
+
for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
|
|
655
|
+
if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
|
|
656
|
+
}
|
|
657
|
+
return maxWeight;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}));
|
|
661
|
+
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/core/analyzer/index.ts
|
|
664
|
+
var init_analyzer = __esmMin((() => {
|
|
665
|
+
init_analyzer$1();
|
|
666
|
+
init_dbscan();
|
|
667
|
+
}));
|
|
668
|
+
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/constants/package-version.ts
|
|
671
|
+
var manifestPath, PACKAGE_VERSION;
|
|
672
|
+
var init_package_version = __esmMin((() => {
|
|
673
|
+
manifestPath = (0, node_fs.existsSync)(new URL("../package.json", require("url").pathToFileURL(__filename).href)) ? "../package.json" : "../../package.json";
|
|
674
|
+
PACKAGE_VERSION = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)(manifestPath).version;
|
|
675
|
+
}));
|
|
513
676
|
|
|
514
677
|
//#endregion
|
|
515
|
-
//#region src/
|
|
678
|
+
//#region src/core/constants/workspace-layout.ts
|
|
679
|
+
function getTempWorkspaceDir(sessionId) {
|
|
680
|
+
return (0, node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
681
|
+
}
|
|
682
|
+
var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
|
|
683
|
+
var init_workspace_layout = __esmMin((() => {
|
|
684
|
+
APP_NAME = "scene-sieve";
|
|
685
|
+
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
686
|
+
TEMP_BASE_DIR = (0, node_os.tmpdir)();
|
|
687
|
+
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
688
|
+
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
689
|
+
}));
|
|
690
|
+
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region src/core/utils/filesystem/paths.ts
|
|
516
693
|
async function ensureDir(dirPath) {
|
|
517
694
|
await (0, node_fs_promises.mkdir)(dirPath, { recursive: true });
|
|
518
695
|
}
|
|
@@ -546,17 +723,638 @@ function resolveAbsolute(p) {
|
|
|
546
723
|
* e.g., /path/to/video.mp4 -> /path/to/video_scenes
|
|
547
724
|
*/
|
|
548
725
|
function deriveOutputPath(inputPath) {
|
|
549
|
-
|
|
726
|
+
const dir = (0, node_path.resolve)(inputPath, "..");
|
|
727
|
+
const name = (0, node_path.basename)(inputPath, (0, node_path.extname)(inputPath));
|
|
728
|
+
return (0, node_path.resolve)(dir, `${name}_scenes`);
|
|
729
|
+
}
|
|
730
|
+
var init_paths = __esmMin((() => {}));
|
|
731
|
+
|
|
732
|
+
//#endregion
|
|
733
|
+
//#region src/core/workspace/workspace.ts
|
|
734
|
+
async function createWorkspace(sessionId) {
|
|
735
|
+
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
736
|
+
await ensureDir((0, node_path.join)(workspacePath, "frames"));
|
|
737
|
+
await ensureDir((0, node_path.join)(workspacePath, "output"));
|
|
738
|
+
return workspacePath;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Write selected JPEGs and the supplied document before replacing the output directory.
|
|
742
|
+
* @param ctx - Workspace, quality and destination settings.
|
|
743
|
+
* @param selectedFrames - Frames paired by position with document.frames.
|
|
744
|
+
* @param document - Complete metadata, including the output file names.
|
|
745
|
+
* @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
|
|
746
|
+
* @returns Selected JPEG paths, optional sheet path and finally the metadata path.
|
|
747
|
+
* @throws Rejects mismatched frame counts and propagates image or filesystem errors.
|
|
748
|
+
*/
|
|
749
|
+
async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
|
|
750
|
+
if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
|
|
751
|
+
const stagingDir = (0, node_path.join)(ctx.workspacePath, "output");
|
|
752
|
+
const outputPath = ctx.options.outputPath;
|
|
753
|
+
const quality = ctx.options.quality;
|
|
754
|
+
const outputFiles = [];
|
|
755
|
+
for (let i = 0; i < selectedFrames.length; i++) {
|
|
756
|
+
const frame = selectedFrames[i];
|
|
757
|
+
const fileName = document.frames[i].fileName;
|
|
758
|
+
const destPath = (0, node_path.join)(stagingDir, fileName);
|
|
759
|
+
await (0, sharp.default)(frame.extractPath).jpeg({
|
|
760
|
+
quality,
|
|
761
|
+
mozjpeg: true
|
|
762
|
+
}).toFile(destPath);
|
|
763
|
+
outputFiles.push((0, node_path.join)(outputPath, fileName));
|
|
764
|
+
}
|
|
765
|
+
if (sheetBuffer) {
|
|
766
|
+
await (0, node_fs_promises.writeFile)((0, node_path.join)(stagingDir, SHEET_FILE_NAME), sheetBuffer);
|
|
767
|
+
outputFiles.push((0, node_path.join)(outputPath, SHEET_FILE_NAME));
|
|
768
|
+
}
|
|
769
|
+
const metadataPath = (0, node_path.join)(stagingDir, METADATA_FILE_NAME);
|
|
770
|
+
await (0, node_fs_promises.writeFile)(metadataPath, JSON.stringify(document, null, 2));
|
|
771
|
+
outputFiles.push((0, node_path.join)(outputPath, METADATA_FILE_NAME));
|
|
772
|
+
await ensureDir((0, node_path.join)(outputPath, ".."));
|
|
773
|
+
await (0, node_fs_promises.rm)(outputPath, {
|
|
774
|
+
recursive: true,
|
|
775
|
+
force: true
|
|
776
|
+
});
|
|
777
|
+
await (0, node_fs_promises.rename)(stagingDir, outputPath);
|
|
778
|
+
return outputFiles;
|
|
779
|
+
}
|
|
780
|
+
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
781
|
+
const segmentPath = (0, node_path.join)(parentWorkspacePath, "segments", String(segmentIndex));
|
|
782
|
+
await ensureDir((0, node_path.join)(segmentPath, "frames"));
|
|
783
|
+
return segmentPath;
|
|
784
|
+
}
|
|
785
|
+
async function cleanupWorkspace(workspacePath) {
|
|
786
|
+
if (!workspacePath) return;
|
|
787
|
+
try {
|
|
788
|
+
await (0, node_fs_promises.rm)(workspacePath, {
|
|
789
|
+
recursive: true,
|
|
790
|
+
force: true
|
|
791
|
+
});
|
|
792
|
+
} catch {}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Write a video buffer to a temp file in the workspace and return the path.
|
|
796
|
+
* Used by 'buffer' input mode.
|
|
797
|
+
*/
|
|
798
|
+
async function writeInputBuffer(buffer, workspacePath) {
|
|
799
|
+
const inputDir = (0, node_path.join)(workspacePath, "input");
|
|
800
|
+
await ensureDir(inputDir);
|
|
801
|
+
const tempPath = (0, node_path.join)(inputDir, "input.mp4");
|
|
802
|
+
await (0, node_fs_promises.writeFile)(tempPath, buffer);
|
|
803
|
+
return tempPath;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Write an array of frame Buffers as JPG files and return FrameNode[].
|
|
807
|
+
* Used by 'frames' input mode.
|
|
808
|
+
*/
|
|
809
|
+
async function writeInputFrames(frames, workspacePath) {
|
|
810
|
+
const framesDir = (0, node_path.join)(workspacePath, "frames");
|
|
811
|
+
await ensureDir(framesDir);
|
|
812
|
+
const frameNodes = [];
|
|
813
|
+
for (let i = 0; i < frames.length; i++) {
|
|
814
|
+
const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
|
|
815
|
+
const extractPath = (0, node_path.join)(framesDir, filename);
|
|
816
|
+
await (0, node_fs_promises.writeFile)(extractPath, frames[i]);
|
|
817
|
+
frameNodes.push({
|
|
818
|
+
id: i,
|
|
819
|
+
timestamp: i,
|
|
820
|
+
extractPath
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
return frameNodes;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Read selected FrameNode files as Buffers with JPEG compression.
|
|
827
|
+
* Used to return output buffers in 'buffer' and 'frames' modes.
|
|
828
|
+
*/
|
|
829
|
+
async function readFramesAsBuffers(frameNodes, quality) {
|
|
830
|
+
return Promise.all((0, _winglet_common_utils.map)(frameNodes, (f) => (0, sharp.default)(f.extractPath).jpeg({
|
|
831
|
+
quality,
|
|
832
|
+
mozjpeg: true
|
|
833
|
+
}).toBuffer()));
|
|
834
|
+
}
|
|
835
|
+
var init_workspace$1 = __esmMin((() => {
|
|
836
|
+
init_workspace_layout();
|
|
837
|
+
init_pipeline_defaults();
|
|
838
|
+
init_paths();
|
|
839
|
+
}));
|
|
840
|
+
|
|
841
|
+
//#endregion
|
|
842
|
+
//#region src/core/workspace/index.ts
|
|
843
|
+
var init_workspace = __esmMin((() => {
|
|
844
|
+
init_workspace$1();
|
|
845
|
+
}));
|
|
846
|
+
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
|
|
849
|
+
var YCoverageTree;
|
|
850
|
+
var init_y_coverage_tree = __esmMin((() => {
|
|
851
|
+
YCoverageTree = class {
|
|
852
|
+
bounds;
|
|
853
|
+
/** Number of whole-node covering intervals, independent of descendants. */
|
|
854
|
+
counts;
|
|
855
|
+
/** Covered geometric length for each node, including partially covered children. */
|
|
856
|
+
lengths;
|
|
857
|
+
/**
|
|
858
|
+
* Allocate linear storage for the elementary intervals between coordinates.
|
|
859
|
+
* @param bounds - At least two sorted unique finite y endpoints.
|
|
860
|
+
*/
|
|
861
|
+
constructor(bounds) {
|
|
862
|
+
this.bounds = bounds;
|
|
863
|
+
this.counts = Array(4 * bounds.length).fill(0);
|
|
864
|
+
this.lengths = Array(4 * bounds.length).fill(0);
|
|
865
|
+
}
|
|
866
|
+
/** Total active covered y length, in the input coordinate system. */
|
|
867
|
+
get coveredLength() {
|
|
868
|
+
return this.lengths[1];
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Adjust a nonempty half-open interval and refresh its ancestors' lengths.
|
|
872
|
+
* @param start - Inclusive endpoint index; 0 <= start < end.
|
|
873
|
+
* @param end - Exclusive endpoint index; end < bounds.length.
|
|
874
|
+
* @param delta - One on entry, minus one for the matching departure.
|
|
875
|
+
* @param node - Internal tree slot; callers use the root default.
|
|
876
|
+
* @param left - Inclusive endpoint index of this node.
|
|
877
|
+
* @param right - Exclusive endpoint index of this node.
|
|
878
|
+
* @returns Nothing; mutates this tree's coverage in O(log n).
|
|
879
|
+
*/
|
|
880
|
+
update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
|
|
881
|
+
if (start <= left && right <= end) this.counts[node] += delta;
|
|
882
|
+
else {
|
|
883
|
+
const middle = Math.floor((left + right) / 2);
|
|
884
|
+
if (start < middle) this.update(start, end, delta, node * 2, left, middle);
|
|
885
|
+
if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
|
|
886
|
+
}
|
|
887
|
+
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];
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
}));
|
|
891
|
+
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region src/core/utils/metadata/change/union-area.ts
|
|
894
|
+
/**
|
|
895
|
+
* Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
|
|
896
|
+
* @param boxes - Finite rectangles; nonpositive dimensions are ignored.
|
|
897
|
+
* @returns Covered area in the input coordinate system, or zero for empty input.
|
|
898
|
+
*/
|
|
899
|
+
function unionArea(boxes) {
|
|
900
|
+
const events = [];
|
|
901
|
+
const endpoints = [];
|
|
902
|
+
for (const box of boxes) {
|
|
903
|
+
if (!(box.width > 0 && box.height > 0)) continue;
|
|
904
|
+
const end = box.y + box.height;
|
|
905
|
+
if (end === box.y) continue;
|
|
906
|
+
events.push({
|
|
907
|
+
x: box.x,
|
|
908
|
+
start: box.y,
|
|
909
|
+
end,
|
|
910
|
+
delta: 1
|
|
911
|
+
});
|
|
912
|
+
events.push({
|
|
913
|
+
x: box.x + box.width,
|
|
914
|
+
start: box.y,
|
|
915
|
+
end,
|
|
916
|
+
delta: -1
|
|
917
|
+
});
|
|
918
|
+
endpoints.push(box.y, end);
|
|
919
|
+
}
|
|
920
|
+
if (events.length === 0) return 0;
|
|
921
|
+
events.sort((a, b) => a.x - b.x);
|
|
922
|
+
endpoints.sort((a, b) => a - b);
|
|
923
|
+
const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
|
|
924
|
+
const indices = new Map(bounds.map((value, index) => [value, index]));
|
|
925
|
+
const coverage = new YCoverageTree(bounds);
|
|
926
|
+
let area = 0;
|
|
927
|
+
let previousX = events[0].x;
|
|
928
|
+
for (const event of events) {
|
|
929
|
+
area += (event.x - previousX) * coverage.coveredLength;
|
|
930
|
+
coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
|
|
931
|
+
previousX = event.x;
|
|
932
|
+
}
|
|
933
|
+
return area;
|
|
934
|
+
}
|
|
935
|
+
var init_union_area = __esmMin((() => {
|
|
936
|
+
init_y_coverage_tree();
|
|
937
|
+
}));
|
|
938
|
+
|
|
939
|
+
//#endregion
|
|
940
|
+
//#region src/core/utils/metadata/build-edge-metadata.ts
|
|
941
|
+
/**
|
|
942
|
+
* Serialize raw candidate edges in graph order.
|
|
943
|
+
* @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
|
|
944
|
+
* @param analysisResolution - Analysis dimensions used for both area fractions.
|
|
945
|
+
* @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
|
|
946
|
+
*/
|
|
947
|
+
function buildEdgeMetadata(graph, analysisResolution) {
|
|
948
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
949
|
+
return (0, _winglet_common_utils.map)(graph, (edge) => ({
|
|
950
|
+
sourceFrameId: edge.sourceId + 1,
|
|
951
|
+
targetFrameId: edge.targetId + 1,
|
|
952
|
+
score: Math.round(edge.score * 1e6) / 1e6,
|
|
953
|
+
areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
|
|
954
|
+
animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
|
|
955
|
+
}));
|
|
956
|
+
}
|
|
957
|
+
var init_build_edge_metadata = __esmMin((() => {
|
|
958
|
+
init_union_area();
|
|
959
|
+
}));
|
|
960
|
+
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/core/utils/metadata/scale-bounding-box.ts
|
|
963
|
+
/**
|
|
964
|
+
* Convert an analysis box to clamped integer output pixels.
|
|
965
|
+
* @param box - Analysis-space rectangle; the input remains unchanged.
|
|
966
|
+
* @param sx - Horizontal output-to-analysis scale.
|
|
967
|
+
* @param sy - Vertical output-to-analysis scale.
|
|
968
|
+
* @param width - Nonnegative output image width.
|
|
969
|
+
* @param height - Nonnegative output image height.
|
|
970
|
+
* @returns A rectangle contained within the output dimensions.
|
|
971
|
+
*/
|
|
972
|
+
function scaleBoundingBox(box, sx, sy, width, height) {
|
|
973
|
+
const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
|
|
974
|
+
const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
|
|
975
|
+
return {
|
|
976
|
+
x,
|
|
977
|
+
y,
|
|
978
|
+
width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
|
|
979
|
+
height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
var init_scale_bounding_box = __esmMin((() => {}));
|
|
983
|
+
|
|
984
|
+
//#endregion
|
|
985
|
+
//#region src/core/utils/metadata/change/select-regions.ts
|
|
986
|
+
/**
|
|
987
|
+
* Select distinct positive-area boxes with deterministic area and coordinate ties.
|
|
988
|
+
* @param boxes - Already scaled output rectangles; the input is not mutated.
|
|
989
|
+
* @param limit - Nonnegative maximum number of regions.
|
|
990
|
+
* @returns Largest boxes ordered by area descending, then y, x and width ascending.
|
|
991
|
+
*/
|
|
992
|
+
function selectRegions(boxes, limit) {
|
|
993
|
+
return (0, _winglet_common_utils.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);
|
|
994
|
+
}
|
|
995
|
+
var init_select_regions = __esmMin((() => {}));
|
|
996
|
+
|
|
997
|
+
//#endregion
|
|
998
|
+
//#region src/core/utils/metadata/change/build-frame-change.ts
|
|
999
|
+
/**
|
|
1000
|
+
* Aggregate adjacent-pair evidence across one selected-frame span.
|
|
1001
|
+
* @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
|
|
1002
|
+
* @returns Rounded raw scores, analysis-space union ratio and output-space regions.
|
|
1003
|
+
*/
|
|
1004
|
+
function buildFrameChange(input) {
|
|
1005
|
+
const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
|
|
1006
|
+
const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
|
|
1007
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
1008
|
+
const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
|
|
1009
|
+
const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
|
|
1010
|
+
const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
|
|
1011
|
+
const regions = area > 0 ? selectRegions((0, _winglet_common_utils.map)(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
|
|
1012
|
+
return {
|
|
1013
|
+
fromFrameId,
|
|
1014
|
+
skippedCandidates,
|
|
1015
|
+
peakScore: Math.round(peakScore * 1e6) / 1e6,
|
|
1016
|
+
sumScore: Math.round(sumScore * 1e6) / 1e6,
|
|
1017
|
+
areaRatio: Math.round(areaRatio * 1e4) / 1e4,
|
|
1018
|
+
regions
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
var init_build_frame_change = __esmMin((() => {
|
|
1022
|
+
init_scale_bounding_box();
|
|
1023
|
+
init_select_regions();
|
|
1024
|
+
init_union_area();
|
|
1025
|
+
}));
|
|
1026
|
+
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/core/utils/metadata/build-frame-metadata.ts
|
|
1029
|
+
/**
|
|
1030
|
+
* Describe selected frames using candidate adjacency rather than synthetic pruning edges.
|
|
1031
|
+
* @param input - Chronological candidates and selections, raw graph and output dimensions.
|
|
1032
|
+
* @returns One-based frame summaries with rounded timestamps and nonnegative holds.
|
|
1033
|
+
*/
|
|
1034
|
+
function buildFrameMetadata(input) {
|
|
1035
|
+
const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
|
|
1036
|
+
const edgesByPair = new Map((0, _winglet_common_utils.map)(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
|
|
1037
|
+
const candidateIndices = new Map((0, _winglet_common_utils.map)(frames, (frame, index) => [frame.id, index]));
|
|
1038
|
+
const padding = Math.max(4, String(frames.length).length);
|
|
1039
|
+
return (0, _winglet_common_utils.map)(selected, (frame, index) => {
|
|
1040
|
+
const timestampMs = Math.round(frame.timestamp * 1e3);
|
|
1041
|
+
const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
|
|
1042
|
+
const previous = selected[index - 1];
|
|
1043
|
+
const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
|
|
1044
|
+
const currentIndex = candidateIndices.get(frame.id);
|
|
1045
|
+
const spanEdges = [];
|
|
1046
|
+
if (previous) for (let i = previousIndex; i < currentIndex; i++) {
|
|
1047
|
+
const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
|
|
1048
|
+
if (edge) spanEdges.push(edge);
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
step: index + 1,
|
|
1052
|
+
fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
|
|
1053
|
+
frameId: frame.id + 1,
|
|
1054
|
+
timestampMs,
|
|
1055
|
+
holdsMs: Math.max(0, nextTimestampMs - timestampMs),
|
|
1056
|
+
change: previous ? buildFrameChange({
|
|
1057
|
+
spanEdges,
|
|
1058
|
+
fromFrameId: previous.id + 1,
|
|
1059
|
+
skippedCandidates: currentIndex - previousIndex - 1,
|
|
1060
|
+
analysisResolution,
|
|
1061
|
+
outputResolution,
|
|
1062
|
+
regionLimit: 5
|
|
1063
|
+
}) : null
|
|
1064
|
+
};
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
var init_build_frame_metadata = __esmMin((() => {
|
|
1068
|
+
init_pipeline_defaults();
|
|
1069
|
+
init_build_frame_change();
|
|
1070
|
+
}));
|
|
1071
|
+
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/core/utils/metadata/build-tool-metadata.ts
|
|
1074
|
+
/**
|
|
1075
|
+
* Record the tool and the nine selection and encoding settings in contract order.
|
|
1076
|
+
* @param options - Validated pipeline settings; operational options are excluded.
|
|
1077
|
+
* @param version - Runtime package version supplied by the I/O boundary.
|
|
1078
|
+
* @returns Deterministic tool provenance without paths or execution details.
|
|
1079
|
+
*/
|
|
1080
|
+
function buildToolMetadata(options, version) {
|
|
1081
|
+
return {
|
|
1082
|
+
name: "@lumy-pack/scene-sieve",
|
|
1083
|
+
version,
|
|
1084
|
+
params: {
|
|
1085
|
+
fps: options.fps,
|
|
1086
|
+
count: options.count,
|
|
1087
|
+
threshold: options.threshold,
|
|
1088
|
+
scale: options.scale,
|
|
1089
|
+
quality: options.quality,
|
|
1090
|
+
maxFrames: options.maxFrames,
|
|
1091
|
+
iouThreshold: options.iouThreshold,
|
|
1092
|
+
animationThreshold: options.animationThreshold,
|
|
1093
|
+
maxSegmentDuration: options.maxSegmentDuration
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
var init_build_tool_metadata = __esmMin((() => {}));
|
|
1098
|
+
|
|
1099
|
+
//#endregion
|
|
1100
|
+
//#region src/core/utils/metadata/build-sieve-metadata.ts
|
|
1101
|
+
/**
|
|
1102
|
+
* Assemble the complete v2 document without I/O or mutation.
|
|
1103
|
+
* @param input - Pipeline state, selections, output-space video and zero-based animations.
|
|
1104
|
+
* @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
|
|
1105
|
+
*/
|
|
1106
|
+
function buildSieveMetadata(input) {
|
|
1107
|
+
const { ctx, selected, video, animations, version, sheet } = input;
|
|
1108
|
+
const analysisResolution = ctx.analysisResolution ?? {
|
|
1109
|
+
width: 0,
|
|
1110
|
+
height: 0
|
|
1111
|
+
};
|
|
1112
|
+
return {
|
|
1113
|
+
metadataVersion: 2,
|
|
1114
|
+
tool: buildToolMetadata(ctx.options, version),
|
|
1115
|
+
video,
|
|
1116
|
+
frames: buildFrameMetadata({
|
|
1117
|
+
frames: ctx.frames,
|
|
1118
|
+
graph: ctx.graph,
|
|
1119
|
+
selected,
|
|
1120
|
+
originalDurationMs: video.originalDurationMs,
|
|
1121
|
+
analysisResolution,
|
|
1122
|
+
outputResolution: video.resolution
|
|
1123
|
+
}),
|
|
1124
|
+
animations: (0, _winglet_common_utils.map)(animations, (animation) => ({
|
|
1125
|
+
...animation,
|
|
1126
|
+
startFrameId: animation.startFrameId + 1,
|
|
1127
|
+
endFrameId: animation.endFrameId + 1,
|
|
1128
|
+
durationMs: Math.round(animation.durationMs)
|
|
1129
|
+
})),
|
|
1130
|
+
...sheet ? { sheet } : {},
|
|
1131
|
+
...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
var init_build_sieve_metadata = __esmMin((() => {
|
|
1135
|
+
init_pipeline_defaults();
|
|
1136
|
+
init_build_edge_metadata();
|
|
1137
|
+
init_build_frame_metadata();
|
|
1138
|
+
init_build_tool_metadata();
|
|
1139
|
+
}));
|
|
1140
|
+
|
|
1141
|
+
//#endregion
|
|
1142
|
+
//#region src/core/utils/metadata/build-video-metadata.ts
|
|
1143
|
+
/**
|
|
1144
|
+
* Read output dimensions and build consistent video and animation metadata.
|
|
1145
|
+
* JPEG finalization does not resize, so the source dimensions match the output.
|
|
1146
|
+
* @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
|
|
1147
|
+
* @param selected Selected frames in output order; the first candidate is the fallback.
|
|
1148
|
+
* @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
|
|
1149
|
+
* @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
|
|
1150
|
+
* @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
|
|
1151
|
+
*/
|
|
1152
|
+
async function buildVideoMetadata(ctx, selected, analysisResolution) {
|
|
1153
|
+
const firstFrame = selected[0] ?? ctx.frames[0];
|
|
1154
|
+
const dimensions = firstFrame ? await (0, sharp.default)(firstFrame.extractPath).metadata() : void 0;
|
|
1155
|
+
const width = dimensions?.width ?? 0;
|
|
1156
|
+
const height = dimensions?.height ?? 0;
|
|
1157
|
+
const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
|
|
1158
|
+
const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
|
|
1159
|
+
const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
|
|
1160
|
+
const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
|
|
1161
|
+
return {
|
|
1162
|
+
video: {
|
|
1163
|
+
originalDurationMs: Math.round(duration * 1e3),
|
|
1164
|
+
fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
|
|
1165
|
+
resolution: {
|
|
1166
|
+
width,
|
|
1167
|
+
height
|
|
1168
|
+
},
|
|
1169
|
+
candidatesCount: ctx.frames.length,
|
|
1170
|
+
selectedCount: selected.length,
|
|
1171
|
+
source: {
|
|
1172
|
+
fileName: ctx.options.mode === "file" && ctx.options.inputPath ? (0, node_path.basename)(ctx.options.inputPath) : null,
|
|
1173
|
+
mode: ctx.options.mode
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
animations: (ctx.animations ?? []).map((animation) => ({
|
|
1177
|
+
...animation,
|
|
1178
|
+
boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
|
|
1179
|
+
}))
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
var init_build_video_metadata = __esmMin((() => {
|
|
1183
|
+
init_scale_bounding_box();
|
|
1184
|
+
}));
|
|
1185
|
+
|
|
1186
|
+
//#endregion
|
|
1187
|
+
//#region src/core/utils/sheet/build-tile-label-svg.ts
|
|
1188
|
+
/**
|
|
1189
|
+
* Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
|
|
1190
|
+
* @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
|
|
1191
|
+
* @param tileWidth - Positive tile canvas width in pixels.
|
|
1192
|
+
* @param tileHeight - Positive tile canvas height in pixels.
|
|
1193
|
+
* @returns Encoded SVG bytes for a top-left sharp composite overlay.
|
|
1194
|
+
*/
|
|
1195
|
+
function buildTileLabelSvg(text, tileWidth, tileHeight) {
|
|
1196
|
+
const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
|
|
1197
|
+
const padX = Math.round(fontSize * .4);
|
|
1198
|
+
const padY = Math.round(fontSize * .2);
|
|
1199
|
+
const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
|
|
1200
|
+
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>`);
|
|
1201
|
+
}
|
|
1202
|
+
var init_build_tile_label_svg = __esmMin((() => {
|
|
1203
|
+
init_pipeline_defaults();
|
|
1204
|
+
}));
|
|
1205
|
+
|
|
1206
|
+
//#endregion
|
|
1207
|
+
//#region src/core/utils/sheet/format-tile-label.ts
|
|
1208
|
+
/**
|
|
1209
|
+
* Format a contact sheet label without rounding into the next tenth of a second.
|
|
1210
|
+
* @param frameId - One-based candidate frame ID.
|
|
1211
|
+
* @param timestampMs - Nonnegative candidate time in milliseconds.
|
|
1212
|
+
* @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
|
|
1213
|
+
*/
|
|
1214
|
+
function formatTileLabel(frameId, timestampMs) {
|
|
1215
|
+
const tenths = Math.floor(timestampMs / 100);
|
|
1216
|
+
return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
|
|
1217
|
+
}
|
|
1218
|
+
var init_format_tile_label = __esmMin((() => {}));
|
|
1219
|
+
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/core/utils/sheet/sample-tile-frames.ts
|
|
1222
|
+
/**
|
|
1223
|
+
* Sample a sequence uniformly while always retaining both endpoints.
|
|
1224
|
+
* @param items - Items in temporal order.
|
|
1225
|
+
* @param maxTiles - Validated integer limit of at least two.
|
|
1226
|
+
* @returns The original sequence when within the limit, otherwise evenly sampled items.
|
|
1227
|
+
*/
|
|
1228
|
+
function sampleTileFrames(items, maxTiles) {
|
|
1229
|
+
if (items.length <= maxTiles) return {
|
|
1230
|
+
items,
|
|
1231
|
+
sampled: false
|
|
1232
|
+
};
|
|
1233
|
+
return {
|
|
1234
|
+
items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
|
|
1235
|
+
sampled: true
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
var init_sample_tile_frames = __esmMin((() => {}));
|
|
1239
|
+
|
|
1240
|
+
//#endregion
|
|
1241
|
+
//#region src/core/utils/sheet/render-contact-sheet.ts
|
|
1242
|
+
/**
|
|
1243
|
+
* Read selected frame images and render a row-major contact sheet.
|
|
1244
|
+
* @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
|
|
1245
|
+
* @returns JPEG bytes and the effective tile layout with one-based frame IDs.
|
|
1246
|
+
* @throws Propagates sharp image reading, compositing or encoding errors.
|
|
1247
|
+
*/
|
|
1248
|
+
async function renderContactSheet(input) {
|
|
1249
|
+
const { selected, resolution, options, quality } = input;
|
|
1250
|
+
const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
|
|
1251
|
+
const columns = Math.min(options.columns, items.length);
|
|
1252
|
+
const rows = Math.ceil(items.length / columns);
|
|
1253
|
+
const tileWidth = options.tileWidth;
|
|
1254
|
+
const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
|
|
1255
|
+
const gap = 4;
|
|
1256
|
+
const tiles = await Promise.all((0, _winglet_common_utils.map)(items, async (frame, index) => {
|
|
1257
|
+
const tile = (0, sharp.default)(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
|
|
1258
|
+
if (options.label) {
|
|
1259
|
+
const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
|
|
1260
|
+
tile.composite([{
|
|
1261
|
+
input: buildTileLabelSvg(text, tileWidth, tileHeight),
|
|
1262
|
+
top: 0,
|
|
1263
|
+
left: 0
|
|
1264
|
+
}]);
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
input: await tile.png().toBuffer(),
|
|
1268
|
+
top: gap + Math.floor(index / columns) * (tileHeight + gap),
|
|
1269
|
+
left: gap + index % columns * (tileWidth + gap)
|
|
1270
|
+
};
|
|
1271
|
+
}));
|
|
1272
|
+
return {
|
|
1273
|
+
buffer: await (0, sharp.default)({ create: {
|
|
1274
|
+
width: columns * tileWidth + (columns + 1) * gap,
|
|
1275
|
+
height: rows * tileHeight + (rows + 1) * gap,
|
|
1276
|
+
channels: 3,
|
|
1277
|
+
background: "white"
|
|
1278
|
+
} }).composite(tiles).jpeg({
|
|
1279
|
+
quality,
|
|
1280
|
+
mozjpeg: true
|
|
1281
|
+
}).toBuffer(),
|
|
1282
|
+
metadata: {
|
|
1283
|
+
fileName: SHEET_FILE_NAME,
|
|
1284
|
+
columns,
|
|
1285
|
+
tileWidth,
|
|
1286
|
+
tileHeight,
|
|
1287
|
+
frameIds: (0, _winglet_common_utils.map)(items, (frame) => frame.id + 1),
|
|
1288
|
+
sampled
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
var init_render_contact_sheet = __esmMin((() => {
|
|
1293
|
+
init_pipeline_defaults();
|
|
1294
|
+
init_build_tile_label_svg();
|
|
1295
|
+
init_format_tile_label();
|
|
1296
|
+
init_sample_tile_frames();
|
|
1297
|
+
}));
|
|
1298
|
+
|
|
1299
|
+
//#endregion
|
|
1300
|
+
//#region src/core/utils/output/finalize-selection.ts
|
|
1301
|
+
/**
|
|
1302
|
+
* Read output dimensions once, render optional sheets and finalize the shared v2 document.
|
|
1303
|
+
* @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
|
|
1304
|
+
* @param selected - Selected frames in temporal order.
|
|
1305
|
+
* @returns Mode-specific output, the document and zero-based API animations.
|
|
1306
|
+
* @throws Propagates image, rendering and output I/O errors to the orchestrator.
|
|
1307
|
+
*/
|
|
1308
|
+
async function finalizeSelection(ctx, selected) {
|
|
1309
|
+
const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
|
|
1310
|
+
const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
|
|
1311
|
+
selected,
|
|
1312
|
+
resolution: video.resolution,
|
|
1313
|
+
options: ctx.options.sheet,
|
|
1314
|
+
quality: ctx.options.quality
|
|
1315
|
+
}) : void 0;
|
|
1316
|
+
const document = buildSieveMetadata({
|
|
1317
|
+
ctx,
|
|
1318
|
+
selected,
|
|
1319
|
+
video,
|
|
1320
|
+
animations,
|
|
1321
|
+
version: PACKAGE_VERSION,
|
|
1322
|
+
...sheet ? { sheet: sheet.metadata } : {}
|
|
1323
|
+
});
|
|
1324
|
+
if (ctx.options.mode === "file") return {
|
|
1325
|
+
outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
|
|
1326
|
+
document,
|
|
1327
|
+
animations
|
|
1328
|
+
};
|
|
1329
|
+
return {
|
|
1330
|
+
outputFiles: [],
|
|
1331
|
+
outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
|
|
1332
|
+
document,
|
|
1333
|
+
animations,
|
|
1334
|
+
...sheet ? { sheetBuffer: sheet.buffer } : {}
|
|
1335
|
+
};
|
|
550
1336
|
}
|
|
1337
|
+
var init_finalize_selection = __esmMin((() => {
|
|
1338
|
+
init_package_version();
|
|
1339
|
+
init_workspace();
|
|
1340
|
+
init_build_sieve_metadata();
|
|
1341
|
+
init_build_video_metadata();
|
|
1342
|
+
init_render_contact_sheet();
|
|
1343
|
+
}));
|
|
551
1344
|
|
|
552
1345
|
//#endregion
|
|
553
|
-
//#region src/core/extractor.ts
|
|
1346
|
+
//#region src/core/extractor/extractor.ts
|
|
554
1347
|
/**
|
|
555
1348
|
* Extract frames from video/GIF using FFmpeg.
|
|
556
|
-
*
|
|
557
|
-
*
|
|
1349
|
+
* @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
|
|
1350
|
+
* @returns Extracted candidates, or the unchanged input array in frames mode.
|
|
1351
|
+
* @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
|
|
558
1352
|
*/
|
|
559
1353
|
async function extractFrames(ctx) {
|
|
1354
|
+
if (ctx.options.mode === "frames") {
|
|
1355
|
+
ctx.effectiveFps = 1;
|
|
1356
|
+
return ctx.frames;
|
|
1357
|
+
}
|
|
560
1358
|
const framesDir = (0, node_path.join)(ctx.workspacePath, "frames");
|
|
561
1359
|
const { inputPath, fps, maxFrames, scale } = ctx.options;
|
|
562
1360
|
if (!inputPath) throw new Error("inputPath is required for frame extraction");
|
|
@@ -571,19 +1369,30 @@ async function extractFrames(ctx) {
|
|
|
571
1369
|
if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
|
|
572
1370
|
logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
|
|
573
1371
|
await ensureDir(framesDir);
|
|
1372
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
574
1373
|
let effectiveFps = fps;
|
|
575
1374
|
if (duration > 0) {
|
|
576
|
-
const fpsCap =
|
|
1375
|
+
const fpsCap = frameLimit / duration;
|
|
577
1376
|
effectiveFps = Math.min(fps, fpsCap);
|
|
578
|
-
effectiveFps = Math.max(.5, effectiveFps);
|
|
579
1377
|
logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
|
|
580
1378
|
}
|
|
581
|
-
|
|
1379
|
+
ctx.effectiveFps = effectiveFps;
|
|
1380
|
+
ctx.sourceDurationSec = duration;
|
|
1381
|
+
const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
|
|
582
1382
|
ctx.emitProgress(100);
|
|
583
1383
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
584
1384
|
return frames;
|
|
585
1385
|
}
|
|
586
|
-
|
|
1386
|
+
/**
|
|
1387
|
+
* Write scaled JPEG candidates through the bundled FFmpeg runtime.
|
|
1388
|
+
* @param inputPath Readable video input.
|
|
1389
|
+
* @param outputDir Existing frame directory.
|
|
1390
|
+
* @param fps Positive effective sampling frequency.
|
|
1391
|
+
* @param scale Output image height.
|
|
1392
|
+
* @param frameLimit Maximum number of output frames.
|
|
1393
|
+
* @returns Candidates with local output-grid timestamps; rejects on extraction failure.
|
|
1394
|
+
*/
|
|
1395
|
+
async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
|
|
587
1396
|
const outputPattern = (0, node_path.join)(outputDir, FRAME_FILENAME_PATTERN);
|
|
588
1397
|
await (0, execa.execa)(ffmpeg_static.default, [
|
|
589
1398
|
"-i",
|
|
@@ -592,9 +1401,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
|
|
|
592
1401
|
`fps=${fps},scale=-1:${scale}`,
|
|
593
1402
|
"-q:v",
|
|
594
1403
|
"2",
|
|
1404
|
+
"-frames:v",
|
|
1405
|
+
String(frameLimit),
|
|
595
1406
|
outputPattern
|
|
596
1407
|
]);
|
|
597
|
-
return buildFrameList(outputDir,
|
|
1408
|
+
return buildFrameList(outputDir, fps);
|
|
598
1409
|
}
|
|
599
1410
|
async function getVideoMetadata(inputPath) {
|
|
600
1411
|
const { stdout } = await (0, execa.execa)(_ffprobe_installer_ffprobe.path, [
|
|
@@ -608,12 +1419,19 @@ async function getVideoMetadata(inputPath) {
|
|
|
608
1419
|
]);
|
|
609
1420
|
return JSON.parse(stdout);
|
|
610
1421
|
}
|
|
611
|
-
|
|
612
|
-
|
|
1422
|
+
/**
|
|
1423
|
+
* Read sorted JPEG paths and attach local output-grid times.
|
|
1424
|
+
* @param framesDir Extracted frame directory; filesystem errors propagate.
|
|
1425
|
+
* @param effectiveFps Positive frequency used by the fps filter.
|
|
1426
|
+
* @returns Zero-based candidates without a segment seek offset.
|
|
1427
|
+
*/
|
|
1428
|
+
async function buildFrameList(framesDir, effectiveFps) {
|
|
1429
|
+
const files = await (0, node_fs_promises.readdir)(framesDir);
|
|
1430
|
+
const jpgFiles = (0, _winglet_common_utils.filter)(files, (f) => f.endsWith(".jpg")).sort();
|
|
613
1431
|
if (jpgFiles.length === 0) return [];
|
|
614
1432
|
return (0, _winglet_common_utils.map)(jpgFiles, (file, index) => ({
|
|
615
1433
|
id: index,
|
|
616
|
-
timestamp:
|
|
1434
|
+
timestamp: index / effectiveFps,
|
|
617
1435
|
extractPath: (0, node_path.join)(framesDir, file)
|
|
618
1436
|
}));
|
|
619
1437
|
}
|
|
@@ -627,9 +1445,10 @@ async function buildFrameList(framesDir, duration) {
|
|
|
627
1445
|
* @param scale - Height scale for vision analysis
|
|
628
1446
|
* @param startTime - Start time in seconds
|
|
629
1447
|
* @param duration - Duration in seconds to extract
|
|
1448
|
+
* @param frameLimit - Positive output limit; defaults to the range's grid capacity
|
|
630
1449
|
* @returns Array of FrameNode with segment-local timestamps (starting from 0)
|
|
631
1450
|
*/
|
|
632
|
-
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
|
|
1451
|
+
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
|
|
633
1452
|
const outputPattern = (0, node_path.join)(outputDir, FRAME_FILENAME_PATTERN);
|
|
634
1453
|
await (0, execa.execa)(ffmpeg_static.default, [
|
|
635
1454
|
"-ss",
|
|
@@ -642,139 +1461,157 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
|
|
|
642
1461
|
`fps=${fps},scale=-1:${scale}`,
|
|
643
1462
|
"-q:v",
|
|
644
1463
|
"2",
|
|
1464
|
+
"-frames:v",
|
|
1465
|
+
String(frameLimit),
|
|
645
1466
|
outputPattern
|
|
646
1467
|
]);
|
|
647
|
-
return buildFrameList(outputDir,
|
|
1468
|
+
return buildFrameList(outputDir, fps);
|
|
648
1469
|
}
|
|
1470
|
+
var init_extractor$1 = __esmMin((() => {
|
|
1471
|
+
init_workspace_layout();
|
|
1472
|
+
init_logger();
|
|
1473
|
+
init_paths();
|
|
1474
|
+
}));
|
|
649
1475
|
|
|
650
1476
|
//#endregion
|
|
651
|
-
//#region src/core/
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
async function finalizeOutput(ctx, selectedFrames) {
|
|
659
|
-
const stagingDir = (0, node_path.join)(ctx.workspacePath, "output");
|
|
660
|
-
const outputPath = ctx.options.outputPath;
|
|
661
|
-
const quality = ctx.options.quality;
|
|
662
|
-
const outputFiles = [];
|
|
663
|
-
const framesMetadata = [];
|
|
664
|
-
const totalFramesCount = ctx.frames.length;
|
|
665
|
-
const padding = Math.max(4, String(totalFramesCount).length);
|
|
666
|
-
for (let i = 0; i < selectedFrames.length; i++) {
|
|
667
|
-
const frame = selectedFrames[i];
|
|
668
|
-
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
669
|
-
const destPath = (0, node_path.join)(stagingDir, fileName);
|
|
670
|
-
await (0, sharp.default)(frame.extractPath).jpeg({
|
|
671
|
-
quality,
|
|
672
|
-
mozjpeg: true
|
|
673
|
-
}).toFile(destPath);
|
|
674
|
-
outputFiles.push((0, node_path.join)(outputPath, fileName));
|
|
675
|
-
framesMetadata.push({
|
|
676
|
-
step: i + 1,
|
|
677
|
-
fileName,
|
|
678
|
-
frameId: frame.id + 1,
|
|
679
|
-
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
const metadata = {
|
|
683
|
-
video: {
|
|
684
|
-
originalDurationMs: Math.round((ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3),
|
|
685
|
-
fps: ctx.options.fps,
|
|
686
|
-
resolution: {
|
|
687
|
-
width: ctx.options.scale,
|
|
688
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
689
|
-
}
|
|
690
|
-
},
|
|
691
|
-
frames: framesMetadata,
|
|
692
|
-
animations: (0, _winglet_common_utils.map)(ctx.animations || [], (anim) => ({
|
|
693
|
-
...anim,
|
|
694
|
-
startFrameId: anim.startFrameId + 1,
|
|
695
|
-
endFrameId: anim.endFrameId + 1,
|
|
696
|
-
durationMs: Math.round(anim.durationMs)
|
|
697
|
-
}))
|
|
698
|
-
};
|
|
699
|
-
await (0, node_fs_promises.writeFile)((0, node_path.join)(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
|
|
700
|
-
outputFiles.push((0, node_path.join)(outputPath, ".metadata.json"));
|
|
701
|
-
await ensureDir((0, node_path.join)(outputPath, ".."));
|
|
702
|
-
await (0, node_fs_promises.rm)(outputPath, {
|
|
703
|
-
recursive: true,
|
|
704
|
-
force: true
|
|
705
|
-
});
|
|
706
|
-
await (0, node_fs_promises.rename)(stagingDir, outputPath);
|
|
707
|
-
return outputFiles;
|
|
708
|
-
}
|
|
709
|
-
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
710
|
-
const segmentPath = (0, node_path.join)(parentWorkspacePath, "segments", String(segmentIndex));
|
|
711
|
-
await ensureDir((0, node_path.join)(segmentPath, "frames"));
|
|
712
|
-
return segmentPath;
|
|
713
|
-
}
|
|
714
|
-
async function cleanupWorkspace(workspacePath) {
|
|
715
|
-
if (!workspacePath) return;
|
|
716
|
-
try {
|
|
717
|
-
await (0, node_fs_promises.rm)(workspacePath, {
|
|
718
|
-
recursive: true,
|
|
719
|
-
force: true
|
|
720
|
-
});
|
|
721
|
-
} catch {}
|
|
722
|
-
}
|
|
723
|
-
/**
|
|
724
|
-
* Write a video buffer to a temp file in the workspace and return the path.
|
|
725
|
-
* Used by 'buffer' input mode.
|
|
726
|
-
*/
|
|
727
|
-
async function writeInputBuffer(buffer, workspacePath) {
|
|
728
|
-
const inputDir = (0, node_path.join)(workspacePath, "input");
|
|
729
|
-
await ensureDir(inputDir);
|
|
730
|
-
const tempPath = (0, node_path.join)(inputDir, "input.mp4");
|
|
731
|
-
await (0, node_fs_promises.writeFile)(tempPath, buffer);
|
|
732
|
-
return tempPath;
|
|
733
|
-
}
|
|
1477
|
+
//#region src/core/extractor/index.ts
|
|
1478
|
+
var init_extractor = __esmMin((() => {
|
|
1479
|
+
init_extractor$1();
|
|
1480
|
+
}));
|
|
1481
|
+
|
|
1482
|
+
//#endregion
|
|
1483
|
+
//#region src/core/input-resolver/validation/validate-options.ts
|
|
734
1484
|
/**
|
|
735
|
-
*
|
|
736
|
-
*
|
|
1485
|
+
* Reject invalid numeric options before defaults or pipeline effects are applied.
|
|
1486
|
+
* @param options - Supplied options; omitted numeric fields use pipeline defaults.
|
|
1487
|
+
* @returns Nothing when all supplied numeric fields satisfy their contracts.
|
|
1488
|
+
* @throws An input error naming the invalid option and its received value.
|
|
737
1489
|
*/
|
|
738
|
-
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1490
|
+
function validateOptions(options) {
|
|
1491
|
+
for (const [name, min, max, integer, exclusiveMin, requirement] of [
|
|
1492
|
+
[
|
|
1493
|
+
"count",
|
|
1494
|
+
1,
|
|
1495
|
+
Infinity,
|
|
1496
|
+
true,
|
|
1497
|
+
false,
|
|
1498
|
+
"an integer >= 1"
|
|
1499
|
+
],
|
|
1500
|
+
[
|
|
1501
|
+
"threshold",
|
|
1502
|
+
0,
|
|
1503
|
+
1,
|
|
1504
|
+
false,
|
|
1505
|
+
true,
|
|
1506
|
+
"in range (0, 1] and finite"
|
|
1507
|
+
],
|
|
1508
|
+
[
|
|
1509
|
+
"fps",
|
|
1510
|
+
0,
|
|
1511
|
+
Infinity,
|
|
1512
|
+
false,
|
|
1513
|
+
true,
|
|
1514
|
+
"finite and > 0"
|
|
1515
|
+
],
|
|
1516
|
+
[
|
|
1517
|
+
"maxFrames",
|
|
1518
|
+
2,
|
|
1519
|
+
Infinity,
|
|
1520
|
+
true,
|
|
1521
|
+
false,
|
|
1522
|
+
"an integer >= 2"
|
|
1523
|
+
],
|
|
1524
|
+
[
|
|
1525
|
+
"scale",
|
|
1526
|
+
16,
|
|
1527
|
+
Infinity,
|
|
1528
|
+
true,
|
|
1529
|
+
false,
|
|
1530
|
+
"an integer >= 16"
|
|
1531
|
+
],
|
|
1532
|
+
[
|
|
1533
|
+
"quality",
|
|
1534
|
+
1,
|
|
1535
|
+
100,
|
|
1536
|
+
true,
|
|
1537
|
+
false,
|
|
1538
|
+
"an integer in range [1, 100]"
|
|
1539
|
+
],
|
|
1540
|
+
[
|
|
1541
|
+
"iouThreshold",
|
|
1542
|
+
0,
|
|
1543
|
+
1,
|
|
1544
|
+
false,
|
|
1545
|
+
false,
|
|
1546
|
+
"finite and in range [0, 1]"
|
|
1547
|
+
],
|
|
1548
|
+
[
|
|
1549
|
+
"animationThreshold",
|
|
1550
|
+
1,
|
|
1551
|
+
Infinity,
|
|
1552
|
+
true,
|
|
1553
|
+
false,
|
|
1554
|
+
"an integer >= 1"
|
|
1555
|
+
],
|
|
1556
|
+
[
|
|
1557
|
+
"maxSegmentDuration",
|
|
1558
|
+
0,
|
|
1559
|
+
Infinity,
|
|
1560
|
+
false,
|
|
1561
|
+
true,
|
|
1562
|
+
"finite and > 0"
|
|
1563
|
+
],
|
|
1564
|
+
[
|
|
1565
|
+
"concurrency",
|
|
1566
|
+
1,
|
|
1567
|
+
Infinity,
|
|
1568
|
+
true,
|
|
1569
|
+
false,
|
|
1570
|
+
"an integer >= 1"
|
|
1571
|
+
]
|
|
1572
|
+
]) {
|
|
1573
|
+
const value = options[name];
|
|
1574
|
+
if (value === void 0) continue;
|
|
1575
|
+
if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
|
|
750
1576
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1577
|
+
const sheet = options.sheet;
|
|
1578
|
+
if (sheet === void 0 || typeof sheet === "boolean") return;
|
|
1579
|
+
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)}`);
|
|
1580
|
+
for (const [name, min] of [
|
|
1581
|
+
["columns", 1],
|
|
1582
|
+
["tileWidth", 16],
|
|
1583
|
+
["maxTiles", 2]
|
|
1584
|
+
]) {
|
|
1585
|
+
const value = sheet[name];
|
|
1586
|
+
if (value === void 0) continue;
|
|
1587
|
+
if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
|
|
1588
|
+
}
|
|
1589
|
+
if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
|
|
762
1590
|
}
|
|
1591
|
+
var init_validate_options = __esmMin((() => {}));
|
|
763
1592
|
|
|
764
1593
|
//#endregion
|
|
765
|
-
//#region src/core/input-resolver.ts
|
|
1594
|
+
//#region src/core/input-resolver/input-resolver.ts
|
|
1595
|
+
/**
|
|
1596
|
+
* Validate supplied options and resolve defaults and paths for the pipeline.
|
|
1597
|
+
* @param options - Mode-specific input and optional numeric settings.
|
|
1598
|
+
* @returns Complete pipeline settings with absolute file input paths.
|
|
1599
|
+
* @throws An input error if a supplied numeric setting is invalid.
|
|
1600
|
+
*/
|
|
766
1601
|
function resolveOptions(options) {
|
|
1602
|
+
validateOptions(options);
|
|
767
1603
|
const mode = options.mode;
|
|
768
1604
|
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
769
1605
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : (0, node_path.join)(process.cwd(), "scene-sieve-output"));
|
|
770
1606
|
const threshold = options.threshold ?? .5;
|
|
771
|
-
|
|
1607
|
+
const pruneMode = "threshold-with-cap";
|
|
1608
|
+
const sheet = typeof options.sheet === "object" ? options.sheet : {};
|
|
772
1609
|
return {
|
|
773
1610
|
mode,
|
|
774
1611
|
inputPath,
|
|
775
1612
|
count: options.count ?? 20,
|
|
776
1613
|
threshold,
|
|
777
|
-
pruneMode
|
|
1614
|
+
pruneMode,
|
|
778
1615
|
outputPath,
|
|
779
1616
|
fps: options.fps ?? 5,
|
|
780
1617
|
maxFrames: options.maxFrames ?? 300,
|
|
@@ -784,7 +1621,14 @@ function resolveOptions(options) {
|
|
|
784
1621
|
animationThreshold: options.animationThreshold ?? 5,
|
|
785
1622
|
debug: options.debug ?? false,
|
|
786
1623
|
maxSegmentDuration: options.maxSegmentDuration ?? 300,
|
|
787
|
-
concurrency: options.concurrency ?? 2
|
|
1624
|
+
concurrency: options.concurrency ?? 2,
|
|
1625
|
+
sheet: options.sheet ? {
|
|
1626
|
+
columns: sheet.columns ?? 4,
|
|
1627
|
+
tileWidth: sheet.tileWidth ?? 320,
|
|
1628
|
+
maxTiles: sheet.maxTiles ?? 40,
|
|
1629
|
+
label: sheet.label ?? true
|
|
1630
|
+
} : null,
|
|
1631
|
+
includeEdges: options.includeEdges ?? false
|
|
788
1632
|
};
|
|
789
1633
|
}
|
|
790
1634
|
/**
|
|
@@ -793,6 +1637,10 @@ function resolveOptions(options) {
|
|
|
793
1637
|
* - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
|
|
794
1638
|
* - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
|
|
795
1639
|
* - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
|
|
1640
|
+
* @param options - Input source; encoded frames must have matching dimensions.
|
|
1641
|
+
* @param workspacePath - Workspace receiving temporary input files.
|
|
1642
|
+
* @returns Frame nodes or a resolved video path for extraction.
|
|
1643
|
+
* @throws Propagates metadata or write errors and rejects mismatched frame sizes.
|
|
796
1644
|
*/
|
|
797
1645
|
async function resolveInput(options, workspacePath) {
|
|
798
1646
|
if (options.mode === "file") return {
|
|
@@ -803,12 +1651,51 @@ async function resolveInput(options, workspacePath) {
|
|
|
803
1651
|
frames: [],
|
|
804
1652
|
resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
|
|
805
1653
|
};
|
|
806
|
-
if (options.mode === "frames")
|
|
1654
|
+
if (options.mode === "frames") {
|
|
1655
|
+
let dimensions;
|
|
1656
|
+
for (const buffer of options.inputFrames) {
|
|
1657
|
+
const { width, height } = await (0, sharp.default)(buffer).metadata();
|
|
1658
|
+
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}`);
|
|
1659
|
+
dimensions = {
|
|
1660
|
+
width,
|
|
1661
|
+
height
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
|
|
1665
|
+
}
|
|
807
1666
|
throw new Error(`Unsupported input mode: ${options.mode}`);
|
|
808
1667
|
}
|
|
1668
|
+
var init_input_resolver$1 = __esmMin((() => {
|
|
1669
|
+
init_pipeline_defaults();
|
|
1670
|
+
init_paths();
|
|
1671
|
+
init_validate_options();
|
|
1672
|
+
init_workspace();
|
|
1673
|
+
}));
|
|
1674
|
+
|
|
1675
|
+
//#endregion
|
|
1676
|
+
//#region src/core/input-resolver/index.ts
|
|
1677
|
+
var init_input_resolver = __esmMin((() => {
|
|
1678
|
+
init_input_resolver$1();
|
|
1679
|
+
}));
|
|
809
1680
|
|
|
810
1681
|
//#endregion
|
|
811
|
-
//#region src/
|
|
1682
|
+
//#region src/core/pruner/scoring/normalize-scores.ts
|
|
1683
|
+
/**
|
|
1684
|
+
* Find the first position whose score is at least the requested value.
|
|
1685
|
+
* @param sorted - Finite positive scores sorted in ascending order.
|
|
1686
|
+
* @param value - A finite positive score present in sorted.
|
|
1687
|
+
* @returns The first matching rank, including the first position of any tie.
|
|
1688
|
+
*/
|
|
1689
|
+
function lowerBound(sorted, value) {
|
|
1690
|
+
let low = 0;
|
|
1691
|
+
let high = sorted.length;
|
|
1692
|
+
while (low < high) {
|
|
1693
|
+
const mid = Math.floor((low + high) / 2);
|
|
1694
|
+
if (sorted[mid] < value) low = mid + 1;
|
|
1695
|
+
else high = mid;
|
|
1696
|
+
}
|
|
1697
|
+
return low;
|
|
1698
|
+
}
|
|
812
1699
|
/**
|
|
813
1700
|
* Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
|
|
814
1701
|
*
|
|
@@ -853,64 +1740,67 @@ function normalizeScores(items) {
|
|
|
853
1740
|
});
|
|
854
1741
|
const cdf = (0, _winglet_common_utils.map)(safeScores, (s) => {
|
|
855
1742
|
if (s <= 0) return 0;
|
|
856
|
-
return sorted
|
|
1743
|
+
return lowerBound(sorted, s) / sorted.length;
|
|
857
1744
|
});
|
|
858
|
-
return (0, _winglet_common_utils.map)(logisticZ, (z, i) => z *
|
|
1745
|
+
return (0, _winglet_common_utils.map)(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
|
|
859
1746
|
}
|
|
1747
|
+
var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
|
|
1748
|
+
var init_normalize_scores = __esmMin((() => {
|
|
1749
|
+
NORMALIZATION_ALPHA = .4;
|
|
1750
|
+
NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
1751
|
+
NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
1752
|
+
}));
|
|
860
1753
|
|
|
861
1754
|
//#endregion
|
|
862
|
-
//#region src/
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
var MinHeap = class {
|
|
870
|
-
h = [];
|
|
871
|
-
get size() {
|
|
872
|
-
return this.h.length;
|
|
873
|
-
}
|
|
874
|
-
push(entry) {
|
|
875
|
-
this.h.push(entry);
|
|
876
|
-
this.siftUp(this.h.length - 1);
|
|
877
|
-
}
|
|
878
|
-
pop() {
|
|
879
|
-
const n = this.h.length;
|
|
880
|
-
if (n === 0) return void 0;
|
|
881
|
-
const top = this.h[0];
|
|
882
|
-
const last = this.h.pop();
|
|
883
|
-
if (n > 1) {
|
|
884
|
-
this.h[0] = last;
|
|
885
|
-
this.siftDown(0);
|
|
1755
|
+
//#region src/core/pruner/heap/min-heap.ts
|
|
1756
|
+
var MinHeap;
|
|
1757
|
+
var init_min_heap = __esmMin((() => {
|
|
1758
|
+
MinHeap = class {
|
|
1759
|
+
h = [];
|
|
1760
|
+
get size() {
|
|
1761
|
+
return this.h.length;
|
|
886
1762
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
while (i > 0) {
|
|
891
|
-
const p = i - 1 >> 1;
|
|
892
|
-
if (this.h[p].score <= this.h[i].score) break;
|
|
893
|
-
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
894
|
-
i = p;
|
|
1763
|
+
push(entry) {
|
|
1764
|
+
this.h.push(entry);
|
|
1765
|
+
this.siftUp(this.h.length - 1);
|
|
895
1766
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
907
|
-
i = m;
|
|
1767
|
+
pop() {
|
|
1768
|
+
const n = this.h.length;
|
|
1769
|
+
if (n === 0) return void 0;
|
|
1770
|
+
const top = this.h[0];
|
|
1771
|
+
const last = this.h.pop();
|
|
1772
|
+
if (n > 1) {
|
|
1773
|
+
this.h[0] = last;
|
|
1774
|
+
this.siftDown(0);
|
|
1775
|
+
}
|
|
1776
|
+
return top;
|
|
908
1777
|
}
|
|
909
|
-
|
|
910
|
-
|
|
1778
|
+
siftUp(i) {
|
|
1779
|
+
while (i > 0) {
|
|
1780
|
+
const p = i - 1 >> 1;
|
|
1781
|
+
if (this.h[p].score <= this.h[i].score) break;
|
|
1782
|
+
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
1783
|
+
i = p;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
siftDown(i) {
|
|
1787
|
+
const n = this.h.length;
|
|
1788
|
+
for (;;) {
|
|
1789
|
+
let m = i;
|
|
1790
|
+
const l = 2 * i + 1;
|
|
1791
|
+
const r = 2 * i + 2;
|
|
1792
|
+
if (l < n && this.h[l].score < this.h[m].score) m = l;
|
|
1793
|
+
if (r < n && this.h[r].score < this.h[m].score) m = r;
|
|
1794
|
+
if (m === i) break;
|
|
1795
|
+
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
1796
|
+
i = m;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
}));
|
|
911
1801
|
|
|
912
1802
|
//#endregion
|
|
913
|
-
//#region src/core/pruner.ts
|
|
1803
|
+
//#region src/core/pruner/pruner.ts
|
|
914
1804
|
/**
|
|
915
1805
|
* Edge-aware greedy merge with re-linking — O(N log N).
|
|
916
1806
|
*
|
|
@@ -1024,7 +1914,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
|
1024
1914
|
return result;
|
|
1025
1915
|
}
|
|
1026
1916
|
/**
|
|
1027
|
-
* Threshold-based pruning with NMS -- O(N).
|
|
1917
|
+
* Threshold-based pruning with NMS -- including normalization, O(N log N).
|
|
1028
1918
|
*
|
|
1029
1919
|
* 1. Scores are normalized to [0, 1] via percentile normalization.
|
|
1030
1920
|
* 2. Edges with normalized score >= threshold are collected.
|
|
@@ -1087,9 +1977,19 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
1087
1977
|
}
|
|
1088
1978
|
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1089
1979
|
}
|
|
1980
|
+
var init_pruner$1 = __esmMin((() => {
|
|
1981
|
+
init_normalize_scores();
|
|
1982
|
+
init_min_heap();
|
|
1983
|
+
}));
|
|
1090
1984
|
|
|
1091
1985
|
//#endregion
|
|
1092
|
-
//#region src/
|
|
1986
|
+
//#region src/core/pruner/index.ts
|
|
1987
|
+
var init_pruner = __esmMin((() => {
|
|
1988
|
+
init_pruner$1();
|
|
1989
|
+
}));
|
|
1990
|
+
|
|
1991
|
+
//#endregion
|
|
1992
|
+
//#region src/core/segmenter/scheduling/concurrency.ts
|
|
1093
1993
|
/**
|
|
1094
1994
|
* Creates a concurrency limiter that runs at most `limit` tasks in parallel.
|
|
1095
1995
|
* Lightweight replacement for p-limit to avoid external dependency.
|
|
@@ -1109,9 +2009,10 @@ function concurrencyLimit(limit) {
|
|
|
1109
2009
|
}
|
|
1110
2010
|
};
|
|
1111
2011
|
}
|
|
2012
|
+
var init_concurrency = __esmMin((() => {}));
|
|
1112
2013
|
|
|
1113
2014
|
//#endregion
|
|
1114
|
-
//#region src/core/segmenter.ts
|
|
2015
|
+
//#region src/core/segmenter/segmenter.ts
|
|
1115
2016
|
/**
|
|
1116
2017
|
* Determine whether segmentation should be used.
|
|
1117
2018
|
* Returns false for frames mode and GIF files.
|
|
@@ -1125,53 +2026,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
|
|
|
1125
2026
|
return true;
|
|
1126
2027
|
}
|
|
1127
2028
|
/**
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
2029
|
+
* Partition the global extraction grid into nonempty logical segments.
|
|
2030
|
+
* @param totalDuration Positive source duration in seconds.
|
|
2031
|
+
* @param maxSegmentDuration Positive logical segment width in seconds.
|
|
2032
|
+
* @param maxFrames Candidate budget, defensively raised to at least two.
|
|
2033
|
+
* @param fps Positive requested sampling frequency.
|
|
2034
|
+
* @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
|
|
1134
2035
|
*/
|
|
1135
2036
|
function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
|
|
1136
|
-
const
|
|
2037
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
2038
|
+
const effectiveFps = Math.min(fps, frameLimit / totalDuration);
|
|
1137
2039
|
if (totalDuration <= maxSegmentDuration) return [{
|
|
1138
2040
|
index: 0,
|
|
1139
2041
|
startTime: 0,
|
|
1140
2042
|
endTime: totalDuration,
|
|
1141
2043
|
duration: totalDuration,
|
|
1142
|
-
allocatedFrames:
|
|
2044
|
+
allocatedFrames: frameLimit,
|
|
1143
2045
|
effectiveFps,
|
|
1144
2046
|
overlapBefore: 0,
|
|
1145
2047
|
overlapAfter: 0,
|
|
1146
2048
|
extractStartTime: 0,
|
|
1147
2049
|
extractDuration: totalDuration
|
|
1148
2050
|
}];
|
|
1149
|
-
const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
|
|
1150
|
-
const overlapTime = 1 / effectiveFps;
|
|
1151
2051
|
const segments = [];
|
|
1152
|
-
for (let
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
const
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
2052
|
+
for (let slot = 0; slot < frameLimit; slot++) {
|
|
2053
|
+
const timestamp = slot / effectiveFps;
|
|
2054
|
+
if (timestamp >= totalDuration) break;
|
|
2055
|
+
const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
|
|
2056
|
+
const previous = segments[segments.length - 1];
|
|
2057
|
+
if (previous?.startTime === startTime) {
|
|
2058
|
+
previous.allocatedFrames++;
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
|
|
1160
2062
|
segments.push({
|
|
1161
|
-
index:
|
|
2063
|
+
index: segments.length,
|
|
1162
2064
|
startTime,
|
|
1163
2065
|
endTime,
|
|
1164
|
-
duration,
|
|
1165
|
-
allocatedFrames:
|
|
2066
|
+
duration: endTime - startTime,
|
|
2067
|
+
allocatedFrames: 1,
|
|
1166
2068
|
effectiveFps,
|
|
1167
|
-
overlapBefore,
|
|
1168
|
-
overlapAfter,
|
|
1169
|
-
extractStartTime,
|
|
1170
|
-
extractDuration
|
|
2069
|
+
overlapBefore: 0,
|
|
2070
|
+
overlapAfter: 0,
|
|
2071
|
+
extractStartTime: timestamp,
|
|
2072
|
+
extractDuration: 0
|
|
1171
2073
|
});
|
|
1172
2074
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
2075
|
+
let firstSlot = 0;
|
|
2076
|
+
for (const segment of segments) {
|
|
2077
|
+
const nextSlot = firstSlot + segment.allocatedFrames;
|
|
2078
|
+
segment.overlapBefore = segment.index > 0 ? 1 : 0;
|
|
2079
|
+
segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
|
|
2080
|
+
segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
|
|
2081
|
+
segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
|
|
2082
|
+
segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
|
|
2083
|
+
firstSlot = nextSlot;
|
|
2084
|
+
}
|
|
1175
2085
|
return segments;
|
|
1176
2086
|
}
|
|
1177
2087
|
/**
|
|
@@ -1190,44 +2100,58 @@ function collectAllFrames(segmentResults) {
|
|
|
1190
2100
|
return allFrames;
|
|
1191
2101
|
}
|
|
1192
2102
|
/**
|
|
1193
|
-
* Sort frames
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
2103
|
+
* Sort frames in place and alias overlap duplicates to the first survivor.
|
|
2104
|
+
* @param frames Collected entries whose segment index and local ID identify a frame.
|
|
2105
|
+
* @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
|
|
2106
|
+
* @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
|
|
1196
2107
|
*/
|
|
1197
2108
|
function deduplicateFrames(frames, effectiveFps) {
|
|
1198
2109
|
frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
|
|
1199
2110
|
const dupThreshold = 1 / (effectiveFps * 2);
|
|
1200
2111
|
const unique = [];
|
|
2112
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1201
2113
|
for (const entry of frames) {
|
|
1202
2114
|
if (unique.length > 0) {
|
|
1203
2115
|
const last = unique[unique.length - 1];
|
|
1204
|
-
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold)
|
|
2116
|
+
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
|
|
2117
|
+
aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
1205
2120
|
}
|
|
1206
2121
|
unique.push(entry);
|
|
1207
2122
|
}
|
|
1208
|
-
return
|
|
2123
|
+
return {
|
|
2124
|
+
unique,
|
|
2125
|
+
aliases
|
|
2126
|
+
};
|
|
1209
2127
|
}
|
|
1210
2128
|
/**
|
|
1211
|
-
* Assign sequential global IDs
|
|
1212
|
-
*
|
|
2129
|
+
* Assign sequential global IDs and retain duplicate local IDs as aliases.
|
|
2130
|
+
* @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
|
|
2131
|
+
* @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
|
|
2132
|
+
* @returns Remapped frames and a global ID lookup covering survivors and duplicates.
|
|
1213
2133
|
*/
|
|
1214
|
-
function remapFrameIds(uniqueFrames) {
|
|
2134
|
+
function remapFrameIds(uniqueFrames, aliases) {
|
|
1215
2135
|
const globalIdMap = /* @__PURE__ */ new Map();
|
|
2136
|
+
const frames = uniqueFrames.map((entry, globalId) => {
|
|
2137
|
+
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
2138
|
+
return {
|
|
2139
|
+
id: globalId,
|
|
2140
|
+
timestamp: entry.frame.timestamp,
|
|
2141
|
+
extractPath: entry.frame.extractPath
|
|
2142
|
+
};
|
|
2143
|
+
});
|
|
2144
|
+
for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
|
|
1216
2145
|
return {
|
|
1217
|
-
frames
|
|
1218
|
-
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1219
|
-
return {
|
|
1220
|
-
id: globalId,
|
|
1221
|
-
timestamp: entry.frame.timestamp,
|
|
1222
|
-
extractPath: entry.frame.extractPath
|
|
1223
|
-
};
|
|
1224
|
-
}),
|
|
2146
|
+
frames,
|
|
1225
2147
|
globalIdMap
|
|
1226
2148
|
};
|
|
1227
2149
|
}
|
|
1228
2150
|
/**
|
|
1229
|
-
* Remap
|
|
1230
|
-
*
|
|
2151
|
+
* Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
|
|
2152
|
+
* @param segmentResults Segment-local edges in encounter order.
|
|
2153
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
2154
|
+
* @returns One edge per surviving directed pair without changing its score.
|
|
1231
2155
|
*/
|
|
1232
2156
|
function remapEdges(segmentResults, globalIdMap) {
|
|
1233
2157
|
const edges = [];
|
|
@@ -1236,62 +2160,73 @@ function remapEdges(segmentResults, globalIdMap) {
|
|
|
1236
2160
|
const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
|
|
1237
2161
|
const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
|
|
1238
2162
|
if (newSourceId === void 0 || newTargetId === void 0) continue;
|
|
2163
|
+
if (newSourceId === newTargetId) continue;
|
|
1239
2164
|
const edgeKey = `${newSourceId}-${newTargetId}`;
|
|
1240
2165
|
const existingIdx = edgeMap.get(edgeKey);
|
|
1241
2166
|
if (existingIdx !== void 0) {
|
|
1242
2167
|
if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
|
|
2168
|
+
...edge,
|
|
1243
2169
|
sourceId: newSourceId,
|
|
1244
|
-
targetId: newTargetId
|
|
1245
|
-
score: edge.score
|
|
2170
|
+
targetId: newTargetId
|
|
1246
2171
|
};
|
|
1247
2172
|
} else {
|
|
1248
2173
|
edgeMap.set(edgeKey, edges.length);
|
|
1249
2174
|
edges.push({
|
|
2175
|
+
...edge,
|
|
1250
2176
|
sourceId: newSourceId,
|
|
1251
|
-
targetId: newTargetId
|
|
1252
|
-
score: edge.score
|
|
2177
|
+
targetId: newTargetId
|
|
1253
2178
|
});
|
|
1254
2179
|
}
|
|
1255
2180
|
}
|
|
1256
2181
|
return edges;
|
|
1257
2182
|
}
|
|
1258
2183
|
/**
|
|
1259
|
-
* Remap
|
|
1260
|
-
*
|
|
2184
|
+
* Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
|
|
2185
|
+
* @param segmentResults Segment-local tracker entries in encounter order.
|
|
2186
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
2187
|
+
* @returns One animation per directed pair with its original tracker duration and metadata.
|
|
1261
2188
|
*/
|
|
1262
2189
|
function remapAnimations(segmentResults, globalIdMap) {
|
|
1263
|
-
const animations =
|
|
2190
|
+
const animations = /* @__PURE__ */ new Map();
|
|
1264
2191
|
for (const result of segmentResults) for (const anim of result.animations) {
|
|
1265
2192
|
const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
|
|
1266
2193
|
const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
|
|
1267
2194
|
if (newStartId === void 0 || newEndId === void 0) continue;
|
|
1268
|
-
|
|
2195
|
+
if (newStartId === newEndId) continue;
|
|
2196
|
+
const animationKey = `${newStartId}-${newEndId}`;
|
|
2197
|
+
if (animations.has(animationKey)) continue;
|
|
2198
|
+
animations.set(animationKey, {
|
|
1269
2199
|
...anim,
|
|
1270
2200
|
startFrameId: newStartId,
|
|
1271
2201
|
endFrameId: newEndId
|
|
1272
2202
|
});
|
|
1273
2203
|
}
|
|
1274
|
-
return animations;
|
|
2204
|
+
return [...animations.values()];
|
|
1275
2205
|
}
|
|
1276
2206
|
/**
|
|
1277
2207
|
* Merge multiple segment results into a single unified frame/edge/animation set.
|
|
1278
|
-
*
|
|
1279
|
-
* -
|
|
1280
|
-
*
|
|
1281
|
-
* - Duplicate edges keep higher score
|
|
2208
|
+
* @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
|
|
2209
|
+
* @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
|
|
2210
|
+
* Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
|
|
1282
2211
|
*/
|
|
1283
2212
|
function mergeSegmentFrames(segmentResults) {
|
|
1284
2213
|
if (segmentResults.length === 0) return {
|
|
1285
2214
|
frames: [],
|
|
1286
2215
|
edges: [],
|
|
1287
|
-
animations: []
|
|
2216
|
+
animations: [],
|
|
2217
|
+
analysisResolution: {
|
|
2218
|
+
width: 0,
|
|
2219
|
+
height: 0
|
|
2220
|
+
}
|
|
1288
2221
|
};
|
|
1289
2222
|
const effectiveFps = segmentResults[0].segment.effectiveFps;
|
|
1290
|
-
const {
|
|
2223
|
+
const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
|
|
2224
|
+
const { frames, globalIdMap } = remapFrameIds(unique, aliases);
|
|
1291
2225
|
return {
|
|
1292
2226
|
frames,
|
|
1293
2227
|
edges: remapEdges(segmentResults, globalIdMap),
|
|
1294
|
-
animations: remapAnimations(segmentResults, globalIdMap)
|
|
2228
|
+
animations: remapAnimations(segmentResults, globalIdMap),
|
|
2229
|
+
analysisResolution: segmentResults[0].analysisResolution
|
|
1295
2230
|
};
|
|
1296
2231
|
}
|
|
1297
2232
|
function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
|
|
@@ -1302,6 +2237,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1302
2237
|
maxFrames: segment.allocatedFrames
|
|
1303
2238
|
},
|
|
1304
2239
|
workspacePath: segmentWorkspacePath,
|
|
2240
|
+
effectiveFps: segment.effectiveFps,
|
|
1305
2241
|
frames,
|
|
1306
2242
|
graph: [],
|
|
1307
2243
|
status: "ANALYZING",
|
|
@@ -1313,19 +2249,26 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1313
2249
|
* Each segment uses an isolated workspace directory.
|
|
1314
2250
|
*/
|
|
1315
2251
|
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1316
|
-
const
|
|
2252
|
+
const framesDir = (0, node_path.join)(workspacePath, "frames");
|
|
2253
|
+
const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
|
|
1317
2254
|
if (frames.length < 2) return {
|
|
1318
2255
|
segment,
|
|
1319
2256
|
frames,
|
|
1320
2257
|
edges: [],
|
|
1321
|
-
animations: []
|
|
2258
|
+
animations: [],
|
|
2259
|
+
analysisResolution: {
|
|
2260
|
+
width: 0,
|
|
2261
|
+
height: 0
|
|
2262
|
+
}
|
|
1322
2263
|
};
|
|
1323
|
-
const
|
|
2264
|
+
const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
|
|
2265
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1324
2266
|
return {
|
|
1325
2267
|
segment,
|
|
1326
2268
|
frames,
|
|
1327
2269
|
edges,
|
|
1328
|
-
animations
|
|
2270
|
+
animations,
|
|
2271
|
+
analysisResolution
|
|
1329
2272
|
};
|
|
1330
2273
|
}
|
|
1331
2274
|
/**
|
|
@@ -1365,7 +2308,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1365
2308
|
});
|
|
1366
2309
|
})));
|
|
1367
2310
|
options.onProgress?.("ANALYZING", 100);
|
|
1368
|
-
const { frames, edges, animations } = mergeSegmentFrames(results);
|
|
2311
|
+
const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
|
|
1369
2312
|
logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
|
|
1370
2313
|
options.onProgress?.("PRUNING", 0);
|
|
1371
2314
|
const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
@@ -1374,6 +2317,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1374
2317
|
options.onProgress?.("FINALIZING", 0);
|
|
1375
2318
|
const ctx = {
|
|
1376
2319
|
options: resolvedOptions,
|
|
2320
|
+
effectiveFps: segments[0]?.effectiveFps,
|
|
2321
|
+
sourceDurationSec: totalDuration,
|
|
2322
|
+
analysisResolution,
|
|
1377
2323
|
workspacePath: mainWorkspace,
|
|
1378
2324
|
frames,
|
|
1379
2325
|
graph: edges,
|
|
@@ -1381,27 +2327,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1381
2327
|
status: "FINALIZING",
|
|
1382
2328
|
emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
|
|
1383
2329
|
};
|
|
1384
|
-
|
|
1385
|
-
let outputBuffers;
|
|
1386
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1387
|
-
else outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
2330
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
1388
2331
|
options.onProgress?.("FINALIZING", 100);
|
|
1389
2332
|
logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
|
|
1390
2333
|
return {
|
|
1391
2334
|
success: true,
|
|
1392
2335
|
originalFramesCount: frames.length,
|
|
1393
2336
|
prunedFramesCount: prunedFrames.length,
|
|
1394
|
-
outputFiles,
|
|
1395
|
-
outputBuffers,
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
width: resolvedOptions.scale,
|
|
1402
|
-
height: Math.round(resolvedOptions.scale * 9 / 16)
|
|
1403
|
-
}
|
|
1404
|
-
},
|
|
2337
|
+
outputFiles: finalized.outputFiles,
|
|
2338
|
+
outputBuffers: finalized.outputBuffers,
|
|
2339
|
+
video: finalized.document.video,
|
|
2340
|
+
animations: finalized.animations,
|
|
2341
|
+
frames: finalized.document.frames,
|
|
2342
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2343
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1405
2344
|
executionTimeMs: Date.now() - pipelineStart
|
|
1406
2345
|
};
|
|
1407
2346
|
} catch (error) {
|
|
@@ -1413,11 +2352,33 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1413
2352
|
else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
|
|
1414
2353
|
}
|
|
1415
2354
|
}
|
|
2355
|
+
var init_segmenter$1 = __esmMin((() => {
|
|
2356
|
+
init_concurrency();
|
|
2357
|
+
init_logger();
|
|
2358
|
+
init_analyzer();
|
|
2359
|
+
init_finalize_selection();
|
|
2360
|
+
init_extractor();
|
|
2361
|
+
init_input_resolver();
|
|
2362
|
+
init_pruner();
|
|
2363
|
+
init_workspace();
|
|
2364
|
+
}));
|
|
2365
|
+
|
|
2366
|
+
//#endregion
|
|
2367
|
+
//#region src/core/segmenter/index.ts
|
|
2368
|
+
var init_segmenter = __esmMin((() => {
|
|
2369
|
+
init_segmenter$1();
|
|
2370
|
+
}));
|
|
1416
2371
|
|
|
1417
2372
|
//#endregion
|
|
1418
|
-
//#region src/core/orchestrator.ts
|
|
2373
|
+
//#region src/core/orchestrator/orchestrator.ts
|
|
2374
|
+
/**
|
|
2375
|
+
* Run the five pipeline stages, delegating segmented inputs to the segmenter.
|
|
2376
|
+
* @param options - Mode-specific input and optional pipeline settings.
|
|
2377
|
+
* @returns Selected outputs with v2 frame metadata and zero-based API animations.
|
|
2378
|
+
* @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
|
|
2379
|
+
*/
|
|
1419
2380
|
async function runPipeline(options) {
|
|
1420
|
-
|
|
2381
|
+
setDebugMode(options.debug ?? false);
|
|
1421
2382
|
const resolvedOptions = resolveOptions(options);
|
|
1422
2383
|
if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
|
|
1423
2384
|
const startTime = Date.now();
|
|
@@ -1437,50 +2398,47 @@ async function runPipeline(options) {
|
|
|
1437
2398
|
logger.debug(`Workspace created: ${ctx.workspacePath}`);
|
|
1438
2399
|
ctx.status = "EXTRACTING";
|
|
1439
2400
|
const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
|
|
1440
|
-
if (resolvedOptions.mode === "frames")
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
2401
|
+
if (resolvedOptions.mode === "frames") {
|
|
2402
|
+
ctx.frames = resolvedFrames;
|
|
2403
|
+
ctx.effectiveFps = 1;
|
|
2404
|
+
} else {
|
|
2405
|
+
const extractCtx = {
|
|
2406
|
+
...ctx,
|
|
2407
|
+
options: {
|
|
2408
|
+
...resolvedOptions,
|
|
2409
|
+
inputPath: resolvedInputPath
|
|
2410
|
+
}
|
|
2411
|
+
};
|
|
2412
|
+
ctx.frames = await extractFrames(extractCtx);
|
|
2413
|
+
ctx.effectiveFps = extractCtx.effectiveFps;
|
|
2414
|
+
ctx.sourceDurationSec = extractCtx.sourceDurationSec;
|
|
2415
|
+
}
|
|
1448
2416
|
ctx.emitProgress(100);
|
|
1449
2417
|
ctx.status = "ANALYZING";
|
|
1450
|
-
const { edges, animations } = await analyzeFrames(ctx);
|
|
2418
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1451
2419
|
ctx.graph = edges;
|
|
1452
2420
|
ctx.animations = animations;
|
|
2421
|
+
ctx.analysisResolution = analysisResolution;
|
|
1453
2422
|
ctx.status = "PRUNING";
|
|
1454
2423
|
const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
1455
2424
|
const prunedFrames = (0, _winglet_common_utils.filter)(ctx.frames, (f) => survivingIds.has(f.id));
|
|
1456
2425
|
ctx.emitProgress(100);
|
|
1457
2426
|
ctx.status = "FINALIZING";
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1461
|
-
outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1462
|
-
ctx.emitProgress(100);
|
|
1463
|
-
} else {
|
|
1464
|
-
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1465
|
-
ctx.emitProgress(100);
|
|
1466
|
-
}
|
|
2427
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
2428
|
+
ctx.emitProgress(100);
|
|
1467
2429
|
ctx.status = "SUCCESS";
|
|
1468
2430
|
logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
|
|
1469
2431
|
return {
|
|
1470
2432
|
success: true,
|
|
1471
2433
|
originalFramesCount: ctx.frames.length,
|
|
1472
2434
|
prunedFramesCount: prunedFrames.length,
|
|
1473
|
-
outputFiles,
|
|
1474
|
-
outputBuffers,
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
width: ctx.options.scale,
|
|
1481
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1482
|
-
}
|
|
1483
|
-
},
|
|
2435
|
+
outputFiles: finalized.outputFiles,
|
|
2436
|
+
outputBuffers: finalized.outputBuffers,
|
|
2437
|
+
video: finalized.document.video,
|
|
2438
|
+
animations: finalized.animations,
|
|
2439
|
+
frames: finalized.document.frames,
|
|
2440
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2441
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1484
2442
|
executionTimeMs: Date.now() - startTime
|
|
1485
2443
|
};
|
|
1486
2444
|
} catch (error) {
|
|
@@ -1493,6 +2451,20 @@ async function runPipeline(options) {
|
|
|
1493
2451
|
else logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
|
|
1494
2452
|
}
|
|
1495
2453
|
}
|
|
2454
|
+
var init_orchestrator = __esmMin((() => {
|
|
2455
|
+
init_logger();
|
|
2456
|
+
init_analyzer();
|
|
2457
|
+
init_finalize_selection();
|
|
2458
|
+
init_extractor();
|
|
2459
|
+
init_input_resolver();
|
|
2460
|
+
init_pruner();
|
|
2461
|
+
init_segmenter();
|
|
2462
|
+
init_workspace();
|
|
2463
|
+
}));
|
|
2464
|
+
|
|
2465
|
+
//#endregion
|
|
2466
|
+
//#region src/index.ts
|
|
2467
|
+
init_orchestrator();
|
|
1496
2468
|
|
|
1497
2469
|
//#endregion
|
|
1498
2470
|
exports.extractScenes = runPipeline;
|