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
89 changes: 57 additions & 32 deletions src/components/FramingControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,63 @@ 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"}
</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 className="flex flex-col gap-4">
<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" && (
<div className="border-t pt-4 border-[var(--border)] w-full">
<label className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)] mb-2 block">
Padding Style
</label>
<div className="flex gap-2">
{(["black", "blurred"] as const).map((style) => (
<button
key={style}
onClick={() => onChange({ fitBackground: style })}
className={cn(
"flex-1 min-h-[36px] text-sm rounded border transition-all duration-150",
(recipe.fitBackground || "black") === style
? "border-film-500 bg-film-50 text-film-700 font-medium"
: "border-[var(--border)] text-[var(--muted)] hover:border-film-300 bg-[var(--surface)]"
)}
>
{style === "black" ? "Solid Black" : "Blurred Video"}
</button>
))}
</div>
</div>
)}
</div>
);
}
17 changes: 13 additions & 4 deletions src/lib/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,10 +346,19 @@ 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.fitBackground === "blurred") {
filters.push(
`split=2[orig][bg]`,
`[bg]scale=${targetW}:${targetH}:force_original_aspect_ratio=increase,crop=${targetW}:${targetH},boxblur=40:40[bg_blur]`,
`[orig]scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease[fg]`,
`[bg_blur][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
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface EditRecipe {
customWidth: number;
customHeight: number;
framing: "fit" | "fill";
fitBackground?: "black" | "blurred";
trimStart: number;
trimEnd: number | null;
rotate: 0 | 90 | 180 | 270;
Expand Down Expand Up @@ -90,6 +91,7 @@ export function isValidRecipe(value: unknown): value is EditRecipe {
if (typeof v.customWidth !== "number" || !isFinite(v.customWidth)) return false;
if (typeof v.customHeight !== "number" || !isFinite(v.customHeight)) return false;
if (v.framing !== "fit" && v.framing !== "fill") return false;
if (v.fitBackground && v.fitBackground !== "black" && v.fitBackground !== "blurred") return false;
if (typeof v.trimStart !== "number" || !isFinite(v.trimStart)) return false;
if (!(v.trimEnd === null || (typeof v.trimEnd === "number" && isFinite(v.trimEnd)))) return false;
if (![0, 90, 180, 270].includes(v.rotate)) return false;
Expand Down