From 2f25435921d9afe8e375e28bcb179b854d833e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Do=C4=9Fan=20=C3=96zban?= Date: Mon, 17 Aug 2026 12:47:19 +0200 Subject: [PATCH] Make layout blur, opacity, and watermark position apply in rendered videos. Still-image encodes ignored boxblur and 1s black fills; switch to gblur plus full-length black frames, and honor brand watermark position. --- lib/ffmpeg/brand-watermark.ts | 9 +++++++-- lib/ffmpeg/encode.ts | 1 + lib/layout.ts | 29 +++++++++++++++++++---------- scripts/layout.test.ts | 13 ++++++++++++- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/lib/ffmpeg/brand-watermark.ts b/lib/ffmpeg/brand-watermark.ts index 2d4bb21..396c945 100644 --- a/lib/ffmpeg/brand-watermark.ts +++ b/lib/ffmpeg/brand-watermark.ts @@ -1,20 +1,25 @@ /** * Modular FFmpeg filter fragments for the Songs2VID brand watermark overlay. - * Always bottom-right with padding; scales to a fraction of frame width. + * Honors corner/center position + padding; scales to a fraction of frame width. */ +import { overlayXy, type WatermarkPosition } from "../watermark"; + export function buildBrandWatermarkOverlayFilters(options: { baseLabel: string; watermarkInputIndex: number; watermarkWidthPx: number; + position?: WatermarkPosition; offsetX?: number; offsetY?: number; outLabel?: string; }): string[] { const ox = options.offsetX ?? 20; const oy = options.offsetY ?? 20; + const position = options.position ?? "bottom-right"; + const { x, y } = overlayXy(position, ox, oy); const out = options.outLabel ?? "vout"; return [ `[${options.watermarkInputIndex}:v]scale=${options.watermarkWidthPx}:-1[wm]`, - `[${options.baseLabel}][wm]overlay=W-w-${ox}:H-h-${oy}[${out}]`, + `[${options.baseLabel}][wm]overlay=${x}:${y}[${out}]`, ]; } diff --git a/lib/ffmpeg/encode.ts b/lib/ffmpeg/encode.ts index 01982ea..e252b7f 100644 --- a/lib/ffmpeg/encode.ts +++ b/lib/ffmpeg/encode.ts @@ -360,6 +360,7 @@ export async function encodeVideo(options: { baseLabel, watermarkInputIndex: defaultWmInputIndex, watermarkWidthPx: watermarkWidth, + position: settings.position, offsetX: settings.offsetX, offsetY: settings.offsetY, }), diff --git a/lib/layout.ts b/lib/layout.ts index 7d2b045..2a30b06 100644 --- a/lib/layout.ts +++ b/lib/layout.ts @@ -64,7 +64,7 @@ export type LayoutSettings = { * Ignored when `template` is set (art-track always uses a blur fill). */ blurFill: boolean; - /** 0–100 → FFmpeg boxblur intensity. */ + /** 0-100 - FFmpeg gblur intensity. */ blurAmount: number; /** 0–100 → how visible the blurred fill is (vs black). */ blurOpacity: number; @@ -134,12 +134,21 @@ export function blurToBoxblur(blurAmount: number): { radius: number; power: numb return { radius, power }; } +/** + * Still-image cover inputs (-loop 1 -r 1) respond poorly to boxblur; gblur is + * reliable and maps 0-100 to a visible sigma range. + */ export function boxblurFilterSegment(blurAmount: number): string { - const bb = blurToBoxblur(blurAmount); - if (!bb) return ""; - return `boxblur=luma_radius=${bb.radius}:luma_power=${bb.power}:chroma_radius=${bb.radius}:chroma_power=${bb.power}`; + const amount = clampBlurAmount(blurAmount, 0); + if (amount <= 0) return ""; + const sigma = Math.max(0.8, (amount / 100) * 24); + return `gblur=sigma=${sigma.toFixed(2)}`; } +/** Solid black used when fading blur opacity; must outlast any track length. */ +const BLACK_FRAME = (width: number, height: number, label: string) => + `color=c=black:s=${width}x${height}:d=86400[${label}]`; + type RawLayoutInput = { template?: unknown; layoutTemplate?: unknown; @@ -516,14 +525,14 @@ export function buildArtTrackFilterComplex(opts: { if (opacity >= 0.999) { parts.push(`${bgSrc}${bgChain}[blurred]`); } else if (opacity <= 0.001) { - parts.push(`color=c=black:s=${W}x${H}:d=1[blurred]`); + parts.push(BLACK_FRAME(W, H, "blurred")); } else { const a = opacity.toFixed(3); const b = (1 - opacity).toFixed(3); parts.push( `${bgSrc}${bgChain}[blur_raw]`, - `color=c=black:s=${W}x${H}:d=1[blk]`, - `[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`, + BLACK_FRAME(W, H, "blk"), + `[blur_raw][blk]blend=all_expr='A*${a}+B*${b}'[blurred]`, ); } @@ -560,14 +569,14 @@ export function buildClassicBlurFillFilterComplex(opts: { if (opacity >= 0.999) { parts.push(`[bg]${bgChain}[blurred]`); } else if (opacity <= 0.001) { - parts.push(`color=c=black:s=${W}x${H}:d=1[blurred]`); + parts.push(BLACK_FRAME(W, H, "blurred")); } else { const a = opacity.toFixed(3); const b = (1 - opacity).toFixed(3); parts.push( `[bg]${bgChain}[blur_raw]`, - `color=c=black:s=${W}x${H}:d=1[blk]`, - `[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`, + BLACK_FRAME(W, H, "blk"), + `[blur_raw][blk]blend=all_expr='A*${a}+B*${b}'[blurred]`, ); } diff --git a/scripts/layout.test.ts b/scripts/layout.test.ts index 75b894b..d9b878b 100644 --- a/scripts/layout.test.ts +++ b/scripts/layout.test.ts @@ -26,7 +26,7 @@ function testEnumsAndClamp() { assert.equal(blurToBoxblur(0), null); const mid = blurToBoxblur(50); assert.ok(mid && mid.radius >= 1 && mid.power >= 1); - assert.ok(boxblurFilterSegment(60).includes("boxblur=")); + assert.ok(boxblurFilterSegment(60).includes("gblur=")); assert.equal(boxblurFilterSegment(0), ""); } @@ -197,10 +197,21 @@ function testGeometryAndFilter() { blurOpacity: 100, }); assert.ok(classicBlur.includes("[0:v]split=2")); + assert.ok(classicBlur.includes("gblur=") || classicBlur.includes("[blurred]")); assert.ok(classicBlur.includes("overlay=(W-w)/2:(H-h)/2")); assert.equal(classicBlur.includes("pad="), false); assert.equal(classicBlur.includes("drawtext="), false); assert.ok(classicBlur.endsWith("[laid]")); + + const classicOpacity = buildClassicBlurFillFilterComplex({ + width: 1280, + height: 720, + blurAmount: 40, + blurOpacity: 50, + }); + assert.ok(classicOpacity.includes("d=86400")); + assert.equal(classicOpacity.includes("shortest=1"), false); + assert.ok(classicOpacity.includes("blend=")); } testEnumsAndClamp();