diff --git a/src/components/FramingControl.tsx b/src/components/FramingControl.tsx index f0da50fa..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 e713f779..f497ba6f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -21,6 +21,7 @@ export const DEFAULT_RECIPE: EditRecipe = { stabilization: false, denoise: false, soundOnCompletion: false, + blurBackground: false, normalizeAudio: false, textOverlays: [], version: RECIPE_VERSION, diff --git a/src/lib/exportEstimate.test.ts b/src/lib/exportEstimate.test.ts index de54f52c..fa7180f4 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"; import { describe, test, expect } from "vitest"; diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 625387d2..16dfeadd 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -346,10 +346,20 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n } 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 deb816b8..36785e04 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -34,6 +34,7 @@ export interface EditRecipe { contrast: number; saturation: number; soundOnCompletion: boolean; + blurBackground: boolean; textOverlays: TextOverlay[]; version: number; } @@ -75,6 +76,38 @@ export type ExportStatus = | "done" | "error"; +export const SPEED_STEPS = [ + 0.25, + 0.5, + 0.75, + 1, + 1.25, + 1.5, + 2, + 4, +] as const; + +export const DEFAULT_RECIPE: EditRecipe = { + preset: "vertical-9-16", + customWidth: 1920, + customHeight: 1080, + framing: "fit", + trimStart: 0, + trimEnd: null, + rotate: 0, + keepAudio: true, + normalizeAudio: false, + speed: 1, + quality: 23, + format: "mp4", + stabilization: false, + brightness: 0, + contrast: 0, + saturation: 0, + soundOnCompletion: false, + blurBackground: false, +}; + export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;