@lumy-pack/scene-sieve 0.0.5 → 0.0.7

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/README.md CHANGED
@@ -13,6 +13,8 @@ Video/GIF ──▶ Extract (FFmpeg) ──▶ Analyze (OpenCV) ──▶ Prune
13
13
 
14
14
  ## Features
15
15
 
16
+ - **Animation Tracking** — Detects and records loading spinners or other repetitive animations
17
+ - **Rich Metadata** — Generates `.metadata.json` with scene timestamps and animation details
16
18
  - **Smart frame selection** — Identifies visually significant scene changes, not just evenly-spaced samples
17
19
  - **Computer vision pipeline** — AKAZE feature detection, DBSCAN clustering, IoU tracking, and information gain scoring
18
20
  - **Three input modes** — File path, video Buffer, or pre-extracted frame Buffers
@@ -35,7 +37,7 @@ yarn add @lumy-pack/scene-sieve
35
37
  ### CLI
36
38
 
37
39
  ```bash
38
- # Extract 5 key scenes (default)
40
+ # Extract 20 key scenes (default)
39
41
  npx scene-sieve input.mp4
40
42
 
41
43
  # Keep exactly 8 scenes
@@ -44,8 +46,8 @@ npx scene-sieve input.mp4 -n 8
44
46
  # Use threshold-based selection
45
47
  npx scene-sieve input.mp4 -t 0.3
46
48
 
47
- # Specify output directory and JPEG quality
48
- npx scene-sieve input.mp4 -n 10 -o ./scenes -q 90
49
+ # Specify max frames to extract and output directory
50
+ npx scene-sieve input.mp4 -mf 500 -o ./scenes -q 90
49
51
  ```
50
52
 
51
53
  ### Module
