@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/index.cjs
CHANGED
|
@@ -78,10 +78,12 @@ var import_sharp = __toESM(require("sharp"), 1);
|
|
|
78
78
|
var import_node_os = require("os");
|
|
79
79
|
var import_node_path = require("path");
|
|
80
80
|
var APP_NAME = "scene-sieve";
|
|
81
|
-
var DEFAULT_COUNT =
|
|
81
|
+
var DEFAULT_COUNT = 20;
|
|
82
|
+
var DEFAULT_THRESHOLD = 0.5;
|
|
82
83
|
var DEFAULT_FPS = 5;
|
|
83
84
|
var DEFAULT_SCALE = 720;
|
|
84
85
|
var DEFAULT_QUALITY = 80;
|
|
86
|
+
var NORMALIZATION_PERCENTILE = 0.9;
|
|
85
87
|
var WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
86
88
|
var TEMP_BASE_DIR = (0, import_node_os.tmpdir)();
|
|
87
89
|
var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
@@ -101,6 +103,10 @@ var IOU_THRESHOLD = 0.9;
|
|
|
101
103
|
var DECAY_LAMBDA = 0.95;
|
|
102
104
|
var ANIMATION_FRAME_THRESHOLD = 5;
|
|
103
105
|
var MATCH_DISTANCE_THRESHOLD = 0.75;
|
|
106
|
+
var PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
107
|
+
var PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
108
|
+
var PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
109
|
+
var PIXELDIFF_SAMPLE_SPACING = 8;
|
|
104
110
|
function getTempWorkspaceDir(sessionId) {
|
|
105
111
|
return (0, import_node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
106
112
|
}
|
|
@@ -360,6 +366,60 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
|
360
366
|
if (matches) matches.delete();
|
|
361
367
|
}
|
|
362
368
|
}
|
|
369
|
+
function computePixelDiff(cvLib, frame1, frame2) {
|
|
370
|
+
const cv = cvLib;
|
|
371
|
+
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
372
|
+
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
373
|
+
const diff = new cv.Mat();
|
|
374
|
+
const blurred = new cv.Mat();
|
|
375
|
+
const binary = new cv.Mat();
|
|
376
|
+
const contours = new cv.MatVector();
|
|
377
|
+
const hierarchy = new cv.Mat();
|
|
378
|
+
try {
|
|
379
|
+
mat1.data.set(frame1.data);
|
|
380
|
+
mat2.data.set(frame2.data);
|
|
381
|
+
cv.absdiff(mat1, mat2, diff);
|
|
382
|
+
const ksize = new cv.Size(
|
|
383
|
+
PIXELDIFF_GAUSSIAN_KERNEL,
|
|
384
|
+
PIXELDIFF_GAUSSIAN_KERNEL
|
|
385
|
+
);
|
|
386
|
+
cv.GaussianBlur(diff, blurred, ksize, 0);
|
|
387
|
+
cv.threshold(
|
|
388
|
+
blurred,
|
|
389
|
+
binary,
|
|
390
|
+
PIXELDIFF_BINARY_THRESHOLD,
|
|
391
|
+
255,
|
|
392
|
+
cv.THRESH_BINARY
|
|
393
|
+
);
|
|
394
|
+
cv.findContours(
|
|
395
|
+
binary,
|
|
396
|
+
contours,
|
|
397
|
+
hierarchy,
|
|
398
|
+
cv.RETR_EXTERNAL,
|
|
399
|
+
cv.CHAIN_APPROX_SIMPLE
|
|
400
|
+
);
|
|
401
|
+
const points = [];
|
|
402
|
+
for (let c = 0; c < contours.size(); c++) {
|
|
403
|
+
const contour = contours.get(c);
|
|
404
|
+
const rect = cv.boundingRect(contour);
|
|
405
|
+
if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
|
|
406
|
+
for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
|
|
407
|
+
for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
|
|
408
|
+
points.push({ x, y });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return points;
|
|
413
|
+
} finally {
|
|
414
|
+
mat1.delete();
|
|
415
|
+
mat2.delete();
|
|
416
|
+
diff.delete();
|
|
417
|
+
blurred.delete();
|
|
418
|
+
binary.delete();
|
|
419
|
+
contours.delete();
|
|
420
|
+
hierarchy.delete();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
363
423
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
364
424
|
if (clusters.length === 0) return 0;
|
|
365
425
|
let gain = 0;
|
|
@@ -394,8 +454,28 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
394
454
|
preprocessed[i],
|
|
395
455
|
preprocessed[i + 1]
|
|
396
456
|
);
|
|
397
|
-
|
|
398
|
-
|
|
457
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
458
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
459
|
+
if (clusters.length === 0) {
|
|
460
|
+
const pixelDiffPoints = computePixelDiff(
|
|
461
|
+
cvLib,
|
|
462
|
+
preprocessed[i],
|
|
463
|
+
preprocessed[i + 1]
|
|
464
|
+
);
|
|
465
|
+
if (pixelDiffPoints.length > 0) {
|
|
466
|
+
logger.debug(
|
|
467
|
+
`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
|
|
468
|
+
);
|
|
469
|
+
dbscanResult = dbscan(
|
|
470
|
+
pixelDiffPoints,
|
|
471
|
+
imageWidth,
|
|
472
|
+
imageHeight,
|
|
473
|
+
void 0,
|
|
474
|
+
2
|
|
475
|
+
);
|
|
476
|
+
clusters = dbscanResult.boundingBoxes;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
399
479
|
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
400
480
|
for (const label of dbscanResult.labels) {
|
|
401
481
|
if (label >= 0) {
|
|
@@ -413,7 +493,9 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
413
493
|
animationIndices,
|
|
414
494
|
animationWeights
|
|
415
495
|
);
|
|
416
|
-
logger.debug(
|
|
496
|
+
logger.debug(
|
|
497
|
+
`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
|
|
498
|
+
);
|
|
417
499
|
edges.push({
|
|
418
500
|
sourceId: frames[i].id,
|
|
419
501
|
targetId: frames[i + 1].id,
|
|
@@ -458,9 +540,9 @@ async function analyzeFrames(ctx) {
|
|
|
458
540
|
// src/core/extractor.ts
|
|
459
541
|
var import_promises2 = require("fs/promises");
|
|
460
542
|
var import_node_path3 = require("path");
|
|
461
|
-
var import_fluent_ffmpeg = __toESM(require("fluent-ffmpeg"), 1);
|
|
462
|
-
var import_ffmpeg_static = __toESM(require("ffmpeg-static"), 1);
|
|
463
543
|
var import_ffprobe = require("@ffprobe-installer/ffprobe");
|
|
544
|
+
var import_execa = require("execa");
|
|
545
|
+
var import_ffmpeg_static = __toESM(require("ffmpeg-static"), 1);
|
|
464
546
|
|
|
465
547
|
// src/utils/paths.ts
|
|
466
548
|
var import_promises = require("fs/promises");
|
|
@@ -486,8 +568,6 @@ function isSupportedFile(filePath, extensions) {
|
|
|
486
568
|
}
|
|
487
569
|
|
|
488
570
|
// src/core/extractor.ts
|
|
489
|
-
if (import_ffmpeg_static.default) import_fluent_ffmpeg.default.setFfmpegPath(import_ffmpeg_static.default);
|
|
490
|
-
import_fluent_ffmpeg.default.setFfprobePath(import_ffprobe.path);
|
|
491
571
|
async function extractFrames(ctx) {
|
|
492
572
|
const framesDir = (0, import_node_path3.join)(ctx.workspacePath, "frames");
|
|
493
573
|
const { inputPath, fps, scale } = ctx.options;
|
|
@@ -498,7 +578,10 @@ async function extractFrames(ctx) {
|
|
|
498
578
|
if (!exists) {
|
|
499
579
|
throw new Error(`Input file not found: ${inputPath}`);
|
|
500
580
|
}
|
|
501
|
-
const allExtensions = [
|
|
581
|
+
const allExtensions = [
|
|
582
|
+
...SUPPORTED_VIDEO_EXTENSIONS,
|
|
583
|
+
...SUPPORTED_GIF_EXTENSIONS
|
|
584
|
+
];
|
|
502
585
|
if (!isSupportedFile(inputPath, allExtensions)) {
|
|
503
586
|
throw new Error(`Unsupported file format: ${inputPath}`);
|
|
504
587
|
}
|
|
@@ -524,35 +607,43 @@ async function extractFrames(ctx) {
|
|
|
524
607
|
}
|
|
525
608
|
async function extractIFrames(inputPath, outputDir, scale) {
|
|
526
609
|
const outputPattern = (0, import_node_path3.join)(outputDir, "frame_%06d.jpg");
|
|
527
|
-
await
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
610
|
+
await (0, import_execa.execa)(import_ffmpeg_static.default, [
|
|
611
|
+
"-i",
|
|
612
|
+
inputPath,
|
|
613
|
+
"-vf",
|
|
614
|
+
`select='eq(pict_type,I)',scale=-1:${scale}`,
|
|
615
|
+
"-vsync",
|
|
616
|
+
"vfr",
|
|
617
|
+
"-q:v",
|
|
618
|
+
"2",
|
|
619
|
+
outputPattern
|
|
620
|
+
]);
|
|
534
621
|
return buildFrameList(outputDir, inputPath);
|
|
535
622
|
}
|
|
536
623
|
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
537
624
|
const outputPattern = (0, import_node_path3.join)(outputDir, "frame_%06d.jpg");
|
|
538
|
-
await
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
625
|
+
await (0, import_execa.execa)(import_ffmpeg_static.default, [
|
|
626
|
+
"-i",
|
|
627
|
+
inputPath,
|
|
628
|
+
"-vf",
|
|
629
|
+
`fps=${fps},scale=-1:${scale}`,
|
|
630
|
+
"-q:v",
|
|
631
|
+
"2",
|
|
632
|
+
outputPattern
|
|
633
|
+
]);
|
|
544
634
|
return buildFrameList(outputDir, inputPath);
|
|
545
635
|
}
|
|
546
636
|
async function getVideoDuration(inputPath) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
637
|
+
const { stdout } = await (0, import_execa.execa)(import_ffprobe.path, [
|
|
638
|
+
"-v",
|
|
639
|
+
"quiet",
|
|
640
|
+
"-print_format",
|
|
641
|
+
"json",
|
|
642
|
+
"-show_format",
|
|
643
|
+
inputPath
|
|
644
|
+
]);
|
|
645
|
+
const metadata = JSON.parse(stdout);
|
|
646
|
+
return parseFloat(metadata.format?.duration ?? "0");
|
|
556
647
|
}
|
|
557
648
|
async function buildFrameList(framesDir, inputPath) {
|
|
558
649
|
const files = await (0, import_promises2.readdir)(framesDir);
|
|
@@ -564,7 +655,9 @@ async function buildFrameList(framesDir, inputPath) {
|
|
|
564
655
|
try {
|
|
565
656
|
duration = await getVideoDuration(inputPath);
|
|
566
657
|
} catch {
|
|
567
|
-
logger.debug(
|
|
658
|
+
logger.debug(
|
|
659
|
+
"Could not determine video duration; using frame index for timestamps"
|
|
660
|
+
);
|
|
568
661
|
}
|
|
569
662
|
return jpgFiles.map((file, index) => ({
|
|
570
663
|
id: index,
|
|
@@ -641,15 +734,13 @@ function resolveOptions(options) {
|
|
|
641
734
|
const mode = options.mode;
|
|
642
735
|
const inputPath = mode === "file" ? options.inputPath : void 0;
|
|
643
736
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : (0, import_node_path5.join)(process.cwd(), "scene-sieve-output"));
|
|
644
|
-
const threshold = options.threshold;
|
|
645
|
-
if (threshold
|
|
737
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
738
|
+
if (threshold <= 0 || threshold > 1) {
|
|
646
739
|
throw new Error(
|
|
647
740
|
`threshold must be in range (0, 1], received: ${threshold}`
|
|
648
741
|
);
|
|
649
742
|
}
|
|
650
|
-
const
|
|
651
|
-
const hasExplicitCount = options.count !== void 0;
|
|
652
|
-
const pruneMode = hasThreshold && hasExplicitCount ? "threshold-with-cap" : hasThreshold ? "threshold" : "count";
|
|
743
|
+
const pruneMode = "threshold-with-cap";
|
|
653
744
|
return {
|
|
654
745
|
mode,
|
|
655
746
|
inputPath,
|
|
@@ -668,7 +759,10 @@ async function resolveInput(options, workspacePath) {
|
|
|
668
759
|
return { frames: [], resolvedInputPath: options.inputPath };
|
|
669
760
|
}
|
|
670
761
|
if (options.mode === "buffer") {
|
|
671
|
-
const resolvedInputPath = await writeInputBuffer(
|
|
762
|
+
const resolvedInputPath = await writeInputBuffer(
|
|
763
|
+
options.inputBuffer,
|
|
764
|
+
workspacePath
|
|
765
|
+
);
|
|
672
766
|
return { frames: [], resolvedInputPath };
|
|
673
767
|
}
|
|
674
768
|
if (options.mode === "frames") {
|
|
@@ -737,7 +831,11 @@ function pruneTo(graph, frames, targetCount) {
|
|
|
737
831
|
const heap = new MinHeap();
|
|
738
832
|
for (const edge of graph) {
|
|
739
833
|
edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
|
|
740
|
-
heap.push({
|
|
834
|
+
heap.push({
|
|
835
|
+
score: edge.score,
|
|
836
|
+
srcId: edge.sourceId,
|
|
837
|
+
tgtId: edge.targetId
|
|
838
|
+
});
|
|
741
839
|
}
|
|
742
840
|
const surviving = new Set(frames.map((f) => f.id));
|
|
743
841
|
const firstId = frames[0].id;
|
|
@@ -773,9 +871,55 @@ function normalizeScores(graph) {
|
|
|
773
871
|
const safeScores = graph.map(
|
|
774
872
|
(e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
|
|
775
873
|
);
|
|
776
|
-
const
|
|
777
|
-
if (
|
|
778
|
-
|
|
874
|
+
const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
|
|
875
|
+
if (sorted.length === 0) return safeScores;
|
|
876
|
+
const pIdx = Math.min(
|
|
877
|
+
Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
|
|
878
|
+
sorted.length - 1
|
|
879
|
+
);
|
|
880
|
+
const refScore = sorted[pIdx];
|
|
881
|
+
return safeScores.map((s) => Math.min(s / refScore, 1));
|
|
882
|
+
}
|
|
883
|
+
function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
884
|
+
const result = /* @__PURE__ */ new Set();
|
|
885
|
+
let runStart = 0;
|
|
886
|
+
while (runStart < passingIndices.length) {
|
|
887
|
+
let runEnd = runStart;
|
|
888
|
+
while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
|
|
889
|
+
runEnd++;
|
|
890
|
+
}
|
|
891
|
+
const runLen = runEnd - runStart + 1;
|
|
892
|
+
if (runLen === 1) {
|
|
893
|
+
result.add(graph[passingIndices[runStart]].targetId);
|
|
894
|
+
} else {
|
|
895
|
+
const peaks = [];
|
|
896
|
+
for (let j = runStart; j <= runEnd; j++) {
|
|
897
|
+
const idx = passingIndices[j];
|
|
898
|
+
const score = normalizedScores[idx];
|
|
899
|
+
const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
|
|
900
|
+
const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
|
|
901
|
+
if (score > prevScore && score > nextScore) {
|
|
902
|
+
peaks.push(idx);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (peaks.length > 0) {
|
|
906
|
+
for (const peakIdx of peaks) {
|
|
907
|
+
result.add(graph[peakIdx].targetId);
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
let peakIdx = passingIndices[runStart];
|
|
911
|
+
for (let j = runStart + 1; j <= runEnd; j++) {
|
|
912
|
+
const idx = passingIndices[j];
|
|
913
|
+
if (normalizedScores[idx] > normalizedScores[peakIdx]) {
|
|
914
|
+
peakIdx = idx;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
result.add(graph[peakIdx].targetId);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
runStart = runEnd + 1;
|
|
921
|
+
}
|
|
922
|
+
return result;
|
|
779
923
|
}
|
|
780
924
|
function pruneByThreshold(graph, frames, threshold) {
|
|
781
925
|
if (frames.length === 0) return /* @__PURE__ */ new Set();
|
|
@@ -783,11 +927,16 @@ function pruneByThreshold(graph, frames, threshold) {
|
|
|
783
927
|
surviving.add(frames[0].id);
|
|
784
928
|
surviving.add(frames[frames.length - 1].id);
|
|
785
929
|
const normalized = normalizeScores(graph);
|
|
930
|
+
const passingIndices = [];
|
|
786
931
|
for (let i = 0; i < graph.length; i++) {
|
|
787
932
|
if (normalized[i] >= threshold) {
|
|
788
|
-
|
|
933
|
+
passingIndices.push(i);
|
|
789
934
|
}
|
|
790
935
|
}
|
|
936
|
+
const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
|
|
937
|
+
for (const id of nmsTargets) {
|
|
938
|
+
surviving.add(id);
|
|
939
|
+
}
|
|
791
940
|
return surviving;
|
|
792
941
|
}
|
|
793
942
|
function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
@@ -871,35 +1020,22 @@ async function runPipeline(options) {
|
|
|
871
1020
|
ctx.status = "ANALYZING";
|
|
872
1021
|
ctx.graph = await analyzeFrames(ctx);
|
|
873
1022
|
ctx.status = "PRUNING";
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
resolvedOptions.threshold,
|
|
881
|
-
resolvedOptions.count
|
|
882
|
-
);
|
|
883
|
-
break;
|
|
884
|
-
case "threshold":
|
|
885
|
-
survivingIds = pruneByThreshold(
|
|
886
|
-
ctx.graph,
|
|
887
|
-
ctx.frames,
|
|
888
|
-
resolvedOptions.threshold
|
|
889
|
-
);
|
|
890
|
-
break;
|
|
891
|
-
case "count":
|
|
892
|
-
default:
|
|
893
|
-
survivingIds = pruneTo(ctx.graph, ctx.frames, resolvedOptions.count);
|
|
894
|
-
break;
|
|
895
|
-
}
|
|
1023
|
+
const survivingIds = pruneByThresholdWithCap(
|
|
1024
|
+
ctx.graph,
|
|
1025
|
+
ctx.frames,
|
|
1026
|
+
resolvedOptions.threshold,
|
|
1027
|
+
resolvedOptions.count
|
|
1028
|
+
);
|
|
896
1029
|
const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
|
|
897
1030
|
ctx.emitProgress(100);
|
|
898
1031
|
ctx.status = "FINALIZING";
|
|
899
1032
|
let outputFiles = [];
|
|
900
1033
|
let outputBuffers;
|
|
901
1034
|
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
902
|
-
outputBuffers = await readFramesAsBuffers(
|
|
1035
|
+
outputBuffers = await readFramesAsBuffers(
|
|
1036
|
+
prunedFrames,
|
|
1037
|
+
resolvedOptions.quality
|
|
1038
|
+
);
|
|
903
1039
|
ctx.emitProgress(100);
|
|
904
1040
|
} else {
|
|
905
1041
|
outputFiles = await finalizeOutput(ctx, prunedFrames);
|