@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/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 = 5;
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
- const dbscanResult = dbscan(sNew, imageWidth, imageHeight);
398
- const clusters = dbscanResult.boundingBoxes;
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(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
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 = [...SUPPORTED_VIDEO_EXTENSIONS, ...SUPPORTED_GIF_EXTENSIONS];
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 new Promise((resolve2, reject) => {
528
- (0, import_fluent_ffmpeg.default)(inputPath).outputOptions([
529
- `-vf select='eq(pict_type,I)',scale=-1:${scale}`,
530
- "-vsync vfr",
531
- "-q:v 2"
532
- ]).output(outputPattern).on("end", () => resolve2()).on("error", (err) => reject(err)).run();
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 new Promise((resolve2, reject) => {
539
- (0, import_fluent_ffmpeg.default)(inputPath).outputOptions([
540
- `-vf fps=${fps},scale=-1:${scale}`,
541
- "-q:v 2"
542
- ]).output(outputPattern).on("end", () => resolve2()).on("error", (err) => reject(err)).run();
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
- return new Promise((resolve2, reject) => {
548
- import_fluent_ffmpeg.default.ffprobe(inputPath, (err, metadata) => {
549
- if (err) {
550
- reject(err);
551
- return;
552
- }
553
- resolve2(metadata.format.duration ?? 0);
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("Could not determine video duration; using frame index for timestamps");
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 !== void 0 && (threshold <= 0 || threshold > 1)) {
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 hasThreshold = threshold !== void 0;
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(options.inputBuffer, workspacePath);
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({ score: edge.score, srcId: edge.sourceId, tgtId: edge.targetId });
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 maxScore = Math.max(...safeScores);
777
- if (maxScore === 0) return safeScores;
778
- return safeScores.map((s) => s / maxScore);
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
- surviving.add(graph[i].targetId);
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
- let survivingIds;
875
- switch (resolvedOptions.pruneMode) {
876
- case "threshold-with-cap":
877
- survivingIds = pruneByThresholdWithCap(
878
- ctx.graph,
879
- ctx.frames,
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(prunedFrames, resolvedOptions.quality);
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);