@lumy-pack/scene-sieve 0.0.6 → 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, 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_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,14 +65,6 @@ 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";
77
69
  FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
78
70
  OPENCV_BATCH_SIZE = 10;
@@ -600,9 +592,6 @@ function deriveOutputPath(inputPath) {
600
592
  const name = basename(inputPath, extname(inputPath));
601
593
  return resolve(dir, `${name}_scenes`);
602
594
  }
603
- function isSupportedFile(filePath, extensions) {
604
- return extensions.includes(extname(filePath).toLowerCase());
605
- }
606
595
  var init_paths = __esm({
607
596
  "src/utils/paths.ts"() {
608
597
  "use strict";
@@ -625,31 +614,46 @@ async function extractFrames(ctx) {
625
614
  if (!exists) {
626
615
  throw new Error(`Input file not found: ${inputPath}`);
627
616
  }
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}`);
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}`);
634
623
  }
635
- 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
+ );
636
635
  await ensureDir(framesDir);
637
636
  let effectiveFps = fps;
638
- const duration = await getVideoDuration(inputPath).catch(() => 0);
639
637
  if (duration > 0) {
640
638
  const fpsCap = maxFrames / duration;
641
639
  effectiveFps = Math.min(fps, fpsCap);
642
640
  effectiveFps = Math.max(0.5, effectiveFps);
643
641
  logger.debug(
644
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
642
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
645
643
  );
646
644
  }
647
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
645
+ const frames = await extractByFps(
646
+ inputPath,
647
+ framesDir,
648
+ effectiveFps,
649
+ scale,
650
+ duration
651
+ );
648
652
  ctx.emitProgress(100);
649
653
  logger.debug(`Extracted ${frames.length} frames`);
650
654
  return frames;
651
655
  }
652
- async function extractByFps(inputPath, outputDir, fps, scale) {
656
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
653
657
  const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
654
658
  await execa(ffmpegPath, [
655
659
  "-i",
@@ -660,34 +664,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
660
664
  "2",
661
665
  outputPattern
662
666
  ]);
663
- return buildFrameList(outputDir, inputPath);
667
+ return buildFrameList(outputDir, duration);
664
668
  }
