@doedja/scenecut 1.0.1 → 1.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.
@@ -280,31 +280,66 @@ class RingBuffer {
280
280
  this.availableBytes += chunkSize;
281
281
  }
282
282
  /**
283
- * Read data from the ring buffer
283
+ * Read data from the ring buffer (allocates new buffer)
284
284
  */
285
285
  read(size) {
286
286
  if (size > this.availableBytes) {
287
287
  throw new Error('RingBuffer underflow: not enough data available');
288
288
  }
289
289
  const result = Buffer.allocUnsafe(size);
290
+ this.readIntoBuffer(result, 0, size);
291
+ return result;
292
+ }
293
+ /**
294
+ * Read data directly into a pre-allocated Uint8Array (zero-allocation)
295
+ */
296
+ readInto(target, offset, size) {
297
+ if (size > this.availableBytes) {
298
+ throw new Error('RingBuffer underflow: not enough data available');
299
+ }
290
300
  const endSpace = this.capacity - this.readPos;
291
301
  if (size <= endSpace) {
292
302
  // No wrap-around needed
293
- this.buffer.copy(result, 0, this.readPos, this.readPos + size);
303
+ for (let i = 0; i < size; i++) {
304
+ target[offset + i] = this.buffer[this.readPos + i];
305
+ }
294
306
  this.readPos += size;
295
307
  }
296
308
  else {
297
309
  // Wrap-around: split read
298
- this.buffer.copy(result, 0, this.readPos, this.capacity);
299
- this.buffer.copy(result, endSpace, 0, size - endSpace);
300
- this.readPos = size - endSpace;
310
+ for (let i = 0; i < endSpace; i++) {
311
+ target[offset + i] = this.buffer[this.readPos + i];
312
+ }
313
+ const remaining = size - endSpace;
314
+ for (let i = 0; i < remaining; i++) {
315
+ target[offset + endSpace + i] = this.buffer[i];
316
+ }
317
+ this.readPos = remaining;
301
318
  }
302
319
  // Wrap read position if at end
303
320
  if (this.readPos >= this.capacity) {
304
321
  this.readPos = 0;
305
322
  }
306
323
  this.availableBytes -= size;
307
- return result;
324
+ }
325
+ /**
326
+ * Internal: read into a Node.js Buffer
327
+ */
328
+ readIntoBuffer(result, offset, size) {
329
+ const endSpace = this.capacity - this.readPos;
330
+ if (size <= endSpace) {
331
+ this.buffer.copy(result, offset, this.readPos, this.readPos + size);
332
+ this.readPos += size;
333
+ }
334
+ else {
335
+ this.buffer.copy(result, offset, this.readPos, this.capacity);
336
+ this.buffer.copy(result, offset + endSpace, 0, size - endSpace);
337
+ this.readPos = size - endSpace;
338
+ }
339
+ if (this.readPos >= this.capacity) {
340
+ this.readPos = 0;
341
+ }
342
+ this.availableBytes -= size;
308
343
  }
309
344
  /**
310
345
  * Get number of bytes available to read
@@ -333,7 +368,7 @@ class FFmpegDecoder {
333
368
  this.frameBuffer = new FrameBuffer(this.options.maxBufferFrames);
334
369
  }
335
370
  /**
336
- * Get video metadata
371
+ * Get video metadata (with richer codec/format info)
337
372
  */
338
373
  async getMetadata() {
339
374
  if (this.metadata) {
@@ -360,7 +395,10 @@ class FFmpegDecoder {
360
395
  resolution: {
361
396
  width: videoStream.width || 0,
362
397
  height: videoStream.height || 0
363
- }
398
+ },
399
+ codec: videoStream.codec_name || undefined,
400
+ pixelFormat: videoStream.pix_fmt || undefined,
401
+ bitrate: metadata.format.bit_rate ? parseInt(String(metadata.format.bit_rate)) : undefined
364
402
  };
365
403
  resolve(this.metadata);
366
404
  });
