@lumy-pack/scene-sieve 0.0.4 → 0.0.5

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.
package/dist/cli.mjs CHANGED
@@ -1,86 +1,94 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- import { createRequire as createRequire2 } from "module";
5
- import { Command } from "commander";
6
-
7
- // src/core/orchestrator.ts
8
- import { randomUUID } from "crypto";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
9
11
 
10
12
  // src/utils/logger.ts
11
13
  import pc from "picocolors";
12
- var debugMode = false;
13
14
  function setDebugMode(enabled) {
14
15
  debugMode = enabled;
15
16
  }
16
17
  function timestamp() {
17
18
  return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
18
19
  }
19
- var logger = {
20
- info(message) {
21
- console.log(`${pc.blue("info")} ${message}`);
22
- },
23
- success(message) {
24
- console.log(`
20
+ var debugMode, logger;
21
+ var init_logger = __esm({
22
+ "src/utils/logger.ts"() {
23
+ "use strict";
24
+ debugMode = false;
25
+ logger = {
26
+ info(message) {
27
+ console.log(`${pc.blue("info")} ${message}`);
28
+ },
29
+ success(message) {
30
+ console.log(`
25
31
  ${pc.green("done")} ${message}`);
26
- },
27
- warn(message) {
28
- console.warn(`${pc.yellow("warn")} ${message}`);
29
- },
30
- error(message) {
31
- console.error(`${pc.red("error")} ${message}`);
32
- },
33
- debug(message) {
34
- if (debugMode) {
35
- console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
36
- }
32
+ },
33
+ warn(message) {
34
+ console.warn(`${pc.yellow("warn")} ${message}`);
35
+ },
36
+ error(message) {
37
+ console.error(`${pc.red("error")} ${message}`);
38
+ },
39
+ debug(message) {
40
+ if (debugMode) {
41
+ console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
42
+ }
43
+ }
44
+ };
37
45
  }
38
- };
39
-
40
- // src/core/analyzer.ts
41
- import { createRequire } from "module";
42
- import sharp from "sharp";
46
+ });
43
47
 
44
48
  // src/constants.ts
45
49
  import { tmpdir } from "os";
46
50
  import { join } from "path";
47
- var APP_NAME = "scene-sieve";
48
- var DEFAULT_COUNT = 20;
49
- var DEFAULT_THRESHOLD = 0.5;
50
- var DEFAULT_FPS = 5;
51
- var DEFAULT_SCALE = 720;
52
- var DEFAULT_QUALITY = 80;
53
- var NORMALIZATION_PERCENTILE = 0.9;
54
- var WORKSPACE_PREFIX = `${APP_NAME}-`;
55
- var TEMP_BASE_DIR = tmpdir();
56
- var SUPPORTED_VIDEO_EXTENSIONS = [
57
- ".mp4",
58
- ".mov",
59
- ".avi",
60
- ".mkv",
61
- ".webm"
62
- ];
63
- var SUPPORTED_GIF_EXTENSIONS = [".gif"];
64
- var FRAME_OUTPUT_EXTENSION = ".jpg";
65
- var OPENCV_BATCH_SIZE = 10;
66
- var MIN_IFRAME_COUNT = 3;
67
- var DBSCAN_ALPHA = 0.03;
68
- var DBSCAN_MIN_PTS = 4;
69
- var IOU_THRESHOLD = 0.9;
70
- var DECAY_LAMBDA = 0.95;
71
- var ANIMATION_FRAME_THRESHOLD = 5;
72
- var MATCH_DISTANCE_THRESHOLD = 0.25;
73
- var PIXELDIFF_GAUSSIAN_KERNEL = 3;
74
- var PIXELDIFF_BINARY_THRESHOLD = 30;
75
- var PIXELDIFF_CONTOUR_MIN_AREA = 100;
76
- var PIXELDIFF_SAMPLE_SPACING = 8;
77
51
  function getTempWorkspaceDir(sessionId) {
78
52
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
79
53
  }
54
+ var APP_NAME, DEFAULT_COUNT, DEFAULT_THRESHOLD, DEFAULT_FPS, DEFAULT_SCALE, DEFAULT_QUALITY, DEFAULT_MAX_FRAMES, NORMALIZATION_PERCENTILE, WORKSPACE_PREFIX, TEMP_BASE_DIR, SUPPORTED_VIDEO_EXTENSIONS, SUPPORTED_GIF_EXTENSIONS, FRAME_OUTPUT_EXTENSION, OPENCV_BATCH_SIZE, DBSCAN_ALPHA, DBSCAN_MIN_PTS, IOU_THRESHOLD, DECAY_LAMBDA, ANIMATION_FRAME_THRESHOLD, MATCH_DISTANCE_THRESHOLD, PIXELDIFF_GAUSSIAN_KERNEL, PIXELDIFF_BINARY_THRESHOLD, PIXELDIFF_CONTOUR_MIN_AREA, PIXELDIFF_SAMPLE_SPACING;
55
+ var init_constants = __esm({
56
+ "src/constants.ts"() {
57
+ "use strict";
58
+ APP_NAME = "scene-sieve";
59
+ DEFAULT_COUNT = 20;
60
+ DEFAULT_THRESHOLD = 0.5;
61
+ DEFAULT_FPS = 5;
62
+ DEFAULT_SCALE = 720;
63
+ DEFAULT_QUALITY = 80;
64
+ DEFAULT_MAX_FRAMES = 300;
65
+ NORMALIZATION_PERCENTILE = 0.9;
66
+ WORKSPACE_PREFIX = `${APP_NAME}-`;
67
+ TEMP_BASE_DIR = tmpdir();
68
+ SUPPORTED_VIDEO_EXTENSIONS = [
69
+ ".mp4",
70
+ ".mov",
71
+ ".avi",
72
+ ".mkv",
73
+ ".webm"
74
+ ];
75
+ SUPPORTED_GIF_EXTENSIONS = [".gif"];
76
+ FRAME_OUTPUT_EXTENSION = ".jpg";
77
+ OPENCV_BATCH_SIZE = 10;
78
+ DBSCAN_ALPHA = 0.03;
79
+ DBSCAN_MIN_PTS = 4;
80
+ IOU_THRESHOLD = 0.9;
81
+ DECAY_LAMBDA = 0.95;
82
+ ANIMATION_FRAME_THRESHOLD = 5;
83
+ MATCH_DISTANCE_THRESHOLD = 0.25;
84
+ PIXELDIFF_GAUSSIAN_KERNEL = 3;
85
+ PIXELDIFF_BINARY_THRESHOLD = 30;
86
+ PIXELDIFF_CONTOUR_MIN_AREA = 100;
87
+ PIXELDIFF_SAMPLE_SPACING = 8;
88
+ }
89
+ });
80
90
 
81
91
  // src/core/dbscan.ts
82
- var UNVISITED = -2;
83
- var NOISE = -1;
84
92
  function dbscan(points, imageWidth, imageHeight, alpha, minPts) {
85
93
  if (points.length === 0) {
86
94
  return { labels: [], boundingBoxes: [] };
@@ -155,11 +163,19 @@ function findNeighbors(points, idx, epsSquared) {
155
163
  }
156
164
  return neighbors;
157
165
  }
166
+ var UNVISITED, NOISE;
167
+ var init_dbscan = __esm({
168
+ "src/core/dbscan.ts"() {
169
+ "use strict";
170
+ init_constants();
171
+ UNVISITED = -2;
172
+ NOISE = -1;
173
+ }
174
+ });
158
175
 
159
176
  // src/core/analyzer.ts
160
- var OPENCV_INIT_TIMEOUT_MS = 3e4;
161
- var require2 = createRequire(import.meta.url);
162
- var cvReady = null;
177
+ import { createRequire } from "module";
178
+ import sharp from "sharp";
163
179
  async function ensureOpenCV() {
164
180
  if (!cvReady) {
165
181
  cvReady = (async () => {
@@ -201,67 +217,6 @@ function computeIoU(a, b) {
201
217
  const union = aArea + bArea - intersection;
202
218
  return union === 0 ? 0 : intersection / union;
203
219
  }
204
- var IoUTracker = class {
205
- regions = [];
206
- update(boxes, pairIndex) {
207
- const animationIndices = /* @__PURE__ */ new Set();
208
- const matched = /* @__PURE__ */ new Set();
209
- for (let bi = 0; bi < boxes.length; bi++) {
210
- const box = boxes[bi];
211
- let bestIoU = 0;
212
- let bestRegionIdx = -1;
213
- for (let ri = 0; ri < this.regions.length; ri++) {
214
- if (matched.has(ri)) continue;
215
- const iou = computeIoU(box, this.regions[ri].box);
216
- if (iou > bestIoU) {
217
- bestIoU = iou;
218
- bestRegionIdx = ri;
219
- }
220
- }
221
- if (bestIoU > IOU_THRESHOLD && bestRegionIdx !== -1) {
222
- const region = this.regions[bestRegionIdx];
223
- const gap = pairIndex - region.lastSeen;
224
- region.box = box;
225
- region.consecutiveCount++;
226
- region.lastSeen = pairIndex;
227
- region.weight *= Math.pow(DECAY_LAMBDA, gap);
228
- matched.add(bestRegionIdx);
229
- if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
230
- animationIndices.add(bi);
231
- }
232
- } else {
233
- this.regions.push({
234
- box,
235
- consecutiveCount: 1,
236
- lastSeen: pairIndex,
237
- weight: 1
238
- });
239
- }
240
- }
241
- for (let ri = 0; ri < this.regions.length; ri++) {
242
- if (!matched.has(ri)) {
243
- const gap = pairIndex - this.regions[ri].lastSeen;
244
- this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
245
- }
246
- }
247
- this.regions = this.regions.filter((r) => r.weight > 0.01);
248
- return animationIndices;
249
- }
250
- getAnimationWeight(boxIndex, boxes) {
251
- if (boxIndex >= boxes.length) return 0;
252
- const box = boxes[boxIndex];
253
- let maxWeight = 0;
254
- for (const region of this.regions) {
255
- if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
256
- const iou = computeIoU(box, region.box);
257
- if (iou > IOU_THRESHOLD) {
258
- maxWeight = Math.max(maxWeight, region.weight);
259
- }
260
- }
261
- }
262
- return maxWeight;
263
- }
264
- };
265
220
  async function computeAKAZEDiff(cvLib, frame1, frame2) {
266
221
  const cv = cvLib;
267
222
  const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
@@ -498,13 +453,79 @@ async function analyzeFrames(ctx) {
498
453
  logger.debug(`Computed ${edges.length} score edges`);
499
454
  return edges;
500
455
  }
501
-
502
- // src/core/extractor.ts
503
- import { readdir } from "fs/promises";
504
- import { join as join2 } from "path";
505
- import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
506
- import { execa } from "execa";
507
- import ffmpegPath from "ffmpeg-static";
456
+ var OPENCV_INIT_TIMEOUT_MS, require2, cvReady, IoUTracker;
457
+ var init_analyzer = __esm({
458
+ "src/core/analyzer.ts"() {
459
+ "use strict";
460
+ init_constants();
461
+ init_logger();
462
+ init_dbscan();
463
+ OPENCV_INIT_TIMEOUT_MS = 3e4;
464
+ require2 = createRequire(import.meta.url);
465
+ cvReady = null;
466
+ IoUTracker = class {
467
+ regions = [];
468
+ update(boxes, pairIndex) {
469
+ const animationIndices = /* @__PURE__ */ new Set();
470
+ const matched = /* @__PURE__ */ new Set();
471
+ for (let bi = 0; bi < boxes.length; bi++) {
472
+ const box = boxes[bi];
473
+ let bestIoU = 0;
474
+ let bestRegionIdx = -1;
475
+ for (let ri = 0; ri < this.regions.length; ri++) {
476
+ if (matched.has(ri)) continue;
477
+ const iou = computeIoU(box, this.regions[ri].box);
478
+ if (iou > bestIoU) {
479
+ bestIoU = iou;
480
+ bestRegionIdx = ri;
481
+ }
482
+ }
483
+ if (bestIoU > IOU_THRESHOLD && bestRegionIdx !== -1) {
484
+ const region = this.regions[bestRegionIdx];
485
+ const gap = pairIndex - region.lastSeen;
486
+ region.box = box;
487
+ region.consecutiveCount++;
488
+ region.lastSeen = pairIndex;
489
+ region.weight *= Math.pow(DECAY_LAMBDA, gap);
490
+ matched.add(bestRegionIdx);
491
+ if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
492
+ animationIndices.add(bi);
493
+ }
494
+ } else {
495
+ this.regions.push({
496
+ box,
497
+ consecutiveCount: 1,
498
+ lastSeen: pairIndex,
499
+ weight: 1
500
+ });
501
+ }
502
+ }
503
+ for (let ri = 0; ri < this.regions.length; ri++) {
504
+ if (!matched.has(ri)) {
505
+ const gap = pairIndex - this.regions[ri].lastSeen;
506
+ this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
507
+ }
508
+ }
509
+ this.regions = this.regions.filter((r) => r.weight > 0.01);
510
+ return animationIndices;
511
+ }
512
+ getAnimationWeight(boxIndex, boxes) {
513
+ if (boxIndex >= boxes.length) return 0;
514
+ const box = boxes[boxIndex];
515
+ let maxWeight = 0;
516
+ for (const region of this.regions) {
517
+ if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
518
+ const iou = computeIoU(box, region.box);
519
+ if (iou > IOU_THRESHOLD) {
520
+ maxWeight = Math.max(maxWeight, region.weight);
521
+ }
522
+ }
523
+ }
524
+ return maxWeight;
525
+ }
526
+ };
527
+ }
528
+ });
508
529
 
509
530
  // src/utils/paths.ts
510
531
  import { mkdir, stat } from "fs/promises";
@@ -539,11 +560,21 @@ function deriveOutputPath(inputPath) {
539
560
  function isSupportedFile(filePath, extensions) {
540
561
  return extensions.includes(extname(filePath).toLowerCase());
541
562
  }
563
+ var init_paths = __esm({
564
+ "src/utils/paths.ts"() {
565
+ "use strict";
566
+ }
567
+ });
542
568
 
543
569
  // src/core/extractor.ts
570
+ import { readdir } from "fs/promises";
571
+ import { join as join2 } from "path";
572
+ import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
573
+ import { execa } from "execa";
574
+ import ffmpegPath from "ffmpeg-static";
544
575
  async function extractFrames(ctx) {
545
576
  const framesDir = join2(ctx.workspacePath, "frames");
546
- const { inputPath, fps, scale } = ctx.options;
577
+ const { inputPath, fps, maxFrames, scale } = ctx.options;
547
578
  if (!inputPath) {
548
579
  throw new Error("inputPath is required for frame extraction");
549
580
  }
@@ -560,39 +591,21 @@ async function extractFrames(ctx) {
560
591
  }
561
592
  logger.debug(`Extracting frames from: ${inputPath}`);
562
593
  await ensureDir(framesDir);
563
- const isGif = isSupportedFile(inputPath, SUPPORTED_GIF_EXTENSIONS);
564
- let frames;
565
- if (isGif) {
566
- logger.debug("GIF detected \u2014 using FPS extraction");
567
- frames = await extractByFps(inputPath, framesDir, fps, scale);
568
- } else {
569
- frames = await extractIFrames(inputPath, framesDir, scale);
570
- if (frames.length < MIN_IFRAME_COUNT) {
571
- logger.debug(
572
- `Insufficient I-frames (${frames.length}), falling back to FPS mode`
573
- );
574
- frames = await extractByFps(inputPath, framesDir, fps, scale);
575
- }
594
+ let effectiveFps = fps;
595
+ const duration = await getVideoDuration(inputPath).catch(() => 0);
596
+ if (duration > 0) {
597
+ const fpsCap = maxFrames / duration;
598
+ effectiveFps = Math.min(fps, fpsCap);
599
+ effectiveFps = Math.max(0.5, effectiveFps);
600
+ logger.debug(
601
+ `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
602
+ );
576
603
  }
604
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
577
605
  ctx.emitProgress(100);
578
606
  logger.debug(`Extracted ${frames.length} frames`);
579
607
  return frames;
580
608
  }
