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