nodejs-audio-visualizer 5.2.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -1,5 +1,12 @@
1
1
  import { expect } from 'chai';
2
- import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from '../highlight';
2
+ import {
3
+ computeHighlightSlice,
4
+ HIGHLIGHT_DURATION_SEC,
5
+ MAX_BEAT_SNAP_SEC,
6
+ buildBeatCandidates,
7
+ snapSegmentToBeats,
8
+ snapDropToBeat,
9
+ } from '../highlight';
3
10
 
4
11
  const dummySpectrums = (n: number) => Array.from({ length: n }, () => [0]);
5
12
 
@@ -113,9 +120,10 @@ describe('computeHighlightSlice', function() {
113
120
  ]);
114
121
 
115
122
  expect(result.startFrame).equal(10);
116
- expect(result.highlightFrames).equal(highlightFrames);
123
+ // The end moves from frame 460 onto the nearby beat at 500.
124
+ expect(result.highlightFrames).equal(490);
117
125
  expect(result.audioSegments).deep.equal([
118
- { seekSeconds: 10 / fps, durationSeconds: highlightFrames / fps },
126
+ { seekSeconds: 10 / fps, durationSeconds: 490 / fps },
119
127
  ]);
120
128
  expect(result.runs).have.length(1);
121
129
  });
@@ -175,4 +183,215 @@ describe('computeHighlightSlice', function() {
175
183
  expect(result.audioSegments).have.length(1);
176
184
  expect(result.runs).have.length(1);
177
185
  });
