├── .dockerignore
├── .env.example
├── .github
└── workflows
│ └── docker.yml
├── .gitignore
├── .npmignore
├── .prettierignore
├── .prettierrc
├── .vscode
├── settings.json
└── tailwind.json
├── Dockerfile
├── LICENSE
├── README.md
├── _headers
├── bun.lock
├── docker-compose.yml
├── eslint.config.js
├── nginx.conf
├── package.json
├── postcss.config.js
├── src
├── app.d.ts
├── app.html
├── lib
│ ├── animation
│ │ └── index.ts
│ ├── assets
│ │ ├── VERT_Feature.webp
│ │ ├── avatars
│ │ │ ├── azurejelly.jpg
│ │ │ ├── jovannmc.jpg
│ │ │ ├── liam.jpg
│ │ │ ├── nullptr.jpg
│ │ │ └── realmy.jpg
│ │ ├── font
│ │ │ ├── HostGrotesk-Italic.woff2
│ │ │ ├── HostGrotesk-Medium.woff2
│ │ │ ├── HostGrotesk-MediumItalic.woff2
│ │ │ ├── HostGrotesk-Regular.woff2
│ │ │ ├── HostGrotesk-SemiBold.woff2
│ │ │ └── HostGrotesk-SemiBoldItalic.woff2
│ │ ├── hotmilk.svg
│ │ ├── style
│ │ │ └── host-grotesk.css
│ │ └── vert-bg.svg
│ ├── components
│ │ ├── functional
│ │ │ ├── ConversionPanel.svelte
│ │ │ ├── Dialog.svelte
│ │ │ ├── Dropdown.svelte
│ │ │ ├── FancyInput.svelte
│ │ │ ├── FancyMenu.svelte
│ │ │ ├── FormatDropdown.svelte
│ │ │ └── Uploader.svelte
│ │ ├── layout
│ │ │ ├── Dialogs.svelte
│ │ │ ├── Footer.svelte
│ │ │ ├── Gradients.svelte
│ │ │ ├── MobileLogo.svelte
│ │ │ ├── Navbar
│ │ │ │ ├── Base.svelte
│ │ │ │ ├── Desktop.svelte
│ │ │ │ ├── Mobile.svelte
│ │ │ │ └── index.ts
│ │ │ ├── PageContent.svelte
│ │ │ ├── Toasts.svelte
│ │ │ ├── UploadRegion.svelte
│ │ │ └── index.ts
│ │ └── visual
│ │ │ ├── Panel.svelte
│ │ │ ├── ProgressBar.svelte
│ │ │ ├── Toast.svelte
│ │ │ ├── Tooltip.svelte
│ │ │ ├── effects
│ │ │ └── ProgressiveBlur.svelte
│ │ │ └── svg
│ │ │ ├── Logo.svelte
│ │ │ ├── LogoBeta.svelte
│ │ │ └── VertVBig.svelte
│ ├── consts.ts
│ ├── converters
│ │ ├── converter.svelte.ts
│ │ ├── ffmpeg.svelte.ts
│ │ ├── index.ts
│ │ ├── pandoc.svelte.ts
│ │ ├── vertd.svelte.ts
│ │ └── vips.svelte.ts
│ ├── css
│ │ └── app.scss
│ ├── logger
│ │ └── index.ts
│ ├── parse
│ │ ├── ani.ts
│ │ └── icns
│ │ │ └── index.ts
│ ├── sections
│ │ ├── about
│ │ │ ├── Credits.svelte
│ │ │ ├── Donate.svelte
│ │ │ ├── Resources.svelte
│ │ │ ├── Vertd.svelte
│ │ │ ├── Why.svelte
│ │ │ └── index.ts
│ │ └── settings
│ │ │ ├── Appearance.svelte
│ │ │ ├── Conversion.svelte
│ │ │ ├── Privacy.svelte
│ │ │ ├── Vertd.svelte
│ │ │ └── index.svelte.ts
│ ├── store
│ │ ├── DialogProvider.ts
│ │ ├── ToastProvider.ts
│ │ └── index.svelte.ts
│ ├── types
│ │ ├── conversion-worker.ts
│ │ ├── file.svelte.ts
│ │ ├── index.ts
│ │ └── util.ts
│ └── workers
│ │ ├── pandoc.ts
│ │ └── vips.ts
└── routes
│ ├── +layout.server.ts
│ ├── +layout.svelte
│ ├── +layout.ts
│ ├── +page.svelte
│ ├── about
│ └── +page.svelte
│ ├── convert
│ └── +page.svelte
│ ├── jpegify
│ └── +page.svelte
│ └── settings
│ └── +page.svelte
├── static
├── banner.png
├── favicon.png
├── lettermark.jpg
├── manifest.json
└── pandoc.wasm
├── svelte.config.js
├── tailwind.config.ts
├── tsconfig.json
└── vite.config.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .git/
3 | build/
4 | dist/
5 | .svelte-kit/
6 | .output/
7 | .vercel/
8 | .vscode/
9 |
10 | LICENSE
11 | README.md
12 | Dockerfile
13 | docker-compose.yml
14 | .npmrc
15 | .prettier*
16 | .gitignore
17 | .env.*
18 | .env
19 |
20 | .DS_Store
21 | Thumbs.db
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | PUB_HOSTNAME=localhost:5173 # only gets used for plausible (for now)
2 | PUB_PLAUSIBLE_URL=https://plausible.example.com # can be empty
3 | PUB_ENV=development # "production", "development", or "nightly"
4 | PUB_VERTD_URL=https://vertd.vert.sh # default vertd instance
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | tags: [ 'v*' ]
7 | pull_request:
8 | branches: [ "main" ]
9 |
10 | jobs:
11 | build-and-push:
12 | runs-on: ubuntu-latest
13 | permissions:
14 | contents: read
15 | packages: write
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - name: Set up Docker Buildx
21 | uses: docker/setup-buildx-action@v3
22 |
23 | - name: Login to GitHub Container Registry
24 | if: github.event_name != 'pull_request'
25 | uses: docker/login-action@v3
26 | with:
27 | registry: ghcr.io
28 | username: ${{ github.actor }}
29 | password: ${{ secrets.GITHUB_TOKEN }}
30 |
31 | - name: Extract metadata
32 | id: meta
33 | uses: docker/metadata-action@v5
34 | with:
35 | images: ghcr.io/${{ github.repository }}
36 | tags: |
37 | type=ref,event=branch
38 | type=ref,event=pr
39 | type=semver,pattern={{version}}
40 | type=semver,pattern={{major}}.{{minor}}
41 | type=sha,format=short
42 | type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
43 |
44 | - name: Build and push
45 | uses: docker/build-push-action@v5
46 | with:
47 | context: .
48 | push: ${{ github.event_name != 'pull_request' }}
49 | platforms: linux/amd64,linux/arm64
50 | tags: ${{ steps.meta.outputs.tags }}
51 | labels: ${{ steps.meta.outputs.labels }}
52 | cache-from: type=gha
53 | cache-to: type=gha,mode=max
54 | build-args: |
55 | PUB_ENV=production
56 | PUB_HOSTNAME=${{ vars.PUB_HOSTNAME || '' }}
57 | PUB_PLAUSIBLE_URL=${{ vars.PUB_PLAUSIBLE_URL || '' }}
58 | PUB_VERTD_URL=https://vertd.vert.sh
59 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | # Output
4 | .output
5 | .vercel
6 | /.svelte-kit
7 | /build
8 |
9 | # OS
10 | .DS_Store
11 | Thumbs.db
12 |
13 | # Env
14 | .env
15 | .env.*
16 | !.env.example
17 | !.env.test
18 |
19 | # Vite
20 | vite.config.js.timestamp-*
21 | vite.config.ts.timestamp-*
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src/routes
2 | src/app.d.ts
3 | src/app.html
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Package Managers
2 | package-lock.json
3 | pnpm-lock.yaml
4 | yarn.lock
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "tabWidth": 4,
4 | "singleQuote": false
5 | }
6 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.customData": [".vscode/tailwind.json"]
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/tailwind.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1.1,
3 | "atDirectives": [
4 | {
5 | "name": "@tailwind",
6 | "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
7 | "references": [
8 | {
9 | "name": "Tailwind Documentation",
10 | "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
11 | }
12 | ]
13 | },
14 | {
15 | "name": "@apply",
16 | "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
17 | "references": [
18 | {
19 | "name": "Tailwind Documentation",
20 | "url": "https://tailwindcss.com/docs/functions-and-directives#apply"
21 | }
22 | ]
23 | },
24 | {
25 | "name": "@responsive",
26 | "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
27 | "references": [
28 | {
29 | "name": "Tailwind Documentation",
30 | "url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
31 | }
32 | ]
33 | },
34 | {
35 | "name": "@screen",
36 | "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
37 | "references": [
38 | {
39 | "name": "Tailwind Documentation",
40 | "url": "https://tailwindcss.com/docs/functions-and-directives#screen"
41 | }
42 | ]
43 | },
44 | {
45 | "name": "@variants",
46 | "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
47 | "references": [
48 | {
49 | "name": "Tailwind Documentation",
50 | "url": "https://tailwindcss.com/docs/functions-and-directives#variants"
51 | }
52 | ]
53 | }
54 | ]
55 | }
56 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM oven/bun AS builder
2 |
3 | WORKDIR /app
4 |
5 | ARG PUB_ENV
6 | ARG PUB_HOSTNAME
7 | ARG PUB_PLAUSIBLE_URL
8 | ARG PUB_VERTD_URL
9 |
10 | ENV PUB_ENV=${PUB_ENV}
11 | ENV PUB_HOSTNAME=${PUB_HOSTNAME}
12 | ENV PUB_PLAUSIBLE_URL=${PUB_PLAUSIBLE_URL}
13 | ENV PUB_VERTD_URL=${PUB_VERTD_URL}
14 |
15 | COPY package.json ./
16 |
17 | RUN bun install
18 |
19 | COPY . ./
20 |
21 | RUN bun run build
22 |
23 | FROM nginx:stable-alpine
24 |
25 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf
26 |
27 | COPY --from=builder /app/build /usr/share/nginx/html
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | VERT is a file conversion utility that uses WebAssembly to convert files on your device instead of a cloud. Check out the live instance at [vert.sh](https://vert.sh).
7 |
8 | VERT is built in Svelte and TypeScript.
9 |
10 | ## Features
11 |
12 | - Convert files directly on your device using WebAssembly *
13 | - No file size limits
14 | - Supports multiple file formats
15 | - User-friendly interface built with Svelte
16 |
17 | * Non-local video conversion is available with our official instance, but the [daemon](https://github.com/VERT-sh/vertd) is easily self-hostable to maintain privacy and fully local functionality.
18 |
19 | ## Getting Started
20 |
21 | ### Prerequisites
22 |
23 | Make sure you have the following installed:
24 |
25 | - [Bun](https://bun.sh/)
26 |
27 | ### Installation
28 | ```sh
29 | # Clone the repository
30 | git clone https://github.com/VERT-sh/vert.git
31 | cd vert
32 | # Install dependencies
33 | bun i
34 | ```
35 |
36 | ### Running Locally
37 |
38 | To run the project locally, run `bun dev`.
39 |
40 | This will start a development server. Open your browser and navigate to `http://localhost:5173` to see the application.
41 |
42 | ### Building for Production
43 |
44 | Before building for production, make sure you create a `.env` file in the root of the project with the following content:
45 |
46 | ```sh
47 | PUB_HOSTNAME=example.com # change to your domain, only gets used for Plausible (for now)
48 | PUB_PLAUSIBLE_URL=https://plausible.example.com # can be empty if not using Plausible
49 | PUB_ENV=production # "production", "development" or "nightly"
50 | PUB_VERTD_URL=https://vertd.vert.sh # default vertd instance
51 | ```
52 |
53 | To build the project for production, run `bun run build`
54 |
55 | This will build the site to the `build` folder. You should then use a web server like [nginx](https://nginx.org) to serve the files inside that folder.
56 |
57 | If using nginx, you can use the [nginx.conf](./nginx.conf) file as a starting point. Make sure you keep [cross-origin isolation](https://web.dev/articles/cross-origin-isolation-guide) enabled.
58 |
59 | ### With Docker
60 |
61 | Clone the repository, then build a Docker image with:
62 | ```shell
63 | $ docker build -t vert-sh/vert \
64 | --build-arg PUB_ENV=production \
65 | --build-arg PUB_HOSTNAME=vert.sh \
66 | --build-arg PUB_PLAUSIBLE_URL=https://plausible.example.com \
67 | --build-arg PUB_VERTD_URL=https://vertd.vert.sh .
68 | ```
69 |
70 | You can then run it by using:
71 | ```shell
72 | $ docker run -d \
73 | --restart unless-stopped \
74 | -p 3000:80 \
75 | --name "vert" \
76 | vert-sh/vert
77 | ```
78 |
79 | This will do the following:
80 | - Use the previously built image as the container `vert`, in detached mode
81 | - Continuously restart the container until manually stopped
82 | - Map `3000/tcp` (host) to `80/tcp` (container)
83 |
84 | We also have a [`docker-compose.yml`](./docker-compose.yml) file available. Use `docker compose up` if you want to start the stack, or `docker compose down` to bring it down. You can pass `--build` to `docker compose up` to rebuild the Docker image (useful if you've changed any of the environment variables) as well as `-d` to start it in detached mode. You can read more about Docker Compose in general [here](https://docs.docker.com/compose/intro/compose-application-model/).
85 |
86 | While there's an image you can pull instead of cloning the repo and building the image yourself, you will not be able to update any of the environment variables (e.g. `PUB_PLAUSIBLE_URL`) as they're baked directly into the image and not obtained during runtime. If you're okay with this, you can simply run this command instead:
87 | ```shell
88 | $ docker run -d \
89 | --restart unless-stopped \
90 | -p 3000:80 \
91 | --name "vert" \
92 | ghcr.io/vert-sh/vert:latest
93 | ```
94 |
95 | ## License
96 |
97 | This project is licensed under the AGPL-3.0 License, please see the [LICENSE](LICENSE) file for details.
98 |
--------------------------------------------------------------------------------
/_headers:
--------------------------------------------------------------------------------
1 | # For libvips/wasm-vips converter (images)
2 | /*
3 | Cross-Origin-Embedder-Policy: require-corp
4 | Cross-Origin-Opener-Policy: same-origin
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | vert:
3 | container_name: vert
4 | image: vert-sh/vert:latest
5 | environment:
6 | - PUB_HOSTNAME=${PUB_HOSTNAME:-vert.sh}
7 | - PUB_PLAUSIBLE_URL=${PUB_PLAUSIBLE_URL:-https://plausible.example.com}
8 | - PUB_ENV=${PUB_ENV:-production}
9 | - PORT=${PORT:-3000}
10 | - PUB_VERTD_URL=${PUB_VERTD_URL:-https://vertd.vert.sh}
11 | build:
12 | context: .
13 | args:
14 | PUB_HOSTNAME: ${PUB_HOSTNAME:-vert.sh}
15 | PUB_PLAUSIBLE_URL: ${PUB_PLAUSIBLE_URL:-https://plausible.example.com}
16 | PUB_ENV: ${PUB_ENV:-production}
17 | PUB_VERTD_URL: ${PUB_VERTD_URL:-https://vertd.vert.sh}
18 | restart: unless-stopped
19 | ports:
20 | - ${PORT:-3000}:80
21 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import prettier from 'eslint-config-prettier';
2 | import js from '@eslint/js';
3 | import svelte from 'eslint-plugin-svelte';
4 | import globals from 'globals';
5 | import ts from 'typescript-eslint';
6 |
7 | export default ts.config(
8 | js.configs.recommended,
9 | ...ts.configs.recommended,
10 | ...svelte.configs['flat/recommended'],
11 | prettier,
12 | ...svelte.configs['flat/prettier'],
13 | {
14 | languageOptions: {
15 | globals: {
16 | ...globals.browser,
17 | ...globals.node
18 | }
19 | }
20 | },
21 | {
22 | files: ['**/*.svelte'],
23 |
24 | languageOptions: {
25 | parserOptions: {
26 | parser: ts.parser
27 | }
28 | }
29 | },
30 | {
31 | ignores: ['build/', '.svelte-kit/', 'dist/']
32 | }
33 | );
34 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name vert;
4 |
5 | root /usr/share/nginx/html;
6 | index index.html;
7 |
8 | client_max_body_size 10M;
9 |
10 | location / {
11 | try_files $uri $uri/ /index.html;
12 | }
13 |
14 | error_page 404 /index.html;
15 |
16 | add_header Cross-Origin-Embedder-Policy "require-corp";
17 | add_header Cross-Origin-Opener-Policy "same-origin";
18 | add_header Cross-Origin-Resource-Policy "cross-origin";
19 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vert",
3 | "version": "0.0.1",
4 | "type": "module",
5 | "scripts": {
6 | "dev": "vite dev",
7 | "build": "vite build",
8 | "preview": "vite preview",
9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
11 | "format": "prettier --write .",
12 | "lint": "prettier --check . && eslint ."
13 | },
14 | "devDependencies": {
15 | "@poppanator/sveltekit-svg": "^5.0.0",
16 | "@sveltejs/adapter-static": "^3.0.8",
17 | "@sveltejs/kit": "^2.16.0",
18 | "@sveltejs/vite-plugin-svelte": "^4.0.4",
19 | "@types/eslint": "^9.6.1",
20 | "autoprefixer": "^10.4.20",
21 | "eslint": "^9.18.0",
22 | "eslint-config-prettier": "^10.0.1",
23 | "eslint-plugin-svelte": "^2.46.1",
24 | "globals": "^15.14.0",
25 | "prettier": "^3.4.2",
26 | "prettier-plugin-svelte": "^3.3.3",
27 | "prettier-plugin-tailwindcss": "^0.6.10",
28 | "sass": "^1.83.4",
29 | "svelte": "^5.19.0",
30 | "svelte-check": "^4.1.4",
31 | "tailwindcss": "^3.4.17",
32 | "typescript": "^5.7.3",
33 | "typescript-eslint": "^8.20.0",
34 | "vite": "^5.4.11",
35 | "vite-plugin-top-level-await": "^1.5.0"
36 | },
37 | "dependencies": {
38 | "@bjorn3/browser_wasi_shim": "^0.4.1",
39 | "@ffmpeg/ffmpeg": "^0.12.15",
40 | "@ffmpeg/util": "^0.12.2",
41 | "@fontsource/azeret-mono": "^5.1.1",
42 | "@fontsource/lexend": "^5.1.2",
43 | "@fontsource/radio-canada-big": "^5.1.1",
44 | "@imagemagick/magick-wasm": "^0.0.34",
45 | "byte-data": "^19.0.1",
46 | "client-zip": "^2.4.6",
47 | "clsx": "^2.1.1",
48 | "lucide-svelte": "^0.475.0",
49 | "music-metadata": "^11.0.0",
50 | "p-queue": "^8.1.0",
51 | "riff-file": "^1.0.3",
52 | "vert-wasm": "^0.0.2",
53 | "vite-plugin-static-copy": "^2.2.0",
54 | "vite-plugin-wasm": "^3.4.1",
55 | "wasm-vips": "^0.0.11"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/src/app.d.ts:
--------------------------------------------------------------------------------
1 | import "@poppanator/sveltekit-svg/dist/svg";
2 |
3 | type EventPayload = {
4 | readonly n: string;
5 | readonly u: Location["href"];
6 | readonly d: Location["hostname"];
7 | readonly r: Document["referrer"] | null;
8 | readonly w: Window["innerWidth"];
9 | readonly h: 1 | 0;
10 | readonly p?: string;
11 | };
12 |
13 | type CallbackArgs = {
14 | readonly status: number;
15 | };
16 |
17 | type EventOptions = {
18 | /**
19 | * Callback called when the event is successfully sent.
20 | */
21 | readonly callback?: (args: CallbackArgs) => void;
22 | /**
23 | * Properties to be bound to the event.
24 | */
25 | readonly props?: { readonly [propName: string]: string | number | boolean };
26 | };
27 |
28 | declare global {
29 | interface Window {
30 | plausible: TrackEvent;
31 | }
32 | }
33 |
34 | /**
35 | * Options used when initializing the tracker.
36 | */
37 | export type PlausibleInitOptions = {
38 | /**
39 | * If true, pageviews will be tracked when the URL hash changes.
40 | * Enable this if you are using a frontend that uses hash-based routing.
41 | */
42 | readonly hashMode?: boolean;
43 | /**
44 | * Set to true if you want events to be tracked when running the site locally.
45 | */
46 | readonly trackLocalhost?: boolean;
47 | /**
48 | * The domain to bind the event to.
49 | * Defaults to `location.hostname`
50 | */
51 | readonly domain?: Location["hostname"];
52 | /**
53 | * The API host where the events will be sent.
54 | * Defaults to `'https://plausible.io'`
55 | */
56 | readonly apiHost?: string;
57 | };
58 |
59 | /**
60 | * Data passed to Plausible as events.
61 | */
62 | export type PlausibleEventData = {
63 | /**
64 | * The URL to bind the event to.
65 | * Defaults to `location.href`.
66 | */
67 | readonly url?: Location["href"];
68 | /**
69 | * The referrer to bind the event to.
70 | * Defaults to `document.referrer`
71 | */
72 | readonly referrer?: Document["referrer"] | null;
73 | /**
74 | * The current device's width.
75 | * Defaults to `window.innerWidth`
76 | */
77 | readonly deviceWidth?: Window["innerWidth"];
78 | };
79 |
80 | /**
81 | * Options used when tracking Plausible events.
82 | */
83 | export type PlausibleOptions = PlausibleInitOptions & PlausibleEventData;
84 |
85 | /**
86 | * Tracks a custom event.
87 | *
88 | * Use it to track your defined goals by providing the goal's name as `eventName`.
89 | *
90 | * ### Example
91 | * ```js
92 | * import Plausible from 'plausible-tracker'
93 | *
94 | * const { trackEvent } = Plausible()
95 | *
96 | * // Tracks the 'signup' goal
97 | * trackEvent('signup')
98 | *
99 | * // Tracks the 'Download' goal passing a 'method' property.
100 | * trackEvent('Download', { props: { method: 'HTTP' } })
101 | * ```
102 | *
103 | * @param eventName - Name of the event to track
104 | * @param options - Event options.
105 | * @param eventData - Optional event data to send. Defaults to the current page's data merged with the default options provided earlier.
106 | */
107 | type TrackEvent = (
108 | eventName: string,
109 | options?: EventOptions,
110 | eventData?: PlausibleOptions,
111 | ) => void;
112 |
113 | /**
114 | * Manually tracks a page view.
115 | *
116 | * ### Example
117 | * ```js
118 | * import Plausible from 'plausible-tracker'
119 | *
120 | * const { trackPageview } = Plausible()
121 | *
122 | * // Track a page view
123 | * trackPageview()
124 | * ```
125 | *
126 | * @param eventData - Optional event data to send. Defaults to the current page's data merged with the default options provided earlier.
127 | * @param options - Event options.
128 | */
129 | type TrackPageview = (
130 | eventData?: PlausibleOptions,
131 | options?: EventOptions,
132 | ) => void;
133 |
134 | /**
135 | * Cleans up all event listeners attached.
136 | */
137 | type Cleanup = () => void;
138 |
139 | /**
140 | * Tracks the current page and all further pages automatically.
141 | *
142 | * Call this if you don't want to manually manage pageview tracking.
143 | *
144 | * ### Example
145 | * ```js
146 | * import Plausible from 'plausible-tracker'
147 | *
148 | * const { enableAutoPageviews } = Plausible()
149 | *
150 | * // This tracks the current page view and all future ones as well
151 | * enableAutoPageviews()
152 | * ```
153 | *
154 | * The returned value is a callback that removes the added event listeners and restores `history.pushState`
155 | * ```js
156 | * import Plausible from 'plausible-tracker'
157 | *
158 | * const { enableAutoPageviews } = Plausible()
159 | *
160 | * const cleanup = enableAutoPageviews()
161 | *
162 | * // Remove event listeners and restore `history.pushState`
163 | * cleanup()
164 | * ```
165 | */
166 | type EnableAutoPageviews = () => Cleanup;
167 |
168 | /**
169 | * Tracks all outbound link clicks automatically
170 | *
171 | * Call this if you don't want to manually manage these links.
172 | *
173 | * It works using a **[MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver)** to automagically detect link nodes throughout your application and bind `click` events to them.
174 | *
175 | * Optionally takes the same parameters as [`MutationObserver.observe`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe).
176 | *
177 | * ### Example
178 | * ```js
179 | * import Plausible from 'plausible-tracker'
180 | *
181 | * const { enableAutoOutboundTracking } = Plausible()
182 | *
183 | * // This tracks all the existing and future outbound links on your page.
184 | * enableAutoOutboundTracking()
185 | * ```
186 | *
187 | * The returned value is a callback that removes the added event listeners and disconnects the observer
188 | * ```js
189 | * import Plausible from 'plausible-tracker'
190 | *
191 | * const { enableAutoOutboundTracking } = Plausible()
192 | *
193 | * const cleanup = enableAutoOutboundTracking()
194 | *
195 | * // Remove event listeners and disconnect the observer
196 | * cleanup()
197 | * ```
198 | */
199 | type EnableAutoOutboundTracking = (
200 | targetNode?: Node & ParentNode,
201 | observerInit?: MutationObserverInit,
202 | ) => Cleanup;
203 |
204 | /**
205 | * Initializes the tracker with your default values.
206 | *
207 | * ### Example (es module)
208 | * ```js
209 | * import Plausible from 'plausible-tracker'
210 | *
211 | * const { enableAutoPageviews, trackEvent } = Plausible({
212 | * domain: 'my-app-domain.com',
213 | * hashMode: true
214 | * })
215 | *
216 | * enableAutoPageviews()
217 | *
218 | * function onUserRegister() {
219 | * trackEvent('register')
220 | * }
221 | * ```
222 | *
223 | * ### Example (commonjs)
224 | * ```js
225 | * var Plausible = require('plausible-tracker');
226 | *
227 | * var { enableAutoPageviews, trackEvent } = Plausible({
228 | * domain: 'my-app-domain.com',
229 | * hashMode: true
230 | * })
231 | *
232 | * enableAutoPageviews()
233 | *
234 | * function onUserRegister() {
235 | * trackEvent('register')
236 | * }
237 | * ```
238 | *
239 | * @param defaults - Default event parameters that will be applied to all requests.
240 | */
241 |
242 | declare global {
243 | namespace App {
244 | // interface Error {}
245 | // interface Locals {}
246 | // interface PageData {}
247 | // interface PageState {}
248 | // interface Platform {}
249 | }
250 | }
251 |
252 | declare module "svelte/elements" {
253 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
254 | interface HTMLAttributes {
255 | [key: `event-${string}`]: string | undefined | null;
256 | }
257 | }
258 |
259 | export {};
260 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | %sveltekit.head%
15 |
43 |
44 |
45 | %sveltekit.body%
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/lib/animation/index.ts:
--------------------------------------------------------------------------------
1 | import { isMobile, effects } from "$lib/store/index.svelte";
2 | import type { AnimationConfig, FlipParams } from "svelte/animate";
3 | import { cubicOut } from "svelte/easing";
4 | import {
5 | fade as svelteFade,
6 | fly as svelteFly,
7 | type FadeParams,
8 | type FlyParams,
9 | } from "svelte/transition";
10 |
11 | // Subscribe to stores
12 | let effectsEnabled = true;
13 | let isMobileDevice = false;
14 |
15 | // FIXME: there is sometimes an issue in dev where subscribing to the store just breaks everything? (.subscribe() not existing on effects, somehow)
16 | // you gotta restart the dev server to fix and it only seems to happen in dev. somehow effects being called before its defined?
17 | effects.subscribe((value) => {
18 | effectsEnabled = value;
19 | });
20 |
21 | isMobile.subscribe((value) => {
22 | isMobileDevice = value;
23 | });
24 |
25 | export const transition =
26 | "linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001)";
27 |
28 | export const duration = 500;
29 |
30 | export function fade(node: HTMLElement, options: FadeParams) {
31 | if (!effectsEnabled) return {};
32 | const animation = svelteFade(node, options);
33 | return animation;
34 | }
35 |
36 | export function fly(node: HTMLElement, options: FlyParams) {
37 | if (!effectsEnabled || isMobileDevice) return {};
38 | const animation = svelteFly(node, options);
39 | return animation;
40 | }
41 |
42 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
43 | export function is_function(thing: unknown): thing is Function {
44 | return typeof thing === "function";
45 | }
46 |
47 | type Params = FlipParams & {};
48 |
49 | /**
50 | * The flip function calculates the start and end position of an element and animates between them, translating the x and y values.
51 | * `flip` stands for [First, Last, Invert, Play](https://aerotwist.com/blog/flip-your-animations/).
52 | *
53 | * https://svelte.dev/docs/svelte-animate#flip
54 | */
55 | export function flip(
56 | node: HTMLElement,
57 | { from, to }: { from: DOMRect; to: DOMRect },
58 | params: Params = {},
59 | ): AnimationConfig {
60 | const style = getComputedStyle(node);
61 | const transform = style.transform === "none" ? "" : style.transform;
62 | const [ox, oy] = style.transformOrigin.split(" ").map(parseFloat);
63 | const dx = from.left + (from.width * ox) / to.width - (to.left + ox);
64 | const dy = from.top + (from.height * oy) / to.height - (to.top + oy);
65 | const {
66 | delay = 0,
67 | duration = (d) => Math.sqrt(d) * 120,
68 | easing = cubicOut,
69 | } = params;
70 | return {
71 | delay,
72 | duration: is_function(duration)
73 | ? duration(Math.sqrt(dx * dx + dy * dy))
74 | : duration,
75 | easing,
76 | css: (_t, u) => {
77 | const x = u * dx;
78 | const y = u * dy;
79 | // const sx = scale ? t + (u * from.width) / to.width : 1;
80 | // const sy = scale ? t + (u * from.height) / to.height : 1;
81 | return `transform: ${transform} translate(${x}px, ${y}px);`;
82 | },
83 | };
84 | }
85 |
--------------------------------------------------------------------------------
/src/lib/assets/VERT_Feature.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/VERT_Feature.webp
--------------------------------------------------------------------------------
/src/lib/assets/avatars/azurejelly.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/avatars/azurejelly.jpg
--------------------------------------------------------------------------------
/src/lib/assets/avatars/jovannmc.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/avatars/jovannmc.jpg
--------------------------------------------------------------------------------
/src/lib/assets/avatars/liam.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/avatars/liam.jpg
--------------------------------------------------------------------------------
/src/lib/assets/avatars/nullptr.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/avatars/nullptr.jpg
--------------------------------------------------------------------------------
/src/lib/assets/avatars/realmy.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/avatars/realmy.jpg
--------------------------------------------------------------------------------
/src/lib/assets/font/HostGrotesk-Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/font/HostGrotesk-Italic.woff2
--------------------------------------------------------------------------------
/src/lib/assets/font/HostGrotesk-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/font/HostGrotesk-Medium.woff2
--------------------------------------------------------------------------------
/src/lib/assets/font/HostGrotesk-MediumItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/font/HostGrotesk-MediumItalic.woff2
--------------------------------------------------------------------------------
/src/lib/assets/font/HostGrotesk-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/font/HostGrotesk-Regular.woff2
--------------------------------------------------------------------------------
/src/lib/assets/font/HostGrotesk-SemiBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/font/HostGrotesk-SemiBold.woff2
--------------------------------------------------------------------------------
/src/lib/assets/font/HostGrotesk-SemiBoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/assets/font/HostGrotesk-SemiBoldItalic.woff2
--------------------------------------------------------------------------------
/src/lib/assets/hotmilk.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
7 |
8 |
13 |
14 |
--------------------------------------------------------------------------------
/src/lib/assets/style/host-grotesk.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Host Grotesk";
3 | font-style: normal;
4 | font-weight: 400;
5 | src: url("$lib/assets/font/HostGrotesk-Regular.woff2") format("woff2");
6 | }
7 |
8 | @font-face {
9 | font-family: "Host Grotesk";
10 | font-style: italic;
11 | font-weight: 400;
12 | src: url("$lib/assets/font/HostGrotesk-Italic.woff2") format("woff2");
13 | }
14 |
15 | @font-face {
16 | font-family: "Host Grotesk";
17 | font-style: normal;
18 | font-weight: 500;
19 | src: url("$lib/assets/font/HostGrotesk-Medium.woff2") format("woff2");
20 | }
21 |
22 | @font-face {
23 | font-family: "Host Grotesk";
24 | font-style: italic;
25 | font-weight: 500;
26 | src: url("$lib/assets/font/HostGrotesk-MediumItalic.woff2") format("woff2");
27 | }
28 |
29 | @font-face {
30 | font-family: "Host Grotesk";
31 | font-style: normal;
32 | font-weight: 600;
33 | src: url("$lib/assets/font/HostGrotesk-SemiBold.woff2") format("woff2");
34 | }
35 |
36 | @font-face {
37 | font-family: "Host Grotesk";
38 | font-style: italic;
39 | font-weight: 600;
40 | src: url("$lib/assets/font/HostGrotesk-SemiBoldItalic.woff2")
41 | format("woff2");
42 | }
43 |
--------------------------------------------------------------------------------
/src/lib/assets/vert-bg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/lib/components/functional/ConversionPanel.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
19 |
22 |
files.convertAll()}
24 | class="btn {$effects
25 | ? ''
26 | : '!scale-100'} highlight flex gap-3 max-md:w-full"
27 | disabled={!files.ready}
28 | >
29 |
30 | Convert all
31 |
32 |
files.downloadAll()}
38 | >
39 |
40 | Download all as .zip
41 |
42 | {#if $isMobile}
43 |
(files.files = [])}
49 | >
50 |
51 | Remove all files
52 |
53 | {:else}
54 |
55 | (files.files = [])}
61 | >
62 |
63 |
64 |
65 | {/if}
66 |
67 |
68 |
69 |
Set all to
70 | {#if files.requiredConverters.length === 1}
71 |
73 | files.files.forEach((f) => {
74 | if (f.from !== r) {
75 | f.to = r;
76 | f.result = null;
77 | }
78 | })}
79 | {categories}
80 | />
81 | {:else}
82 |
83 | {/if}
84 |
85 |
86 | {#if files.files.length > 50}
87 |
88 |
89 | {progress}/{length}
90 |
91 |
94 |
95 | {/if}
96 |
97 |
--------------------------------------------------------------------------------
/src/lib/components/functional/Dialog.svelte:
--------------------------------------------------------------------------------
1 |
37 |
38 |
51 |
52 |
53 |
56 |
57 |
58 |
{title}
59 |
60 |
61 |
64 |
65 | {#each buttons as { text, action }, i}
66 | {
72 | action();
73 | removeDialog(id);
74 | }}
75 | >
76 | {text}
77 |
78 | {/each}
79 |
80 |
81 |
--------------------------------------------------------------------------------
/src/lib/components/functional/Dropdown.svelte:
--------------------------------------------------------------------------------
1 |
52 |
53 |
59 |
(hover = true)}
71 | onmouseleave={() => (hover = false)}
72 | {disabled}
73 | >
74 |
75 |
76 | {#key selected}
77 |
92 | {selected}
93 |
94 | {/key}
95 | {#each options as option}
96 |
99 | {option}
100 |
101 | {/each}
102 |
103 |
109 |
110 | {#if open}
111 |
119 | {#each options as option}
120 | select(option)}
123 | >
124 | {option}
125 |
126 | {/each}
127 |
128 | {/if}
129 |
130 |
--------------------------------------------------------------------------------
/src/lib/components/functional/FancyInput.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
33 | {#if prefix}
34 |
35 | {prefix}
38 |
39 | {/if}
40 | {#if extension}
41 |
42 | {extension}
45 |
46 | {/if}
47 |
48 |
--------------------------------------------------------------------------------
/src/lib/components/functional/FancyMenu.svelte:
--------------------------------------------------------------------------------
1 |
36 |
37 |
93 |
--------------------------------------------------------------------------------
/src/lib/components/functional/FormatDropdown.svelte:
--------------------------------------------------------------------------------
1 |
136 |
137 |
141 |
(open = !open)}
146 | onmouseenter={() => (hover = true)}
147 | onmouseleave={() => (hover = false)}
148 | {disabled}
149 | >
150 |
151 |
152 | {#key selected}
153 |
164 | {selected}
165 |
166 | {/key}
167 | {#if currentCategory}
168 | {#each categories[currentCategory].formats as option}
169 |
172 | {option}
173 |
174 | {/each}
175 | {/if}
176 |
177 |
183 |
184 | {#if open}
185 |
196 |
197 |
198 |
199 |
206 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 | {#each filteredData.categories as category}
217 | selectCategory(category)}
223 | >
224 | {category}
225 |
226 | {/each}
227 |
228 |
229 |
230 |
231 | {#each filteredData.formats as format}
232 | selectOption(format)}
238 | >
239 | {format}
240 |
241 | {/each}
242 |
243 |
244 | {/if}
245 |
246 |
--------------------------------------------------------------------------------
/src/lib/components/functional/Uploader.svelte:
--------------------------------------------------------------------------------
1 |
75 |
76 |
84 |
85 |
92 |
95 |
98 |
99 |
100 |
101 | Drop or click to {jpegify ? "JPEGIFY" : "convert"}
102 |
103 |
104 |
105 |
--------------------------------------------------------------------------------
/src/lib/components/layout/Dialogs.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 | {#if dialogList.length > 0}
18 |
29 | {#each dialogList as { id, title, message, buttons, type }, i}
30 | {#if i === 0}
31 |
32 | {/if}
33 | {/each}
34 |
35 | {/if}
36 |
--------------------------------------------------------------------------------
/src/lib/components/layout/Footer.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
38 |
--------------------------------------------------------------------------------
/src/lib/components/layout/Gradients.svelte:
--------------------------------------------------------------------------------
1 |
70 |
71 | {#if page.url.pathname === "/"}
72 |
79 |
82 |
83 | {/if}
84 |
85 |
92 |
93 | {#if page.url.pathname === "/convert/" && files.files.length === 1}
94 | {@const bgMask =
95 | "linear-gradient(to top, transparent 5%, rgba(0, 0, 0, 0.5) 100%)"}
96 |
107 | {/if}
108 |
109 |
174 |
--------------------------------------------------------------------------------
/src/lib/components/layout/MobileLogo.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
--------------------------------------------------------------------------------
/src/lib/components/layout/Navbar/Base.svelte:
--------------------------------------------------------------------------------
1 |
92 |
93 | {#snippet link(item: (typeof items)[0], index: number)}
94 | {@const Icon = item.icon}
95 |
108 |
109 | {#key item.name}
110 |
121 |
122 |
123 | {#if item.badge}
124 |
132 | {#key item.badge}
133 |
144 | {item.badge}
145 |
146 | {/key}
147 |
148 | {/if}
149 |
150 |
151 | {item.name}
152 |
153 |
154 | {/key}
155 |
156 |
157 | {/snippet}
158 |
159 |
160 |
161 | {@const linkRect = linkRects.at(selectedIndex) || linkRects[0]}
162 | {#if linkRect}
163 |
173 | {/if}
174 |
178 |
179 |
180 |
181 |
182 | {#each items as item, i (item.url)}
183 | {@render link(item, i)}
184 | {/each}
185 |
186 |
187 | {
189 | const isDark =
190 | document.documentElement.classList.contains("dark");
191 | setTheme(isDark ? "light" : "dark");
192 | }}
193 | class="w-14 h-full items-center justify-center hidden md:flex"
194 | >
195 |
196 |
197 |
198 |
199 |
200 |
201 |
--------------------------------------------------------------------------------
/src/lib/components/layout/Navbar/Desktop.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/lib/components/layout/Navbar/Mobile.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/layout/Navbar/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Desktop } from "./Desktop.svelte";
2 | export { default as Mobile } from "./Mobile.svelte";
--------------------------------------------------------------------------------
/src/lib/components/layout/PageContent.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 | {#key page.url.pathname}
13 |
27 |
40 | {@render children()}
41 |
42 |
43 | {/key}
44 |
45 |
--------------------------------------------------------------------------------
/src/lib/components/layout/Toasts.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
14 | {#each toastList as { id, type, message, durations }}
15 |
16 |
17 |
18 | {/each}
19 |
20 |
--------------------------------------------------------------------------------
/src/lib/components/layout/UploadRegion.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 | {#if $dropping}
8 |
18 | {/if}
19 |
20 |
47 |
--------------------------------------------------------------------------------
/src/lib/components/layout/index.ts:
--------------------------------------------------------------------------------
1 | export { default as UploadRegion } from './UploadRegion.svelte';
2 | export { default as Gradients } from './Gradients.svelte';
3 | export { default as Toasts } from './Toasts.svelte';
4 | export { default as Dialogs } from './Dialogs.svelte';
5 | export { default as PageContent } from './PageContent.svelte';
6 | export { default as MobileLogo } from './MobileLogo.svelte';
7 | export { default as Footer } from './Footer.svelte';
--------------------------------------------------------------------------------
/src/lib/components/visual/Panel.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 | {@render children?.()}
14 |
15 |
--------------------------------------------------------------------------------
/src/lib/components/visual/ProgressBar.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
24 |
25 |
64 |
--------------------------------------------------------------------------------
/src/lib/components/visual/Toast.svelte:
--------------------------------------------------------------------------------
1 |
52 |
53 |
66 |
75 |
removeToast(id)}
78 | >
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/src/lib/components/visual/Tooltip.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
36 | {@render children()}
37 | {#if showTooltip}
38 |
44 | {text}
45 |
46 | {/if}
47 |
48 |
49 |
107 |
--------------------------------------------------------------------------------
/src/lib/components/visual/effects/ProgressiveBlur.svelte:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 | {#each blurSteps as { blurIntensity, mask }, index}
46 |
54 | {/each}
55 |
64 |
68 |
69 |
--------------------------------------------------------------------------------
/src/lib/components/visual/svg/Logo.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/lib/components/visual/svg/LogoBeta.svelte:
--------------------------------------------------------------------------------
1 |
20 |
--------------------------------------------------------------------------------
/src/lib/components/visual/svg/VertVBig.svelte:
--------------------------------------------------------------------------------
1 |
8 |
13 |
18 |
23 |
24 |
32 |
33 |
34 |
35 |
36 |
44 |
45 |
46 |
47 |
48 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/lib/consts.ts:
--------------------------------------------------------------------------------
1 | import { PUB_ENV } from "$env/static/public";
2 |
3 | export const GITHUB_URL_VERT = "https://github.com/VERT-sh/VERT";
4 | export const GITHUB_URL_VERTD = "https://github.com/VERT-sh/vertd";
5 | export const GITHUB_API_URL = "https://api.github.com/repos/VERT-sh/VERT";
6 | export const DISCORD_URL = "https://discord.gg/kqevGxYPak";
7 | export const VERT_NAME =
8 | PUB_ENV === "development"
9 | ? "VERT Local"
10 | : PUB_ENV === "nightly"
11 | ? "VERT Nightly"
12 | : "VERT.sh";
13 | export const CONTACT_EMAIL = "hello@vert.sh";
14 |
--------------------------------------------------------------------------------
/src/lib/converters/converter.svelte.ts:
--------------------------------------------------------------------------------
1 | import type { VertFile } from "$lib/types";
2 |
3 | export class FormatInfo {
4 | public name: string;
5 |
6 | constructor(
7 | name: string,
8 | public fromSupported: boolean,
9 | public toSupported: boolean,
10 | ) {
11 | this.name = name;
12 | if (!this.name.startsWith(".")) {
13 | this.name = `.${this.name}`;
14 | }
15 |
16 | if (!this.fromSupported && !this.toSupported) {
17 | throw new Error("Format must support at least one direction");
18 | }
19 | }
20 | }
21 |
22 | /**
23 | * Base class for all converters.
24 | */
25 | export class Converter {
26 | /**
27 | * The public name of the converter.
28 | */
29 | public name: string = "Unknown";
30 | /**
31 | * List of supported formats.
32 | */
33 | public supportedFormats: FormatInfo[] = [];
34 | /**
35 | * Convert a file to a different format.
36 | * @param input The input file.
37 | * @param to The format to convert to. Includes the dot.
38 | */
39 | public ready: boolean = $state(false);
40 | public readonly reportsProgress: boolean = false;
41 |
42 | public async convert(
43 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
44 | input: VertFile,
45 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
46 | to: string,
47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
48 | ...args: any[]
49 | ): Promise {
50 | throw new Error("Not implemented");
51 | }
52 |
53 | public async valid(): Promise {
54 | return true;
55 | }
56 |
57 | public formatStrings(predicate?: (f: FormatInfo) => boolean) {
58 | if (predicate) {
59 | return this.supportedFormats.filter(predicate).map((f) => f.name);
60 | }
61 | return this.supportedFormats.map((f) => f.name);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/lib/converters/ffmpeg.svelte.ts:
--------------------------------------------------------------------------------
1 | import { VertFile } from "$lib/types";
2 | import { Converter, FormatInfo } from "./converter.svelte";
3 | import { FFmpeg } from "@ffmpeg/ffmpeg";
4 | import { browser } from "$app/environment";
5 | import { error, log } from "$lib/logger";
6 | import { addToast } from "$lib/store/ToastProvider";
7 |
8 | export class FFmpegConverter extends Converter {
9 | private ffmpeg: FFmpeg = null!;
10 | public name = "ffmpeg";
11 | public ready = $state(false);
12 |
13 | public supportedFormats = [
14 | new FormatInfo("mp3", true, true),
15 | new FormatInfo("wav", true, true),
16 | new FormatInfo("flac", true, true),
17 | new FormatInfo("ogg", true, true),
18 | new FormatInfo("aac", true, true),
19 | new FormatInfo("m4a", true, true),
20 | new FormatInfo("wma", true, true),
21 | new FormatInfo("amr", true, true),
22 | new FormatInfo("ac3", true, true),
23 | new FormatInfo("alac", true, false),
24 | new FormatInfo("aiff", true, true),
25 | ];
26 |
27 | public readonly reportsProgress = true;
28 |
29 | constructor() {
30 | super();
31 | log(["converters", this.name], `created converter`);
32 | if (!browser) return;
33 | try {
34 | // this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance
35 | this.ffmpeg = new FFmpeg();
36 | (async () => {
37 | const baseURL =
38 | "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
39 | await this.ffmpeg.load({
40 | coreURL: `${baseURL}/ffmpeg-core.js`,
41 | wasmURL: `${baseURL}/ffmpeg-core.wasm`,
42 | });
43 | this.ready = true;
44 | })();
45 | } catch (err) {
46 | error(["converters", this.name], `error loading ffmpeg: ${err}`);
47 | addToast(
48 | "error",
49 | `Error loading ffmpeg, some features may not work.`,
50 | );
51 | }
52 | }
53 |
54 | public async convert(input: VertFile, to: string): Promise {
55 | if (!to.startsWith(".")) to = `.${to}`;
56 | const ffmpeg = new FFmpeg();
57 | ffmpeg.on("progress", (progress) => {
58 | input.progress = progress.progress * 100;
59 | });
60 | const baseURL =
61 | "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
62 | await ffmpeg.load({
63 | coreURL: `${baseURL}/ffmpeg-core.js`,
64 | wasmURL: `${baseURL}/ffmpeg-core.wasm`,
65 | });
66 | const buf = new Uint8Array(await input.file.arrayBuffer());
67 | await ffmpeg.writeFile("input", buf);
68 | log(
69 | ["converters", this.name],
70 | `wrote ${input.name} to ffmpeg virtual fs`,
71 | );
72 | await ffmpeg.exec(["-i", "input", "output" + to]);
73 | log(["converters", this.name], `executed ffmpeg command`);
74 | const output = (await ffmpeg.readFile(
75 | "output" + to,
76 | )) as unknown as Uint8Array;
77 | log(
78 | ["converters", this.name],
79 | `read ${input.name.split(".").slice(0, -1).join(".") + to} from ffmpeg virtual fs`,
80 | );
81 | ffmpeg.terminate();
82 | return new VertFile(new File([output], input.name), to);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/lib/converters/index.ts:
--------------------------------------------------------------------------------
1 | import type { Categories } from "$lib/types";
2 | import { FFmpegConverter } from "./ffmpeg.svelte";
3 | import { PandocConverter } from "./pandoc.svelte";
4 | import { VertdConverter } from "./vertd.svelte";
5 | import { VipsConverter } from "./vips.svelte";
6 |
7 | export const converters = [
8 | new VipsConverter(),
9 | new FFmpegConverter(),
10 | new VertdConverter(),
11 | new PandocConverter(),
12 | ];
13 |
14 | export function getConverterByFormat(format: string) {
15 | for (const converter of converters) {
16 | if (converter.supportedFormats.some((f) => f.name === format)) {
17 | return converter;
18 | }
19 | }
20 | return null;
21 | }
22 |
23 | export const categories: Categories = {
24 | image: { formats: [""], canConvertTo: [] },
25 | video: { formats: [""], canConvertTo: [] }, // add "audio" when "nullptr/experimental-audio-to-video" is implemented
26 | audio: { formats: [""], canConvertTo: [] }, // add "video" when "nullptr/experimental-audio-to-video" is implemented
27 | docs: { formats: [""], canConvertTo: [] },
28 | };
29 |
30 | categories.audio.formats =
31 | converters
32 | .find((c) => c.name === "ffmpeg")
33 | ?.formatStrings((f) => f.toSupported) || [];
34 | categories.video.formats =
35 | converters
36 | .find((c) => c.name === "vertd")
37 | ?.formatStrings((f) => f.toSupported) || [];
38 | categories.image.formats =
39 | converters
40 | .find((c) => c.name === "libvips")
41 | ?.formatStrings((f) => f.toSupported) || [];
42 | categories.docs.formats =
43 | converters
44 | .find((c) => c.name === "pandoc")
45 | ?.formatStrings((f) => f.toSupported) || [];
--------------------------------------------------------------------------------
/src/lib/converters/pandoc.svelte.ts:
--------------------------------------------------------------------------------
1 | import { VertFile } from "$lib/types";
2 | import { Converter, FormatInfo } from "./converter.svelte";
3 | import { browser } from "$app/environment";
4 | import PandocWorker from "$lib/workers/pandoc?worker&url";
5 |
6 | export class PandocConverter extends Converter {
7 | public name = "pandoc";
8 | public ready = $state(false);
9 | public wasm: ArrayBuffer = null!;
10 |
11 | constructor() {
12 | super();
13 | if (!browser) return;
14 | (async () => {
15 | this.wasm = await fetch("/pandoc.wasm").then((r) =>
16 | r.arrayBuffer(),
17 | );
18 | this.ready = true;
19 | })();
20 | }
21 |
22 | public async convert(input: VertFile, to: string): Promise {
23 | const worker = new Worker(PandocWorker, {
24 | type: "module",
25 | });
26 | worker.postMessage({ type: "load", wasm: this.wasm });
27 | await waitForMessage(worker, "loaded");
28 | worker.postMessage({
29 | type: "convert",
30 | to,
31 | file: input.file,
32 | });
33 | const result = await waitForMessage(worker);
34 | if (result.type === "error") {
35 | worker.terminate();
36 | // throw new Error(result.error);
37 | switch (result.errorKind) {
38 | case "PandocUnknownReaderError": {
39 | throw new Error(
40 | `${input.from} is not a supported input format for documents.`,
41 | );
42 | }
43 |
44 | case "PandocUnknownWriterError": {
45 | throw new Error(
46 | `${to} is not a supported output format for documents.`,
47 | );
48 | }
49 |
50 | default:
51 | if (result.errorKind)
52 | throw new Error(
53 | `[${result.errorKind}] ${result.error}`,
54 | );
55 | else throw new Error(result.error);
56 | }
57 | }
58 | worker.terminate();
59 | if (!to.startsWith(".")) to = `.${to}`;
60 | return new VertFile(
61 | new File([result.output], input.name),
62 | result.isZip ? ".zip" : to,
63 | );
64 | }
65 |
66 | public supportedFormats = [
67 | new FormatInfo("docx", true, true),
68 | new FormatInfo("xml", true, true),
69 | new FormatInfo("doc", true, true),
70 | new FormatInfo("md", true, true),
71 | new FormatInfo("html", true, true),
72 | new FormatInfo("rtf", true, true),
73 | new FormatInfo("csv", true, true),
74 | new FormatInfo("tsv", true, true),
75 | new FormatInfo("json", true, true),
76 | new FormatInfo("rst", true, true),
77 | new FormatInfo("epub", true, true),
78 | new FormatInfo("odt", true, true),
79 | new FormatInfo("docbook", true, true),
80 | ];
81 | }
82 |
83 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
84 | function waitForMessage(worker: Worker, type?: string): Promise {
85 | return new Promise((resolve) => {
86 | const onMessage = (e: MessageEvent) => {
87 | if (type && e.data.type === type) {
88 | worker.removeEventListener("message", onMessage);
89 | resolve(e.data);
90 | } else {
91 | worker.removeEventListener("message", onMessage);
92 | resolve(e.data);
93 | }
94 | };
95 | worker.addEventListener("message", onMessage);
96 | });
97 | }
98 |
--------------------------------------------------------------------------------
/src/lib/converters/vertd.svelte.ts:
--------------------------------------------------------------------------------
1 | import { log } from "$lib/logger";
2 | import { Settings } from "$lib/sections/settings/index.svelte";
3 | import { VertFile } from "$lib/types";
4 | import { Converter, FormatInfo } from "./converter.svelte";
5 |
6 | interface VertdError {
7 | type: "error";
8 | data: string;
9 | }
10 |
11 | interface VertdSuccess {
12 | type: "success";
13 | data: T;
14 | }
15 |
16 | type VertdResponse = VertdError | VertdSuccess;
17 |
18 | interface UploadResponse {
19 | id: string;
20 | auth: string;
21 | from: string;
22 | to: null;
23 | completed: false;
24 | totalFrames: number;
25 | }
26 |
27 | interface RouteMap {
28 | "/api/upload": UploadResponse;
29 | "/api/version": string;
30 | }
31 |
32 | const vertdFetch = async (
33 | url: U,
34 | options: RequestInit,
35 | ): Promise => {
36 | const domain = Settings.instance.settings.vertdURL;
37 | const res = await fetch(`${domain}${url}`, options);
38 | const text = await res.text();
39 | let json: VertdResponse = null!;
40 | try {
41 | json = JSON.parse(text);
42 | } catch {
43 | throw new Error(text);
44 | }
45 |
46 | if (json.type === "error") {
47 | throw new Error(json.data);
48 | }
49 |
50 | return json.data as RouteMap[U];
51 | };
52 |
53 | // ws types
54 |
55 | export type ConversionSpeed =
56 | | "verySlow"
57 | | "slower"
58 | | "slow"
59 | | "medium"
60 | | "fast"
61 | | "ultraFast";
62 |
63 | interface StartJobMessage {
64 | type: "startJob";
65 | data: {
66 | token: string;
67 | jobId: string;
68 | to: string;
69 | speed: ConversionSpeed;
70 | };
71 | }
72 |
73 | interface ErrorMessage {
74 | type: "error";
75 | data: {
76 | message: string;
77 | };
78 | }
79 |
80 | interface ProgressMessage {
81 | type: "progressUpdate";
82 | data: ProgressData;
83 | }
84 |
85 | interface CompletedMessage {
86 | type: "jobFinished";
87 | data: {
88 | jobId: string;
89 | };
90 | }
91 |
92 | interface FpsProgress {
93 | type: "fps";
94 | data: number;
95 | }
96 |
97 | interface FrameProgress {
98 | type: "frame";
99 | data: number;
100 | }
101 |
102 | type ProgressData = FpsProgress | FrameProgress;
103 |
104 | type VertdMessage =
105 | | StartJobMessage
106 | | ErrorMessage
107 | | ProgressMessage
108 | | CompletedMessage;
109 |
110 | const progressEstimates = {
111 | upload: 25,
112 | convert: 50,
113 | download: 25,
114 | };
115 |
116 | const progressEstimate = (
117 | progress: number,
118 | type: keyof typeof progressEstimates,
119 | ) => {
120 | const previousValues = Object.values(progressEstimates)
121 | .filter((_, i) => i < Object.keys(progressEstimates).indexOf(type))
122 | .reduce((a, b) => a + b, 0);
123 | return progress * progressEstimates[type] + previousValues;
124 | };
125 |
126 | const uploadFile = async (file: VertFile): Promise => {
127 | const apiUrl = Settings.instance.settings.vertdURL;
128 | const formData = new FormData();
129 | formData.append("file", file.file, file.name);
130 | const xhr = new XMLHttpRequest();
131 | xhr.open("POST", `${apiUrl}/api/upload`, true);
132 |
133 | return new Promise((resolve, reject) => {
134 | xhr.upload.addEventListener("progress", (e) => {
135 | console.log(e);
136 | if (e.lengthComputable) {
137 | file.progress = progressEstimate(e.loaded / e.total, "upload");
138 | }
139 | });
140 |
141 | console.log("meow");
142 |
143 | xhr.onload = () => {
144 | try {
145 | console.log("xhr.responseText");
146 | const res = JSON.parse(xhr.responseText);
147 | if (res.type === "error") {
148 | reject(res.data);
149 | return;
150 | }
151 | resolve(res.data);
152 | } catch {
153 | console.log(xhr.responseText);
154 | reject(xhr.statusText);
155 | }
156 | };
157 |
158 | xhr.onerror = () => {
159 | console.log(xhr.statusText);
160 | reject(xhr.statusText);
161 | };
162 |
163 | xhr.send(formData);
164 | console.log("sent!");
165 | });
166 | };
167 |
168 | const downloadFile = async (url: string, file: VertFile): Promise => {
169 | const xhr = new XMLHttpRequest();
170 | xhr.open("GET", url, true);
171 | xhr.responseType = "blob";
172 |
173 | return new Promise((resolve, reject) => {
174 | xhr.addEventListener("progress", (e) => {
175 | if (e.lengthComputable) {
176 | file.progress = progressEstimate(
177 | e.loaded / e.total,
178 | "download",
179 | );
180 | }
181 | });
182 |
183 | xhr.onload = () => {
184 | if (xhr.status === 200) {
185 | resolve(xhr.response);
186 | } else {
187 | reject(xhr.statusText);
188 | }
189 | };
190 |
191 | xhr.onerror = () => {
192 | reject(xhr.statusText);
193 | };
194 |
195 | xhr.send();
196 | });
197 | };
198 |
199 | export class VertdConverter extends Converter {
200 | public name = "vertd";
201 | public ready = $state(false);
202 | public reportsProgress = true;
203 |
204 | public supportedFormats = [
205 | new FormatInfo("mkv", true, true),
206 | new FormatInfo("mp4", true, true),
207 | new FormatInfo("webm", true, true),
208 | new FormatInfo("avi", true, true),
209 | new FormatInfo("wmv", true, true),
210 | new FormatInfo("mov", true, true),
211 | new FormatInfo("gif", true, true),
212 | new FormatInfo("mts", true, true),
213 | new FormatInfo("ts", true, true),
214 | new FormatInfo("m2ts", true, true),
215 | ];
216 |
217 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
218 | private log: (...msg: any[]) => void = () => {};
219 |
220 | constructor() {
221 | super();
222 | this.log = (msg) => log(["converters", this.name], msg);
223 | this.log("created converter");
224 | this.log("not rly sure how to implement this :P");
225 | this.ready = true;
226 | }
227 |
228 | public async convert(input: VertFile, to: string): Promise {
229 | if (to.startsWith(".")) to = to.slice(1);
230 |
231 | const uploadRes = await uploadFile(input);
232 | console.log(uploadRes);
233 |
234 | return new Promise((resolve, reject) => {
235 | const apiUrl = Settings.instance.settings.vertdURL;
236 | const protocol = apiUrl.startsWith("https") ? "wss:" : "ws:";
237 | const ws = new WebSocket(
238 | `${protocol}//${apiUrl.replace("http://", "").replace("https://", "")}/api/ws`,
239 | );
240 | ws.onopen = () => {
241 | const speed = Settings.instance.settings.vertdSpeed;
242 | this.log("opened ws connection to vertd");
243 | const msg: StartJobMessage = {
244 | type: "startJob",
245 | data: {
246 | jobId: uploadRes.id,
247 | token: uploadRes.auth,
248 | to,
249 | speed,
250 | },
251 | };
252 | ws.send(JSON.stringify(msg));
253 | this.log("sent startJob message");
254 | };
255 |
256 | ws.onmessage = async (e) => {
257 | const msg: VertdMessage = JSON.parse(e.data);
258 | this.log(`received message ${msg.type}`);
259 | switch (msg.type) {
260 | case "progressUpdate": {
261 | const data = msg.data;
262 | if (data.type !== "frame") break;
263 | const frame = data.data;
264 | input.progress = progressEstimate(
265 | frame / uploadRes.totalFrames,
266 | "convert",
267 | );
268 | break;
269 | }
270 |
271 | case "jobFinished": {
272 | this.log("job finished");
273 | ws.close();
274 | const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`;
275 | this.log(`downloading from ${url}`);
276 | // const res = await fetch(url).then((res) => res.blob());
277 | const res = await downloadFile(url, input);
278 | resolve(new VertFile(new File([res], input.name), to));
279 | break;
280 | }
281 |
282 | case "error": {
283 | this.log(`error: ${msg.data.message}`);
284 | reject(msg.data.message);
285 | }
286 | }
287 | };
288 | });
289 | }
290 |
291 | public async valid(): Promise {
292 | if (!Settings.instance.settings.vertdURL) {
293 | return false;
294 | }
295 |
296 | try {
297 | await vertdFetch("/api/version", {
298 | method: "GET",
299 | });
300 | return true;
301 | } catch (e) {
302 | this.log(e as unknown as string);
303 | return false;
304 | }
305 | }
306 | }
307 |
--------------------------------------------------------------------------------
/src/lib/converters/vips.svelte.ts:
--------------------------------------------------------------------------------
1 | import { browser } from "$app/environment";
2 | import { error, log } from "$lib/logger";
3 | import { addToast } from "$lib/store/ToastProvider";
4 | import type { OmitBetterStrict, WorkerMessage } from "$lib/types";
5 | import { VertFile } from "$lib/types";
6 | import VipsWorker from "$lib/workers/vips?worker&url";
7 | import { Converter, FormatInfo } from "./converter.svelte";
8 |
9 | export class VipsConverter extends Converter {
10 | private worker: Worker = browser
11 | ? new Worker(VipsWorker, {
12 | type: "module",
13 | })
14 | : null!;
15 | private id = 0;
16 | public name = "libvips";
17 | public ready = $state(false);
18 |
19 | public supportedFormats = [
20 | new FormatInfo("png", true, true),
21 | new FormatInfo("jpeg", true, true),
22 | new FormatInfo("jpg", true, true),
23 | new FormatInfo("webp", true, true),
24 | new FormatInfo("gif", true, true),
25 | new FormatInfo("heic", true, false),
26 | new FormatInfo("ico", true, false),
27 | new FormatInfo("bmp", true, false),
28 | new FormatInfo("cur", true, false),
29 | new FormatInfo("ani", true, false),
30 | new FormatInfo("icns", true, false),
31 | new FormatInfo("nef", true, false),
32 | new FormatInfo("cr2", true, false),
33 | new FormatInfo("hdr", true, true),
34 | new FormatInfo("jpe", true, true),
35 | new FormatInfo("dng", true, false),
36 | new FormatInfo("mat", true, true),
37 | new FormatInfo("pbm", true, true),
38 | new FormatInfo("pfm", true, true),
39 | new FormatInfo("pgm", true, true),
40 | new FormatInfo("pnm", true, true),
41 | new FormatInfo("ppm", false, true),
42 | new FormatInfo("raw", false, true),
43 | new FormatInfo("tif", true, true),
44 | new FormatInfo("tiff", true, true),
45 | new FormatInfo("jfif", true, true),
46 | new FormatInfo("avif", true, true),
47 | ];
48 |
49 | public readonly reportsProgress = false;
50 |
51 | constructor() {
52 | super();
53 | log(["converters", this.name], `created converter`);
54 | if (!browser) return;
55 | log(["converters", this.name], `loading worker @ ${VipsWorker}`);
56 | this.worker.onmessage = (e) => {
57 | const message: WorkerMessage = e.data;
58 | log(["converters", this.name], `received message ${message.type}`);
59 | if (message.type === "loaded") {
60 | this.ready = true;
61 | } else if (message.type === "error") {
62 | error(
63 | ["converters", this.name],
64 | `error in worker: ${message.error}`,
65 | );
66 | addToast(
67 | "error",
68 | `Error in VIPS worker, image conversion may not work as expected.`,
69 | );
70 | throw new Error(message.error);
71 | }
72 | };
73 | }
74 |
75 | public async convert(
76 | input: VertFile,
77 | to: string,
78 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
79 | ...args: any[]
80 | ): Promise {
81 | const compression: number | undefined = args.at(0);
82 | log(["converters", this.name], `converting ${input.name} to ${to}`);
83 | const msg = {
84 | type: "convert",
85 | input: {
86 | file: input.file,
87 | name: input.name,
88 | to: input.to,
89 | from: input.from,
90 | },
91 | to,
92 | compression,
93 | } as WorkerMessage;
94 | const res = await this.sendMessage(msg);
95 |
96 | if (res.type === "finished") {
97 | log(["converters", this.name], `converted ${input.name} to ${to}`);
98 | return new VertFile(
99 | new File([res.output as unknown as BlobPart], input.name),
100 | res.zip ? ".zip" : to,
101 | );
102 | }
103 |
104 | if (res.type === "error") {
105 | throw new Error(res.error);
106 | }
107 |
108 | throw new Error("Unknown message type");
109 | }
110 |
111 | private sendMessage(
112 | message: OmitBetterStrict,
113 | ): Promise> {
114 | const id = this.id++;
115 | let resolved = false;
116 | return new Promise((resolve) => {
117 | const onMessage = (e: MessageEvent) => {
118 | if (e.data.id === id) {
119 | this.worker.removeEventListener("message", onMessage);
120 | resolve(e.data);
121 | resolved = true;
122 | }
123 | };
124 |
125 | setTimeout(() => {
126 | if (!resolved) {
127 | this.worker.removeEventListener("message", onMessage);
128 | throw new Error("Timeout");
129 | }
130 | }, 60000);
131 |
132 | this.worker.addEventListener("message", onMessage);
133 | const msg = { ...message, id, worker: null };
134 | try {
135 | this.worker.postMessage(msg);
136 | } catch (e) {
137 | error(["converters", this.name], e);
138 | }
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/lib/logger/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { browser } from "$app/environment";
3 |
4 | const randomColorFromStr = (str: string) => {
5 | // generate a pleasant color from a string, using HSL
6 | let hash = 0;
7 | for (let i = 0; i < str.length; i++) {
8 | hash = str.charCodeAt(i) + ((hash << 5) - hash);
9 | }
10 | const h = hash % 360;
11 | return `hsl(${h}, 75%, 71%)`;
12 | };
13 |
14 | const whiteOrBlack = (hsl: string) => {
15 | // determine if the text should be white or black based on the background color
16 | const [, , l] = hsl
17 | .replace("hsl(", "")
18 | .replace(")", "")
19 | .split(",")
20 | .map((v) => parseInt(v));
21 | return l > 70 ? "black" : "white";
22 | };
23 |
24 | export const log = (prefix: string | string[], ...args: any[]) => {
25 | const prefixes = Array.isArray(prefix) ? prefix : [prefix];
26 | if (!browser)
27 | return console.log(prefixes.map((p) => `[${p}]`).join(" "), ...args);
28 | const prefixesWithMeta = prefixes.map((p) => ({
29 | prefix: p,
30 | bgColor: randomColorFromStr(p),
31 | textColor: whiteOrBlack(randomColorFromStr(p)),
32 | }));
33 |
34 | console.log(
35 | `%c${prefixesWithMeta.map(({ prefix }) => prefix).join(" %c")}`,
36 | ...prefixesWithMeta.map(
37 | ({ bgColor, textColor }, i) =>
38 | `color: ${textColor}; background-color: ${bgColor}; margin-left: ${i === 0 ? 0 : -6}px; padding: 0px 4px 0 4px; border-radius: 0px 9999px 9999px 0px;`,
39 | ),
40 | ...args,
41 | );
42 | };
43 |
44 | export const error = (prefix: string | string[], ...args: any[]) => {
45 | const prefixes = Array.isArray(prefix) ? prefix : [prefix];
46 | if (!browser)
47 | return console.error(prefixes.map((p) => `[${p}]`).join(" "), ...args);
48 | const prefixesWithMeta = prefixes.map((p) => ({
49 | prefix: p,
50 | bgColor: randomColorFromStr(p),
51 | textColor: whiteOrBlack(randomColorFromStr(p)),
52 | }));
53 |
54 | console.error(
55 | `%c${prefixesWithMeta.map(({ prefix }) => prefix).join(" %c")}`,
56 | ...prefixesWithMeta.map(
57 | ({ bgColor, textColor }, i) =>
58 | `color: ${textColor}; background-color: ${bgColor}; margin-left: ${i === 0 ? 0 : -6}px; padding: 0px 4px 0 4px; border-radius: 0px 9999px 9999px 0px;`,
59 | ),
60 | ...args,
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/lib/parse/ani.ts:
--------------------------------------------------------------------------------
1 | // THIS CODE IS FROM https://github.com/captbaritone/webamp/blob/15b0312cb794973a0e615d894df942452e920c36/packages/ani-cursor/src/parser.ts
2 | // LICENSED UNDER MIT. (c) Jordan Eldredge and Webamp contributors
3 |
4 | // this code is ripped from their project because i didn't want to
5 | // re-invent the wheel, BUT the library they provide (ani-cursor)
6 | // doesn't expose the internals.
7 |
8 | import { RIFFFile } from "riff-file";
9 | import { unpackArray, unpackString } from "byte-data";
10 |
11 | type Chunk = {
12 | format: string;
13 | chunkId: string;
14 | chunkData: {
15 | start: number;
16 | end: number;
17 | };
18 | subChunks: Chunk[];
19 | };
20 |
21 | // https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3
22 | type AniMetadata = {
23 | cbSize: number; // Data structure size (in bytes)
24 | nFrames: number; // Number of images (also known as frames) stored in the file
25 | nSteps: number; // Number of frames to be displayed before the animation repeats
26 | iWidth: number; // Width of frame (in pixels)
27 | iHeight: number; // Height of frame (in pixels)
28 | iBitCount: number; // Number of bits per pixel
29 | nPlanes: number; // Number of color planes
30 | iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units)
31 | bfAttributes: number; // ANI attribute bit flags
32 | };
33 |
34 | type ParsedAni = {
35 | rate: number[] | null;
36 | seq: number[] | null;
37 | images: Uint8Array[];
38 | metadata: AniMetadata;
39 | artist: string | null;
40 | title: string | null;
41 | };
42 |
43 | const DWORD = { bits: 32, be: false, signed: false, fp: false };
44 |
45 | export function parseAni(arr: Uint8Array): ParsedAni {
46 | const riff = new RIFFFile();
47 |
48 | riff.setSignature(arr);
49 |
50 | const signature = riff.signature as Chunk;
51 | if (signature.format !== "ACON") {
52 | throw new Error(
53 | `Expected format. Expected "ACON", got "${signature.format}"`,
54 | );
55 | }
56 |
57 | // Helper function to get a chunk by chunkId and transform it if it's non-null.
58 | function mapChunk(
59 | chunkId: string,
60 | mapper: (chunk: Chunk) => T,
61 | ): T | null {
62 | const chunk = riff.findChunk(chunkId) as Chunk | null;
63 | return chunk == null ? null : mapper(chunk);
64 | }
65 |
66 | function readImages(chunk: Chunk, frameCount: number): Uint8Array[] {
67 | return chunk.subChunks.slice(0, frameCount).map((c) => {
68 | if (c.chunkId !== "icon") {
69 | throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`);
70 | }
71 | return arr.slice(c.chunkData.start, c.chunkData.end);
72 | });
73 | }
74 |
75 | const metadata = mapChunk("anih", (c) => {
76 | const words = unpackArray(
77 | arr,
78 | DWORD,
79 | c.chunkData.start,
80 | c.chunkData.end,
81 | );
82 | return {
83 | cbSize: words[0],
84 | nFrames: words[1],
85 | nSteps: words[2],
86 | iWidth: words[3],
87 | iHeight: words[4],
88 | iBitCount: words[5],
89 | nPlanes: words[6],
90 | iDispRate: words[7],
91 | bfAttributes: words[8],
92 | };
93 | });
94 |
95 | if (metadata == null) {
96 | throw new Error("Did not find anih");
97 | }
98 |
99 | const rate = mapChunk("rate", (c) => {
100 | return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
101 | });
102 | // chunkIds are always four chars, hence the trailing space.
103 | const seq = mapChunk("seq ", (c) => {
104 | return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
105 | });
106 |
107 | const lists = riff.findChunk("LIST", true) as Chunk[] | null;
108 | const imageChunk = lists?.find((c) => c.format === "fram");
109 | if (imageChunk == null) {
110 | throw new Error("Did not find fram LIST");
111 | }
112 |
113 | let images = readImages(imageChunk, metadata.nFrames);
114 |
115 | let title = null;
116 | let artist = null;
117 |
118 | const infoChunk = lists?.find((c) => c.format === "INFO");
119 | if (infoChunk != null) {
120 | infoChunk.subChunks.forEach((c) => {
121 | switch (c.chunkId) {
122 | case "INAM":
123 | title = unpackString(
124 | arr,
125 | c.chunkData.start,
126 | c.chunkData.end,
127 | );
128 | break;
129 | case "IART":
130 | artist = unpackString(
131 | arr,
132 | c.chunkData.start,
133 | c.chunkData.end,
134 | );
135 | break;
136 | case "LIST":
137 | // Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason?
138 | if (c.format === "fram") {
139 | images = readImages(c, metadata.nFrames);
140 | }
141 | break;
142 |
143 | default:
144 | // Unexpected subchunk
145 | }
146 | });
147 | }
148 |
149 | return { images, rate, seq, metadata, artist, title };
150 | }
151 |
--------------------------------------------------------------------------------
/src/lib/parse/icns/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/src/lib/parse/icns/index.ts
--------------------------------------------------------------------------------
/src/lib/sections/about/Credits.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 | {#snippet contributor(
10 | name: string,
11 | github: string,
12 | avatar: string,
13 | role?: string,
14 | smaller?: boolean,
15 | )}
16 |
17 |
23 |
33 |
34 | {#if role}
35 |
36 |
41 | {name}
42 |
43 |
{role}
44 |
45 | {/if}
46 |
47 | {/snippet}
48 |
49 |
50 |
51 |
52 |
53 |
54 | Credits
55 |
56 |
57 |
58 | If you would like to contact the development team, please use the email
59 | found on the "Resources" card.
60 |
61 |
62 |
63 |
64 |
65 | {#each mainContribs as contrib}
66 | {@const { name, github, avatar, role } = contrib}
67 | {@render contributor(name, github, avatar, role)}
68 | {/each}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
Notable contributors
76 |
77 |
78 | We'd like to thank these people for their major
79 | contributions to VERT.
80 |
81 |
82 | {#each notableContribs as contrib}
83 | {@const { name, github, avatar, role } = contrib}
84 | {@render contributor(name, github, avatar, role, true)}
85 | {/each}
86 |
87 |
88 |
89 |
90 |
91 |
92 |
124 |
125 | {#if ghContribs && ghContribs.length > 0}
126 |
127 | {#each ghContribs as contrib}
128 | {@const { name, github, avatar } = contrib}
129 | {@render contributor(name, github, avatar)}
130 | {/each}
131 |
132 | {/if}
133 |
134 |
Libraries
135 |
136 | A big thanks to FFmpeg (audio, video), libvips (images) and
137 | Pandoc (documents) for maintaining such excellent libraries for
138 | so many years. VERT relies on them to provide you with your
139 | conversions.
140 |
141 |
142 |
144 |
--------------------------------------------------------------------------------
/src/lib/sections/about/Donate.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
57 |
58 | {#snippet donor(name: string, amount: number | string, avatar: string)}
59 |
68 | {/snippet}
69 |
70 |
71 |
72 |
73 |
76 |
77 |
78 | Donate to VERT
79 |
80 |
81 | With your support, we can keep maintaining and improving VERT.
82 |
83 |
84 |
85 |
96 |
97 | (type = "one-time")}
99 | class={clsx(
100 | "btn flex-1 p-4 rounded-lg flex items-center justify-center",
101 | {
102 | "!scale-100": !$effects,
103 | "bg-accent-red text-black": type === "one-time",
104 | },
105 | )}
106 | >
107 |
108 | One-time
109 |
110 |
111 | (type = "monthly")}
114 | class={clsx(
115 | "btn flex-1 p-4 rounded-lg flex items-center justify-center",
116 | {
117 | "!scale-100": !$effects,
118 | "bg-accent-red text-black": type === "monthly",
119 | },
120 | )}
121 | >
122 |
123 | Monthly
124 |
125 |
126 |
127 | {#each presetAmounts as preset}
128 |
amountClick(preset)}
130 | class={clsx(
131 | "btn flex-1 p-4 rounded-lg flex items-center justify-center",
132 | {
133 | "!scale-100": !$effects,
134 | "bg-accent-red text-black": amount === preset,
135 | },
136 | )}
137 | >
138 | ${preset} USD
139 |
140 | {/each}
141 |
142 |
148 |
149 |
150 |
151 |
152 |
153 |
{
157 | if (e.key === "Enter") {
158 | paymentClick();
159 | }
160 | }}
161 | onclick={paymentClick}
162 | class={clsx(
163 | "btn flex-1 p-3 relative rounded-3xl bg-accent-red border-2 border-accent-red h-14 text-black",
164 | {
165 | "h-64 rounded-2xl bg-transparent cursor-auto !scale-100 -mt-10 -mb-2":
166 | paying,
167 | "!scale-100": !$effects,
168 | },
169 | )}
170 | style="transition: height {payDuration}ms {transition}, border-radius {payDuration}ms {transition}, background-color {payDuration}ms {transition}, transform {payDuration}ms {transition}, margin {payDuration}ms {transition};"
171 | >
172 |
173 | Pay now
174 |
175 |
176 |
177 |
178 |
179 |
Our top donors
180 | {#if donors && donors.length > 0}
181 |
182 | People like these fuel the things we love to do. Thank you
183 | so much!
184 |
185 | {:else}
186 |
187 | Seems like no one has donated yet... so if you do, you will
188 | pop up here!
189 |
190 | {/if}
191 |
192 |
193 | {#if donors && donors.length > 0}
194 |
195 | {#each donors as dono}
196 | {@const { name, amount, avatar } = dono}
197 | {@render donor(name, amount || "0.00", avatar)}
198 | {/each}
199 |
200 | {/if}
201 |
202 |
203 |
204 |
214 |
--------------------------------------------------------------------------------
/src/lib/sections/about/Resources.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
18 |
19 |
20 | Resources
21 |
22 |
57 |
58 |
--------------------------------------------------------------------------------
/src/lib/sections/about/Vertd.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
33 | Sponsors
34 |
35 |
36 |
45 |
46 | Want to support us? Contact a developer in the Discord
50 | server, or send an email to
51 |
52 |
58 | {#if copied}
59 |
60 | {:else}
61 |
62 | {/if}
63 | hello@vert.sh
64 |
65 | !
66 |
67 |
68 |
69 |
70 |
79 |
--------------------------------------------------------------------------------
/src/lib/sections/about/Why.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
11 |
12 |
13 | Why VERT?
14 |
15 |
16 | File converters have always disappointed us. They're ugly,
17 | riddled with ads, and most importantly; slow. We decided to solve this
18 | problem once and for all by making an alternative that solves all those
19 | problems, and more.
20 |
21 | All non-video files are converted completely on-device; this means that there's
22 | no delay between sending and receiving the files from a server, and we never
23 | get to snoop on the files you convert.
24 |
25 |
26 | Video files get uploaded to our lightning-fast RTX 4000 Ada server. Your
27 | videos stay on there for an hour, or after they're converted (in the case
28 | of the input which you upload) or downloaded (in the case of the output which
29 | is to be downloaded), whichever comes first.
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/lib/sections/about/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Credits } from "./Credits.svelte";
2 | export { default as Donate } from "./Donate.svelte";
3 | export { default as Resources } from "./Resources.svelte";
4 | export { default as Why } from "./Why.svelte";
5 | export { default as Vertd } from "./Vertd.svelte";
6 |
--------------------------------------------------------------------------------
/src/lib/sections/settings/Appearance.svelte:
--------------------------------------------------------------------------------
1 |
64 |
65 |
66 |
67 |
68 |
73 | Appearance
74 |
75 |
76 |
77 |
78 |
Brightness theme
79 |
80 | Want a sunny flash-bang, or a quiet lonely night?
81 |
82 |
83 |
84 |
85 | setTheme("light")}
88 | class="btn {$effects ? "" : "!scale-100"} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
89 | >
90 |
91 | Light
92 |
93 |
94 | setTheme("dark")}
97 | class="btn {$effects ? "" : "!scale-100"} flex-1 p-4 rounded-lg text-black flex items-center justify-center"
98 | >
99 |
100 | Dark
101 |
102 |
103 |
104 |
105 |
106 |
107 |
Effect settings
108 |
109 | Would you like fancy effects, or a more static
110 | experience?
111 |
112 |
113 |
114 |
115 |
setEffects(true)}
118 | class="btn {$effects ? "" : "!scale-100"} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
119 | >
120 |
121 | Enable
122 |
123 |
124 |
setEffects(false)}
127 | class="btn {$effects ? "" : "!scale-100"} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
128 | >
129 |
130 | Disable
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
--------------------------------------------------------------------------------
/src/lib/sections/settings/Conversion.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
18 | Conversion
19 |
20 |
21 |
22 |
23 |
File name format
24 |
25 | This will determine the name of the file on download, not including the file extension.
29 | You can put these following templates in the format, which
30 | will be replaced with the relevant information:
31 | %name%
32 | for the original file name,
33 | %extension%
34 | for the original file extension, and
35 | %date%
36 | for a date string of when the file was converted.
37 |
38 |
39 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/lib/sections/settings/Privacy.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
18 | Privacy
19 |
20 |
21 |
22 |
23 |
Plausible analytics
24 |
25 | We use Plausible , a privacy-focused analytics tool, to gather
30 | completely anonymous statistics. All data is anonymized
31 | and aggregated, and no identifiable information is ever
32 | sent or stored. You can view the analytics
33 | here and choose to opt out below.
38 |
39 |
40 |
41 |
42 |
(settings.plausible = true)}
44 | class="btn {$effects
45 | ? ''
46 | : '!scale-100'} {settings.plausible
47 | ? 'selected'
48 | : ''} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
49 | >
50 |
51 | Opt-in
52 |
53 |
54 |
(settings.plausible = false)}
56 | class="btn {$effects
57 | ? ''
58 | : '!scale-100'} {settings.plausible
59 | ? ''
60 | : 'selected'} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
61 | >
62 |
63 | Opt-out
64 |
65 |
66 |
67 |
68 |
69 |
71 |
--------------------------------------------------------------------------------
/src/lib/sections/settings/Vertd.svelte:
--------------------------------------------------------------------------------
1 |
49 |
50 |
51 |
52 |
53 |
58 | Video conversion
59 |
60 |
67 | status: {vertdCommit
68 | ? vertdCommit === "loading"
69 | ? "loading..."
70 | : `available, commit id ${vertdCommit}`
71 | : "unavailable (is the url right?)"}
72 |
73 |
74 |
75 |
76 | The vertd
project is a server wrapper for FFmpeg.
77 | This allows you to convert videos through the convenience of
78 | VERT's web interface, while still being able to harness the power
79 | of your GPU to do it as quickly as possible.
80 |
81 |
82 | We host a public instance for your convenience, but it is
83 | quite easy to host your own on your PC or server if you know
84 | what you are doing. You can download the server binaries here - the process of setting this up will become easier in the
88 | future, so stay tuned!
89 |
90 |
91 |
Instance URL
92 |
97 |
98 |
99 |
100 |
Conversion speed
101 |
102 | This describes the tradeoff between speed and
103 | quality. Faster speeds will result in lower quality,
104 | but will get the job done quicker.
105 |
106 |
107 |
{
118 | switch (settings.vertdSpeed) {
119 | case "verySlow":
120 | return "Very Slow";
121 | case "slower":
122 | return "Slower";
123 | case "slow":
124 | return "Slow";
125 | case "medium":
126 | return "Medium";
127 | case "fast":
128 | return "Fast";
129 | case "ultraFast":
130 | return "Ultra Fast";
131 | }
132 | })()}
133 | onselect={(selected) => {
134 | switch (selected) {
135 | case "Very Slow":
136 | settings.vertdSpeed = "verySlow";
137 | break;
138 | case "Slower":
139 | settings.vertdSpeed = "slower";
140 | break;
141 | case "Slow":
142 | settings.vertdSpeed = "slow";
143 | break;
144 | case "Medium":
145 | settings.vertdSpeed = "medium";
146 | break;
147 | case "Fast":
148 | settings.vertdSpeed = "fast";
149 | break;
150 | case "Ultra Fast":
151 | settings.vertdSpeed = "ultraFast";
152 | break;
153 | }
154 | }}
155 | />
156 |
157 |
158 |
159 |
160 |
161 |
--------------------------------------------------------------------------------
/src/lib/sections/settings/index.svelte.ts:
--------------------------------------------------------------------------------
1 | import { PUB_VERTD_URL } from "$env/static/public";
2 | import type { ConversionSpeed } from "$lib/converters/vertd.svelte";
3 |
4 | export { default as Appearance } from "./Appearance.svelte";
5 | export { default as Conversion } from "./Conversion.svelte";
6 | export { default as Vertd } from "./Vertd.svelte";
7 | export { default as Privacy } from "./Privacy.svelte";
8 |
9 | export interface ISettings {
10 | filenameFormat: string;
11 | plausible: boolean;
12 | vertdURL: string;
13 | vertdSpeed: ConversionSpeed;
14 | }
15 |
16 | export class Settings {
17 | public static instance = new Settings();
18 |
19 | public settings: ISettings = $state({
20 | filenameFormat: "VERT_%name%",
21 | plausible: true,
22 | vertdURL: PUB_VERTD_URL,
23 | vertdSpeed: "slow",
24 | });
25 |
26 | public save() {
27 | localStorage.setItem("settings", JSON.stringify(this.settings));
28 | }
29 |
30 | public load() {
31 | const ls = localStorage.getItem("settings");
32 | if (!ls) return;
33 | const settings: ISettings = JSON.parse(ls);
34 | this.settings = {
35 | ...this.settings,
36 | ...settings,
37 | };
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/store/DialogProvider.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 |
3 | type DialogType = "success" | "error" | "info" | "warning";
4 |
5 | export interface Dialog {
6 | id: number;
7 | title: string;
8 | message: string;
9 | buttons: {
10 | text: string;
11 | action: () => void;
12 | }[];
13 | type: DialogType;
14 | }
15 |
16 | const dialogs = writable([]);
17 |
18 | let dialogId = 0;
19 |
20 | function addDialog(
21 | title: string,
22 | message: string,
23 | buttons: Dialog["buttons"],
24 | type: DialogType,
25 | ) {
26 | const id = dialogId++;
27 |
28 | const newDialog: Dialog = {
29 | id,
30 | title,
31 | message,
32 | buttons,
33 | type,
34 | };
35 | dialogs.update((currentDialogs) => [...currentDialogs, newDialog]);
36 |
37 | return id;
38 | }
39 |
40 | function removeDialog(id: number) {
41 | dialogs.update((currentDialogs) =>
42 | currentDialogs.filter((dialog) => dialog.id !== id),
43 | );
44 | }
45 |
46 | export { dialogs, addDialog, removeDialog };
47 |
--------------------------------------------------------------------------------
/src/lib/store/ToastProvider.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 |
3 | export type ToastType = "success" | "error" | "info" | "warning";
4 |
5 | export interface Toast {
6 | id: number;
7 | type: ToastType;
8 | message: string;
9 | disappearing: boolean;
10 | durations: {
11 | enter: number;
12 | stay: number;
13 | exit: number;
14 | };
15 | }
16 |
17 | const toasts = writable([]);
18 |
19 | let toastId = 0;
20 |
21 | function addToast(
22 | type: ToastType,
23 | message: string,
24 | disappearing?: boolean,
25 | durations?: { enter: number; stay: number; exit: number },
26 | ) {
27 | const id = toastId++;
28 |
29 | durations = durations ?? {
30 | enter: 300,
31 | stay: disappearing || disappearing === undefined ? 5000 : 86400000, // 24h cause why not
32 | exit: 500,
33 | };
34 |
35 | const newToast: Toast = {
36 | id,
37 | type,
38 | message,
39 | disappearing: disappearing ?? true,
40 | durations,
41 | };
42 | toasts.update((currentToasts) => [...currentToasts, newToast]);
43 |
44 | setTimeout(
45 | () => {
46 | removeToast(id);
47 | },
48 | durations.enter + durations.stay + durations.exit,
49 | );
50 |
51 | return id;
52 | }
53 |
54 | function removeToast(id: number) {
55 | toasts.update((currentToasts) =>
56 | currentToasts.filter((toast) => toast.id !== id),
57 | );
58 | }
59 |
60 | export { toasts, addToast, removeToast };
61 |
--------------------------------------------------------------------------------
/src/lib/store/index.svelte.ts:
--------------------------------------------------------------------------------
1 | import { browser } from "$app/environment";
2 | import { converters } from "$lib/converters";
3 | import { error, log } from "$lib/logger";
4 | import { VertFile } from "$lib/types";
5 | import { parseBlob, selectCover } from "music-metadata";
6 | import { writable } from "svelte/store";
7 | import { addDialog } from "./DialogProvider";
8 | import PQueue from "p-queue";
9 |
10 | class Files {
11 | public files = $state([]);
12 |
13 | public requiredConverters = $derived(
14 | Array.from(new Set(files.files.map((f) => f.converters).flat())),
15 | );
16 |
17 | public ready = $derived(
18 | this.files.length === 0
19 | ? false
20 | : this.requiredConverters.every((f) => f?.ready) &&
21 | this.files.every((f) => !f.processing),
22 | );
23 | public results = $derived(
24 | this.files.length === 0 ? false : this.files.every((f) => f.result),
25 | );
26 |
27 | private thumbnailQueue = new PQueue({
28 | concurrency: browser ? navigator.hardwareConcurrency || 4 : 4,
29 | });
30 |
31 | private _addThumbnail = async (file: VertFile) => {
32 | this.thumbnailQueue.add(async () => {
33 | const isAudio = converters
34 | .find((c) => c.name === "ffmpeg")
35 | ?.formatStrings()
36 | ?.includes(file.from.toLowerCase());
37 | const isVideo = converters
38 | .find((c) => c.name === "vertd")
39 | ?.formatStrings()
40 | ?.includes(file.from.toLowerCase());
41 |
42 | try {
43 | if (isAudio) {
44 | // try to get the thumbnail from the audio via music-metadata
45 | const { common } = await parseBlob(file.file, {
46 | skipPostHeaders: true,
47 | });
48 | const cover = selectCover(common.picture);
49 | if (cover) {
50 | const blob = new Blob([cover.data], {
51 | type: cover.format,
52 | });
53 | file.blobUrl = URL.createObjectURL(blob);
54 | }
55 | } else if (isVideo) {
56 | // video
57 | file.blobUrl = await this._generateThumbnailFromMedia(
58 | file.file,
59 | true,
60 | );
61 | } else {
62 | // image
63 | file.blobUrl = await this._generateThumbnailFromMedia(
64 | file.file,
65 | false,
66 | );
67 | }
68 | } catch (e) {
69 | error(["files"], e);
70 | }
71 | });
72 | };
73 |
74 | private async _generateThumbnailFromMedia(
75 | file: File,
76 | isVideo: boolean,
77 | ): Promise {
78 | const maxSize = 180;
79 | const mediaElement = isVideo
80 | ? document.createElement("video")
81 | : new Image();
82 | mediaElement.src = URL.createObjectURL(file);
83 |
84 | await new Promise((resolve) => {
85 | if (isVideo) {
86 | (mediaElement as HTMLVideoElement).onloadeddata = resolve;
87 | } else {
88 | (mediaElement as HTMLImageElement).onload = resolve;
89 | }
90 | });
91 |
92 | const canvas = document.createElement("canvas");
93 | const ctx = canvas.getContext("2d");
94 | if (!ctx) return undefined;
95 |
96 | const width = isVideo
97 | ? (mediaElement as HTMLVideoElement).videoWidth
98 | : (mediaElement as HTMLImageElement).width;
99 | const height = isVideo
100 | ? (mediaElement as HTMLVideoElement).videoHeight
101 | : (mediaElement as HTMLImageElement).height;
102 |
103 | const scale = Math.max(maxSize / width, maxSize / height);
104 | canvas.width = width * scale;
105 | canvas.height = height * scale;
106 | ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height);
107 | const url = canvas.toDataURL();
108 | canvas.remove();
109 | return url;
110 | }
111 |
112 | private _warningShown = false;
113 | private _add(file: VertFile | File) {
114 | if (file instanceof VertFile) {
115 | this.files.push(file);
116 | this._addThumbnail(file);
117 | } else {
118 | const format = "." + file.name.split(".").pop()?.toLowerCase();
119 | if (!format) {
120 | log(["files"], `no extension found for ${file.name}`);
121 | return;
122 | }
123 | const converter = converters.find((c) =>
124 | c
125 | .formatStrings()
126 | .includes(format || ".somenonexistentextension"),
127 | );
128 | if (!converter) {
129 | log(["files"], `no converter found for ${file.name}`);
130 | this.files.push(new VertFile(file, format));
131 | return;
132 | }
133 | const to = converter.formatStrings().find((f) => f !== format);
134 | if (!to) {
135 | log(["files"], `no output format found for ${file.name}`);
136 | return;
137 | }
138 | const vf = new VertFile(file, to);
139 | this.files.push(vf);
140 | this._addThumbnail(vf);
141 |
142 | const isVideo = converter.name === "vertd";
143 | const acceptedExternalWarning =
144 | localStorage.getItem("acceptedExternalWarning") === "true";
145 | if (isVideo && !acceptedExternalWarning && !this._warningShown) {
146 | this._warningShown = true;
147 | const message =
148 | "If you choose to convert into a video format, some of your files will be uploaded to an external server to be converted. Do you want to continue?";
149 | const buttons = [
150 | {
151 | text: "No",
152 | action: () => {
153 | this.files = [
154 | ...this.files.filter(
155 | (f) =>
156 | !f.converters
157 | .map((c) => c.name)
158 | .includes("vertd"),
159 | ),
160 | ];
161 | this._warningShown = false;
162 | },
163 | },
164 | {
165 | text: "Yes",
166 | action: () => {
167 | localStorage.setItem(
168 | "acceptedExternalWarning",
169 | "true",
170 | );
171 | this._warningShown = false;
172 | },
173 | },
174 | ];
175 | addDialog(
176 | "External server warning",
177 | message,
178 | buttons,
179 | "warning",
180 | );
181 | }
182 | }
183 | }
184 |
185 | public add(file: VertFile | null | undefined): void;
186 | public add(file: File | null | undefined): void;
187 | public add(file: File[] | null | undefined): void;
188 | public add(file: VertFile[] | null | undefined): void;
189 | public add(file: FileList | null | undefined): void;
190 | public add(
191 | file:
192 | | VertFile
193 | | File
194 | | VertFile[]
195 | | File[]
196 | | FileList
197 | | null
198 | | undefined,
199 | ) {
200 | if (!file) return;
201 | if (Array.isArray(file) || file instanceof FileList) {
202 | for (const f of file) {
203 | this._add(f);
204 | }
205 | } else {
206 | this._add(file);
207 | }
208 | }
209 |
210 | public async convertAll() {
211 | const promiseFns = this.files.map((f) => () => f.convert());
212 | const coreCount = navigator.hardwareConcurrency || 4;
213 | const queue = new PQueue({ concurrency: coreCount });
214 | await Promise.all(promiseFns.map((fn) => queue.add(fn)));
215 | }
216 |
217 | public async downloadAll() {
218 | if (files.files.length === 0) return;
219 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
220 | const dlFiles: any[] = [];
221 | for (let i = 0; i < files.files.length; i++) {
222 | const file = files.files[i];
223 | const result = file.result;
224 | if (!result) {
225 | error(["files"], "No result found");
226 | continue;
227 | }
228 | dlFiles.push({
229 | name: file.file.name.replace(/\.[^/.]+$/, "") + result.to,
230 | lastModified: Date.now(),
231 | input: await result.file.arrayBuffer(),
232 | });
233 | }
234 | const { downloadZip } = await import("client-zip");
235 | const blob = await downloadZip(dlFiles, "converted.zip").blob();
236 | const url = URL.createObjectURL(blob);
237 |
238 | const settings = JSON.parse(localStorage.getItem("settings") ?? "{}");
239 | const filenameFormat = settings.filenameFormat ?? "VERT_%name%";
240 |
241 | const format = (name: string) => {
242 | const date = new Date().toISOString();
243 | return name
244 | .replace(/%date%/g, date)
245 | .replace(/%name%/g, "Multi")
246 | .replace(/%extension%/g, "");
247 | };
248 |
249 | const a = document.createElement("a");
250 | a.href = url;
251 | a.download = `${format(filenameFormat)}.zip`;
252 | a.click();
253 | URL.revokeObjectURL(url);
254 | a.remove();
255 | }
256 | }
257 |
258 | export function setTheme(themeTo: "light" | "dark") {
259 | document.documentElement.classList.remove("light", "dark");
260 | document.documentElement.classList.add(themeTo);
261 | localStorage.setItem("theme", themeTo);
262 | log(["theme"], `set to ${themeTo}`);
263 | theme.set(themeTo);
264 |
265 | // Lock dark reader if it's set to dark mode
266 | if (themeTo === "dark") {
267 | const lock = document.createElement("meta");
268 | lock.name = "darkreader-lock";
269 | document.head.appendChild(lock);
270 | } else {
271 | const lock = document.querySelector('meta[name="darkreader-lock"]');
272 | if (lock) lock.remove();
273 | }
274 | }
275 |
276 | export function setEffects(effectsEnabled: boolean) {
277 | localStorage.setItem("effects", effectsEnabled.toString());
278 | log(["effects"], `set to ${effectsEnabled}`);
279 | effects.set(effectsEnabled);
280 | }
281 |
282 | export const files = new Files();
283 | export const showGradient = writable(true);
284 | export const gradientColor = writable("");
285 | export const goingLeft = writable(false);
286 | export const dropping = writable(false);
287 | export const vertdLoaded = writable(false);
288 |
289 | export const isMobile = writable(false);
290 | export const effects = writable(true);
291 | export const theme = writable<"light" | "dark">("light");
292 |
--------------------------------------------------------------------------------
/src/lib/types/conversion-worker.ts:
--------------------------------------------------------------------------------
1 | import { VertFile } from "./file.svelte";
2 |
3 | interface ConvertMessage {
4 | type: "convert";
5 | input: VertFile;
6 | to: string;
7 | compression: number | null;
8 | }
9 |
10 | interface FinishedMessage {
11 | type: "finished";
12 | output: ArrayBufferLike;
13 | zip?: boolean;
14 | }
15 |
16 | interface LoadedMessage {
17 | type: "loaded";
18 | }
19 |
20 | interface ErrorMessage {
21 | type: "error";
22 | error: string;
23 | }
24 |
25 | export type WorkerMessage = (
26 | | ConvertMessage
27 | | FinishedMessage
28 | | LoadedMessage
29 | | ErrorMessage
30 | ) & {
31 | id: number;
32 | };
33 |
--------------------------------------------------------------------------------
/src/lib/types/file.svelte.ts:
--------------------------------------------------------------------------------
1 | import { converters } from "$lib/converters";
2 | import type { Converter } from "$lib/converters/converter.svelte";
3 | import { error } from "$lib/logger";
4 | import { addToast } from "$lib/store/ToastProvider";
5 |
6 | export class VertFile {
7 | public id: string = Math.random().toString(36).slice(2, 8);
8 | public readonly file: File;
9 |
10 | public get from() {
11 | return ("." + this.file.name.split(".").pop() || "").toLowerCase();
12 | }
13 |
14 | public get name() {
15 | return this.file.name;
16 | }
17 |
18 | public progress = $state(0);
19 | public result = $state(null);
20 |
21 | public to = $state("");
22 |
23 | public blobUrl = $state();
24 |
25 | public processing = $state(false);
26 |
27 | public converters: Converter[] = [];
28 |
29 | public findConverters(supportedFormats: string[] = [this.from]) {
30 | const converter = this.converters.filter((converter) =>
31 | converter.formatStrings().map((f) => supportedFormats.includes(f)),
32 | );
33 | return converter;
34 | }
35 |
36 | public findConverter() {
37 | const converter = this.converters.find(
38 | (converter) =>
39 | converter.formatStrings().includes(this.from) &&
40 | converter.formatStrings().includes(this.to),
41 | );
42 | return converter;
43 | }
44 |
45 | constructor(file: File, to: string, blobUrl?: string) {
46 | const ext = file.name.split(".").pop();
47 | const newFile = new File(
48 | [file.slice(0, file.size, file.type)],
49 | `${file.name.split(".").slice(0, -1).join(".")}.${ext?.toLowerCase()}`,
50 | );
51 | this.file = newFile;
52 | this.to = to;
53 | this.converters = converters.filter((c) =>
54 | c.formatStrings().includes(this.from),
55 | );
56 | this.convert = this.convert.bind(this);
57 | this.download = this.download.bind(this);
58 | this.blobUrl = blobUrl;
59 | }
60 |
61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
62 | public async convert(...args: any[]) {
63 | if (!this.converters.length) throw new Error("No converters found");
64 | const converter = this.findConverter();
65 | if (!converter) throw new Error("No converter found");
66 | this.result = null;
67 | this.progress = 0;
68 | this.processing = true;
69 | let res;
70 | try {
71 | res = await converter.convert(this, this.to, ...args);
72 | this.result = res;
73 | } catch (err) {
74 | const castedErr = err as Error;
75 | error(["files"], castedErr.message);
76 | addToast(
77 | "error",
78 | `Error converting file ${this.file.name}: ${castedErr.message || castedErr}`,
79 | );
80 | this.result = null;
81 | }
82 | this.processing = false;
83 | return res;
84 | }
85 |
86 | public async download() {
87 | if (!this.result) throw new Error("No result found");
88 |
89 | // give the freedom to the converter to set the extension (ie. pandoc uses this to output zips)
90 | let to = this.result.to;
91 | if (!to.startsWith(".")) to = `.${to}`;
92 |
93 | const settings = JSON.parse(localStorage.getItem("settings") ?? "{}");
94 | const filenameFormat = settings.filenameFormat ?? "VERT_%name%";
95 |
96 | const format = (name: string) => {
97 | const date = new Date().toISOString();
98 | const baseName = this.file.name.replace(/\.[^/.]+$/, "");
99 | const originalExtension = this.file.name.split(".").pop()!;
100 | return name
101 | .replace(/%date%/g, date)
102 | .replace(/%name%/g, baseName)
103 | .replace(/%extension%/g, originalExtension);
104 | };
105 |
106 | const blob = URL.createObjectURL(
107 | new Blob([await this.result.file.arrayBuffer()], {
108 | type: to.slice(1),
109 | }),
110 | );
111 | const a = document.createElement("a");
112 | a.href = blob;
113 | a.download = `${format(filenameFormat)}${to}`;
114 | // force it to not open in a new tab
115 | a.target = "_blank";
116 | a.style.display = "none";
117 | a.click();
118 | URL.revokeObjectURL(blob);
119 | a.remove();
120 | }
121 | }
122 |
123 | export interface Categories {
124 | [key: string]: {
125 | formats: string[];
126 | canConvertTo?: string[];
127 | };
128 | }
129 |
--------------------------------------------------------------------------------
/src/lib/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./file.svelte";
2 | export * from "./util";
3 | export * from "./conversion-worker";
--------------------------------------------------------------------------------
/src/lib/types/util.ts:
--------------------------------------------------------------------------------
1 | export type OmitBetterStrict = T extends unknown
2 | ? Pick>
3 | : never;
4 |
--------------------------------------------------------------------------------
/src/lib/workers/vips.ts:
--------------------------------------------------------------------------------
1 | import Vips from "wasm-vips";
2 | import {
3 | initializeImageMagick,
4 | MagickFormat,
5 | MagickImage,
6 | MagickImageCollection,
7 | MagickReadSettings,
8 | type IMagickImage,
9 | } from "@imagemagick/magick-wasm";
10 | import { makeZip } from "client-zip";
11 | import wasm from "@imagemagick/magick-wasm/magick.wasm?url";
12 | import { parseAni } from "$lib/parse/ani";
13 | import { parseIcns } from "vert-wasm";
14 |
15 | const vipsPromise = Vips({
16 | dynamicLibraries: [],
17 | });
18 |
19 | const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url));
20 |
21 | const magickRequiredFormats = [
22 | ".dng",
23 | ".heic",
24 | ".ico",
25 | ".cur",
26 | ".ani",
27 | ".cr2",
28 | ".nef",
29 | ".bmp",
30 | ];
31 | const unsupportedFrom: string[] = [];
32 | const unsupportedTo = [...magickRequiredFormats];
33 |
34 | vipsPromise
35 | .then(() => {
36 | postMessage({ type: "loaded" });
37 | })
38 | .catch((error) => {
39 | postMessage({ type: "error", error });
40 | });
41 |
42 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
43 | const handleMessage = async (message: any): Promise => {
44 | const vips = await vipsPromise;
45 | switch (message.type) {
46 | case "convert": {
47 | if (!message.to.startsWith(".")) message.to = `.${message.to}`;
48 | console.log(message);
49 | if (unsupportedFrom.includes(message.input.from)) {
50 | return {
51 | type: "error",
52 | error: `Unsupported input format ${message.input.from}`,
53 | };
54 | }
55 |
56 | if (unsupportedTo.includes(message.to)) {
57 | return {
58 | type: "error",
59 | error: `Unsupported output format ${message.to}`,
60 | };
61 | }
62 |
63 | const buffer = await message.input.file.arrayBuffer();
64 | if (
65 | magickRequiredFormats.includes(message.input.from) ||
66 | magickRequiredFormats.includes(message.to)
67 | ) {
68 | // only wait when we need to
69 | await magickPromise;
70 |
71 | // special ico handling to split them all into separate images
72 | if (message.input.from === ".ico") {
73 | const imgs = MagickImageCollection.create();
74 |
75 | while (true) {
76 | try {
77 | const img = MagickImage.create(
78 | new Uint8Array(buffer),
79 | new MagickReadSettings({
80 | format: MagickFormat.Ico,
81 | frameIndex: imgs.length,
82 | }),
83 | );
84 | imgs.push(img);
85 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
86 | } catch (_) {
87 | break;
88 | }
89 | }
90 |
91 | if (imgs.length === 0) {
92 | return {
93 | type: "error",
94 | error: `Failed to read ICO -- no images found inside?`,
95 | };
96 | }
97 |
98 | const convertedImgs: Uint8Array[] = [];
99 | await Promise.all(
100 | imgs.map(async (img, i) => {
101 | const output = await magickConvert(img, message.to);
102 | convertedImgs[i] = output;
103 | }),
104 | );
105 |
106 | const zip = makeZip(
107 | convertedImgs.map(
108 | (img, i) =>
109 | new File(
110 | [img],
111 | `image${i}.${message.to.slice(1)}`,
112 | ),
113 | ),
114 | "images.zip",
115 | );
116 |
117 | // read the ReadableStream to the end
118 | const zipBytes = await readToEnd(zip.getReader());
119 |
120 | imgs.dispose();
121 |
122 | return {
123 | type: "finished",
124 | output: zipBytes,
125 | zip: true,
126 | };
127 | } else if (message.input.from === ".ani") {
128 | console.log("Parsing ANI file");
129 | try {
130 | const parsedAni = parseAni(new Uint8Array(buffer));
131 | const files: File[] = [];
132 | await Promise.all(
133 | parsedAni.images.map(async (img, i) => {
134 | const blob = await magickConvert(
135 | MagickImage.create(
136 | img,
137 | new MagickReadSettings({
138 | format: MagickFormat.Ico,
139 | }),
140 | ),
141 | message.to,
142 | );
143 | files.push(
144 | new File([blob], `image${i}${message.to}`),
145 | );
146 | }),
147 | );
148 |
149 | const zip = makeZip(files, "images.zip");
150 | const zipBytes = await readToEnd(zip.getReader());
151 |
152 | return {
153 | type: "finished",
154 | output: zipBytes,
155 | zip: true,
156 | };
157 | } catch (e) {
158 | console.error(e);
159 | }
160 | }
161 |
162 | const img = MagickImage.create(
163 | new Uint8Array(buffer),
164 | new MagickReadSettings({
165 | format: message.input.from
166 | .slice(1)
167 | .toUpperCase() as MagickFormat,
168 | }),
169 | );
170 |
171 | const converted = await magickConvert(img, message.to);
172 |
173 | return {
174 | type: "finished",
175 | output: converted,
176 | };
177 | }
178 |
179 | if (message.input.from === ".icns") {
180 | const icns: Uint8Array[] = parseIcns(new Uint8Array(buffer));
181 | // Result in vert-wasm maps to a string in JS
182 | if (typeof icns === "string") {
183 | return {
184 | type: "error",
185 | error: `Failed to read ICNS -- ${icns}`,
186 | };
187 | }
188 | const formats = [
189 | MagickFormat.Png,
190 | MagickFormat.Jpeg,
191 | MagickFormat.Rgba,
192 | MagickFormat.Rgb,
193 | ];
194 | const outputs: Uint8Array[] = [];
195 | for (const file of icns) {
196 | for (const format of formats) {
197 | try {
198 | const img = MagickImage.create(
199 | file,
200 | new MagickReadSettings({
201 | format: format,
202 | }),
203 | );
204 | const converted = await magickConvert(
205 | img,
206 | message.to,
207 | );
208 | outputs.push(converted);
209 | break;
210 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
211 | } catch (_) {
212 | continue;
213 | }
214 | }
215 | }
216 |
217 | const zip = makeZip(
218 | outputs.map(
219 | (img, i) =>
220 | new File([img], `image${i}.${message.to.slice(1)}`),
221 | ),
222 | "images.zip",
223 | );
224 | const zipBytes = await readToEnd(zip.getReader());
225 | return {
226 | type: "finished",
227 | output: zipBytes,
228 | zip: true,
229 | };
230 | }
231 |
232 | let image = vips.Image.newFromBuffer(buffer);
233 |
234 | // check if animated image & keep it animated when converting
235 | if (image.getTypeof("n-pages") > 0) {
236 | image = vips.Image.newFromBuffer(buffer, "[n=-1]");
237 | }
238 |
239 | const opts: { [key: string]: string } = {};
240 | if (typeof message.compression !== "undefined") {
241 | opts["Q"] = Math.min(100, message.compression + 1).toString();
242 | }
243 |
244 | const output = image.writeToBuffer(message.to, opts);
245 | image.delete();
246 | return {
247 | type: "finished",
248 | output: output.buffer,
249 | };
250 | }
251 | }
252 | };
253 |
254 | const readToEnd = async (reader: ReadableStreamDefaultReader) => {
255 | const chunks: Uint8Array[] = [];
256 | let done = false;
257 | while (!done) {
258 | const { value, done: d } = await reader.read();
259 | if (value) chunks.push(value);
260 | done = d;
261 | }
262 | const blob = new Blob(chunks, { type: "application/zip" });
263 | const arrayBuffer = await blob.arrayBuffer();
264 | return new Uint8Array(arrayBuffer);
265 | };
266 |
267 | const magickToBlob = async (img: IMagickImage): Promise => {
268 | const canvas = new OffscreenCanvas(img.width, img.height);
269 | return new Promise((resolve, reject) =>
270 | img.getPixels(async (p) => {
271 | const area = p.getArea(0, 0, img.width, img.height);
272 | const chunkSize = img.hasAlpha ? 4 : 3;
273 | const chunks = Math.ceil(area.length / chunkSize);
274 | const data = new Uint8ClampedArray(chunks * 4);
275 |
276 | for (let j = 0, k = 0; j < area.length; j += chunkSize, k += 4) {
277 | data[k] = area[j];
278 | data[k + 1] = area[j + 1];
279 | data[k + 2] = area[j + 2];
280 | data[k + 3] = img.hasAlpha ? area[j + 3] : 255;
281 | }
282 |
283 | const ctx = canvas.getContext("2d");
284 | if (!ctx) {
285 | reject(new Error("Failed to get canvas context"));
286 | return;
287 | }
288 |
289 | ctx.putImageData(new ImageData(data, img.width, img.height), 0, 0);
290 |
291 | const blob = await canvas.convertToBlob({
292 | type: "image/png",
293 | });
294 |
295 | resolve(blob);
296 | }),
297 | );
298 | };
299 |
300 | const magickConvert = async (img: IMagickImage, to: string) => {
301 | const vips = await vipsPromise;
302 |
303 | const intermediary = await magickToBlob(img);
304 | const buf = await intermediary.arrayBuffer();
305 |
306 | const imgVips = vips.Image.newFromBuffer(buf);
307 | const output = imgVips.writeToBuffer(to);
308 |
309 | imgVips.delete();
310 | img.dispose();
311 |
312 | return output;
313 | };
314 |
315 | onmessage = async (e) => {
316 | const message = e.data;
317 | try {
318 | const res = await handleMessage(message);
319 | if (!res) return;
320 | postMessage({
321 | ...res,
322 | id: message.id,
323 | });
324 | } catch (e) {
325 | postMessage({
326 | type: "error",
327 | error: e,
328 | id: message.id,
329 | });
330 | }
331 | };
332 |
--------------------------------------------------------------------------------
/src/routes/+layout.server.ts:
--------------------------------------------------------------------------------
1 | export const load = () => {
2 | const isAprilFools =
3 | new Date().getDate() === 1 && new Date().getMonth() === 3;
4 | return { isAprilFools };
5 | };
6 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
88 |
89 |
90 | {VERT_NAME}
91 |
92 |
96 |
100 |
101 |
105 |
109 |
110 |
111 |
115 |
119 |
120 |
121 | {#if enablePlausible}
122 |
127 | {/if}
128 | {#if data.isAprilFools}
129 |
134 | {/if}
135 |
136 |
137 |
138 | handleDrag(e, true)}
142 | ondragover={(e) => handleDrag(e, true)}
143 | ondragleave={(e) => handleDrag(e, false)}
144 | role="region"
145 | >
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
--------------------------------------------------------------------------------
/src/routes/+layout.ts:
--------------------------------------------------------------------------------
1 | import { browser } from "$app/environment";
2 |
3 | export const load = ({ data }) => {
4 | if (!browser) return data;
5 | window.plausible =
6 | window.plausible ||
7 | ((_, opts) => {
8 | opts?.callback?.({
9 | status: 200,
10 | });
11 | });
12 |
13 | return data;
14 | };
15 |
16 | export const prerender = true;
17 | export const trailingSlash = "always";
18 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
66 |
67 |
68 |
69 |
72 |
73 |
76 | The file converter you'll love.
77 |
78 |
81 | All image, audio, and document processing is done on your
82 | device. Videos are converted on our lightning-fast servers.
83 | No file size limit, no ads, and completely open source.
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
VERT supports...
96 |
97 |
98 | {#each Object.entries(status) as [key, s]}
99 | {@const Icon = s.icon}
100 |
101 |
102 |
110 |
111 |
112 |
{key}
113 |
114 |
115 |
116 | {#if key === "Video"}
117 |
118 | Video uploads to a server for processing by
119 | default, learn how to set it up locally here .
124 |
125 | {:else}
126 |
129 | Local fully supported
130 |
131 | {/if}
132 |
133 | Status:
134 | {s.ready ? "ready" : "not ready"}
135 |
136 |
137 | Supported formats:
138 | {#each s.formats.split(", ") as format, index}
139 | {@const isPartial = format.endsWith("*")}
140 | {@const formatName = isPartial
141 | ? format.slice(0, -1)
142 | : format}
143 | {#if isPartial}
144 |
145 |
146 | {formatName}* {index <
149 | s.formats.split(", ").length - 1
150 | ? ", "
151 | : ""}
152 |
153 |
154 | {:else}
155 |
156 | {formatName}{index <
157 | s.formats.split(", ").length - 1
158 | ? ", "
159 | : ""}
160 |
161 | {/if}
162 | {/each}
163 |
164 |
165 |
166 | {/each}
167 |
168 |
169 |
170 |
171 |
192 |
--------------------------------------------------------------------------------
/src/routes/about/+page.svelte:
--------------------------------------------------------------------------------
1 |
133 |
134 |
135 |
136 |
137 | About
138 |
139 |
140 |
143 |
144 |
145 |
148 |
149 | {#if !donationsEnabled}
150 |
151 | {/if}
152 |
153 |
154 |
155 |
156 |
157 |
158 | {#if donationsEnabled}
159 |
160 | {/if}
161 |
162 |
163 |
164 |
--------------------------------------------------------------------------------
/src/routes/jpegify/+page.svelte:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
SECRET JPEGIFY!!!
35 |
36 | (shh... don't tell anyone!)
37 |
38 |
39 |
48 |
JPEGIFY {compressionInverted}%!!!
54 |
55 | {#each images as file, i (file.id)}
56 |
65 |
66 |
69 |
76 |
83 |
84 |
85 | {
87 | file?.download();
88 | }}
89 | disabled={!!!file.result}
90 | class="btn bg-accent text-black rounded-2xl text-2xl w-full mx-auto"
91 | >
92 | Download
93 |
94 | {
96 | URL.revokeObjectURL(
97 | forcedBlobURLs.get(file.id) || "",
98 | );
99 | forcedBlobURLs.delete(file.id);
100 | files.files = files.files.filter(
101 | (f) => f.id !== file.id,
102 | );
103 | }}
104 | class="btn border-accent-red border-2 bg-transparent text-black dynadark:text-white rounded-2xl text-2xl w-full mx-auto"
105 | >
106 | Delete
107 |
108 |
109 |
110 |
111 | {/each}
112 |
113 |
114 |
--------------------------------------------------------------------------------
/src/routes/settings/+page.svelte:
--------------------------------------------------------------------------------
1 |
49 |
50 |
51 |
52 |
53 | Settings
54 |
55 |
56 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | {#if PUB_PLAUSIBLE_URL}
67 |
68 | {/if}
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/static/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/static/banner.png
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/static/favicon.png
--------------------------------------------------------------------------------
/static/lettermark.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/static/lettermark.jpg
--------------------------------------------------------------------------------
/static/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "VERT",
3 | "short_name": "VERT",
4 | "description": "The file converter you'll love",
5 | "start_url": "/",
6 | "display": "standalone",
7 | "background_color": "#ffffff",
8 | "theme_color": "#F2ABEE",
9 | "icons": [
10 | {
11 | "src": "lettermark.jpg",
12 | "sizes": "192x192",
13 | "type": "image/png"
14 | },
15 | {
16 | "src": "lettermark.jpg",
17 | "sizes": "512x512",
18 | "type": "image/png"
19 | }
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/static/pandoc.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VERT-sh/VERT/c04507d1ee5faa869c428cc19fde684a60c04cc3/static/pandoc.wasm
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from "@sveltejs/adapter-static";
2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://svelte.dev/docs/kit/integrations
7 | // for more information about preprocessors
8 | preprocess: vitePreprocess(),
9 |
10 | kit: {
11 | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
13 | // See https://svelte.dev/docs/kit/adapters for more information about adapters.
14 | adapter: adapter(),
15 | paths: {
16 | relative: false,
17 | },
18 | env: {
19 | publicPrefix: "PUB_",
20 | privatePrefix: "PRI_",
21 | },
22 | },
23 | };
24 |
25 | export default config;
26 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | import plugin from "tailwindcss/plugin";
3 |
4 | export default {
5 | content: ["./src/**/*.{html,js,svelte,ts}"],
6 | theme: {
7 | extend: {
8 | backgroundColor: {
9 | panel: "var(--bg-panel)",
10 | "panel-highlight": "var(--bg-panel-highlight)",
11 | separator: "var(--bg-separator)",
12 | button: "var(--bg-button)",
13 | "panel-alt": "var(--bg-button)",
14 | badge: "var(--bg-badge)",
15 | },
16 | borderColor: {
17 | separator: "var(--bg-separator)",
18 | button: "var(--bg-button)",
19 | },
20 | textColor: {
21 | foreground: "var(--fg)",
22 | muted: "var(--fg-muted)",
23 | accent: "var(--fg-accent)",
24 | failure: "var(--fg-failure)",
25 | "on-accent": "var(--fg-on-accent)",
26 | "on-badge": "var(--fg-on-badge)",
27 | },
28 | colors: {
29 | accent: "var(--accent)",
30 | "accent-alt": "var(--accent-alt)",
31 | "accent-pink": "var(--accent-pink)",
32 | "accent-pink-alt": "var(--accent-pink-alt)",
33 | "accent-red": "var(--accent-red)",
34 | "accent-red-alt": "var(--accent-red-alt)",
35 | "accent-purple-alt": "var(--accent-purple-alt)",
36 | "accent-purple": "var(--accent-purple)",
37 | "accent-blue": "var(--accent-blue)",
38 | "accent-blue-alt": "var(--accent-blue-alt)",
39 | "accent-green": "var(--accent-green)",
40 | "accent-green-alt": "var(--accent-green-alt)",
41 | },
42 | boxShadow: {
43 | panel: "var(--shadow-panel)",
44 | },
45 | fontFamily: {
46 | display: "var(--font-display)",
47 | body: "var(--font-body)",
48 | },
49 | blur: {
50 | xs: "2px",
51 | },
52 | borderRadius: {
53 | "2.5xl": "1.25rem",
54 | },
55 | },
56 | },
57 |
58 | plugins: [
59 | plugin(function ({ addVariant }) {
60 | addVariant("dynadark", [
61 | ":root:not(.light).dark &",
62 | "@media (prefers-color-scheme: dark) { :root:not(.light) &",
63 | ]);
64 | }),
65 | ],
66 | } satisfies Config;
67 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": true,
12 | "moduleResolution": "bundler"
13 | }
14 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
15 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
16 | //
17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
18 | // from the referenced tsconfig.json - TypeScript does not merge them in
19 | }
20 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from "@sveltejs/kit/vite";
2 | import { defineConfig, type PluginOption } from "vite";
3 | import { viteStaticCopy } from "vite-plugin-static-copy";
4 | import svg from "@poppanator/sveltekit-svg";
5 | import wasm from "vite-plugin-wasm";
6 |
7 | export default defineConfig(({ command }) => {
8 | const plugins: PluginOption[] = [
9 | sveltekit(),
10 | {
11 | name: "vips-request-middleware",
12 | configureServer(server) {
13 | server.middlewares.use((_req, res, next) => {
14 | res.setHeader(
15 | "Cross-Origin-Embedder-Policy",
16 | "require-corp",
17 | );
18 | res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
19 | next();
20 | });
21 | },
22 | },
23 | svg({
24 | includePaths: ["./src/lib/assets"],
25 | svgoOptions: {
26 | multipass: true,
27 | plugins: [
28 | {
29 | name: "preset-default",
30 | params: { overrides: { removeViewBox: false } },
31 | },
32 | { name: "removeAttrs", params: { attrs: "(fill|stroke)" } },
33 | ],
34 | },
35 | }),
36 | viteStaticCopy({
37 | targets: [
38 | {
39 | src: "_headers",
40 | dest: "",
41 | },
42 | {
43 | src: "node_modules/wasm-vips/lib/vips-*.wasm",
44 | dest: "_app/immutable/workers",
45 | },
46 | ],
47 | }),
48 | ];
49 |
50 | if (command === "serve") {
51 | plugins.unshift(wasm());
52 | }
53 |
54 | return {
55 | plugins,
56 | worker: {
57 | plugins: () => [wasm()],
58 | format: "es",
59 | },
60 | optimizeDeps: {
61 | exclude: [
62 | "wasm-vips",
63 | "@ffmpeg/core-mt",
64 | "@ffmpeg/ffmpeg",
65 | "@ffmpeg/util",
66 | ],
67 | },
68 | css: {
69 | preprocessorOptions: {
70 | scss: {
71 | api: "modern",
72 | },
73 | },
74 | },
75 | build: {
76 | target: "esnext",
77 | },
78 | };
79 | });
80 |
--------------------------------------------------------------------------------