nodejs-audio-visualizer 5.0.0 → 5.1.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.
- package/README.md +1 -1
- package/dist/audio.d.ts +1 -0
- package/dist/audio.js +8 -0
- package/dist/audio.js.map +1 -1
- package/dist/beats.d.ts +20 -0
- package/dist/beats.js +180 -3
- package/dist/beats.js.map +1 -1
- package/dist/highlight.d.ts +2 -0
- package/dist/highlight.js +33 -24
- package/dist/highlight.js.map +1 -1
- package/dist/image.d.ts +13 -0
- package/dist/image.js +80 -0
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +66 -34
- package/dist/index.js.map +1 -1
- package/dist/video.d.ts +41 -1
- package/dist/video.js +150 -2
- package/dist/video.js.map +1 -1
- package/package.json +3 -2
- package/src/audio.ts +9 -0
- package/src/beats.ts +214 -3
- package/src/highlight.ts +21 -14
- package/src/image.ts +98 -0
- package/src/index.ts +57 -6
- package/src/tests/audio.test.ts +6 -0
- package/src/tests/beats.test.ts +134 -0
- package/src/tests/image.test.ts +86 -0
- package/src/tests/video.test.ts +163 -1
- package/src/typings.d.ts +22 -0
- package/src/video.ts +173 -1
package/src/tests/video.test.ts
CHANGED
|
@@ -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, spawnConcatVideoFrameReader } from '../video';
|
|
4
|
+
import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, buildBeatSyncedSegments, 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
|
|
|
@@ -282,4 +282,166 @@ describe('video', function () {
|
|
|
282
282
|
expect(exitCode).equal(1);
|
|
283
283
|
expect(reason).equal('waitForProcessExit timeout (5ms)');
|
|
284
284
|
});
|
|
285
|
+
|
|
286
|
+
it('getCutFrameIndices skips the opening segment', function () {
|
|
287
|
+
const segments = buildBeatSyncedSegments(
|
|
288
|
+
[12, 24, 36],
|
|
289
|
+
48,
|
|
290
|
+
[
|
|
291
|
+
{ frameNumber: 0, pts: 0, ptsTime: 1 },
|
|
292
|
+
{ frameNumber: 1, pts: 0, ptsTime: 4 },
|
|
293
|
+
{ frameNumber: 2, pts: 0, ptsTime: 8 },
|
|
294
|
+
],
|
|
295
|
+
12,
|
|
296
|
+
30,
|
|
297
|
+
{ minCutIntervalSeconds: 0 },
|
|
298
|
+
);
|
|
299
|
+
expect(getCutFrameIndices(segments)).deep.equal([12, 24, 36]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('getCutFrameIndices is empty when auto-edit has no scene changes', function () {
|
|
303
|
+
const segments = buildBeatSyncedSegments([10, 20], 30, [], 8, 30);
|
|
304
|
+
expect(segments).deep.equal([
|
|
305
|
+
{ outputStartFrame: 0, videoSeekSeconds: 0, frameCount: 30 },
|
|
306
|
+
]);
|
|
307
|
+
expect(getCutFrameIndices(segments)).deep.equal([]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('selectAutoEditCutFrames keeps at least 2 seconds between cuts', function () {
|
|
311
|
+
const fps = 30;
|
|
312
|
+
const beats = [15, 30, 45, 60, 75, 90, 105, 120].map(frameIndex => ({ frameIndex }));
|
|
313
|
+
expect(selectAutoEditCutFrames(beats, fps, 150)).deep.equal([60, 120]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('selectAutoEditCutFrames prefers the strongest beat in the allowed window', function () {
|
|
317
|
+
const fps = 30;
|
|
318
|
+
const beats = [
|
|
319
|
+
{ frameIndex: 60, intensity: 0.2 },
|
|
320
|
+
{ frameIndex: 90, intensity: 0.9 },
|
|
321
|
+
{ frameIndex: 100, intensity: 0.4 },
|
|
322
|
+
];
|
|
323
|
+
expect(selectAutoEditCutFrames(beats, fps, 180)).deep.equal([90]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('buildBeatSyncedSegments spaces auto-edit cuts instead of cutting every beat', function () {
|
|
327
|
+
const segments = buildBeatSyncedSegments(
|
|
328
|
+
[15, 30, 45, 60, 75, 90, 105, 120],
|
|
329
|
+
150,
|
|
330
|
+
[
|
|
331
|
+
{ frameNumber: 0, pts: 0, ptsTime: 1 },
|
|
332
|
+
{ frameNumber: 1, pts: 0, ptsTime: 4 },
|
|
333
|
+
],
|
|
334
|
+
12,
|
|
335
|
+
30,
|
|
336
|
+
);
|
|
337
|
+
expect(getCutFrameIndices(segments)).deep.equal([60, 120]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('snapBeatsToTempoGrid snaps onsets within a quarter-beat to the grid', function () {
|
|
341
|
+
const snapped = snapBeatsToTempoGrid(
|
|
342
|
+
[
|
|
343
|
+
{ frameIndex: 29, intensity: 0.8 },
|
|
344
|
+
{ frameIndex: 50, intensity: 0.4 },
|
|
345
|
+
],
|
|
346
|
+
{ periodFrames: 14, phaseFrame: 0 },
|
|
347
|
+
200,
|
|
348
|
+
);
|
|
349
|
+
expect(snapped.map(beat => beat.frameIndex)).deep.equal([28, 50]);
|
|
350
|
+
expect(snapped[0].intensity).equal(0.8);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('projectBeatsOntoTempoGrid fills the grid and copies nearby onset intensity', function () {
|
|
354
|
+
const projected = projectBeatsOntoTempoGrid(
|
|
355
|
+
[
|
|
356
|
+
{ frameIndex: 29, intensity: 0.8 },
|
|
357
|
+
{ frameIndex: 50, intensity: 0.4 },
|
|
358
|
+
],
|
|
359
|
+
{ periodFrames: 14, phaseFrame: 0 },
|
|
360
|
+
50,
|
|
361
|
+
);
|
|
362
|
+
expect(projected.map(beat => beat.frameIndex)).deep.equal([14, 28, 42]);
|
|
363
|
+
expect(projected.map(beat => beat.intensity)).deep.equal([0, 0.8, 0]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('buildBeatSyncedSegments uses 2-4 beat spacing when tempo is set', function () {
|
|
367
|
+
const fps = 30;
|
|
368
|
+
const bpm = 90;
|
|
369
|
+
const periodFrames = fps * 60 / bpm;
|
|
370
|
+
const segments = buildBeatSyncedSegments(
|
|
371
|
+
[20, 40, 60, 80, 100, 120, 140, 160],
|
|
372
|
+
200,
|
|
373
|
+
[
|
|
374
|
+
{ frameNumber: 0, pts: 0, ptsTime: 1 },
|
|
375
|
+
{ frameNumber: 1, pts: 0, ptsTime: 4 },
|
|
376
|
+
],
|
|
377
|
+
12,
|
|
378
|
+
fps,
|
|
379
|
+
{ tempo: { bpm, periodFrames, phaseFrame: 0 } },
|
|
380
|
+
);
|
|
381
|
+
expect(getCutFrameIndices(segments)).deep.equal([40, 80, 120, 160]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('buildBeatSyncedSegments places scene cuts on the BPM grid', function () {
|
|
385
|
+
const fps = 30;
|
|
386
|
+
const bpm = 128;
|
|
387
|
+
const periodFrames = 14;
|
|
388
|
+
const totalFrames = 180;
|
|
389
|
+
const segments = buildBeatSyncedSegments(
|
|
390
|
+
[57, 113],
|
|
391
|
+
totalFrames,
|
|
392
|
+
[
|
|
393
|
+
{ frameNumber: 0, pts: 0, ptsTime: 1 },
|
|
394
|
+
{ frameNumber: 1, pts: 0, ptsTime: 4 },
|
|
395
|
+
],
|
|
396
|
+
12,
|
|
397
|
+
fps,
|
|
398
|
+
{ tempo: { bpm, periodFrames, phaseFrame: 0 } },
|
|
399
|
+
);
|
|
400
|
+
const cuts = getCutFrameIndices(segments);
|
|
401
|
+
expect(cuts.length).greaterThan(0);
|
|
402
|
+
for (const cut of cuts) {
|
|
403
|
+
expect(cut % periodFrames).equal(0);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('buildBeatSyncedSegments cuts on the BPM grid even without detected onsets', function () {
|
|
408
|
+
const fps = 30;
|
|
409
|
+
const bpm = 90;
|
|
410
|
+
const periodFrames = fps * 60 / bpm;
|
|
411
|
+
const segments = buildBeatSyncedSegments(
|
|
412
|
+
[],
|
|
413
|
+
200,
|
|
414
|
+
[
|
|
415
|
+
{ frameNumber: 0, pts: 0, ptsTime: 1 },
|
|
416
|
+
{ frameNumber: 1, pts: 0, ptsTime: 4 },
|
|
417
|
+
],
|
|
418
|
+
12,
|
|
419
|
+
fps,
|
|
420
|
+
{ tempo: { bpm, periodFrames, phaseFrame: 0 } },
|
|
421
|
+
);
|
|
422
|
+
expect(getCutFrameIndices(segments)).deep.equal([40, 80, 120, 160]);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('buildBeatSyncedSegments prefers the strongest on-grid beat in the cut window', function () {
|
|
426
|
+
const fps = 30;
|
|
427
|
+
const bpm = 90;
|
|
428
|
+
const periodFrames = fps * 60 / bpm;
|
|
429
|
+
const beats = [20, 40, 60, 80, 100, 120, 140, 160];
|
|
430
|
+
const intensities = [0.1, 0.1, 0.9, 0.1, 0.1, 0.8, 0.1, 0.1];
|
|
431
|
+
const segments = buildBeatSyncedSegments(
|
|
432
|
+
beats,
|
|
433
|
+
160,
|
|
434
|
+
[
|
|
435
|
+
{ frameNumber: 0, pts: 0, ptsTime: 1 },
|
|
436
|
+
{ frameNumber: 1, pts: 0, ptsTime: 4 },
|
|
437
|
+
],
|
|
438
|
+
12,
|
|
439
|
+
fps,
|
|
440
|
+
{
|
|
441
|
+
beatIntensities: intensities,
|
|
442
|
+
tempo: { bpm, periodFrames, phaseFrame: 0 },
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
expect(getCutFrameIndices(segments)).deep.equal([60, 120]);
|
|
446
|
+
});
|
|
285
447
|
});
|
package/src/typings.d.ts
CHANGED
|
@@ -4,4 +4,26 @@ declare module 'fft-js' {
|
|
|
4
4
|
fftFreq(phasors: [number, number][], sampleRate: number): number[];
|
|
5
5
|
fftMag(phasors: [number, number][]): number[];
|
|
6
6
|
};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare module 'music-tempo' {
|
|
10
|
+
export interface MusicTempoParams {
|
|
11
|
+
bufferSize?: number;
|
|
12
|
+
hopSize?: number;
|
|
13
|
+
timeStep?: number;
|
|
14
|
+
decayRate?: number;
|
|
15
|
+
peakFindingWindow?: number;
|
|
16
|
+
meanWndMultiplier?: number;
|
|
17
|
+
peakThreshold?: number;
|
|
18
|
+
minBeatInterval?: number;
|
|
19
|
+
maxBeatInterval?: number;
|
|
20
|
+
expiryTime?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default class MusicTempo {
|
|
24
|
+
constructor(audioData: number[] | Float32Array, params?: MusicTempoParams);
|
|
25
|
+
tempo: string | number;
|
|
26
|
+
beats: number[];
|
|
27
|
+
beatInterval: number;
|
|
28
|
+
}
|
|
7
29
|
}
|
package/src/video.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { resolve as resolvePath, join as joinPath } from 'path';
|
|
|
5
5
|
import { tmpdir } from 'os';
|
|
6
6
|
import ffmpegPath from 'ffmpeg-static';
|
|
7
7
|
import { defaults } from './config';
|
|
8
|
+
import { TempoEstimate, beatGridFrameIndices } from './beats';
|
|
8
9
|
|
|
9
10
|
export interface AudioMuxSegment {
|
|
10
11
|
seekSeconds: number;
|
|
@@ -426,11 +427,156 @@ const mergeSmallScenes = (sceneChanges: SceneChange[], videoDuration: number, mi
|
|
|
426
427
|
return merged;
|
|
427
428
|
};
|
|
428
429
|
|
|
430
|
+
/** Minimum time between auto-edit cuts; cuts still land on beats. */
|
|
431
|
+
export const DEFAULT_AUTO_EDIT_MIN_CUT_INTERVAL_SECONDS = 2;
|
|
432
|
+
/** Window after the minimum gap in which the strongest beat is chosen. */
|
|
433
|
+
export const DEFAULT_AUTO_EDIT_MAX_CUT_INTERVAL_SECONDS = 4;
|
|
434
|
+
/** Half a bar of 4/4, used as the minimum gap when tempo is known. */
|
|
435
|
+
export const AUTO_EDIT_MIN_CUT_BEATS = 2;
|
|
436
|
+
/** One bar of 4/4, used as the maximum gap when tempo is known. */
|
|
437
|
+
export const AUTO_EDIT_MAX_CUT_BEATS = 4;
|
|
438
|
+
/** Snap an onset to the tempo grid when it is within this fraction of a beat. */
|
|
439
|
+
export const BEAT_SNAP_TOLERANCE = 0.25;
|
|
440
|
+
|
|
441
|
+
export const cutIntervalSecondsFromTempo = (bpm: number) => ({
|
|
442
|
+
minCutIntervalSeconds: AUTO_EDIT_MIN_CUT_BEATS * (60 / bpm),
|
|
443
|
+
maxCutIntervalSeconds: AUTO_EDIT_MAX_CUT_BEATS * (60 / bpm),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
export const snapBeatsToTempoGrid = (
|
|
447
|
+
beats: Array<{ frameIndex: number; intensity?: number }>,
|
|
448
|
+
tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
|
|
449
|
+
totalFrames: number,
|
|
450
|
+
): Array<{ frameIndex: number; intensity?: number }> => {
|
|
451
|
+
const period = tempo.periodFrames;
|
|
452
|
+
if (!(period > 0)) {
|
|
453
|
+
return beats.slice();
|
|
454
|
+
}
|
|
455
|
+
const maxSnap = Math.max(1, period * BEAT_SNAP_TOLERANCE);
|
|
456
|
+
const merged: Array<{ frameIndex: number; intensity?: number }> = [];
|
|
457
|
+
const push = (beat: { frameIndex: number; intensity?: number }) => {
|
|
458
|
+
for (let i = 0; i < merged.length; i++) {
|
|
459
|
+
if (merged[i].frameIndex === beat.frameIndex) {
|
|
460
|
+
if ((beat.intensity ?? 0) > (merged[i].intensity ?? 0)) {
|
|
461
|
+
merged[i] = beat;
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
merged.push(beat);
|
|
467
|
+
};
|
|
468
|
+
for (const beat of beats) {
|
|
469
|
+
const n = Math.round((beat.frameIndex - tempo.phaseFrame) / period);
|
|
470
|
+
const grid = Math.round(tempo.phaseFrame + n * period);
|
|
471
|
+
const dist = Math.abs(beat.frameIndex - grid);
|
|
472
|
+
if (dist <= maxSnap && grid > 0 && grid < totalFrames) {
|
|
473
|
+
push({ frameIndex: grid, intensity: beat.intensity });
|
|
474
|
+
} else {
|
|
475
|
+
push(beat);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return merged.sort((a, b) => a.frameIndex - b.frameIndex);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/** Every BPM-grid beat, with onset intensity copied onto the nearest grid frame. */
|
|
482
|
+
export const projectBeatsOntoTempoGrid = (
|
|
483
|
+
beats: Array<{ frameIndex: number; intensity?: number }>,
|
|
484
|
+
tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
|
|
485
|
+
totalFrames: number,
|
|
486
|
+
): Array<{ frameIndex: number; intensity?: number }> => {
|
|
487
|
+
const grid = beatGridFrameIndices(tempo, totalFrames);
|
|
488
|
+
if (grid.length === 0) {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
const period = tempo.periodFrames;
|
|
492
|
+
const maxSnap = Math.max(1, period * BEAT_SNAP_TOLERANCE);
|
|
493
|
+
const intensityByFrame = new Map<number, number>();
|
|
494
|
+
for (const beat of beats) {
|
|
495
|
+
const n = Math.round((beat.frameIndex - tempo.phaseFrame) / period);
|
|
496
|
+
const gridFrame = Math.round(tempo.phaseFrame + n * period);
|
|
497
|
+
if (Math.abs(beat.frameIndex - gridFrame) > maxSnap) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (gridFrame <= 0 || gridFrame >= totalFrames) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const intensity = beat.intensity ?? 0;
|
|
504
|
+
if (intensity >= (intensityByFrame.get(gridFrame) ?? 0)) {
|
|
505
|
+
intensityByFrame.set(gridFrame, intensity);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return grid.map(frameIndex => ({
|
|
509
|
+
frameIndex,
|
|
510
|
+
intensity: intensityByFrame.get(frameIndex) ?? 0,
|
|
511
|
+
}));
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
export const selectAutoEditCutFrames = (
|
|
515
|
+
beats: Array<{ frameIndex: number; intensity?: number }>,
|
|
516
|
+
fps: number,
|
|
517
|
+
totalFrames: number,
|
|
518
|
+
minIntervalSeconds = DEFAULT_AUTO_EDIT_MIN_CUT_INTERVAL_SECONDS,
|
|
519
|
+
maxIntervalSeconds = DEFAULT_AUTO_EDIT_MAX_CUT_INTERVAL_SECONDS,
|
|
520
|
+
): number[] => {
|
|
521
|
+
const sorted = beats
|
|
522
|
+
.filter(beat => beat.frameIndex > 0 && beat.frameIndex < totalFrames)
|
|
523
|
+
.sort((a, b) => a.frameIndex - b.frameIndex);
|
|
524
|
+
if (sorted.length === 0 || fps <= 0) {
|
|
525
|
+
return [];
|
|
526
|
+
}
|
|
527
|
+
if (minIntervalSeconds <= 0) {
|
|
528
|
+
return sorted.map(beat => beat.frameIndex);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const minGap = Math.max(1, Math.round(fps * minIntervalSeconds));
|
|
532
|
+
const maxGap = Math.max(minGap, Math.round(fps * maxIntervalSeconds));
|
|
533
|
+
const selected: number[] = [];
|
|
534
|
+
let lastCut = 0;
|
|
535
|
+
|
|
536
|
+
while (lastCut + minGap < totalFrames) {
|
|
537
|
+
const windowStart = lastCut + minGap;
|
|
538
|
+
const windowEnd = Math.min(lastCut + maxGap, totalFrames);
|
|
539
|
+
|
|
540
|
+
let best: { frameIndex: number; intensity?: number } | undefined;
|
|
541
|
+
let nextAfterWindow: { frameIndex: number; intensity?: number } | undefined;
|
|
542
|
+
|
|
543
|
+
for (const beat of sorted) {
|
|
544
|
+
if (beat.frameIndex < windowStart) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (beat.frameIndex >= windowEnd) {
|
|
548
|
+
nextAfterWindow = beat;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
const intensity = beat.intensity ?? 0;
|
|
552
|
+
if (!best || intensity > (best.intensity ?? 0)) {
|
|
553
|
+
best = beat;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const chosen = best ?? nextAfterWindow;
|
|
558
|
+
if (!chosen) {
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
selected.push(chosen.frameIndex);
|
|
562
|
+
lastCut = chosen.frameIndex;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return selected;
|
|
566
|
+
};
|
|
567
|
+
|
|
429
568
|
export const buildBeatSyncedSegments = (
|
|
430
569
|
beatFrameIndices: number[],
|
|
431
570
|
totalFrames: number,
|
|
432
571
|
sceneChanges: SceneChange[],
|
|
433
572
|
videoDuration: number,
|
|
573
|
+
fps: number,
|
|
574
|
+
options?: {
|
|
575
|
+
beatIntensities?: number[];
|
|
576
|
+
minCutIntervalSeconds?: number;
|
|
577
|
+
maxCutIntervalSeconds?: number;
|
|
578
|
+
tempo?: TempoEstimate;
|
|
579
|
+
},
|
|
434
580
|
): VideoSegment[] => {
|
|
435
581
|
if (sceneChanges.length === 0) {
|
|
436
582
|
return [
|
|
@@ -451,7 +597,27 @@ export const buildBeatSyncedSegments = (
|
|
|
451
597
|
seekPositions = Array.from({ length: count }, (_, i) => (i * videoDuration) / count);
|
|
452
598
|
}
|
|
453
599
|
|
|
454
|
-
const
|
|
600
|
+
const tempo = options?.tempo;
|
|
601
|
+
const tempoIntervals = tempo && tempo.bpm > 0
|
|
602
|
+
? cutIntervalSecondsFromTempo(tempo.bpm)
|
|
603
|
+
: undefined;
|
|
604
|
+
let beats: Array<{ frameIndex: number; intensity?: number }> = beatFrameIndices.map((frameIndex, i) => ({
|
|
605
|
+
frameIndex,
|
|
606
|
+
intensity: options?.beatIntensities?.[i],
|
|
607
|
+
}));
|
|
608
|
+
if (tempo) {
|
|
609
|
+
beats = projectBeatsOntoTempoGrid(beats, tempo, totalFrames);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const cutBeats = selectAutoEditCutFrames(
|
|
613
|
+
beats,
|
|
614
|
+
fps,
|
|
615
|
+
totalFrames,
|
|
616
|
+
options?.minCutIntervalSeconds ?? tempoIntervals?.minCutIntervalSeconds,
|
|
617
|
+
options?.maxCutIntervalSeconds ?? tempoIntervals?.maxCutIntervalSeconds,
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
const boundaries = [0, ...cutBeats];
|
|
455
621
|
if (boundaries[boundaries.length - 1] !== totalFrames) {
|
|
456
622
|
boundaries.push(totalFrames);
|
|
457
623
|
}
|
|
@@ -468,6 +634,12 @@ export const buildBeatSyncedSegments = (
|
|
|
468
634
|
return segments;
|
|
469
635
|
};
|
|
470
636
|
|
|
637
|
+
export const getCutFrameIndices = (segments: VideoSegment[]): number[] =>
|
|
638
|
+
segments
|
|
639
|
+
.slice(1)
|
|
640
|
+
.map(segment => segment.outputStartFrame)
|
|
641
|
+
.filter(frameIndex => frameIndex > 0);
|
|
642
|
+
|
|
471
643
|
export const writeConcatFile = (
|
|
472
644
|
segments: VideoSegment[],
|
|
473
645
|
videoPath: string,
|