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
25 changes: 7 additions & 18 deletions src/components/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import { MAX_FILE_SIZE, WARNING_FILE_SIZE } from "@/lib/types";

interface Props {
onFileSelect: (file: File) => void;
onMultipleFileSelect?: (files: FileList | null) => void;
currentFile: File | null;
fileError: string;
duration: number;
}

export default function FileUpload({
onFileSelect,
onMultipleFileSelect,
currentFile,
fileError,
duration,
Expand Down Expand Up @@ -78,9 +80,7 @@ export default function FileUpload({
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);

const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
onMultipleFileSelect?.(e.dataTransfer.files)
};

const FileInfo = () => (
Expand Down Expand Up @@ -135,16 +135,6 @@ export default function FileUpload({
{fileError}
</p>
)}
<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
</div>

<p className="text-xs text-gray-500 mt-3 break-words">
Expand All @@ -158,11 +148,11 @@ export default function FileUpload({
<input
ref={inputRef}
type="file"
multiple
accept="video/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
onMultipleFileSelect?.(e.target.files)
}}
/>
</div>
Expand Down Expand Up @@ -238,12 +228,11 @@ export default function FileUpload({
<input
ref={inputRef}
type="file"
multiple
accept="video/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];

if (f) handleFile(f);
onMultipleFileSelect?.(e.target.files)
}}
/>
</div>
Expand Down
110 changes: 107 additions & 3 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function KeyboardShortcutsPanel() {

export default function VideoEditor() {
const {
file, duration, recipe, status, progress,
file, setFile, clips, setClips, handleMultipleFilesSelect, duration, recipe, status, progress,
result, error, updateRecipe,
handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings,
videoRef,
Expand Down Expand Up @@ -193,7 +193,111 @@ export default function VideoEditor() {

<div className="space-y-4 min-w-0">
<div className="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)] animate-fade-in">
<FileUpload onFileSelect={handleFileSelect} currentFile={file} fileError={fileError} duration={duration} />
<FileUpload onFileSelect={handleFileSelect} onMultipleFileSelect={handleMultipleFilesSelect} currentFile={file} fileError={fileError} duration={duration} />

{clips.length > 0 && (
<div className="mt-4 space-y-2">
<h3 className="text-sm font-semibold text-[var(--text)]">
Selected Clips
</h3>


{clips.map((clip, index) => (
<div
key={index}
className={cn(
"rounded-lg border p-3 flex items-center justify-between transition-colors",
file === clip
? "border-film-500 bg-film-50"
: "border-[var(--border)] bg-[var(--bg)]"
)}
>
{/* Clickable preview area */}
<button
type="button"
onClick={() => handleFileSelect(clip)}
className="flex-1 cursor-pointer truncate text-left"
>
<span className="text-sm truncate block">
{index + 1}. {clip.name}
</span>

{file === clip && (
<p className="text-xs text-film-500 mt-1">
Previewing
</p>
)}
</button>

{/* Action buttons */}
<div className="flex items-center gap-2 ml-3 shrink-0">
{/* Move Up */}
<button
type="button"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation()

const updated = [...clips]

const temp = updated[index]!
updated[index] = updated[index - 1]!
updated[index - 1] = temp
setClips(updated)
}}
className="px-2 py-1 rounded border border-[var(--border)] text-xs disabled:opacity-40 hover:border-film-400 transition-colors"
>
</button>

{/* Move Down */}
<button
type="button"
disabled={index === clips.length - 1}
onClick={(e) => {
e.stopPropagation()

const updated = [...clips]
const temp = updated[index]!
updated[index] = updated[index + 1]!
updated[index + 1] = temp

setClips(updated)
}}
className="px-2 py-1 rounded border border-[var(--border)] text-xs disabled:opacity-40 hover:border-film-400 transition-colors"
>
</button>

{/* Remove */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()

const updated = clips.filter((_, i) => i !== index)

setClips(updated)

// If removed clip is current preview
if (clip === file) {
if (updated.length > 0) {
handleFileSelect(updated[0]!)
} else {
reset()
}
}
}}
className="px-2 py-1 rounded border border-red-400 text-xs text-red-500 hover:bg-red-50 transition-colors"
>
Remove
</button>
</div>
</div>
))}

</div>
)}

{!file && (
<div className="text-center text-[var(--muted)] py-6">
Expand All @@ -204,7 +308,7 @@ export default function VideoEditor() {

{file && (
<div className="mt-4 animate-fade-in">
<VideoPreview file={file} recipe={recipe} videoRef={videoRef} />
<VideoPreview key={`${file?.name}-${file?.size}`} file={file} recipe={recipe} videoRef={videoRef} />

<div className="mt-3">
<ThumbnailStrip
Expand Down
47 changes: 45 additions & 2 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, 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";
import { loadFFmpeg, mergeVideos ,exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg";
import { suggestPreset } from "@/lib/presetSuggestion";

const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser";
Expand Down Expand Up @@ -117,6 +117,7 @@ function validateRecipe(recipe: EditRecipe, duration: number ): string | null {

export function useVideoEditor() {
const [file, setFile] = useState<File | null>(null);
const [clips, setClips] = useState<File[]>([])
const [duration, setDuration] = useState<number>(0);
const [videoMetadata, setVideoMetadata] = useState<{
width: number;
Expand Down Expand Up @@ -385,6 +386,28 @@ export function useVideoEditor() {
}
}, []);

const handleMultipleFilesSelect = useCallback(
async (selectedFiles: FileList | null) => {
if (!selectedFiles) return

const filesArray = Array.from(selectedFiles)

const validFiles = filesArray.filter((file) =>
file.type.startsWith("video/")
)

if (validFiles.length === 0) {
setFileError("Please upload valid video files.")
return
}

setClips(validFiles)
await handleFileSelect(validFiles[0]!)
setFileError("")
},
[handleFileSelect]
)

const handleExport = useCallback(async () => {
if (!file) return;
if (status === "loading-engine" || status === "exporting") {
Expand Down Expand Up @@ -414,6 +437,21 @@ export function useVideoEditor() {

setStatus("exporting");

if (clips.length > 1) {
const mergedResult = await mergeVideos(
ffmpeg,
clips,
setProgress,
abortController.signal
)

if (exportCancelledRef.current) return

setResult(mergedResult)
setStatus("done")
return
}

const exportResult = await exportVideo(
ffmpeg,
file,
Expand Down Expand Up @@ -457,7 +495,7 @@ export function useVideoEditor() {
exportAbortControllerRef.current = null;
}
}
}, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, loopMusic, musicFile, musicVolume, originalAudioVolume]);
}, [file, clips, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, loopMusic, musicFile, musicVolume, originalAudioVolume]);


useEffect(() => {
Expand Down Expand Up @@ -574,6 +612,7 @@ export function useVideoEditor() {
const reset = useCallback(() => {
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
setFile(null);
setClips([]);
setVideoMetadata(null);
setDuration(0);
setRecipe(DEFAULT_RECIPE);
Expand Down Expand Up @@ -604,6 +643,10 @@ export function useVideoEditor() {

return {
file,
setFile,
clips,
setClips,
handleMultipleFilesSelect,
duration,
recipe,
status,
Expand Down
Loading