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
17 changes: 17 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ const nextConfig: NextConfig = {
config.resolve.fallback = { fs: false };
return config;
},
headers: async () => {
return [
{
source: "/(.*)",
headers: [
{
key: "Cross-Origin-Opener-Policy",
value: "same-origin",
},
{
key: "Cross-Origin-Embedder-Policy",
value: "require-corp",
},
],
},
];
},
};

export default nextConfig;
16 changes: 16 additions & 0 deletions public/ffmpeg/core-mt/ffmpeg-core.js

Large diffs are not rendered by default.

Binary file added public/ffmpeg/core-mt/ffmpeg-core.wasm
Binary file not shown.
1 change: 1 addition & 0 deletions public/ffmpeg/core-mt/ffmpeg-core.worker.js

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

16 changes: 16 additions & 0 deletions public/ffmpeg/core/ffmpeg-core.js

Large diffs are not rendered by default.

Binary file added public/ffmpeg/core/ffmpeg-core.wasm
Binary file not shown.
21 changes: 21 additions & 0 deletions public/ffmpeg/umd/ffmpeg-core.js

Large diffs are not rendered by default.

Binary file added public/ffmpeg/umd/ffmpeg-core.wasm
Binary file not shown.
96 changes: 96 additions & 0 deletions scripts/download_ffmpeg_local.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const urlModule = require('url');

const targetDir = path.resolve(__dirname, '../public/ffmpeg');
const coreDir = path.join(targetDir, 'core');
const coreMtDir = path.join(targetDir, 'core-mt');

// Ensure directories exist
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
if (!fs.existsSync(coreDir)) fs.mkdirSync(coreDir, { recursive: true });
if (!fs.existsSync(coreMtDir)) fs.mkdirSync(coreMtDir, { recursive: true });

const filesToDownload = [
// Single-threaded core
{
url: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm/ffmpeg-core.js',
dest: path.join(coreDir, 'ffmpeg-core.js')
},
{
url: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm/ffmpeg-core.wasm',
dest: path.join(coreDir, 'ffmpeg-core.wasm')
},
// Multi-threaded core
{
url: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.6/dist/esm/ffmpeg-core.js',
dest: path.join(coreMtDir, 'ffmpeg-core.js')
},
{
url: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.6/dist/esm/ffmpeg-core.worker.js',
dest: path.join(coreMtDir, 'ffmpeg-core.worker.js')
},
{
url: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.6/dist/esm/ffmpeg-core.wasm',
dest: path.join(coreMtDir, 'ffmpeg-core.wasm')
}
];

function fetchWithRedirects(urlStr, destStream, redirectCount = 0) {
return new Promise((resolve, reject) => {
if (redirectCount > 10) {
reject(new Error("Too many redirects"));
return;
}

https.get(urlStr, (response) => {
const { statusCode } = response;

// Handle redirects
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
const nextUrl = urlModule.resolve(urlStr, response.headers.location);
resolve(fetchWithRedirects(nextUrl, destStream, redirectCount + 1));
return;
}

if (statusCode === 200) {
response.pipe(destStream);
response.on('end', () => resolve());
} else {
reject(new Error(`Failed with status code: ${statusCode} for URL: ${urlStr}`));
}
}).on('error', (err) => reject(err));
});
}

async function downloadFile(url, dest) {
if (fs.existsSync(dest) && fs.statSync(dest).size > 1000) {
console.log(`Skipping ${dest} - already downloaded.`);
return;
}

console.log(`Downloading ${url} -> ${dest} ...`);
const fileStream = fs.createWriteStream(dest);
try {
await fetchWithRedirects(url, fileStream);
fileStream.close();
console.log(`Successfully downloaded to ${dest}`);
} catch (error) {
fileStream.close();
fs.unlink(dest, () => {}); // Delete partial file
throw error;
}
}

