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
6 changes: 4 additions & 2 deletions src/components/DownloadResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface Props {
}

export default function DownloadResult({ result, onReset, soundOnCompletion }: Props) {
const defaultName = `reframe_${result.width}x${result.height}`;
const defaultName = result.format === "zip" ? "reframe_batch" : `reframe_${result.width}x${result.height}`;
const [name, setName] = useState(defaultName);

const invalidCharRegex = /[<>:"/\\|?*]/;
Expand Down Expand Up @@ -54,7 +54,9 @@ export default function DownloadResult({ result, onReset, soundOnCompletion }: P
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="bg-[var(--bg)] rounded-lg p-3 border border-[var(--border)]">
<p className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)] mb-1">Resolution</p>
<p className="font-heading font-bold text-[var(--text)]">{result.width} × {result.height}</p>
<p className="font-heading font-bold text-[var(--text)]">
{result.format === "zip" ? "Multiple files" : `${result.width} × ${result.height}`}
</p>
</div>
<div className="bg-[var(--bg)] rounded-lg p-3 border border-[var(--border)]">
<p className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)] mb-1">File size</p>
Expand Down
8 changes: 7 additions & 1 deletion src/components/ExportOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import TipCarousel from "./TipCarousel";
interface Props {
status: ExportStatus;
progress: number;
progressText?: string;
onCancel?: () => void;
}

