vargai 0.4.0-alpha57 → 0.4.0-alpha59

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-alpha57",
73
+ "version": "0.4.0-alpha59",
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
- function escapeDrawText(text: string): string {
45
+ export function escapeDrawText(text: string): string {
46
46
  return text
47
47
  .replace(/\\/g, "\\\\")
48
48
  .replace(/'/g, "'\\''")
@@ -51,7 +51,10 @@ function escapeDrawText(text: string): string {
51
51
  .replace(/\]/g, "\\]");
52
52
  }
53
53
 
54
- function parseSize(val: number | string | undefined, base: number): number {
54
+ export function parseSize(
55
+ val: number | string | undefined,
56
+ base: number,
57
+ ): number {
55
58
  if (val === undefined) return base;
56
59
  if (typeof val === "number") return Math.round(val);
57
60
  if (val.endsWith("%")) {
@@ -464,7 +467,7 @@ export function getGradientFilter(
464
467
  // 3. Gets composited on top of base layers (not as base layer)
465
468
  // 4. Uses overlay filter for positioning instead of pad filter
466
469
 
467
- function resolvePositionForOverlay(
470
+ export function resolvePositionForOverlay(
468
471
  position: Position | undefined,
469
472
  width: number,
470
473
  height: number,
@@ -32,9 +32,25 @@ 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
+
35
51
  // ─── Helpers ─────────────────────────────────────────────────────────────────
36
52
 
37
- function hexToRgb(hex: string): { r: number; g: number; b: number } {
53
+ export function hexToRgb(hex: string): { r: number; g: number; b: number } {
38
54
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
39
55
  if (!result) return { r: 255, g: 107, b: 0 };
40
56
  return {
@@ -48,7 +64,7 @@ function clamp(value: number, max = 255): number {
48
64
  return Math.min(Math.floor(value), max);
49
65
  }
50
66
 
51
- function createButtonSvg(
67
+ export function createButtonSvg(
52
68
  width: number,
53
69
  height: number,
54
70
  radius: number,
@@ -66,7 +82,7 @@ function createButtonSvg(
66
82
  </svg>`;
67
83
  }
68
84
 
69
- function escapeXml(text: string): string {
85
+ export function escapeXml(text: string): string {
70
86
  return text
71
87
  .replace(/&/g, "&amp;")
72
88
  .replace(/</g, "&lt;")
@@ -75,7 +91,7 @@ function escapeXml(text: string): string {
75
91
  .replace(/'/g, "&apos;");
76
92
  }
77
93
 
78
- function getButtonYPosition(
94
+ export function getButtonYPosition(
79
95
  position: "top" | "center" | "bottom",
80
96
  videoHeight: number,
81
97
  buttonHeight: number,
@@ -91,16 +107,16 @@ function getButtonYPosition(
91
107
  }
92
108
 
93
109
  /** Ensure even dimension for ffmpeg */
94
- function even(n: number): number {
110
+ export function even(n: number): number {
95
111
  return n % 2 === 0 ? n : n + 1;
96
112
  }
97
113
 
98
114
  /**
99
115
  * Elastic ease oscillator as an ffmpeg expression.
100
116
  * Period P seconds, using time variable `tv` ("t" for scale/eq, "T" for geq).
101
- * Returns 0 1.15 (overshoot) 1.0 (settle) 0 (fall) per cycle.
117
+ * Returns 0 -> 1.15 (overshoot) -> 1.0 (settle) -> 0 (fall) per cycle.
102
118
  */
103
- function oscExpr(tv: string, P: number): string {
119
+ export function oscExpr(tv: string, P: number): string {
104
120
  const ph = `(mod(${tv},${P})/${P})`;
105
121
  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)))`;
106
122
  }
@@ -117,39 +133,18 @@ async function resolvePathForBackend(
117
133
  return backend.resolvePath(localPath);
118
134
  }
119
135
 
120
- // ─── Main ────────────────────────────────────────────────────────────────────
136
+ // ─── PNG Rendering ───────────────────────────────────────────────────────────
121
137
 
122
138
  /**
123
- * Create a blinking CTA button video using Sharp for static PNG rendering
124
- * and a single FFmpeg filter_complex for all animation.
139
+ * Render the blinking button and glow PNGs using Sharp.
125
140
  *
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.
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.
137
143
  */
138
- export async function createBlinkingButton(
144
+ export async function renderBlinkingButtonPngs(
139
145
  options: BlinkingButtonOptions,
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;
146
+ ): Promise<BlinkingButtonPngs> {
147
+ const { text, width, height, bgColor, textColor } = options;
153
148
 
154
149
  const btnWidth = options.buttonWidth ?? Math.floor(width * 0.7);
155
150
  const btnHeight = options.buttonHeight ?? Math.floor(height * 0.09);
@@ -167,8 +162,6 @@ export async function createBlinkingButton(
167
162
  const btnNativeW = even(btnWidth);
168
163
  const btnNativeH = even(btnHeight);
169
164
 
170
- // ── Step 1: Render PNGs with Sharp ─────────────────────────────────────────
171
-
172
165
  const rgb = hexToRgb(bgColor);
173
166
  const topColor = {
174
167
  r: clamp(rgb.r * 1.15),
@@ -282,12 +275,60 @@ export async function createBlinkingButton(
282
275
  Bun.write(glowPngPath, glowBuf),
283
276
  ]);
284
277
 
285
- // ── Step 2: Build ffmpeg filter_complex ────────────────────────────────────
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;
286
327
 
287
328
  const P = blinkFrequency;
288
329
  const osc = oscExpr("t", P);
289
330
 
290
- // eq gamma for brightness: 0.85 at rest 1.2 at peak
331
+ // eq gamma for brightness: 0.85 at rest -> 1.2 at peak
291
332
  const gammaExpr = `0.85+0.35*max(0,${osc})`;
292
333
 
293
334
  // Button scale (on native-size input)
@@ -298,39 +339,75 @@ export async function createBlinkingButton(
298
339
  const glowSW = `ceil(${cw}*(1.0+0.12*(${osc}))*1.15/2)*2`;
299
340
  const glowSH = `ceil(${ch}*(1.0+0.12*(${osc}))*1.15/2)*2`;
300
341
 
301
- // Filter complex: uses overlay for centering (no crop+pad drift)
302
- const filterComplex = [
342
+ const filters = [
303
343
  // Three transparent canvases (base + one per animated layer)
304
- `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[base]`,
344
+ `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[btn_base]`,
305
345
  `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[btn_canvas]`,
306
346
  `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[glow_canvas]`,
307
347
 
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]`,
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]`,
311
351
  `[btn_rgb]eq=gamma='${gammaExpr}':eval=frame[btn_eq]`,
312
- `[btn_eq][alpha]alphamerge,format=rgba,` +
352
+ `[btn_eq][btn_alpha]alphamerge,format=rgba,` +
313
353
  `scale=w='${btnSW}':h='${btnSH}':eval=frame:flags=lanczos` +
314
354
  `[btn_scaled]`,
315
- `[btn_canvas][btn_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[btn]`,
355
+ `[btn_canvas][btn_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[btn_layer]`,
316
356
 
317
- // Glow: scale center on canvas (opacity baked in PNG)
318
- `[1:v]format=rgba,` +
357
+ // Glow: scale -> center on canvas (opacity baked in PNG)
358
+ `[${glowInputIdx}:v]format=rgba,` +
319
359
  `scale=w='${glowSW}':h='${glowSH}':eval=frame:flags=lanczos` +
320
360
  `[glow_scaled]`,
321
- `[glow_canvas][glow_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[glow]`,
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
+ }
322
375
 
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(";");
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;
327
397
 
328
- // ── Step 3: Run ffmpeg via backend ─────────────────────────────────────────
398
+ const pngs = await renderBlinkingButtonPngs(options);
329
399
 
330
400
  // Resolve PNG paths for cloud backends (uploads to storage)
331
- const btnInput = await resolvePathForBackend(btnPngPath, backend);
332
- const glowInput = await resolvePathForBackend(glowPngPath, backend);
401
+ const btnInput = await resolvePathForBackend(pngs.btnPngPath, backend);
402
+ const glowInput = await resolvePathForBackend(pngs.glowPngPath, backend);
333
403
 
404
+ const filterParts = buildBlinkingButtonFilter(0, 1, pngs, {
405
+ duration,
406
+ fps,
407
+ blinkFrequency,
408
+ });
409
+
410
+ const ts = Date.now();
334
411
  const outputPath = `/tmp/varg-blink-btn-${ts}.mov`;
335
412
 
336
413
  const result = await backend.run({
@@ -338,10 +415,10 @@ export async function createBlinkingButton(
338
415
  { path: btnInput, options: ["-loop", "1"] },
339
416
  { path: glowInput, options: ["-loop", "1"] },
340
417
  ],
341
- filterComplex,
418
+ filterComplex: filterParts.filters.join(";"),
342
419
  outputArgs: [
343
420
  "-map",
344
- "[out]",
421
+ `[${filterParts.outputLabel}]`,
345
422
  "-c:v",
346
423
  "prores_ks",
347
424
  "-profile:v",
@@ -354,8 +431,7 @@ export async function createBlinkingButton(
354
431
  outputPath,
355
432
  });
356
433
 
357
- // ── Calculate overlay position on full video frame ─────────────────────────
358
-
434
+ const { canvasWidth: cw, canvasHeight: ch } = pngs;
359
435
  const btnY = getButtonYPosition(position, height, ch);
360
436
  const btnX = Math.floor((width - cw) / 2);
361
437
 
@@ -0,0 +1,157 @@
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
+ });
@@ -1,33 +1,31 @@
1
- import { editly } from "../../ai-sdk/providers/editly";
1
+ import { depsToKey } from "../../ai-sdk/cache";
2
2
  import type {
3
3
  FFmpegBackend,
4
+ FFmpegInput,
4
5
  FFmpegOutput,
5
6
  } from "../../ai-sdk/providers/editly/backends/types";
7
+ import {
8
+ escapeDrawText,
9
+ parseSize,
10
+ resolvePositionForOverlay,
11
+ } from "../../ai-sdk/providers/editly/layers";
6
12
  import type {
7
- Clip,
8
- ImageOverlayLayer,
9
- Layer,
10
13
  Position,
11
14
  PositionObject,
12
15
  SizeValue,
13
- TitleLayer,
14
16
  } from "../../ai-sdk/providers/editly/types";
15
17
  import type { PackshotProps, VargElement } from "../types";
16
18
  import type { RenderContext } from "./context";
17
19
  import { renderImage } from "./image";
18
- import { createBlinkingButton } from "./packshot/blinking-button";
20
+ import {
21
+ type BlinkingButtonPngs,
22
+ buildBlinkingButtonFilter,
23
+ even,
24
+ getButtonYPosition,
25
+ renderBlinkingButtonPngs,
26
+ } from "./packshot/blinking-button";
19
27
 
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
- }
28
+ // ─── Position helpers ────────────────────────────────────────────────────────
31
29
 
32
30
  /**
33
31
  * Type guard: returns true if `pos` is a PositionObject ({ x, y }).
@@ -38,12 +36,6 @@ function isPositionObject(pos: Position): pos is PositionObject {
38
36
 
39
37
  /**
40
38
  * 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.
47
39
  */
48
40
  function sizeValueToFraction(value: SizeValue, total: number): number {
49
41
  if (typeof value === "number") {
@@ -62,16 +54,6 @@ function sizeValueToFraction(value: SizeValue, total: number): number {
62
54
  return 0.5;
63
55
  }
64
56
 
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
- */
75
57
  function positionObjectToString(
76
58
  obj: PositionObject,
77
59
  refWidth = 1,
@@ -88,7 +70,7 @@ function positionObjectToString(
88
70
  if (row === "center" && col === "center") return "center";
89
71
  if (row === "center")
90
72
  return `center-${col}` as "center-left" | "center-right";
91
- if (col === "center") return row; // "top" | "bottom"
73
+ if (col === "center") return row;
92
74
  return `${row}-${col}` as
93
75
  | "top-left"
94
76
  | "top-right"
@@ -102,6 +84,295 @@ function resolvePosition(pos: Position | undefined): Position {
102
84
  return pos;
103
85
  }
104
86
 
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
+
105
376
  export async function renderPackshot(
106
377
  element: VargElement<"packshot">,
107
378
  ctx: RenderContext,
@@ -109,173 +380,140 @@ export async function renderPackshot(
109
380
  const props = element.props as PackshotProps;
110
381
  const duration = props.duration ?? 3;
111
382
 
112
- const layers: Layer[] = [];
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";
113
404
 
114
- // ===== BACKGROUND LAYER =====
115
405
  if (props.background) {
116
406
  if (typeof props.background === "string") {
117
- layers.push({
118
- type: "fill-color" as const,
119
- color: props.background,
120
- });
407
+ bgType = "fill-color";
408
+ bgColorOrPath = props.background;
121
409
  } else {
410
+ bgType = "image";
122
411
  const bgFile = await renderImage(props.background, ctx);
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
- });
412
+ bgColorOrPath = await ctx.backend.resolvePath(bgFile);
129
413
  }
130
- } else {
131
- layers.push({
132
- type: "fill-color" as const,
133
- color: "#000000",
134
- });
135
414
  }
136
415
 
137
- // ===== LOGO LAYER =====
416
+ // ── Resolve logo ───────────────────────────────────────────────────────────
417
+
418
+ let logoPath: string | undefined;
138
419
  if (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);
420
+ logoPath = await ctx.backend.resolvePath(props.logo);
146
421
  }
147
422
 
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
- }
423
+ // ── Render blinking button PNGs ────────────────────────────────────────────
158
424
 
159
- // ===== STATIC CTA (non-blinking) =====
160
- if (props.cta && !props.blinkCta) {
161
- const ctaLayer: TitleLayer = {
162
- type: "title",
425
+ let blinkCtaOpts:
426
+ | {
427
+ pngs: BlinkingButtonPngs;
428
+ btnPngPath: string;
429
+ glowPngPath: string;
430
+ blinkFrequency: number;
431
+ position: "top" | "center" | "bottom";
432
+ }
433
+ | undefined;
434
+
435
+ if (props.cta && props.blinkCta) {
436
+ const pngs = await renderBlinkingButtonPngs({
163
437
  text: props.cta,
164
- textColor: props.ctaColor ?? "white",
165
- position: resolvePosition(props.ctaPosition ?? "bottom"),
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),
166
460
  };
167
- layers.push(ctaLayer);
168
461
  }
169
462
 
170
- // Create base packshot video
171
- const clip: Clip = {
172
- layers,
173
- duration,
174
- };
175
-
176
- const basePath = `/tmp/varg-packshot-${Date.now()}.mp4`;
463
+ // ── Build unified filter graph ─────────────────────────────────────────────
177
464
 
178
- await editly({
179
- outPath: basePath,
465
+ const graph = buildPackshotFilter({
180
466
  width: ctx.width,
181
467
  height: ctx.height,
182
468
  fps: ctx.fps,
183
- clips: [clip],
184
- backend: ctx.backend,
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,
185
486
  });
186
487
 
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
- );
488
+ // ── Execute single backend.run() ───────────────────────────────────────────
205
489
 
206
- // Composite button overlay at correct position on base video via backend
207
- const baseInput = await resolveInputMaybeUpload(
208
- { type: "file", path: basePath },
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
- });
490
+ const outputPath = `/tmp/varg-packshot-${Date.now()}.mp4`;
230
491
 
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
- ctx.tempFiles.push(basePath);
237
- return overlayResult.output.url;
238
- }
492
+ const result = await ctx.backend.run({
493
+ inputs: graph.inputs,
494
+ filterComplex: graph.filterComplex,
495
+ outputArgs: graph.outputArgs,
496
+ outputPath,
497
+ });
239
498
 
240
- ctx.tempFiles.push(basePath);
241
- return basePath;
242
- }
499
+ // ── Cache the result ───────────────────────────────────────────────────────
243
500
 
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";
501
+ const outputUrl =
502
+ result.output.type === "url" ? result.output.url : result.output.path;
259
503
 
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";
504
+ if (ctx.cache) {
505
+ await ctx.cache.set(cacheKey, {
506
+ url: outputUrl,
507
+ mediaType: "video/mp4",
508
+ });
266
509
  }
267
510
 
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";
511
+ // ── Return ─────────────────────────────────────────────────────────────────
512
+
513
+ if (result.output.type === "file") {
514
+ ctx.tempFiles.push(result.output.path);
515
+ return result.output.path;
280
516
  }
517
+
518
+ return result.output.url;
281
519
  }