nodejs-audio-visualizer 5.0.0 → 5.2.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.
Files changed (53) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -2
  3. package/dist/audio.d.ts +13 -12
  4. package/dist/audio.js +128 -120
  5. package/dist/audio.js.map +1 -1
  6. package/dist/beats.d.ts +39 -19
  7. package/dist/beats.js +220 -43
  8. package/dist/beats.js.map +1 -1
  9. package/dist/bpmEncoder.d.ts +14 -14
  10. package/dist/bpmEncoder.js +97 -97
  11. package/dist/config.d.ts +78 -77
  12. package/dist/config.js +377 -369
  13. package/dist/config.js.map +1 -1
  14. package/dist/dsp.d.ts +16 -16
  15. package/dist/dsp.js +76 -76
  16. package/dist/highlight.d.ts +34 -32
  17. package/dist/highlight.js +334 -325
  18. package/dist/highlight.js.map +1 -1
  19. package/dist/image.d.ts +97 -79
  20. package/dist/image.js +545 -458
  21. package/dist/image.js.map +1 -1
  22. package/dist/index.d.ts +87 -79
  23. package/dist/index.js +597 -534
  24. package/dist/index.js.map +1 -1
  25. package/dist/subtitleConvert.d.ts +3 -3
  26. package/dist/subtitleConvert.js +61 -61
  27. package/dist/video.d.ts +150 -80
  28. package/dist/video.js +677 -434
  29. package/dist/video.js.map +1 -1
  30. package/dist/waitForEventLoop.d.ts +1 -1
  31. package/dist/waitForEventLoop.js +5 -5
  32. package/package.json +62 -61
  33. package/src/audio.ts +129 -120
  34. package/src/beats.ts +279 -68
  35. package/src/bpmEncoder.ts +97 -97
  36. package/src/config.ts +487 -476
  37. package/src/dsp.ts +95 -95
  38. package/src/highlight.ts +291 -284
  39. package/src/image.ts +707 -602
  40. package/src/index.ts +124 -31
  41. package/src/subtitleConvert.ts +63 -63
  42. package/src/tests/audio.test.ts +102 -96
  43. package/src/tests/beats.test.ts +134 -0
  44. package/src/tests/config.test.ts +534 -496
  45. package/src/tests/dsp.test.ts +24 -24
  46. package/src/tests/highlight.test.ts +178 -178
  47. package/src/tests/image.test.ts +319 -222
  48. package/src/tests/index.test.ts +180 -180
  49. package/src/tests/subtitleConvert.test.ts +23 -23
  50. package/src/tests/video.test.ts +535 -285
  51. package/src/typings.d.ts +28 -6
  52. package/src/video.ts +856 -550
  53. package/src/waitForEventLoop.ts +2 -2
