@lumy-pack/scene-sieve 0.0.1 → 0.0.2
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/dist/cli.mjs +212 -69
- package/dist/constants.d.ts +7 -1
- package/dist/core/analyzer.d.ts +26 -0
- package/dist/core/index.d.ts +2 -2
- package/dist/core/input-resolver.d.ts +1 -1
- package/dist/core/pruner.d.ts +27 -8
- package/dist/index.cjs +202 -66
- package/dist/index.mjs +202 -66
- package/dist/types/index.d.ts +2 -2
- package/package.json +2 -3
package/dist/cli.mjs
CHANGED
|
@@ -44,10 +44,12 @@ import sharp from "sharp";
|
|
|
44
44
|
import { tmpdir } from "os";
|
|
45
45
|
import { join } from "path";
|
|
46
46
|
var APP_NAME = "scene-sieve";
|
|
47
|
-
var DEFAULT_COUNT =
|
|
47
|
+
var DEFAULT_COUNT = 20;
|
|
48
|
+
var DEFAULT_THRESHOLD = 0.5;
|
|
48
49
|
var DEFAULT_FPS = 5;
|
|
49
50
|
var DEFAULT_SCALE = 720;
|
|
50
51
|
var DEFAULT_QUALITY = 80;
|
|
52
|
+
var NORMALIZATION_PERCENTILE = 0.9;
|
|
51
53
|
var WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
52
54
|
var TEMP_BASE_DIR = tmpdir();
|
|
53
55
|
var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
@@ -67,6 +69,10 @@ var IOU_THRESHOLD = 0.9;
|
|
|
67
69
|
var DECAY_LAMBDA = 0.95;
|
|
68
70
|
var ANIMATION_FRAME_THRESHOLD = 5;
|
|
69
71
|
var MATCH_DISTANCE_THRESHOLD = 0.75;
|
|
72
|
+
var PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
73
|
+
var PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
74
|
+
var PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
75
|
+
var PIXELDIFF_SAMPLE_SPACING = 8;
|
|
70
76
|
function getTempWorkspaceDir(sessionId) {
|
|
71
77
|
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
72
78
|
}
|
|
@@ -326,6 +332,60 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
|
326
332
|
if (matches) matches.delete();
|
|
327
333
|
}
|
|
328
334
|
}
|
|
335
|
+
function computePixelDiff(cvLib, frame1, frame2) {
|
|
336
|
+
const cv = cvLib;
|
|
337
|
+
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
338
|
+
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
339
|
+
const diff = new cv.Mat();
|
|
340
|
+
const blurred = new cv.Mat();
|
|
341
|
+
const binary = new cv.Mat();
|
|
342
|
+
const contours = new cv.MatVector();
|
|
343
|
+
const hierarchy = new cv.Mat();
|
|
344
|
+
try {
|
|
345
|
+
mat1.data.set(frame1.data);
|
|
346
|
+
mat2.data.set(frame2.data);
|
|
347
|
+
cv.absdiff(mat1, mat2, diff);
|
|
348
|
+
const ksize = new cv.Size(
|
|
349
|
+
PIXELDIFF_GAUSSIAN_KERNEL,
|
|
350
|
+
PIXELDIFF_GAUSSIAN_KERNEL
|
|
351
|
+
);
|
|
352
|
+
cv.GaussianBlur(diff, blurred, ksize, 0);
|
|
353
|
+
cv.threshold(
|
|
354
|
+
blurred,
|
|
355
|
+
binary,
|
|
356
|
+
PIXELDIFF_BINARY_THRESHOLD,
|
|
357
|
+
255,
|
|
358
|
+
cv.THRESH_BINARY
|
|
359
|
+
);
|
|
360
|
+
cv.findContours(
|
|
361
|
+
binary,
|
|
362
|
+
contours,
|
|
363
|
+
hierarchy,
|
|
364
|
+
cv.RETR_EXTERNAL,
|
|
365
|
+
cv.CHAIN_APPROX_SIMPLE
|
|
366
|
+
);
|
|
367
|
+
const points = [];
|
|
368
|
+
for (let c = 0; c < contours.size(); c++) {
|
|
369
|
+
const contour = contours.get(c);
|
|
370
|
+
const rect = cv.boundingRect(contour);
|
|
371
|
+
if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
|
|
372
|
+
for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
|
|
373
|
+
for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
|
|
374
|
+
points.push({ x, y });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return points;
|
|
379
|
+
} finally {
|
|
380
|
+
mat1.delete();
|
|
381
|
+
mat2.delete();
|
|
382
|
+
diff.delete();
|
|
383
|
+
blurred.delete();
|
|
384
|
+
binary.delete();
|
|
385
|
+
contours.delete();
|
|
386
|
+
hierarchy.delete();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
329
389
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
330
390
|
if (clusters.length === 0) return 0;
|
|
331
391
|
let gain = 0;
|
|
@@ -360,8 +420,28 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
360
420
|
preprocessed[i],
|
|
361
421
|
preprocessed[i + 1]
|
|
362
422
|
);
|
|
363
|
-
|
|
364
|
-
|
|
423
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
424
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
425
|
+
if (clusters.length === 0) {
|
|
426
|
+
const pixelDiffPoints = computePixelDiff(
|
|
427
|
+
cvLib,
|
|
428
|
+
preprocessed[i],
|
|
429
|
+
preprocessed[i + 1]
|
|
430
|
+
);
|
|
431
|
+
if (pixelDiffPoints.length > 0) {
|
|
432
|
+
logger.debug(
|
|
433
|
+
`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
|
|
434
|
+
);
|
|
435
|
+
dbscanResult = dbscan(
|
|
436
|
+
pixelDiffPoints,
|
|
437
|
+
imageWidth,
|
|
438
|
+
imageHeight,
|
|
439
|
+
void 0,
|
|
440
|
+
2
|
|
441
|
+
);
|
|
442
|
+
clusters = dbscanResult.boundingBoxes;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
365
445
|
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
366
446
|
for (const label of dbscanResult.labels) {
|
|
367
447
|
if (label >= 0) {
|
|
@@ -379,7 +459,9 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
379
459
|
animationIndices,
|
|
380
460
|
animationWeights
|
|
381
461
|
);
|
|
382
|
-
logger.debug(
|
|
462
|
+
logger.debug(
|
|
463
|
+
`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
|
|
464
|
+
);
|
|
383
465
|
edges.push({
|
|
384
466
|
sourceId: frames[i].id,
|
|
385
467
|
targetId: frames[i + 1].id,
|
|
@@ -424,9 +506,9 @@ async function analyzeFrames(ctx) {
|
|
|
424
506
|
// src/core/extractor.ts
|
|
425
507
|
import { readdir } from "fs/promises";
|
|
426
508
|
import { join as join2 } from "path";
|
|
427
|
-
import ffmpeg from "fluent-ffmpeg";
|
|
428
|
-
import ffmpegStatic from "ffmpeg-static";
|
|
429
509
|
import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
|
|
510
|
+
import { execa } from "execa";
|
|
511
|
+
import ffmpegPath from "ffmpeg-static";
|
|
430
512
|
|
|
431
513
|
// src/utils/paths.ts
|
|
432
514
|
import { mkdir, stat } from "fs/promises";
|
|
@@ -452,8 +534,6 @@ function isSupportedFile(filePath, extensions) {
|
|
|
452
534
|
}
|
|
453
535
|
|
|
454
536
|
// src/core/extractor.ts
|
|
455
|
-
if (ffmpegStatic) ffmpeg.setFfmpegPath(ffmpegStatic);
|
|
456
|
-
ffmpeg.setFfprobePath(ffprobePath);
|
|
457
537
|
async function extractFrames(ctx) {
|
|
458
538
|
const framesDir = join2(ctx.workspacePath, "frames");
|
|
459
539
|
const { inputPath, fps, scale } = ctx.options;
|
|
@@ -464,7 +544,10 @@ async function extractFrames(ctx) {
|
|
|
464
544
|
if (!exists) {
|
|
465
545
|
throw new Error(`Input file not found: ${inputPath}`);
|
|
466
546
|
}
|
|
467
|
-
const allExtensions = [
|
|
547
|
+
const allExtensions = [
|
|
548
|
+
...SUPPORTED_VIDEO_EXTENSIONS,
|
|
549
|
+
...SUPPORTED_GIF_EXTENSIONS
|
|
550
|
+
];
|
|
468
551
|
if (!isSupportedFile(inputPath, allExtensions)) {
|
|
469
552
|
throw new Error(`Unsupported file format: ${inputPath}`);
|
|
470
553
|
}
|
|
@@ -490,35 +573,43 @@ async function extractFrames(ctx) {
|
|
|
490
573
|
}
|
|
491
574
|
async function extractIFrames(inputPath, outputDir, scale) {
|
|
492
575
|
const outputPattern = join2(outputDir, "frame_%06d.jpg");
|
|
493
|
-
await
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
576
|
+
await execa(ffmpegPath, [
|
|
577
|
+
"-i",
|
|
578
|
+
inputPath,
|
|
579
|
+
"-vf",
|
|
580
|
+
`select='eq(pict_type,I)',scale=-1:${scale}`,
|
|
581
|
+
"-vsync",
|
|
582
|
+
"vfr",
|
|
583
|
+
"-q:v",
|
|
584
|
+
"2",
|
|
585
|
+
outputPattern
|
|
586
|
+
]);
|
|
500
587
|
return buildFrameList(outputDir, inputPath);
|
|
501
588
|
}
|
|
502
589
|
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
503
590
|
const outputPattern = join2(outputDir, "frame_%06d.jpg");
|
|
504
|
-
await
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
591
|
+
await execa(ffmpegPath, [
|
|
592
|
+
"-i",
|
|
593
|
+
inputPath,
|
|
594
|
+
"-vf",
|
|
595
|
+
`fps=${fps},scale=-1:${scale}`,
|
|
596
|
+
"-q:v",
|
|
597
|
+
"2",
|
|
598
|
+
outputPattern
|
|
599
|
+
]);
|
|
510
600
|
return buildFrameList(outputDir, inputPath);
|
|
511
601
|
}
|
|
512
602
|
async function getVideoDuration(inputPath) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
603
|
+
const { stdout } = await execa(ffprobePath, [
|
|
604
|
+
"-v",
|
|
605
|
+
"quiet",
|
|
606
|
+
"-print_format",
|
|
607
|
+
"json",
|
|
608
|
+
"-show_format",
|
|
609
|
+
inputPath
|
|
610
|
+
]);
|
|
611
|
+
const metadata = JSON.parse(stdout);
|
|
612
|
+
return parseFloat(metadata.format?.duration ?? "0");
|
|
522
613
|
}
|
|
523
614
|
async function buildFrameList(framesDir, inputPath) {
|
|
524
615
|
const files = await readdir(framesDir);
|
|
@@ -530,7 +621,9 @@ async function buildFrameList(framesDir, inputPath) {
|
|
|
530
621
|
try {
|
|
531
622
|
duration = await getVideoDuration(inputPath);
|
|
532
623
|
} catch {
|
|
533
|
-
logger.debug(
|
|
624
|
+
logger.debug(
|
|
625
|
+
"Could not determine video duration; using frame index for timestamps"
|
|
626
|
+
);
|
|
534
627
|
}
|
|
535
628
|
return jpgFiles.map((file, index) => ({
|
|
536
629
|
id: index,
|
|
@@ -607,15 +700,13 @@ function resolveOptions(options) {
|
|
|
607
700
|
const mode = options.mode;
|
|
608
701
|
const inputPath = mode === "file" ? options.inputPath : void 0;
|
|
609
702
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join4(process.cwd(), "scene-sieve-output"));
|
|
610
|
-
const threshold = options.threshold;
|
|
611
|
-
if (threshold
|
|
703
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
704
|
+
if (threshold <= 0 || threshold > 1) {
|
|
612
705
|
throw new Error(
|
|
613
706
|
`threshold must be in range (0, 1], received: ${threshold}`
|
|
614
707
|
);
|
|
615
708
|
}
|
|
616
|
-
const
|
|
617
|
-
const hasExplicitCount = options.count !== void 0;
|
|
618
|
-
const pruneMode = hasThreshold && hasExplicitCount ? "threshold-with-cap" : hasThreshold ? "threshold" : "count";
|
|
709
|
+
const pruneMode = "threshold-with-cap";
|
|
619
710
|
return {
|
|
620
711
|
mode,
|
|
621
712
|
inputPath,
|
|
@@ -634,7 +725,10 @@ async function resolveInput(options, workspacePath) {
|
|
|
634
725
|
return { frames: [], resolvedInputPath: options.inputPath };
|
|
635
726
|
}
|
|
636
727
|
if (options.mode === "buffer") {
|
|
637
|
-
const resolvedInputPath = await writeInputBuffer(
|
|
728
|
+
const resolvedInputPath = await writeInputBuffer(
|
|
729
|
+
options.inputBuffer,
|
|
730
|
+
workspacePath
|
|
731
|
+
);
|
|
638
732
|
return { frames: [], resolvedInputPath };
|
|
639
733
|
}
|
|
640
734
|
if (options.mode === "frames") {
|
|
@@ -703,7 +797,11 @@ function pruneTo(graph, frames, targetCount) {
|
|
|
703
797
|
const heap = new MinHeap();
|
|
704
798
|
for (const edge of graph) {
|
|
705
799
|
edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
|
|
706
|
-
heap.push({
|
|
800
|
+
heap.push({
|
|
801
|
+
score: edge.score,
|
|
802
|
+
srcId: edge.sourceId,
|
|
803
|
+
tgtId: edge.targetId
|
|
804
|
+
});
|
|
707
805
|
}
|
|
708
806
|
const surviving = new Set(frames.map((f) => f.id));
|
|
709
807
|
const firstId = frames[0].id;
|
|
@@ -739,9 +837,55 @@ function normalizeScores(graph) {
|
|
|
739
837
|
const safeScores = graph.map(
|
|
740
838
|
(e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
|
|
741
839
|
);
|
|
742
|
-
const
|
|
743
|
-
if (
|
|
744
|
-
|
|
840
|
+
const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
|
|
841
|
+
if (sorted.length === 0) return safeScores;
|
|
842
|
+
const pIdx = Math.min(
|
|
843
|
+
Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
|
|
844
|
+
sorted.length - 1
|
|
845
|
+
);
|
|
846
|
+
const refScore = sorted[pIdx];
|
|
847
|
+
return safeScores.map((s) => Math.min(s / refScore, 1));
|
|
848
|
+
}
|
|
849
|
+
function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
850
|
+
const result = /* @__PURE__ */ new Set();
|
|
851
|
+
let runStart = 0;
|
|
852
|
+
while (runStart < passingIndices.length) {
|
|
853
|
+
let runEnd = runStart;
|
|
854
|
+
while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
|
|
855
|
+
runEnd++;
|
|
856
|
+
}
|
|
857
|
+
const runLen = runEnd - runStart + 1;
|
|
858
|
+
if (runLen === 1) {
|
|
859
|
+
result.add(graph[passingIndices[runStart]].targetId);
|
|
860
|
+
} else {
|
|
861
|
+
const peaks = [];
|
|
862
|
+
for (let j = runStart; j <= runEnd; j++) {
|
|
863
|
+
const idx = passingIndices[j];
|
|
864
|
+
const score = normalizedScores[idx];
|
|
865
|
+
const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
|
|
866
|
+
const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
|
|
867
|
+
if (score > prevScore && score > nextScore) {
|
|
868
|
+
peaks.push(idx);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (peaks.length > 0) {
|
|
872
|
+
for (const peakIdx of peaks) {
|
|
873
|
+
result.add(graph[peakIdx].targetId);
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
let peakIdx = passingIndices[runStart];
|
|
877
|
+
for (let j = runStart + 1; j <= runEnd; j++) {
|
|
878
|
+
const idx = passingIndices[j];
|
|
879
|
+
if (normalizedScores[idx] > normalizedScores[peakIdx]) {
|
|
880
|
+
peakIdx = idx;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
result.add(graph[peakIdx].targetId);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
runStart = runEnd + 1;
|
|
887
|
+
}
|
|
888
|
+
return result;
|
|
745
889
|
}
|
|
746
890
|
function pruneByThreshold(graph, frames, threshold) {
|
|
747
891
|
if (frames.length === 0) return /* @__PURE__ */ new Set();
|
|
@@ -749,11 +893,16 @@ function pruneByThreshold(graph, frames, threshold) {
|
|
|
749
893
|
surviving.add(frames[0].id);
|
|
750
894
|
surviving.add(frames[frames.length - 1].id);
|
|
751
895
|
const normalized = normalizeScores(graph);
|
|
896
|
+
const passingIndices = [];
|
|
752
897
|
for (let i = 0; i < graph.length; i++) {
|
|
753
898
|
if (normalized[i] >= threshold) {
|
|
754
|
-
|
|
899
|
+
passingIndices.push(i);
|
|
755
900
|
}
|
|
756
901
|
}
|
|
902
|
+
const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
|
|
903
|
+
for (const id of nmsTargets) {
|
|
904
|
+
surviving.add(id);
|
|
905
|
+
}
|
|
757
906
|
return surviving;
|
|
758
907
|
}
|
|
759
908
|
function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
@@ -837,35 +986,22 @@ async function runPipeline(options) {
|
|
|
837
986
|
ctx.status = "ANALYZING";
|
|
838
987
|
ctx.graph = await analyzeFrames(ctx);
|
|
839
988
|
ctx.status = "PRUNING";
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
resolvedOptions.threshold,
|
|
847
|
-
resolvedOptions.count
|
|
848
|
-
);
|
|
849
|
-
break;
|
|
850
|
-
case "threshold":
|
|
851
|
-
survivingIds = pruneByThreshold(
|
|
852
|
-
ctx.graph,
|
|
853
|
-
ctx.frames,
|
|
854
|
-
resolvedOptions.threshold
|
|
855
|
-
);
|
|
856
|
-
break;
|
|
857
|
-
case "count":
|
|
858
|
-
default:
|
|
859
|
-
survivingIds = pruneTo(ctx.graph, ctx.frames, resolvedOptions.count);
|
|
860
|
-
break;
|
|
861
|
-
}
|
|
989
|
+
const survivingIds = pruneByThresholdWithCap(
|
|
990
|
+
ctx.graph,
|
|
991
|
+
ctx.frames,
|
|
992
|
+
resolvedOptions.threshold,
|
|
993
|
+
resolvedOptions.count
|
|
994
|
+
);
|
|
862
995
|
const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
|
|
863
996
|
ctx.emitProgress(100);
|
|
864
997
|
ctx.status = "FINALIZING";
|
|
865
998
|
let outputFiles = [];
|
|
866
999
|
let outputBuffers;
|
|
867
1000
|
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
868
|
-
outputBuffers = await readFramesAsBuffers(
|
|
1001
|
+
outputBuffers = await readFramesAsBuffers(
|
|
1002
|
+
prunedFrames,
|
|
1003
|
+
resolvedOptions.quality
|
|
1004
|
+
);
|
|
869
1005
|
ctx.emitProgress(100);
|
|
870
1006
|
} else {
|
|
871
1007
|
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
@@ -901,7 +1037,10 @@ async function runPipeline(options) {
|
|
|
901
1037
|
var require3 = createRequire2(import.meta.url);
|
|
902
1038
|
var { version } = require3("../package.json");
|
|
903
1039
|
var program = new Command();
|
|
904
|
-
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>", "
|
|
1040
|
+
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(
|
|
1041
|
+
"-t, --threshold <number>",
|
|
1042
|
+
"Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
|
|
1043
|
+
).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Fallback FPS for frame extraction", "5").option("-s, --scale <number>", "Scale size for vision analysis", "720").option("-q, --quality <number>", "JPEG output quality 1-100", "80").option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
|
|
905
1044
|
const { default: ora } = await import("ora");
|
|
906
1045
|
const { default: cliProgress } = await import("cli-progress");
|
|
907
1046
|
const spinner = ora("Initializing...").start();
|
|
@@ -933,8 +1072,12 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
|
|
|
933
1072
|
`
|
|
934
1073
|
Done! ${result.originalFramesCount} frames -> ${result.prunedFramesCount} scenes (${result.executionTimeMs}ms)`
|
|
935
1074
|
);
|
|
936
|
-
|
|
937
|
-
|
|
1075
|
+
if (opts.debug) {
|
|
1076
|
+
console.log(
|
|
1077
|
+
`Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`
|
|
1078
|
+
);
|
|
1079
|
+
result.outputFiles.forEach((f) => console.log(` - ${f}`));
|
|
1080
|
+
}
|
|
938
1081
|
} catch (error) {
|
|
939
1082
|
spinner.fail(
|
|
940
1083
|
`Failed: ${error instanceof Error ? error.message : String(error)}`
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export declare const APP_NAME = "scene-sieve";
|
|
2
|
-
export declare const DEFAULT_COUNT =
|
|
2
|
+
export declare const DEFAULT_COUNT = 20;
|
|
3
|
+
export declare const DEFAULT_THRESHOLD = 0.5;
|
|
3
4
|
export declare const DEFAULT_FPS = 5;
|
|
4
5
|
export declare const DEFAULT_SCALE = 720;
|
|
5
6
|
export declare const DEFAULT_QUALITY = 80;
|
|
7
|
+
export declare const NORMALIZATION_PERCENTILE = 0.9;
|
|
6
8
|
export declare const WORKSPACE_PREFIX = "scene-sieve-";
|
|
7
9
|
export declare const TEMP_BASE_DIR: string;
|
|
8
10
|
export declare const SUPPORTED_VIDEO_EXTENSIONS: string[];
|
|
@@ -17,4 +19,8 @@ export declare const IOU_THRESHOLD = 0.9;
|
|
|
17
19
|
export declare const DECAY_LAMBDA = 0.95;
|
|
18
20
|
export declare const ANIMATION_FRAME_THRESHOLD = 5;
|
|
19
21
|
export declare const MATCH_DISTANCE_THRESHOLD = 0.75;
|
|
22
|
+
export declare const PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
23
|
+
export declare const PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
24
|
+
export declare const PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
25
|
+
export declare const PIXELDIFF_SAMPLE_SPACING = 8;
|
|
20
26
|
export declare function getTempWorkspaceDir(sessionId: string): string;
|
package/dist/core/analyzer.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BoundingBox, ProcessContext, ScoreEdge } from '../types/index.js';
|
|
2
2
|
import type { Point2D } from './dbscan.js';
|
|
3
|
+
type CvLib = typeof import('@techstark/opencv-js');
|
|
3
4
|
export declare function preprocessFrame(framePath: string, scale: number): Promise<{
|
|
4
5
|
data: Uint8Array;
|
|
5
6
|
width: number;
|
|
@@ -15,6 +16,30 @@ export interface AKAZEResult {
|
|
|
15
16
|
sNew: Point2D[];
|
|
16
17
|
sLoss: Point2D[];
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Pixel-level difference fallback for AKAZE blind spots.
|
|
21
|
+
*
|
|
22
|
+
* When AKAZE produces sparse results (typical for UI screen recordings
|
|
23
|
+
* where form fields, dropdowns, or overlays change), this function
|
|
24
|
+
* detects changed regions via cv.absdiff and generates synthetic
|
|
25
|
+
* Point2D[] that feed into the existing DBSCAN → IoU → G(t) pipeline.
|
|
26
|
+
*
|
|
27
|
+
* Algorithm:
|
|
28
|
+
* 1. absdiff(frame1, frame2) → grayscale difference
|
|
29
|
+
* 2. GaussianBlur → reduce JPEG compression noise
|
|
30
|
+
* 3. threshold → binary mask of significant changes
|
|
31
|
+
* 4. findContours → bounding rects of changed regions
|
|
32
|
+
* 5. Grid sampling within each bounding rect → Point2D[]
|
|
33
|
+
*/
|
|
34
|
+
export declare function computePixelDiff(cvLib: CvLib, frame1: {
|
|
35
|
+
data: Uint8Array;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}, frame2: {
|
|
39
|
+
data: Uint8Array;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
}): Point2D[];
|
|
18
43
|
export declare function computeInformationGain(clusters: BoundingBox[], clusterPoints: number[], imageArea: number, animationIndices: Set<number>, animationWeights: number[]): number;
|
|
19
44
|
/**
|
|
20
45
|
* Analyze adjacent frame pairs to compute information gain scores (G(t)).
|
|
@@ -27,3 +52,4 @@ export declare function computeInformationGain(clusters: BoundingBox[], clusterP
|
|
|
27
52
|
* 4. G(t) Information Gain Scoring
|
|
28
53
|
*/
|
|
29
54
|
export declare function analyzeFrames(ctx: ProcessContext): Promise<ScoreEdge[]>;
|
|
55
|
+
export {};
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { runPipeline } from './orchestrator.js';
|
|
2
|
-
export { analyzeFrames, computeIoU, computeInformationGain } from './analyzer.js';
|
|
2
|
+
export { analyzeFrames, computeIoU, computeInformationGain, } from './analyzer.js';
|
|
3
3
|
export { extractFrames } from './extractor.js';
|
|
4
|
-
export { pruneTo, pruneByThreshold, pruneByThresholdWithCap } from './pruner.js';
|
|
4
|
+
export { pruneTo, pruneByThreshold, pruneByThresholdWithCap, suppressConsecutiveRuns, } from './pruner.js';
|
|
5
5
|
export { dbscan } from './dbscan.js';
|
|
6
6
|
export type { Point2D } from './dbscan.js';
|
|
7
7
|
export { resolveInput, resolveOptions } from './input-resolver.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FrameNode,
|
|
1
|
+
import type { FrameNode, ResolvedOptions, SieveOptions } from '../types/index.js';
|
|
2
2
|
export declare function resolveOptions(options: SieveOptions): ResolvedOptions;
|
|
3
3
|
/**
|
|
4
4
|
* Resolve the input source to a list of FrameNode[].
|
package/dist/core/pruner.d.ts
CHANGED
|
@@ -14,17 +14,36 @@ import type { FrameNode, ScoreEdge } from '../types/index.js';
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function pruneTo(graph: ScoreEdge[], frames: FrameNode[], targetCount: number): Set<number>;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Non-Maximum Suppression (NMS) for consecutive edge runs.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Consecutive edges share overlapping frames (edge i: frame i->i+1,
|
|
20
|
+
* edge i+1: frame i+1->i+2), so consecutive passing edges indicate
|
|
21
|
+
* the same visual transition region. This function groups consecutive
|
|
22
|
+
* passing edge indices into "runs" and keeps all distinct peaks per run.
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
+
* Multi-peak detection: within each run, strict local maxima (score higher
|
|
25
|
+
* than both neighbors) are identified. Each local maximum represents a
|
|
26
|
+
* distinct visual transition. If no strict local maxima exist (plateau or
|
|
27
|
+
* monotonic sequence), the global peak of the run is selected as fallback.
|
|
28
|
+
*
|
|
29
|
+
* Single-element runs are unaffected (isolated transitions preserved).
|
|
24
30
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
31
|
+
* @param graph - full ScoreEdge array (for targetId lookup)
|
|
32
|
+
* @param passingIndices - edge indices that passed threshold filtering (sorted ascending)
|
|
33
|
+
* @param normalizedScores - normalized score array (same length as graph)
|
|
34
|
+
* @returns Set of targetIds to add to surviving set (one or more per run)
|
|
35
|
+
*/
|
|
36
|
+
export declare function suppressConsecutiveRuns(graph: ScoreEdge[], passingIndices: number[], normalizedScores: number[]): Set<number>;
|
|
37
|
+
/**
|
|
38
|
+
* Threshold-based pruning with NMS -- O(N).
|
|
39
|
+
*
|
|
40
|
+
* 1. Scores are normalized to [0, 1] via percentile normalization.
|
|
41
|
+
* 2. Edges with normalized score >= threshold are collected.
|
|
42
|
+
* 3. Non-Maximum Suppression groups consecutive passing edges and keeps
|
|
43
|
+
* only the peak per run, preventing near-duplicate frame selection
|
|
44
|
+
* from a single visual transition.
|
|
45
|
+
*
|
|
46
|
+
* First and last frames are always preserved (boundary protection).
|
|
28
47
|
*/
|
|
29
48
|
export declare function pruneByThreshold(graph: ScoreEdge[], frames: FrameNode[], threshold: number): Set<number>;
|
|
30
49
|
/**
|