581
- async function extractIFrames(inputPath, outputDir, scale) {
582
- const outputPattern = join2(outputDir, "frame_%06d.jpg");
583
- await execa(ffmpegPath, [
584
- "-i",
585
- inputPath,
586
- "-vf",
587
- `select='eq(pict_type,I)',scale=-1:${scale}`,
588
- "-vsync",
589
- "vfr",
590
- "-q:v",
591
- "2",
592
- outputPattern
593
- ]);
594
- return buildFrameList(outputDir, inputPath);
595
- }
596
609
  async function extractByFps(inputPath, outputDir, fps, scale) {
597
610
  const outputPattern = join2(outputDir, "frame_%06d.jpg");
598
611
  await execa(ffmpegPath, [
@@ -638,12 +651,17 @@ async function buildFrameList(framesDir, inputPath) {
638
651
  extractPath: join2(framesDir, file)
639
652
  }));
640
653
  }
641
-
642
- // src/core/input-resolver.ts
643
- import { join as join4 } from "path";
654
+ var init_extractor = __esm({
655
+ "src/core/extractor.ts"() {
656
+ "use strict";
657
+ init_constants();
658
+ init_logger();
659
+ init_paths();
660
+ }
661
+ });
644
662
 
645
663
  // src/core/workspace.ts
