@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/index.cjs CHANGED
@@ -55,7 +55,8 @@ var logger = {
55
55
  console.log(`${import_picocolors.default.blue("info")} ${message}`);
56
56
  },
57
57
  success(message) {
58
- console.log(`${import_picocolors.default.green("done")} ${message}`);
58
+ console.log(`
59
+ ${import_picocolors.default.green("done")} ${message}`);
59
60
  },
60
61
  warn(message) {
61
62
  console.warn(`${import_picocolors.default.yellow("warn")} ${message}`);
@@ -78,10 +79,12 @@ var import_sharp = __toESM(require("sharp"), 1);
78
79
  var import_node_os = require("os");
79
80
  var import_node_path = require("path");
80
81
  var APP_NAME = "scene-sieve";
81
- var DEFAULT_COUNT = 5;
82
+ var DEFAULT_COUNT = 20;
83
+ var DEFAULT_THRESHOLD = 0.5;
82
84
  var DEFAULT_FPS = 5;
83
85
  var DEFAULT_SCALE = 720;
84
86
  var DEFAULT_QUALITY = 80;
87
+ var NORMALIZATION_PERCENTILE = 0.9;
85
88
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
86
89
  var TEMP_BASE_DIR = (0, import_node_os.tmpdir)();
87
90
  var SUPPORTED_VIDEO_EXTENSIONS = [
@@ -101,6 +104,10 @@ var IOU_THRESHOLD = 0.9;
101
104
  var DECAY_LAMBDA = 0.95;
102
105
  var ANIMATION_FRAME_THRESHOLD = 5;
103
106
  var MATCH_DISTANCE_THRESHOLD = 0.75;
107
+ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
108
+ var PIXELDIFF_BINARY_THRESHOLD = 30;
109
+ var PIXELDIFF_CONTOUR_MIN_AREA = 100;
110
+ var PIXELDIFF_SAMPLE_SPACING = 8;
104
111
  function getTempWorkspaceDir(sessionId) {
105
112
  return (0, import_node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
106
113
  }
@@ -360,6 +367,60 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
360
367
  if (matches) matches.delete();
361
368
  }
362
369
  }
370
+ function computePixelDiff(cvLib, frame1, frame2) {
371
+ const cv = cvLib;
372
+ const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
373
+ const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
374
+ const diff = new cv.Mat();
375
+ const blurred = new cv.Mat();
376
+ const binary = new cv.Mat();
377
+ const contours = new cv.MatVector();
378
+ const hierarchy = new cv.Mat();
379
+ try {
380
+ mat1.data.set(frame1.data);
381
+ mat2.data.set(frame2.data);
382
+ cv.absdiff(mat1, mat2, diff);
383
+ const ksize = new cv.Size(
384
+ PIXELDIFF_GAUSSIAN_KERNEL,
385
+ PIXELDIFF_GAUSSIAN_KERNEL
386
+ );
387
+ cv.GaussianBlur(diff, blurred, ksize, 0);
388
+ cv.threshold(
389
+ blurred,
390
+ binary,
391
+ PIXELDIFF_BINARY_THRESHOLD,
392
+ 255,
393
+ cv.THRESH_BINARY
394
+ );
395
+ cv.findContours(
396
+ binary,
397
+ contours,
398
+ hierarchy,
399
+ cv.RETR_EXTERNAL,
400
+ cv.CHAIN_APPROX_SIMPLE
401
+ );
402
+ const points = [];
403
+ for (let c = 0; c < contours.size(); c++) {
404
+ const contour = contours.get(c);
405
+ const rect = cv.boundingRect(contour);
406
+ if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
407
+ for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
408
+ for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
409
+ points.push({ x, y });
410
+ }
411
+ }
412
+ }
413
+ return points;
414
+ } finally {
415
+ mat1.delete();
416
+ mat2.delete();
417
+ diff.delete();
418
+ blurred.delete();
419
+ binary.delete();
420
+ contours.delete();
421
+ hierarchy.delete();
422
+ }
423
+ }
363
424
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
364
425
  if (clusters.length === 0) return 0;
365
426
  let gain = 0;
@@ -394,8 +455,28 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
394
455
  preprocessed[i],
