diff --git a/src/components/AudioSpeedControl.tsx b/src/components/AudioSpeedControl.tsx
index dd6c6007..5ce82103 100644
--- a/src/components/AudioSpeedControl.tsx
+++ b/src/components/AudioSpeedControl.tsx
@@ -145,6 +145,54 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) {
)}
+
+
+
+
+ {recipe.audioFadeIn.toFixed(1)}s
+
+
+
onChange({ audioFadeIn: Number(e.target.value) })}
+ aria-label="Audio fade in duration"
+ className="w-full accent-film-600 disabled:cursor-not-allowed"
+ />
+
+
+
+ {recipe.audioFadeOut.toFixed(1)}s
+
+
+
onChange({ audioFadeOut: Number(e.target.value) })}
+ aria-label="Audio fade out duration"
+ className="w-full accent-film-600 disabled:cursor-not-allowed"
+ />
+ {!recipe.keepAudio && (
+
+ Enable audio to adjust fade timing.
+
+ )}
+
+
{recipe.keepAudio && (recipe.trimStart !== 0 || recipe.trimEnd !== null) && (
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index f2edb45c..af7e1d8b 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -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 (
@@ -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;
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index e713f779..c1ee70da 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -22,6 +22,8 @@ export const DEFAULT_RECIPE: EditRecipe = {
denoise: false,
soundOnCompletion: false,
normalizeAudio: false,
+ audioFadeIn: 0,
+ audioFadeOut: 0,
textOverlays: [],
version: RECIPE_VERSION,
};
diff --git a/src/lib/editorPersistence.ts b/src/lib/editorPersistence.ts
index 4b51537d..7aff4655 100644
--- a/src/lib/editorPersistence.ts
+++ b/src/lib/editorPersistence.ts
@@ -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
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index 625387d2..e4208d0f 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -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[] = [];
@@ -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(",");
}
@@ -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(",");
diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts
index f72ef583..552165e9 100644
--- a/src/lib/ffmpeg.worker.ts
+++ b/src/lib/ffmpeg.worker.ts
@@ -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[] = [];
@@ -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(",");
}
@@ -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(",");
diff --git a/src/lib/tests/editorPersistence.test.ts b/src/lib/tests/editorPersistence.test.ts
index e93ec704..25b93988 100644
--- a/src/lib/tests/editorPersistence.test.ts
+++ b/src/lib/tests/editorPersistence.test.ts
@@ -12,26 +12,42 @@ import {
} from "@/lib/editorPersistence";
import { EditRecipe } from "@/lib/types";
+function createStorage() {
+ const store = new Map
();
+ 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);
@@ -41,20 +57,20 @@ 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,
@@ -62,7 +78,7 @@ describe("editorPersistence", () => {
});
it("reads overlay state while ignoring any legacy embedded recipe", () => {
- localStorage.setItem(
+ storage.setItem(
EDITOR_STATE_KEY,
JSON.stringify({
recipe: { quality: 12 },
@@ -72,7 +88,7 @@ describe("editorPersistence", () => {
})
);
- expect(loadOverlayState(localStorage, {
+ expect(loadOverlayState(storage, {
overlayPosition: "bottom-right",
overlaySize: 150,
overlayOpacity: 100,
@@ -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");
});
});
diff --git a/src/lib/tests/ffmpeg.test.ts b/src/lib/tests/ffmpeg.test.ts
index c2e42e74..5e4385cd 100644
--- a/src/lib/tests/ffmpeg.test.ts
+++ b/src/lib/tests/ffmpeg.test.ts
@@ -1,17 +1,22 @@
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", () => {
@@ -19,30 +24,46 @@ describe("buildAudioFilter", () => {
// 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");
+ });
});
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 90827df9..9c8aae8f 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -34,6 +34,8 @@ export interface EditRecipe {
contrast: number;
saturation: number;
soundOnCompletion: boolean;
+ audioFadeIn: number;
+ audioFadeOut: number;
textOverlays: TextOverlay[];
version: number;
}
@@ -141,6 +143,8 @@ export function isValidRecipe(value: unknown): value is EditRecipe {
if (typeof v.contrast !== "number" || !isFinite(v.contrast)) return false;
if (typeof v.saturation !== "number" || !isFinite(v.saturation)) return false;
if (typeof v.soundOnCompletion !== "boolean") return false;
+ if (typeof v.audioFadeIn !== "number" || !isFinite(v.audioFadeIn)) return false;
+ if (typeof v.audioFadeOut !== "number" || !isFinite(v.audioFadeOut)) return false;
if (!Array.isArray(v.textOverlays)) return false;
return true;