nodejs-audio-visualizer 5.1.0 → 5.3.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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -80
  3. package/dist/audio.d.ts +13 -13
  4. package/dist/audio.js +128 -128
  5. package/dist/beats.d.ts +39 -39
  6. package/dist/beats.js +220 -220
  7. package/dist/bpmEncoder.d.ts +14 -14
  8. package/dist/bpmEncoder.js +97 -97
  9. package/dist/config.d.ts +78 -77
  10. package/dist/config.js +377 -369
  11. package/dist/config.js.map +1 -1
  12. package/dist/dsp.d.ts +16 -16
  13. package/dist/dsp.js +76 -76
  14. package/dist/highlight.d.ts +66 -34
  15. package/dist/highlight.js +471 -334
  16. package/dist/highlight.js.map +1 -1
  17. package/dist/image.d.ts +97 -92
  18. package/dist/image.js +545 -538
  19. package/dist/image.js.map +1 -1
  20. package/dist/index.d.ts +87 -79
  21. package/dist/index.js +598 -566
  22. package/dist/index.js.map +1 -1
  23. package/dist/subtitleConvert.d.ts +3 -3
  24. package/dist/subtitleConvert.js +61 -61
  25. package/dist/video.d.ts +150 -120
  26. package/dist/video.js +677 -582
  27. package/dist/video.js.map +1 -1
  28. package/dist/waitForEventLoop.d.ts +1 -1
  29. package/dist/waitForEventLoop.js +5 -5
  30. package/package.json +62 -62
  31. package/src/audio.ts +129 -129
  32. package/src/beats.ts +279 -279
  33. package/src/bpmEncoder.ts +97 -97
  34. package/src/config.ts +487 -476
  35. package/src/dsp.ts +95 -95
  36. package/src/highlight.ts +451 -291
  37. package/src/image.ts +707 -700
  38. package/src/index.ts +813 -769
  39. package/src/subtitleConvert.ts +63 -63
  40. package/src/tests/audio.test.ts +102 -102
  41. package/src/tests/beats.test.ts +134 -134
  42. package/src/tests/config.test.ts +534 -496
  43. package/src/tests/dsp.test.ts +24 -24
  44. package/src/tests/highlight.test.ts +261 -178
  45. package/src/tests/image.test.ts +319 -308
  46. package/src/tests/index.test.ts +180 -180
  47. package/src/tests/subtitleConvert.test.ts +23 -23
  48. package/src/tests/video.test.ts +535 -447
  49. package/src/typings.d.ts +28 -28
  50. package/src/video.ts +856 -722
  51. package/src/waitForEventLoop.ts +2 -2