646
- import { rename, rm, writeFile } from "fs/promises";
664
+ import { readdir as readdir2, rename, rm, stat as stat2, writeFile } from "fs/promises";
647
665
  import { join as join3 } from "path";
648
666
  import sharp2 from "sharp";
649
667
  async function createWorkspace(sessionId) {
@@ -676,6 +694,21 @@ async function cleanupWorkspace(workspacePath) {
676
694
  } catch {
677
695
  }
678
696
  }
697
+ async function cleanupStaleWorkspaces() {
698
+ const entries = await readdir2(TEMP_BASE_DIR);
699
+ const now = Date.now();
700
+ for (const entry of entries) {
701
+ if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
702
+ const fullPath = join3(TEMP_BASE_DIR, entry);
703
+ try {
704
+ const info = await stat2(fullPath);
705
+ if (info.isDirectory() && now - info.mtimeMs > STALE_THRESHOLD_MS) {
706
+ await rm(fullPath, { recursive: true, force: true });
707
+ }
708
+ } catch {
709
+ }
710
+ }
711
+ }
679
712
  async function writeInputBuffer(buffer, workspacePath) {
680
713
  const inputDir = join3(workspacePath, "input");
681
714
  await ensureDir(inputDir);
@@ -702,8 +735,18 @@ async function readFramesAsBuffers(frameNodes, quality) {
702
735
  )
703
736
  );
