@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/cli.mjs CHANGED
@@ -6,13 +6,13 @@ import { Box, Text, render, useApp } from "ink";
6
6
  import React, { useEffect, useState } from "react";
7
7
  import Spinner from "ink-spinner";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
- import { homedir, tmpdir } from "node:os";
10
- import { basename, dirname, extname, join, resolve } from "node:path";
11
9
  import { randomUUID } from "node:crypto";
12
10
  import { filter, map } from "@winglet/common-utils";
13
11
  import pc from "picocolors";
14
12
  import sharp from "sharp";
15
13
  import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
14
+ import { basename, dirname, extname, join, resolve } from "node:path";
15
+ import { homedir, tmpdir } from "node:os";
16
16
  import { path } from "@ffprobe-installer/ffprobe";
17
17
  import { execa } from "execa";
18
18
  import ffmpegPath from "ffmpeg-static";
@@ -84,7 +84,7 @@ function respondError(command, code, message, startTime, version, details) {
84
84
  }
85
85
 
86
86
  //#endregion
87
- //#region src/components/ProgressBar.tsx
87
+ //#region src/cli/components/ProgressBar.tsx
88
88
  const ProgressBar = ({ percent, width = 30 }) => {
89
89
  const clamped = Math.max(0, Math.min(100, percent));
90
90
  const filled = Math.round(width * (clamped / 100));
@@ -107,7 +107,7 @@ const ProgressBar = ({ percent, width = 30 }) => {
107
107
  };
108
108
 
109
109
  //#endregion
110
- //#region src/components/PhaseStep.tsx
110
+ //#region src/cli/components/PhaseStep.tsx
111
111
  const PhaseStep = ({ phase }) => {
112
112
  const icon = (() => {
113
113
  switch (phase.status) {
@@ -146,28 +146,18 @@ const PhaseStep = ({ phase }) => {
146
146
  };
147
147
 
148
148
  //#endregion
149
- //#region src/constants.ts
150
- function getTempWorkspaceDir(sessionId) {
151
- return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
152
- }
153
- var APP_NAME, DEFAULT_THRESHOLD, NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN, DBSCAN_ALPHA, IOU_THRESHOLD, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
154
- var init_constants = __esmMin((() => {
155
- APP_NAME = "scene-sieve";
149
+ //#region src/constants/pipeline-defaults.ts
150
+ var DEFAULT_THRESHOLD, IOU_THRESHOLD, SHEET_LABEL_HEIGHT_RATIO, SHEET_FILE_NAME, METADATA_FILE_NAME;
151
+ var init_pipeline_defaults = __esmMin((() => {
156
152
  DEFAULT_THRESHOLD = .5;
157
- NORMALIZATION_ALPHA = .4;
158
- NORMALIZATION_MAD_COEFFICIENT = 1.4826;
159
- WORKSPACE_PREFIX = `${APP_NAME}-`;
160
- TEMP_BASE_DIR = tmpdir();
161
- FRAME_OUTPUT_EXTENSION = ".jpg";
162
- FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
163
- DBSCAN_ALPHA = .03;
164
153
  IOU_THRESHOLD = .9;
165
- DECAY_LAMBDA = .95;
166
- MATCH_DISTANCE_THRESHOLD = .25;
154
+ SHEET_LABEL_HEIGHT_RATIO = .07;
155
+ SHEET_FILE_NAME = "sheet.jpg";
156
+ METADATA_FILE_NAME = ".metadata.json";
167
157
  }));
168
158
 
169
159
  //#endregion
170
- //#region src/utils/logger.ts
160
+ //#region src/logging/logger.ts
171
161
  function setDebugMode(enabled) {
172
162
  debugMode = enabled;
173
163
  }
@@ -197,14 +187,25 @@ var init_logger = __esmMin((() => {
197
187
  console.error(`${pc.red("error")} ${message}`);
198
188
  },
199
189
  debug(message) {
200
- if (debugMode) if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
201
- else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
190
+ if (debugMode) {
191
+ if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
192
+ else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
193
+ }
202
194
  }
203
195
  };
204
196
  }));
205
197
 
206
198
  //#endregion
207
- //#region src/core/dbscan.ts
199
+ //#region src/core/analyzer/constants/vision-tuning.ts
200
+ var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
201
+ var init_vision_tuning = __esmMin((() => {
202
+ DBSCAN_ALPHA = .03;
203
+ DECAY_LAMBDA = .95;
204
+ MATCH_DISTANCE_THRESHOLD = .25;
205
+ }));
206
+
207
+ //#endregion
208
+ //#region src/core/analyzer/clustering/dbscan.ts
208
209
  /**
209
210
  * DBSCAN clustering with resolution-independent eps.
210
211
  * eps = alpha * sqrt(width^2 + height^2)
@@ -282,13 +283,116 @@ function findNeighbors(points, idx, epsSquared) {
282
283
  }
283
284
  var UNVISITED, NOISE;
284
285
  var init_dbscan = __esmMin((() => {
285
- init_constants();
286
+ init_vision_tuning();
286
287
  UNVISITED = -2;
287
288
  NOISE = -1;
288
289
  }));
289
290
 
290
291
  //#endregion
291
- //#region src/core/analyzer.ts
292
+ //#region src/core/analyzer/features/feature-diff.ts
293
+ /**
294
+ * Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
295
+ * @param cvLib - Initialized OpenCV runtime.
296
+ * @param prev - Previous frame's live features, owned by the caller.
297
+ * @param next - Next frame's live features, owned by the caller.
298
+ * @returns Unmatched next-frame coordinates without changing input ownership.
299
+ * @throws Propagates matching errors after releasing temporary native handles.
300
+ */
301
+ function computeNewPoints(cvLib, prev, next) {
302
+ let matcher = null;
303
+ let matches = null;
304
+ try {
305
+ const matchedIndices = /* @__PURE__ */ new Set();
306
+ if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
307
+ matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
308
+ matches = new cvLib.DMatchVectorVector();
309
+ matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
310
+ for (let i = 0; i < matches.size(); i++) {
311
+ const pair = matches.get(i);
312
+ try {
313
+ if (pair.size() < 2) continue;
314
+ const best = pair.get(0);
315
+ const second = pair.get(1);
316
+ if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
317
+ } finally {
318
+ pair.delete();
319
+ }
320
+ }
321
+ } finally {
322
+ matcher?.delete();
323
+ }
324
+ const points = [];
325
+ for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
326
+ const { x, y } = next.keypoints.get(i).pt;
327
+ points.push({
328
+ x,
329
+ y
330
+ });
331
+ }
332
+ return points;
333
+ } finally {
334
+ matches?.delete();
335
+ }
336
+ }
337
+ var init_feature_diff = __esmMin((() => {
338
+ init_vision_tuning();
339
+ }));
340
+
341
+ //#endregion
342
+ //#region src/core/analyzer/features/frame-features.ts
343
+ /**
344
+ * Detect one frame's features without retaining its image or mask.
345
+ * @param cvLib - Initialized OpenCV runtime.
346
+ * @param akaze - Detector owned and released by the caller.
347
+ * @param frame - Grayscale bytes with matching width and height.
348
+ * @returns Feature handles that the caller must delete.
349
+ * @throws Propagates native errors after releasing partial allocations.
350
+ */
351
+ function computeFrameFeatures(cvLib, akaze, frame) {
352
+ let image = null;
353
+ let mask = null;
354
+ let keypoints = null;
355
+ let descriptors = null;
356
+ try {
357
+ image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
358
+ image.data.set(frame.data);
359
+ mask = new cvLib.Mat();
360
+ keypoints = new cvLib.KeyPointVector();
361
+ descriptors = new cvLib.Mat();
362
+ akaze.detectAndCompute(image, mask, keypoints, descriptors);
363
+ const ownedKeypoints = keypoints;
364
+ const ownedDescriptors = descriptors;
365
+ let deleted = false;
366
+ const features = {
367
+ width: frame.width,
368
+ height: frame.height,
369
+ keypoints: ownedKeypoints,
370
+ descriptors: ownedDescriptors,
371
+ /** Release the transferred handles exactly once. */
372
+ delete() {
373
+ if (deleted) return;
374
+ deleted = true;
375
+ try {
376
+ ownedKeypoints.delete();
377
+ } finally {
378
+ ownedDescriptors.delete();
379
+ }
380
+ }
381
+ };
382
+ keypoints = null;
383
+ descriptors = null;
384
+ return features;
385
+ } finally {
386
+ image?.delete();
387
+ mask?.delete();
388
+ keypoints?.delete();
389
+ descriptors?.delete();
390
+ }
391
+ }
392
+ var init_frame_features = __esmMin((() => {}));
393
+
394
+ //#endregion
395
+ //#region src/core/analyzer/analyzer.ts
292
396
  async function ensureOpenCV() {
293
397
  if (!cvReady) cvReady = (async () => {
294
398
  const cvObj = require("@techstark/opencv-js");
@@ -327,77 +431,6 @@ function computeIoU(a, b) {
327
431
  const union = a.width * a.height + b.width * b.height - intersection;
328
432
  return union === 0 ? 0 : intersection / union;
329
433
  }
330
- async function computeAKAZEDiff(cvLib, frame1, frame2) {
331
- const cv = cvLib;
332
- const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
333
- mat1.data.set(frame1.data);
334
- const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
335
- mat2.data.set(frame2.data);
336
- const kp1 = new cvLib.KeyPointVector();
337
- const kp2 = new cvLib.KeyPointVector();
338
- const desc1 = new cvLib.Mat();
339
- const desc2 = new cvLib.Mat();
340
- const mask1 = new cvLib.Mat();
341
- const mask2 = new cvLib.Mat();
342
- const akaze = new cvLib.AKAZE();
343
- let matches = null;
344
- try {
345
- akaze.detectAndCompute(mat1, mask1, kp1, desc1);
346
- akaze.detectAndCompute(mat2, mask2, kp2, desc2);
347
- const matchedKp1Indices = /* @__PURE__ */ new Set();
348
- const matchedKp2Indices = /* @__PURE__ */ new Set();
349
- if (desc1.rows > 0 && desc2.rows > 0) {
350
- const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
351
- try {
352
- matches = new cvLib.DMatchVectorVector();
353
- matcher.knnMatch(desc1, desc2, matches, 2);
354
- for (let i = 0; i < matches.size(); i++) {
355
- const pair = matches.get(i);
356
- if (pair.size() < 2) continue;
357
- const m0 = pair.get(0);
358
- const m1 = pair.get(1);
359
- if (m0.distance < .25 * m1.distance) {
360
- matchedKp1Indices.add(m0.queryIdx);
361
- matchedKp2Indices.add(m0.trainIdx);
362
- }
363
- }
364
- } finally {
365
- matcher.delete();
366
- }
367
- }
368
- const sNew = [];
369
- for (let i = 0; i < kp2.size(); i++) if (!matchedKp2Indices.has(i)) {
370
- const pt = kp2.get(i).pt;
371
- sNew.push({
372
- x: pt.x,
373
- y: pt.y
374
- });
375
- }
376
- const sLoss = [];
377
- for (let i = 0; i < kp1.size(); i++) if (!matchedKp1Indices.has(i)) {
378
- const pt = kp1.get(i).pt;
379
- sLoss.push({
380
- x: pt.x,
381
- y: pt.y
382
- });
383
- }
384
- return {
385
- sNew,
386
- sLoss
387
- };
388
- } finally {
389
- mat1.delete();
390
- mat2.delete();
391
- kp1.delete();
392
- kp2.delete();
393
- desc1.delete();
394
- desc2.delete();
395
- mask1.delete();
396
- mask2.delete();
397
- akaze.delete();
398
- if (matches) matches.delete();
399
- }
400
- }
401
434
  /**
402
435
  * Pixel-level difference fallback for AKAZE blind spots.
403
436
  *
@@ -412,17 +445,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
412
445
  * 3. threshold → binary mask of significant changes
413
446
  * 4. findContours → bounding rects of changed regions
414
447
  * 5. Grid sampling within each bounding rect → Point2D[]
448
+ *
449
+ * @param cvLib - Initialized OpenCV runtime shared by the analyzer.
450
+ * @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
451
+ * @param frame2 - Next grayscale frame, with the same dimensions as frame1.
452
+ * @returns Grid-sampled points from changed regions.
453
+ * @throws Propagates allocation or OpenCV errors after releasing acquired handles.
415
454
  */
416
455
  function computePixelDiff(cvLib, frame1, frame2) {
417
456
  const cv = cvLib;
418
- const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
419
- const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
420
- const diff = new cv.Mat();
421
- const blurred = new cv.Mat();
422
- const binary = new cv.Mat();
423
- const contours = new cv.MatVector();
424
- const hierarchy = new cv.Mat();
457
+ let mat1 = null;
458
+ let mat2 = null;
459
+ let diff = null;
460
+ let blurred = null;
461
+ let binary = null;
462
+ let contours = null;
463
+ let hierarchy = null;
425
464
  try {
465
+ mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
466
+ mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
467
+ diff = new cv.Mat();
468
+ blurred = new cv.Mat();
469
+ binary = new cv.Mat();
470
+ contours = new cv.MatVector();
471
+ hierarchy = new cv.Mat();
426
472
  mat1.data.set(frame1.data);
427
473
  mat2.data.set(frame2.data);
428
474
  cv.absdiff(mat1, mat2, diff);
@@ -433,22 +479,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
433
479
  const points = [];
434
480
  for (let c = 0; c < contours.size(); c++) {
435
481
  const contour = contours.get(c);
436
- const rect = cv.boundingRect(contour);
437
- if (rect.width * rect.height < 100) continue;
438
- 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({
439
- x,
440
- y
441
- });
482
+ try {
483
+ const rect = cv.boundingRect(contour);
484
+ if (rect.width * rect.height < 100) continue;
485
+ 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({
486
+ x,
487
+ y
488
+ });
489
+ } finally {
490
+ contour.delete();
491
+ }
442
492
  }
443
493
  return points;
444
494
  } finally {
445
- mat1.delete();
446
- mat2.delete();
447
- diff.delete();
448
- blurred.delete();
449
- binary.delete();
450
- contours.delete();
451
- hierarchy.delete();
495
+ mat1?.delete();
496
+ mat2?.delete();
497
+ diff?.delete();
498
+ blurred?.delete();
499
+ binary?.delete();
500
+ contours?.delete();
501
+ hierarchy?.delete();
452
502
  }
453
503
  }
454
504
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
@@ -467,47 +517,91 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
467
517
  }
468
518
  return gain;
469
519
  }
470
- async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
520
+ /**
521
+ * Analyze one batch, retaining its boundary frame for the following batch.
522
+ * @param cvLib - Initialized OpenCV runtime.
523
+ * @param akaze - Detector owned by analyzeFrames.
524
+ * @param frames - Boundary frame followed by new adjacent frames.
525
+ * @param carry - Boundary bytes and live features transferred from the previous batch.
526
+ * @param scale - Maximum preprocessing width.
527
+ * @param tracker - Stateful animation tracker shared across batches.
528
+ * @param pairOffset - Global position of this batch's first pair.
529
+ * @returns Scores, pair failure count, and ownership of the final frame's features.
530
+ */
531
+ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
471
532
  const edges = [];
472
- const preprocessed = await Promise.all(map(frames, (f) => preprocessFrame(f.extractPath, scale)));
473
- const imageWidth = preprocessed[0]?.width ?? scale;
474
- const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
475
- const imageArea = imageWidth * imageHeight;
476
- for (let i = 0; i < frames.length - 1; i++) {
477
- const pairIndex = pairOffset + i;
478
- try {
479
- const { sNew } = await computeAKAZEDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
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;
533
+ let failures = 0;
534
+ let prev = carry?.features ?? null;
535
+ let next = null;
536
+ try {
537
+ const preprocessed = await Promise.all(map(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
538
+ const imageWidth = preprocessed[0]?.width ?? scale;
539
+ const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
540
+ const imageArea = imageWidth * imageHeight;
541
+ for (let i = 0; i < frames.length - 1; i++) {
542
+ const pairIndex = pairOffset + i;
543
+ try {
544
+ prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
545
+ next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
546
+ const sNew = computeNewPoints(cvLib, prev, next);
547
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
548
+ let clusters = dbscanResult.boundingBoxes;
549
+ if (clusters.length === 0) {
550
+ const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
551
+ if (pixelDiffPoints.length > 0) {
552
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
553
+ dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
554
+ clusters = dbscanResult.boundingBoxes;
555
+ }
488
556
  }
557
+ const clusterPointCounts = new Array(clusters.length).fill(0);
558
+ for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
559
+ const animationIndices = tracker.update(clusters, pairIndex);
560
+ const change = {
561
+ regions: filter(clusters, (_, ci) => !animationIndices.has(ci)),
562
+ animatedRegions: filter(clusters, (_, ci) => animationIndices.has(ci))
563
+ };
564
+ const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
565
+ const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
566
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
567
+ edges.push({
568
+ sourceId: frames[i].id,
569
+ targetId: frames[i + 1].id,
570
+ score,
571
+ change
572
+ });
573
+ } catch (err) {
574
+ logger.warn(`Frame pair analysis failed: ${String(err)}`);
575
+ failures++;
576
+ edges.push({
577
+ sourceId: frames[i].id,
578
+ targetId: frames[i + 1].id,
579
+ score: 0
580
+ });
581
+ } finally {
582
+ prev?.delete();
583
+ prev = next;
584
+ next = null;
489
585
  }
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 animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
494
- const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
495
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
496
- edges.push({
497
- sourceId: frames[i].id,
498
- targetId: frames[i + 1].id,
499
- score
500
- });
501
- } catch (err) {
502
- logger.debug(`Frame pair analysis failed: ${String(err)}`);
503
- edges.push({
504
- sourceId: frames[i].id,
505
- targetId: frames[i + 1].id,
506
- score: 0
507
- });
508
586
  }
587
+ const result = {
588
+ edges,
589
+ failures,
590
+ analysisResolution: {
591
+ width: imageWidth,
592
+ height: imageHeight
593
+ },
594
+ carry: prev ? {
595
+ preprocessed: preprocessed[preprocessed.length - 1],
596
+ features: prev
597
+ } : null
598
+ };
599
+ prev = null;
600
+ return result;
601
+ } finally {
602
+ prev?.delete();
603
+ next?.delete();
509
604
  }
510
- return edges;
511
605
  }
512
606
  /**
513
607
  * Analyze adjacent frame pairs to compute information gain scores (G(t)).
@@ -518,37 +612,66 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
518
612
  * 2. DBSCAN Spatial Clustering
519
613
  * 3. Spatio-temporal IoU Tracking
520
614
  * 4. G(t) Information Gain Scoring
615
+ * @param ctx - Frames, analysis options, and the progress callback for this run.
616
+ * @returns Adjacent scores and tracked animations in analysis coordinates.
617
+ * @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
521
618
  */
522
619
  async function analyzeFrames(ctx) {
523
620
  const { frames } = ctx;
524
621
  if (frames.length < 2) return {
525
622
  edges: [],
526
- animations: []
623
+ animations: [],
624
+ analysisResolution: {
625
+ width: 0,
626
+ height: 0
627
+ }
527
628
  };
528
629
  logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
529
630
  const cvLib = await ensureOpenCV();
530
631
  const edges = [];
531
- const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
632
+ const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
532
633
  const scale = ctx.options.scale;
533
- for (let i = 0; i < frames.length - 1; i += 10) {
534
- const batchEnd = Math.min(i + 10 + 1, frames.length);
535
- const batchEdges = await analyzeBatch(cvLib, frames.slice(i, batchEnd), scale, tracker, i);
536
- edges.push(...batchEdges);
537
- const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
538
- ctx.emitProgress(progress);
634
+ let akaze = null;
635
+ let carry = null;
636
+ let analysisResolution = {
637
+ width: 0,
638
+ height: 0
639
+ };
640
+ let failures = 0;
641
+ try {
642
+ akaze = new cvLib.AKAZE();
643
+ for (let i = 0; i < frames.length - 1; i += 10) {
644
+ const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
645
+ const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
646
+ carry = result.carry;
647
+ failures += result.failures;
648
+ if (i === 0) analysisResolution = result.analysisResolution;
649
+ edges.push(...result.edges);
650
+ const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
651
+ ctx.emitProgress(progress);
652
+ }
653
+ } finally {
654
+ carry?.features.delete();
655
+ akaze?.delete();
539
656
  }
657
+ const pairs = frames.length - 1;
658
+ if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
540
659
  const animations = tracker.flushAndGetAnimations();
541
660
  logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
542
661
  return {
543
662
  edges,
544
- animations
663
+ animations,
664
+ analysisResolution
545
665
  };
546
666
  }
547
667
  var OPENCV_INIT_TIMEOUT_MS, require, cvReady, IoUTracker;
548
- var init_analyzer = __esmMin((() => {
549
- init_constants();
668
+ var init_analyzer$1 = __esmMin((() => {
669
+ init_pipeline_defaults();
670
+ init_vision_tuning();
550
671
  init_logger();
551
672
  init_dbscan();
673
+ init_feature_diff();
674
+ init_frame_features();
552
675
  OPENCV_INIT_TIMEOUT_MS = 3e4;
553
676
  require = createRequire(import.meta.url);
554
677
  cvReady = null;
@@ -630,58 +753,728 @@ var init_analyzer = __esmMin((() => {
630
753
  for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
631
754
  if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
632
755
  }
633
- return maxWeight;
634
- }
756
+ return maxWeight;
757
+ }
758
+ };
759
+ }));
760
+
761
+ //#endregion
762
+ //#region src/core/analyzer/index.ts
763
+ var init_analyzer = __esmMin((() => {
764
+ init_analyzer$1();
765
+ init_dbscan();
766
+ }));
767
+
768
+ //#endregion
769
+ //#region src/constants/package-version.ts
770
+ var manifestPath, PACKAGE_VERSION;
771
+ var init_package_version = __esmMin((() => {
772
+ manifestPath = existsSync(new URL("../package.json", import.meta.url)) ? "../package.json" : "../../package.json";
773
+ PACKAGE_VERSION = createRequire(import.meta.url)(manifestPath).version;
774
+ }));
775
+
776
+ //#endregion
777
+ //#region src/core/constants/workspace-layout.ts
778
+ function getTempWorkspaceDir(sessionId) {
779
+ return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
780
+ }
781
+ var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
782
+ var init_workspace_layout = __esmMin((() => {
783
+ APP_NAME = "scene-sieve";
784
+ WORKSPACE_PREFIX = `${APP_NAME}-`;
785
+ TEMP_BASE_DIR = tmpdir();
786
+ FRAME_OUTPUT_EXTENSION = ".jpg";
787
+ FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
788
+ }));
789
+
790
+ //#endregion
791
+ //#region src/core/utils/filesystem/paths.ts
792
+ async function ensureDir(dirPath) {
793
+ await mkdir(dirPath, { recursive: true });
794
+ }
795
+ async function fileExists(filePath) {
796
+ try {
797
+ await stat(filePath);
798
+ return true;
799
+ } catch {
800
+ return false;
801
+ }
802
+ }
803
+ /**
804
+ * Expand leading ~ to homedir. Node's path.resolve() does not expand ~,
805
+ * so paths like ~/Desktop/foo depend on process.cwd() and can produce
806
+ * different results when run from different directories.
807
+ */
808
+ function expandTilde(p) {
809
+ if (p === "~") return homedir();
810
+ if (p.startsWith("~/") || p.startsWith("~\\")) return resolve(homedir(), p.slice(2));
811
+ return p;
812
+ }
813
+ /**
814
+ * Resolve path to absolute. Expands ~ to homedir first so that the result
815
+ * does not depend on process.cwd().
816
+ */
817
+ function resolveAbsolute(p) {
818
+ return resolve(expandTilde(p));
819
+ }
820
+ /**
821
+ * Derive default output directory name from input file path.
822
+ * e.g., /path/to/video.mp4 -> /path/to/video_scenes
823
+ */
824
+ function deriveOutputPath(inputPath) {
825
+ const dir = resolve(inputPath, "..");
826
+ const name = basename(inputPath, extname(inputPath));
827
+ return resolve(dir, `${name}_scenes`);
828
+ }
829
+ var init_paths = __esmMin((() => {}));
830
+
831
+ //#endregion
832
+ //#region src/core/workspace/workspace.ts
833
+ async function createWorkspace(sessionId) {
834
+ const workspacePath = getTempWorkspaceDir(sessionId);
835
+ await ensureDir(join(workspacePath, "frames"));
836
+ await ensureDir(join(workspacePath, "output"));
837
+ return workspacePath;
838
+ }
839
+ /**
840
+ * Write selected JPEGs and the supplied document before replacing the output directory.
841
+ * @param ctx - Workspace, quality and destination settings.
842
+ * @param selectedFrames - Frames paired by position with document.frames.
843
+ * @param document - Complete metadata, including the output file names.
844
+ * @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
845
+ * @returns Selected JPEG paths, optional sheet path and finally the metadata path.
846
+ * @throws Rejects mismatched frame counts and propagates image or filesystem errors.
847
+ */
848
+ async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
849
+ if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
850
+ const stagingDir = join(ctx.workspacePath, "output");
851
+ const outputPath = ctx.options.outputPath;
852
+ const quality = ctx.options.quality;
853
+ const outputFiles = [];
854
+ for (let i = 0; i < selectedFrames.length; i++) {
855
+ const frame = selectedFrames[i];
856
+ const fileName = document.frames[i].fileName;
857
+ const destPath = join(stagingDir, fileName);
858
+ await sharp(frame.extractPath).jpeg({
859
+ quality,
860
+ mozjpeg: true
861
+ }).toFile(destPath);
862
+ outputFiles.push(join(outputPath, fileName));
863
+ }
864
+ if (sheetBuffer) {
865
+ await writeFile(join(stagingDir, SHEET_FILE_NAME), sheetBuffer);
866
+ outputFiles.push(join(outputPath, SHEET_FILE_NAME));
867
+ }
868
+ const metadataPath = join(stagingDir, METADATA_FILE_NAME);
869
+ await writeFile(metadataPath, JSON.stringify(document, null, 2));
870
+ outputFiles.push(join(outputPath, METADATA_FILE_NAME));
871
+ await ensureDir(join(outputPath, ".."));
872
+ await rm(outputPath, {
873
+ recursive: true,
874
+ force: true
875
+ });
876
+ await rename(stagingDir, outputPath);
877
+ return outputFiles;
878
+ }
879
+ async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
880
+ const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
881
+ await ensureDir(join(segmentPath, "frames"));
882
+ return segmentPath;
883
+ }
884
+ async function cleanupWorkspace(workspacePath) {
885
+ if (!workspacePath) return;
886
+ try {
887
+ await rm(workspacePath, {
888
+ recursive: true,
889
+ force: true
890
+ });
891
+ } catch {}
892
+ }
893
+ /**
894
+ * Remove stale workspace directories left by previous interrupted runs.
895
+ * Only deletes directories older than 1 hour to avoid removing active workspaces.
896
+ */
897
+ async function cleanupStaleWorkspaces() {
898
+ const entries = await readdir(TEMP_BASE_DIR);
899
+ const now = Date.now();
900
+ for (const entry of entries) {
901
+ if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
902
+ const fullPath = join(TEMP_BASE_DIR, entry);
903
+ try {
904
+ const info = await stat(fullPath);
905
+ if (info.isDirectory() && now - info.mtimeMs > STALE_THRESHOLD_MS) await rm(fullPath, {
906
+ recursive: true,
907
+ force: true
908
+ });
909
+ } catch {}
910
+ }
911
+ }
912
+ /**
913
+ * Write a video buffer to a temp file in the workspace and return the path.
914
+ * Used by 'buffer' input mode.
915
+ */
916
+ async function writeInputBuffer(buffer, workspacePath) {
917
+ const inputDir = join(workspacePath, "input");
918
+ await ensureDir(inputDir);
919
+ const tempPath = join(inputDir, "input.mp4");
920
+ await writeFile(tempPath, buffer);
921
+ return tempPath;
922
+ }
923
+ /**
924
+ * Write an array of frame Buffers as JPG files and return FrameNode[].
925
+ * Used by 'frames' input mode.
926
+ */
927
+ async function writeInputFrames(frames, workspacePath) {
928
+ const framesDir = join(workspacePath, "frames");
929
+ await ensureDir(framesDir);
930
+ const frameNodes = [];
931
+ for (let i = 0; i < frames.length; i++) {
932
+ const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
933
+ const extractPath = join(framesDir, filename);
934
+ await writeFile(extractPath, frames[i]);
935
+ frameNodes.push({
936
+ id: i,
937
+ timestamp: i,
938
+ extractPath
939
+ });
940
+ }
941
+ return frameNodes;
942
+ }
943
+ /**
944
+ * Read selected FrameNode files as Buffers with JPEG compression.
945
+ * Used to return output buffers in 'buffer' and 'frames' modes.
946
+ */
947
+ async function readFramesAsBuffers(frameNodes, quality) {
948
+ return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
949
+ quality,
950
+ mozjpeg: true
951
+ }).toBuffer()));
952
+ }
953
+ var STALE_THRESHOLD_MS;
954
+ var init_workspace$1 = __esmMin((() => {
955
+ init_workspace_layout();
956
+ init_pipeline_defaults();
957
+ init_paths();
958
+ STALE_THRESHOLD_MS = 36e5;
959
+ }));
960
+
961
+ //#endregion
962
+ //#region src/core/workspace/index.ts
963
+ var init_workspace = __esmMin((() => {
964
+ init_workspace$1();
965
+ }));
966
+
967
+ //#endregion
968
+ //#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
969
+ var YCoverageTree;
970
+ var init_y_coverage_tree = __esmMin((() => {
971
+ YCoverageTree = class {
972
+ bounds;
973
+ /** Number of whole-node covering intervals, independent of descendants. */
974
+ counts;
975
+ /** Covered geometric length for each node, including partially covered children. */
976
+ lengths;
977
+ /**
978
+ * Allocate linear storage for the elementary intervals between coordinates.
979
+ * @param bounds - At least two sorted unique finite y endpoints.
980
+ */
981
+ constructor(bounds) {
982
+ this.bounds = bounds;
983
+ this.counts = Array(4 * bounds.length).fill(0);
984
+ this.lengths = Array(4 * bounds.length).fill(0);
985
+ }
986
+ /** Total active covered y length, in the input coordinate system. */
987
+ get coveredLength() {
988
+ return this.lengths[1];
989
+ }
990
+ /**
991
+ * Adjust a nonempty half-open interval and refresh its ancestors' lengths.
992
+ * @param start - Inclusive endpoint index; 0 <= start < end.
993
+ * @param end - Exclusive endpoint index; end < bounds.length.
994
+ * @param delta - One on entry, minus one for the matching departure.
995
+ * @param node - Internal tree slot; callers use the root default.
996
+ * @param left - Inclusive endpoint index of this node.
997
+ * @param right - Exclusive endpoint index of this node.
998
+ * @returns Nothing; mutates this tree's coverage in O(log n).
999
+ */
1000
+ update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
1001
+ if (start <= left && right <= end) this.counts[node] += delta;
1002
+ else {
1003
+ const middle = Math.floor((left + right) / 2);
1004
+ if (start < middle) this.update(start, end, delta, node * 2, left, middle);
1005
+ if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
1006
+ }
1007
+ 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];
1008
+ }
1009
+ };
1010
+ }));
1011
+
1012
+ //#endregion
1013
+ //#region src/core/utils/metadata/change/union-area.ts
1014
+ /**
1015
+ * Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
1016
+ * @param boxes - Finite rectangles; nonpositive dimensions are ignored.
1017
+ * @returns Covered area in the input coordinate system, or zero for empty input.
1018
+ */
1019
+ function unionArea(boxes) {
1020
+ const events = [];
1021
+ const endpoints = [];
1022
+ for (const box of boxes) {
1023
+ if (!(box.width > 0 && box.height > 0)) continue;
1024
+ const end = box.y + box.height;
1025
+ if (end === box.y) continue;
1026
+ events.push({
1027
+ x: box.x,
1028
+ start: box.y,
1029
+ end,
1030
+ delta: 1
1031
+ });
1032
+ events.push({
1033
+ x: box.x + box.width,
1034
+ start: box.y,
1035
+ end,
1036
+ delta: -1
1037
+ });
1038
+ endpoints.push(box.y, end);
1039
+ }
1040
+ if (events.length === 0) return 0;
1041
+ events.sort((a, b) => a.x - b.x);
1042
+ endpoints.sort((a, b) => a - b);
1043
+ const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
1044
+ const indices = new Map(bounds.map((value, index) => [value, index]));
1045
+ const coverage = new YCoverageTree(bounds);
1046
+ let area = 0;
1047
+ let previousX = events[0].x;
1048
+ for (const event of events) {
1049
+ area += (event.x - previousX) * coverage.coveredLength;
1050
+ coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
1051
+ previousX = event.x;
1052
+ }
1053
+ return area;
1054
+ }
1055
+ var init_union_area = __esmMin((() => {
1056
+ init_y_coverage_tree();
1057
+ }));
1058
+
1059
+ //#endregion
1060
+ //#region src/core/utils/metadata/build-edge-metadata.ts
1061
+ /**
1062
+ * Serialize raw candidate edges in graph order.
1063
+ * @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
1064
+ * @param analysisResolution - Analysis dimensions used for both area fractions.
1065
+ * @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
1066
+ */
1067
+ function buildEdgeMetadata(graph, analysisResolution) {
1068
+ const area = analysisResolution.width * analysisResolution.height;
1069
+ return map(graph, (edge) => ({
1070
+ sourceFrameId: edge.sourceId + 1,
1071
+ targetFrameId: edge.targetId + 1,
1072
+ score: Math.round(edge.score * 1e6) / 1e6,
1073
+ areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
1074
+ animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
1075
+ }));
1076
+ }
1077
+ var init_build_edge_metadata = __esmMin((() => {
1078
+ init_union_area();
1079
+ }));
1080
+
1081
+ //#endregion
1082
+ //#region src/core/utils/metadata/scale-bounding-box.ts
1083
+ /**
1084
+ * Convert an analysis box to clamped integer output pixels.
1085
+ * @param box - Analysis-space rectangle; the input remains unchanged.
1086
+ * @param sx - Horizontal output-to-analysis scale.
1087
+ * @param sy - Vertical output-to-analysis scale.
1088
+ * @param width - Nonnegative output image width.
1089
+ * @param height - Nonnegative output image height.
1090
+ * @returns A rectangle contained within the output dimensions.
1091
+ */
1092
+ function scaleBoundingBox(box, sx, sy, width, height) {
1093
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
1094
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
1095
+ return {
1096
+ x,
1097
+ y,
1098
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
1099
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
1100
+ };
1101
+ }
1102
+ var init_scale_bounding_box = __esmMin((() => {}));
1103
+
1104
+ //#endregion
1105
+ //#region src/core/utils/metadata/change/select-regions.ts
1106
+ /**
1107
+ * Select distinct positive-area boxes with deterministic area and coordinate ties.
1108
+ * @param boxes - Already scaled output rectangles; the input is not mutated.
1109
+ * @param limit - Nonnegative maximum number of regions.
1110
+ * @returns Largest boxes ordered by area descending, then y, x and width ascending.
1111
+ */
1112
+ function selectRegions(boxes, limit) {
1113
+ 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);
1114
+ }
1115
+ var init_select_regions = __esmMin((() => {}));
1116
+
1117
+ //#endregion
1118
+ //#region src/core/utils/metadata/change/build-frame-change.ts
1119
+ /**
1120
+ * Aggregate adjacent-pair evidence across one selected-frame span.
1121
+ * @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
1122
+ * @returns Rounded raw scores, analysis-space union ratio and output-space regions.
1123
+ */
1124
+ function buildFrameChange(input) {
1125
+ const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
1126
+ const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
1127
+ const area = analysisResolution.width * analysisResolution.height;
1128
+ const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
1129
+ const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
1130
+ const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
1131
+ const regions = area > 0 ? selectRegions(map(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
1132
+ return {
1133
+ fromFrameId,
1134
+ skippedCandidates,
1135
+ peakScore: Math.round(peakScore * 1e6) / 1e6,
1136
+ sumScore: Math.round(sumScore * 1e6) / 1e6,
1137
+ areaRatio: Math.round(areaRatio * 1e4) / 1e4,
1138
+ regions
1139
+ };
1140
+ }
1141
+ var init_build_frame_change = __esmMin((() => {
1142
+ init_scale_bounding_box();
1143
+ init_select_regions();
1144
+ init_union_area();
1145
+ }));
1146
+
1147
+ //#endregion
1148
+ //#region src/core/utils/metadata/build-frame-metadata.ts
1149
+ /**
1150
+ * Describe selected frames using candidate adjacency rather than synthetic pruning edges.
1151
+ * @param input - Chronological candidates and selections, raw graph and output dimensions.
1152
+ * @returns One-based frame summaries with rounded timestamps and nonnegative holds.
1153
+ */
1154
+ function buildFrameMetadata(input) {
1155
+ const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
1156
+ const edgesByPair = new Map(map(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
1157
+ const candidateIndices = new Map(map(frames, (frame, index) => [frame.id, index]));
1158
+ const padding = Math.max(4, String(frames.length).length);
1159
+ return map(selected, (frame, index) => {
1160
+ const timestampMs = Math.round(frame.timestamp * 1e3);
1161
+ const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
1162
+ const previous = selected[index - 1];
1163
+ const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
1164
+ const currentIndex = candidateIndices.get(frame.id);
1165
+ const spanEdges = [];
1166
+ if (previous) for (let i = previousIndex; i < currentIndex; i++) {
1167
+ const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
1168
+ if (edge) spanEdges.push(edge);
1169
+ }
1170
+ return {
1171
+ step: index + 1,
1172
+ fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
1173
+ frameId: frame.id + 1,
1174
+ timestampMs,
1175
+ holdsMs: Math.max(0, nextTimestampMs - timestampMs),
1176
+ change: previous ? buildFrameChange({
1177
+ spanEdges,
1178
+ fromFrameId: previous.id + 1,
1179
+ skippedCandidates: currentIndex - previousIndex - 1,
1180
+ analysisResolution,
1181
+ outputResolution,
1182
+ regionLimit: 5
1183
+ }) : null
1184
+ };
1185
+ });
1186
+ }
1187
+ var init_build_frame_metadata = __esmMin((() => {
1188
+ init_pipeline_defaults();
1189
+ init_build_frame_change();
1190
+ }));
1191
+
1192
+ //#endregion
1193
+ //#region src/core/utils/metadata/build-tool-metadata.ts
1194
+ /**
1195
+ * Record the tool and the nine selection and encoding settings in contract order.
1196
+ * @param options - Validated pipeline settings; operational options are excluded.
1197
+ * @param version - Runtime package version supplied by the I/O boundary.
1198
+ * @returns Deterministic tool provenance without paths or execution details.
1199
+ */
1200
+ function buildToolMetadata(options, version) {
1201
+ return {
1202
+ name: "@lumy-pack/scene-sieve",
1203
+ version,
1204
+ params: {
1205
+ fps: options.fps,
1206
+ count: options.count,
1207
+ threshold: options.threshold,
1208
+ scale: options.scale,
1209
+ quality: options.quality,
1210
+ maxFrames: options.maxFrames,
1211
+ iouThreshold: options.iouThreshold,
1212
+ animationThreshold: options.animationThreshold,
1213
+ maxSegmentDuration: options.maxSegmentDuration
1214
+ }
1215
+ };
1216
+ }
1217
+ var init_build_tool_metadata = __esmMin((() => {}));
1218
+
1219
+ //#endregion
1220
+ //#region src/core/utils/metadata/build-sieve-metadata.ts
1221
+ /**
1222
+ * Assemble the complete v2 document without I/O or mutation.
1223
+ * @param input - Pipeline state, selections, output-space video and zero-based animations.
1224
+ * @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
1225
+ */
1226
+ function buildSieveMetadata(input) {
1227
+ const { ctx, selected, video, animations, version, sheet } = input;
1228
+ const analysisResolution = ctx.analysisResolution ?? {
1229
+ width: 0,
1230
+ height: 0
1231
+ };
1232
+ return {
1233
+ metadataVersion: 2,
1234
+ tool: buildToolMetadata(ctx.options, version),
1235
+ video,
1236
+ frames: buildFrameMetadata({
1237
+ frames: ctx.frames,
1238
+ graph: ctx.graph,
1239
+ selected,
1240
+ originalDurationMs: video.originalDurationMs,
1241
+ analysisResolution,
1242
+ outputResolution: video.resolution
1243
+ }),
1244
+ animations: map(animations, (animation) => ({
1245
+ ...animation,
1246
+ startFrameId: animation.startFrameId + 1,
1247
+ endFrameId: animation.endFrameId + 1,
1248
+ durationMs: Math.round(animation.durationMs)
1249
+ })),
1250
+ ...sheet ? { sheet } : {},
1251
+ ...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
1252
+ };
1253
+ }
1254
+ var init_build_sieve_metadata = __esmMin((() => {
1255
+ init_pipeline_defaults();
1256
+ init_build_edge_metadata();
1257
+ init_build_frame_metadata();
1258
+ init_build_tool_metadata();
1259
+ }));
1260
+
1261
+ //#endregion
1262
+ //#region src/core/utils/metadata/build-video-metadata.ts
1263
+ /**
1264
+ * Read output dimensions and build consistent video and animation metadata.
1265
+ * JPEG finalization does not resize, so the source dimensions match the output.
1266
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
1267
+ * @param selected Selected frames in output order; the first candidate is the fallback.
1268
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
1269
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
1270
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
1271
+ */
1272
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
1273
+ const firstFrame = selected[0] ?? ctx.frames[0];
1274
+ const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
1275
+ const width = dimensions?.width ?? 0;
1276
+ const height = dimensions?.height ?? 0;
1277
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
1278
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
1279
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
1280
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
1281
+ return {
1282
+ video: {
1283
+ originalDurationMs: Math.round(duration * 1e3),
1284
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
1285
+ resolution: {
1286
+ width,
1287
+ height
1288
+ },
1289
+ candidatesCount: ctx.frames.length,
1290
+ selectedCount: selected.length,
1291
+ source: {
1292
+ fileName: ctx.options.mode === "file" && ctx.options.inputPath ? basename(ctx.options.inputPath) : null,
1293
+ mode: ctx.options.mode
1294
+ }
1295
+ },
1296
+ animations: (ctx.animations ?? []).map((animation) => ({
1297
+ ...animation,
1298
+ boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
1299
+ }))
635
1300
  };
1301
+ }
1302
+ var init_build_video_metadata = __esmMin((() => {
1303
+ init_scale_bounding_box();
636
1304
  }));
637
1305
 
638
1306
  //#endregion
639
- //#region src/utils/paths.ts
640
- async function ensureDir(dirPath) {
641
- await mkdir(dirPath, { recursive: true });
1307
+ //#region src/core/utils/sheet/build-tile-label-svg.ts
1308
+ /**
1309
+ * Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
1310
+ * @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
1311
+ * @param tileWidth - Positive tile canvas width in pixels.
1312
+ * @param tileHeight - Positive tile canvas height in pixels.
1313
+ * @returns Encoded SVG bytes for a top-left sharp composite overlay.
1314
+ */
1315
+ function buildTileLabelSvg(text, tileWidth, tileHeight) {
1316
+ const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
1317
+ const padX = Math.round(fontSize * .4);
1318
+ const padY = Math.round(fontSize * .2);
1319
+ const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
1320
+ 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>`);
642
1321
  }
643
- async function fileExists(filePath) {
644
- try {
645
- await stat(filePath);
646
- return true;
647
- } catch {
648
- return false;
649
- }
1322
+ var init_build_tile_label_svg = __esmMin((() => {
1323
+ init_pipeline_defaults();
1324
+ }));
1325
+
1326
+ //#endregion
1327
+ //#region src/core/utils/sheet/format-tile-label.ts
1328
+ /**
1329
+ * Format a contact sheet label without rounding into the next tenth of a second.
1330
+ * @param frameId - One-based candidate frame ID.
1331
+ * @param timestampMs - Nonnegative candidate time in milliseconds.
1332
+ * @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
1333
+ */
1334
+ function formatTileLabel(frameId, timestampMs) {
1335
+ const tenths = Math.floor(timestampMs / 100);
1336
+ return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
650
1337
  }
1338
+ var init_format_tile_label = __esmMin((() => {}));
1339
+
1340
+ //#endregion
1341
+ //#region src/core/utils/sheet/sample-tile-frames.ts
651
1342
  /**
652
- * Expand leading ~ to homedir. Node's path.resolve() does not expand ~,
653
- * so paths like ~/Desktop/foo depend on process.cwd() and can produce
654
- * different results when run from different directories.
1343
+ * Sample a sequence uniformly while always retaining both endpoints.
1344
+ * @param items - Items in temporal order.
1345
+ * @param maxTiles - Validated integer limit of at least two.
1346
+ * @returns The original sequence when within the limit, otherwise evenly sampled items.
655
1347
  */
656
- function expandTilde(p) {
657
- if (p === "~") return homedir();
658
- if (p.startsWith("~/") || p.startsWith("~\\")) return resolve(homedir(), p.slice(2));
659
- return p;
1348
+ function sampleTileFrames(items, maxTiles) {
1349
+ if (items.length <= maxTiles) return {
1350
+ items,
1351
+ sampled: false
1352
+ };
1353
+ return {
1354
+ items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
1355
+ sampled: true
1356
+ };
660
1357
  }
1358
+ var init_sample_tile_frames = __esmMin((() => {}));
1359
+
1360
+ //#endregion
1361
+ //#region src/core/utils/sheet/render-contact-sheet.ts
661
1362
  /**
662
- * Resolve path to absolute. Expands ~ to homedir first so that the result
663
- * does not depend on process.cwd().
1363
+ * Read selected frame images and render a row-major contact sheet.
1364
+ * @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
1365
+ * @returns JPEG bytes and the effective tile layout with one-based frame IDs.
1366
+ * @throws Propagates sharp image reading, compositing or encoding errors.
664
1367
  */
665
- function resolveAbsolute(p) {
666
- return resolve(expandTilde(p));
1368
+ async function renderContactSheet(input) {
1369
+ const { selected, resolution, options, quality } = input;
1370
+ const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
1371
+ const columns = Math.min(options.columns, items.length);
1372
+ const rows = Math.ceil(items.length / columns);
1373
+ const tileWidth = options.tileWidth;
1374
+ const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
1375
+ const gap = 4;
1376
+ const tiles = await Promise.all(map(items, async (frame, index) => {
1377
+ const tile = sharp(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
1378
+ if (options.label) {
1379
+ const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
1380
+ tile.composite([{
1381
+ input: buildTileLabelSvg(text, tileWidth, tileHeight),
1382
+ top: 0,
1383
+ left: 0
1384
+ }]);
1385
+ }
1386
+ return {
1387
+ input: await tile.png().toBuffer(),
1388
+ top: gap + Math.floor(index / columns) * (tileHeight + gap),
1389
+ left: gap + index % columns * (tileWidth + gap)
1390
+ };
1391
+ }));
1392
+ return {
1393
+ buffer: await sharp({ create: {
1394
+ width: columns * tileWidth + (columns + 1) * gap,
1395
+ height: rows * tileHeight + (rows + 1) * gap,
1396
+ channels: 3,
1397
+ background: "white"
1398
+ } }).composite(tiles).jpeg({
1399
+ quality,
1400
+ mozjpeg: true
1401
+ }).toBuffer(),
1402
+ metadata: {
1403
+ fileName: SHEET_FILE_NAME,
1404
+ columns,
1405
+ tileWidth,
1406
+ tileHeight,
1407
+ frameIds: map(items, (frame) => frame.id + 1),
1408
+ sampled
1409
+ }
1410
+ };
667
1411
  }
1412
+ var init_render_contact_sheet = __esmMin((() => {
1413
+ init_pipeline_defaults();
1414
+ init_build_tile_label_svg();
1415
+ init_format_tile_label();
1416
+ init_sample_tile_frames();
1417
+ }));
1418
+
1419
+ //#endregion
1420
+ //#region src/core/utils/output/finalize-selection.ts
668
1421
  /**
669
- * Derive default output directory name from input file path.
670
- * e.g., /path/to/video.mp4 -> /path/to/video_scenes
1422
+ * Read output dimensions once, render optional sheets and finalize the shared v2 document.
1423
+ * @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
1424
+ * @param selected - Selected frames in temporal order.
1425
+ * @returns Mode-specific output, the document and zero-based API animations.
1426
+ * @throws Propagates image, rendering and output I/O errors to the orchestrator.
671
1427
  */
672
- function deriveOutputPath(inputPath) {
673
- return resolve(resolve(inputPath, ".."), `${basename(inputPath, extname(inputPath))}_scenes`);
1428
+ async function finalizeSelection(ctx, selected) {
1429
+ const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
1430
+ const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
1431
+ selected,
1432
+ resolution: video.resolution,
1433
+ options: ctx.options.sheet,
1434
+ quality: ctx.options.quality
1435
+ }) : void 0;
1436
+ const document = buildSieveMetadata({
1437
+ ctx,
1438
+ selected,
1439
+ video,
1440
+ animations,
1441
+ version: PACKAGE_VERSION,
1442
+ ...sheet ? { sheet: sheet.metadata } : {}
1443
+ });
1444
+ if (ctx.options.mode === "file") return {
1445
+ outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
1446
+ document,
1447
+ animations
1448
+ };
1449
+ return {
1450
+ outputFiles: [],
1451
+ outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
1452
+ document,
1453
+ animations,
1454
+ ...sheet ? { sheetBuffer: sheet.buffer } : {}
1455
+ };
674
1456
  }
675
- var init_paths = __esmMin((() => {}));
1457
+ var init_finalize_selection = __esmMin((() => {
1458
+ init_package_version();
1459
+ init_workspace();
1460
+ init_build_sieve_metadata();
1461
+ init_build_video_metadata();
1462
+ init_render_contact_sheet();
1463
+ }));
676
1464
 
677
1465
  //#endregion
678
- //#region src/core/extractor.ts
1466
+ //#region src/core/extractor/extractor.ts
679
1467
  /**
680
1468
  * Extract frames from video/GIF using FFmpeg.
681
- * Always uses FPS-based extraction. For long videos, FPS is automatically
682
- * reduced to stay within maxFrames budget.
1469
+ * @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
1470
+ * @returns Extracted candidates, or the unchanged input array in frames mode.
1471
+ * @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
683
1472
  */
684
1473
  async function extractFrames(ctx) {
1474
+ if (ctx.options.mode === "frames") {
1475
+ ctx.effectiveFps = 1;
1476
+ return ctx.frames;
1477
+ }
685
1478
  const framesDir = join(ctx.workspacePath, "frames");
686
1479
  const { inputPath, fps, maxFrames, scale } = ctx.options;
687
1480
  if (!inputPath) throw new Error("inputPath is required for frame extraction");
@@ -696,19 +1489,30 @@ async function extractFrames(ctx) {
696
1489
  if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
697
1490
  logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
698
1491
  await ensureDir(framesDir);
1492
+ const frameLimit = Math.max(2, maxFrames);
699
1493
  let effectiveFps = fps;
700
1494
  if (duration > 0) {
701
- const fpsCap = maxFrames / duration;
1495
+ const fpsCap = frameLimit / duration;
702
1496
  effectiveFps = Math.min(fps, fpsCap);
703
- effectiveFps = Math.max(.5, effectiveFps);
704
1497
  logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
705
1498
  }
706
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, duration);
1499
+ ctx.effectiveFps = effectiveFps;
1500
+ ctx.sourceDurationSec = duration;
1501
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
707
1502
  ctx.emitProgress(100);
708
1503
  logger.debug(`Extracted ${frames.length} frames`);
709
1504
  return frames;
710
1505
  }
711
- async function extractByFps(inputPath, outputDir, fps, scale, duration) {
1506
+ /**
1507
+ * Write scaled JPEG candidates through the bundled FFmpeg runtime.
1508
+ * @param inputPath Readable video input.
1509
+ * @param outputDir Existing frame directory.
1510
+ * @param fps Positive effective sampling frequency.
1511
+ * @param scale Output image height.
1512
+ * @param frameLimit Maximum number of output frames.
1513
+ * @returns Candidates with local output-grid timestamps; rejects on extraction failure.
1514
+ */
1515
+ async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
712
1516
  const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
713
1517
  await execa(ffmpegPath, [
714
1518
  "-i",
@@ -717,9 +1521,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
717
1521
  `fps=${fps},scale=-1:${scale}`,
718
1522
  "-q:v",
719
1523
  "2",
1524
+ "-frames:v",
1525
+ String(frameLimit),
720
1526
  outputPattern
721
1527
  ]);
722
- return buildFrameList(outputDir, duration);
1528
+ return buildFrameList(outputDir, fps);
723
1529
  }
724
1530
  async function getVideoMetadata(inputPath) {
725
1531
  const { stdout } = await execa(path, [
@@ -733,12 +1539,19 @@ async function getVideoMetadata(inputPath) {
733
1539
  ]);
734
1540
  return JSON.parse(stdout);
735
1541
  }
736
- async function buildFrameList(framesDir, duration) {
737
- const jpgFiles = filter(await readdir(framesDir), (f) => f.endsWith(".jpg")).sort();
1542
+ /**
1543
+ * Read sorted JPEG paths and attach local output-grid times.
1544
+ * @param framesDir Extracted frame directory; filesystem errors propagate.
1545
+ * @param effectiveFps Positive frequency used by the fps filter.
1546
+ * @returns Zero-based candidates without a segment seek offset.
1547
+ */
1548
+ async function buildFrameList(framesDir, effectiveFps) {
1549
+ const files = await readdir(framesDir);
1550
+ const jpgFiles = filter(files, (f) => f.endsWith(".jpg")).sort();
738
1551
  if (jpgFiles.length === 0) return [];
739
1552
  return map(jpgFiles, (file, index) => ({
740
1553
  id: index,
741
- timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
1554
+ timestamp: index / effectiveFps,
742
1555
  extractPath: join(framesDir, file)
743
1556
  }));
744
1557
  }
@@ -752,9 +1565,10 @@ async function buildFrameList(framesDir, duration) {
752
1565
  * @param scale - Height scale for vision analysis
753
1566
  * @param startTime - Start time in seconds
754
1567
  * @param duration - Duration in seconds to extract
1568
+ * @param frameLimit - Positive output limit; defaults to the range's grid capacity
755
1569
  * @returns Array of FrameNode with segment-local timestamps (starting from 0)
756
1570
  */
757
- async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
1571
+ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
758
1572
  const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
759
1573
  await execa(ffmpegPath, [
760
1574
  "-ss",
@@ -767,169 +1581,157 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
767
1581
  `fps=${fps},scale=-1:${scale}`,
768
1582
  "-q:v",
769
1583
  "2",
1584
+ "-frames:v",
1585
+ String(frameLimit),
770
1586
  outputPattern
771
1587
  ]);
772
- return buildFrameList(outputDir, duration);
1588
+ return buildFrameList(outputDir, fps);
773
1589
  }
774
- var init_extractor = __esmMin((() => {
775
- init_constants();
1590
+ var init_extractor$1 = __esmMin((() => {
1591
+ init_workspace_layout();
776
1592
  init_logger();
777
1593
  init_paths();
778
1594
  }));
779
1595
 
780
1596
  //#endregion
781
- //#region src/core/workspace.ts
782
- async function createWorkspace(sessionId) {
783
- const workspacePath = getTempWorkspaceDir(sessionId);
784
- await ensureDir(join(workspacePath, "frames"));
785
- await ensureDir(join(workspacePath, "output"));
786
- return workspacePath;
787
- }
788
- async function finalizeOutput(ctx, selectedFrames) {
789
- const stagingDir = join(ctx.workspacePath, "output");
790
- const outputPath = ctx.options.outputPath;
791
- const quality = ctx.options.quality;
792
- const outputFiles = [];
793
- const framesMetadata = [];
794
- const totalFramesCount = ctx.frames.length;
795
- const padding = Math.max(4, String(totalFramesCount).length);
796
- for (let i = 0; i < selectedFrames.length; i++) {
797
- const frame = selectedFrames[i];
798
- const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
799
- const destPath = join(stagingDir, fileName);
800
- await sharp(frame.extractPath).jpeg({
801
- quality,
802
- mozjpeg: true
803
- }).toFile(destPath);
804
- outputFiles.push(join(outputPath, fileName));
805
- framesMetadata.push({
806
- step: i + 1,
807
- fileName,
808
- frameId: frame.id + 1,
809
- timestampMs: Math.round(frame.timestamp * 1e3)
810
- });
811
- }
812
- const metadata = {
813
- video: {
814
- originalDurationMs: Math.round((ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3),
815
- fps: ctx.options.fps,
816
- resolution: {
817
- width: ctx.options.scale,
818
- height: Math.round(ctx.options.scale * 9 / 16)
819
- }
820
- },
821
- frames: framesMetadata,
822
- animations: map(ctx.animations || [], (anim) => ({
823
- ...anim,
824
- startFrameId: anim.startFrameId + 1,
825
- endFrameId: anim.endFrameId + 1,
826
- durationMs: Math.round(anim.durationMs)
827
- }))
828
- };
829
- await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
830
- outputFiles.push(join(outputPath, ".metadata.json"));
831
- await ensureDir(join(outputPath, ".."));
832
- await rm(outputPath, {
833
- recursive: true,
834
- force: true
835
- });
836
- await rename(stagingDir, outputPath);
837
- return outputFiles;
838
- }
839
- async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
840
- const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
841
- await ensureDir(join(segmentPath, "frames"));
842
- return segmentPath;
843
- }
844
- async function cleanupWorkspace(workspacePath) {
845
- if (!workspacePath) return;
846
- try {
847
- await rm(workspacePath, {
848
- recursive: true,
849
- force: true
850
- });
851
- } catch {}
852
- }
1597
+ //#region src/core/extractor/index.ts
1598
+ var init_extractor = __esmMin((() => {
1599
+ init_extractor$1();
1600
+ }));
1601
+
1602
+ //#endregion
1603
+ //#region src/core/input-resolver/validation/validate-options.ts
853
1604
  /**
854
- * Remove stale workspace directories left by previous interrupted runs.
855
- * Only deletes directories older than 1 hour to avoid removing active workspaces.
1605
+ * Reject invalid numeric options before defaults or pipeline effects are applied.
1606
+ * @param options - Supplied options; omitted numeric fields use pipeline defaults.
1607
+ * @returns Nothing when all supplied numeric fields satisfy their contracts.
1608
+ * @throws An input error naming the invalid option and its received value.
856
1609
  */
857
- async function cleanupStaleWorkspaces() {
858
- const entries = await readdir(TEMP_BASE_DIR);
859
- const now = Date.now();
860
- for (const entry of entries) {
861
- if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
862
- const fullPath = join(TEMP_BASE_DIR, entry);
863
- try {
864
- const info = await stat(fullPath);
865
- if (info.isDirectory() && now - info.mtimeMs > STALE_THRESHOLD_MS) await rm(fullPath, {
866
- recursive: true,
867
- force: true
868
- });
869
- } catch {}
1610
+ function validateOptions(options) {
1611
+ for (const [name, min, max, integer, exclusiveMin, requirement] of [
1612
+ [
1613
+ "count",
1614
+ 1,
1615
+ Infinity,
1616
+ true,
1617
+ false,
1618
+ "an integer >= 1"
1619
+ ],
1620
+ [
1621
+ "threshold",
1622
+ 0,
1623
+ 1,
1624
+ false,
1625
+ true,
1626
+ "in range (0, 1] and finite"
1627
+ ],
1628
+ [
1629
+ "fps",
1630
+ 0,
1631
+ Infinity,
1632
+ false,
1633
+ true,
1634
+ "finite and > 0"
1635
+ ],
1636
+ [
1637
+ "maxFrames",
1638
+ 2,
1639
+ Infinity,
1640
+ true,
1641
+ false,
1642
+ "an integer >= 2"
1643
+ ],
1644
+ [
1645
+ "scale",
1646
+ 16,
1647
+ Infinity,
1648
+ true,
1649
+ false,
1650
+ "an integer >= 16"
1651
+ ],
1652
+ [
1653
+ "quality",
1654
+ 1,
1655
+ 100,
1656
+ true,
1657
+ false,
1658
+ "an integer in range [1, 100]"
1659
+ ],
1660
+ [
1661
+ "iouThreshold",
1662
+ 0,
1663
+ 1,
1664
+ false,
1665
+ false,
1666
+ "finite and in range [0, 1]"
1667
+ ],
1668
+ [
1669
+ "animationThreshold",
1670
+ 1,
1671
+ Infinity,
1672
+ true,
1673
+ false,
1674
+ "an integer >= 1"
1675
+ ],
1676
+ [
1677
+ "maxSegmentDuration",
1678
+ 0,
1679
+ Infinity,
1680
+ false,
1681
+ true,
1682
+ "finite and > 0"
1683
+ ],
1684
+ [
1685
+ "concurrency",
1686
+ 1,
1687
+ Infinity,
1688
+ true,
1689
+ false,
1690
+ "an integer >= 1"
1691
+ ]
1692
+ ]) {
1693
+ const value = options[name];
1694
+ if (value === void 0) continue;
1695
+ if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
870
1696
  }
871
- }
872
- /**
873
- * Write a video buffer to a temp file in the workspace and return the path.
874
- * Used by 'buffer' input mode.
875
- */
876
- async function writeInputBuffer(buffer, workspacePath) {
877
- const inputDir = join(workspacePath, "input");
878
- await ensureDir(inputDir);
879
- const tempPath = join(inputDir, "input.mp4");
880
- await writeFile(tempPath, buffer);
881
- return tempPath;
882
- }
883
- /**
884
- * Write an array of frame Buffers as JPG files and return FrameNode[].
885
- * Used by 'frames' input mode.
886
- */
887
- async function writeInputFrames(frames, workspacePath) {
888
- const framesDir = join(workspacePath, "frames");
889
- await ensureDir(framesDir);
890
- const frameNodes = [];
891
- for (let i = 0; i < frames.length; i++) {
892
- const extractPath = join(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
893
- await writeFile(extractPath, frames[i]);
894
- frameNodes.push({
895
- id: i,
896
- timestamp: i,
897
- extractPath
898
- });
1697
+ const sheet = options.sheet;
1698
+ if (sheet === void 0 || typeof sheet === "boolean") return;
1699
+ 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)}`);
1700
+ for (const [name, min] of [
1701
+ ["columns", 1],
1702
+ ["tileWidth", 16],
1703
+ ["maxTiles", 2]
1704
+ ]) {
1705
+ const value = sheet[name];
1706
+ if (value === void 0) continue;
1707
+ if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
899
1708
  }
900
- return frameNodes;
901
- }
902
- /**
903
- * Read selected FrameNode files as Buffers with JPEG compression.
904
- * Used to return output buffers in 'buffer' and 'frames' modes.
905
- */
906
- async function readFramesAsBuffers(frameNodes, quality) {
907
- return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
908
- quality,
909
- mozjpeg: true
910
- }).toBuffer()));
1709
+ if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
911
1710
  }
912
- var STALE_THRESHOLD_MS;
913
- var init_workspace = __esmMin((() => {
914
- init_constants();
915
- init_paths();
916
- STALE_THRESHOLD_MS = 3600 * 1e3;
917
- }));
1711
+ var init_validate_options = __esmMin((() => {}));
918
1712
 
919
1713
  //#endregion
920
- //#region src/core/input-resolver.ts
1714
+ //#region src/core/input-resolver/input-resolver.ts
1715
+ /**
1716
+ * Validate supplied options and resolve defaults and paths for the pipeline.
1717
+ * @param options - Mode-specific input and optional numeric settings.
1718
+ * @returns Complete pipeline settings with absolute file input paths.
1719
+ * @throws An input error if a supplied numeric setting is invalid.
1720
+ */
921
1721
  function resolveOptions(options) {
1722
+ validateOptions(options);
922
1723
  const mode = options.mode;
923
1724
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
924
1725
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
925
1726
  const threshold = options.threshold ?? .5;
926
- if (threshold <= 0 || threshold > 1) throw new Error(`threshold must be in range (0, 1], received: ${threshold}`);
1727
+ const pruneMode = "threshold-with-cap";
1728
+ const sheet = typeof options.sheet === "object" ? options.sheet : {};
927
1729
  return {
928
1730
  mode,
929
1731
  inputPath,
930
1732
  count: options.count ?? 20,
931
1733
  threshold,
932
- pruneMode: "threshold-with-cap",
1734
+ pruneMode,
933
1735
  outputPath,
934
1736
  fps: options.fps ?? 5,
935
1737
  maxFrames: options.maxFrames ?? 300,
@@ -939,7 +1741,14 @@ function resolveOptions(options) {
939
1741
  animationThreshold: options.animationThreshold ?? 5,
940
1742
  debug: options.debug ?? false,
941
1743
  maxSegmentDuration: options.maxSegmentDuration ?? 300,
942
- concurrency: options.concurrency ?? 2
1744
+ concurrency: options.concurrency ?? 2,
1745
+ sheet: options.sheet ? {
1746
+ columns: sheet.columns ?? 4,
1747
+ tileWidth: sheet.tileWidth ?? 320,
1748
+ maxTiles: sheet.maxTiles ?? 40,
1749
+ label: sheet.label ?? true
1750
+ } : null,
1751
+ includeEdges: options.includeEdges ?? false
943
1752
  };
944
1753
  }
945
1754
  /**
@@ -948,6 +1757,10 @@ function resolveOptions(options) {
948
1757
  * - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
949
1758
  * - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
950
1759
  * - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
1760
+ * @param options - Input source; encoded frames must have matching dimensions.
1761
+ * @param workspacePath - Workspace receiving temporary input files.
1762
+ * @returns Frame nodes or a resolved video path for extraction.
1763
+ * @throws Propagates metadata or write errors and rejects mismatched frame sizes.
951
1764
  */
952
1765
  async function resolveInput(options, workspacePath) {
953
1766
  if (options.mode === "file") return {
@@ -958,17 +1771,51 @@ async function resolveInput(options, workspacePath) {
958
1771
  frames: [],
959
1772
  resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
960
1773
  };
961
- if (options.mode === "frames") return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1774
+ if (options.mode === "frames") {
1775
+ let dimensions;
1776
+ for (const buffer of options.inputFrames) {
1777
+ const { width, height } = await sharp(buffer).metadata();
1778
+ 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}`);
1779
+ dimensions = {
1780
+ width,
1781
+ height
1782
+ };
1783
+ }
1784
+ return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1785
+ }
962
1786
  throw new Error(`Unsupported input mode: ${options.mode}`);
963
1787
  }
964
- var init_input_resolver = __esmMin((() => {
965
- init_constants();
1788
+ var init_input_resolver$1 = __esmMin((() => {
1789
+ init_pipeline_defaults();
966
1790
  init_paths();
1791
+ init_validate_options();
967
1792
  init_workspace();
968
1793
  }));
969
1794
 
970
1795
  //#endregion
971
- //#region src/utils/math.ts
1796
+ //#region src/core/input-resolver/index.ts
1797
+ var init_input_resolver = __esmMin((() => {
1798
+ init_input_resolver$1();
1799
+ }));
1800
+
1801
+ //#endregion
1802
+ //#region src/core/pruner/scoring/normalize-scores.ts
1803
+ /**
1804
+ * Find the first position whose score is at least the requested value.
1805
+ * @param sorted - Finite positive scores sorted in ascending order.
1806
+ * @param value - A finite positive score present in sorted.
1807
+ * @returns The first matching rank, including the first position of any tie.
1808
+ */
1809
+ function lowerBound(sorted, value) {
1810
+ let low = 0;
1811
+ let high = sorted.length;
1812
+ while (low < high) {
1813
+ const mid = Math.floor((low + high) / 2);
1814
+ if (sorted[mid] < value) low = mid + 1;
1815
+ else high = mid;
1816
+ }
1817
+ return low;
1818
+ }
972
1819
  /**
973
1820
  * Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
974
1821
  *
@@ -1013,16 +1860,19 @@ function normalizeScores(items) {
1013
1860
  });
1014
1861
  const cdf = map(safeScores, (s) => {
1015
1862
  if (s <= 0) return 0;
1016
- return sorted.findIndex((v) => v >= s) / sorted.length;
1863
+ return lowerBound(sorted, s) / sorted.length;
1017
1864
  });
1018
- return map(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
1865
+ return map(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
1019
1866
  }
1020
- var init_math = __esmMin((() => {
1021
- init_constants();
1867
+ var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
1868
+ var init_normalize_scores = __esmMin((() => {
1869
+ NORMALIZATION_ALPHA = .4;
1870
+ NORMALIZATION_MAD_COEFFICIENT = 1.4826;
1871
+ NORMALIZATION_MIN_SAMPLE_SIZE = 10;
1022
1872
  }));
1023
1873
 
1024
1874
  //#endregion
1025
- //#region src/utils/min-heap.ts
1875
+ //#region src/core/pruner/heap/min-heap.ts
1026
1876
  var MinHeap;
1027
1877
  var init_min_heap = __esmMin((() => {
1028
1878
  MinHeap = class {
@@ -1070,7 +1920,7 @@ var init_min_heap = __esmMin((() => {
1070
1920
  }));
1071
1921
 
1072
1922
  //#endregion
1073
- //#region src/core/pruner.ts
1923
+ //#region src/core/pruner/pruner.ts
1074
1924
  /**
1075
1925
  * Edge-aware greedy merge with re-linking — O(N log N).
1076
1926
  *
@@ -1184,7 +2034,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
1184
2034
  return result;
1185
2035
  }
1186
2036
  /**
1187
- * Threshold-based pruning with NMS -- O(N).
2037
+ * Threshold-based pruning with NMS -- including normalization, O(N log N).
1188
2038
  *
1189
2039
  * 1. Scores are normalized to [0, 1] via percentile normalization.
1190
2040
  * 2. Edges with normalized score >= threshold are collected.
@@ -1247,13 +2097,19 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1247
2097
  }
1248
2098
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
1249
2099
  }
1250
- var init_pruner = __esmMin((() => {
1251
- init_math();
2100
+ var init_pruner$1 = __esmMin((() => {
2101
+ init_normalize_scores();
1252
2102
  init_min_heap();
1253
2103
  }));
1254
2104
 
1255
2105
  //#endregion
1256
- //#region src/utils/concurrency.ts
2106
+ //#region src/core/pruner/index.ts
2107
+ var init_pruner = __esmMin((() => {
2108
+ init_pruner$1();
2109
+ }));
2110
+
2111
+ //#endregion
2112
+ //#region src/core/segmenter/scheduling/concurrency.ts
1257
2113
  /**
1258
2114
  * Creates a concurrency limiter that runs at most `limit` tasks in parallel.
1259
2115
  * Lightweight replacement for p-limit to avoid external dependency.
@@ -1276,7 +2132,7 @@ function concurrencyLimit(limit) {
1276
2132
  var init_concurrency = __esmMin((() => {}));
1277
2133
 
1278
2134
  //#endregion
1279
- //#region src/core/segmenter.ts
2135
+ //#region src/core/segmenter/segmenter.ts
1280
2136
  /**
1281
2137
  * Determine whether segmentation should be used.
1282
2138
  * Returns false for frames mode and GIF files.
@@ -1290,53 +2146,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
1290
2146
  return true;
1291
2147
  }
1292
2148
  /**
1293
- * Compute segment boundaries with overlap, frame allocation, and effectiveFps.
1294
- * Pure function — no I/O.
1295
- *
1296
- * - effectiveFps is uniform across all segments
1297
- * - Overlap: 1 frame at each internal boundary
1298
- * - allocatedFrames total <= maxFrames (last segment adjusted if needed)
2149
+ * Partition the global extraction grid into nonempty logical segments.
2150
+ * @param totalDuration Positive source duration in seconds.
2151
+ * @param maxSegmentDuration Positive logical segment width in seconds.
2152
+ * @param maxFrames Candidate budget, defensively raised to at least two.
2153
+ * @param fps Positive requested sampling frequency.
2154
+ * @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
1299
2155
  */
1300
2156
  function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
1301
- const effectiveFps = Math.max(.5, Math.min(fps, maxFrames / totalDuration));
2157
+ const frameLimit = Math.max(2, maxFrames);
2158
+ const effectiveFps = Math.min(fps, frameLimit / totalDuration);
1302
2159
  if (totalDuration <= maxSegmentDuration) return [{
1303
2160
  index: 0,
1304
2161
  startTime: 0,
1305
2162
  endTime: totalDuration,
1306
2163
  duration: totalDuration,
1307
- allocatedFrames: Math.min(Math.ceil(effectiveFps * totalDuration), maxFrames),
2164
+ allocatedFrames: frameLimit,
1308
2165
  effectiveFps,
1309
2166
  overlapBefore: 0,
1310
2167
  overlapAfter: 0,
1311
2168
  extractStartTime: 0,
1312
2169
  extractDuration: totalDuration
1313
2170
  }];
1314
- const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
1315
- const overlapTime = 1 / effectiveFps;
1316
2171
  const segments = [];
1317
- for (let i = 0; i < segmentCount; i++) {
1318
- const startTime = i * maxSegmentDuration;
1319
- const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
1320
- const duration = endTime - startTime;
1321
- const overlapBefore = i > 0 ? 1 : 0;
1322
- const overlapAfter = i < segmentCount - 1 ? 1 : 0;
1323
- const extractStartTime = Math.max(0, startTime - overlapBefore * overlapTime);
1324
- const extractDuration = Math.min(totalDuration, endTime + overlapAfter * overlapTime) - extractStartTime;
2172
+ for (let slot = 0; slot < frameLimit; slot++) {
2173
+ const timestamp = slot / effectiveFps;
2174
+ if (timestamp >= totalDuration) break;
2175
+ const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
2176
+ const previous = segments[segments.length - 1];
2177
+ if (previous?.startTime === startTime) {
2178
+ previous.allocatedFrames++;
2179
+ continue;
2180
+ }
2181
+ const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
1325
2182
  segments.push({
1326
- index: i,
2183
+ index: segments.length,
1327
2184
  startTime,
1328
2185
  endTime,
1329
- duration,
1330
- allocatedFrames: Math.ceil(effectiveFps * duration),
2186
+ duration: endTime - startTime,
2187
+ allocatedFrames: 1,
1331
2188
  effectiveFps,
1332
- overlapBefore,
1333
- overlapAfter,
1334
- extractStartTime,
1335
- extractDuration
2189
+ overlapBefore: 0,
2190
+ overlapAfter: 0,
2191
+ extractStartTime: timestamp,
2192
+ extractDuration: 0
1336
2193
  });
1337
2194
  }
1338
- const totalAllocated = segments.reduce((sum, s) => sum + s.allocatedFrames, 0);
1339
- if (totalAllocated > maxFrames) segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
2195
+ let firstSlot = 0;
2196
+ for (const segment of segments) {
2197
+ const nextSlot = firstSlot + segment.allocatedFrames;
2198
+ segment.overlapBefore = segment.index > 0 ? 1 : 0;
2199
+ segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
2200
+ segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
2201
+ segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
2202
+ segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
2203
+ firstSlot = nextSlot;
2204
+ }
1340
2205
  return segments;
1341
2206
  }
1342
2207
  /**
@@ -1355,44 +2220,58 @@ function collectAllFrames(segmentResults) {
1355
2220
  return allFrames;
1356
2221
  }
1357
2222
  /**
1358
- * Sort frames by timestamp then remove overlap duplicates.
1359
- * Threshold: 1/(effectiveFps * 2) — adaptive to fps (Section 18 note 5).
1360
- * Keeps the first occurrence (earlier segment).
2223
+ * Sort frames in place and alias overlap duplicates to the first survivor.
2224
+ * @param frames Collected entries whose segment index and local ID identify a frame.
2225
+ * @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
2226
+ * @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
1361
2227
  */
1362
2228
  function deduplicateFrames(frames, effectiveFps) {
1363
2229
  frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1364
2230
  const dupThreshold = 1 / (effectiveFps * 2);
1365
2231
  const unique = [];
2232
+ const aliases = /* @__PURE__ */ new Map();
1366
2233
  for (const entry of frames) {
1367
2234
  if (unique.length > 0) {
1368
2235
  const last = unique[unique.length - 1];
1369
- if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) continue;
2236
+ if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
2237
+ aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
2238
+ continue;
2239
+ }
1370
2240
  }
1371
2241
  unique.push(entry);
1372
2242
  }
1373
- return unique;
2243
+ return {
2244
+ unique,
2245
+ aliases
2246
+ };
1374
2247
  }
1375
2248
  /**
1376
- * Assign sequential global IDs to deduplicated frames and build a lookup map.
1377
- * Returns the remapped FrameNode array and the "segmentIndex:localId" -> globalId map.
2249
+ * Assign sequential global IDs and retain duplicate local IDs as aliases.
2250
+ * @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
2251
+ * @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
2252
+ * @returns Remapped frames and a global ID lookup covering survivors and duplicates.
1378
2253
  */
1379
- function remapFrameIds(uniqueFrames) {
2254
+ function remapFrameIds(uniqueFrames, aliases) {
1380
2255
  const globalIdMap = /* @__PURE__ */ new Map();
2256
+ const frames = uniqueFrames.map((entry, globalId) => {
2257
+ globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
2258
+ return {
2259
+ id: globalId,
2260
+ timestamp: entry.frame.timestamp,
2261
+ extractPath: entry.frame.extractPath
2262
+ };
2263
+ });
2264
+ for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
1381
2265
  return {
1382
- frames: uniqueFrames.map((entry, globalId) => {
1383
- globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1384
- return {
1385
- id: globalId,
1386
- timestamp: entry.frame.timestamp,
1387
- extractPath: entry.frame.extractPath
1388
- };
1389
- }),
2266
+ frames,
1390
2267
  globalIdMap
1391
2268
  };
1392
2269
  }
1393
2270
  /**
1394
- * Remap edge source/target IDs using the global ID map.
1395
- * Duplicate edges (same source-target pair) retain the higher score.
2271
+ * Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
2272
+ * @param segmentResults Segment-local edges in encounter order.
2273
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
2274
+ * @returns One edge per surviving directed pair without changing its score.
1396
2275
  */
1397
2276
  function remapEdges(segmentResults, globalIdMap) {
1398
2277
  const edges = [];
@@ -1401,62 +2280,73 @@ function remapEdges(segmentResults, globalIdMap) {
1401
2280
  const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
1402
2281
  const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
1403
2282
  if (newSourceId === void 0 || newTargetId === void 0) continue;
2283
+ if (newSourceId === newTargetId) continue;
1404
2284
  const edgeKey = `${newSourceId}-${newTargetId}`;
1405
2285
  const existingIdx = edgeMap.get(edgeKey);
1406
2286
  if (existingIdx !== void 0) {
1407
2287
  if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
2288
+ ...edge,
1408
2289
  sourceId: newSourceId,
1409
- targetId: newTargetId,
1410
- score: edge.score
2290
+ targetId: newTargetId
1411
2291
  };
1412
2292
  } else {
1413
2293
  edgeMap.set(edgeKey, edges.length);
1414
2294
  edges.push({
2295
+ ...edge,
1415
2296
  sourceId: newSourceId,
1416
- targetId: newTargetId,
1417
- score: edge.score
2297
+ targetId: newTargetId
1418
2298
  });
1419
2299
  }
1420
2300
  }
1421
2301
  return edges;
1422
2302
  }
1423
2303
  /**
1424
- * Remap animation startFrameId/endFrameId using the global ID map.
1425
- * Animations whose frame IDs were deduplicated (not in map) are dropped.
2304
+ * Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
2305
+ * @param segmentResults Segment-local tracker entries in encounter order.
2306
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
2307
+ * @returns One animation per directed pair with its original tracker duration and metadata.
1426
2308
  */
1427
2309
  function remapAnimations(segmentResults, globalIdMap) {
1428
- const animations = [];
2310
+ const animations = /* @__PURE__ */ new Map();
1429
2311
  for (const result of segmentResults) for (const anim of result.animations) {
1430
2312
  const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
1431
2313
  const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
1432
2314
  if (newStartId === void 0 || newEndId === void 0) continue;
1433
- animations.push({
2315
+ if (newStartId === newEndId) continue;
2316
+ const animationKey = `${newStartId}-${newEndId}`;
2317
+ if (animations.has(animationKey)) continue;
2318
+ animations.set(animationKey, {
1434
2319
  ...anim,
1435
2320
  startFrameId: newStartId,
1436
2321
  endFrameId: newEndId
1437
2322
  });
1438
2323
  }
1439
- return animations;
2324
+ return [...animations.values()];
1440
2325
  }
1441
2326
  /**
1442
2327
  * Merge multiple segment results into a single unified frame/edge/animation set.
1443
- * - Timestamps adjusted using extractStartTime (Section 18 note 1)
1444
- * - Overlap frames deduplicated by threshold 1/(effectiveFps*2) (Section 18 note 5)
1445
- * - Global IDs reassigned after dedup
1446
- * - Duplicate edges keep higher score
2328
+ * @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
2329
+ * @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
2330
+ * Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
1447
2331
  */
1448
2332
  function mergeSegmentFrames(segmentResults) {
1449
2333
  if (segmentResults.length === 0) return {
1450
2334
  frames: [],
1451
2335
  edges: [],
1452
- animations: []
2336
+ animations: [],
2337
+ analysisResolution: {
2338
+ width: 0,
2339
+ height: 0
2340
+ }
1453
2341
  };
1454
2342
  const effectiveFps = segmentResults[0].segment.effectiveFps;
1455
- const { frames, globalIdMap } = remapFrameIds(deduplicateFrames(collectAllFrames(segmentResults), effectiveFps));
2343
+ const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
2344
+ const { frames, globalIdMap } = remapFrameIds(unique, aliases);
1456
2345
  return {
1457
2346
  frames,
1458
2347
  edges: remapEdges(segmentResults, globalIdMap),
1459
- animations: remapAnimations(segmentResults, globalIdMap)
2348
+ animations: remapAnimations(segmentResults, globalIdMap),
2349
+ analysisResolution: segmentResults[0].analysisResolution
1460
2350
  };
1461
2351
  }
1462
2352
  function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
@@ -1467,6 +2357,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1467
2357
  maxFrames: segment.allocatedFrames
1468
2358
  },
1469
2359
  workspacePath: segmentWorkspacePath,
2360
+ effectiveFps: segment.effectiveFps,
1470
2361
  frames,
1471
2362
  graph: [],
1472
2363
  status: "ANALYZING",
@@ -1478,19 +2369,26 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1478
2369
  * Each segment uses an isolated workspace directory.
1479
2370
  */
1480
2371
  async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1481
- const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration);
2372
+ const framesDir = join(workspacePath, "frames");
2373
+ const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1482
2374
  if (frames.length < 2) return {
1483
2375
  segment,
1484
2376
  frames,
1485
2377
  edges: [],
1486
- animations: []
2378
+ animations: [],
2379
+ analysisResolution: {
2380
+ width: 0,
2381
+ height: 0
2382
+ }
1487
2383
  };
1488
- const { edges, animations } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
2384
+ const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
2385
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1489
2386
  return {
1490
2387
  segment,
1491
2388
  frames,
1492
2389
  edges,
1493
- animations
2390
+ animations,
2391
+ analysisResolution
1494
2392
  };
1495
2393
  }
1496
2394
  /**
@@ -1530,7 +2428,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1530
2428
  });
1531
2429
  })));
1532
2430
  options.onProgress?.("ANALYZING", 100);
1533
- const { frames, edges, animations } = mergeSegmentFrames(results);
2431
+ const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
1534
2432
  logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
1535
2433
  options.onProgress?.("PRUNING", 0);
1536
2434
  const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
@@ -1539,6 +2437,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1539
2437
  options.onProgress?.("FINALIZING", 0);
1540
2438
  const ctx = {
1541
2439
  options: resolvedOptions,
2440
+ effectiveFps: segments[0]?.effectiveFps,
2441
+ sourceDurationSec: totalDuration,
2442
+ analysisResolution,
1542
2443
  workspacePath: mainWorkspace,
1543
2444
  frames,
1544
2445
  graph: edges,
@@ -1546,27 +2447,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1546
2447
  status: "FINALIZING",
1547
2448
  emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1548
2449
  };
1549
- let outputFiles = [];
1550
- let outputBuffers;
1551
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1552
- else outputFiles = await finalizeOutput(ctx, prunedFrames);
2450
+ const finalized = await finalizeSelection(ctx, prunedFrames);
1553
2451
  options.onProgress?.("FINALIZING", 100);
1554
2452
  logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1555
2453
  return {
1556
2454
  success: true,
1557
2455
  originalFramesCount: frames.length,
1558
2456
  prunedFramesCount: prunedFrames.length,
1559
- outputFiles,
1560
- outputBuffers,
1561
- animations,
1562
- video: {
1563
- originalDurationMs: totalDuration * 1e3,
1564
- fps: resolvedOptions.fps,
1565
- resolution: {
1566
- width: resolvedOptions.scale,
1567
- height: Math.round(resolvedOptions.scale * 9 / 16)
1568
- }
1569
- },
2457
+ outputFiles: finalized.outputFiles,
2458
+ outputBuffers: finalized.outputBuffers,
2459
+ video: finalized.document.video,
2460
+ animations: finalized.animations,
2461
+ frames: finalized.document.frames,
2462
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2463
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1570
2464
  executionTimeMs: Date.now() - pipelineStart
1571
2465
  };
1572
2466
  } catch (error) {
@@ -1578,10 +2472,11 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1578
2472
  else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1579
2473
  }
1580
2474
  }
1581
- var init_segmenter = __esmMin((() => {
2475
+ var init_segmenter$1 = __esmMin((() => {
1582
2476
  init_concurrency();
1583
2477
  init_logger();
1584
2478
  init_analyzer();
2479
+ init_finalize_selection();
1585
2480
  init_extractor();
1586
2481
  init_input_resolver();
1587
2482
  init_pruner();
@@ -1589,10 +2484,22 @@ var init_segmenter = __esmMin((() => {
1589
2484
  }));
1590
2485
 
1591
2486
  //#endregion
1592
- //#region src/core/orchestrator.ts
2487
+ //#region src/core/segmenter/index.ts
2488
+ var init_segmenter = __esmMin((() => {
2489
+ init_segmenter$1();
2490
+ }));
2491
+
2492
+ //#endregion
2493
+ //#region src/core/orchestrator/orchestrator.ts
1593
2494
  var orchestrator_exports = /* @__PURE__ */ __exportAll({ runPipeline: () => runPipeline });
2495
+ /**
2496
+ * Run the five pipeline stages, delegating segmented inputs to the segmenter.
2497
+ * @param options - Mode-specific input and optional pipeline settings.
2498
+ * @returns Selected outputs with v2 frame metadata and zero-based API animations.
2499
+ * @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
2500
+ */
1594
2501
  async function runPipeline(options) {
1595
- if (options.debug ?? false) setDebugMode(true);
2502
+ setDebugMode(options.debug ?? false);
1596
2503
  const resolvedOptions = resolveOptions(options);
1597
2504
  if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
1598
2505
  const startTime = Date.now();
@@ -1612,50 +2519,47 @@ async function runPipeline(options) {
1612
2519
  logger.debug(`Workspace created: ${ctx.workspacePath}`);
1613
2520
  ctx.status = "EXTRACTING";
1614
2521
  const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
1615
- if (resolvedOptions.mode === "frames") ctx.frames = resolvedFrames;
1616
- else ctx.frames = await extractFrames({
1617
- ...ctx,
1618
- options: {
1619
- ...resolvedOptions,
1620
- inputPath: resolvedInputPath
1621
- }
1622
- });
2522
+ if (resolvedOptions.mode === "frames") {
2523
+ ctx.frames = resolvedFrames;
2524
+ ctx.effectiveFps = 1;
2525
+ } else {
2526
+ const extractCtx = {
2527
+ ...ctx,
2528
+ options: {
2529
+ ...resolvedOptions,
2530
+ inputPath: resolvedInputPath
2531
+ }
2532
+ };
2533
+ ctx.frames = await extractFrames(extractCtx);
2534
+ ctx.effectiveFps = extractCtx.effectiveFps;
2535
+ ctx.sourceDurationSec = extractCtx.sourceDurationSec;
2536
+ }
1623
2537
  ctx.emitProgress(100);
1624
2538
  ctx.status = "ANALYZING";
1625
- const { edges, animations } = await analyzeFrames(ctx);
2539
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1626
2540
  ctx.graph = edges;
1627
2541
  ctx.animations = animations;
2542
+ ctx.analysisResolution = analysisResolution;
1628
2543
  ctx.status = "PRUNING";
1629
2544
  const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
1630
2545
  const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
1631
2546
  ctx.emitProgress(100);
1632
2547
  ctx.status = "FINALIZING";
1633
- let outputFiles = [];
1634
- let outputBuffers;
1635
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1636
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1637
- ctx.emitProgress(100);
1638
- } else {
1639
- outputFiles = await finalizeOutput(ctx, prunedFrames);
1640
- ctx.emitProgress(100);
1641
- }
2548
+ const finalized = await finalizeSelection(ctx, prunedFrames);
2549
+ ctx.emitProgress(100);
1642
2550
  ctx.status = "SUCCESS";
1643
2551
  logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
1644
2552
  return {
1645
2553
  success: true,
1646
2554
  originalFramesCount: ctx.frames.length,
1647
2555
  prunedFramesCount: prunedFrames.length,
1648
- outputFiles,
1649
- outputBuffers,
1650
- animations: ctx.animations,
1651
- video: {
1652
- originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
1653
- fps: ctx.options.fps,
1654
- resolution: {
1655
- width: ctx.options.scale,
1656
- height: Math.round(ctx.options.scale * 9 / 16)
1657
- }
1658
- },
2556
+ outputFiles: finalized.outputFiles,
2557
+ outputBuffers: finalized.outputBuffers,
2558
+ video: finalized.document.video,
2559
+ animations: finalized.animations,
2560
+ frames: finalized.document.frames,
2561
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2562
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1659
2563
  executionTimeMs: Date.now() - startTime
1660
2564
  };
1661
2565
  } catch (error) {
@@ -1671,6 +2575,7 @@ async function runPipeline(options) {
1671
2575
  var init_orchestrator = __esmMin((() => {
1672
2576
  init_logger();
1673
2577
  init_analyzer();
2578
+ init_finalize_selection();
1674
2579
  init_extractor();
1675
2580
  init_input_resolver();
1676
2581
  init_pruner();
@@ -1679,13 +2584,16 @@ var init_orchestrator = __esmMin((() => {
1679
2584
  }));
1680
2585
 
1681
2586
  //#endregion
1682
- //#region src/core/run-in-worker.ts
1683
- init_orchestrator();
2587
+ //#region src/core/orchestrator/worker/run-in-worker.ts
1684
2588
  /**
1685
2589
  * Run the pipeline, choosing the best execution strategy:
1686
2590
  *
1687
2591
  * - Production (bundled .mjs): Worker thread — spinner never freezes
1688
2592
  * - Dev mode (tsx .ts): Main thread — simpler, spinner may stutter during CPU work
2593
+ * @param options - Serializable input and pipeline settings for this run.
2594
+ * @param onProgress - Receives worker progress updates.
2595
+ * @returns The pipeline result; settlement is unchanged by later exit events.
2596
+ * @throws Rejects worker errors or any worker exit before a result is received.
1689
2597
  */
1690
2598
  async function runPipelineInWorker(options, onProgress) {
1691
2599
  const currentFile = fileURLToPath(import.meta.url);
@@ -1711,13 +2619,13 @@ async function runPipelineInWorker(options, onProgress) {
1711
2619
  });
1712
2620
  worker.on("error", reject);
1713
2621
  worker.on("exit", (code) => {
1714
- if (code !== 0 && code !== 1) reject(/* @__PURE__ */ new Error(`Worker exited with code ${code}`));
2622
+ reject(/* @__PURE__ */ new Error(`Worker exited with code ${code} without a result`));
1715
2623
  });
1716
2624
  });
1717
2625
  }
1718
2626
 
1719
2627
  //#endregion
1720
- //#region src/errors.ts
2628
+ //#region src/cli/errors/classify-error.ts
1721
2629
  const SieveErrorCode = {
1722
2630
  INVALID_INPUT: "INVALID_INPUT",
1723
2631
  FILE_NOT_FOUND: "FILE_NOT_FOUND",
@@ -1726,16 +2634,22 @@ const SieveErrorCode = {
1726
2634
  WORKER_ERROR: "WORKER_ERROR",
1727
2635
  UNKNOWN: "UNKNOWN"
1728
2636
  };
2637
+ /**
2638
+ * Classify pipeline errors for structured CLI responses.
2639
+ * @param error - Failure with a diagnostic message and optional filesystem code.
2640
+ * @returns The existing error code matching the failure.
2641
+ */
1729
2642
  function classifyError(error) {
1730
2643
  const msg = error.message.toLowerCase();
1731
- if (error.code === "ENOENT" || msg.includes("not found")) return SieveErrorCode.FILE_NOT_FOUND;
2644
+ if (msg.includes("must be")) return SieveErrorCode.INVALID_INPUT;
2645
+ else if (error.code === "ENOENT" || msg.includes("not found")) return SieveErrorCode.FILE_NOT_FOUND;
1732
2646
  else if (msg.includes("no video stream") || msg.includes("invalid format")) return SieveErrorCode.INVALID_FORMAT;
1733
2647
  else if (msg.includes("worker")) return SieveErrorCode.WORKER_ERROR;
1734
2648
  else return SieveErrorCode.PIPELINE_ERROR;
1735
2649
  }
1736
2650
 
1737
2651
  //#endregion
1738
- //#region src/utils/command-registry.ts
2652
+ //#region src/cli/commands/command-registry.ts
1739
2653
  const SIEVE_COMMAND = {
1740
2654
  name: "scene-sieve",
1741
2655
  description: "Extract key frames from video and GIF files",
@@ -1746,6 +2660,16 @@ const SIEVE_COMMAND = {
1746
2660
  required: true
1747
2661
  }],
1748
2662
  options: [
2663
+ {
2664
+ flag: "--sheet",
2665
+ description: "Generate a contact sheet of selected frames",
2666
+ type: "boolean"
2667
+ },
2668
+ {
2669
+ flag: "--include-edges",
2670
+ description: "Include candidate edge diagnostics in metadata",
2671
+ type: "boolean"
2672
+ },
1749
2673
  {
1750
2674
  flag: "-n, --count <number>",
1751
2675
  description: "Max number of frames to keep (default: 20)",
@@ -1831,28 +2755,46 @@ const SIEVE_COMMAND = {
1831
2755
  };
1832
2756
 
1833
2757
  //#endregion
1834
- //#region src/utils/parse-options.ts
2758
+ //#region src/cli/options/parse-options.ts
2759
+ /**
2760
+ * Parse one complete decimal string without truncating fractional values.
2761
+ * @param value - Decimal CLI argument, optionally using an exponent.
2762
+ * @param integer - Whether the result must be an integer.
2763
+ * @returns The finite parsed number, or NaN for invalid input.
2764
+ */
2765
+ function parseNumberStrict(value, integer = false) {
2766
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return NaN;
2767
+ const number = Number(value);
2768
+ return Number.isFinite(number) && (!integer || Number.isInteger(number)) ? number : NaN;
2769
+ }
2770
+ /**
2771
+ * Convert CLI strings to pipeline values for subsequent range validation.
2772
+ * @param opts - Commander options with raw numeric strings.
2773
+ * @returns Typed settings, preserving invalid numeric input as NaN.
2774
+ */
1835
2775
  function parsePipelineOptions(opts) {
1836
2776
  return {
1837
- ...opts.threshold !== void 0 ? { threshold: parseFloat(opts.threshold) } : {},
1838
- ...opts.count !== void 0 ? { count: parseInt(opts.count, 10) } : {},
2777
+ ...opts.threshold !== void 0 ? { threshold: parseNumberStrict(opts.threshold) } : {},
2778
+ ...opts.count !== void 0 ? { count: parseNumberStrict(opts.count, true) } : {},
1839
2779
  outputPath: opts.output,
1840
- fps: parseInt(opts.fps, 10),
1841
- maxFrames: parseInt(opts.maxFrames, 10),
1842
- scale: parseInt(opts.scale, 10),
1843
- quality: parseInt(opts.quality, 10),
1844
- iouThreshold: opts.iouThreshold !== void 0 ? parseFloat(opts.iouThreshold) : void 0,
1845
- animationThreshold: opts.animThreshold !== void 0 ? parseInt(opts.animThreshold, 10) : void 0,
1846
- maxSegmentDuration: opts.maxSegmentDuration !== void 0 ? parseInt(opts.maxSegmentDuration, 10) : void 0,
1847
- concurrency: opts.concurrency !== void 0 ? parseInt(opts.concurrency, 10) : void 0,
1848
- debug: opts.debug ?? false
2780
+ fps: parseNumberStrict(opts.fps),
2781
+ maxFrames: parseNumberStrict(opts.maxFrames, true),
2782
+ scale: parseNumberStrict(opts.scale, true),
2783
+ quality: parseNumberStrict(opts.quality, true),
2784
+ iouThreshold: opts.iouThreshold !== void 0 ? parseNumberStrict(opts.iouThreshold) : void 0,
2785
+ animationThreshold: opts.animThreshold !== void 0 ? parseNumberStrict(opts.animThreshold, true) : void 0,
2786
+ maxSegmentDuration: opts.maxSegmentDuration !== void 0 ? parseNumberStrict(opts.maxSegmentDuration) : void 0,
2787
+ concurrency: opts.concurrency !== void 0 ? parseNumberStrict(opts.concurrency, true) : void 0,
2788
+ debug: opts.debug ?? false,
2789
+ ...opts.sheet !== void 0 ? { sheet: opts.sheet } : {},
2790
+ ...opts.includeEdges !== void 0 ? { includeEdges: opts.includeEdges } : {}
1849
2791
  };
1850
2792
  }
1851
2793
 
1852
2794
  //#endregion
1853
- //#region src/commands/Sieve.tsx
1854
- init_constants();
1855
- init_workspace();
2795
+ //#region src/cli/commands/Sieve.tsx
2796
+ init_pipeline_defaults();
2797
+ init_orchestrator(), init_workspace$1();
1856
2798
  init_logger();
1857
2799
  const PHASE_DEFS = [
1858
2800
  {
@@ -1894,7 +2836,7 @@ function phaseKeyToIndex(phase) {
1894
2836
  }
1895
2837
  function registerSieveCommand(program, version) {
1896
2838
  const cmd = SIEVE_COMMAND;
1897
- program.argument("<input>", cmd.arguments[0].description).option("-n, --count <number>", cmd.options.find((o) => o.flag.includes("--count")).description).option("-t, --threshold <number>", cmd.options.find((o) => o.flag.includes("--threshold")).description).option("-o, --output <path>", cmd.options.find((o) => o.flag.includes("--output")).description).option("--fps <number>", cmd.options.find((o) => o.flag.includes("--fps")).description, String(5)).option("-mf, --max-frames <number>", cmd.options.find((o) => o.flag.includes("--max-frames")).description, String(300)).option("-s, --scale <number>", cmd.options.find((o) => o.flag.includes("--scale")).description, String(720)).option("-q, --quality <number>", cmd.options.find((o) => o.flag.includes("--quality")).description, String(80)).option("-it, --iou-threshold <number>", cmd.options.find((o) => o.flag.includes("--iou-threshold")).description).option("-at, --anim-threshold <number>", cmd.options.find((o) => o.flag.includes("--anim-threshold")).description).option("--max-segment-duration <number>", cmd.options.find((o) => o.flag.includes("--max-segment-duration")).description).option("--concurrency <number>", cmd.options.find((o) => o.flag.includes("--concurrency")).description).option("--debug", cmd.options.find((o) => o.flag.includes("--debug")).description).option("--json", cmd.options.find((o) => o.flag.includes("--json")).description).option("--describe", cmd.options.find((o) => o.flag.includes("--describe")).description).action(async (input, opts) => {
2839
+ program.argument("<input>", cmd.arguments[0].description).option("-n, --count <number>", cmd.options.find((o) => o.flag.includes("--count")).description).option("-t, --threshold <number>", cmd.options.find((o) => o.flag.includes("--threshold")).description).option("-o, --output <path>", cmd.options.find((o) => o.flag.includes("--output")).description).option("--fps <number>", cmd.options.find((o) => o.flag.includes("--fps")).description, String(5)).option("-mf, --max-frames <number>", cmd.options.find((o) => o.flag.includes("--max-frames")).description, String(300)).option("-s, --scale <number>", cmd.options.find((o) => o.flag.includes("--scale")).description, String(720)).option("-q, --quality <number>", cmd.options.find((o) => o.flag.includes("--quality")).description, String(80)).option("-it, --iou-threshold <number>", cmd.options.find((o) => o.flag.includes("--iou-threshold")).description).option("-at, --anim-threshold <number>", cmd.options.find((o) => o.flag.includes("--anim-threshold")).description).option("--max-segment-duration <number>", cmd.options.find((o) => o.flag.includes("--max-segment-duration")).description).option("--concurrency <number>", cmd.options.find((o) => o.flag.includes("--concurrency")).description).option("--debug", cmd.options.find((o) => o.flag.includes("--debug")).description).option("--sheet", cmd.options.find((o) => o.flag === "--sheet").description).option("--include-edges", cmd.options.find((o) => o.flag === "--include-edges").description).option("--json", cmd.options.find((o) => o.flag.includes("--json")).description).option("--describe", cmd.options.find((o) => o.flag.includes("--describe")).description).action(async (input, opts) => {
1898
2840
  const parsed = parsePipelineOptions(opts);
1899
2841
  if (opts.json) {
1900
2842
  setJsonMode(true);
@@ -1915,14 +2857,17 @@ function registerSieveCommand(program, version) {
1915
2857
  }) + "\n");
1916
2858
  }
1917
2859
  });
1918
- respond("extract", {
2860
+ const data = {
1919
2861
  success: result.success,
1920
2862
  originalFrames: result.originalFramesCount,
1921
2863
  selectedFrames: result.prunedFramesCount,
1922
2864
  outputFiles: result.outputFiles,
1923
2865
  animations: result.animations ?? [],
1924
- video: result.video ?? null
1925
- }, startTime, version);
2866
+ video: result.video ?? null,
2867
+ frames: result.frames ?? [],
2868
+ sheet: result.sheet ?? null
2869
+ };
2870
+ respond("extract", data, startTime, version);
1926
2871
  } catch (error) {
1927
2872
  const err = error instanceof Error ? error : new Error(String(error));
1928
2873
  respondError("extract", classifyError(err), err.message, startTime, version);
@@ -1972,7 +2917,9 @@ const SieveView = (props) => {
1972
2917
  animationThreshold: props.animationThreshold,
1973
2918
  maxSegmentDuration: props.maxSegmentDuration,
1974
2919
  concurrency: props.concurrency,
1975
- debug: props.debug
2920
+ debug: props.debug,
2921
+ ...props.sheet !== void 0 ? { sheet: props.sheet } : {},
2922
+ includeEdges: props.includeEdges
1976
2923
  }, (phase, percent) => {
1977
2924
  const phaseIdx = phaseKeyToIndex(phase);
1978
2925
  if (phaseIdx < 0) return;
@@ -2029,8 +2976,9 @@ const SieveView = (props) => {
2029
2976
  };
2030
2977
  return next;
2031
2978
  });
2032
- setError(err instanceof Error ? err.message : String(err));
2033
- setTimeout(() => exit(), 100);
2979
+ const failure = err instanceof Error ? err : new Error(String(err));
2980
+ setError(failure.message);
2981
+ setTimeout(() => exit(failure), 100);
2034
2982
  }
2035
2983
  })();
2036
2984
  }, []);
@@ -2105,23 +3053,23 @@ const SieveView = (props) => {
2105
3053
 
2106
3054
  //#endregion
2107
3055
  //#region src/cli.ts
2108
- const { version } = createRequire(import.meta.url)("../package.json");
3056
+ init_package_version();
2109
3057
  const program = new Command();
2110
- program.name("scene-sieve").description("Extract key frames from video and GIF files").version(version);
2111
- registerSieveCommand(program, version);
3058
+ program.name("scene-sieve").description("Extract key frames from video and GIF files").version(PACKAGE_VERSION);
3059
+ registerSieveCommand(program, PACKAGE_VERSION);
2112
3060
  if (process.argv.includes("--describe")) {
2113
3061
  const startTime = Date.now();
2114
3062
  respond("describe", {
2115
3063
  name: SIEVE_COMMAND.name,
2116
- version,
3064
+ version: PACKAGE_VERSION,
2117
3065
  description: SIEVE_COMMAND.description,
2118
3066
  arguments: SIEVE_COMMAND.arguments,
2119
3067
  options: SIEVE_COMMAND.options
2120
- }, startTime, version);
3068
+ }, startTime, PACKAGE_VERSION);
2121
3069
  process.exit(0);
2122
3070
  }
2123
3071
  program.parseAsync(process.argv).catch((error) => {
2124
- if (process.argv.includes("--json")) respondError("extract", SieveErrorCode.UNKNOWN, error.message, Date.now(), version);
3072
+ if (process.argv.includes("--json")) respondError("extract", SieveErrorCode.UNKNOWN, error.message, Date.now(), PACKAGE_VERSION);
2125
3073
  else console.error("Fatal error:", error.message);
2126
3074
  process.exit(1);
2127
3075
  });