feat: donations

This commit is contained in:
not-nullptr 2025-06-26 01:33:22 +01:00
parent 3f3c280241
commit 859760aaf7
22 changed files with 519 additions and 478 deletions

View File

@ -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
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

View File

@ -1,4 +0,0 @@
# For libvips/wasm-vips converter (images)
/*
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

View File

@ -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=="],

View File

@ -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

View File

@ -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";
}

View File

@ -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"
}
}

View File

@ -64,8 +64,6 @@
const maskImage = $derived(
`linear-gradient(to top, transparent ${100 - at.current}%, black 100%)`,
);
$inspect(colors);
</script>
{#if page.url.pathname === "/"}

View File

@ -63,9 +63,6 @@
let links = $state<HTMLAnchorElement[]>([]);
let container = $state<HTMLDivElement>();
let containerRect = $derived(container?.getBoundingClientRect());
$effect(() => {
$inspect(containerRect);
});
const linkRects = $derived(links.map((l) => l.getBoundingClientRect()));

View File

@ -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) || [];
?.formatStrings((f) => f.toSupported) || [];

View File

@ -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);
}

View File

@ -133,7 +133,7 @@
<h2 class="mt-2 -mb-2">Libraries</h2>
<p class="font-normal">
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.

View File

@ -7,12 +7,27 @@
</script>
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
PUB_DONATION_URL,
PUB_HOSTNAME,
PUB_STRIPE_KEY,
} from "$env/static/public";
// import { PUB_STRIPE_KEY, PUB_DONATION_API } from "$env/static/public";
import { fade } from "$lib/animation";
import FancyInput from "$lib/components/functional/FancyInput.svelte";
import Panel from "$lib/components/visual/Panel.svelte";
import { effects } from "$lib/store/index.svelte";
import { addToast } from "$lib/store/ToastProvider";
import {
loadStripe,
type Stripe,
type StripeElements,
} from "@stripe/stripe-js";
import clsx from "clsx";
import {
CalendarHeartIcon,
@ -21,29 +36,47 @@
WalletIcon,
} from "lucide-svelte";
import { onMount, tick } from "svelte";
import { Elements, PaymentElement } from "svelte-stripe";
import { quintOut } from "svelte/easing";
type Props = {
donors: Donor[];
};
let { donors }: Props = $props();
let amount = $state(1);
let customAmount = $state("");
let type = $state("one-time");
let stripe = $state<Stripe | null>(null);
const presetAmounts = [1, 10, 25];
let paying = $state(false);
let paymentState = $state<"prepay" | "fetching" | "details">("prepay");
let enablePay = $state(false);
let clientSecret = $state<string | null>(null);
let elements: StripeElements | null = $state(null);
const amountClick = (preset: number) => {
amount = preset;
customAmount = "";
};
const paymentClick = async () => {};
const paymentClick = async () => {
if (paymentState !== "prepay") return;
paymentState = "fetching";
const res = await fetch(`${PUB_DONATION_URL}/billing`, {
method: "POST",
body: (amount * 100).toString(),
});
if (!res.ok) {
paymentState = "prepay";
addToast(
"error",
"Error fetching payment details. Please try again later.",
);
return;
}
const { data }: { data: string } = await res.json();
clientSecret = data;
paymentState = "details";
};
$effect(() => {
if (customAmount) {
@ -53,19 +86,73 @@
const payDuration = 400;
const transition = "cubic-bezier(0.23, 1, 0.320, 1)";
</script>
{#snippet donor(name: string, amount: number | string, avatar: string)}
<div class="flex items-center bg-button rounded-full overflow-hidden">
<img
src={avatar}
alt={name}
title={name}
class="w-9 h-9 rounded-full"
/>
<p class="text-sm text-black dynadark:text-white px-4">${amount}</p>
</div>
{/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");
}
});
</script>
<Panel class="flex flex-col gap-8 p-6">
<div class="flex flex-col gap-3">
@ -84,10 +171,10 @@
<div
class="flex flex-col gap-3 w-full overflow-visible"
style="height: {paying ? 0 : 124}px;
transform: scaleY({paying ? 0 : 1});
opacity: {paying ? 0 : 1};
filter: blur({paying ? 4 : 0}px);
style="height: {paymentState !== 'prepay' ? 0 : 124}px;
transform: scaleY({paymentState !== 'prepay' ? 0 : 1});
opacity: {paymentState !== 'prepay' ? 0 : 1};
filter: blur({paymentState !== 'prepay' ? 4 : 0}px);
transition: height {payDuration}ms {transition},
opacity {payDuration - 200}ms {transition},
transform {payDuration}ms {transition},
@ -162,52 +249,64 @@
class={clsx(
"btn flex-1 p-3 relative rounded-3xl bg-accent-red border-2 border-accent-red h-14 text-black",
{
"h-64 rounded-2xl bg-transparent cursor-auto !scale-100 -mt-10 -mb-2":
paying,
"h-[450px] rounded-2xl bg-transparent cursor-auto !scale-100 -mt-10 -mb-2":
paymentState !== "prepay",
"!scale-100": !$effects,
},
)}
style="transition: height {payDuration}ms {transition}, border-radius {payDuration}ms {transition}, background-color {payDuration}ms {transition}, transform {payDuration}ms {transition}, margin {payDuration}ms {transition};"
style="transition: height {payDuration}ms {transition}, border-radius {payDuration}ms {transition}, background-color {payDuration}ms {transition}, transform {payDuration}ms {transition}, margin {payDuration}ms {transition}; will-change: height, border-radius, background-color, transform, margin;"
>
<WalletIcon size="24" class="inline-block mr-2" />
Pay now
</div>
</div>
<div class="grid grid-cols-1 grid-rows-1 w-full h-full">
{#if paymentState !== "prepay"}
<div
transition:fade={{
duration: payDuration,
easing: quintOut,
}}
class="row-start-1 col-start-1 flex w-full h-full flex-col gap-4"
>
<div
class="flex-grow max-h-full overflow-y-auto overflow-x-hidden"
>
{#if stripe && clientSecret}
<Elements {stripe} {clientSecret} bind:elements>
<PaymentElement
on:change={(e) => {
enablePay = e.detail.complete;
}}
/>
</Elements>
{/if}
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h2 class="text-base font-bold">Our top donors</h2>
{#if donors && donors.length > 0}
<p class="text-base text-muted font-normal">
People like these fuel the things we love to do. Thank you
so much!
</p>
{:else}
<p class="text-base text-muted font-normal italic">
Seems like no one has donated yet... so if you do, you will
pop up here!
</p>
{/if}
</div>
{#if donors && donors.length > 0}
<div class="flex flex-row flex-wrap gap-2">
{#each donors as dono}
{@const { name, amount, avatar } = dono}
{@render donor(name, amount || "0.00", avatar)}
{/each}
<div class="flex-shrink-0">
<button
disabled={!stripe ||
!clientSecret ||
!enablePay}
class="btn w-full h-12 bg-accent-red text-black rounded-full mt-4"
onclick={donate}
>
Donate ${amount} USD
</button>
</div>
</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
transition:fade={{
duration: payDuration,
easing: quintOut,
}}
onclick={paymentClick}
class="row-start-1 col-start-1 flex justify-center items-center"
>
<WalletIcon size="24" class="inline-block mr-2" />
Pay now
</div>
{/if}
</div>
{/if}
</div>
</div>
</Panel>
<style>
:global(
.StripeElement,
.StripeElement *,
iframe[name="__privateStripeFrame39314"]
) {
width: 50px !important;
height: 50px !important;
}
</style>

View File

@ -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";

302
src/lib/workers/magick.ts Normal file
View File

@ -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<any> => {
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<Uint8Array>) => {
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<Blob> => {
const canvas = new OffscreenCanvas(img.width, img.height);
return new Promise<Blob>((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<Uint8Array>((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,
});
}
};

View File

@ -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<any> => {
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<T> 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<Uint8Array>) => {
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<Blob> => {
const canvas = new OffscreenCanvas(img.width, img.height);
return new Promise<Blob>((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,
});
}
};

View File

@ -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";

View File

@ -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 @@
<b>Status: </b>
{s.ready ? "ready" : "not ready"}
</p>
<p>
<div>
<span class="flex flex-wrap justify-center">
<b>Supported formats:&nbsp;</b>
{#each s.formats.split(", ") as format, index}
@ -161,7 +163,7 @@
</span>
{/each}
</span>
</p>
</div>
</div>
</div>
{/each}

View File

@ -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");
</script>
<div class="flex flex-col h-full items-center">
@ -142,22 +143,17 @@
>
<!-- Why VERT? & Credits -->
<div class="flex flex-col gap-4 flex-1">
<!-- {#if donationsEnabled}
<About.Donate donors={[]} />
{/if} -->
<About.Why />
{#if !donationsEnabled}
<About.Vertd />
{#if donationsEnabled}
<About.Donate />
{/if}
<About.Why />
<About.Sponsors />
</div>
<!-- Resources & Donate to VERT -->
<div class="flex flex-col gap-4 flex-1">
<About.Resources />
<About.Credits {mainContribs} {notableContribs} {ghContribs} />
{#if donationsEnabled}
<About.Vertd />
{/if}
</div>
</div>
</div>

View File

@ -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

View File

@ -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"),
),
);

View File

@ -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: {