diff --git a/package.json b/package.json index e6537d8..529cc84 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1383c3..b7188cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/video-editor.tsx b/src/components/video-editor.tsx index 0cc3e2c..26f99ac 100644 --- a/src/components/video-editor.tsx +++ b/src/components/video-editor.tsx @@ -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(null); const [videoFile, setVideoFile] = useState(null); const [duration, setDuration] = useState(0); const [trimValues, setTrimValues] = useState([0, 0]); + const [isTrimming, setIsTrimming] = useState(false); + const [trimmedVideoUrl, setTrimmedVideoUrl] = useState(null); + const [progress, setProgress] = useState(0); const videoRef = useRef(null); + useEffect(() => { + // Eagerly load FFmpeg + getFFmpeg(); + }, []); + const handleFileChange = (event: ChangeEvent) => { 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 ( @@ -58,10 +117,11 @@ export function VideoEditor() {
@@ -73,6 +133,7 @@ export function VideoEditor() { max={duration} step={0.1} aria-label="Video trimmer" + disabled={isTrimming} />
Start: {formatTime(trimValues[0])} @@ -80,6 +141,13 @@ export function VideoEditor() {
+ {isTrimming && ( +
+ + +

{progress}% complete

+
+ )} ) : (
@@ -107,13 +175,19 @@ export function VideoEditor() { {videoSrc && ( - - )} diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts new file mode 100644 index 0000000..2b29eba --- /dev/null +++ b/src/lib/ffmpeg.ts @@ -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; +} \ No newline at end of file