@gannochenko/staticstripes 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.prettierrc +8 -0
  2. package/Makefile +69 -0
  3. package/dist/asset-manager.d.ts +16 -0
  4. package/dist/asset-manager.d.ts.map +1 -0
  5. package/dist/asset-manager.js +50 -0
  6. package/dist/asset-manager.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +257 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/container-renderer.d.ts +21 -0
  12. package/dist/container-renderer.d.ts.map +1 -0
  13. package/dist/container-renderer.js +149 -0
  14. package/dist/container-renderer.js.map +1 -0
  15. package/dist/expression-parser.d.ts +63 -0
  16. package/dist/expression-parser.d.ts.map +1 -0
  17. package/dist/expression-parser.js +145 -0
  18. package/dist/expression-parser.js.map +1 -0
  19. package/dist/ffmpeg.d.ts +375 -0
  20. package/dist/ffmpeg.d.ts.map +1 -0
  21. package/dist/ffmpeg.js +997 -0
  22. package/dist/ffmpeg.js.map +1 -0
  23. package/dist/ffprobe.d.ts +2 -0
  24. package/dist/ffprobe.d.ts.map +1 -0
  25. package/dist/ffprobe.js +31 -0
  26. package/dist/ffprobe.js.map +1 -0
  27. package/dist/html-parser.d.ts +56 -0
  28. package/dist/html-parser.d.ts.map +1 -0
  29. package/dist/html-parser.js +208 -0
  30. package/dist/html-parser.js.map +1 -0
  31. package/dist/html-project-parser.d.ts +169 -0
  32. package/dist/html-project-parser.d.ts.map +1 -0
  33. package/dist/html-project-parser.js +954 -0
  34. package/dist/html-project-parser.js.map +1 -0
  35. package/dist/index.d.ts +6 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +18 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/label-generator.d.ts +35 -0
  40. package/dist/label-generator.d.ts.map +1 -0
  41. package/dist/label-generator.js +69 -0
  42. package/dist/label-generator.js.map +1 -0
  43. package/dist/project.d.ts +29 -0
  44. package/dist/project.d.ts.map +1 -0
  45. package/dist/project.js +137 -0
  46. package/dist/project.js.map +1 -0
  47. package/dist/sample-sequences.d.ts +5 -0
  48. package/dist/sample-sequences.d.ts.map +1 -0
  49. package/dist/sample-sequences.js +199 -0
  50. package/dist/sample-sequences.js.map +1 -0
  51. package/dist/sample-streams.d.ts +2 -0
  52. package/dist/sample-streams.d.ts.map +1 -0
  53. package/dist/sample-streams.js +109 -0
  54. package/dist/sample-streams.js.map +1 -0
  55. package/dist/sequence.d.ts +21 -0
  56. package/dist/sequence.d.ts.map +1 -0
  57. package/dist/sequence.js +269 -0
  58. package/dist/sequence.js.map +1 -0
  59. package/dist/stream.d.ts +135 -0
  60. package/dist/stream.d.ts.map +1 -0
  61. package/dist/stream.js +779 -0
  62. package/dist/stream.js.map +1 -0
  63. package/dist/type.d.ts +73 -0
  64. package/dist/type.d.ts.map +1 -0
  65. package/dist/type.js +3 -0
  66. package/dist/type.js.map +1 -0
  67. package/eslint.config.js +44 -0
  68. package/package.json +50 -0
  69. package/src/asset-manager.ts +55 -0
  70. package/src/cli.ts +306 -0
  71. package/src/container-renderer.ts +190 -0
  72. package/src/expression-parser.test.ts +459 -0
  73. package/src/expression-parser.ts +199 -0
  74. package/src/ffmpeg.ts +1403 -0
  75. package/src/ffprobe.ts +29 -0
  76. package/src/html-parser.ts +221 -0
  77. package/src/html-project-parser.ts +1195 -0
  78. package/src/index.ts +9 -0
  79. package/src/label-generator.ts +74 -0
  80. package/src/project.ts +180 -0
  81. package/src/sample-sequences.ts +225 -0
  82. package/src/sample-streams.ts +142 -0
  83. package/src/sequence.ts +330 -0
  84. package/src/stream.ts +1012 -0
  85. package/src/type.ts +81 -0
  86. package/tsconfig.json +24 -0
