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 +1 -1
- package/src/ai-sdk/providers/editly/layers.ts +3 -6
- package/src/react/renderers/context.ts +2 -0
- package/src/react/renderers/image.ts +4 -0
- package/src/react/renderers/packshot/blinking-button.ts +61 -137
- package/src/react/renderers/packshot.ts +173 -411
- package/src/react/renderers/render.ts +1 -0
- package/src/react/renderers/video.ts +4 -0
- package/src/react/renderers/packshot/packshot.test.ts +0 -157
package/package.json
CHANGED
|
@@ -42,7 +42,7 @@ function getCropPositionExpr(position: CropPosition | undefined): {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[];
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
function escapeXml(text: string): string {
|
|
86
70
|
return text
|
|
87
71
|
.replace(/&/g, "&")
|
|
88
72
|
.replace(/</g, "<")
|
|
@@ -91,7 +75,7 @@ export function escapeXml(text: string): string {
|
|
|
91
75
|
.replace(/'/g, "'");
|
|
92
76
|
}
|
|
93
77
|
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
101
|
+
* Returns 0 → 1.15 (overshoot) → 1.0 (settle) → 0 (fall) per cycle.
|
|
118
102
|
*/
|
|
119
|
-
|
|
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
|
-
// ───
|
|
120
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
137
121
|
|
|
138
122
|
/**
|
|
139
|
-
*
|
|
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
|
-
*
|
|
142
|
-
*
|
|
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
|
|
138
|
+
export async function createBlinkingButton(
|
|
145
139
|
options: BlinkingButtonOptions,
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
|
349
|
-
`[
|
|
350
|
-
`[btn_a]alphaextract[
|
|
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][
|
|
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[
|
|
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
|
|
358
|
-
`[
|
|
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[
|
|
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
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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(
|
|
402
|
-
const glowInput = await resolvePathForBackend(
|
|
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
|
|
341
|
+
filterComplex,
|
|
419
342
|
outputArgs: [
|
|
420
343
|
"-map",
|
|
421
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
417
|
-
|
|
418
|
-
let logoPath: string | undefined;
|
|
137
|
+
// ===== LOGO LAYER =====
|
|
419
138
|
if (props.logo) {
|
|
420
|
-
|
|
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
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
436
|
-
|
|
159
|
+
// ===== STATIC CTA (non-blinking) =====
|
|
160
|
+
if (props.cta && !props.blinkCta) {
|
|
161
|
+
const ctaLayer: TitleLayer = {
|
|
162
|
+
type: "title",
|
|
437
163
|
text: props.cta,
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
//
|
|
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
|
|
178
|
+
const baseResult = await editly({
|
|
179
|
+
outPath: basePath,
|
|
466
180
|
width: ctx.width,
|
|
467
181
|
height: ctx.height,
|
|
468
182
|
fps: ctx.fps,
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
239
|
+
if (baseResult.output.type === "url") return baseResult.output.url;
|
|
240
|
+
ctx.tempFiles.push(basePath);
|
|
241
|
+
return basePath;
|
|
242
|
+
}
|
|
500
243
|
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
}
|
|
@@ -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
|
-
});
|