@lumy-pack/scene-sieve 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +47 -24
  2. package/dist/{errors.d.ts → cli/errors/classify-error.d.ts} +5 -0
  3. package/dist/cli/index.d.ts +3 -0
  4. package/dist/{utils → cli/options}/parse-options.d.ts +6 -1
  5. package/dist/cli.mjs +755 -319
  6. package/dist/constants/pipeline-defaults.d.ts +13 -0
  7. package/dist/core/{analyzer.d.ts → analyzer/analyzer.d.ts} +11 -6
  8. package/dist/core/{dbscan.d.ts → analyzer/clustering/dbscan.d.ts} +1 -1
  9. package/dist/core/analyzer/constants/vision-tuning.d.ts +12 -0
  10. package/dist/core/analyzer/features/feature-diff.d.ts +14 -0
  11. package/dist/core/analyzer/features/frame-features.d.ts +32 -0
  12. package/dist/core/analyzer/index.d.ts +3 -0
  13. package/dist/core/constants/workspace-layout.d.ts +9 -0
  14. package/dist/core/{extractor.d.ts → extractor/extractor.d.ts} +6 -4
  15. package/dist/core/extractor/index.d.ts +1 -0
  16. package/dist/core/index.d.ts +8 -9
  17. package/dist/core/input-resolver/index.d.ts +1 -0
  18. package/dist/core/{input-resolver.d.ts → input-resolver/input-resolver.d.ts} +11 -1
  19. package/dist/core/input-resolver/validation/validate-options.d.ts +8 -0
  20. package/dist/core/orchestrator/index.d.ts +3 -0
  21. package/dist/core/{orchestrator.d.ts → orchestrator/orchestrator.d.ts} +1 -1
  22. package/dist/core/{run-in-worker.d.ts → orchestrator/worker/run-in-worker.d.ts} +5 -1
  23. package/dist/core/pruner/index.d.ts +1 -0
  24. package/dist/core/{pruner.d.ts → pruner/pruner.d.ts} +2 -2
  25. package/dist/{utils/math.d.ts → core/pruner/scoring/normalize-scores.d.ts} +4 -0
  26. package/dist/core/segmenter/index.d.ts +1 -0
  27. package/dist/core/{segmenter.d.ts → segmenter/segmenter.d.ts} +11 -11
  28. package/dist/core/utils/metadata/build-video-metadata.d.ts +14 -0
  29. package/dist/core/workspace/index.d.ts +1 -0
  30. package/dist/core/{workspace.d.ts → workspace/workspace.d.ts} +1 -1
  31. package/dist/index.cjs +910 -434
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.mjs +913 -434
  34. package/dist/pipeline-worker.mjs +635 -278
  35. package/dist/types/index.d.ts +25 -0
  36. package/package.json +1 -1
  37. package/dist/constants.d.ts +0 -32
  38. /package/dist/{commands → cli/commands}/Sieve.d.ts +0 -0
  39. /package/dist/{utils → cli/commands}/command-registry.d.ts +0 -0
  40. /package/dist/{components → cli/components}/PhaseStep.d.ts +0 -0
  41. /package/dist/{components → cli/components}/ProgressBar.d.ts +0 -0
  42. /package/dist/core/{pipeline-worker.d.ts → orchestrator/worker/pipeline-worker.d.ts} +0 -0
  43. /package/dist/{utils → core/pruner/heap}/min-heap.d.ts +0 -0
  44. /package/dist/{utils → core/segmenter/scheduling}/concurrency.d.ts +0 -0
  45. /package/dist/{utils → core/utils/filesystem}/paths.d.ts +0 -0
  46. /package/dist/{utils → logging}/logger.d.ts +0 -0
package/dist/cli.mjs CHANGED
@@ -6,16 +6,16 @@ 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";
16
15
  import { path } from "@ffprobe-installer/ffprobe";
17
16
  import { execa } from "execa";
18
17
  import ffmpegPath from "ffmpeg-static";
18
+ import { homedir, tmpdir } from "node:os";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { Worker } from "node:worker_threads";
21
21
 
@@ -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,15 @@ 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;
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;
167
154
  }));
168
155
 
169
156
  //#endregion
170
- //#region src/utils/logger.ts
157
+ //#region src/logging/logger.ts
171
158
  function setDebugMode(enabled) {
172
159
  debugMode = enabled;
173
160
  }
@@ -204,7 +191,16 @@ var init_logger = __esmMin((() => {
204
191
  }));
205
192
 
206
193
  //#endregion
