[dyad] Implement video cutting - wrote 4 file(s), added @ffmpeg/ffmpeg, @ffmpeg/util package(s)
This commit is contained in:
@@ -9,6 +9,8 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
|
"@ffmpeg/util": "^0.12.2",
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
|||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -8,6 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@ffmpeg/ffmpeg':
|
||||||
|
specifier: ^0.12.15
|
||||||
|
version: 0.12.15
|
||||||
|
'@ffmpeg/util':
|
||||||
|
specifier: ^0.12.2
|
||||||
|
version: 0.12.2
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1(react-hook-form@7.56.4(react@19.2.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':
|
'@emnapi/runtime@1.4.3':
|
||||||
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
|
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':
|
'@floating-ui/core@1.7.0':
|
||||||
resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
|
resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
|
||||||
|
|
||||||
@@ -2128,6 +2146,14 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
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':
|
'@floating-ui/core@1.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.9
|
'@floating-ui/utils': 0.2.9
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
"use client";
|
"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 { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Slider } from "@/components/ui/slider";
|
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() {
|
export function VideoEditor() {
|
||||||
const [videoSrc, setVideoSrc] = useState<string | null>(null);
|
const [videoSrc, setVideoSrc] = useState<string | null>(null);
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [trimValues, setTrimValues] = useState([0, 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);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Eagerly load FFmpeg
|
||||||
|
getFFmpeg();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
setVideoFile(file);
|
setVideoFile(file);
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
setVideoSrc(url);
|
setVideoSrc(url);
|
||||||
|
setTrimmedVideoUrl(null);
|
||||||
|
setProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,6 +55,52 @@ export function VideoEditor() {
|
|||||||
).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
|
).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 (
|
return (
|
||||||
<Card className="w-full shadow-lg rounded-2xl border">
|
<Card className="w-full shadow-lg rounded-2xl border">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
@@ -58,10 +117,11 @@ export function VideoEditor() {
|
|||||||
<div className="aspect-video w-full overflow-hidden rounded-lg border">
|
<div className="aspect-video w-full overflow-hidden rounded-lg border">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoSrc}
|
src={trimmedVideoUrl || videoSrc}
|
||||||
controls
|
controls
|
||||||
className="w-full h-full object-contain bg-black"
|
className="w-full h-full object-contain bg-black"
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
key={trimmedVideoUrl} // Re-render video element when trimmed url changes
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -73,6 +133,7 @@ export function VideoEditor() {
|
|||||||
max={duration}
|
max={duration}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
aria-label="Video trimmer"
|
aria-label="Video trimmer"
|
||||||
|
disabled={isTrimming}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-sm font-mono text-muted-foreground">
|
<div className="flex justify-between text-sm font-mono text-muted-foreground">
|
||||||
<span>Start: {formatTime(trimValues[0])}</span>
|
<span>Start: {formatTime(trimValues[0])}</span>
|
||||||
@@ -80,6 +141,13 @@ export function VideoEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center justify-center w-full">
|
||||||
@@ -107,13 +175,19 @@ export function VideoEditor() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
{videoSrc && (
|
{videoSrc && (
|
||||||
<CardFooter className="flex justify-end space-x-4 p-6">
|
<CardFooter className="flex justify-end space-x-4 p-6">
|
||||||
<Button variant="outline" size="lg" disabled>
|
<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" />
|
<Scissors className="mr-2 h-5 w-5" />
|
||||||
Cut Video
|
)}
|
||||||
|
{isTrimming ? "Trimming..." : "Cut Video"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" size="lg" disabled>
|
<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" />
|
<Download className="mr-2 h-5 w-5" />
|
||||||
Export
|
Export
|
||||||
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
|
|||||||
22
src/lib/ffmpeg.ts
Normal file
22
src/lib/ffmpeg.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user