Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export default function VideoEditor() {

{file && (
<div className="mt-4 animate-fade-in">
<VideoPreview file={file} videoRef={videoRef} />
<VideoPreview file={file} recipe={recipe} videoRef={videoRef} />

<div className="mt-3">
<ThumbnailStrip
Expand Down
128 changes: 94 additions & 34 deletions src/components/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { EditRecipe } from "@/lib/types";
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;
Expand All @@ -18,37 +20,79 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
const urlRef = useRef<string | null>(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;

Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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 && (
<div
className={cn(
"absolute top-2 left-2 z-20 max-w-[calc(100%-5rem)] rounded-lg border px-3 py-2 text-xs font-semibold shadow-lg backdrop-blur-sm animate-fade-in",
frameNotice.kind === "success"
? "border-emerald-400/30 bg-emerald-950/85 text-emerald-100"
: "border-red-400/30 bg-red-950/85 text-red-100"
)}
role="status"
aria-live="polite"
>
{frameNotice.message}
</div>
)}
{isLoading && (
<div
className="absolute inset-0 animate-pulse bg-gray-700 rounded-xl transition-opacity duration-300"
Expand Down Expand Up @@ -231,7 +289,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
)}

{/* Toggle button */}
{recipe && !isLoading && (
{activeRecipe && !isLoading && (
<button
type="button"
onClick={() => setShowOverlay((v) => !v)}
Expand All @@ -252,13 +310,15 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
{!isLoading && (
<button
type="button"
onClick={handleGrabFrame}
className="absolute top-2 right-2 px-2 py-1 text-[10px] font-heading font-bold uppercase tracking-wider rounded transition-colors z-10 pointer-events-auto bg-black/60 text-white/70 hover:bg-black/80 flex items-center gap-1"
aria-label="Grab frame as PNG"
title="Download current frame as PNG"
onClick={() => void handleGrabFrame()}
disabled={isExportingFrame}
className="absolute top-2 right-2 px-2 py-1 text-[10px] font-heading font-bold uppercase tracking-wider rounded transition-colors z-10 pointer-events-auto bg-black/60 text-white/70 hover:bg-black/80 flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Export current frame as PNG"
aria-keyshortcuts="T"
title="Export current frame as PNG (T)"
>
<Camera className="w-3 h-3" />
Grab frame
{isExportingFrame ? "Exporting" : "Export frame"}
</button>
)}
</div>
Expand Down
121 changes: 121 additions & 0 deletions src/lib/frame-export.ts
Original file line number Diff line number Diff line change
@@ -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<Blob>((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(),
};
}
Loading
Loading