import type { ConverterInput, ConverterOutput, } from "../workers/gif-to-mp4-worker.ts"; import WorkerURL from "../workers/gif-to-mp4-worker?worker&url"; import { convertGifToMp4 } from "../converters/gif-to-mp4.ts"; import { createResource, createSignal, For, Show } from "solid-js"; import { effect } from "solid-js/web"; 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; file?: File; filePath: string; selected: boolean; errored?: boolean }[] >([]); const [mp4Videos, setMp4Videos] = createSignal< { url: string; filePath: string; selected: boolean }[] >([]); const [viewMode, setViewMode] = createSignal<"grid" | "list">("grid"); const [showAllInputs, setShowAllInputs] = createSignal(false); const [latestInputGifs, setLatestInputGifs] = createSignal< { url: string; selected: boolean; errored?: boolean }[] >([]); const [conversionProgress, setConversionProgress] = createSignal(0); const [totalGifs, setTotalGifs] = createSignal(0); const [converting, setConverting] = createSignal(false); const [useThreadPool, setUseThreadPool] = createSignal(true); // New state to track checkbox const [dynamicPool] = createResource(() => "document" in globalThis, async (test) => { if (test) { const { availableParallelism, DynamicThreadPool, PoolEvents } = await import("@poolifier/poolifier-web-worker"); // Set up a fixed worker pool const _pool = new DynamicThreadPool( Math.max(1, Math.floor(availableParallelism() / 2)), availableParallelism(), new URL(WorkerURL, location.origin), { errorEventHandler: (e) => { console.error("Worker pool error:", e); }, messageEventHandler: (e) => { console.log({ e }) }, startWorkers: true } ); _pool?.eventTarget?.addEventListener(PoolEvents.ready, () => console.info("Pool is ready") ); _pool.eventTarget?.addEventListener(PoolEvents.busy, () => console.info("Pool is busy") ); return { pool: _pool, availableParallelism, PoolEvents }; } }); const handleSearch = (event: Event) => { 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, filePath, selected: false, errored: false }]); }) .catch(() => { setGifUrls([ ...gifUrls(), { url: input, filePath, selected: false, errored: true }, ]); }); } }; const handleUpload = async (event: Event) => { const files = (event.target as HTMLInputElement).files; if (files) { 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) ); setGifUrls([...gifUrls(), ...newUrls]); } }; const convertAllToMp4 = async () => { // Step 1: Filter out any GIFs that encountered errors const validGifs = gifUrls().filter((gif) => !gif.errored); // Set total GIFs for tracking progress setTotalGifs(validGifs.length); setConversionProgress(0); setConverting(true); const convertedVideos: { url: string; filePath: string; selected: boolean; }[] = []; const pool = dynamicPool(); if (pool && useThreadPool()) { console.log({ UseThreadPools: true, pool, }) // Distribute conversion tasks among worker threads await Promise.all( Array.from(validGifs.entries(), async ([index, gif]) => { const result = await pool.pool.execute({ url: gif.url, file: gif.file, index }); const output = result.message; if (output.status === "success" && output.file) { const videoUrl = URL.createObjectURL(output.file); convertedVideos.push({ url: videoUrl, filePath: gif.filePath?.replace(/\.gif$/, ".mp4") ?? `video${index}.mp4`, selected: false, }); setConversionProgress((prev) => prev + 1); } else { // Handle errors by marking them in the UI const updatedGifs = [...gifUrls()]; updatedGifs[index].errored = true; setGifUrls(updatedGifs); } }) ); } else { // Step 2: Chunk the valid GIFs into groups of 2 const gifChunks = chunkArray(Array.from(validGifs.entries()), 2); // Step 3: Process each chunk sequentially using Promise.all for (const chunk of gifChunks) { const chunkResults = await Promise.allSettled( chunk.map(async ([index, gif]) => { const videoFile = await convertGifToMp4(gif.file ?? gif.url); const videoUrl = URL.createObjectURL(videoFile!); return { url: videoUrl, filePath: gif.filePath?.replace(/\.gif$/, ".mp4") ?? `video${index}.mp4`, selected: false, }; }) ); const videos = chunkResults.map((result, index) => { if (result.status === "fulfilled") { convertedVideos.push(result.value); return result.value; } else { // Handle errors by marking them in the UI const updatedGifs = [...gifUrls()]; updatedGifs[chunk[index][0]].errored = true; setGifUrls(updatedGifs); } }).filter(video => video); // Step 4: Update the progress after each chunk is processed setConversionProgress((prev) => prev + videos.length); } } // Step 5: Set the converted MP4 videos in state and reset progress setMp4Videos(convertedVideos); setConverting(false); }; /** * Downloads MP4 videos either as individual files or as a zip archive. * * @param type - Whether to download all videos or only selected ones ("all" or "selected"). * @param asZip - Whether to download the videos as a zip file. */ const downloadVideos = async (type: "all" | "selected", asZip: boolean) => { // Step 1: Filter videos based on the selection type const videos = type === "all" ? mp4Videos() : mp4Videos().filter((video) => video.selected); // If downloading as zip, we proceed with zipping logic if (asZip) { try { // Step 2: Initialize the ZipWriter to create a zip file in memory const zipFileWriter = new BlobWriter("application/zip"); const zipWriter = new ZipWriter(zipFileWriter); // Step 3: Loop through each video and add it to the zip file for (const video of videos) { try { const response = await fetch(video.url); const blob = await response.blob(); // Add the MP4 file to the zip using its file name const fileName = video.filePath.endsWith(".mp4") ? video.filePath : `${video.filePath}.mp4`; await zipWriter.add(fileName, new BlobReader(blob)); } catch (videoError) { console.error( `Failed to fetch and add video ${video.filePath} to zip:`, videoError ); } } // Step 4: Finalize the zip file const zipBlob = await zipWriter.close(); // Step 5: Trigger a download for the zip file const a = document.createElement("a"); a.href = URL.createObjectURL(zipBlob); a.download = "videos.zip"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } catch (error) { console.error("Failed to create or download zip file:", error); } } else { // Logic for downloading videos individually (covered previously) try { if ("showDirectoryPicker" in window) { try { // Step 3: Request directory access from the user const directoryHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker(); const counter = new Map() // Step 4: Loop through each video and save it in the selected directory for (const video of videos) { try { if (!counter.has(video.filePath)) { counter.set(video.filePath, 0); } const count = counter.get(video.filePath) ?? 0; const countSuffix = count === 0 ? "" : count; // Create a new file handle for each video in the directory const fileName = video.filePath.endsWith(".mp4") ? video.filePath.replace(".mp4", `${countSuffix}.mp4`) : `${video.filePath}${countSuffix}.mp4`; const fileHandle = await directoryHandle.getFileHandle( fileName, { create: true } ); // Create a writable stream for the file const writable = await fileHandle.createWritable(); // Fetch the video data and write it to the file const response = await fetch(video.url); const blob = await response.blob(); await writable.write(blob); await writable.close(); counter.set( video.filePath, (counter.get(video.filePath) ?? 0) + 1 ); } catch (videoError) { console.error( `Failed to save video ${video.filePath}:`, videoError ); } } } catch (error) { console.error("Failed to select a directory or save files:", error); } } else { for (const video of videos) { // Fallback for browsers without showSaveFilePicker const a = document.createElement("a"); a.href = video.url; a.download = video.filePath.endsWith(".mp4") ? video.filePath : `${video.filePath}.mp4`; document.body.appendChild(a); a.click(); document.body.removeChild(a); } } } catch (error) { console.error("Failed to download video:", error); } } }; const toggleGifSelection = (index: number) => { const updatedGifs = [...gifUrls()]; updatedGifs[index].selected = !updatedGifs[index].selected; console.log({ updatedGifs, index, }); setGifUrls(updatedGifs); }; const toggleVideoSelection = (index: number) => { const updatedVideos = [...mp4Videos()]; updatedVideos[index].selected = !updatedVideos[index].selected; setMp4Videos(updatedVideos); }; const deleteSelectedGifs = () => { gifUrls().forEach((gif) => { if (gif.selected) { URL.revokeObjectURL(gif.url); } }); setGifUrls(gifUrls().filter((gif) => !gif.selected)); }; effect(() => { const urls = Array.from(gifUrls()); setLatestInputGifs(urls.slice(-5)); }); return (

GIF-to-MP4 Converter

{/* Checkbox to configure if thread pool should be used */}
setUseThreadPool(!useThreadPool())} class="mr-2" />
{/* Progress bar */}
{conversionProgress()} / {totalGifs()} GIFs converted

GIF Input List (Latest 5)

{latestInputGifs().map((gif, index) => (
toggleGifSelection(index)} > {`GIF

GIF {index + 1}

Failed to load

))}
5}>

All GIF Inputs

{(gif, index) => (
toggleGifSelection(index())} > {`GIF

GIF {index() + 1}

Failed to load

Selected

)}

MP4 Videos

{(video, index) => (
toggleVideoSelection(index())} >

Video {index() + 1}

Click to {video.selected ? "deselect" : "select"}

)}
); }