diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index 085d0dfb9..97eb8adab 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -104,29 +104,42 @@ async function legacyUploadCap( setUploadStatus: (state: UploadStatus | undefined) => void, queryClient: QueryClient, ) { - const parser = await import("@remotion/media-parser"); - const webcodecs = await import("@remotion/webcodecs"); + const { Input } = await import("mediabunny"); try { setUploadStatus({ status: "parsing" }); - const metadata = await parser.parseMedia({ - src: file, - fields: { - durationInSeconds: true, - dimensions: true, - fps: true, - numberOfAudioChannels: true, - sampleRate: true, - }, + const { BlobSource, ALL_FORMATS } = await import("mediabunny"); + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, }); - const duration = metadata.durationInSeconds + // Get metadata from the input + const videoTracks = await input.getVideoTracks(); + const audioTracks = await input.getAudioTracks(); + const videoTrack = videoTracks[0]; + const audioTrack = audioTracks[0]; + const fileDuration = await input.computeDuration(); + + const metadata = { + durationInSeconds: fileDuration, + dimensions: videoTrack + ? { width: videoTrack.displayWidth, height: videoTrack.displayHeight } + : undefined, + fps: videoTrack + ? (await videoTrack.computePacketStats()).averagePacketRate + : undefined, + numberOfAudioChannels: audioTrack?.numberOfChannels, + sampleRate: audioTrack?.sampleRate, + }; + + const videoDuration = metadata.durationInSeconds ? Math.round(metadata.durationInSeconds) : undefined; setUploadStatus({ status: "creating" }); const videoData = await createVideoAndGetUploadUrl({ - duration, + duration: videoDuration, resolution: metadata.dimensions ? `${metadata.dimensions.width}x${metadata.dimensions.height}` : undefined, @@ -165,24 +178,59 @@ async function legacyUploadCap( const resizeOptions = calculateResizeOptions(); - const convertResult = await webcodecs.convertMedia({ - src: file, - container: "mp4", - videoCodec: "h264", - audioCodec: "aac", - ...(resizeOptions && { resize: resizeOptions }), - onProgress: ({ overallProgress }) => { - if (overallProgress !== null) { - const progressValue = overallProgress * 100; - setUploadStatus({ - status: "converting", - capId: uploadId, - progress: progressValue, - }); - } + const { + Output, + Mp4OutputFormat, + BufferTarget, + Conversion, + BlobSource, + ALL_FORMATS, + } = await import("mediabunny"); + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, + }); + const output = new Output({ + format: new Mp4OutputFormat(), + target: new BufferTarget(), + }); + + const conversion = await Conversion.init({ + input, + output, + video: { + codec: "avc", + ...(resizeOptions && { + width: Math.round(metadata.dimensions!.width * resizeOptions.scale), + height: Math.round( + metadata.dimensions!.height * resizeOptions.scale, + ), + }), + }, + audio: { + codec: "aac", }, }); - optimizedBlob = await convertResult.save(); + + if (!conversion.isValid) { + throw new Error("Video conversion configuration is invalid"); + } + + conversion.onProgress = (progress) => { + const progressValue = progress * 100; + setUploadStatus({ + status: "converting", + capId: uploadId, + progress: progressValue, + }); + }; + + await conversion.execute(); + const buffer = output.target.buffer; + if (!buffer) { + throw new Error("Conversion produced no output buffer"); + } + optimizedBlob = new Blob([buffer]); if (optimizedBlob.size === 0) throw new Error("Conversion produced empty file"); diff --git a/apps/web/app/(org)/verify-otp/form.tsx b/apps/web/app/(org)/verify-otp/form.tsx index 4c7ff058a..b56812d54 100644 --- a/apps/web/app/(org)/verify-otp/form.tsx +++ b/apps/web/app/(org)/verify-otp/form.tsx @@ -75,12 +75,24 @@ export function VerifyOTPForm({ const otpCode = code.join(""); if (otpCode.length !== 6) throw "Please enter a complete 6-digit code"; - // shoutout https://github.com/buoyad/Tally/pull/14 - const res = await fetch( - `/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent("/login-success")}`, - ); + const callback = next || "/dashboard"; + const url = `/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent(callback)}`; - if (!res.url.includes("/login-success")) { + const isSafari = + typeof navigator !== "undefined" && + /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + + if (isSafari) { + window.location.assign(url); + return; + } + + const res = await fetch(url, { + credentials: "include", + redirect: "follow", + }); + + if (!res.url.includes(callback)) { setCode(["", "", "", "", "", ""]); inputRefs.current[0]?.focus(); throw "Invalid code. Please try again."; diff --git a/apps/web/app/(site)/tools/convert/[conversionPath]/page.tsx b/apps/web/app/(site)/tools/convert/[conversionPath]/page.tsx index a6c209a16..374429dac 100644 --- a/apps/web/app/(site)/tools/convert/[conversionPath]/page.tsx +++ b/apps/web/app/(site)/tools/convert/[conversionPath]/page.tsx @@ -97,7 +97,7 @@ export default async function ConversionPage(props: ConversionPageProps) { faqs: [ { question: `How does the ${sourceFormat.toUpperCase()} to ${targetFormat.toUpperCase()} converter work?`, - answer: `Our converter uses Remotion (remotion.dev) directly in your browser. When you upload a ${sourceFormat.toUpperCase()} file, it gets processed locally on your device and converted to ${targetFormat.toUpperCase()} format without ever being sent to a server.`, + answer: `Our converter uses Mediabunny (mediabunny.dev) directly in your browser. When you upload a ${sourceFormat.toUpperCase()} file, it gets processed locally on your device and converted to ${targetFormat.toUpperCase()} format without ever being sent to a server.`, }, { question: "Is there a file size limit?", diff --git a/apps/web/app/(site)/tools/convert/avi-to-mp4/page.tsx b/apps/web/app/(site)/tools/convert/avi-to-mp4/page.tsx index 67b6cf0e2..aff65ea3f 100644 --- a/apps/web/app/(site)/tools/convert/avi-to-mp4/page.tsx +++ b/apps/web/app/(site)/tools/convert/avi-to-mp4/page.tsx @@ -57,7 +57,7 @@ export default function AVIToMP4Page() { { question: "How does the AVI to MP4 converter work?", answer: - "Our converter uses Remotion (remotion.dev) directly in your browser. When you upload an AVI file, it gets processed locally on your device and converted to MP4 format without ever being sent to a server.", + "Our converter uses Mediabunny (mediabunny.dev) directly in your browser. When you upload an AVI file, it gets processed locally on your device and converted to MP4 format without ever being sent to a server.", }, { question: "Is there a file size limit?", diff --git a/apps/web/app/(site)/tools/convert/mkv-to-mp4/page.tsx b/apps/web/app/(site)/tools/convert/mkv-to-mp4/page.tsx index 16968cd5d..e71574a12 100644 --- a/apps/web/app/(site)/tools/convert/mkv-to-mp4/page.tsx +++ b/apps/web/app/(site)/tools/convert/mkv-to-mp4/page.tsx @@ -57,7 +57,7 @@ export default function MKVToMP4Page() { { question: "How does the MKV to MP4 converter work?", answer: - "Our converter uses Remotion (remotion.dev) directly in your browser. When you upload an MKV file, it gets processed locally on your device and converted to MP4 format without ever being sent to a server.", + "Our converter uses Mediabunny (mediabunny.dev) directly in your browser. When you upload an MKV file, it gets processed locally on your device and converted to MP4 format without ever being sent to a server.", }, { question: "Is there a file size limit?", diff --git a/apps/web/app/(site)/tools/convert/mov-to-mp4/page.tsx b/apps/web/app/(site)/tools/convert/mov-to-mp4/page.tsx index 7c48a3bb1..dc4450392 100644 --- a/apps/web/app/(site)/tools/convert/mov-to-mp4/page.tsx +++ b/apps/web/app/(site)/tools/convert/mov-to-mp4/page.tsx @@ -57,7 +57,7 @@ export default function MOVToMP4Page() { { question: "How does the MOV to MP4 converter work?", answer: - "Our converter uses Remotion (remotion.dev) directly in your browser. When you upload a MOV file, it gets processed locally on your device and converted to MP4 format without ever being sent to a server.", + "Our converter uses Mediabunny (mediabunny.dev) directly in your browser. When you upload a MOV file, it gets processed locally on your device and converted to MP4 format without ever being sent to a server.", }, { question: "Is there a file size limit?", diff --git a/apps/web/app/(site)/tools/convert/mp4-to-gif/page.tsx b/apps/web/app/(site)/tools/convert/mp4-to-gif/page.tsx index 13319ed36..5ff8cb122 100644 --- a/apps/web/app/(site)/tools/convert/mp4-to-gif/page.tsx +++ b/apps/web/app/(site)/tools/convert/mp4-to-gif/page.tsx @@ -51,14 +51,14 @@ export default function MP4ToGIFPage() { { title: "High Quality Conversion", description: - "We use Remotion technology to create optimized GIFs from your videos.", + "We use Mediabunny technology to create optimized GIFs from your videos.", }, ], faqs: [ { question: "How does the MP4 to GIF converter work?", answer: - "Our converter uses Remotion (remotion.dev) directly in your browser. When you upload an MP4 file, it gets processed locally on your device and converted to an animated GIF without ever being sent to a server.", + "Our converter uses Mediabunny (mediabunny.dev) directly in your browser. When you upload an MP4 file, it gets processed locally on your device and converted to an animated GIF without ever being sent to a server.", }, { question: "Is there a file size limit?", diff --git a/apps/web/app/(site)/tools/convert/mp4-to-mp3/page.tsx b/apps/web/app/(site)/tools/convert/mp4-to-mp3/page.tsx index 23024ef4f..1ee971af5 100644 --- a/apps/web/app/(site)/tools/convert/mp4-to-mp3/page.tsx +++ b/apps/web/app/(site)/tools/convert/mp4-to-mp3/page.tsx @@ -50,14 +50,14 @@ export default function MP4ToMP3Page() { { title: "High Quality Conversion", description: - "We use Remotion technology to ensure high-quality audio extraction.", + "We use Mediabunny technology to ensure high-quality audio extraction.", }, ], faqs: [ { question: "How does the MP4 to MP3 converter work?", answer: - "Our converter uses Remotion (remotion.dev) directly in your browser. When you upload an MP4 file, it extracts the audio track locally on your device and saves it as an MP3 file without ever being sent to a server.", + "Our converter uses Mediabunny (mediabunny.dev) directly in your browser. When you upload an MP4 file, it extracts the audio track locally on your device and saves it as an MP3 file without ever being sent to a server.", }, { question: "Is there a file size limit?", diff --git a/apps/web/app/(site)/tools/convert/mp4-to-webm/page.tsx b/apps/web/app/(site)/tools/convert/mp4-to-webm/page.tsx index 780fc7f07..c17d28643 100644 --- a/apps/web/app/(site)/tools/convert/mp4-to-webm/page.tsx +++ b/apps/web/app/(site)/tools/convert/mp4-to-webm/page.tsx @@ -57,7 +57,7 @@ export default function MP4ToWebMPage() { { question: "How does the MP4 to WebM converter work?", answer: - "Our converter uses Remotion (remotion.dev) directly in your browser. When you upload an MP4 file, it gets processed locally on your device and converted to WebM format without ever being sent to a server.", + "Our converter uses Mediabunny (mediabunny.dev) directly in your browser. When you upload an MP4 file, it gets processed locally on your device and converted to WebM format without ever being sent to a server.", }, { question: "Is there a file size limit?", diff --git a/apps/web/app/(site)/tools/convert/webm-to-mp4/page.tsx b/apps/web/app/(site)/tools/convert/webm-to-mp4/page.tsx index 4018982fc..8999dbff5 100644 --- a/apps/web/app/(site)/tools/convert/webm-to-mp4/page.tsx +++ b/apps/web/app/(site)/tools/convert/webm-to-mp4/page.tsx @@ -57,7 +57,7 @@ export default function WebmToMp4Page() { { question: "How does the WebM to MP4 converter work?", answer: - "Our converter uses Remotion (remotion.dev) directly in your browser. When you upload a WebM file, it gets processed locally on your device and converted to MP4 format without ever being sent to a server.", + "Our converter uses Mediabunny (mediabunny.dev) directly in your browser. When you upload a WebM file, it gets processed locally on your device and converted to MP4 format without ever being sent to a server.", }, { question: "Is there a file size limit?", diff --git a/apps/web/app/(site)/tools/video-speed-controller/page.tsx b/apps/web/app/(site)/tools/video-speed-controller/page.tsx index e0db851bf..2ff8d756d 100644 --- a/apps/web/app/(site)/tools/video-speed-controller/page.tsx +++ b/apps/web/app/(site)/tools/video-speed-controller/page.tsx @@ -7,7 +7,7 @@ const content = { "Instantly speed up or slow down any MP4, WebM or MOV in your browser. No uploads, no quality loss.", featuresTitle: "Why Use Our Online Video Speed Controller?", featuresDescription: - "Powered by WebCodecs + Remotion, Cap processes every frame locally for near-instant results—while keeping your files 100% private.", + "Powered by WebCodecs + Mediabunny, Cap processes every frame locally for near-instant results—while keeping your files 100% private.", features: [ { title: "WebCodecs-Level Speed", @@ -69,7 +69,7 @@ export const metadata = { title: "Video Speed Controller Online – Speed Up or Slow Down Videos (0.25×-3×)", description: - "Free WebCodecs-powered tool to change video speed online. Adjust playback from 0.25× to 3× without quality loss—processed locally for privacy.", + "Free Mediabunny-powered tool to change video speed online. Adjust playback from 0.25× to 3× without quality loss—processed locally for privacy.", keywords: [ "video speed controller", "speed up video online", diff --git a/apps/web/app/api/notifications/route.ts b/apps/web/app/api/notifications/route.ts index 0ea8748be..083a8236c 100644 --- a/apps/web/app/api/notifications/route.ts +++ b/apps/web/app/api/notifications/route.ts @@ -5,7 +5,7 @@ import { Notification as APINotification } from "@cap/web-api-contract"; import { and, ColumnBaseConfig, desc, eq, isNull, sql } from "drizzle-orm"; import { MySqlColumn } from "drizzle-orm/mysql-core"; import { NextResponse } from "next/server"; -import { AvcProfileInfo } from "node_modules/@remotion/media-parser/dist/containers/avc/parse-avc"; + import { z } from "zod"; import type { NotificationType } from "@/lib/Notification"; import { jsonExtractString } from "@/utils/sql"; diff --git a/apps/web/app/s/[videoId]/_components/OtpForm.tsx b/apps/web/app/s/[videoId]/_components/OtpForm.tsx index 56d3855c3..657460406 100644 --- a/apps/web/app/s/[videoId]/_components/OtpForm.tsx +++ b/apps/web/app/s/[videoId]/_components/OtpForm.tsx @@ -75,11 +75,24 @@ const OtpForm = ({ const otpCode = code.join(""); if (otpCode.length !== 6) throw "Please enter a complete 6-digit code"; - const res = await fetch( - `/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent("/login-success")}`, - ); + const callback = "/dashboard"; + const url = `/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent(callback)}`; - if (!res.url.includes("/login-success")) { + const isSafari = + typeof navigator !== "undefined" && + /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + + if (isSafari) { + window.location.assign(url); + return; + } + + const res = await fetch(url, { + credentials: "include", + redirect: "follow", + }); + + if (!res.url.includes(callback)) { setCode(["", "", "", "", "", ""]); inputRefs.current[0]?.focus(); throw "Invalid code. Please try again."; diff --git a/apps/web/components/tools/MediaFormatConverter.tsx b/apps/web/components/tools/MediaFormatConverter.tsx index 4e25de860..2689be4bc 100644 --- a/apps/web/components/tools/MediaFormatConverter.tsx +++ b/apps/web/components/tools/MediaFormatConverter.tsx @@ -1,8 +1,15 @@ "use client"; import { Button } from "@cap/ui"; -import * as MediaParser from "@remotion/media-parser"; -import type { WebCodecsController } from "@remotion/webcodecs"; +import { + Input, + Output, + Conversion, + BufferTarget, + Mp4OutputFormat, + WebMOutputFormat, + WavOutputFormat, +} from "mediabunny"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; @@ -179,7 +186,6 @@ export const MediaFormatConverter = ({ const fileInputRef = useRef(null); const recordedChunksRef = useRef([]); - const parserControllerRef = useRef<{ abort: () => void } | null>(null); useEffect(() => { if ( @@ -226,12 +232,12 @@ export const MediaFormatConverter = ({ }, []); useEffect(() => { - const loadRemotionModules = async () => { + const loadMediabunnyModules = async () => { try { - const parser = await import("@remotion/media-parser"); + await import("mediabunny"); setMediaEngineLoaded(true); } catch (error) { - console.error("Failed to load Remotion modules:", error); + console.error("Failed to load mediabunny modules:", error); setMediaEngineLoaded(false); setError( "Failed to load media conversion engine. Please try again later.", @@ -239,7 +245,7 @@ export const MediaFormatConverter = ({ } }; - loadRemotionModules(); + loadMediabunnyModules(); }, []); const handleFileChange = (e: React.ChangeEvent) => { @@ -327,11 +333,6 @@ export const MediaFormatConverter = ({ setError(null); setProgress(0); - if (parserControllerRef.current) { - parserControllerRef.current.abort(); - } - parserControllerRef.current = { abort: () => {} }; - trackEvent(`${conversionPath}_conversion_started`, { fileSize: file.size, fileName: file.name, @@ -354,58 +355,67 @@ export const MediaFormatConverter = ({ } catch (err: any) { console.error("Detailed conversion error:", err); - if (MediaParser.hasBeenAborted && MediaParser.hasBeenAborted(err)) { - setError("Conversion was cancelled"); + let errorMessage = "Conversion failed: "; + if (err.message) { + errorMessage += err.message; + } else if (typeof err === "string") { + errorMessage += err; } else { - let errorMessage = "Conversion failed: "; - if (err.message) { - errorMessage += err.message; - } else if (typeof err === "string") { - errorMessage += err; - } else { - errorMessage += "Unknown error occurred during conversion"; - } - - setError(errorMessage); - - trackEvent(`${conversionPath}_conversion_failed`, { - fileSize: file.size, - fileName: file.name, - error: err.message || "Unknown error", - }); + errorMessage += "Unknown error occurred"; } + setError(errorMessage); + + trackEvent(`${conversionPath}_conversion_failed`, { + fileSize: file.size, + fileName: file.name, + error: err.message || "Unknown error", + }); } finally { setIsConverting(false); - parserControllerRef.current = null; } }; const extractAudioFromVideo = async (inputFile: File): Promise => { try { - const parser = await import("@remotion/media-parser"); - const webcodecs = await import("@remotion/webcodecs"); - - const handleProgress = (progressEvent: { progress: number }) => { - setProgress(Math.min(Math.round(progressEvent.progress * 100), 99)); - }; + const { + Input, + Output, + Conversion, + WavOutputFormat, + BufferTarget, + BlobSource, + ALL_FORMATS, + } = await import("mediabunny"); + + const input = new Input({ + source: new BlobSource(inputFile), + formats: ALL_FORMATS, + }); + const output = new Output({ + format: new WavOutputFormat(), + target: new BufferTarget(), + }); - const controller = parser.mediaParserController - ? parser.mediaParserController() - : null; - parserControllerRef.current = controller; - - const result = await webcodecs.convertMedia({ - src: inputFile, - container: "wav", - onProgress: ({ overallProgress }) => { - if (overallProgress !== null) { - setProgress(Math.min(Math.round(overallProgress * 100), 99)); - } - }, - controller: controller as unknown as WebCodecsController, + const conversion = await Conversion.init({ + input, + output, + video: { discard: true }, // Only extract audio }); - const blob = await result.save(); + if (!conversion.isValid) { + throw new Error("Audio extraction configuration is invalid"); + } + + conversion.onProgress = (progress: number) => { + setProgress(Math.min(Math.round(progress * 100), 99)); + }; + + await conversion.execute(); + const buffer = output.target.buffer; + if (!buffer) { + throw new Error("Audio extraction produced no output buffer"); + } + const blob = new Blob([buffer]); const url = URL.createObjectURL(blob); setOutputUrl(url); setProgress(100); @@ -423,23 +433,7 @@ export const MediaFormatConverter = ({ const convertVideoToGif = async (inputFile: File): Promise => { try { - const parser = await import("@remotion/media-parser"); - const webcodecs = await import("@remotion/webcodecs"); - - const onProgress = ({ - overallProgress, - }: { - overallProgress: number | null; - }) => { - if (overallProgress !== null) { - setProgress(Math.min(Math.round(overallProgress * 100), 99)); - } - }; - - const controller = parser.mediaParserController - ? parser.mediaParserController() - : null; - parserControllerRef.current = controller; + const { Input } = await import("mediabunny"); console.log(`Starting video to GIF conversion`); console.log( @@ -455,15 +449,23 @@ export const MediaFormatConverter = ({ ); } - const metadata = await parser.parseMedia({ - src: inputFile, - fields: { - durationInSeconds: true, - dimensions: true, - videoCodec: true, - }, + const { BlobSource, ALL_FORMATS } = await import("mediabunny"); + const input = new Input({ + source: new BlobSource(inputFile), + formats: ALL_FORMATS, }); + const videoTracks = await input.getVideoTracks(); + const videoTrack = videoTracks[0]; + const duration = await input.computeDuration(); + const metadata = { + durationInSeconds: duration, + dimensions: videoTrack + ? { width: videoTrack.displayWidth, height: videoTrack.displayHeight } + : undefined, + videoCodec: videoTrack?.codec, + }; + console.log("Video metadata for GIF conversion:", metadata); const originalWidth = metadata.dimensions?.width || 1920; @@ -562,47 +564,38 @@ export const MediaFormatConverter = ({ } catch (error) { console.error("Error converting video to GIF:", error); - if (MediaParser.hasBeenAborted && MediaParser.hasBeenAborted(error)) { - setError("Conversion was cancelled"); - } else { - let errorMessage = "GIF conversion failed: "; - - if (error instanceof Error) { - errorMessage += error.message; - } else if (typeof error === "string") { - errorMessage += error; - } else { - errorMessage += "Unknown error occurred during conversion"; - } + let errorMessage = "GIF conversion failed: "; - setError(errorMessage); + if (error instanceof Error) { + errorMessage += error.message; + } else if (typeof error === "string") { + errorMessage += error; + } else { + errorMessage += "Unknown error occurred"; } + setError(errorMessage); + throw error; } }; const convertVideoFormat = async (inputFile: File): Promise => { try { - const parser = await import("@remotion/media-parser"); - const webcodecs = await import("@remotion/webcodecs"); - - const onProgress = ({ - overallProgress, - }: { - overallProgress: number | null; - }) => { - if (overallProgress !== null) { - setProgress(Math.min(Math.round(overallProgress * 100), 99)); - } + const { + Input, + Output, + Conversion, + Mp4OutputFormat, + WebMOutputFormat, + BufferTarget, + } = await import("mediabunny"); + + const onProgress = (progress: number) => { + setProgress(Math.min(Math.round(progress * 100), 99)); }; - const controller = parser.mediaParserController - ? parser.mediaParserController() - : null; - parserControllerRef.current = controller; - - console.log(`Starting conversion with Remotion: ${conversionPath}`); + console.log(`Starting conversion with mediabunny: ${conversionPath}`); console.log( `Input file: ${inputFile.name}, size: ${inputFile.size} bytes`, ); @@ -618,36 +611,52 @@ export const MediaFormatConverter = ({ ); } - const metadata = await parser.parseMedia({ - src: inputFile, - fields: { - durationInSeconds: true, - dimensions: true, - videoCodec: true, - }, + const { BlobSource, ALL_FORMATS } = await import("mediabunny"); + const input = new Input({ + source: new BlobSource(inputFile), + formats: ALL_FORMATS, }); + const videoTracks = await input.getVideoTracks(); + const videoTrack = videoTracks[0]; + const duration = await input.computeDuration(); + const metadata = { + durationInSeconds: duration, + dimensions: videoTrack + ? { width: videoTrack.displayWidth, height: videoTrack.displayHeight } + : undefined, + videoCodec: videoTrack?.codec, + }; + console.log("Video metadata:", metadata); - const outputContainer = currentTargetFormat === "webm" ? "webm" : "mp4"; + const isWebM = currentTargetFormat === "webm"; + const videoCodec = isWebM ? "vp8" : "h264"; - let videoCodec; - if (outputContainer === "webm") { - videoCodec = "vp8"; - } else { - videoCodec = "h264"; - } + const output = new Output({ + format: isWebM ? new WebMOutputFormat() : new Mp4OutputFormat(), + target: new BufferTarget(), + }); - const result = await webcodecs.convertMedia({ - src: inputFile, - container: outputContainer as any, - videoCodec: videoCodec as any, - onProgress, - controller: controller as unknown as WebCodecsController, - expectedDurationInSeconds: metadata.durationInSeconds || undefined, + const conversion = await Conversion.init({ + input, + output, + video: { + codec: videoCodec === "h264" ? "avc" : videoCodec, + }, }); - const blob = await result.save(); + if (!conversion.isValid) { + throw new Error("Video conversion configuration is invalid"); + } + + conversion.onProgress = onProgress; + await conversion.execute(); + const buffer = output.target.buffer; + if (!buffer) { + throw new Error("Video conversion produced no output buffer"); + } + const blob = new Blob([buffer]); const url = URL.createObjectURL(blob); setOutputUrl(url); @@ -663,22 +672,18 @@ export const MediaFormatConverter = ({ } catch (error) { console.error("Error converting video format:", error); - if (MediaParser.hasBeenAborted && MediaParser.hasBeenAborted(error)) { - setError("Conversion was cancelled"); - } else { - let errorMessage = "Conversion failed: "; - - if (error instanceof Error) { - errorMessage += error.message; - } else if (typeof error === "string") { - errorMessage += error; - } else { - errorMessage += "Unknown error occurred during conversion"; - } + let errorMessage = "Conversion failed: "; - setError(errorMessage); + if (error instanceof Error) { + errorMessage += error.message; + } else if (typeof error === "string") { + errorMessage += error; + } else { + errorMessage += "Unknown error occurred"; } + setError(errorMessage); + throw error; } }; @@ -727,11 +732,6 @@ export const MediaFormatConverter = ({ URL.revokeObjectURL(outputUrl); } - if (parserControllerRef.current) { - parserControllerRef.current.abort(); - parserControllerRef.current = null; - } - setFile(null); setOutputUrl(null); setProgress(0); diff --git a/apps/web/package.json b/apps/web/package.json index 14555f865..fe13dc0cc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,8 +57,6 @@ "@radix-ui/react-tooltip": "^1.2.6", "@react-email/components": "^0.1.0", "@react-email/render": "1.1.2", - "@remotion/media-parser": "^4.0.291", - "@remotion/webcodecs": "^4.0.291", "@rive-app/react-canvas": "^4.18.7", "@stripe/stripe-js": "^3.3.0", "@t3-oss/env-nextjs": "^0.12.0", @@ -96,6 +94,7 @@ "lodash": "^4.17.21", "lucide-react": "^0.525.0", "media-chrome": "^4.12.0", + "mediabunny": "^1.23.0", "moment": "^2.30.1", "motion": "^12.18.1", "next": "15.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79a8921fc..37f60dece 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -564,12 +564,6 @@ importers: '@react-email/render': specifier: 1.1.2 version: 1.1.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@remotion/media-parser': - specifier: ^4.0.291 - version: 4.0.298 - '@remotion/webcodecs': - specifier: ^4.0.291 - version: 4.0.298 '@rive-app/react-canvas': specifier: ^4.18.7 version: 4.19.0(react@19.1.1) @@ -681,6 +675,9 @@ importers: media-chrome: specifier: ^4.12.0 version: 4.12.0(react@19.1.1) + mediabunny: + specifier: ^1.23.0 + version: 1.23.0 moment: specifier: ^2.30.1 version: 2.30.1 @@ -692,7 +689,7 @@ importers: version: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-contentlayer2: specifier: ^0.5.3 version: 0.5.8(acorn@8.15.0)(contentlayer2@0.5.8(acorn@8.15.0)(esbuild@0.25.5))(esbuild@0.25.5)(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -994,7 +991,7 @@ importers: version: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-email: specifier: ^4.0.16 version: 4.0.16(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -5256,15 +5253,6 @@ packages: resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} engines: {node: '>=14.0.0'} - '@remotion/licensing@4.0.298': - resolution: {integrity: sha512-s1WWAA4roF0b/fGBqxW3vtuFfN+dlr0haHIS0GcjHJ6F6z7vr2SS4mHf26HR3jMOUwrwKfX2gHHur3Kc/96LEw==} - - '@remotion/media-parser@4.0.298': - resolution: {integrity: sha512-SPPuPkb9ViAU/YntcJeAWRTPWQRaRGxquUlNkqUj3ggjv3reTw9OufQoYf1xuSQUla+JVPhiFvsBqwlmugPPiw==} - - '@remotion/webcodecs@4.0.298': - resolution: {integrity: sha512-eT9yQtEpxnczfEIfbf980qJVTkrN793ItlDD0KcVz30Gl2e0636r2ClSpm220kJyFWS/Zh4FzcgSCOS9/Ptf+w==} - '@rive-app/canvas@2.27.1': resolution: {integrity: sha512-TnRZdwS/y6RvoTwaZJVlG91NP+m97WcXgSY08wY8R8m8iyVQtpvkRN9oNKwlh46YHPAqGTafaqOzxc7H7ijZDw==} @@ -5992,10 +5980,10 @@ packages: react-dom: optional: true - '@storybook/builder-vite@10.0.0-beta.8': - resolution: {integrity: sha512-KlIdcmvjbB9WgrjSml9vmCDKqnLu6s88K9V/BsTklfKr71Nh0LEiltpiIsVabXmk8FcxWsVbvecoui0NcawWtA==} + '@storybook/builder-vite@10.0.0-beta.9': + resolution: {integrity: sha512-R8nSDRWr9eFOF+Bi9+wfJA6jjXGcLCrbgBt2bLebzfXhCNmfHCMaqRG2ZaNdEV4u9KJyakKdzKEotvAkaPONUA==} peerDependencies: - storybook: ^10.0.0-beta.8 + storybook: ^10.0.0-beta.9 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 '@storybook/core@8.6.12': @@ -6006,12 +5994,12 @@ packages: prettier: optional: true - '@storybook/csf-plugin@10.0.0-beta.8': - resolution: {integrity: sha512-nwMQ51ZTI8bPy15iar49frYByW9Fwq+UM6RkF0ExoPaDqV32VPZW46ChhTGVaKH6mHmZR7sNAXdboxulXGTP0Q==} + '@storybook/csf-plugin@10.0.0-beta.9': + resolution: {integrity: sha512-0PfoiNKNJGNaWZBc31sevMOfhzX0VqrrKqE98DCMyEvZLUPnKy4t7iCE5TNFH+rUd5D7MdVzwgAmJ+CdtlmHIQ==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.0.0-beta.8 + storybook: ^10.0.0-beta.9 vite: '*' webpack: '*' peerDependenciesMeta: @@ -6530,6 +6518,12 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dom-mediacapture-transform@0.1.11': + resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==} + + '@types/dom-webcodecs@0.1.13': + resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} + '@types/dom-webcodecs@0.1.14': resolution: {integrity: sha512-ba9aF0qARLLQpLihONIRbj8VvAdUxO+5jIxlscVcDAQTcJmq5qVr781+ino5qbQUJUmO21cLP2eLeXYWzao5Vg==} @@ -10723,6 +10717,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + mediabunny@1.23.0: + resolution: {integrity: sha512-mWdhhdRquePfTgZ+18DLCFWmqwO6oGP/YudmRU8Puh+whMUJ3VSb9KMAHPl2utim66ixKdZw+DbmdqBfBxRp0A==} + memfs@4.17.1: resolution: {integrity: sha512-thuTRd7F4m4dReCIy7vv4eNYnU6XI/tHMLSMMHLiortw/Y0QxqKtinG523U2aerzwYWGi606oBP4oMPy4+edag==} engines: {node: '>= 4.0.0'} @@ -18671,15 +18668,6 @@ snapshots: '@remix-run/router@1.23.0': {} - '@remotion/licensing@4.0.298': {} - - '@remotion/media-parser@4.0.298': {} - - '@remotion/webcodecs@4.0.298': - dependencies: - '@remotion/licensing': 4.0.298 - '@remotion/media-parser': 4.0.298 - '@rive-app/canvas@2.27.1': {} '@rive-app/react-canvas@4.19.0(react@19.1.1)': @@ -19554,9 +19542,9 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - '@storybook/builder-vite@10.0.0-beta.8(esbuild@0.25.4)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4))': + '@storybook/builder-vite@10.0.0-beta.9(esbuild@0.25.4)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4))': dependencies: - '@storybook/csf-plugin': 10.0.0-beta.8(esbuild@0.25.4)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4)) + '@storybook/csf-plugin': 10.0.0-beta.9(esbuild@0.25.4)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4)) storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 vite: 6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1) @@ -19586,7 +19574,7 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.0.0-beta.8(esbuild@0.25.4)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4))': + '@storybook/csf-plugin@10.0.0-beta.9(esbuild@0.25.4)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4))': dependencies: storybook: 8.6.12(prettier@3.5.3) unplugin: 2.3.10 @@ -20151,6 +20139,12 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/dom-mediacapture-transform@0.1.11': + dependencies: + '@types/dom-webcodecs': 0.1.14 + + '@types/dom-webcodecs@0.1.13': {} + '@types/dom-webcodecs@0.1.14': {} '@types/eslint-scope@3.7.7': @@ -25443,6 +25437,11 @@ snapshots: media-typer@1.1.0: {} + mediabunny@1.23.0: + dependencies: + '@types/dom-mediacapture-transform': 0.1.11 + '@types/dom-webcodecs': 0.1.13 + memfs@4.17.1: dependencies: '@jsonjoy.com/json-pack': 1.2.0(tslib@2.8.1) @@ -25946,7 +25945,7 @@ snapshots: p-wait-for: 5.0.2 qs: 6.14.0 - next-auth@4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next-auth@4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@babel/runtime': 7.27.1 '@panva/hkdf': 1.2.1 @@ -28002,7 +28001,7 @@ snapshots: storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(esbuild@0.25.4)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.5.3))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4)): dependencies: - '@storybook/builder-vite': 10.0.0-beta.8(esbuild@0.25.4)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4)) + '@storybook/builder-vite': 10.0.0-beta.9(esbuild@0.25.4)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.4)) '@storybook/types': 9.0.0-alpha.1(storybook@8.6.12(prettier@3.5.3)) magic-string: 0.30.17 solid-js: 1.9.6