@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.
- package/README.md +158 -107
- package/bin/cli.js +91 -25
- package/dist/decoder/ffmpeg-decoder.d.ts +15 -3
- package/dist/decoder/ffmpeg-decoder.d.ts.map +1 -1
- package/dist/decoder/ffmpeg-decoder.js +135 -14
- package/dist/decoder/ffmpeg-decoder.js.map +1 -1
- package/dist/detection/detector.d.ts.map +1 -1
- package/dist/detection/detector.js +134 -17
- package/dist/detection/detector.js.map +1 -1
- package/dist/detection/temporal-smoother.d.ts +32 -0
- package/dist/detection/temporal-smoother.d.ts.map +1 -0
- package/dist/detection/temporal-smoother.js +88 -0
- package/dist/detection/temporal-smoother.js.map +1 -0
- package/dist/detection/wasm-bridge.d.ts +26 -23
- package/dist/detection/wasm-bridge.d.ts.map +1 -1
- package/dist/detection/wasm-bridge.js +107 -62
- package/dist/detection/wasm-bridge.js.map +1 -1
- package/dist/detection.wasm.js +1 -1
- package/dist/detection.wasm.wasm +0 -0
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -1
- package/dist/index.js.map +1 -1
- package/dist/keyframes.cjs.js +488 -94
- package/dist/keyframes.cjs.js.map +1 -1
- package/dist/keyframes.esm.js +487 -95
- package/dist/keyframes.esm.js.map +1 -1
- package/dist/types/index.d.ts +36 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/keyframes.cjs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
516
|
-
|
|
517
|
-
this.
|
|
518
|
-
this.
|
|
519
|
-
|
|
520
|
-
this.
|
|
521
|
-
this.
|
|
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
|
-
*
|
|
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.
|
|
568
|
-
this.module._free(this.
|
|
569
|
-
if (this.
|
|
570
|
-
this.module._free(this.
|
|
571
|
-
this.
|
|
572
|
-
this.
|
|
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.
|
|
578
|
-
this.module._free(this.
|
|
579
|
-
if (this.
|
|
580
|
-
this.module._free(this.
|
|
581
|
-
this.
|
|
582
|
-
this.
|
|
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
|
-
*
|
|
590
|
-
*
|
|
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
|
|
596
|
-
* @
|
|
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
|
|
605
|
-
if (!this.
|
|
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
|
-
//
|
|
609
|
-
this.
|
|
610
|
-
this.
|
|
611
|
-
|
|
612
|
-
this.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
this.
|
|
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 || '
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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;
|