@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.mjs CHANGED
@@ -38,10 +38,12 @@ import sharp from "sharp";
38
38
  import { tmpdir } from "os";
39
39
  import { join } from "path";
40
40
  var APP_NAME = "scene-sieve";
41
- var DEFAULT_COUNT = 5;
41
+ var DEFAULT_COUNT = 20;
42
+ var DEFAULT_THRESHOLD = 0.5;
42
43
  var DEFAULT_FPS = 5;
43
44
  var DEFAULT_SCALE = 720;
44
45
  var DEFAULT_QUALITY = 80;
46
+ var NORMALIZATION_PERCENTILE = 0.9;
45
47
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
46
48
  var TEMP_BASE_DIR = tmpdir();
47
49
  var SUPPORTED_VIDEO_EXTENSIONS = [
@@ -61,6 +63,10 @@ var IOU_THRESHOLD = 0.9;
61
63
  var DECAY_LAMBDA = 0.95;
62
64
  var ANIMATION_FRAME_THRESHOLD = 5;
63
65
  var MATCH_DISTANCE_THRESHOLD = 0.75;
66
+ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
67
+ var PIXELDIFF_BINARY_THRESHOLD = 30;
68
+ var PIXELDIFF_CONTOUR_MIN_AREA = 100;
69
+ var PIXELDIFF_SAMPLE_SPACING = 8;
64
70
  function getTempWorkspaceDir(sessionId) {
65
71
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
66
72
  }
@@ -320,6 +326,60 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
320
326
  if (matches) matches.delete();
321
327
  }
322
328
  }
329
+ function computePixelDiff(cvLib, frame1, frame2) {
330
+ const cv = cvLib;
331
+ const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
332
+ const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
333
+ const diff = new cv.Mat();
334
+ const blurred = new cv.Mat();
335
+ const binary = new cv.Mat();
336
+ const contours = new cv.MatVector();
337
+ const hierarchy = new cv.Mat();
338
+ try {
339
+ mat1.data.set(frame1.data);
340
+ mat2.data.set(frame2.data);
341
+ cv.absdiff(mat1, mat2, diff);
342
+ const ksize = new cv.Size(
343
+ PIXELDIFF_GAUSSIAN_KERNEL,
344
+ PIXELDIFF_GAUSSIAN_KERNEL
345
+ );
346
+ cv.GaussianBlur(diff, blurred, ksize, 0);
347
+ cv.threshold(
348
+ blurred,
349
+ binary,
350
+ PIXELDIFF_BINARY_THRESHOLD,
351
+ 255,
352
+ cv.THRESH_BINARY
353
+ );
354
+ cv.findContours(
355
+ binary,
356
+ contours,
357
+ hierarchy,
358
+ cv.RETR_EXTERNAL,
359
+ cv.CHAIN_APPROX_SIMPLE
360
+ );
361
+ const points = [];
362
+ for (let c = 0; c < contours.size(); c++) {
363
+ const contour = contours.get(c);
364
+ const rect = cv.boundingRect(contour);
365
+ if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
366
+ for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
367
+ for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
368
+ points.push({ x, y });
369
+ }
370
+ }
371
+ }
372
+ return points;
373
+ } finally {
374
+ mat1.delete();
375
+ mat2.delete();
376
+ diff.delete();
377
+ blurred.delete();
378
+ binary.delete();
379
+ contours.delete();
380
+ hierarchy.delete();
381
+ }
382
+ }
323
383
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
324
384
  if (clusters.length === 0) return 0;
325
385
  let gain = 0;
@@ -354,8 +414,28 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
354
414
  preprocessed[i],
355
415
  preprocessed[i + 1]
356
416
  );