186
+
187
+ it('starts the whole lead-in before the drop when leadInFrames is given', async function() {
188
+ const fps = 10;
189
+ const totalFrames = 1000;
190
+ // Quiet until frame 600, loud after: the drop is at 600.
191
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 600, totalFrames);
192
+ const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
193
+
194
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 43);
195
+
196
+ expect(result.runs).have.length(1);
197
+ expect(result.runs[0].startFrame).equal(557);
198
+ expect(result.runs[0].leadInFrames).equal(43);
199
+ // The lead-in comes on top of the ~15s that follow the drop.
200
+ expect(result.runs[0].highlightFrames).equal(43 + Math.ceil(HIGHLIGHT_DURATION_SEC * fps));
201
+ });
202
+
203
+ it('pads the lead-in with silence before the track start when only an early drop fits', async function() {
204
+ const fps = 10;
205
+ // A full 130-frame lead-in needs a drop at 130+, but ~15s must follow it: only 100 fits.
206
+ const totalFrames = 250;
207
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 100, totalFrames);
208
+ const beats = Array.from({ length: 50 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
209
+
210
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 130);
211
+ const run = result.runs[0];
212
+
213
+ expect(run.startFrame).equal(-30);
214
+ expect(run.leadInFrames).equal(130);
215
+ expect(run.spectrums).have.length(run.highlightFrames);
216
+ expect(run.spectrums.slice(0, 30).every(s => s.every(v => v === 0))).equal(true);
217
+ expect(run.beatFrameIndices[0]).equal(30);
218
+ expect(run.audioSegment).deep.equal({
219
+ seekSeconds: 0,
220
+ durationSeconds: (run.highlightFrames - 30) / fps,
221
+ delaySeconds: 3,
222
+ });
223
+ });
224
+
225
+ it('picks a separate drop for each highlight', async function() {
226
+ const fps = 10;
227
+ const totalFrames = 2000;
228
+ const spectrums = Array.from({ length: totalFrames }, (_, i) =>
229
+ (i >= 500 && i < 800) || (i >= 1400 && i < 1700) ? [1] : [0],
230
+ );
231
+ const beats = Array.from({ length: 400 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
232
+
233
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 2, null, 30);
234
+
235
+ expect(result.runs.map(run => run.startFrame + (run.leadInFrames as number))).deep.equal([500, 1400]);
236
+ expect(result.runs.map(run => run.leadInFrames)).deep.equal([30, 30]);
237
+ });
238
+
239
+ it('ends the lead-in on the energy onset even when beats are off it', async function() {
240
+ const fps = 10;
241
+ const totalFrames = 1000;
242
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 602, totalFrames);
243
+ // Beats every 5 frames, none on the onset at 602.
244
+ const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
245
+
246
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 43);
247
+
248
+ expect(result.runs[0].startFrame + (result.runs[0].leadInFrames as number)).equal(602);
249
+ expect(result.runs[0].leadInFrames).equal(43);
250
+ });
251
+
252
+ it('ends the lead-in on a nearby grid beat when a tempo grid is given', async function() {
253
+ const fps = 10;
254
+ const totalFrames = 1000;
255
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 602, totalFrames);
256
+ const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
257
+ // Grid beats every 10 frames on multiples of 10: 600 is 2 frames (0.2 beat) from the onset.
258
+ const grid = { periodFrames: 10, phaseFrame: 0 };
259
+
260
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, grid, 43);
261
+
262
+ expect(result.runs[0].startFrame).equal(557);
263
+ expect(result.runs[0].leadInFrames).equal(43);
264
+ });
265
+
266
+ it('keeps the full lead-in rather than ending it past the drop or on a weak rise', async function() {
267
+ const fps = 10;
268
+ const totalFrames = 600;
269
+ // Big drop at 140, then a small bump at 300; a 200-frame lead-in doesn't fit before 140.
270
+ const spectrums = Array.from({ length: totalFrames }, (_, i) =>
271
+ i < 140 ? [0.1] : i < 300 ? [1] : [1.1],
272
+ );
273
+ const beats = Array.from({ length: 120 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
274
+
275
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 200);
276
+
277
+ expect(result.runs[0].startFrame).equal(-60);
278
+ expect(result.runs[0].leadInFrames).equal(200);
279
+ });
280
+
281
+ it('puts the lead-in before the energy window when no drop is found', async function() {
282
+ const fps = 10;
283
+ const totalFrames = 1000;
284
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
285
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 600, totalFrames);
286
+
287
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 1, null, 900);
288
+
289
+ // The window starts at 600; the rest of the lead-in is silence before the track start.
290
+ expect(result.runs[0].startFrame).equal(-300);
291
+ expect(result.runs[0].leadInFrames).equal(900);
292
+ expect(result.runs[0].highlightFrames).equal(900 + highlightFrames);
293
+ expect(result.runs[0].audioSegment.delaySeconds).equal(30);
294
+ });
295
+
296
+ it('snaps highlight start and end onto detected beats', async function() {
297
+ const fps = 30;
298
+ const totalFrames = 1000;
299
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
300
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 300, 300 + highlightFrames);
301
+
302
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
303
+ { frameIndex: 290, intensity: 1 },
304
+ { frameIndex: 600, intensity: 1 },
305
+ { frameIndex: 760, intensity: 1 },
306
+ ]);
307
+
308
+ expect(result.startFrame).equal(290);
309
+ expect(result.highlightFrames).equal(760 - 290);
310
+ expect(result.audioSegments).deep.equal([
311
+ { seekSeconds: 290 / fps, durationSeconds: (760 - 290) / fps },
312
+ ]);
313
+ expect(result.beatFrameIndices).deep.equal([0, 310]);
314
+ });
315
+
316
+ it('snaps to a tempo grid and prefers a whole number of bars', async function() {
317
+ const fps = 30;
318
+ const totalFrames = 2000;
319
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
320
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 505, 505 + highlightFrames);
321
+ // 120 BPM at 30 fps: a beat every 15 frames, a bar every 60.
322
+ const beatGrid = { periodFrames: 15, phaseFrame: 5 };
323
+
324
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 1, beatGrid);
325
+
326
+ expect((result.startFrame - 5) % 15).equal(0);
327
+ expect(result.highlightFrames % 60).equal(0);
328
+ expect(Math.abs(result.startFrame - 505)).lte(MAX_BEAT_SNAP_SEC * fps);
329
+ expect(Math.abs(result.highlightFrames - highlightFrames)).lte(2 * MAX_BEAT_SNAP_SEC * fps);
330
+ });
331
+
332
+ it('keeps several snapped highlights from overlapping', async function() {
333
+ const fps = 30;
334
+ const totalFrames = 3000;
335
+ const spectrums = Array.from({ length: totalFrames }, (_, i) => [1 + Math.sin(i / 50)]);
336
+ const beatGrid = { periodFrames: 14, phaseFrame: 3 };
337
+
338
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 4, beatGrid);
339
+
340
+ for (let i = 1; i < result.runs.length; i++) {
341
+ const prev = result.runs[i - 1];
342
+ expect(result.runs[i].startFrame).gte(prev.startFrame + prev.highlightFrames);
343
+ }
344
+ });
345
+ });
346
+
347
+ describe('buildBeatCandidates', function() {
348
+ it('uses onsets when no tempo grid is given', function() {
349
+ expect(buildBeatCandidates(30, 100, [
350
+ { frameIndex: 40, intensity: 1 },
351
+ { frameIndex: 10, intensity: 1 },
352
+ { frameIndex: 10, intensity: 1 },
353
+ ])).deep.equal([10, 40]);
354
+ });
355
+
356
+ it('uses tracked beats, then the grid nudged onto nearby onsets', function() {
357
+ const frames = buildBeatCandidates(10, 60, [{ frameIndex: 41, intensity: 1 }], {
358
+ periodFrames: 10,
359
+ phaseFrame: 0,
360
+ beatsSec: [0.1, 1.1, 2.1],
361
+ });
362
+ expect(frames).deep.equal([1, 11, 21, 30, 41, 50, 60]);
363
+ });
364
+ });
365
+
366
+ describe('snapSegmentToBeats', function() {
367
+ it('leaves a segment unchanged when no beat is close enough', function() {
368
+ const seg = { startFrame: 100, highlightFrames: 200 };
369
+ expect(snapSegmentToBeats(seg, 1000, [10, 900], 20)).deep.equal(seg);
370
+ });
371
+ });
372
+
373
+ describe('snapDropToBeat', function() {
374
+ it('moves the drop and its start together onto a beat within a fifth of a beat', function() {
375
+ const drop = { startFrame: 60, dropFrame: 103 };
376
+ snapDropToBeat(drop, [80, 100, 120], 20);
377
+ expect(drop).deep.equal({ startFrame: 57, dropFrame: 100 });
378
+ });
379
+
380
+ it('keeps the drop when the nearest beat is too far', function() {
381
+ const drop = { startFrame: 60, dropFrame: 105 };
382
+ snapDropToBeat(drop, [80, 100, 120], 20);
383
+ expect(drop).deep.equal({ startFrame: 60, dropFrame: 105 });
384
+ });
385
+
386
+ it('keeps the drop without a tempo period', function() {
387
+ const drop = { startFrame: 60, dropFrame: 101 };
388
+ snapDropToBeat(drop, [80, 100, 120]);
389
+ expect(drop).deep.equal({ startFrame: 60, dropFrame: 101 });
390
+ });
391
+
392
+ it('does not push a lead-in that fit in the track back past its start', function() {
393
+ const drop = { startFrame: 1, dropFrame: 43 };
394
+ snapDropToBeat(drop, [40], 20);
395
+ expect(drop).deep.equal({ startFrame: 1, dropFrame: 43 });
396
+ });
178
397
  });
