nodejs-audio-visualizer 5.3.0 → 5.4.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/README.md +1 -0
- package/dist/beats.d.ts +9 -4
- package/dist/beats.js +158 -65
- package/dist/beats.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/highlight.d.ts +27 -2
- package/dist/highlight.js +201 -66
- package/dist/highlight.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +139 -70
- package/dist/index.js.map +1 -1
- package/dist/video.d.ts +19 -0
- package/dist/video.js +62 -3
- package/dist/video.js.map +1 -1
- package/package.json +2 -3
- package/src/beats.ts +187 -70
- package/src/config.ts +11 -0
- package/src/highlight.ts +203 -37
- package/src/index.ts +83 -14
- package/src/tests/config.test.ts +19 -0
- package/src/tests/highlight.test.ts +136 -0
- package/src/tests/video.test.ts +80 -1
- package/src/typings.d.ts +0 -22
- package/src/video.ts +81 -0
package/src/highlight.ts
CHANGED
|
@@ -20,6 +20,12 @@ const BEATS_PER_BAR = 4;
|
|
|
20
20
|
const OFF_BAR_PENALTY = 3;
|
|
21
21
|
/** Grid beats past the analyzed tempo region snap to a detected onset within this share of a beat. */
|
|
22
22
|
const GRID_ONSET_SNAP_SHARE = 0.2;
|
|
23
|
+
/** Seconds of audio compared before and after a frame to score it as a drop. */
|
|
24
|
+
export const DROP_WINDOW_SEC = 4;
|
|
25
|
+
/** Share of the track's biggest energy rise a peak needs to count as a drop. */
|
|
26
|
+
const MIN_DROP_SCORE_SHARE = 0.5;
|
|
27
|
+
/** With a tempo grid, a drop moves onto a beat within this share of a beat, so the hook ends on the beat. */
|
|
28
|
+
const DROP_BEAT_SNAP_SHARE = 0.2;
|
|
23
29
|
|
|
24
30
|
/** Tempo grid used to snap highlight cuts to beats (subset of `TempoEstimate`). */
|
|
25
31
|
export interface HighlightBeatGrid {
|
|
@@ -32,6 +38,8 @@ export interface HighlightBeatGrid {
|
|
|
32
38
|
export interface HighlightAudioSegment {
|
|
33
39
|
seekSeconds: number;
|
|
34
40
|
durationSeconds: number;
|
|
41
|
+
/** Silence played before the audio, for a lead-in reaching back past the track start. */
|
|
42
|
+
delaySeconds?: number;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/** One auto-highlight segment before concatenation (for separate output files). */
|
|
@@ -42,16 +50,27 @@ export interface HighlightRun {
|
|
|
42
50
|
beatFrameIndices: number[];
|
|
43
51
|
beatIntensities: number[];
|
|
44
52
|
audioSegment: HighlightAudioSegment;
|
|
53
|
+
/** Frames from the run start to the detected drop (or window start); set when a lead-in was requested. */
|
|
54
|
+
leadInFrames?: number;
|
|
45
55
|
}
|
|
46
56
|
|
|
57
|
+
/**
|
|
58
|
+
* `seg.startFrame` may be negative (a lead-in reaching back past the track start): those
|
|
59
|
+
* frames get silent spectrums and are played as silence before the audio.
|
|
60
|
+
*/
|
|
47
61
|
function buildHighlightRun(
|
|
48
62
|
fps: number,
|
|
49
|
-
seg: { startFrame: number; highlightFrames: number },
|
|
63
|
+
seg: { startFrame: number; highlightFrames: number; leadInFrames?: number },
|
|
50
64
|
spectrums: number[][],
|
|
51
65
|
beatEvents: BeatFrameEvent[],
|
|
52
66
|
): HighlightRun {
|
|
53
|
-
const { startFrame, highlightFrames } = seg;
|
|
54
|
-
const
|
|
67
|
+
const { startFrame, highlightFrames, leadInFrames } = seg;
|
|
68
|
+
const silentFrames = Math.max(0, -startFrame);
|
|
69
|
+
const silentSpectrum = new Array<number>(spectrums[0]?.length ?? 0).fill(0);
|
|
70
|
+
const spectrumsSlice = [
|
|
71
|
+
...Array.from({ length: silentFrames }, () => silentSpectrum.slice()),
|
|
72
|
+
...spectrums.slice(startFrame + silentFrames, startFrame + highlightFrames),
|
|
73
|
+
];
|
|
55
74
|
const beatsInWindow = beatEvents
|
|
56
75
|
.filter(b => b.frameIndex >= startFrame && b.frameIndex < startFrame + highlightFrames)
|
|
57
76
|
.sort((a, b) => a.frameIndex - b.frameIndex);
|
|
@@ -64,9 +83,11 @@ function buildHighlightRun(
|
|
|
64
83
|
beatFrameIndices,
|
|
65
84
|
beatIntensities,
|
|
66
85
|
audioSegment: {
|
|
67
|
-
seekSeconds: startFrame / fps,
|
|
68
|
-
durationSeconds: highlightFrames / fps,
|
|
86
|
+
seekSeconds: (startFrame + silentFrames) / fps,
|
|
87
|
+
durationSeconds: (highlightFrames - silentFrames) / fps,
|
|
88
|
+
...(silentFrames > 0 && { delaySeconds: silentFrames / fps }),
|
|
69
89
|
},
|
|
90
|
+
...(leadInFrames !== undefined && { leadInFrames }),
|
|
70
91
|
};
|
|
71
92
|
}
|
|
72
93
|
|
|
@@ -184,6 +205,107 @@ async function findBestHighlightStart(
|
|
|
184
205
|
return bestStart;
|
|
185
206
|
}
|
|
186
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Scores each frame by the rise in mean energy from the `dropWindowFrames` before it to the
|
|
210
|
+
* `dropWindowFrames` after it, and picks the best drop, i.e. a frame whose rise is the biggest
|
|
211
|
+
* within `dropWindowFrames` on either side, such that the lead-in before it plus `mainFrames`
|
|
212
|
+
* after it fit in the track and miss the excluded ranges. Frames rather than beat candidates are
|
|
213
|
+
* scored so the drop lands on the actual energy onset (a beat grid can be off by a fraction of a
|
|
214
|
+
* beat), and only peaks count so a frame just past a drop is never taken for one. Peaks rising
|
|
215
|
+
* less than `MIN_DROP_SCORE_SHARE` of the biggest one are not drops. The window
|
|
216
|
+
* starts exactly `leadInFrames` before the drop, so the whole lead-in plays. Drops too close to
|
|
217
|
+
* the track start for the full lead-in are only used (with the window starting before the track,
|
|
218
|
+
* i.e. at a negative frame) when no other drop fits.
|
|
219
|
+
*/
|
|
220
|
+
function findBestDropSegment(
|
|
221
|
+
energies: Float64Array,
|
|
222
|
+
totalFrames: number,
|
|
223
|
+
mainFrames: number,
|
|
224
|
+
leadInFrames: number,
|
|
225
|
+
dropWindowFrames: number,
|
|
226
|
+
excludeRanges: Array<{ start: number; endExclusive: number }>,
|
|
227
|
+
): { startFrame: number; dropFrame: number } | null {
|
|
228
|
+
const prefix = new Float64Array(totalFrames + 1);
|
|
229
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
230
|
+
prefix[i + 1] = prefix[i] + energies[i];
|
|
231
|
+
}
|
|
232
|
+
const meanEnergy = (from: number, to: number) => {
|
|
233
|
+
const a = Math.max(0, from);
|
|
234
|
+
const b = Math.min(totalFrames, to);
|
|
235
|
+
return b > a ? (prefix[b] - prefix[a]) / (b - a) : 0;
|
|
236
|
+
};
|
|
237
|
+
const scores = new Float64Array(totalFrames);
|
|
238
|
+
for (let f = 0; f < totalFrames; f++) {
|
|
239
|
+
scores[f] = meanEnergy(f, f + dropWindowFrames) - meanEnergy(f - dropWindowFrames, f);
|
|
240
|
+
}
|
|
241
|
+
const dropFrames: number[] = [];
|
|
242
|
+
for (let f = 1; f < totalFrames; f++) {
|
|
243
|
+
if (!(scores[f] > 0)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
let isPeak = true;
|
|
247
|
+
const from = Math.max(0, f - dropWindowFrames);
|
|
248
|
+
const to = Math.min(totalFrames - 1, f + dropWindowFrames);
|
|
249
|
+
for (let g = from; g <= to && isPeak; g++) {
|
|
250
|
+
// Ties go to the earliest frame, where the rise starts.
|
|
251
|
+
isPeak = scores[g] < scores[f] || (scores[g] === scores[f] && g >= f);
|
|
252
|
+
}
|
|
253
|
+
if (isPeak) {
|
|
254
|
+
dropFrames.push(f);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const maxScore = dropFrames.reduce((max, f) => Math.max(max, scores[f]), 0);
|
|
258
|
+
const strongDropFrames = dropFrames.filter(f => scores[f] >= maxScore * MIN_DROP_SCORE_SHARE);
|
|
259
|
+
|
|
260
|
+
const findBest = (allowBeforeTrackStart: boolean) => {
|
|
261
|
+
let best: { startFrame: number; dropFrame: number } | null = null;
|
|
262
|
+
let bestScore = -Infinity;
|
|
263
|
+
for (const dropFrame of strongDropFrames) {
|
|
264
|
+
const startFrame = dropFrame - leadInFrames;
|
|
265
|
+
if (startFrame < 0 && !allowBeforeTrackStart) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (
|
|
269
|
+
dropFrame + mainFrames > totalFrames ||
|
|
270
|
+
rangesOverlap(startFrame, dropFrame + mainFrames, excludeRanges)
|
|
271
|
+
) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (scores[dropFrame] > bestScore) {
|
|
275
|
+
bestScore = scores[dropFrame];
|
|
276
|
+
best = { startFrame, dropFrame };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return best;
|
|
280
|
+
};
|
|
281
|
+
return findBest(false) ?? findBest(true);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Moves a drop (and its window start with it) onto the nearest beat within `DROP_BEAT_SNAP_SHARE`
|
|
286
|
+
* of a beat. Only done with a tempo grid: raw onsets alone are too unreliable to override the
|
|
287
|
+
* energy onset. A lead-in that fit in the track is never pushed back past its start.
|
|
288
|
+
*/
|
|
289
|
+
export function snapDropToBeat(
|
|
290
|
+
drop: { startFrame: number; dropFrame: number },
|
|
291
|
+
beatFrames: number[],
|
|
292
|
+
periodFrames?: number,
|
|
293
|
+
): void {
|
|
294
|
+
if (!periodFrames || !(periodFrames > 0) || !isFinite(periodFrames)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const beat = nearestOnset(beatFrames, drop.dropFrame);
|
|
298
|
+
if (beat === null || Math.abs(beat - drop.dropFrame) > periodFrames * DROP_BEAT_SNAP_SHARE) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const startFrame = drop.startFrame + (beat - drop.dropFrame);
|
|
302
|
+
if (startFrame < 0 && drop.startFrame >= 0) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
drop.startFrame = startFrame;
|
|
306
|
+
drop.dropFrame = beat;
|
|
307
|
+
}
|
|
308
|
+
|
|
187
309
|
function nearestOnset(sortedOnsets: number[], frame: number): number | null {
|
|
188
310
|
let lo = 0;
|
|
189
311
|
let hi = sortedOnsets.length;
|
|
@@ -269,12 +391,14 @@ export function snapSegmentToBeats(
|
|
|
269
391
|
beatFrames: number[],
|
|
270
392
|
maxShiftFrames: number,
|
|
271
393
|
periodFrames?: number,
|
|
394
|
+
/** Keep the start where it is and only move the end. */
|
|
395
|
+
lockStart = false,
|
|
272
396
|
): { startFrame: number; highlightFrames: number } {
|
|
273
397
|
const rawStart = seg.startFrame;
|
|
274
398
|
const rawEnd = seg.startFrame + seg.highlightFrames;
|
|
275
399
|
const near = (target: number, max: number) =>
|
|
276
400
|
beatFrames.filter(f => Math.abs(f - target) <= maxShiftFrames && f >= 0 && f <= max);
|
|
277
|
-
const startCands = near(rawStart, totalFrames - 1);
|
|
401
|
+
const startCands = lockStart ? [rawStart] : near(rawStart, totalFrames - 1);
|
|
278
402
|
const endCands = near(rawEnd, totalFrames);
|
|
279
403
|
if (startCands.length === 0) {
|
|
280
404
|
startCands.push(rawStart);
|
|
@@ -314,6 +438,14 @@ export function snapSegmentToBeats(
|
|
|
314
438
|
* Picks up to `segmentCount` non-overlapping ~15s windows with highest summed spectral energy each,
|
|
315
439
|
* moves their boundaries onto beats (see `snapSegmentToBeats`), sorts them chronologically,
|
|
316
440
|
* concatenates spectrums, and remaps beat indices.
|
|
441
|
+
*
|
|
442
|
+
* With `leadInFrames` (e.g. a hook video's length), each window is instead built around a detected
|
|
443
|
+
* drop: it starts `leadInFrames` before the drop, so the whole lead-in plays and ends on the drop
|
|
444
|
+
* (see `HighlightRun.leadInFrames`), and runs ~15s past the drop, so the lead-in is added on top of
|
|
445
|
+
* the ~15s rather than taken out of it. With a tempo grid, the drop is moved onto a nearby beat
|
|
446
|
+
* (see `snapDropToBeat`), so the hook ends on the beat. When no drop fits, the lead-in is put before the energy
|
|
447
|
+
* window instead. The lead-in is never shortened: where it reaches back past the track start,
|
|
448
|
+
* the run starts at a negative frame and that part is silent (see `buildHighlightRun`).
|
|
317
449
|
*/
|
|
318
450
|
export async function computeHighlightSlice(
|
|
319
451
|
fps: number,
|
|
@@ -322,6 +454,7 @@ export async function computeHighlightSlice(
|
|
|
322
454
|
beatEvents: BeatFrameEvent[],
|
|
323
455
|
segmentCount = 1,
|
|
324
456
|
beatGrid?: HighlightBeatGrid | null,
|
|
457
|
+
leadInFrames?: number,
|
|
325
458
|
): Promise<{
|
|
326
459
|
startFrame: number;
|
|
327
460
|
highlightFrames: number;
|
|
@@ -350,23 +483,21 @@ export async function computeHighlightSlice(
|
|
|
350
483
|
}
|
|
351
484
|
|
|
352
485
|
if (totalFrames <= highlightFramesTarget) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
];
|
|
359
|
-
const fullSeg = { startFrame: 0, highlightFrames: totalFrames };
|
|
486
|
+
// The whole track follows the lead-in, which is silent as it all comes before the track start.
|
|
487
|
+
const leadIn = leadInFrames !== undefined && leadInFrames > 0 ? leadInFrames : 0;
|
|
488
|
+
const fullSeg = leadIn > 0
|
|
489
|
+
? { startFrame: -leadIn, highlightFrames: leadIn + totalFrames, leadInFrames: leadIn }
|
|
490
|
+
: { startFrame: 0, highlightFrames: totalFrames };
|
|
360
491
|
const fullRun = buildHighlightRun(fps, fullSeg, spectrums, beatEvents);
|
|
361
492
|
return {
|
|
362
|
-
startFrame:
|
|
363
|
-
highlightFrames:
|
|
364
|
-
spectrums: spectrums
|
|
493
|
+
startFrame: fullRun.startFrame,
|
|
494
|
+
highlightFrames: fullRun.highlightFrames,
|
|
495
|
+
spectrums: fullRun.spectrums,
|
|
365
496
|
beatFrameIndices: fullRun.beatFrameIndices,
|
|
366
497
|
beatIntensities: fullRun.beatIntensities,
|
|
367
|
-
audioSeekSeconds:
|
|
368
|
-
audioDurationSeconds:
|
|
369
|
-
audioSegments,
|
|
498
|
+
audioSeekSeconds: fullRun.audioSegment.seekSeconds,
|
|
499
|
+
audioDurationSeconds: fullRun.audioSegment.durationSeconds,
|
|
500
|
+
audioSegments: [fullRun.audioSegment],
|
|
370
501
|
runs: [fullRun],
|
|
371
502
|
};
|
|
372
503
|
}
|
|
@@ -374,12 +505,48 @@ export async function computeHighlightSlice(
|
|
|
374
505
|
const highlightFrames = highlightFramesTarget;
|
|
375
506
|
const energies = await buildFrameEnergies(spectrums, totalFrames);
|
|
376
507
|
const excludeRanges: Array<{ start: number; endExclusive: number }> = [];
|
|
377
|
-
const rawSegments: Array<{ startFrame: number; highlightFrames: number }> = [];
|
|
508
|
+
const rawSegments: Array<{ startFrame: number; highlightFrames: number; leadInFrames?: number }> = [];
|
|
378
509
|
const beatFrames = buildBeatCandidates(fps, totalFrames, beatEvents, beatGrid);
|
|
379
510
|
const maxShiftFrames = Math.round(MAX_BEAT_SNAP_SEC * fps);
|
|
511
|
+
const useDrops = leadInFrames !== undefined && leadInFrames > 0;
|
|
512
|
+
const dropWindowFrames = Math.round(DROP_WINDOW_SEC * fps);
|
|
380
513
|
|
|
381
514
|
const n = Math.max(1, Math.floor(segmentCount));
|
|
382
515
|
for (let k = 0; k < n; k++) {
|
|
516
|
+
const drop = useDrops
|
|
517
|
+
? findBestDropSegment(
|
|
518
|
+
energies,
|
|
519
|
+
totalFrames,
|
|
520
|
+
highlightFrames,
|
|
521
|
+
leadInFrames as number,
|
|
522
|
+
dropWindowFrames,
|
|
523
|
+
excludeRanges,
|
|
524
|
+
)
|
|
525
|
+
: null;
|
|
526
|
+
if (drop) {
|
|
527
|
+
snapDropToBeat(drop, beatFrames, beatGrid?.periodFrames);
|
|
528
|
+
// Only the part from the drop on is ~15s; its end is snapped to a beat.
|
|
529
|
+
const main = snapSegmentToBeats(
|
|
530
|
+
{ startFrame: drop.dropFrame, highlightFrames },
|
|
531
|
+
totalFrames,
|
|
532
|
+
beatFrames,
|
|
533
|
+
maxShiftFrames,
|
|
534
|
+
beatGrid?.periodFrames,
|
|
535
|
+
true,
|
|
536
|
+
);
|
|
537
|
+
const dropLeadIn = drop.dropFrame - drop.startFrame;
|
|
538
|
+
rawSegments.push({
|
|
539
|
+
startFrame: drop.startFrame,
|
|
540
|
+
highlightFrames: dropLeadIn + main.highlightFrames,
|
|
541
|
+
leadInFrames: dropLeadIn,
|
|
542
|
+
});
|
|
543
|
+
excludeRanges.push({
|
|
544
|
+
start: drop.startFrame - maxShiftFrames,
|
|
545
|
+
endExclusive: main.startFrame + main.highlightFrames + maxShiftFrames,
|
|
546
|
+
});
|
|
547
|
+
await waitForEventLoop();
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
383
550
|
const startFrame = await findBestHighlightStart(
|
|
384
551
|
energies,
|
|
385
552
|
totalFrames,
|
|
@@ -396,7 +563,16 @@ export async function computeHighlightSlice(
|
|
|
396
563
|
maxShiftFrames,
|
|
397
564
|
beatGrid?.periodFrames,
|
|
398
565
|
);
|
|
399
|
-
|
|
566
|
+
// The whole lead-in plays before the window, even over an earlier window (each highlight with
|
|
567
|
+
// a lead-in is its own output) or before the track start (as silence).
|
|
568
|
+
const windowLeadIn = useDrops ? leadInFrames as number : 0;
|
|
569
|
+
rawSegments.push(windowLeadIn > 0
|
|
570
|
+
? {
|
|
571
|
+
startFrame: snapped.startFrame - windowLeadIn,
|
|
572
|
+
highlightFrames: windowLeadIn + snapped.highlightFrames,
|
|
573
|
+
leadInFrames: windowLeadIn,
|
|
574
|
+
}
|
|
575
|
+
: snapped);
|
|
400
576
|
// Padded so the next raw window, once snapped, cannot reach back into this one.
|
|
401
577
|
excludeRanges.push({
|
|
402
578
|
start: snapped.startFrame - maxShiftFrames,
|
|
@@ -415,25 +591,15 @@ export async function computeHighlightSlice(
|
|
|
415
591
|
const beatIntensities: number[] = [];
|
|
416
592
|
let outOffset = 0;
|
|
417
593
|
|
|
418
|
-
for (const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
.filter(b => b.frameIndex >= seg.startFrame && b.frameIndex < seg.startFrame + seg.highlightFrames)
|
|
424
|
-
.sort((a, b) => a.frameIndex - b.frameIndex);
|
|
425
|
-
for (const b of beatsInSeg) {
|
|
426
|
-
beatFrameIndices.push(b.frameIndex - seg.startFrame + outOffset);
|
|
427
|
-
beatIntensities.push(b.intensity);
|
|
428
|
-
}
|
|
429
|
-
outOffset += seg.highlightFrames;
|
|
594
|
+
for (const run of runs) {
|
|
595
|
+
slicedSpectrums.push(...run.spectrums);
|
|
596
|
+
beatFrameIndices.push(...run.beatFrameIndices.map(frameIndex => frameIndex + outOffset));
|
|
597
|
+
beatIntensities.push(...run.beatIntensities);
|
|
598
|
+
outOffset += run.highlightFrames;
|
|
430
599
|
}
|
|
431
600
|
|
|
432
601
|
const totalHighlightFrames = slicedSpectrums.length;
|
|
433
|
-
const audioSegments: HighlightAudioSegment[] =
|
|
434
|
-
seekSeconds: seg.startFrame / fps,
|
|
435
|
-
durationSeconds: seg.highlightFrames / fps,
|
|
436
|
-
}));
|
|
602
|
+
const audioSegments: HighlightAudioSegment[] = runs.map(run => run.audioSegment);
|
|
437
603
|
|
|
438
604
|
const first = audioSegments[0] ?? { seekSeconds: 0, durationSeconds: 0 };
|
|
439
605
|
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getAudioFilePath,
|
|
5
5
|
getBackgroundImagePath,
|
|
6
6
|
getBackgroundVideoPaths,
|
|
7
|
+
getHookVideoPath,
|
|
7
8
|
getOutVideoPath,
|
|
8
9
|
getSubtitleRenderSpec,
|
|
9
10
|
subtitleAlignmentToAss,
|
|
@@ -40,9 +41,9 @@ import {
|
|
|
40
41
|
import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor, pcmU8ToFloatSamples } from './audio';
|
|
41
42
|
import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps, applyCameraShake, getCutShakeOffset, buildCutShakeAmplitudes, CAMERA_SHAKE_DELAY_SECONDS, CAMERA_SHAKE_EVERY_BEATS } from './image';
|
|
42
43
|
import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
|
|
43
|
-
import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, getCutFrameIndices, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile, VideoSegment } from './video';
|
|
44
|
+
import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, computeHookFrameCount, getVideoFrameCount, getCutFrameIndices, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile, VideoSegment } from './video';
|
|
44
45
|
import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
|
|
45
|
-
import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
|
|
46
|
+
import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, shiftTempoPhase, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
|
|
46
47
|
export { BeatInfo, BeatDetectorOptions, TempoEstimate, estimateTempo, beatGridFrameIndices, shiftTempoPhase, tempoForWindow } from './beats';
|
|
47
48
|
import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
|
|
48
49
|
import { waitForEventLoop } from './waitForEventLoop';
|
|
@@ -74,6 +75,14 @@ export interface Config {
|
|
|
74
75
|
* videos instead of auto-detecting scene changes within a single video.
|
|
75
76
|
*/
|
|
76
77
|
path: string | string[];
|
|
78
|
+
/**
|
|
79
|
+
* Video played once, always in full, before the `path` video(s). When `audio.autoHighlight`
|
|
80
|
+
* is true, each highlight is chosen so the hook ends on a detected drop and `path` starts with it,
|
|
81
|
+
* playing ~15s from the drop (the hook comes on top of the ~15s rather than being counted in it).
|
|
82
|
+
* If the drop is closer to the track start than the hook's length, the track is preceded by silence.
|
|
83
|
+
* The hook's sound, if any, is mixed over the track, which fades in from silence to full volume while the hook plays.
|
|
84
|
+
*/
|
|
85
|
+
hookPath?: string;
|
|
77
86
|
autoEdit?: boolean;
|
|
78
87
|
/** When `autoEdit` is true, disables the brief camera shake applied on cuts, or every 2 beats when there are no cuts (default true). */
|
|
79
88
|
cameraShake?: boolean;
|
|
@@ -284,6 +293,10 @@ async function prepareBackgroundForRender(params: {
|
|
|
284
293
|
tempo?: TempoEstimate | null;
|
|
285
294
|
/** First video to play for `video.path` arrays without `autoEdit`. */
|
|
286
295
|
startVideoIndex?: number;
|
|
296
|
+
/** Video played once before the background videos. */
|
|
297
|
+
hookVideoPath?: string;
|
|
298
|
+
/** Hook length in frames (the lead-in up to a detected drop); the full hook when unset. */
|
|
299
|
+
hookFrameCount?: number;
|
|
287
300
|
}): Promise<{
|
|
288
301
|
backgroundWidth: number;
|
|
289
302
|
backgroundHeight: number;
|
|
@@ -294,6 +307,8 @@ async function prepareBackgroundForRender(params: {
|
|
|
294
307
|
encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
|
|
295
308
|
concatFilePath?: string;
|
|
296
309
|
cutFrameIndices: number[];
|
|
310
|
+
/** Hook video whose sound plays over the first `frameCount` output frames. */
|
|
311
|
+
hookAudio?: { path: string; frameCount: number };
|
|
297
312
|
}> {
|
|
298
313
|
const {
|
|
299
314
|
useVideoBackground,
|
|
@@ -308,6 +323,8 @@ async function prepareBackgroundForRender(params: {
|
|
|
308
323
|
readVideoFrameTimeout,
|
|
309
324
|
tempo,
|
|
310
325
|
startVideoIndex = 0,
|
|
326
|
+
hookVideoPath,
|
|
327
|
+
hookFrameCount,
|
|
311
328
|
} = params;
|
|
312
329
|
|
|
313
330
|
if (useVideoBackground && backgroundVideoPaths && backgroundVideoPaths.length > 0) {
|
|
@@ -319,21 +336,43 @@ async function prepareBackgroundForRender(params: {
|
|
|
319
336
|
const videoFrameSize = backgroundWidth * backgroundHeight * 3;
|
|
320
337
|
const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
|
|
321
338
|
|
|
339
|
+
const hookVideoInfo = hookVideoPath ? await getVideoInfo(hookVideoPath) : undefined;
|
|
340
|
+
// Real frame count, so the hook never has to wrap back to its start.
|
|
341
|
+
const hookAvailableFrames = hookVideoPath ? await getVideoFrameCount(hookVideoPath, fps) : 0;
|
|
342
|
+
const hookFrames = !hookVideoInfo
|
|
343
|
+
? 0
|
|
344
|
+
: hookFrameCount !== undefined
|
|
345
|
+
? Math.min(hookFrameCount, hookAvailableFrames, framesCount)
|
|
346
|
+
: computeHookFrameCount(hookAvailableFrames / fps, fps, framesCount);
|
|
347
|
+
// The background videos fill the frames after the hook, on a timeline starting at the hook's end.
|
|
348
|
+
const mainFramesCount = framesCount - hookFrames;
|
|
349
|
+
const mainBeatFrameIndices: number[] = [];
|
|
350
|
+
const mainBeatIntensities: number[] | undefined = beatIntensities ? [] : undefined;
|
|
351
|
+
beatFrameIndices.forEach((frameIndex, i) => {
|
|
352
|
+
if (frameIndex >= hookFrames) {
|
|
353
|
+
mainBeatFrameIndices.push(frameIndex - hookFrames);
|
|
354
|
+
mainBeatIntensities?.push(beatIntensities![i]);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
const mainTempo = tempo ? shiftTempoPhase(tempo, hookFrames) : tempo;
|
|
358
|
+
|
|
322
359
|
let segments: VideoSegment[];
|
|
323
|
-
if (
|
|
360
|
+
if (mainFramesCount <= 0) {
|
|
361
|
+
segments = [];
|
|
362
|
+
} else if (backgroundVideoPaths.length > 1) {
|
|
324
363
|
segments = autoEditVideo
|
|
325
364
|
? buildShuffledVideoSegments(
|
|
326
|
-
|
|
327
|
-
|
|
365
|
+
mainBeatFrameIndices,
|
|
366
|
+
mainFramesCount,
|
|
328
367
|
backgroundVideoPaths.length,
|
|
329
368
|
fps,
|
|
330
369
|
{
|
|
331
|
-
beatIntensities,
|
|
332
|
-
...(
|
|
370
|
+
beatIntensities: mainBeatIntensities,
|
|
371
|
+
...(mainTempo ? { tempo: mainTempo } : {}),
|
|
333
372
|
},
|
|
334
373
|
)
|
|
335
374
|
: buildSequentialVideoSegments(
|
|
336
|
-
|
|
375
|
+
mainFramesCount,
|
|
337
376
|
videoInfos.map(info => info.duration),
|
|
338
377
|
fps,
|
|
339
378
|
startVideoIndex % backgroundVideoPaths.length,
|
|
@@ -343,19 +382,29 @@ async function prepareBackgroundForRender(params: {
|
|
|
343
382
|
? await detectSceneChanges(backgroundVideoPaths[0])
|
|
344
383
|
: [];
|
|
345
384
|
segments = buildBeatSyncedSegments(
|
|
346
|
-
|
|
347
|
-
|
|
385
|
+
mainBeatFrameIndices,
|
|
386
|
+
mainFramesCount,
|
|
348
387
|
sceneChanges,
|
|
349
388
|
primaryVideoInfo.duration,
|
|
350
389
|
fps,
|
|
351
390
|
{
|
|
352
|
-
beatIntensities,
|
|
353
|
-
...(autoEditVideo &&
|
|
391
|
+
beatIntensities: mainBeatIntensities,
|
|
392
|
+
...(autoEditVideo && mainTempo ? { tempo: mainTempo } : {}),
|
|
354
393
|
},
|
|
355
394
|
);
|
|
356
395
|
}
|
|
357
396
|
|
|
358
397
|
const videoSources = videoInfos.map((info, i) => ({ path: backgroundVideoPaths[i], duration: info.duration }));
|
|
398
|
+
if (hookVideoPath && hookVideoInfo && hookFrames > 0) {
|
|
399
|
+
segments = [
|
|
400
|
+
{ outputStartFrame: 0, videoSeekSeconds: 0, frameCount: hookFrames, videoIndex: videoSources.length },
|
|
401
|
+
...segments.map(segment => ({
|
|
402
|
+
...segment,
|
|
403
|
+
outputStartFrame: segment.outputStartFrame + hookFrames,
|
|
404
|
+
})),
|
|
405
|
+
];
|
|
406
|
+
videoSources.push({ path: hookVideoPath, duration: hookAvailableFrames / fps });
|
|
407
|
+
}
|
|
359
408
|
const concatFilePath = writeConcatFile(segments, videoSources, fps);
|
|
360
409
|
const cutFrameIndices = autoEditVideo ? getCutFrameIndices(segments) : [];
|
|
361
410
|
|
|
@@ -377,7 +426,7 @@ async function prepareBackgroundForRender(params: {
|
|
|
377
426
|
readVideoFrameTimeout,
|
|
378
427
|
);
|
|
379
428
|
if (!firstFrame) {
|
|
380
|
-
throw new Error(`Could not read frames from video: ${
|
|
429
|
+
throw new Error(`Could not read frames from video: ${videoSources.map(source => source.path).join(', ')}`);
|
|
381
430
|
}
|
|
382
431
|
const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
|
|
383
432
|
const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
|
|
@@ -391,6 +440,9 @@ async function prepareBackgroundForRender(params: {
|
|
|
391
440
|
encodeVideoFrame,
|
|
392
441
|
concatFilePath,
|
|
393
442
|
cutFrameIndices,
|
|
443
|
+
...(hookVideoPath && hookVideoInfo?.hasAudio && hookFrames > 0 && {
|
|
444
|
+
hookAudio: { path: hookVideoPath, frameCount: hookFrames },
|
|
445
|
+
}),
|
|
394
446
|
};
|
|
395
447
|
}
|
|
396
448
|
|
|
@@ -501,6 +553,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
501
553
|
}
|
|
502
554
|
}
|
|
503
555
|
const backgroundVideoPaths = getBackgroundVideoPaths(config);
|
|
556
|
+
const hookVideoPath = getHookVideoPath(config);
|
|
504
557
|
const backgroundImagePath = getBackgroundImagePath(config);
|
|
505
558
|
const useVideoBackground = !!backgroundVideoPaths && backgroundVideoPaths.length > 0;
|
|
506
559
|
|
|
@@ -572,6 +625,10 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
572
625
|
|
|
573
626
|
if (autoHighlight) {
|
|
574
627
|
reportProgress(PRE_PROCESS_PROGRESS_SHARE + 1);
|
|
628
|
+
// With a hook, highlights are built so the hook ends on a detected drop.
|
|
629
|
+
const hookLeadInFrames = hookVideoPath
|
|
630
|
+
? await getVideoFrameCount(hookVideoPath, FPS)
|
|
631
|
+
: undefined;
|
|
575
632
|
highlightSlice = await computeHighlightSlice(
|
|
576
633
|
FPS,
|
|
577
634
|
framesCount,
|
|
@@ -579,6 +636,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
579
636
|
preprocessed.beatEvents,
|
|
580
637
|
getAudioAutoHighlightCount(config),
|
|
581
638
|
trackTempo,
|
|
639
|
+
hookLeadInFrames,
|
|
582
640
|
);
|
|
583
641
|
spectrumsForRender = highlightSlice.spectrums;
|
|
584
642
|
beatIndicesForRender = highlightSlice.beatFrameIndices;
|
|
@@ -605,6 +663,8 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
605
663
|
frameCount: number;
|
|
606
664
|
startFrame: number;
|
|
607
665
|
audioSegment: import('./highlight').HighlightAudioSegment | undefined;
|
|
666
|
+
/** Frames until the detected drop, where the hook should end. */
|
|
667
|
+
leadInFrames?: number;
|
|
608
668
|
};
|
|
609
669
|
|
|
610
670
|
const passes: VideoRenderPass[] =
|
|
@@ -622,6 +682,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
622
682
|
frameCount: run.highlightFrames,
|
|
623
683
|
startFrame: run.startFrame,
|
|
624
684
|
audioSegment: run.audioSegment,
|
|
685
|
+
leadInFrames: run.leadInFrames,
|
|
625
686
|
};
|
|
626
687
|
})
|
|
627
688
|
: [
|
|
@@ -638,6 +699,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
638
699
|
highlightSlice.audioSegments.length > 0
|
|
639
700
|
? highlightSlice.audioSegments[0]
|
|
640
701
|
: undefined,
|
|
702
|
+
leadInFrames: highlightSlice?.runs.length === 1 ? highlightSlice.runs[0].leadInFrames : undefined,
|
|
641
703
|
},
|
|
642
704
|
];
|
|
643
705
|
|
|
@@ -659,9 +721,10 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
659
721
|
try {
|
|
660
722
|
passLoop: for (let passIndex = 0; passIndex < passes.length; passIndex++) {
|
|
661
723
|
const pass = passes[passIndex];
|
|
662
|
-
const
|
|
724
|
+
const windowTempo = trackTempo
|
|
663
725
|
? tempoForWindow(trackTempo, pass.startFrame, FPS, pass.frameCount, pass.beatIndices)
|
|
664
726
|
: null;
|
|
727
|
+
const tempo = autoEditVideo ? windowTempo : null;
|
|
665
728
|
const shakeAmplitudes = tempo
|
|
666
729
|
? buildCutShakeAmplitudes(tempo.periodFrames)
|
|
667
730
|
: undefined;
|
|
@@ -675,6 +738,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
675
738
|
encodeVideoFrame,
|
|
676
739
|
concatFilePath,
|
|
677
740
|
cutFrameIndices,
|
|
741
|
+
hookAudio,
|
|
678
742
|
} = await prepareBackgroundForRender({
|
|
679
743
|
useVideoBackground,
|
|
680
744
|
backgroundVideoPaths,
|
|
@@ -689,6 +753,8 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
689
753
|
...(tempo ? { tempo } : {}),
|
|
690
754
|
// Each separate highlight output opens with a different video from the array.
|
|
691
755
|
startVideoIndex: passIndex,
|
|
756
|
+
hookVideoPath,
|
|
757
|
+
...(pass.leadInFrames !== undefined ? { hookFrameCount: pass.leadInFrames } : {}),
|
|
692
758
|
});
|
|
693
759
|
reportRenderProgress();
|
|
694
760
|
|
|
@@ -713,6 +779,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
713
779
|
}),
|
|
714
780
|
fps: FPS,
|
|
715
781
|
...(pass.audioSegment && { audioSegment: pass.audioSegment }),
|
|
782
|
+
...(hookAudio && {
|
|
783
|
+
overlayAudio: { filename: hookAudio.path, durationSeconds: hookAudio.frameCount / FPS },
|
|
784
|
+
}),
|
|
716
785
|
...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
|
|
717
786
|
...(ffmpeg_preset && { preset: ffmpeg_preset }),
|
|
718
787
|
});
|
package/src/tests/config.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getAudioAutoHighlightCount,
|
|
9
9
|
getBackgroundImagePath,
|
|
10
10
|
getBackgroundVideoPaths,
|
|
11
|
+
getHookVideoPath,
|
|
11
12
|
getCameraShakeEnabled,
|
|
12
13
|
getOutVideoPath,
|
|
13
14
|
getSubtitleRenderSpec,
|
|
@@ -93,6 +94,24 @@ describe('config', function() {
|
|
|
93
94
|
).to.throw('Invalid video.path');
|
|
94
95
|
});
|
|
95
96
|
|
|
97
|
+
it('getHookVideoPath', function() {
|
|
98
|
+
expect(
|
|
99
|
+
getHookVideoPath({ audio: { path: 'a.wav' } } as Config)
|
|
100
|
+
).equal(undefined);
|
|
101
|
+
|
|
102
|
+
expect(
|
|
103
|
+
getHookVideoPath({ video: { path: 'a.mp4' } } as Config)
|
|
104
|
+
).equal(undefined);
|
|
105
|
+
|
|
106
|
+
expect(
|
|
107
|
+
getHookVideoPath({ video: { path: 'a.mp4', hookPath: 'hook.mp4' } } as Config)
|
|
108
|
+
).equal(path.resolve('hook.mp4'));
|
|
109
|
+
|
|
110
|
+
expect(
|
|
111
|
+
getHookVideoPath.bind(undefined, { video: { path: 'a.mp4', hookPath: '' } } as Config)
|
|
112
|
+
).to.throw('Invalid video.hookPath');
|
|
113
|
+
});
|
|
114
|
+
|
|
96
115
|
it('getCameraShakeEnabled', function() {
|
|
97
116
|
expect(
|
|
98
117
|
getCameraShakeEnabled({ audio: { path: 'a.wav' } } as Config)
|