@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/cli.mjs CHANGED
@@ -21,7 +21,8 @@ var logger = {
21
21
  console.log(`${pc.blue("info")} ${message}`);
22
22
  },
23
23
  success(message) {
24
- console.log(`${pc.green("done")} ${message}`);
24
+ console.log(`
25
+ ${pc.green("done")} ${message}`);
25
26
  },
26
27
  warn(message) {
27
28
  console.warn(`${pc.yellow("warn")} ${message}`);
@@ -44,10 +45,12 @@ import sharp from "sharp";
44
45
  import { tmpdir } from "os";
45
46
  import { join } from "path";
46
47
  var APP_NAME = "scene-sieve";
47
- var DEFAULT_COUNT = 5;
48
+ var DEFAULT_COUNT = 20;
49
+ var DEFAULT_THRESHOLD = 0.5;
48
50
  var DEFAULT_FPS = 5;
49
51
  var DEFAULT_SCALE = 720;
50
52
  var DEFAULT_QUALITY = 80;
53
+ var NORMALIZATION_PERCENTILE = 0.9;
51
54
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
52
55
  var TEMP_BASE_DIR = tmpdir();
53
56
  var SUPPORTED_VIDEO_EXTENSIONS = [
@@ -67,6 +70,10 @@ var IOU_THRESHOLD = 0.9;
67
70
  var DECAY_LAMBDA = 0.95;
68
71
  var ANIMATION_FRAME_THRESHOLD = 5;
69
72
  var MATCH_DISTANCE_THRESHOLD = 0.75;
73
+ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
74
+ var PIXELDIFF_BINARY_THRESHOLD = 30;
75
+ var PIXELDIFF_CONTOUR_MIN_AREA = 100;
76
+ var PIXELDIFF_SAMPLE_SPACING = 8;
70
77
  function getTempWorkspaceDir(sessionId) {
71
78
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
72
79
  }
@@ -326,6 +333,60 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
326
333
  if (matches) matches.delete();
327
334
  }
328
335
  }
336
+ function computePixelDiff(cvLib, frame1, frame2) {
337
+ const cv = cvLib;
338
+ const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
339
+ const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
340
+ const diff = new cv.Mat();
341
+ const blurred = new cv.Mat();
342
+ const binary = new cv.Mat();
343
+ const contours = new cv.MatVector();
344
+ const hierarchy = new cv.Mat();
345
+ try {
346
+ mat1.data.set(frame1.data);
347
+ mat2.data.set(frame2.data);
348
+ cv.absdiff(mat1, mat2, diff);
349
+ const ksize = new cv.Size(
350
+ PIXELDIFF_GAUSSIAN_KERNEL,
351
+ PIXELDIFF_GAUSSIAN_KERNEL
352
+ );
353
+ cv.GaussianBlur(diff, blurred, ksize, 0);
354
+ cv.threshold(
355
+ blurred,
356
+ binary,
357
+ PIXELDIFF_BINARY_THRESHOLD,
358
+ 255,
359
+ cv.THRESH_BINARY
360
+ );
361
+ cv.findContours(
362
+ binary,
363
+ contours,
364
+ hierarchy,
365
+ cv.RETR_EXTERNAL,
366
+ cv.CHAIN_APPROX_SIMPLE
367
+ );
368
+ const points = [];
369
+ for (let c = 0; c < contours.size(); c++) {
370
+ const contour = contours.get(c);
371
+ const rect = cv.boundingRect(contour);
372
+ if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
373
+ for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
374
+ for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
375
+ points.push({ x, y });
376
+ }
377
+ }
378
+ }
379
+ return points;
380
+ } finally {
381
+ mat1.delete();
382
+ mat2.delete();
383
+ diff.delete();
384
+ blurred.delete();
385
+ binary.delete();
386
+ contours.delete();
387
+ hierarchy.delete();
388
+ }
389
+ }
329
390
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
330
391
  if (clusters.length === 0) return 0;
331
392
  let gain = 0;
@@ -360,8 +421,28 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
360
421
  preprocessed[i],
