}
@@ -727,7 +792,9 @@ 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, "")})
)}
@@ -768,10 +835,10 @@ 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(
// Hidden on mobile — replaced by the fixed bottom bar below.
"w-full hidden lg:flex items-center justify-center gap-3 py-5 min-h-[44px] rounded-xl",
@@ -781,7 +848,7 @@ export default function VideoEditor() {
: "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed"
)}
>
-
+
{isProcessing ? "PROCESSING" : "EXPORT"}
diff --git a/src/hooks/tests/useVideoEditor.test.ts b/src/hooks/tests/useVideoEditor.test.ts
index a99157ec..8d62afea 100644
--- a/src/hooks/tests/useVideoEditor.test.ts
+++ b/src/hooks/tests/useVideoEditor.test.ts
@@ -1,5 +1,5 @@
import { renderHook, act } from "@testing-library/react";
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useVideoEditor } from "../useVideoEditor";
import { DEFAULT_RECIPE } from "@/lib/constants";
@@ -39,29 +39,38 @@ describe("useVideoEditor core controller", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
+ if (typeof window !== "undefined") {
+ window.history.replaceState(null, "", window.location.pathname);
+ }
+ });
+
+ afterEach(() => {
+ localStorageMock.clear();
});
it("should initialize with the default recipe", () => {
- const { result } = renderHook(() => useVideoEditor());
+ const { result, unmount } = renderHook(() => useVideoEditor());
expect(result.current.recipe).toEqual({
...DEFAULT_RECIPE,
soundOnCompletion: false,
});
expect(result.current.status).toBe("idle");
expect(result.current.file).toBeNull();
+ unmount();
});
it("should safely update recipe with valid patch values", () => {
- const { result } = renderHook(() => useVideoEditor());
+ const { result, unmount } = renderHook(() => useVideoEditor());
act(() => {
result.current.updateRecipe({ speed: 2, quality: 20 });
});
expect(result.current.recipe.speed).toBe(2);
expect(result.current.recipe.quality).toBe(20);
+ unmount();
});
it("should ignore invalid patch values via isValidValue check", () => {
- const { result } = renderHook(() => useVideoEditor());
+ const { result, unmount } = renderHook(() => useVideoEditor());
act(() => {
// 9999 is invalid for speed, -50 is invalid for quality
result.current.updateRecipe({ speed: 9999, quality: -50 } as any);
@@ -69,6 +78,7 @@ describe("useVideoEditor core controller", () => {
// Values should remain as defaults
expect(result.current.recipe.speed).toBe(DEFAULT_RECIPE.speed);
expect(result.current.recipe.quality).toBe(DEFAULT_RECIPE.quality);
+ unmount();
});
it("should reset settings to default when resetSettings is called", () => {
@@ -83,4 +93,24 @@ describe("useVideoEditor core controller", () => {
});
expect(result.current.recipe.speed).toBe(DEFAULT_RECIPE.speed);
});
+
+ it("should handle recipes with Unicode characters in text overlays without crashing", () => {
+ const { result } = renderHook(() => useVideoEditor());
+ const unicodeOverlay = {
+ id: "test-1",
+ text: "Hello 🎬 😊 广州!",
+ x: 10,
+ y: 20,
+ fontSize: 24,
+ color: "#ffffff",
+ fontWeight: "normal" as const,
+ };
+
+ act(() => {
+ result.current.updateRecipe({ textOverlays: [unicodeOverlay] });
+ });
+
+ expect(result.current.recipe.textOverlays).toEqual([unicodeOverlay]);
+ });
});
+
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index 6dff76f1..b4415ddc 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -1,7 +1,16 @@
"use client";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
-import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, TimelineTrack, MultiTrackEditorState, isValidRecipe } from "@/lib/types";
+import {
+ EditRecipe,
+ ExportResult,
+ ExportStatus,
+ MAX_FILE_SIZE,
+ OverlayPosition,
+ TimelineTrack,
+ MultiTrackEditorState,
+ isValidRecipe,
+} from "@/lib/types";
import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants";
import { getPresetById } from "@/lib/presets";
import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg";
@@ -28,18 +37,24 @@ import { saveSessionFile, loadSessionFile, clearSessionFile } from "@/lib/sessio
const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser";
-export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> {
+export function extractMetadata(
+ file: File
+): Promise<{ width: number; height: number; duration: number }> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const video = document.createElement("video");
const timeout = setTimeout(() => {
URL.revokeObjectURL(url);
- reject( new Error("Video metaData load timeout — the file may be too large or the device too slow. Please try again.") );
+ reject(
+ new Error(
+ "Video metaData load timeout — the file may be too large or the device too slow. Please try again."
+ )
+ );
}, 5000);
video.preload = "metadata";
video.onloadedmetadata = () => {
- clearTimeout(timeout)
+ clearTimeout(timeout);
resolve({
width: video.videoWidth,
height: video.videoHeight,
@@ -48,7 +63,7 @@ export function extractMetadata(file: File): Promise<{ width: number; height: nu
URL.revokeObjectURL(url);
};
video.onerror = () => {
- clearTimeout(timeout)
+ clearTimeout(timeout);
URL.revokeObjectURL(url);
reject(new Error("Failed to load video metadata"));
};
@@ -65,7 +80,10 @@ function verifyMagicBytes(file: File): Promise
{
return;
}
const arr = new Uint8Array(e.target.result as ArrayBuffer);
- const hex = Array.from(arr).map(b => b.toString(16).padStart(2, "0")).join("").toUpperCase();
+ const hex = Array.from(arr)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("")
+ .toUpperCase();
const ascii = String.fromCharCode(...arr);
// WebM / MKV
@@ -81,71 +99,67 @@ function verifyMagicBytes(file: File): Promise {
});
}
-function validateRecipe(recipe: EditRecipe, duration: number ): string | null {
+function validateRecipe(recipe: EditRecipe, duration: number): string | null {
const validations: Array<[boolean, string]> = [
- [
- recipe.trimStart < 0,
- "Trim start time cannot be less than 0 seconds.",
- ],
+ [recipe.trimStart < 0, "Trim start time cannot be less than 0 seconds."],
[
recipe.trimEnd !== null && duration > 0 && recipe.trimEnd > duration,
`Trim end time cannot exceed the video duration (${Math.floor(duration)}s).`,
],
[
- recipe.trimEnd !== null
- ? recipe.trimStart >= recipe.trimEnd
- : (duration > 0 && recipe.trimStart >= duration),
+ recipe.trimEnd !== null
+ ? recipe.trimStart >= recipe.trimEnd
+ : duration > 0 && recipe.trimStart >= duration,
"Trim start time must be earlier than the end time.",
],
[
- recipe.preset === "custom" && (Number.isNaN(recipe.customWidth) || recipe.customWidth < 16 || recipe.customWidth > 7680),
+ recipe.preset === "custom" &&
+ (Number.isNaN(recipe.customWidth) || recipe.customWidth < 16 || recipe.customWidth > 7680),
"Width must be between 16px and 7680px.",
],
[
- recipe.preset === "custom" && (Number.isNaN(recipe.customHeight) || recipe.customHeight < 16 || recipe.customHeight > 7680),
+ recipe.preset === "custom" &&
+ (Number.isNaN(recipe.customHeight) ||
+ recipe.customHeight < 16 ||
+ recipe.customHeight > 7680),
"Height must be between 16px and 7680px.",
],
[
!(SPEED_STEPS as readonly number[]).includes(recipe.speed),
"Please select a valid playback speed.",
],
- [
- recipe.quality < 18 || recipe.quality > 30,
- "Quality must be between 18 and 30.",
- ],
- [
- recipe.brightness < -1 || recipe.brightness > 1,
- "Brightness must be between -1 and 1.",
- ],
+ [recipe.quality < 18 || recipe.quality > 30, "Quality must be between 18 and 30."],
+ [recipe.brightness < -1 || recipe.brightness > 1, "Brightness must be between -1 and 1."],
- [
- recipe.contrast < 0 || recipe.contrast > 2,
- "Contrast must be between 0 and 2.",
- ],
+ [recipe.contrast < 0 || recipe.contrast > 2, "Contrast must be between 0 and 2."],
- [
- recipe.saturation < 0 || recipe.saturation > 3,
- "Saturation must be between 0 and 3.",
- ],
- [
- recipe.sharpness < 0 || recipe.sharpness > 3,
- "Sharpness must be between 0 and 3.",
- ],
+ [recipe.saturation < 0 || recipe.saturation > 3, "Saturation must be between 0 and 3."],
+ [recipe.sharpness < 0 || recipe.sharpness > 3, "Sharpness must be between 0 and 3."],
];
- return (
- validations.find(([condition]) => condition)?.[1] ??
- null
- );
+ return validations.find(([condition]) => condition)?.[1] ?? null;
}
function encodeRecipe(recipe: EditRecipe): string {
- return btoa(JSON.stringify(recipe));
+ return encodeURIComponent(JSON.stringify(recipe));
}
function decodeRecipe(encoded: string): Partial | null {
try {
- const decoded = JSON.parse(atob(encoded));
+ let jsonString: string;
+ try {
+ jsonString = decodeURIComponent(encoded);
+ if (!jsonString.trim().startsWith("{")) {
+ throw new Error();
+ }
+ } catch {
+ try {
+ jsonString = decodeURIComponent(escape(atob(encoded)));
+ } catch {
+ jsonString = atob(encoded);
+ }
+ }
+ const decoded = JSON.parse(jsonString);
if (!decoded || typeof decoded !== "object") return null;
// Validate the merged recipe before accepting any decoded values.
// This prevents a tampered or malformed share URL from injecting
@@ -191,25 +205,29 @@ export function useVideoEditor() {
const [currentTime, setCurrentTime] = useState(0);
// Phase 1 MVP: Multi-track timeline support
- const [multiTrackState, setMultiTrackState] = useState(createMultiTrackState);
+ const [multiTrackState, setMultiTrackState] =
+ useState(createMultiTrackState);
const addTrack = useCallback((track: TimelineTrack) => {
- setMultiTrackState(prev => addTrackToTimeline(prev, track));
+ setMultiTrackState((prev) => addTrackToTimeline(prev, track));
}, []);
const removeTrack = useCallback((trackId: string) => {
- setMultiTrackState(prev => removeTrackFromTimeline(prev, trackId));
+ setMultiTrackState((prev) => removeTrackFromTimeline(prev, trackId));
}, []);
const updateTrack = useCallback((trackId: string, updates: Partial) => {
- setMultiTrackState(prev => updateTrackInTimeline(prev, trackId, updates));
+ setMultiTrackState((prev) => updateTrackInTimeline(prev, trackId, updates));
}, []);
- const addVideoTrack = useCallback((videoFile: File, startTime: number = 0) => {
- const track = createTimelineTrack("video", videoFile, startTime);
- addTrack(track);
- return track;
- }, [addTrack]);
+ const addVideoTrack = useCallback(
+ (videoFile: File, startTime: number = 0) => {
+ const track = createTimelineTrack("video", videoFile, startTime);
+ addTrack(track);
+ return track;
+ },
+ [addTrack]
+ );
const isValidValue = (key: keyof EditRecipe, val: any): boolean => {
switch (key) {
@@ -228,7 +246,11 @@ export function useVideoEditor() {
case "rotate":
return val === 0 || val === 90 || val === 180 || val === 270;
case "speed":
- return typeof val === "number" && !isNaN(val) && [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4].includes(val);
+ return (
+ typeof val === "number" &&
+ !isNaN(val) &&
+ [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4].includes(val)
+ );
case "quality":
return typeof val === "number" && !isNaN(val) && val >= 18 && val <= 30;
case "format":
@@ -263,21 +285,23 @@ export function useVideoEditor() {
useEffect(() => {
if (typeof window === "undefined") return;
-
+
// Auto-restore saved video session
- loadSessionFile().then(async (savedFile) => {
- if (savedFile) {
- try {
- const { width, height, duration: dur } = await extractMetadata(savedFile);
- setDuration(dur);
- setVideoMetadata({ width, height, duration: dur });
- setFile(savedFile);
- } catch (e) {
- console.error("Failed to restore video session:", e);
- clearSessionFile().catch(console.error);
+ loadSessionFile()
+ .then(async (savedFile) => {
+ if (savedFile) {
+ try {
+ const { width, height, duration: dur } = await extractMetadata(savedFile);
+ setDuration(dur);
+ setVideoMetadata({ width, height, duration: dur });
+ setFile(savedFile);
+ } catch (e) {
+ console.error("Failed to restore video session:", e);
+ clearSessionFile().catch(console.error);
+ }
}
- }
- }).catch(console.error);
+ })
+ .catch(console.error);
try {
const params = new URLSearchParams(window.location.search);
@@ -298,7 +322,7 @@ export function useVideoEditor() {
}
const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array;
- const hasRecipeParams = recipeKeys.some(key => params.has(key));
+ const hasRecipeParams = recipeKeys.some((key) => params.has(key));
if (hasRecipeParams) {
const updatedPatch: Partial = {};
@@ -323,16 +347,21 @@ export function useVideoEditor() {
});
if (Object.keys(updatedPatch).length > 0) {
- setRecipe(prev => ({
+ setRecipe((prev) => ({
...prev,
- ...updatedPatch
+ ...updatedPatch,
}));
}
} else {
- setRecipe((current) => loadPersistedRecipe(localStorage, migratePersistedRecipe({
- ...current,
- soundOnCompletion: getStoredSoundPreference(localStorage),
- })));
+ setRecipe((current) =>
+ loadPersistedRecipe(
+ localStorage,
+ migratePersistedRecipe({
+ ...current,
+ soundOnCompletion: getStoredSoundPreference(localStorage),
+ })
+ )
+ );
}
} catch (e) {
// ignore
@@ -392,7 +421,7 @@ export function useVideoEditor() {
setError(null);
setFile(null);
setVideoMetadata(null);
-
+
if (!selectedFile) {
setFileError("");
return;
@@ -412,17 +441,21 @@ export function useVideoEditor() {
return;
}
- const validExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv'];
+ const validExtensions = [".mp4", ".mov", ".avi", ".webm", ".mkv"];
const filename = selectedFile.name.toLowerCase();
- const hasValidExtension = validExtensions.some(ext => filename.endsWith(ext));
+ const hasValidExtension = validExtensions.some((ext) => filename.endsWith(ext));
if (!hasValidExtension) {
- setError(`Layer 1 Validation Failed: Invalid file extension. Expected one of: ${validExtensions.join(', ')}`);
+ setError(
+ `Layer 1 Validation Failed: Invalid file extension. Expected one of: ${validExtensions.join(", ")}`
+ );
setStatus("error");
return;
}
if (!selectedFile.type.startsWith("video/")) {
- setError(`Layer 2 Validation Failed: Invalid MIME type. Expected video/*, got ${selectedFile.type || 'unknown'}`);
+ setError(
+ `Layer 2 Validation Failed: Invalid MIME type. Expected video/*, got ${selectedFile.type || "unknown"}`
+ );
setStatus("error");
return;
}
@@ -432,7 +465,9 @@ export function useVideoEditor() {
try {
const isVideo = await verifyMagicBytes(selectedFile);
if (!isVideo) {
- setError("Layer 3 Validation Failed: Invalid file content. The file's magic bytes do not match known video formats.");
+ setError(
+ "Layer 3 Validation Failed: Invalid file content. The file's magic bytes do not match known video formats."
+ );
setStatus("error");
return;
}
@@ -445,7 +480,7 @@ export function useVideoEditor() {
const suggested = getDownscaledDimensions(width, height);
setError(
`Layer 5 Validation Failed: Resolution too high (${width}×${height}). ` +
- `Maximum supported is 8K. Suggested safe size: ${suggested.width}×${suggested.height}.`
+ `Maximum supported is 8K. Suggested safe size: ${suggested.width}×${suggested.height}.`
);
setStatus("error");
return;
@@ -457,7 +492,9 @@ export function useVideoEditor() {
saveSessionFile(selectedFile).catch(console.error);
if (dimensionCheck === "warning") {
- console.warn(`[Reframe] High resolution video detected (${width}×${height}). Export may be slow.`);
+ console.warn(
+ `[Reframe] High resolution video detected (${width}×${height}). Export may be slow.`
+ );
}
setRecipe((prev) => {
const suggestedPreset = suggestPreset(width, height);
@@ -471,11 +508,12 @@ export function useVideoEditor() {
};
});
} catch (err) {
- setError(`Layer 4 Validation Failed: ${err instanceof Error ? err.message : "Unknown error"}`);
+ setError(
+ `Layer 4 Validation Failed: ${err instanceof Error ? err.message : "Unknown error"}`
+ );
setStatus("error");
}
})();
-
}, []);
const handleExport = useCallback(async () => {
@@ -535,23 +573,22 @@ export function useVideoEditor() {
exportDurationMs: Date.now() - startedAt,
});
setStatus("done");
- } catch (err) {
+ } catch (err) {
if (exportCancelledRef.current) return;
console.error("export failed:", err);
if (err instanceof FFmpegLoadError) {
setError(err.message);
- } else if (err instanceof Error && err.message.includes('network')) {
- setError('Network error. Check your internet connection and try again.');
- } else if (err instanceof Error && err.message.includes('codec')) {
- setError('This video format is not supported. Try converting to MP4 first.');
+ } else if (err instanceof Error && err.message.includes("network")) {
+ setError("Network error. Check your internet connection and try again.");
+ } else if (err instanceof Error && err.message.includes("codec")) {
+ setError("This video format is not supported. Try converting to MP4 first.");
} else {
- setError('Export failed. Please try again or use a different video.');
+ setError("Export failed. Please try again or use a different video.");
}
setExportStartedAt(null);
setStatus("error");
- }
- finally {
+ } finally {
if (exportAbortControllerRef.current === abortController) {
exportAbortControllerRef.current = null;
}
@@ -572,7 +609,6 @@ export function useVideoEditor() {
status,
]);
-
useEffect(() => {
if (status === "exporting") {
document.title = `Exporting ${progress}% | Reframe`;
@@ -594,9 +630,7 @@ export function useVideoEditor() {
}, []);
useEffect(() => {
- const shouldWarn =
- status === "exporting" ||
- status === "loading-engine";
+ const shouldWarn = status === "exporting" || status === "loading-engine";
if (!shouldWarn) return;
@@ -608,7 +642,7 @@ export function useVideoEditor() {
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [status]);
-
+
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (
@@ -636,11 +670,7 @@ export function useVideoEditor() {
if (e.key.toLowerCase() !== "m" || e.ctrlKey || e.metaKey || e.altKey) return;
const target = e.target as HTMLElement;
- if (
- target.tagName === "INPUT" ||
- target.tagName === "TEXTAREA" ||
- target.isContentEditable
- ) {
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return;
}
@@ -653,13 +683,13 @@ export function useVideoEditor() {
};
}, [file]);
- useEffect(()=>{
- return ()=>{
- if(result?.blobUrl){
+ useEffect(() => {
+ return () => {
+ if (result?.blobUrl) {
URL.revokeObjectURL(result.blobUrl);
}
- }
- },[result?.blobUrl])
+ };
+ }, [result?.blobUrl]);
useEffect(() => {
return () => {
@@ -689,7 +719,6 @@ export function useVideoEditor() {
setExportStartedAt(null);
}, []);
-
const reset = useCallback(() => {
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
setFile(null);
@@ -710,7 +739,6 @@ export function useVideoEditor() {
}
}, [result]);
-
useEffect(() => {
persistSoundPreference(localStorage, recipe.soundOnCompletion);
}, [recipe.soundOnCompletion]);
@@ -728,8 +756,8 @@ export function useVideoEditor() {
});
const toggleSound = useCallback(() => {
- updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion });
-}, [recipe.soundOnCompletion, updateRecipe]);
+ updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion });
+ }, [recipe.soundOnCompletion, updateRecipe]);
return {
file,
diff --git a/src/lib/exportEstimate.test.ts b/src/lib/exportEstimate.test.ts
index fa7180f4..d186e203 100644
--- a/src/lib/exportEstimate.test.ts
+++ b/src/lib/exportEstimate.test.ts
@@ -1,7 +1,5 @@
-import { describe, test, expect } from "vitest";
import { estimateExportSize, formatEstimatedSize } from "./exportEstimate";
import { EditRecipe } from "./types";
-import { describe, test, expect } from "vitest";
// Minimal recipe factory — only the fields estimateExportSize cares about
function makeRecipe(overrides: Partial = {}): EditRecipe {
@@ -50,9 +48,9 @@ describe("estimateExportSize", () => {
expect(long / short).toBeCloseTo(4, 0);
});
- test("higher resolution (4k) produces a larger estimate than 720p", () => {
- const hd = estimateExportSize(makeRecipe({ preset: "720p" }), 60);
- const uhd = estimateExportSize(makeRecipe({ preset: "4k" }), 60);
+ test("higher resolution produces a larger estimate than lower resolution", () => {
+ const hd = estimateExportSize(makeRecipe({ preset: "twitter-hd" }), 60);
+ const uhd = estimateExportSize(makeRecipe({ preset: "landscape-16-9" }), 60);
expect(uhd).toBeGreaterThan(hd);
});