(async () => {
try {
for (const item of filesToDownload) {
await downloadFile(item.url, item.dest);
}
console.log("All FFmpeg WASM files downloaded successfully!");
} catch (error) {
console.error("Failed to download local FFmpeg files:", error);
process.exit(1);
}
})();
78 changes: 78 additions & 0 deletions scripts/download_ffmpeg_umd.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const urlModule = require('url');

const targetDir = path.resolve(__dirname, '../public/ffmpeg/umd');

// Ensure directory exists
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });

const filesToDownload = [
{
url: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js',
dest: path.join(targetDir, 'ffmpeg-core.js')
},
{
url: 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.wasm',
dest: path.join(targetDir, 'ffmpeg-core.wasm')
}
];

function fetchWithRedirects(urlStr, destStream, redirectCount = 0) {
return new Promise((resolve, reject) => {
if (redirectCount > 10) {
reject(new Error("Too many redirects"));
return;
}

https.get(urlStr, (response) => {
const { statusCode } = response;

// Handle redirects
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
const nextUrl = urlModule.resolve(urlStr, response.headers.location);
resolve(fetchWithRedirects(nextUrl, destStream, redirectCount + 1));
return;
}

if (statusCode === 200) {
response.pipe(destStream);
response.on('end', () => resolve());
} else {
reject(new Error(`Failed with status code: ${statusCode} for URL: ${urlStr}`));
}
}).on('error', (err) => reject(err));
});
}

async function downloadFile(url, dest) {
if (fs.existsSync(dest) && fs.statSync(dest).size > 1000) {
console.log(`Skipping ${dest} - already downloaded.`);
return;
}

console.log(`Downloading ${url} -> ${dest} ...`);
const fileStream = fs.createWriteStream(dest);
try {
await fetchWithRedirects(url, fileStream);
fileStream.close();
console.log(`Successfully downloaded to ${dest}`);
} catch (error) {
fileStream.close();
fs.unlink(dest, () => {}); // Delete partial file
throw error;
}
}

(async () => {
try {
for (const item of filesToDownload) {
await downloadFile(item.url, item.dest);
}
console.log("All FFmpeg UMD WASM files downloaded successfully!");
} catch (error) {
console.error("Failed to download local FFmpeg UMD files:", error);
process.exit(1);
}
})();
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ThemeProvider } from "@/components/ThemeProvider";
import { ThemeToggle } from "@/components/ThemeToggle";
import ScrollToTop from "@/components/ScrollToTop";
import BrandLogo from "@/components/BrandLogo";
import SplashScreen from "@/components/SplashScreen";

