diff --git a/src/components/ExportSettings.tsx b/src/components/ExportSettings.tsx index ddc29a85..d961bdde 100644 --- a/src/components/ExportSettings.tsx +++ b/src/components/ExportSettings.tsx @@ -186,6 +186,57 @@ export default function ExportSettings({ +
+
+ + + + + onChange({ + denoise: e.target.checked, + }) + } + aria-label="Enable noise reduction" + aria-checked={recipe.denoise} + className="w-full accent-film-600 cursor-pointer" + /> + +
+ +

+ Reduce low-light video grain +

+ +
+ + May slightly increase export time. + +
+
); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fddfe9f2..f838e8a2 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,7 +19,8 @@ export const DEFAULT_RECIPE: EditRecipe = { contrast: 1, saturation: 1, stabilization: false, + denoise: false, soundOnCompletion: false, normalizeAudio: false, version: RECIPE_VERSION, -}; \ No newline at end of file +}; diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 4f215075..b5a6e428 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -39,7 +39,7 @@ export class FFmpegLoadError extends Error { } export async function loadFFmpeg( - signal?: AbortSignal, + signal?: AbortSignal, onProgress?: (percent: number) => void ): Promise { if (ffmpegInstance?.loaded) { @@ -96,7 +96,7 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): filters.push("setpts=PTS-STARTPTS"); } - + if (recipe.stabilization) { filters.push("deshake"); } @@ -122,9 +122,14 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): } if (recipe.speed !== 1) { - const pts = (1 / recipe.speed).toFixed(4); - filters.push(`setpts=${pts}*PTS`); + const pts = (1 / recipe.speed).toFixed(4); + filters.push(`setpts=${pts}*PTS`); + } + + if (recipe.denoise) { + filters.push("hqdn3d=1.5:1.5:6:6"); } + filters.push( `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}` ); @@ -177,7 +182,7 @@ 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.speed, recipe.normalizeAudio ?? false) : ""; const afParts = [audioTrim, audioSpeed].filter(Boolean); const af = afParts.join(","); @@ -328,7 +333,7 @@ export async function exportVideo( onProgress(Math.min(99, Math.round(progress * 100))); }; - + try { await ffmpeg.writeFile(inputName, await fetchFile(file), { signal }); @@ -477,4 +482,4 @@ export async function exportVideo( export function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} \ No newline at end of file +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 91040259..2e12a177 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,7 @@ export interface EditRecipe { quality: number; format: "mp4" | "webm" | "mkv" | "gif"; stabilization: boolean; + denoise: boolean; brightness: number; contrast: number; saturation: number; @@ -81,6 +82,7 @@ export const DEFAULT_RECIPE: EditRecipe = { quality: 23, format: "mp4", stabilization: false, + denoise: false, brightness: 0, contrast: 0, saturation: 0, @@ -118,4 +120,4 @@ export function isValidRecipe(value: unknown): value is EditRecipe { if (typeof v.soundOnCompletion !== "boolean") return false; return true; -} \ No newline at end of file +}