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