feat: .icns support

This commit is contained in:
not-nullptr 2025-04-15 15:11:05 +01:00
parent fc6e14109c
commit db7b9406a7
9 changed files with 832 additions and 818 deletions

View File

@ -7,6 +7,7 @@
"@bjorn3/browser_wasi_shim": "^0.4.1", "@bjorn3/browser_wasi_shim": "^0.4.1",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"@fiahfy/icns": "^0.0.7",
"@fontsource/azeret-mono": "^5.1.1", "@fontsource/azeret-mono": "^5.1.1",
"@fontsource/lexend": "^5.1.2", "@fontsource/lexend": "^5.1.2",
"@fontsource/radio-canada-big": "^5.1.1", "@fontsource/radio-canada-big": "^5.1.1",
@ -120,6 +121,10 @@
"@ffmpeg/util": ["@ffmpeg/util@0.12.2", "", {}, "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw=="], "@ffmpeg/util": ["@ffmpeg/util@0.12.2", "", {}, "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw=="],
"@fiahfy/icns": ["@fiahfy/icns@0.0.7", "", { "dependencies": { "@fiahfy/packbits": "^0.0.6", "pngjs": "^6.0.0" } }, "sha512-0apAtbUXTU3Opy/Z4h69o53voBa+am8FmdZauyagUMskAVYN1a5yIRk48Sf+tEdBLlefbvqLWPJ4pxr/Y/QtTg=="],
"@fiahfy/packbits": ["@fiahfy/packbits@0.0.6", "", {}, "sha512-XuhF/edg+iIvXjkCWgfj6fWtRi/KrEPg2ILXj1l86EN4EssuOiPcLKgkMDr9cL8jTGtVd/MKUWW6Y0/ZVf1PGA=="],
"@fontsource/azeret-mono": ["@fontsource/azeret-mono@5.2.5", "", {}, "sha512-GRzKYuD1CVOS6Jag/ohDCycLV9a3TK6y1T73A8q0JoDZTVO85DNapqLK+SV2gYtTFldahNAlDSIaizv9MLhR1A=="], "@fontsource/azeret-mono": ["@fontsource/azeret-mono@5.2.5", "", {}, "sha512-GRzKYuD1CVOS6Jag/ohDCycLV9a3TK6y1T73A8q0JoDZTVO85DNapqLK+SV2gYtTFldahNAlDSIaizv9MLhR1A=="],
"@fontsource/lexend": ["@fontsource/lexend@5.2.5", "", {}, "sha512-Mv2XQ+B4ek2lNCGRW5ddLTW8T3xTT17AnCk1IETpoef57XHz+e42fUfLAYMrmiJLOGpR44qnyJ5S6D323A5EIw=="], "@fontsource/lexend": ["@fontsource/lexend@5.2.5", "", {}, "sha512-Mv2XQ+B4ek2lNCGRW5ddLTW8T3xTT17AnCk1IETpoef57XHz+e42fUfLAYMrmiJLOGpR44qnyJ5S6D323A5EIw=="],
@ -608,6 +613,8 @@
"pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="], "pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="],
"pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],

View File

@ -37,6 +37,7 @@
"@bjorn3/browser_wasi_shim": "^0.4.1", "@bjorn3/browser_wasi_shim": "^0.4.1",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"@fiahfy/icns": "^0.0.7",
"@fontsource/azeret-mono": "^5.1.1", "@fontsource/azeret-mono": "^5.1.1",
"@fontsource/lexend": "^5.1.2", "@fontsource/lexend": "^5.1.2",
"@fontsource/radio-canada-big": "^5.1.1", "@fontsource/radio-canada-big": "^5.1.1",

View File

@ -1,47 +1,47 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png"> <link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png">
<link rel="apple-touch-startup-image" href="%sveltekit.assets%/lettermark.jpg"> <link rel="apple-touch-startup-image" href="%sveltekit.assets%/lettermark.jpg">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
%sveltekit.head% %sveltekit.head%
<script> <script>
(function () { (function () {
// Apply theme before DOM is loaded // Apply theme before DOM is loaded
let theme = localStorage.getItem("theme"); let theme = localStorage.getItem("theme");
const prefersDark = window.matchMedia( const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)", "(prefers-color-scheme: dark)",
).matches; ).matches;
console.log( console.log(
`Theme: ${theme || "N/A"}, prefers dark: ${prefersDark}`, `Theme: ${theme || "N/A"}, prefers dark: ${prefersDark}`,
); );
if (theme !== "light" && theme !== "dark") { if (theme !== "light" && theme !== "dark") {
console.log("Invalid theme, setting to default"); console.log("Invalid theme, setting to default");
theme = prefersDark ? "dark" : "light"; theme = prefersDark ? "dark" : "light";
localStorage.setItem("theme", theme); localStorage.setItem("theme", theme);
} }
console.log(`Applying theme: ${theme}`); console.log(`Applying theme: ${theme}`);
document.documentElement.classList.add(theme); document.documentElement.classList.add(theme);
// Lock dark reader if it's set to dark mode // Lock dark reader if it's set to dark mode
if (theme === "dark") { if (theme === "dark") {
const lock = document.createElement('meta'); const lock = document.createElement('meta');
lock.name = 'darkreader-lock'; lock.name = 'darkreader-lock';
document.head.appendChild(lock); document.head.appendChild(lock);
} }
})(); })();
</script> </script>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View File

