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
48 changes: 48 additions & 0 deletions src/components/AudioSpeedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,54 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) {
</button>
)}

<div className={cn("space-y-3", !recipe.keepAudio && "opacity-50")}>
<div className="flex items-center justify-between gap-3">
<label htmlFor="audio-fade-in" className="text-sm font-heading font-semibold uppercase tracking-wider text-[var(--muted)]">
Fade in
</label>
<span className="text-sm text-[var(--muted)] font-medium">
{recipe.audioFadeIn.toFixed(1)}s
</span>
</div>
<input
id="audio-fade-in"
type="range"
min={0}
max={5}
step={0.1}
value={recipe.audioFadeIn}
disabled={!recipe.keepAudio}
onChange={(e) => onChange({ audioFadeIn: Number(e.target.value) })}
aria-label="Audio fade in duration"
className="w-full accent-film-600 disabled:cursor-not-allowed"
/>
<div className="flex items-center justify-between gap-3">
<label htmlFor="audio-fade-out" className="text-sm font-heading font-semibold uppercase tracking-wider text-[var(--muted)]">
Fade out
</label>
<span className="text-sm text-[var(--muted)] font-medium">
{recipe.audioFadeOut.toFixed(1)}s
</span>
</div>
<input
id="audio-fade-out"
type="range"
min={0}
max={5}
step={0.1}
value={recipe.audioFadeOut}
disabled={!recipe.keepAudio}
onChange={(e) => onChange({ audioFadeOut: Number(e.target.value) })}
aria-label="Audio fade out duration"
className="w-full accent-film-600 disabled:cursor-not-allowed"
/>
{!recipe.keepAudio && (
<p className="text-xs text-[var(--muted)]">
Enable audio to adjust fade timing.
</p>
)}
</div>

