diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index f89872d5..d43f5d18 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -130,7 +130,7 @@ export default function VideoEditor() {
{file && (
-
+
(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;
+ }
- const canvas = document.createElement("canvas");
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
+ isExportingFrameRef.current = true;
+ setIsExportingFrame(true);
- const ctx = canvas.getContext("2d");
- if (!ctx) return;
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ 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]);
- canvas.toBlob((blob) => {
- if (!blob) return;
+ useEffect(() => {
+ const handleShortcut = (e: KeyboardEvent) => {
+ if (e.repeat) 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`;
+ const target = e.target as HTMLElement | null;
+ if (
+ target &&
+ (target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable)
+ ) {
+ return;
+ }
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- a.click();
- URL.revokeObjectURL(url);
- }, "image/png");
- }, [videoRef]);
+ if (e.code === "KeyT") {
+ e.preventDefault();
+ void handleGrabFrame();
+ }
+ };
+
+ window.addEventListener("keydown", handleShortcut);
+ return () => window.removeEventListener("keydown", handleShortcut);
+ }, [handleGrabFrame]);
useEffect(() => {
if (!file) return;
@@ -107,11 +151,11 @@ 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;
@@ -121,7 +165,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
const containerRatio = containerW / containerH; // 1.777…
const outputRatio = preset.width / preset.height;
- if (recipe.framing === "fit") {
+ if (activeRecipe.framing === "fit") {
// Letterbox: the output video fits entirely inside 16:9, padded with bars.
if (outputRatio > containerRatio) {
// Wider output → pillarbox bars on top & bottom
@@ -181,8 +225,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 && (
setShowOverlay((v) => !v)}
@@ -252,13 +310,15 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
{!isLoading && (
)}
diff --git a/src/lib/frame-export.ts b/src/lib/frame-export.ts
new file mode 100644
index 00000000..abefd543
--- /dev/null
+++ b/src/lib/frame-export.ts
@@ -0,0 +1,121 @@
+import { DEFAULT_RECIPE } from "./constants";
+import { getPresetById } from "./presets";
+import { EditRecipe } from "./types";
+
+export interface FrameExportSize {
+ width: number;
+ height: number;
+}
+
+export interface FrameExportTransform extends FrameExportSize {
+ rotation: number;
+ scale: number;
+}
+
+function resolveOutputSize(recipe: EditRecipe): FrameExportSize {
+ if (recipe.preset === "custom") {
+ return {
+ width: recipe.customWidth,
+ height: recipe.customHeight,
+ };
+ }
+
+ return (
+ getPresetById(recipe.preset) ?? {
+ width: DEFAULT_RECIPE.customWidth,
+ height: DEFAULT_RECIPE.customHeight,
+ }
+ );
+}
+
+export function getFrameExportTransform(
+ recipe: EditRecipe,
+ sourceWidth: number,
+ sourceHeight: number
+): FrameExportTransform {
+ const { width, height } = resolveOutputSize(recipe);
+ const rotated = recipe.rotate === 90 || recipe.rotate === 270;
+
+ const fittedWidth = rotated ? sourceHeight : sourceWidth;
+ const fittedHeight = rotated ? sourceWidth : sourceHeight;
+
+ const scaleX = width / fittedWidth;
+ const scaleY = height / fittedHeight;
+ const scale = recipe.framing === "fit" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
+
+ return {
+ width,
+ height,
+ rotation: (recipe.rotate * Math.PI) / 180,
+ scale,
+ };
+}
+
+export function formatFrameExportFilename(date = new Date()): string {
+ const pad = (value: number) => value.toString().padStart(2, "0");
+
+ return `reframe-frame-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.png`;
+}
+
+export async function captureFrameAsPng(
+ video: HTMLVideoElement,
+ recipe: EditRecipe
+): Promise<{ blob: Blob; width: number; height: number; filename: string }> {
+ if (
+ video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA ||
+ video.videoWidth === 0 ||
+ video.videoHeight === 0
+ ) {
+ throw new Error("The current frame is not ready yet.");
+ }
+
+ const { width, height, rotation, scale } = getFrameExportTransform(
+ recipe,
+ video.videoWidth,
+ video.videoHeight
+ );
+
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("Canvas export is not supported in this browser.");
+ }
+
+ ctx.fillStyle = "#000000";
+ ctx.fillRect(0, 0, width, height);
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = "high";
+ ctx.save();
+ ctx.translate(width / 2, height / 2);
+ ctx.rotate(rotation);
+ ctx.scale(scale, scale);
+ ctx.drawImage(
+ video,
+ -video.videoWidth / 2,
+ -video.videoHeight / 2,
+ video.videoWidth,
+ video.videoHeight
+ );
+ ctx.restore();
+
+ const blob = await new Promise((resolve, reject) => {
+ canvas.toBlob((result) => {
+ if (result) {
+ resolve(result);
+ return;
+ }
+
+ reject(new Error("Could not create a PNG export."));
+ }, "image/png");
+ });
+
+ return {
+ blob,
+ width,
+ height,
+ filename: formatFrameExportFilename(),
+ };
+}
\ No newline at end of file
diff --git a/src/lib/tests/frame-export.test.ts b/src/lib/tests/frame-export.test.ts
new file mode 100644
index 00000000..183775cb
--- /dev/null
+++ b/src/lib/tests/frame-export.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_RECIPE } from "../constants";
+import { formatFrameExportFilename, getFrameExportTransform } from "../frame-export";
+
+describe("getFrameExportTransform", () => {
+ it("uses the preset output size for built-in presets", () => {
+ const result = getFrameExportTransform(
+ {
+ ...DEFAULT_RECIPE,
+ preset: "landscape-16-9",
+ },
+ 1920,
+ 1080
+ );
+
+ expect(result.width).toBe(1920);
+ expect(result.height).toBe(1080);
+ expect(result.scale).toBe(1);
+ expect(result.rotation).toBe(0);
+ });
+
+ it("fits rotated source footage into a portrait canvas", () => {
+ const result = getFrameExportTransform(
+ {
+ ...DEFAULT_RECIPE,
+ preset: "vertical-9-16",
+ framing: "fit",
+ rotate: 90,
+ },
+ 1920,
+ 1080
+ );
+
+ expect(result.width).toBe(1080);
+ expect(result.height).toBe(1920);
+ expect(result.scale).toBe(1);
+ expect(result.rotation).toBeCloseTo(Math.PI / 2);
+ });
+
+ it("crops more aggressively in fill mode", () => {
+ const result = getFrameExportTransform(
+ {
+ ...DEFAULT_RECIPE,
+ preset: "vertical-9-16",
+ framing: "fill",
+ rotate: 0,
+ },
+ 1920,
+ 1080
+ );
+
+ expect(result.scale).toBeCloseTo(1.7777777778);
+ });
+
+ it("uses custom dimensions when the custom preset is active", () => {
+ const result = getFrameExportTransform(
+ {
+ ...DEFAULT_RECIPE,
+ preset: "custom",
+ customWidth: 640,
+ customHeight: 360,
+ },
+ 1280,
+ 720
+ );
+
+ expect(result.width).toBe(640);
+ expect(result.height).toBe(360);
+ });
+});
+
+describe("formatFrameExportFilename", () => {
+ it("builds a stable timestamped filename", () => {
+ expect(formatFrameExportFilename(new Date("2026-05-19T14:23:55"))).toBe(
+ "reframe-frame-20260519-142355.png"
+ );
+ });
+});
\ No newline at end of file