704
737
  }
738
+ var STALE_THRESHOLD_MS;
739
+ var init_workspace = __esm({
740
+ "src/core/workspace.ts"() {
741
+ "use strict";
742
+ init_constants();
743
+ init_paths();
744
+ STALE_THRESHOLD_MS = 60 * 60 * 1e3;
745
+ }
746
+ });
705
747
 
706
748
  // src/core/input-resolver.ts
749
+ import { join as join4 } from "path";
707
750
  function resolveOptions(options) {
708
751
  const mode = options.mode;
709
752
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
@@ -723,6 +766,7 @@ function resolveOptions(options) {
723
766
  pruneMode,
724
767
  outputPath,
725
768
  fps: options.fps ?? DEFAULT_FPS,
769
+ maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
726
770
  scale: options.scale ?? DEFAULT_SCALE,
727
771
  quality: options.quality ?? DEFAULT_QUALITY,
728
772
  debug: options.debug ?? false
@@ -748,50 +792,64 @@ async function resolveInput(options, workspacePath) {
748
792
  }
749
793
  throw new Error(`Unsupported input mode: ${options.mode}`);
750
794
  }
795
+ var init_input_resolver = __esm({
796
+ "src/core/input-resolver.ts"() {
797
+ "use strict";
798
+ init_constants();
799
+ init_paths();
800
+ init_workspace();
801
+ }
802
+ });
751
803
 
752
804
  // src/utils/min-heap.ts
753
- var MinHeap = class {
754
- h = [];
755
- get size() {
756
- return this.h.length;
757
- }
758
- push(entry) {
759
- this.h.push(entry);
760
- this.siftUp(this.h.length - 1);
761
- }
762
- pop() {
763
- const n = this.h.length;
764
- if (n === 0) return void 0;
765
- const top = this.h[0];
766
- const last = this.h.pop();
767
- if (n > 1) {
768
- this.h[0] = last;
769
- this.siftDown(0);
770
- }
771
- return top;
772
- }
773
- siftUp(i) {
774
- while (i > 0) {
775
- const p = i - 1 >> 1;
776
- if (this.h[p].score <= this.h[i].score) break;
777
- [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
778
- i = p;
779
- }
780
- }
781
- siftDown(i) {
782
- const n = this.h.length;
783
- for (; ; ) {
784
- let m = i;
785
- const l = 2 * i + 1;
786
- const r = 2 * i + 2;
787
- if (l < n && this.h[l].score < this.h[m].score) m = l;
788
- if (r < n && this.h[r].score < this.h[m].score) m = r;
789
- if (m === i) break;
790
- [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
791
- i = m;
792
- }
805
+ var MinHeap;
806
+ var init_min_heap = __esm({
807
+ "src/utils/min-heap.ts"() {
808
+ "use strict";
809
+ MinHeap = class {
810
+ h = [];
811
+ get size() {
812
+ return this.h.length;
813
+ }
814
+ push(entry) {
815
+ this.h.push(entry);
816
+ this.siftUp(this.h.length - 1);
817
+ }
818
+ pop() {
819
+ const n = this.h.length;
820
+ if (n === 0) return void 0;
821
+ const top = this.h[0];
822
+ const last = this.h.pop();
823
+ if (n > 1) {
824
+ this.h[0] = last;
825
+ this.siftDown(0);
826
+ }
827
+ return top;
828
+ }
829
+ siftUp(i) {
830
+ while (i > 0) {
831
+ const p = i - 1 >> 1;
832
+ if (this.h[p].score <= this.h[i].score) break;
833
+ [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
834
+ i = p;
835
+ }
836
+ }
837
+ siftDown(i) {
838
+ const n = this.h.length;
839
+ for (; ; ) {
840
+ let m = i;
841
+ const l = 2 * i + 1;
842
+ const r = 2 * i + 2;
843
+ if (l < n && this.h[l].score < this.h[m].score) m = l;
844
+ if (r < n && this.h[r].score < this.h[m].score) m = r;
845
+ if (m === i) break;
846
+ [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
847
+ i = m;
848
+ }
849
+ }
850
+ };
793
851
  }
794
- };
852
+ });
795
853
 
796
854
  // src/core/pruner.ts
797
855
  function pruneTo(graph, frames, targetCount) {
@@ -953,8 +1011,20 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
953
1011
  }
954
1012
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
955
1013
  }
1014
+ var init_pruner = __esm({
1015
+ "src/core/pruner.ts"() {
1016
+ "use strict";
1017
+ init_constants();
1018
+ init_min_heap();
1019
+ }
1020
+ });
956
1021
 
957
1022
  // src/core/orchestrator.ts
1023
+ var orchestrator_exports = {};
1024
+ __export(orchestrator_exports, {
1025
+ runPipeline: () => runPipeline
1026
+ });
1027
+ import { randomUUID } from "crypto";
958
1028
  async function runPipeline(options) {
959
1029
  const startTime = Date.now();
960
1030
  const sessionId = randomUUID();
@@ -1043,6 +1113,286 @@ async function runPipeline(options) {
1043
1113
  }
1044
1114
  }
1045
1115
  }
1116
+ var init_orchestrator = __esm({
1117
+ "src/core/orchestrator.ts"() {
1118
+ "use strict";
1119
+ init_logger();
1120
+ init_analyzer();
1121
+ init_extractor();
1122
+ init_input_resolver();
1123
+ init_pruner();
1124
+ init_workspace();
1125
+ }
1126
+ });
1127
+
1128
+ // src/cli.ts
1129
+ import { createRequire as createRequire2 } from "module";
1130
+ import { Command } from "commander";
1131
+ import { render } from "ink";
1132
+ import React2 from "react";
1133
+
1134
+ // src/commands/Sieve.tsx
1135
+ import { Box as Box2, Text as Text3, useApp } from "ink";
1136
+ import { useEffect, useState } from "react";
1137
+
1138
+ // src/components/PhaseStep.tsx
1139
+ import { Box, Text as Text2 } from "ink";
1140
+ import Spinner from "ink-spinner";
1141
+
1142
+ // src/components/ProgressBar.tsx
1143
+ import { Text } from "ink";
1144
+ import { jsx, jsxs } from "react/jsx-runtime";
1145
+ var ProgressBar = ({
1146
+ percent,
1147
+ width = 30
1148
+ }) => {
1149
+ const clamped = Math.max(0, Math.min(100, percent));
1150
+ const filled = Math.round(width * (clamped / 100));
1151
+ const empty = width - filled;
1152
+ return /* @__PURE__ */ jsxs(Text, { children: [
1153
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "\u2588".repeat(filled) }),
1154
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "\u2591".repeat(empty) }),
1155
+ /* @__PURE__ */ jsxs(Text, { children: [
1156
+ " ",
1157
+ clamped,
1158
+ "%"
1159
+ ] })
1160
+ ] });
1161
+ };
1162
+
1163
+ // src/components/PhaseStep.tsx
1164
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1165
+ var PhaseStep = ({ phase }) => {
1166
+ const icon = (() => {
1167
+ switch (phase.status) {
1168
+ case "done":
1169
+ return /* @__PURE__ */ jsx2(Text2, { color: "green", children: "\u2713" });
1170
+ case "running":
1171
+ return /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: /* @__PURE__ */ jsx2(Spinner, { type: "dots" }) });
1172
+ case "failed":
1173
+ return /* @__PURE__ */ jsx2(Text2, { color: "red", children: "\u2717" });
1174
+ default:
1175
+ return /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u25CB" });
1176
+ }
1177
+ })();
1178
+ const duration = phase.status === "done" && phase.durationMs !== void 0 ? `Done (${Math.round(phase.durationMs / 1e3)}s)` : "";
1179
+ return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", children: [
1180
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1181
+ " ",
1182
+ icon,
1183
+ " ",
1184
+ phase.label,
1185
+ duration ? /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
1186
+ " ",
1187
+ duration
1188
+ ] }) : null
1189
+ ] }),
1190
+ phase.status === "running" && phase.hasProgress && phase.percent > 0 && /* @__PURE__ */ jsxs2(Text2, { children: [
1191
+ " ",
1192
+ /* @__PURE__ */ jsx2(ProgressBar, { percent: phase.percent })
1193
+ ] })
1194
+ ] });
1195
+ };
1196
+
1197
+ // src/core/run-in-worker.ts
1198
+ import { dirname, join as join5 } from "path";
1199
+ import { fileURLToPath } from "url";
1200
+ import { Worker } from "worker_threads";
1201
+ async function runPipelineInWorker(options, onProgress) {
1202
+ const currentFile = fileURLToPath(import.meta.url);
1203
+ if (!currentFile.endsWith(".mjs")) {
1204
+ const { runPipeline: runPipeline2 } = await Promise.resolve().then(() => (init_orchestrator(), orchestrator_exports));
1205
+ return runPipeline2({ ...options, onProgress });
1206
+ }
1207
+ const workerPath = join5(dirname(currentFile), "pipeline-worker.mjs");
1208
+ return new Promise((resolve2, reject) => {
1209
+ const worker = new Worker(workerPath, { workerData: options });
1210
+ worker.on(
1211
+ "message",
1212
+ (msg) => {
1213
+ if (msg.type === "progress" && msg.phase && msg.percent !== void 0) {
1214
+ onProgress(msg.phase, msg.percent);
1215
+ } else if (msg.type === "result") {
1216
+ resolve2(msg.result);
1217
+ worker.terminate();
1218
+ } else if (msg.type === "error") {
1219
+ reject(new Error(msg.message));
1220
+ worker.terminate();
1221
+ }
1222
+ }
1223
+ );
1224
+ worker.on("error", reject);
1225
+ worker.on("exit", (code) => {
1226
+ if (code !== 0 && code !== 1) {
1227
+ reject(new Error(`Worker exited with code ${code}`));
1228
+ }
1229
+ });
1230
+ });
1231
+ }
1232
+
1233
+ // src/commands/Sieve.tsx
1234
+ init_workspace();
1235
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1236
+ var PHASE_DEFS = [
1237
+ { key: "INIT", label: "Initializing workspace", hasProgress: false },
1238
+ { key: "EXTRACTING", label: "Extracting frames", hasProgress: false },
1239
+ { key: "ANALYZING", label: "Analyzing frame similarity", hasProgress: true },
1240
+ { key: "PRUNING", label: "Pruning similar frames", hasProgress: false },
1241
+ { key: "FINALIZING", label: "Finalizing output", hasProgress: false }
1242
+ ];
1243
+ function createInitialPhases() {
1244
+ return PHASE_DEFS.map((def) => ({
1245
+ label: def.label,
1246
+ status: "pending",
1247
+ hasProgress: def.hasProgress,
1248
+ percent: 0
1249
+ }));
1250
+ }
1251
+ function phaseKeyToIndex(phase) {
1252
+ return PHASE_DEFS.findIndex((d) => d.key === phase);
1253
+ }
1254
+ var SieveView = (props) => {
1255
+ const { exit } = useApp();
1256
+ const [phases, setPhases] = useState(createInitialPhases);
1257
+ const [result, setResult] = useState(null);
1258
+ const [error, setError] = useState(null);
1259
+ useEffect(() => {
1260
+ const phaseStartTimes = PHASE_DEFS.map(() => 0);
1261
+ let currentPhaseKey = "";
1262
+ (async () => {
1263
+ try {
1264
+ await cleanupStaleWorkspaces().catch(() => {
1265
+ });
1266
+ phaseStartTimes[0] = Date.now();
1267
+ setPhases((prev) => {
1268
+ const next = [...prev];
1269
+ next[0] = { ...next[0], status: "running" };
1270
+ return next;
1271
+ });
1272
+ const res = await runPipelineInWorker(
1273
+ {
1274
+ mode: "file",
1275
+ inputPath: props.input,
1276
+ ...props.threshold !== void 0 ? { threshold: props.threshold } : {},
1277
+ ...props.count !== void 0 ? { count: props.count } : {},
1278
+ outputPath: props.output,
1279
+ fps: props.fps,
1280
+ maxFrames: props.maxFrames,
1281
+ scale: props.scale,
1282
+ quality: props.quality,
1283
+ debug: props.debug
1284
+ },
1285
+ (phase, percent) => {
1286
+ const phaseIdx = phaseKeyToIndex(phase);
1287
+ if (phaseIdx < 0) return;
1288
+ if (phase !== currentPhaseKey) {
1289
+ const now2 = Date.now();
1290
+ currentPhaseKey = phase;
1291
+ phaseStartTimes[phaseIdx] = now2;
1292
+ setPhases((prev) => {
1293
+ const next = [...prev];
1294
+ for (let i = 0; i < next.length; i++) {
1295
+ if (i < phaseIdx) {
1296
+ if (next[i].status !== "done") {
1297
+ next[i] = {
1298
+ ...next[i],
1299
+ status: "done",
1300
+ percent: 100,
1301
+ durationMs: phaseStartTimes[i] ? now2 - phaseStartTimes[i] : 0
1302
+ };
1303
+ }
1304
+ } else if (i === phaseIdx) {
1305
+ next[i] = { ...next[i], status: "running", percent: 0 };
1306
+ }
1307
+ }
1308
+ return next;
1309
+ });
1310
+ }
1311
+ setPhases((prev) => {
1312
+ const next = [...prev];
1313
+ if (next[phaseIdx].status === "running") {
1314
+ next[phaseIdx] = {
1315
+ ...next[phaseIdx],
1316
+ percent: Math.round(percent)
1317
+ };
1318
+ }
1319
+ return next;
1320
+ });
1321
+ }
1322
+ );
1323
+ const now = Date.now();
1324
+ setPhases(
1325
+ (prev) => prev.map((p, i) => {
1326
+ if (p.status !== "done") {
1327
+ return {
1328
+ ...p,
1329
+ status: "done",
1330
+ percent: 100,
1331
+ durationMs: phaseStartTimes[i] ? now - phaseStartTimes[i] : 0
1332
+ };
1333
+ }
1334
+ return p;
1335
+ })
1336
+ );
1337
+ setResult(res);
1338
+ setTimeout(() => exit(), 100);
1339
+ } catch (err) {
1340
+ const now = Date.now();
1341
+ setPhases((prev) => {
1342
+ const next = [...prev];
1343
+ for (let i = 0; i < next.length; i++) {
1344
+ if (next[i].status === "running") {
1345
+ next[i] = {
1346
+ ...next[i],
1347
+ status: "failed",
1348
+ durationMs: phaseStartTimes[i] ? now - phaseStartTimes[i] : 0
1349
+ };
1350
+ }
1351
+ }
1352
+ return next;
1353
+ });
1354
+ setError(err instanceof Error ? err.message : String(err));
1355
+ setTimeout(() => exit(), 100);
1356
+ }
1357
+ })();
1358
+ }, []);
1359
+ return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
1360
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, children: [
1361
+ "\u25B8 scene-sieve",
1362
+ " \u2014 ",
1363
+ props.input.split("/").pop()
1364
+ ] }),
1365
+ /* @__PURE__ */ jsx3(Text3, { children: " " }),
1366
+ phases.map((phase, i) => /* @__PURE__ */ jsx3(PhaseStep, { phase }, i)),
1367
+ error && /* @__PURE__ */ jsx3(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
1368
+ "\u2717 Failed \u2014 ",
1369
+ error
1370
+ ] }) }),
1371
+ result && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
1372
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: " \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }),
1373
+ /* @__PURE__ */ jsxs3(Text3, { color: "green", bold: true, children: [
1374
+ "\u2713 Done",
1375
+ " \u2014 ",
1376
+ result.originalFramesCount,
1377
+ " frames \u2192 ",
1378
+ result.prunedFramesCount,
1379
+ " scenes (",
1380
+ (result.executionTimeMs / 1e3).toFixed(1),
1381
+ "s)"
1382
+ ] }),
1383
+ props.debug && result.outputFiles.length > 0 && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
1384
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
1385
+ "Output: ",
1386
+ result.outputFiles[0]?.replace(/\/[^/]+$/, "/")
1387
+ ] }),
1388
+ result.outputFiles.map((f, i) => /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
1389
+ " - ",
1390
+ f
1391
+ ] }, i))
1392
+ ] })
1393
+ ] })
1394
+ ] });
1395
+ };
1046
1396
 