395
456
  preprocessed[i + 1]
396
457
  );
397
- const dbscanResult = dbscan(sNew, imageWidth, imageHeight);
398
- const clusters = dbscanResult.boundingBoxes;
458
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
459
+ let clusters = dbscanResult.boundingBoxes;
460
+ if (clusters.length === 0) {
461
+ const pixelDiffPoints = computePixelDiff(
462
+ cvLib,
463
+ preprocessed[i],
464
+ preprocessed[i + 1]
465
+ );
466
+ if (pixelDiffPoints.length > 0) {
467
+ logger.debug(
468
+ `Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
469
+ );
470
+ dbscanResult = dbscan(
471
+ pixelDiffPoints,
472
+ imageWidth,
473
+ imageHeight,
474
+ void 0,
475
+ 2
476
+ );
477
+ clusters = dbscanResult.boundingBoxes;
478
+ }
479
+ }
399
480
  const clusterPointCounts = new Array(clusters.length).fill(0);
400
481
  for (const label of dbscanResult.labels) {
401
482
  if (label >= 0) {
@@ -413,7 +494,9 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
413
494
  animationIndices,
414
495
  animationWeights
415
496
  );
416
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
497
+ logger.debug(
498
+ `Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
499
+ );
417
500
  edges.push({
418
501
  sourceId: frames[i].id,
419
502
  targetId: frames[i + 1].id,
@@ -458,12 +541,13 @@ async function analyzeFrames(ctx) {
458
541
  // src/core/extractor.ts
459
542
  var import_promises2 = require("fs/promises");
460
543
  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
544
  var import_ffprobe = require("@ffprobe-installer/ffprobe");
545
+ var import_execa = require("execa");
546
+ var import_ffmpeg_static = __toESM(require("ffmpeg-static"), 1);
464
547
 
465
548
  // src/utils/paths.ts
466
549
  var import_promises = require("fs/promises");
550
+ var import_node_os2 = require("os");
467
551
  var import_node_path2 = require("path");
468
552
  async function ensureDir(dirPath) {
469
553
  await (0, import_promises.mkdir)(dirPath, { recursive: true });
@@ -476,6 +560,16 @@ async function fileExists(filePath) {
476
560
  return false;
477
561
  }
478
562
  }
563
+ function expandTilde(p) {
564
+ if (p === "~") return (0, import_node_os2.homedir)();
565
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
566
+ return (0, import_node_path2.resolve)((0, import_node_os2.homedir)(), p.slice(2));
567
+ }
568
+ return p;
569
+ }
570
+ function resolveAbsolute(p) {
571
+ return (0, import_node_path2.resolve)(expandTilde(p));
572
+ }
479
573
  function deriveOutputPath(inputPath) {
480
574
  const dir = (0, import_node_path2.resolve)(inputPath, "..");
481
575
  const name = (0, import_node_path2.basename)(inputPath, (0, import_node_path2.extname)(inputPath));
@@ -486,8 +580,6 @@ function isSupportedFile(filePath, extensions) {
486
580
  }
487
581
 
488
582
  // 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
583
  async function extractFrames(ctx) {
492
584
  const framesDir = (0, import_node_path3.join)(ctx.workspacePath, "frames");
493
585
  const { inputPath, fps, scale } = ctx.options;
@@ -498,7 +590,10 @@ async function extractFrames(ctx) {
498
590
  if (!exists) {
499
591
  throw new Error(`Input file not found: ${inputPath}`);
500
592
  }
501
- const allExtensions = [...SUPPORTED_VIDEO_EXTENSIONS, ...SUPPORTED_GIF_EXTENSIONS];
593
+ const allExtensions = [
594
+ ...SUPPORTED_VIDEO_EXTENSIONS,
595
+ ...SUPPORTED_GIF_EXTENSIONS
596
+ ];
502
597
  if (!isSupportedFile(inputPath, allExtensions)) {
503
598
  throw new Error(`Unsupported file format: ${inputPath}`);
504
599
  }
@@ -524,35 +619,43 @@ async function extractFrames(ctx) {
524
619
  }
525
620
  async function extractIFrames(inputPath, outputDir, scale) {
526
621
  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
- });
622
+ await (0, import_execa.execa)(import_ffmpeg_static.default, [
623
+ "-i",
624
+ inputPath,
625
+ "-vf",
626
+ `select='eq(pict_type,I)',scale=-1:${scale}`,
627
+ "-vsync",
628
+ "vfr",
629
+ "-q:v",
630
+ "2",
631
+ outputPattern
632
+ ]);
534
633
  return buildFrameList(outputDir, inputPath);
535
634
  }
536
635
  async function extractByFps(inputPath, outputDir, fps, scale) {
537
636
  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
- });
637
+ await (0, import_execa.execa)(import_ffmpeg_static.default, [
638
+ "-i",
639
+ inputPath,
640
+ "-vf",
641
+ `fps=${fps},scale=-1:${scale}`,
642
+ "-q:v",
643
+ "2",
644
+ outputPattern
645
+ ]);
544
646
  return buildFrameList(outputDir, inputPath);
545
647
  }
546
648
  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
- });
649
+ const { stdout } = await (0, import_execa.execa)(import_ffprobe.path, [
650
+ "-v",
651
+ "quiet",
652
+ "-print_format",
653
+ "json",
654
+ "-show_format",
655
+ inputPath
656
+ ]);
657
+ const metadata = JSON.parse(stdout);
658
+ return parseFloat(metadata.format?.duration ?? "0");
556
659
  }