@ -1,197 +1,197 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { page } from "$app/state"; import { page } from "$app/state";
import { duration, fade } from "$lib/animation"; import { duration, fade } from "$lib/animation";
import { import {
effects, effects,
files, files,
goingLeft, goingLeft,
setTheme, setTheme,
} from "$lib/store/index.svelte"; } from "$lib/store/index.svelte";
import clsx from "clsx"; import clsx from "clsx";
import { import {
InfoIcon, InfoIcon,
MoonIcon, MoonIcon,
RefreshCw, RefreshCw,
SettingsIcon, SettingsIcon,
SunIcon, SunIcon,
UploadIcon, UploadIcon,
} from "lucide-svelte"; } from "lucide-svelte";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
import Panel from "../../visual/Panel.svelte"; import Panel from "../../visual/Panel.svelte";
import Logo from "../../visual/svg/Logo.svelte"; import Logo from "../../visual/svg/Logo.svelte";
import { beforeNavigate } from "$app/navigation"; import { beforeNavigate } from "$app/navigation";
const items = $derived< const items = $derived<
{ {
name: string; name: string;
url: string; url: string;
activeMatch: (pathname: string) => boolean; activeMatch: (pathname: string) => boolean;
icon: any; icon: any;
badge?: number; badge?: number;
}[] }[]
>([ >([
{ {
name: "Upload", name: "Upload",
url: "/", url: "/",
activeMatch: (pathname) => pathname === "/", activeMatch: (pathname) => pathname === "/",
icon: UploadIcon, icon: UploadIcon,
}, },
{ {
name: "Convert", name: "Convert",
url: "/convert/", url: "/convert/",
activeMatch: (pathname) => activeMatch: (pathname) =>
pathname === "/convert/" || pathname === "/convert", pathname === "/convert/" || pathname === "/convert",
icon: RefreshCw, icon: RefreshCw,
badge: files.files.length, badge: files.files.length,
}, },
{ {
name: "Settings", name: "Settings",
url: "/settings/", url: "/settings/",
activeMatch: (pathname) => pathname.startsWith("/settings"), activeMatch: (pathname) => pathname.startsWith("/settings"),
icon: SettingsIcon, icon: SettingsIcon,
}, },
{ {
name: "About", name: "About",
url: "/about/", url: "/about/",
activeMatch: (pathname) => pathname.startsWith("/about"), activeMatch: (pathname) => pathname.startsWith("/about"),
icon: InfoIcon, icon: InfoIcon,
}, },
]); ]);
let links = $state<HTMLAnchorElement[]>([]); let links = $state<HTMLAnchorElement[]>([]);
let container = $state<HTMLDivElement>(); let container = $state<HTMLDivElement>();
let containerRect = $derived(container?.getBoundingClientRect()); let containerRect = $derived(container?.getBoundingClientRect());
$effect(() => { $effect(() => {
$inspect(containerRect); $inspect(containerRect);
}); });
const linkRects = $derived(links.map((l) => l.getBoundingClientRect())); const linkRects = $derived(links.map((l) => l.getBoundingClientRect()));
const selectedIndex = $derived( const selectedIndex = $derived(
items.findIndex((i) => i.activeMatch(page.url.pathname)), items.findIndex((i) => i.activeMatch(page.url.pathname)),
); );
const isSecretPage = $derived(selectedIndex === -1); const isSecretPage = $derived(selectedIndex === -1);
beforeNavigate((e) => { beforeNavigate((e) => {
const oldIndex = items.findIndex((i) => const oldIndex = items.findIndex((i) =>
i.activeMatch(e.from?.url.pathname || ""), i.activeMatch(e.from?.url.pathname || ""),
); );
const newIndex = items.findIndex((i) => const newIndex = items.findIndex((i) =>
i.activeMatch(e.to?.url.pathname || ""), i.activeMatch(e.to?.url.pathname || ""),
); );
if (newIndex < oldIndex) { if (newIndex < oldIndex) {
goingLeft.set(true); goingLeft.set(true);
} else { } else {
goingLeft.set(false); goingLeft.set(false);
} }
}); });
</script> </script>
{#snippet link(item: (typeof items)[0], index: number)} {#snippet link(item: (typeof items)[0], index: number)}
{@const Icon = item.icon} {@const Icon = item.icon}
<a <a
bind:this={links[index]} bind:this={links[index]}
href={item.url} href={item.url}
aria-label={item.name} aria-label={item.name}
class={clsx( class={clsx(
"min-w-16 md:min-w-32 h-full relative z-10 rounded-xl flex flex-1 items-center justify-center gap-3 overflow-hidden", "min-w-16 md:min-w-32 h-full relative z-10 rounded-xl flex flex-1 items-center justify-center gap-3 overflow-hidden",
{ {
"bg-panel-highlight": "bg-panel-highlight":
item.activeMatch(page.url.pathname) && !browser, item.activeMatch(page.url.pathname) && !browser,
}, },
)} )}
draggable={false} draggable={false}
> >
<div class="grid grid-rows-1 grid-cols-1"> <div class="grid grid-rows-1 grid-cols-1">
{#key item.name} {#key item.name}
<div <div
class="w-full row-start-1 col-start-1 h-full flex items-center justify-center gap-3" class="w-full row-start-1 col-start-1 h-full flex items-center justify-center gap-3"
in:fade={{ in:fade={{
duration, duration,
easing: quintOut, easing: quintOut,
}} }}
out:fade={{ out:fade={{
duration, duration,
easing: quintOut, easing: quintOut,
}} }}
> >
<div class="relative"> <div class="relative">
<Icon /> <Icon />
{#if item.badge} {#if item.badge}
<div <div
class="absolute overflow-hidden grid grid-rows-1 grid-cols-1 -top-1 font-display -right-1 w-fit px-1.5 h-4 rounded-full bg-badge text-on-badge font-medium" class="absolute overflow-hidden grid grid-rows-1 grid-cols-1 -top-1 font-display -right-1 w-fit px-1.5 h-4 rounded-full bg-badge text-on-badge font-medium"
style="font-size: 0.7rem;" style="font-size: 0.7rem;"
transition:fade={{ transition:fade={{
duration, duration,
easing: quintOut, easing: quintOut,
}} }}
> >
{#key item.badge} {#key item.badge}
<div <div
class="flex items-center justify-center w-full h-full col-start-1 row-start-1" class="flex items-center justify-center w-full h-full col-start-1 row-start-1"
in:fade={{ in:fade={{
duration, duration,
easing: quintOut, easing: quintOut,
}} }}
out:fade={{ out:fade={{
duration, duration,
easing: quintOut, easing: quintOut,
}} }}
> >
{item.badge} {item.badge}
</div> </div>
{/key} {/key}
</div> </div>
{/if} {/if}
</div> </div>
<p class="font-medium hidden md:flex"> <p class="font-medium hidden md:flex">
{item.name} {item.name}
</p> </p>
</div> </div>
{/key} {/key}
</div> </div>
</a> </a>
{/snippet} {/snippet}
<div bind:this={container}> <div bind:this={container}>
<Panel class="max-w-[778px] w-screen h-20 flex items-center gap-3 relative"> <Panel class="max-w-[778px] w-screen h-20 flex items-center gap-3 relative">
{@const linkRect = linkRects.at(selectedIndex) || linkRects[0]} {@const linkRect = linkRects.at(selectedIndex) || linkRects[0]}
{#if linkRect} {#if linkRect}
<div <div
class="absolute bg-panel-highlight rounded-xl" class="absolute bg-panel-highlight rounded-xl"
style="width: {linkRect.width}px; height: {linkRect.height}px; top: {linkRect.top - style="width: {linkRect.width}px; height: {linkRect.height}px; top: {linkRect.top -
(containerRect?.top || 0)}px; left: {linkRect.left - (containerRect?.top || 0)}px; left: {linkRect.left -
(containerRect?.left || 0)}px; opacity: {isSecretPage (containerRect?.left || 0)}px; opacity: {isSecretPage
? 0 ? 0
: 1}; {$effects : 1}; {$effects
? `transition: left var(--transition) ${duration}ms, top var(--transition) ${duration}ms, opacity var(--transition) ${duration}ms;` ? `transition: left var(--transition) ${duration}ms, top var(--transition) ${duration}ms, opacity var(--transition) ${duration}ms;`
: ''}" : ''}"
></div> ></div>
{/if} {/if}
<a <a
class="w-28 h-full bg-accent rounded-xl items-center justify-center hidden md:flex" class="w-28 h-full bg-accent rounded-xl items-center justify-center hidden md:flex"
href="/" href="/"
> >
<div class="h-5 w-full"> <div class="h-5 w-full">
<Logo /> <Logo />
</div> </div>
</a> </a>
{#each items as item, i (item.url)} {#each items as item, i (item.url)}
{@render link(item, i)} {@render link(item, i)}
{/each} {/each}
<div class="w-0.5 bg-separator h-full hidden md:flex"></div> <div class="w-0.5 bg-separator h-full hidden md:flex"></div>
<button <button
onclick={() => { onclick={() => {
const isDark = const isDark =
document.documentElement.classList.contains("dark"); document.documentElement.classList.contains("dark");
setTheme(isDark ? "light" : "dark"); setTheme(isDark ? "light" : "dark");
}} }}
class="w-14 h-full items-center justify-center hidden md:flex" class="w-14 h-full items-center justify-center hidden md:flex"
> >
<SunIcon class="dynadark:hidden block" /> <SunIcon class="dynadark:hidden block" />
<MoonIcon class="dynadark:block hidden" /> <MoonIcon class="dynadark:block hidden" />
</button> </button>
</Panel> </Panel>
</div> </div>

View File

@ -1,4 +1,4 @@
import type { Converter, FormatInfo } from "./converter.svelte"; import type { Converter } from "./converter.svelte";
import { FFmpegConverter } from "./ffmpeg.svelte"; import { FFmpegConverter } from "./ffmpeg.svelte";
import { PandocConverter } from "./pandoc.svelte"; import { PandocConverter } from "./pandoc.svelte";
import { VertdConverter } from "./vertd.svelte"; import { VertdConverter } from "./vertd.svelte";

View File

@ -1,150 +1,150 @@
// THIS CODE IS FROM https://github.com/captbaritone/webamp/blob/15b0312cb794973a0e615d894df942452e920c36/packages/ani-cursor/src/parser.ts // THIS CODE IS FROM https://github.com/captbaritone/webamp/blob/15b0312cb794973a0e615d894df942452e920c36/packages/ani-cursor/src/parser.ts
// LICENSED UNDER MIT. (c) Jordan Eldredge and Webamp contributors // LICENSED UNDER MIT. (c) Jordan Eldredge and Webamp contributors
// this code is ripped from their project because i didn't want to // this code is ripped from their project because i didn't want to
// re-invent the wheel, BUT the library they provide (ani-cursor) // re-invent the wheel, BUT the library they provide (ani-cursor)
// doesn't expose the internals. // doesn't expose the internals.
import { RIFFFile } from "riff-file"; import { RIFFFile } from "riff-file";
import { unpackArray, unpackString } from "byte-data"; import { unpackArray, unpackString } from "byte-data";
type Chunk = { type Chunk = {
format: string; format: string;
chunkId: string; chunkId: string;
chunkData: { chunkData: {
start: number; start: number;
end: number; end: number;
}; };
subChunks: Chunk[]; subChunks: Chunk[];
}; };
// https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3 // https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3
type AniMetadata = { type AniMetadata = {
cbSize: number; // Data structure size (in bytes) cbSize: number; // Data structure size (in bytes)
nFrames: number; // Number of images (also known as frames) stored in the file nFrames: number; // Number of images (also known as frames) stored in the file
nSteps: number; // Number of frames to be displayed before the animation repeats nSteps: number; // Number of frames to be displayed before the animation repeats
iWidth: number; // Width of frame (in pixels) iWidth: number; // Width of frame (in pixels)
iHeight: number; // Height of frame (in pixels) iHeight: number; // Height of frame (in pixels)
iBitCount: number; // Number of bits per pixel iBitCount: number; // Number of bits per pixel
nPlanes: number; // Number of color planes nPlanes: number; // Number of color planes
iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units) iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units)
bfAttributes: number; // ANI attribute bit flags bfAttributes: number; // ANI attribute bit flags
}; };
type ParsedAni = { type ParsedAni = {
rate: number[] | null; rate: number[] | null;
seq: number[] | null; seq: number[] | null;
images: Uint8Array[]; images: Uint8Array[];
metadata: AniMetadata; metadata: AniMetadata;
artist: string | null; artist: string | null;
title: string | null; title: string | null;
}; };
const DWORD = { bits: 32, be: false, signed: false, fp: false }; const DWORD = { bits: 32, be: false, signed: false, fp: false };
export function parseAni(arr: Uint8Array): ParsedAni { export function parseAni(arr: Uint8Array): ParsedAni {
const riff = new RIFFFile(); const riff = new RIFFFile();
riff.setSignature(arr); riff.setSignature(arr);
const signature = riff.signature as Chunk; const signature = riff.signature as Chunk;
if (signature.format !== "ACON") { if (signature.format !== "ACON") {
throw new Error( throw new Error(
`Expected format. Expected "ACON", got "${signature.format}"`, `Expected format. Expected "ACON", got "${signature.format}"`,
); );
} }
// Helper function to get a chunk by chunkId and transform it if it's non-null. // Helper function to get a chunk by chunkId and transform it if it's non-null.
function mapChunk<T>( function mapChunk<T>(
chunkId: string, chunkId: string,
mapper: (chunk: Chunk) => T, mapper: (chunk: Chunk) => T,
): T | null { ): T | null {
const chunk = riff.findChunk(chunkId) as Chunk | null; const chunk = riff.findChunk(chunkId) as Chunk | null;
return chunk == null ? null : mapper(chunk); return chunk == null ? null : mapper(chunk);
} }
function readImages(chunk: Chunk, frameCount: number): Uint8Array[] { function readImages(chunk: Chunk, frameCount: number): Uint8Array[] {
return chunk.subChunks.slice(0, frameCount).map((c) => { return chunk.subChunks.slice(0, frameCount).map((c) => {
if (c.chunkId !== "icon") { if (c.chunkId !== "icon") {
throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`); throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`);
} }
return arr.slice(c.chunkData.start, c.chunkData.end); return arr.slice(c.chunkData.start, c.chunkData.end);
}); });
} }
const metadata = mapChunk("anih", (c) => { const metadata = mapChunk("anih", (c) => {
const words = unpackArray( const words = unpackArray(
arr, arr,
DWORD, DWORD,
c.chunkData.start, c.chunkData.start,
c.chunkData.end, c.chunkData.end,
); );
return { return {
cbSize: words[0], cbSize: words[0],
nFrames: words[1], nFrames: words[1],
nSteps: words[2], nSteps: words[2],
iWidth: words[3], iWidth: words[3],
iHeight: words[4], iHeight: words[4],
iBitCount: words[5], iBitCount: words[5],
nPlanes: words[6], nPlanes: words[6],
iDispRate: words[7], iDispRate: words[7],
bfAttributes: words[8], bfAttributes: words[8],
}; };
}); });
if (metadata == null) { if (metadata == null) {
throw new Error("Did not find anih"); throw new Error("Did not find anih");
} }
const rate = mapChunk("rate", (c) => { const rate = mapChunk("rate", (c) => {
return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
}); });
// chunkIds are always four chars, hence the trailing space. // chunkIds are always four chars, hence the trailing space.
const seq = mapChunk("seq ", (c) => { const seq = mapChunk("seq ", (c) => {
return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
}); });
const lists = riff.findChunk("LIST", true) as Chunk[] | null; const lists = riff.findChunk("LIST", true) as Chunk[] | null;
const imageChunk = lists?.find((c) => c.format === "fram"); const imageChunk = lists?.find((c) => c.format === "fram");
if (imageChunk == null) { if (imageChunk == null) {
throw new Error("Did not find fram LIST"); throw new Error("Did not find fram LIST");
} }
let images = readImages(imageChunk, metadata.nFrames); let images = readImages(imageChunk, metadata.nFrames);
let title = null; let title = null;
let artist = null; let artist = null;
const infoChunk = lists?.find((c) => c.format === "INFO"); const infoChunk = lists?.find((c) => c.format === "INFO");
if (infoChunk != null) { if (infoChunk != null) {
infoChunk.subChunks.forEach((c) => { infoChunk.subChunks.forEach((c) => {
switch (c.chunkId) { switch (c.chunkId) {
case "INAM": case "INAM":
title = unpackString( title = unpackString(
arr, arr,
c.chunkData.start, c.chunkData.start,
c.chunkData.end, c.chunkData.end,
); );
break; break;
case "IART": case "IART":
artist = unpackString( artist = unpackString(
arr, arr,
c.chunkData.start, c.chunkData.start,
c.chunkData.end, c.chunkData.end,
); );
break; break;
case "LIST": case "LIST":
// Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason? // Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason?
if (c.format === "fram") { if (c.format === "fram") {
images = readImages(c, metadata.nFrames); images = readImages(c, metadata.nFrames);
} }
break; break;
default: default:
// Unexpected subchunk // Unexpected subchunk
} }
}); });
} }
return { images, rate, seq, metadata, artist, title }; return { images, rate, seq, metadata, artist, title };
} }

View File

@ -1,32 +1,32 @@
import { VertFile } from "./file.svelte"; import { VertFile } from "./file.svelte";
interface ConvertMessage { interface ConvertMessage {
type: "convert"; type: "convert";
input: VertFile; input: VertFile;
to: string; to: string;
compression: number | null; compression: number | null;
} }
interface FinishedMessage { interface FinishedMessage {
type: "finished"; type: "finished";
output: ArrayBufferLike; output: ArrayBufferLike;
zip?: boolean; zip?: boolean;
} }
interface LoadedMessage { interface LoadedMessage {
type: "loaded"; type: "loaded";
} }
interface ErrorMessage { interface ErrorMessage {
type: "error"; type: "error";
error: string; error: string;
} }
export type WorkerMessage = ( export type WorkerMessage = (
| ConvertMessage | ConvertMessage
| FinishedMessage | FinishedMessage
| LoadedMessage | LoadedMessage
| ErrorMessage | ErrorMessage
) & { ) & {
id: number; id: number;
}; };

View File

@ -1,278 +1,284 @@
import Vips from "wasm-vips"; import Vips from "wasm-vips";
import { import {
initializeImageMagick, initializeImageMagick,
MagickFormat, MagickFormat,
MagickImage, MagickImage,
MagickImageCollection, MagickImageCollection,
MagickReadSettings, MagickReadSettings,
type IMagickImage, type IMagickImage,
} from "@imagemagick/magick-wasm"; } from "@imagemagick/magick-wasm";
import { makeZip } from "client-zip"; import { makeZip } from "client-zip";
import wasm from "@imagemagick/magick-wasm/magick.wasm?url"; import wasm from "@imagemagick/magick-wasm/magick.wasm?url";
import { parseAni } from "$lib/parse/ani"; import { parseAni } from "$lib/parse/ani";
import { Icns } from "@fiahfy/icns/dist";
const vipsPromise = Vips({
dynamicLibraries: [], const vipsPromise = Vips({
}); dynamicLibraries: [],
});
const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url));
const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url));
const magickRequiredFormats = [
".dng", const magickRequiredFormats = [
".heic", ".dng",
".ico", ".heic",
".cur", ".ico",
".ani", ".cur",
".cr2", ".ani",
".nef", ".cr2",
]; ".nef",
const unsupportedFrom: string[] = []; ];
const unsupportedTo = [...magickRequiredFormats]; const unsupportedFrom: string[] = [];
const unsupportedTo = [...magickRequiredFormats];
vipsPromise
.then(() => { vipsPromise
postMessage({ type: "loaded" }); .then(() => {
}) postMessage({ type: "loaded" });
.catch((error) => { })
postMessage({ type: "error", error }); .catch((error) => {
}); postMessage({ type: "error", error });
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMessage = async (message: any): Promise<any> => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const vips = await vipsPromise; const handleMessage = async (message: any): Promise<any> => {
switch (message.type) { const vips = await vipsPromise;
case "convert": { switch (message.type) {
if (!message.to.startsWith(".")) message.to = `.${message.to}`; case "convert": {
console.log(message); if (!message.to.startsWith(".")) message.to = `.${message.to}`;
if (unsupportedFrom.includes(message.input.from)) { console.log(message);
return { if (unsupportedFrom.includes(message.input.from)) {
type: "error", return {
error: `Unsupported input format ${message.input.from}`, type: "error",
}; error: `Unsupported input format ${message.input.from}`,
} };
}
if (unsupportedTo.includes(message.to)) {
return { if (unsupportedTo.includes(message.to)) {
type: "error", return {
error: `Unsupported output format ${message.to}`, type: "error",
}; error: `Unsupported output format ${message.to}`,
} };
}
const buffer = await message.input.file.arrayBuffer();
if ( const buffer = await message.input.file.arrayBuffer();
magickRequiredFormats.includes(message.input.from) || if (
magickRequiredFormats.includes(message.to) magickRequiredFormats.includes(message.input.from) ||
) { magickRequiredFormats.includes(message.to)
// only wait when we need to ) {
await magickPromise; // only wait when we need to
await magickPromise;
// special ico handling to split them all into separate images
if (message.input.from === ".ico") { // special ico handling to split them all into separate images
const imgs = MagickImageCollection.create(); if (message.input.from === ".ico") {
const imgs = MagickImageCollection.create();
while (true) {
try { while (true) {
const img = MagickImage.create( try {
new Uint8Array(buffer), const img = MagickImage.create(
new MagickReadSettings({ new Uint8Array(buffer),
format: MagickFormat.Ico, new MagickReadSettings({
frameIndex: imgs.length, format: MagickFormat.Ico,
}), frameIndex: imgs.length,
); }),
imgs.push(img); );
// eslint-disable-next-line @typescript-eslint/no-unused-vars imgs.push(img);
} catch (_) { // eslint-disable-next-line @typescript-eslint/no-unused-vars
break; } catch (_) {
} break;
} }
}
if (imgs.length === 0) {
return { if (imgs.length === 0) {
type: "error", return {
error: `Failed to read ICO -- no images found inside?`, type: "error",
}; error: `Failed to read ICO -- no images found inside?`,
} };
}
const convertedImgs: Uint8Array[] = [];
await Promise.all( const convertedImgs: Uint8Array[] = [];
imgs.map(async (img, i) => { await Promise.all(
const output = await magickConvert(img, message.to); imgs.map(async (img, i) => {
convertedImgs[i] = output; const output = await magickConvert(img, message.to);
}), convertedImgs[i] = output;
); }),
);
const zip = makeZip(
convertedImgs.map( const zip = makeZip(
(img, i) => convertedImgs.map(
new File( (img, i) =>
[img], new File(
`image${i}.${message.to.slice(1)}`, [img],
), `image${i}.${message.to.slice(1)}`,
), ),
"images.zip", ),
); "images.zip",
);
// read the ReadableStream to the end
const zipBytes = await readToEnd(zip.getReader()); // read the ReadableStream to the end
const zipBytes = await readToEnd(zip.getReader());
imgs.dispose();
imgs.dispose();
return {
type: "finished", return {
output: zipBytes, type: "finished",
zip: true, output: zipBytes,
}; zip: true,
} else if (message.input.from === ".ani") { };
console.log("Parsing ANI file"); } else if (message.input.from === ".ani") {
try { console.log("Parsing ANI file");
const parsedAni = parseAni(new Uint8Array(buffer)); try {
const files: File[] = []; const parsedAni = parseAni(new Uint8Array(buffer));
await Promise.all( const files: File[] = [];
parsedAni.images.map(async (img, i) => { await Promise.all(
const blob = await magickConvert( parsedAni.images.map(async (img, i) => {
MagickImage.create( const blob = await magickConvert(
img, MagickImage.create(
new MagickReadSettings({ img,
format: MagickFormat.Ico, new MagickReadSettings({
}), format: MagickFormat.Ico,
), }),
message.to, ),
); message.to,
files.push( );
new File([blob], `image${i}${message.to}`), files.push(
); new File([blob], `image${i}${message.to}`),
}), );
); }),
);
const zip = makeZip(files, "images.zip");
const zipBytes = await readToEnd(zip.getReader()); const zip = makeZip(files, "images.zip");
const zipBytes = await readToEnd(zip.getReader());
return {
type: "finished", return {
output: zipBytes, type: "finished",
zip: true, output: zipBytes,
}; zip: true,
} catch (e) { };
console.error(e); } catch (e) {
} console.error(e);
} }
}
console.log(message.input.from);
console.log(message.input.from);
const img = MagickImage.create(
new Uint8Array(buffer), const img = MagickImage.create(
new MagickReadSettings({ new Uint8Array(buffer),
format: message.input.from new MagickReadSettings({
.slice(1) format: message.input.from
.toUpperCase() as MagickFormat, .slice(1)
}), .toUpperCase() as MagickFormat,
); }),
);
const converted = await magickConvert(img, message.to);
const converted = await magickConvert(img, message.to);
return {
type: "finished", return {
output: converted, type: "finished",
}; output: converted,
} };
}
let image = vips.Image.newFromBuffer(buffer, "");
if (message.input.from === ".icns") {
// check if animated image & keep it animated when converting const icns = Icns.from(new Uint8Array(buffer));
if (image.getTypeof("n-pages") > 0) { console.log(icns);
image = vips.Image.newFromBuffer(buffer, "[n=-1]"); }
}
let image = vips.Image.newFromBuffer(buffer, "");
const opts: { [key: string]: string } = {};
if (typeof message.compression !== "undefined") { // check if animated image & keep it animated when converting
opts["Q"] = Math.min(100, message.compression + 1).toString(); if (image.getTypeof("n-pages") > 0) {
} image = vips.Image.newFromBuffer(buffer, "[n=-1]");
}
const output = image.writeToBuffer(message.to, opts);
image.delete(); const opts: { [key: string]: string } = {};
return { if (typeof message.compression !== "undefined") {
type: "finished", opts["Q"] = Math.min(100, message.compression + 1).toString();
output: output.buffer, }
};
} const output = image.writeToBuffer(message.to, opts);
} image.delete();
}; return {
type: "finished",
const readToEnd = async (reader: ReadableStreamDefaultReader<Uint8Array>) => { output: output.buffer,
const chunks: Uint8Array[] = []; };
let done = false; }
while (!done) { }
const { value, done: d } = await reader.read(); };
if (value) chunks.push(value);
done = d; const readToEnd = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
} const chunks: Uint8Array[] = [];
const blob = new Blob(chunks, { type: "application/zip" }); let done = false;
const arrayBuffer = await blob.arrayBuffer(); while (!done) {
return new Uint8Array(arrayBuffer); const { value, done: d } = await reader.read();
}; if (value) chunks.push(value);
done = d;
const magickToBlob = async (img: IMagickImage): Promise<Blob> => { }
const canvas = new OffscreenCanvas(img.width, img.height); const blob = new Blob(chunks, { type: "application/zip" });
return new Promise<Blob>((resolve, reject) => const arrayBuffer = await blob.arrayBuffer();
img.getPixels(async (p) => { return new Uint8Array(arrayBuffer);
const area = p.getArea(0, 0, img.width, img.height); };
const chunkSize = img.hasAlpha ? 4 : 3;
const chunks = Math.ceil(area.length / chunkSize); const magickToBlob = async (img: IMagickImage): Promise<Blob> => {
const data = new Uint8ClampedArray(chunks * 4); const canvas = new OffscreenCanvas(img.width, img.height);
return new Promise<Blob>((resolve, reject) =>
for (let j = 0, k = 0; j < area.length; j += chunkSize, k += 4) { img.getPixels(async (p) => {
data[k] = area[j]; const area = p.getArea(0, 0, img.width, img.height);
data[k + 1] = area[j + 1]; const chunkSize = img.hasAlpha ? 4 : 3;
data[k + 2] = area[j + 2]; const chunks = Math.ceil(area.length / chunkSize);
data[k + 3] = img.hasAlpha ? area[j + 3] : 255; const data = new Uint8ClampedArray(chunks * 4);
}
for (let j = 0, k = 0; j < area.length; j += chunkSize, k += 4) {
const ctx = canvas.getContext("2d"); data[k] = area[j];
if (!ctx) { data[k + 1] = area[j + 1];
reject(new Error("Failed to get canvas context")); data[k + 2] = area[j + 2];
return; data[k + 3] = img.hasAlpha ? area[j + 3] : 255;
} }
ctx.putImageData(new ImageData(data, img.width, img.height), 0, 0); const ctx = canvas.getContext("2d");
if (!ctx) {
const blob = await canvas.convertToBlob({ reject(new Error("Failed to get canvas context"));
type: "image/png", return;
}); }
resolve(blob); ctx.putImageData(new ImageData(data, img.width, img.height), 0, 0);
}),
); const blob = await canvas.convertToBlob({
}; type: "image/png",
});
const magickConvert = async (img: IMagickImage, to: string) => {
const vips = await vipsPromise; resolve(blob);
}),
const intermediary = await magickToBlob(img); );
const buf = await intermediary.arrayBuffer(); };
const imgVips = vips.Image.newFromBuffer(buf); const magickConvert = async (img: IMagickImage, to: string) => {
const output = imgVips.writeToBuffer(to); const vips = await vipsPromise;
imgVips.delete(); const intermediary = await magickToBlob(img);
img.dispose(); const buf = await intermediary.arrayBuffer();
return output; const imgVips = vips.Image.newFromBuffer(buf);
}; const output = imgVips.writeToBuffer(to);
onmessage = async (e) => { imgVips.delete();
const message = e.data; img.dispose();
try {
const res = await handleMessage(message); return output;
if (!res) return; };
postMessage({
...res, onmessage = async (e) => {
id: message.id, const message = e.data;
}); try {
} catch (e) { const res = await handleMessage(message);
postMessage({ if (!res) return;
type: "error", postMessage({
error: e, ...res,
id: message.id, id: message.id,
}); });
} } catch (e) {
}; postMessage({
type: "error",
error: e,
id: message.id,
});
}
};

