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