@lumy-pack/scene-sieve 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -19
- package/dist/cli.mjs +164 -55
- package/dist/commands/Sieve.d.ts +2 -0
- package/dist/constants.d.ts +0 -2
- package/dist/core/analyzer.d.ts +9 -2
- package/dist/index.cjs +132 -49
- package/dist/index.mjs +132 -49
- package/dist/pipeline-worker.mjs +132 -49
- package/dist/types/index.d.ts +26 -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, 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,15 +65,8 @@ var init_constants = __esm({
|
|
|
65
65
|
NORMALIZATION_PERCENTILE = 0.9;
|
|
66
66
|
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
67
67
|
TEMP_BASE_DIR = tmpdir();
|
|
68
|
-
SUPPORTED_VIDEO_EXTENSIONS = [
|
|
69
|
-
".mp4",
|
|
70
|
-
".mov",
|
|
71
|
-
".avi",
|
|
72
|
-
".mkv",
|
|
73
|
-
".webm"
|
|
74
|
-
];
|
|
75
|
-
SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
76
68
|
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
69
|
+
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
77
70
|
OPENCV_BATCH_SIZE = 10;
|
|
78
71
|
DBSCAN_ALPHA = 0.03;
|
|
79
72
|
DBSCAN_MIN_PTS = 4;
|
|
@@ -431,13 +424,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
431
424
|
}
|
|
432
425
|
async function analyzeFrames(ctx) {
|
|
433
426
|
const { frames } = ctx;
|
|
434
|
-
if (frames.length < 2) return [];
|
|
427
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
435
428
|
logger.debug(
|
|
436
429
|
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
437
430
|
);
|
|
438
431
|
const cvLib = await ensureOpenCV();
|
|
439
432
|
const edges = [];
|
|
440
|
-
const tracker = new IoUTracker(
|
|
433
|
+
const tracker = new IoUTracker(
|
|
434
|
+
ctx.options.fps,
|
|
435
|
+
ctx.options.iouThreshold,
|
|
436
|
+
ctx.options.animationThreshold
|
|
437
|
+
);
|
|
441
438
|
const scale = ctx.options.scale;
|
|
442
439
|
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
443
440
|
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
@@ -450,8 +447,11 @@ async function analyzeFrames(ctx) {
|
|
|
450
447
|
);
|
|
451
448
|
ctx.emitProgress(progress);
|
|
452
449
|
}
|
|
453
|
-
|
|
454
|
-
|
|
450
|
+
const animations = tracker.flushAndGetAnimations();
|
|
451
|
+
logger.debug(
|
|
452
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
453
|
+
);
|
|
454
|
+
return { edges, animations };
|
|
455
455
|
}
|
|
456
456
|
var OPENCV_INIT_TIMEOUT_MS, require2, cvReady, IoUTracker;
|
|
457
457
|
var init_analyzer = __esm({
|
|
@@ -464,7 +464,13 @@ var init_analyzer = __esm({
|
|
|
464
464
|
require2 = createRequire(import.meta.url);
|
|
465
465
|
cvReady = null;
|
|
466
466
|
IoUTracker = class {
|
|
467
|
+
constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
|
|
468
|
+
this.fps = fps;
|
|
469
|
+
this.iouThreshold = iouThreshold;
|
|
470
|
+
this.animationThreshold = animationThreshold;
|
|
471
|
+
}
|
|
467
472
|
regions = [];
|
|
473
|
+
extractedAnimations = [];
|
|
468
474
|
update(boxes, pairIndex) {
|
|
469
475
|
const animationIndices = /* @__PURE__ */ new Set();
|
|
470
476
|
const matched = /* @__PURE__ */ new Set();
|
|
@@ -480,7 +486,7 @@ var init_analyzer = __esm({
|
|
|
480
486
|
bestRegionIdx = ri;
|
|
481
487
|
}
|
|
482
488
|
}
|
|
483
|
-
if (bestIoU >
|
|
489
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
484
490
|
const region = this.regions[bestRegionIdx];
|
|
485
491
|
const gap = pairIndex - region.lastSeen;
|
|
486
492
|
region.box = box;
|
|
@@ -488,13 +494,14 @@ var init_analyzer = __esm({
|
|
|
488
494
|
region.lastSeen = pairIndex;
|
|
489
495
|
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
490
496
|
matched.add(bestRegionIdx);
|
|
491
|
-
if (region.consecutiveCount >=
|
|
497
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
492
498
|
animationIndices.add(bi);
|
|
493
499
|
}
|
|
494
500
|
} else {
|
|
495
501
|
this.regions.push({
|
|
496
502
|
box,
|
|
497
503
|
consecutiveCount: 1,
|
|
504
|
+
firstSeen: pairIndex,
|
|
498
505
|
lastSeen: pairIndex,
|
|
499
506
|
weight: 1
|
|
500
507
|
});
|
|
@@ -506,17 +513,45 @@ var init_analyzer = __esm({
|
|
|
506
513
|
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
507
514
|
}
|
|
508
515
|
}
|
|
509
|
-
|
|
516
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
517
|
+
const region = this.regions[i];
|
|
518
|
+
if (region.weight <= 0.01 && !matched.has(i)) {
|
|
519
|
+
this.collectAnimation(region);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
this.regions = this.regions.filter(
|
|
523
|
+
(r, i) => r.weight > 0.01 || matched.has(i)
|
|
524
|
+
);
|
|
510
525
|
return animationIndices;
|
|
511
526
|
}
|
|
527
|
+
collectAnimation(region) {
|
|
528
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
529
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
530
|
+
this.extractedAnimations.push({
|
|
531
|
+
type: "loading_spinner",
|
|
532
|
+
// 기본값으로 loading_spinner 사용
|
|
533
|
+
boundingBox: region.box,
|
|
534
|
+
startFrameId: region.firstSeen,
|
|
535
|
+
endFrameId: region.lastSeen,
|
|
536
|
+
durationMs
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
flushAndGetAnimations() {
|
|
541
|
+
for (const region of this.regions) {
|
|
542
|
+
this.collectAnimation(region);
|
|
543
|
+
}
|
|
544
|
+
this.regions = [];
|
|
545
|
+
return this.extractedAnimations;
|
|
546
|
+
}
|
|
512
547
|
getAnimationWeight(boxIndex, boxes) {
|
|
513
548
|
if (boxIndex >= boxes.length) return 0;
|
|
514
549
|
const box = boxes[boxIndex];
|
|
515
550
|
let maxWeight = 0;
|
|
516
551
|
for (const region of this.regions) {
|
|
517
|
-
if (region.consecutiveCount >=
|
|
552
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
518
553
|
const iou = computeIoU(box, region.box);
|
|
519
|
-
if (iou >
|
|
554
|
+
if (iou > this.iouThreshold) {
|
|
520
555
|
maxWeight = Math.max(maxWeight, region.weight);
|
|
521
556
|
}
|
|
522
557
|
}
|
|
@@ -557,9 +592,6 @@ function deriveOutputPath(inputPath) {
|
|
|
557
592
|
const name = basename(inputPath, extname(inputPath));
|
|
558
593
|
return resolve(dir, `${name}_scenes`);
|
|
559
594
|
}
|
|
560
|
-
function isSupportedFile(filePath, extensions) {
|
|
561
|
-
return extensions.includes(extname(filePath).toLowerCase());
|
|
562
|
-
}
|
|
563
595
|
var init_paths = __esm({
|
|
564
596
|
"src/utils/paths.ts"() {
|
|
565
597
|
"use strict";
|
|
@@ -582,32 +614,47 @@ async function extractFrames(ctx) {
|
|
|
582
614
|
if (!exists) {
|
|
583
615
|
throw new Error(`Input file not found: ${inputPath}`);
|
|
584
616
|
}
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (!
|
|
590
|
-
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}`);
|
|
591
623
|
}
|
|
592
|
-
|
|
624
|
+
const formatName = metadata.format.format_name ?? "";
|
|
625
|
+
const duration = parseFloat(metadata.format.duration ?? "0");
|
|
626
|
+
const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
|
|
627
|
+
if (!hasVideoStream) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`No video stream found in file: ${inputPath} (detected format: ${formatName})`
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
logger.debug(
|
|
633
|
+
`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
|
|
634
|
+
);
|
|
593
635
|
await ensureDir(framesDir);
|
|
594
636
|
let effectiveFps = fps;
|
|
595
|
-
const duration = await getVideoDuration(inputPath).catch(() => 0);
|
|
596
637
|
if (duration > 0) {
|
|
597
638
|
const fpsCap = maxFrames / duration;
|
|
598
639
|
effectiveFps = Math.min(fps, fpsCap);
|
|
599
640
|
effectiveFps = Math.max(0.5, effectiveFps);
|
|
600
641
|
logger.debug(
|
|
601
|
-
`
|
|
642
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
602
643
|
);
|
|
603
644
|
}
|
|
604
|
-
const frames = await extractByFps(
|
|
645
|
+
const frames = await extractByFps(
|
|
646
|
+
inputPath,
|
|
647
|
+
framesDir,
|
|
648
|
+
effectiveFps,
|
|
649
|
+
scale,
|
|
650
|
+
duration
|
|
651
|
+
);
|
|
605
652
|
ctx.emitProgress(100);
|
|
606
653
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
607
654
|
return frames;
|
|
608
655
|
}
|
|
609
|
-
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
610
|
-
const outputPattern = join2(outputDir,
|
|
656
|
+
async function extractByFps(inputPath, outputDir, fps, scale, duration) {
|
|
657
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
611
658
|
await execa(ffmpegPath, [
|
|
612
659
|
"-i",
|
|
613
660
|
inputPath,
|
|
@@ -617,34 +664,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
|
617
664
|
"2",
|
|
618
665
|
outputPattern
|
|
619
666
|
]);
|
|
620
|
-
return buildFrameList(outputDir,
|
|
667
|
+
return buildFrameList(outputDir, duration);
|
|
621
668
|
}
|
|
622
|
-
async function
|
|
669
|
+
async function getVideoMetadata(inputPath) {
|
|
623
670
|
const { stdout } = await execa(ffprobePath, [
|
|
624
671
|
"-v",
|
|
625
672
|
"quiet",
|
|
626
673
|
"-print_format",
|
|
627
674
|
"json",
|
|
628
675
|
"-show_format",
|
|
676
|
+
"-show_streams",
|
|
629
677
|
inputPath
|
|
630
678
|
]);
|
|
631
|
-
|
|
632
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
679
|
+
return JSON.parse(stdout);
|
|
633
680
|
}
|
|
634
|
-
async function buildFrameList(framesDir,
|
|
681
|
+
async function buildFrameList(framesDir, duration) {
|
|
635
682
|
const files = await readdir(framesDir);
|
|
636
683
|
const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
|
|
637
684
|
if (jpgFiles.length === 0) {
|
|
638
685
|
return [];
|
|
639
686
|
}
|
|
640
|
-
let duration = 0;
|
|
641
|
-
try {
|
|
642
|
-
duration = await getVideoDuration(inputPath);
|
|
643
|
-
} catch {
|
|
644
|
-
logger.debug(
|
|
645
|
-
"Could not determine video duration; using frame index for timestamps"
|
|
646
|
-
);
|
|
647
|
-
}
|
|
648
687
|
return jpgFiles.map((file, index) => ({
|
|
649
688
|
id: index,
|
|
650
689
|
timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
|
|
@@ -675,13 +714,44 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
675
714
|
const outputPath = ctx.options.outputPath;
|
|
676
715
|
const quality = ctx.options.quality;
|
|
677
716
|
const outputFiles = [];
|
|
717
|
+
const framesMetadata = [];
|
|
718
|
+
const totalFramesCount = ctx.frames.length;
|
|
719
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
678
720
|
for (let i = 0; i < selectedFrames.length; i++) {
|
|
679
721
|
const frame = selectedFrames[i];
|
|
680
|
-
const
|
|
681
|
-
const destPath = join3(stagingDir,
|
|
722
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
723
|
+
const destPath = join3(stagingDir, fileName);
|
|
682
724
|
await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
683
|
-
outputFiles.push(join3(outputPath,
|
|
725
|
+
outputFiles.push(join3(outputPath, fileName));
|
|
726
|
+
framesMetadata.push({
|
|
727
|
+
step: i + 1,
|
|
728
|
+
fileName,
|
|
729
|
+
frameId: frame.id + 1,
|
|
730
|
+
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
731
|
+
});
|
|
684
732
|
}
|
|
733
|
+
const metadata = {
|
|
734
|
+
video: {
|
|
735
|
+
originalDurationMs: Math.round(
|
|
736
|
+
(ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
|
|
737
|
+
),
|
|
738
|
+
fps: ctx.options.fps,
|
|
739
|
+
resolution: {
|
|
740
|
+
width: ctx.options.scale,
|
|
741
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
frames: framesMetadata,
|
|
745
|
+
animations: (ctx.animations || []).map((anim) => ({
|
|
746
|
+
...anim,
|
|
747
|
+
startFrameId: anim.startFrameId + 1,
|
|
748
|
+
endFrameId: anim.endFrameId + 1,
|
|
749
|
+
durationMs: Math.round(anim.durationMs)
|
|
750
|
+
}))
|
|
751
|
+
};
|
|
752
|
+
const metadataPath = join3(stagingDir, ".metadata.json");
|
|
753
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
754
|
+
outputFiles.push(join3(outputPath, ".metadata.json"));
|
|
685
755
|
await ensureDir(join3(outputPath, ".."));
|
|
686
756
|
await rm(outputPath, { recursive: true, force: true });
|
|
687
757
|
await rename(stagingDir, outputPath);
|
|
@@ -769,6 +839,8 @@ function resolveOptions(options) {
|
|
|
769
839
|
maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
770
840
|
scale: options.scale ?? DEFAULT_SCALE,
|
|
771
841
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
842
|
+
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
843
|
+
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
772
844
|
debug: options.debug ?? false
|
|
773
845
|
};
|
|
774
846
|
}
|
|
@@ -1065,7 +1137,9 @@ async function runPipeline(options) {
|
|
|
1065
1137
|
}
|
|
1066
1138
|
ctx.emitProgress(100);
|
|
1067
1139
|
ctx.status = "ANALYZING";
|
|
1068
|
-
|
|
1140
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1141
|
+
ctx.graph = edges;
|
|
1142
|
+
ctx.animations = animations;
|
|
1069
1143
|
ctx.status = "PRUNING";
|
|
1070
1144
|
const survivingIds = pruneByThresholdWithCap(
|
|
1071
1145
|
ctx.graph,
|
|
@@ -1098,6 +1172,15 @@ async function runPipeline(options) {
|
|
|
1098
1172
|
prunedFramesCount: prunedFrames.length,
|
|
1099
1173
|
outputFiles,
|
|
1100
1174
|
outputBuffers,
|
|
1175
|
+
animations: ctx.animations,
|
|
1176
|
+
video: {
|
|
1177
|
+
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1178
|
+
fps: ctx.options.fps,
|
|
1179
|
+
resolution: {
|
|
1180
|
+
width: ctx.options.scale,
|
|
1181
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1101
1184
|
executionTimeMs: Date.now() - startTime
|
|
1102
1185
|
};
|
|
1103
1186
|
} catch (error) {
|
|
@@ -1280,6 +1363,8 @@ var SieveView = (props) => {
|
|
|
1280
1363
|
maxFrames: props.maxFrames,
|
|
1281
1364
|
scale: props.scale,
|
|
1282
1365
|
quality: props.quality,
|
|
1366
|
+
iouThreshold: props.iouThreshold,
|
|
1367
|
+
animationThreshold: props.animationThreshold,
|
|
1283
1368
|
debug: props.debug
|
|
1284
1369
|
},
|
|
1285
1370
|
(phase, percent) => {
|
|
@@ -1374,12 +1459,19 @@ var SieveView = (props) => {
|
|
|
1374
1459
|
"\u2713 Done",
|
|
1375
1460
|
" \u2014 ",
|
|
1376
1461
|
result.originalFramesCount,
|
|
1377
|
-
" frames \u2192
|
|
1462
|
+
" frames \u2192",
|
|
1463
|
+
" ",
|
|
1378
1464
|
result.prunedFramesCount,
|
|
1379
1465
|
" scenes (",
|
|
1380
1466
|
(result.executionTimeMs / 1e3).toFixed(1),
|
|
1381
1467
|
"s)"
|
|
1382
1468
|
] }),
|
|
1469
|
+
result.animations && result.animations.length > 0 && /* @__PURE__ */ jsxs3(Text3, { color: "blue", children: [
|
|
1470
|
+
"\u2139 Found",
|
|
1471
|
+
" ",
|
|
1472
|
+
result.animations.length,
|
|
1473
|
+
" animations (recorded in .metadata.json)"
|
|
1474
|
+
] }),
|
|
1383
1475
|
props.debug && result.outputFiles.length > 0 && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
|
|
1384
1476
|
/* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
1385
1477
|
"Output: ",
|
|
@@ -1395,17 +1487,32 @@ var SieveView = (props) => {
|
|
|
1395
1487
|
};
|
|
1396
1488
|
|
|
1397
1489
|
// src/cli.ts
|
|
1490
|
+
init_constants();
|
|
1398
1491
|
var require3 = createRequire2(import.meta.url);
|
|
1399
1492
|
var { version } = require3("../package.json");
|
|
1400
1493
|
var program = new Command();
|
|
1401
1494
|
program.name("scene-sieve").description("Extract key frames from video and GIF files").version(version).argument("<input>", "Input video or GIF file path").option("-n, --count <number>", "Max number of frames to keep (default: 20)").option(
|
|
1402
1495
|
"-t, --threshold <number>",
|
|
1403
1496
|
"Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
|
|
1404
|
-
).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Max FPS for frame extraction",
|
|
1405
|
-
"--max-frames <number>",
|
|
1497
|
+
).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Max FPS for frame extraction", String(DEFAULT_FPS)).option(
|
|
1498
|
+
"-mf, --max-frames <number>",
|
|
1406
1499
|
"Max frames to extract (auto-reduces FPS for long videos)",
|
|
1407
|
-
|
|
1408
|
-
).option(
|
|
1500
|
+
String(DEFAULT_MAX_FRAMES)
|
|
1501
|
+
).option(
|
|
1502
|
+
"-s, --scale <number>",
|
|
1503
|
+
"Scale size for vision analysis",
|
|
1504
|
+
String(DEFAULT_SCALE)
|
|
1505
|
+
).option(
|
|
1506
|
+
"-q, --quality <number>",
|
|
1507
|
+
"JPEG output quality 1-100",
|
|
1508
|
+
String(DEFAULT_QUALITY)
|
|
1509
|
+
).option(
|
|
1510
|
+
"-it, --iou-threshold <number>",
|
|
1511
|
+
`IoU threshold for animation tracking (0-1) (default: ${IOU_THRESHOLD})`
|
|
1512
|
+
).option(
|
|
1513
|
+
"-at, --anim-threshold <number>",
|
|
1514
|
+
`Min consecutive frames for animation (default: ${ANIMATION_FRAME_THRESHOLD})`
|
|
1515
|
+
).option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
|
|
1409
1516
|
const { waitUntilExit } = render(
|
|
1410
1517
|
React2.createElement(SieveView, {
|
|
1411
1518
|
input,
|
|
@@ -1416,6 +1523,8 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
|
|
|
1416
1523
|
maxFrames: parseInt(opts.maxFrames, 10),
|
|
1417
1524
|
scale: parseInt(opts.scale, 10),
|
|
1418
1525
|
quality: parseInt(opts.quality, 10),
|
|
1526
|
+
iouThreshold: opts.iouThreshold !== void 0 ? parseFloat(opts.iouThreshold) : void 0,
|
|
1527
|
+
animationThreshold: opts.animThreshold !== void 0 ? parseInt(opts.animThreshold, 10) : void 0,
|
|
1419
1528
|
debug: opts.debug ?? false
|
|
1420
1529
|
})
|
|
1421
1530
|
);
|
package/dist/commands/Sieve.d.ts
CHANGED
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/core/analyzer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BoundingBox, ProcessContext
|
|
1
|
+
import type { AnalysisResult, AnimationMetadata, BoundingBox, ProcessContext } from '../types/index.js';
|
|
2
2
|
import type { Point2D } from './dbscan.js';
|
|
3
3
|
type CvLib = typeof import('@techstark/opencv-js');
|
|
4
4
|
export declare function preprocessFrame(framePath: string, scale: number): Promise<{
|
|
@@ -8,8 +8,15 @@ export declare function preprocessFrame(framePath: string, scale: number): Promi
|
|
|
8
8
|
}>;
|
|
9
9
|
export declare function computeIoU(a: BoundingBox, b: BoundingBox): number;
|
|
10
10
|
export declare class IoUTracker {
|
|
11
|
+
private fps;
|
|
12
|
+
private iouThreshold;
|
|
13
|
+
private animationThreshold;
|
|
11
14
|
private regions;
|
|
15
|
+
private extractedAnimations;
|
|
16
|
+
constructor(fps?: number, iouThreshold?: number, animationThreshold?: number);
|
|
12
17
|
update(boxes: BoundingBox[], pairIndex: number): Set<number>;
|
|
18
|
+
private collectAnimation;
|
|
19
|
+
flushAndGetAnimations(): AnimationMetadata[];
|
|
13
20
|
getAnimationWeight(boxIndex: number, boxes: BoundingBox[]): number;
|
|
14
21
|
}
|
|
15
22
|
export interface AKAZEResult {
|
|
@@ -51,5 +58,5 @@ export declare function computeInformationGain(clusters: BoundingBox[], clusterP
|
|
|
51
58
|
* 3. Spatio-temporal IoU Tracking
|
|
52
59
|
* 4. G(t) Information Gain Scoring
|
|
53
60
|
*/
|
|
54
|
-
export declare function analyzeFrames(ctx: ProcessContext): Promise<
|
|
61
|
+
export declare function analyzeFrames(ctx: ProcessContext): Promise<AnalysisResult>;
|
|
55
62
|
export {};
|