import { browser } from "$app/environment"; import { byNative, converters } from "$lib/converters"; import { error, log } from "$lib/util/logger"; import { VertFile } from "$lib/types"; import { parseBlob, selectCover } from "music-metadata"; import { writable } from "svelte/store"; import { addDialog } from "./DialogProvider"; import PQueue from "p-queue"; import { getLocale, setLocale } from "$lib/paraglide/runtime"; import { m } from "$lib/paraglide/messages"; import sanitizeHtml from "sanitize-html"; import { ToastManager } from "$lib/util/toast.svelte"; import { GB } from "$lib/util/consts"; class Files { public files = $state([]); public requiredConverters = $derived( Array.from(new Set(files.files.map((f) => f.converters).flat())), ); public ready = $derived( this.files.length === 0 ? false : this.requiredConverters.every((f) => f?.status === "ready") && this.files.every((f) => !f.processing), ); public results = $derived( this.files.length === 0 ? false : this.files.every((f) => f.result), ); private thumbnailQueue = new PQueue({ concurrency: browser ? navigator.hardwareConcurrency || 4 : 4, }); private _addThumbnail = async (file: VertFile) => { this.thumbnailQueue.add(async () => { const isAudio = converters .find((c) => c.name === "ffmpeg") ?.supportedFormats.filter((f) => f.isNative) .map((f) => f.name) ?.includes(file.from.toLowerCase()); const isVideo = converters .find((c) => c.name === "vertd") ?.supportedFormats.filter((f) => f.isNative) .map((f) => f.name) ?.includes(file.from.toLowerCase()); try { if (isAudio) { // try to get the thumbnail from the audio via music-metadata const { common } = await parseBlob(file.file, { skipPostHeaders: true, }); const cover = selectCover(common.picture); if (cover) { const arrayBuffer = cover.data.buffer instanceof ArrayBuffer ? cover.data.buffer : new Uint8Array(cover.data).buffer; const blob = new Blob([new Uint8Array(arrayBuffer)], { type: cover.format, }); file.blobUrl = URL.createObjectURL(blob); } } else if (isVideo) { // video file.blobUrl = await this._generateThumbnailFromMedia( file.file, true, ); } else { // image file.blobUrl = await this._generateThumbnailFromMedia( file.file, false, ); } } catch (e) { error(["files"], e); } }); }; private async _generateThumbnailFromMedia( file: File, isVideo: boolean, ): Promise { const maxSize = 180; const mediaElement = isVideo ? document.createElement("video") : new Image(); mediaElement.src = URL.createObjectURL(file); await new Promise((resolve, reject) => { if (isVideo) { const video = mediaElement as HTMLVideoElement; // seek to 10% of video time or 2 seconds in video.onloadeddata = () => { const seekTime = Math.min(video.duration * 0.1, 2); video.currentTime = seekTime; }; video.onseeked = resolve; video.onerror = reject; } else { (mediaElement as HTMLImageElement).onload = resolve; (mediaElement as HTMLImageElement).onerror = reject; } }); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return undefined; const width = isVideo ? (mediaElement as HTMLVideoElement).videoWidth : (mediaElement as HTMLImageElement).width; const height = isVideo ? (mediaElement as HTMLVideoElement).videoHeight : (mediaElement as HTMLImageElement).height; const scale = Math.max(maxSize / width, maxSize / height); canvas.width = width * scale; canvas.height = height * scale; ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height); // check if completely transparent const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const isTransparent = Array.from(imageData.data).every( (value, index) => { return (index + 1) % 4 !== 0 || value === 0; }, ); if (isTransparent) { canvas.remove(); return undefined; } const url = canvas.toDataURL(); canvas.remove(); return url; } private async _handleZipFile(file: File): Promise { try { log(["files"], `extracting zip file: ${file.name}`); ToastManager.add({ type: "info", message: m["convert.archive_file.extracting"]({ filename: file.name, }), }); const { extractZip } = await import("$lib/util/zip"); const entries = await extractZip(file); const totalEntries = entries.length; log(["files"], `extracted ${totalEntries} files from zip`); // check if all files in zip use the same converter and are compatible const convertersUsed = new Set(); let incompatibleFiles = false; for (const { filename } of entries) { const format = "." + filename.split(".").pop()?.toLowerCase(); if (!format || format === ".zip") { incompatibleFiles = true; continue; } const converter = converters .sort(byNative(format)) .find((c) => c.formatStrings().includes(format)); if (converter) convertersUsed.add(converter.name); else incompatibleFiles = true; } const converterCount = convertersUsed.size; const canConvertAsOne = converterCount === 1 && !incompatibleFiles; log( ["files"], `extracted ${entries.length} files from zip (converters: ${converterCount}, compatible: ${canConvertAsOne})`, ); if (canConvertAsOne) { // all files use same converter - add zip as a single VertFile file const vf = new VertFile(file, ".zip"); vf.converters = converters.filter( (c) => c.name === Array.from(convertersUsed)[0], ); const converterName = vf.converters[0].name; const type = converterName === "imagemagick" ? "image" : converterName === "ffmpeg" ? "audio" : converterName === "pandoc" ? "doc" : "video"; this.files.push(vf); this._addThumbnail(vf); ToastManager.add({ type: "success", message: m["convert.archive_file.detected"]({ type: m[`convert.archive_file.${type}`](), filename: file.name, }), }); } else { // mixed converters/incompatible files - extract all individually for (const { filename, data } of entries) { this._add( new File([new Uint8Array(data)], filename, { type: "application/octet-stream", }), ); } ToastManager.add({ type: "success", message: m["convert.archive_file.extracted"]({ filename: file.name, extract_count: entries.length, ignore_count: 0, }), }); } } catch (e) { error(["files"], `error processing zip file: ${e}`); throw e; } } private _warningShown = false; private async _add(file: VertFile | File) { if (file instanceof VertFile) { this.files.push(file); this._addThumbnail(file); } else { // if zip, extract and add contents const isZip = file.name.toLowerCase().endsWith(".zip") || file.type === "application/zip" || file.type === "application/x-zip-compressed"; if (isZip) { try { await this._handleZipFile(file); return; } catch (err) { error(["files"], `error extracting zip file: ${err}`); ToastManager.add({ type: "error", message: m["convert.archive_file.extract_error"]({ filename: file.name, error: String(err), }), }); return; } } // regular files const format = "." + file.name.split(".").pop()?.toLowerCase(); if (!format) { log(["files"], `no extension found for ${file.name}`); return; } const converter = converters .sort(byNative(format)) .find((converter) => converter.formatStrings().includes(format), ); if (!converter) { log(["files"], `no converter found for ${file.name}`); this.files.push(new VertFile(file, format)); return; } const to = converter.formatStrings().find((f) => f !== format); if (!to) { log(["files"], `no output format found for ${file.name}`); return; } const vf = new VertFile(file, to); this.files.push(vf); this._addThumbnail(vf); const convName = converter.name; if (file.size > MAX_ARRAY_BUFFER_SIZE && convName === "vertd") { ToastManager.add({ type: "warning", message: m["convert.large_file_warning"]({ limit: (MAX_ARRAY_BUFFER_SIZE / GB).toFixed(2), }), durations: { stay: 10000, }, }); } const isVideo = convName === "vertd"; const acceptedExternalWarning = localStorage.getItem("acceptedExternalWarning") === "true"; if (isVideo && !acceptedExternalWarning && !this._warningShown) { this._warningShown = true; const title = m["convert.external_warning.title"](); const message = m["convert.external_warning.text"](); const buttons = [ { text: m["convert.external_warning.no"](), action: () => { this.files = [ ...this.files.filter( (f) => !f.converters .map((c) => c.name) .includes("vertd"), ), ]; this._warningShown = false; }, }, { text: m["convert.external_warning.yes"](), action: () => { localStorage.setItem( "acceptedExternalWarning", "true", ); this._warningShown = false; }, }, ]; addDialog(title, message, buttons, "warning"); } } } public add(file: VertFile | null | undefined): void; public add(file: File | null | undefined): void; public add(file: File[] | null | undefined): void; public add(file: VertFile[] | null | undefined): void; public add(file: FileList | null | undefined): void; public add( file: | VertFile | File | VertFile[] | File[] | FileList | null | undefined, ) { if (!file) return; if (Array.isArray(file) || file instanceof FileList) { for (const f of file) { this._add(f); } } else { this._add(file); } } public async convertAll() { const promiseFns = this.files.map((f) => () => f.convert()); const coreCount = navigator.hardwareConcurrency || 4; const queue = new PQueue({ concurrency: coreCount }); await Promise.all(promiseFns.map((fn) => queue.add(fn))); } public async downloadAll() { if (this.files.length === 0) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const dlFiles: any[] = []; const fileNames: string[] = []; for (let i = 0; i < this.files.length; i++) { const file = this.files[i]; const result = file.result; if (!result) { error(["files"], "No result found"); continue; } let to = result.to; if (!to.startsWith(".")) to = `.${to}`; fileNames.push(file.file.name.replace(/\.[^/.]+$/, "") + to); } for (let i = 0; i < this.files.length; i++) { const file = this.files[i]; const result = file.result; if (!result) continue; let fileName = fileNames[i]; // check if this filename appears more than once const isDuplicate = fileNames.filter((name) => name === fileName).length > 1; if (isDuplicate) { const nameParts = fileName.lastIndexOf("."); const nameWithoutExt = fileName.substring(0, nameParts); const ext = fileName.substring(nameParts); fileName = `${nameWithoutExt} (${i + 1})${ext}`; } dlFiles.push({ name: fileName, lastModified: Date.now(), input: await result.file.arrayBuffer(), }); } const { downloadZip } = await import("client-zip"); const blob = await downloadZip(dlFiles, "converted.zip").blob(); const url = URL.createObjectURL(blob); const settings = JSON.parse(localStorage.getItem("settings") ?? "{}"); const filenameFormat = settings.filenameFormat || "VERT_%name%"; const format = (name: string) => { const date = new Date().toISOString(); return name .replace(/%date%/g, date) .replace(/%name%/g, "Multi") .replace(/%extension%/g, ""); }; const a = document.createElement("a"); a.href = url; a.download = `${format(filenameFormat)}.zip`; a.click(); URL.revokeObjectURL(url); a.remove(); } } export function setTheme(themeTo: "light" | "dark") { document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.add(themeTo); localStorage.setItem("theme", themeTo); log(["theme"], `set to ${themeTo}`); theme.set(themeTo); // Lock dark reader if it's set to dark mode if (themeTo === "dark") { const lock = document.createElement("meta"); lock.name = "darkreader-lock"; document.head.appendChild(lock); } else { const lock = document.querySelector('meta[name="darkreader-lock"]'); if (lock) lock.remove(); } } export function setEffects(effectsEnabled: boolean) { localStorage.setItem("effects", effectsEnabled.toString()); log(["effects"], `set to ${effectsEnabled}`); effects.set(effectsEnabled); } export const files = new Files(); export const showGradient = writable(true); export const gradientColor = writable(""); export const goingLeft = writable(false); export const dropping = writable(false); export const vertdLoaded = writable(false); export const dropdownStates = writable>({}); export const isMobile = writable(false); export const effects = writable(true); export const theme = writable<"light" | "dark">("light"); export const locale = writable(getLocale()); export const availableLocales = { en: "English", es: "Español", fr: "Français", de: "Deutsch", it: "Italiano", ba: "Bosanski", hr: "Hrvatski", id: "Bahasa Indonesia", tr: "Türkçe", ja: "日本語", ko: "한국어", el: "Ελληνικά", "zh-Hans": "简体中文", "zh-Hant": "繁體中文", "pt-BR": "Português (Brasil)", }; export function updateLocale(newLocale: string) { if (!Object.keys(availableLocales).includes(newLocale)) newLocale = "en"; log(["locale"], `set to ${newLocale}`); localStorage.setItem("locale", newLocale); // @ts-expect-error shush setLocale(newLocale, { reload: false }); // @ts-expect-error shush locale.set(newLocale); } export function link( tag: string | string[], text: string, links: string | string[], newTab?: boolean | boolean[], className?: string | string[], ) { if (!text) return ""; const tags = Array.isArray(tag) ? tag : [tag]; const linksArr = Array.isArray(links) ? links : [links]; const newTabArr = Array.isArray(newTab) ? newTab : [newTab]; const classArr = Array.isArray(className) ? className : [className]; let result = text; tags.forEach((t, i) => { const link = linksArr[i] ?? "#"; const target = newTabArr[i] ? 'target="_blank" rel="noopener noreferrer"' : ""; const cls = classArr[i] ? `class="${classArr[i]}"` : ""; const regex = new RegExp(`\\[${t}\\](.*?)\\[\\/${t}\\]`, "g"); result = result.replace( regex, (_, inner) => `${inner}`, ); }); return result; } export function sanitize( html: string, allowedTags: string[] = ["a", "b", "code", "br"], ): string { return sanitizeHtml(html, { allowedTags: allowedTags, allowedAttributes: { a: ["href", "target", "rel", "class"], "*": ["class"], }, allowedSchemes: ["http", "https", "mailto", "blob"], }); } /** * Binary search for a max value without knowing the exact value, only that it * can be under or over It dose not test every number but instead looks for * 1,2,4,8,16,32,64,128,96,95 to figure out that you thought about #96 from * 0-infinity * * @example findFirstPositive(x => matchMedia(`(max-resolution: ${x}dpi)`).matches) * @author Jimmy Wärting * @see {@link https://stackoverflow.com/a/72124984/1008999} * @param {function} f The function to run the test on (should return truthy or falsy values) * @param {bigint} [b=1] Where to start looking from * @param {function} d privately used to calculate the next value to test * @returns {bigint} Integer */ function findFirstPositive( f: (x: bigint) => number, b = 1n, d = (e: bigint, g: bigint, c?: bigint): bigint => g < e ? -1n : 0 < f((c = (e + g) >> 1n)) ? c == e || 0 >= f(c - 1n) ? c : d(e, c - 1n) : d(c + 1n, g), ): bigint { for (; 0 >= f(b); b <<= 1n); return d(b >> 1n, b) - 1n; } export const getMaxArrayBufferSize = (): number => { if (typeof window === "undefined") return 2 * GB; // default for SSR // check cache first const cached = localStorage.getItem("maxArrayBufferSize"); if (cached) { const parsed = Number(cached); log( ["converters"], `using cached max ArrayBuffer size: ${parsed} bytes`, ); if (!isNaN(parsed) && parsed > 0) return parsed; } // detect max size using binary search const maxSize = findFirstPositive((x) => { try { new ArrayBuffer(Number(x)); return 0; // false = can allocate } catch { return 1; // true = cannot allocate } }); const result = Number(maxSize); localStorage.setItem("maxArrayBufferSize", result.toString()); log(["converters"], `detected max ArrayBuffer size: ${result} bytes`); return result; }; export const MAX_ARRAY_BUFFER_SIZE = getMaxArrayBufferSize();