1047
1397
  // src/cli.ts
1048
1398
  var require3 = createRequire2(import.meta.url);
@@ -1051,50 +1401,25 @@ var program = new Command();
1051
1401
  program.name("scene-sieve").description("Extract key frames from video and GIF files").version(version).argument("<input>", "Input video or GIF file path").option("-n, --count <number>", "Max number of frames to keep (default: 20)").option(
1052
1402
  "-t, --threshold <number>",
1053
1403
  "Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
1054
- ).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Fallback FPS for frame extraction", "5").option("-s, --scale <number>", "Scale size for vision analysis", "720").option("-q, --quality <number>", "JPEG output quality 1-100", "80").option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
1055
- const { default: ora } = await import("ora");
1056
- const { default: cliProgress } = await import("cli-progress");
1057
- const spinner = ora("Initializing...").start();
1058
- try {
1059
- spinner.stop();
1060
- const bar = new cliProgress.SingleBar({
1061
- format: "{phase} |{bar}| {percentage}%",
1062
- barCompleteChar: "\u2588",
1063
- barIncompleteChar: "\u2591",
1064
- hideCursor: true
1065
- });
1066
- bar.start(100, 0, { phase: "EXTRACTING" });
1067
- const result = await runPipeline({
1068
- mode: "file",
1069
- inputPath: input,
1404
+ ).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Max FPS for frame extraction", "5").option(
1405
+ "--max-frames <number>",
1406
+ "Max frames to extract (auto-reduces FPS for long videos)",
1407
+ "300"
1408
+ ).option("-s, --scale <number>", "Scale size for vision analysis", "720").option("-q, --quality <number>", "JPEG output quality 1-100", "80").option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
1409
+ const { waitUntilExit } = render(
1410
+ React2.createElement(SieveView, {
1411
+ input,
1070
1412
  ...opts.threshold !== void 0 ? { threshold: parseFloat(opts.threshold) } : {},
1071
1413
  ...opts.count !== void 0 ? { count: parseInt(opts.count, 10) } : {},
1072
- outputPath: opts.output,
1414
+ output: opts.output,
1073
1415
  fps: parseInt(opts.fps, 10),
1416
+ maxFrames: parseInt(opts.maxFrames, 10),
1074
1417
  scale: parseInt(opts.scale, 10),
1075
1418
  quality: parseInt(opts.quality, 10),
1076
- debug: opts.debug ?? false,
1077
- onProgress: (phase, percent) => {
1078
- bar.update(Math.round(percent), { phase });
1079
- }
1080
- });
1081
- bar.stop();
1082
- console.log(
1083
- `
1084
- Done! ${result.originalFramesCount} frames -> ${result.prunedFramesCount} scenes (${result.executionTimeMs}ms)`
1085
- );
1086
- if (opts.debug) {
1087
- console.log(
1088
- `Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`
1089
- );
1090
- result.outputFiles.forEach((f) => console.log(` - ${f}`));
1091
- }
1092
- } catch (error) {
1093
- spinner.fail(
1094
- `Failed: ${error instanceof Error ? error.message : String(error)}`
1095
- );
1096
- process.exit(1);
1097
- }
1419
+ debug: opts.debug ?? false
1420
+ })
1421
+ );
1422
+ await waitUntilExit();
1098
1423
  });
1099
1424
  program.parseAsync(process.argv).catch((error) => {
1100
1425
  console.error("Fatal error:", error.message);