@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.mjs CHANGED
@@ -15,7 +15,8 @@ var logger = {
15
15
  console.log(`${pc.blue("info")} ${message}`);
16
16
  },
17
17
  success(message) {
18
- console.log(`${pc.green("done")} ${message}`);
18
+ console.log(`
19
+ ${pc.green("done")} ${message}`);
19
20
  },
20
21
  warn(message) {
21
22
  console.warn(`${pc.yellow("warn")} ${message}`);
@@ -38,10 +39,12 @@ import sharp from "sharp";
38
39
  import { tmpdir } from "os";
39
40
  import { join } from "path";
40
41
  var APP_NAME = "scene-sieve";
41
- var DEFAULT_COUNT = 5;
42
+ var DEFAULT_COUNT = 20;
43
+ var DEFAULT_THRESHOLD = 0.5;
42
44
  var DEFAULT_FPS = 5;
43
45
  var DEFAULT_SCALE = 720;
44
46
  var DEFAULT_QUALITY = 80;
47
+ var NORMALIZATION_PERCENTILE = 0.9;
45
48
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
46
49
  var TEMP_BASE_DIR = tmpdir();
47
50
  var SUPPORTED_VIDEO_EXTENSIONS = [
@@ -61,6 +64,10 @@ var IOU_THRESHOLD = 0.9;
61
64
  var DECAY_LAMBDA = 0.95;
62
65
  var ANIMATION_FRAME_THRESHOLD = 5;
63
66
  var MATCH_DISTANCE_THRESHOLD = 0.75;
67
+ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
68
+ var PIXELDIFF_BINARY_THRESHOLD = 30;
69
+ var PIXELDIFF_CONTOUR_MIN_AREA = 100;
70
+ var PIXELDIFF_SAMPLE_SPACING = 8;
64
71
  function getTempWorkspaceDir(sessionId) {
65
72
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
66
73
  }
@@ -320,6 +327,60 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
320
327
  if (matches) matches.delete();
321
328
  }
322
329
  }
330
+ function computePixelDiff(cvLib, frame1, frame2) {
331
+ const cv = cvLib;
332
+ const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
333
+ const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
334
+ const diff = new cv.Mat();
335
+ const blurred = new cv.Mat();
336
+ const binary = new cv.Mat();
337
+ const contours = new cv.MatVector();
338
+ const hierarchy = new cv.Mat();
339
+ try {
340
+ mat1.data.set(frame1.data);
341
+ mat2.data.set(frame2.data);
342
+ cv.absdiff(mat1, mat2, diff);
343
+ const ksize = new cv.Size(
344
+ PIXELDIFF_GAUSSIAN_KERNEL,
345
+ PIXELDIFF_GAUSSIAN_KERNEL
346
+ );
347
+ cv.GaussianBlur(diff, blurred, ksize, 0);
348
+ cv.threshold(
349
+ blurred,
350
+ binary,
351
+ PIXELDIFF_BINARY_THRESHOLD,
352
+ 255,
353
+ cv.THRESH_BINARY
354
+ );
355
+ cv.findContours(
356
+ binary,
357
+ contours,
358
+ hierarchy,
359
+ cv.RETR_EXTERNAL,
360
+ cv.CHAIN_APPROX_SIMPLE
361
+ );
362
+ const points = [];
363
+ for (let c = 0; c < contours.size(); c++) {
364
+ const contour = contours.get(c);
365
+ const rect = cv.boundingRect(contour);
366
+ if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
367
+ for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
368
+ for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
369
+ points.push({ x, y });
370
+ }
371
+ }
372
+ }
373
+ return points;
374
+ } finally {
375
+ mat1.delete();
376
+ mat2.delete();
377
+ diff.delete();
378
+ blurred.delete();
379
+ binary.delete();
380
+ contours.delete();
381
+ hierarchy.delete();
382
+ }
383
+ }
323
384
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
324
385
  if (clusters.length === 0) return 0;
325
386
  let gain = 0;
@@ -354,8 +415,28 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
354
415
  preprocessed[i],
355
416
  preprocessed[i + 1]
356
417
  );
