@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.
Files changed (46) hide show
  1. package/README.md +47 -24
  2. package/dist/{errors.d.ts → cli/errors/classify-error.d.ts} +5 -0
  3. package/dist/cli/index.d.ts +3 -0
  4. package/dist/{utils → cli/options}/parse-options.d.ts +6 -1
  5. package/dist/cli.mjs +755 -319
  6. package/dist/constants/pipeline-defaults.d.ts +13 -0
  7. package/dist/core/{analyzer.d.ts → analyzer/analyzer.d.ts} +11 -6
  8. package/dist/core/{dbscan.d.ts → analyzer/clustering/dbscan.d.ts} +1 -1
  9. package/dist/core/analyzer/constants/vision-tuning.d.ts +12 -0
  10. package/dist/core/analyzer/features/feature-diff.d.ts +14 -0
  11. package/dist/core/analyzer/features/frame-features.d.ts +32 -0
  12. package/dist/core/analyzer/index.d.ts +3 -0
  13. package/dist/core/constants/workspace-layout.d.ts +9 -0
  14. package/dist/core/{extractor.d.ts → extractor/extractor.d.ts} +6 -4
  15. package/dist/core/extractor/index.d.ts +1 -0
  16. package/dist/core/index.d.ts +8 -9
  17. package/dist/core/input-resolver/index.d.ts +1 -0
  18. package/dist/core/{input-resolver.d.ts → input-resolver/input-resolver.d.ts} +11 -1
  19. package/dist/core/input-resolver/validation/validate-options.d.ts +8 -0
  20. package/dist/core/orchestrator/index.d.ts +3 -0
  21. package/dist/core/{orchestrator.d.ts → orchestrator/orchestrator.d.ts} +1 -1
  22. package/dist/core/{run-in-worker.d.ts → orchestrator/worker/run-in-worker.d.ts} +5 -1
  23. package/dist/core/pruner/index.d.ts +1 -0
  24. package/dist/core/{pruner.d.ts → pruner/pruner.d.ts} +2 -2
  25. package/dist/{utils/math.d.ts → core/pruner/scoring/normalize-scores.d.ts} +4 -0
  26. package/dist/core/segmenter/index.d.ts +1 -0
  27. package/dist/core/{segmenter.d.ts → segmenter/segmenter.d.ts} +11 -11
  28. package/dist/core/utils/metadata/build-video-metadata.d.ts +14 -0
  29. package/dist/core/workspace/index.d.ts +1 -0
  30. package/dist/core/{workspace.d.ts → workspace/workspace.d.ts} +1 -1
  31. package/dist/index.cjs +910 -434
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.mjs +913 -434
  34. package/dist/pipeline-worker.mjs +635 -278
  35. package/dist/types/index.d.ts +25 -0
  36. package/package.json +1 -1
  37. package/dist/constants.d.ts +0 -32
  38. /package/dist/{commands → cli/commands}/Sieve.d.ts +0 -0
  39. /package/dist/{utils → cli/commands}/command-registry.d.ts +0 -0
  40. /package/dist/{components → cli/components}/PhaseStep.d.ts +0 -0
  41. /package/dist/{components → cli/components}/ProgressBar.d.ts +0 -0
  42. /package/dist/core/{pipeline-worker.d.ts → orchestrator/worker/pipeline-worker.d.ts} +0 -0
  43. /package/dist/{utils → core/pruner/heap}/min-heap.d.ts +0 -0
  44. /package/dist/{utils → core/segmenter/scheduling}/concurrency.d.ts +0 -0
  45. /package/dist/{utils → core/utils/filesystem}/paths.d.ts +0 -0
  46. /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++) {
@@ -33,66 +41,66 @@ 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 node_os = require("node:os");
37
- let node_path = require("node:path");
38
44
  let node_fs_promises = require("node:fs/promises");
45
+ let node_path = require("node:path");
39
46
  let _ffprobe_installer_ffprobe = require("@ffprobe-installer/ffprobe");
40
47
  let execa = require("execa");
41
48
  let ffmpeg_static = require("ffmpeg-static");
42
49
  ffmpeg_static = __toESM(ffmpeg_static, 1);
50
+ let node_os = require("node:os");
43
51
 
44
- //#region src/utils/logger.ts
45
- let debugMode = false;
46
- let jsonMode = false;
52
+ //#region src/logging/logger.ts
47
53
  function setDebugMode(enabled) {
48
54
  debugMode = enabled;
49
55
  }
50
56
  function timestamp() {
51
57
  return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
52
58
  }
53
- const logger = {
54
- info(message) {
55
- if (jsonMode) process.stderr.write(`${picocolors.default.blue("info")} ${message}\n`);
56
- else console.log(`${picocolors.default.blue("info")} ${message}`);
57
- },
58
- success(message) {
59
- if (jsonMode) process.stderr.write(`${picocolors.default.green("done")} ${message}\n`);
60
- else console.log(`\n${picocolors.default.green("done")} ${message}`);
61
- },
62
- warn(message) {
63
- console.warn(`${picocolors.default.yellow("warn")} ${message}`);
64
- },
65
- error(message) {
66
- console.error(`${picocolors.default.red("error")} ${message}`);
67
- },
68
- debug(message) {
69
- if (debugMode) if (jsonMode) process.stderr.write(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}\n`);
70
- else console.log(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}`);
71
- }
72
- };
59
+ var debugMode, jsonMode, logger;
60
+ var init_logger = __esmMin((() => {
61
+ debugMode = false;
62
+ jsonMode = false;
63
+ logger = {
64
+ info(message) {
65
+ if (jsonMode) process.stderr.write(`${picocolors.default.blue("info")} ${message}\n`);
66
+ else console.log(`${picocolors.default.blue("info")} ${message}`);
67
+ },
68
+ success(message) {
69
+ if (jsonMode) process.stderr.write(`${picocolors.default.green("done")} ${message}\n`);
70
+ else console.log(`\n${picocolors.default.green("done")} ${message}`);
71
+ },
72
+ warn(message) {
73
+ console.warn(`${picocolors.default.yellow("warn")} ${message}`);
74
+ },
75
+ error(message) {
76
+ console.error(`${picocolors.default.red("error")} ${message}`);
77
+ },
78
+ debug(message) {
79
+ if (debugMode) if (jsonMode) process.stderr.write(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}\n`);
80
+ else console.log(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}`);
81
+ }
82
+ };
83
+ }));
73
84
 
74
85
  //#endregion