@@ -63,7 +65,11 @@ const result = await extractScenes({
63
65
  console.log(
64
66
  `${result.prunedFramesCount} scenes extracted in ${result.executionTimeMs}ms`,
65
67
  );
66
- // Output: scenes/scene_001.jpg, scenes/scene_002.jpg, ...
68
+ // Output:
69
+ // scenes/frame_0001.jpg
70
+ // scenes/frame_0002.jpg
71
+ // ...
72
+ // scenes/.metadata.json
67
73
  ```
68
74
 
69
75
  ## CLI Reference
@@ -72,16 +78,19 @@ console.log(
72
78
  scene-sieve <input> [options]
73
79
  ```
74
80
 
75
- | Option | Description | Default |
76
- | -------------------------- | -------------------------------------- | --------------------------- |
77
- | `<input>` | Input video or GIF file path | (required) |
78
- | `-n, --count <number>` | Number of frames to keep | `5` (when no `--threshold`) |
79
- | `-t, --threshold <number>` | Normalized score threshold (0, 1] | |
80
- | `-o, --output <path>` | Output directory | Same directory as input |
81
- | `--fps <number>` | Fallback FPS for frame extraction | `5` |
82
- | `-s, --scale <number>` | Scale size for vision analysis (px) | `720` |
83
- | `-q, --quality <number>` | JPEG output quality (1–100) | `80` |
84
- | `--debug` | Preserve temp workspace for inspection | `false` |
81
+ | Option | Description | Default |
82
+ | ------------------------------ | ----------------------------------------------- | ---------------------------- |
83
+ | `<input>` | Input video or GIF file path | (required) |
84
+ | `-n, --count <number>` | Max number of frames to keep | `20` |
85
+ | `-t, --threshold <number>` | Normalized score threshold (0, 1] | `0.5` |
86
+ | `-o, --output <path>` | Output directory | Same directory as input |
87
+ | `--fps <number>` | Max FPS for frame extraction | `5` |
88
+ | `-mf, --max-frames <number>` | Max frames to extract (auto-reduces FPS) | `300` |
89
+ | `-s, --scale <number>` | Scale size for vision analysis (px) | `720` |
90
+ | `-q, --quality <number>` | JPEG output quality (1–100) | `80` |
91
+ | `-it, --iou-threshold <number>`| IoU threshold for animation tracking (0–1) | `0.9` |
92
+ | `-at, --anim-threshold <number>`| Min consecutive frames for animation | `5` |
93
+ | `--debug` | Preserve temp workspace for inspection | `false` |
85
94
 
86
95
  ### Supported Formats
87
96
 
@@ -134,7 +143,7 @@ const result = await extractScenes({
134
143
  });
135
144
 
136
145
  console.log(result.outputFiles);
137
- // ['./output/scene_001.jpg', './output/scene_002.jpg', ...]
146
+ // ['./output/frame_0001.jpg', './output/frame_0002.jpg', ..., './output/.metadata.json']
138
147
  ```
139
148
 
140
149
  #### Buffer Mode
@@ -178,12 +187,15 @@ console.log(result.outputBuffers?.length); // 5
178
187
 
179
188
  ```typescript
180
189
  interface SieveOptionsBase {
181
- count?: number; // Frames to keep (default: 5 when no threshold)
182
- threshold?: number; // Score threshold in range (0, 1]
190
+ count?: number; // Max frames to keep (default: 20)
191
+ threshold?: number; // Score threshold in range (0, 1] (default: 0.5)
183
192
  outputPath?: string; // Output directory (file mode only)
184
193
  fps?: number; // Extraction FPS (default: 5)
194
+ maxFrames?: number; // Max frames to extract (default: 300)
185
195
  scale?: number; // Analysis scale in px (default: 720)
186
196
  quality?: number; // JPEG quality 1-100 (default: 80)
197
+ iouThreshold?: number; // IoU for animation tracking (default: 0.9)
198
+ animationThreshold?: number; // Min frames for animation (default: 5)
187
199
  debug?: boolean; // Preserve temp workspace (default: false)
188
200
  onProgress?: (phase: ProgressPhase, percent: number) => void;
189
201
  }
@@ -200,6 +212,8 @@ interface SieveResult {
200
212
  prunedFramesCount: number; // Frames selected as key scenes
201
213
  outputFiles: string[]; // File paths (file mode)
202
214
  outputBuffers?: Buffer[]; // JPEG buffers (buffer/frames mode)
215
+ animations?: AnimationMetadata[]; // Detected animations
216
+ video?: VideoMetadata; // Video source metadata
203
217
  executionTimeMs: number;
204
218
  }
205
219
  ```
@@ -228,6 +242,37 @@ const result = await extractScenes({
228
242
  });
229
243
  ```
230
244
 
245
+ ## Output Metadata
246
+
247
+ When running in `file` mode, `scene-sieve` generates a `.metadata.json` file in the output directory.
248
+
249
+ ```json
250
+ {
251
+ "video": {
252
+ "originalDurationMs": 15000,
253
+ "fps": 5,
254
+ "resolution": { "width": 720, "height": 405 }
255
+ },
256
+ "frames": [
257
+ {
258
+ "step": 1,
259
+ "fileName": "frame_0001.jpg",
260
+ "frameId": 1,
261
+ "timestampMs": 0
262
+ }
263
+ ],
264
+ "animations": [
265
+ {
266
+ "type": "loading_spinner",
267
+ "boundingBox": { "x": 100, "y": 200, "width": 50, "height": 50 },
268
+ "startFrameId": 12,
269
+ "endFrameId": 25,
270
+ "durationMs": 2600
271
+ }
272
+ ]
273
+ }
274
+ ```
275
+
231
276
  ## How It Works
232
277
 
233
278
  ### Pipeline
@@ -246,8 +291,8 @@ The analyzer scores each pair of adjacent frames through 4 stages:
246
291
 
247
292
  1. **AKAZE Feature Diff** — Detects and matches keypoints between frames; identifies newly appeared and disappeared features
248
293
  2. **DBSCAN Clustering** — Groups new feature points into spatial clusters
249
- 3. **IoU Tracking** — Tracks cluster bounding boxes across time; applies decay to repeated animation regions
250
- 4. **G(t) Scoring** — Calculates information gain from cluster area ratio and feature density, discounting animated areas
294
+ 3. **IoU Tracking** — Tracks cluster bounding boxes across time; identifies and records repeated animation regions (e.g. loading spinners)
295
+ 4. **G(t) Scoring** — Calculates information gain from cluster area ratio and feature density, discounting animated areas to focus on unique scene content
251
296
 
252
297
  Frames with higher G(t) scores represent greater visual change and are preserved during pruning.
253
298
 
package/dist/cli.mjs CHANGED
@@ -51,7 +51,7 @@ import { join } from "path";
51
51
  function getTempWorkspaceDir(sessionId) {
52
52
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
53
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;
54
+ var APP_NAME, DEFAULT_COUNT, DEFAULT_THRESHOLD, DEFAULT_FPS, DEFAULT_SCALE, DEFAULT_QUALITY, DEFAULT_MAX_FRAMES, NORMALIZATION_PERCENTILE, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN, 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
55
  var init_constants = __esm({
56
56
  "src/constants.ts"() {
57
57
  "use strict";
@@ -65,15 +65,8 @@ var init_constants = __esm({
65
65
  NORMALIZATION_PERCENTILE = 0.9;
66
66
  WORKSPACE_PREFIX = `${APP_NAME}-`;
67
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
68
  FRAME_OUTPUT_EXTENSION = ".jpg";
69
+ FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
77
70
  OPENCV_BATCH_SIZE = 10;
78
71
  DBSCAN_ALPHA = 0.03;
79
72
  DBSCAN_MIN_PTS = 4;
@@ -431,13 +424,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
431
424
  }
432
425
  async function analyzeFrames(ctx) {
433
426
  const { frames } = ctx;
434
- if (frames.length < 2) return [];
427
+ if (frames.length < 2) return { edges: [], animations: [] };
435
428
  logger.debug(
436
429
  `Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
437
430
  );
438
431
  const cvLib = await ensureOpenCV();
439
432
  const edges = [];
440
- const tracker = new IoUTracker();
433
+ const tracker = new IoUTracker(
434
+ ctx.options.fps,
435
+ ctx.options.iouThreshold,
436
+ ctx.options.animationThreshold
437
+ );
441
438
  const scale = ctx.options.scale;
442
439
  for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
443
440
  const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
@@ -450,8 +447,11 @@ async function analyzeFrames(ctx) {
450
447
  );
451
448
  ctx.emitProgress(progress);
452
449
  }
453
- logger.debug(`Computed ${edges.length} score edges`);
454
- return edges;
450
+ const animations = tracker.flushAndGetAnimations();
451
+ logger.debug(
452
+ `Computed ${edges.length} score edges and ${animations.length} animations`
453
+ );
454
+ return { edges, animations };
455
455
  }
456
456
  var OPENCV_INIT_TIMEOUT_MS, require2, cvReady, IoUTracker;
457
457
  var init_analyzer = __esm({
@@ -464,7 +464,13 @@ var init_analyzer = __esm({
464
464
  require2 = createRequire(import.meta.url);
465
465
  cvReady = null;
466
466
  IoUTracker = class {
467
+ constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
468
+ this.fps = fps;
469
+ this.iouThreshold = iouThreshold;
470
+ this.animationThreshold = animationThreshold;
471
+ }
467
472
  regions = [];
473
+ extractedAnimations = [];
468
474
  update(boxes, pairIndex) {
469
475
  const animationIndices = /* @__PURE__ */ new Set();
470
476
  const matched = /* @__PURE__ */ new Set();
@@ -480,7 +486,7 @@ var init_analyzer = __esm({
480
486
  bestRegionIdx = ri;
481
487
  }
482
488
  }
483
- if (bestIoU > IOU_THRESHOLD && bestRegionIdx !== -1) {
489
+ if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
484
490
  const region = this.regions[bestRegionIdx];
485
491
  const gap = pairIndex - region.lastSeen;
486
492
  region.box = box;
@@ -488,13 +494,14 @@ var init_analyzer = __esm({
488
494
  region.lastSeen = pairIndex;
489
495
  region.weight *= Math.pow(DECAY_LAMBDA, gap);
490
496
  matched.add(bestRegionIdx);
491
- if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
497
+ if (region.consecutiveCount >= this.animationThreshold) {
492
498
  animationIndices.add(bi);
493
499
  }
494
500
  } else {
495
501
  this.regions.push({
496
502
  box,
497
503
  consecutiveCount: 1,
504
+ firstSeen: pairIndex,
498
505
  lastSeen: pairIndex,
499
506
  weight: 1
500
507
  });
@@ -506,17 +513,45 @@ var init_analyzer = __esm({
506
513
  this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
507
514
  }
508
515
  }
509
- this.regions = this.regions.filter((r) => r.weight > 0.01);
516
+ for (let i = 0; i < this.regions.length; i++) {
517
+ const region = this.regions[i];
518
+ if (region.weight <= 0.01 && !matched.has(i)) {
519
+ this.collectAnimation(region);
520
+ }
521
+ }
522
+ this.regions = this.regions.filter(
523
+ (r, i) => r.weight > 0.01 || matched.has(i)
524
+ );
510
525
  return animationIndices;
511
526
  }
527
+ collectAnimation(region) {
528
+ if (region.consecutiveCount >= this.animationThreshold) {
529
+ const durationMs = region.consecutiveCount / this.fps * 1e3;
530
+ this.extractedAnimations.push({
531
+ type: "loading_spinner",
532
+ // 기본값으로 loading_spinner 사용
533
+ boundingBox: region.box,
534
+ startFrameId: region.firstSeen,
535
+ endFrameId: region.lastSeen,
536
+ durationMs
537
+ });
538
+ }
539
+ }
540
+ flushAndGetAnimations() {
541
+ for (const region of this.regions) {
542
+ this.collectAnimation(region);
543
+ }
544
+ this.regions = [];
545
+ return this.extractedAnimations;
546
+ }
512
547
  getAnimationWeight(boxIndex, boxes) {
513
548
  if (boxIndex >= boxes.length) return 0;
514
549
  const box = boxes[boxIndex];
515
550
  let maxWeight = 0;
516
551
  for (const region of this.regions) {
517
- if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
552
+ if (region.consecutiveCount >= this.animationThreshold) {
518
553
  const iou = computeIoU(box, region.box);
519
- if (iou > IOU_THRESHOLD) {
554
+ if (iou > this.iouThreshold) {
520
555
  maxWeight = Math.max(maxWeight, region.weight);
521
556
  }
522
557
  }
@@ -557,9 +592,6 @@ function deriveOutputPath(inputPath) {
557
592
  const name = basename(inputPath, extname(inputPath));
558
593
  return resolve(dir, `${name}_scenes`);
559
594
  }
560
- function isSupportedFile(filePath, extensions) {
561
- return extensions.includes(extname(filePath).toLowerCase());
562
- }
563
595
  var init_paths = __esm({
564
596
  "src/utils/paths.ts"() {
565
597
  "use strict";
@@ -582,32 +614,47 @@ async function extractFrames(ctx) {
582
614
  if (!exists) {
583
615
  throw new Error(`Input file not found: ${inputPath}`);
584
616
  }
585
- const allExtensions = [
586
- ...SUPPORTED_VIDEO_EXTENSIONS,
587
- ...SUPPORTED_GIF_EXTENSIONS
588
- ];
589
- if (!isSupportedFile(inputPath, allExtensions)) {
590
- throw new Error(`Unsupported file format: ${inputPath}`);
617
+ const metadata = await getVideoMetadata(inputPath).catch((err) => {
618
+ logger.debug(`ffprobe failed: ${err.message}`);
619
+ return null;
620
+ });
621
+ if (!metadata || !metadata.format) {
622
+ throw new Error(`Could not read file metadata: ${inputPath}`);
591
623
  }
592
- logger.debug(`Extracting frames from: ${inputPath}`);
624
+ const formatName = metadata.format.format_name ?? "";
625
+ const duration = parseFloat(metadata.format.duration ?? "0");
626
+ const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
627
+ if (!hasVideoStream) {
628
+ throw new Error(
629
+ `No video stream found in file: ${inputPath} (detected format: ${formatName})`
630
+ );
631
+ }
632
+ logger.debug(
633
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
634
+ );
593
635
  await ensureDir(framesDir);
594
636
  let effectiveFps = fps;
595
- const duration = await getVideoDuration(inputPath).catch(() => 0);
596
637
  if (duration > 0) {
597
638
  const fpsCap = maxFrames / duration;
598
639
  effectiveFps = Math.min(fps, fpsCap);
599
640
  effectiveFps = Math.max(0.5, effectiveFps);
600
641
  logger.debug(
601
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
642
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
602
643
  );
603
644
  }
604
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
645
+ const frames = await extractByFps(
646
+ inputPath,
647
+ framesDir,
648
+ effectiveFps,
649
+ scale,
650
+ duration
651
+ );
605
652
  ctx.emitProgress(100);
606
653
  logger.debug(`Extracted ${frames.length} frames`);
607
654
  return frames;
608
655
  }
609
- async function extractByFps(inputPath, outputDir, fps, scale) {
610
- const outputPattern = join2(outputDir, "frame_%06d.jpg");
656
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
657
+ const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
611
658
  await execa(ffmpegPath, [
612
659
  "-i",
613
660
  inputPath,
@@ -617,34 +664,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
617
664
  "2",
618
665
  outputPattern
619
666
  ]);
620
- return buildFrameList(outputDir, inputPath);
667
+ return buildFrameList(outputDir, duration);
621
668
  }
622
- async function getVideoDuration(inputPath) {
669
+ async function getVideoMetadata(inputPath) {
623
670
  const { stdout } = await execa(ffprobePath, [
624
671
  "-v",
625
672
  "quiet",
626
673
  "-print_format",
627
674
  "json",
628
675
  "-show_format",
676
+ "-show_streams",
629
677
  inputPath
630
678
  ]);
631
- const metadata = JSON.parse(stdout);
632
- return parseFloat(metadata.format?.duration ?? "0");
679
+ return JSON.parse(stdout);
633
680
  }
634
- async function buildFrameList(framesDir, inputPath) {
681
+ async function buildFrameList(framesDir, duration) {
635
682
  const files = await readdir(framesDir);
636
683
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
637
684
  if (jpgFiles.length === 0) {
638
685
  return [];
639
686
  }
640
- let duration = 0;
641
- try {
642
- duration = await getVideoDuration(inputPath);
643
- } catch {
644
- logger.debug(
645
- "Could not determine video duration; using frame index for timestamps"
646
- );
647
- }
648
687
  return jpgFiles.map((file, index) => ({
649
688
  id: index,
650
689
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -675,13 +714,44 @@ async function finalizeOutput(ctx, selectedFrames) {
675
714
  const outputPath = ctx.options.outputPath;
676
715
  const quality = ctx.options.quality;
677
716
  const outputFiles = [];
717
+ const framesMetadata = [];
718
+ const totalFramesCount = ctx.frames.length;
719
+ const padding = Math.max(4, String(totalFramesCount).length);
678
720
  for (let i = 0; i < selectedFrames.length; i++) {
679
721
  const frame = selectedFrames[i];
680
- const destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
681
- const destPath = join3(stagingDir, destName);
722
+ const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
723
+ const destPath = join3(stagingDir, fileName);
682
724
  await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
683
- outputFiles.push(join3(outputPath, destName));
725
+ outputFiles.push(join3(outputPath, fileName));
726
+ framesMetadata.push({
727
+ step: i + 1,
728
+ fileName,
729
+ frameId: frame.id + 1,
730
+ timestampMs: Math.round(frame.timestamp * 1e3)
731
+ });
684
732
  }
733
+ const metadata = {
734
+ video: {
735
+ originalDurationMs: Math.round(
736
+ (ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
737
+ ),
738
+ fps: ctx.options.fps,
739
+ resolution: {
740
+ width: ctx.options.scale,
741
+ height: Math.round(ctx.options.scale * 9 / 16)
742
+ }
743
+ },
744
+ frames: framesMetadata,
745
+ animations: (ctx.animations || []).map((anim) => ({
746
+ ...anim,
747
+ startFrameId: anim.startFrameId + 1,
748
+ endFrameId: anim.endFrameId + 1,
749
+ durationMs: Math.round(anim.durationMs)
750
+ }))
751
+ };
752
+ const metadataPath = join3(stagingDir, ".metadata.json");
753
+ await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
754
+ outputFiles.push(join3(outputPath, ".metadata.json"));
685
755
  await ensureDir(join3(outputPath, ".."));
686
756
  await rm(outputPath, { recursive: true, force: true });
687
757
  await rename(stagingDir, outputPath);
@@ -769,6 +839,8 @@ function resolveOptions(options) {
769
839
  maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
770
840
  scale: options.scale ?? DEFAULT_SCALE,
771
841
  quality: options.quality ?? DEFAULT_QUALITY,
842
+ iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
843
+ animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
772
844
  debug: options.debug ?? false
773
845
  };
774
846
  }
@@ -1065,7 +1137,9 @@ async function runPipeline(options) {
1065
1137
  }
1066
1138
  ctx.emitProgress(100);
1067
1139
  ctx.status = "ANALYZING";
1068
- ctx.graph = await analyzeFrames(ctx);
1140
+ const { edges, animations } = await analyzeFrames(ctx);
1141
+ ctx.graph = edges;
1142
+ ctx.animations = animations;
1069
1143
  ctx.status = "PRUNING";
1070
1144
  const survivingIds = pruneByThresholdWithCap(
1071
1145
  ctx.graph,
@@ -1098,6 +1172,15 @@ async function runPipeline(options) {
1098
1172
  prunedFramesCount: prunedFrames.length,
1099
1173
  outputFiles,
1100
1174
  outputBuffers,
1175
+ animations: ctx.animations,
1176
+ video: {
1177
+ originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
1178
+ fps: ctx.options.fps,
1179
+ resolution: {
1180
+ width: ctx.options.scale,
1181
+ height: Math.round(ctx.options.scale * 9 / 16)
1182
+ }
1183
+ },
1101
1184
  executionTimeMs: Date.now() - startTime
1102
1185
  };
1103
1186
  } catch (error) {
@@ -1280,6 +1363,8 @@ var SieveView = (props) => {
1280
1363
  maxFrames: props.maxFrames,
1281
1364
  scale: props.scale,
1282
1365
  quality: props.quality,
1366
+ iouThreshold: props.iouThreshold,
1367
+ animationThreshold: props.animationThreshold,
1283
1368
  debug: props.debug
1284
1369
  },
1285
1370
  (phase, percent) => {
@@ -1374,12 +1459,19 @@ var SieveView = (props) => {
1374
1459
  "\u2713 Done",
1375
1460
  " \u2014 ",
1376
1461
  result.originalFramesCount,
1377
- " frames \u2192 ",
1462
+ " frames \u2192",
1463
+ " ",
1378
1464
  result.prunedFramesCount,
1379
1465
  " scenes (",
1380
1466
  (result.executionTimeMs / 1e3).toFixed(1),
1381
1467
  "s)"
1382
1468
  ] }),
1469
+ result.animations && result.animations.length > 0 && /* @__PURE__ */ jsxs3(Text3, { color: "blue", children: [
1470
+ "\u2139 Found",
1471
+ " ",
1472
+ result.animations.length,
1473
+ " animations (recorded in .metadata.json)"
1474
+ ] }),
1383
1475
  props.debug && result.outputFiles.length > 0 && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
1384
1476
  /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
1385
1477
  "Output: ",
@@ -1395,17 +1487,32 @@ var SieveView = (props) => {
1395
1487
  };
1396
1488
 
1397
1489
  // src/cli.ts
1490
+ init_constants();
1398
1491
  var require3 = createRequire2(import.meta.url);
1399
1492
  var { version } = require3("../package.json");
1400
1493
  var program = new Command();
1401
1494
  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(
1402
1495
  "-t, --threshold <number>",
1403
1496
  "Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
1404
- ).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Max FPS for frame extraction", "5").option(
1405
- "--max-frames <number>",
1497
+ ).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Max FPS for frame extraction", String(DEFAULT_FPS)).option(
1498
+ "-mf, --max-frames <number>",
1406
1499
  "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) => {
1500
+ String(DEFAULT_MAX_FRAMES)
1501
+ ).option(
1502
+ "-s, --scale <number>",
1503
+ "Scale size for vision analysis",
1504
+ String(DEFAULT_SCALE)
1505
+ ).option(
1506
+ "-q, --quality <number>",
1507
+ "JPEG output quality 1-100",
1508
+ String(DEFAULT_QUALITY)
1509
+ ).option(
1510
+ "-it, --iou-threshold <number>",
1511
+ `IoU threshold for animation tracking (0-1) (default: ${IOU_THRESHOLD})`
1512
+ ).option(
1513
+ "-at, --anim-threshold <number>",
1514
+ `Min consecutive frames for animation (default: ${ANIMATION_FRAME_THRESHOLD})`
1515
+ ).option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
1409
1516
  const { waitUntilExit } = render(
1410
1517
  React2.createElement(SieveView, {
1411
1518
  input,
@@ -1416,6 +1523,8 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
1416
1523
  maxFrames: parseInt(opts.maxFrames, 10),
1417
1524
  scale: parseInt(opts.scale, 10),
1418
1525
  quality: parseInt(opts.quality, 10),
1526
+ iouThreshold: opts.iouThreshold !== void 0 ? parseFloat(opts.iouThreshold) : void 0,
1527
+ animationThreshold: opts.animThreshold !== void 0 ? parseInt(opts.animThreshold, 10) : void 0,
1419
1528
  debug: opts.debug ?? false
1420
1529
  })
1421
1530
  );
@@ -8,6 +8,8 @@ export interface SieveViewProps {
8
8
  maxFrames: number;
9
9
  scale: number;
10
10
  quality: number;
11
+ iouThreshold?: number;
12
+ animationThreshold?: number;
11
13
  debug: boolean;
12
14
  }
13
15
  export declare const SieveView: React.FC<SieveViewProps>;
@@ -8,8 +8,6 @@ export declare const DEFAULT_MAX_FRAMES = 300;
8
8
  export declare const NORMALIZATION_PERCENTILE = 0.9;
9
9
  export declare const WORKSPACE_PREFIX = "scene-sieve-";
10
10
  export declare const TEMP_BASE_DIR: string;
11
- export declare const SUPPORTED_VIDEO_EXTENSIONS: string[];
12
- export declare const SUPPORTED_GIF_EXTENSIONS: string[];
13
11
  export declare const FRAME_OUTPUT_EXTENSION = ".jpg";
14
12
  export declare const FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
15
13
  export declare const OPENCV_BATCH_SIZE = 10;
@@ -1,4 +1,4 @@
1
- import type { BoundingBox, ProcessContext, ScoreEdge } from '../types/index.js';
1
+ import type { AnalysisResult, AnimationMetadata, BoundingBox, ProcessContext } from '../types/index.js';
2
2
  import type { Point2D } from './dbscan.js';
3
3
  type CvLib = typeof import('@techstark/opencv-js');
4
4
  export declare function preprocessFrame(framePath: string, scale: number): Promise<{
@@ -8,8 +8,15 @@ export declare function preprocessFrame(framePath: string, scale: number): Promi
8
8
  }>;
9
9
  export declare function computeIoU(a: BoundingBox, b: BoundingBox): number;
10
10
  export declare class IoUTracker {
11
+ private fps;
12
+ private iouThreshold;
13
+ private animationThreshold;
11
14
  private regions;
15
+ private extractedAnimations;
16
+ constructor(fps?: number, iouThreshold?: number, animationThreshold?: number);
12
17
  update(boxes: BoundingBox[], pairIndex: number): Set<number>;
18
+ private collectAnimation;
19
+ flushAndGetAnimations(): AnimationMetadata[];
13
20
  getAnimationWeight(boxIndex: number, boxes: BoundingBox[]): number;
14
21
  }
15
22
  export interface AKAZEResult {
@@ -51,5 +58,5 @@ export declare function computeInformationGain(clusters: BoundingBox[], clusterP
51
58
  * 3. Spatio-temporal IoU Tracking
52
59
  * 4. G(t) Information Gain Scoring
53
60
  */
54
- export declare function analyzeFrames(ctx: ProcessContext): Promise<ScoreEdge[]>;
61
+ export declare function analyzeFrames(ctx: ProcessContext): Promise<AnalysisResult>;
55
62
  export {};