357
- const dbscanResult = dbscan(sNew, imageWidth, imageHeight);
358
- const clusters = dbscanResult.boundingBoxes;
418
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
419
+ let clusters = dbscanResult.boundingBoxes;
420
+ if (clusters.length === 0) {
421
+ const pixelDiffPoints = computePixelDiff(
422
+ cvLib,
423
+ preprocessed[i],
424
+ preprocessed[i + 1]
425
+ );
426
+ if (pixelDiffPoints.length > 0) {
427
+ logger.debug(
428
+ `Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
429
+ );
430
+ dbscanResult = dbscan(
431
+ pixelDiffPoints,
432
+ imageWidth,
433
+ imageHeight,
434
+ void 0,
435
+ 2
436
+ );
437
+ clusters = dbscanResult.boundingBoxes;
438
+ }
439
+ }
359
440
  const clusterPointCounts = new Array(clusters.length).fill(0);
360
441
  for (const label of dbscanResult.labels) {
361
442
  if (label >= 0) {
@@ -373,7 +454,9 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
373
454
  animationIndices,
374
455
  animationWeights
375
456
  );
376
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
457
+ logger.debug(
458
+ `Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
459
+ );
377
460
  edges.push({
378
461
  sourceId: frames[i].id,
379
462
  targetId: frames[i + 1].id,
@@ -418,12 +501,13 @@ async function analyzeFrames(ctx) {
418
501
  // src/core/extractor.ts
419
502
  import { readdir } from "fs/promises";
420
503
  import { join as join2 } from "path";
421
- import ffmpeg from "fluent-ffmpeg";
422
- import ffmpegStatic from "ffmpeg-static";
423
504
  import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
505
+ import { execa } from "execa";
506
+ import ffmpegPath from "ffmpeg-static";
424
507
 
425
508
  // src/utils/paths.ts
426
509
  import { mkdir, stat } from "fs/promises";
510
+ import { homedir } from "os";
427
511
  import { basename, extname, resolve } from "path";
428
512
  async function ensureDir(dirPath) {
429
513
  await mkdir(dirPath, { recursive: true });
@@ -436,6 +520,16 @@ async function fileExists(filePath) {
436
520
  return false;
437
521
  }
438
522
  }
523
+ function expandTilde(p) {
524
+ if (p === "~") return homedir();
525
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
526
+ return resolve(homedir(), p.slice(2));
527
+ }
528
+ return p;
529
+ }
530
+ function resolveAbsolute(p) {
531
+ return resolve(expandTilde(p));
532
+ }
439
533
  function deriveOutputPath(inputPath) {
440
534
  const dir = resolve(inputPath, "..");
441
535
  const name = basename(inputPath, extname(inputPath));
@@ -446,8 +540,6 @@ function isSupportedFile(filePath, extensions) {
446
540
  }
447
541
 
448
542
  // src/core/extractor.ts
449
- if (ffmpegStatic) ffmpeg.setFfmpegPath(ffmpegStatic);
450
- ffmpeg.setFfprobePath(ffprobePath);
451
543
  async function extractFrames(ctx) {
452
544
  const framesDir = join2(ctx.workspacePath, "frames");
453
545
  const { inputPath, fps, scale } = ctx.options;
@@ -458,7 +550,10 @@ async function extractFrames(ctx) {
458
550
  if (!exists) {
459
551
  throw new Error(`Input file not found: ${inputPath}`);
460
552
  }
461
- const allExtensions = [...SUPPORTED_VIDEO_EXTENSIONS, ...SUPPORTED_GIF_EXTENSIONS];
553
+ const allExtensions = [
554
+ ...SUPPORTED_VIDEO_EXTENSIONS,
555
+ ...SUPPORTED_GIF_EXTENSIONS
556
+ ];
462
557
  if (!isSupportedFile(inputPath, allExtensions)) {
463
558
  throw new Error(`Unsupported file format: ${inputPath}`);
464
559
  }
@@ -484,35 +579,43 @@ async function extractFrames(ctx) {
484
579
  }
485
580
  async function extractIFrames(inputPath, outputDir, scale) {
486
581
  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
- });
582
+ await execa(ffmpegPath, [
583
+ "-i",
584
+ inputPath,
585
+ "-vf",
586
+ `select='eq(pict_type,I)',scale=-1:${scale}`,
587
+ "-vsync",
588
+ "vfr",
589
+ "-q:v",
590
+ "2",
591
+ outputPattern
592
+ ]);
494
593
  return buildFrameList(outputDir, inputPath);
495
594
  }
496
595
  async function extractByFps(inputPath, outputDir, fps, scale) {
497
596
  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
- });
597
+ await execa(ffmpegPath, [
598
+ "-i",
599
+ inputPath,
600
+ "-vf",
601
+ `fps=${fps},scale=-1:${scale}`,
602
+ "-q:v",
603
+ "2",
604
+ outputPattern
605
+ ]);
504
606
  return buildFrameList(outputDir, inputPath);
505
607
  }
506
608
  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
- });
609
+ const { stdout } = await execa(ffprobePath, [
610
+ "-v",
611
+ "quiet",
612
+ "-print_format",
613
+ "json",
614
+ "-show_format",
615
+ inputPath
616
+ ]);
617
+ const metadata = JSON.parse(stdout);
618
+ return parseFloat(metadata.format?.duration ?? "0");
516
619
  }