View File

@ -1,113 +1,113 @@
<script lang="ts"> <script lang="ts">
import { flip } from "$lib/animation"; import { flip } from "$lib/animation";
import Uploader from "$lib/components/functional/Uploader.svelte"; import Uploader from "$lib/components/functional/Uploader.svelte";
import Panel from "$lib/components/visual/Panel.svelte"; import Panel from "$lib/components/visual/Panel.svelte";
import { files } from "$lib/store/index.svelte"; import { files } from "$lib/store/index.svelte";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
import { blur } from "svelte/transition"; import { blur } from "svelte/transition";
const images = $derived( const images = $derived(
files.files.filter((f) => files.files.filter((f) =>
f.converters.map((c) => c.name).includes("libvips"), f.converters.map((c) => c.name).includes("libvips"),
), ),
); );
let forcedBlobURLs = $state<Map<string, string>>(new Map()); let forcedBlobURLs = $state<Map<string, string>>(new Map());
const jpegify = () => { const jpegify = () => {
const imgs = [...images]; const imgs = [...images];
imgs.map(async (f, i) => { imgs.map(async (f, i) => {
f.to = ".jpeg"; f.to = ".jpeg";
const result = await f.convert(compression); const result = await f.convert(compression);
if (!result) return; if (!result) return;
forcedBlobURLs.set(f.id, URL.createObjectURL(result.file)); forcedBlobURLs.set(f.id, URL.createObjectURL(result.file));
forcedBlobURLs = new Map([...forcedBlobURLs]); forcedBlobURLs = new Map([...forcedBlobURLs]);
}); });
}; };
let compressionInverted = $state(10); let compressionInverted = $state(10);
const compression = $derived(100 - compressionInverted); const compression = $derived(100 - compressionInverted);
const processing = $derived(images.map((f) => f.processing).includes(true)); const processing = $derived(images.map((f) => f.processing).includes(true));
</script> </script>
<div class="mx-auto w-full max-w-[778px] flex flex-col gap-8"> <div class="mx-auto w-full max-w-[778px] flex flex-col gap-8">
<h1 class="text-5xl text-center">SECRET JPEGIFY!!!</h1> <h1 class="text-5xl text-center">SECRET JPEGIFY!!!</h1>
<p class="text-muted text-center -mt-4 font-normal italic"> <p class="text-muted text-center -mt-4 font-normal italic">
(shh... don't tell anyone!) (shh... don't tell anyone!)
</p> </p>
<Uploader class="w-full h-64" jpegify={true} /> <Uploader class="w-full h-64" jpegify={true} />
<input <input
type="range" type="range"
min="1" min="1"
max="100" max="100"
step="1" step="1"
class="w-full h-2 bg-panel rounded-lg appearance-none cursor-pointer" class="w-full h-2 bg-panel rounded-lg appearance-none cursor-pointer"
bind:value={compressionInverted} bind:value={compressionInverted}
disabled={processing} disabled={processing}
/> />
<button <button
onclick={jpegify} onclick={jpegify}
disabled={processing} disabled={processing}
class="btn bg-accent text-black rounded-2xl text-2xl w-full mx-auto" class="btn bg-accent text-black rounded-2xl text-2xl w-full mx-auto"
>JPEGIFY {compressionInverted}%!!!</button >JPEGIFY {compressionInverted}%!!!</button
> >
<div class="flex flex-wrap flex-row justify-center gap-4"> <div class="flex flex-wrap flex-row justify-center gap-4">
{#each images as file, i (file.id)} {#each images as file, i (file.id)}
<div <div
class="max-w-full w-full h-96" class="max-w-full w-full h-96"
animate:flip={{ duration: 400, easing: quintOut }} animate:flip={{ duration: 400, easing: quintOut }}
transition:blur={{ transition:blur={{
duration: 400, duration: 400,
amount: 8, amount: 8,
easing: quintOut, easing: quintOut,
}} }}
> >
<Panel class="w-full h-full flex flex-col gap-4 relative z-0"> <Panel class="w-full h-full flex flex-col gap-4 relative z-0">
<div <div
class="relative rounded-xl flex-grow overflow-hidden flex items-center justify-center" class="relative rounded-xl flex-grow overflow-hidden flex items-center justify-center"
> >
<img <img
src={forcedBlobURLs.get(file.id) || src={forcedBlobURLs.get(file.id) ||
file.result?.blobUrl || file.result?.blobUrl ||
file.blobUrl} file.blobUrl}
alt={file.name} alt={file.name}
class="h-full relative" class="h-full relative"
/> />
<img <img
src={forcedBlobURLs.get(file.id) || src={forcedBlobURLs.get(file.id) ||
file.result?.blobUrl || file.result?.blobUrl ||
file.blobUrl} file.blobUrl}
alt={file.name} alt={file.name}
class="h-full absolute top-0 left-0 w-full object-cover blur-2xl -z-10" class="h-full absolute top-0 left-0 w-full object-cover blur-2xl -z-10"
/> />
</div> </div>
<div class="flex-shrink-0 flex items-center gap-4 w-full"> <div class="flex-shrink-0 flex items-center gap-4 w-full">
<button <button
onclick={() => { onclick={() => {
file?.download(); file?.download();
}} }}
disabled={!!!file.result} disabled={!!!file.result}
class="btn bg-accent text-black rounded-2xl text-2xl w-full mx-auto" class="btn bg-accent text-black rounded-2xl text-2xl w-full mx-auto"
> >
Download Download
</button> </button>
<button <button
onclick={() => { onclick={() => {
URL.revokeObjectURL( URL.revokeObjectURL(
forcedBlobURLs.get(file.id) || "", forcedBlobURLs.get(file.id) || "",
); );
forcedBlobURLs.delete(file.id); forcedBlobURLs.delete(file.id);
files.files = files.files.filter( files.files = files.files.filter(
(f) => f.id !== file.id, (f) => f.id !== file.id,
); );
}} }}
class="btn border-accent-red border-2 bg-transparent text-black dynadark:text-white rounded-2xl text-2xl w-full mx-auto" class="btn border-accent-red border-2 bg-transparent text-black dynadark:text-white rounded-2xl text-2xl w-full mx-auto"
> >
Delete Delete
</button> </button>
</div> </div>
</Panel> </Panel>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>