diff --git a/src/components/DownloadResult.tsx b/src/components/DownloadResult.tsx
index 9cb04286..6fac016a 100644
--- a/src/components/DownloadResult.tsx
+++ b/src/components/DownloadResult.tsx
@@ -3,7 +3,8 @@
import { useState, useEffect } from "react";
import { ExportResult } from "@/lib/types";
import { formatBytes } from "@/lib/utils";
-import { Download, RotateCcw, Share2, AlertCircle, Volume2, VolumeX } from "lucide-react";
+import { buildDownloadFilename } from "@/lib/fileNaming";
+import { Download, RotateCcw, Share2, Volume2, VolumeX } from "lucide-react";
import LottiePlayer from "./LottiePlayer";
import { NativeShareButton } from "./NativeShareButton";
import successAnim from "@/lib/lottie/success.json";
@@ -27,15 +28,17 @@ interface Props {
onReset: () => void;
soundOnCompletion: boolean;
onToggleSound: () => void;
+ defaultName?: string;
}
-export default function DownloadResult({ result, onReset, soundOnCompletion, onToggleSound }: Props) {
- const defaultName = `reframe_${result.width}x${result.height}`;
- const [name, setName] = useState(defaultName);
+export default function DownloadResult({ result, onReset, soundOnCompletion, onToggleSound, defaultName }: Props) {
+ const [name, setName] = useState(defaultName ?? `reframe_${result.width}x${result.height}`);
- const invalidCharRegex = /[<>:"/\\|?*]/;
- const isValid = !invalidCharRegex.test(name) && name.trim().length > 0;
- const filename = `${name.trim() || "untitled"}.${result.format}`;
+ useEffect(() => {
+ setName(defaultName ?? `reframe_${result.width}x${result.height}`);
+ }, [defaultName, result.width, result.height]);
+
+ const filename = buildDownloadFilename(name, result.format);
const shareHref = `https://x.com/intent/tweet?text=${encodeURIComponent(SHARE_TWEET_TEXT)}`;
@@ -105,7 +108,7 @@ export default function DownloadResult({ result, onReset, soundOnCompletion, onT
- = 100 ? "text-[var(--error)] font-medium" : "text-[var(--muted)]")}>
+
{100 - name.length} chars remaining
@@ -117,8 +120,7 @@ export default function DownloadResult({ result, onReset, soundOnCompletion, onT
onChange={(e) => setName(e.target.value)}
maxLength={100}
className={cn(
- "flex-1 px-3 py-2.5 bg-[var(--bg)] border rounded-lg text-sm transition-colors text-[var(--text)] placeholder:text-[var(--muted)]",
- !isValid && name.length > 0 ? "border-[var(--error)] focus:outline-[var(--error)] focus:ring-1 focus:ring-[var(--error)]" : "border-[var(--border)] focus:outline-[var(--accent)]"
+ "flex-1 px-3 py-2.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm transition-colors text-[var(--text)] placeholder:text-[var(--muted)] focus:outline-[var(--accent)]"
)}
placeholder="Enter filename"
/>
@@ -126,27 +128,16 @@ export default function DownloadResult({ result, onReset, soundOnCompletion, onT
.{result.format}
- {!isValid && name.length > 0 && (
-
-
- Filename contains invalid characters (\ / : * ? " < > |)
-
- )}
{
- if (!isValid) e.preventDefault();
- }}
>
Download {result.format.toUpperCase()}
diff --git a/src/components/ExportSettings.tsx b/src/components/ExportSettings.tsx
index 6eee9b9b..3bce8478 100644
--- a/src/components/ExportSettings.tsx
+++ b/src/components/ExportSettings.tsx
@@ -11,6 +11,7 @@ import {
estimateExportSize,
formatEstimatedSize,
} from "@/lib/exportEstimate";
+import { sanitizeFilenameBase } from "@/lib/fileNaming";
interface Props {
recipe: EditRecipe;
@@ -18,12 +19,16 @@ interface Props {
onChange: (
patch: Partial
) => void;
+ exportName: string;
+ onExportNameChange: (name: string) => void;
}
export default function ExportSettings({
recipe,
duration,
onChange,
+ exportName,
+ onExportNameChange,
}: Props) {
const label =
recipe.quality <= 21
@@ -44,6 +49,28 @@ export default function ExportSettings({
return (
<>
+
+
+
onExportNameChange(sanitizeFilenameBase(e.target.value))}
+ placeholder="reframe-video"
+ maxLength={80}
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400"
+ />
+
+ Used as the default filename when the export completes.
+
+
+
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index f2edb45c..4a8e1ba1 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -189,6 +189,7 @@ export function useVideoEditor() {
const [overlaySize, setOverlaySize] = useState(150);
const [overlayOpacity, setOverlayOpacity] = useState(100);
const [currentTime, setCurrentTime] = useState(0);
+ const [exportName, setExportName] = useState("reframe-video");
// Phase 1 MVP: Multi-track timeline support
const [multiTrackState, setMultiTrackState] = useState
(createMultiTrackState);
@@ -350,6 +351,7 @@ export function useVideoEditor() {
setError(null);
setFile(null);
setVideoMetadata(null);
+ setExportName("reframe-video");
if (!selectedFile) {
setFileError("");
@@ -623,6 +625,7 @@ export function useVideoEditor() {
const resetSettings = useCallback(() => {
setRecipe(DEFAULT_RECIPE);
+ setExportName("reframe-video");
try {
localStorage.removeItem(RECIPE_STORAGE_KEY);
localStorage.removeItem(LEGACY_SETTINGS_KEY);
@@ -649,6 +652,7 @@ export function useVideoEditor() {
setVideoMetadata(null);
setDuration(0);
setRecipe(DEFAULT_RECIPE);
+ setExportName("reframe-video");
setStatus("idle");
setProgress(0);
setResult(null);
@@ -720,6 +724,8 @@ export function useVideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
+ exportName,
+ setExportName,
// Phase 1 MVP: Multi-track timeline support
multiTrackState,
addTrack,
diff --git a/src/lib/fileNaming.ts b/src/lib/fileNaming.ts
new file mode 100644
index 00000000..662e3688
--- /dev/null
+++ b/src/lib/fileNaming.ts
@@ -0,0 +1,15 @@
+const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\u0000-\u001F]/g;
+
+export function sanitizeFilenameBase(input: string): string {
+ const cleaned = input
+ .replace(INVALID_FILENAME_CHARS, "")
+ .replace(/\s+/g, " ")
+ .trim()
+ .replace(/[. ]+$/g, "");
+
+ return cleaned || "reframe-video";
+}
+
+export function buildDownloadFilename(baseName: string, extension: string): string {
+ return `${sanitizeFilenameBase(baseName)}.${extension}`;
+}
diff --git a/src/lib/tests/fileNaming.test.ts b/src/lib/tests/fileNaming.test.ts
new file mode 100644
index 00000000..a601e27a
--- /dev/null
+++ b/src/lib/tests/fileNaming.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from "vitest";
+import { buildDownloadFilename, sanitizeFilenameBase } from "../fileNaming";
+
+describe("fileNaming", () => {
+ it("removes invalid filename characters and normalizes spacing", () => {
+ expect(sanitizeFilenameBase(' my:video / export ')).toBe("myvideo export");
+ });
+
+ it("falls back to a safe default when the name becomes empty", () => {
+ expect(sanitizeFilenameBase(" ")).toBe("reframe-video");
+ });
+
+ it("builds a complete filename with the requested extension", () => {
+ expect(buildDownloadFilename('final*cut', "mp4")).toBe("finalcut.mp4");
+ });
+});