@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/cli.mjs CHANGED
@@ -44,10 +44,12 @@ import sharp from "sharp";
44
44
  import { tmpdir } from "os";
45
45
  import { join } from "path";
46
46
  var APP_NAME = "scene-sieve";
47
- var DEFAULT_COUNT = 5;
47
+ var DEFAULT_COUNT = 20;
48
+ var DEFAULT_THRESHOLD = 0.5;
48
49
  var DEFAULT_FPS = 5;
49
50
  var DEFAULT_SCALE = 720;
50
51
  var DEFAULT_QUALITY = 80;
52
+ var NORMALIZATION_PERCENTILE = 0.9;
51
53
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
52
54
  var TEMP_BASE_DIR = tmpdir();
53
55
  var SUPPORTED_VIDEO_EXTENSIONS = [
@@ -67,6 +69,10 @@ var IOU_THRESHOLD = 0.9;
67
69
  var DECAY_LAMBDA = 0.95;
68
70
  var ANIMATION_FRAME_THRESHOLD = 5;
69
71
  var MATCH_DISTANCE_THRESHOLD = 0.75;
72
+ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
73
+ var PIXELDIFF_BINARY_THRESHOLD = 30;
74
+ var PIXELDIFF_CONTOUR_MIN_AREA = 100;
75
+ var PIXELDIFF_SAMPLE_SPACING = 8;
70
76
  function getTempWorkspaceDir(sessionId) {
71
77
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
72
78
  }
@@ -326,6 +332,60 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
326
332
  if (matches) matches.delete();
327
333
  }
328
334
  }
335
+ function computePixelDiff(cvLib, frame1, frame2) {
336
+ const cv = cvLib;
337
+ const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
338
+ const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
339
+ const diff = new cv.Mat();
340
+ const blurred = new cv.Mat();
341
+ const binary = new cv.Mat();
342
+ const contours = new cv.MatVector();
343
+ const hierarchy = new cv.Mat();
344
+ try {
345
+ mat1.data.set(frame1.data);
346
+ mat2.data.set(frame2.data);
347
+ cv.absdiff(mat1, mat2, diff);
348
+ const ksize = new cv.Size(
349
+ PIXELDIFF_GAUSSIAN_KERNEL,
350
+ PIXELDIFF_GAUSSIAN_KERNEL
351
+ );
352
+ cv.GaussianBlur(diff, blurred, ksize, 0);
353
+ cv.threshold(
354
+ blurred,
355
+ binary,
356
+ PIXELDIFF_BINARY_THRESHOLD,
357
+ 255,
358
+ cv.THRESH_BINARY
359
+ );
360
+ cv.findContours(
361
+ binary,
362
+ contours,
363
+ hierarchy,
364
+ cv.RETR_EXTERNAL,
365
+ cv.CHAIN_APPROX_SIMPLE
366
+ );
367
+ const points = [];
368
+ for (let c = 0; c < contours.size(); c++) {
369
+ const contour = contours.get(c);
370
+ const rect = cv.boundingRect(contour);
371
+ if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
372
+ for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
373
+ for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
374
+ points.push({ x, y });
375
+ }
376
+ }
377
+ }
378
+ return points;
379
+ } finally {
380
+ mat1.delete();
381
+ mat2.delete();
382
+ diff.delete();
383
+ blurred.delete();
384
+ binary.delete();
385
+ contours.delete();
386
+ hierarchy.delete();
387
+ }
388
+ }
329
389
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
330
390
  if (clusters.length === 0) return 0;
331
391
  let gain = 0;
@@ -360,8 +420,28 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
360
420
  preprocessed[i],
361
421
  preprocessed[i + 1]
362
422
  );
