@lumy-pack/scene-sieve 0.0.3 → 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.75;
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 () => {
@@ -182,7 +198,7 @@ async function ensureOpenCV() {
182
198
  async function preprocessFrame(framePath, scale) {
183
199
  const { data, info } = await sharp(framePath).resize({ width: scale, withoutEnlargement: true }).grayscale().blur(1).raw().toBuffer({ resolveWithObject: true });
184
200
  return {
185
- data: new Uint8Array(data.buffer),
201
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
186
202
  width: info.width,
187
203
  height: info.height
188
204
  };
@@ -201,78 +217,12 @@ 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
- const mat1 = cvLib.matFromImageData({
267
- data: frame1.data,
268
- width: frame1.width,
269
- height: frame1.height
270
- });
271
- const mat2 = cvLib.matFromImageData({
272
- data: frame2.data,
273
- width: frame2.width,
274
- height: frame2.height
275
- });
221
+ const cv = cvLib;
222
+ const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
223
+ mat1.data.set(frame1.data);
224
+ const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
225
+ mat2.data.set(frame2.data);
276
226
  const kp1 = new cvLib.KeyPointVector();
277
227
  const kp2 = new cvLib.KeyPointVector();
278
228
  const desc1 = new cvLib.Mat();
@@ -503,13 +453,79 @@ async function analyzeFrames(ctx) {
503
453
  logger.debug(`Computed ${edges.length} score edges`);
504
454
  return edges;
505
455
  }
506
-
507
- // src/core/extractor.ts
508
- import { readdir } from "fs/promises";
509
- import { join as join2 } from "path";
510
- import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
511
- import { execa } from "execa";
512
- 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
+ });
513
529
 
514
530
  // src/utils/paths.ts
515
531
  import { mkdir, stat } from "fs/promises";
@@ -544,11 +560,21 @@ function deriveOutputPath(inputPath) {
544
560
  function isSupportedFile(filePath, extensions) {
545
561
  return extensions.includes(extname(filePath).toLowerCase());
546
562
  }
563
+ var init_paths = __esm({
564
+ "src/utils/paths.ts"() {
565
+ "use strict";
566
+ }
567
+ });
547
568
 
548
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";
549
575
  async function extractFrames(ctx) {
550
576
  const framesDir = join2(ctx.workspacePath, "frames");
551
- const { inputPath, fps, scale } = ctx.options;
577
+ const { inputPath, fps, maxFrames, scale } = ctx.options;
552
578
  if (!inputPath) {
553
579
  throw new Error("inputPath is required for frame extraction");
554
580
  }
@@ -565,39 +591,21 @@ async function extractFrames(ctx) {
565
591
  }
566
592
  logger.debug(`Extracting frames from: ${inputPath}`);
567
593
  await ensureDir(framesDir);
568
- const isGif = isSupportedFile(inputPath, SUPPORTED_GIF_EXTENSIONS);
569
- let frames;
570
- if (isGif) {
571
- logger.debug("GIF detected \u2014 using FPS extraction");
572
- frames = await extractByFps(inputPath, framesDir, fps, scale);
573
- } else {
574
- frames = await extractIFrames(inputPath, framesDir, scale);
575
- if (frames.length < MIN_IFRAME_COUNT) {
576
- logger.debug(
577
- `Insufficient I-frames (${frames.length}), falling back to FPS mode`
578
- );
579
- frames = await extractByFps(inputPath, framesDir, fps, scale);
580
- }
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
+ );
581
603
  }
604
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
582
605
  ctx.emitProgress(100);
583
606
  logger.debug(`Extracted ${frames.length} frames`);
584
607
  return frames;
585
608
  }
586
- async function extractIFrames(inputPath, outputDir, scale) {
587
- const outputPattern = join2(outputDir, "frame_%06d.jpg");
588
- await execa(ffmpegPath, [
589
- "-i",
590
- inputPath,
591
- "-vf",
592
- `select='eq(pict_type,I)',scale=-1:${scale}`,
593
- "-vsync",
594
- "vfr",
595
- "-q:v",
596
- "2",
597
- outputPattern
598
- ]);
599
- return buildFrameList(outputDir, inputPath);
600
- }
601
609
  async function extractByFps(inputPath, outputDir, fps, scale) {
602
610
  const outputPattern = join2(outputDir, "frame_%06d.jpg");
603
611
  await execa(ffmpegPath, [
@@ -643,12 +651,17 @@ async function buildFrameList(framesDir, inputPath) {
643
651
  extractPath: join2(framesDir, file)
644
652
  }));
645
653
  }
646
-
647
- // src/core/input-resolver.ts
648
- 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
+ });
649
662
 
650
663
  // src/core/workspace.ts
651
- import { rename, rm, writeFile } from "fs/promises";
664
+ import { readdir as readdir2, rename, rm, stat as stat2, writeFile } from "fs/promises";
652
665
  import { join as join3 } from "path";
653
666
  import sharp2 from "sharp";
654
667
  async function createWorkspace(sessionId) {
@@ -670,6 +683,7 @@ async function finalizeOutput(ctx, selectedFrames) {
670
683
  outputFiles.push(join3(outputPath, destName));
671
684
  }
672
685
  await ensureDir(join3(outputPath, ".."));
686
+ await rm(outputPath, { recursive: true, force: true });
673
687
  await rename(stagingDir, outputPath);
674
688
  return outputFiles;
675
689
  }
@@ -680,6 +694,21 @@ async function cleanupWorkspace(workspacePath) {
680
694
  } catch {
681
695
  }
682
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
+ }
683
712
  async function writeInputBuffer(buffer, workspacePath) {
684
713
  const inputDir = join3(workspacePath, "input");
685
714
  await ensureDir(inputDir);
@@ -706,8 +735,18 @@ async function readFramesAsBuffers(frameNodes, quality) {
706
735
  )
707
736
  );
