[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

@@ -9,6 +9,8 @@
"lint": "next lint"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",

26
pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@ffmpeg/ffmpeg':
specifier: ^0.12.15
version: 0.12.15
'@ffmpeg/util':
specifier: ^0.12.2
version: 0.12.2
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.0.1(react-hook-form@7.56.4(react@19.2.1))
@@ -207,6 +213,18 @@ packages:
'@emnapi/runtime@1.4.3':
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
'@ffmpeg/ffmpeg@0.12.15':
resolution: {integrity: sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==}
engines: {node: '>=18.x'}
'@ffmpeg/types@0.12.4':
resolution: {integrity: sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==}
engines: {node: '>=16.x'}
'@ffmpeg/util@0.12.2':
resolution: {integrity: sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==}
engines: {node: '>=18.x'}
'@floating-ui/core@1.7.0':
resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
@@ -2128,6 +2146,14 @@ snapshots:
tslib: 2.8.1
optional: true
'@ffmpeg/ffmpeg@0.12.15':
dependencies:
'@ffmpeg/types': 0.12.4
'@ffmpeg/types@0.12.4': {}
'@ffmpeg/util@0.12.2': {}
'@floating-ui/core@1.7.0':
dependencies:
'@floating-ui/utils': 0.2.9

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);
}
};
@@ -42,6 +55,52 @@ export function VideoEditor() {
).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">
<CardHeader className="text-center">
@@ -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>
)}

22
src/lib/ffmpeg.ts Normal file
View File

@@ -0,0 +1,22 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
let ffmpeg: FFmpeg | null;
export async function getFFmpeg() {
if (ffmpeg) {
return ffmpeg;
}
ffmpeg = new FFmpeg();
if (!ffmpeg.loaded) {
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
}
return ffmpeg;
}