Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 57 additions & 31 deletions src/components/FramingControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,64 @@ interface Props {

export default function FramingControl({ recipe, onChange }: Props) {
return (
<div className="flex gap-2">
{(["fit", "fill"] as const).map((mode) => {
const Icon = mode === "fit" ? Maximize2 : Crop;
const active = recipe.framing === mode;
return (
<button
type="button"
key={mode}
title={mode === "fit" ? "Fit: Adds black bars (letterbox) to fill empty space" : "Fill: Crops the video to fill the entire frame"}
onClick={() => onChange({ framing: mode })}
className={cn(
"flex-1 min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-2 py-4 rounded-lg border transition-all duration-150 hover:scale-[1.02] active:scale-[0.98]",
active
? "border-film-500 bg-film-50 text-film-700"
: "border-[var(--border)] text-[var(--muted)] hover:border-film-300 bg-[var(--surface)]"
)}
>
<Icon size={18} aria-hidden="true"/>
<span className="sr-only">
Set framing to {mode === "fit" ? "fit within frame" : "fill frame by cropping"}
<div className="flex flex-col gap-3">
<div className="flex gap-2">
{(["fit", "fill"] as const).map((mode) => {
const Icon = mode === "fit" ? Maximize2 : Crop;
const active = recipe.framing === mode;
return (
<button
type="button"
key={mode}
title={mode === "fit" ? "Fit: Adds black bars (letterbox) to fill empty space" : "Fill: Crops the video to fill the entire frame"}
onClick={() => onChange({ framing: mode })}
className={cn(
"flex-1 min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-2 py-4 rounded-lg border transition-all duration-150 hover:scale-[1.02] active:scale-[0.98]",
active
? "border-film-500 bg-film-50 text-film-700"
: "border-[var(--border)] text-[var(--muted)] hover:border-film-300 bg-[var(--surface)]"
)}
>
<Icon size={18} aria-hidden="true"/>
<span className="sr-only">
Set framing to {mode === "fit" ? "fit within frame" : "fill frame by cropping"}
</span>
<div className="text-center">
<p className="text-xs font-heading font-semibold uppercase tracking-wider">
{mode === "fit" ? "Fit" : "Fill"}
</p>
<p className="text-[10px] text-[var(--muted)] mt-0.5">
{mode === "fit" ? "Letterbox / pillarbox" : "Crop to frame"}
</p>
</div>
</button>
);
})}
</div>

{recipe.framing === "fit" && (
<label
htmlFor="blurBackground-toggle"
className="flex items-center gap-3 cursor-pointer p-3 rounded-lg border border-[var(--border)] bg-[var(--surface)] hover:border-film-300 transition-colors"
>
<input
id="blurBackground-toggle"
type="checkbox"
checked={recipe.blurBackground || false}
onChange={(e) => onChange({ blurBackground: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-film-600 focus:ring-film-500 bg-white"
/>
<span className="sr-only">Pillarbox Blur</span>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100" aria-hidden="true">
Pillarbox Blur
</span>
<span className="text-xs text-[var(--muted)] mt-0.5">
Fill empty space with blurred video
</span>
<div className="text-center">
<p className="text-xs font-heading font-semibold uppercase tracking-wider">
{mode === "fit" ? "Fit" : "Fill"}
</p>
<p className="text-[10px] text-[var(--muted)] mt-0.5">
{mode === "fit" ? "Letterbox / pillarbox" : "Crop to frame"}
</p>
</div>
</button>
);
})}
</div>
</label>
)}
</div>
);
}
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export const DEFAULT_RECIPE: EditRecipe = {
saturation: 1,
stabilization: false,
soundOnCompletion: false,
blurBackground: false,
normalizeAudio: false,
};
1 change: 1 addition & 0 deletions src/lib/exportEstimate.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { describe, test, expect } from "vitest";
import { estimateExportSize, formatEstimatedSize } from "./exportEstimate";
import { EditRecipe } from "./types";

Expand Down
20 changes: 15 additions & 5 deletions src/lib/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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`,
Expand Down
40 changes: 39 additions & 1 deletion src/lib/tests/ffmpeg.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});

2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface EditRecipe {
contrast: number;
saturation: number;
soundOnCompletion: boolean;
blurBackground: boolean;
}

export type OverlayPosition =
Expand Down Expand Up @@ -82,6 +83,7 @@ export const DEFAULT_RECIPE: EditRecipe = {
contrast: 0,
saturation: 0,
soundOnCompletion: false,
blurBackground: false,
};

export const MAX_FILE_SIZE =
Expand Down