361
422
  preprocessed[i + 1]
362
423
  );
363
- const dbscanResult = dbscan(sNew, imageWidth, imageHeight);
364
- const clusters = dbscanResult.boundingBoxes;
424
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
425
+ let clusters = dbscanResult.boundingBoxes;
426
+ if (clusters.length === 0) {
427
+ const pixelDiffPoints = computePixelDiff(
428
+ cvLib,
429
+ preprocessed[i],
430
+ preprocessed[i + 1]
431
+ );
432
+ if (pixelDiffPoints.length > 0) {
433
+ logger.debug(
434
+ `Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
435
+ );
436
+ dbscanResult = dbscan(
437
+ pixelDiffPoints,
438
+ imageWidth,
439
+ imageHeight,
440
+ void 0,
441
+ 2
442
+ );
443
+ clusters = dbscanResult.boundingBoxes;
444
+ }
445
+ }
365
446
  const clusterPointCounts = new Array(clusters.length).fill(0);
366
447
  for (const label of dbscanResult.labels) {
367
448
  if (label >= 0) {
@@ -379,7 +460,9 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
379
460
  animationIndices,
380
461
  animationWeights
381
462
  );
382
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
463
+ logger.debug(
464
+ `Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
465
+ );
383
466
  edges.push({
384
467
  sourceId: frames[i].id,
385
468
  targetId: frames[i + 1].id,
@@ -424,12 +507,13 @@ async function analyzeFrames(ctx) {
424
507
  // src/core/extractor.ts
425
508
  import { readdir } from "fs/promises";
426
509
  import { join as join2 } from "path";
427
- import ffmpeg from "fluent-ffmpeg";
428
- import ffmpegStatic from "ffmpeg-static";
429
510
  import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
511
+ import { execa } from "execa";
512
+ import ffmpegPath from "ffmpeg-static";
430
513
 
431
514
  // src/utils/paths.ts
432
515
  import { mkdir, stat } from "fs/promises";
516
+ import { homedir } from "os";
433
517
  import { basename, extname, resolve } from "path";
434
518
  async function ensureDir(dirPath) {
435
519
  await mkdir(dirPath, { recursive: true });
@@ -442,6 +526,16 @@ async function fileExists(filePath) {
442
526
  return false;
443
527
  }
444
528
  }
529
+ function expandTilde(p) {
530
+ if (p === "~") return homedir();
531
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
532
+ return resolve(homedir(), p.slice(2));
533
+ }
534
+ return p;
535
+ }
536
+ function resolveAbsolute(p) {
537
+ return resolve(expandTilde(p));
538
+ }
445
539
  function deriveOutputPath(inputPath) {
446
540
  const dir = resolve(inputPath, "..");
447
541
  const name = basename(inputPath, extname(inputPath));
@@ -452,8 +546,6 @@ function isSupportedFile(filePath, extensions) {
452
546
  }
453
547
 
454
548
  // src/core/extractor.ts
455
- if (ffmpegStatic) ffmpeg.setFfmpegPath(ffmpegStatic);
456
- ffmpeg.setFfprobePath(ffprobePath);
457
549
  async function extractFrames(ctx) {
458
550
  const framesDir = join2(ctx.workspacePath, "frames");
459
551
  const { inputPath, fps, scale } = ctx.options;
@@ -464,7 +556,10 @@ async function extractFrames(ctx) {
464
556
  if (!exists) {
465
557
  throw new Error(`Input file not found: ${inputPath}`);
466
558
  }
467
- const allExtensions = [...SUPPORTED_VIDEO_EXTENSIONS, ...SUPPORTED_GIF_EXTENSIONS];
559
+ const allExtensions = [
560
+ ...SUPPORTED_VIDEO_EXTENSIONS,
561
+ ...SUPPORTED_GIF_EXTENSIONS
562
+ ];
468
563
  if (!isSupportedFile(inputPath, allExtensions)) {
469
564
  throw new Error(`Unsupported file format: ${inputPath}`);
470
565
  }
@@ -490,35 +585,43 @@ async function extractFrames(ctx) {
490
585
  }
491
586
  async function extractIFrames(inputPath, outputDir, scale) {
492
587
  const outputPattern = join2(outputDir, "frame_%06d.jpg");
493
- await new Promise((resolve2, reject) => {
494
- ffmpeg(inputPath).outputOptions([
495
- `-vf select='eq(pict_type,I)',scale=-1:${scale}`,
496
- "-vsync vfr",
497
- "-q:v 2"
498
- ]).output(outputPattern).on("end", () => resolve2()).on("error", (err) => reject(err)).run();
499
- });
588
+ await execa(ffmpegPath, [
589
+ "-i",
590
+ inputPath,
591
+ "-vf",
592
+ `select='eq(pict_type,I)',scale=-1:${scale}`,
593
+ "-vsync",
594
+ "vfr",
595
+ "-q:v",
596
+ "2",
597
+ outputPattern
598
+ ]);
500
599
  return buildFrameList(outputDir, inputPath);
501
600
  }
502
601
  async function extractByFps(inputPath, outputDir, fps, scale) {
503
602
  const outputPattern = join2(outputDir, "frame_%06d.jpg");
504
- await new Promise((resolve2, reject) => {
505
- ffmpeg(inputPath).outputOptions([
506
- `-vf fps=${fps},scale=-1:${scale}`,
507
- "-q:v 2"
508
- ]).output(outputPattern).on("end", () => resolve2()).on("error", (err) => reject(err)).run();
509
- });
603
+ await execa(ffmpegPath, [
604
+ "-i",
605
+ inputPath,
606
+ "-vf",
607
+ `fps=${fps},scale=-1:${scale}`,
608
+ "-q:v",
609
+ "2",
610
+ outputPattern
611
+ ]);
510
612
  return buildFrameList(outputDir, inputPath);
511
613
  }
512
614
  async function getVideoDuration(inputPath) {
513
- return new Promise((resolve2, reject) => {
514
- ffmpeg.ffprobe(inputPath, (err, metadata) => {
515
- if (err) {
516
- reject(err);
517
- return;
518
- }
519
- resolve2(metadata.format.duration ?? 0);
520
- });
521
- });
615
+ const { stdout } = await execa(ffprobePath, [
616
+ "-v",
617
+ "quiet",
618
+ "-print_format",
619
+ "json",
620
+ "-show_format",
621
+ inputPath
622
+ ]);
623
+ const metadata = JSON.parse(stdout);
624
+ return parseFloat(metadata.format?.duration ?? "0");
522
625
  }
523
626
  async function buildFrameList(framesDir, inputPath) {
524
627
  const files = await readdir(framesDir);
@@ -530,7 +633,9 @@ async function buildFrameList(framesDir, inputPath) {
530
633
  try {
531
634
  duration = await getVideoDuration(inputPath);
532
635
  } catch {
533
- logger.debug("Could not determine video duration; using frame index for timestamps");
636
+ logger.debug(
637
+ "Could not determine video duration; using frame index for timestamps"
638
+ );
534
639
  }
535
640
  return jpgFiles.map((file, index) => ({
536
641
  id: index,
@@ -605,17 +710,15 @@ async function readFramesAsBuffers(frameNodes, quality) {
605
710
  // src/core/input-resolver.ts
606
711
  function resolveOptions(options) {
607
712
  const mode = options.mode;
608
- const inputPath = mode === "file" ? options.inputPath : void 0;
713
+ const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
609
714
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join4(process.cwd(), "scene-sieve-output"));
610
- const threshold = options.threshold;
611
- if (threshold !== void 0 && (threshold <= 0 || threshold > 1)) {
715
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
716
+ if (threshold <= 0 || threshold > 1) {
612
717
  throw new Error(
613
718
  `threshold must be in range (0, 1], received: ${threshold}`
614
719
  );
615
720
  }
616
- const hasThreshold = threshold !== void 0;
617
- const hasExplicitCount = options.count !== void 0;
618
- const pruneMode = hasThreshold && hasExplicitCount ? "threshold-with-cap" : hasThreshold ? "threshold" : "count";
721
+ const pruneMode = "threshold-with-cap";
619
722
  return {
620
723
  mode,
621
724
  inputPath,
@@ -631,10 +734,16 @@ function resolveOptions(options) {
631
734
  }
632
735
  async function resolveInput(options, workspacePath) {
633
736
  if (options.mode === "file") {
634
- return { frames: [], resolvedInputPath: options.inputPath };
737
+ return {
738
+ frames: [],
739
+ resolvedInputPath: resolveAbsolute(options.inputPath)
740
+ };
635
741
  }
636
742
  if (options.mode === "buffer") {
637
- const resolvedInputPath = await writeInputBuffer(options.inputBuffer, workspacePath);
743
+ const resolvedInputPath = await writeInputBuffer(
744
+ options.inputBuffer,
745
+ workspacePath
746
+ );
638
747
  return { frames: [], resolvedInputPath };
639
748
  }
640
749
  if (options.mode === "frames") {
@@ -703,7 +812,11 @@ function pruneTo(graph, frames, targetCount) {
703
812
  const heap = new MinHeap();
704
813
  for (const edge of graph) {
705
814
  edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
706
- heap.push({ score: edge.score, srcId: edge.sourceId, tgtId: edge.targetId });
815
+ heap.push({
816
+ score: edge.score,
817
+ srcId: edge.sourceId,
818
+ tgtId: edge.targetId
819
+ });
707
820
  }
708
821
  const surviving = new Set(frames.map((f) => f.id));
709
822
  const firstId = frames[0].id;
@@ -739,9 +852,55 @@ function normalizeScores(graph) {
739
852
  const safeScores = graph.map(
740
853
  (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
741
854
  );
742
- const maxScore = Math.max(...safeScores);
743
- if (maxScore === 0) return safeScores;
744
- return safeScores.map((s) => s / maxScore);
855
+ const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
856
+ if (sorted.length === 0) return safeScores;
857
+ const pIdx = Math.min(
858
+ Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
859
+ sorted.length - 1
860
+ );
861
+ const refScore = sorted[pIdx];
862
+ return safeScores.map((s) => Math.min(s / refScore, 1));
863
+ }
864
+ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
865
+ const result = /* @__PURE__ */ new Set();
866
+ let runStart = 0;
867
+ while (runStart < passingIndices.length) {
868
+ let runEnd = runStart;
869
+ while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
870
+ runEnd++;
871
+ }
872
+ const runLen = runEnd - runStart + 1;
873
+ if (runLen === 1) {
874
+ result.add(graph[passingIndices[runStart]].targetId);
875
+ } else {
876
+ const peaks = [];
877
+ for (let j = runStart; j <= runEnd; j++) {
878
+ const idx = passingIndices[j];
879
+ const score = normalizedScores[idx];
880
+ const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
881
+ const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
882
+ if (score > prevScore && score > nextScore) {
883
+ peaks.push(idx);
884
+ }
885
+ }
886
+ if (peaks.length > 0) {
887
+ for (const peakIdx of peaks) {
888
+ result.add(graph[peakIdx].targetId);
889
+ }
890
+ } else {
891
+ let peakIdx = passingIndices[runStart];
892
+ for (let j = runStart + 1; j <= runEnd; j++) {
893
+ const idx = passingIndices[j];
894
+ if (normalizedScores[idx] > normalizedScores[peakIdx]) {
895
+ peakIdx = idx;
896
+ }
897
+ }
898
+ result.add(graph[peakIdx].targetId);
899
+ }
900
+ }
901
+ runStart = runEnd + 1;
902
+ }
903
+ return result;
745
904
  }
746
905
  function pruneByThreshold(graph, frames, threshold) {
747
906
  if (frames.length === 0) return /* @__PURE__ */ new Set();
@@ -749,11 +908,16 @@ function pruneByThreshold(graph, frames, threshold) {
749
908
  surviving.add(frames[0].id);
750
909
  surviving.add(frames[frames.length - 1].id);
751
910
  const normalized = normalizeScores(graph);
911
+ const passingIndices = [];
752
912
  for (let i = 0; i < graph.length; i++) {
753
913
  if (normalized[i] >= threshold) {
754
- surviving.add(graph[i].targetId);
914
+ passingIndices.push(i);
755
915
  }
756
916
  }
917
+ const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
918
+ for (const id of nmsTargets) {
919
+ surviving.add(id);
920
+ }
757
921
  return surviving;
758
922
  }
759
923
  function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
@@ -837,35 +1001,22 @@ async function runPipeline(options) {
837
1001
  ctx.status = "ANALYZING";
838
1002
  ctx.graph = await analyzeFrames(ctx);
839
1003
  ctx.status = "PRUNING";
840
- let survivingIds;
841
- switch (resolvedOptions.pruneMode) {
842
- case "threshold-with-cap":
843
- survivingIds = pruneByThresholdWithCap(
844
- ctx.graph,
845
- ctx.frames,
846
- resolvedOptions.threshold,
847
- resolvedOptions.count
848
- );
849
- break;
850
- case "threshold":
851
- survivingIds = pruneByThreshold(
852
- ctx.graph,
853
- ctx.frames,
854
- resolvedOptions.threshold
855
- );
856
- break;
857
- case "count":
858
- default:
859
- survivingIds = pruneTo(ctx.graph, ctx.frames, resolvedOptions.count);
860
- break;
861
- }
1004
+ const survivingIds = pruneByThresholdWithCap(
1005
+ ctx.graph,
1006
+ ctx.frames,
1007
+ resolvedOptions.threshold,
1008
+ resolvedOptions.count
1009
+ );
862
1010
  const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
863
1011
  ctx.emitProgress(100);
864
1012
  ctx.status = "FINALIZING";
865
1013
  let outputFiles = [];
866
1014
  let outputBuffers;
867
1015
  if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
868
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1016
+ outputBuffers = await readFramesAsBuffers(
1017
+ prunedFrames,
1018
+ resolvedOptions.quality
1019
+ );
869
1020
  ctx.emitProgress(100);
870
1021
  } else {
871
1022
  outputFiles = await finalizeOutput(ctx, prunedFrames);
@@ -901,7 +1052,10 @@ async function runPipeline(options) {
901
1052
  var require3 = createRequire2(import.meta.url);
902
1053
  var { version } = require3("../package.json");
903
1054
  var program = new Command();
904
- program.name("scene-sieve").description("Extract key frames from video and GIF files").version(version).argument("<input>", "Input video or GIF file path").option("-n, --count <number>", "Number of frames to keep (default: 5 when no --threshold)").option("-t, --threshold <number>", "Normalized threshold 0~1 (keeps frames above ratio of max change; combine with -n to cap result count)").option("-o, --output <path>", "Output directory path").option("--fps <number>", "Fallback FPS for frame extraction", "5").option("-s, --scale <number>", "Scale size for vision analysis", "720").option("-q, --quality <number>", "JPEG output quality 1-100", "80").option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
1055
+ program.name("scene-sieve").description("Extract key frames from video and GIF files").version(version).argument("<input>", "Input video or GIF file path").option("-n, --count <number>", "Max number of frames to keep (default: 20)").option(
1056
+ "-t, --threshold <number>",
1057
+ "Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
1058
+ ).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Fallback FPS for frame extraction", "5").option("-s, --scale <number>", "Scale size for vision analysis", "720").option("-q, --quality <number>", "JPEG output quality 1-100", "80").option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
905
1059
  const { default: ora } = await import("ora");
906
1060
  const { default: cliProgress } = await import("cli-progress");
907
1061
  const spinner = ora("Initializing...").start();
@@ -933,8 +1087,12 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
933
1087
  `
934
1088
  Done! ${result.originalFramesCount} frames -> ${result.prunedFramesCount} scenes (${result.executionTimeMs}ms)`
935
1089
  );
936
- console.log(`Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`);
937
- result.outputFiles.forEach((f) => console.log(` - ${f}`));
1090
+ if (opts.debug) {
1091
+ console.log(
1092
+ `Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`
1093
+ );
1094
+ result.outputFiles.forEach((f) => console.log(` - ${f}`));
1095
+ }
938
1096
  } catch (error) {
939
1097
  spinner.fail(
940
1098
  `Failed: ${error instanceof Error ? error.message : String(error)}`
@@ -1,8 +1,10 @@
1
1
  export declare const APP_NAME = "scene-sieve";
2
- export declare const DEFAULT_COUNT = 5;
2
+ export declare const DEFAULT_COUNT = 20;
3
+ export declare const DEFAULT_THRESHOLD = 0.5;
3
4
  export declare const DEFAULT_FPS = 5;
4
5
  export declare const DEFAULT_SCALE = 720;
5
6
  export declare const DEFAULT_QUALITY = 80;
7
+ export declare const NORMALIZATION_PERCENTILE = 0.9;
6
8
  export declare const WORKSPACE_PREFIX = "scene-sieve-";
7
9
  export declare const TEMP_BASE_DIR: string;
8
10
  export declare const SUPPORTED_VIDEO_EXTENSIONS: string[];
@@ -17,4 +19,8 @@ export declare const IOU_THRESHOLD = 0.9;
17
19
  export declare const DECAY_LAMBDA = 0.95;
18
20
  export declare const ANIMATION_FRAME_THRESHOLD = 5;
19
21
  export declare const MATCH_DISTANCE_THRESHOLD = 0.75;
22
+ export declare const PIXELDIFF_GAUSSIAN_KERNEL = 3;
23
+ export declare const PIXELDIFF_BINARY_THRESHOLD = 30;
24
+ export declare const PIXELDIFF_CONTOUR_MIN_AREA = 100;
25
+ export declare const PIXELDIFF_SAMPLE_SPACING = 8;
20
26
  export declare function getTempWorkspaceDir(sessionId: string): string;
@@ -1,5 +1,6 @@
1
1
  import type { BoundingBox, ProcessContext, ScoreEdge } from '../types/index.js';
2
2
  import type { Point2D } from './dbscan.js';
3
+ type CvLib = typeof import('@techstark/opencv-js');
3
4
  export declare function preprocessFrame(framePath: string, scale: number): Promise<{
4
5
  data: Uint8Array;
5
6
  width: number;
@@ -15,6 +16,30 @@ export interface AKAZEResult {
15
16
  sNew: Point2D[];
16
17
  sLoss: Point2D[];
17
18
  }
19
+ /**
20
+ * Pixel-level difference fallback for AKAZE blind spots.
21
+ *
22
+ * When AKAZE produces sparse results (typical for UI screen recordings
23
+ * where form fields, dropdowns, or overlays change), this function
24
+ * detects changed regions via cv.absdiff and generates synthetic
25
+ * Point2D[] that feed into the existing DBSCAN → IoU → G(t) pipeline.
26
+ *
27
+ * Algorithm:
28
+ * 1. absdiff(frame1, frame2) → grayscale difference
29
+ * 2. GaussianBlur → reduce JPEG compression noise
30
+ * 3. threshold → binary mask of significant changes
31
+ * 4. findContours → bounding rects of changed regions
32
+ * 5. Grid sampling within each bounding rect → Point2D[]
33
+ */
34
+ export declare function computePixelDiff(cvLib: CvLib, frame1: {
35
+ data: Uint8Array;
36
+ width: number;
37
+ height: number;
38
+ }, frame2: {
39
+ data: Uint8Array;
40
+ width: number;
41
+ height: number;
42
+ }): Point2D[];
18
43
  export declare function computeInformationGain(clusters: BoundingBox[], clusterPoints: number[], imageArea: number, animationIndices: Set<number>, animationWeights: number[]): number;
19
44
  /**
20
45
  * Analyze adjacent frame pairs to compute information gain scores (G(t)).
@@ -27,3 +52,4 @@ export declare function computeInformationGain(clusters: BoundingBox[], clusterP
27
52
  * 4. G(t) Information Gain Scoring
28
53
  */
29
54
  export declare function analyzeFrames(ctx: ProcessContext): Promise<ScoreEdge[]>;
55
+ export {};
@@ -1,7 +1,7 @@
1
1
  export { runPipeline } from './orchestrator.js';
2
- export { analyzeFrames, computeIoU, computeInformationGain } from './analyzer.js';
2
+ export { analyzeFrames, computeIoU, computeInformationGain, } from './analyzer.js';
3
3
  export { extractFrames } from './extractor.js';
4
- export { pruneTo, pruneByThreshold, pruneByThresholdWithCap } from './pruner.js';
4
+ export { pruneTo, pruneByThreshold, pruneByThresholdWithCap, suppressConsecutiveRuns, } from './pruner.js';
5
5
  export { dbscan } from './dbscan.js';
6
6
  export type { Point2D } from './dbscan.js';
7
7
  export { resolveInput, resolveOptions } from './input-resolver.js';
@@ -1,4 +1,4 @@
1
- import type { FrameNode, SieveOptions, ResolvedOptions } from '../types/index.js';
1
+ import type { FrameNode, ResolvedOptions, SieveOptions } from '../types/index.js';
2
2
  export declare function resolveOptions(options: SieveOptions): ResolvedOptions;
3
3
  /**
4
4
  * Resolve the input source to a list of FrameNode[].
@@ -14,17 +14,36 @@ import type { FrameNode, ScoreEdge } from '../types/index.js';
14
14
  */
15
15
  export declare function pruneTo(graph: ScoreEdge[], frames: FrameNode[], targetCount: number): Set<number>;
16
16
  /**
17
- * Threshold-based pruning — O(N).
17
+ * Non-Maximum Suppression (NMS) for consecutive edge runs.
18
18
  *
19
- * Scores are normalized to [0, 1] via max-normalization.
20
- * threshold=0.5 means "keep frames where the change is at least 50%
21
- * of the maximum change observed in this sequence".
19
+ * Consecutive edges share overlapping frames (edge i: frame i->i+1,
20
+ * edge i+1: frame i+1->i+2), so consecutive passing edges indicate
21
+ * the same visual transition region. This function groups consecutive
22
+ * passing edge indices into "runs" and keeps all distinct peaks per run.
22
23
  *
23
- * First and last frames are always preserved (boundary protection).
24
+ * Multi-peak detection: within each run, strict local maxima (score higher
25
+ * than both neighbors) are identified. Each local maximum represents a
26
+ * distinct visual transition. If no strict local maxima exist (plateau or
27
+ * monotonic sequence), the global peak of the run is selected as fallback.
28
+ *
29
+ * Single-element runs are unaffected (isolated transitions preserved).
24
30
  *
25
- * Unlike pruneTo (which targets an exact count via greedy merge),
26
- * this function has no count limit every significant scene transition
27
- * above the threshold is preserved.
31
+ * @param graph - full ScoreEdge array (for targetId lookup)
32
+ * @param passingIndices - edge indices that passed threshold filtering (sorted ascending)
33
+ * @param normalizedScores - normalized score array (same length as graph)
34
+ * @returns Set of targetIds to add to surviving set (one or more per run)
35
+ */
36
+ export declare function suppressConsecutiveRuns(graph: ScoreEdge[], passingIndices: number[], normalizedScores: number[]): Set<number>;
37
+ /**
38
+ * Threshold-based pruning with NMS -- O(N).
39
+ *
40
+ * 1. Scores are normalized to [0, 1] via percentile normalization.
41
+ * 2. Edges with normalized score >= threshold are collected.
42
+ * 3. Non-Maximum Suppression groups consecutive passing edges and keeps
43
+ * only the peak per run, preventing near-duplicate frame selection
44
+ * from a single visual transition.
45
+ *
46
+ * First and last frames are always preserved (boundary protection).
28
47
  */
29
48
  export declare function pruneByThreshold(graph: ScoreEdge[], frames: FrameNode[], threshold: number): Set<number>;
30
49
  /**