diff --git a/src/components/ColorGradePresets.tsx b/src/components/ColorGradePresets.tsx
new file mode 100644
index 00000000..699d0998
--- /dev/null
+++ b/src/components/ColorGradePresets.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+interface ColorPreset {
+ label: string;
+ brightness: number;
+ contrast: number;
+ saturation: number;
+}
+
+const PRESETS: ColorPreset[] = [
+ { label: "Natural", brightness: 0, contrast: 1, saturation: 1 },
+ { label: "Warm", brightness: 0.05, contrast: 1.05, saturation: 1.15 },
+ { label: "Cool", brightness: 0, contrast: 1.05, saturation: 0.9 },
+ { label: "Cinematic", brightness: -0.05, contrast: 1.2, saturation: 0.8 },
+ { label: "Vivid", brightness: 0, contrast: 1.1, saturation: 1.3 },
+ { label: "B&W", brightness: 0.15, contrast: 1.15, saturation: 0 },
+];
+
+interface Props {
+ brightness: number;
+ contrast: number;
+ saturation: number;
+ onChange: (patch: { brightness: number; contrast: number; saturation: number }) => void;
+}
+
+export default function ColorGradePresets({ brightness, contrast, saturation, onChange }: Props) {
+ const activePreset = PRESETS.find(
+ (p) => p.brightness === brightness && p.contrast === contrast && p.saturation === saturation
+ );
+
+ return (
+
+ {PRESETS.map((preset) => {
+ const isActive = activePreset?.label === preset.label;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index db5c3793..06b36ba4 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -16,7 +16,7 @@ import ExportSettings from "./ExportSettings";
import ExportOverlay from "./ExportOverlay";
import DownloadResult from "./DownloadResult";
import ImageOverlay from "./ImageOverlay"
-
+import ColorGradePresets from "./ColorGradePresets";
import { cn } from "@/lib/utils";
import {
Layers, Crop, Scissors, RotateCw, Volume2,
@@ -64,37 +64,37 @@ function KeyboardShortcutsPanel() {
const [open, setOpen] = useState(false);
const shortcuts: { keys: React.ReactNode[]; label: string }[] = [
- {
- keys: [
- Ctrl,
- +,
- Shift,
- +,
- E
- ],
- label: "Export video",
- },
- {
- keys: [M],
- label: "Toggle audio mute",
- },
- {
- keys: [R],
- label: "Reset all settings",
- },
- {
- keys: [Esc],
- label: "Cancel export",
- },
- {
- keys: [1, –, 9],
- label: "Switch preset by index",
- },
- {
- keys: [?],
- label: "Toggle this panel",
- },
-];
+ {
+ keys: [
+ Ctrl,
+ +,
+ Shift,
+ +,
+ E,
+ ],
+ label: "Export video",
+ },
+ {
+ keys: [M],
+ label: "Toggle audio mute",
+ },
+ {
+ keys: [R],
+ label: "Reset all settings",
+ },
+ {
+ keys: [Esc],
+ label: "Cancel export",
+ },
+ {
+ keys: [1, –, 9],
+ label: "Switch preset by index",
+ },
+ {
+ keys: [?],
+ label: "Toggle this panel",
+ },
+ ];
return (
@@ -295,6 +295,13 @@ export default function VideoEditor() {
title="Adjustments"
delay={175}
>
+
+
{/* Brightness */}
diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx
index 71095330..48a94edc 100644
--- a/src/components/VideoPreview.tsx
+++ b/src/components/VideoPreview.tsx
@@ -3,9 +3,12 @@
import { useEffect, useRef, useState, useCallback, RefObject } from "react";
import { EditRecipe } from "@/lib/types";
+import { buildColorGradeCssFilter } from "@/lib/colorGrade";
import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";
import { Camera } from "lucide-react";
+import { captureFrameAsPng } from "@/lib/frame-export";
+import { DEFAULT_RECIPE } from "@/lib/constants";
interface Props {
file: File | null;
@@ -18,37 +21,79 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
const urlRef = useRef
(null);
const [isLoading, setIsLoading] = useState(true);
const [showOverlay, setShowOverlay] = useState(false);
+ const [frameNotice, setFrameNotice] = useState<{
+ kind: "success" | "error";
+ message: string;
+ } | null>(null);
+ const [isExportingFrame, setIsExportingFrame] = useState(false);
+ const isExportingFrameRef = useRef(false);
const onLoadedRef = useRef<(() => void) | null>(null);
+ const activeRecipe = recipe ?? DEFAULT_RECIPE;
+
+ useEffect(() => {
+ if (!frameNotice) return;
+
+ const timeoutId = window.setTimeout(() => setFrameNotice(null), 2500);
+ return () => window.clearTimeout(timeoutId);
+ }, [frameNotice]);
/** Capture the current video frame and download it as a PNG. */
- const handleGrabFrame = useCallback(() => {
+ const handleGrabFrame = useCallback(async () => {
+ if (isExportingFrameRef.current) return;
+
const video = videoRef.current;
- if (!video || video.readyState < 2) return;
+ if (!video) {
+ setFrameNotice({ kind: "error", message: "No video frame is available yet." });
+ return;
+ }
+
+ isExportingFrameRef.current = true;
+ setIsExportingFrame(true);
- const canvas = document.createElement("canvas");
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
+ try {
+ const { blob, filename } = await captureFrameAsPng(video, activeRecipe);
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setFrameNotice({ kind: "success", message: `Saved ${filename}` });
+ } catch (error) {
+ console.error("frame export failed:", error);
+ setFrameNotice({
+ kind: "error",
+ message: error instanceof Error ? error.message : "Frame export failed.",
+ });
+ } finally {
+ isExportingFrameRef.current = false;
+ setIsExportingFrame(false);
+ }
+ }, [activeRecipe, videoRef]);
- const ctx = canvas.getContext("2d");
- if (!ctx) return;
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ useEffect(() => {
+ const handleShortcut = (e: KeyboardEvent) => {
+ if (e.repeat) return;
- canvas.toBlob((blob) => {
- if (!blob) return;
+ const target = e.target as HTMLElement | null;
+ if (
+ target &&
+ (target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable)
+ ) {
+ return;
+ }
- const totalSec = Math.floor(video.currentTime);
- const mins = String(Math.floor(totalSec / 60)).padStart(2, "0");
- const secs = String(totalSec % 60).padStart(2, "0");
- const filename = `frame-${mins}m${secs}s.png`;
+ if (e.code === "KeyT") {
+ e.preventDefault();
+ void handleGrabFrame();
+ }
+ };
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- a.click();
- URL.revokeObjectURL(url);
- }, "image/png");
- }, [videoRef]);
+ window.addEventListener("keydown", handleShortcut);
+ return () => window.removeEventListener("keydown", handleShortcut);
+ }, [handleGrabFrame]);
useEffect(() => {
if (!file) return;
@@ -58,7 +103,6 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
const id = ++lastId.current;
const url = URL.createObjectURL(file);
- // cleanup previous object URL safely
if (urlRef.current) {
URL.revokeObjectURL(urlRef.current);
}
@@ -70,31 +114,26 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
video.src = url;
video.load();
- // define handler once per effect run
const handleLoaded = () => {
if (lastId.current !== id) return;
video.play().catch(() => {});
};
onLoadedRef.current = handleLoaded;
-
video.addEventListener("loadeddata", handleLoaded);
return () => {
- // cleanup event listener safely
if (onLoadedRef.current) {
video.removeEventListener("loadeddata", onLoadedRef.current);
onLoadedRef.current = null;
}
- // stop playback safely
if (video) {
video.pause();
video.removeAttribute("src");
video.load();
}
- // revoke only if still current
if (urlRef.current === url) {
URL.revokeObjectURL(urlRef.current);
urlRef.current = null;
@@ -102,16 +141,16 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
};
}, [file, videoRef]);
- // sync mute state to video element
+ // sync mute state to video element (audio normalization)
useEffect(() => {
- if (!videoRef.current || !recipe) return;
- videoRef.current.muted = !recipe.keepAudio;
- }, [recipe, videoRef]);
+ if (!videoRef.current) return;
+ videoRef.current.muted = !activeRecipe.keepAudio;
+ }, [activeRecipe.keepAudio, videoRef]);
useEffect(() => {
- if (!videoRef.current || !recipe) return;
- videoRef.current.playbackRate = recipe.speed;
- }, [recipe, videoRef]);
+ if (!videoRef.current) return;
+ videoRef.current.playbackRate = activeRecipe.speed;
+ }, [activeRecipe.speed, videoRef]);
/**
* Compute the overlay geometry for the selected preset + framing mode.
@@ -119,42 +158,35 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
* We express widths/heights as percentage strings for CSS.
*/
const overlay = (() => {
- if (!recipe || !showOverlay) return null;
+ if (!activeRecipe || !showOverlay) return null;
- const preset = recipe.preset === "custom"
- ? { width: recipe.customWidth, height: recipe.customHeight }
- : getPresetById(recipe.preset);
+ const preset = activeRecipe.preset === "custom"
+ ? { width: activeRecipe.customWidth, height: activeRecipe.customHeight }
+ : getPresetById(activeRecipe.preset);
if (!preset) return null;
- // Preview container is 16:9
const containerW = 16;
const containerH = 9;
- const containerRatio = containerW / containerH; // 1.777…
+ const containerRatio = containerW / containerH;
const outputRatio = preset.width / preset.height;
- if (recipe.framing === "fit") {
- // Letterbox: the output video fits entirely inside 16:9, padded with bars.
+ if (activeRecipe.framing === "fit") {
if (outputRatio > containerRatio) {
- // Wider output → pillarbox bars on top & bottom
const contentH = (containerRatio / outputRatio) * 100;
const barH = (100 - contentH) / 2;
return { mode: "fit", barTop: `${barH}%`, barBottom: `${barH}%`, barLeft: "0", barRight: "0" };
} else {
- // Taller output → letterbox bars on left & right
const contentW = (outputRatio / containerRatio) * 100;
const barW = (100 - contentW) / 2;
return { mode: "fit", barTop: "0", barBottom: "0", barLeft: `${barW}%`, barRight: `${barW}%` };
}
} else {
- // Fill / crop: the output fills the entire 16:9 preview — show a box representing what survives the crop.
if (outputRatio < containerRatio) {
- // Output is taller → crops top & bottom
const visibleH = (outputRatio / containerRatio) * 100;
const cropH = (100 - visibleH) / 2;
return { mode: "fill", barTop: `${cropH}%`, barBottom: `${cropH}%`, barLeft: "0", barRight: "0" };
} else {
- // Output is wider → crops left & right
const visibleW = (containerRatio / outputRatio) * 100;
const cropW = (100 - visibleW) / 2;
return { mode: "fill", barTop: "0", barBottom: "0", barLeft: `${cropW}%`, barRight: `${cropW}%` };
@@ -164,6 +196,8 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
if (!file) return null;
+ const colorFilter = buildColorGradeCssFilter(activeRecipe);
+
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.code === "Space") {
const target = e.target as HTMLElement;
@@ -177,7 +211,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
const video = videoRef.current;
if (video) {
- e.preventDefault(); // Prevent default page scroll
+ e.preventDefault();
if (video.paused) {
video.play().catch(() => {});
} else {
@@ -193,8 +227,22 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
className="relative w-full rounded-lg overflow-hidden bg-[#0a0a0a] aspect-video focus:outline-none focus-visible:ring-2 focus-visible:ring-film-500"
tabIndex={0}
onKeyDown={handleKeyDown}
- aria-label="Video preview (press Space to play/pause)"
+ aria-label="Video preview (press Space to play/pause, T to export the current frame)"
>
+ {frameNotice && (
+
+ {frameNotice.message}
+
+ )}
{isLoading && (
setIsLoading(false)}
playsInline
- muted={!recipe?.keepAudio}
+ muted={!activeRecipe.keepAudio}
>
- {/* Letterbox / Crop overlay */}
{overlay && (
{overlay.mode === "fit" ? (
- // Letterbox: semi-transparent bars outside the content area
<>
@@ -225,7 +272,6 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
>
) : (
- // Fill/crop: dashed border around the surviving area, dimmed outside
<>
@@ -245,8 +291,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
)}
- {/* Toggle button */}
- {recipe && !isLoading && (
+ {activeRecipe && !isLoading && (
)}
- {/* Grab frame button */}
{!isLoading && (
)}
);
-}
\ No newline at end of file
+}
diff --git a/src/lib/colorGrade.ts b/src/lib/colorGrade.ts
new file mode 100644
index 00000000..c45687ce
--- /dev/null
+++ b/src/lib/colorGrade.ts
@@ -0,0 +1,8 @@
+import type { EditRecipe } from "./types";
+
+/** CSS filter for live preview — values map 1:1 to recipe sliders. */
+export function buildColorGradeCssFilter(
+ recipe: Pick
+): string {
+ return `brightness(${1 + recipe.brightness}) contrast(${recipe.contrast}) saturate(${recipe.saturation})`;
+}
diff --git a/src/lib/exportEstimate.ts b/src/lib/exportEstimate.ts
index 77c22573..6591b0b1 100644
--- a/src/lib/exportEstimate.ts
+++ b/src/lib/exportEstimate.ts
@@ -29,9 +29,9 @@ function getOutputDimensions(recipe: EditRecipe): { width: number; height: numbe
const dims = PRESET_DIMENSIONS[recipe.preset];
if (dims) return dims;
}
- return {
- width: recipe.customWidth || 1920,
- height: recipe.customHeight || 1080
+ return {
+ width: recipe.customWidth || 1920,
+ height: recipe.customHeight || 1080,
};
}
@@ -100,16 +100,16 @@ export function estimateExportSize(recipe: EditRecipe, duration: number): number
// 4. Handle high-quality adaptive GIF estimation separately
if (recipe.format === "gif") {
const GIF_FPS = 15;
-
+
// Set base compression scaling factor for maximum quality (CRF 18)
const BASE_COMPRESSION = 0.85;
-
+
// Linearly reduce compression ratio as CRF slider increases toward 30
const qualityLossModifier = (recipe.quality - 18) * 0.035;
const effectiveCompression = Math.max(BASE_COMPRESSION - qualityLossModifier, 0.35);
const frames = outputDuration * GIF_FPS;
-
+
// Uncompressed raw/palette-mapped payload calculation (size in MB)
return (width * height * frames * effectiveCompression) / (1024 * 1024);
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 91040259..dbf9ac6b 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -82,8 +82,8 @@ export const DEFAULT_RECIPE: EditRecipe = {
format: "mp4",
stabilization: false,
brightness: 0,
- contrast: 0,
- saturation: 0,
+ contrast: 1,
+ saturation: 1,
soundOnCompletion: false,
version: RECIPE_VERSION,
};