diff --git a/.env.example b/.env.example index 69d992c..e9d99fd 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ PUB_HOSTNAME=localhost:5173 # only gets used for plausible (for now) PUB_PLAUSIBLE_URL=https://plausible.example.com # can be empty PUB_ENV=development # "production", "development", or "nightly" -PUB_VERTD_URL=https://vertd.vert.sh # default vertd instance \ No newline at end of file +PUB_VERTD_URL=https://vertd.vert.sh # default vertd instance + +# please do not change these. donations help a lot +PUB_DONATION_URL=https://donations.vert.sh +PUB_STRIPE_KEY=pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2 \ No newline at end of file diff --git a/_headers b/_headers deleted file mode 100644 index 2568767..0000000 --- a/_headers +++ /dev/null @@ -1,4 +0,0 @@ -# For libvips/wasm-vips converter (images) -/* - Cross-Origin-Embedder-Policy: require-corp - Cross-Origin-Opener-Policy: same-origin \ No newline at end of file diff --git a/bun.lock b/bun.lock index 98bb611..56ea25f 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@fontsource/lexend": "^5.1.2", "@fontsource/radio-canada-big": "^5.1.1", "@imagemagick/magick-wasm": "^0.0.34", + "@stripe/stripe-js": "^7.4.0", "byte-data": "^19.0.1", "client-zip": "^2.4.6", "clsx": "^2.1.1", @@ -18,10 +19,10 @@ "music-metadata": "^11.0.0", "p-queue": "^8.1.0", "riff-file": "^1.0.3", + "svelte-stripe": "^1.4.0", "vert-wasm": "^0.0.2", "vite-plugin-static-copy": "^2.2.0", "vite-plugin-wasm": "^3.4.1", - "wasm-vips": "^0.0.11", }, "devDependencies": { "@poppanator/sveltekit-svg": "^5.0.0", @@ -235,6 +236,8 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + "@stripe/stripe-js": ["@stripe/stripe-js@7.4.0", "", {}, "sha512-lQHQPfXPTBeh0XFjq6PqSBAyR7umwcJbvJhXV77uGCUDD6ymXJU/f2164ydLMLCCceNuPlbV9b+1smx98efwWQ=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="], @@ -727,6 +730,8 @@ "svelte-eslint-parser": ["svelte-eslint-parser@0.43.0", "", { "dependencies": { "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "postcss": "^8.4.39", "postcss-scss": "^4.0.9" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA=="], + "svelte-stripe": ["svelte-stripe@1.4.0", "", { "peerDependencies": { "@stripe/stripe-js": "^3 || ^4", "svelte": "^3 || ^4 || ^5" } }, "sha512-RUSui4IszIBXhGt3mT3pLJX17OJ34A0O+LAcZLooWVYQCAv95umVXoRB6WmjMabj3jOoJ8c3KHGufaJLRlIzRg=="], + "svgo": ["svgo@3.3.2", "", { "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0" }, "bin": "./bin/svgo" }, "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw=="], "tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], @@ -777,8 +782,6 @@ "vitefu": ["vitefu@1.0.6", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA=="], - "wasm-vips": ["wasm-vips@0.0.11", "", {}, "sha512-bzFU7WcimMY4WeqnZk7whKVpSXxpagISXPJwsk2VHF4lgIN9rl4uUo5sF9x6jOlACuCH6ITZUJ7QPTYmy60NCQ=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], diff --git a/docker-compose.yml b/docker-compose.yml index 13afdea..57bec0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: - PUB_ENV=${PUB_ENV:-production} - PORT=${PORT:-3000} - PUB_VERTD_URL=${PUB_VERTD_URL:-https://vertd.vert.sh} + - PUB_DONATION_URL=${PUB_DONATION_URL:-https://donations.vert.sh} + - PUB_STRIPE_KEY=${PUB_STRIPE_KEY:-pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2} build: context: . args: @@ -15,6 +17,9 @@ services: PUB_PLAUSIBLE_URL: ${PUB_PLAUSIBLE_URL:-https://plausible.example.com} PUB_ENV: ${PUB_ENV:-production} PUB_VERTD_URL: ${PUB_VERTD_URL:-https://vertd.vert.sh} + PUB_DONATION_URL: ${PUB_DONATION_URL:-https://donations.vert.sh} + PUB_STRIPE_KEY: ${PUB_STRIPE_KEY:-pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2} + restart: unless-stopped ports: - ${PORT:-3000}:80 diff --git a/nginx.conf b/nginx.conf index 0a5be24..b8f487b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -12,8 +12,4 @@ server { } error_page 404 /index.html; - - add_header Cross-Origin-Embedder-Policy "require-corp"; - add_header Cross-Origin-Opener-Policy "same-origin"; - add_header Cross-Origin-Resource-Policy "cross-origin"; } \ No newline at end of file diff --git a/package.json b/package.json index 9f410a7..420f119 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@fontsource/lexend": "^5.1.2", "@fontsource/radio-canada-big": "^5.1.1", "@imagemagick/magick-wasm": "^0.0.34", + "@stripe/stripe-js": "^7.4.0", "byte-data": "^19.0.1", "client-zip": "^2.4.6", "clsx": "^2.1.1", @@ -49,9 +50,9 @@ "music-metadata": "^11.0.0", "p-queue": "^8.1.0", "riff-file": "^1.0.3", + "svelte-stripe": "^1.4.0", "vert-wasm": "^0.0.2", "vite-plugin-static-copy": "^2.2.0", - "vite-plugin-wasm": "^3.4.1", - "wasm-vips": "^0.0.11" + "vite-plugin-wasm": "^3.4.1" } } diff --git a/src/lib/components/layout/Gradients.svelte b/src/lib/components/layout/Gradients.svelte index 73ef662..7a7d85b 100644 --- a/src/lib/components/layout/Gradients.svelte +++ b/src/lib/components/layout/Gradients.svelte @@ -64,8 +64,6 @@ const maskImage = $derived( `linear-gradient(to top, transparent ${100 - at.current}%, black 100%)`, ); - - $inspect(colors); {#if page.url.pathname === "/"} diff --git a/src/lib/components/layout/Navbar/Base.svelte b/src/lib/components/layout/Navbar/Base.svelte index 29a9416..42aa298 100644 --- a/src/lib/components/layout/Navbar/Base.svelte +++ b/src/lib/components/layout/Navbar/Base.svelte @@ -63,9 +63,6 @@ let links = $state([]); let container = $state(); let containerRect = $derived(container?.getBoundingClientRect()); - $effect(() => { - $inspect(containerRect); - }); const linkRects = $derived(links.map((l) => l.getBoundingClientRect())); diff --git a/src/lib/converters/index.ts b/src/lib/converters/index.ts index 2165207..83c5fe8 100644 --- a/src/lib/converters/index.ts +++ b/src/lib/converters/index.ts @@ -2,10 +2,10 @@ import type { Categories } from "$lib/types"; import { FFmpegConverter } from "./ffmpeg.svelte"; import { PandocConverter } from "./pandoc.svelte"; import { VertdConverter } from "./vertd.svelte"; -import { VipsConverter } from "./vips.svelte"; +import { MagickConverter } from "./magick.svelte"; export const converters = [ - new VipsConverter(), + new MagickConverter(), new FFmpegConverter(), new VertdConverter(), new PandocConverter(), @@ -37,9 +37,9 @@ categories.video.formats = ?.formatStrings((f) => f.toSupported) || []; categories.image.formats = converters - .find((c) => c.name === "libvips") + .find((c) => c.name === "imagemagick") ?.formatStrings((f) => f.toSupported) || []; categories.docs.formats = converters .find((c) => c.name === "pandoc") - ?.formatStrings((f) => f.toSupported) || []; \ No newline at end of file + ?.formatStrings((f) => f.toSupported) || []; diff --git a/src/lib/converters/vips.svelte.ts b/src/lib/converters/magick.svelte.ts similarity index 91% rename from src/lib/converters/vips.svelte.ts rename to src/lib/converters/magick.svelte.ts index 9de7bb1..bf49918 100644 --- a/src/lib/converters/vips.svelte.ts +++ b/src/lib/converters/magick.svelte.ts @@ -3,17 +3,17 @@ import { error, log } from "$lib/logger"; import { addToast } from "$lib/store/ToastProvider"; import type { OmitBetterStrict, WorkerMessage } from "$lib/types"; import { VertFile } from "$lib/types"; -import VipsWorker from "$lib/workers/vips?worker&url"; +import MagickWorker from "$lib/workers/magick?worker&url"; import { Converter, FormatInfo } from "./converter.svelte"; -export class VipsConverter extends Converter { +export class MagickConverter extends Converter { private worker: Worker = browser - ? new Worker(VipsWorker, { + ? new Worker(MagickWorker, { type: "module", }) : null!; private id = 0; - public name = "libvips"; + public name = "imagemagick"; public ready = $state(false); public supportedFormats = [ @@ -39,7 +39,6 @@ export class VipsConverter extends Converter { new FormatInfo("pgm", true, true), new FormatInfo("pnm", true, true), new FormatInfo("ppm", false, true), - new FormatInfo("raw", false, true), new FormatInfo("tif", true, true), new FormatInfo("tiff", true, true), new FormatInfo("jfif", true, true), @@ -51,7 +50,7 @@ export class VipsConverter extends Converter { super(); log(["converters", this.name], `created converter`); if (!browser) return; - log(["converters", this.name], `loading worker @ ${VipsWorker}`); + log(["converters", this.name], `loading worker @ ${MagickWorker}`); this.worker.onmessage = (e) => { const message: WorkerMessage = e.data; log(["converters", this.name], `received message ${message.type}`); @@ -64,7 +63,7 @@ export class VipsConverter extends Converter { ); addToast( "error", - `Error in VIPS worker, image conversion may not work as expected.`, + `Error in Magick worker, image conversion may not work as expected.`, ); throw new Error(message.error); } diff --git a/src/lib/sections/about/Credits.svelte b/src/lib/sections/about/Credits.svelte index a8dc5cd..d21e0fc 100644 --- a/src/lib/sections/about/Credits.svelte +++ b/src/lib/sections/about/Credits.svelte @@ -133,7 +133,7 @@

