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/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 spectrumsSlice = spectrums.slice(startFrame, startFrame + highlightFrames);
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
- const audioSegments: HighlightAudioSegment[] = [
354
- {
355
- seekSeconds: 0,
356
- durationSeconds: totalFrames / fps,
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: 0,
363
- highlightFrames: totalFrames,
364
- spectrums: spectrums.slice(),
493
+ startFrame: fullRun.startFrame,
494
+ highlightFrames: fullRun.highlightFrames,
495
+ spectrums: fullRun.spectrums,
365
496
  beatFrameIndices: fullRun.beatFrameIndices,
366
497
  beatIntensities: fullRun.beatIntensities,
367
- audioSeekSeconds: 0,
368
- audioDurationSeconds: totalFrames / fps,
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
- rawSegments.push(snapped);
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 seg of rawSegments) {
419
- const chunk = spectrums.slice(seg.startFrame, seg.startFrame + seg.highlightFrames);
420
- slicedSpectrums.push(...chunk);
421
-
422
- const beatsInSeg = beatEvents
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[] = rawSegments.map(seg => ({
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 (backgroundVideoPaths.length > 1) {
360
+ if (mainFramesCount <= 0) {
361
+ segments = [];
362
+ } else if (backgroundVideoPaths.length > 1) {
324
363
  segments = autoEditVideo
325
364
  ? buildShuffledVideoSegments(
326
- beatFrameIndices,
327
- framesCount,
365
+ mainBeatFrameIndices,
366
+ mainFramesCount,
328
367
  backgroundVideoPaths.length,
329
368
  fps,
330
369
  {
331
- beatIntensities,
332
- ...(tempo ? { tempo } : {}),
370
+ beatIntensities: mainBeatIntensities,
371
+ ...(mainTempo ? { tempo: mainTempo } : {}),
333
372
  },
334
373
  )
335
374
  : buildSequentialVideoSegments(
336
- framesCount,
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
- beatFrameIndices,
347
- framesCount,
385
+ mainBeatFrameIndices,
386
+ mainFramesCount,
348
387
  sceneChanges,
349
388
  primaryVideoInfo.duration,
350
389
  fps,
351
390
  {
352
- beatIntensities,
353
- ...(autoEditVideo && tempo ? { tempo } : {}),
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: ${backgroundVideoPaths.join(', ')}`);
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 tempo = autoEditVideo && trackTempo
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
  });
@@ -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)