nodejs-audio-visualizer 4.0.0 → 4.1.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 +8 -2
- package/dist/beats.d.ts +19 -0
- package/dist/beats.js +44 -0
- package/dist/beats.js.map +1 -0
- package/dist/bpmEncoder.d.ts +4 -0
- package/dist/bpmEncoder.js +65 -43
- package/dist/bpmEncoder.js.map +1 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/image.d.ts +2 -1
- package/dist/image.js +42 -5
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +191 -46
- package/dist/index.js.map +1 -1
- package/dist/video.d.ts +41 -1
- package/dist/video.js +244 -0
- package/dist/video.js.map +1 -1
- package/package.json +2 -2
- package/src/beats.ts +68 -0
- package/src/bpmEncoder.ts +57 -31
- package/src/config.ts +10 -1
- package/src/image.ts +46 -4
- package/src/index.ts +253 -34
- package/src/video.ts +302 -1
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getAudioFilePath,
|
|
3
3
|
getBackgroundImagePath,
|
|
4
|
+
getBackgroundVideoPath,
|
|
4
5
|
getOutVideoPath,
|
|
5
6
|
getFPS,
|
|
6
7
|
getSpectrumBusMargin,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
getFfmpeg_cfr,
|
|
14
15
|
getFfmpeg_preset,
|
|
15
16
|
getFrame_processing_delay,
|
|
17
|
+
getOutputResolution,
|
|
16
18
|
rotationAliasValues,
|
|
17
19
|
getSpectrumRotation,
|
|
18
20
|
getSpectrumEffect,
|
|
@@ -25,12 +27,14 @@ import {
|
|
|
25
27
|
getPolarEffect,
|
|
26
28
|
getPolarColor,
|
|
27
29
|
getPolarOpacityParsed,
|
|
30
|
+
getAutoEditVideo,
|
|
28
31
|
} from './config';
|
|
29
32
|
import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
|
|
30
|
-
import { parseImage, getImageColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
|
|
31
|
-
import { spawnFfmpegVideoWriter, getProgress, calculateProgress, waitDrain } from './video';
|
|
32
|
-
import { createBpmEncoder, EncodedBmp } from './bpmEncoder';
|
|
33
|
-
import {
|
|
33
|
+
import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
|
|
34
|
+
import { spawnFfmpegVideoWriter, getProgress, calculateProgress, waitDrain, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, spawnConcatVideoFrameReader, cleanupConcatFile } from './video';
|
|
35
|
+
import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
|
|
36
|
+
import { createBeatDetector } from './beats';
|
|
37
|
+
export { BeatInfo, BeatDetectorOptions } from './beats';
|
|
34
38
|
|
|
35
39
|
export const PCM_FORMAT = {
|
|
36
40
|
bit: 8,
|
|
@@ -44,12 +48,20 @@ export interface Config {
|
|
|
44
48
|
audio: {
|
|
45
49
|
path: string
|
|
46
50
|
};
|
|
47
|
-
image
|
|
51
|
+
image?: {
|
|
48
52
|
path: string;
|
|
49
53
|
};
|
|
54
|
+
video?: {
|
|
55
|
+
path: string;
|
|
56
|
+
autoEdit?: boolean;
|
|
57
|
+
};
|
|
50
58
|
outVideo: {
|
|
51
59
|
path: string;
|
|
52
60
|
fps?: number;
|
|
61
|
+
resolution?: {
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
};
|
|
53
65
|
spectrum?: {
|
|
54
66
|
width?: SpectrumSizeValue;
|
|
55
67
|
height?: SpectrumSizeValue;
|
|
@@ -95,17 +107,19 @@ const sleep = (timeout: number) =>
|
|
|
95
107
|
|
|
96
108
|
const createVisualizerFrameGenerator = (
|
|
97
109
|
config: Config,
|
|
98
|
-
|
|
110
|
+
backgroundWidth: number,
|
|
111
|
+
backgroundHeight: number,
|
|
112
|
+
defaultColor: Color,
|
|
99
113
|
spectrumBusMargin: number
|
|
100
114
|
): (params: CommonVisualizerFrameProps) => EncodedBmp => {
|
|
101
115
|
if (!config.outVideo.spectrum) {
|
|
102
116
|
const createPolarVisualizerFrame = createPolarVisualizerFrameGenerator();
|
|
103
|
-
const polarX = getPolarXAbsolute(config,
|
|
104
|
-
const polarY = getPolarYAbsolute(config,
|
|
117
|
+
const polarX = getPolarXAbsolute(config, backgroundWidth);
|
|
118
|
+
const polarY = getPolarYAbsolute(config, backgroundHeight);
|
|
105
119
|
const polarInnerRadius = getPolarInnerRadius(config);
|
|
106
120
|
const polarMaxBarLength = getPolarMaxBarLength(config);
|
|
107
121
|
const polarBarWidth = getPolarBarWidth(config);
|
|
108
|
-
const polarColor = getPolarColor(config) || invertColor(
|
|
122
|
+
const polarColor = getPolarColor(config) || invertColor(defaultColor);
|
|
109
123
|
const polarEffect = getPolarEffect(config);
|
|
110
124
|
const polarOpacity = getPolarOpacityParsed(config);
|
|
111
125
|
|
|
@@ -123,18 +137,18 @@ const createVisualizerFrameGenerator = (
|
|
|
123
137
|
});
|
|
124
138
|
};
|
|
125
139
|
}
|
|
126
|
-
const
|
|
127
|
-
const spectrumWidth = getSpectrumWidthAbsolute(config,
|
|
128
|
-
const spectrumHeight = getSpectrumHeightAbsolute(config,
|
|
129
|
-
const spectrumX = getSpectrumXAbsolute(config, spectrumWidth,
|
|
130
|
-
const spectrumY = getSpectrumYAbsolute(config, spectrumHeight,
|
|
140
|
+
const createSpectrumVisualizerFrame = createSpectrumVisualizerFrameGenerator();
|
|
141
|
+
const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundWidth);
|
|
142
|
+
const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundHeight);
|
|
143
|
+
const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundWidth);
|
|
144
|
+
const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundHeight);
|
|
131
145
|
const spectrumRotation = getSpectrumRotation(config);
|
|
132
|
-
const spectrumColor = getSpectrumColor(config) || invertColor(
|
|
146
|
+
const spectrumColor = getSpectrumColor(config) || invertColor(defaultColor);
|
|
133
147
|
const spectrumEffect = getSpectrumEffect(config);
|
|
134
148
|
const spectrumOpacity = getSpectrumOpacityParsed(config);
|
|
135
149
|
|
|
136
150
|
return (params: CommonVisualizerFrameProps) => {
|
|
137
|
-
return
|
|
151
|
+
return createSpectrumVisualizerFrame({
|
|
138
152
|
...params,
|
|
139
153
|
size: { width: spectrumWidth, height: spectrumHeight },
|
|
140
154
|
position: { x: spectrumX, y: spectrumY },
|
|
@@ -147,19 +161,195 @@ const createVisualizerFrameGenerator = (
|
|
|
147
161
|
};
|
|
148
162
|
};
|
|
149
163
|
|
|
164
|
+
interface PreProcessedAudio {
|
|
165
|
+
spectrums: number[][];
|
|
166
|
+
beatFrameIndices: number[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const preProcessAudio = (
|
|
170
|
+
audioBuffer: Buffer,
|
|
171
|
+
sampleRate: number,
|
|
172
|
+
fps: number,
|
|
173
|
+
framesCount: number,
|
|
174
|
+
): PreProcessedAudio => {
|
|
175
|
+
const audioDataStep = Math.trunc(audioBuffer.length / framesCount);
|
|
176
|
+
const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
|
|
177
|
+
const skipFramesCount = fps < 45 ? 1 : 2;
|
|
178
|
+
const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
|
|
179
|
+
const detectBeat = createBeatDetector(fps);
|
|
180
|
+
|
|
181
|
+
const spectrums: number[][] = [];
|
|
182
|
+
const beatFrameIndices: number[] = [];
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < framesCount; i++) {
|
|
185
|
+
const currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
|
|
186
|
+
processingBuffer.copyWithin(0, currentFrameData.length);
|
|
187
|
+
processingBuffer.set(currentFrameData, PROCESSING_BUFFER_SIZE - currentFrameData.length);
|
|
188
|
+
|
|
189
|
+
const audioDataParser = () => Array.from(processingBuffer);
|
|
190
|
+
const spectrum = processSpectrum(audioDataParser);
|
|
191
|
+
const beat = detectBeat(spectrum);
|
|
192
|
+
|
|
193
|
+
spectrums.push(spectrum);
|
|
194
|
+
if (beat.isBeat) {
|
|
195
|
+
beatFrameIndices.push(i);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { spectrums, beatFrameIndices };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
async function prepareBackgroundForRender(params: {
|
|
203
|
+
useVideoBackground: boolean;
|
|
204
|
+
backgroundVideoPath: string | undefined;
|
|
205
|
+
backgroundImagePath: string | undefined;
|
|
206
|
+
beatFrameIndices: number[];
|
|
207
|
+
framesCount: number;
|
|
208
|
+
outputResolution: ReturnType<typeof getOutputResolution>;
|
|
209
|
+
fps: number;
|
|
210
|
+
autoEditVideo: boolean;
|
|
211
|
+
}): Promise<{
|
|
212
|
+
backgroundWidth: number;
|
|
213
|
+
backgroundHeight: number;
|
|
214
|
+
defaultColor: Color;
|
|
215
|
+
staticBackgroundBuffer: EncodedBmp;
|
|
216
|
+
videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
|
|
217
|
+
videoFrameSize: number;
|
|
218
|
+
encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
|
|
219
|
+
concatFilePath?: string;
|
|
220
|
+
}> {
|
|
221
|
+
const {
|
|
222
|
+
useVideoBackground,
|
|
223
|
+
backgroundVideoPath,
|
|
224
|
+
backgroundImagePath,
|
|
225
|
+
beatFrameIndices,
|
|
226
|
+
framesCount,
|
|
227
|
+
outputResolution,
|
|
228
|
+
fps,
|
|
229
|
+
autoEditVideo,
|
|
230
|
+
} = params;
|
|
231
|
+
|
|
232
|
+
if (useVideoBackground && backgroundVideoPath) {
|
|
233
|
+
const videoInfo = await getVideoInfo(backgroundVideoPath);
|
|
234
|
+
const sceneChanges = autoEditVideo
|
|
235
|
+
? await detectSceneChanges(backgroundVideoPath)
|
|
236
|
+
: [];
|
|
237
|
+
const backgroundWidth = outputResolution?.width ?? videoInfo.width;
|
|
238
|
+
const backgroundHeight = outputResolution?.height ?? videoInfo.height;
|
|
239
|
+
|
|
240
|
+
const videoFrameSize = backgroundWidth * backgroundHeight * 3;
|
|
241
|
+
const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
|
|
242
|
+
|
|
243
|
+
const segments = buildBeatSyncedSegments(
|
|
244
|
+
beatFrameIndices,
|
|
245
|
+
framesCount,
|
|
246
|
+
sceneChanges,
|
|
247
|
+
videoInfo.duration,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const concatFilePath = writeConcatFile(segments, backgroundVideoPath, videoInfo.duration, fps);
|
|
251
|
+
|
|
252
|
+
const videoFrameReader = spawnConcatVideoFrameReader({
|
|
253
|
+
concatFilePath,
|
|
254
|
+
fps,
|
|
255
|
+
totalFrames: framesCount,
|
|
256
|
+
...(outputResolution && {
|
|
257
|
+
width: backgroundWidth,
|
|
258
|
+
height: backgroundHeight,
|
|
259
|
+
sourceWidth: videoInfo.width,
|
|
260
|
+
sourceHeight: videoInfo.height,
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const firstFrame = await readVideoFrame(videoFrameReader.stdout, videoFrameSize);
|
|
265
|
+
if (!firstFrame) {
|
|
266
|
+
throw new Error(`Could not read frames from video: ${backgroundVideoPath}`);
|
|
267
|
+
}
|
|
268
|
+
const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
|
|
269
|
+
const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
|
|
270
|
+
return {
|
|
271
|
+
backgroundWidth,
|
|
272
|
+
backgroundHeight,
|
|
273
|
+
defaultColor,
|
|
274
|
+
staticBackgroundBuffer,
|
|
275
|
+
videoFrameReader,
|
|
276
|
+
videoFrameSize,
|
|
277
|
+
encodeVideoFrame,
|
|
278
|
+
concatFilePath,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!backgroundImagePath) {
|
|
283
|
+
throw new Error('Background image path is required when not using video background.');
|
|
284
|
+
}
|
|
285
|
+
const backgroundImageBmpBuffer = await convertToBmp(
|
|
286
|
+
backgroundImagePath,
|
|
287
|
+
outputResolution?.width,
|
|
288
|
+
outputResolution?.height,
|
|
289
|
+
);
|
|
290
|
+
const backgroundImage = parseImage(backgroundImageBmpBuffer);
|
|
291
|
+
const backgroundWidth = backgroundImage.width;
|
|
292
|
+
const backgroundHeight = backgroundImage.height;
|
|
293
|
+
const defaultColor = getImageColor(backgroundImage);
|
|
294
|
+
|
|
295
|
+
const bpmEncoder = createBpmEncoder({ width: backgroundWidth, height: backgroundHeight });
|
|
296
|
+
const staticBackgroundBuffer = bpmEncoder(backgroundImage.data);
|
|
297
|
+
return {
|
|
298
|
+
backgroundWidth,
|
|
299
|
+
backgroundHeight,
|
|
300
|
+
defaultColor,
|
|
301
|
+
staticBackgroundBuffer,
|
|
302
|
+
videoFrameSize: 0,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function resolveBackgroundFrameBuffer(params: {
|
|
307
|
+
frameIndex: number;
|
|
308
|
+
useVideoBackground: boolean;
|
|
309
|
+
videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
|
|
310
|
+
videoFrameSize: number;
|
|
311
|
+
encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
|
|
312
|
+
staticBackgroundBuffer: EncodedBmp;
|
|
313
|
+
}): Promise<EncodedBmp> {
|
|
314
|
+
const {
|
|
315
|
+
frameIndex,
|
|
316
|
+
useVideoBackground,
|
|
317
|
+
videoFrameReader,
|
|
318
|
+
videoFrameSize,
|
|
319
|
+
encodeVideoFrame,
|
|
320
|
+
staticBackgroundBuffer,
|
|
321
|
+
} = params;
|
|
322
|
+
|
|
323
|
+
if (useVideoBackground && videoFrameReader && encodeVideoFrame) {
|
|
324
|
+
if (frameIndex === 0) {
|
|
325
|
+
return staticBackgroundBuffer;
|
|
326
|
+
}
|
|
327
|
+
const videoFrame = await readVideoFrame(videoFrameReader.stdout, videoFrameSize);
|
|
328
|
+
return videoFrame
|
|
329
|
+
? encodeVideoFrame(videoFrame)
|
|
330
|
+
: staticBackgroundBuffer;
|
|
331
|
+
}
|
|
332
|
+
return staticBackgroundBuffer;
|
|
333
|
+
}
|
|
334
|
+
|
|
150
335
|
export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
|
|
151
336
|
new Promise<number>(async (resolve) => {
|
|
152
|
-
// Validate that only one visualizer type is specified
|
|
153
337
|
if (config.outVideo.spectrum && config.outVideo.polar) {
|
|
154
338
|
throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
|
|
155
339
|
}
|
|
340
|
+
if (!config.image && !config.video) {
|
|
341
|
+
throw new Error('Either "image" or "video" must be specified as the background source.');
|
|
342
|
+
}
|
|
343
|
+
if (config.image && config.video) {
|
|
344
|
+
throw new Error('Cannot use both "image" and "video" options. Please specify only one background source.');
|
|
345
|
+
}
|
|
156
346
|
|
|
157
347
|
const audioFilePath = getAudioFilePath(config);
|
|
158
|
-
const backgroundImagePath = getBackgroundImagePath(config);
|
|
159
348
|
const outVideoPath = getOutVideoPath(config);
|
|
349
|
+
const backgroundVideoPath = getBackgroundVideoPath(config);
|
|
350
|
+
const backgroundImagePath = getBackgroundImagePath(config);
|
|
351
|
+
const useVideoBackground = !!backgroundVideoPath;
|
|
160
352
|
|
|
161
|
-
const backgroundImageBmpBuffer = await convertToBmp(backgroundImagePath);
|
|
162
|
-
const backgroundImage = parseImage(backgroundImageBmpBuffer);
|
|
163
353
|
const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
|
|
164
354
|
const audioBuffer = audioReader.audioBuffer;
|
|
165
355
|
const sampleRate = audioReader.sampleRate;
|
|
@@ -173,12 +363,35 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
173
363
|
const ffmpeg_preset = getFfmpeg_preset(config);
|
|
174
364
|
const frame_processing_delay = getFrame_processing_delay(config);
|
|
175
365
|
|
|
176
|
-
const createVisualizerFrame = createVisualizerFrameGenerator(config, backgroundImage, spectrumBusMargin);
|
|
177
|
-
|
|
178
366
|
const audioDuration = audioBuffer.length / sampleRate;
|
|
179
367
|
const framesCount = Math.trunc(audioDuration * FPS);
|
|
180
|
-
const
|
|
181
|
-
|
|
368
|
+
const outputResolution = getOutputResolution(config);
|
|
369
|
+
|
|
370
|
+
const preprocessed = preProcessAudio(audioBuffer, sampleRate, FPS, framesCount);
|
|
371
|
+
|
|
372
|
+
const {
|
|
373
|
+
backgroundWidth,
|
|
374
|
+
backgroundHeight,
|
|
375
|
+
defaultColor,
|
|
376
|
+
staticBackgroundBuffer,
|
|
377
|
+
videoFrameReader,
|
|
378
|
+
videoFrameSize,
|
|
379
|
+
encodeVideoFrame,
|
|
380
|
+
concatFilePath,
|
|
381
|
+
} = await prepareBackgroundForRender({
|
|
382
|
+
useVideoBackground,
|
|
383
|
+
backgroundVideoPath,
|
|
384
|
+
backgroundImagePath,
|
|
385
|
+
beatFrameIndices: preprocessed.beatFrameIndices,
|
|
386
|
+
framesCount,
|
|
387
|
+
outputResolution,
|
|
388
|
+
fps: FPS,
|
|
389
|
+
autoEditVideo: getAutoEditVideo(config),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const createVisualizerFrame = createVisualizerFrameGenerator(
|
|
393
|
+
config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
|
|
394
|
+
);
|
|
182
395
|
|
|
183
396
|
const ffmpegVideoWriter = spawnFfmpegVideoWriter({
|
|
184
397
|
audioFilename: audioFilePath,
|
|
@@ -190,18 +403,18 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
190
403
|
});
|
|
191
404
|
ffmpegVideoWriter.on('exit', (code: number) => resolve(code));
|
|
192
405
|
|
|
193
|
-
const bpmEncoder = createBpmEncoder({ width: backgroundImage.width, height: backgroundImage.height });
|
|
194
|
-
const backgroundImageBuffer = bpmEncoder(backgroundImage.data);
|
|
195
|
-
const skipFramesCount = FPS < 45 ? 1 : 2;
|
|
196
|
-
const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
|
|
197
|
-
|
|
198
406
|
for (let i = 0; i < framesCount; i++) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
407
|
+
const spectrum = preprocessed.spectrums[i];
|
|
408
|
+
|
|
409
|
+
const backgroundImageBuffer = await resolveBackgroundFrameBuffer({
|
|
410
|
+
frameIndex: i,
|
|
411
|
+
useVideoBackground,
|
|
412
|
+
videoFrameReader,
|
|
413
|
+
videoFrameSize,
|
|
414
|
+
encodeVideoFrame,
|
|
415
|
+
staticBackgroundBuffer,
|
|
416
|
+
});
|
|
202
417
|
|
|
203
|
-
const audioDataParser = () => Array.from(processingBuffer);
|
|
204
|
-
const spectrum = processSpectrum(audioDataParser);
|
|
205
418
|
const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
|
|
206
419
|
backgroundImageBuffer,
|
|
207
420
|
spectrum,
|
|
@@ -219,5 +432,11 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
219
432
|
}
|
|
220
433
|
}
|
|
221
434
|
|
|
435
|
+
if (videoFrameReader) {
|
|
436
|
+
videoFrameReader.kill();
|
|
437
|
+
}
|
|
438
|
+
if (concatFilePath) {
|
|
439
|
+
cleanupConcatFile(concatFilePath);
|
|
440
|
+
}
|
|
222
441
|
ffmpegVideoWriter.stdin.end();
|
|
223
442
|
});
|
package/src/video.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { Writable } from 'stream';
|
|
1
|
+
import { Readable, Writable } from 'stream';
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
|
+
import { writeFileSync, unlinkSync } from 'fs';
|
|
4
|
+
import { resolve as resolvePath, join as joinPath } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
3
6
|
import ffmpegPath from 'ffmpeg-static';
|
|
4
7
|
|
|
5
8
|
interface FfmpegVideoWriterConfig {
|
|
@@ -34,6 +37,170 @@ export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
|
|
|
34
37
|
return ffmpeg;
|
|
35
38
|
};
|
|
36
39
|
|
|
40
|
+
export interface VideoInfo {
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
duration: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SceneChange {
|
|
47
|
+
frameNumber: number;
|
|
48
|
+
pts: number;
|
|
49
|
+
ptsTime: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const detectSceneChanges = (videoPath: string, threshold = 0.4): Promise<SceneChange[]> =>
|
|
53
|
+
new Promise((resolve, reject) => {
|
|
54
|
+
if (!ffmpegPath) {
|
|
55
|
+
reject(new Error('ffmpeg path not found'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const ffmpeg = spawn(ffmpegPath, [
|
|
59
|
+
'-i', videoPath,
|
|
60
|
+
'-filter:v', `select='gt(scene,${threshold})',showinfo`,
|
|
61
|
+
'-f', 'null',
|
|
62
|
+
'-'
|
|
63
|
+
]);
|
|
64
|
+
let stderr = '';
|
|
65
|
+
ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
66
|
+
ffmpeg.on('close', () => {
|
|
67
|
+
const sceneChanges: SceneChange[] = [];
|
|
68
|
+
for (const line of stderr.split('\n')) {
|
|
69
|
+
if (!line.includes('Parsed_showinfo')) continue;
|
|
70
|
+
const nMatch = line.match(/n:\s*(\d+)/);
|
|
71
|
+
const ptsMatch = line.match(/pts:\s*(\d+)/);
|
|
72
|
+
const ptsTimeMatch = line.match(/pts_time:\s*([\d.]+)/);
|
|
73
|
+
if (nMatch && ptsTimeMatch) {
|
|
74
|
+
sceneChanges.push({
|
|
75
|
+
frameNumber: parseInt(nMatch[1]),
|
|
76
|
+
pts: ptsMatch ? parseInt(ptsMatch[1]) : 0,
|
|
77
|
+
ptsTime: parseFloat(ptsTimeMatch[1]),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
resolve(sceneChanges);
|
|
82
|
+
});
|
|
83
|
+
ffmpeg.on('error', reject);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
|
|
87
|
+
new Promise((resolvePromise, reject) => {
|
|
88
|
+
if (!ffmpegPath) {
|
|
89
|
+
reject(new Error('ffmpeg path not found'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const ffmpeg = spawn(ffmpegPath, ['-i', videoPath, '-hide_banner']);
|
|
93
|
+
let stderr = '';
|
|
94
|
+
ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
95
|
+
ffmpeg.on('close', () => {
|
|
96
|
+
const dimMatch = stderr.match(/Stream.*Video:.*?(\d{2,5})x(\d{2,5})/);
|
|
97
|
+
if (!dimMatch) {
|
|
98
|
+
reject(new Error(`Could not determine video dimensions for: ${videoPath}`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const durMatch = stderr.match(/Duration:\s*(\d+):(\d+):([\d.]+)/);
|
|
102
|
+
const duration = durMatch
|
|
103
|
+
? parseInt(durMatch[1]) * 3600 + parseInt(durMatch[2]) * 60 + parseFloat(durMatch[3])
|
|
104
|
+
: 0;
|
|
105
|
+
resolvePromise({
|
|
106
|
+
width: parseInt(dimMatch[1]),
|
|
107
|
+
height: parseInt(dimMatch[2]),
|
|
108
|
+
duration,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const isOrientationMismatch = (sw: number, sh: number, tw: number, th: number): boolean =>
|
|
114
|
+
(sw > sh && tw < th) || (sw < sh && tw > th);
|
|
115
|
+
|
|
116
|
+
export const buildVideoFilter = (
|
|
117
|
+
sourceWidth: number,
|
|
118
|
+
sourceHeight: number,
|
|
119
|
+
targetWidth: number,
|
|
120
|
+
targetHeight: number,
|
|
121
|
+
): string => {
|
|
122
|
+
if (!isOrientationMismatch(sourceWidth, sourceHeight, targetWidth, targetHeight)) {
|
|
123
|
+
return `scale=${targetWidth}:${targetHeight}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const targetAspect = targetWidth / targetHeight;
|
|
127
|
+
const makeEven = (n: number) => Math.floor(n / 2) * 2;
|
|
128
|
+
let cropW: number, cropH: number, cropX: number, cropY: number;
|
|
129
|
+
|
|
130
|
+
if (sourceWidth / sourceHeight > targetAspect) {
|
|
131
|
+
cropW = makeEven(Math.round(sourceHeight * targetAspect));
|
|
132
|
+
cropH = makeEven(sourceHeight);
|
|
133
|
+
cropX = Math.floor((sourceWidth - cropW) / 2);
|
|
134
|
+
cropY = Math.floor((sourceHeight - cropH) / 2);
|
|
135
|
+
} else {
|
|
136
|
+
cropW = makeEven(sourceWidth);
|
|
137
|
+
cropH = makeEven(Math.round(sourceWidth / targetAspect));
|
|
138
|
+
cropX = Math.floor((sourceWidth - cropW) / 2);
|
|
139
|
+
cropY = Math.floor((sourceHeight - cropH) / 2);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return `crop=${cropW}:${cropH}:${cropX}:${cropY},scale=${targetWidth}:${targetHeight}`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const spawnVideoFrameReader = (config: {
|
|
146
|
+
videoPath: string;
|
|
147
|
+
fps: number;
|
|
148
|
+
totalFrames: number;
|
|
149
|
+
width?: number;
|
|
150
|
+
height?: number;
|
|
151
|
+
sourceWidth?: number;
|
|
152
|
+
sourceHeight?: number;
|
|
153
|
+
}) => {
|
|
154
|
+
if (!ffmpegPath) {
|
|
155
|
+
throw new Error('ffmpeg path not found');
|
|
156
|
+
}
|
|
157
|
+
const args = [
|
|
158
|
+
'-stream_loop', '-1',
|
|
159
|
+
'-i', config.videoPath,
|
|
160
|
+
'-r', `${config.fps}`,
|
|
161
|
+
'-frames:v', `${config.totalFrames}`,
|
|
162
|
+
];
|
|
163
|
+
if (config.width && config.height) {
|
|
164
|
+
const filter = config.sourceWidth && config.sourceHeight
|
|
165
|
+
? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
|
|
166
|
+
: `scale=${config.width}:${config.height}`;
|
|
167
|
+
args.push('-vf', filter);
|
|
168
|
+
}
|
|
169
|
+
args.push(
|
|
170
|
+
'-f', 'rawvideo',
|
|
171
|
+
'-pix_fmt', 'bgr24',
|
|
172
|
+
'-v', 'quiet',
|
|
173
|
+
'pipe:1'
|
|
174
|
+
);
|
|
175
|
+
return spawn(ffmpegPath, args);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const readVideoFrame = (stream: Readable, frameSize: number): Promise<Buffer | null> =>
|
|
179
|
+
new Promise((resolve) => {
|
|
180
|
+
const tryRead = () => {
|
|
181
|
+
const data = stream.read(frameSize) as Buffer | null;
|
|
182
|
+
if (data !== null) {
|
|
183
|
+
resolve(data.length === frameSize ? data : null);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const onReadable = () => {
|
|
187
|
+
cleanup();
|
|
188
|
+
tryRead();
|
|
189
|
+
};
|
|
190
|
+
const onEnd = () => {
|
|
191
|
+
cleanup();
|
|
192
|
+
resolve(null);
|
|
193
|
+
};
|
|
194
|
+
const cleanup = () => {
|
|
195
|
+
stream.removeListener('readable', onReadable);
|
|
196
|
+
stream.removeListener('end', onEnd);
|
|
197
|
+
};
|
|
198
|
+
stream.once('readable', onReadable);
|
|
199
|
+
stream.once('end', onEnd);
|
|
200
|
+
};
|
|
201
|
+
tryRead();
|
|
202
|
+
});
|
|
203
|
+
|
|
37
204
|
export const getProgress = (onProgress: (currentFrame: number) => any) =>
|
|
38
205
|
(stderrOutput: Buffer) => {
|
|
39
206
|
const matchResult = stderrOutput.toString().match(/frame=[ ]+(\d+)/);
|
|
@@ -55,3 +222,137 @@ export const calculateProgress = (framesCount: number, callback: (progress: numb
|
|
|
55
222
|
|
|
56
223
|
export const waitDrain = (stream: Writable) =>
|
|
57
224
|
new Promise<void>(resolve => stream.once('drain', resolve));
|
|
225
|
+
|
|
226
|
+
export interface VideoSegment {
|
|
227
|
+
outputStartFrame: number;
|
|
228
|
+
videoSeekSeconds: number;
|
|
229
|
+
frameCount: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const mergeSmallScenes = (sceneChanges: SceneChange[], videoDuration: number, minDuration = 2): SceneChange[] => {
|
|
233
|
+
if (sceneChanges.length === 0) return [];
|
|
234
|
+
const merged: SceneChange[] = [sceneChanges[0]];
|
|
235
|
+
for (let i = 1; i < sceneChanges.length; i++) {
|
|
236
|
+
const lastKept = merged[merged.length - 1];
|
|
237
|
+
const gap = sceneChanges[i].ptsTime - lastKept.ptsTime;
|
|
238
|
+
if (gap >= minDuration) {
|
|
239
|
+
merged.push(sceneChanges[i]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const lastScene = merged[merged.length - 1];
|
|
243
|
+
if (videoDuration - lastScene.ptsTime < minDuration && merged.length > 1) {
|
|
244
|
+
merged.pop();
|
|
245
|
+
}
|
|
246
|
+
return merged;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const buildBeatSyncedSegments = (
|
|
250
|
+
beatFrameIndices: number[],
|
|
251
|
+
totalFrames: number,
|
|
252
|
+
sceneChanges: SceneChange[],
|
|
253
|
+
videoDuration: number,
|
|
254
|
+
): VideoSegment[] => {
|
|
255
|
+
if (sceneChanges.length === 0) {
|
|
256
|
+
return [
|
|
257
|
+
{
|
|
258
|
+
outputStartFrame: 0,
|
|
259
|
+
videoSeekSeconds: 0,
|
|
260
|
+
frameCount: totalFrames,
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const consolidated = mergeSmallScenes(sceneChanges, videoDuration);
|
|
266
|
+
let seekPositions: number[];
|
|
267
|
+
if (consolidated.length >= 2) {
|
|
268
|
+
seekPositions = consolidated.map(sc => sc.ptsTime);
|
|
269
|
+
} else {
|
|
270
|
+
const count = Math.max(10, Math.ceil(videoDuration / 3));
|
|
271
|
+
seekPositions = Array.from({ length: count }, (_, i) => (i * videoDuration) / count);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const boundaries = [0, ...beatFrameIndices];
|
|
275
|
+
if (boundaries[boundaries.length - 1] !== totalFrames) {
|
|
276
|
+
boundaries.push(totalFrames);
|
|
277
|
+
}
|
|
278
|
+
const unique = Array.from(new Set(boundaries)).sort((a, b) => a - b);
|
|
279
|
+
|
|
280
|
+
const segments: VideoSegment[] = [];
|
|
281
|
+
for (let i = 0; i < unique.length - 1; i++) {
|
|
282
|
+
segments.push({
|
|
283
|
+
outputStartFrame: unique[i],
|
|
284
|
+
videoSeekSeconds: seekPositions[i % seekPositions.length],
|
|
285
|
+
frameCount: unique[i + 1] - unique[i],
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return segments;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const writeConcatFile = (
|
|
292
|
+
segments: VideoSegment[],
|
|
293
|
+
videoPath: string,
|
|
294
|
+
videoDuration: number,
|
|
295
|
+
fps: number,
|
|
296
|
+
): string => {
|
|
297
|
+
const absVideoPath = resolvePath(videoPath).replace(/\\/g, '/');
|
|
298
|
+
const escaped = absVideoPath.replace(/'/g, "'\\''");
|
|
299
|
+
let content = '';
|
|
300
|
+
|
|
301
|
+
for (const seg of segments) {
|
|
302
|
+
const segDuration = seg.frameCount / fps;
|
|
303
|
+
let remaining = segDuration;
|
|
304
|
+
let pos = seg.videoSeekSeconds % videoDuration;
|
|
305
|
+
|
|
306
|
+
while (remaining > 0.001) {
|
|
307
|
+
const available = videoDuration - pos;
|
|
308
|
+
const take = Math.min(remaining, available);
|
|
309
|
+
content += `file '${escaped}'\n`;
|
|
310
|
+
content += `inpoint ${pos.toFixed(6)}\n`;
|
|
311
|
+
content += `outpoint ${(pos + take).toFixed(6)}\n\n`;
|
|
312
|
+
remaining -= take;
|
|
313
|
+
pos = 0;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const concatPath = joinPath(tmpdir(), `av-concat-${Date.now()}.txt`);
|
|
318
|
+
writeFileSync(concatPath, content, 'utf-8');
|
|
319
|
+
return concatPath;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export const spawnConcatVideoFrameReader = (config: {
|
|
323
|
+
concatFilePath: string;
|
|
324
|
+
fps: number;
|
|
325
|
+
totalFrames: number;
|
|
326
|
+
width?: number;
|
|
327
|
+
height?: number;
|
|
328
|
+
sourceWidth?: number;
|
|
329
|
+
sourceHeight?: number;
|
|
330
|
+
}) => {
|
|
331
|
+
if (!ffmpegPath) {
|
|
332
|
+
throw new Error('ffmpeg path not found');
|
|
333
|
+
}
|
|
334
|
+
const args = [
|
|
335
|
+
'-f', 'concat',
|
|
336
|
+
'-safe', '0',
|
|
337
|
+
'-i', config.concatFilePath,
|
|
338
|
+
'-r', `${config.fps}`,
|
|
339
|
+
'-frames:v', `${config.totalFrames}`,
|
|
340
|
+
];
|
|
341
|
+
if (config.width && config.height) {
|
|
342
|
+
const filter = config.sourceWidth && config.sourceHeight
|
|
343
|
+
? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
|
|
344
|
+
: `scale=${config.width}:${config.height}`;
|
|
345
|
+
args.push('-vf', filter);
|
|
346
|
+
}
|
|
347
|
+
args.push(
|
|
348
|
+
'-f', 'rawvideo',
|
|
349
|
+
'-pix_fmt', 'bgr24',
|
|
350
|
+
'-v', 'quiet',
|
|
351
|
+
'pipe:1'
|
|
352
|
+
);
|
|
353
|
+
return spawn(ffmpegPath, args);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
export const cleanupConcatFile = (filePath: string) => {
|
|
357
|
+
try { unlinkSync(filePath); } catch {}
|
|
358
|
+
};
|