From 5b1a5164395b1623a90cb6faee188cdde679cbb1 Mon Sep 17 00:00:00 2001 From: Brenden Bishop Date: Sun, 4 Jan 2026 21:59:16 -0500 Subject: [PATCH 1/3] feat: migrate to client-side ffmpeg.wasm for Vercel compatibility Replace server-side FFmpeg with client-side ffmpeg.wasm to fix deployment issues on Vercel (413 payload limit, no FFmpeg binary). - Add useVideoConversion hook with ffmpeg.wasm integration - Use ultrafast preset for ~5x faster encoding - Add live terminal messages with real-time progress updates - Remove server-side /api/convert route - Add 'live' message type for instant-update terminal messages - Update terminal-content to support live messages Video processing now runs entirely in the browser using WebAssembly. --- README.md | 67 ++---- bun.lock | 8 + next.config.mjs | 14 +- package.json | 6 +- src/app/api/convert/route.ts | 175 -------------- src/components/terminal-content.tsx | 37 ++- .../video-convert/VideoConvertFlow.tsx | 135 +++++++++-- src/hooks/useVideoConversion.ts | 217 ++++++++++++++++++ src/types/terminal.ts | 2 + 9 files changed, 403 insertions(+), 258 deletions(-) delete mode 100644 src/app/api/convert/route.ts create mode 100644 src/hooks/useVideoConversion.ts diff --git a/README.md b/README.md index af93a05..40c0f76 100644 --- a/README.md +++ b/README.md @@ -9,54 +9,26 @@ Press your app preview videos into App Store perfection. - Convert videos to macOS App Store format (1920×1080) - Convert videos to iOS App Store format (886×1920) - Add silent audio track (fixes common Apple upload rejections) -- Fast server-side processing with FFmpeg +- **100% client-side processing** - your video never leaves your browser - Desktop-only (video processing requires desktop browser) ## Requirements ### System Requirements -- **Node.js** 18.x or higher -- **FFmpeg** installed on the server (required for video processing) +- **Node.js** 18.x or higher (for development/hosting) +- **Modern browser** with SharedArrayBuffer support (Chrome, Firefox, Edge) ### Video Requirements - Format: MP4 -- Duration: 15-30 seconds -- Max file size: 500MB +- Duration: 15-30 seconds (App Store limit) -## Server Requirements +## How It Works -This application requires FFmpeg to be installed on the server. +Ciderpress uses [ffmpeg.wasm](https://github.com/ffmpegwasm/ffmpeg.wasm) to process videos entirely in your browser using WebAssembly. No server-side processing required. -### macOS - -```bash -brew install ffmpeg -ffmpeg -version -``` - -### Ubuntu/Debian - -```bash -sudo apt update -sudo apt install -y ffmpeg -ffmpeg -version -``` - -### CentOS/RHEL/Fedora - -```bash -sudo dnf install -y ffmpeg ffmpeg-devel -ffmpeg -version -``` - -### Windows - -1. Download FFmpeg from [ffmpeg.org/download.html](https://www.ffmpeg.org/download.html) -2. Extract to `C:\ffmpeg` -3. Add `C:\ffmpeg\bin` to system PATH -4. Verify: `ffmpeg -version` +The first time you convert a video, the app downloads the FFmpeg WASM core (~31MB). This is cached by your browser for future use. ## Development @@ -89,14 +61,13 @@ bun run build - **Animation:** Motion (Framer Motion) - **Linting:** Biome - **Testing:** Vitest + React Testing Library -- **Video Processing:** FFmpeg (server-side) +- **Video Processing:** ffmpeg.wasm (client-side WebAssembly) ## Project Structure ``` src/ ├── app/ # Next.js App Router -│ ├── api/convert/ # Video conversion API endpoint │ └── page.tsx # Main page ├── components/ │ ├── providers/ # React context providers @@ -110,22 +81,22 @@ src/ ## Deployment -Deploy to any Node.js platform that supports Next.js: +Deploy to any static hosting or Node.js platform: -- Vercel -- Railway -- Render -- DigitalOcean App Platform -- AWS (EC2, ECS, Lambda) +- Vercel (recommended) +- Netlify +- Cloudflare Pages +- Any static host -**Important:** Ensure FFmpeg is installed on your deployment server. The app will return a 503 error if FFmpeg is not available. +No special server configuration required since all video processing happens client-side. -### Environment Variables +### Required Headers -No environment variables are required for basic operation. For production, consider: +The app requires these security headers for SharedArrayBuffer support (already configured in `next.config.mjs`): -```env -NODE_ENV=production +``` +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin ``` ## License diff --git a/bun.lock b/bun.lock index 2b43d26..0b44974 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,8 @@ "": { "name": "app-preview-converter", "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", @@ -180,6 +182,12 @@ "@exodus/bytes": ["@exodus/bytes@1.7.0", "", { "peerDependencies": { "@exodus/crypto": "^1.0.0-rc.4" }, "optionalPeers": ["@exodus/crypto"] }, "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ=="], + "@ffmpeg/ffmpeg": ["@ffmpeg/ffmpeg@0.12.15", "", { "dependencies": { "@ffmpeg/types": "^0.12.4" } }, "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw=="], + + "@ffmpeg/types": ["@ffmpeg/types@0.12.4", "", {}, "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A=="], + + "@ffmpeg/util": ["@ffmpeg/util@0.12.2", "", {}, "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], diff --git a/next.config.mjs b/next.config.mjs index ab594a2..0c6a670 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,9 +1,15 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - // Enable Turbopack (default in Next.js 16) - turbopack: {}, - - // Set security headers for SharedArrayBuffer support + // Enable WebAssembly support for ffmpeg.wasm + webpack: (config) => { + config.experiments = { + ...config.experiments, + asyncWebAssembly: true, + }; + return config; + }, + + // Set security headers for SharedArrayBuffer support (required for ffmpeg.wasm) async headers() { return [ { diff --git a/package.json b/package.json index 5831220..8e97e69 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ }, "packageManager": "bun@1.2.20", "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "next dev --webpack", + "build": "next build --webpack", "start": "next start", "lint": "biome lint ./src", "format": "biome format --write ./src", @@ -20,6 +20,8 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", diff --git a/src/app/api/convert/route.ts b/src/app/api/convert/route.ts deleted file mode 100644 index 960f31f..0000000 --- a/src/app/api/convert/route.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { execFile } from "node:child_process"; -import { promises as fs } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import util from "node:util"; -import { type NextRequest, NextResponse } from "next/server"; -import { v4 as uuidv4 } from "uuid"; - -const execFileAsync = util.promisify(execFile); - -// Constants -const MAX_FILE_SIZE_MB = 500; -const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; -const PLATFORM_RESOLUTIONS = { - macOS: "1920:1080", - iOS: "886:1920", -} as const; - -type Platform = keyof typeof PLATFORM_RESOLUTIONS; - -// Check if ffmpeg is available -async function checkFfmpegAvailable(): Promise { - try { - await execFileAsync("ffmpeg", ["-version"]); - return true; - } catch { - return false; - } -} - -// Clean up temporary files -async function cleanupFiles(...filePaths: string[]): Promise { - for (const filePath of filePaths) { - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch { - // File doesn't exist or can't be deleted - ignore - } - } -} - -export async function POST(request: NextRequest) { - // Check ffmpeg availability first - const ffmpegAvailable = await checkFfmpegAvailable(); - if (!ffmpegAvailable) { - return NextResponse.json( - { - error: "FFmpeg is not installed on the server", - details: "Video conversion requires FFmpeg to be installed", - }, - { status: 503 }, - ); - } - - try { - if (!request.formData) { - return NextResponse.json({ error: "FormData not supported" }, { status: 400 }); - } - - const formData = await request.formData(); - const videoFile = formData.get("video") as File; - const platform = (formData.get("platform") as Platform) || "macOS"; - const addSilentAudio = formData.get("addSilentAudio") === "true"; - - // Validation - if (!videoFile) { - return NextResponse.json({ error: "No video file provided" }, { status: 400 }); - } - - if (videoFile.size > MAX_FILE_SIZE_BYTES) { - return NextResponse.json( - { - error: `File too large`, - details: `Maximum file size is ${MAX_FILE_SIZE_MB}MB`, - }, - { status: 400 }, - ); - } - - if (!["macOS", "iOS"].includes(platform)) { - return NextResponse.json( - { error: "Invalid platform", details: "Must be macOS or iOS" }, - { status: 400 }, - ); - } - - // Create temporary file paths - const tempDir = os.tmpdir(); - const uniqueId = uuidv4(); - const inputPath = path.join(tempDir, `input-${uniqueId}.mp4`); - const tempOutputPath = path.join(tempDir, `temp-${uniqueId}.mp4`); - const outputPath = path.join(tempDir, `output-${uniqueId}.mp4`); - const finalPath = path.join(tempDir, `final-${uniqueId}.mp4`); - - try { - // Save the uploaded file to disk - const videoBuffer = Buffer.from(await videoFile.arrayBuffer()); - await fs.writeFile(inputPath, videoBuffer); - - const resolution = PLATFORM_RESOLUTIONS[platform]; - const scaleFilter = `scale=${resolution}:flags=lanczos,setsar=1`; - - if (addSilentAudio) { - // Convert video without audio first - await execFileAsync("ffmpeg", [ - "-y", - "-i", - inputPath, - "-vf", - scaleFilter, - "-an", - tempOutputPath, - ]); - - // Add silent audio track - await execFileAsync("ffmpeg", [ - "-y", - "-f", - "lavfi", - "-i", - "anullsrc=channel_layout=stereo:sample_rate=48000", - "-i", - tempOutputPath, - "-c:v", - "copy", - "-c:a", - "aac", - "-b:a", - "128k", - "-shortest", - outputPath, - ]); - } else { - // Just convert video - await execFileAsync("ffmpeg", ["-y", "-i", inputPath, "-vf", scaleFilter, outputPath]); - } - - // Convert to 30fps (Apple requirement) - await execFileAsync("ffmpeg", ["-y", "-i", outputPath, "-r", "30", finalPath]); - - // Move final to output path - await fs.rename(finalPath, outputPath); - - // Read the output file - const outputBuffer = await fs.readFile(outputPath); - - // Generate filename - const filename = `${platform}_Preview${addSilentAudio ? "_with_silent_audio" : ""}.mp4`; - - return new NextResponse(outputBuffer, { - headers: { - "Content-Type": "video/mp4", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); - } finally { - // Clean up all temporary files - await cleanupFiles(inputPath, tempOutputPath, outputPath, finalPath); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - return NextResponse.json( - { - error: "Video conversion failed", - details: - process.env.NODE_ENV === "development" - ? errorMessage - : "Please try again or use a different video file", - }, - { status: 500 }, - ); - } -} diff --git a/src/components/terminal-content.tsx b/src/components/terminal-content.tsx index 24c7a17..5bdb538 100644 --- a/src/components/terminal-content.tsx +++ b/src/components/terminal-content.tsx @@ -188,19 +188,42 @@ export default function TerminalContent({ return null; } + const textClassName = cn( + "break-words w-full", + message.type === "prompt" && "text-cyan-400", + message.type === "info" && "text-neutral-400", + message.type === "success" && "text-emerald-400", + message.type === "error" && "text-red-400", + ); + + // Live messages render instantly and update in real-time + if (message.live) { + // Auto-complete live messages so the queue keeps moving + if (!isComplete) { + handleMessageComplete(index); + } + + return ( +
+ + {message.text} + + {message.buttons && ( + + {renderButtons(message.buttons, index)} + + )} +
+ ); + } + return (
handleMessageComplete(index)} - className={cn( - "break-words w-full", - message.type === "prompt" && "text-cyan-400", - message.type === "info" && "text-neutral-400", - message.type === "success" && "text-emerald-400", - message.type === "error" && "text-red-400", - )} + className={textClassName} > {message.text} diff --git a/src/components/video-convert/VideoConvertFlow.tsx b/src/components/video-convert/VideoConvertFlow.tsx index be19dc9..463e18c 100644 --- a/src/components/video-convert/VideoConvertFlow.tsx +++ b/src/components/video-convert/VideoConvertFlow.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import TerminalContent from "@/components/terminal-content"; import { useTerminalMessages } from "@/hooks/useTerminalMessages"; import { useUploadButtonState } from "@/hooks/useUploadButtonState"; +import { useVideoConversion } from "@/hooks/useVideoConversion"; import { validateVideo } from "@/lib/video-validation"; import type { TerminalMessage } from "@/types/terminal"; @@ -21,6 +22,16 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF const selectedPlatformRef = useRef<"macOS" | "iOS">("macOS"); const addSilentAudioRef = useRef(true); + // Client-side video conversion + const { + loadFFmpeg, + convertVideo, + reset: resetConversion, + error: conversionError, + progress, + step, + } = useVideoConversion(); + const { messages, initializeMessages, @@ -29,9 +40,9 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF addPlatformSuccessMessage, addAudioPrompt, addAudioSuccessMessage, - addConversionStartMessage, addConversionCompleteMessage, addSupportMessage, + addErrorMessage, setMessages, } = useTerminalMessages(); @@ -84,29 +95,98 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF addUploadPrompt(handleFileUpload); }, [initializeMessages, show, addUploadPrompt, handleFileUpload]); + // Track which step we've added a message for + const lastStepRef = useRef("idle"); + // Map of step -> message index for live updates + const stepMsgIndicesRef = useRef>(new Map()); + + // Steps that show UI messages (skip "audio" - it's too fast to be useful) + const UI_STEPS = ["loading", "scaling", "finalizing"] as const; + + // Get the status text based on step and progress + const getStatusText = (currentStep: string, currentProgress: number): string => { + switch (currentStep) { + case "loading": + return "🍎 Warming up the cider press..."; + case "scaling": + return `🍏 Pressing... ${currentProgress}%`; + case "finalizing": + return "🫗 Bottling your fresh cider..."; + default: + return ""; + } + }; + + // Add a new live message when step changes (only for UI-visible steps) + useEffect(() => { + if (step === "idle" || step === "complete") return; + if (!UI_STEPS.includes(step as typeof UI_STEPS[number])) return; + if (step === lastStepRef.current) return; + + lastStepRef.current = step; + + // Add a new live message for this step + setMessages((prev) => { + const newIndex = prev.length; + stepMsgIndicesRef.current.set(step, newIndex); + return [ + ...prev, + { + text: getStatusText(step, progress), + delay: 0, + type: "info" as const, + live: true, + }, + ]; + }); + }, [step, progress, setMessages]); + + // Update the current step's live message with progress + useEffect(() => { + if (step === "idle" || step === "complete") return; + if (!UI_STEPS.includes(step as typeof UI_STEPS[number])) return; + + const msgIndex = stepMsgIndicesRef.current.get(step); + if (msgIndex === undefined) return; + + const text = getStatusText(step, progress); + if (!text) return; + + setMessages((prev) => { + if (msgIndex >= prev.length) return prev; + if (prev[msgIndex].text === text) return prev; + + const updated = [...prev]; + updated[msgIndex] = { ...updated[msgIndex], text }; + return updated; + }); + }, [step, progress, setMessages]); + + // Reset on restart + useEffect(() => { + if (step === "idle") { + lastStepRef.current = "idle"; + stepMsgIndicesRef.current.clear(); + } + }, [step]); + const handleConversion = useCallback(async () => { const file = videoFileRef.current; if (!file) return; - addConversionStartMessage(); - - try { - const formData = new FormData(); - formData.append("video", file); - formData.append("platform", selectedPlatformRef.current); - formData.append("addSilentAudio", addSilentAudioRef.current.toString()); - - const response = await fetch("/api/convert", { - method: "POST", - body: formData, - }); + const loaded = await loadFFmpeg(); + if (!loaded) { + addErrorMessage("Failed to load video processor. Please refresh and try again."); + return; + } - if (!response.ok) { - throw new Error("Conversion failed"); - } + const result = await convertVideo(file, { + platform: selectedPlatformRef.current, + addSilentAudio: addSilentAudioRef.current, + }); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); + if (result) { + const url = URL.createObjectURL(result.blob); setConvertedVideoUrl(url); addConversionCompleteMessage(); @@ -114,10 +194,10 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF setTimeout(() => { onConversionComplete?.(); }, 1500); - } catch (error) { - console.error("Conversion error:", error); + } else if (conversionError) { + addErrorMessage(conversionError); } - }, [addConversionStartMessage, addConversionCompleteMessage, onConversionComplete]); + }, [loadFFmpeg, convertVideo, conversionError, addConversionCompleteMessage, addErrorMessage, onConversionComplete]); const handlePlatformSelection = useCallback( (action: string) => { @@ -158,12 +238,23 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF }, [convertedVideoUrl, addSupportMessage]); const handleRestart = useCallback(() => { + // Reset all refs videoFileRef.current = null; selectedPlatformRef.current = "macOS"; addSilentAudioRef.current = true; + lastStepRef.current = "idle"; + stepMsgIndicesRef.current.clear(); + + // Reset state setConvertedVideoUrl(null); setUploadKey((prev) => prev + 1); - }, []); + resetConversion(); + + // Reinitialize messages + initializeMessages(); + show(); + addUploadPrompt(handleFileUpload); + }, [resetConversion, initializeMessages, show, addUploadPrompt, handleFileUpload]); const handleButtonClick = useCallback( (action: string) => { diff --git a/src/hooks/useVideoConversion.ts b/src/hooks/useVideoConversion.ts new file mode 100644 index 0000000..acfe670 --- /dev/null +++ b/src/hooks/useVideoConversion.ts @@ -0,0 +1,217 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +type Platform = "macOS" | "iOS"; + +interface ConversionOptions { + platform: Platform; + addSilentAudio: boolean; +} + +interface ConversionResult { + blob: Blob; + filename: string; +} + +const PLATFORM_RESOLUTIONS = { + macOS: "1920:1080", + iOS: "886:1920", +} as const; + +// CDN URLs for ffmpeg core files (single-threaded - MT has issues in browser) +const CORE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd/ffmpeg-core.js"; +const WASM_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd/ffmpeg-core.wasm"; + +// Singleton state +let ffmpegInstance: any = null; +let isLoaded = false; +let loadPromise: Promise | null = null; +let ffmpegUtils: { fetchFile: (file: File) => Promise } | null = null; + +type ConversionStep = "idle" | "loading" | "scaling" | "audio" | "finalizing" | "complete"; + +export const useVideoConversion = () => { + const [isLoading, setIsLoading] = useState(false); + const [isConverting, setIsConverting] = useState(false); + const [progress, setProgress] = useState(0); + const [step, setStep] = useState("idle"); + const [error, setError] = useState(null); + + const loadFFmpeg = useCallback(async (): Promise => { + if (isLoaded && ffmpegInstance) { + return true; + } + + if (loadPromise) { + return loadPromise; + } + + setIsLoading(true); + setStep("loading"); + setError(null); + + loadPromise = (async () => { + try { + // Import the packages + const { FFmpeg } = await import("@ffmpeg/ffmpeg"); + const { fetchFile, toBlobURL } = await import("@ffmpeg/util"); + + ffmpegUtils = { fetchFile }; + ffmpegInstance = new FFmpeg(); + + // Logging + ffmpegInstance.on("log", ({ type, message }: { type: string; message: string }) => { + console.log(`[FFmpeg ${type}]`, message); + }); + + // Progress tracking + ffmpegInstance.on("progress", ({ progress: p }: { progress: number }) => { + console.log(`[FFmpeg] Progress: ${Math.round(p * 100)}%`); + setProgress(Math.round(p * 100)); + }); + + console.log("[FFmpeg] Loading core from CDN..."); + // Convert CDN URLs to blob URLs for reliability + const [coreURL, wasmURL] = await Promise.all([ + toBlobURL(CORE_URL, "text/javascript"), + toBlobURL(WASM_URL, "application/wasm"), + ]); + + // Load single-threaded core (MT has issues in browser environment) + await ffmpegInstance.load({ + coreURL, + wasmURL, + }); + console.log("[FFmpeg] Core loaded successfully!"); + + isLoaded = true; + setIsLoading(false); + return true; + } catch (err) { + console.error("[FFmpeg] Load error:", err); + setError(err instanceof Error ? err.message : "Failed to load video processor"); + setIsLoading(false); + loadPromise = null; + return false; + } + })(); + + return loadPromise; + }, []); + + const convertVideo = useCallback( + async (file: File, options: ConversionOptions): Promise => { + if (!ffmpegInstance || !isLoaded || !ffmpegUtils) { + setError("FFmpeg not loaded"); + return null; + } + + setIsConverting(true); + setProgress(0); + setStep("scaling"); + setError(null); + + try { + const { fetchFile } = ffmpegUtils; + + const inputName = "input.mp4"; + const scaledName = "scaled.mp4"; + const outputName = "output.mp4"; + + console.log("[FFmpeg] Writing input file..."); + // Write input file + await ffmpegInstance.writeFile(inputName, await fetchFile(file)); + console.log("[FFmpeg] Input file written, starting conversion..."); + + const resolution = PLATFORM_RESOLUTIONS[options.platform]; + const scaleFilter = `scale=${resolution}:flags=lanczos,setsar=1`; + + if (options.addSilentAudio) { + console.log("[FFmpeg] Step 1: Scaling video..."); + // Step 1: Scale video, remove audio (use ultrafast preset for speed) + await ffmpegInstance.exec([ + "-i", inputName, + "-vf", scaleFilter, + "-preset", "ultrafast", + "-an", + scaledName, + ]); + + console.log("[FFmpeg] Step 2: Adding silent audio..."); + setStep("audio"); + setProgress(0); + // Step 2: Add silent audio + set framerate + await ffmpegInstance.exec([ + "-f", "lavfi", + "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", + "-i", scaledName, + "-c:v", "copy", + "-c:a", "aac", + "-b:a", "128k", + "-shortest", + "-r", "30", + outputName, + ]); + } else { + console.log("[FFmpeg] Scaling video..."); + await ffmpegInstance.exec([ + "-i", inputName, + "-vf", scaleFilter, + "-preset", "ultrafast", + "-r", "30", + outputName, + ]); + } + + console.log("[FFmpeg] Reading output file..."); + setStep("finalizing"); + // Read output + const data = await ffmpegInstance.readFile(outputName); + console.log("[FFmpeg] Conversion complete!"); + setStep("complete"); + + // Cleanup + await ffmpegInstance.deleteFile(inputName); + if (options.addSilentAudio) { + await ffmpegInstance.deleteFile(scaledName); + } + await ffmpegInstance.deleteFile(outputName); + + // Create blob + const uint8Data = data instanceof Uint8Array ? data : new Uint8Array(data); + const blob = new Blob([uint8Data.slice()], { type: "video/mp4" }); + + const filename = `${options.platform}_Preview${options.addSilentAudio ? "_with_silent_audio" : ""}.mp4`; + + setIsConverting(false); + setProgress(100); + + return { blob, filename }; + } catch (err) { + console.error("[FFmpeg] Conversion error:", err); + setError(err instanceof Error ? err.message : "Conversion failed"); + setIsConverting(false); + return null; + } + }, + [] + ); + + const reset = useCallback(() => { + setProgress(0); + setStep("idle"); + setError(null); + }, []); + + return { + loadFFmpeg, + convertVideo, + reset, + isLoading, + isConverting, + progress, + step, + error, + }; +}; diff --git a/src/types/terminal.ts b/src/types/terminal.ts index 3094dfd..000a3de 100644 --- a/src/types/terminal.ts +++ b/src/types/terminal.ts @@ -11,4 +11,6 @@ export interface TerminalMessage { type?: "info" | "prompt" | "success" | "error" | "buttons-container" | "button-inline"; buttons?: Button[]; action?: string; + /** If true, renders instantly without typing animation and updates live */ + live?: boolean; } From 58bc71a7bbcceeeb2d69ffac7ec355b11c5bcf58 Mon Sep 17 00:00:00 2001 From: Brenden Bishop Date: Sun, 4 Jan 2026 21:59:16 -0500 Subject: [PATCH 2/3] feat: migrate to client-side ffmpeg.wasm for Vercel compatibility Replace server-side FFmpeg with client-side ffmpeg.wasm to fix deployment issues on Vercel (413 payload limit, no FFmpeg binary). - Add useVideoConversion hook with ffmpeg.wasm integration - Use ultrafast preset for ~5x faster encoding - Add live terminal messages with real-time progress updates - Remove server-side /api/convert route - Add 'live' message type for instant-update terminal messages - Update terminal-content to support live messages Video processing now runs entirely in the browser using WebAssembly. --- README.md | 67 ++--- bun.lock | 8 + next.config.mjs | 14 +- package.json | 6 +- src/app/api/convert/route.ts | 175 ------------- src/components/terminal-content.tsx | 37 ++- .../video-convert/VideoConvertFlow.tsx | 142 +++++++++-- src/hooks/useVideoConversion.ts | 232 ++++++++++++++++++ src/types/terminal.ts | 2 + 9 files changed, 425 insertions(+), 258 deletions(-) delete mode 100644 src/app/api/convert/route.ts create mode 100644 src/hooks/useVideoConversion.ts diff --git a/README.md b/README.md index af93a05..40c0f76 100644 --- a/README.md +++ b/README.md @@ -9,54 +9,26 @@ Press your app preview videos into App Store perfection. - Convert videos to macOS App Store format (1920×1080) - Convert videos to iOS App Store format (886×1920) - Add silent audio track (fixes common Apple upload rejections) -- Fast server-side processing with FFmpeg +- **100% client-side processing** - your video never leaves your browser - Desktop-only (video processing requires desktop browser) ## Requirements ### System Requirements -- **Node.js** 18.x or higher -- **FFmpeg** installed on the server (required for video processing) +- **Node.js** 18.x or higher (for development/hosting) +- **Modern browser** with SharedArrayBuffer support (Chrome, Firefox, Edge) ### Video Requirements - Format: MP4 -- Duration: 15-30 seconds -- Max file size: 500MB +- Duration: 15-30 seconds (App Store limit) -## Server Requirements +## How It Works -This application requires FFmpeg to be installed on the server. +Ciderpress uses [ffmpeg.wasm](https://github.com/ffmpegwasm/ffmpeg.wasm) to process videos entirely in your browser using WebAssembly. No server-side processing required. -### macOS - -```bash -brew install ffmpeg -ffmpeg -version -``` - -### Ubuntu/Debian - -```bash -sudo apt update -sudo apt install -y ffmpeg -ffmpeg -version -``` - -### CentOS/RHEL/Fedora - -```bash -sudo dnf install -y ffmpeg ffmpeg-devel -ffmpeg -version -``` - -### Windows - -1. Download FFmpeg from [ffmpeg.org/download.html](https://www.ffmpeg.org/download.html) -2. Extract to `C:\ffmpeg` -3. Add `C:\ffmpeg\bin` to system PATH -4. Verify: `ffmpeg -version` +The first time you convert a video, the app downloads the FFmpeg WASM core (~31MB). This is cached by your browser for future use. ## Development @@ -89,14 +61,13 @@ bun run build - **Animation:** Motion (Framer Motion) - **Linting:** Biome - **Testing:** Vitest + React Testing Library -- **Video Processing:** FFmpeg (server-side) +- **Video Processing:** ffmpeg.wasm (client-side WebAssembly) ## Project Structure ``` src/ ├── app/ # Next.js App Router -│ ├── api/convert/ # Video conversion API endpoint │ └── page.tsx # Main page ├── components/ │ ├── providers/ # React context providers @@ -110,22 +81,22 @@ src/ ## Deployment -Deploy to any Node.js platform that supports Next.js: +Deploy to any static hosting or Node.js platform: -- Vercel -- Railway -- Render -- DigitalOcean App Platform -- AWS (EC2, ECS, Lambda) +- Vercel (recommended) +- Netlify +- Cloudflare Pages +- Any static host -**Important:** Ensure FFmpeg is installed on your deployment server. The app will return a 503 error if FFmpeg is not available. +No special server configuration required since all video processing happens client-side. -### Environment Variables +### Required Headers -No environment variables are required for basic operation. For production, consider: +The app requires these security headers for SharedArrayBuffer support (already configured in `next.config.mjs`): -```env -NODE_ENV=production +``` +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin ``` ## License diff --git a/bun.lock b/bun.lock index 2b43d26..0b44974 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,8 @@ "": { "name": "app-preview-converter", "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", @@ -180,6 +182,12 @@ "@exodus/bytes": ["@exodus/bytes@1.7.0", "", { "peerDependencies": { "@exodus/crypto": "^1.0.0-rc.4" }, "optionalPeers": ["@exodus/crypto"] }, "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ=="], + "@ffmpeg/ffmpeg": ["@ffmpeg/ffmpeg@0.12.15", "", { "dependencies": { "@ffmpeg/types": "^0.12.4" } }, "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw=="], + + "@ffmpeg/types": ["@ffmpeg/types@0.12.4", "", {}, "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A=="], + + "@ffmpeg/util": ["@ffmpeg/util@0.12.2", "", {}, "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], diff --git a/next.config.mjs b/next.config.mjs index ab594a2..0c6a670 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,9 +1,15 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - // Enable Turbopack (default in Next.js 16) - turbopack: {}, - - // Set security headers for SharedArrayBuffer support + // Enable WebAssembly support for ffmpeg.wasm + webpack: (config) => { + config.experiments = { + ...config.experiments, + asyncWebAssembly: true, + }; + return config; + }, + + // Set security headers for SharedArrayBuffer support (required for ffmpeg.wasm) async headers() { return [ { diff --git a/package.json b/package.json index 5831220..8e97e69 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ }, "packageManager": "bun@1.2.20", "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "next dev --webpack", + "build": "next build --webpack", "start": "next start", "lint": "biome lint ./src", "format": "biome format --write ./src", @@ -20,6 +20,8 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", diff --git a/src/app/api/convert/route.ts b/src/app/api/convert/route.ts deleted file mode 100644 index 960f31f..0000000 --- a/src/app/api/convert/route.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { execFile } from "node:child_process"; -import { promises as fs } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import util from "node:util"; -import { type NextRequest, NextResponse } from "next/server"; -import { v4 as uuidv4 } from "uuid"; - -const execFileAsync = util.promisify(execFile); - -// Constants -const MAX_FILE_SIZE_MB = 500; -const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; -const PLATFORM_RESOLUTIONS = { - macOS: "1920:1080", - iOS: "886:1920", -} as const; - -type Platform = keyof typeof PLATFORM_RESOLUTIONS; - -// Check if ffmpeg is available -async function checkFfmpegAvailable(): Promise { - try { - await execFileAsync("ffmpeg", ["-version"]); - return true; - } catch { - return false; - } -} - -// Clean up temporary files -async function cleanupFiles(...filePaths: string[]): Promise { - for (const filePath of filePaths) { - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch { - // File doesn't exist or can't be deleted - ignore - } - } -} - -export async function POST(request: NextRequest) { - // Check ffmpeg availability first - const ffmpegAvailable = await checkFfmpegAvailable(); - if (!ffmpegAvailable) { - return NextResponse.json( - { - error: "FFmpeg is not installed on the server", - details: "Video conversion requires FFmpeg to be installed", - }, - { status: 503 }, - ); - } - - try { - if (!request.formData) { - return NextResponse.json({ error: "FormData not supported" }, { status: 400 }); - } - - const formData = await request.formData(); - const videoFile = formData.get("video") as File; - const platform = (formData.get("platform") as Platform) || "macOS"; - const addSilentAudio = formData.get("addSilentAudio") === "true"; - - // Validation - if (!videoFile) { - return NextResponse.json({ error: "No video file provided" }, { status: 400 }); - } - - if (videoFile.size > MAX_FILE_SIZE_BYTES) { - return NextResponse.json( - { - error: `File too large`, - details: `Maximum file size is ${MAX_FILE_SIZE_MB}MB`, - }, - { status: 400 }, - ); - } - - if (!["macOS", "iOS"].includes(platform)) { - return NextResponse.json( - { error: "Invalid platform", details: "Must be macOS or iOS" }, - { status: 400 }, - ); - } - - // Create temporary file paths - const tempDir = os.tmpdir(); - const uniqueId = uuidv4(); - const inputPath = path.join(tempDir, `input-${uniqueId}.mp4`); - const tempOutputPath = path.join(tempDir, `temp-${uniqueId}.mp4`); - const outputPath = path.join(tempDir, `output-${uniqueId}.mp4`); - const finalPath = path.join(tempDir, `final-${uniqueId}.mp4`); - - try { - // Save the uploaded file to disk - const videoBuffer = Buffer.from(await videoFile.arrayBuffer()); - await fs.writeFile(inputPath, videoBuffer); - - const resolution = PLATFORM_RESOLUTIONS[platform]; - const scaleFilter = `scale=${resolution}:flags=lanczos,setsar=1`; - - if (addSilentAudio) { - // Convert video without audio first - await execFileAsync("ffmpeg", [ - "-y", - "-i", - inputPath, - "-vf", - scaleFilter, - "-an", - tempOutputPath, - ]); - - // Add silent audio track - await execFileAsync("ffmpeg", [ - "-y", - "-f", - "lavfi", - "-i", - "anullsrc=channel_layout=stereo:sample_rate=48000", - "-i", - tempOutputPath, - "-c:v", - "copy", - "-c:a", - "aac", - "-b:a", - "128k", - "-shortest", - outputPath, - ]); - } else { - // Just convert video - await execFileAsync("ffmpeg", ["-y", "-i", inputPath, "-vf", scaleFilter, outputPath]); - } - - // Convert to 30fps (Apple requirement) - await execFileAsync("ffmpeg", ["-y", "-i", outputPath, "-r", "30", finalPath]); - - // Move final to output path - await fs.rename(finalPath, outputPath); - - // Read the output file - const outputBuffer = await fs.readFile(outputPath); - - // Generate filename - const filename = `${platform}_Preview${addSilentAudio ? "_with_silent_audio" : ""}.mp4`; - - return new NextResponse(outputBuffer, { - headers: { - "Content-Type": "video/mp4", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); - } finally { - // Clean up all temporary files - await cleanupFiles(inputPath, tempOutputPath, outputPath, finalPath); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - return NextResponse.json( - { - error: "Video conversion failed", - details: - process.env.NODE_ENV === "development" - ? errorMessage - : "Please try again or use a different video file", - }, - { status: 500 }, - ); - } -} diff --git a/src/components/terminal-content.tsx b/src/components/terminal-content.tsx index 24c7a17..5bdb538 100644 --- a/src/components/terminal-content.tsx +++ b/src/components/terminal-content.tsx @@ -188,19 +188,42 @@ export default function TerminalContent({ return null; } + const textClassName = cn( + "break-words w-full", + message.type === "prompt" && "text-cyan-400", + message.type === "info" && "text-neutral-400", + message.type === "success" && "text-emerald-400", + message.type === "error" && "text-red-400", + ); + + // Live messages render instantly and update in real-time + if (message.live) { + // Auto-complete live messages so the queue keeps moving + if (!isComplete) { + handleMessageComplete(index); + } + + return ( +
+ + {message.text} + + {message.buttons && ( + + {renderButtons(message.buttons, index)} + + )} +
+ ); + } + return (
handleMessageComplete(index)} - className={cn( - "break-words w-full", - message.type === "prompt" && "text-cyan-400", - message.type === "info" && "text-neutral-400", - message.type === "success" && "text-emerald-400", - message.type === "error" && "text-red-400", - )} + className={textClassName} > {message.text} diff --git a/src/components/video-convert/VideoConvertFlow.tsx b/src/components/video-convert/VideoConvertFlow.tsx index be19dc9..246ed4b 100644 --- a/src/components/video-convert/VideoConvertFlow.tsx +++ b/src/components/video-convert/VideoConvertFlow.tsx @@ -4,9 +4,27 @@ import { useCallback, useEffect, useRef, useState } from "react"; import TerminalContent from "@/components/terminal-content"; import { useTerminalMessages } from "@/hooks/useTerminalMessages"; import { useUploadButtonState } from "@/hooks/useUploadButtonState"; +import { useVideoConversion } from "@/hooks/useVideoConversion"; import { validateVideo } from "@/lib/video-validation"; import type { TerminalMessage } from "@/types/terminal"; +// Steps that show UI messages (skip "audio" - it's too fast to be useful) +const UI_STEPS = ["loading", "scaling", "finalizing"] as const; + +// Get the status text based on step and progress +const getStatusText = (currentStep: string, currentProgress: number): string => { + switch (currentStep) { + case "loading": + return "🍎 Warming up the cider press..."; + case "scaling": + return `🍏 Pressing... ${currentProgress}%`; + case "finalizing": + return "🫗 Bottling your fresh cider..."; + default: + return ""; + } +}; + interface VideoConvertFlowProps { onConversionComplete?: () => void; } @@ -21,6 +39,16 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF const selectedPlatformRef = useRef<"macOS" | "iOS">("macOS"); const addSilentAudioRef = useRef(true); + // Client-side video conversion + const { + loadFFmpeg, + convertVideo, + reset: resetConversion, + error: conversionError, + progress, + step, + } = useVideoConversion(); + const { messages, initializeMessages, @@ -29,9 +57,9 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF addPlatformSuccessMessage, addAudioPrompt, addAudioSuccessMessage, - addConversionStartMessage, addConversionCompleteMessage, addSupportMessage, + addErrorMessage, setMessages, } = useTerminalMessages(); @@ -84,29 +112,81 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF addUploadPrompt(handleFileUpload); }, [initializeMessages, show, addUploadPrompt, handleFileUpload]); + // Track which step we've added a message for + const lastStepRef = useRef("idle"); + // Map of step -> message index for live updates + const stepMsgIndicesRef = useRef>(new Map()); + + // Add a new live message when step changes (only for UI-visible steps) + useEffect(() => { + if (step === "idle" || step === "complete") return; + if (!UI_STEPS.includes(step as (typeof UI_STEPS)[number])) return; + if (step === lastStepRef.current) return; + + lastStepRef.current = step; + + // Add a new live message for this step + setMessages((prev) => { + const newIndex = prev.length; + stepMsgIndicesRef.current.set(step, newIndex); + return [ + ...prev, + { + text: getStatusText(step, progress), + delay: 0, + type: "info" as const, + live: true, + }, + ]; + }); + }, [step, progress, setMessages]); + + // Update the current step's live message with progress + useEffect(() => { + if (step === "idle" || step === "complete") return; + if (!UI_STEPS.includes(step as (typeof UI_STEPS)[number])) return; + + const msgIndex = stepMsgIndicesRef.current.get(step); + if (msgIndex === undefined) return; + + const text = getStatusText(step, progress); + if (!text) return; + + setMessages((prev) => { + if (msgIndex >= prev.length) return prev; + if (prev[msgIndex].text === text) return prev; + + const updated = [...prev]; + updated[msgIndex] = { ...updated[msgIndex], text }; + return updated; + }); + }, [step, progress, setMessages]); + + // Reset on restart + useEffect(() => { + if (step === "idle") { + lastStepRef.current = "idle"; + stepMsgIndicesRef.current.clear(); + } + }, [step]); + const handleConversion = useCallback(async () => { const file = videoFileRef.current; if (!file) return; - addConversionStartMessage(); - - try { - const formData = new FormData(); - formData.append("video", file); - formData.append("platform", selectedPlatformRef.current); - formData.append("addSilentAudio", addSilentAudioRef.current.toString()); - - const response = await fetch("/api/convert", { - method: "POST", - body: formData, - }); + const loaded = await loadFFmpeg(); + if (!loaded) { + addErrorMessage("Failed to load video processor. Please refresh and try again."); + return; + } - if (!response.ok) { - throw new Error("Conversion failed"); - } + const result = await convertVideo(file, { + platform: selectedPlatformRef.current, + addSilentAudio: addSilentAudioRef.current, + }); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); + if (result) { + const url = URL.createObjectURL(result.blob); setConvertedVideoUrl(url); addConversionCompleteMessage(); @@ -114,10 +194,17 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF setTimeout(() => { onConversionComplete?.(); }, 1500); - } catch (error) { - console.error("Conversion error:", error); + } else if (conversionError) { + addErrorMessage(conversionError); } - }, [addConversionStartMessage, addConversionCompleteMessage, onConversionComplete]); + }, [ + loadFFmpeg, + convertVideo, + conversionError, + addConversionCompleteMessage, + addErrorMessage, + onConversionComplete, + ]); const handlePlatformSelection = useCallback( (action: string) => { @@ -158,12 +245,23 @@ export default function VideoConvertFlow({ onConversionComplete }: VideoConvertF }, [convertedVideoUrl, addSupportMessage]); const handleRestart = useCallback(() => { + // Reset all refs videoFileRef.current = null; selectedPlatformRef.current = "macOS"; addSilentAudioRef.current = true; + lastStepRef.current = "idle"; + stepMsgIndicesRef.current.clear(); + + // Reset state setConvertedVideoUrl(null); setUploadKey((prev) => prev + 1); - }, []); + resetConversion(); + + // Reinitialize messages + initializeMessages(); + show(); + addUploadPrompt(handleFileUpload); + }, [resetConversion, initializeMessages, show, addUploadPrompt, handleFileUpload]); const handleButtonClick = useCallback( (action: string) => { diff --git a/src/hooks/useVideoConversion.ts b/src/hooks/useVideoConversion.ts new file mode 100644 index 0000000..1c7a894 --- /dev/null +++ b/src/hooks/useVideoConversion.ts @@ -0,0 +1,232 @@ +"use client"; + +import { useCallback, useState } from "react"; + +type Platform = "macOS" | "iOS"; + +interface ConversionOptions { + platform: Platform; + addSilentAudio: boolean; +} + +interface ConversionResult { + blob: Blob; + filename: string; +} + +const PLATFORM_RESOLUTIONS = { + macOS: "1920:1080", + iOS: "886:1920", +} as const; + +// CDN URLs for ffmpeg core files (single-threaded - MT has issues in browser) +const CORE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd/ffmpeg-core.js"; +const WASM_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd/ffmpeg-core.wasm"; + +// Singleton state +// biome-ignore lint/suspicious/noExplicitAny: FFmpeg type not exported from @ffmpeg/ffmpeg +let ffmpegInstance: any = null; +let isLoaded = false; +let loadPromise: Promise | null = null; +let ffmpegUtils: { fetchFile: (file: File) => Promise } | null = null; + +type ConversionStep = "idle" | "loading" | "scaling" | "audio" | "finalizing" | "complete"; + +export const useVideoConversion = () => { + const [isLoading, setIsLoading] = useState(false); + const [isConverting, setIsConverting] = useState(false); + const [progress, setProgress] = useState(0); + const [step, setStep] = useState("idle"); + const [error, setError] = useState(null); + + const loadFFmpeg = useCallback(async (): Promise => { + if (isLoaded && ffmpegInstance) { + return true; + } + + if (loadPromise) { + return loadPromise; + } + + setIsLoading(true); + setStep("loading"); + setError(null); + + loadPromise = (async () => { + try { + // Import the packages + const { FFmpeg } = await import("@ffmpeg/ffmpeg"); + const { fetchFile, toBlobURL } = await import("@ffmpeg/util"); + + ffmpegUtils = { fetchFile }; + ffmpegInstance = new FFmpeg(); + + // Logging + ffmpegInstance.on("log", ({ type, message }: { type: string; message: string }) => { + console.log(`[FFmpeg ${type}]`, message); + }); + + // Progress tracking + ffmpegInstance.on("progress", ({ progress: p }: { progress: number }) => { + console.log(`[FFmpeg] Progress: ${Math.round(p * 100)}%`); + setProgress(Math.round(p * 100)); + }); + + console.log("[FFmpeg] Loading core from CDN..."); + // Convert CDN URLs to blob URLs for reliability + const [coreURL, wasmURL] = await Promise.all([ + toBlobURL(CORE_URL, "text/javascript"), + toBlobURL(WASM_URL, "application/wasm"), + ]); + + // Load single-threaded core (MT has issues in browser environment) + await ffmpegInstance.load({ + coreURL, + wasmURL, + }); + console.log("[FFmpeg] Core loaded successfully!"); + + isLoaded = true; + setIsLoading(false); + return true; + } catch (err) { + console.error("[FFmpeg] Load error:", err); + setError(err instanceof Error ? err.message : "Failed to load video processor"); + setIsLoading(false); + loadPromise = null; + return false; + } + })(); + + return loadPromise; + }, []); + + const convertVideo = useCallback( + async (file: File, options: ConversionOptions): Promise => { + if (!ffmpegInstance || !isLoaded || !ffmpegUtils) { + setError("FFmpeg not loaded"); + return null; + } + + setIsConverting(true); + setProgress(0); + setStep("scaling"); + setError(null); + + try { + const { fetchFile } = ffmpegUtils; + + const inputName = "input.mp4"; + const scaledName = "scaled.mp4"; + const outputName = "output.mp4"; + + console.log("[FFmpeg] Writing input file..."); + // Write input file + await ffmpegInstance.writeFile(inputName, await fetchFile(file)); + console.log("[FFmpeg] Input file written, starting conversion..."); + + const resolution = PLATFORM_RESOLUTIONS[options.platform]; + const scaleFilter = `scale=${resolution}:flags=lanczos,setsar=1`; + + if (options.addSilentAudio) { + console.log("[FFmpeg] Step 1: Scaling video..."); + // Step 1: Scale video, remove audio (use ultrafast preset for speed) + await ffmpegInstance.exec([ + "-i", + inputName, + "-vf", + scaleFilter, + "-preset", + "ultrafast", + "-an", + scaledName, + ]); + + console.log("[FFmpeg] Step 2: Adding silent audio..."); + setStep("audio"); + setProgress(0); + // Step 2: Add silent audio + set framerate + await ffmpegInstance.exec([ + "-f", + "lavfi", + "-i", + "anullsrc=channel_layout=stereo:sample_rate=48000", + "-i", + scaledName, + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "128k", + "-shortest", + "-r", + "30", + outputName, + ]); + } else { + console.log("[FFmpeg] Scaling video..."); + await ffmpegInstance.exec([ + "-i", + inputName, + "-vf", + scaleFilter, + "-preset", + "ultrafast", + "-r", + "30", + outputName, + ]); + } + + console.log("[FFmpeg] Reading output file..."); + setStep("finalizing"); + // Read output + const data = await ffmpegInstance.readFile(outputName); + console.log("[FFmpeg] Conversion complete!"); + setStep("complete"); + + // Cleanup + await ffmpegInstance.deleteFile(inputName); + if (options.addSilentAudio) { + await ffmpegInstance.deleteFile(scaledName); + } + await ffmpegInstance.deleteFile(outputName); + + // Create blob + const uint8Data = data instanceof Uint8Array ? data : new Uint8Array(data); + const blob = new Blob([uint8Data.slice()], { type: "video/mp4" }); + + const filename = `${options.platform}_Preview${options.addSilentAudio ? "_with_silent_audio" : ""}.mp4`; + + setIsConverting(false); + setProgress(100); + + return { blob, filename }; + } catch (err) { + console.error("[FFmpeg] Conversion error:", err); + setError(err instanceof Error ? err.message : "Conversion failed"); + setIsConverting(false); + return null; + } + }, + [], + ); + + const reset = useCallback(() => { + setProgress(0); + setStep("idle"); + setError(null); + }, []); + + return { + loadFFmpeg, + convertVideo, + reset, + isLoading, + isConverting, + progress, + step, + error, + }; +}; diff --git a/src/types/terminal.ts b/src/types/terminal.ts index 3094dfd..000a3de 100644 --- a/src/types/terminal.ts +++ b/src/types/terminal.ts @@ -11,4 +11,6 @@ export interface TerminalMessage { type?: "info" | "prompt" | "success" | "error" | "buttons-container" | "button-inline"; buttons?: Button[]; action?: string; + /** If true, renders instantly without typing animation and updates live */ + live?: boolean; } From da61651c380078878cdc175cfb1ba5fb8ec291d5 Mon Sep 17 00:00:00 2001 From: Brenden Bishop Date: Sun, 4 Jan 2026 22:09:25 -0500 Subject: [PATCH 3/3] Update VideoConvertFlow.tsx --- .../video-convert/VideoConvertFlow.tsx | 44 +++++-------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/src/components/video-convert/VideoConvertFlow.tsx b/src/components/video-convert/VideoConvertFlow.tsx index 77f1159..ab90712 100644 --- a/src/components/video-convert/VideoConvertFlow.tsx +++ b/src/components/video-convert/VideoConvertFlow.tsx @@ -12,10 +12,7 @@ import type { TerminalMessage } from "@/types/terminal"; const UI_STEPS = ["loading", "scaling", "finalizing"] as const; // Get the status text based on step and progress -const getStatusText = ( - currentStep: string, - currentProgress: number -): string => { +const getStatusText = (currentStep: string, currentProgress: number): string => { switch (currentStep) { case "loading": return "🍎 Warming up the cider press..."; @@ -32,12 +29,8 @@ interface VideoConvertFlowProps { onConversionComplete?: () => void; } -export default function VideoConvertFlow({ - onConversionComplete, -}: VideoConvertFlowProps) { - const [convertedVideoUrl, setConvertedVideoUrl] = useState( - null - ); +export default function VideoConvertFlow({ onConversionComplete }: VideoConvertFlowProps) { + const [convertedVideoUrl, setConvertedVideoUrl] = useState(null); const [_uploadKey, setUploadKey] = useState(0); const { show, hide } = useUploadButtonState(); @@ -73,9 +66,7 @@ export default function VideoConvertFlow({ const updateMessage = useCallback( (message: string, type: "success" | "error") => { setMessages((currentMessages) => { - const uploadPromptIndex = currentMessages.findIndex( - (msg) => msg.type === "prompt" - ); + const uploadPromptIndex = currentMessages.findIndex((msg) => msg.type === "prompt"); if (uploadPromptIndex === -1) return currentMessages; const baseMessages = currentMessages.slice(0, uploadPromptIndex + 1); @@ -94,7 +85,7 @@ export default function VideoConvertFlow({ show(); } }, - [setMessages, show] + [setMessages, show], ); const handleFileUpload = useCallback( @@ -111,7 +102,7 @@ export default function VideoConvertFlow({ updateMessage(errorMessage, "error"); } }, - [hide, updateMessage, addPlatformPrompt] + [hide, updateMessage, addPlatformPrompt], ); // Initialize messages on mount or when uploadKey changes (restart) @@ -185,9 +176,7 @@ export default function VideoConvertFlow({ const loaded = await loadFFmpeg(); if (!loaded) { - addErrorMessage( - "Failed to load video processor. Please refresh and try again." - ); + addErrorMessage("Failed to load video processor. Please refresh and try again."); return; } @@ -226,7 +215,7 @@ export default function VideoConvertFlow({ addAudioPrompt(); } }, - [addPlatformSuccessMessage, addAudioPrompt] + [addPlatformSuccessMessage, addAudioPrompt], ); const handleAudioSelection = useCallback( @@ -239,7 +228,7 @@ export default function VideoConvertFlow({ await handleConversion(); } }, - [addAudioSuccessMessage, handleConversion] + [addAudioSuccessMessage, handleConversion], ); const handleDownload = useCallback(() => { @@ -274,13 +263,7 @@ export default function VideoConvertFlow({ initializeMessages(); show(); addUploadPrompt(handleFileUpload); - }, [ - resetConversion, - initializeMessages, - show, - addUploadPrompt, - handleFileUpload, - ]); + }, [resetConversion, initializeMessages, show, addUploadPrompt, handleFileUpload]); const handleButtonClick = useCallback( (action: string) => { @@ -292,12 +275,7 @@ export default function VideoConvertFlow({ window.open("https://buymeacoffee.com/brendenbishop", "_blank"); } }, - [ - handlePlatformSelection, - handleAudioSelection, - handleDownload, - handleRestart, - ] + [handlePlatformSelection, handleAudioSelection, handleDownload, handleRestart], ); return (