363
- const dbscanResult = dbscan(sNew, imageWidth, imageHeight);
364
- const clusters = dbscanResult.boundingBoxes;
423
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
424
+ let clusters = dbscanResult.boundingBoxes;
425
+ if (clusters.length === 0) {
426
+ const pixelDiffPoints = computePixelDiff(
427
+ cvLib,
428
+ preprocessed[i],
429
+ preprocessed[i + 1]
430
+ );
431
+ if (pixelDiffPoints.length > 0) {
432
+ logger.debug(
433
+ `Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
434
+ );
435
+ dbscanResult = dbscan(
436
+ pixelDiffPoints,
437
+ imageWidth,
438
+ imageHeight,
439
+ void 0,
440
+ 2
441
+ );
442
+ clusters = dbscanResult.boundingBoxes;
443
+ }
444
+ }
365
445
  const clusterPointCounts = new Array(clusters.length).fill(0);
366
446
  for (const label of dbscanResult.labels) {
367
447
  if (label >= 0) {
@@ -379,7 +459,9 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
379
459
  animationIndices,
380
460
  animationWeights
381
461
  );
382
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
462
+ logger.debug(
463
+ `Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
464
+ );
383
465
  edges.push({
384
466
  sourceId: frames[i].id,
385
467
  targetId: frames[i + 1].id,
@@ -424,9 +506,9 @@ async function analyzeFrames(ctx) {
424
506
  // src/core/extractor.ts
425
507
  import { readdir } from "fs/promises";
426
508
  import { join as join2 } from "path";
427
- import ffmpeg from "fluent-ffmpeg";
428
- import ffmpegStatic from "ffmpeg-static";
429
509
  import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
510
+ import { execa } from "execa";
511
+ import ffmpegPath from "ffmpeg-static";
430
512
 
431
513
  // src/utils/paths.ts
432
514
  import { mkdir, stat } from "fs/promises";
@@ -452,8 +534,6 @@ function isSupportedFile(filePath, extensions) {
452
534
  }
453
535
 
454
536
  // src/core/extractor.ts
455
- if (ffmpegStatic) ffmpeg.setFfmpegPath(ffmpegStatic);
456
- ffmpeg.setFfprobePath(ffprobePath);
457
537
  async function extractFrames(ctx) {
458
538
  const framesDir = join2(ctx.workspacePath, "frames");
459
539
  const { inputPath, fps, scale } = ctx.options;
@@ -464,7 +544,10 @@ async function extractFrames(ctx) {
464
544
  if (!exists) {
465
545
  throw new Error(`Input file not found: ${inputPath}`);
466
546
  }
467
- const allExtensions = [...SUPPORTED_VIDEO_EXTENSIONS, ...SUPPORTED_GIF_EXTENSIONS];
547
+ const allExtensions = [
548
+ ...SUPPORTED_VIDEO_EXTENSIONS,
549
+ ...SUPPORTED_GIF_EXTENSIONS
550
+ ];
468
551
  if (!isSupportedFile(inputPath, allExtensions)) {
469
552
  throw new Error(`Unsupported file format: ${inputPath}`);
470
553
  }
@@ -490,35 +573,43 @@ async function extractFrames(ctx) {
490
573
  }
491
574
  async function extractIFrames(inputPath, outputDir, scale) {
492
575
  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
- });
576
+ await execa(ffmpegPath, [
577
+ "-i",
578
+ inputPath,
579
+ "-vf",
580
+ `select='eq(pict_type,I)',scale=-1:${scale}`,
581
+ "-vsync",
582
+ "vfr",
583
+ "-q:v",
584
+ "2",
585
+ outputPattern
586
+ ]);
500
587
  return buildFrameList(outputDir, inputPath);
501
588
  }
502
589
  async function extractByFps(inputPath, outputDir, fps, scale) {
503
590
  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
- });
591
+ await execa(ffmpegPath, [
592
+ "-i",
593
+ inputPath,
594
+ "-vf",
595
+ `fps=${fps},scale=-1:${scale}`,
596
+ "-q:v",
597
+ "2",
598
+ outputPattern
599
+ ]);
510
600
  return buildFrameList(outputDir, inputPath);
511
601
  }
512
602
  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
- });
603
+ const { stdout } = await execa(ffprobePath, [
604
+ "-v",
605
+ "quiet",
606
+ "-print_format",
607
+ "json",
608
+ "-show_format",
609
+ inputPath
610
+ ]);
611
+ const metadata = JSON.parse(stdout);
612
+ return parseFloat(metadata.format?.duration ?? "0");
522
613
  }