207
- //#region src/core/dbscan.ts
194
+ //#region src/core/analyzer/constants/vision-tuning.ts
195
+ var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
196
+ var init_vision_tuning = __esmMin((() => {
197
+ DBSCAN_ALPHA = .03;
198
+ DECAY_LAMBDA = .95;
199
+ MATCH_DISTANCE_THRESHOLD = .25;
200
+ }));
201
+
202
+ //#endregion
203
+ //#region src/core/analyzer/clustering/dbscan.ts
208
204
  /**
209
205
  * DBSCAN clustering with resolution-independent eps.
210
206
  * eps = alpha * sqrt(width^2 + height^2)
@@ -282,13 +278,116 @@ function findNeighbors(points, idx, epsSquared) {
282
278
  }
283
279
  var UNVISITED, NOISE;
284
280
  var init_dbscan = __esmMin((() => {
285
- init_constants();
281
+ init_vision_tuning();
286
282
  UNVISITED = -2;
287
283
  NOISE = -1;
288
284
  }));
289
285
 
290
286
  //#endregion
291
- //#region src/core/analyzer.ts
287
+ //#region src/core/analyzer/features/feature-diff.ts
288
+ /**
289
+ * Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
290
+ * @param cvLib - Initialized OpenCV runtime.
291
+ * @param prev - Previous frame's live features, owned by the caller.
292
+ * @param next - Next frame's live features, owned by the caller.
293
+ * @returns Unmatched next-frame coordinates without changing input ownership.
294
+ * @throws Propagates matching errors after releasing temporary native handles.
295
+ */
296
+ function computeNewPoints(cvLib, prev, next) {
297
+ let matcher = null;
298
+ let matches = null;
299
+ try {
300
+ const matchedIndices = /* @__PURE__ */ new Set();
301
+ if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
302
+ matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
303
+ matches = new cvLib.DMatchVectorVector();
304
+ matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
305
+ for (let i = 0; i < matches.size(); i++) {
306
+ const pair = matches.get(i);
307
+ try {
308
+ if (pair.size() < 2) continue;
309
+ const best = pair.get(0);
310
+ const second = pair.get(1);
311
+ if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
312
+ } finally {
313
+ pair.delete();
314
+ }
315
+ }
316
+ } finally {
317
+ matcher?.delete();
318
+ }
319
+ const points = [];
320
+ for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
321
+ const { x, y } = next.keypoints.get(i).pt;
322
+ points.push({
323
+ x,
324
+ y
325
+ });
326
+ }
327
+ return points;
328
+ } finally {
329
+ matches?.delete();
330
+ }
331
+ }
332
+ var init_feature_diff = __esmMin((() => {
333
+ init_vision_tuning();
334
+ }));
335
+
336
+ //#endregion
337
+ //#region src/core/analyzer/features/frame-features.ts
338
+ /**
339
+ * Detect one frame's features without retaining its image or mask.
340
+ * @param cvLib - Initialized OpenCV runtime.
341
+ * @param akaze - Detector owned and released by the caller.
342
+ * @param frame - Grayscale bytes with matching width and height.
343
+ * @returns Feature handles that the caller must delete.
344
+ * @throws Propagates native errors after releasing partial allocations.
345
+ */
346
+ function computeFrameFeatures(cvLib, akaze, frame) {
347
+ let image = null;
348
+ let mask = null;
349
+ let keypoints = null;
350
+ let descriptors = null;
351
+ try {
352
+ image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
353
+ image.data.set(frame.data);
354
+ mask = new cvLib.Mat();
355
+ keypoints = new cvLib.KeyPointVector();
356
+ descriptors = new cvLib.Mat();
357
+ akaze.detectAndCompute(image, mask, keypoints, descriptors);
358
+ const ownedKeypoints = keypoints;
359
+ const ownedDescriptors = descriptors;
360
+ let deleted = false;
361
+ const features = {
362
+ width: frame.width,
363
+ height: frame.height,
364
+ keypoints: ownedKeypoints,
365
+ descriptors: ownedDescriptors,
366
+ /** Release the transferred handles exactly once. */
367
+ delete() {
368
+ if (deleted) return;
369
+ deleted = true;
370
+ try {
371
+ ownedKeypoints.delete();
372
+ } finally {
373
+ ownedDescriptors.delete();
374
+ }
375
+ }
376
+ };
377
+ keypoints = null;
378
+ descriptors = null;
379
+ return features;
380
+ } finally {
381
+ image?.delete();
382
+ mask?.delete();
383
+ keypoints?.delete();
384
+ descriptors?.delete();
385
+ }
386
+ }
387
+ var init_frame_features = __esmMin((() => {}));
388
+
389
+ //#endregion
390
+ //#region src/core/analyzer/analyzer.ts
292
391
  async function ensureOpenCV() {
293
392
  if (!cvReady) cvReady = (async () => {
294
393
  const cvObj = require("@techstark/opencv-js");
@@ -327,77 +426,6 @@ function computeIoU(a, b) {
327
426
  const union = a.width * a.height + b.width * b.height - intersection;
328
427
  return union === 0 ? 0 : intersection / union;
329
428
  }
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
429
  /**
402
430
  * Pixel-level difference fallback for AKAZE blind spots.
403
431
  *
@@ -412,17 +440,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
412
440
  * 3. threshold → binary mask of significant changes
413
441
  * 4. findContours → bounding rects of changed regions
414
442
  * 5. Grid sampling within each bounding rect → Point2D[]
443
+ *
444
+ * @param cvLib - Initialized OpenCV runtime shared by the analyzer.
445
+ * @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
446
+ * @param frame2 - Next grayscale frame, with the same dimensions as frame1.
447
+ * @returns Grid-sampled points from changed regions.
448
+ * @throws Propagates allocation or OpenCV errors after releasing acquired handles.
415
449
  */
416
450
  function computePixelDiff(cvLib, frame1, frame2) {
417
451
  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();
452
+ let mat1 = null;
453
+ let mat2 = null;
454
+ let diff = null;
455
+ let blurred = null;
456
+ let binary = null;
457
+ let contours = null;
458
+ let hierarchy = null;
425
459
  try {
460
+ mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
461
+ mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
462
+ diff = new cv.Mat();
463
+ blurred = new cv.Mat();
464
+ binary = new cv.Mat();
465
+ contours = new cv.MatVector();
466
+ hierarchy = new cv.Mat();
426
467
  mat1.data.set(frame1.data);
427
468
  mat2.data.set(frame2.data);
428
469
  cv.absdiff(mat1, mat2, diff);
@@ -433,22 +474,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
433
474
  const points = [];
434
475
  for (let c = 0; c < contours.size(); c++) {
435
476
  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
- });
477
+ try {
478
+ const rect = cv.boundingRect(contour);
479
+ if (rect.width * rect.height < 100) continue;
480
+ 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({
481
+ x,
482
+ y
483
+ });
484
+ } finally {
485
+ contour.delete();
486
+ }
442
487
  }
443
488
  return points;
444
489
  } finally {
445
- mat1.delete();
446
- mat2.delete();
447
- diff.delete();
448
- blurred.delete();
449
- binary.delete();
450
- contours.delete();
451
- hierarchy.delete();
490
+ mat1?.delete();
491
+ mat2?.delete();
492
+ diff?.delete();
493
+ blurred?.delete();
494
+ binary?.delete();
495
+ contours?.delete();
496
+ hierarchy?.delete();
452
497
  }
453
498
  }
454
499
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
@@ -467,47 +512,85 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
467
512
  }
468
513
  return gain;
469
514
  }
470
- async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
515
+ /**
516
+ * Analyze one batch, retaining its boundary frame for the following batch.
517
+ * @param cvLib - Initialized OpenCV runtime.
518
+ * @param akaze - Detector owned by analyzeFrames.
519
+ * @param frames - Boundary frame followed by new adjacent frames.
520
+ * @param carry - Boundary bytes and live features transferred from the previous batch.
521
+ * @param scale - Maximum preprocessing width.
522
+ * @param tracker - Stateful animation tracker shared across batches.
523
+ * @param pairOffset - Global position of this batch's first pair.
524
+ * @returns Scores, pair failure count, and ownership of the final frame's features.
525
+ */
526
+ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
471
527
  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;