package/src/image.ts CHANGED
@@ -1,700 +1,707 @@
1
- import { RotationAliasName } from './index';
2
- import { decode, BmpDecoder } from 'bmp-js';
3
- import { EncodedBmp } from './bpmEncoder';
4
- import Jimp from 'jimp';
5
- import { SpectrumEffect } from './config';
6
-
7
- export interface Color {
8
- red: number;
9
- green: number;
10
- blue: number;
11
- }
12
-
13
- interface Position {
14
- x: number;
15
- y: number;
16
- }
17
-
18
- interface Size {
19
- width: number;
20
- height: number;
21
- }
22
-
23
- interface MixColorProps {
24
- color: Color;
25
- colorOpacity: number;
26
- backgroundColor: Color;
27
- }
28
-
29
- export const mixValues = (value1: number, value1Opacity: number, value2: number) =>
30
- Math.round(value2 * (1 - value1Opacity) + value1 * value1Opacity);
31
-
32
- export const mixColors = ({
33
- color,
34
- colorOpacity,
35
- backgroundColor,
36
- }: MixColorProps): Color => {
37
- return {
38
- red: mixValues(color.red, colorOpacity, backgroundColor.red),
39
- green: mixValues(color.green, colorOpacity, backgroundColor.green),
40
- blue: mixValues(color.blue, colorOpacity, backgroundColor.blue),
41
- };
42
- };
43
-
44
- const getRectPixelColor = (params: {
45
- imageDstBuffer: EncodedBmp;
46
- pixelIndex: number;
47
- color: Color;
48
- opacity: number;
49
- }) => {
50
- const {
51
- imageDstBuffer,
52
- pixelIndex,
53
- color,
54
- opacity,
55
- } = params;
56
- if (opacity === 1) {
57
- return color;
58
- }
59
-
60
- const backgroundPixelColor: Color = {
61
- blue: imageDstBuffer.data[pixelIndex],
62
- green: imageDstBuffer.data[pixelIndex + 1],
63
- red: imageDstBuffer.data[pixelIndex + 2],
64
- };
65
- const resultColor = mixColors({
66
- color,
67
- colorOpacity: opacity,
68
- backgroundColor: backgroundPixelColor,
69
- });
70
- return resultColor;
71
- };
72
-
73
- interface DrawRectPorps {
74
- imageDstBuffer: EncodedBmp;
75
- position: Position;
76
- size: Size;
77
- color: Color;
78
- opacity: number;
79
- }
80
-
81
- export const drawRect = ({
82
- imageDstBuffer,
83
- position,
84
- size,
85
- color,
86
- opacity,
87
- }: DrawRectPorps) => {
88
- for (let currY = position.y; currY < position.y + size.height; currY++) {
89
- for (let currX = position.x; currX < position.x + size.width; currX++) {
90
- const pixelIndex = imageDstBuffer.shiftPos + currY * imageDstBuffer.rowBytes + currX * 3;
91
- const pixelColor = getRectPixelColor({
92
- imageDstBuffer,
93
- pixelIndex,
94
- color,
95
- opacity,
96
- });
97
- imageDstBuffer.data[pixelIndex] = pixelColor.blue;
98
- imageDstBuffer.data[pixelIndex + 1] = pixelColor.green;
99
- imageDstBuffer.data[pixelIndex + 2] = pixelColor.red;
100
- }
101
- }
102
- };
103
-
104
- interface Edge {
105
- yMin: number;
106
- yMax: number;
107
- x: number;
108
- dx: number;
109
- }
110
-
111
- interface FillRectProps {
112
- imageDstBuffer: EncodedBmp;
113
- points: [Position, Position, Position, Position];
114
- color: Color;
115
- opacity: number;
116
- }
117
-
118
- const buildQuadEdgeTable = (points: [Position, Position, Position, Position]): Edge[] => {
119
- const edges: Edge[] = [];
120
-
121
- for (let i = 0; i < points.length; i++) {
122
- const p1 = points[i];
123
- const p2 = points[(i + 1) % points.length];
124
-
125
- if (p1.y === p2.y) continue; // Skip horizontal edges
126
-
127
- const yMin = Math.min(p1.y, p2.y);
128
- const yMax = Math.max(p1.y, p2.y);
129
- const x = p1.y < p2.y ? p1.x : p2.x;
130
- const dx = (p2.x - p1.x) / (p2.y - p1.y);
131
-
132
- edges.push({ yMin, yMax, x, dx });
133
- }
134
-
135
- return edges.sort((a, b) => a.yMin - b.yMin);
136
- };
137
-
138
- /**
139
- * Fills a quadrilateral (4-sided polygon) using scan-line algorithm
140
- * Only the outer edges are anti-aliased
141
- */
142
- export const fillRect = ({
143
- imageDstBuffer,
144
- points,
145
- color,
146
- opacity,
147
- }: FillRectProps) => {
148
- // Build edge table from all 4 vertices
149
- const edgeTable = buildQuadEdgeTable(points);
150
- if (edgeTable.length === 0) return;
151
-
152
- // Find the bounding box
153
- const yMin = Math.min(...points.map(p => p.y));
154
- const yMax = Math.max(...points.map(p => p.y));
155
-
156
- // Active Edge Table for scan line algorithm
157
- const activeEdges: Edge[] = [];
158
-
159
- // Scan line algorithm: process each horizontal line
160
- for (let y = Math.floor(yMin); y <= Math.ceil(yMax); y++) {
161
- // Add edges that start at this scanline to AET
162
- for (const edge of edgeTable) {
163
- if (Math.floor(edge.yMin) === y) {
164
- activeEdges.push({ ...edge });
165
- }
166
- }
167
-
168
- // Remove edges that end at this scanline from AET
169
- for (let i = activeEdges.length - 1; i >= 0; i--) {
170
- if (Math.ceil(activeEdges[i].yMax) <= y) {
171
- activeEdges.splice(i, 1);
172
- }
173
- }
174
-
175
- // Sort active edges by x coordinate
176
- activeEdges.sort((a, b) => a.x - b.x);
177
-
178
- // Fill pixels between pairs of intersections
179
- for (let i = 0; i < activeEdges.length; i += 2) {
180
- if (i + 1 < activeEdges.length) {
181
- const xStart = activeEdges[i].x;
182
- const xEnd = activeEdges[i + 1].x;
183
-
184
- // Anti-aliased rendering with sub-pixel precision
185
- const xStartFloor = Math.floor(xStart);
186
- const xEndCeil = Math.ceil(xEnd);
187
-
188
- for (let x = xStartFloor; x < xEndCeil; x++) {
189
- const pixelIndex = imageDstBuffer.shiftPos + y * imageDstBuffer.rowBytes + x * 3;
190
-
191
- // Calculate coverage for anti-aliasing
192
- let coverage = 1.0;
193
- if (x === xStartFloor) {
194
- // Left edge pixel - calculate fractional coverage
195
- coverage = Math.min(1.0, 1.0 - (xStart - xStartFloor));
196
- } else if (x === xEndCeil - 1) {
197
- // Right edge pixel - calculate fractional coverage
198
- coverage = Math.min(1.0, xEnd - Math.floor(xEnd));
199
- }
200
-
201
- // Apply coverage to opacity for smooth edges
202
- const effectiveOpacity = opacity * coverage;
203
-
204
- const pixelColor = getRectPixelColor({
205
- imageDstBuffer,
206
- pixelIndex,
207
- color,
208
- opacity: effectiveOpacity,
209
- });
210
- imageDstBuffer.data[pixelIndex] = pixelColor.blue;
211
- imageDstBuffer.data[pixelIndex + 1] = pixelColor.green;
212
- imageDstBuffer.data[pixelIndex + 2] = pixelColor.red;
213
- }
214
- }
215
- }
216
-
217
- // Update x coordinates for next scanline (x' = x + dx)
218
- for (const edge of activeEdges) {
219
- edge.x += edge.dx;
220
- }
221
- }
222
- };
223
-
224
- interface DrawSpectrumProps {
225
- imageDstBuffer: EncodedBmp;
226
- spectrum: number[];
227
- size: Size;
228
- position: Position;
229
- rotation: RotationAliasName;
230
- margin: number;
231
- color: Color;
232
- opacity: number;
233
- }
234
-
235
- const getRectY = (rotation: RotationAliasName, top: number, height: number, rectHeight: number) => {
236
- switch (rotation) {
237
- case 'up':
238
- return top + height - rectHeight;
239
- case 'mirror':
240
- return top + Math.trunc((height - rectHeight) / 2);
241
- default:
242
- return top;
243
- }
244
- };
245
-
246
- const minRectHeight = 2;
247
-
248
- const getRectHeight = (height: number, spectrumValue: number) => {
249
- const rectHeight = Math.trunc(height * spectrumValue);
250
- if (rectHeight < minRectHeight) {
251
- return minRectHeight;
252
- }
253
- return rectHeight;
254
- };
255
-
256
- const drawSpectrum = ({
257
- imageDstBuffer,
258
- spectrum,
259
- size,
260
- position,
261
- rotation,
262
- margin,
263
- color,
264
- opacity,
265
- }: DrawSpectrumProps) => {
266
- const busWidth = Math.trunc(size.width / spectrum.length);
267
- const left = Math.trunc(position.x);
268
- const top = Math.trunc(position.y);
269
- const height = Math.trunc(size.height);
270
-
271
- for (let spectrumX = 0; spectrumX < spectrum.length; spectrumX++) {
272
- const spectrumValue = spectrum[spectrumX];
273
- if ((spectrumValue > 1) || (spectrumValue < 0)) {
274
- throw new Error('Spectrum values must be in range from 0 to 1');
275
- }
276
-
277
- const rectX = left + busWidth * spectrumX;
278
- const rectHeight = getRectHeight(height, spectrumValue);
279
- const rectY = getRectY(rotation, top, height, rectHeight);
280
- drawRect({
281
- imageDstBuffer,
282
- position: { x: rectX + margin, y: rectY },
283
- size: { width: busWidth - margin / 2, height: rectHeight },
284
- color,
285
- opacity,
286
- });
287
- }
288
- };
289
-
290
- const calcCirclePoint = (angleRadians: number, radius: number): Position => {
291
- const circleX = radius * Math.cos(angleRadians);
292
- const circleY = radius * Math.sin(angleRadians);
293
- return { x: circleX, y: circleY };
294
- };
295
-
296
- const lerp = (start: number, end: number, t: number) =>
297
- start + (end - start) * t;
298
-
299
- interface DrawPolarSpectrumProps {
300
- imageDstBuffer: EncodedBmp;
301
- spectrum: number[];
302
- centerX: number;
303
- centerY: number;
304
- innerRadius: number;
305
- maxBarLength: number;
306
- barWidth: number;
307
- color: Color;
308
- opacity: number;
309
- }
310
-
311
- const drawPolarSpectrum = ({
312
- imageDstBuffer,
313
- spectrum,
314
- centerX,
315
- centerY,
316
- innerRadius,
317
- maxBarLength,
318
- barWidth,
319
- color,
320
- opacity,
321
- }: DrawPolarSpectrumProps) => {
322
- const angleStep = (2 * Math.PI) / spectrum.length;
323
- const innerLength = innerRadius * 2 * Math.PI;
324
- const radiansPerPixel = 2 * Math.PI / innerLength;
325
- const barWidthInRadians = radiansPerPixel * barWidth;
326
- const spectrumAverage = spectrum.reduce((a, b) => a + b, 0) / spectrum.length;
327
- const scaleFactor = lerp(0.75, 1, spectrumAverage);
328
- const scaledMaxOuterRadius = (innerRadius + maxBarLength) * scaleFactor;
329
- const scaledInnerRadius = innerRadius * scaleFactor;
330
- for (let i = 0; i < spectrum.length; i++) {
331
- const currentSpectrumValue = spectrum[i] || 0;
332
- const busRadius = lerp(scaledInnerRadius, scaledMaxOuterRadius, currentSpectrumValue);
333
- const angle = i * angleStep;
334
- const angleRadiansStart = angle;
335
- const angleRadiansEnd = angle + barWidthInRadians;
336
-
337
- const point1 = calcCirclePoint(angleRadiansStart, scaledInnerRadius);
338
- const point2 = calcCirclePoint(angleRadiansStart, busRadius);
339
-
340
- const point3 = calcCirclePoint(angleRadiansEnd, scaledInnerRadius);
341
- const point4 = calcCirclePoint(angleRadiansEnd, busRadius);
342
-
343
- fillRect({
344
- imageDstBuffer,
345
- points: [
346
- { x: centerX + Math.round(point1.x), y: centerY + Math.round(point1.y) },
347
- { x: centerX + Math.round(point2.x), y: centerY + Math.round(point2.y) },
348
- { x: centerX + Math.round(point4.x), y: centerY + Math.round(point4.y) },
349
- { x: centerX + Math.round(point3.x), y: centerY + Math.round(point3.y) },
350
- ],
351
- color,
352
- opacity,
353
- });
354
- }
355
- };
356
-
357
- export interface CommonVisualizerFrameProps {
358
- backgroundImageBuffer: EncodedBmp;
359
- spectrum: number[];
360
- }
361
-
362
- export interface CreateVisualizerFrameProps {
363
- size: Size;
364
- position: Position;
365
- rotation: RotationAliasName;
366
- margin: number;
367
- color: Color | string;
368
- opacity: number;
369
- spectrumEffect?: SpectrumEffect;
370
- }
371
-
372
- export interface CreatePolarVisualizerFrameProps {
373
- centerX: number;
374
- centerY: number;
375
- innerRadius: number;
376
- maxBarLength: number;
377
- barWidth: number;
378
- color: Color | string;
379
- opacity: number;
380
- spectrumEffect?: SpectrumEffect;
381
- }
382
-
383
- export const createSpectrumVisualizerFrameGenerator = () => {
384
- let prevSpectrum: number[] | null = null;
385
-
386
- return ({
387
- backgroundImageBuffer,
388
- spectrum,
389
- size,
390
- position,
391
- rotation,
392
- margin,
393
- color,
394
- opacity,
395
- spectrumEffect,
396
- }: CommonVisualizerFrameProps & CreateVisualizerFrameProps) => {
397
- const imageDstBuffer = Object.assign({}, backgroundImageBuffer);
398
- imageDstBuffer.data = Buffer.from(backgroundImageBuffer.data);
399
-
400
- const rgbSpectrumColor = (typeof color === 'string') ? hexToRgb(color) : color;
401
- if (spectrumEffect === 'volume') {
402
- drawSpectrum({
403
- imageDstBuffer,
404
- spectrum,
405
- size,
406
- position: { x: position.x + 2, y: position.y + 2 },
407
- rotation,
408
- margin,
409
- color: rgbSpectrumColor,
410
- opacity: opacity * 0.5,
411
- });
412
- }
413
- if (spectrumEffect === 'smooth' && prevSpectrum) {
414
- drawSpectrum({
415
- imageDstBuffer,
416
- spectrum: prevSpectrum,
417
- size,
418
- position,
419
- rotation,
420
- margin,
421
- color: rgbSpectrumColor,
422
- opacity: opacity * 0.3,
423
- });
424
- }
425
- drawSpectrum({
426
- imageDstBuffer,
427
- spectrum,
428
- size,
429
- position,
430
- rotation,
431
- margin,
432
- color: rgbSpectrumColor,
433
- opacity,
434
- });
435
-
436
- prevSpectrum = spectrum;
437
-
438
- return imageDstBuffer;
439
- };
440
- };
441
-
442
- export const createPolarVisualizerFrameGenerator = () => {
443
- let prevSpectrum: number[] | null = null;
444
-
445
- return ({
446
- backgroundImageBuffer,
447
- spectrum,
448
- centerX,
449
- centerY,
450
- innerRadius,
451
- maxBarLength,
452
- barWidth,
453
- color,
454
- opacity,
455
- spectrumEffect,
456
- }: CommonVisualizerFrameProps & CreatePolarVisualizerFrameProps) => {
457
- const imageDstBuffer = Object.assign({}, backgroundImageBuffer);
458
- imageDstBuffer.data = Buffer.from(backgroundImageBuffer.data);
459
-
460
- const rgbSpectrumColor = (typeof color === 'string') ? hexToRgb(color) : color;
461
-
462
- if (spectrumEffect === 'volume') {
463
- drawPolarSpectrum({
464
- imageDstBuffer,
465
- spectrum,
466
- centerX: centerX + 2,
467
- centerY: centerY + 2,
468
- innerRadius,
469
- maxBarLength,
470
- barWidth,
471
- color: rgbSpectrumColor,
472
- opacity: opacity * 0.5,
473
- });
474
- }
475
-
476
- if (spectrumEffect === 'smooth' && prevSpectrum) {
477
- drawPolarSpectrum({
478
- imageDstBuffer,
479
- spectrum: prevSpectrum,
480
- centerX,
481
- centerY,
482
- innerRadius,
483
- maxBarLength,
484
- barWidth,
485
- color: rgbSpectrumColor,
486
- opacity: opacity * 0.3,
487
- });
488
- }
489
-
490
- drawPolarSpectrum({
491
- imageDstBuffer,
492
- spectrum,
493
- centerX,
494
- centerY,
495
- innerRadius,
496
- maxBarLength,
497
- barWidth,
498
- color: rgbSpectrumColor,
499
- opacity,
500
- });
501
-
502
- prevSpectrum = spectrum;
503
-
504
- return imageDstBuffer;
505
- };
506
- };
507
-
508
- export const convertToBmp = async (filePath: string, targetWidth?: number, targetHeight?: number) =>
509
- new Promise<Buffer>(async (resolve, reject) => {
510
- try {
511
- const image = await Jimp.read(filePath);
512
- if (targetWidth && targetHeight) {
513
- const sw = image.getWidth();
514
- const sh = image.getHeight();
515
- const isOrientationMismatch =
516
- (sw > sh && targetWidth < targetHeight) ||
517
- (sw < sh && targetWidth > targetHeight);
518
-
519
- if (isOrientationMismatch) {
520
- const targetAspect = targetWidth / targetHeight;
521
- let cropW: number, cropH: number, cropX: number, cropY: number;
522
- if (sw / sh > targetAspect) {
523
- cropW = Math.round(sh * targetAspect);
524
- cropH = sh;
525
- cropX = Math.round((sw - cropW) / 2);
526
- cropY = 0;
527
- } else {
528
- cropW = sw;
529
- cropH = Math.round(sw / targetAspect);
530
- cropX = 0;
531
- cropY = Math.round((sh - cropH) / 2);
532
- }
533
- image.crop(cropX, cropY, cropW, cropH);
534
- }
535
-
536
- image.resize(targetWidth, targetHeight);
537
- }
538
- image.getBuffer("image/bmp", (err, value) => {
539
- if (err) {
540
- reject(err);
541
- }
542
- resolve(value);
543
- });
544
- } catch {
545
- throw new Error(`Cannot procces image file from path: ${filePath}`);
546
- }
547
- });
548
-
549
- export const parseImage = (buffer: Buffer) => decode(buffer);
550
-
551
- export const getImageColor = (image: BmpDecoder): Color => {
552
- let blueSum = 0;
553
- let greenSum = 0;
554
- let redSum = 0;
555
-
556
- for (let currY = 0; currY < image.height; currY++) {
557
- for (let currX = 0; currX < image.width; currX++) {
558
- const idx = (image.width * currY + currX) << 2;
559
-
560
- blueSum += image.data[idx + 1];
561
- greenSum += image.data[idx + 2];
562
- redSum += image.data[idx + 3];
563
- }
564
- }
565
-
566
- const pixelsCount = image.width * image.height;
567
- return {
568
- red: ~~(redSum / pixelsCount),
569
- green: ~~(greenSum / pixelsCount),
570
- blue: ~~(blueSum / pixelsCount)
571
- };
572
- };
573
-
574
- export const getVideoFrameColor = (bgrBuffer: Buffer, width: number, height: number): Color => {
575
- let blueSum = 0;
576
- let greenSum = 0;
577
- let redSum = 0;
578
- const pixelsCount = width * height;
579
-
580
- for (let i = 0; i < bgrBuffer.length; i += 3) {
581
- blueSum += bgrBuffer[i];
582
- greenSum += bgrBuffer[i + 1];
583
- redSum += bgrBuffer[i + 2];
584
- }
585
-
586
- return {
587
- red: ~~(redSum / pixelsCount),
588
- green: ~~(greenSum / pixelsCount),
589
- blue: ~~(blueSum / pixelsCount),
590
- };
591
- };
592
-
593
- export const invertColor = (color: Color) =>
594
- ({ red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue });
595
-
596
- /** Pixel shift strength for the cut frame and the frames after it. */
597
- export const CUT_SHAKE_AMPLITUDE_BY_OFFSET = [14, 9, 5, 3, 1] as const;
598
- export const CUT_SHAKE_PEAK_AMPLITUDE = 14;
599
- export const MIN_CUT_SHAKE_FRAMES = 3;
600
- export const SHAKE_DURATION_BEAT_FRACTION = 1 / 3;
601
- /** Camera shake fires on every Nth beat of the tempo grid (half a bar of 4/4). */
602
- export const CAMERA_SHAKE_EVERY_BEATS = 2;
603
-
604
- export const buildCutShakeAmplitudes = (periodFrames: number): number[] => {
605
- if (!(periodFrames > 0)) {
606
- return Array.from(CUT_SHAKE_AMPLITUDE_BY_OFFSET);
607
- }
608
- const frameCount = Math.max(
609
- MIN_CUT_SHAKE_FRAMES,
610
- Math.round(periodFrames * SHAKE_DURATION_BEAT_FRACTION),
611
- );
612
- if (frameCount === 1) {
613
- return [CUT_SHAKE_PEAK_AMPLITUDE];
614
- }
615
- const decayTo = 1 / CUT_SHAKE_PEAK_AMPLITUDE;
616
- const amplitudes: number[] = [];
617
- for (let i = 0; i < frameCount; i++) {
618
- const t = i / (frameCount - 1);
619
- amplitudes.push(Math.max(1, Math.round(CUT_SHAKE_PEAK_AMPLITUDE * Math.pow(decayTo, t))));
620
- }
621
- return amplitudes;
622
- };
623
-
624
- const clampInt = (value: number, min: number, max: number) => {
625
- if (value < min) {
626
- return min;
627
- }
628
- if (value > max) {
629
- return max;
630
- }
631
- return value;
632
- };
633
-
634
- export const getCutShakeOffset = (
635
- frameIndex: number,
636
- cutFrames: ReadonlySet<number>,
637
- amplitudes: ReadonlyArray<number> = CUT_SHAKE_AMPLITUDE_BY_OFFSET,
638
- ): { x: number; y: number } => {
639
- if (amplitudes.length === 0) {
640
- return { x: 0, y: 0 };
641
- }
642
- let framesAfterCut = -1;
643
- let originCut = -1;
644
- for (let offset = 0; offset < amplitudes.length; offset++) {
645
- const cut = frameIndex - offset;
646
- if (cutFrames.has(cut) && (framesAfterCut === -1 || offset < framesAfterCut)) {
647
- framesAfterCut = offset;
648
- originCut = cut;
649
- }
650
- }
651
- if (framesAfterCut === -1) {
652
- return { x: 0, y: 0 };
653
- }
654
- const amplitude = amplitudes[framesAfterCut];
655
- const angle = originCut * 2.399 + framesAfterCut * 2.1;
656
- return {
657
- x: Math.round(Math.cos(angle) * amplitude),
658
- y: Math.round(Math.sin(angle) * amplitude),
659
- };
660
- };
661
-
662
- export const applyCameraShake = (
663
- imageDstBuffer: EncodedBmp,
664
- width: number,
665
- height: number,
666
- offsetX: number,
667
- offsetY: number,
668
- ) => {
669
- const ox = Math.round(offsetX);
670
- const oy = Math.round(offsetY);
671
- if (ox === 0 && oy === 0) {
672
- return;
673
- }
674
- const { shiftPos, rowBytes, data } = imageDstBuffer;
675
- const pixelBytes = height * rowBytes;
676
- const sourcePixels = Buffer.allocUnsafe(pixelBytes);
677
- data.copy(sourcePixels, 0, shiftPos, shiftPos + pixelBytes);
678
-
679
- for (let y = 0; y < height; y++) {
680
- const srcY = clampInt(y - oy, 0, height - 1);
681
- const dstRow = shiftPos + y * rowBytes;
682
- const srcRow = srcY * rowBytes;
683
- for (let x = 0; x < width; x++) {
684
- const srcX = clampInt(x - ox, 0, width - 1);
685
- const dstIndex = dstRow + x * 3;
686
- const srcIndex = srcRow + srcX * 3;
687
- data[dstIndex] = sourcePixels[srcIndex];
688
- data[dstIndex + 1] = sourcePixels[srcIndex + 1];
689
- data[dstIndex + 2] = sourcePixels[srcIndex + 2];
690
- }
691
- }
692
- };
693
-
694
- const hexToRgb = (hex: String) => {
695
- const red = parseInt(hex.substring(1, 3), 16);
696
- const green = parseInt(hex.substring(3, 5), 16);
697
- const blue = parseInt(hex.substring(5, 7), 16);
698
-
699
- return { red, green, blue };
700
- };
1
+ import { RotationAliasName } from './index';
2
+ import { decode, BmpDecoder } from 'bmp-js';
3
+ import { EncodedBmp } from './bpmEncoder';
4
+ import Jimp from 'jimp';
5
+ import { SpectrumEffect } from './config';
6
+
7
+ export interface Color {
8
+ red: number;
9
+ green: number;
10
+ blue: number;
11
+ }
12
+
13
+ interface Position {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ interface Size {
19
+ width: number;
20
+ height: number;
21
+ }
22
+
23
+ interface MixColorProps {
24
+ color: Color;
25
+ colorOpacity: number;
26
+ backgroundColor: Color;
27
+ }
28
+
29
+ export const mixValues = (value1: number, value1Opacity: number, value2: number) =>
30
+ Math.round(value2 * (1 - value1Opacity) + value1 * value1Opacity);
31
+
32
+ export const mixColors = ({
33
+ color,
34
+ colorOpacity,
35
+ backgroundColor,
36
+ }: MixColorProps): Color => {
37
+ return {
38
+ red: mixValues(color.red, colorOpacity, backgroundColor.red),
39
+ green: mixValues(color.green, colorOpacity, backgroundColor.green),
40
+ blue: mixValues(color.blue, colorOpacity, backgroundColor.blue),
41
+ };
42
+ };
43
+
44
+ const getRectPixelColor = (params: {
45
+ imageDstBuffer: EncodedBmp;
46
+ pixelIndex: number;
47
+ color: Color;
48
+ opacity: number;
49
+ }) => {
50
+ const {
51
+ imageDstBuffer,
52
+ pixelIndex,
53
+ color,
54
+ opacity,
55
+ } = params;
56
+ if (opacity === 1) {
57
+ return color;
58
+ }
59
+
60
+ const backgroundPixelColor: Color = {
61
+ blue: imageDstBuffer.data[pixelIndex],
62
+ green: imageDstBuffer.data[pixelIndex + 1],
63
+ red: imageDstBuffer.data[pixelIndex + 2],
64
+ };
65
+ const resultColor = mixColors({
66
+ color,
67
+ colorOpacity: opacity,
68
+ backgroundColor: backgroundPixelColor,
69
+ });
70
+ return resultColor;
71
+ };
72
+
73
+ interface DrawRectPorps {
74
+ imageDstBuffer: EncodedBmp;
75
+ position: Position;
76
+ size: Size;
77
+ color: Color;
78
+ opacity: number;
79
+ }
80
+
81
+ export const drawRect = ({
82
+ imageDstBuffer,
83
+ position,
84
+ size,
85
+ color,
86
+ opacity,
87
+ }: DrawRectPorps) => {
88
+ for (let currY = position.y; currY < position.y + size.height; currY++) {
89
+ for (let currX = position.x; currX < position.x + size.width; currX++) {
90
+ const pixelIndex = imageDstBuffer.shiftPos + currY * imageDstBuffer.rowBytes + currX * 3;
91
+ const pixelColor = getRectPixelColor({
92
+ imageDstBuffer,
93
+ pixelIndex,
94
+ color,
95
+ opacity,
96
+ });
97
+ imageDstBuffer.data[pixelIndex] = pixelColor.blue;
98
+ imageDstBuffer.data[pixelIndex + 1] = pixelColor.green;
99
+ imageDstBuffer.data[pixelIndex + 2] = pixelColor.red;
100
+ }
101
+ }
102
+ };
103
+
104
+ interface Edge {
105
+ yMin: number;
106
+ yMax: number;
107
+ x: number;
108
+ dx: number;
109
+ }
110
+
111
+ interface FillRectProps {
112
+ imageDstBuffer: EncodedBmp;
113
+ points: [Position, Position, Position, Position];
114
+ color: Color;
115
+ opacity: number;
116
+ }
117
+
118
+ const buildQuadEdgeTable = (points: [Position, Position, Position, Position]): Edge[] => {
119
+ const edges: Edge[] = [];
120
+
121
+ for (let i = 0; i < points.length; i++) {
122
+ const p1 = points[i];
123
+ const p2 = points[(i + 1) % points.length];
124
+
125
+ if (p1.y === p2.y) continue; // Skip horizontal edges
126
+
127
+ const yMin = Math.min(p1.y, p2.y);
128
+ const yMax = Math.max(p1.y, p2.y);
129
+ const x = p1.y < p2.y ? p1.x : p2.x;
130
+ const dx = (p2.x - p1.x) / (p2.y - p1.y);
131
+
132
+ edges.push({ yMin, yMax, x, dx });
133
+ }
134
+
135
+ return edges.sort((a, b) => a.yMin - b.yMin);
136
+ };
137
+
138
+ /**
139
+ * Fills a quadrilateral (4-sided polygon) using scan-line algorithm
140
+ * Only the outer edges are anti-aliased
141
+ */
142
+ export const fillRect = ({
143
+ imageDstBuffer,
144
+ points,
145
+ color,
146
+ opacity,
147
+ }: FillRectProps) => {
148
+ // Build edge table from all 4 vertices
149
+ const edgeTable = buildQuadEdgeTable(points);
150
+ if (edgeTable.length === 0) return;
151
+
152
+ // Find the bounding box
153
+ const yMin = Math.min(...points.map(p => p.y));
154
+ const yMax = Math.max(...points.map(p => p.y));
155
+
156
+ // Active Edge Table for scan line algorithm
157
+ const activeEdges: Edge[] = [];
158
+
159
+ // Scan line algorithm: process each horizontal line
160
+ for (let y = Math.floor(yMin); y <= Math.ceil(yMax); y++) {
161
+ // Add edges that start at this scanline to AET
162
+ for (const edge of edgeTable) {
163
+ if (Math.floor(edge.yMin) === y) {
164
+ activeEdges.push({ ...edge });
165
+ }
166
+ }
167
+
168
+ // Remove edges that end at this scanline from AET
169
+ for (let i = activeEdges.length - 1; i >= 0; i--) {
170
+ if (Math.ceil(activeEdges[i].yMax) <= y) {
171
+ activeEdges.splice(i, 1);
172
+ }
173
+ }
174
+
175
+ // Sort active edges by x coordinate
176
+ activeEdges.sort((a, b) => a.x - b.x);
177
+
178
+ // Fill pixels between pairs of intersections
179
+ for (let i = 0; i < activeEdges.length; i += 2) {
180
+ if (i + 1 < activeEdges.length) {
181
+ const xStart = activeEdges[i].x;
182
+ const xEnd = activeEdges[i + 1].x;
183
+
184
+ // Anti-aliased rendering with sub-pixel precision
185
+ const xStartFloor = Math.floor(xStart);
186
+ const xEndCeil = Math.ceil(xEnd);
187
+
188
+ for (let x = xStartFloor; x < xEndCeil; x++) {
189
+ const pixelIndex = imageDstBuffer.shiftPos + y * imageDstBuffer.rowBytes + x * 3;
190
+
191
+ // Calculate coverage for anti-aliasing
192
+ let coverage = 1.0;
193
+ if (x === xStartFloor) {
194
+ // Left edge pixel - calculate fractional coverage
195
+ coverage = Math.min(1.0, 1.0 - (xStart - xStartFloor));
196
+ } else if (x === xEndCeil - 1) {
197
+ // Right edge pixel - calculate fractional coverage
198
+ coverage = Math.min(1.0, xEnd - Math.floor(xEnd));
199
+ }
200
+
201
+ // Apply coverage to opacity for smooth edges
202
+ const effectiveOpacity = opacity * coverage;
203
+
204
+ const pixelColor = getRectPixelColor({
205
+ imageDstBuffer,
206
+ pixelIndex,
207
+ color,
208
+ opacity: effectiveOpacity,
209
+ });
210
+ imageDstBuffer.data[pixelIndex] = pixelColor.blue;
211
+ imageDstBuffer.data[pixelIndex + 1] = pixelColor.green;
212
+ imageDstBuffer.data[pixelIndex + 2] = pixelColor.red;
213
+ }
214
+ }
215
+ }
216
+
217
+ // Update x coordinates for next scanline (x' = x + dx)
218
+ for (const edge of activeEdges) {
219
+ edge.x += edge.dx;
220
+ }
221
+ }
222
+ };
223
+
224
+ interface DrawSpectrumProps {
225
+ imageDstBuffer: EncodedBmp;
226
+ spectrum: number[];
227
+ size: Size;
228
+ position: Position;
229
+ rotation: RotationAliasName;
230
+ margin: number;
231
+ color: Color;
232
+ opacity: number;
233
+ }
234
+
235
+ const getRectY = (rotation: RotationAliasName, top: number, height: number, rectHeight: number) => {
236
+ switch (rotation) {
237
+ case 'up':
238
+ return top + height - rectHeight;
239
+ case 'mirror':
240
+ return top + Math.trunc((height - rectHeight) / 2);
241
+ default:
242
+ return top;
243
+ }
244
+ };
245
+
246
+ const minRectHeight = 2;
247
+
248
+ const getRectHeight = (height: number, spectrumValue: number) => {
249
+ const rectHeight = Math.trunc(height * spectrumValue);
250
+ if (rectHeight < minRectHeight) {
251
+ return minRectHeight;
252
+ }
253
+ return rectHeight;
254
+ };
255
+
256
+ const drawSpectrum = ({
257
+ imageDstBuffer,
258
+ spectrum,
259
+ size,
260
+ position,
261
+ rotation,
262
+ margin,
263
+ color,
264
+ opacity,
265
+ }: DrawSpectrumProps) => {
266
+ const busWidth = Math.trunc(size.width / spectrum.length);
267
+ const left = Math.trunc(position.x);
268
+ const top = Math.trunc(position.y);
269
+ const height = Math.trunc(size.height);
270
+
271
+ for (let spectrumX = 0; spectrumX < spectrum.length; spectrumX++) {
272
+ const spectrumValue = spectrum[spectrumX];
273
+ if ((spectrumValue > 1) || (spectrumValue < 0)) {
274
+ throw new Error('Spectrum values must be in range from 0 to 1');
275
+ }
276
+
277
+ const rectX = left + busWidth * spectrumX;
278
+ const rectHeight = getRectHeight(height, spectrumValue);
279
+ const rectY = getRectY(rotation, top, height, rectHeight);
280
+ drawRect({
281
+ imageDstBuffer,
282
+ position: { x: rectX + margin, y: rectY },
283
+ size: { width: busWidth - margin / 2, height: rectHeight },
284
+ color,
285
+ opacity,
286
+ });
287
+ }
288
+ };
289
+
290
+ const calcCirclePoint = (angleRadians: number, radius: number): Position => {
291
+ const circleX = radius * Math.cos(angleRadians);
292
+ const circleY = radius * Math.sin(angleRadians);
293
+ return { x: circleX, y: circleY };
294
+ };
295
+
296
+ const lerp = (start: number, end: number, t: number) =>
297
+ start + (end - start) * t;
298
+
299
+ interface DrawPolarSpectrumProps {
300
+ imageDstBuffer: EncodedBmp;
301
+ spectrum: number[];
302
+ centerX: number;
303
+ centerY: number;
304
+ innerRadius: number;
305
+ maxBarLength: number;
306
+ barWidth: number;
307
+ color: Color;
308
+ opacity: number;
309
+ }
310
+
311
+ const drawPolarSpectrum = ({
312
+ imageDstBuffer,
313
+ spectrum,
314
+ centerX,
315
+ centerY,
316
+ innerRadius,
317
+ maxBarLength,
318
+ barWidth,
319
+ color,
320
+ opacity,
321
+ }: DrawPolarSpectrumProps) => {
322
+ const angleStep = (2 * Math.PI) / spectrum.length;
323
+ const innerLength = innerRadius * 2 * Math.PI;
324
+ const radiansPerPixel = 2 * Math.PI / innerLength;
325
+ const barWidthInRadians = radiansPerPixel * barWidth;
326
+ const spectrumAverage = spectrum.reduce((a, b) => a + b, 0) / spectrum.length;
327
+ const scaleFactor = lerp(0.75, 1, spectrumAverage);
328
+ const scaledMaxOuterRadius = (innerRadius + maxBarLength) * scaleFactor;
329
+ const scaledInnerRadius = innerRadius * scaleFactor;
330
+ for (let i = 0; i < spectrum.length; i++) {
331
+ const currentSpectrumValue = spectrum[i] || 0;
332
+ const busRadius = lerp(scaledInnerRadius, scaledMaxOuterRadius, currentSpectrumValue);
333
+ const angle = i * angleStep;
334
+ const angleRadiansStart = angle;
335
+ const angleRadiansEnd = angle + barWidthInRadians;
336
+
337
+ const point1 = calcCirclePoint(angleRadiansStart, scaledInnerRadius);
338
+ const point2 = calcCirclePoint(angleRadiansStart, busRadius);
339
+
340
+ const point3 = calcCirclePoint(angleRadiansEnd, scaledInnerRadius);
341
+ const point4 = calcCirclePoint(angleRadiansEnd, busRadius);
342
+
343
+ fillRect({
344
+ imageDstBuffer,
345
+ points: [
346
+ { x: centerX + Math.round(point1.x), y: centerY + Math.round(point1.y) },
347
+ { x: centerX + Math.round(point2.x), y: centerY + Math.round(point2.y) },
348
+ { x: centerX + Math.round(point4.x), y: centerY + Math.round(point4.y) },
349
+ { x: centerX + Math.round(point3.x), y: centerY + Math.round(point3.y) },
350
+ ],
351
+ color,
352
+ opacity,
353
+ });
354
+ }
355
+ };
356
+
357
+ export interface CommonVisualizerFrameProps {
358
+ backgroundImageBuffer: EncodedBmp;
359
+ spectrum: number[];
360
+ }
361
+
362
+ export interface CreateVisualizerFrameProps {
363
+ size: Size;
364
+ position: Position;
365
+ rotation: RotationAliasName;
366
+ margin: number;
367
+ color: Color | string;
368
+ opacity: number;
369
+ spectrumEffect?: SpectrumEffect;
370
+ }
371
+
372
+ export interface CreatePolarVisualizerFrameProps {
373
+ centerX: number;
374
+ centerY: number;
375
+ innerRadius: number;
376
+ maxBarLength: number;
377
+ barWidth: number;
378
+ color: Color | string;
379
+ opacity: number;
380
+ spectrumEffect?: SpectrumEffect;
381
+ }
382
+
383
+ export const createSpectrumVisualizerFrameGenerator = () => {
384
+ let prevSpectrum: number[] | null = null;
385
+
386
+ return ({
387
+ backgroundImageBuffer,
388
+ spectrum,
389
+ size,
390
+ position,
391
+ rotation,
392
+ margin,
393
+ color,
394
+ opacity,
395
+ spectrumEffect,
396
+ }: CommonVisualizerFrameProps & CreateVisualizerFrameProps) => {
397
+ const imageDstBuffer = Object.assign({}, backgroundImageBuffer);
398
+ imageDstBuffer.data = Buffer.from(backgroundImageBuffer.data);
399
+
400
+ const rgbSpectrumColor = (typeof color === 'string') ? hexToRgb(color) : color;
401
+ if (spectrumEffect === 'volume') {
402
+ drawSpectrum({
403
+ imageDstBuffer,
404
+ spectrum,
405
+ size,
406
+ position: { x: position.x + 2, y: position.y + 2 },
407
+ rotation,
408
+ margin,
409
+ color: rgbSpectrumColor,
410
+ opacity: opacity * 0.5,
411
+ });
412
+ }
413
+ if (spectrumEffect === 'smooth' && prevSpectrum) {
414
+ drawSpectrum({
415
+ imageDstBuffer,
416
+ spectrum: prevSpectrum,
417
+ size,
418
+ position,
419
+ rotation,
420
+ margin,
421
+ color: rgbSpectrumColor,
422
+ opacity: opacity * 0.3,
423
+ });
424
+ }
425
+ drawSpectrum({
426
+ imageDstBuffer,
427
+ spectrum,
428
+ size,
429
+ position,
430
+ rotation,
431
+ margin,
432
+ color: rgbSpectrumColor,
433
+ opacity,
434
+ });
435
+
436
+ prevSpectrum = spectrum;
437
+
438
+ return imageDstBuffer;
439
+ };
440
+ };
441
+
442
+ export const createPolarVisualizerFrameGenerator = () => {
443
+ let prevSpectrum: number[] | null = null;
444
+
445
+ return ({
446
+ backgroundImageBuffer,
447
+ spectrum,
448
+ centerX,
449
+ centerY,
450
+ innerRadius,
451
+ maxBarLength,
452
+ barWidth,
453
+ color,
454
+ opacity,
455
+ spectrumEffect,
456
+ }: CommonVisualizerFrameProps & CreatePolarVisualizerFrameProps) => {
457
+ const imageDstBuffer = Object.assign({}, backgroundImageBuffer);
458
+ imageDstBuffer.data = Buffer.from(backgroundImageBuffer.data);
459
+
460
+ const rgbSpectrumColor = (typeof color === 'string') ? hexToRgb(color) : color;
461
+
462
+ if (spectrumEffect === 'volume') {
463
+ drawPolarSpectrum({
464
+ imageDstBuffer,
465
+ spectrum,
466
+ centerX: centerX + 2,
467
+ centerY: centerY + 2,
468
+ innerRadius,
469
+ maxBarLength,
470
+ barWidth,
471
+ color: rgbSpectrumColor,
472
+ opacity: opacity * 0.5,
473
+ });
474
+ }
475
+
476
+ if (spectrumEffect === 'smooth' && prevSpectrum) {
477
+ drawPolarSpectrum({
478
+ imageDstBuffer,
479
+ spectrum: prevSpectrum,
480
+ centerX,
481
+ centerY,
482
+ innerRadius,
483
+ maxBarLength,
484
+ barWidth,
485
+ color: rgbSpectrumColor,
486
+ opacity: opacity * 0.3,
487
+ });
488
+ }
489
+
490
+ drawPolarSpectrum({
491
+ imageDstBuffer,
492
+ spectrum,
493
+ centerX,
494
+ centerY,
495
+ innerRadius,
496
+ maxBarLength,
497
+ barWidth,
498
+ color: rgbSpectrumColor,
499
+ opacity,
500
+ });
501
+
502
+ prevSpectrum = spectrum;
503
+
504
+ return imageDstBuffer;
505
+ };
506
+ };
507
+
508
+ export const convertToBmp = async (filePath: string, targetWidth?: number, targetHeight?: number) =>
509
+ new Promise<Buffer>(async (resolve, reject) => {
510
+ try {
511
+ const image = await Jimp.read(filePath);
512
+ if (targetWidth && targetHeight) {
513
+ const sw = image.getWidth();
514
+ const sh = image.getHeight();
515
+ const isOrientationMismatch =
516
+ (sw > sh && targetWidth < targetHeight) ||
517
+ (sw < sh && targetWidth > targetHeight);
518
+
519
+ if (isOrientationMismatch) {
520
+ const targetAspect = targetWidth / targetHeight;
521
+ let cropW: number, cropH: number, cropX: number, cropY: number;
522
+ if (sw / sh > targetAspect) {
523
+ cropW = Math.round(sh * targetAspect);
524
+ cropH = sh;
525
+ cropX = Math.round((sw - cropW) / 2);
526
+ cropY = 0;
527
+ } else {
528
+ cropW = sw;
529
+ cropH = Math.round(sw / targetAspect);
530
+ cropX = 0;
531
+ cropY = Math.round((sh - cropH) / 2);
532
+ }
533
+ image.crop(cropX, cropY, cropW, cropH);
534
+ }
535
+
536
+ image.resize(targetWidth, targetHeight);
537
+ }
538
+ image.getBuffer("image/bmp", (err, value) => {
539
+ if (err) {
540
+ reject(err);
541
+ }
542
+ resolve(value);
543
+ });
544
+ } catch {
545
+ throw new Error(`Cannot procces image file from path: ${filePath}`);
546
+ }
547
+ });
548
+
549
+ export const parseImage = (buffer: Buffer) => decode(buffer);
550
+
551
+ export const getImageColor = (image: BmpDecoder): Color => {
552
+ let blueSum = 0;
553
+ let greenSum = 0;
554
+ let redSum = 0;
555
+
556
+ for (let currY = 0; currY < image.height; currY++) {
557
+ for (let currX = 0; currX < image.width; currX++) {
558
+ const idx = (image.width * currY + currX) << 2;
559
+
560
+ blueSum += image.data[idx + 1];
561
+ greenSum += image.data[idx + 2];
562
+ redSum += image.data[idx + 3];
563
+ }
564
+ }
565
+
566
+ const pixelsCount = image.width * image.height;
567
+ return {
568
+ red: ~~(redSum / pixelsCount),
569
+ green: ~~(greenSum / pixelsCount),
570
+ blue: ~~(blueSum / pixelsCount)
571
+ };
572
+ };
573
+
574
+ export const getVideoFrameColor = (bgrBuffer: Buffer, width: number, height: number): Color => {
575
+ let blueSum = 0;
576
+ let greenSum = 0;
577
+ let redSum = 0;
578
+ const pixelsCount = width * height;
579
+
580
+ for (let i = 0; i < bgrBuffer.length; i += 3) {
581
+ blueSum += bgrBuffer[i];
582
+ greenSum += bgrBuffer[i + 1];
583
+ redSum += bgrBuffer[i + 2];
584
+ }
585
+
586
+ return {
587
+ red: ~~(redSum / pixelsCount),
588
+ green: ~~(greenSum / pixelsCount),
589
+ blue: ~~(blueSum / pixelsCount),
590
+ };
591
+ };
592
+
593
+ export const invertColor = (color: Color) =>
594
+ ({ red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue });
595
+
596
+ /** Pixel shift strength for the cut frame and the frames after it. */
597
+ export const CUT_SHAKE_AMPLITUDE_BY_OFFSET = [14, 9, 5, 3, 1] as const;
598
+ export const CUT_SHAKE_PEAK_AMPLITUDE = 14;
599
+ export const MIN_CUT_SHAKE_FRAMES = 3;
600
+ export const SHAKE_DURATION_BEAT_FRACTION = 1 / 3;
601
+ /** Camera shake starts this many seconds after the cut instead of on the cut frame. */
602
+ export const CAMERA_SHAKE_DELAY_SECONDS = 0.1;
603
+ /**
604
+ * When auto-edit produces no cuts (e.g. a single continuous-shot video with no detected
605
+ * scene changes), camera shake falls back to every Nth beat of the tempo grid instead.
606
+ */
607
+ export const CAMERA_SHAKE_EVERY_BEATS = 2;
608
+
609
+ export const buildCutShakeAmplitudes = (periodFrames: number): number[] => {
610
+ if (!(periodFrames > 0)) {
611
+ return Array.from(CUT_SHAKE_AMPLITUDE_BY_OFFSET);
612
+ }
613
+ const frameCount = Math.max(
614
+ MIN_CUT_SHAKE_FRAMES,
615
+ Math.round(periodFrames * SHAKE_DURATION_BEAT_FRACTION),
616
+ );
617
+ if (frameCount === 1) {
618
+ return [CUT_SHAKE_PEAK_AMPLITUDE];
619
+ }
620
+ const decayTo = 1 / CUT_SHAKE_PEAK_AMPLITUDE;
621
+ const amplitudes: number[] = [];
622
+ for (let i = 0; i < frameCount; i++) {
623
+ const t = i / (frameCount - 1);
624
+ amplitudes.push(Math.max(1, Math.round(CUT_SHAKE_PEAK_AMPLITUDE * Math.pow(decayTo, t))));
625
+ }
626
+ return amplitudes;
627
+ };
628
+
629
+ const clampInt = (value: number, min: number, max: number) => {
630
+ if (value < min) {
631
+ return min;
632
+ }
633
+ if (value > max) {
634
+ return max;
635
+ }
636
+ return value;
637
+ };
638
+
639
+ export const getCutShakeOffset = (
640
+ frameIndex: number,
641
+ cutFrames: ReadonlySet<number>,
642
+ amplitudes: ReadonlyArray<number> = CUT_SHAKE_AMPLITUDE_BY_OFFSET,
643
+ delayFrames = 0,
644
+ ): { x: number; y: number } => {
645
+ if (amplitudes.length === 0) {
646
+ return { x: 0, y: 0 };
647
+ }
648
+ const delayedFrameIndex = frameIndex - delayFrames;
649
+ let framesAfterCut = -1;
650
+ let originCut = -1;
651
+ for (let offset = 0; offset < amplitudes.length; offset++) {
652
+ const cut = delayedFrameIndex - offset;
653
+ if (cutFrames.has(cut) && (framesAfterCut === -1 || offset < framesAfterCut)) {
654
+ framesAfterCut = offset;
655
+ originCut = cut;
656
+ }
657
+ }
658
+ if (framesAfterCut === -1) {
659
+ return { x: 0, y: 0 };
660
+ }
661
+ const amplitude = amplitudes[framesAfterCut];
662
+ const angle = originCut * 2.399 + framesAfterCut * 2.1;
663
+ return {
664
+ x: Math.round(Math.cos(angle) * amplitude),
665
+ y: Math.round(Math.sin(angle) * amplitude),
666
+ };
667
+ };
668
+
669
+ export const applyCameraShake = (
670
+ imageDstBuffer: EncodedBmp,
671
+ width: number,
672
+ height: number,
673
+ offsetX: number,
674
+ offsetY: number,
675
+ ) => {
676
+ const ox = Math.round(offsetX);
677
+ const oy = Math.round(offsetY);
678
+ if (ox === 0 && oy === 0) {
679
+ return;
680
+ }
681
+ const { shiftPos, rowBytes, data } = imageDstBuffer;
682
+ const pixelBytes = height * rowBytes;
683
+ const sourcePixels = Buffer.allocUnsafe(pixelBytes);
684
+ data.copy(sourcePixels, 0, shiftPos, shiftPos + pixelBytes);
685
+
686
+ for (let y = 0; y < height; y++) {
687
+ const srcY = clampInt(y - oy, 0, height - 1);
688
+ const dstRow = shiftPos + y * rowBytes;
689
+ const srcRow = srcY * rowBytes;
690
+ for (let x = 0; x < width; x++) {
691
+ const srcX = clampInt(x - ox, 0, width - 1);
692
+ const dstIndex = dstRow + x * 3;
693
+ const srcIndex = srcRow + srcX * 3;
694
+ data[dstIndex] = sourcePixels[srcIndex];
695
+ data[dstIndex + 1] = sourcePixels[srcIndex + 1];
696
+ data[dstIndex + 2] = sourcePixels[srcIndex + 2];
697
+ }
698
+ }
699
+ };
700
+
701
+ const hexToRgb = (hex: String) => {
702
+ const red = parseInt(hex.substring(1, 3), 16);
703
+ const green = parseInt(hex.substring(3, 5), 16);
704
+ const blue = parseInt(hex.substring(5, 7), 16);
705
+
706
+ return { red, green, blue };
707
+ };