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