528
+ let failures = 0;
529
+ let prev = carry?.features ?? null;
530
+ let next = null;
531
+ try {
532
+ const preprocessed = await Promise.all(map(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
533
+ const imageWidth = preprocessed[0]?.width ?? scale;
534
+ const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
535
+ const imageArea = imageWidth * imageHeight;
536
+ for (let i = 0; i < frames.length - 1; i++) {
537
+ const pairIndex = pairOffset + i;
538
+ try {
539
+ prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
540
+ next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
541
+ let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
542
+ let clusters = dbscanResult.boundingBoxes;
543
+ if (clusters.length === 0) {
544
+ const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
545
+ if (pixelDiffPoints.length > 0) {
546
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
547
+ dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
548
+ clusters = dbscanResult.boundingBoxes;
549
+ }
488
550
  }
551
+ const clusterPointCounts = new Array(clusters.length).fill(0);
552
+ for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
553
+ const animationIndices = tracker.update(clusters, pairIndex);
554
+ const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
555
+ const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
556
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
557
+ edges.push({
558
+ sourceId: frames[i].id,
559
+ targetId: frames[i + 1].id,
560
+ score
561
+ });
562
+ } catch (err) {
563
+ logger.warn(`Frame pair analysis failed: ${String(err)}`);
564
+ failures++;
565
+ edges.push({
566
+ sourceId: frames[i].id,
567
+ targetId: frames[i + 1].id,
568
+ score: 0
569
+ });
570
+ } finally {
571
+ prev?.delete();
572
+ prev = next;
573
+ next = null;
489
574
  }
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
575
  }
576
+ const result = {
577
+ edges,
578
+ failures,
579
+ analysisResolution: {
580
+ width: imageWidth,
581
+ height: imageHeight
582
+ },
583
+ carry: prev ? {
584
+ preprocessed: preprocessed[preprocessed.length - 1],
585
+ features: prev
586
+ } : null
587
+ };
588
+ prev = null;
589
+ return result;
590
+ } finally {
591
+ prev?.delete();
592
+ next?.delete();
509
593
  }
510
- return edges;
511
594
  }
512
595
  /**
513
596
  * Analyze adjacent frame pairs to compute information gain scores (G(t)).
@@ -518,37 +601,66 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
518
601
  * 2. DBSCAN Spatial Clustering
519
602
  * 3. Spatio-temporal IoU Tracking
520
603
  * 4. G(t) Information Gain Scoring
604
+ * @param ctx - Frames, analysis options, and the progress callback for this run.
605
+ * @returns Adjacent scores and tracked animations in analysis coordinates.
606
+ * @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
521
607
  */
522
608
  async function analyzeFrames(ctx) {
523
609
  const { frames } = ctx;
524
610
  if (frames.length < 2) return {
525
611
  edges: [],
526
- animations: []
612
+ animations: [],
613
+ analysisResolution: {
614
+ width: 0,
615
+ height: 0
616
+ }
527
617
  };
528
618
  logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
529
619
  const cvLib = await ensureOpenCV();
530
620
  const edges = [];
531
- const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
621
+ const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
532
622
  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);
623
+ let akaze = null;
624
+ let carry = null;
625
+ let analysisResolution = {
626
+ width: 0,
627
+ height: 0
628
+ };
629
+ let failures = 0;
630
+ try {
631
+ akaze = new cvLib.AKAZE();
632
+ for (let i = 0; i < frames.length - 1; i += 10) {
633
+ const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
634
+ const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
635
+ carry = result.carry;
636
+ failures += result.failures;
637
+ if (i === 0) analysisResolution = result.analysisResolution;
638
+ edges.push(...result.edges);
639
+ const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
640
+ ctx.emitProgress(progress);
641
+ }
642
+ } finally {
643
+ carry?.features.delete();
644
+ akaze?.delete();
539
645
  }
646
+ const pairs = frames.length - 1;
647
+ if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
540
648
  const animations = tracker.flushAndGetAnimations();
541
649
  logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
542
650
  return {
543
651
  edges,
544
- animations
652
+ animations,
653
+ analysisResolution
545
654
  };
546
655
  }
547
656
  var OPENCV_INIT_TIMEOUT_MS, require, cvReady, IoUTracker;