708
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
+ });
709
747
 
710
748
  // src/core/input-resolver.ts
749
+ import { join as join4 } from "path";
711
750
  function resolveOptions(options) {
712
751
  const mode = options.mode;
713
752
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
@@ -727,6 +766,7 @@ function resolveOptions(options) {
727
766
  pruneMode,
728
767
  outputPath,
729
768
  fps: options.fps ?? DEFAULT_FPS,
769
+ maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
730
770
  scale: options.scale ?? DEFAULT_SCALE,
731
771
  quality: options.quality ?? DEFAULT_QUALITY,
732
772
  debug: options.debug ?? false
@@ -752,50 +792,64 @@ async function resolveInput(options, workspacePath) {
752
792
  }
753
793
  throw new Error(`Unsupported input mode: ${options.mode}`);
754
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
+ });
755
803
 
756
804
  // src/utils/min-heap.ts
757
- var MinHeap = class {
758
- h = [];
759
- get size() {
760
- return this.h.length;
761
- }
762
- push(entry) {
763
- this.h.push(entry);
764
- this.siftUp(this.h.length - 1);
765
- }
766
- pop() {
767
- const n = this.h.length;
768
- if (n === 0) return void 0;
769
- const top = this.h[0];
770
- const last = this.h.pop();
771
- if (n > 1) {
772
- this.h[0] = last;
773
- this.siftDown(0);
774
- }
775
- return top;
776
- }
777
- siftUp(i) {
778
- while (i > 0) {
779
- const p = i - 1 >> 1;
780
- if (this.h[p].score <= this.h[i].score) break;
781
- [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
782
- i = p;
783
- }
784
- }
785
- siftDown(i) {
786
- const n = this.h.length;
787
- for (; ; ) {
788
- let m = i;
789
- const l = 2 * i + 1;
790
- const r = 2 * i + 2;
791
- if (l < n && this.h[l].score < this.h[m].score) m = l;
792
- if (r < n && this.h[r].score < this.h[m].score) m = r;
793
- if (m === i) break;
794
- [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
795
- i = m;
796
- }
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
+ };
797
851
  }
798
- };
852
+ });
799
853
 
800
854
  // src/core/pruner.ts
801
855
  function pruneTo(graph, frames, targetCount) {
@@ -957,8 +1011,20 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
957
1011
  }
958
1012
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
959
1013
  }
1014
+ var init_pruner = __esm({
1015
+ "src/core/pruner.ts"() {
1016
+ "use strict";
1017
+ init_constants();
1018
+ init_min_heap();
1019
+ }
1020
+ });
960
1021
 
961
1022
  // src/core/orchestrator.ts
1023
+ var orchestrator_exports = {};
1024
+ __export(orchestrator_exports, {
1025
+ runPipeline: () => runPipeline
1026
+ });
1027
+ import { randomUUID } from "crypto";
962
1028
  async function runPipeline(options) {
963
1029
  const startTime = Date.now();
964
1030
  const sessionId = randomUUID();
@@ -1047,6 +1113,286 @@ async function runPipeline(options) {
1047
1113
  }
1048
1114
  }
1049
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
+ };
1050
1396
 
1051
1397
  // src/cli.ts
1052
1398
  var require3 = createRequire2(import.meta.url);
@@ -1055,50 +1401,25 @@ var program = new Command();
1055
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(
1056
1402
  "-t, --threshold <number>",
1057
1403
  "Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
1058
- ).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) => {
1059
- const { default: ora } = await import("ora");
1060
- const { default: cliProgress } = await import("cli-progress");
1061
- const spinner = ora("Initializing...").start();
1062
- try {
1063
- spinner.stop();
1064
- const bar = new cliProgress.SingleBar({
1065
- format: "{phase} |{bar}| {percentage}%",
1066
- barCompleteChar: "\u2588",
1067
- barIncompleteChar: "\u2591",
1068
- hideCursor: true
1069
- });
1070
- bar.start(100, 0, { phase: "EXTRACTING" });
1071
- const result = await runPipeline({
1072
- mode: "file",
1073
- 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,
1074
1412
  ...opts.threshold !== void 0 ? { threshold: parseFloat(opts.threshold) } : {},
1075
1413
  ...opts.count !== void 0 ? { count: parseInt(opts.count, 10) } : {},
1076
- outputPath: opts.output,
1414
+ output: opts.output,
1077
1415
  fps: parseInt(opts.fps, 10),
1416
+ maxFrames: parseInt(opts.maxFrames, 10),
1078
1417
  scale: parseInt(opts.scale, 10),
1079
1418
  quality: parseInt(opts.quality, 10),
1080
- debug: opts.debug ?? false,
1081
- onProgress: (phase, percent) => {
1082
- bar.update(Math.round(percent), { phase });
1083
- }
1084
- });
1085
- bar.stop();
1086
- console.log(
1087
- `
1088
- Done! ${result.originalFramesCount} frames -> ${result.prunedFramesCount} scenes (${result.executionTimeMs}ms)`
1089
- );
1090
- if (opts.debug) {
1091
- console.log(
1092
- `Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`
1093
- );
1094
- result.outputFiles.forEach((f) => console.log(` - ${f}`));
1095
- }
1096
- } catch (error) {
1097
- spinner.fail(
1098
- `Failed: ${error instanceof Error ? error.message : String(error)}`
1099
- );
1100
- process.exit(1);
1101
- }
1419
+ debug: opts.debug ?? false
1420
+ })
1421
+ );
1422
+ await waitUntilExit();
1102
1423
  });
1103
1424
  program.parseAsync(process.argv).catch((error) => {
1104
1425
  console.error("Fatal error:", error.message);