diff --git a/src/ai-sdk/providers/editly/layers.ts b/src/ai-sdk/providers/editly/layers.ts index 557a12cf..bec679c7 100644 --- a/src/ai-sdk/providers/editly/layers.ts +++ b/src/ai-sdk/providers/editly/layers.ts @@ -42,7 +42,7 @@ function getCropPositionExpr(position: CropPosition | undefined): { } } -function escapeDrawText(text: string): string { +export function escapeDrawText(text: string): string { return text .replace(/\\/g, "\\\\") .replace(/'/g, "'\\''") @@ -51,7 +51,10 @@ function escapeDrawText(text: string): string { .replace(/\]/g, "\\]"); } -function parseSize(val: number | string | undefined, base: number): number { +export function parseSize( + val: number | string | undefined, + base: number, +): number { if (val === undefined) return base; if (typeof val === "number") return Math.round(val); if (val.endsWith("%")) { @@ -464,7 +467,7 @@ export function getGradientFilter( // 3. Gets composited on top of base layers (not as base layer) // 4. Uses overlay filter for positioning instead of pad filter -function resolvePositionForOverlay( +export function resolvePositionForOverlay( position: Position | undefined, width: number, height: number, diff --git a/src/react/renderers/packshot.ts b/src/react/renderers/packshot.ts index bfced595..c6c10e05 100644 --- a/src/react/renderers/packshot.ts +++ b/src/react/renderers/packshot.ts @@ -1,33 +1,31 @@ -import { editly } from "../../ai-sdk/providers/editly"; +import { depsToKey } from "../../ai-sdk/cache"; import type { FFmpegBackend, + FFmpegInput, FFmpegOutput, } from "../../ai-sdk/providers/editly/backends/types"; +import { + escapeDrawText, + parseSize, + resolvePositionForOverlay, +} from "../../ai-sdk/providers/editly/layers"; import type { - Clip, - ImageOverlayLayer, - Layer, Position, PositionObject, SizeValue, - TitleLayer, } from "../../ai-sdk/providers/editly/types"; import type { PackshotProps, VargElement } from "../types"; import type { RenderContext } from "./context"; import { renderImage } from "./image"; -import { createBlinkingButton } from "./packshot/blinking-button"; +import { + type BlinkingButtonPngs, + buildBlinkingButtonFilter, + even, + getButtonYPosition, + renderBlinkingButtonPngs, +} from "./packshot/blinking-button"; -/** - * Resolve an FFmpegOutput to a string path/URL via the backend. - * Local backend returns local paths; cloud backends upload and return URLs. - */ -async function resolveInputMaybeUpload( - input: FFmpegOutput, - backend: FFmpegBackend, -): Promise { - if (input.type === "url") return input.url; - return backend.resolvePath(input.path); -} +// ─── Position helpers ──────────────────────────────────────────────────────── /** * Type guard: returns true if `pos` is a PositionObject ({ x, y }). @@ -38,12 +36,6 @@ function isPositionObject(pos: Position): pos is PositionObject { /** * Parse a SizeValue to a normalised 0-1 fraction. - * - * - `number` – treated as a raw pixel value; divided by `total`. - * - `"50%"` – percentage string; divided by 100. - * - `"120px"` – pixel string; parsed and divided by `total`. - * - * Returns `0.5` (centre) when the value cannot be parsed. */ function sizeValueToFraction(value: SizeValue, total: number): number { if (typeof value === "number") { @@ -62,16 +54,6 @@ function sizeValueToFraction(value: SizeValue, total: number): number { return 0.5; } -/** - * Convert a PositionObject to the nearest string Position. - * - * The x axis is split into thirds: left (< 0.33), center, right (> 0.67). - * The y axis is split into thirds: top (< 0.33), center, bottom (> 0.67). - * - * `refWidth` / `refHeight` are only needed when the SizeValue is in pixels; - * when unknown, pass 1 and only percentage / fraction values will resolve - * correctly. - */ function positionObjectToString( obj: PositionObject, refWidth = 1, @@ -88,7 +70,7 @@ function positionObjectToString( if (row === "center" && col === "center") return "center"; if (row === "center") return `center-${col}` as "center-left" | "center-right"; - if (col === "center") return row; // "top" | "bottom" + if (col === "center") return row; return `${row}-${col}` as | "top-left" | "top-right" @@ -102,6 +84,295 @@ function resolvePosition(pos: Position | undefined): Position { return pos; } +function mapCtaPosition( + pos: Position | undefined, + refHeight = 1, +): "top" | "center" | "bottom" { + if (pos === undefined) return "bottom"; + if (isPositionObject(pos)) { + const fy = sizeValueToFraction(pos.y, refHeight); + if (fy < 0.33) return "top"; + if (fy > 0.67) return "bottom"; + return "center"; + } + switch (pos) { + case "top": + case "top-left": + case "top-right": + return "top"; + case "center": + case "center-left": + case "center-right": + return "center"; + default: + return "bottom"; + } +} + +// ─── Cache key ─────────────────────────────────────────────────────────────── + +function computePackshotCacheKey( + props: PackshotProps, + width: number, + height: number, + fps: number, +): string { + // Background key: color string, or the image element's src URL + let bgKey = "#000000"; + if (props.background) { + if (typeof props.background === "string") { + bgKey = props.background; + } else { + // Image element — use the src prop for cache key + const imgProps = props.background.props as Record; + bgKey = `img:${imgProps.src ?? imgProps.prompt ?? JSON.stringify(imgProps)}`; + } + } + + const deps = [ + "packshot", + bgKey, + props.logo ?? "", + String(resolvePosition(props.logoPosition ?? "center")), + String(props.logoSize ?? "40%"), + props.title ?? "", + props.titleColor ?? "#FFFFFF", + String(resolvePosition(props.titlePosition ?? "center")), + props.cta ?? "", + props.ctaColor ?? "#FF6B00", + props.ctaTextColor ?? "#FFFFFF", + String(props.blinkCta ?? false), + String(props.blinkFrequency ?? 0.8), + String(resolvePosition(props.ctaPosition ?? "bottom")), + props.ctaSize ? `${props.ctaSize.width}x${props.ctaSize.height}` : "", + String(props.duration ?? 3), + String(width), + String(height), + String(fps), + ]; + + return depsToKey("packshot", deps); +} + +// ─── Unified filter builder ────────────────────────────────────────────────── + +interface PackshotFilterGraph { + inputs: FFmpegInput[]; + filterComplex: string; + outputArgs: string[]; +} + +/** + * Build a single FFmpeg filter_complex that produces the complete packshot video. + * + * Merges what was previously 3 separate backend.run() calls into one: + * 1. Background (fill-color or image) + logo overlay + title drawtext + * 2. Blinking button animation (if enabled) + * 3. Final composite of button over base + */ +function buildPackshotFilter(opts: { + width: number; + height: number; + fps: number; + duration: number; + // Background + bgType: "fill-color" | "image"; + bgColorOrPath: string; // hex color or resolved path/URL + // Logo (optional) + logoPath?: string; + logoPosition: Position; + logoSize: SizeValue; + // Title (optional) + titleText?: string; + titleColor: string; + titlePosition: Position; + // Static CTA (optional, mutually exclusive with blinkCta) + staticCtaText?: string; + staticCtaColor?: string; + staticCtaPosition?: Position; + // Blinking CTA (optional) + blinkCta?: { + pngs: BlinkingButtonPngs; + btnPngPath: string; // resolved path/URL + glowPngPath: string; // resolved path/URL + blinkFrequency: number; + position: "top" | "center" | "bottom"; + }; +}): PackshotFilterGraph { + const { width, height, fps, duration } = opts; + const filters: string[] = []; + const inputs: FFmpegInput[] = []; + let inputIdx = 0; + let currentLabel: string; + + // ── Background ───────────────────────────────────────────────────────────── + + if (opts.bgType === "fill-color") { + const bgLabel = "bg0"; + filters.push( + `color=c=${opts.bgColorOrPath}:s=${width}x${height}:d=${duration}:r=${fps}[${bgLabel}]`, + ); + currentLabel = bgLabel; + } else { + // Image background with cover mode + const bgLabel = "bg0"; + inputs.push(opts.bgColorOrPath); // plain string = file path/URL + filters.push( + `[${inputIdx}:v]loop=loop=-1:size=1:start=0,fps=${fps},trim=duration=${duration},` + + `scale=${width}:${height}:force_original_aspect_ratio=increase,` + + `crop=${width}:${height},setsar=1,settb=1/${fps}[${bgLabel}]`, + ); + inputIdx++; + currentLabel = bgLabel; + } + + // ── Logo overlay ─────────────────────────────────────────────────────────── + + if (opts.logoPath) { + const logoInputIdx = inputIdx; + inputs.push(opts.logoPath); + inputIdx++; + + const targetWidth = parseSize(opts.logoSize, width); + const logoScaleLabel = `logo_s`; + filters.push( + `[${logoInputIdx}:v]scale=${targetWidth}:-2,loop=loop=-1:size=1:start=0,fps=${fps},` + + `trim=duration=${duration},setsar=1,settb=1/${fps}[${logoScaleLabel}]`, + ); + + const resolvedPos = resolvePosition(opts.logoPosition); + const { x, y } = resolvePositionForOverlay(resolvedPos, width, height); + const logoOutLabel = "logo_out"; + filters.push( + `[${currentLabel}][${logoScaleLabel}]overlay=${x}:${y}:shortest=1[${logoOutLabel}]`, + ); + currentLabel = logoOutLabel; + } + + // ── Title drawtext ───────────────────────────────────────────────────────── + + if (opts.titleText) { + const text = escapeDrawText(opts.titleText); + const color = opts.titleColor; + + const maxFontSize = Math.round(Math.min(width, height) * 0.08); + const maxTextWidth = width * 0.9; + const fittedFontSize = Math.floor( + maxTextWidth / (opts.titleText.length * 0.55), + ); + const fontSize = Math.max(16, Math.min(maxFontSize, fittedFontSize)); + + let x = "(w-text_w)/2"; + let y = "(h-text_h)/2"; + + const pos = resolvePosition(opts.titlePosition); + if (typeof pos === "string") { + if (pos.includes("left")) x = "w*0.1"; + if (pos.includes("right")) x = "w*0.9-text_w"; + if (pos.includes("top")) y = "h*0.1"; + if (pos.includes("bottom")) y = "h*0.9-text_h"; + } + + const titleOutLabel = "title_out"; + filters.push( + `[${currentLabel}]drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${color}:x=${x}:y=${y}[${titleOutLabel}]`, + ); + currentLabel = titleOutLabel; + } + + // ── Static CTA drawtext (non-blinking) ───────────────────────────────────── + + if (opts.staticCtaText) { + const text = escapeDrawText(opts.staticCtaText); + const color = opts.staticCtaColor ?? "white"; + + const maxFontSize = Math.round(Math.min(width, height) * 0.08); + const maxTextWidth = width * 0.9; + const fittedFontSize = Math.floor( + maxTextWidth / (opts.staticCtaText.length * 0.55), + ); + const fontSize = Math.max(16, Math.min(maxFontSize, fittedFontSize)); + + let x = "(w-text_w)/2"; + let y = "(h-text_h)/2"; + + const pos = resolvePosition(opts.staticCtaPosition ?? "bottom"); + if (typeof pos === "string") { + if (pos.includes("left")) x = "w*0.1"; + if (pos.includes("right")) x = "w*0.9-text_w"; + if (pos.includes("top")) y = "h*0.1"; + if (pos.includes("bottom")) y = "h*0.9-text_h"; + } + + const ctaOutLabel = "cta_out"; + filters.push( + `[${currentLabel}]drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${color}:x=${x}:y=${y}[${ctaOutLabel}]`, + ); + currentLabel = ctaOutLabel; + } + + // ── Blinking CTA overlay ─────────────────────────────────────────────────── + + if (opts.blinkCta) { + const { pngs, btnPngPath, glowPngPath, blinkFrequency, position } = + opts.blinkCta; + + const btnInputIdx = inputIdx; + inputs.push({ path: btnPngPath, options: ["-loop", "1"] }); + inputIdx++; + + const glowInputIdx = inputIdx; + inputs.push({ path: glowPngPath, options: ["-loop", "1"] }); + inputIdx++; + + const btnFilter = buildBlinkingButtonFilter( + btnInputIdx, + glowInputIdx, + pngs, + { duration, fps, blinkFrequency }, + ); + + filters.push(...btnFilter.filters); + + // Overlay the animated button onto the base at the correct position + const btnY = getButtonYPosition(position, height, pngs.canvasHeight); + const btnX = Math.floor((width - pngs.canvasWidth) / 2); + + const finalLabel = "final"; + filters.push( + `[${currentLabel}][${btnFilter.outputLabel}]overlay=${btnX}:${btnY}:format=auto[${finalLabel}]`, + ); + currentLabel = finalLabel; + } + + // ── Output ───────────────────────────────────────────────────────────────── + + const outputArgs = [ + "-map", + `[${currentLabel}]`, + "-r", + String(fps), + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "18", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + ]; + + return { + inputs, + filterComplex: filters.join(";"), + outputArgs, + }; +} + +// ─── Main render function ──────────────────────────────────────────────────── + export async function renderPackshot( element: VargElement<"packshot">, ctx: RenderContext, @@ -109,173 +380,140 @@ export async function renderPackshot( const props = element.props as PackshotProps; const duration = props.duration ?? 3; - const layers: Layer[] = []; + // ── Check cache ──────────────────────────────────────────────────────────── + + const cacheKey = computePackshotCacheKey( + props, + ctx.width, + ctx.height, + ctx.fps, + ); + + if (ctx.cache) { + const cached = await ctx.cache.get(cacheKey); + if (cached) { + const { url } = cached as { url: string; mediaType: string }; + return url; + } + } + + // ── Resolve background ───────────────────────────────────────────────────── + + let bgType: "fill-color" | "image" = "fill-color"; + let bgColorOrPath = "#000000"; - // ===== BACKGROUND LAYER ===== if (props.background) { if (typeof props.background === "string") { - layers.push({ - type: "fill-color" as const, - color: props.background, - }); + bgType = "fill-color"; + bgColorOrPath = props.background; } else { + bgType = "image"; const bgFile = await renderImage(props.background, ctx); - const bgPath = await ctx.backend.resolvePath(bgFile); - layers.push({ - type: "image" as const, - path: bgPath, - resizeMode: "cover" as const, - }); + bgColorOrPath = await ctx.backend.resolvePath(bgFile); } - } else { - layers.push({ - type: "fill-color" as const, - color: "#000000", - }); } - // ===== LOGO LAYER ===== + // ── Resolve logo ─────────────────────────────────────────────────────────── + + let logoPath: string | undefined; if (props.logo) { - const logoLayer: ImageOverlayLayer = { - type: "image-overlay", - path: props.logo, - position: resolvePosition(props.logoPosition ?? "center"), - width: props.logoSize ?? "40%", - }; - layers.push(logoLayer); + logoPath = await ctx.backend.resolvePath(props.logo); } - // ===== TITLE LAYER ===== - if (props.title) { - const titleLayer: TitleLayer = { - type: "title", - text: props.title, - textColor: props.titleColor ?? "#FFFFFF", - position: resolvePosition(props.titlePosition ?? "center"), - }; - layers.push(titleLayer); - } + // ── Render blinking button PNGs ──────────────────────────────────────────── - // ===== STATIC CTA (non-blinking) ===== - if (props.cta && !props.blinkCta) { - const ctaLayer: TitleLayer = { - type: "title", + let blinkCtaOpts: + | { + pngs: BlinkingButtonPngs; + btnPngPath: string; + glowPngPath: string; + blinkFrequency: number; + position: "top" | "center" | "bottom"; + } + | undefined; + + if (props.cta && props.blinkCta) { + const pngs = await renderBlinkingButtonPngs({ text: props.cta, - textColor: props.ctaColor ?? "white", - position: resolvePosition(props.ctaPosition ?? "bottom"), + width: ctx.width, + height: ctx.height, + duration, + fps: ctx.fps, + bgColor: props.ctaColor ?? "#FF6B00", + textColor: props.ctaTextColor ?? "#FFFFFF", + blinkFrequency: props.blinkFrequency ?? 0.8, + position: mapCtaPosition(props.ctaPosition, ctx.height), + buttonWidth: props.ctaSize?.width, + buttonHeight: props.ctaSize?.height, + }); + + // Upload PNGs for cloud backends + const btnPngPath = await ctx.backend.resolvePath(pngs.btnPngPath); + const glowPngPath = await ctx.backend.resolvePath(pngs.glowPngPath); + + blinkCtaOpts = { + pngs, + btnPngPath, + glowPngPath, + blinkFrequency: props.blinkFrequency ?? 0.8, + position: mapCtaPosition(props.ctaPosition, ctx.height), }; - layers.push(ctaLayer); } - // Create base packshot video - const clip: Clip = { - layers, - duration, - }; - - const basePath = `/tmp/varg-packshot-${Date.now()}.mp4`; + // ── Build unified filter graph ───────────────────────────────────────────── - const baseResult = await editly({ - outPath: basePath, + const graph = buildPackshotFilter({ width: ctx.width, height: ctx.height, fps: ctx.fps, - clips: [clip], - backend: ctx.backend, + duration, + bgType, + bgColorOrPath, + logoPath, + logoPosition: props.logoPosition ?? "center", + logoSize: props.logoSize ?? "40%", + titleText: props.title, + titleColor: props.titleColor ?? "#FFFFFF", + titlePosition: props.titlePosition ?? "center", + staticCtaText: props.cta && !props.blinkCta ? props.cta : undefined, + staticCtaColor: + props.cta && !props.blinkCta ? (props.ctaColor ?? "white") : undefined, + staticCtaPosition: + props.cta && !props.blinkCta + ? resolvePosition(props.ctaPosition ?? "bottom") + : undefined, + blinkCta: blinkCtaOpts, }); - // ===== BLINKING CTA OVERLAY ===== - if (props.cta && props.blinkCta) { - const btn = await createBlinkingButton( - { - text: props.cta, - width: ctx.width, - height: ctx.height, - duration, - fps: ctx.fps, - bgColor: props.ctaColor ?? "#FF6B00", - textColor: props.ctaTextColor ?? "#FFFFFF", - blinkFrequency: props.blinkFrequency ?? 0.8, - position: mapCtaPosition(props.ctaPosition, ctx.height), - buttonWidth: props.ctaSize?.width, - buttonHeight: props.ctaSize?.height, - }, - ctx.backend, - ); + // ── Execute single backend.run() ─────────────────────────────────────────── - // Composite button overlay at correct position on base video via backend - const baseInput = await resolveInputMaybeUpload( - baseResult.output, - ctx.backend, - ); - const btnInput = await resolveInputMaybeUpload(btn.output, ctx.backend); - - const finalPath = `/tmp/varg-packshot-final-${Date.now()}.mp4`; - - const overlayResult = await ctx.backend.run({ - inputs: [baseInput, btnInput], - filterComplex: `[0:v][1:v]overlay=${btn.x}:${btn.y}:format=auto`, - outputArgs: [ - "-c:v", - "libx264", - "-preset", - "fast", - "-crf", - "18", - "-pix_fmt", - "yuv420p", - ], - outputPath: finalPath, - }); + const outputPath = `/tmp/varg-packshot-${Date.now()}.mp4`; - if (overlayResult.output.type === "file") { - ctx.tempFiles.push(basePath, overlayResult.output.path); - return overlayResult.output.path; - } - // Cloud backend returns URL - return overlayResult.output.url; - } + const result = await ctx.backend.run({ + inputs: graph.inputs, + filterComplex: graph.filterComplex, + outputArgs: graph.outputArgs, + outputPath, + }); - if (baseResult.output.type === "url") return baseResult.output.url; - ctx.tempFiles.push(basePath); - return basePath; -} + // ── Cache the result ─────────────────────────────────────────────────────── -/** - * Map a Position (string literal **or** PositionObject) to the vertical - * bucket that the blinking-button renderer understands. - * - * When a PositionObject ({ x, y }) is provided the y-coordinate is - * normalised to a 0-1 fraction and mapped to "top" (< 0.33), - * "center" (0.33-0.67), or "bottom" (> 0.67). Pixel values are - * resolved against `refHeight` (defaults to 1, which means only - * percentages will convert correctly when the caller does not supply it). - */ -function mapCtaPosition( - pos: Position | undefined, - refHeight = 1, -): "top" | "center" | "bottom" { - if (pos === undefined) return "bottom"; + const outputUrl = + result.output.type === "url" ? result.output.url : result.output.path; - // Handle PositionObject ({ x, y }) explicitly - if (isPositionObject(pos)) { - const fy = sizeValueToFraction(pos.y, refHeight); - if (fy < 0.33) return "top"; - if (fy > 0.67) return "bottom"; - return "center"; + if (ctx.cache) { + await ctx.cache.set(cacheKey, { + url: outputUrl, + mediaType: "video/mp4", + }); } - // String literal positions - switch (pos) { - case "top": - case "top-left": - case "top-right": - return "top"; - case "center": - case "center-left": - case "center-right": - return "center"; - default: - return "bottom"; + // ── Return ───────────────────────────────────────────────────────────────── + + if (result.output.type === "file") { + ctx.tempFiles.push(result.output.path); + return result.output.path; } + + return result.output.url; } diff --git a/src/react/renderers/packshot/blinking-button.ts b/src/react/renderers/packshot/blinking-button.ts index c5d7601c..a2f10105 100644 --- a/src/react/renderers/packshot/blinking-button.ts +++ b/src/react/renderers/packshot/blinking-button.ts @@ -32,9 +32,25 @@ export interface BlinkingButtonResult { canvasHeight: number; } +/** Result of rendering the blinking button PNGs (Sharp only, no backend). */ +export interface BlinkingButtonPngs { + /** Path to the native-size button PNG (with alpha) */ + btnPngPath: string; + /** Path to the glow PNG (canvas-size, 60% alpha baked in) */ + glowPngPath: string; + /** Native button width (even) */ + btnNativeW: number; + /** Native button height (even) */ + btnNativeH: number; + /** Canvas width (even, includes padding for max scale + glow) */ + canvasWidth: number; + /** Canvas height (even) */ + canvasHeight: number; +} + // ─── Helpers ───────────────────────────────────────────────────────────────── -function hexToRgb(hex: string): { r: number; g: number; b: number } { +export function hexToRgb(hex: string): { r: number; g: number; b: number } { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) return { r: 255, g: 107, b: 0 }; return { @@ -48,7 +64,7 @@ function clamp(value: number, max = 255): number { return Math.min(Math.floor(value), max); } -function createButtonSvg( +export function createButtonSvg( width: number, height: number, radius: number, @@ -66,7 +82,7 @@ function createButtonSvg( `; } -function escapeXml(text: string): string { +export function escapeXml(text: string): string { return text .replace(/&/g, "&") .replace(/ 1.15 (overshoot) -> 1.0 (settle) -> 0 (fall) per cycle. */ -function oscExpr(tv: string, P: number): string { +export function oscExpr(tv: string, P: number): string { const ph = `(mod(${tv},${P})/${P})`; 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)))`; } @@ -117,39 +133,18 @@ async function resolvePathForBackend( return backend.resolvePath(localPath); } -// ─── Main ──────────────────────────────────────────────────────────────────── +// ─── PNG Rendering ─────────────────────────────────────────────────────────── /** - * Create a blinking CTA button video using Sharp for static PNG rendering - * and a single FFmpeg filter_complex for all animation. + * Render the blinking button and glow PNGs using Sharp. * - * Architecture: - * 1. Sharp renders 2 static PNGs: button (native size) + glow (canvas size) - * 2. FFmpeg filter_complex does per-frame animation via expressions: - * - eq(gamma, eval=frame) for brightness pulse (0.85x → 1.2x) - * - scale(eval=frame) for elastic zoom pulse (1.0x → 1.14x) - * - overlay with (W-w)/2 centering for perfect bbox alignment - * - Glow scales 15% larger with 60% max opacity baked in - * 3. Output is ProRes 4444 with alpha channel - * - * Works on both local (ffmpeg binary) and cloud (rendi) backends - * via the FFmpegBackend abstraction. + * This is pure image generation — no FFmpeg, no backend calls. + * Returns paths to two temp PNG files ready for use in an ffmpeg filter graph. */ -export async function createBlinkingButton( +export async function renderBlinkingButtonPngs( options: BlinkingButtonOptions, - backend: FFmpegBackend, -): Promise { - const { - text, - width, - height, - duration, - fps, - bgColor, - textColor, - blinkFrequency = 0.8, - position = "bottom", - } = options; +): Promise { + const { text, width, height, bgColor, textColor } = options; const btnWidth = options.buttonWidth ?? Math.floor(width * 0.7); const btnHeight = options.buttonHeight ?? Math.floor(height * 0.09); @@ -167,8 +162,6 @@ export async function createBlinkingButton( const btnNativeW = even(btnWidth); const btnNativeH = even(btnHeight); - // ── Step 1: Render PNGs with Sharp ───────────────────────────────────────── - const rgb = hexToRgb(bgColor); const topColor = { r: clamp(rgb.r * 1.15), @@ -282,12 +275,60 @@ export async function createBlinkingButton( Bun.write(glowPngPath, glowBuf), ]); - // ── Step 2: Build ffmpeg filter_complex ──────────────────────────────────── + return { + btnPngPath, + glowPngPath, + btnNativeW, + btnNativeH, + canvasWidth: cw, + canvasHeight: ch, + }; +} + +// ─── Filter Builder ────────────────────────────────────────────────────────── + +export interface BlinkingButtonFilterParts { + /** Filter_complex lines for the blinking button animation */ + filters: string[]; + /** + * The output label of the final animated button stream (e.g. "btn_out"). + * This is a transparent RGBA video that can be overlaid onto the base. + */ + outputLabel: string; + /** Canvas width of the button animation */ + canvasWidth: number; + /** Canvas height of the button animation */ + canvasHeight: number; +} + +/** + * Build the ffmpeg filter_complex lines for the blinking button animation. + * + * Takes the input indices for the button PNG and glow PNG, + * and returns filter lines that can be merged into a larger filter_complex. + * + * @param btnInputIdx - ffmpeg input index for the button PNG (fed with -loop 1) + * @param glowInputIdx - ffmpeg input index for the glow PNG (fed with -loop 1) + * @param pngs - dimensions from renderBlinkingButtonPngs() + * @param options - animation settings + */ +export function buildBlinkingButtonFilter( + btnInputIdx: number, + glowInputIdx: number, + pngs: BlinkingButtonPngs, + options: { + duration: number; + fps: number; + blinkFrequency: number; + }, +): BlinkingButtonFilterParts { + const { duration, fps, blinkFrequency } = options; + const { btnNativeW, btnNativeH, canvasWidth: cw, canvasHeight: ch } = pngs; const P = blinkFrequency; const osc = oscExpr("t", P); - // eq gamma for brightness: 0.85 at rest → 1.2 at peak + // eq gamma for brightness: 0.85 at rest -> 1.2 at peak const gammaExpr = `0.85+0.35*max(0,${osc})`; // Button scale (on native-size input) @@ -298,39 +339,75 @@ export async function createBlinkingButton( const glowSW = `ceil(${cw}*(1.0+0.12*(${osc}))*1.15/2)*2`; const glowSH = `ceil(${ch}*(1.0+0.12*(${osc}))*1.15/2)*2`; - // Filter complex: uses overlay for centering (no crop+pad drift) - const filterComplex = [ + const filters = [ // Three transparent canvases (base + one per animated layer) - `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[base]`, + `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[btn_base]`, `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[btn_canvas]`, `color=0x00000000:s=${cw}x${ch}:r=${fps}:d=${duration},format=rgba[glow_canvas]`, - // Button: split alpha → eq(gamma) → merge alpha → scale → center on canvas - `[0:v]format=rgba,split[btn_rgb][btn_a]`, - `[btn_a]alphaextract[alpha]`, + // Button: split alpha -> eq(gamma) -> merge alpha -> scale -> center on canvas + `[${btnInputIdx}:v]format=rgba,split[btn_rgb][btn_a]`, + `[btn_a]alphaextract[btn_alpha]`, `[btn_rgb]eq=gamma='${gammaExpr}':eval=frame[btn_eq]`, - `[btn_eq][alpha]alphamerge,format=rgba,` + + `[btn_eq][btn_alpha]alphamerge,format=rgba,` + `scale=w='${btnSW}':h='${btnSH}':eval=frame:flags=lanczos` + `[btn_scaled]`, - `[btn_canvas][btn_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[btn]`, + `[btn_canvas][btn_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[btn_layer]`, - // Glow: scale → center on canvas (opacity baked in PNG) - `[1:v]format=rgba,` + + // Glow: scale -> center on canvas (opacity baked in PNG) + `[${glowInputIdx}:v]format=rgba,` + `scale=w='${glowSW}':h='${glowSH}':eval=frame:flags=lanczos` + `[glow_scaled]`, - `[glow_canvas][glow_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[glow]`, + `[glow_canvas][glow_scaled]overlay=x='(W-w)/2':y='(H-h)/2':format=auto:eval=frame:shortest=1[glow_layer]`, + + // Final composite: base -> glow -> button + `[btn_base][glow_layer]overlay=format=auto:shortest=1[btn_bg]`, + `[btn_bg][btn_layer]overlay=format=auto:shortest=1[btn_out]`, + ]; + + return { + filters, + outputLabel: "btn_out", + canvasWidth: cw, + canvasHeight: ch, + }; +} - // Final composite: base → glow → button - `[base][glow]overlay=format=auto:shortest=1[bg]`, - `[bg][btn]overlay=format=auto:shortest=1[out]`, - ].join(";"); +// ─── Legacy standalone API ─────────────────────────────────────────────────── + +/** + * Create a blinking CTA button video as a standalone operation. + * + * This is the legacy API that performs its own backend.run() call. + * Prefer using renderBlinkingButtonPngs() + buildBlinkingButtonFilter() + * to merge the button animation into a larger filter graph. + */ +export async function createBlinkingButton( + options: BlinkingButtonOptions, + backend: FFmpegBackend, +): Promise { + const { + duration, + fps, + blinkFrequency = 0.8, + position = "bottom", + width, + height, + } = options; - // ── Step 3: Run ffmpeg via backend ───────────────────────────────────────── + const pngs = await renderBlinkingButtonPngs(options); // Resolve PNG paths for cloud backends (uploads to storage) - const btnInput = await resolvePathForBackend(btnPngPath, backend); - const glowInput = await resolvePathForBackend(glowPngPath, backend); + const btnInput = await resolvePathForBackend(pngs.btnPngPath, backend); + const glowInput = await resolvePathForBackend(pngs.glowPngPath, backend); + const filterParts = buildBlinkingButtonFilter(0, 1, pngs, { + duration, + fps, + blinkFrequency, + }); + + const ts = Date.now(); const outputPath = `/tmp/varg-blink-btn-${ts}.mov`; const result = await backend.run({ @@ -338,10 +415,10 @@ export async function createBlinkingButton( { path: btnInput, options: ["-loop", "1"] }, { path: glowInput, options: ["-loop", "1"] }, ], - filterComplex, + filterComplex: filterParts.filters.join(";"), outputArgs: [ "-map", - "[out]", + `[${filterParts.outputLabel}]`, "-c:v", "prores_ks", "-profile:v", @@ -354,8 +431,7 @@ export async function createBlinkingButton( outputPath, }); - // ── Calculate overlay position on full video frame ───────────────────────── - + const { canvasWidth: cw, canvasHeight: ch } = pngs; const btnY = getButtonYPosition(position, height, ch); const btnX = Math.floor((width - cw) / 2); diff --git a/src/react/renderers/packshot/packshot.test.ts b/src/react/renderers/packshot/packshot.test.ts new file mode 100644 index 00000000..487c3454 --- /dev/null +++ b/src/react/renderers/packshot/packshot.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import { + type BlinkingButtonPngs, + buildBlinkingButtonFilter, + even, + getButtonYPosition, + hexToRgb, + oscExpr, +} from "./blinking-button"; + +// ─── Helper unit tests ─────────────────────────────────────────────────────── + +describe("blinking-button helpers", () => { + test("hexToRgb parses valid hex colors", () => { + expect(hexToRgb("#FF6B00")).toEqual({ r: 255, g: 107, b: 0 }); + expect(hexToRgb("#000000")).toEqual({ r: 0, g: 0, b: 0 }); + expect(hexToRgb("#FFFFFF")).toEqual({ r: 255, g: 255, b: 255 }); + expect(hexToRgb("FF6B00")).toEqual({ r: 255, g: 107, b: 0 }); // no # + }); + + test("hexToRgb returns default for invalid input", () => { + expect(hexToRgb("invalid")).toEqual({ r: 255, g: 107, b: 0 }); + expect(hexToRgb("")).toEqual({ r: 255, g: 107, b: 0 }); + }); + + test("even ensures even numbers", () => { + expect(even(10)).toBe(10); + expect(even(11)).toBe(12); + expect(even(0)).toBe(0); + expect(even(1)).toBe(2); + }); + + test("oscExpr produces well-formed ffmpeg expression", () => { + const expr = oscExpr("t", 0.8); + expect(expr).toContain("mod(t,0.8)"); + expect(expr).toContain("sin("); + expect(expr).toContain("cos("); + expect(expr).toContain("1.15"); + expect(expr).toContain("PI"); + // Should not contain any undefined or NaN + expect(expr).not.toContain("undefined"); + expect(expr).not.toContain("NaN"); + }); + + test("getButtonYPosition returns correct positions", () => { + const videoHeight = 1920; + const buttonHeight = 200; + + const topY = getButtonYPosition("top", videoHeight, buttonHeight); + const centerY = getButtonYPosition("center", videoHeight, buttonHeight); + const bottomY = getButtonYPosition("bottom", videoHeight, buttonHeight); + + // Top should be near the top + expect(topY).toBe(Math.floor(1920 * 0.15)); + // Center should be vertically centered + expect(centerY).toBe(Math.floor((1920 - 200) / 2)); + // Bottom should be in the lower portion + expect(bottomY).toBe(Math.floor(1920 * 0.78 - 200 / 2)); + + // Ordering + expect(topY).toBeLessThan(centerY); + expect(centerY).toBeLessThan(bottomY); + }); +}); + +// ─── buildBlinkingButtonFilter tests ───────────────────────────────────────── + +describe("buildBlinkingButtonFilter", () => { + const mockPngs: BlinkingButtonPngs = { + btnPngPath: "/tmp/test-btn.png", + glowPngPath: "/tmp/test-glow.png", + btnNativeW: 756, + btnNativeH: 172, + canvasWidth: 1000, + canvasHeight: 300, + }; + + test("returns correct structure", () => { + const result = buildBlinkingButtonFilter(2, 3, mockPngs, { + duration: 5, + fps: 30, + blinkFrequency: 0.8, + }); + + expect(result.outputLabel).toBe("btn_out"); + expect(result.canvasWidth).toBe(1000); + expect(result.canvasHeight).toBe(300); + expect(result.filters).toBeInstanceOf(Array); + expect(result.filters.length).toBeGreaterThan(0); + }); + + test("uses correct input indices", () => { + const result = buildBlinkingButtonFilter(5, 6, mockPngs, { + duration: 3, + fps: 30, + blinkFrequency: 0.8, + }); + + const joined = result.filters.join(";"); + // Should reference input 5 for button and 6 for glow + expect(joined).toContain("[5:v]"); + expect(joined).toContain("[6:v]"); + // Should NOT reference old indices + expect(joined).not.toContain("[0:v]"); + expect(joined).not.toContain("[1:v]"); + }); + + test("filter contains animation expressions", () => { + const result = buildBlinkingButtonFilter(0, 1, mockPngs, { + duration: 5, + fps: 30, + blinkFrequency: 0.8, + }); + + const joined = result.filters.join(";"); + // Should contain gamma for brightness animation + expect(joined).toContain("eq=gamma="); + expect(joined).toContain("eval=frame"); + // Should contain scale for zoom animation + expect(joined).toContain("scale=w="); + // Should contain overlay for compositing + expect(joined).toContain("overlay="); + // Should contain transparent canvas generation + expect(joined).toContain("color=0x00000000"); + // Should contain format=rgba for alpha handling + expect(joined).toContain("format=rgba"); + }); + + test("filter contains correct dimensions", () => { + const result = buildBlinkingButtonFilter(0, 1, mockPngs, { + duration: 3, + fps: 24, + blinkFrequency: 1.0, + }); + + const joined = result.filters.join(";"); + // Canvas dimensions + expect(joined).toContain( + `s=${mockPngs.canvasWidth}x${mockPngs.canvasHeight}`, + ); + // FPS + expect(joined).toContain("r=24"); + // Duration + expect(joined).toContain("d=3"); + }); + + test("final output label is btn_out", () => { + const result = buildBlinkingButtonFilter(0, 1, mockPngs, { + duration: 5, + fps: 30, + blinkFrequency: 0.8, + }); + + const lastFilter = result.filters[result.filters.length - 1]!; + expect(lastFilter).toContain("[btn_out]"); + }); +});