[dyad] Implement video cutting - wrote 4 file(s), added @ffmpeg/ffmpeg, @ffmpeg/util package(s)
This commit is contained in:
@@ -1,26 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, ChangeEvent, SyntheticEvent } from "react";
|
||||
import { useState, useRef, ChangeEvent, SyntheticEvent, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { UploadCloud, Scissors, Download } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { UploadCloud, Scissors, Download, Loader2 } from "lucide-react";
|
||||
import { getFFmpeg } from "@/lib/ffmpeg";
|
||||
import { fetchFile } from "@ffmpeg/util";
|
||||
|
||||
export function VideoEditor() {
|
||||
const [videoSrc, setVideoSrc] = useState<string | null>(null);
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [trimValues, setTrimValues] = useState([0, 0]);
|
||||
const [isTrimming, setIsTrimming] = useState(false);
|
||||
const [trimmedVideoUrl, setTrimmedVideoUrl] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Eagerly load FFmpeg
|
||||
getFFmpeg();
|
||||
}, []);
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setVideoFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setVideoSrc(url);
|
||||
setTrimmedVideoUrl(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,6 +54,52 @@ export function VideoEditor() {
|
||||
seconds
|
||||
).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
const formatTimeForFFmpeg = (timeInSeconds: number) => {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
const milliseconds = Math.floor((timeInSeconds % 1) * 1000);
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(
|
||||
seconds
|
||||
).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
const handleCutVideo = async () => {
|
||||
if (!videoFile) return;
|
||||
|
||||
setIsTrimming(true);
|
||||
setTrimmedVideoUrl(null);
|
||||
setProgress(0);
|
||||
|
||||
const ffmpeg = await getFFmpeg();
|
||||
|
||||
ffmpeg.on('progress', ({ progress }) => {
|
||||
setProgress(Math.round(progress * 100));
|
||||
});
|
||||
|
||||
await ffmpeg.writeFile(videoFile.name, await fetchFile(videoFile));
|
||||
|
||||
const [startTime, endTime] = trimValues;
|
||||
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
videoFile.name,
|
||||
'-ss',
|
||||
formatTimeForFFmpeg(startTime),
|
||||
'-to',
|
||||
formatTimeForFFmpeg(endTime),
|
||||
'-c',
|
||||
'copy',
|
||||
'output.mp4',
|
||||
]);
|
||||
|
||||
const data = await ffmpeg.readFile('output.mp4');
|
||||
const url = URL.createObjectURL(new Blob([(data as any).buffer], { type: 'video/mp4' }));
|
||||
|
||||
setTrimmedVideoUrl(url);
|
||||
setIsTrimming(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full shadow-lg rounded-2xl border">
|
||||
@@ -58,10 +117,11 @@ export function VideoEditor() {
|
||||
<div className="aspect-video w-full overflow-hidden rounded-lg border">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
src={trimmedVideoUrl || videoSrc}
|
||||
controls
|
||||
className="w-full h-full object-contain bg-black"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
key={trimmedVideoUrl} // Re-render video element when trimmed url changes
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -73,6 +133,7 @@ export function VideoEditor() {
|
||||
max={duration}
|
||||
step={0.1}
|
||||
aria-label="Video trimmer"
|
||||
disabled={isTrimming}
|
||||
/>
|
||||
<div className="flex justify-between text-sm font-mono text-muted-foreground">
|
||||
<span>Start: {formatTime(trimValues[0])}</span>
|
||||
@@ -80,6 +141,13 @@ export function VideoEditor() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isTrimming && (
|
||||
<div className="space-y-2">
|
||||
<Label>Trimming in progress...</Label>
|
||||
<Progress value={progress} />
|
||||
<p className="text-sm text-muted-foreground text-center">{progress}% complete</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
@@ -107,13 +175,19 @@ export function VideoEditor() {
|
||||
</CardContent>
|
||||
{videoSrc && (
|
||||
<CardFooter className="flex justify-end space-x-4 p-6">
|
||||
<Button variant="outline" size="lg" disabled>
|
||||
<Scissors className="mr-2 h-5 w-5" />
|
||||
Cut Video
|
||||
<Button variant="outline" size="lg" onClick={handleCutVideo} disabled={isTrimming}>
|
||||
{isTrimming ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Scissors className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
{isTrimming ? "Trimming..." : "Cut Video"}
|
||||
</Button>
|
||||
<Button variant="default" size="lg" disabled>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
Export
|
||||
<Button asChild variant="default" size="lg" disabled={!trimmedVideoUrl || isTrimming}>
|
||||
<a href={trimmedVideoUrl!} download={`trimmed-${videoFile?.name || 'video.mp4'}`}>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
Export
|
||||
</a>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user