357
- const dbscanResult = dbscan(sNew, imageWidth, imageHeight);
358
- const clusters = dbscanResult.boundingBoxes;
417
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
418
+ let clusters = dbscanResult.boundingBoxes;
419
+ if (clusters.length === 0) {
420
+ const pixelDiffPoints = computePixelDiff(
421
+ cvLib,
422
+ preprocessed[i],
423
+ preprocessed[i + 1]
424
+ );
425
+ if (pixelDiffPoints.length > 0) {
426
+ logger.debug(
427
+ `Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
428
+ );
429
+ dbscanResult = dbscan(
430
+ pixelDiffPoints,
431
+ imageWidth,
432
+ imageHeight,
433
+ void 0,
434
+ 2
435
+ );
436
+ clusters = dbscanResult.boundingBoxes;
437
+ }
438
+ }
359
439
  const clusterPointCounts = new Array(clusters.length).fill(0);
360
440
  for (const label of dbscanResult.labels) {
361
441
  if (label >= 0) {
@@ -373,7 +453,9 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
373
453
  animationIndices,
374
454
  animationWeights
375
455
  );
376
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
456
+ logger.debug(
457
+ `Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
458
+ );
377
459
  edges.push({
378
460
  sourceId: frames[i].id,
379
461
  targetId: frames[i + 1].id,
@@ -418,9 +500,9 @@ async function analyzeFrames(ctx) {
418
500
  // src/core/extractor.ts
419
501
  import { readdir } from "fs/promises";
420
502
  import { join as join2 } from "path";
421
- import ffmpeg from "fluent-ffmpeg";
422
- import ffmpegStatic from "ffmpeg-static";
423
503
  import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
504
+ import { execa } from "execa";
505
+ import ffmpegPath from "ffmpeg-static";
424
506
 
425
507
  // src/utils/paths.ts
426
508
  import { mkdir, stat } from "fs/promises";
@@ -446,8 +528,6 @@ function isSupportedFile(filePath, extensions) {
446
528
  }
447
529
 
448
530
  // src/core/extractor.ts
449
- if (ffmpegStatic) ffmpeg.setFfmpegPath(ffmpegStatic);
450
- ffmpeg.setFfprobePath(ffprobePath);
451
531
  async function extractFrames(ctx) {
452
532
  const framesDir = join2(ctx.workspacePath, "frames");
453
533
  const { inputPath, fps, scale } = ctx.options;
@@ -458,7 +538,10 @@ async function extractFrames(ctx) {
458
538
  if (!exists) {
459
539
  throw new Error(`Input file not found: ${inputPath}`);
460
540
  }
461
- const allExtensions = [...SUPPORTED_VIDEO_EXTENSIONS, ...SUPPORTED_GIF_EXTENSIONS];
541
+ const allExtensions = [
542
+ ...SUPPORTED_VIDEO_EXTENSIONS,
543
+ ...SUPPORTED_GIF_EXTENSIONS
544
+ ];
462
545
  if (!isSupportedFile(inputPath, allExtensions)) {
463
546
  throw new Error(`Unsupported file format: ${inputPath}`);
464
547
  }
@@ -484,35 +567,43 @@ async function extractFrames(ctx) {
484
567
  }
485
568
  async function extractIFrames(inputPath, outputDir, scale) {
486
569
  const outputPattern = join2(outputDir, "frame_%06d.jpg");
487
- await new Promise((resolve2, reject) => {
488
- ffmpeg(inputPath).outputOptions([
489
- `-vf select='eq(pict_type,I)',scale=-1:${scale}`,
490
- "-vsync vfr",
491
- "-q:v 2"
492
- ]).output(outputPattern).on("end", () => resolve2()).on("error", (err) => reject(err)).run();
493
- });
570
+ await execa(ffmpegPath, [
571
+ "-i",
572
+ inputPath,
573
+ "-vf",
574
+ `select='eq(pict_type,I)',scale=-1:${scale}`,
575
+ "-vsync",
576
+ "vfr",
577
+ "-q:v",
578
+ "2",
579
+ outputPattern
580
+ ]);
494
581
  return buildFrameList(outputDir, inputPath);
495
582
  }
496
583
  async function extractByFps(inputPath, outputDir, fps, scale) {
497
584
  const outputPattern = join2(outputDir, "frame_%06d.jpg");
498
- await new Promise((resolve2, reject) => {
499
- ffmpeg(inputPath).outputOptions([
500
- `-vf fps=${fps},scale=-1:${scale}`,
501
- "-q:v 2"
502
- ]).output(outputPattern).on("end", () => resolve2()).on("error", (err) => reject(err)).run();
503
- });
585
+ await execa(ffmpegPath, [
586
+ "-i",
587
+ inputPath,
588
+ "-vf",
589
+ `fps=${fps},scale=-1:${scale}`,
590
+ "-q:v",
591
+ "2",
592
+ outputPattern
593
+ ]);
504
594
  return buildFrameList(outputDir, inputPath);
505
595
  }
506
596
  async function getVideoDuration(inputPath) {
507
- return new Promise((resolve2, reject) => {
508
- ffmpeg.ffprobe(inputPath, (err, metadata) => {
509
- if (err) {
510
- reject(err);
511
- return;
512
- }
513
- resolve2(metadata.format.duration ?? 0);
514
- });
515
- });
597
+ const { stdout } = await execa(ffprobePath, [
598
+ "-v",
599
+ "quiet",
600
+ "-print_format",
601
+ "json",
602
+ "-show_format",
603
+ inputPath
604
+ ]);
605
+ const metadata = JSON.parse(stdout);
606
+ return parseFloat(metadata.format?.duration ?? "0");
516
607
  }
517
608
  async function buildFrameList(framesDir, inputPath) {
518
609
  const files = await readdir(framesDir);
@@ -524,7 +615,9 @@ async function buildFrameList(framesDir, inputPath) {
524
615
  try {
525
616
  duration = await getVideoDuration(inputPath);
526
617
  } catch {
527
- logger.debug("Could not determine video duration; using frame index for timestamps");
618
+ logger.debug(
619
+ "Could not determine video duration; using frame index for timestamps"
620
+ );
528
621
  }
529
622
  return jpgFiles.map((file, index) => ({
530
623
  id: index,
@@ -601,15 +694,13 @@ function resolveOptions(options) {
601
694
  const mode = options.mode;
602
695
  const inputPath = mode === "file" ? options.inputPath : void 0;
603
696
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join4(process.cwd(), "scene-sieve-output"));
604
- const threshold = options.threshold;
605
- if (threshold !== void 0 && (threshold <= 0 || threshold > 1)) {
697
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
698
+ if (threshold <= 0 || threshold > 1) {
606
699
  throw new Error(
607
700
  `threshold must be in range (0, 1], received: ${threshold}`
608
701
  );
609
702
  }
610
- const hasThreshold = threshold !== void 0;
611
- const hasExplicitCount = options.count !== void 0;
612
- const pruneMode = hasThreshold && hasExplicitCount ? "threshold-with-cap" : hasThreshold ? "threshold" : "count";
703
+ const pruneMode = "threshold-with-cap";
613
704
  return {
614
705
  mode,
615
706
  inputPath,
@@ -628,7 +719,10 @@ async function resolveInput(options, workspacePath) {
628
719
  return { frames: [], resolvedInputPath: options.inputPath };
629
720
  }
630
721
  if (options.mode === "buffer") {
631
- const resolvedInputPath = await writeInputBuffer(options.inputBuffer, workspacePath);
722
+ const resolvedInputPath = await writeInputBuffer(
723
+ options.inputBuffer,
724
+ workspacePath
725
+ );
632
726
  return { frames: [], resolvedInputPath };
633
727
  }
634
728
  if (options.mode === "frames") {
@@ -697,7 +791,11 @@ function pruneTo(graph, frames, targetCount) {
697
791
  const heap = new MinHeap();
698
792
  for (const edge of graph) {
699
793
  edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
700
- heap.push({ score: edge.score, srcId: edge.sourceId, tgtId: edge.targetId });
794
+ heap.push({
795
+ score: edge.score,
796
+ srcId: edge.sourceId,
797
+ tgtId: edge.targetId
798
+ });
701
799
  }
702
800
  const surviving = new Set(frames.map((f) => f.id));
703
801
  const firstId = frames[0].id;
@@ -733,9 +831,55 @@ function normalizeScores(graph) {
733
831
  const safeScores = graph.map(
734
832
  (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
735
833
  );
736
- const maxScore = Math.max(...safeScores);
737
- if (maxScore === 0) return safeScores;
738
- return safeScores.map((s) => s / maxScore);
834
+ const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
835
+ if (sorted.length === 0) return safeScores;
836
+ const pIdx = Math.min(
837
+ Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
838
+ sorted.length - 1
839
+ );
840
+ const refScore = sorted[pIdx];
841
+ return safeScores.map((s) => Math.min(s / refScore, 1));
842
+ }
843
+ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
844
+ const result = /* @__PURE__ */ new Set();
845
+ let runStart = 0;
846
+ while (runStart < passingIndices.length) {
847
+ let runEnd = runStart;
848
+ while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
849
+ runEnd++;
850
+ }
851
+ const runLen = runEnd - runStart + 1;
852
+ if (runLen === 1) {
853
+ result.add(graph[passingIndices[runStart]].targetId);
854
+ } else {
855
+ const peaks = [];
856
+ for (let j = runStart; j <= runEnd; j++) {
857
+ const idx = passingIndices[j];
858
+ const score = normalizedScores[idx];
859
+ const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
860
+ const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
861
+ if (score > prevScore && score > nextScore) {
862
+ peaks.push(idx);
863
+ }
864
+ }
865
+ if (peaks.length > 0) {
866
+ for (const peakIdx of peaks) {
867
+ result.add(graph[peakIdx].targetId);
868
+ }
869
+ } else {
870
+ let peakIdx = passingIndices[runStart];
871
+ for (let j = runStart + 1; j <= runEnd; j++) {
872
+ const idx = passingIndices[j];
873
+ if (normalizedScores[idx] > normalizedScores[peakIdx]) {
874
+ peakIdx = idx;
875
+ }
876
+ }
877
+ result.add(graph[peakIdx].targetId);
878
+ }
879
+ }
880
+ runStart = runEnd + 1;
881
+ }
882
+ return result;
739
883
  }
740
884
  function pruneByThreshold(graph, frames, threshold) {
741
885
  if (frames.length === 0) return /* @__PURE__ */ new Set();
@@ -743,11 +887,16 @@ function pruneByThreshold(graph, frames, threshold) {
743
887
  surviving.add(frames[0].id);
744
888
  surviving.add(frames[frames.length - 1].id);
745
889
  const normalized = normalizeScores(graph);
890
+ const passingIndices = [];
746
891
  for (let i = 0; i < graph.length; i++) {
747
892
  if (normalized[i] >= threshold) {
748
- surviving.add(graph[i].targetId);
893
+ passingIndices.push(i);
749
894
  }
750
895
  }
896
+ const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
897
+ for (const id of nmsTargets) {
898
+ surviving.add(id);
899
+ }
751
900
  return surviving;
752
901
  }
753
902
  function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
@@ -831,35 +980,22 @@ async function runPipeline(options) {
831
980
  ctx.status = "ANALYZING";
832
981
  ctx.graph = await analyzeFrames(ctx);
833
982
  ctx.status = "PRUNING";
834
- let survivingIds;
835
- switch (resolvedOptions.pruneMode) {
836
- case "threshold-with-cap":
837
- survivingIds = pruneByThresholdWithCap(
838
- ctx.graph,
839
- ctx.frames,
840
- resolvedOptions.threshold,
841
- resolvedOptions.count
842
- );
843
- break;
844
- case "threshold":
845
- survivingIds = pruneByThreshold(
846
- ctx.graph,
847
- ctx.frames,
848
- resolvedOptions.threshold
849
- );
850
- break;
851
- case "count":
852
- default:
853
- survivingIds = pruneTo(ctx.graph, ctx.frames, resolvedOptions.count);
854
- break;
855
- }
983
+ const survivingIds = pruneByThresholdWithCap(
984
+ ctx.graph,
985
+ ctx.frames,
986
+ resolvedOptions.threshold,
987
+ resolvedOptions.count
988
+ );
856
989
  const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
857
990
  ctx.emitProgress(100);
858
991
  ctx.status = "FINALIZING";
859
992
  let outputFiles = [];
860
993
  let outputBuffers;
861
994
  if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
862
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
995
+ outputBuffers = await readFramesAsBuffers(
996
+ prunedFrames,
997
+ resolvedOptions.quality
998
+ );
863
999
  ctx.emitProgress(100);
864
1000
  } else {
865
1001
  outputFiles = await finalizeOutput(ctx, prunedFrames);
@@ -24,8 +24,8 @@ export interface ResolvedOptions {
24
24
  mode: 'file' | 'buffer' | 'frames';
25
25
  inputPath?: string;
26
26
  count: number;
27
- threshold?: number;
28
- pruneMode: 'count' | 'threshold' | 'threshold-with-cap';
27
+ threshold: number;
28
+ pruneMode: 'threshold-with-cap';
29
29
  outputPath: string;
30
30
  fps: number;
31
31
  scale: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumy-pack/scene-sieve",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "CLI tool for extracting key frames from video and GIF files",
5
5
  "keywords": [
6
6
  "cli",
@@ -58,15 +58,14 @@
58
58
  "@techstark/opencv-js": "4.12.0-release.1",
59
59
  "cli-progress": "^3.12.0",
60
60
  "commander": "^12.1.0",
61
+ "execa": "^9.5.0",
61
62
  "ffmpeg-static": "^5.2.0",
62
- "fluent-ffmpeg": "^2.1.3",
63
63
  "ora": "^8.0.0",
64
64
  "picocolors": "^1.1.1",
65
65
  "sharp": "^0.33.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/cli-progress": "^3.11.6",
69
- "@types/fluent-ffmpeg": "^2.1.27",
70
69
  "@types/node": "^20.11.0",
71
70
  "@vitest/coverage-v8": "^3.2.4"
72
71
  }