[dyad] Implement video cutting - wrote 4 file(s), added @ffmpeg/ffmpeg, @ffmpeg/util package(s)

This commit is contained in:
[dyad]
2026-01-30 08:18:16 +01:00
parent 82db797db5
commit c6beac5263
4 changed files with 133 additions and 9 deletions

View File

@@ -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>
)}