export const metadata: Metadata = {
title: "Reframe — Resize, trim, and export videos in your browser",
Expand Down Expand Up @@ -75,6 +76,7 @@ export default function RootLayout({
Skip to main content
</a>
<ThemeProvider>
<SplashScreen />
<ErrorBoundary>
<header
role="banner"
Expand Down
28 changes: 25 additions & 3 deletions src/components/AudioSpeedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { EditRecipe } from "@/lib/types"
import { SPEED_STEPS } from "@/lib/constants";
import { Volume2, VolumeX, Gauge, AlertTriangle } from "lucide-react";
import { Volume2, VolumeX, Gauge, AlertTriangle, Rewind } from "lucide-react";
import { cn } from "@/lib/utils";

interface Props {
Expand All @@ -21,7 +21,7 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) {
return "Very Fast";
};

const isModified = recipe.speed !== 1 || !recipe.keepAudio;
const isModified = recipe.speed !== 1 || !recipe.keepAudio || recipe.reverse;

return (
<div className="space-y-4">
Expand All @@ -30,7 +30,7 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) {
<button
type="button"
aria-label="Reset audio settings to default"
onClick={() => onChange({ speed: 1, keepAudio: true })}
onClick={() => onChange({ speed: 1, keepAudio: true, reverse: false })}
className="text-sm font-heading font-semibold uppercase tracking-wider text-film-600 hover:text-film-700 hover:underline transition-all duration-150"
>
Reset to Default
Expand Down Expand Up @@ -70,6 +70,28 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) {
</kbd>
</button>

<button
type="button"
onClick={() => onChange({ reverse: !recipe.reverse })}
aria-label={recipe.reverse ? "Disable reverse playback" : "Enable reverse playback"}
aria-pressed={recipe.reverse}
className={cn(
"w-full flex items-center gap-3 p-3 rounded-lg border transition-all duration-150",
"hover:scale-[1.01] active:scale-[0.99]",
recipe.reverse
? "border-film-300 bg-film-50 text-film-700"
: "border-[var(--border)] bg-[var(--surface)] text-[var(--muted)]"
)}
>
<Rewind size={16} aria-hidden="true" />
<span className="sr-only">
{recipe.reverse ? "Turn reverse playback off" : "Turn reverse playback on"}
</span>
<span className="text-sm font-heading font-semibold flex-1 text-left">
Reverse Playback
</span>
</button>

<div>
<div className="flex items-center justify-between mb-2">
<label
Expand Down
78 changes: 76 additions & 2 deletions src/components/ComparisonPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,74 @@
"use client";

import { useEffect, useRef, useState, useCallback, RefObject } from "react";
import { EditRecipe } from "@/lib/types";
import { EditRecipe, OverlayPosition } from "@/lib/types";
import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";

interface Props {
file: File | null;
recipe?: EditRecipe;
videoRef: RefObject<HTMLVideoElement | null>;
overlayFile?: File | null;
overlayPosition?: OverlayPosition;
overlaySize?: number;
overlayOpacity?: number;
overlayX?: number | null;
overlayY?: number | null;
}

export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
export default function ComparisonPreview({
file,
recipe,
videoRef,
overlayFile,
overlayPosition,
overlaySize,
overlayOpacity,
overlayX,
overlayY,
}: Props) {
const leftVideoRef = useRef<HTMLVideoElement>(null);
const rightVideoRef = useRef<HTMLVideoElement>(null);
const [sliderPosition, setSliderPosition] = useState(50);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

const [overlayUrl, setOverlayUrl] = useState<string>("");

useEffect(() => {
if (!overlayFile) {
setOverlayUrl("");
return;
}
const url = URL.createObjectURL(overlayFile);
setOverlayUrl(url);
return () => URL.revokeObjectURL(url);
}, [overlayFile]);

const getOverlayStyle = () => {
if (!overlayFile) return {};

if (overlayX === undefined || overlayY === undefined || overlayX === null || overlayY === null) {
const spacing = "20px";
if (overlayPosition === "top-left") {
return { left: spacing, top: spacing };
}
if (overlayPosition === "top-right") {
return { right: spacing, top: spacing };
}
if (overlayPosition === "bottom-left") {
return { left: spacing, bottom: spacing };
}
return { right: spacing, bottom: spacing };
}

return {
left: `${overlayX}%`,
top: `${overlayY}%`,
};
};

// Calculate overlay for the right (reframed) side
const overlay = (() => {
if (!recipe) return null;
Expand Down Expand Up @@ -193,13 +244,36 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
<video
ref={rightVideoRef}
className="w-full h-full object-contain"
style={{
filter: recipe
? `brightness(${1 + recipe.brightness}) contrast(${recipe.contrast}) saturate(${recipe.saturation})`
: undefined,
}}
playsInline
muted
autoPlay
loop
>
<track kind="captions" />
</video>

{overlayFile && overlayUrl && (
<div
className="absolute pointer-events-none select-none"
style={{
width: `${overlaySize}px`,
opacity: (overlayOpacity ?? 100) / 100,
zIndex: 40,
...getOverlayStyle(),
}}
>
<img
src={overlayUrl}
alt="Overlay asset"
className="w-full h-auto pointer-events-none"
/>
</div>
)}
</div>

{/* Overlay on reframed side */}
Expand Down
Loading