@doedja/scenecut 1.0.0

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.
Files changed (41) hide show
  1. package/README.md +280 -0
  2. package/bin/cli.js +293 -0
  3. package/dist/decoder/ffmpeg-decoder.d.ts +50 -0
  4. package/dist/decoder/ffmpeg-decoder.d.ts.map +1 -0
  5. package/dist/decoder/ffmpeg-decoder.js +269 -0
  6. package/dist/decoder/ffmpeg-decoder.js.map +1 -0
  7. package/dist/decoder/frame-buffer.d.ts +81 -0
  8. package/dist/decoder/frame-buffer.d.ts.map +1 -0
  9. package/dist/decoder/frame-buffer.js +123 -0
  10. package/dist/decoder/frame-buffer.js.map +1 -0
  11. package/dist/detection/detector.d.ts +19 -0
  12. package/dist/detection/detector.d.ts.map +1 -0
  13. package/dist/detection/detector.js +126 -0
  14. package/dist/detection/detector.js.map +1 -0
  15. package/dist/detection/wasm-bridge.d.ts +82 -0
  16. package/dist/detection/wasm-bridge.d.ts.map +1 -0
  17. package/dist/detection/wasm-bridge.js +182 -0
  18. package/dist/detection/wasm-bridge.js.map +1 -0
  19. package/dist/detection.wasm.js +2 -0
  20. package/dist/detection.wasm.wasm +0 -0
  21. package/dist/index.d.ts +54 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +63 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/keyframes.cjs.js +985 -0
  26. package/dist/keyframes.cjs.js.map +1 -0
  27. package/dist/keyframes.esm.js +946 -0
  28. package/dist/keyframes.esm.js.map +1 -0
  29. package/dist/types/index.d.ts +225 -0
  30. package/dist/types/index.d.ts.map +1 -0
  31. package/dist/types/index.js +5 -0
  32. package/dist/types/index.js.map +1 -0
  33. package/dist/utils/buffer-pool.d.ts +44 -0
  34. package/dist/utils/buffer-pool.d.ts.map +1 -0
  35. package/dist/utils/buffer-pool.js +81 -0
  36. package/dist/utils/buffer-pool.js.map +1 -0
  37. package/dist/utils/frame-processor.d.ts +48 -0
  38. package/dist/utils/frame-processor.d.ts.map +1 -0
  39. package/dist/utils/frame-processor.js +112 -0
  40. package/dist/utils/frame-processor.js.map +1 -0
  41. package/package.json +77 -0