517
620
  async function buildFrameList(framesDir, inputPath) {
518
621
  const files = await readdir(framesDir);
@@ -524,7 +627,9 @@ async function buildFrameList(framesDir, inputPath) {
524
627
  try {
525
628
  duration = await getVideoDuration(inputPath);
526
629
  } catch {
527
- logger.debug("Could not determine video duration; using frame index for timestamps");
630
+ logger.debug(
631
+ "Could not determine video duration; using frame index for timestamps"
632
+ );
528
633
  }
529
634
  return jpgFiles.map((file, index) => ({
530
635
  id: index,
@@ -599,17 +704,15 @@ async function readFramesAsBuffers(frameNodes, quality) {
599
704
  // src/core/input-resolver.ts
600
705
  function resolveOptions(options) {
601
706
  const mode = options.mode;
602
- const inputPath = mode === "file" ? options.inputPath : void 0;
707
+ const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
603
708
  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)) {
709
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
710
+ if (threshold <= 0 || threshold > 1) {
606
711
  throw new Error(
607
712
  `threshold must be in range (0, 1], received: ${threshold}`
608
713
  );
609
714
  }
610
- const hasThreshold = threshold !== void 0;
611
- const hasExplicitCount = options.count !== void 0;
612
- const pruneMode = hasThreshold && hasExplicitCount ? "threshold-with-cap" : hasThreshold ? "threshold" : "count";
715
+ const pruneMode = "threshold-with-cap";
613
716
  return {
614
717
  mode,
615
718
  inputPath,
@@ -625,10 +728,16 @@ function resolveOptions(options) {
625
728
  }
626
729
  async function resolveInput(options, workspacePath) {
627
730
  if (options.mode === "file") {
628
- return { frames: [], resolvedInputPath: options.inputPath };
731
+ return {
732
+ frames: [],
733
+ resolvedInputPath: resolveAbsolute(options.inputPath)
734
+ };
629
735
  }
630
736
  if (options.mode === "buffer") {
631
- const resolvedInputPath = await writeInputBuffer(options.inputBuffer, workspacePath);
737
+ const resolvedInputPath = await writeInputBuffer(
738
+ options.inputBuffer,
739
+ workspacePath
740
+ );
632
741
  return { frames: [], resolvedInputPath };
633
742
  }
634
743
  if (options.mode === "frames") {
@@ -697,7 +806,11 @@ function pruneTo(graph, frames, targetCount) {
697
806
  const heap = new MinHeap();
698
807
  for (const edge of graph) {
699
808
  edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
700
- heap.push({ score: edge.score, srcId: edge.sourceId, tgtId: edge.targetId });
809
+ heap.push({
810
+ score: edge.score,
811
+ srcId: edge.sourceId,
812
+ tgtId: edge.targetId
813
+ });
701
814
  }
702
815
  const surviving = new Set(frames.map((f) => f.id));
703
816
  const firstId = frames[0].id;
@@ -733,9 +846,55 @@ function normalizeScores(graph) {
733
846
  const safeScores = graph.map(
734
847
  (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
735
848
  );
736
- const maxScore = Math.max(...safeScores);
737
- if (maxScore === 0) return safeScores;
738
- return safeScores.map((s) => s / maxScore);
849
+ const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
850
+ if (sorted.length === 0) return safeScores;
851
+ const pIdx = Math.min(
852
+ Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
853
+ sorted.length - 1
854
+ );
855
+ const refScore = sorted[pIdx];
856
+ return safeScores.map((s) => Math.min(s / refScore, 1));
857
+ }
858
+ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
859
+ const result = /* @__PURE__ */ new Set();
860
+ let runStart = 0;
861
+ while (runStart < passingIndices.length) {
862
+ let runEnd = runStart;
863
+ while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
864
+ runEnd++;
865
+ }
866
+ const runLen = runEnd - runStart + 1;
867
+ if (runLen === 1) {
868
+ result.add(graph[passingIndices[runStart]].targetId);
869
+ } else {
870
+ const peaks = [];
871
+ for (let j = runStart; j <= runEnd; j++) {
872
+ const idx = passingIndices[j];
873
+ const score = normalizedScores[idx];
874
+ const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
875
+ const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
876
+ if (score > prevScore && score > nextScore) {
877
+ peaks.push(idx);
878
+ }
879
+ }
880
+ if (peaks.length > 0) {
881
+ for (const peakIdx of peaks) {
882
+ result.add(graph[peakIdx].targetId);
883
+ }
884
+ } else {
885
+ let peakIdx = passingIndices[runStart];
886
+ for (let j = runStart + 1; j <= runEnd; j++) {
887
+ const idx = passingIndices[j];
888
+ if (normalizedScores[idx] > normalizedScores[peakIdx]) {
889
+ peakIdx = idx;
890
+ }
891
+ }
892
+ result.add(graph[peakIdx].targetId);
893
+ }
894
+ }
895
+ runStart = runEnd + 1;
896
+ }
897
+ return result;
739
898
  }
740
899
  function pruneByThreshold(graph, frames, threshold) {
741
900
  if (frames.length === 0) return /* @__PURE__ */ new Set();
@@ -743,11 +902,16 @@ function pruneByThreshold(graph, frames, threshold) {
743
902
  surviving.add(frames[0].id);
744
903
  surviving.add(frames[frames.length - 1].id);
745
904
  const normalized = normalizeScores(graph);
905
+ const passingIndices = [];
746
906
  for (let i = 0; i < graph.length; i++) {
747
907
  if (normalized[i] >= threshold) {
748
- surviving.add(graph[i].targetId);
908
+ passingIndices.push(i);
749
909
  }
750
910
  }
911
+ const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
912
+ for (const id of nmsTargets) {
913
+ surviving.add(id);
914
+ }
751
915
  return surviving;
752
916
  }
753
917
  function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
@@ -831,35 +995,22 @@ async function runPipeline(options) {
831
995
  ctx.status = "ANALYZING";
832
996
  ctx.graph = await analyzeFrames(ctx);
833
997
  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
- }
998
+ const survivingIds = pruneByThresholdWithCap(
999
+ ctx.graph,
1000
+ ctx.frames,
1001
+ resolvedOptions.threshold,
1002
+ resolvedOptions.count
1003
+ );
856
1004
  const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
857
1005
  ctx.emitProgress(100);
858
1006
  ctx.status = "FINALIZING";
859
1007
  let outputFiles = [];
860
1008
  let outputBuffers;
861
1009
  if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
862
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1010
+ outputBuffers = await readFramesAsBuffers(
1011
+ prunedFrames,
1012
+ resolvedOptions.quality
1013
+ );
863
1014
  ctx.emitProgress(100);
864
1015
  } else {
865
1016
  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;
@@ -1,5 +1,15 @@
1
1
  export declare function ensureDir(dirPath: string): Promise<void>;
2
2
  export declare function fileExists(filePath: string): Promise<boolean>;
3
+ /**
4
+ * Expand leading ~ to homedir. Node's path.resolve() does not expand ~,
5
+ * so paths like ~/Desktop/foo depend on process.cwd() and can produce
6
+ * different results when run from different directories.
7
+ */
8
+ export declare function expandTilde(p: string): string;
9
+ /**
10
+ * Resolve path to absolute. Expands ~ to homedir first so that the result
11
+ * does not depend on process.cwd().
12
+ */
3
13
  export declare function resolveAbsolute(p: string): string;
4
14
  /**
5
15
  * Derive default output directory name from input file path.
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.3",
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
  }