@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 +64 -19
- package/dist/cli.mjs +32 -36
- package/dist/constants.d.ts +0 -2
- package/dist/index.cjs +31 -35
- package/dist/index.mjs +31 -35
- package/dist/pipeline-worker.mjs +31 -35
- package/dist/utils/paths.d.ts +0 -1
- package/package.json +1 -1
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
|
|
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
|
|
48
|
-
npx scene-sieve input.mp4 -
|
|
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:
|
|
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
|
|
76
|
-
|
|
|
77
|
-
| `<input>`
|
|
78
|
-
| `-n, --count <number>`
|
|
79
|
-
| `-t, --threshold <number>`
|
|
80
|
-
| `-o, --output <path>`
|
|
81
|
-
| `--fps <number>`
|
|
82
|
-
| `-
|
|
83
|
-
| `-
|
|
84
|
-
|
|
|
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/
|
|
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; //
|
|
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;
|
|
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,
|
|
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
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if (!
|
|
633
|
-
throw new Error(`
|
|
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
|
-
|
|
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
|
-
`
|
|
642
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
645
643
|
);
|
|
646
644
|
}
|
|
647
|
-
const frames = await extractByFps(
|
|
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,
|
|
667
|
+
return buildFrameList(outputDir, duration);
|
|
664
668
|
}
|
|
665
|
-
async function
|
|
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
|
-
|
|
675
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
679
|
+
return JSON.parse(stdout);
|
|
676
680
|
}
|
|
677
|
-
async function buildFrameList(framesDir,
|
|
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,
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (!
|
|
636
|
-
throw new Error(`
|
|
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(
|
|
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
|
-
`
|
|
645
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
648
646
|
);
|
|
649
647
|
}
|
|
650
|
-
const frames = await extractByFps(
|
|
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,
|
|
670
|
+
return buildFrameList(outputDir, duration);
|
|
667
671
|
}
|
|
668
|
-
async function
|
|
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
|
-
|
|
678
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
682
|
+
return JSON.parse(stdout);
|
|
679
683
|
}
|
|
680
|
-
async function buildFrameList(framesDir,
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
if (!
|
|
596
|
-
throw new Error(`
|
|
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(
|
|
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
|
-
`
|
|
605
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
608
606
|
);
|
|
609
607
|
}
|
|
610
|
-
const frames = await extractByFps(
|
|
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,
|
|
630
|
+
return buildFrameList(outputDir, duration);
|
|
627
631
|
}
|
|
628
|
-
async function
|
|
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
|
-
|
|
638
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
642
|
+
return JSON.parse(stdout);
|
|
639
643
|
}
|
|
640
|
-
async function buildFrameList(framesDir,
|
|
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,
|
package/dist/pipeline-worker.mjs
CHANGED
|
@@ -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
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
if (!
|
|
599
|
-
throw new Error(`
|
|
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
|
-
|
|
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
|
-
`
|
|
608
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
611
609
|
);
|
|
612
610
|
}
|
|
613
|
-
const frames = await extractByFps(
|
|
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,
|
|
633
|
+
return buildFrameList(outputDir, duration);
|
|
630
634
|
}
|
|
631
|
-
async function
|
|
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
|
-
|
|
641
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
645
|
+
return JSON.parse(stdout);
|
|
642
646
|
}
|
|
643
|
-
async function buildFrameList(framesDir,
|
|
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,
|
package/dist/utils/paths.d.ts
CHANGED
|
@@ -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;
|