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.
This commit is contained in:
Atakan Doğan Özban
2026-08-17 12:47:19 +02:00
parent 0f363c5620
commit 2f25435921
4 changed files with 39 additions and 13 deletions
+7 -2
View File
@@ -1,20 +1,25 @@
/** /**
* Modular FFmpeg filter fragments for the Songs2VID brand watermark overlay. * 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: { export function buildBrandWatermarkOverlayFilters(options: {
baseLabel: string; baseLabel: string;
watermarkInputIndex: number; watermarkInputIndex: number;
watermarkWidthPx: number; watermarkWidthPx: number;
position?: WatermarkPosition;
offsetX?: number; offsetX?: number;
offsetY?: number; offsetY?: number;
outLabel?: string; outLabel?: string;
}): string[] { }): string[] {
const ox = options.offsetX ?? 20; const ox = options.offsetX ?? 20;
const oy = options.offsetY ?? 20; const oy = options.offsetY ?? 20;
const position = options.position ?? "bottom-right";
const { x, y } = overlayXy(position, ox, oy);
const out = options.outLabel ?? "vout"; const out = options.outLabel ?? "vout";
return [ return [
`[${options.watermarkInputIndex}:v]scale=${options.watermarkWidthPx}:-1[wm]`, `[${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}]`,
]; ];
} }
+1
View File
@@ -360,6 +360,7 @@ export async function encodeVideo(options: {
baseLabel, baseLabel,
watermarkInputIndex: defaultWmInputIndex, watermarkInputIndex: defaultWmInputIndex,
watermarkWidthPx: watermarkWidth, watermarkWidthPx: watermarkWidth,
position: settings.position,
offsetX: settings.offsetX, offsetX: settings.offsetX,
offsetY: settings.offsetY, offsetY: settings.offsetY,
}), }),
+19 -10
View File
@@ -64,7 +64,7 @@ export type LayoutSettings = {
* Ignored when `template` is set (art-track always uses a blur fill). * Ignored when `template` is set (art-track always uses a blur fill).
*/ */
blurFill: boolean; blurFill: boolean;
/** 0100 FFmpeg boxblur intensity. */ /** 0-100 - FFmpeg gblur intensity. */
blurAmount: number; blurAmount: number;
/** 0100 → how visible the blurred fill is (vs black). */ /** 0100 → how visible the blurred fill is (vs black). */
blurOpacity: number; blurOpacity: number;
@@ -134,12 +134,21 @@ export function blurToBoxblur(blurAmount: number): { radius: number; power: numb
return { radius, power }; 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 { export function boxblurFilterSegment(blurAmount: number): string {
const bb = blurToBoxblur(blurAmount); const amount = clampBlurAmount(blurAmount, 0);
if (!bb) return ""; if (amount <= 0) return "";
return `boxblur=luma_radius=${bb.radius}:luma_power=${bb.power}:chroma_radius=${bb.radius}:chroma_power=${bb.power}`; 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 = { type RawLayoutInput = {
template?: unknown; template?: unknown;
layoutTemplate?: unknown; layoutTemplate?: unknown;
@@ -516,14 +525,14 @@ export function buildArtTrackFilterComplex(opts: {
if (opacity >= 0.999) { if (opacity >= 0.999) {
parts.push(`${bgSrc}${bgChain}[blurred]`); parts.push(`${bgSrc}${bgChain}[blurred]`);
} else if (opacity <= 0.001) { } 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 { } else {
const a = opacity.toFixed(3); const a = opacity.toFixed(3);
const b = (1 - opacity).toFixed(3); const b = (1 - opacity).toFixed(3);
parts.push( parts.push(
`${bgSrc}${bgChain}[blur_raw]`, `${bgSrc}${bgChain}[blur_raw]`,
`color=c=black:s=${W}x${H}:d=1[blk]`, BLACK_FRAME(W, H, "blk"),
`[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`, `[blur_raw][blk]blend=all_expr='A*${a}+B*${b}'[blurred]`,
); );
} }
@@ -560,14 +569,14 @@ export function buildClassicBlurFillFilterComplex(opts: {
if (opacity >= 0.999) { if (opacity >= 0.999) {
parts.push(`[bg]${bgChain}[blurred]`); parts.push(`[bg]${bgChain}[blurred]`);
} else if (opacity <= 0.001) { } 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 { } else {
const a = opacity.toFixed(3); const a = opacity.toFixed(3);
const b = (1 - opacity).toFixed(3); const b = (1 - opacity).toFixed(3);
parts.push( parts.push(
`[bg]${bgChain}[blur_raw]`, `[bg]${bgChain}[blur_raw]`,
`color=c=black:s=${W}x${H}:d=1[blk]`, BLACK_FRAME(W, H, "blk"),
`[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`, `[blur_raw][blk]blend=all_expr='A*${a}+B*${b}'[blurred]`,
); );
} }
+12 -1
View File
@@ -26,7 +26,7 @@ function testEnumsAndClamp() {
assert.equal(blurToBoxblur(0), null); assert.equal(blurToBoxblur(0), null);
const mid = blurToBoxblur(50); const mid = blurToBoxblur(50);
assert.ok(mid && mid.radius >= 1 && mid.power >= 1); 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), ""); assert.equal(boxblurFilterSegment(0), "");
} }
@@ -197,10 +197,21 @@ function testGeometryAndFilter() {
blurOpacity: 100, blurOpacity: 100,
}); });
assert.ok(classicBlur.includes("[0:v]split=2")); 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.ok(classicBlur.includes("overlay=(W-w)/2:(H-h)/2"));
assert.equal(classicBlur.includes("pad="), false); assert.equal(classicBlur.includes("pad="), false);
assert.equal(classicBlur.includes("drawtext="), false); assert.equal(classicBlur.includes("drawtext="), false);
assert.ok(classicBlur.endsWith("[laid]")); 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(); testEnumsAndClamp();