@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.
- package/README.md +280 -0
- package/bin/cli.js +293 -0
- package/dist/decoder/ffmpeg-decoder.d.ts +50 -0
- package/dist/decoder/ffmpeg-decoder.d.ts.map +1 -0
- package/dist/decoder/ffmpeg-decoder.js +269 -0
- package/dist/decoder/ffmpeg-decoder.js.map +1 -0
- package/dist/decoder/frame-buffer.d.ts +81 -0
- package/dist/decoder/frame-buffer.d.ts.map +1 -0
- package/dist/decoder/frame-buffer.js +123 -0
- package/dist/decoder/frame-buffer.js.map +1 -0
- package/dist/detection/detector.d.ts +19 -0
- package/dist/detection/detector.d.ts.map +1 -0
- package/dist/detection/detector.js +126 -0
- package/dist/detection/detector.js.map +1 -0
- package/dist/detection/wasm-bridge.d.ts +82 -0
- package/dist/detection/wasm-bridge.d.ts.map +1 -0
- package/dist/detection/wasm-bridge.js +182 -0
- package/dist/detection/wasm-bridge.js.map +1 -0
- package/dist/detection.wasm.js +2 -0
- package/dist/detection.wasm.wasm +0 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/keyframes.cjs.js +985 -0
- package/dist/keyframes.cjs.js.map +1 -0
- package/dist/keyframes.esm.js +946 -0
- package/dist/keyframes.esm.js.map +1 -0
- package/dist/types/index.d.ts +225 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/buffer-pool.d.ts +44 -0
- package/dist/utils/buffer-pool.d.ts.map +1 -0
- package/dist/utils/buffer-pool.js +81 -0
- package/dist/utils/buffer-pool.js.map +1 -0
- package/dist/utils/frame-processor.d.ts +48 -0
- package/dist/utils/frame-processor.d.ts.map +1 -0
- package/dist/utils/frame-processor.js +112 -0
- package/dist/utils/frame-processor.js.map +1 -0
- 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
|