@@ -0,0 +1,946 @@
1
+ import * as ffmpeg from 'fluent-ffmpeg';
2
+ import * as ffmpegInstaller from '@ffmpeg-installer/ffmpeg';
3
+ import * as path from 'path';
4
+ import * as fs from 'fs';
5
+
6
+ /**
7
+ * Buffer Pool - Memory management for frame buffers
8
+ *
9
+ * Reuses TypedArray buffers to reduce memory allocation and GC pressure
10
+ */
11
+ class BufferPool {
12
+ /**
13
+ * Create a new buffer pool
14
+ *
15
+ * @param maxPoolSize Maximum number of buffers to keep per size (default: 4)
16
+ */
17
+ constructor(maxPoolSize = 4) {
18
+ this.pool = new Map();
19
+ this.maxPoolSize = maxPoolSize;
20
+ }
21
+ /**
22
+ * Acquire a buffer of the specified size
23
+ *
24
+ * @param size Buffer size in bytes
25
+ * @returns Uint8Array buffer
26
+ */
27
+ acquire(size) {
28
+ const poolForSize = this.pool.get(size);
29
+ if (poolForSize && poolForSize.length > 0) {
30
+ const buffer = poolForSize.pop();
31
+ // Clear the buffer before reuse
32
+ buffer.fill(0);
33
+ return buffer;
34
+ }
35
+ // No buffer available, allocate new one
36
+ return new Uint8Array(size);
37
+ }
38
+ /**
39
+ * Release a buffer back to the pool
40
+ *
41
+ * @param buffer Buffer to release
42
+ */
43
+ release(buffer) {
44
+ const size = buffer.length;
45
+ let poolForSize = this.pool.get(size);
46
+ if (!poolForSize) {
47
+ poolForSize = [];
48
+ this.pool.set(size, poolForSize);
49
+ }
50
+ // Only keep buffer if pool not full
51
+ if (poolForSize.length < this.maxPoolSize) {
52
+ poolForSize.push(buffer);
53
+ }
54
+ // Otherwise, let it be garbage collected
55
+ }
56
+ /**
57
+ * Get pool statistics
58
+ */
59
+ getStats() {
60
+ const stats = [];
61
+ for (const [size, buffers] of this.pool.entries()) {
62
+ stats.push({
63
+ size,
64
+ count: buffers.length
65
+ });
66
+ }
67
+ return stats.sort((a, b) => b.size - a.size);
68
+ }
69
+ /**
70
+ * Clear all pooled buffers
71
+ */
72
+ clear() {
73
+ this.pool.clear();
74
+ }
75
+ /**
76
+ * Get total memory used by pooled buffers
77
+ */
78
+ getTotalMemory() {
79
+ let total = 0;
80
+ for (const [size, buffers] of this.pool.entries()) {
81
+ total += size * buffers.length;
82
+ }
83
+ return total;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Frame Buffer - Circular buffer for frame management
89
+ *
90
+ * Maintains a circular buffer of frames for efficient scene detection
91
+ * Typically only needs to hold 2 frames (previous and current)
92
+ */
93
+ class FrameBuffer {
94
+ /**
95
+ * Create a new frame buffer
96
+ *
97
+ * @param maxFrames Maximum number of frames to buffer (default: 2)
98
+ * @param bufferPool Optional buffer pool for memory reuse
99
+ */
100
+ constructor(maxFrames = 2, bufferPool) {
101
+ this.currentIndex = 0;
102
+ this.maxFrames = maxFrames;
103
+ this.bufferPool = bufferPool || new BufferPool();
104
+ this.frames = new Array(maxFrames).fill(null);
105
+ }
106
+ /**
107
+ * Push a new frame into the buffer
108
+ *
109
+ * @param frame Frame to push
110
+ * @returns The frame that was evicted, if any
111
+ */
112
+ push(frame) {
113
+ const evictedFrame = this.frames[this.currentIndex];
114
+ // Release the evicted frame's buffer back to pool
115
+ if (evictedFrame) {
116
+ this.bufferPool.release(evictedFrame.data);
117
+ }
118
+ // Store the new frame
119
+ this.frames[this.currentIndex] = frame;
120
+ // Move to next position
121
+ this.currentIndex = (this.currentIndex + 1) % this.maxFrames;
122
+ return evictedFrame;
123
+ }
124
+ /**
125
+ * Get frame at specific offset from current position
126
+ *
127
+ * @param offset Offset from current position (0 = most recent, 1 = previous, etc.)
128
+ * @returns Frame or null if not available
129
+ */
130
+ get(offset = 0) {
131
+ if (offset < 0 || offset >= this.maxFrames) {
132
+ return null;
133
+ }
134
+ const index = (this.currentIndex - 1 - offset + this.maxFrames) % this.maxFrames;
135
+ return this.frames[index];
136
+ }
137
+ /**
138
+ * Get the most recent frame
139
+ */
140
+ getCurrent() {
141
+ return this.get(0);
142
+ }
143
+ /**
144
+ * Get the previous frame
145
+ */
146
+ getPrevious() {
147
+ return this.get(1);
148
+ }
149
+ /**
150
+ * Get both current and previous frames
151
+ *
152
+ * @returns [current, previous] or null if either is not available
153
+ */
154
+ getCurrentAndPrevious() {
155
+ const current = this.getCurrent();
156
+ const previous = this.getPrevious();
157
+ if (!current || !previous) {
158
+ return null;
159
+ }
160
+ return [current, previous];
161
+ }
162
+ /**
163
+ * Allocate a buffer for a new frame
164
+ *
165
+ * @param size Buffer size in bytes
166
+ * @returns Uint8Array buffer
167
+ */
168
+ allocateBuffer(size) {
169
+ return this.bufferPool.acquire(size);
170
+ }
171
+ /**
172
+ * Clear all frames from the buffer
173
+ */
174
+ clear() {
175
+ // Release all buffers back to pool
176
+ for (const frame of this.frames) {
177
+ if (frame) {
178
+ this.bufferPool.release(frame.data);
179
+ }
180
+ }
181
+ this.frames = new Array(this.maxFrames).fill(null);
182
+ this.currentIndex = 0;
183
+ }
184
+ /**
185
+ * Get the number of frames currently in the buffer
186
+ */
187
+ size() {
188
+ return this.frames.filter(f => f !== null).length;
189
+ }
190
+ /**
191
+ * Check if buffer is full
192
+ */
193
+ isFull() {
194
+ return this.size() === this.maxFrames;
195
+ }
196
+ /**
197
+ * Get buffer statistics
198
+ */
199
+ getStats() {
200
+ return {
201
+ maxFrames: this.maxFrames,
202
+ currentFrames: this.size(),
203
+ bufferPoolStats: this.bufferPool.getStats(),
204
+ totalPoolMemory: this.bufferPool.getTotalMemory()
205
+ };
206
+ }
207
+ }
208
+
209
+ /**
210
+ * FFmpeg Decoder - Extract frames from video files
211
+ *
212
+ * Uses fluent-ffmpeg to extract grayscale frames for scene detection
213
+ */
214
+ // Set FFmpeg path from installer
215
+ ffmpeg.setFfmpegPath(ffmpegInstaller.path);
216
+ /**
217
+ * Ring Buffer - Fixed-size circular buffer for streaming data
218
+ * Eliminates repeated Buffer.concat() allocations and GC pressure
219
+ */
220
+ class RingBuffer {
221
+ constructor(size = 8 * 1024 * 1024) {
222
+ this.writePos = 0;
223
+ this.readPos = 0;
224
+ this.availableBytes = 0;
225
+ this.buffer = Buffer.allocUnsafe(size);
226
+ this.capacity = size;
227
+ }
228
+ /**
229
+ * Write data to the ring buffer
230
+ */
231
+ write(chunk) {
232
+ const chunkSize = chunk.length;
233
+ if (chunkSize > this.capacity - this.availableBytes) {
234
+ throw new Error('RingBuffer overflow: chunk too large for available space');
235
+ }
236
+ // Write in two parts if wrapping around
237
+ const endSpace = this.capacity - this.writePos;
238
+ if (chunkSize <= endSpace) {
239
+ // No wrap-around needed
240
+ chunk.copy(this.buffer, this.writePos);
241
+ this.writePos += chunkSize;
242
+ }
243
+ else {
244
+ // Wrap-around: split write
245
+ chunk.copy(this.buffer, this.writePos, 0, endSpace);
246
+ chunk.copy(this.buffer, 0, endSpace, chunkSize);
247
+ this.writePos = chunkSize - endSpace;
248
+ }
249
+ // Wrap write position if at end
250
+ if (this.writePos >= this.capacity) {
251
+ this.writePos = 0;
252
+ }
253
+ this.availableBytes += chunkSize;
254
+ }
255
+ /**
256
+ * Read data from the ring buffer
257
+ */
258
+ read(size) {
259
+ if (size > this.availableBytes) {
260
+ throw new Error('RingBuffer underflow: not enough data available');
261
+ }
262
+ const result = Buffer.allocUnsafe(size);
263
+ const endSpace = this.capacity - this.readPos;
264
+ if (size <= endSpace) {
265
+ // No wrap-around needed
266
+ this.buffer.copy(result, 0, this.readPos, this.readPos + size);
267
+ this.readPos += size;
268
+ }
269
+ else {
270
+ // Wrap-around: split read
271
+ this.buffer.copy(result, 0, this.readPos, this.capacity);
272
+ this.buffer.copy(result, endSpace, 0, size - endSpace);
273
+ this.readPos = size - endSpace;
274
+ }
275
+ // Wrap read position if at end
276
+ if (this.readPos >= this.capacity) {
277
+ this.readPos = 0;
278
+ }
279
+ this.availableBytes -= size;
280
+ return result;
281
+ }
282
+ /**
283
+ * Get number of bytes available to read
284
+ */
285
+ available() {
286
+ return this.availableBytes;
287
+ }
288
+ /**
289
+ * Reset the ring buffer
290
+ */
291
+ reset() {
292
+ this.writePos = 0;
293
+ this.readPos = 0;
294
+ this.availableBytes = 0;
295
+ }
296
+ }
297
+ class FFmpegDecoder {
298
+ constructor(videoPath, options = {}) {
299
+ this.metadata = null;
300
+ this.videoPath = videoPath;
301
+ this.options = {
302
+ pixelFormat: options.pixelFormat || 'gray',
303
+ maxBufferFrames: options.maxBufferFrames || 2,
304
+ skipFrames: options.skipFrames || 0
305
+ };
306
+ this.frameBuffer = new FrameBuffer(this.options.maxBufferFrames);
307
+ }
308
+ /**
309
+ * Get video metadata
310
+ */
311
+ async getMetadata() {
312
+ if (this.metadata) {
313
+ return this.metadata;
314
+ }
315
+ return new Promise((resolve, reject) => {
316
+ ffmpeg.ffprobe(this.videoPath, (err, metadata) => {
317
+ if (err) {
318
+ reject(new Error(`Failed to read video metadata: ${err.message}`));
319
+ return;
320
+ }
321
+ const videoStream = metadata.streams.find(s => s.codec_type === 'video');
322
+ if (!videoStream) {
323
+ reject(new Error('No video stream found'));
324
+ return;
325
+ }
326
+ const fps = this.parseFps(videoStream.r_frame_rate || videoStream.avg_frame_rate || '30/1');
327
+ const duration = parseFloat(String(metadata.format.duration || 0));
328
+ const totalFrames = Math.floor(duration * fps);
329
+ this.metadata = {
330
+ totalFrames,
331
+ duration,
332
+ fps,
333
+ resolution: {
334
+ width: videoStream.width || 0,
335
+ height: videoStream.height || 0
336
+ }
337
+ };
338
+ resolve(this.metadata);
339
+ });
340
+ });
341
+ }
342
+ /**
343
+ * Parse frame rate from FFmpeg format (e.g., "30000/1001")
344
+ */
345
+ parseFps(fpsString) {
346
+ const parts = fpsString.split('/');
347
+ if (parts.length === 2) {
348
+ return parseInt(parts[0]) / parseInt(parts[1]);
349
+ }
350
+ return parseFloat(fpsString);
351
+ }
352
+ /**
353
+ * Extract frames as grayscale data
354
+ *
355
+ * @param onFrame Callback for each frame
356
+ * @param onProgress Optional progress callback
357
+ */
358
+ async extractFrames(onFrame, onProgress) {
359
+ const metadata = await this.getMetadata();
360
+ const { width, height } = metadata.resolution;
361
+ return new Promise((resolve, reject) => {
362
+ let frameNumber = 0;
363
+ const ringBuffer = new RingBuffer(); // 8MB ring buffer
364
+ const frameSize = width * height; // Grayscale: 1 byte per pixel
365
+ const command = ffmpeg.default(this.videoPath)
366
+ .outputOptions([
367
+ '-f', 'image2pipe',
368
+ '-pix_fmt', 'gray',
369
+ '-vcodec', 'rawvideo'
370
+ ])
371
+ .on('error', (err) => {
372
+ reject(new Error(`FFmpeg error: ${err.message}`));
373
+ })
374
+ .on('end', () => {
375
+ resolve();
376
+ });
377
+ const stream = command.pipe();
378
+ stream.on('data', async (chunk) => {
379
+ // Write chunk to ring buffer (no allocation, no copying)
380
+ ringBuffer.write(chunk);
381
+ // Process complete frames
382
+ while (ringBuffer.available() >= frameSize) {
383
+ const frameData = ringBuffer.read(frameSize);
384
+ // Skip frames if requested
385
+ if (this.options.skipFrames > 0 && frameNumber % (this.options.skipFrames + 1) !== 0) {
386
+ frameNumber++;
387
+ continue;
388
+ }
389
+ // Create RawFrame
390
+ const frame = {
391
+ data: new Uint8Array(frameData),
392
+ width,
393
+ height,
394
+ stride: width,
395
+ pts: frameNumber / metadata.fps,
396
+ frameNumber
397
+ };
398
+ // Call callback
399
+ try {
400
+ await onFrame(frame);
401
+ }
402
+ catch (err) {
403
+ stream.destroy();
404
+ reject(err);
405
+ return;
406
+ }
407
+ // Progress callback
408
+ if (onProgress && frameNumber % 30 === 0) {
409
+ onProgress(frameNumber, metadata.totalFrames);
410
+ }
411
+ frameNumber++;
412
+ }
413
+ });
414
+ stream.on('error', (err) => {
415
+ reject(new Error(`Stream error: ${err.message}`));
416
+ });
417
+ });
418
+ }
419
+ /**
420
+ * Extract a single frame at specific frame number
421
+ */
422
+ async extractFrame(frameNumber) {
423
+ const metadata = await this.getMetadata();
424
+ const { width, height } = metadata.resolution;
425
+ const timestamp = frameNumber / metadata.fps;
426
+ return new Promise((resolve, reject) => {
427
+ const ringBuffer = new RingBuffer();
428
+ const frameSize = width * height;
429
+ const command = ffmpeg.default(this.videoPath)
430
+ .seekInput(timestamp)
431
+ .outputOptions([
432
+ '-vframes', '1',
433
+ '-f', 'image2pipe',
434
+ '-pix_fmt', 'gray',
435
+ '-vcodec', 'rawvideo'
436
+ ])
437
+ .on('error', (err) => {
438
+ reject(new Error(`FFmpeg error: ${err.message}`));
439
+ });
440
+ const stream = command.pipe();
441
+ stream.on('data', (chunk) => {
442
+ ringBuffer.write(chunk);
443
+ if (ringBuffer.available() >= frameSize) {
444
+ const frameData = ringBuffer.read(frameSize);
445
+ const frame = {
446
+ data: new Uint8Array(frameData),
447
+ width,
448
+ height,
449
+ stride: width,
450
+ pts: timestamp,
451
+ frameNumber
452
+ };
453
+ resolve(frame);
454
+ }
455
+ });
456
+ stream.on('error', (err) => {
457
+ reject(new Error(`Stream error: ${err.message}`));
458
+ });
459
+ });
460
+ }
461
+ /**
462
+ * Get the frame buffer
463
+ */
464
+ getFrameBuffer() {
465
+ return this.frameBuffer;
466
+ }
467
+ /**
468
+ * Clean up resources
469
+ */
470
+ destroy() {
471
+ this.frameBuffer.clear();
472
+ }
473
+ }
474
+
475
+ /**
476
+ * WASM Bridge - Interface between JavaScript and WebAssembly
477
+ *
478
+ * This module handles:
479
+ * - Loading the WASM module
480
+ * - Memory allocation and management
481
+ * - Calling WASM functions
482
+ * - Data marshalling between JS and WASM
483
+ */
484
+ class WasmBridge {
485
+ constructor() {
486
+ this.module = null;
487
+ this.initialized = false;
488
+ // Pre-allocated WASM buffers for frame processing
489
+ this.prevFramePtr = 0; // Raw previous frame
490
+ this.curFramePtr = 0; // Raw current frame
491
+ this.prevPaddedPtr = 0; // Padded previous frame
492
+ this.curPaddedPtr = 0; // Padded current frame
493
+ this.allocatedFrameSize = 0; // Size of raw frame buffers
494
+ this.allocatedPaddedSize = 0; // Size of padded frame buffers
495
+ }
496
+ /**
497
+ * Initialize the WASM module
498
+ */
499
+ async init() {
500
+ if (this.initialized) {
501
+ return;
502
+ }
503
+ try {
504
+ // Load the WASM module
505
+ const wasmPath = path.join(__dirname, '../dist/detection.wasm.js');
506
+ if (!fs.existsSync(wasmPath)) {
507
+ throw new Error(`WASM module not found at ${wasmPath}. ` +
508
+ `Please run 'npm run build:wasm' to compile the WASM module.`);
509
+ }
510
+ // Dynamic import the WASM module
511
+ const createWasmModule = require(wasmPath);
512
+ this.module = await createWasmModule();
513
+ this.initialized = true;
514
+ }
515
+ catch (error) {
516
+ throw new Error(`Failed to initialize WASM module: ${error}`);
517
+ }
518
+ }
519
+ /**
520
+ * Ensure the WASM module is initialized
521
+ */
522
+ ensureInitialized() {
523
+ if (!this.initialized || !this.module) {
524
+ throw new Error('WASM module not initialized. Call init() first.');
525
+ }
526
+ }
527
+ /**
528
+ * Pre-allocate WASM buffers for frame processing
529
+ * This eliminates per-frame allocation overhead and reduces memory copies
530
+ *
531
+ * @param width Frame width
532
+ * @param height Frame height
533
+ */
534
+ allocateBuffers(width, height) {
535
+ this.ensureInitialized();
536
+ const frameSize = width * height;
537
+ const paddedSize = this.module._calculate_padded_size(width, height);
538
+ // Allocate or re-allocate raw frame buffers if size changed
539
+ if (frameSize !== this.allocatedFrameSize) {
540
+ if (this.prevFramePtr)
541
+ this.module._free(this.prevFramePtr);
542
+ if (this.curFramePtr)
543
+ this.module._free(this.curFramePtr);
544
+ this.prevFramePtr = this.module._malloc(frameSize);
545
+ this.curFramePtr = this.module._malloc(frameSize);
546
+ this.allocatedFrameSize = frameSize;
547
+ }
548
+ // Allocate or re-allocate padded frame buffers if size changed
549
+ if (paddedSize !== this.allocatedPaddedSize) {
550
+ if (this.prevPaddedPtr)
551
+ this.module._free(this.prevPaddedPtr);
552
+ if (this.curPaddedPtr)
553
+ this.module._free(this.curPaddedPtr);
554
+ this.prevPaddedPtr = this.module._malloc(paddedSize);
555
+ this.curPaddedPtr = this.module._malloc(paddedSize);
556
+ this.allocatedPaddedSize = paddedSize;
557
+ }
558
+ }
559
+ /**
560
+ * Detect scene change between two frames
561
+ *
562
+ * Uses pre-allocated WASM buffers to eliminate per-frame allocation
563
+ * and reduce memory copies from 3 to 1 per frame.
564
+ *
565
+ * @param prevFrame Previous frame
566
+ * @param curFrame Current frame
567
+ * @param intraCount Number of consecutive non-scene-change frames
568
+ * @param fcode Motion search range parameter (default: 4 = 256 pixels)
569
+ * @returns true if scene change detected, false otherwise
570
+ */
571
+ detectSceneChange(prevFrame, curFrame, intraCount, fcode = 4) {
572
+ this.ensureInitialized();
573
+ // Validate inputs
574
+ if (prevFrame.width !== curFrame.width || prevFrame.height !== curFrame.height) {
575
+ throw new Error('Frame dimensions must match');
576
+ }
577
+ // Ensure buffers are allocated (should be done once at start)
578
+ if (!this.prevFramePtr || this.allocatedFrameSize !== prevFrame.data.length) {
579
+ this.allocateBuffers(prevFrame.width, prevFrame.height);
580
+ }
581
+ // Single copy: Raw frames -> WASM memory (eliminates 2 extra copies)
582
+ this.module.HEAPU8.set(prevFrame.data, this.prevFramePtr);
583
+ this.module.HEAPU8.set(curFrame.data, this.curFramePtr);
584
+ // Pad frames in-place in WASM (no copy back to JS)
585
+ this.module._pad_frame(this.prevFramePtr, this.prevPaddedPtr, prevFrame.width, prevFrame.height);
586
+ this.module._pad_frame(this.curFramePtr, this.curPaddedPtr, curFrame.width, curFrame.height);
587
+ // Run motion estimation on pre-padded buffers
588
+ const result = this.module._MEanalysis_js(this.prevPaddedPtr, this.curPaddedPtr, prevFrame.width, prevFrame.height, intraCount, fcode);
589
+ return result === 1;
590
+ }
591
+ /**
592
+ * Calculate required buffer size for a padded frame
593
+ *
594
+ * @param width Original frame width
595
+ * @param height Original frame height
596
+ * @returns Required buffer size in bytes
597
+ */
598
+ calculatePaddedSize(width, height) {
599
+ this.ensureInitialized();
600
+ return this.module._calculate_padded_size(width, height);
601
+ }
602
+ /**
603
+ * Get macroblock parameters for a given frame size
604
+ *
605
+ * @param width Frame width
606
+ * @param height Frame height
607
+ * @returns Macroblock parameters
608
+ */
609
+ getMBParam(width, height) {
610
+ const mb_width = Math.ceil(width / 16);
611
+ const mb_height = Math.ceil(height / 16);
612
+ const edge_size = 64;
613
+ return {
614
+ width,
615
+ height,
616
+ mb_width,
617
+ mb_height,
618
+ edged_width: 16 * mb_width + 2 * edge_size,
619
+ edged_height: 16 * mb_height + 2 * edge_size,
620
+ edge_size
621
+ };
622
+ }
623
+ /**
624
+ * Check if the WASM module is initialized
625
+ */
626
+ isInitialized() {
627
+ return this.initialized;
628
+ }
629
+ /**
630
+ * Clean up resources
631
+ */
632
+ destroy() {
633
+ // Free pre-allocated WASM buffers
634
+ if (this.module) {
635
+ if (this.prevFramePtr)
636
+ this.module._free(this.prevFramePtr);
637
+ if (this.curFramePtr)
638
+ this.module._free(this.curFramePtr);
639
+ if (this.prevPaddedPtr)
640
+ this.module._free(this.prevPaddedPtr);
641
+ if (this.curPaddedPtr)
642
+ this.module._free(this.curPaddedPtr);
643
+ }
644
+ this.prevFramePtr = 0;
645
+ this.curFramePtr = 0;
646
+ this.prevPaddedPtr = 0;
647
+ this.curPaddedPtr = 0;
648
+ this.allocatedFrameSize = 0;
649
+ this.allocatedPaddedSize = 0;
650
+ this.module = null;
651
+ this.initialized = false;
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Frame Processor - Utilities for frame preprocessing
657
+ */
658
+ /**
659
+ * Format timestamp as timecode (HH:MM:SS.mmm)
660
+ */
661
+ function formatTimecode(seconds) {
662
+ const hours = Math.floor(seconds / 3600);
663
+ const minutes = Math.floor((seconds % 3600) / 60);
664
+ const secs = Math.floor(seconds % 60);
665
+ const ms = Math.floor((seconds % 1) * 1000);
666
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
667
+ }
668
+ /**
669
+ * Calculate macroblock parameters for frame dimensions
670
+ */
671
+ function calculateMBParam(width, height) {
672
+ const mb_width = Math.ceil(width / 16);
673
+ const mb_height = Math.ceil(height / 16);
674
+ const edge_size = 64;
675
+ return {
676
+ width,
677
+ height,
678
+ mb_width,
679
+ mb_height,
680
+ edged_width: 16 * mb_width + 2 * edge_size,
681
+ edged_height: 16 * mb_height + 2 * edge_size,
682
+ edge_size
683
+ };
684
+ }
685
+ /**
686
+ * Check if frame dimensions are valid
687
+ */
688
+ function validateFrameDimensions(width, height) {
689
+ if (width <= 0 || height <= 0) {
690
+ throw new Error(`Invalid frame dimensions: ${width}x${height}`);
691
+ }
692
+ if (width > 8192 || height > 8192) {
693
+ throw new Error(`Frame dimensions too large: ${width}x${height} (max: 8192x8192)`);
694
+ }
695
+ }
696
+ /**
697
+ * Calculate fcode from search range option
698
+ */
699
+ function calculateFcode(searchRange, width, height) {
700
+ switch (searchRange) {
701
+ case 'small':
702
+ return 2; // 64 pixel range
703
+ case 'medium':
704
+ return 4; // 256 pixel range (default)
705
+ case 'large':
706
+ return 6; // 1024 pixel range
707
+ case 'auto':
708
+ // Auto-adjust based on resolution
709
+ const pixels = width * height;
710
+ if (pixels <= 720 * 480)
711
+ return 3; // SD: 128px
712
+ if (pixels <= 1920 * 1080)
713
+ return 4; // HD: 256px
714
+ return 5; // 4K+: 512px
715
+ default:
716
+ return 4;
717
+ }
718
+ }
719
+ /**
720
+ * Calculate adaptive thresholds based on sensitivity
721
+ */
722
+ function calculateThresholds(sensitivity) {
723
+ switch (sensitivity) {
724
+ case 'low':
725
+ return { intraThresh: 3000, intraThresh2: 150 }; // Less sensitive
726
+ case 'medium':
727
+ return { intraThresh: 2000, intraThresh2: 90 }; // Default
728
+ case 'high':
729
+ return { intraThresh: 1000, intraThresh2: 50 }; // More sensitive
730
+ case 'custom':
731
+ // Will be overridden by customThresholds
732
+ return { intraThresh: 2000, intraThresh2: 90 };
733
+ default:
734
+ return { intraThresh: 2000, intraThresh2: 90 };
735
+ }
736
+ }
737
+ /**
738
+ * Validate frame data
739
+ */
740
+ function validateFrame(frame) {
741
+ if (!frame.data || frame.data.length === 0) {
742
+ throw new Error('Frame data is empty');
743
+ }
744
+ const expectedSize = frame.width * frame.height;
745
+ if (frame.data.length < expectedSize) {
746
+ throw new Error(`Frame data size mismatch: expected at least ${expectedSize}, got ${frame.data.length}`);
747
+ }
748
+ validateFrameDimensions(frame.width, frame.height);
749
+ }
750
+ /**
751
+ * Calculate memory usage for frame
752
+ */
753
+ function calculateFrameMemory(width, height) {
754
+ const mbParam = calculateMBParam(width, height);
755
+ return mbParam.edged_width * mbParam.edged_height;
756
+ }
757
+ /**
758
+ * Estimate processing time based on frame count and resolution
759
+ */
760
+ function estimateProcessingTime(frameCount, width, height, targetFps = 60) {
761
+ // Rough estimate: higher resolution = slower
762
+ const resolutionFactor = (width * height) / (1920 * 1080);
763
+ const adjustedFps = targetFps / resolutionFactor;
764
+ return frameCount / adjustedFps;
765
+ }
766
+
767
+ /**
768
+ * Scene Detector - Main orchestrator for scene change detection
769
+ */
770
+ class SceneDetector {
771
+ constructor(options = {}) {
772
+ // Set default options
773
+ this.options = {
774
+ sensitivity: options.sensitivity || 'medium',
775
+ customThresholds: options.customThresholds || { intraThresh: 2000, intraThresh2: 90 },
776
+ searchRange: options.searchRange || 'medium',
777
+ workers: options.workers || 1, // Multi-threading not implemented yet
778
+ progressive: options.progressive || { enabled: false, initialStep: 1, refinementSteps: [] },
779
+ temporalSmoothing: options.temporalSmoothing || { enabled: false, windowSize: 5, minConsecutive: 2 },
780
+ frameExtraction: options.frameExtraction || { pixelFormat: 'gray', maxBufferFrames: 2 },
781
+ onProgress: options.onProgress || (() => { }),
782
+ onScene: options.onScene || (() => { }),
783
+ format: options.format || 'json'
784
+ };
785
+ this.wasmBridge = new WasmBridge();
786
+ // Initialize detection state
787
+ this.state = {
788
+ intraCount: 1,
789
+ fcode: 4,
790
+ prevFrame: null,
791
+ curFrame: null
792
+ };
793
+ }
794
+ /**
795
+ * Detect scene changes in a video file
796
+ */
797
+ async detect(videoPath) {
798
+ // Initialize WASM module
799
+ await this.wasmBridge.init();
800
+ // Create decoder
801
+ const decoder = new FFmpegDecoder(videoPath, {
802
+ pixelFormat: this.options.frameExtraction.pixelFormat,
803
+ maxBufferFrames: this.options.frameExtraction.maxBufferFrames,
804
+ skipFrames: this.options.frameExtraction.skipFrames
805
+ });
806
+ // Get video metadata
807
+ const metadata = await decoder.getMetadata();
808
+ // Calculate fcode from search range
809
+ this.state.fcode = calculateFcode(this.options.searchRange, metadata.resolution.width, metadata.resolution.height);
810
+ // Pre-allocate WASM buffers (eliminates per-frame allocation overhead)
811
+ this.wasmBridge.allocateBuffers(metadata.resolution.width, metadata.resolution.height);
812
+ // Initialize scene list (frame 0 is always a scene change)
813
+ const scenes = [
814
+ {
815
+ frameNumber: 0,
816
+ timestamp: 0,
817
+ timecode: '00:00:00.000'
818
+ }
819
+ ];
820
+ // Processing statistics
821
+ const startTime = Date.now();
822
+ let processedFrames = 0;
823
+ // Process frames
824
+ await decoder.extractFrames(async (frame) => {
825
+ validateFrame(frame);
826
+ // Update current frame
827
+ this.state.curFrame = frame;
828
+ // Need at least 2 frames to detect scene change
829
+ if (this.state.prevFrame) {
830
+ const isSceneChange = this.wasmBridge.detectSceneChange(this.state.prevFrame, this.state.curFrame, this.state.intraCount, this.state.fcode);
831
+ if (isSceneChange) {
832
+ const scene = {
833
+ frameNumber: frame.frameNumber,
834
+ timestamp: frame.pts,
835
+ timecode: formatTimecode(frame.pts)
836
+ };
837
+ scenes.push(scene);
838
+ // Call scene callback
839
+ this.options.onScene(scene);
840
+ // Reset intraCount
841
+ this.state.intraCount = 1;
842
+ }
843
+ else {
844
+ // Increment intraCount
845
+ this.state.intraCount++;
846
+ }
847
+ }
848
+ // Move current frame to previous
849
+ this.state.prevFrame = this.state.curFrame;
850
+ processedFrames++;
851
+ }, (current, total) => {
852
+ // Progress callback
853
+ const progress = {
854
+ currentFrame: current,
855
+ totalFrames: total,
856
+ percent: Math.round((current / total) * 100)
857
+ };
858
+ // Calculate ETA
859
+ const elapsed = (Date.now() - startTime) / 1000;
860
+ const fps = current / elapsed;
861
+ const remaining = (total - current) / fps;
862
+ progress.eta = remaining;
863
+ this.options.onProgress(progress);
864
+ });
865
+ // Calculate statistics
866
+ const endTime = Date.now();
867
+ const processingTime = (endTime - startTime) / 1000;
868
+ const framesPerSecond = processedFrames / processingTime;
869
+ // Clean up
870
+ decoder.destroy();
871
+ return {
872
+ scenes,
873
+ metadata,
874
+ stats: {
875
+ processingTime,
876
+ framesPerSecond
877
+ }
878
+ };
879
+ }
880
+ /**
881
+ * Destroy the detector and clean up resources
882
+ */
883
+ destroy() {
884
+ this.wasmBridge.destroy();
885
+ this.state.prevFrame = null;
886
+ this.state.curFrame = null;
887
+ }
888
+ }
889
+
890
+ /**
891
+ * keyframes - Scene change detection for Node.js
892
+ *
893
+ * A JavaScript/TypeScript port of vapoursynth-wwxd using Xvid's motion estimation algorithm.
894
+ * Powered by WebAssembly for high performance.
895
+ */
896
+ /**
897
+ * Detect scene changes in a video file (simple API)
898
+ *
899
+ * @param videoPath Path to video file
900
+ * @param options Detection options
901
+ * @returns Detection results with scene changes and metadata
902
+ *
903
+ * @example
904
+ * ```typescript
905
+ * import { detectSceneChanges } from 'keyframes';
906
+ *
907
+ * const results = await detectSceneChanges('input.mp4');
908
+ * console.log(`Found ${results.scenes.length} scenes`);
909
+ *
910
+ * results.scenes.forEach(scene => {
911
+ * console.log(`Scene at ${scene.timecode}`);
912
+ * });
913
+ * ```
914
+ */
915
+ async function detectSceneChanges(videoPath, options) {
916
+ const detector = new SceneDetector(options);
917
+ try {
918
+ const results = await detector.detect(videoPath);
919
+ return results;
920
+ }
921
+ finally {
922
+ detector.destroy();
923
+ }
924
+ }
925
+ /**
926
+ * Version information
927
+ */
928
+ const version = '1.0.0';
929
+ /**
930
+ * Library information
931
+ */
932
+ const info = {
933
+ name: 'keyframes',
934
+ version: '1.0.0',
935
+ description: 'Scene change detection for Node.js using Xvid\'s motion estimation algorithm',
936
+ license: 'GPL-2.0',
937
+ repository: 'https://github.com/yourusername/keyframes',
938
+ author: '',
939
+ credits: {
940
+ original: 'vapoursynth-wwxd by dubhater (https://github.com/dubhater/vapoursynth-wwxd)',
941
+ algorithm: 'Xvid motion estimation (https://www.xvid.com)'
942
+ }
943
+ };
944
+
945
+ export { BufferPool, FFmpegDecoder, FrameBuffer, SceneDetector, WasmBridge, calculateFcode, calculateFrameMemory, calculateMBParam, calculateThresholds, detectSceneChanges, estimateProcessingTime, formatTimecode, info, validateFrame, validateFrameDimensions, version };
946
+ //# sourceMappingURL=keyframes.esm.js.map