@@ -379,16 +417,26 @@ class FFmpegDecoder {
379
417
  /**
380
418
  * Extract frames as grayscale data
381
419
  *
420
+ * Uses pre-allocated alternating buffers to eliminate double allocation.
421
+ * Auto-sizes ring buffer based on video resolution.
422
+ *
382
423
  * @param onFrame Callback for each frame
383
424
  * @param onProgress Optional progress callback
425
+ * @param signal Optional AbortSignal for cancellation
384
426
  */
385
- async extractFrames(onFrame, onProgress) {
427
+ async extractFrames(onFrame, onProgress, signal) {
386
428
  const metadata = await this.getMetadata();
387
429
  const { width, height } = metadata.resolution;
388
430
  return new Promise((resolve, reject) => {
389
431
  let frameNumber = 0;
390
- const ringBuffer = new RingBuffer(); // 8MB ring buffer
391
432
  const frameSize = width * height; // Grayscale: 1 byte per pixel
433
+ // Auto-size ring buffer based on resolution (min 4MB, fits 3 frames)
434
+ const ringBufferSize = Math.max(4 * 1024 * 1024, frameSize * 3);
435
+ const ringBuffer = new RingBuffer(ringBufferSize);
436
+ // Pre-allocate two alternating frame buffers (eliminates double allocation)
437
+ const frameBufferA = new Uint8Array(frameSize);
438
+ const frameBufferB = new Uint8Array(frameSize);
439
+ let useBufferA = true;
392
440
  const command = ffmpeg__namespace.default(this.videoPath)
393
441
  .outputOptions([
394
442
  '-f', 'image2pipe',
@@ -402,20 +450,38 @@ class FFmpegDecoder {
402
450
  resolve();
403
451
  });
404
452
  const stream = command.pipe();
453
+ // Listen for abort signal
454
+ if (signal) {
455
+ const onAbort = () => {
456
+ stream.destroy();
457
+ reject(new Error('Detection aborted'));
458
+ };
459
+ if (signal.aborted) {
460
+ stream.destroy();
461
+ reject(new Error('Detection aborted'));
462
+ return;
463
+ }
464
+ signal.addEventListener('abort', onAbort, { once: true });
465
+ }
405
466
  stream.on('data', async (chunk) => {
406
- // Write chunk to ring buffer (no allocation, no copying)
467
+ // Write chunk to ring buffer
407
468
  ringBuffer.write(chunk);
408
469
  // Process complete frames
409
470
  while (ringBuffer.available() >= frameSize) {
410
- const frameData = ringBuffer.read(frameSize);
411
471
  // Skip frames if requested
412
472
  if (this.options.skipFrames > 0 && frameNumber % (this.options.skipFrames + 1) !== 0) {
473
+ // Still need to consume the data from the ring buffer
474
+ ringBuffer.read(frameSize);
413
475
  frameNumber++;
414
476
  continue;
415
477
  }
416
- // Create RawFrame
478
+ // Read directly into pre-allocated buffer (zero-allocation)
479
+ const targetBuffer = useBufferA ? frameBufferA : frameBufferB;
480
+ ringBuffer.readInto(targetBuffer, 0, frameSize);
481
+ useBufferA = !useBufferA;
482
+ // Create RawFrame (reuses the pre-allocated buffer - no copy)
417
483
  const frame = {
418
- data: new Uint8Array(frameData),
484
+ data: targetBuffer,
419
485
  width,
420
486
  height,
421
487
  stride: width,
@@ -485,6 +551,59 @@ class FFmpegDecoder {
485
551
  });
486
552
  });
487
553
  }
554
+ /**
555
+ * Extract multiple frame images in a single FFmpeg invocation
556
+ * Uses FFmpeg's select filter to avoid N+1 process spawning
557
+ *
558
+ * @param frameNumbers Array of frame numbers to extract
559
+ * @param options Image extraction options
560
+ */
561
+ async extractFrameImages(frameNumbers, options) {
562
+ const metadata = await this.getMetadata();
563
+ // Ensure output directory exists
564
+ if (!fs__namespace.existsSync(options.outputDir)) {
565
+ fs__namespace.mkdirSync(options.outputDir, { recursive: true });
566
+ }
567
+ const format = options.format || 'jpg';
568
+ const quality = options.quality || 85;
569
+ const template = options.filenameTemplate || 'scene_{frame}';
570
+ // Build FFmpeg select filter expression
571
+ // select='eq(n,100)+eq(n,200)+eq(n,300)'
572
+ const selectExpr = frameNumbers.map(n => `eq(n\\,${n})`).join('+');
573
+ return new Promise((resolve, reject) => {
574
+ const outputPaths = [];
575
+ // Generate output filenames
576
+ for (const frameNum of frameNumbers) {
577
+ const timestamp = frameNum / metadata.fps;
578
+ const filename = template
579
+ .replace('{frame}', String(frameNum))
580
+ .replace('{timestamp}', timestamp.toFixed(3));
581
+ outputPaths.push(path__namespace.join(options.outputDir, `${filename}.${format}`));
582
+ }
583
+ // Use FFmpeg with select filter and output pattern
584
+ const outputPattern = path__namespace.join(options.outputDir, `${template.replace('{frame}', '%d').replace('{timestamp}', '%d')}.${format}`);
585
+ const outputOptions = [
586
+ '-vf', `select='${selectExpr}',setpts=N/TB`,
587
+ '-vsync', 'vfr'
588
+ ];
589
+ if (format === 'jpg') {
590
+ outputOptions.push('-qscale:v', String(Math.round((100 - quality) / 3.33)));
591
+ }
592
+ if (options.width) {
593
+ outputOptions.push('-vf', `select='${selectExpr}',scale=${options.width}:-1,setpts=N/TB`);
594
+ }
595
+ ffmpeg__namespace.default(this.videoPath)
596
+ .outputOptions(outputOptions)
597
+ .output(outputPattern)
598
+ .on('error', (err) => {
599
+ reject(new Error(`FFmpeg frame extraction error: ${err.message}`));
600
+ })
601
+ .on('end', () => {
602
+ resolve(outputPaths);
603
+ })
604
+ .run();
605
+ });
606
+ }
488
607
  /**
489
608
  * Get the frame buffer
490
609
  */
@@ -507,19 +626,27 @@ class FFmpegDecoder {
507
626
  * - Memory allocation and management
508
627
  * - Calling WASM functions
509
628
  * - Data marshalling between JS and WASM
629
+ * - Double-buffering to avoid redundant frame copies
510
630
  */
511
631
  class WasmBridge {
512
632
  constructor() {
513
633
  this.module = null;
514
634
  this.initialized = false;
515
- // Pre-allocated WASM buffers for frame processing
516
- this.prevFramePtr = 0; // Raw previous frame
517
- this.curFramePtr = 0; // Raw current frame
518
- this.prevPaddedPtr = 0; // Padded previous frame
519
- this.curPaddedPtr = 0; // Padded current frame
520
- this.allocatedFrameSize = 0; // Size of raw frame buffers
521
- this.allocatedPaddedSize = 0; // Size of padded frame buffers
635
+ // Double-buffered WASM pointers for frame processing
636
+ // Slot A and Slot B raw frame buffers
637
+ this.slotARawPtr = 0;
638
+ this.slotBRawPtr = 0;
639
+ // Slot A and Slot B padded frame buffers
640
+ this.slotAPaddedPtr = 0;
641
+ this.slotBPaddedPtr = 0;
642
+ // Which slot currently holds the "previous" frame (true = A, false = B)
643
+ this.prevIsSlotA = true;
644
+ // Whether the previous slot has valid padded data
645
+ this.prevSlotPadded = false;
646
+ this.allocatedFrameSize = 0;
647
+ this.allocatedPaddedSize = 0;
522
648
  }
649
+ // Frame dimensions (reserved for future use in validation/resizing)
523
650
  /**
524
651
  * Initialize the WASM module
525
652
  */
@@ -552,11 +679,8 @@ class WasmBridge {
552
679
  }
553
680
  }
554
681
  /**
555
- * Pre-allocate WASM buffers for frame processing
556
- * This eliminates per-frame allocation overhead and reduces memory copies
557
- *
558
- * @param width Frame width
559
- * @param height Frame height
682
+ * Pre-allocate WASM buffers for frame processing.
683
+ * Allocates double-buffered raw + padded slots and pre-allocates the MB array.
560
684
  */
561
685
  allocateBuffers(width, height) {
562
686
  this.ensureInitialized();
@@ -564,63 +688,104 @@ class WasmBridge {
564
688
  const paddedSize = this.module._calculate_padded_size(width, height);
565
689
  // Allocate or re-allocate raw frame buffers if size changed
566
690
  if (frameSize !== this.allocatedFrameSize) {
567
- if (this.prevFramePtr)
568
- this.module._free(this.prevFramePtr);
569
- if (this.curFramePtr)
570
- this.module._free(this.curFramePtr);
571
- this.prevFramePtr = this.module._malloc(frameSize);
572
- this.curFramePtr = this.module._malloc(frameSize);
691
+ if (this.slotARawPtr)
692
+ this.module._free(this.slotARawPtr);
693
+ if (this.slotBRawPtr)
694
+ this.module._free(this.slotBRawPtr);
695
+ this.slotARawPtr = this.module._malloc(frameSize);
696
+ this.slotBRawPtr = this.module._malloc(frameSize);
573
697
  this.allocatedFrameSize = frameSize;
574
698
  }
575
699
  // Allocate or re-allocate padded frame buffers if size changed
576
700
  if (paddedSize !== this.allocatedPaddedSize) {
577
- if (this.prevPaddedPtr)
578
- this.module._free(this.prevPaddedPtr);
579
- if (this.curPaddedPtr)
580
- this.module._free(this.curPaddedPtr);
581
- this.prevPaddedPtr = this.module._malloc(paddedSize);
582
- this.curPaddedPtr = this.module._malloc(paddedSize);
701
+ if (this.slotAPaddedPtr)
702
+ this.module._free(this.slotAPaddedPtr);
703
+ if (this.slotBPaddedPtr)
704
+ this.module._free(this.slotBPaddedPtr);
705
+ this.slotAPaddedPtr = this.module._malloc(paddedSize);
706
+ this.slotBPaddedPtr = this.module._malloc(paddedSize);
583
707
  this.allocatedPaddedSize = paddedSize;
584
708
  }
709
+ // Reset double-buffer state
710
+ this.prevIsSlotA = true;
711
+ this.prevSlotPadded = false;
712
+ // Pre-allocate macroblock array in WASM
713
+ const mbResult = this.module._allocate_mb_array(width, height);
714
+ if (mbResult === 0) {
715
+ throw new Error('Failed to pre-allocate macroblock array in WASM');
716
+ }
585
717
  }
586
718
  /**
587
- * Detect scene change between two frames
719
+ * Detect scene change between two frames using double-buffering.
588
720
  *
589
- * Uses pre-allocated WASM buffers to eliminate per-frame allocation
590
- * and reduce memory copies from 3 to 1 per frame.
721
+ * On first call, both frames are copied and padded.
722
+ * On subsequent calls, only the new current frame is copied and padded;
723
+ * the previous frame is already in WASM memory from the last call.
591
724
  *
592
725
  * @param prevFrame Previous frame
593
726
  * @param curFrame Current frame
594
727
  * @param intraCount Number of consecutive non-scene-change frames
595
- * @param fcode Motion search range parameter (default: 4 = 256 pixels)
596
- * @returns true if scene change detected, false otherwise
728
+ * @param fcode Motion search range parameter
729
+ * @param intraThresh Primary intra threshold
730
+ * @param intraThresh2 Secondary intra threshold (sSAD comparison)
731
+ * @returns Scene change result with confidence score
597
732
  */
598
- detectSceneChange(prevFrame, curFrame, intraCount, fcode = 4) {
733
+ detectSceneChange(prevFrame, curFrame, intraCount, fcode = 4, intraThresh = 2000, intraThresh2 = 90) {
599
734
  this.ensureInitialized();
600
735
  // Validate inputs
601
736
  if (prevFrame.width !== curFrame.width || prevFrame.height !== curFrame.height) {
602
737
  throw new Error('Frame dimensions must match');
603
738
  }
604
- // Ensure buffers are allocated (should be done once at start)
605
- if (!this.prevFramePtr || this.allocatedFrameSize !== prevFrame.data.length) {
739
+ // Ensure buffers are allocated
740
+ if (!this.slotARawPtr || this.allocatedFrameSize !== prevFrame.data.length) {
606
741
  this.allocateBuffers(prevFrame.width, prevFrame.height);
607
742
  }
608
- // Single copy: Raw frames -> WASM memory (eliminates 2 extra copies)
609
- this.module.HEAPU8.set(prevFrame.data, this.prevFramePtr);
610
- this.module.HEAPU8.set(curFrame.data, this.curFramePtr);
611
- // Pad frames in-place in WASM (no copy back to JS)
612
- this.module._pad_frame(this.prevFramePtr, this.prevPaddedPtr, prevFrame.width, prevFrame.height);
613
- this.module._pad_frame(this.curFramePtr, this.curPaddedPtr, curFrame.width, curFrame.height);
614
- // Run motion estimation on pre-padded buffers
615
- const result = this.module._MEanalysis_js(this.prevPaddedPtr, this.curPaddedPtr, prevFrame.width, prevFrame.height, intraCount, fcode);
616
- return result === 1;
743
+ // Determine which slot is "prev" and which is "cur"
744
+ const prevRawPtr = this.prevIsSlotA ? this.slotARawPtr : this.slotBRawPtr;
745
+ const prevPaddedPtr = this.prevIsSlotA ? this.slotAPaddedPtr : this.slotBPaddedPtr;
746
+ const curRawPtr = this.prevIsSlotA ? this.slotBRawPtr : this.slotARawPtr;
747
+ const curPaddedPtr = this.prevIsSlotA ? this.slotBPaddedPtr : this.slotAPaddedPtr;
748
+ // Copy and pad previous frame only if not already valid in WASM
749
+ if (!this.prevSlotPadded) {
750
+ this.module.HEAPU8.set(prevFrame.data, prevRawPtr);
751
+ this.module._pad_frame(prevRawPtr, prevPaddedPtr, prevFrame.width, prevFrame.height);
752
+ }
753
+ // Always copy and pad the new current frame
754
+ this.module.HEAPU8.set(curFrame.data, curRawPtr);
755
+ this.module._pad_frame(curRawPtr, curPaddedPtr, curFrame.width, curFrame.height);
756
+ // Run motion estimation with parameterized thresholds
757
+ const rawScore = this.module._MEanalysis_js(prevPaddedPtr, curPaddedPtr, prevFrame.width, prevFrame.height, intraCount, fcode, intraThresh, intraThresh2);
758
+ // Check for WASM error
759
+ if (rawScore === -1) {
760
+ throw new Error('WASM memory allocation failed during scene detection. ' +
761
+ `Frame size: ${prevFrame.width}x${prevFrame.height}. ` +
762
+ 'The video resolution may be too high for available WASM memory.');
763
+ }
764
+ // Swap roles: current slot becomes previous for next call
765
+ this.prevIsSlotA = !this.prevIsSlotA;
766
+ this.prevSlotPadded = true;
767
+ // Determine scene change and confidence
768
+ const isSceneChange = rawScore >= intraThresh2;
769
+ // Normalize confidence: 0 when at threshold, 1 at 2x threshold
770
+ // For non-scene-changes, confidence represents "how close" (0 = very far from threshold)
771
+ let confidence;
772
+ if (isSceneChange) {
773
+ confidence = Math.min(1.0, rawScore / (intraThresh2 * 2));
774
+ }
775
+ else {
776
+ confidence = intraThresh2 > 0 ? Math.min(1.0, rawScore / intraThresh2) : 0;
777
+ }
778
+ return { isSceneChange, confidence };
779
+ }
780
+ /**
781
+ * Reset double-buffer state (e.g., after a seek or when starting fresh)
782
+ */
783
+ resetBufferState() {
784
+ this.prevIsSlotA = true;
785
+ this.prevSlotPadded = false;
617
786
  }
618
787
  /**
619
788
  * Calculate required buffer size for a padded frame
620
- *
621
- * @param width Original frame width
622
- * @param height Original frame height
623
- * @returns Required buffer size in bytes
624
789
  */
625
790
  calculatePaddedSize(width, height) {
626
791
  this.ensureInitialized();
@@ -628,10 +793,6 @@ class WasmBridge {
628
793
  }
629
794
  /**
630
795
  * Get macroblock parameters for a given frame size
631
- *
632
- * @param width Frame width
633
- * @param height Frame height
634
- * @returns Macroblock parameters
635
796
  */
636
797
  getMBParam(width, height) {
637
798
  const mb_width = Math.ceil(width / 16);
@@ -657,28 +818,119 @@ class WasmBridge {
657
818
  * Clean up resources
658
819
  */
659
820
  destroy() {
660
- // Free pre-allocated WASM buffers
661
821
  if (this.module) {
662
- if (this.prevFramePtr)
663
- this.module._free(this.prevFramePtr);
664
- if (this.curFramePtr)
665
- this.module._free(this.curFramePtr);
666
- if (this.prevPaddedPtr)
667
- this.module._free(this.prevPaddedPtr);
668
- if (this.curPaddedPtr)
669
- this.module._free(this.curPaddedPtr);
670
- }
671
- this.prevFramePtr = 0;
672
- this.curFramePtr = 0;
673
- this.prevPaddedPtr = 0;
674
- this.curPaddedPtr = 0;
822
+ // Free pre-allocated macroblock array
823
+ this.module._free_mb_array();
824
+ // Free double-buffered WASM frame buffers
825
+ if (this.slotARawPtr)
826
+ this.module._free(this.slotARawPtr);
827
+ if (this.slotBRawPtr)
828
+ this.module._free(this.slotBRawPtr);
829
+ if (this.slotAPaddedPtr)
830
+ this.module._free(this.slotAPaddedPtr);
831
+ if (this.slotBPaddedPtr)
832
+ this.module._free(this.slotBPaddedPtr);
833
+ }
834
+ this.slotARawPtr = 0;
835
+ this.slotBRawPtr = 0;
836
+ this.slotAPaddedPtr = 0;
837
+ this.slotBPaddedPtr = 0;
675
838
  this.allocatedFrameSize = 0;
676
839
  this.allocatedPaddedSize = 0;
840
+ this.prevSlotPadded = false;
677
841
  this.module = null;
678
842
  this.initialized = false;
679
843
  }
680
844
  }
681
845
 
846
+ /**
847
+ * Temporal Smoother - Sliding window filter to reduce false positives
848
+ *
849
+ * Three rules:
850
+ * 1. Minimum gap: Suppress detections within minConsecutive frames of each other (keep highest confidence)
851
+ * 2. Flash suppression: Isolated single-frame detections with low confidence are suppressed
852
+ * 3. Cluster merging: Consecutive triggered frames (common in dissolves) keep only highest-confidence one
853
+ */
854
+ class TemporalSmoother {
855
+ constructor(config) {
856
+ // Sliding window of recent detections
857
+ this.recentDetections = [];
858
+ // Last confirmed scene change frame
859
+ this.lastConfirmedFrame = 0;
860
+ // Buffer for cluster detection
861
+ this.pendingCluster = [];
862
+ this.nonDetectionCount = 0;
863
+ // Flash suppression: minimum confidence for isolated detections
864
+ this.flashConfidenceThreshold = 0.4;
865
+ this.windowSize = config.windowSize;
866
+ this.minConsecutive = config.minConsecutive;
867
+ }
868
+ /**
869
+ * Process a frame's detection result through temporal smoothing
870
+ */
871
+ process(frameNumber, rawIsSceneChange, rawConfidence) {
872
+ // If no detection, track gap and possibly flush pending cluster
873
+ if (!rawIsSceneChange) {
874
+ this.nonDetectionCount++;
875
+ // If we had a pending cluster and enough non-detections have passed,
876
+ // emit the best detection from the cluster
877
+ if (this.pendingCluster.length > 0 && this.nonDetectionCount >= 2) {
878
+ const best = this.flushCluster();
879
+ if (best) {
880
+ return best;
881
+ }
882
+ }
883
+ return { isSceneChange: false, confidence: 0 };
884
+ }
885
+ // We have a detection
886
+ this.nonDetectionCount = 0;
887
+ // Rule 1: Minimum gap enforcement
888
+ if (frameNumber - this.lastConfirmedFrame < this.minConsecutive) {
889
+ // Too close to last confirmed scene change
890
+ // If this has higher confidence, replace pending, but don't emit yet
891
+ if (this.pendingCluster.length > 0) {
892
+ const best = this.pendingCluster.reduce((a, b) => a.confidence > b.confidence ? a : b);
893
+ if (rawConfidence > best.confidence) {
894
+ // Replace entire cluster with this better detection
895
+ this.pendingCluster = [{ frameNumber, confidence: rawConfidence }];
896
+ }
897
+ }
898
+ return { isSceneChange: false, confidence: 0 };
899
+ }
900
+ // Rule 3: Cluster merging - add to pending cluster
901
+ this.pendingCluster.push({ frameNumber, confidence: rawConfidence });
902
+ // Don't emit immediately; wait to see if more consecutive detections follow
903
+ return { isSceneChange: false, confidence: 0 };
904
+ }
905
+ /**
906
+ * Flush the pending cluster, emitting the highest-confidence detection
907
+ */
908
+ flushCluster() {
909
+ if (this.pendingCluster.length === 0) {
910
+ return null;
911
+ }
912
+ // Find the detection with highest confidence
913
+ const best = this.pendingCluster.reduce((a, b) => a.confidence > b.confidence ? a : b);
914
+ // Rule 2: Flash suppression - isolated single-frame detections with low confidence
915
+ if (this.pendingCluster.length === 1 && best.confidence < this.flashConfidenceThreshold) {
916
+ this.pendingCluster = [];
917
+ return null;
918
+ }
919
+ // Confirm this detection
920
+ this.lastConfirmedFrame = best.frameNumber;
921
+ this.recentDetections.push(best);
922
+ // Keep sliding window bounded
923
+ while (this.recentDetections.length > this.windowSize) {
924
+ this.recentDetections.shift();
925
+ }
926
+ this.pendingCluster = [];
927
+ return {
928
+ isSceneChange: true,
929
+ confidence: best.confidence
930
+ };
931
+ }
932
+ }
933
+
682
934
  /**
683
935
  * Frame Processor - Utilities for frame preprocessing
684
936
  */
@@ -798,16 +1050,17 @@ class SceneDetector {
798
1050
  constructor(options = {}) {
799
1051
  // Set default options
800
1052
  this.options = {
801
- sensitivity: options.sensitivity || 'medium',
1053
+ sensitivity: options.sensitivity || 'low',
802
1054
  customThresholds: options.customThresholds || { intraThresh: 2000, intraThresh2: 90 },
803
1055
  searchRange: options.searchRange || 'medium',
804
- workers: options.workers || 1, // Multi-threading not implemented yet
1056
+ workers: options.workers || 1,
805
1057
  progressive: options.progressive || { enabled: false, initialStep: 1, refinementSteps: [] },
806
1058
  temporalSmoothing: options.temporalSmoothing || { enabled: false, windowSize: 5, minConsecutive: 2 },
807
1059
  frameExtraction: options.frameExtraction || { pixelFormat: 'gray', maxBufferFrames: 2 },
808
1060
  onProgress: options.onProgress || (() => { }),
809
1061
  onScene: options.onScene || (() => { }),
810
- format: options.format || 'json'
1062
+ format: options.format || 'json',
1063
+ signal: options.signal || undefined
811
1064
  };
812
1065
  this.wasmBridge = new WasmBridge();
813
1066
  // Initialize detection state
@@ -834,65 +1087,180 @@ class SceneDetector {
834
1087
  const metadata = await decoder.getMetadata();
835
1088
  // Calculate fcode from search range
836
1089
  this.state.fcode = calculateFcode(this.options.searchRange, metadata.resolution.width, metadata.resolution.height);
837
- // Pre-allocate WASM buffers (eliminates per-frame allocation overhead)
1090
+ // Calculate thresholds from sensitivity
1091
+ let thresholds;
1092
+ if (this.options.sensitivity === 'custom') {
1093
+ thresholds = this.options.customThresholds;
1094
+ }
1095
+ else {
1096
+ thresholds = calculateThresholds(this.options.sensitivity);
1097
+ }
1098
+ // Pre-allocate WASM buffers
838
1099
  this.wasmBridge.allocateBuffers(metadata.resolution.width, metadata.resolution.height);
1100
+ // Initialize temporal smoother if enabled
1101
+ let temporalSmoother = null;
1102
+ if (this.options.temporalSmoothing.enabled) {
1103
+ temporalSmoother = new TemporalSmoother(this.options.temporalSmoothing);
1104
+ }
839
1105
  // Initialize scene list (frame 0 is always a scene change)
840
1106
  const scenes = [
841
1107
  {
842
1108
  frameNumber: 0,
843
1109
  timestamp: 0,
844
- timecode: '00:00:00.000'
1110
+ timecode: '00:00:00.000',
1111
+ confidence: 1.0
845
1112
  }
846
1113
  ];
847
1114
  // Processing statistics
848
1115
  const startTime = Date.now();
849
1116
  let processedFrames = 0;
1117
+ let firstFrameValidated = false;
1118
+ // Rolling FPS window for accurate speed metrics (3-second window)
1119
+ const fpsWindow = [];
1120
+ // Fade/dissolve detection state
1121
+ let keyframeData = null;
1122
+ const driftThresholdFactor = 0.6; // Re-run detection at 60% of base thresholds
1123
+ // Quick-reject sampling interval
1124
+ const quickRejectStep = 64;
1125
+ const quickRejectThreshold = 5;
1126
+ // AbortSignal check
1127
+ const signal = this.options.signal;
850
1128
  // Process frames
851
1129
  await decoder.extractFrames(async (frame) => {
852
- validateFrame(frame);
1130
+ // Check abort signal
1131
+ if (signal && signal.aborted) {
1132
+ throw new Error('Detection aborted');
1133
+ }
1134
+ // Validate only the first frame (dimensions never change within a video)
1135
+ if (!firstFrameValidated) {
1136
+ validateFrame(frame);
1137
+ firstFrameValidated = true;
1138
+ }
853
1139
  // Update current frame
854
1140
  this.state.curFrame = frame;
855
1141
  // Need at least 2 frames to detect scene change
856
1142
  if (this.state.prevFrame) {
857
- const isSceneChange = this.wasmBridge.detectSceneChange(this.state.prevFrame, this.state.curFrame, this.state.intraCount, this.state.fcode);
1143
+ let isSceneChange = false;
1144
+ let confidence = 0;
1145
+ // Quick-reject: sampled MAD between prev and cur frame
1146
+ const prevData = this.state.prevFrame.data;
1147
+ const curData = this.state.curFrame.data;
1148
+ let sampledDiff = 0;
1149
+ let sampleCount = 0;
1150
+ for (let i = 0; i < curData.length; i += quickRejectStep) {
1151
+ sampledDiff += Math.abs(curData[i] - prevData[i]);
1152
+ sampleCount++;
1153
+ }
1154
+ const avgDiff = sampledDiff / sampleCount;
1155
+ if (avgDiff >= quickRejectThreshold) {
1156
+ // Frame differs enough, run full WASM detection
1157
+ const result = this.wasmBridge.detectSceneChange(this.state.prevFrame, this.state.curFrame, this.state.intraCount, this.state.fcode, thresholds.intraThresh, thresholds.intraThresh2);
1158
+ isSceneChange = result.isSceneChange;
1159
+ confidence = result.confidence;
1160
+ // Fade/dissolve detection: if not detected as scene change,
1161
+ // check drift from last keyframe
1162
+ if (!isSceneChange && keyframeData) {
1163
+ let driftSum = 0;
1164
+ let driftCount = 0;
1165
+ // Sample every 4th pixel for speed
1166
+ for (let i = 0; i < curData.length; i += 4) {
1167
+ driftSum += Math.abs(curData[i] - keyframeData[i]);
1168
+ driftCount++;
1169
+ }
1170
+ const driftAvg = driftSum / driftCount;
1171
+ // If cumulative drift is high but per-frame SAD didn't trigger,
1172
+ // re-run with lowered thresholds
1173
+ if (driftAvg > 30) {
1174
+ const fadeResult = this.wasmBridge.detectSceneChange(this.state.prevFrame, this.state.curFrame, this.state.intraCount, this.state.fcode, Math.round(thresholds.intraThresh * driftThresholdFactor), Math.round(thresholds.intraThresh2 * driftThresholdFactor));
1175
+ if (fadeResult.isSceneChange) {
1176
+ isSceneChange = true;
1177
+ confidence = fadeResult.confidence;
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ // else: quick-reject - frames are nearly identical, skip WASM call
1183
+ // Apply temporal smoothing if enabled
1184
+ if (temporalSmoother) {
1185
+ const smoothed = temporalSmoother.process(frame.frameNumber, isSceneChange, confidence);
1186
+ isSceneChange = smoothed.isSceneChange;
1187
+ confidence = smoothed.confidence;
1188
+ }
858
1189
  if (isSceneChange) {
859
1190
  const scene = {
860
1191
  frameNumber: frame.frameNumber,
861
1192
  timestamp: frame.pts,
862
- timecode: formatTimecode(frame.pts)
1193
+ timecode: formatTimecode(frame.pts),
1194
+ confidence
863
1195
  };
864
1196
  scenes.push(scene);
865
1197
  // Call scene callback
866
1198
  this.options.onScene(scene);
867
1199
  // Reset intraCount
868
1200
  this.state.intraCount = 1;
1201
+ // Update keyframe for drift detection
1202
+ keyframeData = new Uint8Array(curData);
869
1203
  }
870
1204
  else {
871
- // Increment intraCount
872
1205
  this.state.intraCount++;
873
1206
  }
874
1207
  }
1208
+ else {
1209
+ // First frame is the initial keyframe for drift detection
1210
+ keyframeData = new Uint8Array(frame.data);
1211
+ }
875
1212
  // Move current frame to previous
876
1213
  this.state.prevFrame = this.state.curFrame;
877
1214
  processedFrames++;
878
1215
  }, (current, total) => {
879
- // Progress callback
1216
+ // Enhanced progress with rolling FPS window
1217
+ const now = Date.now();
1218
+ const elapsed = (now - startTime) / 1000;
1219
+ // Add to rolling window
1220
+ fpsWindow.push({ time: now, frame: current });
1221
+ // Remove samples older than 3 seconds
1222
+ while (fpsWindow.length > 1 && (now - fpsWindow[0].time) > 3000) {
1223
+ fpsWindow.shift();
1224
+ }
1225
+ // Calculate instantaneous FPS from rolling window
1226
+ let currentFps = 0;
1227
+ if (fpsWindow.length >= 2) {
1228
+ const oldest = fpsWindow[0];
1229
+ const newest = fpsWindow[fpsWindow.length - 1];
1230
+ const dt = (newest.time - oldest.time) / 1000;
1231
+ if (dt > 0) {
1232
+ currentFps = (newest.frame - oldest.frame) / dt;
1233
+ }
1234
+ }
1235
+ // Calculate ETA from instantaneous FPS
1236
+ const remaining = currentFps > 0 ? (total - current) / currentFps : undefined;
880
1237
  const progress = {
881
1238
  currentFrame: current,
882
1239
  totalFrames: total,
883
- percent: Math.round((current / total) * 100)
1240
+ percent: Math.round((current / total) * 100),
1241
+ eta: remaining,
1242
+ fps: currentFps,
1243
+ elapsed,
1244
+ scenesDetected: scenes.length
884
1245
  };
885
- // Calculate ETA
886
- const elapsed = (Date.now() - startTime) / 1000;
887
- const fps = current / elapsed;
888
- const remaining = (total - current) / fps;
889
- progress.eta = remaining;
890
1246
  this.options.onProgress(progress);
891
1247
  });
892
1248
  // Calculate statistics
893
1249
  const endTime = Date.now();
894
1250
  const processingTime = (endTime - startTime) / 1000;
895
1251
  const framesPerSecond = processedFrames / processingTime;
1252
+ // Post-process: compute scene durations
1253
+ for (let i = 0; i < scenes.length; i++) {
1254
+ if (i < scenes.length - 1) {
1255
+ scenes[i].duration = scenes[i + 1].timestamp - scenes[i].timestamp;
1256
+ scenes[i].frameCount = scenes[i + 1].frameNumber - scenes[i].frameNumber;
1257
+ }
1258
+ else {
1259
+ // Last scene: duration until end of video
1260
+ scenes[i].duration = metadata.duration - scenes[i].timestamp;
1261
+ scenes[i].frameCount = metadata.totalFrames - scenes[i].frameNumber;
1262
+ }
1263
+ }
896
1264
  // Clean up
897
1265
  decoder.destroy();
898
1266
  return {
@@ -935,7 +1303,7 @@ class SceneDetector {
935
1303
  * console.log(`Found ${results.scenes.length} scenes`);
936
1304
  *
937
1305
  * results.scenes.forEach(scene => {
938
- * console.log(`Scene at ${scene.timecode}`);
1306
+ * console.log(`Scene at ${scene.timecode} (confidence: ${scene.confidence})`);
939
1307
  * });
940
1308
  * ```
941
1309
  */
@@ -949,6 +1317,30 @@ async function detectSceneChanges(videoPath, options) {
949
1317
  detector.destroy();
950
1318
  }
951
1319
  }
1320
+ /**
1321
+ * Extract scene thumbnail images from a video
1322
+ *
1323
+ * @param videoPath Path to video file
1324
+ * @param options Detection options
1325
+ * @param imageOptions Image extraction options
1326
+ * @returns Detection results (images are written to disk)
1327
+ */
1328
+ async function extractSceneImages(videoPath, options, imageOptions) {
1329
+ const detector = new SceneDetector(options);
1330
+ try {
1331
+ const results = await detector.detect(videoPath);
1332
+ if (imageOptions) {
1333
+ const decoder = new FFmpegDecoder(videoPath);
1334
+ const frameNumbers = results.scenes.map(s => s.frameNumber);
1335
+ await decoder.extractFrameImages(frameNumbers, imageOptions);
1336
+ decoder.destroy();
1337
+ }
1338
+ return results;
1339
+ }
1340
+ finally {
1341
+ detector.destroy();
1342
+ }
1343
+ }
952
1344
  /**
953
1345
  * Version information
954
1346
  */
@@ -973,6 +1365,7 @@ exports.BufferPool = BufferPool;
973
1365
  exports.FFmpegDecoder = FFmpegDecoder;
974
1366
  exports.FrameBuffer = FrameBuffer;
975
1367
  exports.SceneDetector = SceneDetector;
1368
+ exports.TemporalSmoother = TemporalSmoother;
976
1369
  exports.WasmBridge = WasmBridge;
977
1370
  exports.calculateFcode = calculateFcode;
978
1371
  exports.calculateFrameMemory = calculateFrameMemory;
@@ -980,6 +1373,7 @@ exports.calculateMBParam = calculateMBParam;
980
1373
  exports.calculateThresholds = calculateThresholds;
981
1374
  exports.detectSceneChanges = detectSceneChanges;
982
1375
  exports.estimateProcessingTime = estimateProcessingTime;
1376
+ exports.extractSceneImages = extractSceneImages;
983
1377
  exports.formatTimecode = formatTimecode;
984
1378
  exports.info = info;
985
1379
  exports.validateFrame = validateFrame;