@lumy-pack/scene-sieve 0.1.0 → 0.2.1

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