Libraries

- A big thanks to FFmpeg (audio, video), libvips (images) and + A big thanks to FFmpeg (audio, video), Imagemagick (images) and Pandoc (documents) for maintaining such excellent libraries for so many years. VERT relies on them to provide you with your conversions. diff --git a/src/lib/sections/about/Donate.svelte b/src/lib/sections/about/Donate.svelte index f27ffdc..9248e99 100644 --- a/src/lib/sections/about/Donate.svelte +++ b/src/lib/sections/about/Donate.svelte @@ -7,12 +7,27 @@ -{#snippet donor(name: string, amount: number | string, avatar: string)} -

- {name} -

${amount}

-
-{/snippet} + onMount(async () => { + stripe = await loadStripe(PUB_STRIPE_KEY); + }); + + const donate = async () => { + if (!stripe || !clientSecret || !elements) return; + + enablePay = false; + + const submitResult = await elements.submit(); + if (submitResult.error) { + addToast( + "error", + `Payment failed: ${submitResult.error.message}. You have not been charged.`, + ); + enablePay = true; + return; + } + + const res = await stripe.confirmPayment({ + elements, + clientSecret, + redirect: "if_required", + confirmParams: { + return_url: page.url.toString(), + }, + }); + + if (res.error) { + addToast( + "error", + `Payment failed: ${res.error.message}. You have not been charged.`, + ); + } else { + addToast("success", "Thank you for your donation!"); + } + + paymentState = "prepay"; + clientSecret = null; + elements = null; + amount = 1; + customAmount = ""; + type = "one-time"; + enablePay = false; + + stripe = await loadStripe(PUB_STRIPE_KEY); + }; + + onMount(() => { + const status = page.url.searchParams.get("redirect_status"); + if (status) { + switch (status) { + case "succeeded": + addToast("success", "Thank you for your donation!"); + break; + default: + addToast( + "error", + "An error occurred while processing your donation. Please try again later.", + ); + } + + goto("/about"); + } + }); +
@@ -84,10 +171,10 @@
- - Pay now -
-
+
+ {#if paymentState !== "prepay"} +
+
+ {#if stripe && clientSecret} + + { + enablePay = e.detail.complete; + }} + /> + + {/if} +
-
-
-

Our top donors

- {#if donors && donors.length > 0} -

- People like these fuel the things we love to do. Thank you - so much! -

- {:else} -

- Seems like no one has donated yet... so if you do, you will - pop up here! -

- {/if} -
- - {#if donors && donors.length > 0} -
- {#each donors as dono} - {@const { name, amount, avatar } = dono} - {@render donor(name, amount || "0.00", avatar)} - {/each} +
+ +
+
+ {:else} + + +
+ + Pay now +
+ {/if}
- {/if} +
- - diff --git a/src/lib/sections/about/Vertd.svelte b/src/lib/sections/about/Sponsors.svelte similarity index 100% rename from src/lib/sections/about/Vertd.svelte rename to src/lib/sections/about/Sponsors.svelte diff --git a/src/lib/sections/about/index.ts b/src/lib/sections/about/index.ts index 91b24ed..53cd148 100644 --- a/src/lib/sections/about/index.ts +++ b/src/lib/sections/about/index.ts @@ -2,4 +2,4 @@ export { default as Credits } from "./Credits.svelte"; export { default as Donate } from "./Donate.svelte"; export { default as Resources } from "./Resources.svelte"; export { default as Why } from "./Why.svelte"; -export { default as Vertd } from "./Vertd.svelte"; +export { default as Sponsors } from "./Sponsors.svelte"; diff --git a/src/lib/workers/magick.ts b/src/lib/workers/magick.ts new file mode 100644 index 0000000..1db298a --- /dev/null +++ b/src/lib/workers/magick.ts @@ -0,0 +1,302 @@ +import { + ImageMagick, + initializeImageMagick, + MagickFormat, + MagickImage, + MagickImageCollection, + MagickReadSettings, + type IMagickImage, +} from "@imagemagick/magick-wasm"; +import { makeZip } from "client-zip"; +import wasm from "@imagemagick/magick-wasm/magick.wasm?url"; +import { parseAni } from "$lib/parse/ani"; +import { parseIcns } from "vert-wasm"; + +const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url)); + +magickPromise + .then(() => { + postMessage({ type: "loaded" }); + }) + .catch((error) => { + postMessage({ type: "error", error }); + }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handleMessage = async (message: any): Promise => { + switch (message.type) { + case "convert": { + if (!message.to.startsWith(".")) message.to = `.${message.to}`; + message.to = message.to.toLowerCase(); + if (message.to === ".jfif") { + message.to = ".jpeg"; + } + + if (message.input.from === ".jfif") { + message.input.from = ".jpeg"; + } + + const buffer = await message.input.file.arrayBuffer(); + // only wait when we need to + await magickPromise; + + // special ico handling to split them all into separate images + if (message.input.from === ".ico") { + const imgs = MagickImageCollection.create(); + + while (true) { + try { + const img = MagickImage.create( + new Uint8Array(buffer), + new MagickReadSettings({ + format: MagickFormat.Ico, + frameIndex: imgs.length, + }), + ); + imgs.push(img); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + break; + } + } + + if (imgs.length === 0) { + return { + type: "error", + error: `Failed to read ICO -- no images found inside?`, + }; + } + + const convertedImgs: Uint8Array[] = []; + await Promise.all( + imgs.map(async (img, i) => { + const output = await magickConvert(img, message.to); + convertedImgs[i] = output; + }), + ); + + const zip = makeZip( + convertedImgs.map( + (img, i) => + new File([img], `image${i}.${message.to.slice(1)}`), + ), + "images.zip", + ); + + // read the ReadableStream to the end + const zipBytes = await readToEnd(zip.getReader()); + + imgs.dispose(); + + return { + type: "finished", + output: zipBytes, + zip: true, + }; + } else if (message.input.from === ".ani") { + console.log("Parsing ANI file"); + try { + const parsedAni = parseAni(new Uint8Array(buffer)); + const files: File[] = []; + await Promise.all( + parsedAni.images.map(async (img, i) => { + const blob = await magickConvert( + MagickImage.create( + img, + new MagickReadSettings({ + format: MagickFormat.Ico, + }), + ), + message.to, + ); + files.push( + new File([blob], `image${i}${message.to}`), + ); + }), + ); + + const zip = makeZip(files, "images.zip"); + const zipBytes = await readToEnd(zip.getReader()); + + return { + type: "finished", + output: zipBytes, + zip: true, + }; + } catch (e) { + console.error(e); + } + } else if (message.input.from === ".icns") { + const icns: Uint8Array[] = parseIcns(new Uint8Array(buffer)); + if (typeof icns === "string") { + return { + type: "error", + error: `Failed to read ICNS -- ${icns}`, + }; + } + + const formats = [ + MagickFormat.Png, + MagickFormat.Jpeg, + MagickFormat.Rgba, + MagickFormat.Rgb, + ]; + const outputs: Uint8Array[] = []; + for (const file of icns) { + for (const format of formats) { + try { + const img = MagickImage.create( + file, + new MagickReadSettings({ + format: format, + }), + ); + const converted = await magickConvert( + img, + message.to, + ); + outputs.push(converted); + break; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + continue; + } + } + } + + const zip = makeZip( + outputs.map( + (img, i) => + new File([img], `image${i}.${message.to.slice(1)}`), + ), + "images.zip", + ); + const zipBytes = await readToEnd(zip.getReader()); + return { + type: "finished", + output: zipBytes, + zip: true, + }; + } + + const img = MagickImage.create( + new Uint8Array(buffer), + new MagickReadSettings({ + format: message.input.from + .slice(1) + .toUpperCase() as MagickFormat, + }), + ); + + const converted = await magickConvert(img, message.to); + + return { + type: "finished", + output: converted, + }; + } + } +}; + +const readToEnd = async (reader: ReadableStreamDefaultReader) => { + const chunks: Uint8Array[] = []; + let done = false; + while (!done) { + const { value, done: d } = await reader.read(); + if (value) chunks.push(value); + done = d; + } + const blob = new Blob(chunks, { type: "application/zip" }); + const arrayBuffer = await blob.arrayBuffer(); + return new Uint8Array(arrayBuffer); +}; + +const magickToBlob = async (img: IMagickImage): Promise => { + const canvas = new OffscreenCanvas(img.width, img.height); + return new Promise((resolve, reject) => + img.getPixels(async (p) => { + // const area = p.getArea(0, 0, img.width, img.height); + // const chunkSize = img.hasAlpha ? 4 : 3; + // const chunks = Math.ceil(area.length / chunkSize); + // const data = new Uint8ClampedArray(chunks * 4); + + // for (let j = 0, k = 0; j < area.length; j += chunkSize, k += 4) { + // data[k] = area[j]; + // data[k + 1] = area[j + 1]; + // data[k + 2] = area[j + 2]; + // data[k + 3] = img.hasAlpha ? area[j + 3] : 255; + // } + + // const ctx = canvas.getContext("2d"); + // if (!ctx) { + // reject(new Error("Failed to get canvas context")); + // return; + // } + + // console.log(img.width, img.height); + + // console.log(data.length, img.width * img.height * 4); + + // ctx.putImageData(new ImageData(data, img.width, img.height), 0, 0); + + // const blob = await canvas.convertToBlob({ + // type: "image/png", + // }); + + const data = p.toByteArray(0, 0, img.width, img.height, "RGBA"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Failed to get canvas context")); + return; + } + + const imageData = new ImageData( + new Uint8ClampedArray(data?.buffer || new ArrayBuffer(0)), + img.width, + img.height, + ); + + ctx.putImageData(imageData, 0, 0); + const blob = await canvas.convertToBlob({ + type: "image/png", + }); + + resolve(blob); + }), + ); +}; + +const magickConvert = async (img: IMagickImage, to: string) => { + const intermediary = await magickToBlob(img); + const buf = new Uint8Array(await intermediary.arrayBuffer()); + let fmt = to.slice(1).toUpperCase(); + if (fmt === "JFIF") fmt = "JPEG"; + + const result = await new Promise((resolve) => { + ImageMagick.read(buf, MagickFormat.Png, (image) => { + image.write(fmt as unknown as MagickFormat, (o) => { + resolve(structuredClone(o)); + }); + }); + }); + + return result; +}; + +onmessage = async (e) => { + const message = e.data; + try { + const res = await handleMessage(message); + if (!res) return; + postMessage({ + ...res, + id: message.id, + }); + } catch (e) { + postMessage({ + type: "error", + error: e, + id: message.id, + }); + } +}; diff --git a/src/lib/workers/vips.ts b/src/lib/workers/vips.ts deleted file mode 100644 index 4e005d3..0000000 --- a/src/lib/workers/vips.ts +++ /dev/null @@ -1,331 +0,0 @@ -import Vips from "wasm-vips"; -import { - initializeImageMagick, - MagickFormat, - MagickImage, - MagickImageCollection, - MagickReadSettings, - type IMagickImage, -} from "@imagemagick/magick-wasm"; -import { makeZip } from "client-zip"; -import wasm from "@imagemagick/magick-wasm/magick.wasm?url"; -import { parseAni } from "$lib/parse/ani"; -import { parseIcns } from "vert-wasm"; - -const vipsPromise = Vips({ - dynamicLibraries: [], -}); - -const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url)); - -const magickRequiredFormats = [ - ".dng", - ".heic", - ".ico", - ".cur", - ".ani", - ".cr2", - ".nef", - ".bmp", -]; -const unsupportedFrom: string[] = []; -const unsupportedTo = [...magickRequiredFormats]; - -vipsPromise - .then(() => { - postMessage({ type: "loaded" }); - }) - .catch((error) => { - postMessage({ type: "error", error }); - }); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const handleMessage = async (message: any): Promise => { - const vips = await vipsPromise; - switch (message.type) { - case "convert": { - if (!message.to.startsWith(".")) message.to = `.${message.to}`; - console.log(message); - if (unsupportedFrom.includes(message.input.from)) { - return { - type: "error", - error: `Unsupported input format ${message.input.from}`, - }; - } - - if (unsupportedTo.includes(message.to)) { - return { - type: "error", - error: `Unsupported output format ${message.to}`, - }; - } - - const buffer = await message.input.file.arrayBuffer(); - if ( - magickRequiredFormats.includes(message.input.from) || - magickRequiredFormats.includes(message.to) - ) { - // only wait when we need to - await magickPromise; - - // special ico handling to split them all into separate images - if (message.input.from === ".ico") { - const imgs = MagickImageCollection.create(); - - while (true) { - try { - const img = MagickImage.create( - new Uint8Array(buffer), - new MagickReadSettings({ - format: MagickFormat.Ico, - frameIndex: imgs.length, - }), - ); - imgs.push(img); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_) { - break; - } - } - - if (imgs.length === 0) { - return { - type: "error", - error: `Failed to read ICO -- no images found inside?`, - }; - } - - const convertedImgs: Uint8Array[] = []; - await Promise.all( - imgs.map(async (img, i) => { - const output = await magickConvert(img, message.to); - convertedImgs[i] = output; - }), - ); - - const zip = makeZip( - convertedImgs.map( - (img, i) => - new File( - [img], - `image${i}.${message.to.slice(1)}`, - ), - ), - "images.zip", - ); - - // read the ReadableStream to the end - const zipBytes = await readToEnd(zip.getReader()); - - imgs.dispose(); - - return { - type: "finished", - output: zipBytes, - zip: true, - }; - } else if (message.input.from === ".ani") { - console.log("Parsing ANI file"); - try { - const parsedAni = parseAni(new Uint8Array(buffer)); - const files: File[] = []; - await Promise.all( - parsedAni.images.map(async (img, i) => { - const blob = await magickConvert( - MagickImage.create( - img, - new MagickReadSettings({ - format: MagickFormat.Ico, - }), - ), - message.to, - ); - files.push( - new File([blob], `image${i}${message.to}`), - ); - }), - ); - - const zip = makeZip(files, "images.zip"); - const zipBytes = await readToEnd(zip.getReader()); - - return { - type: "finished", - output: zipBytes, - zip: true, - }; - } catch (e) { - console.error(e); - } - } - - const img = MagickImage.create( - new Uint8Array(buffer), - new MagickReadSettings({ - format: message.input.from - .slice(1) - .toUpperCase() as MagickFormat, - }), - ); - - const converted = await magickConvert(img, message.to); - - return { - type: "finished", - output: converted, - }; - } - - if (message.input.from === ".icns") { - const icns: Uint8Array[] = parseIcns(new Uint8Array(buffer)); - // Result in vert-wasm maps to a string in JS - if (typeof icns === "string") { - return { - type: "error", - error: `Failed to read ICNS -- ${icns}`, - }; - } - const formats = [ - MagickFormat.Png, - MagickFormat.Jpeg, - MagickFormat.Rgba, - MagickFormat.Rgb, - ]; - const outputs: Uint8Array[] = []; - for (const file of icns) { - for (const format of formats) { - try { - const img = MagickImage.create( - file, - new MagickReadSettings({ - format: format, - }), - ); - const converted = await magickConvert( - img, - message.to, - ); - outputs.push(converted); - break; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_) { - continue; - } - } - } - - const zip = makeZip( - outputs.map( - (img, i) => - new File([img], `image${i}.${message.to.slice(1)}`), - ), - "images.zip", - ); - const zipBytes = await readToEnd(zip.getReader()); - return { - type: "finished", - output: zipBytes, - zip: true, - }; - } - - let image = vips.Image.newFromBuffer(buffer); - - // check if animated image & keep it animated when converting - if (image.getTypeof("n-pages") > 0) { - image = vips.Image.newFromBuffer(buffer, "[n=-1]"); - } - - const opts: { [key: string]: string } = {}; - if (typeof message.compression !== "undefined") { - opts["Q"] = Math.min(100, message.compression + 1).toString(); - } - - const output = image.writeToBuffer(message.to, opts); - image.delete(); - return { - type: "finished", - output: output.buffer, - }; - } - } -}; - -const readToEnd = async (reader: ReadableStreamDefaultReader) => { - const chunks: Uint8Array[] = []; - let done = false; - while (!done) { - const { value, done: d } = await reader.read(); - if (value) chunks.push(value); - done = d; - } - const blob = new Blob(chunks, { type: "application/zip" }); - const arrayBuffer = await blob.arrayBuffer(); - return new Uint8Array(arrayBuffer); -}; - -const magickToBlob = async (img: IMagickImage): Promise => { - const canvas = new OffscreenCanvas(img.width, img.height); - return new Promise((resolve, reject) => - img.getPixels(async (p) => { - const area = p.getArea(0, 0, img.width, img.height); - const chunkSize = img.hasAlpha ? 4 : 3; - const chunks = Math.ceil(area.length / chunkSize); - const data = new Uint8ClampedArray(chunks * 4); - - for (let j = 0, k = 0; j < area.length; j += chunkSize, k += 4) { - data[k] = area[j]; - data[k + 1] = area[j + 1]; - data[k + 2] = area[j + 2]; - data[k + 3] = img.hasAlpha ? area[j + 3] : 255; - } - - const ctx = canvas.getContext("2d"); - if (!ctx) { - reject(new Error("Failed to get canvas context")); - return; - } - - ctx.putImageData(new ImageData(data, img.width, img.height), 0, 0); - - const blob = await canvas.convertToBlob({ - type: "image/png", - }); - - resolve(blob); - }), - ); -}; - -const magickConvert = async (img: IMagickImage, to: string) => { - const vips = await vipsPromise; - - const intermediary = await magickToBlob(img); - const buf = await intermediary.arrayBuffer(); - - const imgVips = vips.Image.newFromBuffer(buf); - const output = imgVips.writeToBuffer(to); - - imgVips.delete(); - img.dispose(); - - return output; -}; - -onmessage = async (e) => { - const message = e.data; - try { - const res = await handleMessage(message); - if (!res) return; - postMessage({ - ...res, - id: message.id, - }); - } catch (e) { - postMessage({ - type: "error", - error: e, - id: message.id, - }); - } -}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4d37589..dcdda5b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,7 +2,11 @@ import { onMount } from "svelte"; import { goto, beforeNavigate, afterNavigate } from "$app/navigation"; - import { PUB_PLAUSIBLE_URL, PUB_HOSTNAME } from "$env/static/public"; + import { + PUB_PLAUSIBLE_URL, + PUB_HOSTNAME, + PUB_DONATION_URL, + } from "$env/static/public"; import { VERT_NAME } from "$lib/consts"; import * as Layout from "$lib/components/layout"; import * as Navbar from "$lib/components/layout/Navbar"; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f9a72dd..cce6399 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -23,8 +23,10 @@ }; } = $derived({ Images: { - ready: converters.find((c) => c.name === "libvips")?.ready || false, - formats: getSupportedFormats("libvips"), + ready: + converters.find((c) => c.name === "imagemagick")?.ready || + false, + formats: getSupportedFormats("imagemagick"), icon: Image, }, Audio: { @@ -133,7 +135,7 @@ Status: {s.ready ? "ready" : "not ready"}

-

+

Supported formats:  {#each s.formats.split(", ") as format, index} @@ -161,7 +163,7 @@ {/each} -

+
{/each} diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 752f2da..ff2916c 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -10,6 +10,8 @@ import avatarAzurejelly from "$lib/assets/avatars/azurejelly.jpg"; import { GITHUB_API_URL } from "$lib/consts"; import { addToast } from "$lib/store/ToastProvider"; + import { dev } from "$app/environment"; + import { page } from "$app/state"; // import { dev } from "$app/environment"; // import { page } from "$app/state"; @@ -61,7 +63,7 @@ github: "https://github.com/RealmyTheMan", role: "Former co-founder & designer", avatar: avatarRealmy, - } + }, ]; let ghContribs: Contributor[] = []; @@ -127,8 +129,7 @@ } }); - // const donationsEnabled = dev || page.url.origin.endsWith("//vert.sh"); - const donationsEnabled = false; + const donationsEnabled = dev || page.url.origin.endsWith("//vert.sh");
@@ -142,22 +143,17 @@ >
- - - {#if !donationsEnabled} - + {#if donationsEnabled} + {/if} + +
- {#if donationsEnabled} - - {/if}
diff --git a/src/routes/convert/+page.svelte b/src/routes/convert/+page.svelte index 9b21bab..f9426ea 100644 --- a/src/routes/convert/+page.svelte +++ b/src/routes/convert/+page.svelte @@ -47,14 +47,6 @@ const handleSelect = (option: string, file: VertFile) => { file.result = null; - switch (option) { - case ".webp": - case ".gif": - addToast( - "warning", - `Converting this file to "${option}" may take some time if animated.`, - ); - } }; $effect(() => { @@ -119,7 +111,7 @@ ?.formatStrings((f) => f.fromSupported) .includes(file.from)} {@const isImage = converters - .find((c) => c.name === "libvips") + .find((c) => c.name === "imagemagick") ?.formatStrings((f) => f.fromSupported) .includes(file.from)} {@const isDocument = converters diff --git a/src/routes/jpegify/+page.svelte b/src/routes/jpegify/+page.svelte index 739abdd..a1d566b 100644 --- a/src/routes/jpegify/+page.svelte +++ b/src/routes/jpegify/+page.svelte @@ -8,7 +8,7 @@ const images = $derived( files.files.filter((f) => - f.converters.map((c) => c.name).includes("libvips"), + f.converters.map((c) => c.name).includes("imagemagick"), ), ); diff --git a/vite.config.ts b/vite.config.ts index 0537844..324e660 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,19 +7,6 @@ import wasm from "vite-plugin-wasm"; export default defineConfig(({ command }) => { const plugins: PluginOption[] = [ sveltekit(), - { - name: "vips-request-middleware", - configureServer(server) { - server.middlewares.use((_req, res, next) => { - res.setHeader( - "Cross-Origin-Embedder-Policy", - "require-corp", - ); - res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); - next(); - }); - }, - }, svg({ includePaths: ["./src/lib/assets"], svgoOptions: { @@ -39,10 +26,6 @@ export default defineConfig(({ command }) => { src: "_headers", dest: "", }, - { - src: "node_modules/wasm-vips/lib/vips-*.wasm", - dest: "_app/immutable/workers", - }, ], }), ]; @@ -58,12 +41,7 @@ export default defineConfig(({ command }) => { format: "es", }, optimizeDeps: { - exclude: [ - "wasm-vips", - "@ffmpeg/core-mt", - "@ffmpeg/ffmpeg", - "@ffmpeg/util", - ], + exclude: ["@ffmpeg/core-mt", "@ffmpeg/ffmpeg", "@ffmpeg/util"], }, css: { preprocessorOptions: {