diff --git a/src/components/FramingControl.tsx b/src/components/FramingControl.tsx index 75d93bc0..49f07905 100644 --- a/src/components/FramingControl.tsx +++ b/src/components/FramingControl.tsx @@ -11,38 +11,64 @@ interface Props { export default function FramingControl({ recipe, onChange }: Props) { return ( -
- {(["fit", "fill"] as const).map((mode) => { - const Icon = mode === "fit" ? Maximize2 : Crop; - const active = recipe.framing === mode; - return ( - + ); + })} +
+ + {recipe.framing === "fit" && ( + + )} ); } \ No newline at end of file diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 976307a4..3cc60550 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,5 +19,6 @@ export const DEFAULT_RECIPE: EditRecipe = { saturation: 1, stabilization: false, soundOnCompletion: false, + blurBackground: false, normalizeAudio: false, }; \ No newline at end of file diff --git a/src/lib/exportEstimate.test.ts b/src/lib/exportEstimate.test.ts index a79e7236..19494b7d 100644 --- a/src/lib/exportEstimate.test.ts +++ b/src/lib/exportEstimate.test.ts @@ -1,3 +1,4 @@ +import { describe, test, expect } from "vitest"; import { estimateExportSize, formatEstimatedSize } from "./exportEstimate"; import { EditRecipe } from "./types"; diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 4f215075..87028d4d 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -87,7 +87,7 @@ function buildSessionId(): string { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } -function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { +export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { const filters: string[] = []; if (recipe.trimStart > 0 || recipe.trimEnd !== null) { @@ -110,10 +110,20 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): } if (recipe.framing === "fit") { - filters.push( - `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`, - `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black` - ); + if (recipe.blurBackground) { + const preStr = filters.length > 0 ? filters.join(",") + "," : ""; + filters.length = 0; // clear existing filters, we will push the complex chain + const bgScale = `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase,crop=${targetW}:${targetH},boxblur=20:20`; + const fgScale = `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`; + filters.push( + `${preStr}split=2[blur][main];[blur]${bgScale}[bg];[main]${fgScale}[fg];[bg][fg]overlay=(W-w)/2:(H-h)/2` + ); + } else { + filters.push( + `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`, + `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black` + ); + } } else { filters.push( `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`, diff --git a/src/lib/tests/ffmpeg.test.ts b/src/lib/tests/ffmpeg.test.ts index c2e42e74..add3a9db 100644 --- a/src/lib/tests/ffmpeg.test.ts +++ b/src/lib/tests/ffmpeg.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { buildAudioFilter } from "../ffmpeg"; +import { buildAudioFilter, buildVideoFilter } from "../ffmpeg"; +import { DEFAULT_RECIPE } from "../types"; describe("buildAudioFilter", () => { it("should return an empty string for 1.0x speed", () => { @@ -46,3 +47,40 @@ describe("buildAudioFilter", () => { expect(result).toContain("loudnorm"); }); }); + +describe("buildVideoFilter", () => { + it("should generate standard letterbox filters when blurBackground is false", () => { + const recipe = { + ...DEFAULT_RECIPE, + framing: "fit" as const, + blurBackground: false, + }; + const filter = buildVideoFilter(recipe, 1080, 1920); + expect(filter).toContain("scale=1080:1920:force_original_aspect_ratio=decrease"); + expect(filter).toContain("pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black"); + }); + + it("should generate complex split and blur filters when blurBackground is true", () => { + const recipe = { + ...DEFAULT_RECIPE, + framing: "fit" as const, + blurBackground: true, + }; + const filter = buildVideoFilter(recipe, 1080, 1920); + expect(filter).toContain("split=2[blur][main]"); + expect(filter).toContain("scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,boxblur=20:20[bg]"); + expect(filter).toContain("scale=1080:1920:force_original_aspect_ratio=decrease[fg]"); + expect(filter).toContain("[bg][fg]overlay=(W-w)/2:(H-h)/2"); + }); + + it("should generate crop filters when framing is fill", () => { + const recipe = { + ...DEFAULT_RECIPE, + framing: "fill" as const, + }; + const filter = buildVideoFilter(recipe, 1080, 1920); + expect(filter).toContain("scale=1080:1920:force_original_aspect_ratio=increase"); + expect(filter).toContain("crop=1080:1920"); + }); +}); + diff --git a/src/lib/types.ts b/src/lib/types.ts index 521a82c2..f8abf2db 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -16,6 +16,7 @@ export interface EditRecipe { contrast: number; saturation: number; soundOnCompletion: boolean; + blurBackground: boolean; } export type OverlayPosition = @@ -82,6 +83,7 @@ export const DEFAULT_RECIPE: EditRecipe = { contrast: 0, saturation: 0, soundOnCompletion: false, + blurBackground: false, }; export const MAX_FILE_SIZE =