nodejs-audio-visualizer 4.0.0 → 4.1.1

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/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
+ };