diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
index 194a82d8..bc336fb0 100644
--- a/src/components/ThemeToggle.tsx
+++ b/src/components/ThemeToggle.tsx
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import { useTheme } from "./ThemeProvider";
-import { useEffect, useState } from "react";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
@@ -27,14 +26,13 @@ export function ThemeToggle() {
}
const isDark = theme === "dark";
- const [mounted, setMounted] = useState(false);
return (
@@ -99,7 +119,7 @@ function AccordionSection({
id={`${id}-panel`}
className={cn(
"transition-all duration-200",
- isOpen ? "block" : "hidden"
+ isOpen ? "block" : "hidden",
)}
>
{children}
@@ -122,37 +142,47 @@ 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 (
@@ -173,9 +203,18 @@ function KeyboardShortcutsPanel() {
height="12"
viewBox="0 0 12 12"
fill="none"
- className={cn("text-[var(--muted)] transition-transform duration-200", open && "rotate-180")}
+ className={cn(
+ "text-[var(--muted)] transition-transform duration-200",
+ open && "rotate-180",
+ )}
>
-
+
@@ -185,7 +224,10 @@ function KeyboardShortcutsPanel() {
className="px-4 pb-3 space-y-2 border-t border-[var(--border)]"
>
{shortcuts.map(({ keys, label }) => (
-
+
{label}
{keys}
@@ -198,15 +240,31 @@ function KeyboardShortcutsPanel() {
export default function VideoEditor() {
const {
- file, duration, recipe, status, progress,
- result, error, exportStartedAt, updateRecipe,
- handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings,
+ file,
+ duration,
+ recipe,
+ status,
+ progress,
+ result,
+ error,
+ exportStartedAt,
+ updateRecipe,
+ handleFileSelect,
+ fileError,
+ handleExport,
+ cancelExport,
+ reset,
+ resetSettings,
videoRef,
seekTo,
- overlayFile, setOverlayFile,
- overlayPosition, setOverlayPosition,
- overlaySize, setOverlaySize,
- overlayOpacity, setOverlayOpacity,
+ overlayFile,
+ setOverlayFile,
+ overlayPosition,
+ setOverlayPosition,
+ overlaySize,
+ setOverlaySize,
+ overlayOpacity,
+ setOverlayOpacity,
recommendedPreset,
currentTime,
toggleSound,
@@ -242,9 +300,12 @@ export default function VideoEditor() {
/**
* Updates a text overlay property and syncs with recipe.
*/
- const handleUpdateTextOverlay = (id: string, updates: Partial
) => {
+ const handleUpdateTextOverlay = (
+ id: string,
+ updates: Partial,
+ ) => {
const updatedOverlays = (recipe.textOverlays || []).map((overlay) =>
- overlay.id === id ? { ...overlay, ...updates } : overlay
+ overlay.id === id ? { ...overlay, ...updates } : overlay,
);
updateRecipe({ textOverlays: updatedOverlays });
};
@@ -263,7 +324,9 @@ export default function VideoEditor() {
useEffect(() => {
if (status === "done" && downloadRef.current) {
- const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const prefersReducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)",
+ ).matches;
downloadRef.current.scrollIntoView({
behavior: prefersReducedMotion ? "instant" : "smooth",
block: "center",
@@ -272,7 +335,8 @@ export default function VideoEditor() {
}, [status]);
const isProcessing = status === "loading-engine" || status === "exporting";
- const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
+ const isMac =
+ typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const intervalSeconds = useMemo(() => {
if (duration <= 30) return 2;
@@ -283,21 +347,28 @@ export default function VideoEditor() {
const videoSrc = useMemo(
() => (file ? URL.createObjectURL(file) : null),
- [file]
+ [file],
);
const exportSummary = useMemo(() => {
const preset = getPresetById(recipe.preset);
- const width = recipe.preset === "custom" ? recipe.customWidth : (preset?.width ?? recipe.customWidth);
- const height = recipe.preset === "custom" ? recipe.customHeight : (preset?.height ?? recipe.customHeight);
+ const width =
+ recipe.preset === "custom"
+ ? recipe.customWidth
+ : (preset?.width ?? recipe.customWidth);
+ const height =
+ recipe.preset === "custom"
+ ? recipe.customHeight
+ : (preset?.height ?? recipe.customHeight);
const framingLabel = recipe.framing === "fit" ? "Fit" : "Fill";
const speedLabel = `${recipe.speed}× speed`;
- const qualityLabel = recipe.quality <= 21
- ? "High"
- : recipe.quality <= 25
- ? "Balanced"
- : "Small file";
+ const qualityLabel =
+ recipe.quality <= 21
+ ? "High"
+ : recipe.quality <= 25
+ ? "Balanced"
+ : "Small file";
return `Exporting to ${width}×${height} ${recipe.format.toUpperCase()} • ${framingLabel} • ${speedLabel} • Quality: ${qualityLabel}`;
}, [recipe]);
@@ -309,7 +380,10 @@ export default function VideoEditor() {
}, [videoSrc]);
return (
-
+
-
-
- REFRAME
-
-
- Your video, any format
-
-
-
- No login. No ads. 100% private.
-
-
-
-
- No login. No ads. 100% private - your video never leaves your device.
-
-
+
+
+ REFRAME
+
+
+ Your video, any format
+
+
+
+ No login. No ads. 100% private.
+
+
+
+
+ No login. No ads. 100% private - your video never leaves your
+ device.
+
+
-
-
+
{!file && (
@@ -388,6 +478,12 @@ export default function VideoEditor() {
selectedTextId={selectedTextId}
onSelectText={setSelectedTextId}
onUpdateText={handleUpdateTextOverlay}
+ overlayFile={overlayFile}
+ overlayPosition={overlayPosition}
+ overlaySize={overlaySize}
+ setOverlaySize={setOverlaySize}
+ overlayOpacity={overlayOpacity}
+ setOverlayPosition={setOverlayPosition}
/>
@@ -411,10 +507,12 @@ export default function VideoEditor() {
)}
{file && (
-
+
-
-
- ›
- Advanced settings
-
-
-
-
-
}
- title="Rotation"
- isOpen={openSections.rotation}
- onToggle={() => toggleSection("rotation")}
- >
-
-
-
-
}
- title="Export"
- isOpen={openSections.export}
- onToggle={() => toggleSection("export")}
- >
-
-
-
-
toggleSection("audio")}
delay={150}
>
-
+
}
@@ -526,7 +597,9 @@ export default function VideoEditor() {
max="1"
step="0.1"
value={recipe.brightness}
- onChange={(e) => updateRecipe({ brightness: Number(e.target.value) })}
+ onChange={(e) =>
+ updateRecipe({ brightness: Number(e.target.value) })
+ }
aria-label="Adjust brightness"
className="w-full accent-film-600"
/>
@@ -551,7 +624,9 @@ export default function VideoEditor() {
max="2"
step="0.1"
value={recipe.contrast}
- onChange={(e) => updateRecipe({ contrast: Number(e.target.value) })}
+ onChange={(e) =>
+ updateRecipe({ contrast: Number(e.target.value) })
+ }
aria-label="Adjust contrast"
className="w-full accent-film-600"
/>
@@ -576,14 +651,20 @@ export default function VideoEditor() {
max="3"
step="0.1"
value={recipe.saturation}
- onChange={(e) => updateRecipe({ saturation: Number(e.target.value) })}
+ onChange={(e) =>
+ updateRecipe({ saturation: Number(e.target.value) })
+ }
aria-label="Adjust saturation"
className="w-full accent-film-600"
/>
-
} title="Output format" delay={190}>
+
}
+ title="Output format"
+ delay={190}
+ >
toggleSection("export")}
delay={200}
>
-
+
-
} title="Image overlay" delay={120}>
+
}
+ title="Image overlay"
+ delay={120}
+ >
-
-
- ›
- Advanced settings
-
-
-
-
-
}
- title="Rotation"
- isOpen={openSections.rotation}
- onToggle={() => toggleSection("rotation")}
- >
-
-
-
-
}
- title="Export"
- isOpen={openSections.export}
- onToggle={() => toggleSection("export")}
- >
-
-
-
-
)}
@@ -647,7 +707,10 @@ export default function VideoEditor() {
role="status"
className="flex items-start gap-3 p-4 bg-film-50 border border-film-200 rounded-xl text-film-800 text-sm animate-fade-in"
>
-
+
Error
{error}
@@ -655,12 +718,18 @@ export default function VideoEditor() {
-
+
{!file && (
@@ -700,7 +776,10 @@ export default function VideoEditor() {
)}
-
+
}
@@ -712,7 +791,10 @@ export default function VideoEditor() {
{recommendedPreset && (
- We detected a {recommendedPreset.label.replace(/\s/g, "")} video → Recommended: {(recommendedPreset.platform.split("·")[0] ?? "").trim()} ({recommendedPreset.label.replace(/\s/g, "")})
+ We detected a {recommendedPreset.label.replace(/\s/g, "")}{" "}
+ video → Recommended:{" "}
+ {(recommendedPreset.platform.split("·")[0] ?? "").trim()}{" "}
+ ({recommendedPreset.label.replace(/\s/g, "")})
)}
@@ -753,19 +835,22 @@ export default function VideoEditor() {
id="export-button"
type="button"
onClick={handleExport}
- disabled={!file || isProcessing}
- aria-label='Export video'
- aria-disabled={!file || isProcessing ? "true" : undefined}
- title={!file ? "Upload a video to enable export" : undefined}
+ disabled={!file || isProcessing}
+ aria-label="Export video"
+ aria-disabled={!file || isProcessing ? "true" : undefined}
+ title={!file ? "Upload a video to enable export" : undefined}
className={cn(
"w-full flex items-center justify-center gap-3 py-5 min-h-[44px] rounded-xl",
"font-display text-2xl tracking-widest transition-all duration-200",
file && !isProcessing
? "bg-[var(--accent)] hover:bg-[var(--accent-hover)] hover:scale-[1.02] text-white shadow-[var(--shadow)] active:scale-[0.98] cursor-pointer"
- : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed"
+ : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed",
)}
>
-
+
{isProcessing ? "PROCESSING" : "EXPORT"}
diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx
index d684c1e5..fed532f3 100644
--- a/src/components/VideoPreview.tsx
+++ b/src/components/VideoPreview.tsx
@@ -16,6 +16,12 @@ interface Props {
selectedTextId?: string | null;
onSelectText?: (id: string | null) => void;
onUpdateText?: (id: string, updates: Partial
) => void;
+ overlayFile?: File | null;
+ overlayPosition?: { x: number; y: number };
+ overlaySize?: number;
+ overlayOpacity?: number;
+ setOverlayPosition?: (p: { x: number; y: number }) => void;
+ setOverlaySize?: (size: number) => void;
}
export default function VideoPreview({
@@ -25,19 +31,77 @@ export default function VideoPreview({
selectedTextId = null,
onSelectText,
onUpdateText,
+ overlayFile,
+ overlayPosition,
+ overlaySize = 250,
+ overlayOpacity = 100,
}: Props) {
const lastId = useRef(0);
const urlRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
- const [showOverlay, setShowOverlay] = useState(false);
const [showComparison, setShowComparison] = useState(false);
const [containerDimensions, setContainerDimensions] = useState({
width: 0,
height: 0,
});
const previewContainerRef = useRef(null);
+ const innerCanvasRef = useRef(null);
const onLoadedRef = useRef<(() => void) | null>(null);
+ const [overlayUrl, setOverlayUrl] = useState(null);
+
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [isMounted, setIsMounted] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+
+ // Listen to video media updates to safely drive the custom timeline bar on the client side
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handleTimeUpdate = () => setCurrentTime(video.currentTime);
+ const handleDurationChange = () => setDuration(video.duration || 0);
+ const handlePlay = () => setIsPlaying(true);
+ const handlePause = () => setIsPlaying(false);
+
+ video.addEventListener("timeupdate", handleTimeUpdate);
+ video.addEventListener("durationchange", handleDurationChange);
+ video.addEventListener("play", handlePlay);
+ video.addEventListener("pause", handlePause);
+
+ return () => {
+ video.removeEventListener("timeupdate", handleTimeUpdate);
+ video.removeEventListener("durationchange", handleDurationChange);
+ video.removeEventListener("play", handlePlay);
+ video.removeEventListener("pause", handlePause);
+ };
+ }, [file, videoRef]);
+
+ const formatTime = (secs: number) => {
+ if (isNaN(secs) || !isFinite(secs)) return "00:00";
+ const m = Math.floor(secs / 60)
+ .toString()
+ .padStart(2, "0");
+ const s = Math.floor(secs % 60)
+ .toString()
+ .padStart(2, "0");
+ return `${m}:${s}`;
+ };
+
+ // Handle local memory compilation for overlay source files safely
+ useEffect(() => {
+ if (!overlayFile) {
+ setOverlayUrl(null);
+ return;
+ }
+ const url = URL.createObjectURL(overlayFile);
+ setOverlayUrl(url);
+ return () => URL.revokeObjectURL(url);
+ }, [overlayFile]);
+
+ /** Capture the current video frame and download it as a PNG. */
const handleGrabFrame = useCallback(() => {
const video = videoRef.current;
if (!video || video.readyState < 2) return;
@@ -52,7 +116,6 @@ export default function VideoPreview({
canvas.toBlob((blob) => {
if (!blob) 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");
@@ -74,10 +137,6 @@ export default function VideoPreview({
setIsLoading(true);
const id = ++lastId.current;
const url = URL.createObjectURL(file);
-
- if (urlRef.current) {
- URL.revokeObjectURL(urlRef.current);
- }
urlRef.current = url;
const video = videoRef.current;
@@ -92,21 +151,18 @@ export default function VideoPreview({
};
onLoadedRef.current = handleLoaded;
-
- video.addEventListener("loadeddata", handleLoaded);
+ video.addEventListener("loadedmetadata", handleLoaded); // Optimized to prevent race-condition load locks
return () => {
if (onLoadedRef.current) {
- video.removeEventListener("loadeddata", onLoadedRef.current);
+ video.removeEventListener("loadedmetadata", onLoadedRef.current);
onLoadedRef.current = null;
}
-
if (video) {
video.pause();
video.removeAttribute("src");
video.load();
}
-
if (urlRef.current === url) {
URL.revokeObjectURL(urlRef.current);
urlRef.current = null;
@@ -124,9 +180,7 @@ export default function VideoPreview({
videoRef.current.playbackRate = recipe.speed;
}, [recipe, videoRef]);
- /**
- * Track preview container dimensions for text overlay positioning.
- */
+ /** Track preview container dimensions for text overlay positioning. */
useEffect(() => {
const updateDimensions = () => {
if (previewContainerRef.current) {
@@ -143,51 +197,34 @@ export default function VideoPreview({
return () => window.removeEventListener("resize", updateDimensions);
}, []);
- const overlay = (() => {
- if (!recipe || !showOverlay) return null;
-
- const preset = recipe.preset === "custom"
+ // --- Absolute WYSIWYG Canvas Math ---
+ const activePreset = recipe
+ ? recipe.preset === "custom"
? { width: recipe.customWidth, height: recipe.customHeight }
- : getPresetById(recipe.preset);
-
- if (!preset) return null;
-
- // Preview container is 16:9
- const containerW = 16;
- const containerH = 9;
- const containerRatio = containerW / containerH; // 1.777…
- const outputRatio = preset.width / preset.height;
-
- if (recipe.framing === "fit") {
- // Letterbox: the output video fits entirely inside 16:9, padded with bars.
- 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}%` };
- }
- }
- })();
-
- if (!file) return null;
+ : getPresetById(recipe.preset)
+ : undefined;
+
+ const containerRatio = 16 / 9;
+ const outputRatio = activePreset
+ ? activePreset.width / activePreset.height
+ : containerRatio;
+
+ let boxTop = 0,
+ boxBottom = 0,
+ boxLeft = 0,
+ boxRight = 0;
+
+ if (outputRatio > containerRatio) {
+ const boxHeightPct = (containerRatio / outputRatio) * 100;
+ const barH = (100 - boxHeightPct) / 2;
+ boxTop = barH;
+ boxBottom = barH;
+ } else {
+ const boxWidthPct = (outputRatio / containerRatio) * 100;
+ const barW = (100 - boxWidthPct) / 2;
+ boxLeft = barW;
+ boxRight = barW;
+ }
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.code === "Space") {
@@ -196,81 +233,70 @@ export default function VideoPreview({
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
- ) {
+ )
return;
- }
const video = videoRef.current;
if (video) {
- e.preventDefault(); // Prevent default page scroll
- if (video.paused) {
- video.play().catch(() => {});
- } else {
- video.pause();
- }
+ e.preventDefault();
+ if (video.paused) video.play().catch(() => {});
+ else video.pause();
}
}
};
+ if (!file) return null;
+
return (
<>
{isLoading && (
)}
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
-