@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 +64 -19
- package/dist/cli.mjs +80 -52
- package/dist/constants.d.ts +6 -3
- package/dist/index.cjs +72 -50
- package/dist/index.mjs +72 -50
- package/dist/pipeline-worker.mjs +72 -50
- package/dist/utils/math.d.ts +27 -0
- 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,
|
|
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
|
-
|
|
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
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if (!
|
|
633
|
-
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
|
+
);
|
|
634
634
|
}
|
|
635
|
-
logger.debug(
|
|
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
|
-
`
|
|
645
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
645
646
|
);
|
|
646
647
|
}
|
|
647
|
-
const frames = await extractByFps(
|
|
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,
|
|
670
|
+
return buildFrameList(outputDir, duration);
|
|
664
671
|
}
|
|
665
|
-
async function
|
|
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
|
-
|
|
675
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
682
|
+
return JSON.parse(stdout);
|
|
676
683
|
}
|
|
677
|
-
async function buildFrameList(framesDir,
|
|
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
|
-
|
|
1121
|
+
init_math();
|
|
1094
1122
|
init_min_heap();
|
|
1095
1123
|
}
|
|
1096
1124
|
});
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (!
|
|
636
|
-
throw new Error(`
|
|
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(
|
|
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
|
-
`
|
|
648
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
648
649
|
);
|
|
649
650
|
}
|
|
650
|
-
const frames = await extractByFps(
|
|
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,
|
|
673
|
+
return buildFrameList(outputDir, duration);
|
|
667
674
|
}
|
|
668
|
-
async function
|
|
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
|
-
|
|
678
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
685
|
+
return JSON.parse(stdout);
|
|
679
686
|
}
|
|
680
|
-
async function buildFrameList(framesDir,
|
|
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
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
if (!
|
|
596
|
-
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}`);
|
|
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(
|
|
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
|
-
`
|
|
608
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
608
609
|
);
|
|
609
610
|
}
|
|
610
|
-
const frames = await extractByFps(
|
|
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,
|
|
633
|
+
return buildFrameList(outputDir, duration);
|
|
627
634
|
}
|
|
628
|
-
async function
|
|
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
|
-
|
|
638
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
645
|
+
return JSON.parse(stdout);
|
|
639
646
|
}
|
|
640
|
-
async function buildFrameList(framesDir,
|
|
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;
|
package/dist/pipeline-worker.mjs
CHANGED
|
@@ -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
|
|
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
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
if (!
|
|
599
|
-
throw new Error(`
|
|
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
|
-
|
|
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
|
-
`
|
|
611
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
611
612
|
);
|
|
612
613
|
}
|
|
613
|
-
const frames = await extractByFps(
|
|
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,
|
|
636
|
+
return buildFrameList(outputDir, duration);
|
|
630
637
|
}
|
|
631
|
-
async function
|
|
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
|
-
|
|
641
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
648
|
+
return JSON.parse(stdout);
|
|
642
649
|
}
|
|
643
|
-
async function buildFrameList(framesDir,
|
|
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[];
|
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;
|