export default function ExportOverlay({ status, progress, onCancel }: Props) {
export default function ExportOverlay({ status, progress, progressText, onCancel }: Props) {
const visible = status === "loading-engine" || status === "exporting";
const previousFocusRef = useRef<HTMLElement | null>(null);
const focusAnchorRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -92,6 +93,11 @@ export default function ExportOverlay({ status, progress, onCancel }: Props) {
<h2 className="font-heading font-bold text-xl tracking-tight text-[var(--text)]">
{isLoading ? "Loading engine" : "Exporting"}
</h2>
{progressText && (
<p className="text-sm font-semibold text-film-600 mt-1">
{progressText}
</p>
)}
<p className="text-sm text-[var(--muted)] mt-1">
{isLoading
? "Downloading the video engine. This only happens once."
Expand Down
82 changes: 62 additions & 20 deletions src/components/PresetSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,19 @@ export default function PresetSelector({ recipe, onChange }: Props) {

const handlePresetSelect = useCallback(
(presetId: string) => {
onChange({ preset: presetId });
setSearch("");
if (recipe.isBatchExport) {
if (presetId === "custom") return;
const currentBatch = recipe.batchPresets || [];
const newBatch = currentBatch.includes(presetId)
? currentBatch.filter((id) => id !== presetId)
: [...currentBatch, presetId];
onChange({ batchPresets: newBatch });
} else {
onChange({ preset: presetId });
setSearch("");
}
},
[onChange],
[onChange, recipe.isBatchExport, recipe.batchPresets],
);

const handleWidthChange = useCallback(
Expand All @@ -83,17 +92,35 @@ export default function PresetSelector({ recipe, onChange }: Props) {

return (
<div id="preset-selector" className="space-y-3">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={14} className="text-[var(--muted)]" />
<div className="flex items-center justify-between mb-3">
<div className="relative flex-1 mr-4">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={14} className="text-[var(--muted)]" />
</div>
<input
type="text"
placeholder="Search formats..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] py-2 pl-9 pr-3 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400"
/>
</div>
<input
type="text"
placeholder="Search formats..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] py-2 pl-9 pr-3 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400"
/>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={recipe.isBatchExport || false}
onChange={(e) => {
const isBatchExport = e.target.checked;
const currentBatch = recipe.batchPresets || [];
const batchPresets = isBatchExport && currentBatch.length === 0 && recipe.preset !== "custom"
? [recipe.preset]
: currentBatch;
onChange({ isBatchExport, batchPresets });
}}
className="w-4 h-4 rounded border-gray-300 text-film-600 focus:ring-film-500"
/>
<span className="text-sm font-semibold text-[var(--text)]">Batch mode</span>
</label>
</div>

<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
Expand All @@ -103,7 +130,9 @@ export default function PresetSelector({ recipe, onChange }: Props) {
</div>
) : (
filteredPresets.map((preset) => {
const active = recipe.preset === preset.id;
const active = recipe.isBatchExport
? (recipe.batchPresets || []).includes(preset.id)
: recipe.preset === preset.id;

return (
<button
Expand All @@ -114,12 +143,20 @@ export default function PresetSelector({ recipe, onChange }: Props) {
aria-label={`Select ${preset.label} preset, ${preset.width} by ${preset.height} pixels`}
aria-pressed={active}
className={cn(
"min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150 cursor-pointer hover:scale-[1.02] active:scale-[0.98]",
"relative min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150 cursor-pointer hover:scale-[1.02] active:scale-[0.98]",
active
? "border-film-500 bg-film-50"
: "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30",
)}
>
{recipe.isBatchExport && (
<div className={cn(
"absolute top-2 right-2 w-4 h-4 rounded-sm border flex items-center justify-center",
active ? "bg-film-500 border-film-500" : "border-[var(--muted)] bg-transparent"
)}>
{active && <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
</div>
)}
<RatioBox
width={preset.width}
height={preset.height}
Expand Down Expand Up @@ -150,12 +187,17 @@ export default function PresetSelector({ recipe, onChange }: Props) {
title="Custom — Set your own dimensions"
aria-label="Select custom dimensions preset"
aria-pressed={recipe.preset === "custom"}
onClick={() => handlePresetSelect("custom")}
onClick={() => {
if (!recipe.isBatchExport) {
handlePresetSelect("custom")
}
}}
className={cn(
"min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150 cursor-pointer hover:scale-[1.02] active:scale-[0.98]",
recipe.preset === "custom"
"min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150",
recipe.isBatchExport ? "opacity-50 cursor-not-allowed bg-[var(--surface)] border-[var(--border)]" : "cursor-pointer hover:scale-[1.02] active:scale-[0.98]",
!recipe.isBatchExport && recipe.preset === "custom"
? "border-film-500 bg-film-50"
: "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30",
: (!recipe.isBatchExport ? "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30" : ""),
)}
>
<Settings2
Expand Down Expand Up @@ -185,7 +227,7 @@ export default function PresetSelector({ recipe, onChange }: Props) {
</button>
</div>

{recipe.preset === "custom" && (
{recipe.preset === "custom" && !recipe.isBatchExport && (
<div className="mt-2 flex items-center gap-4 rounded-lg border border-[var(--border)] bg-[var(--surface)] p-4 shadow-sm animate-fade-in">
<div className="flex-1">
<label
Expand Down
3 changes: 2 additions & 1 deletion src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default function VideoEditor() {
overlaySize, setOverlaySize,
overlayOpacity, setOverlayOpacity,
recommendedPreset,
exportProgressText,
} = useVideoEditor();
const [copied, setCopied] = useState(false);
const downloadRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -89,7 +90,7 @@ export default function VideoEditor() {

return (
<div className="min-h-screen relative flex flex-col" style={{ background: "var(--bg)" }}>
<ExportOverlay status={status} progress={progress} onCancel={cancelExport} />
<ExportOverlay status={status} progress={progress} progressText={exportProgressText} onCancel={cancelExport} />
<OnboardingTour />

<div aria-live="polite" aria-atomic="true" className="sr-only">
Expand Down
116 changes: 93 additions & 23 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ function validateRecipe(recipe: EditRecipe, duration: number ): string | null {
recipe.saturation < 0 || recipe.saturation > 3,
"Saturation must be between 0 and 3.",
],
[
recipe.saturation < 0 || recipe.saturation > 3,
"Saturation must be between 0 and 3.",
],
[
recipe.isBatchExport && (!recipe.batchPresets || recipe.batchPresets.length === 0),
"Please select at least 1 format for batch export.",
],
];

return (
Expand All @@ -130,6 +138,7 @@ export function useVideoEditor() {
});
const [status, setStatus] = useState<ExportStatus>("idle");
const [progress, setProgress] = useState(0);
const [exportProgressText, setExportProgressText] = useState("");
const [result, setResult] = useState<ExportResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [fileError, setFileError] = useState("");
Expand Down Expand Up @@ -273,6 +282,7 @@ export function useVideoEditor() {
try {
setStatus("loading-engine");
setProgress(0);
setExportProgressText("");
setError(null);
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
setResult(null);
Expand All @@ -282,29 +292,88 @@ export function useVideoEditor() {

setStatus("exporting");

const exportResult = await exportVideo(
ffmpeg,
file,
recipe,
setProgress,
abortController.signal,
{
file: musicFile,
musicVolume,
originalAudioVolume,
loopMusic,
},
{
file: overlayFile,
position: overlayPosition,
size: overlaySize,
opacity: overlayOpacity,
if (recipe.isBatchExport && recipe.batchPresets && recipe.batchPresets.length > 0) {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();

for (let i = 0; i < recipe.batchPresets.length; i++) {
if (exportCancelledRef.current) return;
const presetId = recipe.batchPresets[i];
const presetData = getPresetById(presetId);
const presetName = presetData ? presetData.label : presetId;

setExportProgressText(`Export ${i + 1} of ${recipe.batchPresets.length}: ${presetName} (${recipe.format})…`);
setProgress(0);

const batchRecipe = { ...recipe, preset: presetId, isBatchExport: false };
const exportResult = await exportVideo(
ffmpeg,
file,
batchRecipe,
setProgress,
abortController.signal,
{
file: musicFile,
musicVolume,
originalAudioVolume,
loopMusic,
},
{
file: overlayFile,
position: overlayPosition,
size: overlaySize,
opacity: overlayOpacity,
}
);

if (exportCancelledRef.current) return;

const res = await fetch(exportResult.blobUrl);
const blob = await res.blob();

const safeName = presetName.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-');
zip.file(`reframe-${safeName}.${recipe.format}`, blob);

URL.revokeObjectURL(exportResult.blobUrl);
}
);
if (exportCancelledRef.current) return;

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

if (exportCancelledRef.current) return;
setExportProgressText("Zipping files…");
const zipBlob = await zip.generateAsync({ type: "blob" });
if (exportCancelledRef.current) return;

setResult({
blobUrl: URL.createObjectURL(zipBlob),
size: zipBlob.size,
format: "zip"
});
setStatus("done");

} else {
const exportResult = await exportVideo(
ffmpeg,
file,
recipe,
setProgress,
abortController.signal,
{
file: musicFile,
musicVolume,
originalAudioVolume,
loopMusic,
},
{
file: overlayFile,
position: overlayPosition,
size: overlaySize,
opacity: overlayOpacity,
}
);
if (exportCancelledRef.current) return;

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

Expand All @@ -330,7 +399,7 @@ export function useVideoEditor() {

useEffect(() => {
if (status === "exporting") {
document.title = `Exporting ${progress}% | Reframe`;
document.title = `${exportProgressText ? exportProgressText + ' - ' : ''}Exporting ${progress}% | Reframe`;
} else if (status === "loading-engine") {
document.title = `Loading engine... | Reframe`;
} else if (status === "done") {
Expand Down Expand Up @@ -472,5 +541,6 @@ export function useVideoEditor() {
overlayOpacity,
setOverlayOpacity,
recommendedPreset,
exportProgressText,
};
}
10 changes: 7 additions & 3 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface EditRecipe {
contrast: number;
saturation: number;
soundOnCompletion: boolean;
isBatchExport: boolean;
batchPresets: string[];
}

export type OverlayPosition =
Expand All @@ -40,9 +42,9 @@ export interface BackgroundMusicOptions {
export interface ExportResult {
blobUrl: string;
size: number;
width: number;
height: number;
format: "mp4" | "webm" | "mkv";
width?: number;
height?: number;
format: "mp4" | "webm" | "mkv" | "zip";
}

export type ExportStatus =
Expand Down Expand Up @@ -80,6 +82,8 @@ export const DEFAULT_RECIPE: EditRecipe = {
contrast: 0,
saturation: 0,
soundOnCompletion: false,
isBatchExport: false,
batchPresets: [],
};

export const MAX_FILE_SIZE =
Expand Down
Loading