548
- var init_analyzer = __esmMin((() => {
549
- init_constants();
657
+ var init_analyzer$1 = __esmMin((() => {
658
+ init_pipeline_defaults();
659
+ init_vision_tuning();
550
660
  init_logger();
551
661
  init_dbscan();
662
+ init_feature_diff();
663
+ init_frame_features();
552
664
  OPENCV_INIT_TIMEOUT_MS = 3e4;
553
665
  require = createRequire(import.meta.url);
554
666
  cvReady = null;
@@ -636,7 +748,75 @@ var init_analyzer = __esmMin((() => {
636
748
  }));
637
749
 
638
750
  //#endregion
639
- //#region src/utils/paths.ts
751
+ //#region src/core/analyzer/index.ts
752
+ var init_analyzer = __esmMin((() => {
753
+ init_analyzer$1();
754
+ init_dbscan();
755
+ }));
756
+
757
+ //#endregion
758
+ //#region src/core/utils/metadata/build-video-metadata.ts
759
+ /**
760
+ * Read output dimensions and build consistent video and animation metadata.
761
+ * JPEG finalization does not resize, so the source dimensions match the output.
762
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
763
+ * @param selected Selected frames in output order; the first candidate is the fallback.
764
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
765
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
766
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
767
+ */
768
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
769
+ const firstFrame = selected[0] ?? ctx.frames[0];
770
+ const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
771
+ const width = dimensions?.width ?? 0;
772
+ const height = dimensions?.height ?? 0;
773
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
774
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
775
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
776
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
777
+ return {
778
+ video: {
779
+ originalDurationMs: Math.round(duration * 1e3),
780
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
781
+ resolution: {
782
+ width,
783
+ height
784
+ }
785
+ },
786
+ animations: (ctx.animations ?? []).map((animation) => {
787
+ const box = animation.boundingBox;
788
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
789
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
790
+ return {
791
+ ...animation,
792
+ boundingBox: {
793
+ x,
794
+ y,
795
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
796
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
797
+ }
798
+ };
799
+ })
800
+ };
801
+ }
802
+ var init_build_video_metadata = __esmMin((() => {}));
803
+
804
+ //#endregion
805
+ //#region src/core/constants/workspace-layout.ts
806
+ function getTempWorkspaceDir(sessionId) {
807
+ return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
808
+ }
809
+ var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
810
+ var init_workspace_layout = __esmMin((() => {
811
+ APP_NAME = "scene-sieve";
812
+ WORKSPACE_PREFIX = `${APP_NAME}-`;
813
+ TEMP_BASE_DIR = tmpdir();
814
+ FRAME_OUTPUT_EXTENSION = ".jpg";
815
+ FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
816
+ }));
817
+
818
+ //#endregion
819
+ //#region src/core/utils/filesystem/paths.ts
640
820
  async function ensureDir(dirPath) {
641
821
  await mkdir(dirPath, { recursive: true });
642
822
  }
@@ -675,13 +855,18 @@ function deriveOutputPath(inputPath) {
675
855
  var init_paths = __esmMin((() => {}));
676
856
 
677
857
  //#endregion
678
- //#region src/core/extractor.ts
858
+ //#region src/core/extractor/extractor.ts
679
859
  /**
680
860
  * 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.
861
+ * @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
862
+ * @returns Extracted candidates, or the unchanged input array in frames mode.
863
+ * @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
683
864
  */
684
865
  async function extractFrames(ctx) {
866
+ if (ctx.options.mode === "frames") {
867
+ ctx.effectiveFps = 1;
868
+ return ctx.frames;
869
+ }
685
870
  const framesDir = join(ctx.workspacePath, "frames");
686
871
  const { inputPath, fps, maxFrames, scale } = ctx.options;
687
872
  if (!inputPath) throw new Error("inputPath is required for frame extraction");
@@ -696,19 +881,30 @@ async function extractFrames(ctx) {
696
881
  if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
697
882
  logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
698
883
  await ensureDir(framesDir);
884
+ const frameLimit = Math.max(2, maxFrames);
699
885
  let effectiveFps = fps;
700
886
  if (duration > 0) {
701
- const fpsCap = maxFrames / duration;
887
+ const fpsCap = frameLimit / duration;
702
888
  effectiveFps = Math.min(fps, fpsCap);
703
- effectiveFps = Math.max(.5, effectiveFps);
704
889
  logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
705
890
  }
706
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, duration);
891
+ ctx.effectiveFps = effectiveFps;
892
+ ctx.sourceDurationSec = duration;
893
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
707
894
  ctx.emitProgress(100);
708
895
  logger.debug(`Extracted ${frames.length} frames`);
709
896
  return frames;
710
897
  }
711
- async function extractByFps(inputPath, outputDir, fps, scale, duration) {
898
+ /**
899
+ * Write scaled JPEG candidates through the bundled FFmpeg runtime.
900
+ * @param inputPath Readable video input.
901
+ * @param outputDir Existing frame directory.
902
+ * @param fps Positive effective sampling frequency.
903
+ * @param scale Output image height.
904
+ * @param frameLimit Maximum number of output frames.
905
+ * @returns Candidates with local output-grid timestamps; rejects on extraction failure.
906
+ */
907
+ async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
712
908
  const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
713
909
  await execa(ffmpegPath, [
714
910
  "-i",
@@ -717,9 +913,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
717
913
  `fps=${fps},scale=-1:${scale}`,
718
914
  "-q:v",
719
915
  "2",
916
+ "-frames:v",
917
+ String(frameLimit),
720
918
  outputPattern
721
919
  ]);
722
- return buildFrameList(outputDir, duration);
920
+ return buildFrameList(outputDir, fps);
723
921
  }
724
922
  async function getVideoMetadata(inputPath) {
725
923
  const { stdout } = await execa(path, [
@@ -733,12 +931,18 @@ async function getVideoMetadata(inputPath) {
733
931
  ]);
734
932
  return JSON.parse(stdout);
735
933
  }
736
- async function buildFrameList(framesDir, duration) {
934
+ /**
935
+ * Read sorted JPEG paths and attach local output-grid times.
936
+ * @param framesDir Extracted frame directory; filesystem errors propagate.
937
+ * @param effectiveFps Positive frequency used by the fps filter.
938
+ * @returns Zero-based candidates without a segment seek offset.
939
+ */
940
+ async function buildFrameList(framesDir, effectiveFps) {
737
941
  const jpgFiles = filter(await readdir(framesDir), (f) => f.endsWith(".jpg")).sort();
738
942
  if (jpgFiles.length === 0) return [];
739
943
  return map(jpgFiles, (file, index) => ({
740
944
  id: index,
741
- timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
945
+ timestamp: index / effectiveFps,
742
946
  extractPath: join(framesDir, file)
743
947
  }));
744
948
  }
@@ -752,9 +956,10 @@ async function buildFrameList(framesDir, duration) {
752
956
  * @param scale - Height scale for vision analysis
753
957
  * @param startTime - Start time in seconds
754
958
  * @param duration - Duration in seconds to extract
959
+ * @param frameLimit - Positive output limit; defaults to the range's grid capacity
755
960
  * @returns Array of FrameNode with segment-local timestamps (starting from 0)
756
961
  */
757
- async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
962
+ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
758
963
  const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
759
964
  await execa(ffmpegPath, [
760
965
  "-ss",
@@ -767,18 +972,124 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
767
972
  `fps=${fps},scale=-1:${scale}`,
768
973
  "-q:v",
769
974
  "2",
975
+ "-frames:v",
976
+ String(frameLimit),
770
977
  outputPattern
771
978
  ]);
772
- return buildFrameList(outputDir, duration);
979
+ return buildFrameList(outputDir, fps);
773
980
  }
774
- var init_extractor = __esmMin((() => {
775
- init_constants();
981
+ var init_extractor$1 = __esmMin((() => {
982
+ init_workspace_layout();
776
983
  init_logger();
777
984
  init_paths();
778
985
  }));
779
986
 
780
987
  //#endregion
781
- //#region src/core/workspace.ts
988
+ //#region src/core/extractor/index.ts
989
+ var init_extractor = __esmMin((() => {
990
+ init_extractor$1();
991
+ }));
992
+
993
+ //#endregion
994
+ //#region src/core/input-resolver/validation/validate-options.ts
995
+ /**
996
+ * Reject invalid numeric options before defaults or pipeline effects are applied.
997
+ * @param options - Supplied options; omitted numeric fields use pipeline defaults.
998
+ * @returns Nothing when all supplied numeric fields satisfy their contracts.
999
+ * @throws An input error naming the invalid option and its received value.
1000
+ */
1001
+ function validateOptions(options) {
1002
+ for (const [name, min, max, integer, exclusiveMin, requirement] of [
1003
+ [
1004
+ "count",
1005
+ 1,
1006
+ Infinity,
1007
+ true,
1008
+ false,
1009
+ "an integer >= 1"
1010
+ ],
1011
+ [
1012
+ "threshold",
1013
+ 0,
1014
+ 1,
1015
+ false,
1016
+ true,
1017
+ "in range (0, 1] and finite"
1018
+ ],
1019
+ [
1020
+ "fps",
1021
+ 0,
1022
+ Infinity,
1023
+ false,
1024
+ true,
1025
+ "finite and > 0"
1026
+ ],
1027
+ [
1028
+ "maxFrames",
1029
+ 2,
1030
+ Infinity,
1031
+ true,
1032
+ false,
1033
+ "an integer >= 2"
1034
+ ],
1035
+ [
1036
+ "scale",
1037
+ 16,
1038
+ Infinity,
1039
+ true,
1040
+ false,
1041
+ "an integer >= 16"
1042
+ ],
1043
+ [
1044
+ "quality",
1045
+ 1,
1046
+ 100,
1047
+ true,
1048
+ false,
1049
+ "an integer in range [1, 100]"
1050
+ ],
1051
+ [
1052
+ "iouThreshold",
1053
+ 0,
1054
+ 1,
1055
+ false,
1056
+ false,
1057
+ "finite and in range [0, 1]"
1058
+ ],
1059
+ [
1060
+ "animationThreshold",
1061
+ 1,
1062
+ Infinity,
1063
+ true,
1064
+ false,
1065
+ "an integer >= 1"
1066
+ ],
1067
+ [
1068
+ "maxSegmentDuration",
1069
+ 0,
1070
+ Infinity,
1071
+ false,
1072
+ true,
1073
+ "finite and > 0"
1074
+ ],
1075
+ [
1076
+ "concurrency",
1077
+ 1,
1078
+ Infinity,
1079
+ true,
1080
+ false,
1081
+ "an integer >= 1"
1082
+ ]
1083
+ ]) {
1084
+ const value = options[name];
1085
+ if (value === void 0) continue;
1086
+ if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
1087
+ }
1088
+ }
1089
+ var init_validate_options = __esmMin((() => {}));
1090
+
1091
+ //#endregion
1092
+ //#region src/core/workspace/workspace.ts
782
1093
  async function createWorkspace(sessionId) {
783
1094
  const workspacePath = getTempWorkspaceDir(sessionId);
784
1095
  await ensureDir(join(workspacePath, "frames"));
@@ -809,17 +1120,11 @@ async function finalizeOutput(ctx, selectedFrames) {
809
1120
  timestampMs: Math.round(frame.timestamp * 1e3)
810
1121
  });
811
1122
  }
1123
+ const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
812
1124
  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
- },
1125
+ video,
821
1126
  frames: framesMetadata,
822
- animations: map(ctx.animations || [], (anim) => ({
1127
+ animations: map(animations, (anim) => ({
823
1128
  ...anim,
824
1129
  startFrameId: anim.startFrameId + 1,
825
1130
  endFrameId: anim.endFrameId + 1,
@@ -910,20 +1215,33 @@ async function readFramesAsBuffers(frameNodes, quality) {
910
1215
  }).toBuffer()));
911
1216
  }
912
1217
  var STALE_THRESHOLD_MS;
913
- var init_workspace = __esmMin((() => {
914
- init_constants();
1218
+ var init_workspace$1 = __esmMin((() => {
1219
+ init_workspace_layout();
915
1220
  init_paths();
1221
+ init_build_video_metadata();
916
1222
  STALE_THRESHOLD_MS = 3600 * 1e3;
917
1223
  }));
918
1224
 
919
1225
  //#endregion
920
- //#region src/core/input-resolver.ts
1226
+ //#region src/core/workspace/index.ts
1227
+ var init_workspace = __esmMin((() => {
1228
+ init_workspace$1();
1229
+ }));
1230
+
1231
+ //#endregion
1232
+ //#region src/core/input-resolver/input-resolver.ts
1233
+ /**
1234
+ * Validate supplied options and resolve defaults and paths for the pipeline.
1235
+ * @param options - Mode-specific input and optional numeric settings.
1236
+ * @returns Complete pipeline settings with absolute file input paths.
1237
+ * @throws An input error if a supplied numeric setting is invalid.
1238
+ */
921
1239
  function resolveOptions(options) {
1240
+ validateOptions(options);
922
1241
  const mode = options.mode;
923
1242
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
924
1243
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
925
1244
  const threshold = options.threshold ?? .5;
926
- if (threshold <= 0 || threshold > 1) throw new Error(`threshold must be in range (0, 1], received: ${threshold}`);
927
1245
  return {
928
1246
  mode,
929
1247
  inputPath,
@@ -948,6 +1266,10 @@ function resolveOptions(options) {
948
1266
  * - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
949
1267
  * - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
950
1268
  * - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
1269
+ * @param options - Input source; encoded frames must have matching dimensions.
1270
+ * @param workspacePath - Workspace receiving temporary input files.
1271
+ * @returns Frame nodes or a resolved video path for extraction.
1272
+ * @throws Propagates metadata or write errors and rejects mismatched frame sizes.
951
1273
  */
952
1274
  async function resolveInput(options, workspacePath) {
953
1275
  if (options.mode === "file") return {
@@ -958,17 +1280,51 @@ async function resolveInput(options, workspacePath) {
958
1280
  frames: [],
959
1281
  resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
960
1282
  };
961
- if (options.mode === "frames") return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1283
+ if (options.mode === "frames") {
1284
+ let dimensions;
1285
+ for (const buffer of options.inputFrames) {
1286
+ const { width, height } = await sharp(buffer).metadata();
1287
+ 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}`);
1288
+ dimensions = {
1289
+ width,
1290
+ height
1291
+ };
1292
+ }
1293
+ return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1294
+ }
962
1295
  throw new Error(`Unsupported input mode: ${options.mode}`);
963
1296
  }
964
- var init_input_resolver = __esmMin((() => {
965
- init_constants();
1297
+ var init_input_resolver$1 = __esmMin((() => {
1298
+ init_pipeline_defaults();
966
1299
  init_paths();
1300
+ init_validate_options();
967
1301
  init_workspace();
968
1302
  }));
969
1303
 
970
1304
  //#endregion
971
- //#region src/utils/math.ts
1305
+ //#region src/core/input-resolver/index.ts
1306
+ var init_input_resolver = __esmMin((() => {
1307
+ init_input_resolver$1();
1308
+ }));
1309
+
1310
+ //#endregion
1311
+ //#region src/core/pruner/scoring/normalize-scores.ts
1312
+ /**
1313
+ * Find the first position whose score is at least the requested value.
1314
+ * @param sorted - Finite positive scores sorted in ascending order.
1315
+ * @param value - A finite positive score present in sorted.
1316
+ * @returns The first matching rank, including the first position of any tie.
1317
+ */
1318
+ function lowerBound(sorted, value) {
1319
+ let low = 0;
1320
+ let high = sorted.length;
1321
+ while (low < high) {
1322
+ const mid = Math.floor((low + high) / 2);
1323
+ if (sorted[mid] < value) low = mid + 1;
1324
+ else high = mid;
1325
+ }
1326
+ return low;
1327
+ }
972
1328
  /**
973
1329
  * Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
974
1330
  *
@@ -1013,16 +1369,19 @@ function normalizeScores(items) {
1013
1369
  });
1014
1370
  const cdf = map(safeScores, (s) => {
1015
1371
  if (s <= 0) return 0;
1016
- return sorted.findIndex((v) => v >= s) / sorted.length;
1372
+ return lowerBound(sorted, s) / sorted.length;
1017
1373
  });
1018
1374
  return map(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
1019
1375
  }
1020
- var init_math = __esmMin((() => {
1021
- init_constants();
1376
+ var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
1377
+ var init_normalize_scores = __esmMin((() => {
1378
+ NORMALIZATION_ALPHA = .4;
1379
+ NORMALIZATION_MAD_COEFFICIENT = 1.4826;
1380
+ NORMALIZATION_MIN_SAMPLE_SIZE = 10;
1022
1381
  }));
1023
1382
 
1024
1383
  //#endregion
1025
- //#region src/utils/min-heap.ts
1384
+ //#region src/core/pruner/heap/min-heap.ts
1026
1385
  var MinHeap;
1027
1386
  var init_min_heap = __esmMin((() => {
1028
1387
  MinHeap = class {
@@ -1070,7 +1429,7 @@ var init_min_heap = __esmMin((() => {
1070
1429
  }));
1071
1430
 
1072
1431
  //#endregion
1073
- //#region src/core/pruner.ts
1432
+ //#region src/core/pruner/pruner.ts
1074
1433
  /**
1075
1434
  * Edge-aware greedy merge with re-linking — O(N log N).
1076
1435
  *
@@ -1184,7 +1543,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
1184
1543
  return result;
1185
1544
  }
1186
1545
  /**
1187
- * Threshold-based pruning with NMS -- O(N).
1546
+ * Threshold-based pruning with NMS -- including normalization, O(N log N).
1188
1547
  *
1189
1548
  * 1. Scores are normalized to [0, 1] via percentile normalization.
1190
1549
  * 2. Edges with normalized score >= threshold are collected.
@@ -1247,13 +1606,19 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1247
1606
  }
1248
1607
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
1249
1608
  }
1250
- var init_pruner = __esmMin((() => {
1251
- init_math();
1609
+ var init_pruner$1 = __esmMin((() => {
1610
+ init_normalize_scores();
1252
1611
  init_min_heap();
1253
1612
  }));
1254
1613
 
1255
1614
  //#endregion
1256
- //#region src/utils/concurrency.ts
1615
+ //#region src/core/pruner/index.ts
1616
+ var init_pruner = __esmMin((() => {
1617
+ init_pruner$1();
1618
+ }));
1619
+
1620
+ //#endregion
1621
+ //#region src/core/segmenter/scheduling/concurrency.ts
1257
1622
  /**
1258
1623
  * Creates a concurrency limiter that runs at most `limit` tasks in parallel.
1259
1624
  * Lightweight replacement for p-limit to avoid external dependency.
@@ -1276,7 +1641,7 @@ function concurrencyLimit(limit) {
1276
1641
  var init_concurrency = __esmMin((() => {}));
1277
1642
 
1278
1643
  //#endregion
1279
- //#region src/core/segmenter.ts
1644
+ //#region src/core/segmenter/segmenter.ts
1280
1645
  /**
1281
1646
  * Determine whether segmentation should be used.
1282
1647
  * Returns false for frames mode and GIF files.
@@ -1290,53 +1655,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
1290
1655
  return true;
1291
1656
  }
1292
1657
  /**
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)
1658
+ * Partition the global extraction grid into nonempty logical segments.
1659
+ * @param totalDuration Positive source duration in seconds.
1660
+ * @param maxSegmentDuration Positive logical segment width in seconds.
1661
+ * @param maxFrames Candidate budget, defensively raised to at least two.
1662
+ * @param fps Positive requested sampling frequency.
1663
+ * @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
1299
1664
  */
1300
1665
  function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
1301
- const effectiveFps = Math.max(.5, Math.min(fps, maxFrames / totalDuration));
1666
+ const frameLimit = Math.max(2, maxFrames);
1667
+ const effectiveFps = Math.min(fps, frameLimit / totalDuration);
1302
1668
  if (totalDuration <= maxSegmentDuration) return [{
1303
1669
  index: 0,
1304
1670
  startTime: 0,
1305
1671
  endTime: totalDuration,
1306
1672
  duration: totalDuration,
1307
- allocatedFrames: Math.min(Math.ceil(effectiveFps * totalDuration), maxFrames),
1673
+ allocatedFrames: frameLimit,
1308
1674
  effectiveFps,
1309
1675
  overlapBefore: 0,
1310
1676
  overlapAfter: 0,
1311
1677
  extractStartTime: 0,
1312
1678
  extractDuration: totalDuration
1313
1679
  }];
1314
- const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
1315
- const overlapTime = 1 / effectiveFps;
1316
1680
  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;
1681
+ for (let slot = 0; slot < frameLimit; slot++) {
1682
+ const timestamp = slot / effectiveFps;
1683
+ if (timestamp >= totalDuration) break;
1684
+ const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
1685
+ const previous = segments[segments.length - 1];
1686
+ if (previous?.startTime === startTime) {
1687
+ previous.allocatedFrames++;
1688
+ continue;
1689
+ }
1690
+ const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
1325
1691
  segments.push({
1326
- index: i,
1692
+ index: segments.length,
1327
1693
  startTime,
1328
1694
  endTime,
1329
- duration,
1330
- allocatedFrames: Math.ceil(effectiveFps * duration),
1695
+ duration: endTime - startTime,
1696
+ allocatedFrames: 1,
1331
1697
  effectiveFps,
1332
- overlapBefore,
1333
- overlapAfter,
1334
- extractStartTime,
1335
- extractDuration
1698
+ overlapBefore: 0,
1699
+ overlapAfter: 0,
1700
+ extractStartTime: timestamp,
1701
+ extractDuration: 0
1336
1702
  });
1337
1703
  }
1338
- const totalAllocated = segments.reduce((sum, s) => sum + s.allocatedFrames, 0);
1339
- if (totalAllocated > maxFrames) segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
1704
+ let firstSlot = 0;
1705
+ for (const segment of segments) {
1706
+ const nextSlot = firstSlot + segment.allocatedFrames;
1707
+ segment.overlapBefore = segment.index > 0 ? 1 : 0;
1708
+ segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
1709
+ segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
1710
+ segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
1711
+ segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
1712
+ firstSlot = nextSlot;
1713
+ }
1340
1714
  return segments;
1341
1715
  }
1342
1716
  /**
@@ -1355,44 +1729,58 @@ function collectAllFrames(segmentResults) {
1355
1729
  return allFrames;
1356
1730
  }
1357
1731
  /**
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).
1732
+ * Sort frames in place and alias overlap duplicates to the first survivor.
1733
+ * @param frames Collected entries whose segment index and local ID identify a frame.
1734
+ * @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
1735
+ * @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
1361
1736
  */
1362
1737
  function deduplicateFrames(frames, effectiveFps) {
1363
1738
  frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1364
1739
  const dupThreshold = 1 / (effectiveFps * 2);
1365
1740
  const unique = [];
1741
+ const aliases = /* @__PURE__ */ new Map();
1366
1742
  for (const entry of frames) {
1367
1743
  if (unique.length > 0) {
1368
1744
  const last = unique[unique.length - 1];
1369
- if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) continue;
1745
+ if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
1746
+ aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
1747
+ continue;
1748
+ }
1370
1749
  }
1371
1750
  unique.push(entry);
1372
1751
  }
1373
- return unique;
1752
+ return {
1753
+ unique,
1754
+ aliases
1755
+ };
1374
1756
  }
1375
1757
  /**
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.
1758
+ * Assign sequential global IDs and retain duplicate local IDs as aliases.
1759
+ * @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
1760
+ * @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
1761
+ * @returns Remapped frames and a global ID lookup covering survivors and duplicates.
1378
1762
  */
1379
- function remapFrameIds(uniqueFrames) {
1763
+ function remapFrameIds(uniqueFrames, aliases) {
1380
1764
  const globalIdMap = /* @__PURE__ */ new Map();
1765
+ const frames = uniqueFrames.map((entry, globalId) => {
1766
+ globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1767
+ return {
1768
+ id: globalId,
1769
+ timestamp: entry.frame.timestamp,
1770
+ extractPath: entry.frame.extractPath
1771
+ };
1772
+ });
1773
+ for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
1381
1774
  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
- }),
1775
+ frames,
1390
1776
  globalIdMap
1391
1777
  };
1392
1778
  }
1393
1779
  /**
1394
- * Remap edge source/target IDs using the global ID map.
1395
- * Duplicate edges (same source-target pair) retain the higher score.
1780
+ * Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
1781
+ * @param segmentResults Segment-local edges in encounter order.
1782
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
1783
+ * @returns One edge per surviving directed pair without changing its score.
1396
1784
  */
1397
1785
  function remapEdges(segmentResults, globalIdMap) {
1398
1786
  const edges = [];
@@ -1401,6 +1789,7 @@ function remapEdges(segmentResults, globalIdMap) {
1401
1789
  const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
1402
1790
  const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
1403
1791
  if (newSourceId === void 0 || newTargetId === void 0) continue;
1792
+ if (newSourceId === newTargetId) continue;
1404
1793
  const edgeKey = `${newSourceId}-${newTargetId}`;
1405
1794
  const existingIdx = edgeMap.get(edgeKey);
1406
1795
  if (existingIdx !== void 0) {
@@ -1421,42 +1810,52 @@ function remapEdges(segmentResults, globalIdMap) {
1421
1810
  return edges;
1422
1811
  }
1423
1812
  /**
1424
- * Remap animation startFrameId/endFrameId using the global ID map.
1425
- * Animations whose frame IDs were deduplicated (not in map) are dropped.
1813
+ * Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
1814
+ * @param segmentResults Segment-local tracker entries in encounter order.
1815
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
1816
+ * @returns One animation per directed pair with its original tracker duration and metadata.
1426
1817
  */
1427
1818
  function remapAnimations(segmentResults, globalIdMap) {
1428
- const animations = [];
1819
+ const animations = /* @__PURE__ */ new Map();
1429
1820
  for (const result of segmentResults) for (const anim of result.animations) {
1430
1821
  const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
1431
1822
  const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
1432
1823
  if (newStartId === void 0 || newEndId === void 0) continue;
1433
- animations.push({
1824
+ if (newStartId === newEndId) continue;
1825
+ const animationKey = `${newStartId}-${newEndId}`;
1826
+ if (animations.has(animationKey)) continue;
1827
+ animations.set(animationKey, {
1434
1828
  ...anim,
1435
1829
  startFrameId: newStartId,
1436
1830
  endFrameId: newEndId
1437
1831
  });
1438
1832
  }
1439
- return animations;
1833
+ return [...animations.values()];
1440
1834
  }
1441
1835
  /**
1442
1836
  * 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
1837
+ * @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
1838
+ * @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
1839
+ * Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
1447
1840
  */
1448
1841
  function mergeSegmentFrames(segmentResults) {
1449
1842
  if (segmentResults.length === 0) return {
1450
1843
  frames: [],
1451
1844
  edges: [],
1452
- animations: []
1845
+ animations: [],
1846
+ analysisResolution: {
1847
+ width: 0,
1848
+ height: 0
1849
+ }
1453
1850
  };
1454
1851
  const effectiveFps = segmentResults[0].segment.effectiveFps;
1455
- const { frames, globalIdMap } = remapFrameIds(deduplicateFrames(collectAllFrames(segmentResults), effectiveFps));
1852
+ const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
1853
+ const { frames, globalIdMap } = remapFrameIds(unique, aliases);
1456
1854
  return {
1457
1855
  frames,
1458
1856
  edges: remapEdges(segmentResults, globalIdMap),
1459
- animations: remapAnimations(segmentResults, globalIdMap)
1857
+ animations: remapAnimations(segmentResults, globalIdMap),
1858
+ analysisResolution: segmentResults[0].analysisResolution
1460
1859
  };
1461
1860
  }
1462
1861
  function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
@@ -1467,6 +1866,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1467
1866
  maxFrames: segment.allocatedFrames
1468
1867
  },
1469
1868
  workspacePath: segmentWorkspacePath,
1869
+ effectiveFps: segment.effectiveFps,
1470
1870
  frames,
1471
1871
  graph: [],
1472
1872
  status: "ANALYZING",
@@ -1478,19 +1878,24 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1478
1878
  * Each segment uses an isolated workspace directory.
1479
1879
  */
1480
1880
  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);
1881
+ const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1482
1882
  if (frames.length < 2) return {
1483
1883
  segment,
1484
1884
  frames,
1485
1885
  edges: [],
1486
- animations: []
1886
+ animations: [],
1887
+ analysisResolution: {
1888
+ width: 0,
1889
+ height: 0
1890
+ }
1487
1891
  };
1488
- const { edges, animations } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
1892
+ const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
1489
1893
  return {
1490
1894
  segment,
1491
1895
  frames,
1492
1896
  edges,
1493
- animations
1897
+ animations,
1898
+ analysisResolution
1494
1899
  };
1495
1900
  }
1496
1901
  /**
@@ -1530,7 +1935,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1530
1935
  });
1531
1936
  })));
1532
1937
  options.onProgress?.("ANALYZING", 100);
1533
- const { frames, edges, animations } = mergeSegmentFrames(results);
1938
+ const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
1534
1939
  logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
1535
1940
  options.onProgress?.("PRUNING", 0);
1536
1941
  const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
@@ -1539,6 +1944,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1539
1944
  options.onProgress?.("FINALIZING", 0);
1540
1945
  const ctx = {
1541
1946
  options: resolvedOptions,
1947
+ effectiveFps: segments[0]?.effectiveFps,
1948
+ sourceDurationSec: totalDuration,
1949
+ analysisResolution,
1542
1950
  workspacePath: mainWorkspace,
1543
1951
  frames,
1544
1952
  graph: edges,
@@ -1552,21 +1960,14 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1552
1960
  else outputFiles = await finalizeOutput(ctx, prunedFrames);
1553
1961
  options.onProgress?.("FINALIZING", 100);
1554
1962
  logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1963
+ const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1555
1964
  return {
1556
1965
  success: true,
1557
1966
  originalFramesCount: frames.length,
1558
1967
  prunedFramesCount: prunedFrames.length,
1559
1968
  outputFiles,
1560
1969
  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
- },
1970
+ ...outputMetadata,
1570
1971
  executionTimeMs: Date.now() - pipelineStart
1571
1972
  };
1572
1973
  } catch (error) {
@@ -1578,10 +1979,11 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1578
1979
  else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1579
1980
  }
1580
1981
  }
1581
- var init_segmenter = __esmMin((() => {
1982
+ var init_segmenter$1 = __esmMin((() => {
1582
1983
  init_concurrency();
1583
1984
  init_logger();
1584
1985
  init_analyzer();
1986
+ init_build_video_metadata();
1585
1987
  init_extractor();
1586
1988
  init_input_resolver();
1587
1989
  init_pruner();
@@ -1589,10 +1991,16 @@ var init_segmenter = __esmMin((() => {
1589
1991
  }));
1590
1992
 
1591
1993
  //#endregion
1592
- //#region src/core/orchestrator.ts
1994
+ //#region src/core/segmenter/index.ts
1995
+ var init_segmenter = __esmMin((() => {
1996
+ init_segmenter$1();
1997
+ }));
1998
+
1999
+ //#endregion
2000
+ //#region src/core/orchestrator/orchestrator.ts
1593
2001
  var orchestrator_exports = /* @__PURE__ */ __exportAll({ runPipeline: () => runPipeline });
1594
2002
  async function runPipeline(options) {
1595
- if (options.debug ?? false) setDebugMode(true);
2003
+ setDebugMode(options.debug ?? false);
1596
2004
  const resolvedOptions = resolveOptions(options);
1597
2005
  if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
1598
2006
  const startTime = Date.now();
@@ -1612,19 +2020,27 @@ async function runPipeline(options) {
1612
2020
  logger.debug(`Workspace created: ${ctx.workspacePath}`);
1613
2021
  ctx.status = "EXTRACTING";
1614
2022
  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
- });
2023
+ if (resolvedOptions.mode === "frames") {
2024
+ ctx.frames = resolvedFrames;
2025
+ ctx.effectiveFps = 1;
2026
+ } else {
2027
+ const extractCtx = {
2028
+ ...ctx,
2029
+ options: {
2030
+ ...resolvedOptions,
2031
+ inputPath: resolvedInputPath
2032
+ }
2033
+ };
2034
+ ctx.frames = await extractFrames(extractCtx);
2035
+ ctx.effectiveFps = extractCtx.effectiveFps;
2036
+ ctx.sourceDurationSec = extractCtx.sourceDurationSec;
2037
+ }
1623
2038
  ctx.emitProgress(100);
1624
2039
  ctx.status = "ANALYZING";
1625
- const { edges, animations } = await analyzeFrames(ctx);
2040
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1626
2041
  ctx.graph = edges;
1627
2042
  ctx.animations = animations;
2043
+ ctx.analysisResolution = analysisResolution;
1628
2044
  ctx.status = "PRUNING";
1629
2045
  const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
1630
2046
  const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
@@ -1641,21 +2057,14 @@ async function runPipeline(options) {
1641
2057
  }
1642
2058
  ctx.status = "SUCCESS";
1643
2059
  logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
2060
+ const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1644
2061
  return {
1645
2062
  success: true,
1646
2063
  originalFramesCount: ctx.frames.length,
1647
2064
  prunedFramesCount: prunedFrames.length,
1648
2065
  outputFiles,
1649
2066
  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
- },
2067
+ ...metadata,
1659
2068
  executionTimeMs: Date.now() - startTime
1660
2069
  };
1661
2070
  } catch (error) {
@@ -1671,6 +2080,7 @@ async function runPipeline(options) {
1671
2080
  var init_orchestrator = __esmMin((() => {
1672
2081
  init_logger();
1673
2082
  init_analyzer();
2083
+ init_build_video_metadata();
1674
2084
  init_extractor();
1675
2085
  init_input_resolver();
1676
2086
  init_pruner();
@@ -1679,13 +2089,16 @@ var init_orchestrator = __esmMin((() => {
1679
2089
  }));
1680
2090
 
1681
2091
  //#endregion
1682
- //#region src/core/run-in-worker.ts
1683
- init_orchestrator();
2092
+ //#region src/core/orchestrator/worker/run-in-worker.ts
1684
2093
  /**
1685
2094
  * Run the pipeline, choosing the best execution strategy:
1686
2095
  *
1687
2096
  * - Production (bundled .mjs): Worker thread — spinner never freezes
1688
2097
  * - Dev mode (tsx .ts): Main thread — simpler, spinner may stutter during CPU work
2098
+ * @param options - Serializable input and pipeline settings for this run.
2099
+ * @param onProgress - Receives worker progress updates.
2100
+ * @returns The pipeline result; settlement is unchanged by later exit events.
2101
+ * @throws Rejects worker errors or any worker exit before a result is received.
1689
2102
  */
1690
2103
  async function runPipelineInWorker(options, onProgress) {
1691
2104
  const currentFile = fileURLToPath(import.meta.url);
@@ -1711,13 +2124,13 @@ async function runPipelineInWorker(options, onProgress) {
1711
2124
  });
1712
2125
  worker.on("error", reject);
1713
2126
  worker.on("exit", (code) => {
1714
- if (code !== 0 && code !== 1) reject(/* @__PURE__ */ new Error(`Worker exited with code ${code}`));
2127
+ reject(/* @__PURE__ */ new Error(`Worker exited with code ${code} without a result`));
1715
2128
  });
1716
2129
  });
1717
2130
  }
1718
2131
 
1719
2132
  //#endregion
1720
- //#region src/errors.ts
2133
+ //#region src/cli/errors/classify-error.ts
1721
2134
  const SieveErrorCode = {
1722
2135
  INVALID_INPUT: "INVALID_INPUT",
1723
2136
  FILE_NOT_FOUND: "FILE_NOT_FOUND",
@@ -1726,16 +2139,22 @@ const SieveErrorCode = {
1726
2139
  WORKER_ERROR: "WORKER_ERROR",
1727
2140
  UNKNOWN: "UNKNOWN"
1728
2141
  };
2142
+ /**
2143
+ * Classify pipeline errors for structured CLI responses.
2144
+ * @param error - Failure with a diagnostic message and optional filesystem code.
2145
+ * @returns The existing error code matching the failure.
2146
+ */
1729
2147
  function classifyError(error) {
1730
2148
  const msg = error.message.toLowerCase();
1731
- if (error.code === "ENOENT" || msg.includes("not found")) return SieveErrorCode.FILE_NOT_FOUND;
2149
+ if (msg.includes("must be")) return SieveErrorCode.INVALID_INPUT;
2150
+ else if (error.code === "ENOENT" || msg.includes("not found")) return SieveErrorCode.FILE_NOT_FOUND;
1732
2151
  else if (msg.includes("no video stream") || msg.includes("invalid format")) return SieveErrorCode.INVALID_FORMAT;
1733
2152
  else if (msg.includes("worker")) return SieveErrorCode.WORKER_ERROR;
1734
2153
  else return SieveErrorCode.PIPELINE_ERROR;
1735
2154
  }
1736
2155
 
1737
2156
  //#endregion
1738
- //#region src/utils/command-registry.ts
2157
+ //#region src/cli/commands/command-registry.ts
1739
2158
  const SIEVE_COMMAND = {
1740
2159
  name: "scene-sieve",
1741
2160
  description: "Extract key frames from video and GIF files",
@@ -1831,28 +2250,44 @@ const SIEVE_COMMAND = {
1831
2250
  };
1832
2251
 
1833
2252
  //#endregion
1834
- //#region src/utils/parse-options.ts
2253
+ //#region src/cli/options/parse-options.ts
2254
+ /**
2255
+ * Parse one complete decimal string without truncating fractional values.
2256
+ * @param value - Decimal CLI argument, optionally using an exponent.
2257
+ * @param integer - Whether the result must be an integer.
2258
+ * @returns The finite parsed number, or NaN for invalid input.
2259
+ */
2260
+ function parseNumberStrict(value, integer = false) {
2261
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return NaN;
2262
+ const number = Number(value);
2263
+ return Number.isFinite(number) && (!integer || Number.isInteger(number)) ? number : NaN;
2264
+ }
2265
+ /**
2266
+ * Convert CLI strings to pipeline values for subsequent range validation.
2267
+ * @param opts - Commander options with raw numeric strings.
2268
+ * @returns Typed settings, preserving invalid numeric input as NaN.
2269
+ */
1835
2270
  function parsePipelineOptions(opts) {
1836
2271
  return {
1837
- ...opts.threshold !== void 0 ? { threshold: parseFloat(opts.threshold) } : {},
1838
- ...opts.count !== void 0 ? { count: parseInt(opts.count, 10) } : {},
2272
+ ...opts.threshold !== void 0 ? { threshold: parseNumberStrict(opts.threshold) } : {},
2273
+ ...opts.count !== void 0 ? { count: parseNumberStrict(opts.count, true) } : {},
1839
2274
  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,
2275
+ fps: parseNumberStrict(opts.fps),
2276
+ maxFrames: parseNumberStrict(opts.maxFrames, true),
2277
+ scale: parseNumberStrict(opts.scale, true),
2278
+ quality: parseNumberStrict(opts.quality, true),
2279
+ iouThreshold: opts.iouThreshold !== void 0 ? parseNumberStrict(opts.iouThreshold) : void 0,
2280
+ animationThreshold: opts.animThreshold !== void 0 ? parseNumberStrict(opts.animThreshold, true) : void 0,
2281
+ maxSegmentDuration: opts.maxSegmentDuration !== void 0 ? parseNumberStrict(opts.maxSegmentDuration) : void 0,
2282
+ concurrency: opts.concurrency !== void 0 ? parseNumberStrict(opts.concurrency, true) : void 0,
1848
2283
  debug: opts.debug ?? false
1849
2284
  };
1850
2285
  }
1851
2286
 
1852
2287
  //#endregion
1853
- //#region src/commands/Sieve.tsx
1854
- init_constants();
1855
- init_workspace();
2288
+ //#region src/cli/commands/Sieve.tsx
2289
+ init_pipeline_defaults();
2290
+ init_orchestrator(), init_workspace$1();
1856
2291
  init_logger();
1857
2292
  const PHASE_DEFS = [
1858
2293
  {
@@ -2029,8 +2464,9 @@ const SieveView = (props) => {
2029
2464
  };
2030
2465
  return next;
2031
2466
  });
2032
- setError(err instanceof Error ? err.message : String(err));
2033
- setTimeout(() => exit(), 100);
2467
+ const failure = err instanceof Error ? err : new Error(String(err));
2468
+ setError(failure.message);
2469
+ setTimeout(() => exit(failure), 100);
2034
2470
  }
2035
2471
  })();
2036
2472
  }, []);