@@ -174,9 +204,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",
+ )}
>
-
+
@@ -186,7 +225,10 @@ function KeyboardShortcutsPanel() {
className="px-4 pb-3 space-y-2 border-t border-[var(--border)]"
>
{shortcuts.map(({ keys, label }) => (
-
+
{label}
{keys}
@@ -199,15 +241,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,
@@ -221,7 +279,7 @@ export default function VideoEditor() {
handleExport,
status,
cancelExport,
- onToggleShortcutsModal: () => {},
+ onToggleShortcutsModal: () => { },
});
const [copied, setCopied] = useState(false);
@@ -239,7 +297,17 @@ export default function VideoEditor() {
overlaySize,
overlayOpacity,
});
- }, [overlayPosition, overlaySize, overlayOpacity, file]);
+
+ localStorage.setItem(
+ "editorState",
+ JSON.stringify({
+ recipe,
+ overlayPosition,
+ overlaySize,
+ overlayOpacity,
+ }),
+ );
+ }, [recipe, overlayPosition, overlaySize, overlayOpacity, file]);
const [selectedTextId, setSelectedTextId] = useState
(null);
const [openSections, setOpenSections] = useState({
resize: true,
@@ -249,6 +317,17 @@ export default function VideoEditor() {
audio: false,
export: false,
});
+
+ const getCoordinatesFromPreset = (preset: string) => {
+ switch (preset) {
+ case "top-left": return { x: 10, y: 10 };
+ case "top-right": return { x: 90, y: 10 };
+ case "bottom-left": return { x: 10, y: 90 };
+ case "bottom-right": return { x: 90, y: 90 };
+ default: return { x: 50, y: 50 };
+ }
+ };
+
useEffect(() => {
const restored = loadOverlayState(localStorage, {
overlayPosition: initialOverlayState.current.overlayPosition,
@@ -256,11 +335,19 @@ export default function VideoEditor() {
overlayOpacity: initialOverlayState.current.overlayOpacity,
});
- if (restored.overlayPosition) setOverlayPosition(restored.overlayPosition);
+ if (restored.overlayPosition) {
+ if (typeof restored.overlayPosition === 'string') {
+ // Convert the string preset to {x, y} coordinates
+ setOverlayPosition(getCoordinatesFromPreset(restored.overlayPosition));
+ } else {
+ // It's already an object, use it directly
+ setOverlayPosition(restored.overlayPosition);
+ }
+ }
+
if (typeof restored.overlaySize === "number") setOverlaySize(restored.overlaySize);
if (typeof restored.overlayOpacity === "number") setOverlayOpacity(restored.overlayOpacity);
}, [setOverlayOpacity, setOverlayPosition, setOverlaySize]);
-
const toggleSection = (key: keyof typeof openSections) =>
setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
const downloadRef = useRef(null);
@@ -268,9 +355,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 });
};
@@ -289,8 +379,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",
@@ -298,22 +389,23 @@ export default function VideoEditor() {
}
}, [status]);
useEffect(() => {
-const handleBeforeUnload = (e: BeforeUnloadEvent) => {
- if (file) {
- e.preventDefault();
- e.returnValue = "";
- }
-};
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (file) {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+ };
-window.addEventListener("beforeunload", handleBeforeUnload);
+ window.addEventListener("beforeunload", handleBeforeUnload);
-return () => {
- window.removeEventListener("beforeunload", handleBeforeUnload);
-};
-}, [file]);
+ return () => {
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ };
+ }, [file]);
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;
@@ -324,21 +416,28 @@ return () => {
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]);
@@ -350,7 +449,10 @@ return () => {
}, [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 && (
@@ -429,6 +547,12 @@ return () => {
selectedTextId={selectedTextId}
onSelectText={setSelectedTextId}
onUpdateText={handleUpdateTextOverlay}
+ overlayFile={overlayFile}
+ overlayPosition={overlayPosition}
+ overlaySize={overlaySize}
+ setOverlaySize={setOverlaySize}
+ overlayOpacity={overlayOpacity}
+ setOverlayPosition={setOverlayPosition}
/>
@@ -452,10 +576,12 @@ return () => {
)}
{file && (
-
+
{
onToggle={() => toggleSection("audio")}
delay={150}
>
-
+
}
@@ -537,7 +666,9 @@ return () => {
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"
/>
@@ -562,7 +693,9 @@ return () => {
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"
/>
@@ -587,14 +720,20 @@ return () => {
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}
+ >
{
onToggle={() => toggleSection("export")}
delay={200}
>
-
+
-
} title="Image overlay" delay={120}>
+
}
+ title="Image overlay"
+ delay={120}
+ >
{
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}
@@ -636,12 +787,18 @@ return () => {
-
+
{!file && (
@@ -681,7 +845,10 @@ return () => {
)}
-
+
}
@@ -693,7 +860,10 @@ return () => {
{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, "")})
)}
@@ -734,19 +904,22 @@ return () => {
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 b985f5cf..29be3c76 100644
--- a/src/components/VideoPreview.tsx
+++ b/src/components/VideoPreview.tsx
@@ -2,7 +2,7 @@
"use client";
import { useEffect, useRef, useState, useCallback, RefObject } from "react";
-import { EditRecipe, TextOverlay, TimelineTrack, MultiTrackEditorState } from "@/lib/types";
+import { EditRecipe, TextOverlay, MultiTrackEditorState } from "@/lib/types";
import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";
import { Camera } from "lucide-react";
@@ -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;
// Phase 1 MVP: Multi-track support
multiTrackState?: MultiTrackEditorState | null;
multiTrackVideoRefs?: Record>;
@@ -28,25 +34,84 @@ export default function VideoPreview({
selectedTextId = null,
onSelectText,
onUpdateText,
+ overlayFile,
+ overlayPosition,
+ overlaySize = 250,
+ overlayOpacity = 100,
multiTrackState,
multiTrackVideoRefs,
}: 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 [showGridOverlay, setShowGridOverlay] = useState(false);
+ const [showOverlay, setShowOverlay] = useState(false);
const [containerDimensions, setContainerDimensions] = useState({
width: 0,
height: 0,
});
const previewContainerRef = useRef(null);
+ const innerCanvasRef = useRef(null);
const onLoadedRef = useRef<(() => void) | null>(null);
-
+
// Phase 1 MVP: Multi-track URL management
const multiTrackUrlRefs = useRef>({});
+ 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;
@@ -61,7 +126,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");
@@ -83,10 +147,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;
@@ -97,25 +157,22 @@ export default function VideoPreview({
const handleLoaded = () => {
if (lastId.current !== id) return;
- video.play().catch(() => {});
+ video.play().catch(() => { });
};
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;
@@ -145,7 +202,7 @@ export default function VideoPreview({
videoRef.current.load();
// Auto-play for preview
- videoRef.current.play().catch(() => {});
+ videoRef.current.play().catch(() => { });
});
return () => {
@@ -167,9 +224,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) {
@@ -186,51 +241,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") {
@@ -239,112 +277,131 @@ 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 */}
-