{recipe.keepAudio && (recipe.trimStart !== 0 || recipe.trimEnd !== null) && (
<div role="note" className="mt-3 p-3 bg-[var(--accent-muted)] border border-[var(--border)] rounded text-sm text-[var(--text)] leading-relaxed flex items-start gap-2 animate-fade-in">
<AlertTriangle size={12} aria-hidden="true" className="shrink-0 mt-0.5" />
Expand Down
11 changes: 11 additions & 0 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ function validateRecipe(recipe: EditRecipe, duration: number ): string | null {
recipe.saturation < 0 || recipe.saturation > 3,
"Saturation must be between 0 and 3.",
],
[
recipe.audioFadeIn < 0 || recipe.audioFadeIn > 5,
"Audio fade in must be between 0 and 5 seconds.",
],
[
recipe.audioFadeOut < 0 || recipe.audioFadeOut > 5,
"Audio fade out must be between 0 and 5 seconds.",
],
];

return (
Expand Down Expand Up @@ -249,6 +257,9 @@ export function useVideoEditor() {
return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 2;
case "saturation":
return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 3;
case "audioFadeIn":
case "audioFadeOut":
return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 5;
default:
return true;
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const DEFAULT_RECIPE: EditRecipe = {
denoise: false,
soundOnCompletion: false,
normalizeAudio: false,
audioFadeIn: 0,
audioFadeOut: 0,
textOverlays: [],
version: RECIPE_VERSION,
};
2 changes: 1 addition & 1 deletion src/lib/editorPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function loadPersistedRecipe(
try {
const parsed = JSON.parse(raw);
if (isValidRecipe(parsed)) {
return parsed;
return migrateRecipe(parsed);
}
} catch {
// fall through to legacy state
Expand Down
40 changes: 38 additions & 2 deletions src/lib/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,39 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n
return filters.join(",");
}

export function buildAudioFilter(speed: number, normalizeAudio: boolean): string {
function buildAudioFadeFilter(
recipe: EditRecipe,
speed: number,
videoDuration: number
): string {
const fadeIn = Math.max(0, recipe.audioFadeIn || 0);
const fadeOut = Math.max(0, recipe.audioFadeOut || 0);
if (fadeIn === 0 && fadeOut === 0) return "";

const trimmedDuration = Math.max(
0,
(recipe.trimEnd ?? videoDuration) - recipe.trimStart
);
const effectiveDuration = speed > 0 ? trimmedDuration / speed : trimmedDuration;
const filters: string[] = [];

if (fadeIn > 0) {
filters.push(`afade=t=in:st=0:d=${fadeIn}`);
}
if (fadeOut > 0 && effectiveDuration > 0) {
const fadeOutStart = Math.max(0, effectiveDuration - fadeOut);
filters.push(`afade=t=out:st=${Number(fadeOutStart.toFixed(3))}:d=${fadeOut}`);
}

return filters.join(",");
}

export function buildAudioFilter(
recipe: EditRecipe,
normalizeAudio: boolean,
videoDuration: number
): string {
const speed = recipe.speed;
if (speed <= 0) return "";
const filters: string[] = [];

Expand All @@ -412,6 +444,8 @@ export function buildAudioFilter(speed: number, normalizeAudio: boolean): string
}

if (normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11");
const fadeFilter = buildAudioFadeFilter(recipe, speed, videoDuration);
if (fadeFilter) filters.push(fadeFilter);

return filters.join(",");
}
Expand Down Expand Up @@ -440,7 +474,9 @@ function buildArguments(
): string[] {
const vf = buildVideoFilter(recipe, targetW, targetH);
const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : "";
const audioSpeed = hasOriginalAudio
? buildAudioFilter(recipe, recipe.normalizeAudio ?? false, videoDuration)
: "";
const afParts = [audioTrim, audioSpeed].filter(Boolean);
const af = afParts.join(",");

Expand Down
36 changes: 34 additions & 2 deletions src/lib/ffmpeg.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,35 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number):
return filters.join(",");
}

function buildAudioFilter(speed: number, normalizeAudio: boolean): string {
function buildAudioFadeFilter(
recipe: EditRecipe,
speed: number,
videoDuration: number
): string {
const fadeIn = Math.max(0, recipe.audioFadeIn || 0);
const fadeOut = Math.max(0, recipe.audioFadeOut || 0);
if (fadeIn === 0 && fadeOut === 0) return "";

const trimmedDuration = Math.max(
0,
(recipe.trimEnd ?? videoDuration) - recipe.trimStart
);
const effectiveDuration = speed > 0 ? trimmedDuration / speed : trimmedDuration;
const filters: string[] = [];

if (fadeIn > 0) {
filters.push(`afade=t=in:st=0:d=${fadeIn}`);
}
if (fadeOut > 0 && effectiveDuration > 0) {
const fadeOutStart = Math.max(0, effectiveDuration - fadeOut);
filters.push(`afade=t=out:st=${Number(fadeOutStart.toFixed(3))}:d=${fadeOut}`);
}

return filters.join(",");
}

function buildAudioFilter(recipe: EditRecipe, normalizeAudio: boolean, videoDuration: number): string {
const speed = recipe.speed;
if (speed <= 0) return "";
const filters: string[] = [];

Expand All @@ -163,6 +191,8 @@ function buildAudioFilter(speed: number, normalizeAudio: boolean): string {
}

if (normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11");
const fadeFilter = buildAudioFadeFilter(recipe, speed, videoDuration);
if (fadeFilter) filters.push(fadeFilter);

return filters.join(",");
}
Expand Down Expand Up @@ -191,7 +221,9 @@ function buildArguments(
): string[] {
const vf = buildVideoFilter(recipe, targetW, targetH);
const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : "";
const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : "";
const audioSpeed = hasOriginalAudio
? buildAudioFilter(recipe, recipe.normalizeAudio ?? false, videoDuration)
: "";
const afParts = [audioTrim, audioSpeed].filter(Boolean);
const af = afParts.join(",");

Expand Down
46 changes: 31 additions & 15 deletions src/lib/tests/editorPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,42 @@ import {
} from "@/lib/editorPersistence";
import { EditRecipe } from "@/lib/types";

function createStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => store.clear(),
};
}

describe("editorPersistence", () => {
const storage = createStorage();

beforeEach(() => {
localStorage.clear();
storage.clear();
});

it("loads the canonical recipe key first", () => {
const recipe: EditRecipe = { ...DEFAULT_RECIPE, quality: 28, version: DEFAULT_RECIPE.version };
localStorage.setItem(RECIPE_STORAGE_KEY, JSON.stringify(recipe));
localStorage.setItem(LEGACY_SETTINGS_KEY, JSON.stringify({ quality: 18 }));
storage.setItem(RECIPE_STORAGE_KEY, JSON.stringify(recipe));
storage.setItem(LEGACY_SETTINGS_KEY, JSON.stringify({ quality: 18 }));

expect(loadPersistedRecipe(localStorage, DEFAULT_RECIPE)).toEqual(recipe);
expect(loadPersistedRecipe(storage, DEFAULT_RECIPE)).toEqual(recipe);
});

it("migrates legacy settings when the canonical key is missing", () => {
localStorage.setItem(
storage.setItem(
LEGACY_SETTINGS_KEY,
JSON.stringify({ preset: "custom", quality: 19, speed: 1.5, customWidth: 1280, customHeight: 720 })
);

const loaded = loadPersistedRecipe(localStorage, DEFAULT_RECIPE);
const loaded = loadPersistedRecipe(storage, DEFAULT_RECIPE);

expect(loaded.preset).toBe("custom");
expect(loaded.quality).toBe(19);
Expand All @@ -41,28 +57,28 @@ describe("editorPersistence", () => {
});

it("persists only the canonical recipe key and clears legacy recipe settings", () => {
persistRecipe(localStorage, DEFAULT_RECIPE);
persistRecipe(storage, DEFAULT_RECIPE);

expect(localStorage.getItem(RECIPE_STORAGE_KEY)).toBe(JSON.stringify(DEFAULT_RECIPE));
expect(localStorage.getItem(LEGACY_SETTINGS_KEY)).toBeNull();
expect(storage.getItem(RECIPE_STORAGE_KEY)).toBe(JSON.stringify(DEFAULT_RECIPE));
expect(storage.getItem(LEGACY_SETTINGS_KEY)).toBeNull();
});

it("persists overlay state without recipe data", () => {
persistOverlayState(localStorage, {
persistOverlayState(storage, {
overlayPosition: "top-left",
overlaySize: 120,
overlayOpacity: 85,
});

expect(JSON.parse(localStorage.getItem(EDITOR_STATE_KEY) ?? "{}")).toEqual({
expect(JSON.parse(storage.getItem(EDITOR_STATE_KEY) ?? "{}")).toEqual({
overlayPosition: "top-left",
overlaySize: 120,
overlayOpacity: 85,
});
});

it("reads overlay state while ignoring any legacy embedded recipe", () => {
localStorage.setItem(
storage.setItem(
EDITOR_STATE_KEY,
JSON.stringify({
recipe: { quality: 12 },
Expand All @@ -72,7 +88,7 @@ describe("editorPersistence", () => {
})
);

expect(loadOverlayState(localStorage, {
expect(loadOverlayState(storage, {
overlayPosition: "bottom-right",
overlaySize: 150,
overlayOpacity: 100,
Expand All @@ -84,7 +100,7 @@ describe("editorPersistence", () => {
});

it("persists sound preference separately", () => {
persistSoundPreference(localStorage, true);
expect(localStorage.getItem("soundOnCompletion")).toBe("true");
persistSoundPreference(storage, true);
expect(storage.getItem("soundOnCompletion")).toBe("true");
});
});
43 changes: 32 additions & 11 deletions src/lib/tests/ffmpeg.test.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,69 @@
import { describe, it, expect } from "vitest";
import { buildAudioFilter } from "../ffmpeg";
import { DEFAULT_RECIPE } from "@/lib/constants";

function makeRecipe(overrides = {}) {
return { ...DEFAULT_RECIPE, ...overrides };
}

describe("buildAudioFilter", () => {
it("should return an empty string for 1.0x speed", () => {
expect(buildAudioFilter(1, false)).toBe("");
expect(buildAudioFilter(makeRecipe(), false, 60)).toBe("");
});

it("should chain two 0.5x filters for 0.25x speed", () => {
expect(buildAudioFilter(0.25, false)).toBe("atempo=0.5,atempo=0.5");
expect(buildAudioFilter(makeRecipe({ speed: 0.25 }), false, 60)).toBe("atempo=0.5,atempo=0.5");
});

it("should chain two 2.0x filters for 4.0x speed", () => {
expect(buildAudioFilter(4, false)).toBe("atempo=2.0,atempo=2");
expect(buildAudioFilter(makeRecipe({ speed: 4 }), false, 60)).toBe("atempo=2.0,atempo=2");
});

it("should chain multiple 0.5x filters and a remainder for 0.1x speed", () => {
// 0.1 / 0.5 = 0.2
// 0.2 / 0.5 = 0.4
// 0.4 / 0.5 = 0.8
// Result should be three 0.5s and one 0.8
expect(buildAudioFilter(0.1, false)).toBe("atempo=0.5,atempo=0.5,atempo=0.5,atempo=0.8");
expect(buildAudioFilter(makeRecipe({ speed: 0.1 }), false, 60)).toBe("atempo=0.5,atempo=0.5,atempo=0.5,atempo=0.8");
});

it("should chain multiple 2.0x filters and a remainder for 3.0x speed", () => {
// 3.0 / 2.0 = 1.5
expect(buildAudioFilter(3, false)).toBe("atempo=2.0,atempo=1.5");
expect(buildAudioFilter(makeRecipe({ speed: 3 }), false, 60)).toBe("atempo=2.0,atempo=1.5");
});

it("should handle boundary values inside the 0.5x-2.0x range without chaining", () => {
expect(buildAudioFilter(0.5, false)).toBe("atempo=0.5");
expect(buildAudioFilter(2.0, false)).toBe("atempo=2"); // Note: Number(2.0.toFixed(4)) -> 2
expect(buildAudioFilter(1.5, false)).toBe("atempo=1.5");
expect(buildAudioFilter(0.75, false)).toBe("atempo=0.75");
expect(buildAudioFilter(makeRecipe({ speed: 0.5 }), false, 60)).toBe("atempo=0.5");
expect(buildAudioFilter(makeRecipe({ speed: 2.0 }), false, 60)).toBe("atempo=2"); // Note: Number(2.0.toFixed(4)) -> 2
expect(buildAudioFilter(makeRecipe({ speed: 1.5 }), false, 60)).toBe("atempo=1.5");
expect(buildAudioFilter(makeRecipe({ speed: 0.75 }), false, 60)).toBe("atempo=0.75");
});

it("should chain properly for very large speeds", () => {
// 10 / 2.0 = 5
// 5 / 2.0 = 2.5
// 2.5 / 2.0 = 1.25
expect(buildAudioFilter(10, false)).toBe("atempo=2.0,atempo=2.0,atempo=2.0,atempo=1.25");
expect(buildAudioFilter(makeRecipe({ speed: 10 }), false, 60)).toBe("atempo=2.0,atempo=2.0,atempo=2.0,atempo=1.25");
});

it("should append loudnorm filter when normalizeAudio is true", () => {
const result = buildAudioFilter(1, true);
const result = buildAudioFilter(makeRecipe(), true, 60);
expect(result).toContain("loudnorm");
});

it("should append fade filters using the trimmed audio duration", () => {
const result = buildAudioFilter(
makeRecipe({
trimStart: 10,
trimEnd: 40,
audioFadeIn: 1.5,
audioFadeOut: 2,
}),
false,
60,
);

expect(result).toContain("afade=t=in:st=0:d=1.5");
expect(result).toContain("afade=t=out:st=28:d=2");
});
});
Loading