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
|
@@ -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(
|
|
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, "&")
|
|
72
88
|
.replace(/</g, "<")
|
|
@@ -75,7 +91,7 @@ function escapeXml(text: string): string {
|
|
|
75
91
|
.replace(/'/g, "'");
|
|
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
|
|
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
|
-
// ───
|
|
136
|
+
// ─── PNG Rendering ───────────────────────────────────────────────────────────
|
|
121
137
|
|
|
122
138
|
/**
|
|
123
|
-
*
|
|
124
|
-
* and a single FFmpeg filter_complex for all animation.
|
|
139
|
+
* Render the blinking button and glow PNGs using Sharp.
|
|
125
140
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
144
|
+
export async function renderBlinkingButtonPngs(
|
|
139
145
|
options: BlinkingButtonOptions,
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
|
309
|
-
`[
|
|
310
|
-
`[btn_a]alphaextract[
|
|
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][
|
|
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[
|
|
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
|
|
318
|
-
`[
|
|
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[
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
416
|
+
// ── Resolve logo ───────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
let logoPath: string | undefined;
|
|
138
419
|
if (props.logo) {
|
|
139
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
179
|
-
outPath: basePath,
|
|
465
|
+
const graph = buildPackshotFilter({
|
|
180
466
|
width: ctx.width,
|
|
181
467
|
height: ctx.height,
|
|
182
468
|
fps: ctx.fps,
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
241
|
-
return basePath;
|
|
242
|
-
}
|
|
499
|
+
// ── Cache the result ───────────────────────────────────────────────────────
|
|
243
500
|
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
}
|