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/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 { BmpDecoder } from 'bmp-js';
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
- backgroundImage: BmpDecoder,
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, backgroundImage.width);
104
- const polarY = getPolarYAbsolute(config, backgroundImage.height);
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(getImageColor(backgroundImage));
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 createPolarVisualizerFrame = createSpectrumVisualizerFrameGenerator();
127
- const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundImage.width);
128
- const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundImage.height);
129
- const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundImage.width);
130
- const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundImage.height);
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(getImageColor(backgroundImage));
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 createPolarVisualizerFrame({
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 audioDataStep = Math.trunc(audioBuffer.length / framesCount);
181
- const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
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 currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
200
- processingBuffer.copyWithin(0, currentFrameData.length);
201
- processingBuffer.set(currentFrameData, PROCESSING_BUFFER_SIZE - currentFrameData.length);
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
+ };