@lumy-pack/scene-sieve 0.1.0 → 0.2.0
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 +47 -24
- 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 +6 -1
- package/dist/cli.mjs +755 -319
- package/dist/constants/pipeline-defaults.d.ts +13 -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 +8 -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.d.ts → orchestrator/orchestrator.d.ts} +1 -1
- 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-video-metadata.d.ts +14 -0
- package/dist/core/workspace/index.d.ts +1 -0
- package/dist/core/{workspace.d.ts → workspace/workspace.d.ts} +1 -1
- package/dist/index.cjs +910 -434
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +913 -434
- package/dist/pipeline-worker.mjs +635 -278
- package/dist/types/index.d.ts +25 -0
- package/package.json +1 -1
- package/dist/constants.d.ts +0 -32
- /package/dist/{commands → cli/commands}/Sieve.d.ts +0 -0
- /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.mjs
CHANGED
|
@@ -3,65 +3,76 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { filter, map } from "@winglet/common-utils";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import sharp from "sharp";
|
|
6
|
-
import { homedir, tmpdir } from "node:os";
|
|
7
|
-
import { basename, extname, join, resolve } from "node:path";
|
|
8
6
|
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
9
8
|
import { path } from "@ffprobe-installer/ffprobe";
|
|
10
9
|
import { execa } from "execa";
|
|
11
10
|
import ffmpegPath from "ffmpeg-static";
|
|
11
|
+
import { homedir, tmpdir } from "node:os";
|
|
12
|
+
|
|
13
|
+
//#region \0rolldown/runtime.js
|
|
14
|
+
var __esmMin = (fn, res, err) => () => {
|
|
15
|
+
if (err) throw err[0];
|
|
16
|
+
try {
|
|
17
|
+
return fn && (res = fn(fn = 0)), res;
|
|
18
|
+
} catch (e) {
|
|
19
|
+
throw err = [e], e;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
12
22
|
|
|
13
|
-
//#
|
|
14
|
-
|
|
15
|
-
let jsonMode = false;
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/logging/logger.ts
|
|
16
25
|
function setDebugMode(enabled) {
|
|
17
26
|
debugMode = enabled;
|
|
18
27
|
}
|
|
19
28
|
function timestamp() {
|
|
20
29
|
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
21
30
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
var debugMode, jsonMode, logger;
|
|
32
|
+
var init_logger = __esmMin((() => {
|
|
33
|
+
debugMode = false;
|
|
34
|
+
jsonMode = false;
|
|
35
|
+
logger = {
|
|
36
|
+
info(message) {
|
|
37
|
+
if (jsonMode) process.stderr.write(`${pc.blue("info")} ${message}\n`);
|
|
38
|
+
else console.log(`${pc.blue("info")} ${message}`);
|
|
39
|
+
},
|
|
40
|
+
success(message) {
|
|
41
|
+
if (jsonMode) process.stderr.write(`${pc.green("done")} ${message}\n`);
|
|
42
|
+
else console.log(`\n${pc.green("done")} ${message}`);
|
|
43
|
+
},
|
|
44
|
+
warn(message) {
|
|
45
|
+
console.warn(`${pc.yellow("warn")} ${message}`);
|
|
46
|
+
},
|
|
47
|
+
error(message) {
|
|
48
|
+
console.error(`${pc.red("error")} ${message}`);
|
|
49
|
+
},
|
|
50
|
+
debug(message) {
|
|
51
|
+
if (debugMode) if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
|
|
52
|
+
else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}));
|
|
42
56
|
|
|
43
57
|
//#endregion
|
|
44
|
-
//#region src/constants.ts
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
58
|
+
//#region src/constants/pipeline-defaults.ts
|
|
59
|
+
var DEFAULT_THRESHOLD, IOU_THRESHOLD;
|
|
60
|
+
var init_pipeline_defaults = __esmMin((() => {
|
|
61
|
+
DEFAULT_THRESHOLD = .5;
|
|
62
|
+
IOU_THRESHOLD = .9;
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/core/analyzer/constants/vision-tuning.ts
|
|
67
|
+
var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
|
|
68
|
+
var init_vision_tuning = __esmMin((() => {
|
|
69
|
+
DBSCAN_ALPHA = .03;
|
|
70
|
+
DECAY_LAMBDA = .95;
|
|
71
|
+
MATCH_DISTANCE_THRESHOLD = .25;
|
|
72
|
+
}));
|
|
60
73
|
|
|
61
74
|
//#endregion
|
|
62
|
-
//#region src/core/dbscan.ts
|
|
63
|
-
const UNVISITED = -2;
|
|
64
|
-
const NOISE = -1;
|
|
75
|
+
//#region src/core/analyzer/clustering/dbscan.ts
|
|
65
76
|
/**
|
|
66
77
|
* DBSCAN clustering with resolution-independent eps.
|
|
67
78
|
* eps = alpha * sqrt(width^2 + height^2)
|
|
@@ -137,12 +148,118 @@ function findNeighbors(points, idx, epsSquared) {
|
|
|
137
148
|
}
|
|
138
149
|
return neighbors;
|
|
139
150
|
}
|
|
151
|
+
var UNVISITED, NOISE;
|
|
152
|
+
var init_dbscan = __esmMin((() => {
|
|
153
|
+
init_vision_tuning();
|
|
154
|
+
UNVISITED = -2;
|
|
155
|
+
NOISE = -1;
|
|
156
|
+
}));
|
|
140
157
|
|
|
141
158
|
//#endregion
|
|
142
|
-
//#region src/core/analyzer.ts
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
159
|
+
//#region src/core/analyzer/features/feature-diff.ts
|
|
160
|
+
/**
|
|
161
|
+
* Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
|
|
162
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
163
|
+
* @param prev - Previous frame's live features, owned by the caller.
|
|
164
|
+
* @param next - Next frame's live features, owned by the caller.
|
|
165
|
+
* @returns Unmatched next-frame coordinates without changing input ownership.
|
|
166
|
+
* @throws Propagates matching errors after releasing temporary native handles.
|
|
167
|
+
*/
|
|
168
|
+
function computeNewPoints(cvLib, prev, next) {
|
|
169
|
+
let matcher = null;
|
|
170
|
+
let matches = null;
|
|
171
|
+
try {
|
|
172
|
+
const matchedIndices = /* @__PURE__ */ new Set();
|
|
173
|
+
if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
|
|
174
|
+
matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
175
|
+
matches = new cvLib.DMatchVectorVector();
|
|
176
|
+
matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
|
|
177
|
+
for (let i = 0; i < matches.size(); i++) {
|
|
178
|
+
const pair = matches.get(i);
|
|
179
|
+
try {
|
|
180
|
+
if (pair.size() < 2) continue;
|
|
181
|
+
const best = pair.get(0);
|
|
182
|
+
const second = pair.get(1);
|
|
183
|
+
if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
|
|
184
|
+
} finally {
|
|
185
|
+
pair.delete();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
matcher?.delete();
|
|
190
|
+
}
|
|
191
|
+
const points = [];
|
|
192
|
+
for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
|
|
193
|
+
const { x, y } = next.keypoints.get(i).pt;
|
|
194
|
+
points.push({
|
|
195
|
+
x,
|
|
196
|
+
y
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return points;
|
|
200
|
+
} finally {
|
|
201
|
+
matches?.delete();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
var init_feature_diff = __esmMin((() => {
|
|
205
|
+
init_vision_tuning();
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/core/analyzer/features/frame-features.ts
|
|
210
|
+
/**
|
|
211
|
+
* Detect one frame's features without retaining its image or mask.
|
|
212
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
213
|
+
* @param akaze - Detector owned and released by the caller.
|
|
214
|
+
* @param frame - Grayscale bytes with matching width and height.
|
|
215
|
+
* @returns Feature handles that the caller must delete.
|
|
216
|
+
* @throws Propagates native errors after releasing partial allocations.
|
|
217
|
+
*/
|
|
218
|
+
function computeFrameFeatures(cvLib, akaze, frame) {
|
|
219
|
+
let image = null;
|
|
220
|
+
let mask = null;
|
|
221
|
+
let keypoints = null;
|
|
222
|
+
let descriptors = null;
|
|
223
|
+
try {
|
|
224
|
+
image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
|
|
225
|
+
image.data.set(frame.data);
|
|
226
|
+
mask = new cvLib.Mat();
|
|
227
|
+
keypoints = new cvLib.KeyPointVector();
|
|
228
|
+
descriptors = new cvLib.Mat();
|
|
229
|
+
akaze.detectAndCompute(image, mask, keypoints, descriptors);
|
|
230
|
+
const ownedKeypoints = keypoints;
|
|
231
|
+
const ownedDescriptors = descriptors;
|
|
232
|
+
let deleted = false;
|
|
233
|
+
const features = {
|
|
234
|
+
width: frame.width,
|
|
235
|
+
height: frame.height,
|
|
236
|
+
keypoints: ownedKeypoints,
|
|
237
|
+
descriptors: ownedDescriptors,
|
|
238
|
+
/** Release the transferred handles exactly once. */
|
|
239
|
+
delete() {
|
|
240
|
+
if (deleted) return;
|
|
241
|
+
deleted = true;
|
|
242
|
+
try {
|
|
243
|
+
ownedKeypoints.delete();
|
|
244
|
+
} finally {
|
|
245
|
+
ownedDescriptors.delete();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
keypoints = null;
|
|
250
|
+
descriptors = null;
|
|
251
|
+
return features;
|
|
252
|
+
} finally {
|
|
253
|
+
image?.delete();
|
|
254
|
+
mask?.delete();
|
|
255
|
+
keypoints?.delete();
|
|
256
|
+
descriptors?.delete();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
var init_frame_features = __esmMin((() => {}));
|
|
260
|
+
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/core/analyzer/analyzer.ts
|
|
146
263
|
async function ensureOpenCV() {
|
|
147
264
|
if (!cvReady) cvReady = (async () => {
|
|
148
265
|
const cvObj = require("@techstark/opencv-js");
|
|
@@ -181,158 +298,6 @@ function computeIoU(a, b) {
|
|
|
181
298
|
const union = a.width * a.height + b.width * b.height - intersection;
|
|
182
299
|
return union === 0 ? 0 : intersection / union;
|
|
183
300
|
}
|
|
184
|
-
var IoUTracker = class {
|
|
185
|
-
fps;
|
|
186
|
-
iouThreshold;
|
|
187
|
-
animationThreshold;
|
|
188
|
-
regions = [];
|
|
189
|
-
extractedAnimations = [];
|
|
190
|
-
constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
|
|
191
|
-
this.fps = fps;
|
|
192
|
-
this.iouThreshold = iouThreshold;
|
|
193
|
-
this.animationThreshold = animationThreshold;
|
|
194
|
-
}
|
|
195
|
-
update(boxes, pairIndex) {
|
|
196
|
-
const animationIndices = /* @__PURE__ */ new Set();
|
|
197
|
-
const matched = /* @__PURE__ */ new Set();
|
|
198
|
-
for (let bi = 0; bi < boxes.length; bi++) {
|
|
199
|
-
const box = boxes[bi];
|
|
200
|
-
let bestIoU = 0;
|
|
201
|
-
let bestRegionIdx = -1;
|
|
202
|
-
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
203
|
-
if (matched.has(ri)) continue;
|
|
204
|
-
const iou = computeIoU(box, this.regions[ri].box);
|
|
205
|
-
if (iou > bestIoU) {
|
|
206
|
-
bestIoU = iou;
|
|
207
|
-
bestRegionIdx = ri;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
211
|
-
const region = this.regions[bestRegionIdx];
|
|
212
|
-
const gap = pairIndex - region.lastSeen;
|
|
213
|
-
region.box = box;
|
|
214
|
-
region.consecutiveCount++;
|
|
215
|
-
region.lastSeen = pairIndex;
|
|
216
|
-
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
217
|
-
matched.add(bestRegionIdx);
|
|
218
|
-
if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
|
|
219
|
-
} else this.regions.push({
|
|
220
|
-
box,
|
|
221
|
-
consecutiveCount: 1,
|
|
222
|
-
firstSeen: pairIndex,
|
|
223
|
-
lastSeen: pairIndex,
|
|
224
|
-
weight: 1
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
|
|
228
|
-
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
229
|
-
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
230
|
-
}
|
|
231
|
-
for (let i = 0; i < this.regions.length; i++) {
|
|
232
|
-
const region = this.regions[i];
|
|
233
|
-
if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
|
|
234
|
-
}
|
|
235
|
-
this.regions = filter(this.regions, (r, i) => r.weight > .01 || matched.has(i));
|
|
236
|
-
return animationIndices;
|
|
237
|
-
}
|
|
238
|
-
collectAnimation(region) {
|
|
239
|
-
if (region.consecutiveCount >= this.animationThreshold) {
|
|
240
|
-
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
241
|
-
this.extractedAnimations.push({
|
|
242
|
-
type: "loading_spinner",
|
|
243
|
-
boundingBox: region.box,
|
|
244
|
-
startFrameId: region.firstSeen,
|
|
245
|
-
endFrameId: region.lastSeen,
|
|
246
|
-
durationMs
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
flushAndGetAnimations() {
|
|
251
|
-
for (const region of this.regions) this.collectAnimation(region);
|
|
252
|
-
this.regions = [];
|
|
253
|
-
return this.extractedAnimations;
|
|
254
|
-
}
|
|
255
|
-
getAnimationWeight(boxIndex, boxes) {
|
|
256
|
-
if (boxIndex >= boxes.length) return 0;
|
|
257
|
-
const box = boxes[boxIndex];
|
|
258
|
-
let maxWeight = 0;
|
|
259
|
-
for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
|
|
260
|
-
if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
|
|
261
|
-
}
|
|
262
|
-
return maxWeight;
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
266
|
-
const cv = cvLib;
|
|
267
|
-
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
268
|
-
mat1.data.set(frame1.data);
|
|
269
|
-
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
270
|
-
mat2.data.set(frame2.data);
|
|
271
|
-
const kp1 = new cvLib.KeyPointVector();
|
|
272
|
-
const kp2 = new cvLib.KeyPointVector();
|
|
273
|
-
const desc1 = new cvLib.Mat();
|
|
274
|
-
const desc2 = new cvLib.Mat();
|
|
275
|
-
const mask1 = new cvLib.Mat();
|
|
276
|
-
const mask2 = new cvLib.Mat();
|
|
277
|
-
const akaze = new cvLib.AKAZE();
|
|
278
|
-
let matches = null;
|
|
279
|
-
try {
|
|
280
|
-
akaze.detectAndCompute(mat1, mask1, kp1, desc1);
|
|
281
|
-
akaze.detectAndCompute(mat2, mask2, kp2, desc2);
|
|
282
|
-
const matchedKp1Indices = /* @__PURE__ */ new Set();
|
|
283
|
-
const matchedKp2Indices = /* @__PURE__ */ new Set();
|
|
284
|
-
if (desc1.rows > 0 && desc2.rows > 0) {
|
|
285
|
-
const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
286
|
-
try {
|
|
287
|
-
matches = new cvLib.DMatchVectorVector();
|
|
288
|
-
matcher.knnMatch(desc1, desc2, matches, 2);
|
|
289
|
-
for (let i = 0; i < matches.size(); i++) {
|
|
290
|
-
const pair = matches.get(i);
|
|
291
|
-
if (pair.size() < 2) continue;
|
|
292
|
-
const m0 = pair.get(0);
|
|
293
|
-
const m1 = pair.get(1);
|
|
294
|
-
if (m0.distance < .25 * m1.distance) {
|
|
295
|
-
matchedKp1Indices.add(m0.queryIdx);
|
|
296
|
-
matchedKp2Indices.add(m0.trainIdx);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
} finally {
|
|
300
|
-
matcher.delete();
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
const sNew = [];
|
|
304
|
-
for (let i = 0; i < kp2.size(); i++) if (!matchedKp2Indices.has(i)) {
|
|
305
|
-
const pt = kp2.get(i).pt;
|
|
306
|
-
sNew.push({
|
|
307
|
-
x: pt.x,
|
|
308
|
-
y: pt.y
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
const sLoss = [];
|
|
312
|
-
for (let i = 0; i < kp1.size(); i++) if (!matchedKp1Indices.has(i)) {
|
|
313
|
-
const pt = kp1.get(i).pt;
|
|
314
|
-
sLoss.push({
|
|
315
|
-
x: pt.x,
|
|
316
|
-
y: pt.y
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
return {
|
|
320
|
-
sNew,
|
|
321
|
-
sLoss
|
|
322
|
-
};
|
|
323
|
-
} finally {
|
|
324
|
-
mat1.delete();
|
|
325
|
-
mat2.delete();
|
|
326
|
-
kp1.delete();
|
|
327
|
-
kp2.delete();
|
|
328
|
-
desc1.delete();
|
|
329
|
-
desc2.delete();
|
|
330
|
-
mask1.delete();
|
|
331
|
-
mask2.delete();
|
|
332
|
-
akaze.delete();
|
|
333
|
-
if (matches) matches.delete();
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
301
|
/**
|
|
337
302
|
* Pixel-level difference fallback for AKAZE blind spots.
|
|
338
303
|
*
|
|
@@ -347,17 +312,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
|
347
312
|
* 3. threshold → binary mask of significant changes
|
|
348
313
|
* 4. findContours → bounding rects of changed regions
|
|
349
314
|
* 5. Grid sampling within each bounding rect → Point2D[]
|
|
315
|
+
*
|
|
316
|
+
* @param cvLib - Initialized OpenCV runtime shared by the analyzer.
|
|
317
|
+
* @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
|
|
318
|
+
* @param frame2 - Next grayscale frame, with the same dimensions as frame1.
|
|
319
|
+
* @returns Grid-sampled points from changed regions.
|
|
320
|
+
* @throws Propagates allocation or OpenCV errors after releasing acquired handles.
|
|
350
321
|
*/
|
|
351
322
|
function computePixelDiff(cvLib, frame1, frame2) {
|
|
352
323
|
const cv = cvLib;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
324
|
+
let mat1 = null;
|
|
325
|
+
let mat2 = null;
|
|
326
|
+
let diff = null;
|
|
327
|
+
let blurred = null;
|
|
328
|
+
let binary = null;
|
|
329
|
+
let contours = null;
|
|
330
|
+
let hierarchy = null;
|
|
360
331
|
try {
|
|
332
|
+
mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
333
|
+
mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
334
|
+
diff = new cv.Mat();
|
|
335
|
+
blurred = new cv.Mat();
|
|
336
|
+
binary = new cv.Mat();
|
|
337
|
+
contours = new cv.MatVector();
|
|
338
|
+
hierarchy = new cv.Mat();
|
|
361
339
|
mat1.data.set(frame1.data);
|
|
362
340
|
mat2.data.set(frame2.data);
|
|
363
341
|
cv.absdiff(mat1, mat2, diff);
|
|
@@ -368,22 +346,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
|
|
|
368
346
|
const points = [];
|
|
369
347
|
for (let c = 0; c < contours.size(); c++) {
|
|
370
348
|
const contour = contours.get(c);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
x
|
|
375
|
-
|
|
376
|
-
|
|
349
|
+
try {
|
|
350
|
+
const rect = cv.boundingRect(contour);
|
|
351
|
+
if (rect.width * rect.height < 100) continue;
|
|
352
|
+
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({
|
|
353
|
+
x,
|
|
354
|
+
y
|
|
355
|
+
});
|
|
356
|
+
} finally {
|
|
357
|
+
contour.delete();
|
|
358
|
+
}
|
|
377
359
|
}
|
|
378
360
|
return points;
|
|
379
361
|
} finally {
|
|
380
|
-
mat1
|
|
381
|
-
mat2
|
|
382
|
-
diff
|
|
383
|
-
blurred
|
|
384
|
-
binary
|
|
385
|
-
contours
|
|
386
|
-
hierarchy
|
|
362
|
+
mat1?.delete();
|
|
363
|
+
mat2?.delete();
|
|
364
|
+
diff?.delete();
|
|
365
|
+
blurred?.delete();
|
|
366
|
+
binary?.delete();
|
|
367
|
+
contours?.delete();
|
|
368
|
+
hierarchy?.delete();
|
|
387
369
|
}
|
|
388
370
|
}
|
|
389
371
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
@@ -402,47 +384,85 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
|
|
|
402
384
|
}
|
|
403
385
|
return gain;
|
|
404
386
|
}
|
|
405
|
-
|
|
387
|
+
/**
|
|
388
|
+
* Analyze one batch, retaining its boundary frame for the following batch.
|
|
389
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
390
|
+
* @param akaze - Detector owned by analyzeFrames.
|
|
391
|
+
* @param frames - Boundary frame followed by new adjacent frames.
|
|
392
|
+
* @param carry - Boundary bytes and live features transferred from the previous batch.
|
|
393
|
+
* @param scale - Maximum preprocessing width.
|
|
394
|
+
* @param tracker - Stateful animation tracker shared across batches.
|
|
395
|
+
* @param pairOffset - Global position of this batch's first pair.
|
|
396
|
+
* @returns Scores, pair failure count, and ownership of the final frame's features.
|
|
397
|
+
*/
|
|
398
|
+
async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
|
|
406
399
|
const edges = [];
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
400
|
+
let failures = 0;
|
|
401
|
+
let prev = carry?.features ?? null;
|
|
402
|
+
let next = null;
|
|
403
|
+
try {
|
|
404
|
+
const preprocessed = await Promise.all(map(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
|
|
405
|
+
const imageWidth = preprocessed[0]?.width ?? scale;
|
|
406
|
+
const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
|
|
407
|
+
const imageArea = imageWidth * imageHeight;
|
|
408
|
+
for (let i = 0; i < frames.length - 1; i++) {
|
|
409
|
+
const pairIndex = pairOffset + i;
|
|
410
|
+
try {
|
|
411
|
+
prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
|
|
412
|
+
next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
|
|
413
|
+
let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
|
|
414
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
415
|
+
if (clusters.length === 0) {
|
|
416
|
+
const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
|
|
417
|
+
if (pixelDiffPoints.length > 0) {
|
|
418
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
|
|
419
|
+
dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
|
|
420
|
+
clusters = dbscanResult.boundingBoxes;
|
|
421
|
+
}
|
|
423
422
|
}
|
|
423
|
+
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
424
|
+
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
425
|
+
const animationIndices = tracker.update(clusters, pairIndex);
|
|
426
|
+
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
427
|
+
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
428
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
429
|
+
edges.push({
|
|
430
|
+
sourceId: frames[i].id,
|
|
431
|
+
targetId: frames[i + 1].id,
|
|
432
|
+
score
|
|
433
|
+
});
|
|
434
|
+
} catch (err) {
|
|
435
|
+
logger.warn(`Frame pair analysis failed: ${String(err)}`);
|
|
436
|
+
failures++;
|
|
437
|
+
edges.push({
|
|
438
|
+
sourceId: frames[i].id,
|
|
439
|
+
targetId: frames[i + 1].id,
|
|
440
|
+
score: 0
|
|
441
|
+
});
|
|
442
|
+
} finally {
|
|
443
|
+
prev?.delete();
|
|
444
|
+
prev = next;
|
|
445
|
+
next = null;
|
|
424
446
|
}
|
|
425
|
-
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
426
|
-
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
427
|
-
const animationIndices = tracker.update(clusters, pairIndex);
|
|
428
|
-
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
429
|
-
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
430
|
-
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
431
|
-
edges.push({
|
|
432
|
-
sourceId: frames[i].id,
|
|
433
|
-
targetId: frames[i + 1].id,
|
|
434
|
-
score
|
|
435
|
-
});
|
|
436
|
-
} catch (err) {
|
|
437
|
-
logger.debug(`Frame pair analysis failed: ${String(err)}`);
|
|
438
|
-
edges.push({
|
|
439
|
-
sourceId: frames[i].id,
|
|
440
|
-
targetId: frames[i + 1].id,
|
|
441
|
-
score: 0
|
|
442
|
-
});
|
|
443
447
|
}
|
|
448
|
+
const result = {
|
|
449
|
+
edges,
|
|
450
|
+
failures,
|
|
451
|
+
analysisResolution: {
|
|
452
|
+
width: imageWidth,
|
|
453
|
+
height: imageHeight
|
|
454
|
+
},
|
|
455
|
+
carry: prev ? {
|
|
456
|
+
preprocessed: preprocessed[preprocessed.length - 1],
|
|
457
|
+
features: prev
|
|
458
|
+
} : null
|
|
459
|
+
};
|
|
460
|
+
prev = null;
|
|
461
|
+
return result;
|
|
462
|
+
} finally {
|
|
463
|
+
prev?.delete();
|
|
464
|
+
next?.delete();
|
|
444
465
|
}
|
|
445
|
-
return edges;
|
|
446
466
|
}
|
|
447
467
|
/**
|
|
448
468
|
* Analyze adjacent frame pairs to compute information gain scores (G(t)).
|
|
@@ -453,35 +473,222 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
453
473
|
* 2. DBSCAN Spatial Clustering
|
|
454
474
|
* 3. Spatio-temporal IoU Tracking
|
|
455
475
|
* 4. G(t) Information Gain Scoring
|
|
476
|
+
* @param ctx - Frames, analysis options, and the progress callback for this run.
|
|
477
|
+
* @returns Adjacent scores and tracked animations in analysis coordinates.
|
|
478
|
+
* @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
|
|
456
479
|
*/
|
|
457
480
|
async function analyzeFrames(ctx) {
|
|
458
481
|
const { frames } = ctx;
|
|
459
482
|
if (frames.length < 2) return {
|
|
460
483
|
edges: [],
|
|
461
|
-
animations: []
|
|
484
|
+
animations: [],
|
|
485
|
+
analysisResolution: {
|
|
486
|
+
width: 0,
|
|
487
|
+
height: 0
|
|
488
|
+
}
|
|
462
489
|
};
|
|
463
490
|
logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
|
|
464
491
|
const cvLib = await ensureOpenCV();
|
|
465
492
|
const edges = [];
|
|
466
|
-
const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
493
|
+
const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
467
494
|
const scale = ctx.options.scale;
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
495
|
+
let akaze = null;
|
|
496
|
+
let carry = null;
|
|
497
|
+
let analysisResolution = {
|
|
498
|
+
width: 0,
|
|
499
|
+
height: 0
|
|
500
|
+
};
|
|
501
|
+
let failures = 0;
|
|
502
|
+
try {
|
|
503
|
+
akaze = new cvLib.AKAZE();
|
|
504
|
+
for (let i = 0; i < frames.length - 1; i += 10) {
|
|
505
|
+
const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
|
|
506
|
+
const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
|
|
507
|
+
carry = result.carry;
|
|
508
|
+
failures += result.failures;
|
|
509
|
+
if (i === 0) analysisResolution = result.analysisResolution;
|
|
510
|
+
edges.push(...result.edges);
|
|
511
|
+
const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
|
|
512
|
+
ctx.emitProgress(progress);
|
|
513
|
+
}
|
|
514
|
+
} finally {
|
|
515
|
+
carry?.features.delete();
|
|
516
|
+
akaze?.delete();
|
|
474
517
|
}
|
|
518
|
+
const pairs = frames.length - 1;
|
|
519
|
+
if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
|
|
475
520
|
const animations = tracker.flushAndGetAnimations();
|
|
476
521
|
logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
|
|
477
522
|
return {
|
|
478
523
|
edges,
|
|
479
|
-
animations
|
|
524
|
+
animations,
|
|
525
|
+
analysisResolution
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
var OPENCV_INIT_TIMEOUT_MS, require, cvReady, IoUTracker;
|
|
529
|
+
var init_analyzer$1 = __esmMin((() => {
|
|
530
|
+
init_pipeline_defaults();
|
|
531
|
+
init_vision_tuning();
|
|
532
|
+
init_logger();
|
|
533
|
+
init_dbscan();
|
|
534
|
+
init_feature_diff();
|
|
535
|
+
init_frame_features();
|
|
536
|
+
OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
537
|
+
require = createRequire(import.meta.url);
|
|
538
|
+
cvReady = null;
|
|
539
|
+
IoUTracker = class {
|
|
540
|
+
fps;
|
|
541
|
+
iouThreshold;
|
|
542
|
+
animationThreshold;
|
|
543
|
+
regions = [];
|
|
544
|
+
extractedAnimations = [];
|
|
545
|
+
constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
|
|
546
|
+
this.fps = fps;
|
|
547
|
+
this.iouThreshold = iouThreshold;
|
|
548
|
+
this.animationThreshold = animationThreshold;
|
|
549
|
+
}
|
|
550
|
+
update(boxes, pairIndex) {
|
|
551
|
+
const animationIndices = /* @__PURE__ */ new Set();
|
|
552
|
+
const matched = /* @__PURE__ */ new Set();
|
|
553
|
+
for (let bi = 0; bi < boxes.length; bi++) {
|
|
554
|
+
const box = boxes[bi];
|
|
555
|
+
let bestIoU = 0;
|
|
556
|
+
let bestRegionIdx = -1;
|
|
557
|
+
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
558
|
+
if (matched.has(ri)) continue;
|
|
559
|
+
const iou = computeIoU(box, this.regions[ri].box);
|
|
560
|
+
if (iou > bestIoU) {
|
|
561
|
+
bestIoU = iou;
|
|
562
|
+
bestRegionIdx = ri;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
566
|
+
const region = this.regions[bestRegionIdx];
|
|
567
|
+
const gap = pairIndex - region.lastSeen;
|
|
568
|
+
region.box = box;
|
|
569
|
+
region.consecutiveCount++;
|
|
570
|
+
region.lastSeen = pairIndex;
|
|
571
|
+
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
572
|
+
matched.add(bestRegionIdx);
|
|
573
|
+
if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
|
|
574
|
+
} else this.regions.push({
|
|
575
|
+
box,
|
|
576
|
+
consecutiveCount: 1,
|
|
577
|
+
firstSeen: pairIndex,
|
|
578
|
+
lastSeen: pairIndex,
|
|
579
|
+
weight: 1
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
|
|
583
|
+
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
584
|
+
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
585
|
+
}
|
|
586
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
587
|
+
const region = this.regions[i];
|
|
588
|
+
if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
|
|
589
|
+
}
|
|
590
|
+
this.regions = filter(this.regions, (r, i) => r.weight > .01 || matched.has(i));
|
|
591
|
+
return animationIndices;
|
|
592
|
+
}
|
|
593
|
+
collectAnimation(region) {
|
|
594
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
595
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
596
|
+
this.extractedAnimations.push({
|
|
597
|
+
type: "loading_spinner",
|
|
598
|
+
boundingBox: region.box,
|
|
599
|
+
startFrameId: region.firstSeen,
|
|
600
|
+
endFrameId: region.lastSeen,
|
|
601
|
+
durationMs
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
flushAndGetAnimations() {
|
|
606
|
+
for (const region of this.regions) this.collectAnimation(region);
|
|
607
|
+
this.regions = [];
|
|
608
|
+
return this.extractedAnimations;
|
|
609
|
+
}
|
|
610
|
+
getAnimationWeight(boxIndex, boxes) {
|
|
611
|
+
if (boxIndex >= boxes.length) return 0;
|
|
612
|
+
const box = boxes[boxIndex];
|
|
613
|
+
let maxWeight = 0;
|
|
614
|
+
for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
|
|
615
|
+
if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
|
|
616
|
+
}
|
|
617
|
+
return maxWeight;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}));
|
|
621
|
+
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region src/core/analyzer/index.ts
|
|
624
|
+
var init_analyzer = __esmMin((() => {
|
|
625
|
+
init_analyzer$1();
|
|
626
|
+
init_dbscan();
|
|
627
|
+
}));
|
|
628
|
+
|
|
629
|
+
//#endregion
|
|
630
|
+
//#region src/core/utils/metadata/build-video-metadata.ts
|
|
631
|
+
/**
|
|
632
|
+
* Read output dimensions and build consistent video and animation metadata.
|
|
633
|
+
* JPEG finalization does not resize, so the source dimensions match the output.
|
|
634
|
+
* @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
|
|
635
|
+
* @param selected Selected frames in output order; the first candidate is the fallback.
|
|
636
|
+
* @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
|
|
637
|
+
* @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
|
|
638
|
+
* @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
|
|
639
|
+
*/
|
|
640
|
+
async function buildVideoMetadata(ctx, selected, analysisResolution) {
|
|
641
|
+
const firstFrame = selected[0] ?? ctx.frames[0];
|
|
642
|
+
const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
|
|
643
|
+
const width = dimensions?.width ?? 0;
|
|
644
|
+
const height = dimensions?.height ?? 0;
|
|
645
|
+
const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
|
|
646
|
+
const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
|
|
647
|
+
const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
|
|
648
|
+
const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
|
|
649
|
+
return {
|
|
650
|
+
video: {
|
|
651
|
+
originalDurationMs: Math.round(duration * 1e3),
|
|
652
|
+
fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
|
|
653
|
+
resolution: {
|
|
654
|
+
width,
|
|
655
|
+
height
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
animations: (ctx.animations ?? []).map((animation) => {
|
|
659
|
+
const box = animation.boundingBox;
|
|
660
|
+
const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
|
|
661
|
+
const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
|
|
662
|
+
return {
|
|
663
|
+
...animation,
|
|
664
|
+
boundingBox: {
|
|
665
|
+
x,
|
|
666
|
+
y,
|
|
667
|
+
width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
|
|
668
|
+
height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
})
|
|
480
672
|
};
|
|
481
673
|
}
|
|
674
|
+
var init_build_video_metadata = __esmMin((() => {}));
|
|
675
|
+
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/core/constants/workspace-layout.ts
|
|
678
|
+
function getTempWorkspaceDir(sessionId) {
|
|
679
|
+
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
680
|
+
}
|
|
681
|
+
var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
|
|
682
|
+
var init_workspace_layout = __esmMin((() => {
|
|
683
|
+
APP_NAME = "scene-sieve";
|
|
684
|
+
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
685
|
+
TEMP_BASE_DIR = tmpdir();
|
|
686
|
+
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
687
|
+
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
688
|
+
}));
|
|
482
689
|
|
|
483
690
|
//#endregion
|
|
484
|
-
//#region src/utils/paths.ts
|
|
691
|
+
//#region src/core/utils/filesystem/paths.ts
|
|
485
692
|
async function ensureDir(dirPath) {
|
|
486
693
|
await mkdir(dirPath, { recursive: true });
|
|
487
694
|
}
|
|
@@ -517,15 +724,21 @@ function resolveAbsolute(p) {
|
|
|
517
724
|
function deriveOutputPath(inputPath) {
|
|
518
725
|
return resolve(resolve(inputPath, ".."), `${basename(inputPath, extname(inputPath))}_scenes`);
|
|
519
726
|
}
|
|
727
|
+
var init_paths = __esmMin((() => {}));
|
|
520
728
|
|
|
521
729
|
//#endregion
|
|
522
|
-
//#region src/core/extractor.ts
|
|
730
|
+
//#region src/core/extractor/extractor.ts
|
|
523
731
|
/**
|
|
524
732
|
* Extract frames from video/GIF using FFmpeg.
|
|
525
|
-
*
|
|
526
|
-
*
|
|
733
|
+
* @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
|
|
734
|
+
* @returns Extracted candidates, or the unchanged input array in frames mode.
|
|
735
|
+
* @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
|
|
527
736
|
*/
|
|
528
737
|
async function extractFrames(ctx) {
|
|
738
|
+
if (ctx.options.mode === "frames") {
|
|
739
|
+
ctx.effectiveFps = 1;
|
|
740
|
+
return ctx.frames;
|
|
741
|
+
}
|
|
529
742
|
const framesDir = join(ctx.workspacePath, "frames");
|
|
530
743
|
const { inputPath, fps, maxFrames, scale } = ctx.options;
|
|
531
744
|
if (!inputPath) throw new Error("inputPath is required for frame extraction");
|
|
@@ -540,19 +753,30 @@ async function extractFrames(ctx) {
|
|
|
540
753
|
if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
|
|
541
754
|
logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
|
|
542
755
|
await ensureDir(framesDir);
|
|
756
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
543
757
|
let effectiveFps = fps;
|
|
544
758
|
if (duration > 0) {
|
|
545
|
-
const fpsCap =
|
|
759
|
+
const fpsCap = frameLimit / duration;
|
|
546
760
|
effectiveFps = Math.min(fps, fpsCap);
|
|
547
|
-
effectiveFps = Math.max(.5, effectiveFps);
|
|
548
761
|
logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
|
|
549
762
|
}
|
|
550
|
-
|
|
763
|
+
ctx.effectiveFps = effectiveFps;
|
|
764
|
+
ctx.sourceDurationSec = duration;
|
|
765
|
+
const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
|
|
551
766
|
ctx.emitProgress(100);
|
|
552
767
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
553
768
|
return frames;
|
|
554
769
|
}
|
|
555
|
-
|
|
770
|
+
/**
|
|
771
|
+
* Write scaled JPEG candidates through the bundled FFmpeg runtime.
|
|
772
|
+
* @param inputPath Readable video input.
|
|
773
|
+
* @param outputDir Existing frame directory.
|
|
774
|
+
* @param fps Positive effective sampling frequency.
|
|
775
|
+
* @param scale Output image height.
|
|
776
|
+
* @param frameLimit Maximum number of output frames.
|
|
777
|
+
* @returns Candidates with local output-grid timestamps; rejects on extraction failure.
|
|
778
|
+
*/
|
|
779
|
+
async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
|
|
556
780
|
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
557
781
|
await execa(ffmpegPath, [
|
|
558
782
|
"-i",
|
|
@@ -561,9 +785,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
|
|
|
561
785
|
`fps=${fps},scale=-1:${scale}`,
|
|
562
786
|
"-q:v",
|
|
563
787
|
"2",
|
|
788
|
+
"-frames:v",
|
|
789
|
+
String(frameLimit),
|
|
564
790
|
outputPattern
|
|
565
791
|
]);
|
|
566
|
-
return buildFrameList(outputDir,
|
|
792
|
+
return buildFrameList(outputDir, fps);
|
|
567
793
|
}
|
|
568
794
|
async function getVideoMetadata(inputPath) {
|
|
569
795
|
const { stdout } = await execa(path, [
|
|
@@ -577,12 +803,18 @@ async function getVideoMetadata(inputPath) {
|
|
|
577
803
|
]);
|
|
578
804
|
return JSON.parse(stdout);
|
|
579
805
|
}
|
|
580
|
-
|
|
806
|
+
/**
|
|
807
|
+
* Read sorted JPEG paths and attach local output-grid times.
|
|
808
|
+
* @param framesDir Extracted frame directory; filesystem errors propagate.
|
|
809
|
+
* @param effectiveFps Positive frequency used by the fps filter.
|
|
810
|
+
* @returns Zero-based candidates without a segment seek offset.
|
|
811
|
+
*/
|
|
812
|
+
async function buildFrameList(framesDir, effectiveFps) {
|
|
581
813
|
const jpgFiles = filter(await readdir(framesDir), (f) => f.endsWith(".jpg")).sort();
|
|
582
814
|
if (jpgFiles.length === 0) return [];
|
|
583
815
|
return map(jpgFiles, (file, index) => ({
|
|
584
816
|
id: index,
|
|
585
|
-
timestamp:
|
|
817
|
+
timestamp: index / effectiveFps,
|
|
586
818
|
extractPath: join(framesDir, file)
|
|
587
819
|
}));
|
|
588
820
|
}
|
|
@@ -596,9 +828,10 @@ async function buildFrameList(framesDir, duration) {
|
|
|
596
828
|
* @param scale - Height scale for vision analysis
|
|
597
829
|
* @param startTime - Start time in seconds
|
|
598
830
|
* @param duration - Duration in seconds to extract
|
|
831
|
+
* @param frameLimit - Positive output limit; defaults to the range's grid capacity
|
|
599
832
|
* @returns Array of FrameNode with segment-local timestamps (starting from 0)
|
|
600
833
|
*/
|
|
601
|
-
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
|
|
834
|
+
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
|
|
602
835
|
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
603
836
|
await execa(ffmpegPath, [
|
|
604
837
|
"-ss",
|
|
@@ -611,13 +844,124 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
|
|
|
611
844
|
`fps=${fps},scale=-1:${scale}`,
|
|
612
845
|
"-q:v",
|
|
613
846
|
"2",
|
|
847
|
+
"-frames:v",
|
|
848
|
+
String(frameLimit),
|
|
614
849
|
outputPattern
|
|
615
850
|
]);
|
|
616
|
-
return buildFrameList(outputDir,
|
|
851
|
+
return buildFrameList(outputDir, fps);
|
|
852
|
+
}
|
|
853
|
+
var init_extractor$1 = __esmMin((() => {
|
|
854
|
+
init_workspace_layout();
|
|
855
|
+
init_logger();
|
|
856
|
+
init_paths();
|
|
857
|
+
}));
|
|
858
|
+
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region src/core/extractor/index.ts
|
|
861
|
+
var init_extractor = __esmMin((() => {
|
|
862
|
+
init_extractor$1();
|
|
863
|
+
}));
|
|
864
|
+
|
|
865
|
+
//#endregion
|
|
866
|
+
//#region src/core/input-resolver/validation/validate-options.ts
|
|
867
|
+
/**
|
|
868
|
+
* Reject invalid numeric options before defaults or pipeline effects are applied.
|
|
869
|
+
* @param options - Supplied options; omitted numeric fields use pipeline defaults.
|
|
870
|
+
* @returns Nothing when all supplied numeric fields satisfy their contracts.
|
|
871
|
+
* @throws An input error naming the invalid option and its received value.
|
|
872
|
+
*/
|
|
873
|
+
function validateOptions(options) {
|
|
874
|
+
for (const [name, min, max, integer, exclusiveMin, requirement] of [
|
|
875
|
+
[
|
|
876
|
+
"count",
|
|
877
|
+
1,
|
|
878
|
+
Infinity,
|
|
879
|
+
true,
|
|
880
|
+
false,
|
|
881
|
+
"an integer >= 1"
|
|
882
|
+
],
|
|
883
|
+
[
|
|
884
|
+
"threshold",
|
|
885
|
+
0,
|
|
886
|
+
1,
|
|
887
|
+
false,
|
|
888
|
+
true,
|
|
889
|
+
"in range (0, 1] and finite"
|
|
890
|
+
],
|
|
891
|
+
[
|
|
892
|
+
"fps",
|
|
893
|
+
0,
|
|
894
|
+
Infinity,
|
|
895
|
+
false,
|
|
896
|
+
true,
|
|
897
|
+
"finite and > 0"
|
|
898
|
+
],
|
|
899
|
+
[
|
|
900
|
+
"maxFrames",
|
|
901
|
+
2,
|
|
902
|
+
Infinity,
|
|
903
|
+
true,
|
|
904
|
+
false,
|
|
905
|
+
"an integer >= 2"
|
|
906
|
+
],
|
|
907
|
+
[
|
|
908
|
+
"scale",
|
|
909
|
+
16,
|
|
910
|
+
Infinity,
|
|
911
|
+
true,
|
|
912
|
+
false,
|
|
913
|
+
"an integer >= 16"
|
|
914
|
+
],
|
|
915
|
+
[
|
|
916
|
+
"quality",
|
|
917
|
+
1,
|
|
918
|
+
100,
|
|
919
|
+
true,
|
|
920
|
+
false,
|
|
921
|
+
"an integer in range [1, 100]"
|
|
922
|
+
],
|
|
923
|
+
[
|
|
924
|
+
"iouThreshold",
|
|
925
|
+
0,
|
|
926
|
+
1,
|
|
927
|
+
false,
|
|
928
|
+
false,
|
|
929
|
+
"finite and in range [0, 1]"
|
|
930
|
+
],
|
|
931
|
+
[
|
|
932
|
+
"animationThreshold",
|
|
933
|
+
1,
|
|
934
|
+
Infinity,
|
|
935
|
+
true,
|
|
936
|
+
false,
|
|
937
|
+
"an integer >= 1"
|
|
938
|
+
],
|
|
939
|
+
[
|
|
940
|
+
"maxSegmentDuration",
|
|
941
|
+
0,
|
|
942
|
+
Infinity,
|
|
943
|
+
false,
|
|
944
|
+
true,
|
|
945
|
+
"finite and > 0"
|
|
946
|
+
],
|
|
947
|
+
[
|
|
948
|
+
"concurrency",
|
|
949
|
+
1,
|
|
950
|
+
Infinity,
|
|
951
|
+
true,
|
|
952
|
+
false,
|
|
953
|
+
"an integer >= 1"
|
|
954
|
+
]
|
|
955
|
+
]) {
|
|
956
|
+
const value = options[name];
|
|
957
|
+
if (value === void 0) continue;
|
|
958
|
+
if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
|
|
959
|
+
}
|
|
617
960
|
}
|
|
961
|
+
var init_validate_options = __esmMin((() => {}));
|
|
618
962
|
|
|
619
963
|
//#endregion
|
|
620
|
-
//#region src/core/workspace.ts
|
|
964
|
+
//#region src/core/workspace/workspace.ts
|
|
621
965
|
async function createWorkspace(sessionId) {
|
|
622
966
|
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
623
967
|
await ensureDir(join(workspacePath, "frames"));
|
|
@@ -648,17 +992,11 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
648
992
|
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
649
993
|
});
|
|
650
994
|
}
|
|
995
|
+
const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
|
|
651
996
|
const metadata = {
|
|
652
|
-
video
|
|
653
|
-
originalDurationMs: Math.round((ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3),
|
|
654
|
-
fps: ctx.options.fps,
|
|
655
|
-
resolution: {
|
|
656
|
-
width: ctx.options.scale,
|
|
657
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
658
|
-
}
|
|
659
|
-
},
|
|
997
|
+
video,
|
|
660
998
|
frames: framesMetadata,
|
|
661
|
-
animations: map(
|
|
999
|
+
animations: map(animations, (anim) => ({
|
|
662
1000
|
...anim,
|
|
663
1001
|
startFrameId: anim.startFrameId + 1,
|
|
664
1002
|
endFrameId: anim.endFrameId + 1,
|
|
@@ -729,15 +1067,32 @@ async function readFramesAsBuffers(frameNodes, quality) {
|
|
|
729
1067
|
mozjpeg: true
|
|
730
1068
|
}).toBuffer()));
|
|
731
1069
|
}
|
|
1070
|
+
var init_workspace$1 = __esmMin((() => {
|
|
1071
|
+
init_workspace_layout();
|
|
1072
|
+
init_paths();
|
|
1073
|
+
init_build_video_metadata();
|
|
1074
|
+
}));
|
|
732
1075
|
|
|
733
1076
|
//#endregion
|
|
734
|
-
//#region src/core/
|
|
1077
|
+
//#region src/core/workspace/index.ts
|
|
1078
|
+
var init_workspace = __esmMin((() => {
|
|
1079
|
+
init_workspace$1();
|
|
1080
|
+
}));
|
|
1081
|
+
|
|
1082
|
+
//#endregion
|
|
1083
|
+
//#region src/core/input-resolver/input-resolver.ts
|
|
1084
|
+
/**
|
|
1085
|
+
* Validate supplied options and resolve defaults and paths for the pipeline.
|
|
1086
|
+
* @param options - Mode-specific input and optional numeric settings.
|
|
1087
|
+
* @returns Complete pipeline settings with absolute file input paths.
|
|
1088
|
+
* @throws An input error if a supplied numeric setting is invalid.
|
|
1089
|
+
*/
|
|
735
1090
|
function resolveOptions(options) {
|
|
1091
|
+
validateOptions(options);
|
|
736
1092
|
const mode = options.mode;
|
|
737
1093
|
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
738
1094
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
|
|
739
1095
|
const threshold = options.threshold ?? .5;
|
|
740
|
-
if (threshold <= 0 || threshold > 1) throw new Error(`threshold must be in range (0, 1], received: ${threshold}`);
|
|
741
1096
|
return {
|
|
742
1097
|
mode,
|
|
743
1098
|
inputPath,
|
|
@@ -762,6 +1117,10 @@ function resolveOptions(options) {
|
|
|
762
1117
|
* - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
|
|
763
1118
|
* - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
|
|
764
1119
|
* - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
|
|
1120
|
+
* @param options - Input source; encoded frames must have matching dimensions.
|
|
1121
|
+
* @param workspacePath - Workspace receiving temporary input files.
|
|
1122
|
+
* @returns Frame nodes or a resolved video path for extraction.
|
|
1123
|
+
* @throws Propagates metadata or write errors and rejects mismatched frame sizes.
|
|
765
1124
|
*/
|
|
766
1125
|
async function resolveInput(options, workspacePath) {
|
|
767
1126
|
if (options.mode === "file") return {
|
|
@@ -772,12 +1131,51 @@ async function resolveInput(options, workspacePath) {
|
|
|
772
1131
|
frames: [],
|
|
773
1132
|
resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
|
|
774
1133
|
};
|
|
775
|
-
if (options.mode === "frames")
|
|
1134
|
+
if (options.mode === "frames") {
|
|
1135
|
+
let dimensions;
|
|
1136
|
+
for (const buffer of options.inputFrames) {
|
|
1137
|
+
const { width, height } = await sharp(buffer).metadata();
|
|
1138
|
+
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}`);
|
|
1139
|
+
dimensions = {
|
|
1140
|
+
width,
|
|
1141
|
+
height
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
|
|
1145
|
+
}
|
|
776
1146
|
throw new Error(`Unsupported input mode: ${options.mode}`);
|
|
777
1147
|
}
|
|
1148
|
+
var init_input_resolver$1 = __esmMin((() => {
|
|
1149
|
+
init_pipeline_defaults();
|
|
1150
|
+
init_paths();
|
|
1151
|
+
init_validate_options();
|
|
1152
|
+
init_workspace();
|
|
1153
|
+
}));
|
|
778
1154
|
|
|
779
1155
|
//#endregion
|
|
780
|
-
//#region src/
|
|
1156
|
+
//#region src/core/input-resolver/index.ts
|
|
1157
|
+
var init_input_resolver = __esmMin((() => {
|
|
1158
|
+
init_input_resolver$1();
|
|
1159
|
+
}));
|
|
1160
|
+
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/core/pruner/scoring/normalize-scores.ts
|
|
1163
|
+
/**
|
|
1164
|
+
* Find the first position whose score is at least the requested value.
|
|
1165
|
+
* @param sorted - Finite positive scores sorted in ascending order.
|
|
1166
|
+
* @param value - A finite positive score present in sorted.
|
|
1167
|
+
* @returns The first matching rank, including the first position of any tie.
|
|
1168
|
+
*/
|
|
1169
|
+
function lowerBound(sorted, value) {
|
|
1170
|
+
let low = 0;
|
|
1171
|
+
let high = sorted.length;
|
|
1172
|
+
while (low < high) {
|
|
1173
|
+
const mid = Math.floor((low + high) / 2);
|
|
1174
|
+
if (sorted[mid] < value) low = mid + 1;
|
|
1175
|
+
else high = mid;
|
|
1176
|
+
}
|
|
1177
|
+
return low;
|
|
1178
|
+
}
|
|
781
1179
|
/**
|
|
782
1180
|
* Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
|
|
783
1181
|
*
|
|
@@ -822,64 +1220,67 @@ function normalizeScores(items) {
|
|
|
822
1220
|
});
|
|
823
1221
|
const cdf = map(safeScores, (s) => {
|
|
824
1222
|
if (s <= 0) return 0;
|
|
825
|
-
return sorted
|
|
1223
|
+
return lowerBound(sorted, s) / sorted.length;
|
|
826
1224
|
});
|
|
827
1225
|
return map(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
|
|
828
1226
|
}
|
|
1227
|
+
var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
|
|
1228
|
+
var init_normalize_scores = __esmMin((() => {
|
|
1229
|
+
NORMALIZATION_ALPHA = .4;
|
|
1230
|
+
NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
1231
|
+
NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
1232
|
+
}));
|
|
829
1233
|
|
|
830
1234
|
//#endregion
|
|
831
|
-
//#region src/
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
var MinHeap = class {
|
|
839
|
-
h = [];
|
|
840
|
-
get size() {
|
|
841
|
-
return this.h.length;
|
|
842
|
-
}
|
|
843
|
-
push(entry) {
|
|
844
|
-
this.h.push(entry);
|
|
845
|
-
this.siftUp(this.h.length - 1);
|
|
846
|
-
}
|
|
847
|
-
pop() {
|
|
848
|
-
const n = this.h.length;
|
|
849
|
-
if (n === 0) return void 0;
|
|
850
|
-
const top = this.h[0];
|
|
851
|
-
const last = this.h.pop();
|
|
852
|
-
if (n > 1) {
|
|
853
|
-
this.h[0] = last;
|
|
854
|
-
this.siftDown(0);
|
|
1235
|
+
//#region src/core/pruner/heap/min-heap.ts
|
|
1236
|
+
var MinHeap;
|
|
1237
|
+
var init_min_heap = __esmMin((() => {
|
|
1238
|
+
MinHeap = class {
|
|
1239
|
+
h = [];
|
|
1240
|
+
get size() {
|
|
1241
|
+
return this.h.length;
|
|
855
1242
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
while (i > 0) {
|
|
860
|
-
const p = i - 1 >> 1;
|
|
861
|
-
if (this.h[p].score <= this.h[i].score) break;
|
|
862
|
-
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
863
|
-
i = p;
|
|
1243
|
+
push(entry) {
|
|
1244
|
+
this.h.push(entry);
|
|
1245
|
+
this.siftUp(this.h.length - 1);
|
|
864
1246
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
876
|
-
i = m;
|
|
1247
|
+
pop() {
|
|
1248
|
+
const n = this.h.length;
|
|
1249
|
+
if (n === 0) return void 0;
|
|
1250
|
+
const top = this.h[0];
|
|
1251
|
+
const last = this.h.pop();
|
|
1252
|
+
if (n > 1) {
|
|
1253
|
+
this.h[0] = last;
|
|
1254
|
+
this.siftDown(0);
|
|
1255
|
+
}
|
|
1256
|
+
return top;
|
|
877
1257
|
}
|
|
878
|
-
|
|
879
|
-
|
|
1258
|
+
siftUp(i) {
|
|
1259
|
+
while (i > 0) {
|
|
1260
|
+
const p = i - 1 >> 1;
|
|
1261
|
+
if (this.h[p].score <= this.h[i].score) break;
|
|
1262
|
+
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
1263
|
+
i = p;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
siftDown(i) {
|
|
1267
|
+
const n = this.h.length;
|
|
1268
|
+
for (;;) {
|
|
1269
|
+
let m = i;
|
|
1270
|
+
const l = 2 * i + 1;
|
|
1271
|
+
const r = 2 * i + 2;
|
|
1272
|
+
if (l < n && this.h[l].score < this.h[m].score) m = l;
|
|
1273
|
+
if (r < n && this.h[r].score < this.h[m].score) m = r;
|
|
1274
|
+
if (m === i) break;
|
|
1275
|
+
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
1276
|
+
i = m;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
}));
|
|
880
1281
|
|
|
881
1282
|
//#endregion
|
|
882
|
-
//#region src/core/pruner.ts
|
|
1283
|
+
//#region src/core/pruner/pruner.ts
|
|
883
1284
|
/**
|
|
884
1285
|
* Edge-aware greedy merge with re-linking — O(N log N).
|
|
885
1286
|
*
|
|
@@ -993,7 +1394,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
|
993
1394
|
return result;
|
|
994
1395
|
}
|
|
995
1396
|
/**
|
|
996
|
-
* Threshold-based pruning with NMS -- O(N).
|
|
1397
|
+
* Threshold-based pruning with NMS -- including normalization, O(N log N).
|
|
997
1398
|
*
|
|
998
1399
|
* 1. Scores are normalized to [0, 1] via percentile normalization.
|
|
999
1400
|
* 2. Edges with normalized score >= threshold are collected.
|
|
@@ -1056,9 +1457,19 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
1056
1457
|
}
|
|
1057
1458
|
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1058
1459
|
}
|
|
1460
|
+
var init_pruner$1 = __esmMin((() => {
|
|
1461
|
+
init_normalize_scores();
|
|
1462
|
+
init_min_heap();
|
|
1463
|
+
}));
|
|
1059
1464
|
|
|
1060
1465
|
//#endregion
|
|
1061
|
-
//#region src/
|
|
1466
|
+
//#region src/core/pruner/index.ts
|
|
1467
|
+
var init_pruner = __esmMin((() => {
|
|
1468
|
+
init_pruner$1();
|
|
1469
|
+
}));
|
|
1470
|
+
|
|
1471
|
+
//#endregion
|
|
1472
|
+
//#region src/core/segmenter/scheduling/concurrency.ts
|
|
1062
1473
|
/**
|
|
1063
1474
|
* Creates a concurrency limiter that runs at most `limit` tasks in parallel.
|
|
1064
1475
|
* Lightweight replacement for p-limit to avoid external dependency.
|
|
@@ -1078,9 +1489,10 @@ function concurrencyLimit(limit) {
|
|
|
1078
1489
|
}
|
|
1079
1490
|
};
|
|
1080
1491
|
}
|
|
1492
|
+
var init_concurrency = __esmMin((() => {}));
|
|
1081
1493
|
|
|
1082
1494
|
//#endregion
|
|
1083
|
-
//#region src/core/segmenter.ts
|
|
1495
|
+
//#region src/core/segmenter/segmenter.ts
|
|
1084
1496
|
/**
|
|
1085
1497
|
* Determine whether segmentation should be used.
|
|
1086
1498
|
* Returns false for frames mode and GIF files.
|
|
@@ -1094,53 +1506,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
|
|
|
1094
1506
|
return true;
|
|
1095
1507
|
}
|
|
1096
1508
|
/**
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1509
|
+
* Partition the global extraction grid into nonempty logical segments.
|
|
1510
|
+
* @param totalDuration Positive source duration in seconds.
|
|
1511
|
+
* @param maxSegmentDuration Positive logical segment width in seconds.
|
|
1512
|
+
* @param maxFrames Candidate budget, defensively raised to at least two.
|
|
1513
|
+
* @param fps Positive requested sampling frequency.
|
|
1514
|
+
* @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
|
|
1103
1515
|
*/
|
|
1104
1516
|
function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
|
|
1105
|
-
const
|
|
1517
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
1518
|
+
const effectiveFps = Math.min(fps, frameLimit / totalDuration);
|
|
1106
1519
|
if (totalDuration <= maxSegmentDuration) return [{
|
|
1107
1520
|
index: 0,
|
|
1108
1521
|
startTime: 0,
|
|
1109
1522
|
endTime: totalDuration,
|
|
1110
1523
|
duration: totalDuration,
|
|
1111
|
-
allocatedFrames:
|
|
1524
|
+
allocatedFrames: frameLimit,
|
|
1112
1525
|
effectiveFps,
|
|
1113
1526
|
overlapBefore: 0,
|
|
1114
1527
|
overlapAfter: 0,
|
|
1115
1528
|
extractStartTime: 0,
|
|
1116
1529
|
extractDuration: totalDuration
|
|
1117
1530
|
}];
|
|
1118
|
-
const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
|
|
1119
|
-
const overlapTime = 1 / effectiveFps;
|
|
1120
1531
|
const segments = [];
|
|
1121
|
-
for (let
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
const
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1532
|
+
for (let slot = 0; slot < frameLimit; slot++) {
|
|
1533
|
+
const timestamp = slot / effectiveFps;
|
|
1534
|
+
if (timestamp >= totalDuration) break;
|
|
1535
|
+
const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
|
|
1536
|
+
const previous = segments[segments.length - 1];
|
|
1537
|
+
if (previous?.startTime === startTime) {
|
|
1538
|
+
previous.allocatedFrames++;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
|
|
1129
1542
|
segments.push({
|
|
1130
|
-
index:
|
|
1543
|
+
index: segments.length,
|
|
1131
1544
|
startTime,
|
|
1132
1545
|
endTime,
|
|
1133
|
-
duration,
|
|
1134
|
-
allocatedFrames:
|
|
1546
|
+
duration: endTime - startTime,
|
|
1547
|
+
allocatedFrames: 1,
|
|
1135
1548
|
effectiveFps,
|
|
1136
|
-
overlapBefore,
|
|
1137
|
-
overlapAfter,
|
|
1138
|
-
extractStartTime,
|
|
1139
|
-
extractDuration
|
|
1549
|
+
overlapBefore: 0,
|
|
1550
|
+
overlapAfter: 0,
|
|
1551
|
+
extractStartTime: timestamp,
|
|
1552
|
+
extractDuration: 0
|
|
1140
1553
|
});
|
|
1141
1554
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1555
|
+
let firstSlot = 0;
|
|
1556
|
+
for (const segment of segments) {
|
|
1557
|
+
const nextSlot = firstSlot + segment.allocatedFrames;
|
|
1558
|
+
segment.overlapBefore = segment.index > 0 ? 1 : 0;
|
|
1559
|
+
segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
|
|
1560
|
+
segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
|
|
1561
|
+
segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
|
|
1562
|
+
segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
|
|
1563
|
+
firstSlot = nextSlot;
|
|
1564
|
+
}
|
|
1144
1565
|
return segments;
|
|
1145
1566
|
}
|
|
1146
1567
|
/**
|
|
@@ -1159,44 +1580,58 @@ function collectAllFrames(segmentResults) {
|
|
|
1159
1580
|
return allFrames;
|
|
1160
1581
|
}
|
|
1161
1582
|
/**
|
|
1162
|
-
* Sort frames
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1583
|
+
* Sort frames in place and alias overlap duplicates to the first survivor.
|
|
1584
|
+
* @param frames Collected entries whose segment index and local ID identify a frame.
|
|
1585
|
+
* @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
|
|
1586
|
+
* @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
|
|
1165
1587
|
*/
|
|
1166
1588
|
function deduplicateFrames(frames, effectiveFps) {
|
|
1167
1589
|
frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
|
|
1168
1590
|
const dupThreshold = 1 / (effectiveFps * 2);
|
|
1169
1591
|
const unique = [];
|
|
1592
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1170
1593
|
for (const entry of frames) {
|
|
1171
1594
|
if (unique.length > 0) {
|
|
1172
1595
|
const last = unique[unique.length - 1];
|
|
1173
|
-
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold)
|
|
1596
|
+
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
|
|
1597
|
+
aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1174
1600
|
}
|
|
1175
1601
|
unique.push(entry);
|
|
1176
1602
|
}
|
|
1177
|
-
return
|
|
1603
|
+
return {
|
|
1604
|
+
unique,
|
|
1605
|
+
aliases
|
|
1606
|
+
};
|
|
1178
1607
|
}
|
|
1179
1608
|
/**
|
|
1180
|
-
* Assign sequential global IDs
|
|
1181
|
-
*
|
|
1609
|
+
* Assign sequential global IDs and retain duplicate local IDs as aliases.
|
|
1610
|
+
* @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
|
|
1611
|
+
* @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
|
|
1612
|
+
* @returns Remapped frames and a global ID lookup covering survivors and duplicates.
|
|
1182
1613
|
*/
|
|
1183
|
-
function remapFrameIds(uniqueFrames) {
|
|
1614
|
+
function remapFrameIds(uniqueFrames, aliases) {
|
|
1184
1615
|
const globalIdMap = /* @__PURE__ */ new Map();
|
|
1616
|
+
const frames = uniqueFrames.map((entry, globalId) => {
|
|
1617
|
+
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1618
|
+
return {
|
|
1619
|
+
id: globalId,
|
|
1620
|
+
timestamp: entry.frame.timestamp,
|
|
1621
|
+
extractPath: entry.frame.extractPath
|
|
1622
|
+
};
|
|
1623
|
+
});
|
|
1624
|
+
for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
|
|
1185
1625
|
return {
|
|
1186
|
-
frames
|
|
1187
|
-
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1188
|
-
return {
|
|
1189
|
-
id: globalId,
|
|
1190
|
-
timestamp: entry.frame.timestamp,
|
|
1191
|
-
extractPath: entry.frame.extractPath
|
|
1192
|
-
};
|
|
1193
|
-
}),
|
|
1626
|
+
frames,
|
|
1194
1627
|
globalIdMap
|
|
1195
1628
|
};
|
|
1196
1629
|
}
|
|
1197
1630
|
/**
|
|
1198
|
-
* Remap
|
|
1199
|
-
*
|
|
1631
|
+
* Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
|
|
1632
|
+
* @param segmentResults Segment-local edges in encounter order.
|
|
1633
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
1634
|
+
* @returns One edge per surviving directed pair without changing its score.
|
|
1200
1635
|
*/
|
|
1201
1636
|
function remapEdges(segmentResults, globalIdMap) {
|
|
1202
1637
|
const edges = [];
|
|
@@ -1205,6 +1640,7 @@ function remapEdges(segmentResults, globalIdMap) {
|
|
|
1205
1640
|
const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
|
|
1206
1641
|
const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
|
|
1207
1642
|
if (newSourceId === void 0 || newTargetId === void 0) continue;
|
|
1643
|
+
if (newSourceId === newTargetId) continue;
|
|
1208
1644
|
const edgeKey = `${newSourceId}-${newTargetId}`;
|
|
1209
1645
|
const existingIdx = edgeMap.get(edgeKey);
|
|
1210
1646
|
if (existingIdx !== void 0) {
|
|
@@ -1225,42 +1661,52 @@ function remapEdges(segmentResults, globalIdMap) {
|
|
|
1225
1661
|
return edges;
|
|
1226
1662
|
}
|
|
1227
1663
|
/**
|
|
1228
|
-
* Remap
|
|
1229
|
-
*
|
|
1664
|
+
* Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
|
|
1665
|
+
* @param segmentResults Segment-local tracker entries in encounter order.
|
|
1666
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
1667
|
+
* @returns One animation per directed pair with its original tracker duration and metadata.
|
|
1230
1668
|
*/
|
|
1231
1669
|
function remapAnimations(segmentResults, globalIdMap) {
|
|
1232
|
-
const animations =
|
|
1670
|
+
const animations = /* @__PURE__ */ new Map();
|
|
1233
1671
|
for (const result of segmentResults) for (const anim of result.animations) {
|
|
1234
1672
|
const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
|
|
1235
1673
|
const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
|
|
1236
1674
|
if (newStartId === void 0 || newEndId === void 0) continue;
|
|
1237
|
-
|
|
1675
|
+
if (newStartId === newEndId) continue;
|
|
1676
|
+
const animationKey = `${newStartId}-${newEndId}`;
|
|
1677
|
+
if (animations.has(animationKey)) continue;
|
|
1678
|
+
animations.set(animationKey, {
|
|
1238
1679
|
...anim,
|
|
1239
1680
|
startFrameId: newStartId,
|
|
1240
1681
|
endFrameId: newEndId
|
|
1241
1682
|
});
|
|
1242
1683
|
}
|
|
1243
|
-
return animations;
|
|
1684
|
+
return [...animations.values()];
|
|
1244
1685
|
}
|
|
1245
1686
|
/**
|
|
1246
1687
|
* Merge multiple segment results into a single unified frame/edge/animation set.
|
|
1247
|
-
*
|
|
1248
|
-
* -
|
|
1249
|
-
*
|
|
1250
|
-
* - Duplicate edges keep higher score
|
|
1688
|
+
* @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
|
|
1689
|
+
* @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
|
|
1690
|
+
* Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
|
|
1251
1691
|
*/
|
|
1252
1692
|
function mergeSegmentFrames(segmentResults) {
|
|
1253
1693
|
if (segmentResults.length === 0) return {
|
|
1254
1694
|
frames: [],
|
|
1255
1695
|
edges: [],
|
|
1256
|
-
animations: []
|
|
1696
|
+
animations: [],
|
|
1697
|
+
analysisResolution: {
|
|
1698
|
+
width: 0,
|
|
1699
|
+
height: 0
|
|
1700
|
+
}
|
|
1257
1701
|
};
|
|
1258
1702
|
const effectiveFps = segmentResults[0].segment.effectiveFps;
|
|
1259
|
-
const {
|
|
1703
|
+
const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
|
|
1704
|
+
const { frames, globalIdMap } = remapFrameIds(unique, aliases);
|
|
1260
1705
|
return {
|
|
1261
1706
|
frames,
|
|
1262
1707
|
edges: remapEdges(segmentResults, globalIdMap),
|
|
1263
|
-
animations: remapAnimations(segmentResults, globalIdMap)
|
|
1708
|
+
animations: remapAnimations(segmentResults, globalIdMap),
|
|
1709
|
+
analysisResolution: segmentResults[0].analysisResolution
|
|
1264
1710
|
};
|
|
1265
1711
|
}
|
|
1266
1712
|
function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
|
|
@@ -1271,6 +1717,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1271
1717
|
maxFrames: segment.allocatedFrames
|
|
1272
1718
|
},
|
|
1273
1719
|
workspacePath: segmentWorkspacePath,
|
|
1720
|
+
effectiveFps: segment.effectiveFps,
|
|
1274
1721
|
frames,
|
|
1275
1722
|
graph: [],
|
|
1276
1723
|
status: "ANALYZING",
|
|
@@ -1282,19 +1729,24 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1282
1729
|
* Each segment uses an isolated workspace directory.
|
|
1283
1730
|
*/
|
|
1284
1731
|
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1285
|
-
const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration);
|
|
1732
|
+
const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
|
|
1286
1733
|
if (frames.length < 2) return {
|
|
1287
1734
|
segment,
|
|
1288
1735
|
frames,
|
|
1289
1736
|
edges: [],
|
|
1290
|
-
animations: []
|
|
1737
|
+
animations: [],
|
|
1738
|
+
analysisResolution: {
|
|
1739
|
+
width: 0,
|
|
1740
|
+
height: 0
|
|
1741
|
+
}
|
|
1291
1742
|
};
|
|
1292
|
-
const { edges, animations } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
|
|
1743
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
|
|
1293
1744
|
return {
|
|
1294
1745
|
segment,
|
|
1295
1746
|
frames,
|
|
1296
1747
|
edges,
|
|
1297
|
-
animations
|
|
1748
|
+
animations,
|
|
1749
|
+
analysisResolution
|
|
1298
1750
|
};
|
|
1299
1751
|
}
|
|
1300
1752
|
/**
|
|
@@ -1334,7 +1786,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1334
1786
|
});
|
|
1335
1787
|
})));
|
|
1336
1788
|
options.onProgress?.("ANALYZING", 100);
|
|
1337
|
-
const { frames, edges, animations } = mergeSegmentFrames(results);
|
|
1789
|
+
const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
|
|
1338
1790
|
logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
|
|
1339
1791
|
options.onProgress?.("PRUNING", 0);
|
|
1340
1792
|
const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
@@ -1343,6 +1795,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1343
1795
|
options.onProgress?.("FINALIZING", 0);
|
|
1344
1796
|
const ctx = {
|
|
1345
1797
|
options: resolvedOptions,
|
|
1798
|
+
effectiveFps: segments[0]?.effectiveFps,
|
|
1799
|
+
sourceDurationSec: totalDuration,
|
|
1800
|
+
analysisResolution,
|
|
1346
1801
|
workspacePath: mainWorkspace,
|
|
1347
1802
|
frames,
|
|
1348
1803
|
graph: edges,
|
|
@@ -1356,21 +1811,14 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1356
1811
|
else outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1357
1812
|
options.onProgress?.("FINALIZING", 100);
|
|
1358
1813
|
logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
|
|
1814
|
+
const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
|
|
1359
1815
|
return {
|
|
1360
1816
|
success: true,
|
|
1361
1817
|
originalFramesCount: frames.length,
|
|
1362
1818
|
prunedFramesCount: prunedFrames.length,
|
|
1363
1819
|
outputFiles,
|
|
1364
1820
|
outputBuffers,
|
|
1365
|
-
|
|
1366
|
-
video: {
|
|
1367
|
-
originalDurationMs: totalDuration * 1e3,
|
|
1368
|
-
fps: resolvedOptions.fps,
|
|
1369
|
-
resolution: {
|
|
1370
|
-
width: resolvedOptions.scale,
|
|
1371
|
-
height: Math.round(resolvedOptions.scale * 9 / 16)
|
|
1372
|
-
}
|
|
1373
|
-
},
|
|
1821
|
+
...outputMetadata,
|
|
1374
1822
|
executionTimeMs: Date.now() - pipelineStart
|
|
1375
1823
|
};
|
|
1376
1824
|
} catch (error) {
|
|
@@ -1382,11 +1830,27 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1382
1830
|
else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
|
|
1383
1831
|
}
|
|
1384
1832
|
}
|
|
1833
|
+
var init_segmenter$1 = __esmMin((() => {
|
|
1834
|
+
init_concurrency();
|
|
1835
|
+
init_logger();
|
|
1836
|
+
init_analyzer();
|
|
1837
|
+
init_build_video_metadata();
|
|
1838
|
+
init_extractor();
|
|
1839
|
+
init_input_resolver();
|
|
1840
|
+
init_pruner();
|
|
1841
|
+
init_workspace();
|
|
1842
|
+
}));
|
|
1843
|
+
|
|
1844
|
+
//#endregion
|
|
1845
|
+
//#region src/core/segmenter/index.ts
|
|
1846
|
+
var init_segmenter = __esmMin((() => {
|
|
1847
|
+
init_segmenter$1();
|
|
1848
|
+
}));
|
|
1385
1849
|
|
|
1386
1850
|
//#endregion
|
|
1387
|
-
//#region src/core/orchestrator.ts
|
|
1851
|
+
//#region src/core/orchestrator/orchestrator.ts
|
|
1388
1852
|
async function runPipeline(options) {
|
|
1389
|
-
|
|
1853
|
+
setDebugMode(options.debug ?? false);
|
|
1390
1854
|
const resolvedOptions = resolveOptions(options);
|
|
1391
1855
|
if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
|
|
1392
1856
|
const startTime = Date.now();
|
|
@@ -1406,19 +1870,27 @@ async function runPipeline(options) {
|
|
|
1406
1870
|
logger.debug(`Workspace created: ${ctx.workspacePath}`);
|
|
1407
1871
|
ctx.status = "EXTRACTING";
|
|
1408
1872
|
const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
|
|
1409
|
-
if (resolvedOptions.mode === "frames")
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1873
|
+
if (resolvedOptions.mode === "frames") {
|
|
1874
|
+
ctx.frames = resolvedFrames;
|
|
1875
|
+
ctx.effectiveFps = 1;
|
|
1876
|
+
} else {
|
|
1877
|
+
const extractCtx = {
|
|
1878
|
+
...ctx,
|
|
1879
|
+
options: {
|
|
1880
|
+
...resolvedOptions,
|
|
1881
|
+
inputPath: resolvedInputPath
|
|
1882
|
+
}
|
|
1883
|
+
};
|
|
1884
|
+
ctx.frames = await extractFrames(extractCtx);
|
|
1885
|
+
ctx.effectiveFps = extractCtx.effectiveFps;
|
|
1886
|
+
ctx.sourceDurationSec = extractCtx.sourceDurationSec;
|
|
1887
|
+
}
|
|
1417
1888
|
ctx.emitProgress(100);
|
|
1418
1889
|
ctx.status = "ANALYZING";
|
|
1419
|
-
const { edges, animations } = await analyzeFrames(ctx);
|
|
1890
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1420
1891
|
ctx.graph = edges;
|
|
1421
1892
|
ctx.animations = animations;
|
|
1893
|
+
ctx.analysisResolution = analysisResolution;
|
|
1422
1894
|
ctx.status = "PRUNING";
|
|
1423
1895
|
const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
1424
1896
|
const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
|
|
@@ -1435,21 +1907,14 @@ async function runPipeline(options) {
|
|
|
1435
1907
|
}
|
|
1436
1908
|
ctx.status = "SUCCESS";
|
|
1437
1909
|
logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
|
|
1910
|
+
const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
|
|
1438
1911
|
return {
|
|
1439
1912
|
success: true,
|
|
1440
1913
|
originalFramesCount: ctx.frames.length,
|
|
1441
1914
|
prunedFramesCount: prunedFrames.length,
|
|
1442
1915
|
outputFiles,
|
|
1443
1916
|
outputBuffers,
|
|
1444
|
-
|
|
1445
|
-
video: {
|
|
1446
|
-
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1447
|
-
fps: ctx.options.fps,
|
|
1448
|
-
resolution: {
|
|
1449
|
-
width: ctx.options.scale,
|
|
1450
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1451
|
-
}
|
|
1452
|
-
},
|
|
1917
|
+
...metadata,
|
|
1453
1918
|
executionTimeMs: Date.now() - startTime
|
|
1454
1919
|
};
|
|
1455
1920
|
} catch (error) {
|
|
@@ -1462,6 +1927,20 @@ async function runPipeline(options) {
|
|
|
1462
1927
|
else logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
|
|
1463
1928
|
}
|
|
1464
1929
|
}
|
|
1930
|
+
var init_orchestrator = __esmMin((() => {
|
|
1931
|
+
init_logger();
|
|
1932
|
+
init_analyzer();
|
|
1933
|
+
init_build_video_metadata();
|
|
1934
|
+
init_extractor();
|
|
1935
|
+
init_input_resolver();
|
|
1936
|
+
init_pruner();
|
|
1937
|
+
init_segmenter();
|
|
1938
|
+
init_workspace();
|
|
1939
|
+
}));
|
|
1940
|
+
|
|
1941
|
+
//#endregion
|
|
1942
|
+
//#region src/index.ts
|
|
1943
|
+
init_orchestrator();
|
|
1465
1944
|
|
|
1466
1945
|
//#endregion
|
|
1467
1946
|
export { runPipeline as extractScenes };
|