557
660
  async function buildFrameList(framesDir, inputPath) {
558
661
  const files = await (0, import_promises2.readdir)(framesDir);
@@ -564,7 +667,9 @@ async function buildFrameList(framesDir, inputPath) {
564
667
  try {
565
668
  duration = await getVideoDuration(inputPath);
566
669
  } catch {
567
- logger.debug("Could not determine video duration; using frame index for timestamps");
670
+ logger.debug(
671
+ "Could not determine video duration; using frame index for timestamps"
672
+ );
568
673
  }
569
674
  return jpgFiles.map((file, index) => ({
570
675
  id: index,
@@ -639,17 +744,15 @@ async function readFramesAsBuffers(frameNodes, quality) {
639
744
  // src/core/input-resolver.ts
640
745
  function resolveOptions(options) {
641
746
  const mode = options.mode;
642
- const inputPath = mode === "file" ? options.inputPath : void 0;
747
+ const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
643
748
  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)) {
749
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
750
+ if (threshold <= 0 || threshold > 1) {
646
751
  throw new Error(
647
752
  `threshold must be in range (0, 1], received: ${threshold}`
648
753
  );
649
754
  }
650
- const hasThreshold = threshold !== void 0;
651
- const hasExplicitCount = options.count !== void 0;
652
- const pruneMode = hasThreshold && hasExplicitCount ? "threshold-with-cap" : hasThreshold ? "threshold" : "count";
755
+ const pruneMode = "threshold-with-cap";
653
756
  return {
654
757
  mode,
655
758
  inputPath,
@@ -665,10 +768,16 @@ function resolveOptions(options) {
665
768
  }
666
769
  async function resolveInput(options, workspacePath) {
667
770
  if (options.mode === "file") {
668
- return { frames: [], resolvedInputPath: options.inputPath };
771
+ return {
772
+ frames: [],
773
+ resolvedInputPath: resolveAbsolute(options.inputPath)
774
+ };
669
775
  }
670
776
  if (options.mode === "buffer") {
671
- const resolvedInputPath = await writeInputBuffer(options.inputBuffer, workspacePath);
777
+ const resolvedInputPath = await writeInputBuffer(
778
+ options.inputBuffer,
779
+ workspacePath
780
+ );
672
781
  return { frames: [], resolvedInputPath };
673
782
  }
674
783
  if (options.mode === "frames") {
@@ -737,7 +846,11 @@ function pruneTo(graph, frames, targetCount) {
737
846
  const heap = new MinHeap();
738
847
  for (const edge of graph) {
739
848
  edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
740
- heap.push({ score: edge.score, srcId: edge.sourceId, tgtId: edge.targetId });
849
+ heap.push({
850
+ score: edge.score,
851
+ srcId: edge.sourceId,
852
+ tgtId: edge.targetId
853
+ });
741
854
  }
742
855
  const surviving = new Set(frames.map((f) => f.id));
743
856
  const firstId = frames[0].id;
@@ -773,9 +886,55 @@ function normalizeScores(graph) {
773
886
  const safeScores = graph.map(
774
887
  (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
775
888
  );
776
- const maxScore = Math.max(...safeScores);
777
- if (maxScore === 0) return safeScores;
778
- return safeScores.map((s) => s / maxScore);
889
+ const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
890
+ if (sorted.length === 0) return safeScores;
891
+ const pIdx = Math.min(
892
+ Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
893
+ sorted.length - 1
894
+ );
895
+ const refScore = sorted[pIdx];
896
+ return safeScores.map((s) => Math.min(s / refScore, 1));
897
+ }
898
+ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
899
+ const result = /* @__PURE__ */ new Set();
900
+ let runStart = 0;
901
+ while (runStart < passingIndices.length) {
902
+ let runEnd = runStart;
903
+ while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
904
+ runEnd++;
905
+ }
906
+ const runLen = runEnd - runStart + 1;
907
+ if (runLen === 1) {
908
+ result.add(graph[passingIndices[runStart]].targetId);
909
+ } else {
910
+ const peaks = [];
911
+ for (let j = runStart; j <= runEnd; j++) {
912
+ const idx = passingIndices[j];
913
+ const score = normalizedScores[idx];
914
+ const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
915
+ const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
916
+ if (score > prevScore && score > nextScore) {
917
+ peaks.push(idx);
918
+ }
919
+ }
920
+ if (peaks.length > 0) {
921
+ for (const peakIdx of peaks) {
922
+ result.add(graph[peakIdx].targetId);
923
+ }
924
+ } else {
925
+ let peakIdx = passingIndices[runStart];
926
+ for (let j = runStart + 1; j <= runEnd; j++) {
927
+ const idx = passingIndices[j];
928
+ if (normalizedScores[idx] > normalizedScores[peakIdx]) {
929
+ peakIdx = idx;
930
+ }
931
+ }
932
+ result.add(graph[peakIdx].targetId);
933
+ }
934
+ }
935
+ runStart = runEnd + 1;
936
+ }
937
+ return result;
779
938
  }
780
939
  function pruneByThreshold(graph, frames, threshold) {
781
940
  if (frames.length === 0) return /* @__PURE__ */ new Set();
@@ -783,11 +942,16 @@ function pruneByThreshold(graph, frames, threshold) {
783
942
  surviving.add(frames[0].id);
784
943
  surviving.add(frames[frames.length - 1].id);
785
944
  const normalized = normalizeScores(graph);
945
+ const passingIndices = [];
786
946
  for (let i = 0; i < graph.length; i++) {
787
947
  if (normalized[i] >= threshold) {
788
- surviving.add(graph[i].targetId);
948
+ passingIndices.push(i);
789
949
  }
790
950
  }
951
+ const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
952
+ for (const id of nmsTargets) {
953
+ surviving.add(id);
954
+ }
791
955
  return surviving;
792
956
  }
793
957
  function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
@@ -871,35 +1035,22 @@ async function runPipeline(options) {
871
1035
  ctx.status = "ANALYZING";
872
1036
  ctx.graph = await analyzeFrames(ctx);
873
1037
  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
- }
1038
+ const survivingIds = pruneByThresholdWithCap(
1039
+ ctx.graph,
1040
+ ctx.frames,
1041
+ resolvedOptions.threshold,
1042
+ resolvedOptions.count
1043
+ );
896
1044
  const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
897
1045
  ctx.emitProgress(100);
898
1046
  ctx.status = "FINALIZING";
899
1047
  let outputFiles = [];
900
1048
  let outputBuffers;
901
1049
  if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
902
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1050
+ outputBuffers = await readFramesAsBuffers(
1051
+ prunedFrames,
1052
+ resolvedOptions.quality
1053
+ );
903
1054
  ctx.emitProgress(100);
904
1055
  } else {
905
1056
  outputFiles = await finalizeOutput(ctx, prunedFrames);