nodejs-audio-visualizer 4.1.1 → 4.2.2
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 +5 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/highlight.d.ts +32 -0
- package/dist/highlight.js +326 -0
- package/dist/highlight.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +177 -65
- package/dist/index.js.map +1 -1
- package/dist/video.d.ts +9 -0
- package/dist/video.js +8 -9
- package/dist/video.js.map +1 -1
- package/dist/waitForEventLoop.d.ts +1 -0
- package/dist/waitForEventLoop.js +6 -0
- package/dist/waitForEventLoop.js.map +1 -0
- package/package.json +1 -1
- package/src/config.ts +21 -0
- package/src/highlight.ts +284 -0
- package/src/index.ts +191 -66
- package/src/tests/config.test.ts +32 -0
- package/src/tests/highlight.test.ts +178 -0
- package/src/tests/index.test.ts +11 -4
- package/src/tests/video.test.ts +36 -1
- package/src/video.ts +20 -3
- package/src/waitForEventLoop.ts +2 -0
package/src/highlight.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/** Output length per segment when `audio.autoHighlight` is true (seconds). */
|
|
2
|
+
|
|
3
|
+
import { waitForEventLoop } from "./waitForEventLoop";
|
|
4
|
+
|
|
5
|
+
export const HIGHLIGHT_DURATION_SEC = 15;
|
|
6
|
+
|
|
7
|
+
export interface BeatFrameEvent {
|
|
8
|
+
frameIndex: number;
|
|
9
|
+
intensity: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface HighlightAudioSegment {
|
|
13
|
+
seekSeconds: number;
|
|
14
|
+
durationSeconds: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One auto-highlight segment before concatenation (for separate output files). */
|
|
18
|
+
export interface HighlightRun {
|
|
19
|
+
startFrame: number;
|
|
20
|
+
highlightFrames: number;
|
|
21
|
+
spectrums: number[][];
|
|
22
|
+
beatFrameIndices: number[];
|
|
23
|
+
audioSegment: HighlightAudioSegment;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildHighlightRun(
|
|
27
|
+
fps: number,
|
|
28
|
+
seg: { startFrame: number; highlightFrames: number },
|
|
29
|
+
spectrums: number[][],
|
|
30
|
+
beatEvents: BeatFrameEvent[],
|
|
31
|
+
): HighlightRun {
|
|
32
|
+
const { startFrame, highlightFrames } = seg;
|
|
33
|
+
const spectrumsSlice = spectrums.slice(startFrame, startFrame + highlightFrames);
|
|
34
|
+
const beatFrameIndices = beatEvents
|
|
35
|
+
.map(b => b.frameIndex)
|
|
36
|
+
.filter(i => i >= startFrame && i < startFrame + highlightFrames)
|
|
37
|
+
.map(i => i - startFrame)
|
|
38
|
+
.sort((a, b) => a - b);
|
|
39
|
+
return {
|
|
40
|
+
startFrame,
|
|
41
|
+
highlightFrames,
|
|
42
|
+
spectrums: spectrumsSlice,
|
|
43
|
+
beatFrameIndices,
|
|
44
|
+
audioSegment: {
|
|
45
|
+
seekSeconds: startFrame / fps,
|
|
46
|
+
durationSeconds: highlightFrames / fps,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function frameEnergy(spectrum: number[]): number {
|
|
52
|
+
let s = 0;
|
|
53
|
+
for (let i = 0; i < spectrum.length; i++) {
|
|
54
|
+
const v = spectrum[i];
|
|
55
|
+
s += v * v;
|
|
56
|
+
}
|
|
57
|
+
return s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rangesOverlap(
|
|
61
|
+
aStart: number,
|
|
62
|
+
aEndExclusive: number,
|
|
63
|
+
ranges: Array<{ start: number; endExclusive: number }>,
|
|
64
|
+
): boolean {
|
|
65
|
+
for (const r of ranges) {
|
|
66
|
+
if (aStart < r.endExclusive && aEndExclusive > r.start) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function buildFrameEnergies(
|
|
74
|
+
spectrums: number[][],
|
|
75
|
+
totalFrames: number,
|
|
76
|
+
): Promise<Float64Array> {
|
|
77
|
+
const energies = new Float64Array(totalFrames);
|
|
78
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
79
|
+
energies[i] = frameEnergy(spectrums[i] ?? []);
|
|
80
|
+
await waitForEventLoop();
|
|
81
|
+
}
|
|
82
|
+
return energies;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Chooses the fixed-length window whose frames have the highest summed spectral energy
|
|
87
|
+
* (typical chorus / drop vs a single loud intro transient).
|
|
88
|
+
* Skips windows that overlap any excluded range.
|
|
89
|
+
*/
|
|
90
|
+
async function findBestHighlightStart(
|
|
91
|
+
energies: Float64Array,
|
|
92
|
+
totalFrames: number,
|
|
93
|
+
windowFrames: number,
|
|
94
|
+
excludeRanges: Array<{ start: number; endExclusive: number }>,
|
|
95
|
+
): Promise<number | null> {
|
|
96
|
+
if (totalFrames <= windowFrames) {
|
|
97
|
+
if (!rangesOverlap(0, totalFrames, excludeRanges)) {
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let minE = Infinity;
|
|
104
|
+
let maxE = -Infinity;
|
|
105
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
106
|
+
const e = energies[i];
|
|
107
|
+
if (e < minE) {
|
|
108
|
+
minE = e;
|
|
109
|
+
}
|
|
110
|
+
if (e > maxE) {
|
|
111
|
+
maxE = e;
|
|
112
|
+
}
|
|
113
|
+
await waitForEventLoop();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const prefix = new Float64Array(totalFrames + 1);
|
|
117
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
118
|
+
prefix[i + 1] = prefix[i] + energies[i];
|
|
119
|
+
await waitForEventLoop();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const maxStart = totalFrames - windowFrames;
|
|
123
|
+
let bestStart: number | null = null;
|
|
124
|
+
let bestScore = -Infinity;
|
|
125
|
+
|
|
126
|
+
for (let start = 0; start <= maxStart; start++) {
|
|
127
|
+
if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
|
|
128
|
+
await waitForEventLoop();
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const score = prefix[start + windowFrames] - prefix[start];
|
|
132
|
+
if (score > bestScore) {
|
|
133
|
+
bestScore = score;
|
|
134
|
+
bestStart = start;
|
|
135
|
+
}
|
|
136
|
+
await waitForEventLoop();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (bestStart === null) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (maxE <= minE) {
|
|
144
|
+
const center = Math.floor((totalFrames - windowFrames) / 2);
|
|
145
|
+
let bestDist = Infinity;
|
|
146
|
+
let tieBreak: number | null = null;
|
|
147
|
+
for (let start = 0; start <= maxStart; start++) {
|
|
148
|
+
if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
|
|
149
|
+
await waitForEventLoop();
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const dist = Math.abs(start - center);
|
|
153
|
+
if (dist < bestDist) {
|
|
154
|
+
bestDist = dist;
|
|
155
|
+
tieBreak = start;
|
|
156
|
+
}
|
|
157
|
+
await waitForEventLoop();
|
|
158
|
+
}
|
|
159
|
+
return tieBreak;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return bestStart;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Picks up to `segmentCount` non-overlapping 15s windows with highest summed spectral energy each,
|
|
168
|
+
* sorts them chronologically, concatenates spectrums, and remaps beat indices.
|
|
169
|
+
*/
|
|
170
|
+
export async function computeHighlightSlice(
|
|
171
|
+
fps: number,
|
|
172
|
+
totalFrames: number,
|
|
173
|
+
spectrums: number[][],
|
|
174
|
+
beatEvents: BeatFrameEvent[],
|
|
175
|
+
segmentCount = 1,
|
|
176
|
+
): Promise<{
|
|
177
|
+
startFrame: number;
|
|
178
|
+
highlightFrames: number;
|
|
179
|
+
spectrums: number[][];
|
|
180
|
+
beatFrameIndices: number[];
|
|
181
|
+
audioSeekSeconds: number;
|
|
182
|
+
audioDurationSeconds: number;
|
|
183
|
+
audioSegments: HighlightAudioSegment[];
|
|
184
|
+
runs: HighlightRun[];
|
|
185
|
+
}> {
|
|
186
|
+
const highlightFramesTarget = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
|
|
187
|
+
|
|
188
|
+
if (totalFrames <= 0) {
|
|
189
|
+
return {
|
|
190
|
+
startFrame: 0,
|
|
191
|
+
highlightFrames: 0,
|
|
192
|
+
spectrums: [],
|
|
193
|
+
beatFrameIndices: [],
|
|
194
|
+
audioSeekSeconds: 0,
|
|
195
|
+
audioDurationSeconds: 0,
|
|
196
|
+
audioSegments: [],
|
|
197
|
+
runs: [],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (totalFrames <= highlightFramesTarget) {
|
|
202
|
+
const audioSegments: HighlightAudioSegment[] = [
|
|
203
|
+
{
|
|
204
|
+
seekSeconds: 0,
|
|
205
|
+
durationSeconds: totalFrames / fps,
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
const fullSeg = { startFrame: 0, highlightFrames: totalFrames };
|
|
209
|
+
return {
|
|
210
|
+
startFrame: 0,
|
|
211
|
+
highlightFrames: totalFrames,
|
|
212
|
+
spectrums: spectrums.slice(),
|
|
213
|
+
beatFrameIndices: beatEvents.map(b => b.frameIndex).sort((a, b) => a - b),
|
|
214
|
+
audioSeekSeconds: 0,
|
|
215
|
+
audioDurationSeconds: totalFrames / fps,
|
|
216
|
+
audioSegments,
|
|
217
|
+
runs: [buildHighlightRun(fps, fullSeg, spectrums, beatEvents)],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const highlightFrames = highlightFramesTarget;
|
|
222
|
+
const energies = await buildFrameEnergies(spectrums, totalFrames);
|
|
223
|
+
const excludeRanges: Array<{ start: number; endExclusive: number }> = [];
|
|
224
|
+
const rawSegments: Array<{ startFrame: number; highlightFrames: number }> = [];
|
|
225
|
+
|
|
226
|
+
const n = Math.max(1, Math.floor(segmentCount));
|
|
227
|
+
for (let k = 0; k < n; k++) {
|
|
228
|
+
const startFrame = await findBestHighlightStart(
|
|
229
|
+
energies,
|
|
230
|
+
totalFrames,
|
|
231
|
+
highlightFrames,
|
|
232
|
+
excludeRanges,
|
|
233
|
+
);
|
|
234
|
+
if (startFrame === null) {
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
rawSegments.push({ startFrame, highlightFrames });
|
|
238
|
+
excludeRanges.push({ start: startFrame, endExclusive: startFrame + highlightFrames });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
rawSegments.sort((a, b) => a.startFrame - b.startFrame);
|
|
242
|
+
|
|
243
|
+
const runs = rawSegments.map(seg =>
|
|
244
|
+
buildHighlightRun(fps, seg, spectrums, beatEvents),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const slicedSpectrums: number[][] = [];
|
|
248
|
+
const beatFrameIndices: number[] = [];
|
|
249
|
+
let outOffset = 0;
|
|
250
|
+
|
|
251
|
+
for (const seg of rawSegments) {
|
|
252
|
+
const chunk = spectrums.slice(seg.startFrame, seg.startFrame + seg.highlightFrames);
|
|
253
|
+
slicedSpectrums.push(...chunk);
|
|
254
|
+
|
|
255
|
+
for (const b of beatEvents) {
|
|
256
|
+
const i = b.frameIndex;
|
|
257
|
+
if (i >= seg.startFrame && i < seg.startFrame + seg.highlightFrames) {
|
|
258
|
+
beatFrameIndices.push(i - seg.startFrame + outOffset);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
outOffset += seg.highlightFrames;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
beatFrameIndices.sort((a, b) => a - b);
|
|
265
|
+
|
|
266
|
+
const totalHighlightFrames = slicedSpectrums.length;
|
|
267
|
+
const audioSegments: HighlightAudioSegment[] = rawSegments.map(seg => ({
|
|
268
|
+
seekSeconds: seg.startFrame / fps,
|
|
269
|
+
durationSeconds: seg.highlightFrames / fps,
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
const first = audioSegments[0] ?? { seekSeconds: 0, durationSeconds: 0 };
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
startFrame: rawSegments[0]?.startFrame ?? 0,
|
|
276
|
+
highlightFrames: totalHighlightFrames,
|
|
277
|
+
spectrums: slicedSpectrums,
|
|
278
|
+
beatFrameIndices,
|
|
279
|
+
audioSeekSeconds: first.seekSeconds,
|
|
280
|
+
audioDurationSeconds: first.durationSeconds,
|
|
281
|
+
audioSegments,
|
|
282
|
+
runs,
|
|
283
|
+
};
|
|
284
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'path';
|
|
1
2
|
import {
|
|
2
3
|
getAudioFilePath,
|
|
3
4
|
getBackgroundImagePath,
|
|
@@ -28,13 +29,18 @@ import {
|
|
|
28
29
|
getPolarColor,
|
|
29
30
|
getPolarOpacityParsed,
|
|
30
31
|
getAutoEditVideo,
|
|
32
|
+
getAudioAutoHighlight,
|
|
33
|
+
getAudioAutoHighlightCount,
|
|
31
34
|
} from './config';
|
|
32
35
|
import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
|
|
33
36
|
import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
|
|
34
|
-
import { spawnFfmpegVideoWriter, getProgress,
|
|
37
|
+
import { spawnFfmpegVideoWriter, getProgress, waitDrain, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, spawnConcatVideoFrameReader, cleanupConcatFile } from './video';
|
|
35
38
|
import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
|
|
36
39
|
import { createBeatDetector } from './beats';
|
|
37
40
|
export { BeatInfo, BeatDetectorOptions } from './beats';
|
|
41
|
+
import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
|
|
42
|
+
import { waitForEventLoop } from './waitForEventLoop';
|
|
43
|
+
export { computeHighlightSlice, HIGHLIGHT_DURATION_SEC, BeatFrameEvent, HighlightAudioSegment, HighlightRun } from './highlight';
|
|
38
44
|
|
|
39
45
|
export const PCM_FORMAT = {
|
|
40
46
|
bit: 8,
|
|
@@ -46,7 +52,10 @@ const PROCESSING_BUFFER_SIZE = Math.pow(2, 12);
|
|
|
46
52
|
|
|
47
53
|
export interface Config {
|
|
48
54
|
audio: {
|
|
49
|
-
path: string
|
|
55
|
+
path: string;
|
|
56
|
+
autoHighlight?: boolean;
|
|
57
|
+
/** When `autoHighlight` is true, number of non-overlapping 15s windows to stitch (default 1). */
|
|
58
|
+
autoHighlightCount?: number;
|
|
50
59
|
};
|
|
51
60
|
image?: {
|
|
52
61
|
path: string;
|
|
@@ -164,9 +173,10 @@ const createVisualizerFrameGenerator = (
|
|
|
164
173
|
interface PreProcessedAudio {
|
|
165
174
|
spectrums: number[][];
|
|
166
175
|
beatFrameIndices: number[];
|
|
176
|
+
beatEvents: { frameIndex: number; intensity: number }[];
|
|
167
177
|
}
|
|
168
178
|
|
|
169
|
-
const PREPROCESS_YIELD_EVERY =
|
|
179
|
+
const PREPROCESS_YIELD_EVERY = 1;
|
|
170
180
|
|
|
171
181
|
const preProcessAudio = async (
|
|
172
182
|
audioBuffer: Buffer,
|
|
@@ -182,6 +192,7 @@ const preProcessAudio = async (
|
|
|
182
192
|
|
|
183
193
|
const spectrums: number[][] = [];
|
|
184
194
|
const beatFrameIndices: number[] = [];
|
|
195
|
+
const beatEvents: { frameIndex: number; intensity: number }[] = [];
|
|
185
196
|
|
|
186
197
|
for (let i = 0; i < framesCount; i++) {
|
|
187
198
|
const currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
|
|
@@ -195,14 +206,13 @@ const preProcessAudio = async (
|
|
|
195
206
|
spectrums.push(spectrum);
|
|
196
207
|
if (beat.isBeat) {
|
|
197
208
|
beatFrameIndices.push(i);
|
|
209
|
+
beatEvents.push({ frameIndex: i, intensity: beat.intensity });
|
|
198
210
|
}
|
|
199
211
|
|
|
200
|
-
|
|
201
|
-
await new Promise<void>(resolve => setImmediate(resolve));
|
|
202
|
-
}
|
|
212
|
+
await waitForEventLoop();
|
|
203
213
|
}
|
|
204
214
|
|
|
205
|
-
return { spectrums, beatFrameIndices };
|
|
215
|
+
return { spectrums, beatFrameIndices, beatEvents };
|
|
206
216
|
};
|
|
207
217
|
|
|
208
218
|
async function prepareBackgroundForRender(params: {
|
|
@@ -338,8 +348,14 @@ async function resolveBackgroundFrameBuffer(params: {
|
|
|
338
348
|
return staticBackgroundBuffer;
|
|
339
349
|
}
|
|
340
350
|
|
|
351
|
+
export interface RenderAudioVisualizerResult {
|
|
352
|
+
exitCode: number;
|
|
353
|
+
/** Absolute paths of video files written successfully (exit code 0, full pass, no early stop). */
|
|
354
|
+
outputVideoFiles: string[];
|
|
355
|
+
}
|
|
356
|
+
|
|
341
357
|
export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
|
|
342
|
-
new Promise<
|
|
358
|
+
new Promise<RenderAudioVisualizerResult>(async (resolve) => {
|
|
343
359
|
if (config.outVideo.spectrum && config.outVideo.polar) {
|
|
344
360
|
throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
|
|
345
361
|
}
|
|
@@ -378,74 +394,183 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
378
394
|
}
|
|
379
395
|
const preprocessed = await preProcessAudio(audioBuffer, sampleRate, FPS, framesCount);
|
|
380
396
|
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const ffmpegVideoWriter = spawnFfmpegVideoWriter({
|
|
406
|
-
audioFilename: audioFilePath,
|
|
407
|
-
videoFileName: outVideoPath,
|
|
408
|
-
fps: FPS,
|
|
409
|
-
...(!!onProgress && { onStderr: getProgress(calculateProgress(framesCount + 1, onProgress)) }),
|
|
410
|
-
...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
|
|
411
|
-
...(ffmpeg_preset && { preset: ffmpeg_preset }),
|
|
412
|
-
});
|
|
413
|
-
ffmpegVideoWriter.on('exit', (code: number) => resolve(code));
|
|
397
|
+
const autoHighlight = getAudioAutoHighlight(config);
|
|
398
|
+
let spectrumsForRender = preprocessed.spectrums;
|
|
399
|
+
let beatIndicesForRender = preprocessed.beatFrameIndices;
|
|
400
|
+
let framesCountForRender = framesCount;
|
|
401
|
+
type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
|
|
402
|
+
infer R
|
|
403
|
+
>
|
|
404
|
+
? R
|
|
405
|
+
: never;
|
|
406
|
+
let highlightSlice: HighlightSliceResult | undefined;
|
|
407
|
+
|
|
408
|
+
if (autoHighlight) {
|
|
409
|
+
highlightSlice = await computeHighlightSlice(
|
|
410
|
+
FPS,
|
|
411
|
+
framesCount,
|
|
412
|
+
preprocessed.spectrums,
|
|
413
|
+
preprocessed.beatEvents,
|
|
414
|
+
getAudioAutoHighlightCount(config),
|
|
415
|
+
);
|
|
416
|
+
spectrumsForRender = highlightSlice.spectrums;
|
|
417
|
+
beatIndicesForRender = highlightSlice.beatFrameIndices;
|
|
418
|
+
framesCountForRender = highlightSlice.highlightFrames;
|
|
419
|
+
}
|
|
414
420
|
|
|
415
|
-
|
|
416
|
-
|
|
421
|
+
const separateHighlightFiles =
|
|
422
|
+
autoHighlight &&
|
|
423
|
+
highlightSlice &&
|
|
424
|
+
highlightSlice.runs.length > 1;
|
|
425
|
+
|
|
426
|
+
type VideoRenderPass = {
|
|
427
|
+
outPath: string;
|
|
428
|
+
spectrums: number[][];
|
|
429
|
+
beatIndices: number[];
|
|
430
|
+
frameCount: number;
|
|
431
|
+
audioSegment: import('./highlight').HighlightAudioSegment | undefined;
|
|
432
|
+
};
|
|
417
433
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
434
|
+
const passes: VideoRenderPass[] =
|
|
435
|
+
separateHighlightFiles && highlightSlice
|
|
436
|
+
? highlightSlice.runs.map((run, i) => {
|
|
437
|
+
const dir = path.dirname(outVideoPath);
|
|
438
|
+
const ext = path.extname(outVideoPath);
|
|
439
|
+
const base = path.basename(outVideoPath, ext);
|
|
440
|
+
const numberedPath = path.resolve(dir, `${base}-${i + 1}${ext}`);
|
|
441
|
+
return {
|
|
442
|
+
outPath: numberedPath,
|
|
443
|
+
spectrums: run.spectrums,
|
|
444
|
+
beatIndices: run.beatFrameIndices,
|
|
445
|
+
frameCount: run.highlightFrames,
|
|
446
|
+
audioSegment: run.audioSegment,
|
|
447
|
+
};
|
|
448
|
+
})
|
|
449
|
+
: [
|
|
450
|
+
{
|
|
451
|
+
outPath: outVideoPath,
|
|
452
|
+
spectrums: spectrumsForRender,
|
|
453
|
+
beatIndices: beatIndicesForRender,
|
|
454
|
+
frameCount: framesCountForRender,
|
|
455
|
+
audioSegment:
|
|
456
|
+
autoHighlight &&
|
|
457
|
+
highlightSlice &&
|
|
458
|
+
highlightSlice.audioSegments.length > 0
|
|
459
|
+
? highlightSlice.audioSegments[0]
|
|
460
|
+
: undefined,
|
|
461
|
+
},
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
const progressDenominator =
|
|
465
|
+
passes.reduce((s, p) => s + p.frameCount, 0) + passes.length;
|
|
466
|
+
let progressFrameBase = 0;
|
|
467
|
+
let lastExitCode = 0;
|
|
468
|
+
const outputVideoFiles: string[] = [];
|
|
469
|
+
|
|
470
|
+
passLoop: for (const pass of passes) {
|
|
471
|
+
const {
|
|
472
|
+
backgroundWidth,
|
|
473
|
+
backgroundHeight,
|
|
474
|
+
defaultColor,
|
|
475
|
+
staticBackgroundBuffer,
|
|
421
476
|
videoFrameReader,
|
|
422
477
|
videoFrameSize,
|
|
423
478
|
encodeVideoFrame,
|
|
424
|
-
|
|
479
|
+
concatFilePath,
|
|
480
|
+
} = await prepareBackgroundForRender({
|
|
481
|
+
useVideoBackground,
|
|
482
|
+
backgroundVideoPath,
|
|
483
|
+
backgroundImagePath,
|
|
484
|
+
beatFrameIndices: pass.beatIndices,
|
|
485
|
+
framesCount: pass.frameCount,
|
|
486
|
+
outputResolution,
|
|
487
|
+
fps: FPS,
|
|
488
|
+
autoEditVideo: getAutoEditVideo(config),
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const createVisualizerFrame = createVisualizerFrameGenerator(
|
|
492
|
+
config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const ffmpegVideoWriter = spawnFfmpegVideoWriter({
|
|
496
|
+
audioFilename: audioFilePath,
|
|
497
|
+
videoFileName: pass.outPath,
|
|
498
|
+
fps: FPS,
|
|
499
|
+
...(pass.audioSegment && { audioSegment: pass.audioSegment }),
|
|
500
|
+
...(!!onProgress && {
|
|
501
|
+
onStderr: getProgress((currentFrame: number) => {
|
|
502
|
+
const g = progressFrameBase + currentFrame;
|
|
503
|
+
onProgress(
|
|
504
|
+
+(Math.min(100, (g / progressDenominator) * 100)).toFixed(2),
|
|
505
|
+
);
|
|
506
|
+
}),
|
|
507
|
+
}),
|
|
508
|
+
...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
|
|
509
|
+
...(ffmpeg_preset && { preset: ffmpeg_preset }),
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const exitPromise = new Promise<number>(res => {
|
|
513
|
+
let settled = false;
|
|
514
|
+
ffmpegVideoWriter.on('exit', (code: number | null) => {
|
|
515
|
+
if (settled) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
settled = true;
|
|
519
|
+
res(code ?? 0);
|
|
520
|
+
});
|
|
425
521
|
});
|
|
426
522
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
spectrum
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
523
|
+
let stoppedEarly = false;
|
|
524
|
+
for (let i = 0; i < pass.frameCount; i++) {
|
|
525
|
+
const spectrum = pass.spectrums[i];
|
|
526
|
+
|
|
527
|
+
const backgroundImageBuffer = await resolveBackgroundFrameBuffer({
|
|
528
|
+
frameIndex: i,
|
|
529
|
+
useVideoBackground,
|
|
530
|
+
videoFrameReader,
|
|
531
|
+
videoFrameSize,
|
|
532
|
+
encodeVideoFrame,
|
|
533
|
+
staticBackgroundBuffer,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
|
|
537
|
+
backgroundImageBuffer,
|
|
538
|
+
spectrum,
|
|
539
|
+
};
|
|
540
|
+
const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
|
|
541
|
+
const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
|
|
542
|
+
if (!isFrameProcessed) {
|
|
543
|
+
await waitDrain(ffmpegVideoWriter.stdin);
|
|
544
|
+
}
|
|
545
|
+
if (shouldStop && shouldStop()) {
|
|
546
|
+
stoppedEarly = true;
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
if (frame_processing_delay) {
|
|
550
|
+
await sleep(frame_processing_delay);
|
|
551
|
+
}
|
|
552
|
+
await waitForEventLoop();
|
|
435
553
|
}
|
|
436
|
-
|
|
437
|
-
|
|
554
|
+
|
|
555
|
+
if (videoFrameReader) {
|
|
556
|
+
videoFrameReader.kill();
|
|
438
557
|
}
|
|
439
|
-
if (
|
|
440
|
-
|
|
558
|
+
if (concatFilePath) {
|
|
559
|
+
cleanupConcatFile(concatFilePath);
|
|
441
560
|
}
|
|
442
|
-
|
|
561
|
+
ffmpegVideoWriter.stdin.end();
|
|
443
562
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
563
|
+
lastExitCode = await exitPromise;
|
|
564
|
+
progressFrameBase += pass.frameCount + 1;
|
|
565
|
+
|
|
566
|
+
if (lastExitCode === 0 && !stoppedEarly) {
|
|
567
|
+
outputVideoFiles.push(pass.outPath);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (stoppedEarly || lastExitCode !== 0) {
|
|
571
|
+
break passLoop;
|
|
572
|
+
}
|
|
449
573
|
}
|
|
450
|
-
|
|
574
|
+
|
|
575
|
+
resolve({ exitCode: lastExitCode, outputVideoFiles });
|
|
451
576
|
});
|
package/src/tests/config.test.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { Config } from '../index';
|
|
|
4
4
|
import {
|
|
5
5
|
defaults,
|
|
6
6
|
getAudioFilePath,
|
|
7
|
+
getAudioAutoHighlight,
|
|
8
|
+
getAudioAutoHighlightCount,
|
|
7
9
|
getBackgroundImagePath,
|
|
8
10
|
getOutVideoPath,
|
|
9
11
|
getFPS,
|
|
@@ -30,6 +32,36 @@ describe('config', function() {
|
|
|
30
32
|
expect(result).equal(expected);
|
|
31
33
|
});
|
|
32
34
|
|
|
35
|
+
it('getAudioAutoHighlight', function() {
|
|
36
|
+
expect(
|
|
37
|
+
getAudioAutoHighlight({ audio: { path: 'a.wav' } } as Config)
|
|
38
|
+
).equal(false);
|
|
39
|
+
expect(
|
|
40
|
+
getAudioAutoHighlight({ audio: { path: 'a.wav', autoHighlight: false } } as Config)
|
|
41
|
+
).equal(false);
|
|
42
|
+
expect(
|
|
43
|
+
getAudioAutoHighlight({ audio: { path: 'a.wav', autoHighlight: true } } as Config)
|
|
44
|
+
).equal(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('getAudioAutoHighlightCount', function() {
|
|
48
|
+
expect(
|
|
49
|
+
getAudioAutoHighlightCount({ audio: { path: 'a.wav' } } as Config)
|
|
50
|
+
).equal(1);
|
|
51
|
+
expect(
|
|
52
|
+
getAudioAutoHighlightCount({ audio: { path: 'a.wav', autoHighlightCount: 3 } } as Config)
|
|
53
|
+
).equal(3);
|
|
54
|
+
expect(
|
|
55
|
+
getAudioAutoHighlightCount.bind(undefined, { audio: { path: 'a.wav', autoHighlightCount: 0 } } as Config)
|
|
56
|
+
).to.throw('Invalid audio.autoHighlightCount');
|
|
57
|
+
expect(
|
|
58
|
+
getAudioAutoHighlightCount.bind(undefined, { audio: { path: 'a.wav', autoHighlightCount: 1.5 } } as Config)
|
|
59
|
+
).to.throw('Invalid audio.autoHighlightCount');
|
|
60
|
+
expect(
|
|
61
|
+
getAudioAutoHighlightCount.bind(undefined, { audio: { path: 'a.wav', autoHighlightCount: NaN } } as Config)
|
|
62
|
+
).to.throw('Invalid audio.autoHighlightCount');
|
|
63
|
+
});
|
|
64
|
+
|
|
33
65
|
it('getBackgroundImagePath', function() {
|
|
34
66
|
const result = getBackgroundImagePath({
|
|
35
67
|
image: { path: 'test/path' }
|