diff --git a/package.json b/package.json index d8445dd..e2950c1 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,15 @@ "@astrojs/tailwind": "^5.1.2", "@okikio/transferables": "npm:@jsr/okikio__transferables@^1.0.2", "@poolifier/poolifier-web-worker": "npm:@jsr/poolifier__poolifier-web-worker@^0.4.29", + "@std/path": "npm:@jsr/std__path@^1.0.6", "@zip-js/zip-js": "npm:@jsr/zip-js__zip-js@^2.7.52", "astro": "^4.16.6", "comlink": "^4.4.1", "mp4-muxer": "^5.1.3", "solid-js": "^1.9.2", "tailwindcss": "^3.4.14", - "typescript": "^5.6.3" + "typescript": "^5.6.3", + "webm-muxer": "^5.0.2" }, "devDependencies": { "@types/dom-webcodecs": "^0.1.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 728ab96..37dc392 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@poolifier/poolifier-web-worker': specifier: npm:@jsr/poolifier__poolifier-web-worker@^0.4.29 version: '@jsr/poolifier__poolifier-web-worker@0.4.29' + '@std/path': + specifier: npm:@jsr/std__path@^1.0.6 + version: '@jsr/std__path@1.0.6' '@zip-js/zip-js': specifier: npm:@jsr/zip-js__zip-js@^2.7.52 version: '@jsr/zip-js__zip-js@2.7.52' @@ -44,6 +47,9 @@ importers: typescript: specifier: ^5.6.3 version: 5.6.3 + webm-muxer: + specifier: ^5.0.2 + version: 5.0.2 devDependencies: '@types/dom-webcodecs': specifier: ^0.1.13 @@ -507,6 +513,9 @@ packages: '@jsr/poolifier__poolifier-web-worker@0.4.29': resolution: {integrity: sha512-klEYEXNx/jrH1pBjotrs8uQGh/b6yyO1RNL41P5XPLprD0SVWYI07rUTVi6ReVrXP0tYcUNsr8ffmuJMlNd4IQ==, tarball: https://npm.jsr.io/~/11/@jsr/poolifier__poolifier-web-worker/0.4.29.tgz} + '@jsr/std__path@1.0.6': + resolution: {integrity: sha512-A8WmQshHm5W5jN1OO24qH0VHWVv6vvmUzJiODKvvIUW1jNGVf04b86b9XWMoZPwlMEHq1mGgxWN9AuXY0CY2yg==, tarball: https://npm.jsr.io/~/11/@jsr/std__path/1.0.6.tgz} + '@jsr/zip-js__zip-js@2.7.52': resolution: {integrity: sha512-fyqFInHruWXR+0+dePJnaQwEbPCpseCHkom5tQI6/ODeaODbLlrSOAmVmj0ldTy/vkWPQUXB3COd4wGlp+HqCw==, tarball: https://npm.jsr.io/~/11/@jsr/zip-js__zip-js/2.7.52.tgz} @@ -2197,6 +2206,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webm-muxer@5.0.2: + resolution: {integrity: sha512-/1m0ZGOcx8TvEmqY+U7GromXTKtto4pi5ou+ZvaQXEjb+G/v1cln2ZbKK3T4fpWhLBYjKBeYWza/y1Nxccm5pg==} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -2731,6 +2743,8 @@ snapshots: '@jsr/poolifier__poolifier-web-worker@0.4.29': {} + '@jsr/std__path@1.0.6': {} + '@jsr/zip-js__zip-js@2.7.52': {} '@nodelib/fs.scandir@2.1.5': @@ -4743,6 +4757,11 @@ snapshots: web-namespaces@2.0.1: {} + webm-muxer@5.0.2: + dependencies: + '@types/dom-webcodecs': 0.1.13 + '@types/wicg-file-system-access': 2020.9.8 + which-pm-runs@1.1.0: {} which-pm@3.0.0: diff --git a/src/components/Converter.tsx b/src/components/Converter.tsx index 0605059..b83a9f8 100644 --- a/src/components/Converter.tsx +++ b/src/components/Converter.tsx @@ -13,10 +13,11 @@ import "tailwindcss/tailwind.css"; import { BlobReader, BlobWriter, ZipWriter } from "@zip-js/zip-js"; import { chunkArray } from "../utils/utils.ts"; +import { basename } from "@std/path/posix"; export function Converter() { const [gifUrls, setGifUrls] = createSignal< - { url: string; selected: boolean; errored?: boolean }[] + { url: string; file?: File; filePath: string; selected: boolean; errored?: boolean }[] >([]); const [mp4Videos, setMp4Videos] = createSignal< { url: string; filePath: string; selected: boolean }[] @@ -68,17 +69,18 @@ export function Converter() { event.preventDefault(); const input = (event.target as HTMLFormElement)["search"].value.trim(); if (input && !gifUrls().some((gif) => gif.url === input)) { + const filePath = basename(input); fetch(input) .then((response) => { if (!response.ok) { throw new Error("Failed to load GIF"); } - setGifUrls([...gifUrls(), { url: input, selected: false }]); + setGifUrls([...gifUrls(), { url: input, filePath, selected: false, errored: false }]); }) .catch(() => { setGifUrls([ ...gifUrls(), - { url: input, selected: false, errored: true }, + { url: input, filePath, selected: false, errored: true }, ]); }); } @@ -90,10 +92,12 @@ export function Converter() { const urls = Array.from(files).map((file) => { return { url: URL.createObjectURL(file), + file, filePath: file.name, selected: false, }; }); + console.log({urls}) const newUrls = urls.filter( (urlObj) => !gifUrls().some((gif) => gif.url === urlObj.url) ); @@ -130,7 +134,7 @@ export function Converter() { convertedVideos.push({ url: videoUrl, filePath: - output.file?.name?.replace(/\.gif$/, ".mp4") ?? + gif.filePath?.replace(/\.gif$/, ".mp4") ?? `video${index}.mp4`, selected: false, }); @@ -151,12 +155,12 @@ export function Converter() { for (const chunk of gifChunks) { const chunkResults = await Promise.all( chunk.map(async ([index, gif]) => { - const videoFile = await convertGifToMp4(gif.url); + const videoFile = await convertGifToMp4(gif.file ?? gif.url); const videoUrl = URL.createObjectURL(videoFile!); return { url: videoUrl, filePath: - videoFile?.name?.replace(/\.gif$/, ".mp4") ?? + gif.filePath?.replace(/\.gif$/, ".mp4") ?? `video${index}.mp4`, selected: false, }; diff --git a/src/converters/gif-to-mp4.ts b/src/converters/gif-to-mp4.ts index 9b3f3cf..c4206ba 100644 --- a/src/converters/gif-to-mp4.ts +++ b/src/converters/gif-to-mp4.ts @@ -1,105 +1,85 @@ import { Muxer, ArrayBufferTarget } from "mp4-muxer"; -// Function to decode a GIF from a given URL and return a ReadableStream of ImageBitmap frames -export async function decodeGifStream(input: string | File): Promise> { - let data: ReadableStream; +/** + * Decode a GIF from a given URL or File and return a ReadableStream of VideoFrame frames, + * along with the calculated bitrate based on the frame durations, total frame count, and frame rate. + * + * @param input - The input GIF as a URL string or File object. + * @returns A promise that resolves with an object containing the frames stream, estimated bitrate, and frame rate. + */ +export async function decodeGifStream(input: string | File): Promise<{ framesStream: ReadableStream, bitrate: number, frameRate: number }> { + let data: ArrayBuffer; + +// Load the GIF data as an ArrayBuffer if (typeof input === 'string') { const response = await fetch(input); - if (!response.body) { - throw new Error("Failed to get ReadableStream from the response."); + if (!response.ok) { + throw new Error("Failed to fetch GIF data."); } - - data = response.body; + data = await response.arrayBuffer(); } else { - data = input.stream(); + data = await input.arrayBuffer(); } - // Create an ImageDecoder using a ReadableStream from the response + // Create an ImageDecoder using a ReadableStream from the data const imageDecoder = new ImageDecoder({ - data, + data: new Blob([data]).stream(), type: "image/gif", }); - return new ReadableStream({ - async pull(controller) { - let imageIndex = 0; - try { - while (true) { - // Decode the current frame at the given index - const result = await imageDecoder.decode({ frameIndex: imageIndex }); - controller.enqueue(result.image); // Enqueue the decoded image frame to the stream - - const track = imageDecoder.tracks.selectedTrack; - - // If we have decoded all frames, close the loop - if (!track || (imageDecoder.complete && imageIndex + 1 >= track.frameCount)) { - controller.close(); - break; - } - - // Increment the frame index to decode the next frame - imageIndex++; - } - } catch (e) { - if (e instanceof RangeError && imageDecoder.complete) { - // Close the stream when we reach the end - controller.close(); - } else { - // Handle any other errors - controller.error(e); - } - } - }, - }); -} -// Function to encode frames from a ReadableStream to an MP4 file -export async function encodeToMp4Stream(framesStream: ReadableStream, width: number, height: number): Promise { - // Initialize the muxer with video configuration - const muxer = new Muxer({ - target: new ArrayBufferTarget(), - video: { - codec: 'avc', - width: width, - height: height, - }, - fastStart: 'in-memory', - }); + // Arrays to store frames and durations + const frames: VideoFrame[] = []; + let totalDurationUs = 0; // Total duration in microseconds + const defaultDurationUs = 100000; // Default duration per frame in microseconds (0.1 seconds) - // Configure the video encoder - const videoEncoder = new VideoEncoder({ - output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), // Add encoded video chunk to the muxer - error: (e) => console.error(e), // Log any encoding errors - }); + let imageIndex = 0; + while (true) { + // Decode the current frame at the given index + const result = await imageDecoder.decode({ frameIndex: imageIndex }); + frames.push(result.image); // Enqueue the decoded image frame to the stream - videoEncoder.configure({ - codec: 'avc1.42001f', - width: width, - height: height, - bitrate: 1e6, // Set bitrate for the video encoding - }); + // Accumulate total duration + const durationUs = result.image.duration ?? defaultDurationUs; // duration in microseconds + totalDurationUs += durationUs; - const reader = framesStream.getReader(); - try { - while (true) { - // Read frames from the reader until there are no more frames - const { value: frameData, done } = await reader.read(); - if (done) break; - - // Create a new VideoFrame from the ImageBitmap - const frame = new VideoFrame(frameData, { - timestamp: frameData.timestamp, // Use the frame timestamp - }); - videoEncoder.encode(frame, { keyFrame: true }); // Encode the frame, ensuring it is a keyframe - frame.close(); // Close the frame to release resources + // If we have decoded all frames, close the loop + const track = imageDecoder.tracks.selectedTrack; + if (!track || (imageDecoder.complete && imageIndex + 1 >= track.frameCount)) { + break; } - await videoEncoder.flush(); // Ensure all frames are processed - muxer.finalize(); // Finalize the MP4 muxing process + // Increment the frame index to decode the next frame + imageIndex++; + } - return muxer.target.buffer; // Return the final MP4 file as an ArrayBuffer - } catch (e) { throw e; } - finally { reader.releaseLock(); } + const frameCount = frames.length; + let durationSeconds = totalDurationUs / 1e6; // Convert microseconds to seconds + + // Handle cases where totalDurationUs is zero + if (durationSeconds === 0) { + // Set a default duration + durationSeconds = frameCount * (defaultDurationUs / 1e6); + } + + // Calculate bitrate in bits per second + const fileSizeBytes = data.byteLength; + const fileSizeBits = fileSizeBytes * 8; + const bitrate = Math.min(1e6, Math.max(500_000, Math.round(fileSizeBits / durationSeconds))); + + // Calculate frame rate + const frameRate = Math.min(60, Math.max(1, Math.round(frameCount / durationSeconds))); + + const framesStream = new ReadableStream({ + async start(controller) { + for (const frame of frames) { + controller.enqueue(frame); // Enqueue the frame to the stream + } + controller.close(); // Close the stream after enqueuing all frames + }, + }); + + return { framesStream, bitrate, frameRate }; } /** @@ -122,8 +102,10 @@ export async function encodeToMp4Stream(framesStream: ReadableStream */ export async function convertGifToMp4(input: string | File): Promise { try { - // Step 1: Decode GIF frames as a stream - const framesStream = await decodeGifStream(input); + // Step 1: Decode GIF frames as a stream, and get the estimated bitrate and frame rate + const { framesStream, bitrate, frameRate } = await decodeGifStream(input); + console.log(`Estimated Bitrate: ${bitrate} bps`); + console.log(`Frame Rate: ${frameRate} fps`); // Get the first frame to determine video width and height const reader = framesStream.getReader(); @@ -133,8 +115,8 @@ export async function convertGifToMp4(input: string | File): Promise({ @@ -156,7 +138,7 @@ export async function convertGifToMp4(input: string | File): Promise, width: number, height: number, bitrate: number, frameRate: number): Promise { + // Initialize the muxer with video configuration + const muxer = new Muxer({ + target: new ArrayBufferTarget(), + video: { + codec: 'avc', + width: width, + height: height, + frameRate: frameRate, // Frame rate in timescale units (milliseconds) + }, + fastStart: 'in-memory', + }); + + // Configure the video encoder + const videoEncoder = new VideoEncoder({ + output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), // Add encoded video chunk to the muxer + error: (e) => console.error(e), // Log any encoding errors + }); + + videoEncoder.configure({ + codec: 'avc1.42001f', + width: width, + height: height, + bitrate: bitrate, + framerate: frameRate, // Frame rate in timescale units (milliseconds) + }); + + const reader = framesStream.getReader(); + try { + while (true) { + // Read frames from the reader until there are no more frames + const { value: frameData, done } = await reader.read(); + if (done) break; + + videoEncoder.encode(frameData); // Encode the frame, ensuring it is a keyframe + frameData.close(); // Close the frame to release resources + } + + await videoEncoder.flush(); // Ensure all frames are processed + videoEncoder.close(); // Close the encoder after flushing + muxer.finalize(); // Finalize the MP4 muxing process + + return muxer.target.buffer; // Return the final MP4 file as an ArrayBuffer + } catch (e) { + throw e; + } finally { + reader.releaseLock(); + } +} \ No newline at end of file diff --git a/src/converters/gif-to-webm.ts b/src/converters/gif-to-webm.ts new file mode 100644 index 0000000..d1c7047 --- /dev/null +++ b/src/converters/gif-to-webm.ts @@ -0,0 +1,243 @@ +import { Muxer, ArrayBufferTarget } from "webm-muxer"; + +/** + * Decode a GIF from a given URL or File and return a ReadableStream of VideoFrame frames, + * along with the calculated bitrate based on the frame durations, total frame count, and frame rate. + * + * @param input - The input GIF as a URL string or File object. + * @returns A promise that resolves with an object containing the frames stream, estimated bitrate, and frame rate. + */ +export async function decodeGifStream(input: string | File): Promise<{ framesStream: ReadableStream, bitrate: number, frameRate: number }> { + let data: ArrayBuffer; + +// Load the GIF data as an ArrayBuffer + if (typeof input === 'string') { + const response = await fetch(input); + if (!response.ok) { + throw new Error("Failed to fetch GIF data."); + } + data = await response.arrayBuffer(); + } else { + data = await input.arrayBuffer(); + } + + // Parse the GIF to get frame count and total duration + const view = new DataView(data); + let frameCount = 0; + let totalDurationMs = 0; + + for (let i = 0; i < view.byteLength - 9; i++) { + // Check for the Graphics Control Extension block (0x21, 0xF9) + if (view.getUint8(i) === 0x21 && view.getUint8(i + 1) === 0xF9) { + // Frame delay is stored in the next 4 bytes after the block identifier + const delay = view.getUint16(i + 4, true); // Delay time in hundredths of a second + totalDurationMs += delay * 10; // Convert to milliseconds + frameCount++; + } + } + + if (frameCount === 0) { + throw new Error("Failed to extract frame information from the GIF."); + } + + // Calculate bitrate in bits per second + const fileSizeBytes = data.byteLength; + const fileSizeBits = fileSizeBytes * 8; + const durationSeconds = totalDurationMs / 1000; + const bitrate = Math.round(fileSizeBits * 1000 / durationSeconds); + + // Calculate frame rate + const frameRate = Math.round(frameCount * 1000 / durationSeconds); + + // Create an ImageDecoder using a ReadableStream from the data + const imageDecoder = new ImageDecoder({ + data: new Blob([data]).stream(), + type: "image/gif", + }); + + const framesStream = new ReadableStream({ + async pull(controller) { + let imageIndex = 0; + try { + while (true) { + // Decode the current frame at the given index + const result = await imageDecoder.decode({ frameIndex: imageIndex }); + controller.enqueue(result.image); // Enqueue the decoded image frame to the stream + + const track = imageDecoder.tracks.selectedTrack; + + // If we have decoded all frames, close the loop + if (!track || (imageDecoder.complete && imageIndex + 1 >= track.frameCount)) { + controller.close(); + break; + } + + // Increment the frame index to decode the next frame + imageIndex++; + } + } catch (e) { + if (e instanceof RangeError && imageDecoder.complete) { + // Close the stream when we reach the end + controller.close(); + } else { + // Handle any other errors + controller.error(e); + } + } + }, + }); + + return { framesStream, bitrate, frameRate }; +} + +/** + * Converts a GIF (URL or File object) to an MP4 File and returns the File object with the correct file name. + * + * @param input - The input GIF as a URL string or File object. + * @returns A File representing the converted MP4 file with file path info. + * + * @example + * // Example with URL + * const url = "https://example.com/animation.gif"; + * const mp4File = await convertGifToMp4(url); + * console.log(mp4File.name); // "animation.mp4" + * + * @example + * // Example with File object + * const file = new File([gifData], "example.gif", { type: "image/gif" }); + * const mp4File = await convertGifToMp4(file); + * console.log(mp4File.name); // "example.mp4" + */ +export async function convertGifToMp4(input: string | File): Promise { + try { + // Step 1: Decode GIF frames as a stream, and get the estimated bitrate and frame rate + const { framesStream, bitrate, frameRate } = await decodeGifStream(input); + console.log(`Estimated Bitrate: ${bitrate} bps`); + console.log(`Frame Rate: ${frameRate} fps`); + + // Get the first frame to determine video width and height + const reader = framesStream.getReader(); + let { value, done } = await reader.read(); + let firstFrame: VideoFrame | undefined | null = value; + if (done || !firstFrame) { + throw new Error("Failed to decode any frames from the GIF."); + } + + const width = firstFrame.displayWidth % 2 === 0 ? firstFrame.displayWidth : firstFrame.displayWidth + 1; + const height = firstFrame.displayHeight % 2 === 0 ? firstFrame.displayHeight : firstFrame.displayHeight + 1; + + // Step 2: Create a pull-based stream including the first frame + const framesWithFirst = new ReadableStream({ + async pull(controller) { + // Enqueue the first frame first + if (firstFrame) { + controller.enqueue(firstFrame); + firstFrame = null; // Mark first frame as processed + } + + // Pull frames from the original framesStream + const { value, done } = await reader.read(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + }); + + // Step 3: Use the new pull-based frames stream for encoding + const mp4Buffer = await encodeToMp4Stream(framesWithFirst, width, height, bitrate, frameRate); + + // Step 4: Generate the file name with .mp4 extension + const fileName = getFileNameWithMp4(input); + + // Step 5: Create a File object with the MP4 buffer and file name + const mp4File = new File([mp4Buffer], fileName, { type: 'video/mp4' }); + + return mp4File; + } catch (error) { + console.error("Error during conversion:", error); + return undefined; + } +} + +/** + * Helper function to extract the file name and change its extension to .mp4 + * + * @param input - The input URL string or File object + * @returns The file name with .mp4 extension + */ +function getFileNameWithMp4(input: string | File): string { + if (typeof input === 'string') { + // Extract file name from URL and replace the extension with .mp4 + const url = new URL(input); + const fileName = url.pathname.split('/').pop() || 'video'; + return fileName.replace(/\.[^/.]+$/, "") + ".mp4"; + } else if (input instanceof File) { + // Extract file name from File object and replace the extension with .mp4 + return input.name.replace(/\.[^/.]+$/, "") + ".mp4"; + } + return "video.mp4"; // Default fallback +} + +// Example usage: +// const url = "https://example.com/animation.gif"; +// decodeGifStream(url).then(({ framesStream, bitrate, frameRate }) => console.log(`Bitrate: ${bitrate} bps, Frame Rate: ${frameRate} fps`)); + + +/** + * Function to encode frames from a ReadableStream to an MP4 file + * + * @param framesStream - The ReadableStream of frames to encode + * @param width - The width of the video + * @param height - The height of the video + * @param bitrate - The bitrate to use for encoding + * @param frameRate - The frame rate to use for encoding + * @returns A promise that resolves to an ArrayBuffer containing the MP4 file + */ +export async function encodeToMp4Stream(framesStream: ReadableStream, width: number, height: number, bitrate: number, frameRate: number): Promise { + // Initialize the muxer with video configuration + const muxer = new Muxer({ + target: new ArrayBufferTarget(), + video: { + codec: 'V_VP9', + width: width, + height: height, + }, + }); + + // Configure the video encoder + const videoEncoder = new VideoEncoder({ + output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), // Add encoded video chunk to the muxer + error: (e) => console.error(e), // Log any encoding errors + }); + + videoEncoder.configure({ + codec: 'vp09.00.10.08', + width: width, + height: height, + bitrate: 1e6, + }); + + const reader = framesStream.getReader(); + try { + while (true) { + // Read frames from the reader until there are no more frames + const { value: frameData, done } = await reader.read(); + if (done) break; + + videoEncoder.encode(frameData, { keyFrame: true }); // Encode the frame, ensuring it is a keyframe + frameData.close(); // Close the frame to release resources + } + + await videoEncoder.flush(); // Ensure all frames are processed + videoEncoder.close(); // Close the encoder after flushing + muxer.finalize(); // Finalize the MP4 muxing process + + return muxer.target.buffer; // Return the final MP4 file as an ArrayBuffer + } catch (e) { + throw e; + } finally { + reader.releaseLock(); + } +} \ No newline at end of file