Skip to content
Open
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
39 changes: 15 additions & 24 deletions src/components/DownloadResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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)}`;

Expand Down Expand Up @@ -105,7 +108,7 @@ export default function DownloadResult({ result, onReset, soundOnCompletion, onT
<label htmlFor="filename-input" className="text-[var(--muted)] font-heading font-semibold uppercase tracking-wider">
Filename
</label>
<span className={cn("transition-colors", name.length >= 100 ? "text-[var(--error)] font-medium" : "text-[var(--muted)]")}>
<span className="text-[var(--muted)]">
{100 - name.length} chars remaining
</span>
</div>
Expand All @@ -117,36 +120,24 @@ 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"
/>
<span className="text-sm text-[var(--muted)] shrink-0 font-medium bg-[var(--bg)] px-3 py-2.5 border border-[var(--border)] rounded-lg">
.{result.format}
</span>
</div>
{!isValid && name.length > 0 && (
<p className="text-xs text-[var(--error)] px-1 flex items-center gap-1.5 mt-1 animate-fade-in">
<AlertCircle size={12} />
Filename contains invalid characters (\ / : * ? &quot; &lt; &gt; |)
</p>
)}
</div>

<div className="flex flex-wrap gap-2 pt-2">
<a
href={isValid ? result.blobUrl : undefined}
download={isValid ? filename : undefined}
href={result.blobUrl}
download={filename}
className={cn(
"flex-1 min-w-[10rem] flex items-center justify-center gap-2 py-3 text-sm font-heading font-bold uppercase tracking-wide rounded-lg transition-all",
isValid
? "bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] hover:scale-[1.02] active:scale-[0.99] cursor-pointer"
: "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed"
"bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] hover:scale-[1.02] active:scale-[0.99] cursor-pointer"
)}
onClick={(e) => {
if (!isValid) e.preventDefault();
}}
>
<Download size={15} aria-hidden="true" />
Download {result.format.toUpperCase()}
Expand Down
27 changes: 27 additions & 0 deletions src/components/ExportSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,24 @@ import {
estimateExportSize,
formatEstimatedSize,
} from "@/lib/exportEstimate";
import { sanitizeFilenameBase } from "@/lib/fileNaming";

interface Props {
recipe: EditRecipe;
duration: number;
onChange: (
patch: Partial<EditRecipe>
) => void;
exportName: string;
onExportNameChange: (name: string) => void;
}

export default function ExportSettings({
recipe,
duration,
onChange,
exportName,
onExportNameChange,
}: Props) {
const label =
recipe.quality <= 21
Expand All @@ -44,6 +49,28 @@ export default function ExportSettings({

return (
<>
<div>
<label
htmlFor="export-name"
className="text-sm font-heading font-semibold uppercase tracking-wider text-[var(--muted)] flex items-center gap-2 mb-2"
>
<SlidersHorizontal size={10} />
Export name
</label>
<input
id="export-name"
type="text"
value={exportName}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Used as the default filename when the export completes.
</p>
</div>

<div>
<div className="flex items-center justify-between mb-2">
<label
Expand Down
18 changes: 16 additions & 2 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ export default function VideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
exportName,
setExportName,
} = useVideoEditor();

useKeyboardShortcuts({
Expand Down Expand Up @@ -605,7 +607,13 @@ return () => {
onToggle={() => toggleSection("export")}
delay={200}
>
<ExportSettings recipe={recipe} duration={duration} onChange={updateRecipe} />
<ExportSettings
recipe={recipe}
duration={duration}
onChange={updateRecipe}
exportName={exportName}
onExportNameChange={setExportName}
/>
</AccordionSection>
<Section icon={<Layers size={12} />} title="Image overlay" delay={120}>
<ImageOverlay
Expand Down Expand Up @@ -662,7 +670,13 @@ return () => {

{status === "done" && result && (
<div role="status" className="animate-fade-in" ref={downloadRef}>
<DownloadResult result={result} onReset={reset} soundOnCompletion={recipe.soundOnCompletion} onToggleSound={toggleSound} />
<DownloadResult
result={result}
onReset={reset}
soundOnCompletion={recipe.soundOnCompletion}
onToggleSound={toggleSound}
defaultName={exportName}
/>
</div>
)}
</div>
Expand Down
6 changes: 6 additions & 0 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MultiTrackEditorState>(createMultiTrackState);
Expand Down Expand Up @@ -350,6 +351,7 @@ export function useVideoEditor() {
setError(null);
setFile(null);
setVideoMetadata(null);
setExportName("reframe-video");

if (!selectedFile) {
setFileError("");
Expand Down Expand Up @@ -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);
Expand All @@ -649,6 +652,7 @@ export function useVideoEditor() {
setVideoMetadata(null);
setDuration(0);
setRecipe(DEFAULT_RECIPE);
setExportName("reframe-video");
setStatus("idle");
setProgress(0);
setResult(null);
Expand Down Expand Up @@ -720,6 +724,8 @@ export function useVideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
exportName,
setExportName,
// Phase 1 MVP: Multi-track timeline support
multiTrackState,
addTrack,
Expand Down
15 changes: 15 additions & 0 deletions src/lib/fileNaming.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
16 changes: 16 additions & 0 deletions src/lib/tests/fileNaming.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});