@lumy-pack/scene-sieve 0.0.1 → 0.0.3
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 +230 -72
- 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 +220 -69
- package/dist/index.mjs +220 -69
- package/dist/types/index.d.ts +2 -2
- package/dist/utils/paths.d.ts +10 -0
- package/package.json +2 -3
package/dist/cli.mjs
CHANGED
|
@@ -21,7 +21,8 @@ var logger = {
|
|
|
21
21
|
console.log(`${pc.blue("info")} ${message}`);
|
|
22
22
|
},
|
|
23
23
|
success(message) {
|
|
24
|
-
console.log(
|
|
24
|
+
console.log(`
|
|
25
|
+
${pc.green("done")} ${message}`);
|
|
25
26
|
},
|
|
26
27
|
warn(message) {
|
|
27
28
|
console.warn(`${pc.yellow("warn")} ${message}`);
|
|
@@ -44,10 +45,12 @@ import sharp from "sharp";
|
|
|
44
45
|
import { tmpdir } from "os";
|
|
45
46
|
import { join } from "path";
|
|
46
47
|
var APP_NAME = "scene-sieve";
|
|
47
|
-
var DEFAULT_COUNT =
|
|
48
|
+
var DEFAULT_COUNT = 20;
|
|
49
|
+
var DEFAULT_THRESHOLD = 0.5;
|
|
48
50
|
var DEFAULT_FPS = 5;
|
|
49
51
|
var DEFAULT_SCALE = 720;
|
|
50
52
|
var DEFAULT_QUALITY = 80;
|
|
53
|
+
var NORMALIZATION_PERCENTILE = 0.9;
|
|
51
54
|
var WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
52
55
|
var TEMP_BASE_DIR = tmpdir();
|
|
53
56
|
var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
@@ -67,6 +70,10 @@ var IOU_THRESHOLD = 0.9;
|
|
|
67
70
|
var DECAY_LAMBDA = 0.95;
|
|
68
71
|
var ANIMATION_FRAME_THRESHOLD = 5;
|
|
69
72
|
var MATCH_DISTANCE_THRESHOLD = 0.75;
|
|
73
|
+
var PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
74
|
+
var PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
75
|
+
var PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
76
|
+
var PIXELDIFF_SAMPLE_SPACING = 8;
|
|
70
77
|
function getTempWorkspaceDir(sessionId) {
|
|
71
78
|
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
72
79
|
}
|
|
@@ -326,6 +333,60 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
|
326
333
|
if (matches) matches.delete();
|
|
327
334
|
}
|
|
328
335
|
}
|
|
336
|
+
function computePixelDiff(cvLib, frame1, frame2) {
|
|
337
|
+
const cv = cvLib;
|
|
338
|
+
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
339
|
+
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
340
|
+
const diff = new cv.Mat();
|
|
341
|
+
const blurred = new cv.Mat();
|
|
342
|
+
const binary = new cv.Mat();
|
|
343
|
+
const contours = new cv.MatVector();
|
|
344
|
+
const hierarchy = new cv.Mat();
|
|
345
|
+
try {
|
|
346
|
+
mat1.data.set(frame1.data);
|
|
347
|
+
mat2.data.set(frame2.data);
|
|
348
|
+
cv.absdiff(mat1, mat2, diff);
|
|
349
|
+
const ksize = new cv.Size(
|
|
350
|
+
PIXELDIFF_GAUSSIAN_KERNEL,
|
|
351
|
+
PIXELDIFF_GAUSSIAN_KERNEL
|
|
352
|
+
);
|
|
353
|
+
cv.GaussianBlur(diff, blurred, ksize, 0);
|
|
354
|
+
cv.threshold(
|
|
355
|
+
blurred,
|
|
356
|
+
binary,
|
|
357
|
+
PIXELDIFF_BINARY_THRESHOLD,
|
|
358
|
+
255,
|
|
359
|
+
cv.THRESH_BINARY
|
|
360
|
+
);
|
|
361
|
+
cv.findContours(
|
|
362
|
+
binary,
|
|
363
|
+
contours,
|
|
364
|
+
hierarchy,
|
|
365
|
+
cv.RETR_EXTERNAL,
|
|
366
|
+
cv.CHAIN_APPROX_SIMPLE
|
|
367
|
+
);
|
|
368
|
+
const points = [];
|
|
369
|
+
for (let c = 0; c < contours.size(); c++) {
|
|
370
|
+
const contour = contours.get(c);
|
|
371
|
+
const rect = cv.boundingRect(contour);
|
|
372
|
+
if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
|
|
373
|
+
for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
|
|
374
|
+
for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
|
|
375
|
+
points.push({ x, y });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return points;
|
|
380
|
+
} finally {
|
|
381
|
+
mat1.delete();
|
|
382
|
+
mat2.delete();
|
|
383
|
+
diff.delete();
|
|
384
|
+
blurred.delete();
|
|
385
|
+
binary.delete();
|
|
386
|
+
contours.delete();
|
|
387
|
+
hierarchy.delete();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
329
390
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
330
391
|
if (clusters.length === 0) return 0;
|
|
331
392
|
let gain = 0;
|
|
@@ -360,8 +421,28 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
360
421
|
preprocessed[i],
|
|
361
422
|
preprocessed[i + 1]
|
|
362
423
|
);
|
|
363
|
-
|
|
364
|
-
|
|
424
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
425
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
426
|
+
if (clusters.length === 0) {
|
|
427
|
+
const pixelDiffPoints = computePixelDiff(
|
|
428
|
+
cvLib,
|
|
429
|
+
preprocessed[i],
|
|
430
|
+
preprocessed[i + 1]
|
|
431
|
+
);
|
|
432
|
+
if (pixelDiffPoints.length > 0) {
|
|
433
|
+
logger.debug(
|
|
434
|
+
`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
|
|
435
|
+
);
|
|
436
|
+
dbscanResult = dbscan(
|
|
437
|
+
pixelDiffPoints,
|
|
438
|
+
imageWidth,
|
|
439
|
+
imageHeight,
|
|
440
|
+
void 0,
|
|
441
|
+
2
|
|
442
|
+
);
|
|
443
|
+
clusters = dbscanResult.boundingBoxes;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
365
446
|
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
366
447
|
for (const label of dbscanResult.labels) {
|
|
367
448
|
if (label >= 0) {
|
|
@@ -379,7 +460,9 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
379
460
|
animationIndices,
|
|
380
461
|
animationWeights
|
|
381
462
|
);
|
|
382
|
-
logger.debug(
|
|
463
|
+
logger.debug(
|
|
464
|
+
`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
|
|
465
|
+
);
|
|
383
466
|
edges.push({
|
|
384
467
|
sourceId: frames[i].id,
|
|
385
468
|
targetId: frames[i + 1].id,
|
|
@@ -424,12 +507,13 @@ async function analyzeFrames(ctx) {
|
|
|
424
507
|
// src/core/extractor.ts
|
|
425
508
|
import { readdir } from "fs/promises";
|
|
426
509
|
import { join as join2 } from "path";
|
|
427
|
-
import ffmpeg from "fluent-ffmpeg";
|
|
428
|
-
import ffmpegStatic from "ffmpeg-static";
|
|
429
510
|
import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
|
|
511
|
+
import { execa } from "execa";
|
|
512
|
+
import ffmpegPath from "ffmpeg-static";
|
|
430
513
|
|
|
431
514
|
// src/utils/paths.ts
|
|
432
515
|
import { mkdir, stat } from "fs/promises";
|
|
516
|
+
import { homedir } from "os";
|
|
433
517
|
import { basename, extname, resolve } from "path";
|
|
434
518
|
async function ensureDir(dirPath) {
|
|
435
519
|
await mkdir(dirPath, { recursive: true });
|
|
@@ -442,6 +526,16 @@ async function fileExists(filePath) {
|
|
|
442
526
|
return false;
|
|
443
527
|
}
|
|
444
528
|
}
|
|
529
|
+
function expandTilde(p) {
|
|
530
|
+
if (p === "~") return homedir();
|
|
531
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
532
|
+
return resolve(homedir(), p.slice(2));
|
|
533
|
+
}
|
|
534
|
+
return p;
|
|
535
|
+
}
|
|
536
|
+
function resolveAbsolute(p) {
|
|
537
|
+
return resolve(expandTilde(p));
|
|
538
|
+
}
|
|
445
539
|
function deriveOutputPath(inputPath) {
|
|
446
540
|
const dir = resolve(inputPath, "..");
|
|
447
541
|
const name = basename(inputPath, extname(inputPath));
|
|
@@ -452,8 +546,6 @@ function isSupportedFile(filePath, extensions) {
|
|
|
452
546
|
}
|
|
453
547
|
|
|
454
548
|
// src/core/extractor.ts
|
|
455
|
-
if (ffmpegStatic) ffmpeg.setFfmpegPath(ffmpegStatic);
|
|
456
|
-
ffmpeg.setFfprobePath(ffprobePath);
|
|
457
549
|
async function extractFrames(ctx) {
|
|
458
550
|
const framesDir = join2(ctx.workspacePath, "frames");
|
|
459
551
|
const { inputPath, fps, scale } = ctx.options;
|
|
@@ -464,7 +556,10 @@ async function extractFrames(ctx) {
|
|
|
464
556
|
if (!exists) {
|
|
465
557
|
throw new Error(`Input file not found: ${inputPath}`);
|
|
466
558
|
}
|
|
467
|
-
const allExtensions = [
|
|
559
|
+
const allExtensions = [
|
|
560
|
+
...SUPPORTED_VIDEO_EXTENSIONS,
|
|
561
|
+
...SUPPORTED_GIF_EXTENSIONS
|
|
562
|
+
];
|
|
468
563
|
if (!isSupportedFile(inputPath, allExtensions)) {
|
|
469
564
|
throw new Error(`Unsupported file format: ${inputPath}`);
|
|
470
565
|
}
|
|
@@ -490,35 +585,43 @@ async function extractFrames(ctx) {
|
|
|
490
585
|
}
|
|
491
586
|
async function extractIFrames(inputPath, outputDir, scale) {
|
|
492
587
|
const outputPattern = join2(outputDir, "frame_%06d.jpg");
|
|
493
|
-
await
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
588
|
+
await execa(ffmpegPath, [
|
|
589
|
+
"-i",
|
|
590
|
+
inputPath,
|
|
591
|
+
"-vf",
|
|
592
|
+
`select='eq(pict_type,I)',scale=-1:${scale}`,
|
|
593
|
+
"-vsync",
|
|
594
|
+
"vfr",
|
|
595
|
+
"-q:v",
|
|
596
|
+
"2",
|
|
597
|
+
outputPattern
|
|
598
|
+
]);
|
|
500
599
|
return buildFrameList(outputDir, inputPath);
|
|
501
600
|
}
|
|
502
601
|
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
503
602
|
const outputPattern = join2(outputDir, "frame_%06d.jpg");
|
|
504
|
-
await
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
603
|
+
await execa(ffmpegPath, [
|
|
604
|
+
"-i",
|
|
605
|
+
inputPath,
|
|
606
|
+
"-vf",
|
|
607
|
+
`fps=${fps},scale=-1:${scale}`,
|
|
608
|
+
"-q:v",
|
|
609
|
+
"2",
|
|
610
|
+
outputPattern
|
|
611
|
+
]);
|
|
510
612
|
return buildFrameList(outputDir, inputPath);
|
|
511
613
|
}
|
|
512
614
|
async function getVideoDuration(inputPath) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
615
|
+
const { stdout } = await execa(ffprobePath, [
|
|
616
|
+
"-v",
|
|
617
|
+
"quiet",
|
|
618
|
+
"-print_format",
|
|
619
|
+
"json",
|
|
620
|
+
"-show_format",
|
|
621
|
+
inputPath
|
|
622
|
+
]);
|
|
623
|
+
const metadata = JSON.parse(stdout);
|
|
624
|
+
return parseFloat(metadata.format?.duration ?? "0");
|
|
522
625
|
}
|
|
523
626
|
async function buildFrameList(framesDir, inputPath) {
|
|
524
627
|
const files = await readdir(framesDir);
|
|
@@ -530,7 +633,9 @@ async function buildFrameList(framesDir, inputPath) {
|
|
|
530
633
|
try {
|
|
531
634
|
duration = await getVideoDuration(inputPath);
|
|
532
635
|
} catch {
|
|
533
|
-
logger.debug(
|
|
636
|
+
logger.debug(
|
|
637
|
+
"Could not determine video duration; using frame index for timestamps"
|
|
638
|
+
);
|
|
534
639
|
}
|
|
535
640
|
return jpgFiles.map((file, index) => ({
|
|
536
641
|
id: index,
|
|
@@ -605,17 +710,15 @@ async function readFramesAsBuffers(frameNodes, quality) {
|
|
|
605
710
|
// src/core/input-resolver.ts
|
|
606
711
|
function resolveOptions(options) {
|
|
607
712
|
const mode = options.mode;
|
|
608
|
-
const inputPath = mode === "file" ? options.inputPath : void 0;
|
|
713
|
+
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
609
714
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join4(process.cwd(), "scene-sieve-output"));
|
|
610
|
-
const threshold = options.threshold;
|
|
611
|
-
if (threshold
|
|
715
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
716
|
+
if (threshold <= 0 || threshold > 1) {
|
|
612
717
|
throw new Error(
|
|
613
718
|
`threshold must be in range (0, 1], received: ${threshold}`
|
|
614
719
|
);
|
|
615
720
|
}
|
|
616
|
-
const
|
|
617
|
-
const hasExplicitCount = options.count !== void 0;
|
|
618
|
-
const pruneMode = hasThreshold && hasExplicitCount ? "threshold-with-cap" : hasThreshold ? "threshold" : "count";
|
|
721
|
+
const pruneMode = "threshold-with-cap";
|
|
619
722
|
return {
|
|
620
723
|
mode,
|
|
621
724
|
inputPath,
|
|
@@ -631,10 +734,16 @@ function resolveOptions(options) {
|
|
|
631
734
|
}
|
|
632
735
|
async function resolveInput(options, workspacePath) {
|
|
633
736
|
if (options.mode === "file") {
|
|
634
|
-
return {
|
|
737
|
+
return {
|
|
738
|
+
frames: [],
|
|
739
|
+
resolvedInputPath: resolveAbsolute(options.inputPath)
|
|
740
|
+
};
|
|
635
741
|
}
|
|
636
742
|
if (options.mode === "buffer") {
|
|
637
|
-
const resolvedInputPath = await writeInputBuffer(
|
|
743
|
+
const resolvedInputPath = await writeInputBuffer(
|
|
744
|
+
options.inputBuffer,
|
|
745
|
+
workspacePath
|
|
746
|
+
);
|
|
638
747
|
return { frames: [], resolvedInputPath };
|
|
639
748
|
}
|
|
640
749
|
if (options.mode === "frames") {
|
|
@@ -703,7 +812,11 @@ function pruneTo(graph, frames, targetCount) {
|
|
|
703
812
|
const heap = new MinHeap();
|
|
704
813
|
for (const edge of graph) {
|
|
705
814
|
edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
|
|
706
|
-
heap.push({
|
|
815
|
+
heap.push({
|
|
816
|
+
score: edge.score,
|
|
817
|
+
srcId: edge.sourceId,
|
|
818
|
+
tgtId: edge.targetId
|
|
819
|
+
});
|
|
707
820
|
}
|
|
708
821
|
const surviving = new Set(frames.map((f) => f.id));
|
|
709
822
|
const firstId = frames[0].id;
|
|
@@ -739,9 +852,55 @@ function normalizeScores(graph) {
|
|
|
739
852
|
const safeScores = graph.map(
|
|
740
853
|
(e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
|
|
741
854
|
);
|
|
742
|
-
const
|
|
743
|
-
if (
|
|
744
|
-
|
|
855
|
+
const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
|
|
856
|
+
if (sorted.length === 0) return safeScores;
|
|
857
|
+
const pIdx = Math.min(
|
|
858
|
+
Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
|
|
859
|
+
sorted.length - 1
|
|
860
|
+
);
|
|
861
|
+
const refScore = sorted[pIdx];
|
|
862
|
+
return safeScores.map((s) => Math.min(s / refScore, 1));
|
|
863
|
+
}
|
|
864
|
+
function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
865
|
+
const result = /* @__PURE__ */ new Set();
|
|
866
|
+
let runStart = 0;
|
|
867
|
+
while (runStart < passingIndices.length) {
|
|
868
|
+
let runEnd = runStart;
|
|
869
|
+
while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
|
|
870
|
+
runEnd++;
|
|
871
|
+
}
|
|
872
|
+
const runLen = runEnd - runStart + 1;
|
|
873
|
+
if (runLen === 1) {
|
|
874
|
+
result.add(graph[passingIndices[runStart]].targetId);
|
|
875
|
+
} else {
|
|
876
|
+
const peaks = [];
|
|
877
|
+
for (let j = runStart; j <= runEnd; j++) {
|
|
878
|
+
const idx = passingIndices[j];
|
|
879
|
+
const score = normalizedScores[idx];
|
|
880
|
+
const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
|
|
881
|
+
const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
|
|
882
|
+
if (score > prevScore && score > nextScore) {
|
|
883
|
+
peaks.push(idx);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (peaks.length > 0) {
|
|
887
|
+
for (const peakIdx of peaks) {
|
|
888
|
+
result.add(graph[peakIdx].targetId);
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
let peakIdx = passingIndices[runStart];
|
|
892
|
+
for (let j = runStart + 1; j <= runEnd; j++) {
|
|
893
|
+
const idx = passingIndices[j];
|
|
894
|
+
if (normalizedScores[idx] > normalizedScores[peakIdx]) {
|
|
895
|
+
peakIdx = idx;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
result.add(graph[peakIdx].targetId);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
runStart = runEnd + 1;
|
|
902
|
+
}
|
|
903
|
+
return result;
|
|
745
904
|
}
|
|
746
905
|
function pruneByThreshold(graph, frames, threshold) {
|
|
747
906
|
if (frames.length === 0) return /* @__PURE__ */ new Set();
|
|
@@ -749,11 +908,16 @@ function pruneByThreshold(graph, frames, threshold) {
|
|
|
749
908
|
surviving.add(frames[0].id);
|
|
750
909
|
surviving.add(frames[frames.length - 1].id);
|
|
751
910
|
const normalized = normalizeScores(graph);
|
|
911
|
+
const passingIndices = [];
|
|
752
912
|
for (let i = 0; i < graph.length; i++) {
|
|
753
913
|
if (normalized[i] >= threshold) {
|
|
754
|
-
|
|
914
|
+
passingIndices.push(i);
|
|
755
915
|
}
|
|
756
916
|
}
|
|
917
|
+
const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
|
|
918
|
+
for (const id of nmsTargets) {
|
|
919
|
+
surviving.add(id);
|
|
920
|
+
}
|
|
757
921
|
return surviving;
|
|
758
922
|
}
|
|
759
923
|
function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
@@ -837,35 +1001,22 @@ async function runPipeline(options) {
|
|
|
837
1001
|
ctx.status = "ANALYZING";
|
|
838
1002
|
ctx.graph = await analyzeFrames(ctx);
|
|
839
1003
|
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
|
-
}
|
|
1004
|
+
const survivingIds = pruneByThresholdWithCap(
|
|
1005
|
+
ctx.graph,
|
|
1006
|
+
ctx.frames,
|
|
1007
|
+
resolvedOptions.threshold,
|
|
1008
|
+
resolvedOptions.count
|
|
1009
|
+
);
|
|
862
1010
|
const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
|
|
863
1011
|
ctx.emitProgress(100);
|
|
864
1012
|
ctx.status = "FINALIZING";
|
|
865
1013
|
let outputFiles = [];
|
|
866
1014
|
let outputBuffers;
|
|
867
1015
|
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
868
|
-
outputBuffers = await readFramesAsBuffers(
|
|
1016
|
+
outputBuffers = await readFramesAsBuffers(
|
|
1017
|
+
prunedFrames,
|
|
1018
|
+
resolvedOptions.quality
|
|
1019
|
+
);
|
|
869
1020
|
ctx.emitProgress(100);
|
|
870
1021
|
} else {
|
|
871
1022
|
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
@@ -901,7 +1052,10 @@ async function runPipeline(options) {
|
|
|
901
1052
|
var require3 = createRequire2(import.meta.url);
|
|
902
1053
|
var { version } = require3("../package.json");
|
|
903
1054
|
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>", "
|
|
1055
|
+
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(
|
|
1056
|
+
"-t, --threshold <number>",
|
|
1057
|
+
"Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
|
|
1058
|
+
).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
1059
|
const { default: ora } = await import("ora");
|
|
906
1060
|
const { default: cliProgress } = await import("cli-progress");
|
|
907
1061
|
const spinner = ora("Initializing...").start();
|
|
@@ -933,8 +1087,12 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
|
|
|
933
1087
|
`
|
|
934
1088
|
Done! ${result.originalFramesCount} frames -> ${result.prunedFramesCount} scenes (${result.executionTimeMs}ms)`
|
|
935
1089
|
);
|
|
936
|
-
|
|
937
|
-
|
|
1090
|
+
if (opts.debug) {
|
|
1091
|
+
console.log(
|
|
1092
|
+
`Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`
|
|
1093
|
+
);
|
|
1094
|
+
result.outputFiles.forEach((f) => console.log(` - ${f}`));
|
|
1095
|
+
}
|
|
938
1096
|
} catch (error) {
|
|
939
1097
|
spinner.fail(
|
|
940
1098
|
`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
|
/**
|