vargai 0.4.0-alpha59 → 0.4.0-alpha60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -70,7 +70,7 @@
70
70
  "zod": "^4.2.1"
71
71
  },
72
72
  "sideEffects": false,
73
- "version": "0.4.0-alpha59",
73
+ "version": "0.4.0-alpha60",
74
74
  "exports": {
75
75
  ".": "./src/index.ts",
76
76
  "./ai": "./src/ai-sdk/index.ts",
@@ -42,7 +42,7 @@ function getCropPositionExpr(position: CropPosition | undefined): {
42
42
  }
43
43
  }
44
44
 
45
- export function escapeDrawText(text: string): string {
45
+ function escapeDrawText(text: string): string {
46
46
  return text
47
47
  .replace(/\\/g, "\\\\")
48
48
  .replace(/'/g, "'\\''")
@@ -51,10 +51,7 @@ export function escapeDrawText(text: string): string {
51
51
  .replace(/\]/g, "\\]");
52
52
  }
53
53
 
54
- export function parseSize(
55
- val: number | string | undefined,
56
- base: number,
57
- ): number {
54
+ function parseSize(val: number | string | undefined, base: number): number {
58
55
  if (val === undefined) return base;
59
56
  if (typeof val === "number") return Math.round(val);
60
57
  if (val.endsWith("%")) {
@@ -467,7 +464,7 @@ export function getGradientFilter(
467
464
  // 3. Gets composited on top of base layers (not as base layer)
468
465
  // 4. Uses overlay filter for positioning instead of pad filter
469
466
 
470
- export function resolvePositionForOverlay(
467
+ function resolvePositionForOverlay(
471
468
  position: Position | undefined,
472
469
  width: number,
473
470
  height: number,
@@ -3,6 +3,7 @@ import type { CacheStorage } from "../../ai-sdk/cache";
3
3
  import type { File } from "../../ai-sdk/file";
4
4
  import type { generateVideo } from "../../ai-sdk/generate-video";
5
5
  import type { FFmpegBackend } from "../../ai-sdk/providers/editly/backends";
6
+ import type { StorageProvider } from "../../ai-sdk/storage/types";
6
7
  import type { DefaultModels } from "../types";
7
8
  import type { ProgressTracker } from "./progress";
8
9
 
@@ -11,6 +12,7 @@ export interface RenderContext {
11
12
  height: number;
12
13
  fps: number;
13
14
  cache?: CacheStorage;
15
+ storage?: StorageProvider;
14
16
  generateImage: typeof generateImage;
15
17
  generateVideo: typeof generateVideo;
16
18
  tempFiles: string[];
@@ -108,6 +108,10 @@ export async function renderImage(
108
108
  prompt: promptText,
109
109
  });
110
110
 
111
+ if (!file.url && ctx.storage) {
112
+ await file.upload(ctx.storage);
113
+ }
114
+
111
115
  ctx.generatedFiles.push(file);
112
116
 
113
117
  return file;
@@ -32,25 +32,9 @@ export interface BlinkingButtonResult {
32
32
  canvasHeight: number;
33
33
  }
34
34
 
35
- /** Result of rendering the blinking button PNGs (Sharp only, no backend). */
36
- export interface BlinkingButtonPngs {
37
- /** Path to the native-size button PNG (with alpha) */
38
- btnPngPath: string;
39
- /** Path to the glow PNG (canvas-size, 60% alpha baked in) */
40
- glowPngPath: string;
41
- /** Native button width (even) */
42
- btnNativeW: number;
43
- /** Native button height (even) */
44
- btnNativeH: number;
45
- /** Canvas width (even, includes padding for max scale + glow) */
46
- canvasWidth: number;
47
- /** Canvas height (even) */
48
- canvasHeight: number;
49
- }
50
-
51
35
  // ─── Helpers ─────────────────────────────────────────────────────────────────
52
36
 
53
- export function hexToRgb(hex: string): { r: number; g: number; b: number } {
37
+ function hexToRgb(hex: string): { r: number; g: number; b: number } {
54
38
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
55
39
  if (!result) return { r: 255, g: 107, b: 0 };
56
40
  return {
@@ -64,7 +48,7 @@ function clamp(value: number, max = 255): number {
64
48
  return Math.min(Math.floor(value), max);
65
49
  }
66
50
 
67
- export function createButtonSvg(
51
+ function createButtonSvg(
68
52
  width: number,
69
53
  height: number,
70
54
  radius: number,
@@ -82,7 +66,7 @@ export function createButtonSvg(
82
66
  </svg>`;
83
67
  }
84
68
 
85
- export function escapeXml(text: string): string {
69
+ function escapeXml(text: string): string {
86
70
  return text
87
71
  .replace(/&/g, "&amp;")
88
72
  .replace(/</g, "&lt;")
@@ -91,7 +75,7 @@ export function escapeXml(text: string): string {
91
75
  .replace(/'/g, "&apos;");
92
76
  }
93
77
 
94
- export function getButtonYPosition(
78
+ function getButtonYPosition(
95
79
  position: "top" | "center" | "bottom",
96
80
  videoHeight: number,
97
81
  buttonHeight: number,
@@ -107,16 +91,16 @@ export function getButtonYPosition(
107
91
  }
108
92
 
109
93
  /** Ensure even dimension for ffmpeg */
110
- export function even(n: number): number {
94
+ function even(n: number): number {
111
95
  return n % 2 === 0 ? n : n + 1;
112
96
  }
113
97
 
114
98
  /**
115
99
  * Elastic ease oscillator as an ffmpeg expression.
116
100
  * Period P seconds, using time variable `tv` ("t" for scale/eq, "T" for geq).
117
- * Returns 0 -> 1.15 (overshoot) -> 1.0 (settle) -> 0 (fall) per cycle.
101
+ * Returns 0 1.15 (overshoot) 1.0 (settle) 0 (fall) per cycle.
118
102
  */
119
- export function oscExpr(tv: string, P: number): string {
103
+ function oscExpr(tv: string, P: number): string {
120
104
  const ph = `(mod(${tv},${P})/${P})`;
121
105
  return `if(lt(${ph},0.25),sin(${ph}/0.25*PI/2)*1.15,if(lt(${ph},0.4),1.15-0.15*(${ph}-0.25)/0.15,cos((${ph}-0.4)/0.6*PI/2)))`;
122
106
  }
@@ -133,18 +117,39 @@ async function resolvePathForBackend(
133
117
  return backend.resolvePath(localPath);
134
118
  }
135
119
 
136
- // ─── PNG Rendering ───────────────────────────────────────────────────────────
120
+ // ─── Main ────────────────────────────────────────────────────────────────────
137
121
 
138
122
  /**
139
- * Render the blinking button and glow PNGs using Sharp.
123
+ * Create a blinking CTA button video using Sharp for static PNG rendering
124
+ * and a single FFmpeg filter_complex for all animation.
140
125
  *
141
- * This is pure image generation — no FFmpeg, no backend calls.
142
- * Returns paths to two temp PNG files ready for use in an ffmpeg filter graph.
126
+ * Architecture:
127
+ * 1. Sharp renders 2 static PNGs: button (native size) + glow (canvas size)
128
+ * 2. FFmpeg filter_complex does per-frame animation via expressions:
129
+ * - eq(gamma, eval=frame) for brightness pulse (0.85x → 1.2x)
130
+ * - scale(eval=frame) for elastic zoom pulse (1.0x → 1.14x)
131
+ * - overlay with (W-w)/2 centering for perfect bbox alignment
132
+ * - Glow scales 15% larger with 60% max opacity baked in
133
+ * 3. Output is ProRes 4444 with alpha channel
134
+ *
135
+ * Works on both local (ffmpeg binary) and cloud (rendi) backends
136
+ * via the FFmpegBackend abstraction.
143
137
  */
144
- export async function renderBlinkingButtonPngs(
138
+ export async function createBlinkingButton(
145
139
  options: BlinkingButtonOptions,
146
- ): Promise<BlinkingButtonPngs> {
147
- const { text, width, height, bgColor, textColor } = options;
140
+ backend: FFmpegBackend,
141
+ ): Promise<BlinkingButtonResult> {
142
+ const {
143
+ text,
144
+ width,
145
+ height,
146
+ duration,
147
+ fps,
148
+ bgColor,
149
+ textColor,
150
+ blinkFrequency = 0.8,
151
+ position = "bottom",
152
+ } = options;
148
153
 
149
154
  const btnWidth = options.buttonWidth ?? Math.floor(width * 0.7);
150
155
  const btnHeight = options.buttonHeight ?? Math.floor(height * 0.09);
@@ -162,6 +167,8 @@ export async function renderBlinkingButtonPngs(
162
167
  const btnNativeW = even(btnWidth);
163
168
  const btnNativeH = even(btnHeight);
164
169
 
170
+ // ── Step 1: Render PNGs with Sharp ─────────────────────────────────────────
171
+
165
172
  const rgb = hexToRgb(bgColor);
166
173
  const topColor = {
167
174
  r: clamp(rgb.r * 1.15),
@@ -275,60 +282,12 @@ export async function renderBlinkingButtonPngs(
275
282
  Bun.write(glowPngPath, glowBuf),
276
283
  ]);
277
284
 
278
- return {
279
- btnPngPath,
280
- glowPngPath,
281
- btnNativeW,
282
- btnNativeH,
283
- canvasWidth: cw,
284
- canvasHeight: ch,
285
- };
286
- }
287
-
288
- // ─── Filter Builder ──────────────────────────────────────────────────────────
289
-
290
- export interface BlinkingButtonFilterParts {
291
- /** Filter_complex lines for the blinking button animation */
292
- filters: string[];
293
- /**
294
- * The output label of the final animated button stream (e.g. "btn_out").
295
- * This is a transparent RGBA video that can be overlaid onto the base.
296
- */
297
- outputLabel: string;
298
- /** Canvas width of the button animation */
299
- canvasWidth: number;
300
- /** Canvas height of the button animation */
301
- canvasHeight: number;
302
- }
303
-
304
- /**
305
- * Build the ffmpeg filter_complex lines for the blinking button animation.
306
- *
307
- * Takes the input indices for the button PNG and glow PNG,
308
- * and returns filter lines that can be merged into a larger filter_complex.
309
- *
310
- * @param btnInputIdx - ffmpeg input index for the button PNG (fed with -loop 1)
311
- * @param glowInputIdx - ffmpeg input index for the glow PNG (fed with -loop 1)
312
- * @param pngs - dimensions from renderBlinkingButtonPngs()
313
- * @param options - animation settings
314
- */
315
- export function buildBlinkingButtonFilter(
316
- btnInputIdx: number,
317
- glowInputIdx: number,
318
- pngs: BlinkingButtonPngs,
319
- options: {
320
- duration: number;
321
- fps: number;
322
- blinkFrequency: number;
323
- },
324
- ): BlinkingButtonFilterParts {
325
- const { duration, fps, blinkFrequency } = options;
326
- const { btnNativeW, btnNativeH, canvasWidth: cw, canvasHeight: ch } = pngs;
285
+ // ── Step 2: Build ffmpeg filter_complex ────────────────────────────────────
327
286
 
328
287
  const P = blinkFrequency;
329
288
  const osc = oscExpr("t", P);
330
289
 
331
- // eq gamma for brightness: 0.85 at rest -> 1.2 at peak
290
+ // eq gamma for brightness: 0.85 at rest 1.2 at peak
332
291
  const gammaExpr = `0.85+0.35*max(0,${osc})`;
333
292
 
334
293
  // Button scale (on native-size input)
@@ -339,75 +298,39 @@ export function buildBlinkingButtonFilter(
339
298
  const glowSW = `ceil(${cw}*(1.0+0.12*(${osc}))*1.15/2)*2`;
340
299
  const glowSH = `ceil(${ch}*(1.0+0.12*(${osc}))*1.15/2)*2`;
341
300
 
342
- const filters = [
301
+ // Filter complex: uses overlay for centering (no crop+pad drift)
302
+ const filterComplex = [
343
303
  // Three transparent canvases (base + one per animated layer)
344
- `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[btn_base]`,
304
+ `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[base]`,
345
305
  `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[btn_canvas]`,
346
306
  `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[glow_canvas]`,
347
307
 
348
- // Button: split alpha -> eq(gamma) -> merge alpha -> scale -> center on canvas
349
- `[${btnInputIdx}:v]format=rgba,split[btn_rgb][btn_a]`,
350
- `[btn_a]alphaextract[btn_alpha]`,
308
+ // Button: split alpha eq(gamma) merge alpha scale center on canvas
309
+ `[0:v]format=rgba,split[btn_rgb][btn_a]`,
310
+ `[btn_a]alphaextract[alpha]`,
351
311
  `[btn_rgb]eq=gamma='${gammaExpr}':eval=frame[btn_eq]`,
352
- `[btn_eq][btn_alpha]alphamerge,format=rgba,` +
312
+ `[btn_eq][alpha]alphamerge,format=rgba,` +
353
313
  `scale=w='${btnSW}':h='${btnSH}':eval=frame:flags=lanczos` +
354
314
  `[btn_scaled]`,
355
- `[btn_canvas][btn_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[btn_layer]`,
315
+ `[btn_canvas][btn_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[btn]`,
356
316
 
357
- // Glow: scale -> center on canvas (opacity baked in PNG)
358
- `[${glowInputIdx}:v]format=rgba,` +
317
+ // Glow: scale center on canvas (opacity baked in PNG)
318
+ `[1:v]format=rgba,` +
359
319
  `scale=w='${glowSW}':h='${glowSH}':eval=frame:flags=lanczos` +
360
320
  `[glow_scaled]`,
361
- `[glow_canvas][glow_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[glow_layer]`,
362
-
363
- // Final composite: base -> glow -> button
364
- `[btn_base][glow_layer]overlay=format=auto:shortest=1[btn_bg]`,
365
- `[btn_bg][btn_layer]overlay=format=auto:shortest=1[btn_out]`,
366
- ];
367
-
368
- return {
369
- filters,
370
- outputLabel: "btn_out",
371
- canvasWidth: cw,
372
- canvasHeight: ch,
373
- };
374
- }
321
+ `[glow_canvas][glow_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[glow]`,
375
322
 
376
- // ─── Legacy standalone API ───────────────────────────────────────────────────
377
-
378
- /**
379
- * Create a blinking CTA button video as a standalone operation.
380
- *
381
- * This is the legacy API that performs its own backend.run() call.
382
- * Prefer using renderBlinkingButtonPngs() + buildBlinkingButtonFilter()
383
- * to merge the button animation into a larger filter graph.
384
- */
385
- export async function createBlinkingButton(
386
- options: BlinkingButtonOptions,
387
- backend: FFmpegBackend,
388
- ): Promise<BlinkingButtonResult> {
389
- const {
390
- duration,
391
- fps,
392
- blinkFrequency = 0.8,
393
- position = "bottom",
394
- width,
395
- height,
396
- } = options;
323
+ // Final composite: base glow → button
324
+ `[base][glow]overlay=format=auto:shortest=1[bg]`,
325
+ `[bg][btn]overlay=format=auto:shortest=1[out]`,
326
+ ].join(";");
397
327
 
398
- const pngs = await renderBlinkingButtonPngs(options);
328
+ // ── Step 3: Run ffmpeg via backend ─────────────────────────────────────────
399
329
 
400
330
  // Resolve PNG paths for cloud backends (uploads to storage)
401
- const btnInput = await resolvePathForBackend(pngs.btnPngPath, backend);
402
- const glowInput = await resolvePathForBackend(pngs.glowPngPath, backend);
331
+ const btnInput = await resolvePathForBackend(btnPngPath, backend);
332
+ const glowInput = await resolvePathForBackend(glowPngPath, backend);
403
333
 
404
- const filterParts = buildBlinkingButtonFilter(0, 1, pngs, {
405
- duration,
406
- fps,
407
- blinkFrequency,
408
- });
409
-
410
- const ts = Date.now();
411
334
  const outputPath = `/tmp/varg-blink-btn-${ts}.mov`;
412
335
 
413
336
  const result = await backend.run({
@@ -415,10 +338,10 @@ export async function createBlinkingButton(
415
338
  { path: btnInput, options: ["-loop", "1"] },
416
339
  { path: glowInput, options: ["-loop", "1"] },
417
340
  ],
418
- filterComplex: filterParts.filters.join(";"),
341
+ filterComplex,
419
342
  outputArgs: [
420
343
  "-map",
421
- `[${filterParts.outputLabel}]`,
344
+ "[out]",
422
345
  "-c:v",
423
346
  "prores_ks",
424
347
  "-profile:v",
@@ -431,7 +354,8 @@ export async function createBlinkingButton(
431
354
  outputPath,
432
355
  });
433
356
 
434
- const { canvasWidth: cw, canvasHeight: ch } = pngs;
357
+ // ── Calculate overlay position on full video frame ─────────────────────────
358
+
435
359
  const btnY = getButtonYPosition(position, height, ch);
436
360
  const btnX = Math.floor((width - cw) / 2);
437
361
 
@@ -1,31 +1,33 @@
1
- import { depsToKey } from "../../ai-sdk/cache";
1
+ import { editly } from "../../ai-sdk/providers/editly";
2
2
  import type {
3
3
  FFmpegBackend,
4
- FFmpegInput,
5
4
  FFmpegOutput,
6
5
  } from "../../ai-sdk/providers/editly/backends/types";
7
- import {
8
- escapeDrawText,
9
- parseSize,
10
- resolvePositionForOverlay,
11
- } from "../../ai-sdk/providers/editly/layers";
12
6
  import type {
7
+ Clip,
8
+ ImageOverlayLayer,
9
+ Layer,
13
10
  Position,
14
11
  PositionObject,
15
12
  SizeValue,
13
+ TitleLayer,
16
14
  } from "../../ai-sdk/providers/editly/types";
17
15
  import type { PackshotProps, VargElement } from "../types";
18
16
  import type { RenderContext } from "./context";
19
17
  import { renderImage } from "./image";
20
- import {
21
- type BlinkingButtonPngs,
22
- buildBlinkingButtonFilter,
23
- even,
24
- getButtonYPosition,
25
- renderBlinkingButtonPngs,
26
- } from "./packshot/blinking-button";
18
+ import { createBlinkingButton } from "./packshot/blinking-button";
27
19
 
28
- // ─── Position helpers ────────────────────────────────────────────────────────
20
+ /**
21
+ * Resolve an FFmpegOutput to a string path/URL via the backend.
22
+ * Local backend returns local paths; cloud backends upload and return URLs.
23
+ */
24
+ async function resolveInputMaybeUpload(
25
+ input: FFmpegOutput,
26
+ backend: FFmpegBackend,
27
+ ): Promise<string> {
28
+ if (input.type === "url") return input.url;
29
+ return backend.resolvePath(input.path);
30
+ }
29
31
 
30
32
  /**
31
33
  * Type guard: returns true if `pos` is a PositionObject ({ x, y }).
@@ -36,6 +38,12 @@ function isPositionObject(pos: Position): pos is PositionObject {
36
38
 
37
39
  /**
38
40
  * Parse a SizeValue to a normalised 0-1 fraction.
41
+ *
42
+ * - `number` – treated as a raw pixel value; divided by `total`.
43
+ * - `"50%"` – percentage string; divided by 100.
44
+ * - `"120px"` – pixel string; parsed and divided by `total`.
45
+ *
46
+ * Returns `0.5` (centre) when the value cannot be parsed.
39
47
  */
40
48
  function sizeValueToFraction(value: SizeValue, total: number): number {
41
49
  if (typeof value === "number") {
@@ -54,6 +62,16 @@ function sizeValueToFraction(value: SizeValue, total: number): number {
54
62
  return 0.5;
55
63
  }
56
64
 
65
+ /**
66
+ * Convert a PositionObject to the nearest string Position.
67
+ *
68
+ * The x axis is split into thirds: left (< 0.33), center, right (> 0.67).
69
+ * The y axis is split into thirds: top (< 0.33), center, bottom (> 0.67).
70
+ *
71
+ * `refWidth` / `refHeight` are only needed when the SizeValue is in pixels;
72
+ * when unknown, pass 1 and only percentage / fraction values will resolve
73
+ * correctly.
74
+ */
57
75
  function positionObjectToString(
58
76
  obj: PositionObject,
59
77
  refWidth = 1,
@@ -70,7 +88,7 @@ function positionObjectToString(
70
88
  if (row === "center" && col === "center") return "center";
71
89
  if (row === "center")
72
90
  return `center-${col}` as "center-left" | "center-right";
73
- if (col === "center") return row;
91
+ if (col === "center") return row; // "top" | "bottom"
74
92
  return `${row}-${col}` as
75
93
  | "top-left"
76
94
  | "top-right"
@@ -84,295 +102,6 @@ function resolvePosition(pos: Position | undefined): Position {
84
102
  return pos;
85
103
  }
86
104
 
87
- function mapCtaPosition(
88
- pos: Position | undefined,
89
- refHeight = 1,
90
- ): "top" | "center" | "bottom" {
91
- if (pos === undefined) return "bottom";
92
- if (isPositionObject(pos)) {
93
- const fy = sizeValueToFraction(pos.y, refHeight);
94
- if (fy < 0.33) return "top";
95
- if (fy > 0.67) return "bottom";
96
- return "center";
97
- }
98
- switch (pos) {
99
- case "top":
100
- case "top-left":
101
- case "top-right":
102
- return "top";
103
- case "center":
104
- case "center-left":
105
- case "center-right":
106
- return "center";
107
- default:
108
- return "bottom";
109
- }
110
- }
111
-
112
- // ─── Cache key ───────────────────────────────────────────────────────────────
113
-
114
- function computePackshotCacheKey(
115
- props: PackshotProps,
116
- width: number,
117
- height: number,
118
- fps: number,
119
- ): string {
120
- // Background key: color string, or the image element's src URL
121
- let bgKey = "#000000";
122
- if (props.background) {
123
- if (typeof props.background === "string") {
124
- bgKey = props.background;
125
- } else {
126
- // Image element — use the src prop for cache key
127
- const imgProps = props.background.props as Record<string, unknown>;
128
- bgKey = `img:${imgProps.src ?? imgProps.prompt ?? JSON.stringify(imgProps)}`;
129
- }
130
- }
131
-
132
- const deps = [
133
- "packshot",
134
- bgKey,
135
- props.logo ?? "",
136
- String(resolvePosition(props.logoPosition ?? "center")),
137
- String(props.logoSize ?? "40%"),
138
- props.title ?? "",
139
- props.titleColor ?? "#FFFFFF",
140
- String(resolvePosition(props.titlePosition ?? "center")),
141
- props.cta ?? "",
142
- props.ctaColor ?? "#FF6B00",
143
- props.ctaTextColor ?? "#FFFFFF",
144
- String(props.blinkCta ?? false),
145
- String(props.blinkFrequency ?? 0.8),
146
- String(resolvePosition(props.ctaPosition ?? "bottom")),
147
- props.ctaSize ? `${props.ctaSize.width}x${props.ctaSize.height}` : "",
148
- String(props.duration ?? 3),
149
- String(width),
150
- String(height),
151
- String(fps),
152
- ];
153
-
154
- return depsToKey("packshot", deps);
155
- }
156
-
157
- // ─── Unified filter builder ──────────────────────────────────────────────────
158
-
159
- interface PackshotFilterGraph {
160
- inputs: FFmpegInput[];
161
- filterComplex: string;
162
- outputArgs: string[];
163
- }
164
-
165
- /**
166
- * Build a single FFmpeg filter_complex that produces the complete packshot video.
167
- *
168
- * Merges what was previously 3 separate backend.run() calls into one:
169
- * 1. Background (fill-color or image) + logo overlay + title drawtext
170
- * 2. Blinking button animation (if enabled)
171
- * 3. Final composite of button over base
172
- */
173
- function buildPackshotFilter(opts: {
174
- width: number;
175
- height: number;
176
- fps: number;
177
- duration: number;
178
- // Background
179
- bgType: "fill-color" | "image";
180
- bgColorOrPath: string; // hex color or resolved path/URL
181
- // Logo (optional)
182
- logoPath?: string;
183
- logoPosition: Position;
184
- logoSize: SizeValue;
185
- // Title (optional)
186
- titleText?: string;
187
- titleColor: string;
188
- titlePosition: Position;
189
- // Static CTA (optional, mutually exclusive with blinkCta)
190
- staticCtaText?: string;
191
- staticCtaColor?: string;
192
- staticCtaPosition?: Position;
193
- // Blinking CTA (optional)
194
- blinkCta?: {
195
- pngs: BlinkingButtonPngs;
196
- btnPngPath: string; // resolved path/URL
197
- glowPngPath: string; // resolved path/URL
198
- blinkFrequency: number;
199
- position: "top" | "center" | "bottom";
200
- };
201
- }): PackshotFilterGraph {
202
- const { width, height, fps, duration } = opts;
203
- const filters: string[] = [];
204
- const inputs: FFmpegInput[] = [];
205
- let inputIdx = 0;
206
- let currentLabel: string;
207
-
208
- // ── Background ─────────────────────────────────────────────────────────────
209
-
210
- if (opts.bgType === "fill-color") {
211
- const bgLabel = "bg0";
212
- filters.push(
213
- `color=c=${opts.bgColorOrPath}:s=${width}x${height}:d=${duration}:r=${fps}[${bgLabel}]`,
214
- );
215
- currentLabel = bgLabel;
216
- } else {
217
- // Image background with cover mode
218
- const bgLabel = "bg0";
219
- inputs.push(opts.bgColorOrPath); // plain string = file path/URL
220
- filters.push(
221
- `[${inputIdx}:v]loop=loop=-1:size=1:start=0,fps=${fps},trim=duration=${duration},` +
222
- `scale=${width}:${height}:force_original_aspect_ratio=increase,` +
223
- `crop=${width}:${height},setsar=1,settb=1/${fps}[${bgLabel}]`,
224
- );
225
- inputIdx++;
226
- currentLabel = bgLabel;
227
- }
228
-
229
- // ── Logo overlay ───────────────────────────────────────────────────────────
230
-
231
- if (opts.logoPath) {
232
- const logoInputIdx = inputIdx;
233
- inputs.push(opts.logoPath);
234
- inputIdx++;
235
-
236
- const targetWidth = parseSize(opts.logoSize, width);
237
- const logoScaleLabel = `logo_s`;
238
- filters.push(
239
- `[${logoInputIdx}:v]scale=${targetWidth}:-2,loop=loop=-1:size=1:start=0,fps=${fps},` +
240
- `trim=duration=${duration},setsar=1,settb=1/${fps}[${logoScaleLabel}]`,
241
- );
242
-
243
- const resolvedPos = resolvePosition(opts.logoPosition);
244
- const { x, y } = resolvePositionForOverlay(resolvedPos, width, height);
245
- const logoOutLabel = "logo_out";
246
- filters.push(
247
- `[${currentLabel}][${logoScaleLabel}]overlay=${x}:${y}:shortest=1[${logoOutLabel}]`,
248
- );
249
- currentLabel = logoOutLabel;
250
- }
251
-
252
- // ── Title drawtext ─────────────────────────────────────────────────────────
253
-
254
- if (opts.titleText) {
255
- const text = escapeDrawText(opts.titleText);
256
- const color = opts.titleColor;
257
-
258
- const maxFontSize = Math.round(Math.min(width, height) * 0.08);
259
- const maxTextWidth = width * 0.9;
260
- const fittedFontSize = Math.floor(
261
- maxTextWidth / (opts.titleText.length * 0.55),
262
- );
263
- const fontSize = Math.max(16, Math.min(maxFontSize, fittedFontSize));
264
-
265
- let x = "(w-text_w)/2";
266
- let y = "(h-text_h)/2";
267
-
268
- const pos = resolvePosition(opts.titlePosition);
269
- if (typeof pos === "string") {
270
- if (pos.includes("left")) x = "w*0.1";
271
- if (pos.includes("right")) x = "w*0.9-text_w";
272
- if (pos.includes("top")) y = "h*0.1";
273
- if (pos.includes("bottom")) y = "h*0.9-text_h";
274
- }
275
-
276
- const titleOutLabel = "title_out";
277
- filters.push(
278
- `[${currentLabel}]drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${color}:x=${x}:y=${y}[${titleOutLabel}]`,
279
- );
280
- currentLabel = titleOutLabel;
281
- }
282
-
283
- // ── Static CTA drawtext (non-blinking) ─────────────────────────────────────
284
-
285
- if (opts.staticCtaText) {
286
- const text = escapeDrawText(opts.staticCtaText);
287
- const color = opts.staticCtaColor ?? "white";
288
-
289
- const maxFontSize = Math.round(Math.min(width, height) * 0.08);
290
- const maxTextWidth = width * 0.9;
291
- const fittedFontSize = Math.floor(
292
- maxTextWidth / (opts.staticCtaText.length * 0.55),
293
- );
294
- const fontSize = Math.max(16, Math.min(maxFontSize, fittedFontSize));
295
-
296
- let x = "(w-text_w)/2";
297
- let y = "(h-text_h)/2";
298
-
299
- const pos = resolvePosition(opts.staticCtaPosition ?? "bottom");
300
- if (typeof pos === "string") {
301
- if (pos.includes("left")) x = "w*0.1";
302
- if (pos.includes("right")) x = "w*0.9-text_w";
303
- if (pos.includes("top")) y = "h*0.1";
304
- if (pos.includes("bottom")) y = "h*0.9-text_h";
305
- }
306
-
307
- const ctaOutLabel = "cta_out";
308
- filters.push(
309
- `[${currentLabel}]drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${color}:x=${x}:y=${y}[${ctaOutLabel}]`,
310
- );
311
- currentLabel = ctaOutLabel;
312
- }
313
-
314
- // ── Blinking CTA overlay ───────────────────────────────────────────────────
315
-
316
- if (opts.blinkCta) {
317
- const { pngs, btnPngPath, glowPngPath, blinkFrequency, position } =
318
- opts.blinkCta;
319
-
320
- const btnInputIdx = inputIdx;
321
- inputs.push({ path: btnPngPath, options: ["-loop", "1"] });
322
- inputIdx++;
323
-
324
- const glowInputIdx = inputIdx;
325
- inputs.push({ path: glowPngPath, options: ["-loop", "1"] });
326
- inputIdx++;
327
-
328
- const btnFilter = buildBlinkingButtonFilter(
329
- btnInputIdx,
330
- glowInputIdx,
331
- pngs,
332
- { duration, fps, blinkFrequency },
333
- );
334
-
335
- filters.push(...btnFilter.filters);
336
-
337
- // Overlay the animated button onto the base at the correct position
338
- const btnY = getButtonYPosition(position, height, pngs.canvasHeight);
339
- const btnX = Math.floor((width - pngs.canvasWidth) / 2);
340
-
341
- const finalLabel = "final";
342
- filters.push(
343
- `[${currentLabel}][${btnFilter.outputLabel}]overlay=${btnX}:${btnY}:format=auto[${finalLabel}]`,
344
- );
345
- currentLabel = finalLabel;
346
- }
347
-
348
- // ── Output ─────────────────────────────────────────────────────────────────
349
-
350
- const outputArgs = [
351
- "-map",
352
- `[${currentLabel}]`,
353
- "-r",
354
- String(fps),
355
- "-c:v",
356
- "libx264",
357
- "-preset",
358
- "fast",
359
- "-crf",
360
- "18",
361
- "-pix_fmt",
362
- "yuv420p",
363
- "-movflags",
364
- "+faststart",
365
- ];
366
-
367
- return {
368
- inputs,
369
- filterComplex: filters.join(";"),
370
- outputArgs,
371
- };
372
- }
373
-
374
- // ─── Main render function ────────────────────────────────────────────────────
375
-
376
105
  export async function renderPackshot(
377
106
  element: VargElement<"packshot">,
378
107
  ctx: RenderContext,
@@ -380,140 +109,173 @@ export async function renderPackshot(
380
109
  const props = element.props as PackshotProps;
381
110
  const duration = props.duration ?? 3;
382
111
 
383
- // ── Check cache ────────────────────────────────────────────────────────────
384
-
385
- const cacheKey = computePackshotCacheKey(
386
- props,
387
- ctx.width,
388
- ctx.height,
389
- ctx.fps,
390
- );
391
-
392
- if (ctx.cache) {
393
- const cached = await ctx.cache.get(cacheKey);
394
- if (cached) {
395
- const { url } = cached as { url: string; mediaType: string };
396
- return url;
397
- }
398
- }
399
-
400
- // ── Resolve background ─────────────────────────────────────────────────────
401
-
402
- let bgType: "fill-color" | "image" = "fill-color";
403
- let bgColorOrPath = "#000000";
112
+ const layers: Layer[] = [];
404
113
 
114
+ // ===== BACKGROUND LAYER =====
405
115
  if (props.background) {
406
116
  if (typeof props.background === "string") {
407
- bgType = "fill-color";
408
- bgColorOrPath = props.background;
117
+ layers.push({
118
+ type: "fill-color" as const,
119
+ color: props.background,
120
+ });
409
121
  } else {
410
- bgType = "image";
411
122
  const bgFile = await renderImage(props.background, ctx);
412
- bgColorOrPath = await ctx.backend.resolvePath(bgFile);
123
+ const bgPath = await ctx.backend.resolvePath(bgFile);
124
+ layers.push({
125
+ type: "image" as const,
126
+ path: bgPath,
127
+ resizeMode: "cover" as const,
128
+ });
413
129
  }
130
+ } else {
131
+ layers.push({
132
+ type: "fill-color" as const,
133
+ color: "#000000",
134
+ });
414
135
  }
415
136
 
416
- // ── Resolve logo ───────────────────────────────────────────────────────────
417
-
418
- let logoPath: string | undefined;
137
+ // ===== LOGO LAYER =====
419
138
  if (props.logo) {
420
- logoPath = await ctx.backend.resolvePath(props.logo);
139
+ const logoLayer: ImageOverlayLayer = {
140
+ type: "image-overlay",
141
+ path: props.logo,
142
+ position: resolvePosition(props.logoPosition ?? "center"),
143
+ width: props.logoSize ?? "40%",
144
+ };
145
+ layers.push(logoLayer);
421
146
  }
422
147
 
423
- // ── Render blinking button PNGs ────────────────────────────────────────────
424
-
425
- let blinkCtaOpts:
426
- | {
427
- pngs: BlinkingButtonPngs;
428
- btnPngPath: string;
429
- glowPngPath: string;
430
- blinkFrequency: number;
431
- position: "top" | "center" | "bottom";
432
- }
433
- | undefined;
148
+ // ===== TITLE LAYER =====
149
+ if (props.title) {
150
+ const titleLayer: TitleLayer = {
151
+ type: "title",
152
+ text: props.title,
153
+ textColor: props.titleColor ?? "#FFFFFF",
154
+ position: resolvePosition(props.titlePosition ?? "center"),
155
+ };
156
+ layers.push(titleLayer);
157
+ }
434
158
 
435
- if (props.cta && props.blinkCta) {
436
- const pngs = await renderBlinkingButtonPngs({
159
+ // ===== STATIC CTA (non-blinking) =====
160
+ if (props.cta && !props.blinkCta) {
161
+ const ctaLayer: TitleLayer = {
162
+ type: "title",
437
163
  text: props.cta,
438
- width: ctx.width,
439
- height: ctx.height,
440
- duration,
441
- fps: ctx.fps,
442
- bgColor: props.ctaColor ?? "#FF6B00",
443
- textColor: props.ctaTextColor ?? "#FFFFFF",
444
- blinkFrequency: props.blinkFrequency ?? 0.8,
445
- position: mapCtaPosition(props.ctaPosition, ctx.height),
446
- buttonWidth: props.ctaSize?.width,
447
- buttonHeight: props.ctaSize?.height,
448
- });
449
-
450
- // Upload PNGs for cloud backends
451
- const btnPngPath = await ctx.backend.resolvePath(pngs.btnPngPath);
452
- const glowPngPath = await ctx.backend.resolvePath(pngs.glowPngPath);
453
-
454
- blinkCtaOpts = {
455
- pngs,
456
- btnPngPath,
457
- glowPngPath,
458
- blinkFrequency: props.blinkFrequency ?? 0.8,
459
- position: mapCtaPosition(props.ctaPosition, ctx.height),
164
+ textColor: props.ctaColor ?? "white",
165
+ position: resolvePosition(props.ctaPosition ?? "bottom"),
460
166
  };
167
+ layers.push(ctaLayer);
461
168
  }
462
169
 
463
- // ── Build unified filter graph ─────────────────────────────────────────────
170
+ // Create base packshot video
171
+ const clip: Clip = {
172
+ layers,
173
+ duration,
174
+ };
175
+
176
+ const basePath = `/tmp/varg-packshot-${Date.now()}.mp4`;
464
177
 
465
- const graph = buildPackshotFilter({
178
+ const baseResult = await editly({
179
+ outPath: basePath,
466
180
  width: ctx.width,
467
181
  height: ctx.height,
468
182
  fps: ctx.fps,
469
- duration,
470
- bgType,
471
- bgColorOrPath,
472
- logoPath,
473
- logoPosition: props.logoPosition ?? "center",
474
- logoSize: props.logoSize ?? "40%",
475
- titleText: props.title,
476
- titleColor: props.titleColor ?? "#FFFFFF",
477
- titlePosition: props.titlePosition ?? "center",
478
- staticCtaText: props.cta && !props.blinkCta ? props.cta : undefined,
479
- staticCtaColor:
480
- props.cta && !props.blinkCta ? (props.ctaColor ?? "white") : undefined,
481
- staticCtaPosition:
482
- props.cta && !props.blinkCta
483
- ? resolvePosition(props.ctaPosition ?? "bottom")
484
- : undefined,
485
- blinkCta: blinkCtaOpts,
183
+ clips: [clip],
184
+ backend: ctx.backend,
486
185
  });
487
186
 
488
- // ── Execute single backend.run() ───────────────────────────────────────────
187
+ // ===== BLINKING CTA OVERLAY =====
188
+ if (props.cta && props.blinkCta) {
189
+ const btn = await createBlinkingButton(
190
+ {
191
+ text: props.cta,
192
+ width: ctx.width,
193
+ height: ctx.height,
194
+ duration,
195
+ fps: ctx.fps,
196
+ bgColor: props.ctaColor ?? "#FF6B00",
197
+ textColor: props.ctaTextColor ?? "#FFFFFF",
198
+ blinkFrequency: props.blinkFrequency ?? 0.8,
199
+ position: mapCtaPosition(props.ctaPosition, ctx.height),
200
+ buttonWidth: props.ctaSize?.width,
201
+ buttonHeight: props.ctaSize?.height,
202
+ },
203
+ ctx.backend,
204
+ );
489
205
 
490
- const outputPath = `/tmp/varg-packshot-${Date.now()}.mp4`;
206
+ // Composite button overlay at correct position on base video via backend
207
+ const baseInput = await resolveInputMaybeUpload(
208
+ baseResult.output,
209
+ ctx.backend,
210
+ );
211
+ const btnInput = await resolveInputMaybeUpload(btn.output, ctx.backend);
212
+
213
+ const finalPath = `/tmp/varg-packshot-final-${Date.now()}.mp4`;
214
+
215
+ const overlayResult = await ctx.backend.run({
216
+ inputs: [baseInput, btnInput],
217
+ filterComplex: `[0:v][1:v]overlay=${btn.x}:${btn.y}:format=auto`,
218
+ outputArgs: [
219
+ "-c:v",
220
+ "libx264",
221
+ "-preset",
222
+ "fast",
223
+ "-crf",
224
+ "18",
225
+ "-pix_fmt",
226
+ "yuv420p",
227
+ ],
228
+ outputPath: finalPath,
229
+ });
491
230
 
492
- const result = await ctx.backend.run({
493
- inputs: graph.inputs,
494
- filterComplex: graph.filterComplex,
495
- outputArgs: graph.outputArgs,
496
- outputPath,
497
- });
231
+ if (overlayResult.output.type === "file") {
232
+ ctx.tempFiles.push(basePath, overlayResult.output.path);
233
+ return overlayResult.output.path;
234
+ }
235
+ // Cloud backend returns URL
236
+ return overlayResult.output.url;
237
+ }
498
238
 
499
- // ── Cache the result ───────────────────────────────────────────────────────
239
+ if (baseResult.output.type === "url") return baseResult.output.url;
240
+ ctx.tempFiles.push(basePath);
241
+ return basePath;
242
+ }
500
243
 
501
- const outputUrl =
502
- result.output.type === "url" ? result.output.url : result.output.path;
244
+ /**
245
+ * Map a Position (string literal **or** PositionObject) to the vertical
246
+ * bucket that the blinking-button renderer understands.
247
+ *
248
+ * When a PositionObject ({ x, y }) is provided the y-coordinate is
249
+ * normalised to a 0-1 fraction and mapped to "top" (< 0.33),
250
+ * "center" (0.33-0.67), or "bottom" (> 0.67). Pixel values are
251
+ * resolved against `refHeight` (defaults to 1, which means only
252
+ * percentages will convert correctly when the caller does not supply it).
253
+ */
254
+ function mapCtaPosition(
255
+ pos: Position | undefined,
256
+ refHeight = 1,
257
+ ): "top" | "center" | "bottom" {
258
+ if (pos === undefined) return "bottom";
503
259
 
504
- if (ctx.cache) {
505
- await ctx.cache.set(cacheKey, {
506
- url: outputUrl,
507
- mediaType: "video/mp4",
508
- });
260
+ // Handle PositionObject ({ x, y }) explicitly
261
+ if (isPositionObject(pos)) {
262
+ const fy = sizeValueToFraction(pos.y, refHeight);
263
+ if (fy < 0.33) return "top";
264
+ if (fy > 0.67) return "bottom";
265
+ return "center";
509
266
  }
510
267
 
511
- // ── Return ─────────────────────────────────────────────────────────────────
512
-
513
- if (result.output.type === "file") {
514
- ctx.tempFiles.push(result.output.path);
515
- return result.output.path;
268
+ // String literal positions
269
+ switch (pos) {
270
+ case "top":
271
+ case "top-left":
272
+ case "top-right":
273
+ return "top";
274
+ case "center":
275
+ case "center-left":
276
+ case "center-right":
277
+ return "center";
278
+ default:
279
+ return "bottom";
516
280
  }
517
-
518
- return result.output.url;
519
281
  }
@@ -153,6 +153,7 @@ export async function renderRoot(
153
153
  height: props.height ?? 1080,
154
154
  fps: props.fps ?? 30,
155
155
  cache: cacheStorage,
156
+ storage: options.storage,
156
157
  generateImage: wrapGenerateImage,
157
158
  generateVideo: wrapGenerateVideo,
158
159
  tempFiles,
@@ -161,6 +161,10 @@ export async function renderVideo(
161
161
  prompt: promptText,
162
162
  });
163
163
 
164
+ if (!file.url && ctx.storage) {
165
+ await file.upload(ctx.storage);
166
+ }
167
+
164
168
  ctx.generatedFiles.push(file);
165
169
 
166
170
  return file;
@@ -1,157 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- type BlinkingButtonPngs,
4
- buildBlinkingButtonFilter,
5
- even,
6
- getButtonYPosition,
7
- hexToRgb,
8
- oscExpr,
9
- } from "./blinking-button";
10
-
11
- // ─── Helper unit tests ───────────────────────────────────────────────────────
12
-
13
- describe("blinking-button helpers", () => {
14
- test("hexToRgb parses valid hex colors", () => {
15
- expect(hexToRgb("#FF6B00")).toEqual({ r: 255, g: 107, b: 0 });
16
- expect(hexToRgb("#000000")).toEqual({ r: 0, g: 0, b: 0 });
17
- expect(hexToRgb("#FFFFFF")).toEqual({ r: 255, g: 255, b: 255 });
18
- expect(hexToRgb("FF6B00")).toEqual({ r: 255, g: 107, b: 0 }); // no #
19
- });
20
-
21
- test("hexToRgb returns default for invalid input", () => {
22
- expect(hexToRgb("invalid")).toEqual({ r: 255, g: 107, b: 0 });
23
- expect(hexToRgb("")).toEqual({ r: 255, g: 107, b: 0 });
24
- });
25
-
26
- test("even ensures even numbers", () => {
27
- expect(even(10)).toBe(10);
28
- expect(even(11)).toBe(12);
29
- expect(even(0)).toBe(0);
30
- expect(even(1)).toBe(2);
31
- });
32
-
33
- test("oscExpr produces well-formed ffmpeg expression", () => {
34
- const expr = oscExpr("t", 0.8);
35
- expect(expr).toContain("mod(t,0.8)");
36
- expect(expr).toContain("sin(");
37
- expect(expr).toContain("cos(");
38
- expect(expr).toContain("1.15");
39
- expect(expr).toContain("PI");
40
- // Should not contain any undefined or NaN
41
- expect(expr).not.toContain("undefined");
42
- expect(expr).not.toContain("NaN");
43
- });
44
-
45
- test("getButtonYPosition returns correct positions", () => {
46
- const videoHeight = 1920;
47
- const buttonHeight = 200;
48
-
49
- const topY = getButtonYPosition("top", videoHeight, buttonHeight);
50
- const centerY = getButtonYPosition("center", videoHeight, buttonHeight);
51
- const bottomY = getButtonYPosition("bottom", videoHeight, buttonHeight);
52
-
53
- // Top should be near the top
54
- expect(topY).toBe(Math.floor(1920 * 0.15));
55
- // Center should be vertically centered
56
- expect(centerY).toBe(Math.floor((1920 - 200) / 2));
57
- // Bottom should be in the lower portion
58
- expect(bottomY).toBe(Math.floor(1920 * 0.78 - 200 / 2));
59
-
60
- // Ordering
61
- expect(topY).toBeLessThan(centerY);
62
- expect(centerY).toBeLessThan(bottomY);
63
- });
64
- });
65
-
66
- // ─── buildBlinkingButtonFilter tests ─────────────────────────────────────────
67
-
68
- describe("buildBlinkingButtonFilter", () => {
69
- const mockPngs: BlinkingButtonPngs = {
70
- btnPngPath: "/tmp/test-btn.png",
71
- glowPngPath: "/tmp/test-glow.png",
72
- btnNativeW: 756,
73
- btnNativeH: 172,
74
- canvasWidth: 1000,
75
- canvasHeight: 300,
76
- };
77
-
78
- test("returns correct structure", () => {
79
- const result = buildBlinkingButtonFilter(2, 3, mockPngs, {
80
- duration: 5,
81
- fps: 30,
82
- blinkFrequency: 0.8,
83
- });
84
-
85
- expect(result.outputLabel).toBe("btn_out");
86
- expect(result.canvasWidth).toBe(1000);
87
- expect(result.canvasHeight).toBe(300);
88
- expect(result.filters).toBeInstanceOf(Array);
89
- expect(result.filters.length).toBeGreaterThan(0);
90
- });
91
-
92
- test("uses correct input indices", () => {
93
- const result = buildBlinkingButtonFilter(5, 6, mockPngs, {
94
- duration: 3,
95
- fps: 30,
96
- blinkFrequency: 0.8,
97
- });
98
-
99
- const joined = result.filters.join(";");
100
- // Should reference input 5 for button and 6 for glow
101
- expect(joined).toContain("[5:v]");
102
- expect(joined).toContain("[6:v]");
103
- // Should NOT reference old indices
104
- expect(joined).not.toContain("[0:v]");
105
- expect(joined).not.toContain("[1:v]");
106
- });
107
-
108
- test("filter contains animation expressions", () => {
109
- const result = buildBlinkingButtonFilter(0, 1, mockPngs, {
110
- duration: 5,
111
- fps: 30,
112
- blinkFrequency: 0.8,
113
- });
114
-
115
- const joined = result.filters.join(";");
116
- // Should contain gamma for brightness animation
117
- expect(joined).toContain("eq=gamma=");
118
- expect(joined).toContain("eval=frame");
119
- // Should contain scale for zoom animation
120
- expect(joined).toContain("scale=w=");
121
- // Should contain overlay for compositing
122
- expect(joined).toContain("overlay=");
123
- // Should contain transparent canvas generation
124
- expect(joined).toContain("color=0x00000000");
125
- // Should contain format=rgba for alpha handling
126
- expect(joined).toContain("format=rgba");
127
- });
128
-
129
- test("filter contains correct dimensions", () => {
130
- const result = buildBlinkingButtonFilter(0, 1, mockPngs, {
131
- duration: 3,
132
- fps: 24,
133
- blinkFrequency: 1.0,
134
- });
135
-
136
- const joined = result.filters.join(";");
137
- // Canvas dimensions
138
- expect(joined).toContain(
139
- `s=${mockPngs.canvasWidth}x${mockPngs.canvasHeight}`,
140
- );
141
- // FPS
142
- expect(joined).toContain("r=24");
143
- // Duration
144
- expect(joined).toContain("d=3");
145
- });
146
-
147
- test("final output label is btn_out", () => {
148
- const result = buildBlinkingButtonFilter(0, 1, mockPngs, {
149
- duration: 5,
150
- fps: 30,
151
- blinkFrequency: 0.8,
152
- });
153
-
154
- const lastFilter = result.filters[result.filters.length - 1]!;
155
- expect(lastFilter).toContain("[btn_out]");
156
- });
157
- });