75
- //#region src/constants.ts
76
- const APP_NAME = "scene-sieve";
77
- const DEFAULT_THRESHOLD = .5;
78
- const NORMALIZATION_ALPHA = .4;
79
- const NORMALIZATION_MAD_COEFFICIENT = 1.4826;
80
- const WORKSPACE_PREFIX = `${APP_NAME}-`;
81
- const TEMP_BASE_DIR = (0, node_os.tmpdir)();
82
- const FRAME_OUTPUT_EXTENSION = ".jpg";
83
- const FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
84
- const DBSCAN_ALPHA = .03;
85
- const IOU_THRESHOLD = .9;
86
- const DECAY_LAMBDA = .95;
87
- const MATCH_DISTANCE_THRESHOLD = .25;
88
- function getTempWorkspaceDir(sessionId) {
89
- return (0, node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
90
- }
86
+ //#region src/constants/pipeline-defaults.ts
87
+ var DEFAULT_THRESHOLD, IOU_THRESHOLD;
88
+ var init_pipeline_defaults = __esmMin((() => {
89
+ DEFAULT_THRESHOLD = .5;
90
+ IOU_THRESHOLD = .9;
91
+ }));
91
92
 
92
93
  //#endregion
93
- //#region src/core/dbscan.ts
94
- const UNVISITED = -2;
95
- const NOISE = -1;
94
+ //#region src/core/analyzer/constants/vision-tuning.ts
95
+ var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
96
+ var init_vision_tuning = __esmMin((() => {
97
+ DBSCAN_ALPHA = .03;
98
+ DECAY_LAMBDA = .95;
99
+ MATCH_DISTANCE_THRESHOLD = .25;
100
+ }));
101
+
102
+ //#endregion
103
+ //#region src/core/analyzer/clustering/dbscan.ts
96
104
  /**
97
105
  * DBSCAN clustering with resolution-independent eps.
98
106
  * eps = alpha * sqrt(width^2 + height^2)
@@ -168,12 +176,118 @@ function findNeighbors(points, idx, epsSquared) {
168
176
  }
169
177
  return neighbors;
170
178
  }
179
+ var UNVISITED, NOISE;
180
+ var init_dbscan = __esmMin((() => {
181
+ init_vision_tuning();
182
+ UNVISITED = -2;
183
+ NOISE = -1;
184
+ }));
171
185
 
172
186
  //#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;
187
+ //#region src/core/analyzer/features/feature-diff.ts
188
+ /**
189
+ * Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
190
+ * @param cvLib - Initialized OpenCV runtime.
191
+ * @param prev - Previous frame's live features, owned by the caller.
192
+ * @param next - Next frame's live features, owned by the caller.
193
+ * @returns Unmatched next-frame coordinates without changing input ownership.
194
+ * @throws Propagates matching errors after releasing temporary native handles.
195
+ */
196
+ function computeNewPoints(cvLib, prev, next) {
197
+ let matcher = null;
198
+ let matches = null;
199
+ try {
200
+ const matchedIndices = /* @__PURE__ */ new Set();
201
+ if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
202
+ matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
203
+ matches = new cvLib.DMatchVectorVector();
204
+ matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
205
+ for (let i = 0; i < matches.size(); i++) {
206
+ const pair = matches.get(i);
207
+ try {
208
+ if (pair.size() < 2) continue;
209
+ const best = pair.get(0);
210
+ const second = pair.get(1);
211
+ if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
212
+ } finally {
213
+ pair.delete();
214
+ }
215
+ }
216
+ } finally {
217
+ matcher?.delete();
218
+ }
219
+ const points = [];
220
+ for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
221
+ const { x, y } = next.keypoints.get(i).pt;
222
+ points.push({
223
+ x,
224
+ y
225
+ });
226
+ }
227
+ return points;
228
+ } finally {
229
+ matches?.delete();
230
+ }
231
+ }
232
+ var init_feature_diff = __esmMin((() => {
233
+ init_vision_tuning();
234
+ }));
235
+
236
+ //#endregion
237
+ //#region src/core/analyzer/features/frame-features.ts
238
+ /**
239
+ * Detect one frame's features without retaining its image or mask.
240
+ * @param cvLib - Initialized OpenCV runtime.
241
+ * @param akaze - Detector owned and released by the caller.
242
+ * @param frame - Grayscale bytes with matching width and height.
243
+ * @returns Feature handles that the caller must delete.
244
+ * @throws Propagates native errors after releasing partial allocations.
245
+ */
246
+ function computeFrameFeatures(cvLib, akaze, frame) {
247
+ let image = null;
248
+ let mask = null;
249
+ let keypoints = null;
250
+ let descriptors = null;
251
+ try {
252
+ image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
253
+ image.data.set(frame.data);
254
+ mask = new cvLib.Mat();
255
+ keypoints = new cvLib.KeyPointVector();
256
+ descriptors = new cvLib.Mat();
257
+ akaze.detectAndCompute(image, mask, keypoints, descriptors);
258
+ const ownedKeypoints = keypoints;
259
+ const ownedDescriptors = descriptors;
260
+ let deleted = false;
261
+ const features = {
262
+ width: frame.width,
263
+ height: frame.height,
264
+ keypoints: ownedKeypoints,
265
+ descriptors: ownedDescriptors,
266
+ /** Release the transferred handles exactly once. */
267
+ delete() {
268
+ if (deleted) return;
269
+ deleted = true;
270
+ try {
271
+ ownedKeypoints.delete();
272
+ } finally {
273
+ ownedDescriptors.delete();
274
+ }
275
+ }
276
+ };
277
+ keypoints = null;
278
+ descriptors = null;
279
+ return features;
280
+ } finally {
281
+ image?.delete();
282
+ mask?.delete();
283
+ keypoints?.delete();
284
+ descriptors?.delete();
285
+ }
286
+ }
287
+ var init_frame_features = __esmMin((() => {}));
288
+
289
+ //#endregion
290
+ //#region src/core/analyzer/analyzer.ts
177
291
  async function ensureOpenCV() {
178
292
  if (!cvReady) cvReady = (async () => {
179
293
  const cvObj = require$1("@techstark/opencv-js");
@@ -212,158 +326,6 @@ function computeIoU(a, b) {
212
326
  const union = a.width * a.height + b.width * b.height - intersection;
213
327
  return union === 0 ? 0 : intersection / union;
214
328
  }
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
329
  /**
368
330
  * Pixel-level difference fallback for AKAZE blind spots.
369
331
  *
@@ -378,17 +340,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
378
340
  * 3. threshold → binary mask of significant changes
379
341
  * 4. findContours → bounding rects of changed regions
380
342
  * 5. Grid sampling within each bounding rect → Point2D[]
343
+ *
344
+ * @param cvLib - Initialized OpenCV runtime shared by the analyzer.
345
+ * @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
346
+ * @param frame2 - Next grayscale frame, with the same dimensions as frame1.
347
+ * @returns Grid-sampled points from changed regions.
348
+ * @throws Propagates allocation or OpenCV errors after releasing acquired handles.
381
349
  */
382
350
  function computePixelDiff(cvLib, frame1, frame2) {
383
351
  const cv = cvLib;
384
- const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
385
- const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
386
- const diff = new cv.Mat();
387
- const blurred = new cv.Mat();
388
- const binary = new cv.Mat();
389
- const contours = new cv.MatVector();
390
- const hierarchy = new cv.Mat();
352
+ let mat1 = null;
353
+ let mat2 = null;
354
+ let diff = null;
355
+ let blurred = null;
356
+ let binary = null;
357
+ let contours = null;
358
+ let hierarchy = null;
391
359
  try {
360
+ mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
361
+ mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
362
+ diff = new cv.Mat();
363
+ blurred = new cv.Mat();
364
+ binary = new cv.Mat();
365
+ contours = new cv.MatVector();
366
+ hierarchy = new cv.Mat();
392
367
  mat1.data.set(frame1.data);
393
368
  mat2.data.set(frame2.data);
394
369
  cv.absdiff(mat1, mat2, diff);
@@ -399,22 +374,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
399
374
  const points = [];
400
375
  for (let c = 0; c < contours.size(); c++) {
401
376
  const contour = contours.get(c);
402
- const rect = cv.boundingRect(contour);
403
- if (rect.width * rect.height < 100) continue;
404
- 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({
405
- x,
406
- y
407
- });
377
+ try {
378
+ const rect = cv.boundingRect(contour);
379
+ if (rect.width * rect.height < 100) continue;
380
+ 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({
381
+ x,
382
+ y
383
+ });
384
+ } finally {
385
+ contour.delete();
386
+ }
408
387
  }
409
388
  return points;
410
389
  } finally {
411
- mat1.delete();
412
- mat2.delete();
413
- diff.delete();
414
- blurred.delete();
415
- binary.delete();
416
- contours.delete();
417
- hierarchy.delete();
390
+ mat1?.delete();
391
+ mat2?.delete();
392
+ diff?.delete();
393
+ blurred?.delete();
394
+ binary?.delete();
395
+ contours?.delete();
396
+ hierarchy?.delete();
418
397
  }
419
398
  }
420
399
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
@@ -433,47 +412,85 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
433
412
  }
434
413
  return gain;
435
414
  }
436
- async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
415
+ /**
416
+ * Analyze one batch, retaining its boundary frame for the following batch.
417
+ * @param cvLib - Initialized OpenCV runtime.
418
+ * @param akaze - Detector owned by analyzeFrames.
419
+ * @param frames - Boundary frame followed by new adjacent frames.
420
+ * @param carry - Boundary bytes and live features transferred from the previous batch.
421
+ * @param scale - Maximum preprocessing width.
422
+ * @param tracker - Stateful animation tracker shared across batches.
423
+ * @param pairOffset - Global position of this batch's first pair.
424
+ * @returns Scores, pair failure count, and ownership of the final frame's features.
425
+ */
426
+ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
437
427
  const edges = [];
438
- const preprocessed = await Promise.all((0, _winglet_common_utils.map)(frames, (f) => 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
- const { sNew } = await computeAKAZEDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
446
- let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
447
- let clusters = dbscanResult.boundingBoxes;
448
- if (clusters.length === 0) {
449
- const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
450
- if (pixelDiffPoints.length > 0) {
451
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
452
- dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
453
- clusters = dbscanResult.boundingBoxes;
428
+ let failures = 0;
429
+ let prev = carry?.features ?? null;
430
+ let next = null;
431
+ try {
432
+ const preprocessed = await Promise.all((0, _winglet_common_utils.map)(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
433
+ const imageWidth = preprocessed[0]?.width ?? scale;
434
+ const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
435
+ const imageArea = imageWidth * imageHeight;
436
+ for (let i = 0; i < frames.length - 1; i++) {
437
+ const pairIndex = pairOffset + i;
438
+ try {
439
+ prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
440
+ next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
441
+ let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
442
+ let clusters = dbscanResult.boundingBoxes;
443
+ if (clusters.length === 0) {
444
+ const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
445
+ if (pixelDiffPoints.length > 0) {
446
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
447
+ dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
448
+ clusters = dbscanResult.boundingBoxes;
449
+ }
454
450
  }
451
+ const clusterPointCounts = new Array(clusters.length).fill(0);
452
+ for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
453
+ const animationIndices = tracker.update(clusters, pairIndex);
454
+ const animationWeights = (0, _winglet_common_utils.map)(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
455
+ const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
456
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
457
+ edges.push({
458
+ sourceId: frames[i].id,
459
+ targetId: frames[i + 1].id,
460
+ score
461
+ });
462
+ } catch (err) {
463
+ logger.warn(`Frame pair analysis failed: ${String(err)}`);
464
+ failures++;
465
+ edges.push({
466
+ sourceId: frames[i].id,
467
+ targetId: frames[i + 1].id,
468
+ score: 0
469
+ });
470
+ } finally {
471
+ prev?.delete();
472
+ prev = next;
473
+ next = null;
455
474
  }
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
475
  }
476
+ const result = {
477
+ edges,
478
+ failures,
479
+ analysisResolution: {
480
+ width: imageWidth,
481
+ height: imageHeight
482
+ },
483
+ carry: prev ? {
484
+ preprocessed: preprocessed[preprocessed.length - 1],
485
+ features: prev
486
+ } : null
487
+ };
488
+ prev = null;
489
+ return result;
490
+ } finally {
491
+ prev?.delete();
492
+ next?.delete();
475
493
  }
476
- return edges;
477
494
  }
478
495
  /**
479
496
  * Analyze adjacent frame pairs to compute information gain scores (G(t)).
@@ -484,35 +501,222 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
484
501
  * 2. DBSCAN Spatial Clustering
485
502
  * 3. Spatio-temporal IoU Tracking
486
503
  * 4. G(t) Information Gain Scoring
504
+ * @param ctx - Frames, analysis options, and the progress callback for this run.
505
+ * @returns Adjacent scores and tracked animations in analysis coordinates.
506
+ * @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
487
507
  */
488
508
  async function analyzeFrames(ctx) {
489
509
  const { frames } = ctx;
490
510
  if (frames.length < 2) return {
491
511
  edges: [],
492
- animations: []
512
+ animations: [],
513
+ analysisResolution: {
514
+ width: 0,
515
+ height: 0
516
+ }
493
517
  };
494
518
  logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
495
519
  const cvLib = await ensureOpenCV();
496
520
  const edges = [];
497
- const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
521
+ const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
498
522
  const scale = ctx.options.scale;
499
- for (let i = 0; i < frames.length - 1; i += 10) {
500
- const batchEnd = Math.min(i + 10 + 1, frames.length);
501
- const batchEdges = await analyzeBatch(cvLib, frames.slice(i, batchEnd), scale, tracker, i);
502
- edges.push(...batchEdges);
503
- const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
504
- ctx.emitProgress(progress);
523
+ let akaze = null;
524
+ let carry = null;
525
+ let analysisResolution = {
526
+ width: 0,
527
+ height: 0
528
+ };
529
+ let failures = 0;
530
+ try {
531
+ akaze = new cvLib.AKAZE();
532
+ for (let i = 0; i < frames.length - 1; i += 10) {
533
+ const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
534
+ const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
535
+ carry = result.carry;
536
+ failures += result.failures;
537
+ if (i === 0) analysisResolution = result.analysisResolution;
538
+ edges.push(...result.edges);
539
+ const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
540
+ ctx.emitProgress(progress);
541
+ }
542
+ } finally {
543
+ carry?.features.delete();
544
+ akaze?.delete();
505
545
  }
546
+ const pairs = frames.length - 1;
547
+ if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
506
548
  const animations = tracker.flushAndGetAnimations();
507
549
  logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
508
550
  return {
509
551
  edges,
510
- animations
552
+ animations,
553
+ analysisResolution
511
554
  };
512
555
  }
556
+ var OPENCV_INIT_TIMEOUT_MS, require$1, cvReady, IoUTracker;
557
+ var init_analyzer$1 = __esmMin((() => {
558
+ init_pipeline_defaults();
559
+ init_vision_tuning();
560
+ init_logger();
561
+ init_dbscan();
562
+ init_feature_diff();
563
+ init_frame_features();
564
+ OPENCV_INIT_TIMEOUT_MS = 3e4;
565
+ require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
566
+ cvReady = null;
567
+ IoUTracker = class {
568
+ fps;
569
+ iouThreshold;
570
+ animationThreshold;
571
+ regions = [];
572
+ extractedAnimations = [];
573
+ constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
574
+ this.fps = fps;
575
+ this.iouThreshold = iouThreshold;
576
+ this.animationThreshold = animationThreshold;
577
+ }
578
+ update(boxes, pairIndex) {
579
+ const animationIndices = /* @__PURE__ */ new Set();
580
+ const matched = /* @__PURE__ */ new Set();
581
+ for (let bi = 0; bi < boxes.length; bi++) {
582
+ const box = boxes[bi];
583
+ let bestIoU = 0;
584
+ let bestRegionIdx = -1;
585
+ for (let ri = 0; ri < this.regions.length; ri++) {
586
+ if (matched.has(ri)) continue;
587
+ const iou = computeIoU(box, this.regions[ri].box);
588
+ if (iou > bestIoU) {
589
+ bestIoU = iou;
590
+ bestRegionIdx = ri;
591
+ }
592
+ }
593
+ if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
594
+ const region = this.regions[bestRegionIdx];
595
+ const gap = pairIndex - region.lastSeen;
596
+ region.box = box;
597
+ region.consecutiveCount++;
598
+ region.lastSeen = pairIndex;
599
+ region.weight *= Math.pow(DECAY_LAMBDA, gap);
600
+ matched.add(bestRegionIdx);
601
+ if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
602
+ } else this.regions.push({
603
+ box,
604
+ consecutiveCount: 1,
605
+ firstSeen: pairIndex,
606
+ lastSeen: pairIndex,
607
+ weight: 1
608
+ });
609
+ }
610
+ for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
611
+ const gap = pairIndex - this.regions[ri].lastSeen;
612
+ this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
613
+ }
614
+ for (let i = 0; i < this.regions.length; i++) {
615
+ const region = this.regions[i];
616
+ if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
617
+ }
618
+ this.regions = (0, _winglet_common_utils.filter)(this.regions, (r, i) => r.weight > .01 || matched.has(i));
619
+ return animationIndices;
620
+ }
621
+ collectAnimation(region) {
622
+ if (region.consecutiveCount >= this.animationThreshold) {
623
+ const durationMs = region.consecutiveCount / this.fps * 1e3;
624
+ this.extractedAnimations.push({
625
+ type: "loading_spinner",
626
+ boundingBox: region.box,
627
+ startFrameId: region.firstSeen,
628
+ endFrameId: region.lastSeen,
629
+ durationMs
630
+ });
631
+ }
632
+ }
633
+ flushAndGetAnimations() {
634
+ for (const region of this.regions) this.collectAnimation(region);
635
+ this.regions = [];
636
+ return this.extractedAnimations;
637
+ }
638
+ getAnimationWeight(boxIndex, boxes) {
639
+ if (boxIndex >= boxes.length) return 0;
640
+ const box = boxes[boxIndex];
641
+ let maxWeight = 0;
642
+ for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
643
+ if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
644
+ }
645
+ return maxWeight;
646
+ }
647
+ };
648
+ }));
649
+
650
+ //#endregion
651
+ //#region src/core/analyzer/index.ts
652
+ var init_analyzer = __esmMin((() => {
653
+ init_analyzer$1();
654
+ init_dbscan();
655
+ }));
513
656
 
514
657
  //#endregion
515
- //#region src/utils/paths.ts
658
+ //#region src/core/utils/metadata/build-video-metadata.ts
659
+ /**
660
+ * Read output dimensions and build consistent video and animation metadata.
661
+ * JPEG finalization does not resize, so the source dimensions match the output.
662
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
663
+ * @param selected Selected frames in output order; the first candidate is the fallback.
664
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
665
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
666
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
667
+ */
668
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
669
+ const firstFrame = selected[0] ?? ctx.frames[0];
670
+ const dimensions = firstFrame ? await (0, sharp.default)(firstFrame.extractPath).metadata() : void 0;
671
+ const width = dimensions?.width ?? 0;
672
+ const height = dimensions?.height ?? 0;
673
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
674
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
675
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
676
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
677
+ return {
678
+ video: {
679
+ originalDurationMs: Math.round(duration * 1e3),
680
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
681
+ resolution: {
682
+ width,
683
+ height
684
+ }
685
+ },
686
+ animations: (ctx.animations ?? []).map((animation) => {
687
+ const box = animation.boundingBox;
688
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
689
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
690
+ return {
691
+ ...animation,
692
+ boundingBox: {
693
+ x,
694
+ y,
695
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
696
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
697
+ }
698
+ };
699
+ })
700
+ };
701
+ }
702
+ var init_build_video_metadata = __esmMin((() => {}));
703
+
704
+ //#endregion
705
+ //#region src/core/constants/workspace-layout.ts
706
+ function getTempWorkspaceDir(sessionId) {
707
+ return (0, node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
708
+ }
709
+ var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
710
+ var init_workspace_layout = __esmMin((() => {
711
+ APP_NAME = "scene-sieve";
712
+ WORKSPACE_PREFIX = `${APP_NAME}-`;
713
+ TEMP_BASE_DIR = (0, node_os.tmpdir)();
714
+ FRAME_OUTPUT_EXTENSION = ".jpg";
715
+ FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
716
+ }));
717
+
718
+ //#endregion
719
+ //#region src/core/utils/filesystem/paths.ts
516
720
  async function ensureDir(dirPath) {
517
721
  await (0, node_fs_promises.mkdir)(dirPath, { recursive: true });
518
722
  }
@@ -548,15 +752,21 @@ function resolveAbsolute(p) {
548
752
  function deriveOutputPath(inputPath) {
549
753
  return (0, node_path.resolve)((0, node_path.resolve)(inputPath, ".."), `${(0, node_path.basename)(inputPath, (0, node_path.extname)(inputPath))}_scenes`);
550
754
  }
755
+ var init_paths = __esmMin((() => {}));
551
756
 
552
757
  //#endregion
553
- //#region src/core/extractor.ts
758
+ //#region src/core/extractor/extractor.ts
554
759
  /**
555
760
  * Extract frames from video/GIF using FFmpeg.
556
- * Always uses FPS-based extraction. For long videos, FPS is automatically
557
- * reduced to stay within maxFrames budget.
761
+ * @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
762
+ * @returns Extracted candidates, or the unchanged input array in frames mode.
763
+ * @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
558
764
  */
559
765
  async function extractFrames(ctx) {
766
+ if (ctx.options.mode === "frames") {
767
+ ctx.effectiveFps = 1;
768
+ return ctx.frames;
769
+ }
560
770
  const framesDir = (0, node_path.join)(ctx.workspacePath, "frames");
561
771
  const { inputPath, fps, maxFrames, scale } = ctx.options;
562
772
  if (!inputPath) throw new Error("inputPath is required for frame extraction");
@@ -571,19 +781,30 @@ async function extractFrames(ctx) {
571
781
  if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
572
782
  logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
573
783
  await ensureDir(framesDir);
784
+ const frameLimit = Math.max(2, maxFrames);
574
785
  let effectiveFps = fps;
575
786
  if (duration > 0) {
576
- const fpsCap = maxFrames / duration;
787
+ const fpsCap = frameLimit / duration;
577
788
  effectiveFps = Math.min(fps, fpsCap);
578
- effectiveFps = Math.max(.5, effectiveFps);
579
789
  logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
580
790
  }
581
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, duration);
791
+ ctx.effectiveFps = effectiveFps;
792
+ ctx.sourceDurationSec = duration;
793
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
582
794
  ctx.emitProgress(100);
583
795
  logger.debug(`Extracted ${frames.length} frames`);
584
796
  return frames;
585
797
  }
586
- async function extractByFps(inputPath, outputDir, fps, scale, duration) {
798
+ /**
799
+ * Write scaled JPEG candidates through the bundled FFmpeg runtime.
800
+ * @param inputPath Readable video input.
801
+ * @param outputDir Existing frame directory.
802
+ * @param fps Positive effective sampling frequency.
803
+ * @param scale Output image height.
804
+ * @param frameLimit Maximum number of output frames.
805
+ * @returns Candidates with local output-grid timestamps; rejects on extraction failure.
806
+ */
807
+ async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
587
808
  const outputPattern = (0, node_path.join)(outputDir, FRAME_FILENAME_PATTERN);
588
809
  await (0, execa.execa)(ffmpeg_static.default, [
589
810
  "-i",
@@ -592,9 +813,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
592
813
  `fps=${fps},scale=-1:${scale}`,
593
814
  "-q:v",
594
815
  "2",
816
+ "-frames:v",
817
+ String(frameLimit),
595
818
  outputPattern
596
819
  ]);
597
- return buildFrameList(outputDir, duration);
820
+ return buildFrameList(outputDir, fps);
598
821
  }
599
822
  async function getVideoMetadata(inputPath) {
600
823
  const { stdout } = await (0, execa.execa)(_ffprobe_installer_ffprobe.path, [
@@ -608,12 +831,18 @@ async function getVideoMetadata(inputPath) {
608
831
  ]);
609
832
  return JSON.parse(stdout);
610
833
  }
611
- async function buildFrameList(framesDir, duration) {
834
+ /**
835
+ * Read sorted JPEG paths and attach local output-grid times.
836
+ * @param framesDir Extracted frame directory; filesystem errors propagate.
837
+ * @param effectiveFps Positive frequency used by the fps filter.
838
+ * @returns Zero-based candidates without a segment seek offset.
839
+ */
840
+ async function buildFrameList(framesDir, effectiveFps) {
612
841
  const jpgFiles = (0, _winglet_common_utils.filter)(await (0, node_fs_promises.readdir)(framesDir), (f) => f.endsWith(".jpg")).sort();
613
842
  if (jpgFiles.length === 0) return [];
614
843
  return (0, _winglet_common_utils.map)(jpgFiles, (file, index) => ({
615
844
  id: index,
616
- timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
845
+ timestamp: index / effectiveFps,
617
846
  extractPath: (0, node_path.join)(framesDir, file)
618
847
  }));
619
848
  }
@@ -627,9 +856,10 @@ async function buildFrameList(framesDir, duration) {
627
856
  * @param scale - Height scale for vision analysis
628
857
  * @param startTime - Start time in seconds
629
858
  * @param duration - Duration in seconds to extract
859
+ * @param frameLimit - Positive output limit; defaults to the range's grid capacity
630
860
  * @returns Array of FrameNode with segment-local timestamps (starting from 0)
631
861
  */
632
- async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
862
+ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
633
863
  const outputPattern = (0, node_path.join)(outputDir, FRAME_FILENAME_PATTERN);
634
864
  await (0, execa.execa)(ffmpeg_static.default, [
635
865
  "-ss",
@@ -642,13 +872,124 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
642
872
  `fps=${fps},scale=-1:${scale}`,
643
873
  "-q:v",
644
874
  "2",
875
+ "-frames:v",
876
+ String(frameLimit),
645
877
  outputPattern
646
878
  ]);
647
- return buildFrameList(outputDir, duration);
879
+ return buildFrameList(outputDir, fps);
880
+ }
881
+ var init_extractor$1 = __esmMin((() => {
882
+ init_workspace_layout();
883
+ init_logger();
884
+ init_paths();
885
+ }));
886
+
887
+ //#endregion
888
+ //#region src/core/extractor/index.ts
889
+ var init_extractor = __esmMin((() => {
890
+ init_extractor$1();
891
+ }));
892
+
893
+ //#endregion
894
+ //#region src/core/input-resolver/validation/validate-options.ts
895
+ /**
896
+ * Reject invalid numeric options before defaults or pipeline effects are applied.
897
+ * @param options - Supplied options; omitted numeric fields use pipeline defaults.
898
+ * @returns Nothing when all supplied numeric fields satisfy their contracts.
899
+ * @throws An input error naming the invalid option and its received value.
900
+ */
901
+ function validateOptions(options) {
902
+ for (const [name, min, max, integer, exclusiveMin, requirement] of [
903
+ [
904
+ "count",
905
+ 1,
906
+ Infinity,
907
+ true,
908
+ false,
909
+ "an integer >= 1"
910
+ ],
911
+ [
912
+ "threshold",
913
+ 0,
914
+ 1,
915
+ false,
916
+ true,
917
+ "in range (0, 1] and finite"
918
+ ],
919
+ [
920
+ "fps",
921
+ 0,
922
+ Infinity,
923
+ false,
924
+ true,
925
+ "finite and > 0"
926
+ ],
927
+ [
928
+ "maxFrames",
929
+ 2,
930
+ Infinity,
931
+ true,
932
+ false,
933
+ "an integer >= 2"
934
+ ],
935
+ [
936
+ "scale",
937
+ 16,
938
+ Infinity,
939
+ true,
940
+ false,
941
+ "an integer >= 16"
942
+ ],
943
+ [
944
+ "quality",
945
+ 1,
946
+ 100,
947
+ true,
948
+ false,
949
+ "an integer in range [1, 100]"
950
+ ],
951
+ [
952
+ "iouThreshold",
953
+ 0,
954
+ 1,
955
+ false,
956
+ false,
957
+ "finite and in range [0, 1]"
958
+ ],
959
+ [
960
+ "animationThreshold",
961
+ 1,
962
+ Infinity,
963
+ true,
964
+ false,
965
+ "an integer >= 1"
966
+ ],
967
+ [
968
+ "maxSegmentDuration",
969
+ 0,
970
+ Infinity,
971
+ false,
972
+ true,
973
+ "finite and > 0"
974
+ ],
975
+ [
976
+ "concurrency",
977
+ 1,
978
+ Infinity,
979
+ true,
980
+ false,
981
+ "an integer >= 1"
982
+ ]
983
+ ]) {
984
+ const value = options[name];
985
+ if (value === void 0) continue;
986
+ if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
987
+ }
648
988
  }
989
+ var init_validate_options = __esmMin((() => {}));
649
990
 
650
991
  //#endregion
651
- //#region src/core/workspace.ts
992
+ //#region src/core/workspace/workspace.ts
652
993
  async function createWorkspace(sessionId) {
653
994
  const workspacePath = getTempWorkspaceDir(sessionId);
654
995
  await ensureDir((0, node_path.join)(workspacePath, "frames"));
@@ -679,17 +1020,11 @@ async function finalizeOutput(ctx, selectedFrames) {
679
1020
  timestampMs: Math.round(frame.timestamp * 1e3)
680
1021
  });
681
1022
  }
1023
+ const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
682
1024
  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
- },
1025
+ video,
691
1026
  frames: framesMetadata,
692
- animations: (0, _winglet_common_utils.map)(ctx.animations || [], (anim) => ({
1027
+ animations: (0, _winglet_common_utils.map)(animations, (anim) => ({
693
1028
  ...anim,
694
1029
  startFrameId: anim.startFrameId + 1,
695
1030
  endFrameId: anim.endFrameId + 1,
@@ -760,15 +1095,32 @@ async function readFramesAsBuffers(frameNodes, quality) {
760
1095
  mozjpeg: true
761
1096
  }).toBuffer()));
762
1097
  }
1098
+ var init_workspace$1 = __esmMin((() => {
1099
+ init_workspace_layout();
1100
+ init_paths();
1101
+ init_build_video_metadata();
1102
+ }));
763
1103
 
764
1104
  //#endregion
765
- //#region src/core/input-resolver.ts
1105
+ //#region src/core/workspace/index.ts
1106
+ var init_workspace = __esmMin((() => {
1107
+ init_workspace$1();
1108
+ }));
1109
+
1110
+ //#endregion
1111
+ //#region src/core/input-resolver/input-resolver.ts
1112
+ /**
1113
+ * Validate supplied options and resolve defaults and paths for the pipeline.
1114
+ * @param options - Mode-specific input and optional numeric settings.
1115
+ * @returns Complete pipeline settings with absolute file input paths.
1116
+ * @throws An input error if a supplied numeric setting is invalid.
1117
+ */
766
1118
  function resolveOptions(options) {
1119
+ validateOptions(options);
767
1120
  const mode = options.mode;
768
1121
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
769
1122
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : (0, node_path.join)(process.cwd(), "scene-sieve-output"));
770
1123
  const threshold = options.threshold ?? .5;
771
- if (threshold <= 0 || threshold > 1) throw new Error(`threshold must be in range (0, 1], received: ${threshold}`);
772
1124
  return {
773
1125
  mode,
774
1126
  inputPath,
@@ -793,6 +1145,10 @@ function resolveOptions(options) {
793
1145
  * - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
794
1146
  * - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
795
1147
  * - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
1148
+ * @param options - Input source; encoded frames must have matching dimensions.
1149
+ * @param workspacePath - Workspace receiving temporary input files.
1150
+ * @returns Frame nodes or a resolved video path for extraction.
1151
+ * @throws Propagates metadata or write errors and rejects mismatched frame sizes.
796
1152
  */
797
1153
  async function resolveInput(options, workspacePath) {
798
1154
  if (options.mode === "file") return {
@@ -803,12 +1159,51 @@ async function resolveInput(options, workspacePath) {
803
1159
  frames: [],
804
1160
  resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
805
1161
  };
806
- if (options.mode === "frames") return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1162
+ if (options.mode === "frames") {
1163
+ let dimensions;
1164
+ for (const buffer of options.inputFrames) {
1165
+ const { width, height } = await (0, sharp.default)(buffer).metadata();
1166
+ 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}`);
1167
+ dimensions = {
1168
+ width,
1169
+ height
1170
+ };
1171
+ }
1172
+ return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1173
+ }
807
1174
  throw new Error(`Unsupported input mode: ${options.mode}`);
808
1175
  }
1176
+ var init_input_resolver$1 = __esmMin((() => {
1177
+ init_pipeline_defaults();
1178
+ init_paths();
1179
+ init_validate_options();
1180
+ init_workspace();
1181
+ }));
809
1182
 
810
1183
  //#endregion
811
- //#region src/utils/math.ts
1184
+ //#region src/core/input-resolver/index.ts
1185
+ var init_input_resolver = __esmMin((() => {
1186
+ init_input_resolver$1();
1187
+ }));
1188
+
1189
+ //#endregion
1190
+ //#region src/core/pruner/scoring/normalize-scores.ts
1191
+ /**
1192
+ * Find the first position whose score is at least the requested value.
1193
+ * @param sorted - Finite positive scores sorted in ascending order.
1194
+ * @param value - A finite positive score present in sorted.
1195
+ * @returns The first matching rank, including the first position of any tie.
1196
+ */
1197
+ function lowerBound(sorted, value) {
1198
+ let low = 0;
1199
+ let high = sorted.length;
1200
+ while (low < high) {
1201
+ const mid = Math.floor((low + high) / 2);
1202
+ if (sorted[mid] < value) low = mid + 1;
1203
+ else high = mid;
1204
+ }
1205
+ return low;
1206
+ }
812
1207
  /**
813
1208
  * Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
814
1209
  *
@@ -853,64 +1248,67 @@ function normalizeScores(items) {
853
1248
  });
854
1249
  const cdf = (0, _winglet_common_utils.map)(safeScores, (s) => {
855
1250
  if (s <= 0) return 0;
856
- return sorted.findIndex((v) => v >= s) / sorted.length;
1251
+ return lowerBound(sorted, s) / sorted.length;
857
1252
  });
858
1253
  return (0, _winglet_common_utils.map)(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
859
1254
  }
1255
+ var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
1256
+ var init_normalize_scores = __esmMin((() => {
1257
+ NORMALIZATION_ALPHA = .4;
1258
+ NORMALIZATION_MAD_COEFFICIENT = 1.4826;
1259
+ NORMALIZATION_MIN_SAMPLE_SIZE = 10;
1260
+ }));
860
1261
 
861
1262
  //#endregion
862
- //#region src/utils/min-heap.ts
863
- /**
864
- * Generic binary min-heap.
865
- *
866
- * Elements are ordered by a numeric `score` field.
867
- * push/pop: O(log N), size: O(1).
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);
1263
+ //#region src/core/pruner/heap/min-heap.ts
1264
+ var MinHeap;
1265
+ var init_min_heap = __esmMin((() => {
1266
+ MinHeap = class {
1267
+ h = [];
1268
+ get size() {
1269
+ return this.h.length;
886
1270
  }
887
- return top;
888
- }
889
- siftUp(i) {
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;
1271
+ push(entry) {
1272
+ this.h.push(entry);
1273
+ this.siftUp(this.h.length - 1);
895
1274
  }
896
- }
897
- siftDown(i) {
898
- const n = this.h.length;
899
- for (;;) {
900
- let m = i;
901
- const l = 2 * i + 1;
902
- const r = 2 * i + 2;
903
- if (l < n && this.h[l].score < this.h[m].score) m = l;
904
- if (r < n && this.h[r].score < this.h[m].score) m = r;
905
- if (m === i) break;
906
- [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
907
- i = m;
1275
+ pop() {
1276
+ const n = this.h.length;
1277
+ if (n === 0) return void 0;
1278
+ const top = this.h[0];
1279
+ const last = this.h.pop();
1280
+ if (n > 1) {
1281
+ this.h[0] = last;
1282
+ this.siftDown(0);
1283
+ }
1284
+ return top;
908
1285
  }
909
- }
910
- };
1286
+ siftUp(i) {
1287
+ while (i > 0) {
1288
+ const p = i - 1 >> 1;
1289
+ if (this.h[p].score <= this.h[i].score) break;
1290
+ [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
1291
+ i = p;
1292
+ }
1293
+ }
1294
+ siftDown(i) {
1295
+ const n = this.h.length;
1296
+ for (;;) {
1297
+ let m = i;
1298
+ const l = 2 * i + 1;
1299
+ const r = 2 * i + 2;
1300
+ if (l < n && this.h[l].score < this.h[m].score) m = l;
1301
+ if (r < n && this.h[r].score < this.h[m].score) m = r;
1302
+ if (m === i) break;
1303
+ [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
1304
+ i = m;
1305
+ }
1306
+ }
1307
+ };
1308
+ }));
911
1309
 
912
1310
  //#endregion
913
- //#region src/core/pruner.ts
1311
+ //#region src/core/pruner/pruner.ts
914
1312
  /**
915
1313
  * Edge-aware greedy merge with re-linking — O(N log N).
916
1314
  *
@@ -1024,7 +1422,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
1024
1422
  return result;
1025
1423
  }
1026
1424
  /**
1027
- * Threshold-based pruning with NMS -- O(N).
1425
+ * Threshold-based pruning with NMS -- including normalization, O(N log N).
1028
1426
  *
1029
1427
  * 1. Scores are normalized to [0, 1] via percentile normalization.
1030
1428
  * 2. Edges with normalized score >= threshold are collected.
@@ -1087,9 +1485,19 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1087
1485
  }
1088
1486
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
1089
1487
  }
1488
+ var init_pruner$1 = __esmMin((() => {
1489
+ init_normalize_scores();
1490
+ init_min_heap();
1491
+ }));
1090
1492
 
1091
1493
  //#endregion
1092
- //#region src/utils/concurrency.ts
1494
+ //#region src/core/pruner/index.ts
1495
+ var init_pruner = __esmMin((() => {
1496
+ init_pruner$1();
1497
+ }));
1498
+
1499
+ //#endregion
1500
+ //#region src/core/segmenter/scheduling/concurrency.ts
1093
1501
  /**
1094
1502
  * Creates a concurrency limiter that runs at most `limit` tasks in parallel.
1095
1503
  * Lightweight replacement for p-limit to avoid external dependency.
@@ -1109,9 +1517,10 @@ function concurrencyLimit(limit) {
1109
1517
  }
1110
1518
  };
1111
1519
  }
1520
+ var init_concurrency = __esmMin((() => {}));
1112
1521
 
1113
1522
  //#endregion
1114
- //#region src/core/segmenter.ts
1523
+ //#region src/core/segmenter/segmenter.ts
1115
1524
  /**
1116
1525
  * Determine whether segmentation should be used.
1117
1526
  * Returns false for frames mode and GIF files.
@@ -1125,53 +1534,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
1125
1534
  return true;
1126
1535
  }
1127
1536
  /**
1128
- * Compute segment boundaries with overlap, frame allocation, and effectiveFps.
1129
- * Pure function no I/O.
1130
- *
1131
- * - effectiveFps is uniform across all segments
1132
- * - Overlap: 1 frame at each internal boundary
1133
- * - allocatedFrames total <= maxFrames (last segment adjusted if needed)
1537
+ * Partition the global extraction grid into nonempty logical segments.
1538
+ * @param totalDuration Positive source duration in seconds.
1539
+ * @param maxSegmentDuration Positive logical segment width in seconds.
1540
+ * @param maxFrames Candidate budget, defensively raised to at least two.
1541
+ * @param fps Positive requested sampling frequency.
1542
+ * @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
1134
1543
  */
1135
1544
  function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
1136
- const effectiveFps = Math.max(.5, Math.min(fps, maxFrames / totalDuration));
1545
+ const frameLimit = Math.max(2, maxFrames);
1546
+ const effectiveFps = Math.min(fps, frameLimit / totalDuration);
1137
1547
  if (totalDuration <= maxSegmentDuration) return [{
1138
1548
  index: 0,
1139
1549
  startTime: 0,
1140
1550
  endTime: totalDuration,
1141
1551
  duration: totalDuration,
1142
- allocatedFrames: Math.min(Math.ceil(effectiveFps * totalDuration), maxFrames),
1552
+ allocatedFrames: frameLimit,
1143
1553
  effectiveFps,
1144
1554
  overlapBefore: 0,
1145
1555
  overlapAfter: 0,
1146
1556
  extractStartTime: 0,
1147
1557
  extractDuration: totalDuration
1148
1558
  }];
1149
- const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
1150
- const overlapTime = 1 / effectiveFps;
1151
1559
  const segments = [];
1152
- for (let i = 0; i < segmentCount; i++) {
1153
- const startTime = i * maxSegmentDuration;
1154
- const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
1155
- const duration = endTime - startTime;
1156
- const overlapBefore = i > 0 ? 1 : 0;
1157
- const overlapAfter = i < segmentCount - 1 ? 1 : 0;
1158
- const extractStartTime = Math.max(0, startTime - overlapBefore * overlapTime);
1159
- const extractDuration = Math.min(totalDuration, endTime + overlapAfter * overlapTime) - extractStartTime;
1560
+ for (let slot = 0; slot < frameLimit; slot++) {
1561
+ const timestamp = slot / effectiveFps;
1562
+ if (timestamp >= totalDuration) break;
1563
+ const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
1564
+ const previous = segments[segments.length - 1];
1565
+ if (previous?.startTime === startTime) {
1566
+ previous.allocatedFrames++;
1567
+ continue;
1568
+ }
1569
+ const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
1160
1570
  segments.push({
1161
- index: i,
1571
+ index: segments.length,
1162
1572
  startTime,
1163
1573
  endTime,
1164
- duration,
1165
- allocatedFrames: Math.ceil(effectiveFps * duration),
1574
+ duration: endTime - startTime,
1575
+ allocatedFrames: 1,
1166
1576
  effectiveFps,
1167
- overlapBefore,
1168
- overlapAfter,
1169
- extractStartTime,
1170
- extractDuration
1577
+ overlapBefore: 0,
1578
+ overlapAfter: 0,
1579
+ extractStartTime: timestamp,
1580
+ extractDuration: 0
1171
1581
  });
1172
1582
  }
1173
- const totalAllocated = segments.reduce((sum, s) => sum + s.allocatedFrames, 0);
1174
- if (totalAllocated > maxFrames) segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
1583
+ let firstSlot = 0;
1584
+ for (const segment of segments) {
1585
+ const nextSlot = firstSlot + segment.allocatedFrames;
1586
+ segment.overlapBefore = segment.index > 0 ? 1 : 0;
1587
+ segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
1588
+ segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
1589
+ segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
1590
+ segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
1591
+ firstSlot = nextSlot;
1592
+ }
1175
1593
  return segments;
1176
1594
  }
1177
1595
  /**
@@ -1190,44 +1608,58 @@ function collectAllFrames(segmentResults) {
1190
1608
  return allFrames;
1191
1609
  }
1192
1610
  /**
1193
- * Sort frames by timestamp then remove overlap duplicates.
1194
- * Threshold: 1/(effectiveFps * 2) adaptive to fps (Section 18 note 5).
1195
- * Keeps the first occurrence (earlier segment).
1611
+ * Sort frames in place and alias overlap duplicates to the first survivor.
1612
+ * @param frames Collected entries whose segment index and local ID identify a frame.
1613
+ * @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
1614
+ * @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
1196
1615
  */
1197
1616
  function deduplicateFrames(frames, effectiveFps) {
1198
1617
  frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1199
1618
  const dupThreshold = 1 / (effectiveFps * 2);
1200
1619
  const unique = [];
1620
+ const aliases = /* @__PURE__ */ new Map();
1201
1621
  for (const entry of frames) {
1202
1622
  if (unique.length > 0) {
1203
1623
  const last = unique[unique.length - 1];
1204
- if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) continue;
1624
+ if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
1625
+ aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
1626
+ continue;
1627
+ }
1205
1628
  }
1206
1629
  unique.push(entry);
1207
1630
  }
1208
- return unique;
1631
+ return {
1632
+ unique,
1633
+ aliases
1634
+ };
1209
1635
  }
1210
1636
  /**
1211
- * Assign sequential global IDs to deduplicated frames and build a lookup map.
1212
- * Returns the remapped FrameNode array and the "segmentIndex:localId" -> globalId map.
1637
+ * Assign sequential global IDs and retain duplicate local IDs as aliases.
1638
+ * @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
1639
+ * @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
1640
+ * @returns Remapped frames and a global ID lookup covering survivors and duplicates.
1213
1641
  */
1214
- function remapFrameIds(uniqueFrames) {
1642
+ function remapFrameIds(uniqueFrames, aliases) {
1215
1643
  const globalIdMap = /* @__PURE__ */ new Map();
1644
+ const frames = uniqueFrames.map((entry, globalId) => {
1645
+ globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1646
+ return {
1647
+ id: globalId,
1648
+ timestamp: entry.frame.timestamp,
1649
+ extractPath: entry.frame.extractPath
1650
+ };
1651
+ });
1652
+ for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
1216
1653
  return {
1217
- frames: uniqueFrames.map((entry, globalId) => {
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
- }),
1654
+ frames,
1225
1655
  globalIdMap
1226
1656
  };
1227
1657
  }
1228
1658
  /**
1229
- * Remap edge source/target IDs using the global ID map.
1230
- * Duplicate edges (same source-target pair) retain the higher score.
1659
+ * Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
1660
+ * @param segmentResults Segment-local edges in encounter order.
1661
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
1662
+ * @returns One edge per surviving directed pair without changing its score.
1231
1663
  */
1232
1664
  function remapEdges(segmentResults, globalIdMap) {
1233
1665
  const edges = [];
@@ -1236,6 +1668,7 @@ function remapEdges(segmentResults, globalIdMap) {
1236
1668
  const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
1237
1669
  const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
1238
1670
  if (newSourceId === void 0 || newTargetId === void 0) continue;
1671
+ if (newSourceId === newTargetId) continue;
1239
1672
  const edgeKey = `${newSourceId}-${newTargetId}`;
1240
1673
  const existingIdx = edgeMap.get(edgeKey);
1241
1674
  if (existingIdx !== void 0) {
@@ -1256,42 +1689,52 @@ function remapEdges(segmentResults, globalIdMap) {
1256
1689
  return edges;
1257
1690
  }
1258
1691
  /**
1259
- * Remap animation startFrameId/endFrameId using the global ID map.
1260
- * Animations whose frame IDs were deduplicated (not in map) are dropped.
1692
+ * Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
1693
+ * @param segmentResults Segment-local tracker entries in encounter order.
1694
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
1695
+ * @returns One animation per directed pair with its original tracker duration and metadata.
1261
1696
  */
1262
1697
  function remapAnimations(segmentResults, globalIdMap) {
1263
- const animations = [];
1698
+ const animations = /* @__PURE__ */ new Map();
1264
1699
  for (const result of segmentResults) for (const anim of result.animations) {
1265
1700
  const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
1266
1701
  const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
1267
1702
  if (newStartId === void 0 || newEndId === void 0) continue;
1268
- animations.push({
1703
+ if (newStartId === newEndId) continue;
1704
+ const animationKey = `${newStartId}-${newEndId}`;
1705
+ if (animations.has(animationKey)) continue;
1706
+ animations.set(animationKey, {
1269
1707
  ...anim,
1270
1708
  startFrameId: newStartId,
1271
1709
  endFrameId: newEndId
1272
1710
  });
1273
1711
  }
1274
- return animations;
1712
+ return [...animations.values()];
1275
1713
  }
1276
1714
  /**
1277
1715
  * Merge multiple segment results into a single unified frame/edge/animation set.
1278
- * - Timestamps adjusted using extractStartTime (Section 18 note 1)
1279
- * - Overlap frames deduplicated by threshold 1/(effectiveFps*2) (Section 18 note 5)
1280
- * - Global IDs reassigned after dedup
1281
- * - Duplicate edges keep higher score
1716
+ * @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
1717
+ * @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
1718
+ * Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
1282
1719
  */
1283
1720
  function mergeSegmentFrames(segmentResults) {
1284
1721
  if (segmentResults.length === 0) return {
1285
1722
  frames: [],
1286
1723
  edges: [],
1287
- animations: []
1724
+ animations: [],
1725
+ analysisResolution: {
1726
+ width: 0,
1727
+ height: 0
1728
+ }
1288
1729
  };
1289
1730
  const effectiveFps = segmentResults[0].segment.effectiveFps;
1290
- const { frames, globalIdMap } = remapFrameIds(deduplicateFrames(collectAllFrames(segmentResults), effectiveFps));
1731
+ const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
1732
+ const { frames, globalIdMap } = remapFrameIds(unique, aliases);
1291
1733
  return {
1292
1734
  frames,
1293
1735
  edges: remapEdges(segmentResults, globalIdMap),
1294
- animations: remapAnimations(segmentResults, globalIdMap)
1736
+ animations: remapAnimations(segmentResults, globalIdMap),
1737
+ analysisResolution: segmentResults[0].analysisResolution
1295
1738
  };
1296
1739
  }
1297
1740
  function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
@@ -1302,6 +1745,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1302
1745
  maxFrames: segment.allocatedFrames
1303
1746
  },
1304
1747
  workspacePath: segmentWorkspacePath,
1748
+ effectiveFps: segment.effectiveFps,
1305
1749
  frames,
1306
1750
  graph: [],
1307
1751
  status: "ANALYZING",
@@ -1313,19 +1757,24 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1313
1757
  * Each segment uses an isolated workspace directory.
1314
1758
  */
1315
1759
  async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1316
- const frames = await extractFramesForRange(inputPath, (0, node_path.join)(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration);
1760
+ const frames = await extractFramesForRange(inputPath, (0, node_path.join)(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1317
1761
  if (frames.length < 2) return {
1318
1762
  segment,
1319
1763
  frames,
1320
1764
  edges: [],
1321
- animations: []
1765
+ animations: [],
1766
+ analysisResolution: {
1767
+ width: 0,
1768
+ height: 0
1769
+ }
1322
1770
  };
1323
- const { edges, animations } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
1771
+ const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
1324
1772
  return {
1325
1773
  segment,
1326
1774
  frames,
1327
1775
  edges,
1328
- animations
1776
+ animations,
1777
+ analysisResolution
1329
1778
  };
1330
1779
  }
1331
1780
  /**
@@ -1365,7 +1814,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1365
1814
  });
1366
1815
  })));
1367
1816
  options.onProgress?.("ANALYZING", 100);
1368
- const { frames, edges, animations } = mergeSegmentFrames(results);
1817
+ const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
1369
1818
  logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
1370
1819
  options.onProgress?.("PRUNING", 0);
1371
1820
  const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
@@ -1374,6 +1823,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1374
1823
  options.onProgress?.("FINALIZING", 0);
1375
1824
  const ctx = {
1376
1825
  options: resolvedOptions,
1826
+ effectiveFps: segments[0]?.effectiveFps,
1827
+ sourceDurationSec: totalDuration,
1828
+ analysisResolution,
1377
1829
  workspacePath: mainWorkspace,
1378
1830
  frames,
1379
1831
  graph: edges,
@@ -1387,21 +1839,14 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1387
1839
  else outputFiles = await finalizeOutput(ctx, prunedFrames);
1388
1840
  options.onProgress?.("FINALIZING", 100);
1389
1841
  logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1842
+ const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1390
1843
  return {
1391
1844
  success: true,
1392
1845
  originalFramesCount: frames.length,
1393
1846
  prunedFramesCount: prunedFrames.length,
1394
1847
  outputFiles,
1395
1848
  outputBuffers,
1396
- animations,
1397
- video: {
1398
- originalDurationMs: totalDuration * 1e3,
1399
- fps: resolvedOptions.fps,
1400
- resolution: {
1401
- width: resolvedOptions.scale,
1402
- height: Math.round(resolvedOptions.scale * 9 / 16)
1403
- }
1404
- },
1849
+ ...outputMetadata,
1405
1850
  executionTimeMs: Date.now() - pipelineStart
1406
1851
  };
1407
1852
  } catch (error) {
@@ -1413,11 +1858,27 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1413
1858
  else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1414
1859
  }
1415
1860
  }
1861
+ var init_segmenter$1 = __esmMin((() => {
1862
+ init_concurrency();
1863
+ init_logger();
1864
+ init_analyzer();
1865
+ init_build_video_metadata();
1866
+ init_extractor();
1867
+ init_input_resolver();
1868
+ init_pruner();
1869
+ init_workspace();
1870
+ }));
1871
+
1872
+ //#endregion
1873
+ //#region src/core/segmenter/index.ts
1874
+ var init_segmenter = __esmMin((() => {
1875
+ init_segmenter$1();
1876
+ }));
1416
1877
 
1417
1878
  //#endregion
1418
- //#region src/core/orchestrator.ts
1879
+ //#region src/core/orchestrator/orchestrator.ts
1419
1880
  async function runPipeline(options) {
1420
- if (options.debug ?? false) setDebugMode(true);
1881
+ setDebugMode(options.debug ?? false);
1421
1882
  const resolvedOptions = resolveOptions(options);
1422
1883
  if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
1423
1884
  const startTime = Date.now();
@@ -1437,19 +1898,27 @@ async function runPipeline(options) {
1437
1898
  logger.debug(`Workspace created: ${ctx.workspacePath}`);
1438
1899
  ctx.status = "EXTRACTING";
1439
1900
  const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
1440
- if (resolvedOptions.mode === "frames") ctx.frames = resolvedFrames;
1441
- else ctx.frames = await extractFrames({
1442
- ...ctx,
1443
- options: {
1444
- ...resolvedOptions,
1445
- inputPath: resolvedInputPath
1446
- }
1447
- });
1901
+ if (resolvedOptions.mode === "frames") {
1902
+ ctx.frames = resolvedFrames;
1903
+ ctx.effectiveFps = 1;
1904
+ } else {
1905
+ const extractCtx = {
1906
+ ...ctx,
1907
+ options: {
1908
+ ...resolvedOptions,
1909
+ inputPath: resolvedInputPath
1910
+ }
1911
+ };
1912
+ ctx.frames = await extractFrames(extractCtx);
1913
+ ctx.effectiveFps = extractCtx.effectiveFps;
1914
+ ctx.sourceDurationSec = extractCtx.sourceDurationSec;
1915
+ }
1448
1916
  ctx.emitProgress(100);
1449
1917
  ctx.status = "ANALYZING";
1450
- const { edges, animations } = await analyzeFrames(ctx);
1918
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1451
1919
  ctx.graph = edges;
1452
1920
  ctx.animations = animations;
1921
+ ctx.analysisResolution = analysisResolution;
1453
1922
  ctx.status = "PRUNING";
1454
1923
  const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
1455
1924
  const prunedFrames = (0, _winglet_common_utils.filter)(ctx.frames, (f) => survivingIds.has(f.id));
@@ -1466,21 +1935,14 @@ async function runPipeline(options) {
1466
1935
  }
1467
1936
  ctx.status = "SUCCESS";
1468
1937
  logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
1938
+ const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1469
1939
  return {
1470
1940
  success: true,
1471
1941
  originalFramesCount: ctx.frames.length,
1472
1942
  prunedFramesCount: prunedFrames.length,
1473
1943
  outputFiles,
1474
1944
  outputBuffers,
1475
- animations: ctx.animations,
1476
- video: {
1477
- originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
1478
- fps: ctx.options.fps,
1479
- resolution: {
1480
- width: ctx.options.scale,
1481
- height: Math.round(ctx.options.scale * 9 / 16)
1482
- }
1483
- },
1945
+ ...metadata,
1484
1946
  executionTimeMs: Date.now() - startTime
1485
1947
  };
1486
1948
  } catch (error) {
@@ -1493,6 +1955,20 @@ async function runPipeline(options) {
1493
1955
  else logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
1494
1956
  }
1495
1957
  }
1958
+ var init_orchestrator = __esmMin((() => {
1959
+ init_logger();
1960
+ init_analyzer();
1961
+ init_build_video_metadata();
1962
+ init_extractor();
1963
+ init_input_resolver();
1964
+ init_pruner();
1965
+ init_segmenter();
1966
+ init_workspace();
1967
+ }));
1968
+
1969
+ //#endregion
1970
+ //#region src/index.ts
1971
+ init_orchestrator();
1496
1972
 
1497
1973
  //#endregion
1498
1974
  exports.extractScenes = runPipeline;