665
- async function getVideoDuration(inputPath) {
669
+ async function getVideoMetadata(inputPath) {
666
670
  const { stdout } = await execa(ffprobePath, [
667
671
  "-v",
668
672
  "quiet",
669
673
  "-print_format",
670
674
  "json",
671
675
  "-show_format",
676
+ "-show_streams",
672
677
  inputPath
673
678
  ]);
674
- const metadata = JSON.parse(stdout);
675
- return parseFloat(metadata.format?.duration ?? "0");
679
+ return JSON.parse(stdout);
676
680
  }
677
- async function buildFrameList(framesDir, inputPath) {
681
+ async function buildFrameList(framesDir, duration) {
678
682
  const files = await readdir(framesDir);
679
683
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
680
684
  if (jpgFiles.length === 0) {
681
685
  return [];
682
686
  }
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
687
  return jpgFiles.map((file, index) => ({
692
688
  id: index,
693
689
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -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;
package/dist/index.cjs CHANGED
@@ -88,14 +88,6 @@ var DEFAULT_MAX_FRAMES = 300;
88
88
  var NORMALIZATION_PERCENTILE = 0.9;
89
89
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
90
90
  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
91
  var FRAME_OUTPUT_EXTENSION = ".jpg";
100
92
  var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
101
93
  var OPENCV_BATCH_SIZE = 10;
@@ -613,9 +605,6 @@ function deriveOutputPath(inputPath) {
613
605
  const name = (0, import_node_path2.basename)(inputPath, (0, import_node_path2.extname)(inputPath));
614
606
  return (0, import_node_path2.resolve)(dir, `${name}_scenes`);
615
607
  }
616
- function isSupportedFile(filePath, extensions) {
617
- return extensions.includes((0, import_node_path2.extname)(filePath).toLowerCase());
618
- }
619
608
 
620
609
  // src/core/extractor.ts
621
610
  async function extractFrames(ctx) {
@@ -628,31 +617,46 @@ async function extractFrames(ctx) {
628
617
  if (!exists) {
629
618
  throw new Error(`Input file not found: ${inputPath}`);
630
619
  }
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}`);
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
+ );
637
634
  }
638
- logger.debug(`Extracting frames from: ${inputPath}`);
635
+ logger.debug(
636
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
637
+ );
639
638
  await ensureDir(framesDir);
640
639
  let effectiveFps = fps;
641
- const duration = await getVideoDuration(inputPath).catch(() => 0);
642
640
  if (duration > 0) {
643
641
  const fpsCap = maxFrames / duration;
644
642
  effectiveFps = Math.min(fps, fpsCap);
645
643
  effectiveFps = Math.max(0.5, effectiveFps);
646
644
  logger.debug(
647
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
645
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
648
646
  );
649
647
  }
650
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
648
+ const frames = await extractByFps(
649
+ inputPath,
650
+ framesDir,
651
+ effectiveFps,
652
+ scale,
653
+ duration
654
+ );
651
655
  ctx.emitProgress(100);
652
656
  logger.debug(`Extracted ${frames.length} frames`);
653
657
  return frames;
654
658
  }
655
- async function extractByFps(inputPath, outputDir, fps, scale) {
659
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
656
660
  const outputPattern = (0, import_node_path3.join)(outputDir, FRAME_FILENAME_PATTERN);
657
661
  await (0, import_execa.execa)(import_ffmpeg_static.default, [
658
662
  "-i",
@@ -663,34 +667,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
663
667
  "2",
664
668
  outputPattern
665
669
  ]);
666
- return buildFrameList(outputDir, inputPath);
670
+ return buildFrameList(outputDir, duration);
667
671
  }
668
- async function getVideoDuration(inputPath) {
672
+ async function getVideoMetadata(inputPath) {
669
673
  const { stdout } = await (0, import_execa.execa)(import_ffprobe.path, [
670
674
  "-v",
671
675
  "quiet",
672
676
  "-print_format",
673
677
  "json",
674
678
  "-show_format",
679
+ "-show_streams",
675
680
  inputPath
676
681
  ]);
677
- const metadata = JSON.parse(stdout);
678
- return parseFloat(metadata.format?.duration ?? "0");
682
+ return JSON.parse(stdout);
679
683
  }
680
- async function buildFrameList(framesDir, inputPath) {
684
+ async function buildFrameList(framesDir, duration) {
681
685
  const files = await (0, import_promises2.readdir)(framesDir);
682
686
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
683
687
  if (jpgFiles.length === 0) {
684
688
  return [];
685
689
  }
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
690
  return jpgFiles.map((file, index) => ({
695
691
  id: index,
696
692
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
package/dist/index.mjs CHANGED
@@ -48,14 +48,6 @@ var DEFAULT_MAX_FRAMES = 300;
48
48
  var NORMALIZATION_PERCENTILE = 0.9;
49
49
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
50
50
  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
51
  var FRAME_OUTPUT_EXTENSION = ".jpg";
60
52
  var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
61
53
  var OPENCV_BATCH_SIZE = 10;
@@ -573,9 +565,6 @@ function deriveOutputPath(inputPath) {
573
565
  const name = basename(inputPath, extname(inputPath));
574
566
  return resolve(dir, `${name}_scenes`);
575
567
  }
576
- function isSupportedFile(filePath, extensions) {
577
- return extensions.includes(extname(filePath).toLowerCase());
578
- }
579
568
 
580
569
  // src/core/extractor.ts
581
570
  async function extractFrames(ctx) {
@@ -588,31 +577,46 @@ async function extractFrames(ctx) {
588
577
  if (!exists) {
589
578
  throw new Error(`Input file not found: ${inputPath}`);
590
579
  }
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}`);
580
+ const metadata = await getVideoMetadata(inputPath).catch((err) => {
581
+ logger.debug(`ffprobe failed: ${err.message}`);
582
+ return null;
583
+ });
584
+ if (!metadata || !metadata.format) {
585
+ throw new Error(`Could not read file metadata: ${inputPath}`);
586
+ }
587
+ const formatName = metadata.format.format_name ?? "";
588
+ const duration = parseFloat(metadata.format.duration ?? "0");
589
+ const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
590
+ if (!hasVideoStream) {
591
+ throw new Error(
592
+ `No video stream found in file: ${inputPath} (detected format: ${formatName})`
593
+ );
597
594
  }
598
- logger.debug(`Extracting frames from: ${inputPath}`);
595
+ logger.debug(
596
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
597
+ );
599
598
  await ensureDir(framesDir);
600
599
  let effectiveFps = fps;
601
- const duration = await getVideoDuration(inputPath).catch(() => 0);
602
600
  if (duration > 0) {
603
601
  const fpsCap = maxFrames / duration;
604
602
  effectiveFps = Math.min(fps, fpsCap);
605
603
  effectiveFps = Math.max(0.5, effectiveFps);
606
604
  logger.debug(
607
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
605
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
608
606
  );
609
607
  }
610
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
608
+ const frames = await extractByFps(
609
+ inputPath,
610
+ framesDir,
611
+ effectiveFps,
612
+ scale,
613
+ duration
614
+ );
611
615
  ctx.emitProgress(100);
612
616
  logger.debug(`Extracted ${frames.length} frames`);
613
617
  return frames;
614
618
  }
615
- async function extractByFps(inputPath, outputDir, fps, scale) {
619
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
616
620
  const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
617
621
  await execa(ffmpegPath, [
618
622
  "-i",
@@ -623,34 +627,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
623
627
  "2",
624
628
  outputPattern
625
629
  ]);
626
- return buildFrameList(outputDir, inputPath);
630
+ return buildFrameList(outputDir, duration);
627
631
  }
628
- async function getVideoDuration(inputPath) {
632
+ async function getVideoMetadata(inputPath) {
629
633
  const { stdout } = await execa(ffprobePath, [
630
634
  "-v",
631
635
  "quiet",
632
636
  "-print_format",
633
637
  "json",
634
638
  "-show_format",
639
+ "-show_streams",
635
640
  inputPath
636
641
  ]);
637
- const metadata = JSON.parse(stdout);
638
- return parseFloat(metadata.format?.duration ?? "0");
642
+ return JSON.parse(stdout);
639
643
  }
640
- async function buildFrameList(framesDir, inputPath) {
644
+ async function buildFrameList(framesDir, duration) {
641
645
  const files = await readdir(framesDir);
642
646
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
643
647
  if (jpgFiles.length === 0) {
644
648
  return [];
645
649
  }
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
650
  return jpgFiles.map((file, index) => ({
655
651
  id: index,
656
652
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -51,14 +51,6 @@ var DEFAULT_MAX_FRAMES = 300;
51
51
  var NORMALIZATION_PERCENTILE = 0.9;
52
52
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
53
53
  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
54
  var FRAME_OUTPUT_EXTENSION = ".jpg";
63
55
  var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
64
56
  var OPENCV_BATCH_SIZE = 10;
@@ -576,9 +568,6 @@ function deriveOutputPath(inputPath) {
576
568
  const name = basename(inputPath, extname(inputPath));
577
569
  return resolve(dir, `${name}_scenes`);
578
570
  }
579
- function isSupportedFile(filePath, extensions) {
580
- return extensions.includes(extname(filePath).toLowerCase());
581
- }
582
571
 
583
572
  // src/core/extractor.ts
584
573
  async function extractFrames(ctx) {
@@ -591,31 +580,46 @@ async function extractFrames(ctx) {
591
580
  if (!exists) {
592
581
  throw new Error(`Input file not found: ${inputPath}`);
593
582
  }
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}`);
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}`);
600
589
  }
601
- logger.debug(`Extracting frames from: ${inputPath}`);
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
+ }
598
+ logger.debug(
599
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
600
+ );
602
601
  await ensureDir(framesDir);
603
602
  let effectiveFps = fps;
604
- const duration = await getVideoDuration(inputPath).catch(() => 0);
605
603
  if (duration > 0) {
606
604
  const fpsCap = maxFrames / duration;
607
605
  effectiveFps = Math.min(fps, fpsCap);
608
606
  effectiveFps = Math.max(0.5, effectiveFps);
609
607
  logger.debug(
610
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
608
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
611
609
  );
612
610
  }
613
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
611
+ const frames = await extractByFps(
612
+ inputPath,
613
+ framesDir,
614
+ effectiveFps,
615
+ scale,
616
+ duration
617
+ );
614
618
  ctx.emitProgress(100);
615
619
  logger.debug(`Extracted ${frames.length} frames`);
616
620
  return frames;
617
621
  }
618
- async function extractByFps(inputPath, outputDir, fps, scale) {
622
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
619
623
  const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
620
624
  await execa(ffmpegPath, [
621
625
  "-i",
@@ -626,34 +630,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
626
630
  "2",
627
631
  outputPattern
628
632
  ]);
629
- return buildFrameList(outputDir, inputPath);
633
+ return buildFrameList(outputDir, duration);
630
634
  }
631
- async function getVideoDuration(inputPath) {
635
+ async function getVideoMetadata(inputPath) {
632
636
  const { stdout } = await execa(ffprobePath, [
633
637
  "-v",
634
638
  "quiet",
635
639
  "-print_format",
636
640
  "json",
637
641
  "-show_format",
642
+ "-show_streams",
638
643
  inputPath
639
644
  ]);
640
- const metadata = JSON.parse(stdout);
641
- return parseFloat(metadata.format?.duration ?? "0");
645
+ return JSON.parse(stdout);
642
646
  }
643
- async function buildFrameList(framesDir, inputPath) {
647
+ async function buildFrameList(framesDir, duration) {
644
648
  const files = await readdir(framesDir);
645
649
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
646
650
  if (jpgFiles.length === 0) {
647
651
  return [];
648
652
  }
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
653
  return jpgFiles.map((file, index) => ({
658
654
  id: index,
659
655
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -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.7",
4
4
  "description": "CLI tool for extracting key frames from video and GIF files",
5
5
  "keywords": [
6
6
  "cli",