@@ -1,285 +1,535 @@
1
- import { expect } from 'chai';
2
- import { Writable, Readable, Pipe } from 'stream';
3
- import { EventEmitter } from 'events';
4
- import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, spawnConcatVideoFrameReader } from '../video';
5
- import { createSandbox, SinonStub } from 'sinon';
6
- import child_process, { ChildProcessWithoutNullStreams } from 'child_process';
7
-
8
- let childProcessStream = {
9
- stdin: new Writable(),
10
- stderr: new Readable(),
11
- };
12
-
13
- const videoSandbox = createSandbox();
14
-
15
- describe('video', function () {
16
-
17
- this.beforeAll(function () {
18
- videoSandbox.stub(child_process, 'spawn').returns(childProcessStream as ChildProcessWithoutNullStreams);
19
- });
20
-
21
- this.afterAll(function () {
22
- videoSandbox.restore();
23
- });
24
-
25
- it('spawnFfmpegVideoWriter returns truthy value', function () {
26
- const childProcessReadableStream = new Readable();
27
- childProcessReadableStream._read = () => { };
28
- const childProcessWritableStream = new Writable();
29
- (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
30
-
31
- childProcessStream.stdin = childProcessWritableStream;
32
- childProcessStream.stderr = childProcessReadableStream;
33
-
34
- const result = spawnFfmpegVideoWriter({ audioFilename: 'test', videoFileName: 'test', fps: 11 });
35
- expect(!!result).equal(true);
36
- });
37
-
38
- it('spawnFfmpegVideoWriter uses -ss and -t for single audioSegment', function () {
39
- const childProcessReadableStream = new Readable();
40
- childProcessReadableStream._read = () => { };
41
- const childProcessWritableStream = new Writable();
42
- (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
43
-
44
- childProcessStream.stdin = childProcessWritableStream;
45
- childProcessStream.stderr = childProcessReadableStream;
46
-
47
- let spawnArgs: string[] = [];
48
- const spawnStub = child_process.spawn as SinonStub;
49
- spawnStub.callsFake((_cmd: string, args: string[]) => {
50
- spawnArgs = args;
51
- return childProcessStream as ChildProcessWithoutNullStreams;
52
- });
53
-
54
- spawnFfmpegVideoWriter({
55
- audioFilename: 'audio.mp3',
56
- videoFileName: 'out.mp4',
57
- fps: 25,
58
- audioSegment: { seekSeconds: 12, durationSeconds: 15 },
59
- });
60
-
61
- spawnStub.resetBehavior();
62
- spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
63
-
64
- expect(spawnArgs).to.not.include('-filter_complex');
65
- const ssIdx = spawnArgs.indexOf('-ss');
66
- expect(ssIdx).greaterThan(-1);
67
- expect(spawnArgs[ssIdx + 1]).to.equal('12');
68
- const tIdx = spawnArgs.indexOf('-t');
69
- expect(tIdx).greaterThan(-1);
70
- expect(spawnArgs[tIdx + 1]).to.equal('15');
71
- });
72
-
73
- it('spawnFfmpegVideoWriter adds subtitles filter when subtitle file is provided', 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
- subtitleFilename: '/tmp/subtitles.srt',
92
- videoFileName: 'out.mp4',
93
- fps: 25,
94
- });
95
-
96
- spawnStub.resetBehavior();
97
- spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
98
-
99
- const vfIdx = spawnArgs.indexOf('-vf');
100
- expect(vfIdx).greaterThan(-1);
101
- expect(spawnArgs[vfIdx + 1]).to.equal(
102
- "subtitles='/tmp/subtitles.srt':force_style='Alignment=2'",
103
- );
104
- });
105
-
106
- it('spawnFfmpegVideoWriter passes subtitle Alignment from subtitleAlignmentAss', function () {
107
- const childProcessReadableStream = new Readable();
108
- childProcessReadableStream._read = () => { };
109
- const childProcessWritableStream = new Writable();
110
- (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
111
-
112
- childProcessStream.stdin = childProcessWritableStream;
113
- childProcessStream.stderr = childProcessReadableStream;
114
-
115
- let spawnArgs: string[] = [];
116
- const spawnStub = child_process.spawn as SinonStub;
117
- spawnStub.callsFake((_cmd: string, args: string[]) => {
118
- spawnArgs = args;
119
- return childProcessStream as ChildProcessWithoutNullStreams;
120
- });
121
-
122
- spawnFfmpegVideoWriter({
123
- audioFilename: 'audio.mp3',
124
- subtitleFilename: '/tmp/subtitles.srt',
125
- subtitleAlignmentAss: 8,
126
- videoFileName: 'out.mp4',
127
- fps: 25,
128
- });
129
-
130
- spawnStub.resetBehavior();
131
- spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
132
-
133
- const vfIdx = spawnArgs.indexOf('-vf');
134
- expect(vfIdx).greaterThan(-1);
135
- expect(spawnArgs[vfIdx + 1]).to.equal(
136
- "subtitles='/tmp/subtitles.srt':force_style='Alignment=8'",
137
- );
138
- });
139
-
140
- it('spawnFfmpegVideoWriter configures image pipe input before output file', function () {
141
- const childProcessReadableStream = new Readable();
142
- childProcessReadableStream._read = () => { };
143
- const childProcessWritableStream = new Writable();
144
- (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
145
-
146
- childProcessStream.stdin = childProcessWritableStream;
147
- childProcessStream.stderr = childProcessReadableStream;
148
-
149
- let spawnArgs: string[] = [];
150
- const spawnStub = child_process.spawn as SinonStub;
151
- spawnStub.callsFake((_cmd: string, args: string[]) => {
152
- spawnArgs = args;
153
- return childProcessStream as ChildProcessWithoutNullStreams;
154
- });
155
-
156
- spawnFfmpegVideoWriter({
157
- audioFilename: 'audio.mp3',
158
- videoFileName: 'out.mp4',
159
- fps: 23.976,
160
- });
161
-
162
- spawnStub.resetBehavior();
163
- spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
164
-
165
- const audioInputIndex = spawnArgs.indexOf('-i');
166
- expect(audioInputIndex).greaterThan(-1);
167
- expect(spawnArgs[audioInputIndex + 1]).to.equal('audio.mp3');
168
- const imagePipeFlagIndex = spawnArgs.indexOf('image2pipe');
169
- expect(imagePipeFlagIndex).greaterThan(-1);
170
- expect(spawnArgs[imagePipeFlagIndex - 1]).to.equal('-f');
171
- const inputFpsIndex = spawnArgs.indexOf('-framerate');
172
- expect(inputFpsIndex).greaterThan(-1);
173
- expect(spawnArgs[inputFpsIndex + 1]).to.equal('23.976');
174
- const pipeInputIndex = spawnArgs.lastIndexOf('-i');
175
- expect(spawnArgs[pipeInputIndex + 1]).to.equal('-');
176
- expect(spawnArgs.includes('-shortest')).to.equal(true);
177
- expect(spawnArgs[spawnArgs.length - 1]).to.equal('out.mp4');
178
- });
179
-
180
- it('waitDrain resolves true on drain', async function () {
181
- const writable = new Writable();
182
- const waitPromise = waitDrain(writable, undefined, 50);
183
- setTimeout(() => writable.emit('drain'), 5);
184
- const isDrained = await waitPromise;
185
- expect(isDrained).equal(true);
186
- });
187
-
188
- it('waitDrain resolves false on timeout', async function () {
189
- const writable = new Writable();
190
- const isDrained = await waitDrain(writable, undefined, 5);
191
- expect(isDrained).equal(false);
192
- });
193
-
194
- it('readVideoFrame resolves null on timeout', async function () {
195
- const readable = new Readable();
196
- readable._read = () => {};
197
- const frame = await readVideoFrame(readable, 4, 5);
198
- expect(frame).equal(null);
199
- });
200
-
201
- it('readVideoFrame assembles a frame from multiple chunks', async function () {
202
- const readable = new Readable();
203
- readable._read = () => {};
204
- const framePromise = readVideoFrame(readable, 6, 200);
205
- await new Promise(resolve => setImmediate(resolve));
206
- readable.push(Buffer.from([1, 2]));
207
- await new Promise(resolve => setImmediate(resolve));
208
- readable.push(Buffer.from([3, 4]));
209
- await new Promise(resolve => setImmediate(resolve));
210
- readable.push(Buffer.from([5, 6]));
211
- const frame = await framePromise;
212
- expect(!!frame && frame.equals(Buffer.from([1, 2, 3, 4, 5, 6]))).equal(true);
213
- });
214
-
215
- it('readVideoFrame does not timeout after a successful chunked read', async function () {
216
- const errors: any[] = [];
217
- const originalError = console.error;
218
- console.error = (...args: any[]) => {
219
- errors.push(args);
220
- };
221
- const readable = new Readable();
222
- readable._read = () => {};
223
- try {
224
- const timeoutMs = 40;
225
- const framePromise = readVideoFrame(readable, 4, timeoutMs);
226
- await new Promise(resolve => setImmediate(resolve));
227
- readable.push(Buffer.from([1, 2]));
228
- await new Promise(resolve => setImmediate(resolve));
229
- readable.push(Buffer.from([3, 4]));
230
- const frame = await framePromise;
231
- expect(!!frame && frame.equals(Buffer.from([1, 2, 3, 4]))).equal(true);
232
- await new Promise(resolve => setTimeout(resolve, timeoutMs + 30));
233
- expect(errors).deep.equal([]);
234
- } finally {
235
- console.error = originalError;
236
- }
237
- });
238
-
239
- it('spawnConcatVideoFrameReader regenerates timestamps for auto-edit concat', function () {
240
- const childProcessReadableStream = new Readable();
241
- childProcessReadableStream._read = () => { };
242
- const childProcessWritableStream = new Writable();
243
- (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
244
-
245
- childProcessStream.stdin = childProcessWritableStream;
246
- childProcessStream.stderr = childProcessReadableStream;
247
-
248
- let spawnArgs: string[] = [];
249
- const spawnStub = child_process.spawn as SinonStub;
250
- spawnStub.callsFake((_cmd: string, args: string[]) => {
251
- spawnArgs = args;
252
- return childProcessStream as ChildProcessWithoutNullStreams;
253
- });
254
-
255
- spawnConcatVideoFrameReader({
256
- concatFilePath: '/tmp/av-concat.txt',
257
- fps: 30,
258
- totalFrames: 90,
259
- });
260
-
261
- spawnStub.resetBehavior();
262
- spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
263
-
264
- expect(spawnArgs.indexOf('-fflags')).greaterThan(-1);
265
- expect(spawnArgs[spawnArgs.indexOf('-fflags') + 1]).equal('+genpts');
266
- expect(spawnArgs.indexOf('-an')).greaterThan(-1);
267
- expect(spawnArgs[spawnArgs.indexOf('-avoid_negative_ts') + 1]).equal('make_zero');
268
- });
269
-
270
- it('waitForProcessExit resolves exit code', async function () {
271
- const processEmitter = new EventEmitter();
272
- const waitPromise = waitForProcessExit(processEmitter, 50);
273
- setTimeout(() => processEmitter.emit('exit', 0), 5);
274
- const { exitCode, reason } = await waitPromise;
275
- expect(exitCode).equal(0);
276
- expect(reason).equal(undefined);
277
- });
278
-
279
- it('waitForProcessExit resolves non-zero on timeout', async function () {
280
- const processEmitter = new EventEmitter();
281
- const { exitCode, reason } = await waitForProcessExit(processEmitter, 5);
282
- expect(exitCode).equal(1);
283
- expect(reason).equal('waitForProcessExit timeout (5ms)');
284
- });
285
- });
1
+ import { expect } from 'chai';
2
+ import { Writable, Readable, Pipe } from 'stream';
3
+ import { EventEmitter } from 'events';
4
+ import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, getCutFrameIndices, selectAutoEditCutFrames, snapBeatsToTempoGrid, projectBeatsOntoTempoGrid, spawnConcatVideoFrameReader } from '../video';
5
+ import { createSandbox, SinonStub } from 'sinon';
6
+ import child_process, { ChildProcessWithoutNullStreams } from 'child_process';
7
+
8
+ let childProcessStream = {
9
+ stdin: new Writable(),
10
+ stderr: new Readable(),
11
+ };
12
+
13
+ const videoSandbox = createSandbox();
14
+
15
+ describe('video', function () {
16
+
17
+ this.beforeAll(function () {
18
+ videoSandbox.stub(child_process, 'spawn').returns(childProcessStream as ChildProcessWithoutNullStreams);
19
+ });
20
+
21
+ this.afterAll(function () {
22
+ videoSandbox.restore();
23
+ });
24
+
25
+ it('spawnFfmpegVideoWriter returns truthy value', function () {
26
+ const childProcessReadableStream = new Readable();
27
+ childProcessReadableStream._read = () => { };
28
+ const childProcessWritableStream = new Writable();
29
+ (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
30
+
31
+ childProcessStream.stdin = childProcessWritableStream;
32
+ childProcessStream.stderr = childProcessReadableStream;
33
+
34
+ const result = spawnFfmpegVideoWriter({ audioFilename: 'test', videoFileName: 'test', fps: 11 });
35
+ expect(!!result).equal(true);
36
+ });
37
+
38
+ it('spawnFfmpegVideoWriter uses -ss and -t for single audioSegment', function () {
39
+ const childProcessReadableStream = new Readable();
40
+ childProcessReadableStream._read = () => { };
41
+ const childProcessWritableStream = new Writable();
42
+ (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
43
+
44
+ childProcessStream.stdin = childProcessWritableStream;
45
+ childProcessStream.stderr = childProcessReadableStream;
46
+
47
+ let spawnArgs: string[] = [];
48
+ const spawnStub = child_process.spawn as SinonStub;
49
+ spawnStub.callsFake((_cmd: string, args: string[]) => {
50
+ spawnArgs = args;
51
+ return childProcessStream as ChildProcessWithoutNullStreams;
52
+ });
53
+
54
+ spawnFfmpegVideoWriter({
55
+ audioFilename: 'audio.mp3',
56
+ videoFileName: 'out.mp4',
57
+ fps: 25,
58
+ audioSegment: { seekSeconds: 12, durationSeconds: 15 },
59
+ });
60
+
61
+ spawnStub.resetBehavior();
62
+ spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
63
+
64
+ expect(spawnArgs).to.not.include('-filter_complex');
65
+ const ssIdx = spawnArgs.indexOf('-ss');
66
+ expect(ssIdx).greaterThan(-1);
67
+ expect(spawnArgs[ssIdx + 1]).to.equal('12');
68
+ const tIdx = spawnArgs.indexOf('-t');
69
+ expect(tIdx).greaterThan(-1);
70
+ expect(spawnArgs[tIdx + 1]).to.equal('15');
71
+ });
72
+
73
+ it('spawnFfmpegVideoWriter adds subtitles filter when subtitle file is provided', 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
+ subtitleFilename: '/tmp/subtitles.srt',
92
+ videoFileName: 'out.mp4',
93
+ fps: 25,
94
+ });
95
+
96
+ spawnStub.resetBehavior();
97
+ spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
98
+
99
+ const vfIdx = spawnArgs.indexOf('-vf');
100
+ expect(vfIdx).greaterThan(-1);
101
+ expect(spawnArgs[vfIdx + 1]).to.equal(
102
+ "subtitles='/tmp/subtitles.srt':force_style='Alignment=2'",
103
+ );
104
+ });
105
+
106
+ it('spawnFfmpegVideoWriter passes subtitle Alignment from subtitleAlignmentAss', function () {
107
+ const childProcessReadableStream = new Readable();
108
+ childProcessReadableStream._read = () => { };
109
+ const childProcessWritableStream = new Writable();
110
+ (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
111
+
112
+ childProcessStream.stdin = childProcessWritableStream;
113
+ childProcessStream.stderr = childProcessReadableStream;
114
+
115
+ let spawnArgs: string[] = [];
116
+ const spawnStub = child_process.spawn as SinonStub;
117
+ spawnStub.callsFake((_cmd: string, args: string[]) => {
118
+ spawnArgs = args;
119
+ return childProcessStream as ChildProcessWithoutNullStreams;
120
+ });
121
+
122
+ spawnFfmpegVideoWriter({
123
+ audioFilename: 'audio.mp3',
124
+ subtitleFilename: '/tmp/subtitles.srt',
125
+ subtitleAlignmentAss: 8,
126
+ videoFileName: 'out.mp4',
127
+ fps: 25,
128
+ });
129
+
130
+ spawnStub.resetBehavior();
131
+ spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
132
+
133
+ const vfIdx = spawnArgs.indexOf('-vf');
134
+ expect(vfIdx).greaterThan(-1);
135
+ expect(spawnArgs[vfIdx + 1]).to.equal(
136
+ "subtitles='/tmp/subtitles.srt':force_style='Alignment=8'",
137
+ );
138
+ });
139
+
140
+ it('spawnFfmpegVideoWriter configures image pipe input before output file', function () {
141
+ const childProcessReadableStream = new Readable();
142
+ childProcessReadableStream._read = () => { };
143
+ const childProcessWritableStream = new Writable();
144
+ (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
145
+
146
+ childProcessStream.stdin = childProcessWritableStream;
147
+ childProcessStream.stderr = childProcessReadableStream;
148
+
149
+ let spawnArgs: string[] = [];
150
+ const spawnStub = child_process.spawn as SinonStub;
151
+ spawnStub.callsFake((_cmd: string, args: string[]) => {
152
+ spawnArgs = args;
153
+ return childProcessStream as ChildProcessWithoutNullStreams;
154
+ });
155
+
156
+ spawnFfmpegVideoWriter({
157
+ audioFilename: 'audio.mp3',
158
+ videoFileName: 'out.mp4',
159
+ fps: 23.976,
160
+ });
161
+
162
+ spawnStub.resetBehavior();
163
+ spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
164
+
165
+ const audioInputIndex = spawnArgs.indexOf('-i');
166
+ expect(audioInputIndex).greaterThan(-1);
167
+ expect(spawnArgs[audioInputIndex + 1]).to.equal('audio.mp3');
168
+ const imagePipeFlagIndex = spawnArgs.indexOf('image2pipe');
169
+ expect(imagePipeFlagIndex).greaterThan(-1);
170
+ expect(spawnArgs[imagePipeFlagIndex - 1]).to.equal('-f');
171
+ const inputFpsIndex = spawnArgs.indexOf('-framerate');
172
+ expect(inputFpsIndex).greaterThan(-1);
173
+ expect(spawnArgs[inputFpsIndex + 1]).to.equal('23.976');
174
+ const pipeInputIndex = spawnArgs.lastIndexOf('-i');
175
+ expect(spawnArgs[pipeInputIndex + 1]).to.equal('-');
176
+ expect(spawnArgs.includes('-shortest')).to.equal(true);
177
+ expect(spawnArgs[spawnArgs.length - 1]).to.equal('out.mp4');
178
+ });
179
+
180
+ it('waitDrain resolves true on drain', async function () {
181
+ const writable = new Writable();
182
+ const waitPromise = waitDrain(writable, undefined, 50);
183
+ setTimeout(() => writable.emit('drain'), 5);
184
+ const isDrained = await waitPromise;
185
+ expect(isDrained).equal(true);
186
+ });
187
+
188
+ it('waitDrain resolves false on timeout', async function () {
189
+ const writable = new Writable();
190
+ const isDrained = await waitDrain(writable, undefined, 5);
191
+ expect(isDrained).equal(false);
192
+ });
193
+
194
+ it('readVideoFrame resolves null on timeout', async function () {
195
+ const readable = new Readable();
196
+ readable._read = () => {};
197
+ const frame = await readVideoFrame(readable, 4, 5);
198
+ expect(frame).equal(null);
199
+ });
200
+
201
+ it('readVideoFrame assembles a frame from multiple chunks', async function () {
202
+ const readable = new Readable();
203
+ readable._read = () => {};
204
+ const framePromise = readVideoFrame(readable, 6, 200);
205
+ await new Promise(resolve => setImmediate(resolve));
206
+ readable.push(Buffer.from([1, 2]));
207
+ await new Promise(resolve => setImmediate(resolve));
208
+ readable.push(Buffer.from([3, 4]));
209
+ await new Promise(resolve => setImmediate(resolve));
210
+ readable.push(Buffer.from([5, 6]));
211
+ const frame = await framePromise;
212
+ expect(!!frame && frame.equals(Buffer.from([1, 2, 3, 4, 5, 6]))).equal(true);
213
+ });
214
+
215
+ it('readVideoFrame does not timeout after a successful chunked read', async function () {
216
+ const errors: any[] = [];
217
+ const originalError = console.error;
218
+ console.error = (...args: any[]) => {
219
+ errors.push(args);
220
+ };
221
+ const readable = new Readable();
222
+ readable._read = () => {};
223
+ try {
224
+ const timeoutMs = 40;
225
+ const framePromise = readVideoFrame(readable, 4, timeoutMs);
226
+ await new Promise(resolve => setImmediate(resolve));
227
+ readable.push(Buffer.from([1, 2]));
228
+ await new Promise(resolve => setImmediate(resolve));
229
+ readable.push(Buffer.from([3, 4]));
230
+ const frame = await framePromise;
231
+ expect(!!frame && frame.equals(Buffer.from([1, 2, 3, 4]))).equal(true);
232
+ await new Promise(resolve => setTimeout(resolve, timeoutMs + 30));
233
+ expect(errors).deep.equal([]);
234
+ } finally {
235
+ console.error = originalError;
236
+ }
237
+ });
238
+
239
+ it('spawnConcatVideoFrameReader regenerates timestamps for auto-edit concat', function () {
240
+ const childProcessReadableStream = new Readable();
241
+ childProcessReadableStream._read = () => { };
242
+ const childProcessWritableStream = new Writable();
243
+ (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
244
+
245
+ childProcessStream.stdin = childProcessWritableStream;
246
+ childProcessStream.stderr = childProcessReadableStream;
247
+
248
+ let spawnArgs: string[] = [];
249
+ const spawnStub = child_process.spawn as SinonStub;
250
+ spawnStub.callsFake((_cmd: string, args: string[]) => {
251
+ spawnArgs = args;
252
+ return childProcessStream as ChildProcessWithoutNullStreams;
253
+ });
254
+
255
+ spawnConcatVideoFrameReader({
256
+ concatFilePath: '/tmp/av-concat.txt',
257
+ fps: 30,
258
+ totalFrames: 90,
259
+ });
260
+
261
+ spawnStub.resetBehavior();
262
+ spawnStub.returns(childProcessStream as ChildProcessWithoutNullStreams);
263
+
264
+ expect(spawnArgs.indexOf('-fflags')).greaterThan(-1);
265
+ expect(spawnArgs[spawnArgs.indexOf('-fflags') + 1]).equal('+genpts');
266
+ expect(spawnArgs.indexOf('-an')).greaterThan(-1);
267
+ expect(spawnArgs[spawnArgs.indexOf('-avoid_negative_ts') + 1]).equal('make_zero');
268
+ });
269
+
270
+ it('waitForProcessExit resolves exit code', async function () {
271
+ const processEmitter = new EventEmitter();
272
+ const waitPromise = waitForProcessExit(processEmitter, 50);
273
+ setTimeout(() => processEmitter.emit('exit', 0), 5);
274
+ const { exitCode, reason } = await waitPromise;
275
+ expect(exitCode).equal(0);
276
+ expect(reason).equal(undefined);
277
+ });
278
+
279
+ it('waitForProcessExit resolves non-zero on timeout', async function () {
280
+ const processEmitter = new EventEmitter();
281
+ const { exitCode, reason } = await waitForProcessExit(processEmitter, 5);
282
+ expect(exitCode).equal(1);
283
+ expect(reason).equal('waitForProcessExit timeout (5ms)');
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
+ });
447
+
448
+ it('buildSequentialVideoSegments cycles through videos one after another', function () {
449
+ const segments = buildSequentialVideoSegments(100, [2, 3], 10);
450
+ expect(segments).deep.equal([
451
+ { outputStartFrame: 0, videoSeekSeconds: 0, frameCount: 20, videoIndex: 0 },
452
+ { outputStartFrame: 20, videoSeekSeconds: 0, frameCount: 30, videoIndex: 1 },
453
+ { outputStartFrame: 50, videoSeekSeconds: 0, frameCount: 20, videoIndex: 0 },
454
+ { outputStartFrame: 70, videoSeekSeconds: 0, frameCount: 30, videoIndex: 1 },
455
+ ]);
456
+ });
457
+
458
+ it('buildSequentialVideoSegments trims the final segment to fit the remaining frames', function () {
459
+ const segments = buildSequentialVideoSegments(45, [2, 3], 10);
460
+ expect(segments).deep.equal([
461
+ { outputStartFrame: 0, videoSeekSeconds: 0, frameCount: 20, videoIndex: 0 },
462
+ { outputStartFrame: 20, videoSeekSeconds: 0, frameCount: 25, videoIndex: 1 },
463
+ ]);
464
+ });
465
+
466
+ it('buildSequentialVideoSegments starts from the given video index', function () {
467
+ const segments = buildSequentialVideoSegments(80, [2, 3, 1], 10, 1);
468
+ expect(segments).deep.equal([
469
+ { outputStartFrame: 0, videoSeekSeconds: 0, frameCount: 30, videoIndex: 1 },
470
+ { outputStartFrame: 30, videoSeekSeconds: 0, frameCount: 10, videoIndex: 2 },
471
+ { outputStartFrame: 40, videoSeekSeconds: 0, frameCount: 20, videoIndex: 0 },
472
+ { outputStartFrame: 60, videoSeekSeconds: 0, frameCount: 20, videoIndex: 1 },
473
+ ]);
474
+ });
475
+
476
+ it('buildSequentialVideoSegments returns empty for no video durations', function () {
477
+ expect(buildSequentialVideoSegments(100, [], 10)).deep.equal([]);
478
+ });
479
+
480
+ it('buildShuffledVideoSegments assigns a single video when only one is given', function () {
481
+ const segments = buildShuffledVideoSegments([10, 20], 30, 1, 30);
482
+ expect(segments).deep.equal([
483
+ { outputStartFrame: 0, videoSeekSeconds: 0, frameCount: 30, videoIndex: 0 },
484
+ ]);
485
+ });
486
+
487
+ it('buildShuffledVideoSegments cuts on the beat grid and never repeats the same video twice in a row', function () {
488
+ const fps = 30;
489
+ const bpm = 90;
490
+ const periodFrames = fps * 60 / bpm;
491
+ const segments = buildShuffledVideoSegments(
492
+ [20, 40, 60, 80, 100, 120, 140, 160],
493
+ 200,
494
+ 3,
495
+ fps,
496
+ { tempo: { bpm, periodFrames, phaseFrame: 0 }, randomFn: () => 0 },
497
+ );
498
+ expect(getCutFrameIndices(segments)).deep.equal([40, 80, 120, 160]);
499
+ for (let i = 1; i < segments.length; i++) {
500
+ expect(segments[i].videoIndex).not.equal(segments[i - 1].videoIndex);
501
+ }
502
+ for (const segment of segments) {
503
+ expect(segment.videoIndex).gte(0);
504
+ expect(segment.videoIndex).lessThan(3);
505
+ }
506
+ });
507
+
508
+ it('buildShuffledVideoSegments cycles through a shuffled bag of every video instead of picking uniformly at random', function () {
509
+ const fps = 30;
510
+ const videoCount = 4;
511
+ const beatFrameIndices = Array.from({ length: 15 }, (_, i) => (i + 1) * 10);
512
+ // Simple seeded PRNG so the shuffle order is deterministic but not degenerate like `() => 0`.
513
+ let seed = 42;
514
+ const randomFn = () => {
515
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
516
+ return seed / 0x7fffffff;
517
+ };
518
+ const segments = buildShuffledVideoSegments(
519
+ beatFrameIndices,
520
+ 200,
521
+ videoCount,
522
+ fps,
523
+ { minCutIntervalSeconds: 0, randomFn },
524
+ );
525
+ expect(segments.length).equal(16);
526
+
527
+ for (let i = 0; i < segments.length; i += videoCount) {
528
+ const bag = segments.slice(i, i + videoCount).map(segment => segment.videoIndex);
529
+ expect(bag.slice().sort()).deep.equal([0, 1, 2, 3]);
530
+ }
531
+ for (let i = 1; i < segments.length; i++) {
532
+ expect(segments[i].videoIndex).not.equal(segments[i - 1].videoIndex);
533
+ }
534
+ });
535
+ });