@@ -1,7 +1,7 @@
1
1
  import { expect } from 'chai';
2
2
  import { Writable, Readable, Pipe } from 'stream';
3
3
  import { EventEmitter } from 'events';
4
- import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, getCutFrameIndices, selectAutoEditCutFrames, snapBeatsToTempoGrid, projectBeatsOntoTempoGrid, spawnConcatVideoFrameReader } from '../video';
4
+ import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, computeHookFrameCount, getCutFrameIndices, selectAutoEditCutFrames, snapBeatsToTempoGrid, projectBeatsOntoTempoGrid, spawnConcatVideoFrameReader } from '../video';
5
5
  import { createSandbox, SinonStub } from 'sinon';
6
6
  import child_process, { ChildProcessWithoutNullStreams } from 'child_process';
7
7
 
@@ -70,6 +70,80 @@ describe('video', function () {
70
70
  expect(spawnArgs[tIdx + 1]).to.equal('15');
71
71
  });
72
72
 
73
+ it('spawnFfmpegVideoWriter mixes overlay audio while the track fades in', function () {
74
+ const childProcessReadableStream = new Readable();
75
+ childProcessReadableStream._read = () => { };
76
+ const childProcessWritableStream = new Writable();
77
+ (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
78
+
79
+ childProcessStream.stdin = childProcessWritableStream;
80
+ childProcessStream.stderr = childProcessReadableStream;
81
+
82
+ let spawnArgs: string[] = [];
83
+ const spawnStub = child_process.spawn as SinonStub;
84
+ spawnStub.callsFake((_cmd: string, args: string[]) => {
85
+ spawnArgs = args;
86
+ return childProcessStream as ChildProcessWithoutNullStreams;
87
+ });
88
+
89
+ spawnFfmpegVideoWriter({
90
+ audioFilename: 'audio.mp3',
91
+ videoFileName: 'out.mp4',
92
+ fps: 25,
93
+ overlayAudio: { filename: 'hook.mp4', durationSeconds: 3.2 },
94
+ });
95
+
96
+ spawnStub.resetBehavior();
97
+ spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
98
+
99
+ const inputs = spawnArgs.filter((_, i) => spawnArgs[i - 1] === '-i');
100
+ expect(inputs).deep.equal(['audio.mp3', '-', 'hook.mp4']);
101
+ const hookIdx = spawnArgs.indexOf('hook.mp4');
102
+ expect(spawnArgs.slice(hookIdx - 3, hookIdx - 1)).deep.equal(['-t', '3.2']);
103
+ const filter = spawnArgs[spawnArgs.indexOf('-filter_complex') + 1];
104
+ expect(filter).to.include("[0:a]aformat=channel_layouts=stereo,volume='if(gte(t,3.2),1,pow(t/3.2,5))':eval=frame[track]");
105
+ expect(filter).to.include('[2:a]aformat=channel_layouts=stereo[overlay]');
106
+ expect(filter).to.include('[track][overlay]amix=inputs=2:duration=first');
107
+ expect(spawnArgs.join(' ')).to.include('-map 1:v -map [aout]');
108
+ });
109
+
110
+ it('spawnFfmpegVideoWriter delays the track by the audio segment delay', function () {
111
+ const childProcessReadableStream = new Readable();
112
+ childProcessReadableStream._read = () => { };
113
+ const childProcessWritableStream = new Writable();
114
+ (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
115
+
116
+ childProcessStream.stdin = childProcessWritableStream;
117
+ childProcessStream.stderr = childProcessReadableStream;
118
+
119
+ const spawnStub = child_process.spawn as SinonStub;
120
+ const run = (overlay: boolean) => {
121
+ let spawnArgs: string[] = [];
122
+ spawnStub.callsFake((_cmd: string, args: string[]) => {
123
+ spawnArgs = args;
124
+ return childProcessStream as ChildProcessWithoutNullStreams;
125
+ });
126
+ spawnFfmpegVideoWriter({
127
+ audioFilename: 'audio.mp3',
128
+ videoFileName: 'out.mp4',
129
+ fps: 25,
130
+ audioSegment: { seekSeconds: 0, durationSeconds: 12, delaySeconds: 1.5 },
131
+ ...(overlay && { overlayAudio: { filename: 'hook.mp4', durationSeconds: 3.2 } }),
132
+ });
133
+ return spawnArgs;
134
+ };
135
+
136
+ const plainArgs = run(false);
137
+ expect(plainArgs[plainArgs.indexOf('-af') + 1]).equal('adelay=1500:all=1');
138
+ const overlayArgs = run(true);
139
+ spawnStub.resetBehavior();
140
+ spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
141
+
142
+ expect(overlayArgs).to.not.include('-af');
143
+ const filter = overlayArgs[overlayArgs.indexOf('-filter_complex') + 1];
144
+ expect(filter).to.include("[0:a]aformat=channel_layouts=stereo,adelay=1500:all=1,volume='if(gte(t,3.2),1,pow(t/3.2,5))':eval=frame[track]");
145
+ });
146
+
73
147
  it('spawnFfmpegVideoWriter adds subtitles filter when subtitle file is provided', function () {
74
148
  const childProcessReadableStream = new Readable();
75
149
  childProcessReadableStream._read = () => { };
@@ -283,6 +357,11 @@ describe('video', function () {
283
357
  expect(reason).equal('waitForProcessExit timeout (5ms)');
284
358
  });
285
359
 
360
+ it('computeHookFrameCount plays the full hook', function () {
361
+ expect(computeHookFrameCount(2, 30, 300)).equal(60);
362
+ expect(computeHookFrameCount(20, 30, 300)).equal(300);
363
+ });
364
+
286
365
  it('getCutFrameIndices skips the opening segment', function () {
287
366
  const segments = buildBeatSyncedSegments(
288
367
  [12, 24, 36],
package/src/video.ts CHANGED
@@ -10,6 +10,8 @@ import { TempoEstimate, beatGridFrameIndices } from './beats';
10
10
  export interface AudioMuxSegment {
11
11
  seekSeconds: number;
12
12
  durationSeconds: number;
13
+ /** Silence played before the audio. */
14
+ delaySeconds?: number;
13
15
  }
14
16
 
15
17
  interface FfmpegVideoWriterConfig {
@@ -27,6 +29,14 @@ interface FfmpegVideoWriterConfig {
27
29
  audioDurationSeconds?: number;
28
30
  /** One contiguous trim of the same audio file (muxed highlight). */
29
31
  audioSegment?: AudioMuxSegment;
32
+ /**
33
+ * Audio mixed over the start of the track (e.g. the hook video's sound), from its beginning.
34
+ * The track fades in from silence to full volume while it plays, along an easeInQuint curve.
35
+ */
36
+ overlayAudio?: {
37
+ filename: string;
38
+ durationSeconds: number;
39
+ };
30
40
  }
31
41
 
32
42
  const escapeSubtitleFilterPath = (subtitlePath: string) =>
@@ -38,6 +48,13 @@ const escapeSubtitleFilterPath = (subtitlePath: string) =>
38
48
  .replace(/\[/g, '\\[')
39
49
  .replace(/\]/g, '\\]');
40
50
 
51
+ /**
52
+ * Fade-in from silence to full volume over `durationSeconds`, following easeInQuint:
53
+ * gain(x) = x^5, where x is the fraction of the fade elapsed.
54
+ */
55
+ const easeInQuintFadeFilter = (durationSeconds: number) =>
56
+ `volume='if(gte(t,${durationSeconds}),1,pow(t/${durationSeconds},5))':eval=frame`;
57
+
41
58
  export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
42
59
  if (!ffmpegPath) {
43
60
  throw new Error('ffmpeg path not found');
@@ -58,6 +75,30 @@ export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
58
75
  '-vcodec', 'bmp',
59
76
  '-framerate', `${config.fps}`,
60
77
  '-i', '-',
78
+ );
79
+ const delaySeconds = config.audioSegment?.delaySeconds ?? 0;
80
+ const trackDelayFilter = delaySeconds > 0
81
+ ? `adelay=${Math.round(delaySeconds * 1000)}:all=1`
82
+ : '';
83
+ if (config.overlayAudio) {
84
+ const overlaySeconds = config.overlayAudio.durationSeconds;
85
+ args.push(
86
+ '-t', String(overlaySeconds),
87
+ '-i', config.overlayAudio.filename,
88
+ // Both inputs go to stereo first: mixing in e.g. a 5.1 hook otherwise yields a channel
89
+ // layout the AAC encoder rejects. `duration=first` keeps the output as long as the track.
90
+ '-filter_complex',
91
+ `[0:a]aformat=channel_layouts=stereo,${trackDelayFilter ? `${trackDelayFilter},` : ''}`
92
+ + `${easeInQuintFadeFilter(overlaySeconds)}[track];`
93
+ + '[2:a]aformat=channel_layouts=stereo[overlay];'
94
+ + '[track][overlay]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]',
95
+ '-map', '1:v',
96
+ '-map', '[aout]',
97
+ );
98
+ } else if (trackDelayFilter) {
99
+ args.push('-af', trackDelayFilter);
100
+ }
101
+ args.push(
61
102
  '-crf', crf,
62
103
  '-c:a', 'aac', '-b:a', '384k', '-profile:a', 'aac_low',
63
104
  '-c:v', 'libx264',
@@ -86,6 +127,7 @@ export interface VideoInfo {
86
127
  width: number;
87
128
  height: number;
88
129
  duration: number;
130
+ hasAudio: boolean;
89
131
  }
90
132
 
91
133
  export interface SceneChange {
@@ -128,6 +170,37 @@ export const detectSceneChanges = (videoPath: string, threshold = 0.4): Promise<
128
170
  ffmpeg.on('error', reject);
129
171
  });
130
172
 
173
+ /**
174
+ * Number of whole frames the video stream yields at `fps` (partial last frames dropped).
175
+ * Unlike the container duration, this never runs past the last video frame (e.g. when the
176
+ * audio stream is longer), so a clip read for this many frames never wraps back to its start.
177
+ */
178
+ export const getVideoFrameCount = (videoPath: string, fps: number): Promise<number> =>
179
+ new Promise((resolvePromise, reject) => {
180
+ if (!ffmpegPath) {
181
+ reject(new Error('ffmpeg path not found'));
182
+ return;
183
+ }
184
+ const ffmpeg = spawn(ffmpegPath, [
185
+ '-i', videoPath,
186
+ '-map', '0:v:0',
187
+ '-vf', `fps=${fps}:round=down`,
188
+ '-f', 'null',
189
+ '-',
190
+ ]);
191
+ let stderr = '';
192
+ ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
193
+ ffmpeg.on('close', () => {
194
+ const matches = stderr.match(/frame=\s*\d+/g);
195
+ if (!matches) {
196
+ reject(new Error(`Could not count video frames for: ${videoPath}`));
197
+ return;
198
+ }
199
+ resolvePromise(parseInt(matches[matches.length - 1].replace(/\D/g, '')));
200
+ });
201
+ ffmpeg.on('error', reject);
202
+ });
203
+
131
204
  export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
132
205
  new Promise((resolvePromise, reject) => {
133
206
  if (!ffmpegPath) {
@@ -151,6 +224,7 @@ export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
151
224
  width: parseInt(dimMatch[1]),
152
225
  height: parseInt(dimMatch[2]),
153
226
  duration,
227
+ hasAudio: /Stream.*Audio:/.test(stderr),
154
228
  });
155
229
  });
156
230
  });
@@ -762,6 +836,13 @@ export const buildBeatSyncedSegments = (
762
836
  return segments;
763
837
  };
764
838
 
839
+ /** Output frames given to the hook video: it plays in full, but never exceeds `totalFrames`. */
840
+ export const computeHookFrameCount = (
841
+ hookDurationSeconds: number,
842
+ fps: number,
843
+ totalFrames: number,
844
+ ): number => Math.max(0, Math.min(totalFrames, Math.round(hookDurationSeconds * fps)));
845
+
765
846
  export const getCutFrameIndices = (segments: VideoSegment[]): number[] =>
766
847
  segments
767
848
  .slice(1)