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
154 changes: 154 additions & 0 deletions src/components/ExportHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { useState } from "react";

interface ExportHistoryItem {
id: string;
blobUrl: string;
filename: string;
format: string;
size: number;
width: number;
height: number;
exportedAt: number;
}

interface ExportHistoryProps {
history: ExportHistoryItem[];
onClear: () => void;
}

function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;

if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}

return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export default function ExportHistory({
history,
onClear,
}: ExportHistoryProps) {
const [isOpen, setIsOpen] = useState(true);

if (!history.length) return null;

const handleDownload = (item: ExportHistoryItem) => {
const link = document.createElement("a");

link.href = item.blobUrl;
link.download = item.filename;

document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};

return (
<div className="mt-6 bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5 animate-fade-in">
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="flex items-center gap-2 text-left"
>
<h3 className="text-sm font-heading font-bold uppercase tracking-widest text-[var(--muted)]">
Recent Exports
</h3>

<span className="text-xs text-[var(--muted)]">
{isOpen ? "▲" : "▼"}
</span>
</button>

<button
type="button"
onClick={onClear}
className="
px-3 py-1.5
rounded-lg
border border-[var(--border)]
bg-[var(--surface)]
text-xs font-heading font-bold uppercase tracking-widest
text-[var(--muted)]
hover:border-film-500
hover:text-film-500
transition-all
"
>
Clear History
</button>
</div>

<p className="mt-3 text-xs text-[var(--muted)] opacity-70">
History is cleared when you close or refresh the page.
</p>

{isOpen && (
<div className="mt-4 space-y-3">
{history.map((item) => (
<div
key={item.id}
className="
flex flex-col gap-4
rounded-xl
border border-[var(--border)]
bg-[var(--surface)]
p-4
md:flex-row
md:items-center
md:justify-between
"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-[var(--text)]">
{item.filename}
</p>

<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
<span>{item.format.toUpperCase()}</span>

<span>•</span>

<span>
{item.width}×{item.height}
</span>

<span>•</span>

<span>{formatFileSize(item.size)}</span>
</div>

<p className="mt-2 text-xs text-[var(--muted)] opacity-70">
Exported{" "}
{new Date(item.exportedAt).toLocaleString()}
</p>
</div>

<button
type="button"
onClick={() => handleDownload(item)}
className="
px-4 py-2
rounded-lg
bg-film-600
hover:bg-film-700
text-white
text-sm font-semibold
transition-all
active:scale-[0.98]
whitespace-nowrap
"
>
Download
</button>
</div>
))}
</div>
)}
</div>
);
}
22 changes: 16 additions & 6 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SlidersHorizontal, Zap, AlertTriangle, Github
} from "lucide-react";
import OnboardingTour from "./OnboardingTour";
import ExportHistory from "@/components/ExportHistory";

interface SectionProps {
icon: React.ReactNode;
Expand Down Expand Up @@ -51,7 +52,7 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) {
export default function VideoEditor() {
const {
file, duration, recipe, status, progress,
result, error, updateRecipe,
result, exportHistory, clearExportHistory, error, updateRecipe,
handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings,
videoRef,
seekTo,
Expand Down Expand Up @@ -305,11 +306,20 @@ export default function VideoEditor() {
</div>
)}

{status === "done" && result && (
<div role="status" className="animate-fade-in" ref={downloadRef}>
<DownloadResult result={result} onReset={reset} soundOnCompletion={recipe.soundOnCompletion} />
</div>
)}
{status === "done" && result && (
<div role="status" className="animate-fade-in" ref={downloadRef}>
<DownloadResult
result={result}
onReset={reset}
soundOnCompletion={recipe.soundOnCompletion}
/>
</div>
)}

<ExportHistory
history={exportHistory}
onClear={clearExportHistory}
/>
</div>

<div className={cn(
Expand Down
69 changes: 56 additions & 13 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { getPresetById } from "@/lib/presets";
import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg";
import { suggestPreset } from "@/lib/presetSuggestion";

interface ExportHistoryItem extends ExportResult {
id: string;
filename: string;
exportedAt: number;
}

const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser";

export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> {
Expand Down Expand Up @@ -131,6 +137,7 @@ export function useVideoEditor() {
const [status, setStatus] = useState<ExportStatus>("idle");
const [progress, setProgress] = useState(0);
const [result, setResult] = useState<ExportResult | null>(null);
const [exportHistory, setExportHistory] = useState<ExportHistoryItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [fileError, setFileError] = useState("");
const exportAbortControllerRef = useRef<AbortController | null>(null);
Expand Down Expand Up @@ -274,7 +281,6 @@ export function useVideoEditor() {
setStatus("loading-engine");
setProgress(0);
setError(null);
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
setResult(null);

const ffmpeg = await loadFFmpeg(abortController.signal);
Expand Down Expand Up @@ -304,7 +310,29 @@ export function useVideoEditor() {
if (exportCancelledRef.current) return;

setResult(exportResult);
setStatus("done");

const historyItem: ExportHistoryItem = {
...exportResult,
id: crypto.randomUUID(),
filename: file.name,
exportedAt: Date.now(),
};

setExportHistory((prev) => {
const updated = [historyItem, ...prev];

if (updated.length > 5) {
const removed = updated.pop();

if (removed?.blobUrl) {
URL.revokeObjectURL(removed.blobUrl);
}
}

return updated;
});

setStatus("done");
} catch (err) {
if (exportCancelledRef.current) return;

Expand All @@ -325,9 +353,21 @@ export function useVideoEditor() {
exportAbortControllerRef.current = null;
}
}
}, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration]);


}, [
file,
recipe,
status,
overlayFile,
overlayPosition,
overlaySize,
overlayOpacity,
duration,
musicFile,
musicVolume,
originalAudioVolume,
loopMusic,
]);

useEffect(() => {
if (status === "exporting") {
document.title = `Exporting ${progress}% | Reframe`;
Expand Down Expand Up @@ -380,13 +420,15 @@ export function useVideoEditor() {
};
}, [file, status, handleExport]);

useEffect(()=>{
return ()=>{
if(result?.blobUrl){
URL.revokeObjectURL(result.blobUrl);
const clearExportHistory = useCallback(() => {
exportHistory.forEach((item) => {
if (item.blobUrl) {
URL.revokeObjectURL(item.blobUrl);
}
}
},[result?.blobUrl])
});

setExportHistory([]);
}, [exportHistory]);

const resetSettings = useCallback(() => {
setRecipe(DEFAULT_RECIPE);
Expand All @@ -404,7 +446,6 @@ export function useVideoEditor() {


const reset = useCallback(() => {
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
setFile(null);
setVideoMetadata(null);
setDuration(0);
Expand All @@ -413,7 +454,7 @@ export function useVideoEditor() {
setProgress(0);
setResult(null);
setError(null);
}, [result]);
}, []);

useEffect(() => {
if (process.env.NODE_ENV !== "development") return;
Expand Down Expand Up @@ -445,6 +486,8 @@ export function useVideoEditor() {
status,
progress,
result,
exportHistory,
clearExportHistory,
error,
videoRef,
seekTo,
Expand Down
Loading