Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"dependencies": {
"@datadog/browser-rum": "^7.10.0",
"@datadog/browser-rum-react": "^7.10.0",
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"clsx": "^2.1.1",
"focus-trap-react": "^12.0.1",
Expand Down
4 changes: 4 additions & 0 deletions src/components/ErrorBoundaryWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"use client";

import ErrorBoundary from "@/components/ErrorBoundary";
export default ErrorBoundary;
2 changes: 2 additions & 0 deletions src/components/ExportSettings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const meta = {
component: ExportSettings,
parameters: { layout: "padded" },
args: {
videoFile: null,
recipe: makeRecipe(),
onChange: () => {},
duration: 90,
Expand All @@ -19,6 +20,7 @@ const meta = {
<RecipeHarness initial={args.recipe}>
{(recipe, onChange) => (
<ExportSettings
videoFile={args.videoFile}
recipe={recipe}
onChange={onChange}
duration={args.duration}
Expand Down
29 changes: 29 additions & 0 deletions src/components/ExportSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import { cn } from "@/lib/utils";
import {
SlidersHorizontal,
Info as InfoIcon,
Zap,
} from "lucide-react";
import React, { useState } from "react";
import VideoCompressor from "@/components/VideoCompressor";

import {
estimateExportSize,
formatEstimatedSize,
} from "@/lib/exportEstimate";

interface Props {
videoFile: File | null;
recipe: EditRecipe;
duration: number;
onChange: (
Expand All @@ -23,6 +27,7 @@ interface Props {
export default function ExportSettings({
recipe,
duration,
videoFile,
onChange,
}: Props) {
const label =
Expand All @@ -41,6 +46,7 @@ export default function ExportSettings({
duration
)
);
const [isCompressEnabled, setIsCompressEnabled] = useState(false);

return (
<>
Expand Down Expand Up @@ -141,6 +147,29 @@ export default function ExportSettings({
)}
</div>

<div className="mt-6 pt-6 border-t border-gray-700">
<div className="flex items-center justify-between mb-4">
<label htmlFor="compress-video-toggle" className="text-sm font-heading font-semibold uppercase tracking-wider text-[var(--muted)] flex items-center gap-2">
<Zap size={14} className="text-yellow-400" />
Compress Video
</label>
<input
id="compress-video-toggle"
type="checkbox"
checked={isCompressEnabled}
onChange={(e) => setIsCompressEnabled(e.target.checked)}
className="accent-film-600 cursor-pointer"
/>
</div>

{isCompressEnabled && (
<div className="mt-2 mb-4">
<VideoCompressor videoFile={videoFile} />
</div>
)}
</div>


<div>
<div className="flex items-center justify-between mb-1">
<label
Expand Down
160 changes: 160 additions & 0 deletions src/components/VideoCompressor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"use client";
import React, { useState, useRef, useEffect } from 'react';
import { Download, Settings2, Zap } from 'lucide-react';

interface VideoCompressorProps {
videoFile: File | null;
}

const VideoCompressor: React.FC<VideoCompressorProps> = ({ videoFile }) => {
const [isCompressing, setIsCompressing] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('Loading FFmpeg...');
const [compressedBlob, setCompressedBlob] = useState<Blob | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [preset, setPreset] = useState<'balanced' | 'small'>('balanced');
const ffmpegRef = useRef<any>(null);

useEffect(() => {
const loadScript = (src: string): Promise<void> =>
new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve();
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(s);
});

const init = async () => {
try {
// ✅ v0.11.x - no SharedArrayBuffer, no Worker CORS issues
await loadScript('https://cdn.jsdelivr.net/npm/@ffmpeg/ffmpeg@0.10.1/dist/ffmpeg.min.js');
setStatus('Ready');
} catch (err) {
setStatus('Failed to load FFmpeg');
console.error(err);
}
};
init();
}, []);

const compressVideo = async () => {
if (!videoFile) return;

const { createFFmpeg, fetchFile } = (window as any).FFmpeg;

if (!createFFmpeg) {
setStatus('FFmpeg not loaded yet.');
return;
}

setIsCompressing(true);
setProgress(0);

try {
// ✅ corePath points to v0.11.x core - no worker spawning
const ffmpeg = createFFmpeg({
corePath: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.10.0/dist/ffmpeg-core.js',
progress: ({ ratio }: { ratio: number }) => {
setProgress(Math.min(99, Math.round(ratio * 100)));
},
log: false,
});

ffmpegRef.current = ffmpeg;

setStatus('Loading FFmpeg core...');
await ffmpeg.load();

setStatus('Writing file...');
ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(videoFile));

setStatus('Compressing...');
const args = preset === 'small'
? ['-i', 'input.mp4', '-vcodec', 'libx264', '-crf', '28', '-preset', 'veryfast', '-acodec', 'aac', 'output.mp4']
: ['-i', 'input.mp4', '-vcodec', 'libx264', '-crf', '23', '-preset', 'medium', '-acodec', 'aac', 'output.mp4'];

await ffmpeg.run(...args);

setStatus('Finalizing...');
const data = ffmpeg.FS('readFile', 'output.mp4');
const blob = new Blob([data.buffer], { type: 'video/mp4' });

setCompressedBlob(blob);
setProgress(100);
setStatus('Done!');
} catch (err) {
console.error(err);
setStatus(`Error: ${(err as Error).message}`);
} finally {
setIsCompressing(false);
}
};

return (
<div className="p-4 bg-gray-900 text-white rounded-xl border border-gray-700 shadow-xl max-w-sm">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold flex items-center gap-2">
<Zap className="text-yellow-400" /> Compression Tool
</h3>
<button onClick={() => setShowSettings(!showSettings)} className="p-2 hover:bg-gray-800 rounded-full">
<Settings2 size={18} />
</button>
</div>

{showSettings && (
<div className="mb-4 bg-gray-800 p-3 rounded-lg text-sm">
<label htmlFor="compression-preset" className="block mb-2 text-gray-400">Choose Preset</label>
<select
id="compression-preset"
value={preset}
onChange={(e) => setPreset(e.target.value as 'balanced' | 'small')}
className="w-full bg-gray-900 border border-gray-700 rounded p-2"
>
<option value="balanced">Balanced (Recommended)</option>
<option value="small">Smallest Size</option>
</select>
</div>
)}

<p className="text-xs text-gray-400 mb-3">Status: {status}</p>

{!isCompressing && !compressedBlob && (
<button
onClick={compressVideo}
disabled={status !== 'Ready'}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed py-2 rounded-lg font-medium transition"
>
Export & Compress
</button>
)}

{isCompressing && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-gray-400">
<span>{status}</span>
<span>{progress}%</span>
</div>
<div className="w-full bg-gray-700 h-2 rounded-full overflow-hidden">
<div className="bg-indigo-500 h-full transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}

{compressedBlob && (
<a
href={URL.createObjectURL(compressedBlob)}
download="compressed_video.mp4"
className="w-full flex items-center justify-center gap-2 bg-green-600 py-2 rounded-lg font-medium hover:bg-green-500 transition"
>
<Download size={18} /> Download Video
</a>
)}

</div>
);
};


export default VideoCompressor;
34 changes: 24 additions & 10 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -683,16 +683,30 @@ export default function VideoEditor() {
<Section icon={<SlidersHorizontal size={12} />} title="Output format" delay={190}>
<FormatSelector recipe={recipe} onChange={updateRecipe} />
</Section>
<AccordionSection
id="export"
icon={<SlidersHorizontal size={12} />}
title="Export"
isOpen={openSections.export}
onToggle={() => toggleSection("export")}
delay={200}
>
<ExportSettings recipe={recipe} duration={duration} onChange={updateRecipe} />
</AccordionSection>
<Section icon={<SlidersHorizontal size={12} />} title="Export quality" delay={200}>

<ExportSettings
recipe={recipe}
duration={duration}
videoFile={file}
onChange={updateRecipe}
/>
</Section>
<AccordionSection
id="export"
icon={<SlidersHorizontal size={12} />}
title="Export"
isOpen={openSections.export}
onToggle={() => toggleSection("export")}
delay={200}
>
<ExportSettings
recipe={recipe}
duration={duration}
videoFile={file}
onChange={updateRecipe}
/>
</AccordionSection>
<Section icon={<Layers size={12} />} title="Image overlay" delay={120}>
<ImageOverlay
overlayFile={overlayFile}
Expand Down