@lumy-pack/scene-sieve 0.0.6 → 0.0.8

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, 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;
54
+ var APP_NAME, DEFAULT_COUNT, DEFAULT_THRESHOLD, DEFAULT_FPS, DEFAULT_SCALE, DEFAULT_QUALITY, DEFAULT_MAX_FRAMES, NORMALIZATION_LOGISTIC_K, NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE, 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";
@@ -62,17 +62,12 @@ var init_constants = __esm({
62
62
  DEFAULT_SCALE = 720;
63
63
  DEFAULT_QUALITY = 80;
64
64
  DEFAULT_MAX_FRAMES = 300;
65
- NORMALIZATION_PERCENTILE = 0.9;
65
+ NORMALIZATION_LOGISTIC_K = 3;
66
+ NORMALIZATION_ALPHA = 0.4;
67
+ NORMALIZATION_MAD_COEFFICIENT = 1.4826;
68
+ NORMALIZATION_MIN_SAMPLE_SIZE = 10;
66
69
  WORKSPACE_PREFIX = `${APP_NAME}-`;
67
70
  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
71
  FRAME_OUTPUT_EXTENSION = ".jpg";
77
72
  FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
78
73
  OPENCV_BATCH_SIZE = 10;
@@ -600,9 +595,6 @@ function deriveOutputPath(inputPath) {
600
595
  const name = basename(inputPath, extname(inputPath));
601
596
  return resolve(dir, `${name}_scenes`);
602
597
  }
603
- function isSupportedFile(filePath, extensions) {
604
- return extensions.includes(extname(filePath).toLowerCase());
605
- }
606
598
  var init_paths = __esm({
607
599
  "src/utils/paths.ts"() {
608
600
  "use strict";
@@ -625,31 +617,46 @@ async function extractFrames(ctx) {
625
617
  if (!exists) {
626
618
  throw new Error(`Input file not found: ${inputPath}`);
627
619
  }
628
- const allExtensions = [
629
- ...SUPPORTED_VIDEO_EXTENSIONS,
630
- ...SUPPORTED_GIF_EXTENSIONS
631
- ];
632
- if (!isSupportedFile(inputPath, allExtensions)) {
633
- throw new Error(`Unsupported file format: ${inputPath}`);
620
+ const metadata = await getVideoMetadata(inputPath).catch((err) => {
621
+ logger.debug(`ffprobe failed: ${err.message}`);
622
+ return null;
623
+ });
624
+ if (!metadata || !metadata.format) {
625
+ throw new Error(`Could not read file metadata: ${inputPath}`);
626
+ }
627
+ const formatName = metadata.format.format_name ?? "";
628
+ const duration = parseFloat(metadata.format.duration ?? "0");
629
+ const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
630
+ if (!hasVideoStream) {
631
+ throw new Error(
632
+ `No video stream found in file: ${inputPath} (detected format: ${formatName})`
633
+ );
634
634
  }
635
- logger.debug(`Extracting frames from: ${inputPath}`);
635
+ logger.debug(
636
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
637
+ );
636
638
  await ensureDir(framesDir);
637
639
  let effectiveFps = fps;
638
- const duration = await getVideoDuration(inputPath).catch(() => 0);
639
640
  if (duration > 0) {
640
641
  const fpsCap = maxFrames / duration;
641
642
  effectiveFps = Math.min(fps, fpsCap);
642
643
  effectiveFps = Math.max(0.5, effectiveFps);
643
644
  logger.debug(
644
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
645
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
645
646
  );
646
647
  }
647
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
648
+ const frames = await extractByFps(
649
+ inputPath,
650
+ framesDir,
651
+ effectiveFps,
652
+ scale,
653
+ duration
654
+ );
648
655
  ctx.emitProgress(100);
649
656
  logger.debug(`Extracted ${frames.length} frames`);
650
657
  return frames;
651
658
  }
652
- async function extractByFps(inputPath, outputDir, fps, scale) {
659
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
653
660
  const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
654
661
  await execa(ffmpegPath, [
655
662
  "-i",
@@ -660,34 +667,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
660
667
  "2",
661
668
  outputPattern
662
669
  ]);
663
- return buildFrameList(outputDir, inputPath);
670
+ return buildFrameList(outputDir, duration);
664
671
  }
665
- async function getVideoDuration(inputPath) {
672
+ async function getVideoMetadata(inputPath) {
666
673
  const { stdout } = await execa(ffprobePath, [
667
674
  "-v",
668
675
  "quiet",
669
676
  "-print_format",
670
677
  "json",
671
678
  "-show_format",
679
+ "-show_streams",
672
680
  inputPath
673
681
  ]);
674
- const metadata = JSON.parse(stdout);
675
- return parseFloat(metadata.format?.duration ?? "0");
682
+ return JSON.parse(stdout);
676
683
  }
677
- async function buildFrameList(framesDir, inputPath) {
684
+ async function buildFrameList(framesDir, duration) {
678
685
  const files = await readdir(framesDir);
679
686
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
680
687
  if (jpgFiles.length === 0) {
681
688
  return [];
682
689
  }
683
- let duration = 0;
684
- try {
685
- duration = await getVideoDuration(inputPath);
686
- } catch {
687
- logger.debug(
688
- "Could not determine video duration; using frame index for timestamps"
689
- );
690
- }
691
690
  return jpgFiles.map((file, index) => ({
692
691
  id: index,
693
692
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -877,6 +876,49 @@ var init_input_resolver = __esm({
877
876
  }
878
877
  });
879
878
 
879
+ // src/utils/math.ts
880
+ function normalizeScores(items) {
881
+ if (items.length === 0) return [];
882
+ const safeScores = items.map(
883
+ (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
884
+ );
885
+ const positiveScores = safeScores.filter((s) => s > 0);
886
+ if (positiveScores.length === 0) return safeScores;
887
+ const sorted = [...positiveScores].sort((a, b) => a - b);
888
+ if (positiveScores.length <= NORMALIZATION_MIN_SAMPLE_SIZE) {
889
+ const min = sorted[0];
890
+ const max = sorted[sorted.length - 1];
891
+ if (max === min) return safeScores.map((s) => s > 0 ? 1 : 0);
892
+ return safeScores.map(
893
+ (s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1))
894
+ );
895
+ }
896
+ const median = sorted[Math.floor(sorted.length / 2)];
897
+ const absoluteDiffs = positiveScores.map((v) => Math.abs(v - median));
898
+ const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
899
+ const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
900
+ const logisticZ = safeScores.map((s) => {
901
+ if (s <= 0) return 0;
902
+ if (scale === 0) return 1;
903
+ const z = (s - median) / scale;
904
+ return 1 / (1 + Math.exp(-NORMALIZATION_LOGISTIC_K * z));
905
+ });
906
+ const cdf = safeScores.map((s) => {
907
+ if (s <= 0) return 0;
908
+ const rank = sorted.findIndex((v) => v >= s);
909
+ return rank / sorted.length;
910
+ });
911
+ return logisticZ.map(
912
+ (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
913
+ );
914
+ }
915
+ var init_math = __esm({
916
+ "src/utils/math.ts"() {
917
+ "use strict";
918
+ init_constants();
919
+ }
920
+ });
921
+
880
922
  // src/utils/min-heap.ts
881
923
  var MinHeap;
882
924
  var init_min_heap = __esm({
@@ -977,20 +1019,6 @@ function pruneTo(graph, frames, targetCount) {
977
1019
  }
978
1020
  return surviving;
979
1021
  }
980
- function normalizeScores(graph) {
981
- if (graph.length === 0) return [];
982
- const safeScores = graph.map(
983
- (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
984
- );
985
- const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
986
- if (sorted.length === 0) return safeScores;
987
- const pIdx = Math.min(
988
- Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
989
- sorted.length - 1
990
- );
991
- const refScore = sorted[pIdx];
992
- return safeScores.map((s) => Math.min(s / refScore, 1));
993
- }
994
1022
  function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
995
1023
  const result = /* @__PURE__ */ new Set();
996
1024
  let runStart = 0;
@@ -1090,7 +1118,7 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1090
1118
  var init_pruner = __esm({
1091
1119
  "src/core/pruner.ts"() {
1092
1120
  "use strict";
1093
- init_constants();
1121
+ init_math();
1094
1122
  init_min_heap();
1095
1123
  }
1096
1124
  });
@@ -5,11 +5,14 @@ export declare const DEFAULT_FPS = 5;
5
5
  export declare const DEFAULT_SCALE = 720;
6
6
  export declare const DEFAULT_QUALITY = 80;
7
7
  export declare const DEFAULT_MAX_FRAMES = 300;
8
- export declare const NORMALIZATION_PERCENTILE = 0.9;
8
+ export declare const NORMALIZATION_MIN_PERCENTILE = 0.1;
9
+ export declare const NORMALIZATION_MAX_PERCENTILE = 0.9;
10
+ export declare const NORMALIZATION_LOGISTIC_K = 3;
11
+ export declare const NORMALIZATION_ALPHA = 0.4;
12
+ export declare const NORMALIZATION_MAD_COEFFICIENT = 1.4826;
13
+ export declare const NORMALIZATION_MIN_SAMPLE_SIZE = 10;
9
14
  export declare const WORKSPACE_PREFIX = "scene-sieve-";
10
15
  export declare const TEMP_BASE_DIR: string;
11
- export declare const SUPPORTED_VIDEO_EXTENSIONS: string[];
12
- export declare const SUPPORTED_GIF_EXTENSIONS: string[];
13
16
  export declare const FRAME_OUTPUT_EXTENSION = ".jpg";
14
17
  export declare const FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
15
18
  export declare const OPENCV_BATCH_SIZE = 10;
package/dist/index.cjs CHANGED
@@ -85,17 +85,12 @@ var DEFAULT_FPS = 5;
85
85
  var DEFAULT_SCALE = 720;
86
86
  var DEFAULT_QUALITY = 80;
87
87
  var DEFAULT_MAX_FRAMES = 300;
88
- var NORMALIZATION_PERCENTILE = 0.9;
88
+ var NORMALIZATION_LOGISTIC_K = 3;
89
+ var NORMALIZATION_ALPHA = 0.4;
90
+ var NORMALIZATION_MAD_COEFFICIENT = 1.4826;
91
+ var NORMALIZATION_MIN_SAMPLE_SIZE = 10;
89
92
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
90
93
  var TEMP_BASE_DIR = (0, import_node_os.tmpdir)();
91
- var SUPPORTED_VIDEO_EXTENSIONS = [
92
- ".mp4",
93
- ".mov",
94
- ".avi",
95
- ".mkv",
96
- ".webm"
97
- ];
98
- var SUPPORTED_GIF_EXTENSIONS = [".gif"];
99
94
  var FRAME_OUTPUT_EXTENSION = ".jpg";
100
95
  var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
101
96
  var OPENCV_BATCH_SIZE = 10;
@@ -613,9 +608,6 @@ function deriveOutputPath(inputPath) {
613
608
  const name = (0, import_node_path2.basename)(inputPath, (0, import_node_path2.extname)(inputPath));
614
609
  return (0, import_node_path2.resolve)(dir, `${name}_scenes`);
615
610
  }
616
- function isSupportedFile(filePath, extensions) {
617
- return extensions.includes((0, import_node_path2.extname)(filePath).toLowerCase());
618
- }
619
611
 
620
612
  // src/core/extractor.ts
621
613
  async function extractFrames(ctx) {
@@ -628,31 +620,46 @@ async function extractFrames(ctx) {
628
620
  if (!exists) {
629
621
  throw new Error(`Input file not found: ${inputPath}`);
630
622
  }
631
- const allExtensions = [
632
- ...SUPPORTED_VIDEO_EXTENSIONS,
633
- ...SUPPORTED_GIF_EXTENSIONS
634
- ];
635
- if (!isSupportedFile(inputPath, allExtensions)) {
636
- throw new Error(`Unsupported file format: ${inputPath}`);
623
+ const metadata = await getVideoMetadata(inputPath).catch((err) => {
624
+ logger.debug(`ffprobe failed: ${err.message}`);
625
+ return null;
626
+ });
627
+ if (!metadata || !metadata.format) {
628
+ throw new Error(`Could not read file metadata: ${inputPath}`);
629
+ }
630
+ const formatName = metadata.format.format_name ?? "";
631
+ const duration = parseFloat(metadata.format.duration ?? "0");
632
+ const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
633
+ if (!hasVideoStream) {
634
+ throw new Error(
635
+ `No video stream found in file: ${inputPath} (detected format: ${formatName})`
636
+ );
637
637
  }
638
- logger.debug(`Extracting frames from: ${inputPath}`);
638
+ logger.debug(
639
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
640
+ );
639
641
  await ensureDir(framesDir);
640
642
  let effectiveFps = fps;
641
- const duration = await getVideoDuration(inputPath).catch(() => 0);
642
643
  if (duration > 0) {
643
644
  const fpsCap = maxFrames / duration;
644
645
  effectiveFps = Math.min(fps, fpsCap);
645
646
  effectiveFps = Math.max(0.5, effectiveFps);
646
647
  logger.debug(
647
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
648
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
648
649
  );
649
650
  }
650
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
651
+ const frames = await extractByFps(
652
+ inputPath,
653
+ framesDir,
654
+ effectiveFps,
655
+ scale,
656
+ duration
657
+ );
651
658
  ctx.emitProgress(100);
652
659
  logger.debug(`Extracted ${frames.length} frames`);
653
660
  return frames;
654
661
  }
655
- async function extractByFps(inputPath, outputDir, fps, scale) {
662
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
656
663
  const outputPattern = (0, import_node_path3.join)(outputDir, FRAME_FILENAME_PATTERN);
657
664
  await (0, import_execa.execa)(import_ffmpeg_static.default, [
658
665
  "-i",
@@ -663,34 +670,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
663
670
  "2",
664
671
  outputPattern
665
672
  ]);
666
- return buildFrameList(outputDir, inputPath);
673
+ return buildFrameList(outputDir, duration);
667
674
  }
668
- async function getVideoDuration(inputPath) {
675
+ async function getVideoMetadata(inputPath) {
669
676
  const { stdout } = await (0, import_execa.execa)(import_ffprobe.path, [
670
677
  "-v",
671
678
  "quiet",
672
679
  "-print_format",
673
680
  "json",
674
681
  "-show_format",
682
+ "-show_streams",
675
683
  inputPath
676
684
  ]);
677
- const metadata = JSON.parse(stdout);
678
- return parseFloat(metadata.format?.duration ?? "0");
685
+ return JSON.parse(stdout);
679
686
  }
680
- async function buildFrameList(framesDir, inputPath) {
687
+ async function buildFrameList(framesDir, duration) {
681
688
  const files = await (0, import_promises2.readdir)(framesDir);
682
689
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
683
690
  if (jpgFiles.length === 0) {
684
691
  return [];
685
692
  }
686
- let duration = 0;
687
- try {
688
- duration = await getVideoDuration(inputPath);
689
- } catch {
690
- logger.debug(
691
- "Could not determine video duration; using frame index for timestamps"
692
- );
693
- }
694
693
  return jpgFiles.map((file, index) => ({
695
694
  id: index,
696
695
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -843,6 +842,43 @@ async function resolveInput(options, workspacePath) {
843
842
  throw new Error(`Unsupported input mode: ${options.mode}`);
844
843
  }
845
844
 
845
+ // src/utils/math.ts
846
+ function normalizeScores(items) {
847
+ if (items.length === 0) return [];
848
+ const safeScores = items.map(
849
+ (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
850
+ );
851
+ const positiveScores = safeScores.filter((s) => s > 0);
852
+ if (positiveScores.length === 0) return safeScores;
853
+ const sorted = [...positiveScores].sort((a, b) => a - b);
854
+ if (positiveScores.length <= NORMALIZATION_MIN_SAMPLE_SIZE) {
855
+ const min = sorted[0];
856
+ const max = sorted[sorted.length - 1];
857
+ if (max === min) return safeScores.map((s) => s > 0 ? 1 : 0);
858
+ return safeScores.map(
859
+ (s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1))
860
+ );
861
+ }
862
+ const median = sorted[Math.floor(sorted.length / 2)];
863
+ const absoluteDiffs = positiveScores.map((v) => Math.abs(v - median));
864
+ const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
865
+ const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
866
+ const logisticZ = safeScores.map((s) => {
867
+ if (s <= 0) return 0;
868
+ if (scale === 0) return 1;
869
+ const z = (s - median) / scale;
870
+ return 1 / (1 + Math.exp(-NORMALIZATION_LOGISTIC_K * z));
871
+ });
872
+ const cdf = safeScores.map((s) => {
873
+ if (s <= 0) return 0;
874
+ const rank = sorted.findIndex((v) => v >= s);
875
+ return rank / sorted.length;
876
+ });
877
+ return logisticZ.map(
878
+ (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
879
+ );
880
+ }
881
+
846
882
  // src/utils/min-heap.ts
847
883
  var MinHeap = class {
848
884
  h = [];
@@ -937,20 +973,6 @@ function pruneTo(graph, frames, targetCount) {
937
973
  }
938
974
  return surviving;
939
975
  }
940
- function normalizeScores(graph) {
941
- if (graph.length === 0) return [];
942
- const safeScores = graph.map(
943
- (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
944
- );
945
- const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
946
- if (sorted.length === 0) return safeScores;
947
- const pIdx = Math.min(
948
- Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
949
- sorted.length - 1
950
- );
951
- const refScore = sorted[pIdx];
952
- return safeScores.map((s) => Math.min(s / refScore, 1));
953
- }
954
976
  function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
955
977
  const result = /* @__PURE__ */ new Set();
956
978
  let runStart = 0;
package/dist/index.mjs CHANGED
@@ -45,17 +45,12 @@ var DEFAULT_FPS = 5;
45
45
  var DEFAULT_SCALE = 720;
46
46
  var DEFAULT_QUALITY = 80;
47
47
  var DEFAULT_MAX_FRAMES = 300;
48
- var NORMALIZATION_PERCENTILE = 0.9;
48
+ var NORMALIZATION_LOGISTIC_K = 3;
49
+ var NORMALIZATION_ALPHA = 0.4;
50
+ var NORMALIZATION_MAD_COEFFICIENT = 1.4826;
51
+ var NORMALIZATION_MIN_SAMPLE_SIZE = 10;
49
52
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
50
53
  var TEMP_BASE_DIR = tmpdir();
51
- var SUPPORTED_VIDEO_EXTENSIONS = [
52
- ".mp4",
53
- ".mov",
54
- ".avi",
55
- ".mkv",
56
- ".webm"
57
- ];
58
- var SUPPORTED_GIF_EXTENSIONS = [".gif"];
59
54
  var FRAME_OUTPUT_EXTENSION = ".jpg";
60
55
  var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
61
56
  var OPENCV_BATCH_SIZE = 10;
@@ -573,9 +568,6 @@ function deriveOutputPath(inputPath) {
573
568
  const name = basename(inputPath, extname(inputPath));
574
569
  return resolve(dir, `${name}_scenes`);
575
570
  }
576
- function isSupportedFile(filePath, extensions) {
577
- return extensions.includes(extname(filePath).toLowerCase());
578
- }
579
571
 
580
572
  // src/core/extractor.ts
581
573
  async function extractFrames(ctx) {
@@ -588,31 +580,46 @@ async function extractFrames(ctx) {
588
580
  if (!exists) {
589
581
  throw new Error(`Input file not found: ${inputPath}`);
590
582
  }
591
- const allExtensions = [
592
- ...SUPPORTED_VIDEO_EXTENSIONS,
593
- ...SUPPORTED_GIF_EXTENSIONS
594
- ];
595
- if (!isSupportedFile(inputPath, allExtensions)) {
596
- throw new Error(`Unsupported file format: ${inputPath}`);
583
+ const metadata = await getVideoMetadata(inputPath).catch((err) => {
584
+ logger.debug(`ffprobe failed: ${err.message}`);
585
+ return null;
586
+ });
587
+ if (!metadata || !metadata.format) {
588
+ throw new Error(`Could not read file metadata: ${inputPath}`);
589
+ }
590
+ const formatName = metadata.format.format_name ?? "";
591
+ const duration = parseFloat(metadata.format.duration ?? "0");
592
+ const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
593
+ if (!hasVideoStream) {
594
+ throw new Error(
595
+ `No video stream found in file: ${inputPath} (detected format: ${formatName})`
596
+ );
597
597
  }
598
- logger.debug(`Extracting frames from: ${inputPath}`);
598
+ logger.debug(
599
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
600
+ );
599
601
  await ensureDir(framesDir);
600
602
  let effectiveFps = fps;
601
- const duration = await getVideoDuration(inputPath).catch(() => 0);
602
603
  if (duration > 0) {
603
604
  const fpsCap = maxFrames / duration;
604
605
  effectiveFps = Math.min(fps, fpsCap);
605
606
  effectiveFps = Math.max(0.5, effectiveFps);
606
607
  logger.debug(
607
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
608
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
608
609
  );
609
610
  }
610
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
611
+ const frames = await extractByFps(
612
+ inputPath,
613
+ framesDir,
614
+ effectiveFps,
615
+ scale,
616
+ duration
617
+ );
611
618
  ctx.emitProgress(100);
612
619
  logger.debug(`Extracted ${frames.length} frames`);
613
620
  return frames;
614
621
  }
615
- async function extractByFps(inputPath, outputDir, fps, scale) {
622
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
616
623
  const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
617
624
  await execa(ffmpegPath, [
618
625
  "-i",
@@ -623,34 +630,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
623
630
  "2",
624
631
  outputPattern
625
632
  ]);
626
- return buildFrameList(outputDir, inputPath);
633
+ return buildFrameList(outputDir, duration);
627
634
  }
628
- async function getVideoDuration(inputPath) {
635
+ async function getVideoMetadata(inputPath) {
629
636
  const { stdout } = await execa(ffprobePath, [
630
637
  "-v",
631
638
  "quiet",
632
639
  "-print_format",
633
640
  "json",
634
641
  "-show_format",
642
+ "-show_streams",
635
643
  inputPath
636
644
  ]);
637
- const metadata = JSON.parse(stdout);
638
- return parseFloat(metadata.format?.duration ?? "0");
645
+ return JSON.parse(stdout);
639
646
  }
640
- async function buildFrameList(framesDir, inputPath) {
647
+ async function buildFrameList(framesDir, duration) {
641
648
  const files = await readdir(framesDir);
642
649
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
643
650
  if (jpgFiles.length === 0) {
644
651
  return [];
645
652
  }
646
- let duration = 0;
647
- try {
648
- duration = await getVideoDuration(inputPath);
649
- } catch {
650
- logger.debug(
651
- "Could not determine video duration; using frame index for timestamps"
652
- );
653
- }
654
653
  return jpgFiles.map((file, index) => ({
655
654
  id: index,
656
655
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -803,6 +802,43 @@ async function resolveInput(options, workspacePath) {
803
802
  throw new Error(`Unsupported input mode: ${options.mode}`);
804
803
  }
805
804
 
805
+ // src/utils/math.ts
806
+ function normalizeScores(items) {
807
+ if (items.length === 0) return [];
808
+ const safeScores = items.map(
809
+ (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
810
+ );
811
+ const positiveScores = safeScores.filter((s) => s > 0);
812
+ if (positiveScores.length === 0) return safeScores;
813
+ const sorted = [...positiveScores].sort((a, b) => a - b);
814
+ if (positiveScores.length <= NORMALIZATION_MIN_SAMPLE_SIZE) {
815
+ const min = sorted[0];
816
+ const max = sorted[sorted.length - 1];
817
+ if (max === min) return safeScores.map((s) => s > 0 ? 1 : 0);
818
+ return safeScores.map(
819
+ (s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1))
820
+ );
821
+ }
822
+ const median = sorted[Math.floor(sorted.length / 2)];
823
+ const absoluteDiffs = positiveScores.map((v) => Math.abs(v - median));
824
+ const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
825
+ const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
826
+ const logisticZ = safeScores.map((s) => {
827
+ if (s <= 0) return 0;
828
+ if (scale === 0) return 1;
829
+ const z = (s - median) / scale;
830
+ return 1 / (1 + Math.exp(-NORMALIZATION_LOGISTIC_K * z));
831
+ });
832
+ const cdf = safeScores.map((s) => {
833
+ if (s <= 0) return 0;
834
+ const rank = sorted.findIndex((v) => v >= s);
835
+ return rank / sorted.length;
836
+ });
837
+ return logisticZ.map(
838
+ (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
839
+ );
840
+ }
841
+
806
842
  // src/utils/min-heap.ts
807
843
  var MinHeap = class {
808
844
  h = [];
@@ -897,20 +933,6 @@ function pruneTo(graph, frames, targetCount) {
897
933
  }
898
934
  return surviving;
899
935
  }
900
- function normalizeScores(graph) {
901
- if (graph.length === 0) return [];
902
- const safeScores = graph.map(
903
- (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
904
- );
905
- const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
906
- if (sorted.length === 0) return safeScores;
907
- const pIdx = Math.min(
908
- Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
909
- sorted.length - 1
910
- );
911
- const refScore = sorted[pIdx];
912
- return safeScores.map((s) => Math.min(s / refScore, 1));
913
- }
914
936
  function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
915
937
  const result = /* @__PURE__ */ new Set();
916
938
  let runStart = 0;
@@ -48,17 +48,12 @@ var DEFAULT_FPS = 5;
48
48
  var DEFAULT_SCALE = 720;
49
49
  var DEFAULT_QUALITY = 80;
50
50
  var DEFAULT_MAX_FRAMES = 300;
51
- var NORMALIZATION_PERCENTILE = 0.9;
51
+ var NORMALIZATION_LOGISTIC_K = 3;
52
+ var NORMALIZATION_ALPHA = 0.4;
53
+ var NORMALIZATION_MAD_COEFFICIENT = 1.4826;
54
+ var NORMALIZATION_MIN_SAMPLE_SIZE = 10;
52
55
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
53
56
  var TEMP_BASE_DIR = tmpdir();
54
- var SUPPORTED_VIDEO_EXTENSIONS = [
55
- ".mp4",
56
- ".mov",
57
- ".avi",
58
- ".mkv",
59
- ".webm"
60
- ];
61
- var SUPPORTED_GIF_EXTENSIONS = [".gif"];
62
57
  var FRAME_OUTPUT_EXTENSION = ".jpg";
63
58
  var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
64
59
  var OPENCV_BATCH_SIZE = 10;
@@ -576,9 +571,6 @@ function deriveOutputPath(inputPath) {
576
571
  const name = basename(inputPath, extname(inputPath));
577
572
  return resolve(dir, `${name}_scenes`);
578
573
  }
579
- function isSupportedFile(filePath, extensions) {
580
- return extensions.includes(extname(filePath).toLowerCase());
581
- }
582
574
 
583
575
  // src/core/extractor.ts
584
576
  async function extractFrames(ctx) {
@@ -591,31 +583,46 @@ async function extractFrames(ctx) {
591
583
  if (!exists) {
592
584
  throw new Error(`Input file not found: ${inputPath}`);
593
585
  }
594
- const allExtensions = [
595
- ...SUPPORTED_VIDEO_EXTENSIONS,
596
- ...SUPPORTED_GIF_EXTENSIONS
597
- ];
598
- if (!isSupportedFile(inputPath, allExtensions)) {
599
- throw new Error(`Unsupported file format: ${inputPath}`);
586
+ const metadata = await getVideoMetadata(inputPath).catch((err) => {
587
+ logger.debug(`ffprobe failed: ${err.message}`);
588
+ return null;
589
+ });
590
+ if (!metadata || !metadata.format) {
591
+ throw new Error(`Could not read file metadata: ${inputPath}`);
600
592
  }
601
- logger.debug(`Extracting frames from: ${inputPath}`);
593
+ const formatName = metadata.format.format_name ?? "";
594
+ const duration = parseFloat(metadata.format.duration ?? "0");
595
+ const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
596
+ if (!hasVideoStream) {
597
+ throw new Error(
598
+ `No video stream found in file: ${inputPath} (detected format: ${formatName})`
599
+ );
600
+ }
601
+ logger.debug(
602
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
603
+ );
602
604
  await ensureDir(framesDir);
603
605
  let effectiveFps = fps;
604
- const duration = await getVideoDuration(inputPath).catch(() => 0);
605
606
  if (duration > 0) {
606
607
  const fpsCap = maxFrames / duration;
607
608
  effectiveFps = Math.min(fps, fpsCap);
608
609
  effectiveFps = Math.max(0.5, effectiveFps);
609
610
  logger.debug(
610
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
611
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
611
612
  );
612
613
  }
613
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
614
+ const frames = await extractByFps(
615
+ inputPath,
616
+ framesDir,
617
+ effectiveFps,
618
+ scale,
619
+ duration
620
+ );
614
621
  ctx.emitProgress(100);
615
622
  logger.debug(`Extracted ${frames.length} frames`);
616
623
  return frames;
617
624
  }
618
- async function extractByFps(inputPath, outputDir, fps, scale) {
625
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
619
626
  const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
620
627
  await execa(ffmpegPath, [
621
628
  "-i",
@@ -626,34 +633,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
626
633
  "2",
627
634
  outputPattern
628
635
  ]);
629
- return buildFrameList(outputDir, inputPath);
636
+ return buildFrameList(outputDir, duration);
630
637
  }
631
- async function getVideoDuration(inputPath) {
638
+ async function getVideoMetadata(inputPath) {
632
639
  const { stdout } = await execa(ffprobePath, [
633
640
  "-v",
634
641
  "quiet",
635
642
  "-print_format",
636
643
  "json",
637
644
  "-show_format",
645
+ "-show_streams",
638
646
  inputPath
639
647
  ]);
640
- const metadata = JSON.parse(stdout);
641
- return parseFloat(metadata.format?.duration ?? "0");
648
+ return JSON.parse(stdout);
642
649
  }
643
- async function buildFrameList(framesDir, inputPath) {
650
+ async function buildFrameList(framesDir, duration) {
644
651
  const files = await readdir(framesDir);
645
652
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
646
653
  if (jpgFiles.length === 0) {
647
654
  return [];
648
655
  }
649
- let duration = 0;
650
- try {
651
- duration = await getVideoDuration(inputPath);
652
- } catch {
653
- logger.debug(
654
- "Could not determine video duration; using frame index for timestamps"
655
- );
656
- }
657
656
  return jpgFiles.map((file, index) => ({
658
657
  id: index,
659
658
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -806,6 +805,43 @@ async function resolveInput(options2, workspacePath) {
806
805
  throw new Error(`Unsupported input mode: ${options2.mode}`);
807
806
  }
808
807
 
808
+ // src/utils/math.ts
809
+ function normalizeScores(items) {
810
+ if (items.length === 0) return [];
811
+ const safeScores = items.map(
812
+ (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
813
+ );
814
+ const positiveScores = safeScores.filter((s) => s > 0);
815
+ if (positiveScores.length === 0) return safeScores;
816
+ const sorted = [...positiveScores].sort((a, b) => a - b);
817
+ if (positiveScores.length <= NORMALIZATION_MIN_SAMPLE_SIZE) {
818
+ const min = sorted[0];
819
+ const max = sorted[sorted.length - 1];
820
+ if (max === min) return safeScores.map((s) => s > 0 ? 1 : 0);
821
+ return safeScores.map(
822
+ (s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1))
823
+ );
824
+ }
825
+ const median = sorted[Math.floor(sorted.length / 2)];
826
+ const absoluteDiffs = positiveScores.map((v) => Math.abs(v - median));
827
+ const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
828
+ const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
829
+ const logisticZ = safeScores.map((s) => {
830
+ if (s <= 0) return 0;
831
+ if (scale === 0) return 1;
832
+ const z = (s - median) / scale;
833
+ return 1 / (1 + Math.exp(-NORMALIZATION_LOGISTIC_K * z));
834
+ });
835
+ const cdf = safeScores.map((s) => {
836
+ if (s <= 0) return 0;
837
+ const rank = sorted.findIndex((v) => v >= s);
838
+ return rank / sorted.length;
839
+ });
840
+ return logisticZ.map(
841
+ (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
842
+ );
843
+ }
844
+
809
845
  // src/utils/min-heap.ts
810
846
  var MinHeap = class {
811
847
  h = [];
@@ -900,20 +936,6 @@ function pruneTo(graph, frames, targetCount) {
900
936
  }
901
937
  return surviving;
902
938
  }
903
- function normalizeScores(graph) {
904
- if (graph.length === 0) return [];
905
- const safeScores = graph.map(
906
- (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
907
- );
908
- const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
909
- if (sorted.length === 0) return safeScores;
910
- const pIdx = Math.min(
911
- Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
912
- sorted.length - 1
913
- );
914
- const refScore = sorted[pIdx];
915
- return safeScores.map((s) => Math.min(s / refScore, 1));
916
- }
917
939
  function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
918
940
  const result = /* @__PURE__ */ new Set();
919
941
  let runStart = 0;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Interface for objects that have a numeric score.
3
+ */
4
+ export interface ScoredItem {
5
+ score: number;
6
+ }
7
+ /**
8
+ * Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
9
+ *
10
+ * This model combines two mathematical approaches to provide a stable "relative" threshold:
11
+ *
12
+ * 1. Logistic-Robust-Z (Intensity):
13
+ * Calculates Z-scores using Median and Median Absolute Deviation (MAD).
14
+ * Maps these to a sigmoid (logistic) curve. This suppresses noise (scores near median)
15
+ * and highlights significant signals (outliers) without letting extreme outliers
16
+ * crush other meaningful transitions.
17
+ *
18
+ * 2. CDF / Percentile Rank (Relative Position):
19
+ * Maps each score to its percentile rank in the sequence. This ensures that 't'
20
+ * always has a consistent meaning as a "relative rank" regardless of absolute values.
21
+ *
22
+ * The final score is a weighted sum (NORMALIZATION_ALPHA) of both.
23
+ *
24
+ * @param items - Array of items with scores to normalize
25
+ * @returns normalized scores array (same length as input)
26
+ */
27
+ export declare function normalizeScores<T extends ScoredItem>(items: T[]): number[];
@@ -16,4 +16,3 @@ export declare function resolveAbsolute(p: string): string;
16
16
  * e.g., /path/to/video.mp4 -> /path/to/video_scenes
17
17
  */
18
18
  export declare function deriveOutputPath(inputPath: string): string;
19
- export declare function isSupportedFile(filePath: string, extensions: string[]): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumy-pack/scene-sieve",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "CLI tool for extracting key frames from video and GIF files",
5
5
  "keywords": [
6
6
  "cli",