523
614
  async function buildFrameList(framesDir, inputPath) {
524
615
  const files = await readdir(framesDir);
@@ -530,7 +621,9 @@ async function buildFrameList(framesDir, inputPath) {
530
621
  try {
531
622
  duration = await getVideoDuration(inputPath);
532
623
  } catch {
533
- logger.debug("Could not determine video duration; using frame index for timestamps");
624
+ logger.debug(
625
+ "Could not determine video duration; using frame index for timestamps"
626
+ );
534
627
  }
535
628
  return jpgFiles.map((file, index) => ({
536
629
  id: index,
@@ -607,15 +700,13 @@ function resolveOptions(options) {
607
700
  const mode = options.mode;
608
701
  const inputPath = mode === "file" ? options.inputPath : void 0;
609
702
  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)) {
703
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
704
+ if (threshold <= 0 || threshold > 1) {
612
705
  throw new Error(
613
706
  `threshold must be in range (0, 1], received: ${threshold}`
614
707
  );
615
708
  }
616
- const hasThreshold = threshold !== void 0;
617
- const hasExplicitCount = options.count !== void 0;
618
- const pruneMode = hasThreshold && hasExplicitCount ? "threshold-with-cap" : hasThreshold ? "threshold" : "count";
709
+ const pruneMode = "threshold-with-cap";
619
710
  return {
620
711
  mode,
621
712
  inputPath,
@@ -634,7 +725,10 @@ async function resolveInput(options, workspacePath) {
634
725
  return { frames: [], resolvedInputPath: options.inputPath };
635
726
  }
636
727
  if (options.mode === "buffer") {
637
- const resolvedInputPath = await writeInputBuffer(options.inputBuffer, workspacePath);
728
+ const resolvedInputPath = await writeInputBuffer(
729
+ options.inputBuffer,
730
+ workspacePath
731
+ );
638
732
  return { frames: [], resolvedInputPath };
639
733
  }
640
734
  if (options.mode === "frames") {
@@ -703,7 +797,11 @@ function pruneTo(graph, frames, targetCount) {
703
797
  const heap = new MinHeap();
704
798
  for (const edge of graph) {
705
799
  edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
706
- heap.push({ score: edge.score, srcId: edge.sourceId, tgtId: edge.targetId });
800
+ heap.push({
801
+ score: edge.score,
802
+ srcId: edge.sourceId,
803
+ tgtId: edge.targetId
804
+ });
707
805
  }
708
806
  const surviving = new Set(frames.map((f) => f.id));
709
807
  const firstId = frames[0].id;
@@ -739,9 +837,55 @@ function normalizeScores(graph) {
739
837
  const safeScores = graph.map(
740
838
  (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
741
839
  );
742
- const maxScore = Math.max(...safeScores);
743
- if (maxScore === 0) return safeScores;
744
- return safeScores.map((s) => s / maxScore);
840
+ const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
841
+ if (sorted.length === 0) return safeScores;
842
+ const pIdx = Math.min(
843
+ Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
844
+ sorted.length - 1
845
+ );
846
+ const refScore = sorted[pIdx];
847
+ return safeScores.map((s) => Math.min(s / refScore, 1));
848
+ }
849
+ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
850
+ const result = /* @__PURE__ */ new Set();
851
+ let runStart = 0;
852
+ while (runStart < passingIndices.length) {
853
+ let runEnd = runStart;
854
+ while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
855
+ runEnd++;
856
+ }
857
+ const runLen = runEnd - runStart + 1;
858
+ if (runLen === 1) {
859
+ result.add(graph[passingIndices[runStart]].targetId);
860
+ } else {
861
+ const peaks = [];
862
+ for (let j = runStart; j <= runEnd; j++) {
863
+ const idx = passingIndices[j];
864
+ const score = normalizedScores[idx];
865
+ const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
866
+ const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
867
+ if (score > prevScore && score > nextScore) {
868
+ peaks.push(idx);
869
+ }
870
+ }
871
+ if (peaks.length > 0) {
872
+ for (const peakIdx of peaks) {
873
+ result.add(graph[peakIdx].targetId);
874
+ }
875
+ } else {
876
+ let peakIdx = passingIndices[runStart];
877
+ for (let j = runStart + 1; j <= runEnd; j++) {
878
+ const idx = passingIndices[j];
879
+ if (normalizedScores[idx] > normalizedScores[peakIdx]) {
880
+ peakIdx = idx;
881
+ }
882
+ }
883
+ result.add(graph[peakIdx].targetId);
884
+ }
885
+ }
886
+ runStart = runEnd + 1;
887
+ }
888
+ return result;
745
889
  }
746
890
  function pruneByThreshold(graph, frames, threshold) {
747
891
  if (frames.length === 0) return /* @__PURE__ */ new Set();
@@ -749,11 +893,16 @@ function pruneByThreshold(graph, frames, threshold) {
749
893
  surviving.add(frames[0].id);
750
894
  surviving.add(frames[frames.length - 1].id);
751
895
  const normalized = normalizeScores(graph);
896
+ const passingIndices = [];
752
897
  for (let i = 0; i < graph.length; i++) {
753
898
  if (normalized[i] >= threshold) {
754
- surviving.add(graph[i].targetId);
899
+ passingIndices.push(i);
755
900
  }
756
901
  }
902
+ const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
903
+ for (const id of nmsTargets) {
904
+ surviving.add(id);
905
+ }
757
906
  return surviving;
758
907
  }
759
908
  function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
@@ -837,35 +986,22 @@ async function runPipeline(options) {
837
986
  ctx.status = "ANALYZING";
838
987
  ctx.graph = await analyzeFrames(ctx);
839
988
  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
- }
989
+ const survivingIds = pruneByThresholdWithCap(
990
+ ctx.graph,
991
+ ctx.frames,
992
+ resolvedOptions.threshold,
993
+ resolvedOptions.count
994
+ );
862
995
  const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
863
996
  ctx.emitProgress(100);
864
997
  ctx.status = "FINALIZING";
865
998
  let outputFiles = [];
866
999
  let outputBuffers;
867
1000
  if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
868
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1001
+ outputBuffers = await readFramesAsBuffers(
1002
+ prunedFrames,
1003
+ resolvedOptions.quality
1004
+ );
869
1005
  ctx.emitProgress(100);
870
1006
  } else {
871
1007
  outputFiles = await finalizeOutput(ctx, prunedFrames);
@@ -901,7 +1037,10 @@ async function runPipeline(options) {
901
1037
  var require3 = createRequire2(import.meta.url);
902
1038
  var { version } = require3("../package.json");
903
1039
  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) => {
1040
+ 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(
1041
+ "-t, --threshold <number>",
1042
+ "Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
1043
+ ).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
1044
  const { default: ora } = await import("ora");
906
1045
  const { default: cliProgress } = await import("cli-progress");
907
1046
  const spinner = ora("Initializing...").start();
@@ -933,8 +1072,12 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
933
1072
  `
934
1073
  Done! ${result.originalFramesCount} frames -> ${result.prunedFramesCount} scenes (${result.executionTimeMs}ms)`
935
1074
  );
936
- console.log(`Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`);
937
- result.outputFiles.forEach((f) => console.log(` - ${f}`));
1075
+ if (opts.debug) {
1076
+ console.log(
1077
+ `Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`
1078
+ );
1079
+ result.outputFiles.forEach((f) => console.log(` - ${f}`));
1080
+ }
938
1081
  } catch (error) {
939
1082
  spinner.fail(
940
1083
  `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
  /**