├── .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 | VERT's logo 3 |

4 |

VERT.sh

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 | 32 | 42 | {#if $isMobile} 43 | 53 | {:else} 54 | 55 | 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 |
92 | 93 |
94 |
95 | {/if} 96 |
97 | -------------------------------------------------------------------------------- /src/lib/components/functional/Dialog.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 |
51 |
52 |
53 |
56 | 57 |
58 |

{title}

59 |
60 |
61 |
62 |

{message}

63 |
64 |
65 | {#each buttons as { text, action }, i} 66 | 78 | {/each} 79 |
80 |
81 | -------------------------------------------------------------------------------- /src/lib/components/functional/Dropdown.svelte: -------------------------------------------------------------------------------- 1 | 52 | 53 |
59 | 110 | {#if open} 111 |
119 | {#each options as option} 120 | 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 |
41 | {#if activeLinkIndex !== -1} 42 |
49 | {/if} 50 | {#each links as { name, url } (url)} 51 | { 60 | if (shouldGoBack) { 61 | const currentIndex = links.findIndex((i) => 62 | i.activeMatch($page.url.pathname), 63 | ); 64 | const nextIndex = links.findIndex((i) => 65 | i.activeMatch(url), 66 | ); 67 | $shouldGoBack = nextIndex < currentIndex; 68 | } 69 | }} 70 | > 71 |
72 | {#key name} 73 | 86 | {name} 87 | 88 | {/key} 89 |
90 |
91 | {/each} 92 |
93 | -------------------------------------------------------------------------------- /src/lib/components/functional/FormatDropdown.svelte: -------------------------------------------------------------------------------- 1 | 136 | 137 |
141 | 184 | {#if open} 185 |
196 | 197 |
198 |
199 | 206 | 209 | 210 | 211 |
212 |
213 | 214 | 215 |
216 | {#each filteredData.categories as category} 217 | 226 | {/each} 227 |
228 | 229 | 230 |
231 | {#each filteredData.formats as format} 232 | 241 | {/each} 242 |
243 |
244 | {/if} 245 |
246 | -------------------------------------------------------------------------------- /src/lib/components/functional/Uploader.svelte: -------------------------------------------------------------------------------- 1 | 75 | 76 | 84 | 85 | 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 |
6 | 10 |
13 |
14 | 15 |
16 |
17 |
18 |
-------------------------------------------------------------------------------- /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 | 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 | 182 | {#each items as item, i (item.url)} 183 | {@render link(item, i)} 184 | {/each} 185 | 186 | 187 | 198 | 199 |
200 |
201 | -------------------------------------------------------------------------------- /src/lib/components/layout/Navbar/Desktop.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/components/layout/Navbar/Mobile.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | 8 |
9 |
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 |
16 |
23 |
24 | 25 | 64 | -------------------------------------------------------------------------------- /src/lib/components/visual/Toast.svelte: -------------------------------------------------------------------------------- 1 | 52 | 53 |
66 |
67 | 73 |

{message}

74 |
75 | 81 |
82 | -------------------------------------------------------------------------------- /src/lib/components/visual/Tooltip.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 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 | {name} 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 |
93 |

GitHub contributors

94 | {#if ghContribs && ghContribs.length > 0} 95 |

96 | Big thanks 100 | to all these people for helping out! 101 | 107 | Want to help too? 108 | 109 |

110 | {:else} 111 |

112 | Seems like no one has contributed yet... 113 | 119 | be the first to contribute! 120 | 121 |

122 | {/if} 123 |
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 |
60 | {name} 66 |

${amount}

67 |
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 | 110 | 111 | 125 |
126 |
127 | {#each presetAmounts as preset} 128 | 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 |
31 | 32 |
33 | Sponsors 34 |

35 |
36 |
37 | 42 | 43 | 44 |
45 |

46 | Want to support us? Contact a developer in the Discord 50 | server, or send an email to 51 | 52 | 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 | 93 | 94 | 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 | 123 | 124 | 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 | 53 | 54 | 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 | 54 |
55 | {#each images as file, i (file.id)} 56 |
65 | 66 |
69 | {file.name} 76 | {file.name} 83 |
84 |
85 | 94 | 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 | --------------------------------------------------------------------------------