package/src/stream.ts ADDED
@@ -0,0 +1,1012 @@
1
+ import {
2
+ Filter,
3
+ Label,
4
+ Millisecond,
5
+ makeNull,
6
+ makeFps,
7
+ makeTranspose,
8
+ makeTrim,
9
+ makeTPad,
10
+ makeHflip,
11
+ makeVflip,
12
+ makeScale,
13
+ makePad,
14
+ makeGblur,
15
+ makeCrop,
16
+ makeSplit,
17
+ makeOverlay,
18
+ makeEq,
19
+ makeChromakey,
20
+ makeConcat,
21
+ makeFade,
22
+ makeAmix,
23
+ makeAnullsrc,
24
+ makeColor,
25
+ makeVignette,
26
+ makeColorBalance,
27
+ } from './ffmpeg';
28
+
29
+ export const PILLARBOX = 'pillarbox';
30
+ export const AMBIENT = 'ambient';
31
+
32
+ type Dimensions = {
33
+ width: number;
34
+ height: number;
35
+ };
36
+
37
+ export enum Direction {
38
+ CW,
39
+ CW2,
40
+ CCW,
41
+ CCW2,
42
+ }
43
+
44
+ export enum ChromakeySimilarity {
45
+ Strict = 0.1,
46
+ Good = 0.3,
47
+ Forgiving = 0.5,
48
+ Loose = 0.7,
49
+ }
50
+
51
+ export enum ChromakeyBlend {
52
+ Hard = 0.0,
53
+ Smooth = 0.1,
54
+ Soft = 0.2,
55
+ }
56
+
57
+ export enum Colors {
58
+ Transparent = '#00000000',
59
+ }
60
+
61
+ export enum VisualFilter {
62
+ InstagramClarendon = 'instagram-clarendon',
63
+ InstagramGingham = 'instagram-gingham',
64
+ InstagramJuno = 'instagram-juno',
65
+ InstagramLark = 'instagram-lark',
66
+ InstagramLudwig = 'instagram-ludwig',
67
+ InstagramNashville = 'instagram-nashville',
68
+ InstagramValencia = 'instagram-valencia',
69
+ InstagramXProII = 'instagram-xpro2',
70
+ InstagramWillow = 'instagram-willow',
71
+ InstagramLoFi = 'instagram-lofi',
72
+ InstagramInkwell = 'instagram-inkwell',
73
+ InstagramMoon = 'instagram-moon',
74
+ InstagramHudson = 'instagram-hudson',
75
+ InstagramToaster = 'instagram-toaster',
76
+ InstagramWalden = 'instagram-walden',
77
+ InstagramRise = 'instagram-rise',
78
+ InstagramAmaro = 'instagram-amaro',
79
+ InstagramMayfair = 'instagram-mayfair',
80
+ InstagramEarlybird = 'instagram-earlybird',
81
+ InstagramSutro = 'instagram-sutro',
82
+ InstagramAden = 'instagram-aden',
83
+ InstagramCrema = 'instagram-crema',
84
+ }
85
+
86
+ export type ObjectFitContainOptions = {
87
+ ambient?: {
88
+ blurStrength?: number; // Gaussian blur sigma (default: 20)
89
+ brightness?: number; // Background brightness reduction (default: -0.3)
90
+ saturation?: number; // Background saturation (default: 0.8)
91
+ };
92
+ pillarbox?: {
93
+ color: string;
94
+ };
95
+ };
96
+
97
+ export class FilterBuffer {
98
+ private filters: Filter[] = [];
99
+
100
+ public append(filter: Filter) {
101
+ this.filters.push(filter);
102
+ }
103
+
104
+ public render(): string {
105
+ return this.filters.map((filter) => filter.render()).join(';');
106
+ }
107
+ }
108
+
109
+ export function makeStream(label: Label, buf: FilterBuffer): Stream {
110
+ return new Stream(label, buf);
111
+ }
112
+
113
+ export function makeSilentStream(
114
+ duration: Millisecond,
115
+ buf: FilterBuffer,
116
+ ): Stream {
117
+ const filter = makeAnullsrc({ duration });
118
+ buf.append(filter);
119
+ return new Stream(filter.outputs[0], buf);
120
+ }
121
+
122
+ export function makeBlankStream(
123
+ duration: Millisecond,
124
+ width: number,
125
+ height: number,
126
+ fps: number,
127
+ buf: FilterBuffer,
128
+ ): Stream {
129
+ const filter = makeColor({
130
+ duration,
131
+ width,
132
+ height,
133
+ fps,
134
+ color: '#00000000',
135
+ });
136
+ buf.append(filter);
137
+ return new Stream(filter.outputs[0], buf);
138
+ }
139
+
140
+ export class Stream {
141
+ constructor(
142
+ private looseEnd: Label,
143
+ private buf: FilterBuffer,
144
+ ) {}
145
+
146
+ public trim(start: Millisecond, end: Millisecond): Stream {
147
+ const res = makeTrim([this.looseEnd], start, end);
148
+ this.looseEnd = res.outputs[0];
149
+
150
+ this.buf.append(res);
151
+
152
+ return this;
153
+ }
154
+
155
+ public fitOutputSimple(dimensions: Dimensions): Stream {
156
+ // Step 1: Scale video to fit within dimensions while maintaining aspect ratio
157
+ // Using 'force_original_aspect_ratio=decrease' ensures the video fits inside the box
158
+ const scaleRes = makeScale([this.looseEnd], {
159
+ width: dimensions.width,
160
+ height: dimensions.height,
161
+ flags: 'force_original_aspect_ratio=decrease',
162
+ });
163
+ this.looseEnd = scaleRes.outputs[0];
164
+ this.buf.append(scaleRes);
165
+
166
+ // Step 2: Pad to exact dimensions with black bars (centered)
167
+ const padRes = makePad([this.looseEnd], {
168
+ width: dimensions.width,
169
+ height: dimensions.height,
170
+ // x and y default to '(ow-iw)/2' and '(oh-ih)/2' which centers the video
171
+ });
172
+ this.looseEnd = padRes.outputs[0];
173
+ this.buf.append(padRes);
174
+
175
+ return this;
176
+ }
177
+
178
+ public fitOutputCover(dimensions: Dimensions): Stream {
179
+ // Step 1: Scale video to cover dimensions while maintaining aspect ratio
180
+ // Using 'force_original_aspect_ratio=increase' ensures the video fills the entire box
181
+ const scaleRes = makeScale([this.looseEnd], {
182
+ width: dimensions.width,
183
+ height: dimensions.height,
184
+ flags: 'force_original_aspect_ratio=increase',
185
+ });
186
+ this.looseEnd = scaleRes.outputs[0];
187
+ this.buf.append(scaleRes);
188
+
189
+ // Step 2: Crop to exact dimensions (centered)
190
+ const cropRes = makeCrop([this.looseEnd], {
191
+ width: dimensions.width,
192
+ height: dimensions.height,
193
+ // x and y default to '(in_w-out_w)/2' and '(in_h-out_h)/2' which centers the crop
194
+ });
195
+ this.looseEnd = cropRes.outputs[0];
196
+ this.buf.append(cropRes);
197
+
198
+ return this;
199
+ }
200
+
201
+ public fitOutputContain(
202
+ dimensions: Dimensions,
203
+ options: ObjectFitContainOptions = {},
204
+ ): Stream {
205
+ if (options.ambient) {
206
+ const blurStrength = options.ambient?.blurStrength ?? 20;
207
+ const brightness = options.ambient?.brightness ?? -0.3;
208
+ const saturation = options.ambient?.saturation ?? 0.8;
209
+
210
+ // Split input into 2 streams: background and foreground
211
+ const splitRes = makeSplit([this.looseEnd]);
212
+ this.buf.append(splitRes);
213
+
214
+ const [bgLabel, fgLabel] = splitRes.outputs;
215
+
216
+ // // Background stream: cover + blur + darken
217
+ const bgScaleRes = makeScale([bgLabel], {
218
+ width: dimensions.width,
219
+ height: dimensions.height,
220
+ flags: 'force_original_aspect_ratio=increase',
221
+ });
222
+ this.buf.append(bgScaleRes);
223
+
224
+ const bgCropRes = makeCrop(bgScaleRes.outputs, {
225
+ width: dimensions.width,
226
+ height: dimensions.height,
227
+ });
228
+ this.buf.append(bgCropRes);
229
+
230
+ const bgBlurRes = makeGblur(bgCropRes.outputs, {
231
+ sigma: blurStrength,
232
+ steps: 2,
233
+ });
234
+ this.buf.append(bgBlurRes);
235
+
236
+ const bgFinal = makeEq(bgBlurRes.outputs, {
237
+ brightness,
238
+ saturation,
239
+ });
240
+ this.buf.append(bgFinal);
241
+
242
+ ////////////////////////////////////////////////////////////////////////////////////
243
+
244
+ const fgScale = makeScale([fgLabel], {
245
+ width: dimensions.width,
246
+ height: dimensions.height,
247
+ flags: 'force_original_aspect_ratio=decrease',
248
+ });
249
+ this.buf.append(fgScale);
250
+
251
+ // Step 2: Pad to exact dimensions with black bars (centered)
252
+ const fgFinal = makePad(fgScale.outputs, {
253
+ width: dimensions.width,
254
+ height: dimensions.height,
255
+ color: '#00000000', // transparent
256
+ // x and y default to '(ow-iw)/2' and '(oh-ih)/2' which centers the video
257
+ });
258
+ this.buf.append(fgFinal);
259
+
260
+ ////////////////////////////////////////////////////////////////////////////////////
261
+
262
+ // Overlay foreground centered on background
263
+ // (W-w)/2 and (H-h)/2 center the overlay on the background
264
+ const overlayRes = makeOverlay([bgFinal.outputs[0], fgFinal.outputs[0]], {
265
+ x: '(W-w)/2',
266
+ y: '(H-h)/2',
267
+ });
268
+ this.buf.append(overlayRes);
269
+
270
+ this.looseEnd = overlayRes.outputs[0];
271
+ } else {
272
+ // usual pillarbox
273
+ const color = options?.pillarbox?.color ?? '#000000';
274
+
275
+ const scaleRes = makeScale([this.looseEnd], {
276
+ width: dimensions.width,
277
+ height: dimensions.height,
278
+ flags: 'force_original_aspect_ratio=decrease',
279
+ });
280
+ this.looseEnd = scaleRes.outputs[0];
281
+ this.buf.append(scaleRes);
282
+
283
+ // Step 2: Pad to exact dimensions with black bars (centered)
284
+ const padRes = makePad([this.looseEnd], {
285
+ width: dimensions.width,
286
+ height: dimensions.height,
287
+ color: color,
288
+ // x and y default to '(ow-iw)/2' and '(oh-ih)/2' which centers the video
289
+ });
290
+ this.looseEnd = padRes.outputs[0];
291
+ this.buf.append(padRes);
292
+ }
293
+
294
+ return this;
295
+ }
296
+
297
+ public chromakey(parameters: {
298
+ color: string;
299
+ similarity?: number | ChromakeySimilarity;
300
+ blend?: number | ChromakeyBlend;
301
+ }): Stream {
302
+ // Apply chromakey filter
303
+ const chromakeyRes = makeChromakey([this.looseEnd], {
304
+ color: parameters.color,
305
+ similarity: parameters.similarity,
306
+ blend: parameters.blend,
307
+ });
308
+ this.looseEnd = chromakeyRes.outputs[0];
309
+ this.buf.append(chromakeyRes);
310
+
311
+ return this;
312
+ }
313
+
314
+ public fps(value: number): Stream {
315
+ const res = makeFps([this.looseEnd], value);
316
+ this.looseEnd = res.outputs[0];
317
+
318
+ this.buf.append(res);
319
+
320
+ return this;
321
+ }
322
+
323
+ public blur(strength: number): Stream {
324
+ const res = makeGblur([this.looseEnd], {
325
+ sigma: strength,
326
+ });
327
+ this.looseEnd = res.outputs[0];
328
+
329
+ this.buf.append(res);
330
+
331
+ return this;
332
+ }
333
+
334
+ public fade(options: {
335
+ fades: Array<{
336
+ type: 'in' | 'out';
337
+ startTime: Millisecond;
338
+ duration: Millisecond;
339
+ color?: string;
340
+ curve?: string;
341
+ }>;
342
+ }): Stream {
343
+ const res = makeFade([this.looseEnd], options);
344
+ this.looseEnd = res.outputs[0];
345
+
346
+ this.buf.append(res);
347
+
348
+ return this;
349
+ }
350
+
351
+ public transpose(value: 0 | 1 | 2 | 3): Stream {
352
+ const res = makeTranspose([this.looseEnd], value);
353
+ this.looseEnd = res.outputs[0];
354
+
355
+ this.buf.append(res);
356
+
357
+ return this;
358
+ }
359
+
360
+ public cwRotate(direction: Direction): Stream {
361
+ switch (direction) {
362
+ case Direction.CW:
363
+ // 90° clockwise: transpose=1
364
+ this.transpose(1);
365
+ break;
366
+
367
+ case Direction.CCW:
368
+ // 90° counterclockwise: transpose=2
369
+ this.transpose(2);
370
+ break;
371
+
372
+ case Direction.CW2:
373
+ case Direction.CCW2:
374
+ // 180° rotation (same for both directions): hflip + vflip
375
+ const hflipRes = makeHflip([this.looseEnd]);
376
+ this.looseEnd = hflipRes.outputs[0];
377
+ this.buf.append(hflipRes);
378
+
379
+ const vflipRes = makeVflip([this.looseEnd]);
380
+ this.looseEnd = vflipRes.outputs[0];
381
+ this.buf.append(vflipRes);
382
+ break;
383
+ }
384
+
385
+ return this;
386
+ }
387
+
388
+ public concatStream(stream: Stream): Stream {
389
+ return this.concatStreams([stream]);
390
+ }
391
+
392
+ public concatStreams(streams: Stream[]): Stream {
393
+ // todo: check streams type here, it can either be all audio or all video
394
+
395
+ const res = makeConcat([
396
+ this.looseEnd,
397
+ ...streams.map((st) => st.getLooseEnd()),
398
+ ]);
399
+ this.looseEnd = res.outputs[0];
400
+
401
+ if (res.outputs.length > 1) {
402
+ throw new Error(
403
+ 'concat produced several outputs, possible mixup between video and audio streams',
404
+ );
405
+ }
406
+
407
+ this.buf.append(res);
408
+
409
+ return this;
410
+ }
411
+
412
+ public mixStream(
413
+ stream: Stream,
414
+ options?: {
415
+ duration?: 'longest' | 'shortest' | 'first';
416
+ dropout_transition?: number;
417
+ weights?: number[];
418
+ normalize?: boolean;
419
+ },
420
+ ): Stream {
421
+ return this.mixStreams([stream], options);
422
+ }
423
+
424
+ public mixStreams(
425
+ streams: Stream[],
426
+ options?: {
427
+ duration?: 'longest' | 'shortest' | 'first';
428
+ dropout_transition?: number;
429
+ weights?: number[];
430
+ normalize?: boolean;
431
+ },
432
+ ): Stream {
433
+ const res = makeAmix(
434
+ [this.looseEnd, ...streams.map((st) => st.getLooseEnd())],
435
+ options,
436
+ );
437
+ this.looseEnd = res.outputs[0];
438
+
439
+ this.buf.append(res);
440
+
441
+ return this;
442
+ }
443
+
444
+ public tPad(
445
+ options: {
446
+ start?: Millisecond;
447
+ stop?: Millisecond;
448
+ color?: string;
449
+ startMode?: 'clone' | 'add';
450
+ stopMode?: 'clone' | 'add';
451
+ } = {},
452
+ ): Stream {
453
+ const res = makeTPad([this.looseEnd], options);
454
+ this.looseEnd = res.outputs[0];
455
+
456
+ this.buf.append(res);
457
+
458
+ return this;
459
+ }
460
+
461
+ /*
462
+ this stream becomes the bottom layer, and the joining stream - top layer
463
+ For video: uses overlay filter
464
+ For audio: uses amix filter
465
+ */
466
+ public overlayStream(
467
+ stream: Stream,
468
+ options: {
469
+ flipLayers?: boolean;
470
+ offset?: {
471
+ streamDuration: number; // duration of this stream
472
+ otherStreamDuration: number; // duration of the joining stream
473
+ otherStreamOffsetLeft: number; // offset of the joining stream in seconds
474
+ };
475
+ },
476
+ ): Stream {
477
+ const offset = options.offset;
478
+ const flip = !!options.flipLayers;
479
+ const isAudio = this.looseEnd.isAudio;
480
+
481
+ // Validate that both streams are of the same type
482
+ if (isAudio !== stream.getLooseEnd().isAudio) {
483
+ throw new Error(
484
+ 'overlayStream: both streams must be of the same type (both video or both audio)',
485
+ );
486
+ }
487
+
488
+ if (!offset || !offset.otherStreamOffsetLeft) {
489
+ // usual overlay/mix, no offset
490
+ if (isAudio) {
491
+ const res = makeAmix([this.looseEnd, stream.getLooseEnd()]);
492
+ this.looseEnd = res.outputs[0];
493
+ this.buf.append(res);
494
+ } else {
495
+ const res = makeOverlay(
496
+ flip
497
+ ? [stream.getLooseEnd(), this.looseEnd]
498
+ : [this.looseEnd, stream.getLooseEnd()],
499
+ );
500
+ this.looseEnd = res.outputs[0];
501
+ this.buf.append(res);
502
+ }
503
+ } else {
504
+ if (offset.streamDuration === undefined) {
505
+ throw new Error(
506
+ 'exact duration of the fragment in the stream must be provided',
507
+ );
508
+ }
509
+ if (offset.otherStreamDuration === undefined) {
510
+ throw new Error(
511
+ 'exact duration of the fragment in the joining stream must be provided',
512
+ );
513
+ }
514
+
515
+ const offsetLeft = offset.otherStreamOffsetLeft;
516
+
517
+ if (offsetLeft > 0) {
518
+ // Pad the joining stream on the left
519
+ stream.tPad({
520
+ start: offsetLeft,
521
+ ...(isAudio ? {} : { color: Colors.Transparent }),
522
+ });
523
+
524
+ // Pad the main stream on the right if needed
525
+ const mainLeftover =
526
+ offset.otherStreamDuration + offsetLeft - offset.streamDuration;
527
+ if (mainLeftover > 0) {
528
+ this.tPad({
529
+ stop: mainLeftover,
530
+ ...(isAudio ? {} : { color: Colors.Transparent }),
531
+ });
532
+ }
533
+
534
+ // Mix or overlay the streams
535
+ if (isAudio) {
536
+ const res = makeAmix([this.looseEnd, stream.getLooseEnd()]);
537
+ this.looseEnd = res.outputs[0];
538
+ this.buf.append(res);
539
+ } else {
540
+ const overlayRes = makeOverlay(
541
+ flip
542
+ ? [stream.getLooseEnd(), this.looseEnd]
543
+ : [this.looseEnd, stream.getLooseEnd()],
544
+ );
545
+ this.looseEnd = overlayRes.outputs[0];
546
+ this.buf.append(overlayRes);
547
+ }
548
+ } else if (offsetLeft < 0) {
549
+ throw new Error('negative offset is not supported for overlayStream');
550
+ }
551
+ }
552
+
553
+ return this;
554
+ }
555
+
556
+ public endTo(label: Label): Stream {
557
+ const res = makeNull([this.looseEnd]);
558
+ res.outputs[0] = label;
559
+ this.buf.append(res);
560
+
561
+ return this;
562
+ }
563
+
564
+ /**
565
+ * Applies an Instagram-style filter to the video stream
566
+ * @param filterName - The filter to apply
567
+ */
568
+ public filter(filterName: VisualFilter): Stream {
569
+ if (this.looseEnd.isAudio) {
570
+ throw new Error('filter() can only be applied to video streams');
571
+ }
572
+
573
+ switch (filterName) {
574
+ case VisualFilter.InstagramClarendon:
575
+ // Brightens, increases contrast and saturation
576
+ const clarendonEq = makeEq([this.looseEnd], {
577
+ contrast: 1.2,
578
+ brightness: 0.1,
579
+ saturation: 1.3,
580
+ });
581
+ this.looseEnd = clarendonEq.outputs[0];
582
+ this.buf.append(clarendonEq);
583
+ break;
584
+
585
+ case VisualFilter.InstagramGingham:
586
+ // Vintage washed-out look
587
+ const ginghamEq = makeEq([this.looseEnd], {
588
+ saturation: 0.6,
589
+ brightness: 0.05,
590
+ });
591
+ this.looseEnd = ginghamEq.outputs[0];
592
+ this.buf.append(ginghamEq);
593
+
594
+ const ginghamBalance = makeColorBalance([this.looseEnd], {
595
+ rm: 0.1,
596
+ bm: 0.05,
597
+ });
598
+ this.looseEnd = ginghamBalance.outputs[0];
599
+ this.buf.append(ginghamBalance);
600
+ break;
601
+
602
+ case VisualFilter.InstagramJuno:
603
+ // High contrast, saturated, cool tones
604
+ const junoEq = makeEq([this.looseEnd], {
605
+ contrast: 1.3,
606
+ saturation: 1.4,
607
+ });
608
+ this.looseEnd = junoEq.outputs[0];
609
+ this.buf.append(junoEq);
610
+
611
+ const junoBalance = makeColorBalance([this.looseEnd], {
612
+ bh: 0.15,
613
+ gh: 0.1,
614
+ });
615
+ this.looseEnd = junoBalance.outputs[0];
616
+ this.buf.append(junoBalance);
617
+ break;
618
+
619
+ case VisualFilter.InstagramLark:
620
+ // Brightens, desaturated, cool tones
621
+ const larkEq = makeEq([this.looseEnd], {
622
+ brightness: 0.15,
623
+ saturation: 0.7,
624
+ });
625
+ this.looseEnd = larkEq.outputs[0];
626
+ this.buf.append(larkEq);
627
+
628
+ const larkBalance = makeColorBalance([this.looseEnd], {
629
+ bm: 0.1,
630
+ });
631
+ this.looseEnd = larkBalance.outputs[0];
632
+ this.buf.append(larkBalance);
633
+ break;
634
+
635
+ case VisualFilter.InstagramLudwig:
636
+ // Cool tones, subtle vignette
637
+ const ludwigBalance = makeColorBalance([this.looseEnd], {
638
+ bm: 0.08,
639
+ bs: 0.05,
640
+ });
641
+ this.looseEnd = ludwigBalance.outputs[0];
642
+ this.buf.append(ludwigBalance);
643
+
644
+ const ludwigVignette = makeVignette([this.looseEnd], {
645
+ angle: 'PI/4',
646
+ });
647
+ this.looseEnd = ludwigVignette.outputs[0];
648
+ this.buf.append(ludwigVignette);
649
+ break;
650
+
651
+ case VisualFilter.InstagramNashville:
652
+ // Warm vintage, pink tint, vignette
653
+ const nashvilleBalance = makeColorBalance([this.looseEnd], {
654
+ rm: 0.2,
655
+ rh: 0.1,
656
+ bm: -0.1,
657
+ });
658
+ this.looseEnd = nashvilleBalance.outputs[0];
659
+ this.buf.append(nashvilleBalance);
660
+
661
+ const nashvilleEq = makeEq([this.looseEnd], {
662
+ contrast: 0.9,
663
+ saturation: 1.2,
664
+ });
665
+ this.looseEnd = nashvilleEq.outputs[0];
666
+ this.buf.append(nashvilleEq);
667
+
668
+ const nashvilleVignette = makeVignette([this.looseEnd], {
669
+ angle: 'PI/4.5',
670
+ });
671
+ this.looseEnd = nashvilleVignette.outputs[0];
672
+ this.buf.append(nashvilleVignette);
673
+ break;
674
+
675
+ case VisualFilter.InstagramValencia:
676
+ // Warm tones, slight fade
677
+ const valenciaBalance = makeColorBalance([this.looseEnd], {
678
+ rm: 0.15,
679
+ gm: 0.05,
680
+ });
681
+ this.looseEnd = valenciaBalance.outputs[0];
682
+ this.buf.append(valenciaBalance);
683
+
684
+ const valenciaEq = makeEq([this.looseEnd], {
685
+ contrast: 0.95,
686
+ brightness: 0.05,
687
+ });
688
+ this.looseEnd = valenciaEq.outputs[0];
689
+ this.buf.append(valenciaEq);
690
+ break;
691
+
692
+ case VisualFilter.InstagramXProII:
693
+ // High contrast, warm highlights, cool shadows, vignette
694
+ const xproBalance = makeColorBalance([this.looseEnd], {
695
+ rh: 0.2,
696
+ bs: 0.15,
697
+ });
698
+ this.looseEnd = xproBalance.outputs[0];
699
+ this.buf.append(xproBalance);
700
+
701
+ const xproEq = makeEq([this.looseEnd], {
702
+ contrast: 1.4,
703
+ saturation: 1.2,
704
+ });
705
+ this.looseEnd = xproEq.outputs[0];
706
+ this.buf.append(xproEq);
707
+
708
+ const xproVignette = makeVignette([this.looseEnd], {
709
+ angle: 'PI/4',
710
+ });
711
+ this.looseEnd = xproVignette.outputs[0];
712
+ this.buf.append(xproVignette);
713
+ break;
714
+
715
+ case VisualFilter.InstagramWillow:
716
+ // Black and white-ish, desaturated, slight yellow tint
717
+ const willowEq = makeEq([this.looseEnd], {
718
+ saturation: 0.2,
719
+ brightness: 0.05,
720
+ });
721
+ this.looseEnd = willowEq.outputs[0];
722
+ this.buf.append(willowEq);
723
+
724
+ const willowBalance = makeColorBalance([this.looseEnd], {
725
+ rm: 0.05,
726
+ gm: 0.05,
727
+ });
728
+ this.looseEnd = willowBalance.outputs[0];
729
+ this.buf.append(willowBalance);
730
+ break;
731
+
732
+ case VisualFilter.InstagramLoFi:
733
+ // High contrast, high saturation, vignette
734
+ const lofiEq = makeEq([this.looseEnd], {
735
+ contrast: 1.5,
736
+ saturation: 1.4,
737
+ });
738
+ this.looseEnd = lofiEq.outputs[0];
739
+ this.buf.append(lofiEq);
740
+
741
+ const lofiVignette = makeVignette([this.looseEnd], {
742
+ angle: 'PI/4',
743
+ });
744
+ this.looseEnd = lofiVignette.outputs[0];
745
+ this.buf.append(lofiVignette);
746
+ break;
747
+
748
+ case VisualFilter.InstagramInkwell:
749
+ // Classic black and white
750
+ const inkwellEq = makeEq([this.looseEnd], {
751
+ saturation: 0,
752
+ contrast: 1.1,
753
+ });
754
+ this.looseEnd = inkwellEq.outputs[0];
755
+ this.buf.append(inkwellEq);
756
+ break;
757
+
758
+ case VisualFilter.InstagramMoon:
759
+ // Black and white with high contrast and cool tone
760
+ const moonEq = makeEq([this.looseEnd], {
761
+ saturation: 0,
762
+ contrast: 1.4,
763
+ brightness: -0.05,
764
+ });
765
+ this.looseEnd = moonEq.outputs[0];
766
+ this.buf.append(moonEq);
767
+
768
+ const moonBalance = makeColorBalance([this.looseEnd], {
769
+ bs: 0.1,
770
+ bm: 0.08,
771
+ });
772
+ this.looseEnd = moonBalance.outputs[0];
773
+ this.buf.append(moonBalance);
774
+
775
+ const moonVignette = makeVignette([this.looseEnd], {
776
+ angle: 'PI/4.5',
777
+ });
778
+ this.looseEnd = moonVignette.outputs[0];
779
+ this.buf.append(moonVignette);
780
+ break;
781
+
782
+ case VisualFilter.InstagramHudson:
783
+ // Cool tones, high contrast, vignette
784
+ const hudsonBalance = makeColorBalance([this.looseEnd], {
785
+ bm: 0.2,
786
+ bs: 0.15,
787
+ });
788
+ this.looseEnd = hudsonBalance.outputs[0];
789
+ this.buf.append(hudsonBalance);
790
+
791
+ const hudsonEq = makeEq([this.looseEnd], {
792
+ contrast: 1.3,
793
+ });
794
+ this.looseEnd = hudsonEq.outputs[0];
795
+ this.buf.append(hudsonEq);
796
+
797
+ const hudsonVignette = makeVignette([this.looseEnd], {
798
+ angle: 'PI/4.5',
799
+ });
800
+ this.looseEnd = hudsonVignette.outputs[0];
801
+ this.buf.append(hudsonVignette);
802
+ break;
803
+
804
+ case VisualFilter.InstagramToaster:
805
+ // Warm tones, vignette
806
+ const toasterBalance = makeColorBalance([this.looseEnd], {
807
+ rm: 0.25,
808
+ rh: 0.15,
809
+ });
810
+ this.looseEnd = toasterBalance.outputs[0];
811
+ this.buf.append(toasterBalance);
812
+
813
+ const toasterEq = makeEq([this.looseEnd], {
814
+ contrast: 1.2,
815
+ });
816
+ this.looseEnd = toasterEq.outputs[0];
817
+ this.buf.append(toasterEq);
818
+
819
+ const toasterVignette = makeVignette([this.looseEnd], {
820
+ angle: 'PI/4',
821
+ });
822
+ this.looseEnd = toasterVignette.outputs[0];
823
+ this.buf.append(toasterVignette);
824
+ break;
825
+
826
+ case VisualFilter.InstagramWalden:
827
+ // Increased exposure, yellow tones
828
+ const waldenBalance = makeColorBalance([this.looseEnd], {
829
+ rm: 0.1,
830
+ gm: 0.1,
831
+ });
832
+ this.looseEnd = waldenBalance.outputs[0];
833
+ this.buf.append(waldenBalance);
834
+
835
+ const waldenEq = makeEq([this.looseEnd], {
836
+ brightness: 0.15,
837
+ saturation: 1.1,
838
+ });
839
+ this.looseEnd = waldenEq.outputs[0];
840
+ this.buf.append(waldenEq);
841
+ break;
842
+
843
+ case VisualFilter.InstagramRise:
844
+ // Soft, warm glow
845
+ const riseBalance = makeColorBalance([this.looseEnd], {
846
+ rm: 0.12,
847
+ rh: 0.08,
848
+ });
849
+ this.looseEnd = riseBalance.outputs[0];
850
+ this.buf.append(riseBalance);
851
+
852
+ const riseEq = makeEq([this.looseEnd], {
853
+ brightness: 0.1,
854
+ contrast: 0.9,
855
+ saturation: 1.15,
856
+ });
857
+ this.looseEnd = riseEq.outputs[0];
858
+ this.buf.append(riseEq);
859
+ break;
860
+
861
+ case VisualFilter.InstagramAmaro:
862
+ // Increases contrast, adds vignette, cool tone
863
+ const amaroBalance = makeColorBalance([this.looseEnd], {
864
+ bm: 0.1,
865
+ });
866
+ this.looseEnd = amaroBalance.outputs[0];
867
+ this.buf.append(amaroBalance);
868
+
869
+ const amaroEq = makeEq([this.looseEnd], {
870
+ contrast: 1.3,
871
+ saturation: 1.2,
872
+ });
873
+ this.looseEnd = amaroEq.outputs[0];
874
+ this.buf.append(amaroEq);
875
+
876
+ const amaroVignette = makeVignette([this.looseEnd], {
877
+ angle: 'PI/4.5',
878
+ });
879
+ this.looseEnd = amaroVignette.outputs[0];
880
+ this.buf.append(amaroVignette);
881
+ break;
882
+
883
+ case VisualFilter.InstagramMayfair:
884
+ // Warm center, cool edges, vignette
885
+ const mayfairBalance = makeColorBalance([this.looseEnd], {
886
+ rh: 0.15,
887
+ bs: 0.1,
888
+ });
889
+ this.looseEnd = mayfairBalance.outputs[0];
890
+ this.buf.append(mayfairBalance);
891
+
892
+ const mayfairEq = makeEq([this.looseEnd], {
893
+ contrast: 1.1,
894
+ saturation: 1.15,
895
+ });
896
+ this.looseEnd = mayfairEq.outputs[0];
897
+ this.buf.append(mayfairEq);
898
+
899
+ const mayfairVignette = makeVignette([this.looseEnd], {
900
+ angle: 'PI/4',
901
+ });
902
+ this.looseEnd = mayfairVignette.outputs[0];
903
+ this.buf.append(mayfairVignette);
904
+ break;
905
+
906
+ case VisualFilter.InstagramEarlybird:
907
+ // Vintage sepia, vignette
908
+ const earlybirdBalance = makeColorBalance([this.looseEnd], {
909
+ rm: 0.2,
910
+ gm: 0.1,
911
+ bm: -0.15,
912
+ });
913
+ this.looseEnd = earlybirdBalance.outputs[0];
914
+ this.buf.append(earlybirdBalance);
915
+
916
+ const earlybirdEq = makeEq([this.looseEnd], {
917
+ contrast: 1.2,
918
+ saturation: 1.1,
919
+ });
920
+ this.looseEnd = earlybirdEq.outputs[0];
921
+ this.buf.append(earlybirdEq);
922
+
923
+ const earlybirdVignette = makeVignette([this.looseEnd], {
924
+ angle: 'PI/4',
925
+ });
926
+ this.looseEnd = earlybirdVignette.outputs[0];
927
+ this.buf.append(earlybirdVignette);
928
+ break;
929
+
930
+ case VisualFilter.InstagramSutro:
931
+ // Muted colors, purple/brown tint, vignette
932
+ const sutroBalance = makeColorBalance([this.looseEnd], {
933
+ rm: 0.1,
934
+ bm: 0.15,
935
+ });
936
+ this.looseEnd = sutroBalance.outputs[0];
937
+ this.buf.append(sutroBalance);
938
+
939
+ const sutroEq = makeEq([this.looseEnd], {
940
+ saturation: 0.8,
941
+ contrast: 1.2,
942
+ });
943
+ this.looseEnd = sutroEq.outputs[0];
944
+ this.buf.append(sutroEq);
945
+
946
+ const sutroVignette = makeVignette([this.looseEnd], {
947
+ angle: 'PI/3.5',
948
+ });
949
+ this.looseEnd = sutroVignette.outputs[0];
950
+ this.buf.append(sutroVignette);
951
+ break;
952
+
953
+ case VisualFilter.InstagramAden:
954
+ // Muted, cool tones, slight vignette
955
+ const adenBalance = makeColorBalance([this.looseEnd], {
956
+ bm: 0.12,
957
+ });
958
+ this.looseEnd = adenBalance.outputs[0];
959
+ this.buf.append(adenBalance);
960
+
961
+ const adenEq = makeEq([this.looseEnd], {
962
+ saturation: 0.85,
963
+ brightness: 0.08,
964
+ });
965
+ this.looseEnd = adenEq.outputs[0];
966
+ this.buf.append(adenEq);
967
+
968
+ const adenVignette = makeVignette([this.looseEnd], {
969
+ angle: 'PI/5',
970
+ });
971
+ this.looseEnd = adenVignette.outputs[0];
972
+ this.buf.append(adenVignette);
973
+ break;
974
+
975
+ case VisualFilter.InstagramCrema:
976
+ // Creamy warmth, slight vignette
977
+ const cremaBalance = makeColorBalance([this.looseEnd], {
978
+ rm: 0.08,
979
+ gm: 0.05,
980
+ });
981
+ this.looseEnd = cremaBalance.outputs[0];
982
+ this.buf.append(cremaBalance);
983
+
984
+ const cremaEq = makeEq([this.looseEnd], {
985
+ brightness: 0.05,
986
+ contrast: 0.95,
987
+ });
988
+ this.looseEnd = cremaEq.outputs[0];
989
+ this.buf.append(cremaEq);
990
+
991
+ const cremaVignette = makeVignette([this.looseEnd], {
992
+ angle: 'PI/5',
993
+ });
994
+ this.looseEnd = cremaVignette.outputs[0];
995
+ this.buf.append(cremaVignette);
996
+ break;
997
+
998
+ default:
999
+ throw new Error(`Unknown Instagram filter: ${filterName}`);
1000
+ }
1001
+
1002
+ return this;
1003
+ }
1004
+
1005
+ public getLooseEnd(): Label {
1006
+ return this.looseEnd;
1007
+ }
1008
+
1009
+ public render(): string {
1010
+ return this.buf.render();
1011
+ }
1012
+ }