Repository: VERT-sh/VERT Branch: main Commit: 915421386860 Files: 131 Total size: 51.0 MB Directory structure: gitextract_zs7t_6zy/ ├── .dockerignore ├── .env.example ├── .github/ │ └── workflows/ │ ├── docker.yml │ └── pages.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .vscode/ │ ├── extensions.json │ ├── settings.json │ └── tailwind.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs/ │ ├── DOCKER.md │ ├── FAQ.md │ ├── GETTING_STARTED.md │ └── VIDEO_CONVERSION.md ├── eslint.config.js ├── flake.nix ├── messages/ │ ├── ba.json │ ├── de.json │ ├── el.json │ ├── en.json │ ├── es.json │ ├── fr.json │ ├── hr.json │ ├── id.json │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── pt-BR.json │ ├── tr.json │ ├── zh-Hans.json │ └── zh-Hant.json ├── nginx/ │ ├── default-ssl.conf │ └── default.conf ├── package.json ├── postcss.config.js ├── project.inlang/ │ ├── .gitignore │ ├── project_id │ └── settings.json ├── src/ │ ├── app.d.ts │ ├── app.html │ ├── hooks.server.ts │ ├── hooks.ts │ ├── lib/ │ │ ├── assets/ │ │ │ └── style/ │ │ │ └── host-grotesk.css │ │ ├── components/ │ │ │ ├── functional/ │ │ │ │ ├── ConversionPanel.svelte │ │ │ │ ├── Dialog.svelte │ │ │ │ ├── Dropdown.svelte │ │ │ │ ├── FancyInput.svelte │ │ │ │ ├── FancyMenu.svelte │ │ │ │ ├── FormatDropdown.svelte │ │ │ │ ├── Uploader.svelte │ │ │ │ ├── VertdError.svelte │ │ │ │ └── VertdErrorDetails.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 │ │ ├── converters/ │ │ │ ├── converter.svelte.ts │ │ │ ├── ffmpeg.svelte.ts │ │ │ ├── index.ts │ │ │ ├── magick-automated.ts │ │ │ ├── magick.svelte.ts │ │ │ ├── pandoc.svelte.ts │ │ │ └── vertd.svelte.ts │ │ ├── css/ │ │ │ └── app.scss │ │ ├── sections/ │ │ │ ├── about/ │ │ │ │ ├── Credits.svelte │ │ │ │ ├── Donate.svelte │ │ │ │ ├── Resources.svelte │ │ │ │ ├── Sponsors.svelte │ │ │ │ ├── Why.svelte │ │ │ │ └── index.ts │ │ │ └── settings/ │ │ │ ├── Appearance.svelte │ │ │ ├── Conversion.svelte │ │ │ ├── Privacy.svelte │ │ │ ├── Vertd.svelte │ │ │ ├── index.svelte.ts │ │ │ └── vertdSettings.svelte.ts │ │ ├── store/ │ │ │ ├── DialogProvider.ts │ │ │ └── index.svelte.ts │ │ ├── types/ │ │ │ ├── conversion-worker.ts │ │ │ ├── file.svelte.ts │ │ │ ├── index.ts │ │ │ └── util.ts │ │ ├── util/ │ │ │ ├── animation.ts │ │ │ ├── consts.ts │ │ │ ├── ip.ts │ │ │ ├── logger.ts │ │ │ ├── parse/ │ │ │ │ ├── ani.ts │ │ │ │ └── icns/ │ │ │ │ └── index.ts │ │ │ ├── sw.ts │ │ │ ├── toast.svelte.ts │ │ │ └── zip.ts │ │ └── workers/ │ │ ├── magick.ts │ │ └── pandoc.ts │ └── routes/ │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ ├── about/ │ │ └── +page.svelte │ ├── convert/ │ │ └── +page.svelte │ ├── privacy/ │ │ └── +page.svelte │ └── settings/ │ └── +page.svelte ├── static/ │ ├── manifest.json │ ├── pandoc.wasm │ ├── robots.txt │ ├── sitemap.xml │ └── sw.js ├── svelte.config.js ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ node_modules/ build/ dist/ .svelte-kit/ .output/ .vercel/ .vscode/ LICENSE README.md Dockerfile docker-compose.yml .npmrc .prettier* .gitignore .env.* .env .DS_Store Thumbs.db ================================================ FILE: .env.example ================================================ # The hostname used for analytics tracking (currently only used by Plausible) PUB_HOSTNAME=localhost:5173 # URL for your Plausible Analytics instance (leave empty to disable analytics) PUB_PLAUSIBLE_URL=https://plausible.example.com # Application environment: "production", "development", or "nightly" PUB_ENV=development # URL of the vertd daemon for video conversion (default: official VERT instance) PUB_VERTD_URL=https://vertd.vert.sh # Set to true to disable all external requests (vertd, Stripe, Plausible, etc.) # Useful for privacy-focused deployments or air-gapped environments # Note: the ffmpeg worker is still downloaded via a CDN (cdn.jsdelivr.net) PUB_DISABLE_ALL_EXTERNAL_REQUESTS=false # Set to true to disable blocking video conversions of an uploaded file when repeated failures # occur within an hour. Useful for local deployments where secure context (HTTPS) may not be # available - required for calculating file hashes of videos to block temporarily. PUB_DISABLE_FAILURE_BLOCKS=false # Stripe donation settings # Please keep these values the same, they support VERT's development! PUB_DONATION_URL=https://donations.vert.sh PUB_STRIPE_KEY=pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2 ================================================ FILE: .github/workflows/docker.yml ================================================ name: Docker Image CI on: push: branches: ["main"] tags: ["v*"] paths: - "src/**" - "static/**" - "Dockerfile" - ".dockerignore" pull_request: branches: ["main"] paths: - "src/**" - "static/**" - "Dockerfile" - ".dockerignore" workflow_dispatch: jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,format=short type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} - name: Build and push uses: docker/build-push-action@v5 with: context: . push: ${{ github.event_name != 'pull_request' }} platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max build-args: | PUB_ENV=production PUB_HOSTNAME=${{ vars.PUB_HOSTNAME || '' }} PUB_PLAUSIBLE_URL=${{ vars.PUB_PLAUSIBLE_URL || '' }} PUB_VERTD_URL=https://vertd.vert.sh PUB_DISABLE_ALL_EXTERNAL_REQUESTS=false PUB_DONATION_URL=https://donations.vert.sh PUB_STRIPE_KEY=pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2 ================================================ FILE: .github/workflows/pages.yml ================================================ name: Deploy to GitHub Pages on: push: branches: "main" jobs: build_site: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun i - name: build env: BASE_PATH: "/${{ github.event.repository.name }}" PUB_HOSTNAME: "vert.sh" PUB_PLAUSIBLE_URL: "https://ats.vert.sh" PUB_ENV: "production" PUB_VERTD_URL: "https://vertd.vert.sh" PUB_DISABLE_ALL_EXTERNAL_REQUESTS: "false" PUB_DISABLE_FAILURE_BLOCKS: "false" PUB_DONATION_URL: "https://donations.vert.sh" PUB_STRIPE_KEY: "pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2" run: bun run build - name: Upload Artifacts uses: actions/upload-pages-artifact@v3 with: path: "build/" deploy: needs: build_site runs-on: ubuntu-latest permissions: pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ node_modules # Output .output .vercel /.svelte-kit /build # OS .DS_Store Thumbs.db # Env .env .env.* !.env.example !.env.test # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* # IDE .idea ================================================ FILE: .npmignore ================================================ src/routes src/app.d.ts src/app.html ================================================ FILE: .prettierignore ================================================ # Package Managers package-lock.json pnpm-lock.yaml yarn.lock ================================================ FILE: .prettierrc ================================================ { "useTabs": true, "tabWidth": 4, "singleQuote": false, "plugins": ["prettier-plugin-svelte"], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "inlang.vs-code-extension" ] } ================================================ FILE: .vscode/settings.json ================================================ { "css.customData": [".vscode/tailwind.json"] } ================================================ FILE: .vscode/tailwind.json ================================================ { "version": 1.1, "atDirectives": [ { "name": "@tailwind", "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" } ] }, { "name": "@apply", "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.", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#apply" } ] }, { "name": "@responsive", "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", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" } ] }, { "name": "@screen", "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", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#screen" } ] }, { "name": "@variants", "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", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#variants" } ] } ] } ================================================ FILE: Dockerfile ================================================ FROM oven/bun AS builder WORKDIR /app ARG PUB_ENV ARG PUB_HOSTNAME ARG PUB_PLAUSIBLE_URL ARG PUB_VERTD_URL ARG PUB_DISABLE_ALL_EXTERNAL_REQUESTS ARG PUB_DONATION_URL ARG PUB_STRIPE_KEY ARG PUB_DISABLE_FAILURE_BLOCKS=false ENV PUB_ENV=${PUB_ENV} ENV PUB_HOSTNAME=${PUB_HOSTNAME} ENV PUB_PLAUSIBLE_URL=${PUB_PLAUSIBLE_URL} ENV PUB_VERTD_URL=${PUB_VERTD_URL} ENV PUB_DISABLE_ALL_EXTERNAL_REQUESTS=${PUB_DISABLE_ALL_EXTERNAL_REQUESTS} ENV PUB_DONATION_URL=${PUB_DONATION_URL} ENV PUB_STRIPE_KEY=${PUB_STRIPE_KEY} ENV PUB_DISABLE_FAILURE_BLOCKS=${PUB_DISABLE_FAILURE_BLOCKS} COPY package.json ./ RUN apt-get update && \ apt-get install -y --no-install-recommends git && \ rm -rf /var/lib/apt/lists/* RUN bun install COPY . ./ RUN bun run build FROM nginx:stable-alpine EXPOSE 80/tcp COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf COPY --from=builder /app/build /usr/share/nginx/html HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl --fail --silent --output /dev/null http://localhost || exit 1 ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================

VERT's logo

VERT.sh

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). VERT is built in Svelte and TypeScript. ## Screenshots | Upload page | Conversion page | | :--------------------------------------------------: | :------------------------------------------------------: | | ![VERT upload page](docs/images/screenshot-home.png) | ![VERT convert page](docs/images/screenshot-convert.png) | ## Features - Convert files directly on your device using WebAssembly\* - No file or file size limits - Convert images, audio, documents, and video\* - Supports over **250+** file formats - Conversion settings - User-friendly interface built with Svelte \* 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. ## Documentation - [FAQ](./docs/FAQ.md) - [Getting Started](./docs/GETTING_STARTED.md) - [Using Docker](./docs/DOCKER.md) - [Video Conversion](./docs/VIDEO_CONVERSION.md) ## License This project is licensed under the AGPL-3.0 License, please see the [LICENSE](LICENSE) file for details. ## Star History Star History Chart ================================================ FILE: docker-compose.yml ================================================ services: vert: container_name: vert image: ghcr.io/vert-sh/vert:latest build: context: . args: PUB_HOSTNAME: ${PUB_HOSTNAME:-localhost:5173} PUB_PLAUSIBLE_URL: ${PUB_PLAUSIBLE_URL:-} PUB_ENV: ${PUB_ENV:-production} PUB_DISABLE_ALL_EXTERNAL_REQUESTS: ${PUB_DISABLE_ALL_EXTERNAL_REQUESTS:-false} PUB_VERTD_URL: ${PUB_VERTD_URL:-} PUB_DONATION_URL: ${PUB_DONATION_URL:-https://donations.vert.sh} PUB_STRIPE_KEY: ${PUB_STRIPE_KEY:-pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2} restart: unless-stopped ports: - ${PORT:-3000}:80 ================================================ FILE: docs/DOCKER.md ================================================ ## Using Docker This file covers how to run VERT under a Docker container. - [Manually building the image](#manually-building-the-image) - [Using an image from the GitHub Container Registry](#using-an-image-from-the-github-container-registry) ### Manually building the image First, clone the repository: ```shell git clone https://github.com/VERT-sh/VERT cd VERT/ ``` Then build a Docker image with: ```shell docker build -t vert-sh/vert \ --build-arg PUB_ENV=production \ --build-arg PUB_HOSTNAME=vert.sh \ --build-arg PUB_PLAUSIBLE_URL=https://plausible.example.com \ --build-arg PUB_VERTD_URL=https://vertd.vert.sh \ --build-arg PUB_DONATION_URL=https://donations.vert.sh \ --build-arg PUB_DISABLE_ALL_EXTERNAL_REQUESTS=false \ --build-arg PUB_STRIPE_KEY="" . ``` You can then run it by using: ```shell docker run -d \ --restart unless-stopped \ -p 3000:80 \ --name "vert" \ vert-sh/vert ``` This will do the following: - Use the previously built image as the container `vert`, in detached mode - Continuously restart the container until manually stopped - Map `3000/tcp` (host) to `80/tcp` (container) 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/). ### Using an image from the GitHub Container Registry 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: ```shell docker run -d \ --restart unless-stopped \ -p 3000:80 \ --name "vert" \ ghcr.io/vert-sh/vert:latest ``` ================================================ FILE: docs/FAQ.md ================================================ ## FAQ This file covers frequently asked questions. - [Why VERT?](#why-vert) - [What happens with video files?](#what-happens-with-video-files) - [Can I host my own video file converter?](#can-i-host-my-own-video-file-converter) - [What about analytics?](#what-about-analytics) - [What libraries does VERT use?](#what-libraries-does-vert-use) - [Is it possible to fully prevent VERT from making requests to external services?](#is-it-possible-to-fully-prevent-vert-from-making-requests-to-external-services) ### Why VERT? **File converters have always disappointed us.** They're ugly, riddled with ads, and most importantly; slow. We decided to solve this problem once and for all by making an alternative that solves all those problems, and more. All non-video files are converted completely on-device; this means that there's no delay between sending and receiving the files from a server, and we never get to snoop on the files you convert. ### What happens with video files? Video files get uploaded to our lightning-fast RTX 4000 Ada server. Your videos stay on there for an hour if you do not convert them. If you do convert the file, the video will stay on the server for an hour, or until it is downloaded. The file will then be deleted from our server. ### Can I host my own video file converter? Yes. Check out the [Video Conversion](./VIDEO_CONVERSION.md) page. ### What about analytics? We use [Plausible](https://plausible.io/privacy-focused-web-analytics), a privacy-focused analytics tool, to gather completely anonymous statistics. All data is anonymized and aggregated, and no identifiable information is ever sent or stored. You can view the analytics [here](https://ats.vert.sh/vert.sh) and choose to opt out in the [Settings](https://vert.sh/settings/) page. ### Is it possible to fully prevent VERT from making requests to external services? Yes! If you would prefer VERT to not make any requests to external services (video conversion, analytics, among others), you can set the `PUB_DISABLE_ALL_EXTERNAL_REQUESTS` environment variable to `true` **during build time**. The only external request VERT will make with this option is to `cdn.jsdelivr.net`, which is used to download FFmpeg's WebAssembly build. ### What libraries does VERT use? VERT uses FFmpeg for audio and video conversion, imagemagick for images and Pandoc for documents. A big thanks to them for maintaining such excellent libraries for so many years. ================================================ FILE: docs/GETTING_STARTED.md ================================================ ## Getting Started This file covers how to get started with VERT. - [Prerequisites](#prerequisites) - [Installation](#installation) - [Running Locally](#running-locally) - [Building for Production](#building-for-production) - [Using Docker](#using-docker) ### Prerequisites Make sure you have the following installed: - [Bun](https://bun.sh/) ### Installation First, clone the repository: ```sh git clone https://github.com/VERT-sh/VERT cd VERT/ ``` Install dependencies: ```sh bun i ``` And finally, make sure you create a `.env` file in the root of the project. We've included a [`.env.example`](../.env.example) file which you can use to get started. ### Running Locally To run the project locally, run `bun dev`. This will start a development server. Open your browser and navigate to `http://localhost:5173` to see the application. ### Building for Production To build the project for production, run `bun run build`. 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. ### Using Docker Check the dedicated [Docker](./DOCKER.md) page. ================================================ FILE: docs/VIDEO_CONVERSION.md ================================================ ## Video conversion This file covers how video conversion works when using VERT. On VERT, video uploads to a server for processing by default. This is because video conversion is hard to do in a browser as it uses a lot of resources, and will end up running very slowly (if it even works at all). Our answer to this is [`vertd`](https://github.com/VERT-sh/vertd), which is a simple FFmpeg wrapper built in Rust. If you don't understand all that technical jargon, it basically allows you to convert videos using the full capacity of your computer, which results in much faster conversion. It runs on your computer (or a server somewhere, if you know what you're doing), and the VERT web interface reaches out to it in order to convert your videos. We host an official instance of [`vertd`](https://github.com/VERT-sh/vertd) so you do not have to host it yourself for convenience, but considering you're here, you probably want to host it for yourself. Essentially: - Download the latest release of `vertd` for your machine [here](https://github.com/VERT-sh/vertd/releases) - Run the server - Connect the VERT UI to your local `vertd` instance by entering its IP & port - By default, `vertd` runs a HTTP server on port `24153`, so you would put `http://localhost:24153` in the "Instance URL" setting found in VERT's settings (assuming you are running it on your own PC) ================================================ FILE: eslint.config.js ================================================ import prettier from 'eslint-config-prettier'; import js from '@eslint/js'; import svelte from 'eslint-plugin-svelte'; import globals from 'globals'; import ts from 'typescript-eslint'; export default ts.config( js.configs.recommended, ...ts.configs.recommended, ...svelte.configs['flat/recommended'], prettier, ...svelte.configs['flat/prettier'], { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, { files: ['**/*.svelte'], languageOptions: { parserOptions: { parser: ts.parser } } }, { ignores: ['build/', '.svelte-kit/', 'dist/'] } ); ================================================ FILE: flake.nix ================================================ { description = "VERT.sh"; inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; in { devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ bun nodejs # are these needed? nodePackages.prettier nodePackages.eslint ]; }; }); } ================================================ FILE: messages/ba.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Učitaj", "convert": "Konvertuj", "settings": "Postavke", "about": "O nama", "toggle_theme": "Promijeni temu" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Izvorni kod", "discord_server": "Discord server", "privacy_policy": "Politika privatnosti" }, "upload": { "title": "Konverter datoteka koji ćete voljeti.", "subtitle": "Sva obrada slika, zvuka i dokumenata obavlja se na vašem uređaju. Video zapisi se konvertuju na našim izuzetno brzim serverima. Bez ograničenja veličine, bez reklama i potpuno otvorenog koda.", "uploader": { "text": "Prevucite ili kliknite da {action}", "convert": "konvertujete" }, "cards": { "title": "VERT podržava...", "images": "Slike", "audio": "Audio", "documents": "Dokumente", "video": "Video", "video_server_processing": "Server podržava", "local_supported": "Lokalno podržano", "status": { "text": "Status: {status}", "ready": "spreman", "not_ready": "nije spreman", "not_initialized": "nije inicijaliziran", "downloading": "preuzimam...", "initializing": "inicijaliziram...", "unknown": "nepoznat status" }, "supported_formats": "Podržani formati:" }, "tooltip": { "partial_support": "Ovaj format može biti konvertovan samo kao {direction}.", "direction_input": "ulazni (iz)", "direction_output": "izlazni (u)", "video_server_processing": "Video se podrazumijevano otprema na server radi obrade, ovdje možete naučiti kako to postaviti lokalno." } }, "convert": { "archive_file": { "extract": "Raspakuj arhivu", "extracting": "Otkrivena arhiva: {filename}", "extracted": "Izvučeno {extract_count} datoteka iz {filename}. {ignore_count} stavki je ignorisano.", "detected": "Otkrivene {type} datoteke u {filename}.", "audio": "audio", "video": "video", "doc": "dokument", "image": "slika", "extract_error": "Greška pri raspakivanju {filename}: {error}" }, "large_file_warning": "Zbog ograničenja preglednika/uređaja, konverzija videa u audio je onemogućena za ovu datoteku jer je veća od {limit}GB. Preporučujemo Firefox ili Safari za datoteke ove veličine jer imaju manje ograničenja.", "external_warning": { "title": "Upozorenje o vanjskom serveru", "text": "Ako odaberete konverziju u video format, te datoteke će biti otpremljene na vanjski server. Želite li nastaviti?", "yes": "Da", "no": "Ne" }, "panel": { "convert_all": "Konvertuj sve", "download_all": "Preuzmi sve kao .zip", "remove_all": "Ukloni sve datoteke", "set_all_to": "Postavi sve na", "na": "N/A" }, "dropdown": { "audio": "Audio", "video": "Video", "doc": "Dokument", "image": "Slika", "placeholder": "Pretraži format", "no_formats": "Nema dostupnih formata", "no_results": "Nema rezultata koji odgovaraju pretrazi" }, "tooltips": { "unknown_file": "Nepoznat tip datoteke", "audio_file": "Audio datoteka", "video_file": "Video datoteka", "document_file": "Dokument", "image_file": "Slika", "convert_file": "Konvertuj ovu datoteku", "download_file": "Preuzmi ovu datoteku" }, "errors": { "cant_convert": "Ne možemo konvertovati ovu datoteku.", "vertd_server": "šta to radiš..? treba da pokreneš vertd server!", "vertd_generic_view": "Prikaži detalje greške", "vertd_generic_body": "Došlo je do greške prilikom pokušaja konverzije videa. Želite li poslati svoj video programerima da pomognete u rješavanju problema? Samo će vaš video biti poslan. Nikakvi identifikatori neće biti otpremljeni.", "vertd_generic_title": "Greška pri konverziji videa", "vertd_generic_yes": "Pošalji video", "vertd_generic_no": "Ne šalji", "vertd_failed_to_keep": "Neuspjelo čuvanje videa na serveru: {error}", "vertd_details": "Prikaži detalje greške", "vertd_details_body": "Ako pritisnete pošalji, vaš video će također biti priložen uz log greške koji se uvijek automatski šalje nama na pregled. Sljedeće informacije su log koji automatski dobijamo:", "vertd_details_footer": "Ove informacije se koriste isključivo za rješavanje problema i nikada neće biti dijeljene. Pogledajte našu [privacy_link]politiku privatnosti[/privacy_link] za više detalja.", "vertd_details_job_id": "ID zadatka: {jobId}", "vertd_details_from": "Iz formata: {from}", "vertd_details_to": "U format: {to}", "vertd_details_error_message": "Poruka greške: [view_link]Pogledaj log[/view_link]", "vertd_details_close": "Zatvori", "vertd_ratelimit": "Vaš video '{filename}' nije uspio biti konvertovan nekoliko puta. Kako bismo spriječili preopterećenje servera, dalji pokušaji konverzije za ovu datoteku su privremeno blokirani.", "unsupported_format": "Podržane su samo slike, video, audio i dokumenti", "format_output_only": "Ovaj format se trenutno može koristiti samo kao izlaz, ne kao ulaz.", "vertd_not_found": "Nije moguće pronaći vertd instancu za pokretanje konverzije videa. Da li je URL ispravno postavljen?", "worker_downloading": "{type} konverter se trenutno inicijalizira, molimo sačekajte.", "worker_error": "{type} konverter je imao grešku tokom inicijalizacije, pokušajte kasnije ponovo.", "worker_timeout": "{type} konverteru treba duže nego očekivano da se inicijalizira, molimo sačekajte još malo ili osvježite stranicu.", "audio": "audio", "doc": "dokument", "image": "slika" } }, "settings": { "title": "Postavke", "errors": { "save_failed": "Neuspješno spremanje postavki!" }, "appearance": { "title": "Izgled", "brightness_theme": "Tema osvjetljenja", "brightness_description": "Želite li blještavi dan ili tihu, usamljenu noć?", "light": "Svijetla", "dark": "Tamna", "effect_settings": "Efekti", "effect_description": "Želite li zanimljive efekte ili mirnije iskustvo?", "enable": "Uključi", "disable": "Isključi" }, "conversion": { "title": "Konverzija", "advanced_settings": "Napredne postavke", "filename_format": "Format imena datoteke", "filename_description": "Ovo određuje ime datoteke pri preuzimanju, bez ekstenzije. Možete koristiti sljedeće šablone: %name% originalno ime, %extension% originalna ekstenzija, %date% datum konverzije.", "placeholder": "VERT_%name%", "default_format": "Podrazumijevani format konverzije", "default_format_enable": "Uključi", "default_format_disable": "Isključi", "default_format_description": "Ovo mijenja podrazumijevani format koji se odabere kada učitate datoteku ovog tipa.", "default_format_image": "Slike", "default_format_video": "Video", "default_format_audio": "Audio", "default_format_document": "Dokumenti", "metadata": "Metadata", "metadata_description": "Određuje da li se podaci (EXIF, info o pjesmi itd.) čuvaju u konvertovanim datotekama.", "keep": "Zadrži", "remove": "Ukloni", "quality": "Kvalitet konverzije", "quality_description": "Mijenja podrazumijevani kvalitet izlazne datoteke. Veće vrijednosti znače duže vrijeme konverzije i veću veličinu.", "quality_video": "Mijenja izlazni kvalitet videa.", "quality_audio": "Audio (kbps)", "quality_images": "Slika (%)", "rate": "Sample rate (Hz)" }, "vertd": { "title": "Konverzija videa", "status": "status:", "loading": "učitavam...", "available": "dostupan, commit id {commitId}", "unavailable": "nedostupan (da li je URL tačan?)", "description": "vertd je serverski omotač za FFmpeg, omogućava brzo konvertovanje videa koristeći vaš GPU putem VERT web interfejsa.", "hosting_info": "Imamo javnu instancu radi praktičnosti, ali možete lako hostati svoju. Preuzmite server [vertd_link]ovdje[/vertd_link].", "instance": "Instanca", "url_placeholder": "Primjer: http://localhost:24153", "conversion_speed": "Brzina konverzije", "speed_description": "Opisuje odnos između brzine i kvaliteta. Brže = niži kvalitet ali kraće vrijeme.", "speeds": { "very_slow": "Vrlo sporo", "slower": "Sporije", "slow": "Sporo", "medium": "Srednje", "fast": "Brzo", "ultra_fast": "Ultra brzo" }, "auto_instance": "Auto (preporučeno)", "eu_instance": "Falkenstein, Njemačka", "us_instance": "Washington, SAD", "custom_instance": "Prilagođeno" }, "privacy": { "title": "Privatnost i podaci", "plausible_title": "Plausible analitika", "plausible_description": "Koristimo [plausible_link]Plausible[/plausible_link], alat fokusiran na privatnost. Podaci su potpuno anonimni i agregirani. Analitiku možete vidjeti [analytics_link]ovdje[/analytics_link] i isključiti ispod.", "opt_in": "Uključi", "opt_out": "Isključi", "cache_title": "Upravljanje cacheom", "cache_description": "Konverter se kešira u vašem pregledniku radi boljih performansi.", "refresh_cache": "Osvježi cache", "clear_cache": "Obriši cache", "files_cached": "{size} ({count} datoteka)", "loading_cache": "Učitavam...", "total_size": "Ukupna veličina", "files_cached_label": "Keširane datoteke", "cache_cleared": "Cache uspješno obrisan!", "cache_clear_error": "Neuspješno brisanje cachea.", "site_data_title": "Upravljanje podacima stranice", "site_data_description": "Obriši sve podatke stranice uključujući postavke i cache i resetuj VERT.", "clear_all_data": "Obriši sve podatke", "clear_all_data_confirm_title": "Obrisati sve podatke stranice?", "clear_all_data_confirm": "Resetovat će sve postavke i cache i osvježiti stranicu. Ova akcija je nepovratna.", "clear_all_data_cancel": "Otkaži", "all_data_cleared": "Svi podaci obrisani! Osvježavam stranicu...", "all_data_clear_error": "Neuspješno brisanje svih podataka." }, "language": { "title": "Jezik", "description": "Odaberite željeni jezik VERT interfejsa." } }, "about": { "title": "O nama", "why": { "title": "Zašto VERT?", "description": "Konverteri datoteka su nas uvijek razočaravali. Ružni su, puni reklama i, najvažnije, spori. Odlučili smo to riješiti jednom zauvijek.

Sve ne-video datoteke se obrađuju lokalno, što znači da nema slanja datoteka na server — i mi nikad ne vidimo vaše podatke.

Video se otprema na naš brzi RTX 4000 Ada server. Vaši video snimci ostaju tamo sat vremena ako ih ne konvertujete. Ako ih konvertujete, ostaju sat ili dok ih preuzmete, nakon čega se brišu." }, "sponsors": { "title": "Sponzori", "description": "Želite nas podržati? Kontaktirajte nekog od developera na [discord_link]Discordu[/discord_link] ili pošaljite email na", "email_copied": "Email kopiran!" }, "resources": { "title": "Resursi", "discord": "Discord", "source": "Izvor", "email": "Email" }, "donate": { "title": "Donirajte VERT-u", "description": "Vaša podrška pomaže da nastavimo razvijati i unapređivati VERT.", "one_time": "Jednokratno", "monthly": "Mjesečno", "custom": "Prilagođeno", "pay_now": "Plati sada", "donate_amount": "Doniraj ${amount} USD", "thank_you": "Hvala na donaciji!", "payment_failed": "Plaćanje nije uspjelo: {message}{period}. Novac nije skinut s vašeg računa.", "donation_error": "Došlo je do greške pri obradi donacije. Pokušajte ponovo kasnije.", "payment_error": "Greška pri dohvaćanju podataka o plaćanju. Pokušajte ponovo.", "donation_notice_official": "Donacije ovdje idu za zvaničnu VERT instancu (vert.sh) i pomažu razvoj projekta.", "donation_notice_unofficial": "Donacije ovdje idu operateru ove VERT instance. Ako želite podržati zvanične developere, posjetite [official_link]vert.sh[/official_link]." }, "credits": { "title": "Zasluge", "contact_team": "Ako želite kontaktirati razvojni tim, koristite email iz kartice \"Resursi\".", "notable_contributors": "Istaknuti doprinosioci", "notable_description": "Želimo zahvaliti ovim osobama na velikim doprinosima VERT-u.", "github_contributors": "GitHub doprinosioci", "github_description": "Veliko hvala svima! [github_link]Želite pomoći i vi?[/github_link]", "no_contributors": "Izgleda da još niko nije doprinio... [contribute_link]budite prvi![/contribute_link]", "libraries": "Biblioteke", "libraries_description": "Veliko hvala FFmpeg-u (audio, video), ImageMagick-u (slike) i Pandoc-u (dokumenti). VERT se na njima temelji.", "roles": { "lead_developer": "Glavni developer; backend konverzije, UI implementacija", "developer": "Developer; UI implementacija", "designer": "Dizajner; UX, brending, marketing", "docker_ci": "Održavanje Docker & CI podrške", "former_cofounder": "Bivši suosnivač i dizajner" } }, "errors": { "github_contributors": "Greška pri dohvaćanju GitHub doprinosilaca" } }, "workers": { "errors": { "general": "Greška pri konverziji {file}: {message}", "cancel": "Greška pri otkazivanju konverzije za {file}: {message}", "magick": "Greška u Magick workeru, konverzija slika možda neće raditi ispravno.", "ffmpeg": "Greška pri učitavanju FFmpeg-a, neke funkcije možda neće raditi.", "pandoc": "Greška pri učitavanju Pandoc workera, dokumenti možda neće biti konvertovani.", "no_audio": "Nije pronađen audio zapis.", "invalid_rate": "Nevažeća sample rate vrijednost: {rate}Hz", "file_too_large": "Ova datoteka prelazi {limit}GB ograničenje preglednika/uređaja. Pokušajte u Firefoxu ili Safariju." } }, "privacy": { "title": "Politika privatnosti", "summary": { "title": "Sažetak", "description": "VERT-ova politika privatnosti je vrlo jednostavna: ne prikupljamo niti pohranjujemo ikakve podatke o vama. Ne koristimo kolačiće ni trackere, analitika je potpuno privatna, a konverzije (osim videa) rade lokalno. Video se briše nakon preuzimanja ili nakon sat vremena, osim ako nam ne date dozvolu da ga čuvamo radi rješavanja problema. Koristimo Coolify za hosting i Plausible za anonimnu analitiku. Stripe obrađuje donacije i može prikupiti podatke za prevenciju prevara.

Ovo vrijedi za zvaničnu instancu [vert_link]vert.sh[/vert_link]; treće strane mogu raditi drugačije." }, "conversions": { "title": "Konverzije", "description": "Većina konverzija (slike, dokumenti, audio) se obavlja lokalno putem WebAssembly alata (ImageMagick, Pandoc, FFmpeg). Vaše datoteke ne napuštaju uređaj.

Video konverzije se obavljaju na našim serverima jer zahtijevaju više snage. Video se briše nakon preuzimanja ili sat vremena, osim ako nam eksplicitno ne dozvolite duže čuvanje radi otklanjanja grešaka." }, "donations": { "title": "Donacije", "description": "Koristimo Stripe na stranici [about_link]o nama[/about_link] za donacije. Stripe može prikupiti određene informacije radi prevencije prevara, opisano u [stripe_link]njihovoj dokumentaciji[/stripe_link]. Eksterni zahtjevi se šalju tek nakon vašeg klika." }, "conversion_errors": { "title": "Greške pri konverziji", "description": "Kada konverzija videa ne uspije, možemo prikupiti anonimne informacije radi dijagnostike:", "list_job_id": "ID zadatka (anonimizirano ime datoteke)", "list_format_from": "Format iz kojeg se konvertuje", "list_format_to": "Format u koji se konvertuje", "list_stderr": "FFmpeg stderr (poruka greške)", "list_video": "Stvarni video zapis (samo uz vašu dozvolu)", "footer": "Ove informacije se koriste samo za dijagnostiku. Sam video se prikuplja samo uz vašu dozvolu." }, "analytics": { "title": "Analitika", "description": "Koristimo vlastitu Plausible instancu za potpuno anonimnu analitiku. Plausible ne koristi kolačiće i usklađen je sa svim glavnim zakonima o privatnosti. Možete isključiti analitiku u sekciji \"Privatnost i podaci\" u [settings_link]postavkama[/settings_link] i pročitati više [plausible_link]ovdje[/plausible_link]." }, "local_storage": { "title": "Lokalno skladištenje", "description": "Vaše postavke se čuvaju u local storage-u preglednika, a lista GitHub doprinosilaca u session storage-u. Nijedan lični podatak se ne skladišti.

WebAssembly alati (FFmpeg, ImageMagick, Pandoc) se također čuvaju lokalno. Možete ih vidjeti ili obrisati u sekciji \"Privatnost i podaci\" u [settings_link]postavkama[/settings_link]." }, "contact": { "title": "Kontakt", "description": "Za pitanja, pišite nam na: [email_link]hello@vert.sh[/email_link]. Ako koristite treću stranu, kontaktirajte njihovog hostera." }, "last_updated": "Posljednje ažuriranje: 2025-10-29" } } ================================================ FILE: messages/de.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Hochladen", "convert": "Konvertieren", "settings": "Optionen", "about": "Über", "toggle_theme": "Design wechseln" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Quellcode", "discord_server": "Discord-Server", "privacy_policy": "Datenschutzerklärung" }, "upload": { "title": "Der Dateikonverter, den du lieben wirst.", "subtitle": "Die Verarbeitung aller Bild-, Audio- und Dokumentdateien findet direkt auf deinem Gerät statt. Videos werden auf unseren blitzschnellen Servern konvertiert. Kein Dateigrößenlimit, keine Werbung und vollständig Open Source.", "uploader": { "text": "Dateien hier ablegen oder klicken zum {action}", "convert": "Konvertieren" }, "cards": { "title": "VERT unterstützt...", "images": "Bilder", "audio": "Audio", "documents": "Dokumente", "video": "Video", "video_server_processing": "Server-gestützt", "local_supported": "Lokal unterstützt", "status": { "text": "Status: {status}", "ready": "Bereit", "not_ready": "Nicht bereit", "not_initialized": "Nicht initialisiert", "downloading": "Herunterladen...", "initializing": "Initialisieren...", "unknown": "Unbekannter Status" }, "supported_formats": "Unterstützte Formate:" }, "tooltip": { "partial_support": "Dieses Format kann nur als {direction} konvertiert werden.", "direction_input": "Eingabe (von)", "direction_output": "Ausgabe (nach)", "video_server_processing": "Videos werden standardmäßig zur Verarbeitung auf einen Server hochgeladen. Erfahre hier, wie du die Verarbeitung lokal einrichten kannst." } }, "convert": { "archive_file": { "extract": "Archiv entpacken", "extracting": "Archiv erkannt: {filename}", "extracted": "{extract_count} Dateien aus {filename} entpackt. {ignore_count} Elemente wurden ignoriert.", "detected": "{type}-Dateien in {filename} erkannt.", "audio": "Audio", "video": "Video", "doc": "Dokument", "image": "Bild", "extract_error": "Fehler beim Entpacken von {filename}: {error}" }, "large_file_warning": "Aufgrund von Browser-/Gerätebeschränkungen ist die Video-zu-Audio-Konvertierung für diese Datei deaktiviert, da sie größer als {limit}GB ist. Wir empfehlen Firefox oder Safari für Dateien dieser Größe, da diese weniger Einschränkungen haben.", "external_warning": { "title": "Warnung: Externer Server", "text": "Wenn du in ein Videoformat konvertierst, werden diese Dateien zur Verarbeitung auf einen externen Server hochgeladen. Möchtest du fortfahren?", "yes": "Ja", "no": "Nein" }, "panel": { "convert_all": "Alle konvertieren", "download_all": "Alle als .zip laden", "remove_all": "Alle entfernen", "set_all_to": "Alle konvertieren nach", "na": "N/V" }, "dropdown": { "audio": "Audio", "video": "Video", "doc": "Dokument", "image": "Bild", "placeholder": "Format suchen", "no_formats": "Keine Formate verfügbar", "no_results": "Keine Ergebnisse" }, "tooltips": { "unknown_file": "Unbekannter Dateityp", "audio_file": "Audiodatei", "video_file": "Videodatei", "document_file": "Dokumentdatei", "image_file": "Bilddatei", "convert_file": "Diese Datei konvertieren", "download_file": "Diese Datei herunterladen" }, "errors": { "cant_convert": "Wir können diese Datei nicht konvertieren.", "vertd_server": "Was machst du da..? Du solltest den vertd-Server ausführen!", "vertd_generic_view": "Fehlerdetails anzeigen", "vertd_generic_body": "Ein Fehler ist aufgetreten, während versucht wurde, dein Video zu konvertieren. Möchtest du dieses Video an die Entwickler senden, um bei der Behebung dieses Fehlers zu helfen? Nur die Videodatei wird gesendet. Es werden keine Identifikatoren hochgeladen.", "vertd_generic_title": "Videokonvertierungsfehler", "vertd_generic_yes": "Video senden", "vertd_generic_no": "Nicht senden", "vertd_failed_to_keep": "Das Video konnte nicht auf dem Server behalten werden: {error}", "vertd_details": "Fehlerdetails anzeigen", "vertd_details_body": "Wenn du auf Senden drückst, wird dein Video ebenfalls angehängt, zusammen mit dem Fehlerprotokoll, das uns immer zur Überprüfung gemeldet wird. Die folgenden Informationen sind das Protokoll, das wir automatisch erhalten:", "vertd_details_footer": "Diese Informationen werden nur zur Fehlerbehebung verwendet und niemals weitergegeben. Sieh dir unsere [privacy_link]Datenschutzerklärung[/privacy_link] für weitere Details an.", "vertd_details_job_id": "Job-ID: {jobId}", "vertd_details_from": "Von Format: {from}", "vertd_details_to": "Zu Format: {to}", "vertd_details_error_message": "Fehlermeldung: [view_link]Fehlerprotokolle anzeigen[/view_link]", "vertd_details_close": "Schließen", "vertd_ratelimit": "Dein Video, '{filename}', konnte mehrmals nicht konvertiert werden. Um eine Überlastung des Servers zu vermeiden, wurden weitere Konvertierungsversuche für diese Datei vorübergehend blockiert. Bitte versuche es später erneut.", "unsupported_format": "Es werden nur Bild-, Video-, Audio- und Dokumentdateien unterstützt.", "format_output_only": "Dieses Format kann derzeit nur als Ausgabe (konvertiert zu), nicht als Eingabe verwendet werden.", "vertd_not_found": "Konnte die vertd-Instanz nicht finden, um die Videokonvertierung zu starten. Bist du sicher, dass die Instanz-URL korrekt eingestellt ist?", "worker_downloading": "Der {type}-Konverter wird gerade initialisiert, bitte warte einen Moment.", "worker_error": "Beim Initialisieren des {type}-Konverters ist ein Fehler aufgetreten, bitte versuche es später erneut.", "worker_timeout": "Die Initialisierung des {type}-Konverters dauert länger als erwartet, bitte warte noch einen Moment oder lade die Seite neu.", "audio": "Audio", "doc": "Dokument", "image": "Bild" } }, "settings": { "title": "Optionen", "errors": { "save_failed": "Speichern der Einstellungen fehlgeschlagen!" }, "appearance": { "title": "Erscheinungsbild", "brightness_theme": "Farbschema", "brightness_description": "Möchtest du einen sonnigen Blendeffekt oder eine ruhige, einsame Nacht?", "light": "Hell", "dark": "Dunkel", "effect_settings": "Effekteinstellungen", "effect_description": "Möchtest du schicke Effekte oder eine eher statische Erfahrung?", "enable": "Animiert", "disable": "Statisch" }, "conversion": { "title": "Konvertierung", "advanced_settings": "Erweiterte Einstellungen", "filename_format": "Dateinamensformat", "filename_description": "Dies bestimmt den Namen der Datei beim Herunterladen, ohne die Dateiendung. Du kannst folgende Platzhalter verwenden: %name% für den ursprünglichen Dateinamen, %extension% für die ursprüngliche Dateiendung und %date% für das Datum der Konvertierung.", "placeholder": "VERT_%name%", "default_format": "Standard-Format", "default_format_enable": "Aktivieren", "default_format_disable": "Deaktivieren", "default_format_description": "Dies ändert das Format, das standardmäßig ausgewählt wird, wenn du eine Datei dieses Typs hochlädst.", "default_format_image": "Bilder", "default_format_video": "Videos", "default_format_audio": "Audio", "default_format_document": "Dokumente", "metadata": "Metadaten", "metadata_description": "Dies legt fest, ob Metadaten (EXIF, Song-Infos etc.) der Originaldatei in den konvertierten Dateien erhalten bleiben.", "keep": "Behalten", "remove": "Entfernen", "quality": "Qualität", "quality_description": "Dies ändert die Standard-Qualität der konvertierten Dateien. Höhere Werte können zu längeren Konvertierungszeiten und größeren Dateien führen.", "quality_video": "Dies ändert die Standard-Qualität der konvertierten Videodateien. Höhere Werte können zu längeren Konvertierungszeiten und größeren Dateien führen.", "quality_audio": "Audio (kbps)", "quality_images": "Bild (%)", "rate": "Abtastrate (Hz)" }, "vertd": { "title": "Videokonvertierung", "status": "Status:", "loading": "lädt...", "available": "verfügbar, Commit-ID {commitId}", "unavailable": "nicht verfügbar (ist die URL korrekt?)", "description": "Das Projekt vertd ist ein Server-Wrapper für FFmpeg. Dies ermöglicht es dir, Videos bequem über die Weboberfläche von VERT zu konvertieren und dabei die Leistung deiner GPU für maximale Geschwindigkeit zu nutzen.", "hosting_info": "Wir hosten eine öffentliche Instanz für deine Bequemlichkeit, aber es ist einfach, eine eigene auf deinem PC oder Server zu hosten, wenn du weißt, was du tust. Du kannst die Server-Binärdateien [vertd_link]hier[/vertd_link] herunterladen – der Einrichtungsprozess wird in Zukunft noch einfacher!", "instance": "Instanz", "url_placeholder": "Beispiel: http://localhost:24153", "conversion_speed": "Konvertierungsgeschwindigkeit", "speed_description": "Dies beschreibt den Kompromiss zwischen Geschwindigkeit und Qualität. Schnellere Einstellungen führen zu geringerer Qualität, erledigen die Aufgabe aber schneller.", "speeds": { "very_slow": "Sehr langsam", "slower": "Langsamer", "slow": "Langsam", "medium": "Mittel", "fast": "Schnell", "ultra_fast": "Ultraschnell" }, "auto_instance": "Automatisch (empfohlen)", "eu_instance": "Falkenstein, Deutschland", "us_instance": "Washington, USA", "custom_instance": "Benutzerdefiniert" }, "privacy": { "title": "Datenschutz & Daten", "plausible_title": "Plausible Analytics", "plausible_description": "Wir verwenden [plausible_link]Plausible[/plausible_link], ein datenschutzorientiertes Analysetool, um vollständig anonyme Statistiken zu sammeln. Alle Daten werden anonymisiert und aggregiert; es werden niemals identifizierbare Informationen gesendet oder gespeichert. Du kannst die Analysen [analytics_link]hier[/analytics_link] einsehen und dich unten abmelden.", "opt_in": "Einwilligen", "opt_out": "Ablehnen", "cache_title": "Cache-Verwaltung", "cache_description": "Wir speichern die Konverter-Dateien in deinem Browser-Cache, damit du sie nicht jedes Mal neu herunterladen musst. Das verbessert die Leistung und spart Datenvolumen.", "refresh_cache": "Cache aktualisieren", "clear_cache": "Cache leeren", "files_cached": "{size} ({count} Dateien)", "loading_cache": "Lädt...", "total_size": "Gesamtgröße", "files_cached_label": "Gecachte Dateien", "cache_cleared": "Cache erfolgreich geleert!", "cache_clear_error": "Fehler beim Leeren des Caches.", "site_data_title": "Seitendaten-Verwaltung", "site_data_description": "Lösche alle Seitendaten einschließlich Einstellungen und gecachten Dateien, um VERT auf den Standardzustand zurückzusetzen und die Seite neu zu laden.", "clear_all_data": "Alle Seitendaten löschen", "clear_all_data_confirm_title": "Alle Seitendaten löschen?", "clear_all_data_confirm": "Dies setzt alle Einstellungen und den Cache zurück und lädt die Seite neu. Diese Aktion kann nicht rückgängig gemacht werden.", "clear_all_data_cancel": "Abbrechen", "all_data_cleared": "Alle Daten gelöscht! Seite wird neu geladen...", "all_data_clear_error": "Fehler beim Löschen der Seitendaten." }, "language": { "title": "Sprache", "description": "Wähle deine bevorzugte Sprache für die VERT-Benutzeroberfläche." } }, "about": { "title": "Über", "why": { "title": "Warum VERT?", "description": "Dateikonverter haben uns schon immer enttäuscht. Sie sind hässlich, voller Werbung und vor allem langsam. Wir haben beschlossen, dieses Problem ein für alle Mal zu lösen, indem wir eine Alternative schaffen, die all diese Probleme und noch mehr behebt.

Alle Nicht-Videodateien werden vollständig auf deinem Gerät konvertiert; das bedeutet, es gibt keine Verzögerung beim Senden und Empfangen der Dateien von einem Server, und wir können niemals die von dir konvertierten Dateien einsehen.

Videodateien werden auf unseren blitzschnellen RTX 4000 Ada Server hochgeladen. Deine Videos bleiben dort für eine Stunde, wenn du sie nicht konvertierst. Wenn du die Datei konvertierst, bleibt das Video für eine Stunde auf dem Server oder bis es heruntergeladen wird. Anschließend wird die Datei von unserem Server gelöscht." }, "sponsors": { "title": "Sponsoren", "description": "Möchtest du uns unterstützen? Kontaktiere einen Entwickler auf dem [discord_link]Discord[/discord_link]-Server oder sende eine E-Mail an", "email_copied": "E-Mail in die Zwischenablage kopiert!" }, "resources": { "title": "Ressourcen", "discord": "Discord", "source": "Quellcode", "email": "E-Mail" }, "donate": { "title": "An VERT spenden", "description": "Mit deiner Unterstützung können wir VERT weiter pflegen und verbessern.", "one_time": "Einmalig", "monthly": "Monatlich", "custom": "Benutzerdefiniert", "pay_now": "Jetzt zahlen", "donate_amount": "${amount} USD spenden", "thank_you": "Vielen Dank für deine Spende!", "payment_failed": "Zahlung fehlgeschlagen: {message}{period} Dir wurde nichts berechnet.", "donation_error": "Bei der Verarbeitung deiner Spende ist ein Fehler aufgetreten. Bitte versuche es später erneut.", "payment_error": "Fehler beim Abrufen der Zahlungsdetails. Bitte versuche es später erneut.", "donation_notice_official": "Deine Spenden hier gehen an die offizielle VERT-Instanz (vert.sh) und helfen, die Entwicklung des Projekts zu unterstützen.", "donation_notice_unofficial": "Deine Spenden hier gehen an den Betreiber dieser VERT-Instanz. Wenn du die offiziellen VERT-Entwickler unterstützen möchtest, besuche bitte [official_link]vert.sh[/official_link]." }, "credits": { "title": "Credits", "contact_team": "Wenn du das Entwicklungsteam kontaktieren möchtest, verwende bitte die E-Mail-Adresse auf der Karte „Ressourcen“.", "notable_contributors": "Nennenswerte Beiträge", "notable_description": "Wir möchten diesen Personen für ihre wichtigen Beiträge zu VERT danken.", "github_contributors": "GitHub-Mitwirkende", "github_description": "Ein großes Dankeschön an alle für ihre Hilfe! [github_link]Möchtest du auch helfen?[/github_link]", "no_contributors": "Scheint, als hätte noch niemand beigetragen... [contribute_link]Sei der Erste![/contribute_link]", "libraries": "Bibliotheken", "libraries_description": "Ein großes Dankeschön an FFmpeg (Audio, Video), ImageMagick (Bilder) und Pandoc (Dokumente) für die Pflege solch exzellenter Bibliotheken über so viele Jahre. VERT verlässt sich auf sie, um dir deine Konvertierungen zu ermöglichen.", "roles": { "lead_developer": "Lead Developer; Backend, UI-Implementierung", "developer": "Developer; UI-Implementierung", "designer": "Designer; UX, Branding, Marketing", "docker_ci": "Docker & CI-Support", "former_cofounder": "Ehemaliger Co-Founder & Designer" } }, "errors": { "github_contributors": "Fehler beim Abrufen der GitHub-Mitwirkenden" } }, "workers": { "errors": { "general": "Fehler beim Konvertieren von {file}: {message}", "cancel": "Fehler beim Abbrechen der Konvertierung für {file}: {message}", "magick": "Fehler im Magick-Prozess, die Bildkonvertierung funktioniert möglicherweise nicht wie erwartet.", "ffmpeg": "Fehler beim Laden von FFmpeg, einige Funktionen sind möglicherweise nicht verfügbar.", "pandoc": "Fehler beim Laden von Pandoc, die Dokumentkonvertierung funktioniert möglicherweise nicht wie erwartet.", "no_audio": "Kein Audiostream gefunden.", "invalid_rate": "Ungültige Abtastrate angegeben: {rate}Hz", "file_too_large": "Diese Datei überschreitet das Browser-/Gerätelimit von {limit}GB. Versuche es mit Firefox oder Safari, die typischerweise höhere Limits haben." } }, "privacy": { "title": "Datenschutzerklärung", "summary": { "title": "Zusammenfassung", "description": "Die Datenschutzrichtlinie von VERT ist sehr einfach: Wir sammeln oder speichern keinerlei Daten über dich. Wir verwenden keine Cookies oder Tracker, Analysen sind vollständig privat, und alle Konvertierungen (außer Videos) finden lokal in deinem Browser statt. Videos werden nach dem Herunterladen oder nach einer Stunde gelöscht, es sei denn, du gibst uns ausdrücklich die Erlaubnis zur Speicherung; dies wird nur zur Fehlerbehebung verwendet. VERT hostet selbst eine Coolify-Instanz für die Website und vertd (für Videokonvertierung) sowie eine Plausible-Instanz für vollständig anonyme und aggregierte Analysen. Wir nutzen Stripe zur Verarbeitung von Spenden, was einige Daten zur Betrugsprävention sammeln kann.

Beachte, dass dies möglicherweise nur für die offizielle VERT-Instanz unter [vert_link]vert.sh[/vert_link] gilt; Drittanbieter-Instanzen könnten deine Daten anders behandeln." }, "conversions": { "title": "Konvertierungen", "description": "Die meisten Konvertierungen (Bilder, Dokumente, Audio) erfolgen vollständig lokal auf deinem Gerät unter Verwendung von WebAssembly-Versionen der entsprechenden Tools (z. B. ImageMagick, Pandoc, FFmpeg). Das bedeutet, dass deine Dateien dein Gerät nie verlassen und wir niemals Zugriff darauf haben.

Videokonvertierungen werden auf unseren Servern durchgeführt, da sie mehr Rechenleistung erfordern und im Browser noch nicht schnell genug durchgeführt werden können. Videos, die du mit VERT konvertierst, werden nach dem Herunterladen oder nach einer Stunde gelöscht, es sei denn, du gibst uns ausdrücklich die Erlaubnis, sie länger zu speichern, rein zu Fehlerbehebungszwecken." }, "donations": { "title": "Spenden", "description": "Wir verwenden Stripe auf der [about_link]Über[/about_link]-Seite, um Spenden zu sammeln. Stripe kann bestimmte Informationen über die Zahlung und das Gerät zur Betrugsprävention sammeln, wie in [stripe_link]ihrer Dokumentation zur erweiterten Betrugserkennung[/stripe_link] beschrieben. Externe Netzwerkanfragen an Stripe werden verzögert und erst gestellt, wenn du auf den Button zum Bezahlen klickst." }, "conversion_errors": { "title": "Konvertierungsfehler", "description": "Wenn eine Videokonvertierung fehlschlägt, sammeln wir möglicherweise einige anonyme Daten, um das Problem zu diagnostizieren. Diese Daten können beinhalten:", "list_job_id": "Die Job-ID, welche der anonymisierte Dateiname ist", "list_format_from": "Das Format, aus dem du konvertiert hast", "list_format_to": "Das Format, in das du konvertiert hast", "list_stderr": "Die FFmpeg stderr-Ausgabe deines Jobs (Fehlermeldung)", "list_video": "Die eigentliche Videodatei (nur bei ausdrücklicher Erlaubnis)", "footer": "Diese Informationen werden ausschließlich zur Diagnose von Konvertierungsproblemen verwendet. Die eigentliche Videodatei wird nur gesammelt, wenn du uns die Erlaubnis dazu gibst, und dann auch nur zur Fehlerbehebung verwendet." }, "analytics": { "title": "Analysen", "description": "Wir hosten selbst eine Plausible-Instanz für vollständig anonyme und aggregierte Analysen. Plausible verwendet keine Cookies und entspricht allen wichtigen Datenschutzbestimmungen (DSGVO/CCPA/PECR). Du kannst dich im Abschnitt „Datenschutz & Daten“ in den [settings_link]Einstellungen[/settings_link] von den Analysen abmelden und [plausible_link]hier[/plausible_link] mehr über die Datenschutzpraktiken von Plausible lesen." }, "local_storage": { "title": "Lokaler Speicher", "description": "Wir verwenden den lokalen Speicher (Local Storage) deines Browsers, um deine Einstellungen zu speichern, und den Sitzungsspeicher (Session Storage), um die Liste der GitHub-Mitwirkenden für den Bereich „Über“ vorübergehend zu speichern und wiederholte GitHub-API-Anfragen zu reduzieren. Es werden keine persönlichen Daten gespeichert oder übertragen.

Die WebAssembly-Versionen der von uns verwendeten Konvertierungstools (FFmpeg, ImageMagick, Pandoc) werden ebenfalls lokal in deinem Browser gespeichert, wenn du die Website zum ersten Mal besuchst, damit du sie nicht bei jedem Besuch erneut herunterladen musst. Es werden keine persönlichen Daten gespeichert oder übertragen. Du kannst diese Daten jederzeit im Abschnitt „Datenschutz & Daten“ in den [settings_link]Einstellungen[/settings_link] einsehen oder löschen." }, "contact": { "title": "Kontakt", "description": "Für Fragen sende uns eine E-Mail an: [email_link]hello@vert.sh[/email_link]. Wenn du eine Drittanbieter-Instanz von VERT verwendest, kontaktiere bitte stattdessen den Hoster dieser Instanz." }, "last_updated": "Zuletzt aktualisiert: 29.10.2025" } } ================================================ FILE: messages/el.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Μεταφόρτωση", "convert": "Μετατροπή", "settings": "Ρυθμίσεις", "about": "Σχετικά", "toggle_theme": "Εναλλαγή θέματος" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Κώδικας", "discord_server": "Discord" }, "upload": { "title": "Ο μετατροπέας αρχείων που θα λατρέψετε.", "subtitle": "Όλη η επεξεργασία εικόνων, ήχου και εγγράφων γίνεται στη συσκευή σας. Τα βίντεο μετατρέπονται στους κεραυνοβόλα γρήγορους διακομιστές μας. Χωρίς όριο μεγέθους αρχείου, χωρίς διαφημίσεις και εντελώς ανοιχτού κώδικα.", "uploader": { "text": "Σύρετε ή κάντε κλικ για {action}", "convert": "μετατροπή" }, "cards": { "title": "Το VERT υποστηρίζει...", "images": "Εικόνες", "audio": "Ήχο", "documents": "Έγγραφα", "video": "Βίντεο", "video_server_processing": "Υποστηρίζεται από σέρβερ", "local_supported": "Τοπική υποστήριξη", "status": { "text": "Κατάσταση: {status}", "ready": "έτοιμο", "not_ready": "μη έτοιμο", "not_initialized": "μη αρχικοποιημένο", "downloading": "λήψη...", "initializing": "αρχικοποίηση...", "unknown": "άγνωστη κατάσταση" }, "supported_formats": "Υποστηριζόμενες μορφές:" }, "tooltip": { "partial_support": "Αυτή η μορφή μπορεί να μετατραπεί μόνο ως {direction}.", "direction_input": "είσοδος (από)", "direction_output": "έξοδος (προς)", "video_server_processing": "Τα βίντεο μεταφορτώνονται σε σέρβερ για επεξεργασία από προεπιλογή, μάθετε πώς να το ρυθμίσετε τοπικά εδώ." } }, "convert": { "external_warning": { "title": "Προειδοποίηση εξωτερικού σέρβερ", "text": "Εάν επιλέξετε να μετατρέψετε σε μορφή βίντεο, αυτά τα αρχεία θα μεταφορτωθούν σε εξωτερικό σέρβερ για μετατροπή. Θέλετε να συνεχίσετε;", "yes": "Ναι", "no": "Όχι" }, "panel": { "convert_all": "Μετατροπή όλων", "download_all": "Λήψη όλων ως .zip", "remove_all": "Αφαίρεση όλων των αρχείων", "set_all_to": "Ορισμός όλων σε", "na": "Μ/Δ" }, "dropdown": { "audio": "Ήχος", "video": "Βίντεο", "doc": "Έγγραφο", "image": "Εικόνα", "placeholder": "Αναζήτηση μορφής" }, "tooltips": { "unknown_file": "Άγνωστος τύπος αρχείου", "audio_file": "Αρχείο ήχου", "video_file": "Αρχείο βίντεο", "document_file": "Αρχείο εγγράφου", "image_file": "Αρχείο εικόνας", "convert_file": "Μετατροπή αυτού του αρχείου", "download_file": "Λήψη αυτού του αρχείου" }, "errors": { "cant_convert": "Δεν μπορούμε να μετατρέψουμε αυτό το αρχείο.", "vertd_server": "τι κάνεις...; υποτίθεται ότι πρέπει να εκτελέσεις τον σέρβερ vertd!", "vertd_generic_body": "Παρουσιάστηκε σφάλμα κατά την προσπάθεια μετατροπής του βίντεό σας. Θέλετε να υποβάλετε αυτό το βίντεο στους προγραμματιστές για να βοηθήσετε στη διόρθωση αυτού του σφάλματος; Θα αποσταλεί μόνο το αρχείο βίντεό σας. Δεν θα μεταφορτωθούν αναγνωριστικά.", "vertd_generic_title": "Σφάλμα μετατροπής βίντεο", "vertd_generic_yes": "Υποβολή βίντεο", "vertd_generic_no": "Μην υποβάλετε", "vertd_failed_to_keep": "Αποτυχία διατήρησης του βίντεο στον σέρβερ: {error}", "unsupported_format": "Υποστηρίζονται μόνο αρχεία εικόνας, βίντεο, ήχου και εγγράφων", "vertd_not_found": "Δεν ήταν δυνατή η εύρεση της παρουσίας vertd για την έναρξη της μετατροπής βίντεο. Είστε βέβαιοι ότι η διεύθυνση URL έχει ρυθμιστεί σωστά;", "worker_downloading": "Ο μετατροπέας {type} αρχικοποιείται αυτή τη στιγμή, παρακαλώ περιμένετε λίγο.", "worker_error": "Ο μετατροπέας {type} αντιμετώπισε σφάλμα κατά την αρχικοποίηση, παρακαλώ δοκιμάστε ξανά αργότερα.", "worker_timeout": "Ο μετατροπέας {type} χρειάζεται περισσότερο χρόνο από το αναμενόμενο για να αρχικοποιηθεί, παρακαλώ περιμένετε λίγο ακόμη ή ανανεώστε τη σελίδα.", "audio": "ήχου", "doc": "εγγράφου", "image": "εικόνας" } }, "settings": { "title": "Ρυθμίσεις", "errors": { "save_failed": "Αποτυχία αποθήκευσης ρυθμίσεων!" }, "appearance": { "title": "Εμφάνιση", "brightness_theme": "Θέμα φωτεινότητας", "brightness_description": "Θέλετε μια ηλιόλουστη λάμψη ή μια ήσυχη μοναχική νύχτα;", "light": "Φωτεινό", "dark": "Σκούρο", "effect_settings": "Ρυθμίσεις εφέ", "effect_description": "Θα θέλατε φανταχτερά εφέ ή μια πιο στατική εμπειρία;", "enable": "Ενεργοποίηση", "disable": "Απενεργοποίηση" }, "conversion": { "title": "Μετατροπή", "advanced_settings": "Προηγμένες ρυθμίσεις", "filename_format": "Μορφή ονόματος αρχείου", "filename_description": "Αυτό θα καθορίσει το όνομα του αρχείου κατά τη λήψη, χωρίς να περιλαμβάνει την επέκταση αρχείου. Μπορείτε να τοποθετήσετε τα ακόλουθα πρότυπα στη μορφή, τα οποία θα αντικατασταθούν με τις σχετικές πληροφορίες: %name% για το αρχικό όνομα αρχείου, %extension% για την αρχική επέκταση αρχείου και %date% για μια συμβολοσειρά ημερομηνίας του πότε μετατράπηκε το αρχείο.", "placeholder": "VERT_%name%", "default_format": "Προεπιλεγμένη μορφή μετατροπής", "default_format_description": "Αυτό θα αλλάξει την προεπιλεγμένη μορφή που επιλέγεται όταν ανεβάζετε ένα αρχείο αυτού του τύπου.", "default_format_image": "Εικόνες", "default_format_video": "Βίντεο", "default_format_audio": "Ήχος", "default_format_document": "Έγγραφα", "metadata": "Μεταδεδομένα αρχείου", "metadata_description": "Αυτό αλλάζει το αν τυχόν μεταδεδομένα (EXIF, πληροφορίες τραγουδιού κ.λπ.) στο αρχικό αρχείο διατηρούνται στα μετατρεπόμενα αρχεία.", "keep": "Διατήρηση", "remove": "Αφαίρεση", "quality": "Ποιότητα μετατροπής", "quality_description": "Αυτό αλλάζει την προεπιλεγμένη ποιότητα εξόδου των μετατρεπόμενων αρχείων (στην κατηγορία του). Υψηλότερες τιμές μπορεί να οδηγήσουν σε μεγαλύτερους χρόνους μετατροπής και μέγεθος αρχείου.", "quality_video": "Αυτό αλλάζει την προεπιλεγμένη ποιότητα εξόδου των μετατρεπόμενων αρχείων βίντεο. Υψηλότερες τιμές μπορεί να οδηγήσουν σε μεγαλύτερους χρόνους μετατροπής και μέγεθος αρχείου.", "quality_audio": "Ήχος (kbps)", "quality_images": "Εικόνα (%)", "rate": "Ρυθμός δειγματοληψίας (Hz)" }, "vertd": { "title": "Μετατροπή βίντεο", "status": "κατάσταση:", "loading": "φόρτωση...", "available": "διαθέσιμο, αναγνωριστικό έκδοσης {commitId}", "unavailable": "μη διαθέσιμο (είναι σωστή η διεύθυνση url;)", "description": "Το έργο vertd είναι ένα περιτύλιγμα σέρβερ για το FFmpeg. Αυτό σας επιτρέπει να μετατρέπετε βίντεο μέσω της ευκολίας της διεπαφής ιστού του VERT, ενώ εξακολουθείτε να μπορείτε να αξιοποιήσετε τη δύναμη της GPU σας για να το κάνετε όσο το δυνατόν πιο γρήγορα.", "hosting_info": "Φιλοξενούμε μια δημόσια σελίδα για τη διευκόλυνσή σας, αλλά είναι αρκετά εύκολο να φιλοξενήσετε τη δική σας στον υπολογιστή ή τον σέρβερ σας αν γνωρίζετε τι κάνετε. Μπορείτε να κατεβάσετε τα δυαδικά αρχεία του σέρβερ [vertd_link]εδώ[/vertd_link] - η διαδικασία ρύθμισης θα γίνει ευκολότερη στο μέλλον, οπότε μείνετε συντονισμένοι!", "instance": "Παρουσία", "url_placeholder": "Παράδειγμα: http://localhost:24153", "conversion_speed": "Ταχύτητα μετατροπής", "speed_description": "Αυτό περιγράφει τον συμβιβασμό μεταξύ ταχύτητας και ποιότητας. Ταχύτερες ταχύτητες θα έχουν ως αποτέλεσμα χαμηλότερη ποιότητα, αλλά θα ολοκληρώσουν τη δουλειά γρηγορότερα.", "speeds": { "very_slow": "Πολύ αργή", "slower": "Αργότερη", "slow": "Αργή", "medium": "Μέτρια", "fast": "Γρήγορη", "ultra_fast": "Πολύ γρήγορη" }, "auto_instance": "Αυτόματη (συνιστάται)", "eu_instance": "Falkenstein, Γερμανία", "us_instance": "Washington, ΗΠΑ", "custom_instance": "Προσαρμοσμένη" }, "privacy": { "title": "Απόρρητο & δεδομένα", "plausible_title": "Αναλυτικά στοιχεία Plausible", "plausible_description": "Χρησιμοποιούμε το [plausible_link]Plausible[/plausible_link], ένα εργαλείο αναλυτικών που εστιάζει στο απόρρητο, για τη συλλογή εντελώς ανώνυμων στατιστικών. Όλα τα δεδομένα είναι ανωνυμοποιημένα και συγκεντρωτικά και δεν αποστέλλονται ούτε αποθηκεύονται ποτέ αναγνωρίσιμες πληροφορίες. Μπορείτε να δείτε τα αναλυτικά στοιχεία [analytics_link]εδώ[/analytics_link] και να επιλέξετε να εξαιρεθείτε παρακάτω.", "opt_in": "Συμμετοχή", "opt_out": "Εξαίρεση", "cache_title": "Διαχείριση προσωρινής μνήμης", "cache_description": "Αποθηκεύουμε προσωρινά τα αρχεία μετατροπέα στο πρόγραμμα περιήγησής σας, ώστε να μην χρειάζεται να τα κατεβάζετε ξανά κάθε φορά, βελτιώνοντας την απόδοση και μειώνοντας τη χρήση δεδομένων.", "refresh_cache": "Ανανέωση προσωρινής μνήμης", "clear_cache": "Εκκαθάριση προσωρινής μνήμης", "files_cached": "{size} ({count} αρχεία)", "loading_cache": "Φόρτωση...", "total_size": "Συνολικό μέγεθος", "files_cached_label": "Αρχεία σε προσωρινή μνήμη", "cache_cleared": "Η προσωρινή μνήμη εκκαθαρίστηκε επιτυχώς!", "cache_clear_error": "Αποτυχία εκκαθάρισης προσωρινής μνήμης." }, "language": { "title": "Γλώσσα", "description": "Επιλέξτε την προτιμώμενη γλώσσα σας για το περιβάλλον του VERT." } }, "about": { "title": "Σχετικά", "why": { "title": "Γιατί το VERT;", "description": "Οι μετατροπείς αρχείων μας απογοήτευαν πάντα. Είναι άσχημοι, γεμάτοι διαφημίσεις και το πιο σημαντικό· αργοί. Αποφασίσαμε να λύσουμε αυτό το πρόβλημα μια για πάντα δημιουργώντας μια εναλλακτική που λύνει όλα αυτά τα προβλήματα και περισσότερα.

Όλα τα αρχεία που δεν είναι βίντεο μετατρέπονται εντελώς στη συσκευή σας· αυτό σημαίνει ότι δεν υπάρχει καθυστέρηση μεταξύ της αποστολής και της λήψης των αρχείων από έναν σέρβερ και δεν αποκτούμε ποτέ πρόσβαση στα αρχεία που μετατρέπετε.

Τα αρχεία βίντεο μεταφορτώνονται στον αστραπιαία γρήγορο σέρβερ μας RTX 4000 Ada. Τα βίντεό σας παραμένουν εκεί για μία ώρα εάν δεν τα μετατρέψετε. Εάν μετατρέψετε το αρχείο, το βίντεο θα παραμείνει στον σέρβερ για μία ώρα ή μέχρι να ληφθεί. Στη συνέχεια, το αρχείο θα διαγραφεί από τον σέρβερ μας." }, "sponsors": { "title": "Χορηγοί", "description": "Θέλετε να μας υποστηρίξετε; Επικοινωνήστε με έναν προγραμματιστή στον σέρβερ [discord_link]Discord[/discord_link] ή στείλτε email στη διεύθυνση", "email_copied": "Το email αντιγράφηκε στο πρόχειρο!" }, "resources": { "title": "Πόροι", "discord": "Discord", "source": "Πηγαίος κώδικας", "email": "Email" }, "donate": { "title": "Δωρεά στο VERT", "description": "Με την υποστήριξή σας, μπορούμε να συνεχίσουμε να συντηρούμε και να βελτιώνουμε το VERT.", "one_time": "Εφάπαξ", "monthly": "Μηνιαία", "custom": "Προσαρμοσμένη", "pay_now": "Πληρωμή τώρα", "donate_amount": "Δωρεά ${amount} USD", "thank_you": "Σας ευχαριστούμε για τη δωρεά σας!", "payment_failed": "Η πληρωμή απέτυχε: {message}{period} Δεν χρεώθηκε ο λογαριασμός σας.", "donation_error": "Παρουσιάστηκε σφάλμα κατά την επεξεργασία της δωρεάς σας. Παρακαλώ δοκιμάστε ξανά αργότερα.", "payment_error": "Σφάλμα κατά την ανάκτηση στοιχείων πληρωμής. Παρακαλώ δοκιμάστε ξανά αργότερα." }, "credits": { "title": "Τίτλοι", "contact_team": "Εάν θέλετε να επικοινωνήσετε με την ομάδα ανάπτυξης, χρησιμοποιήστε το email που βρίσκεται στην κάρτα «Πόροι».", "notable_contributors": "Αξιόλογοι συνεισφέροντες", "notable_description": "Θα θέλαμε να ευχαριστήσουμε αυτά τα άτομα για τις σημαντικές συνεισφορές τους στο VERT.", "github_contributors": "Συνεισφέροντες στο GitHub", "github_description": "Μεγάλες ευχαριστίες σε όλα αυτά τα άτομα που βοήθησαν! [github_link]Θέλετε να βοηθήσετε κι εσείς;[/github_link]", "no_contributors": "Φαίνεται ότι κανείς δεν έχει συνεισφέρει ακόμα... [contribute_link]γίνετε ο πρώτος που θα συνεισφέρει![/contribute_link]", "libraries": "Βιβλιοθήκες", "libraries_description": "Μεγάλες ευχαριστίες στα FFmpeg (ήχος, βίντεο), ImageMagick (εικόνες) και Pandoc (έγγραφα) που διατηρούν τέτοιες εξαιρετικές βιβλιοθήκες για τόσα χρόνια. Το VERT βασίζεται σε αυτές για να σας παρέχει τις μετατροπές σας.", "roles": { "lead_developer": "Επικεφαλής προγραμματιστής· backend μετατροπής, υλοποίηση UI", "developer": "Προγραμματιστής· υλοποίηση UI", "designer": "Σχεδιαστής· UX, branding, μάρκετινγκ", "docker_ci": "Συντήρηση υποστήριξης Docker & CI", "former_cofounder": "Πρώην συνιδρυτής & σχεδιαστής" } }, "errors": { "github_contributors": "Σφάλμα κατά την ανάκτηση συνεισφερόντων του GitHub" } }, "workers": { "errors": { "general": "Σφάλμα κατά τη μετατροπή του {file}: {message}", "cancel": "Σφάλμα κατά την ακύρωση της μετατροπής για το {file}: {message}", "magick": "Σφάλμα στο worker του Magick, η μετατροπή εικόνων μπορεί να μην λειτουργεί όπως αναμένεται.", "ffmpeg": "Σφάλμα κατά τη φόρτωση του ffmpeg, ορισμένες λειτουργίες μπορεί να μην λειτουργούν.", "no_audio": "Δεν βρέθηκε ροή ήχου.", "invalid_rate": "Καθορίστηκε μη έγκυρος ρυθμός δειγματοληψίας: {rate}Hz" } } } ================================================ FILE: messages/en.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Upload", "convert": "Convert", "settings": "Settings", "about": "About", "toggle_theme": "Toggle theme" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Source code", "discord_server": "Discord server", "privacy_policy": "Privacy policy" }, "upload": { "title": "The file converter you'll love.", "subtitle": "All image, audio, and document processing is done on your device. Videos are converted on our lightning-fast servers. No file size limit, no ads, and completely open source.", "uploader": { "text": "Drop or click to {action}", "convert": "convert" }, "cards": { "title": "VERT supports...", "images": "Images", "audio": "Audio", "documents": "Documents", "video": "Video", "video_server_processing": "Server supported", "local_supported": "Local supported", "status": { "text": "Status: {status}", "ready": "ready", "not_ready": "not ready", "not_initialized": "not initialized", "downloading": "downloading...", "initializing": "initializing...", "unknown": "unknown status" }, "supported_formats": "Supported formats:" }, "tooltip": { "partial_support": "This format can only be converted as {direction}.", "direction_input": "input (from)", "direction_output": "output (to)", "video_server_processing": "Video uploads to a server for processing by default, learn how to set it up locally here." } }, "convert": { "archive_file": { "extract": "Extract archive", "extracting": "Detected archive: {filename}", "extracted": "Extracted {extract_count} files from {filename}. {ignore_count} items were ignored.", "detected": "Detected {type} files in {filename}.", "audio": "audio", "video": "video", "doc": "document", "image": "image", "extract_error": "Error extracting {filename}: {error}" }, "large_file_warning": "Due to browser / device limitations, video to audio conversion is disabled for this file as it is larger than {limit}GB. We recommend using Firefox or Safari for files of this size since they have less limitations.", "external_warning": { "title": "External server warning", "text": "If you choose to convert into a video format, those files will be uploaded to an external server to be converted. Do you want to continue?", "yes": "Yes", "no": "No" }, "panel": { "convert_all": "Convert all", "download_all": "Download all as .zip", "remove_all": "Remove all files", "set_all_to": "Set all to", "na": "N/A" }, "dropdown": { "audio": "Audio", "video": "Video", "doc": "Document", "image": "Image", "placeholder": "Search format", "no_formats": "No formats available", "no_results": "No formats match your search" }, "tooltips": { "unknown_file": "Unknown file type", "audio_file": "Audio file", "video_file": "Video file", "document_file": "Document file", "image_file": "Image file", "convert_file": "Convert this file", "download_file": "Download this file" }, "errors": { "cant_convert": "We can't convert this file.", "vertd_server": "what are you doing..? you're supposed to run the vertd server!", "vertd_generic_view": "View error details", "vertd_generic_body": "An error occurred whilst whilst trying convert your video. Would you like to submit this video to the developers to help fix this bug? Only your video file will be sent. No identifiers will be uploaded.", "vertd_generic_title": "Video conversion error", "vertd_generic_yes": "Submit video", "vertd_generic_no": "Don't submit", "vertd_failed_to_keep": "Failed to keep the video on the server: {error}", "vertd_details": "View error details", "vertd_details_body": "If you press submit, your video will also be attached alongside the error log which is always reported to us for review. The following information is the log that we automatically receive:", "vertd_details_footer": "This information will only be used for troubleshooting purposes and will never be shared. View our [privacy_link]privacy policy[/privacy_link] for more details.", "vertd_details_job_id": "Job ID: {jobId}", "vertd_details_from": "From format: {from}", "vertd_details_to": "To format: {to}", "vertd_details_error_message": "Error message: [view_link]View error logs[/view_link]", "vertd_details_close": "Close", "vertd_ratelimit": "Your video, '{filename}', has failed to convert a few times. To prevent server overload, further conversion attempts for this file have been temporarily blocked. Please try again later.", "unsupported_format": "Only image, video, audio, and document files are supported", "format_output_only": "This format can currently only be used as output (converted to), not as input.", "vertd_not_found": "Could not find the vertd instance to start video conversion. Are you sure the instance URL is set correctly?", "worker_downloading": "The {type} converter is currently being initialized, please wait a few moments.", "worker_error": "The {type} converter had an error during initialization, please try again later.", "worker_timeout": "The {type} converter is taking longer than expected to initialize, please wait a few more moments or refresh the page.", "audio": "audio", "doc": "document", "image": "image" } }, "settings": { "title": "Settings", "errors": { "save_failed": "Failed to save settings!" }, "appearance": { "title": "Appearance", "brightness_theme": "Brightness theme", "brightness_description": "Want a sunny flash-bang, or a quiet lonely night?", "light": "Light", "dark": "Dark", "effect_settings": "Effect settings", "effect_description": "Would you like fancy effects, or a more static experience?", "enable": "Enable", "disable": "Disable" }, "conversion": { "title": "Conversion", "advanced_settings": "Advanced settings", "filename_format": "File name format", "filename_description": "This will determine the name of the file on download, not including the file extension. You can put these following templates in the format, which will be replaced with the relevant information: %name% for the original file name, %extension% for the original file extension, and %date% for a date string of when the file was converted.", "placeholder": "VERT_%name%", "default_format": "Default conversion format", "default_format_enable": "Enable", "default_format_disable": "Disable", "default_format_description": "This will change the default format selected when you upload a file of this file type.", "default_format_image": "Images", "default_format_video": "Videos", "default_format_audio": "Audio", "default_format_document": "Documents", "metadata": "File metadata", "metadata_description": "This changes whether any metadata (EXIF, song info, etc.) on the original file is preserved in converted files.", "keep": "Keep", "remove": "Remove", "quality": "Conversion quality", "quality_description": "This changes the default output quality of the converted files (in its category). Higher values may result in longer conversion times and file size.", "quality_video": "This changes the default output quality of the converted video files. Higher values may result in longer conversion times and file size.", "quality_audio": "Audio (kbps)", "quality_images": "Image (%)", "rate": "Sample rate (Hz)" }, "vertd": { "title": "Video conversion", "status": "status:", "loading": "loading...", "available": "available, commit id {commitId}", "unavailable": "unavailable (is the url right?)", "description": "The vertd project is a server wrapper for FFmpeg. This allows you to convert videos through the convenience of VERT's web interface, while still being able to harness the power of your GPU to do it as quickly as possible.", "hosting_info": "We host a public instance for your convenience, but it is quite easy to host your own on your PC or server if you know what you are doing. You can download the server binaries [vertd_link]here[/vertd_link] - the process of setting this up will become easier in the future, so stay tuned!", "instance": "Instance", "url_placeholder": "Example: http://localhost:24153", "conversion_speed": "Conversion speed", "speed_description": "This describes the tradeoff between speed and quality. Faster speeds will result in lower quality, but will get the job done quicker.", "speeds": { "very_slow": "Very Slow", "slower": "Slower", "slow": "Slow", "medium": "Medium", "fast": "Fast", "ultra_fast": "Ultra Fast" }, "auto_instance": "Auto (recommended)", "eu_instance": "Falkenstein, Germany", "us_instance": "Washington, USA", "custom_instance": "Custom" }, "privacy": { "title": "Privacy & data", "plausible_title": "Plausible analytics", "plausible_description": "We use [plausible_link]Plausible[/plausible_link], a privacy-focused analytics tool, to gather completely anonymous statistics. All data is anonymized and aggregated, and no identifiable information is ever sent or stored. You can view the analytics [analytics_link]here[/analytics_link] and choose to opt out below.", "opt_in": "Opt-in", "opt_out": "Opt-out", "cache_title": "Cache management", "cache_description": "We cache the converter files on your browser so you don't have to re-download them every time, improving performance and reducing data usage.", "refresh_cache": "Refresh cache", "clear_cache": "Clear cache", "files_cached": "{size} ({count} files)", "loading_cache": "Loading...", "total_size": "Total Size", "files_cached_label": "Files Cached", "cache_cleared": "Cache cleared successfully!", "cache_clear_error": "Failed to clear cache.", "site_data_title": "Site data management", "site_data_description": "Clear all site data including settings and cached files, resetting VERT to its default state and reloading the page.", "clear_all_data": "Clear all site data", "clear_all_data_confirm_title": "Clear all site data?", "clear_all_data_confirm": "This will reset all settings & cache, then reload the page. This action cannot be undone.", "clear_all_data_cancel": "Cancel", "all_data_cleared": "All site data cleared! Reloading page...", "all_data_clear_error": "Failed to clear all site data." }, "language": { "title": "Language", "description": "Select your preferred language for the VERT interface." } }, "about": { "title": "About", "why": { "title": "Why VERT?", "description": "File converters have always disappointed us. They're ugly, riddled with ads, and most importantly; slow. We decided to solve this problem once and for all by making an alternative that solves all those problems, and more.

All non-video files are converted completely on-device; this means that there's no delay between sending and receiving the files from a server, and we never get to snoop on the files you convert.

Video files get uploaded to our lightning-fast RTX 4000 Ada server. Your videos stay on there for an hour if you do not convert them. If you do convert the file, the video will stay on the server for an hour, or until it is downloaded. The file will then be deleted from our server." }, "sponsors": { "title": "Sponsors", "description": "Want to support us? Contact a developer in the [discord_link]Discord[/discord_link] server, or send an email to", "email_copied": "Email copied to clipboard!" }, "resources": { "title": "Resources", "discord": "Discord", "source": "Source", "email": "Email" }, "donate": { "title": "Donate to VERT", "description": "With your support, we can keep maintaining and improving VERT.", "one_time": "One-time", "monthly": "Monthly", "custom": "Custom", "pay_now": "Pay now", "donate_amount": "Donate ${amount} USD", "thank_you": "Thank you for your donation!", "payment_failed": "Payment failed: {message}{period} You have not been charged.", "donation_error": "An error occurred while processing your donation. Please try again later.", "payment_error": "Error fetching payment details. Please try again later.", "donation_notice_official": "Your donations here go to the official VERT instance (vert.sh), and helps to support the development of the project.", "donation_notice_unofficial": "Your donations here go to the operator of this VERT instance. If you wish to support the official VERT developers, please visit [official_link]vert.sh[/official_link] instead." }, "credits": { "title": "Credits", "contact_team": "If you would like to contact the development team, please use the email found on the \"Resources\" card.", "notable_contributors": "Notable contributors", "notable_description": "We'd like to thank these people for their major contributions to VERT.", "github_contributors": "GitHub contributors", "github_description": "Big thanks to all these people for helping out! [github_link]Want to help too?[/github_link]", "no_contributors": "Seems like no one has contributed yet... [contribute_link]be the first to contribute![/contribute_link]", "libraries": "Libraries", "libraries_description": "A big thanks to FFmpeg (audio, video), ImageMagick (images) and Pandoc (documents) for maintaining such excellent libraries for so many years. VERT relies on them to provide you with your conversions.", "roles": { "lead_developer": "Lead developer; conversion backend, UI implementation", "developer": "Developer; UI implementation", "designer": "Designer; UX, branding, marketing", "docker_ci": "Maintaining Docker & CI support", "former_cofounder": "Former co-founder & designer" } }, "errors": { "github_contributors": "Error fetching GitHub contributors" } }, "workers": { "errors": { "general": "Error converting {file}: {message}", "cancel": "Error canceling conversion for {file}: {message}", "magick": "Error in Magick worker, image conversion may not work as expected.", "ffmpeg": "Error loading FFmpeg, some features may not work as expected.", "pandoc": "Error loading Pandoc worker, document conversion may not work as expected.", "no_audio": "No audio stream found.", "invalid_rate": "Invalid sample rate specified: {rate}Hz", "file_too_large": "This file exceeds the {limit}GB browser / device limit. Try Firefox or Safari to convert this large file, which typically have higher limits." } }, "privacy": { "title": "Privacy Policy", "summary": { "title": "Summary", "description": "VERT's privacy policy is very simple: we do not collect or store any data on you at all. We don't use cookies or trackers, analytics are completely private, and all conversions (except videos) happen locally on your browser. Videos are deleted after being downloaded, or an hour, unless explicitly given permission by you to be stored; it will only be used for the purpose of troubleshooting. VERT self-hosts a Coolify instance for hosting the website and vertd (for video conversion), and a Plausible instance for completely anonymous and aggregated analytics. We use Stripe to process donations, which may collect some data used for fraud prevention.

Note this may only apply to the official VERT instance at [vert_link]vert.sh[/vert_link]; third-party instances may handle your data differently." }, "conversions": { "title": "Conversions", "description": "Most conversions (images, documents, audio) happen entirely locally on your device using WebAssembly versions of the relevant tools (e.g. ImageMagick, Pandoc, FFmpeg). This means your files never leave your device and we will never have access to them.

Video conversions are performed on our servers because they require more processing power and cannot be done very quickly on the browser yet. Videos you convert with VERT are deleted after being downloaded, or after one hour, unless you explicitly give permission for us to store them longer purely for troubleshooting purposes." }, "donations": { "title": "Donations", "description": "We use Stripe on the [about_link]about[/about_link] page to collect donations. Stripe may collect certain information about the payment and device for fraud prevention as described in [stripe_link]their documentation on advanced fraud detection[/stripe_link]. External network requests to Stripe are deferred, and are only made after you click the button to pay." }, "conversion_errors": { "title": "Conversion Errors", "description": "When a video conversion fails, we may collect some anonymous data to help us diagnose the issue. This data may include:", "list_job_id": "The job ID, which is the anonymized file name", "list_format_from": "The format you converted from", "list_format_to": "The format you converted to", "list_stderr": "The FFmpeg stderr output of your job (error message)", "list_video": "The actual video file (if given explicit permission)", "footer": "This information is used solely for the purpose of diagnosing conversion issues. The actual video file will only ever be collected if you give us permission to do so, where it will only be used for troubleshooting." }, "analytics": { "title": "Analytics", "description": "We self-host a Plausible instance for completely anonymous and aggregated analytics. Plausible does not use cookies and complies with all major privacy regulations (GDPR/CCPA/PECR). You can opt out of analytics in the \"Privacy & data\" section in [settings_link]settings[/settings_link] and read more about Plausible's privacy practices [plausible_link]here[/plausible_link]." }, "local_storage": { "title": "Local Storage", "description": "We use your browser's local storage to save your settings, and your browser's session storage to temporarily store the GitHub contributors list for the \"About\" section to reduce repeated GitHub API requests. No personal data is stored or transmitted.

The WebAssembly versions of the conversion tools we use (FFmpeg, ImageMagick, Pandoc) are also stored locally on your browser when you first visit the website, so you don't need to redownload them each visit. No personal data is stored or transmitted. You may view or delete this data at any time in the \"Privacy & data\" section in [settings_link]settings[/settings_link]." }, "contact": { "title": "Contact", "description": "For questions, email us at: [email_link]hello@vert.sh[/email_link]. If you are using a third-party instance of VERT, please contact the hoster of that instance instead." }, "last_updated": "Last updated: 2025-10-29" }, "toast": { "insecure_context": "You are visiting VERT in an insecure context (e.g. accessing over HTTP instead of HTTPS). Some features may not work as expected." } } ================================================ FILE: messages/es.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Subir", "convert": "Convertir", "settings": "Ajustes", "about": "Acerca de", "toggle_theme": "Cambiar tema" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Código fuente", "discord_server": "Servidor de Discord" }, "upload": { "title": "El convertidor de archivos que te encantará.", "subtitle": "Todo el procesamiento de imágenes, audio y documentos es hecho en tu dispositivo. Los vídeos son convertidos en nuestros servidores ultra rápidos. Sin límite de tamaño de archivo, sin anuncios y de código abierto.", "uploader": { "text": "Arrastra o haz clic para {action}", "convert": "convertir" }, "cards": { "title": "VERT soporta...", "images": "Imágenes", "audio": "Audio", "documents": "Documentos", "video": "Vídeo", "video_server_processing": "Soportado por el servidor", "local_supported": "Soportado localmente", "status": { "text": "Estado: {status}", "ready": "listo", "not_ready": "no listo", "not_initialized": "no inicializado", "downloading": "descargando...", "initializing": "inicializando...", "unknown": "estado desconocido" }, "supported_formats": "Formatos soportados:" }, "tooltip": { "partial_support": "Este formato solo se puede convertir a {direction}.", "direction_input": "entrada (desde)", "direction_output": "salida (hacia)", "video_server_processing": "Por defecto, los vídeos se suben a un servidor para ser procesados. Aprende cómo instalarlo localmente aquí." } }, "convert": { "external_warning": { "title": "Advertencia del servidor externo", "text": "Si eliges convertir a un formato de video, esos archivos se cargarán en un servidor externo para convertirlos. ¿Quieres continuar?", "yes": "Sí", "no": "No" }, "panel": { "convert_all": "Convertir todo", "download_all": "Comprimir todo", "remove_all": "Quitar todos los archivos", "set_all_to": "Cambiar todos a", "na": "N/A" }, "dropdown": { "audio": "Audio", "video": "Vídeo", "doc": "Documento", "image": "Imagen", "placeholder": "Buscar formato" }, "tooltips": { "unknown_file": "Formato de archivo desconocido", "audio_file": "Audio", "video_file": "Vídeo", "document_file": "Documento", "image_file": "Imagen", "convert_file": "Convertir este archivo", "download_file": "Descargar este archivo" }, "errors": { "cant_convert": "No podemos convertir este archivo.", "vertd_server": "¿Qué estás haciendo..? ¡Debes ejecutar el servidor de vertd!", "unsupported_format": "Solo aceptamos imágenes, vídeos, audios y documentos.", "vertd_not_found": "No se encontró la instancia de vertd para iniciar la conversión de vídeos. ¿Estás seguro de que la URL es correcta?", "worker_downloading": "El convertidor {type} se está inicializando actualmente, espere unos momentos.", "worker_error": "El convertidor {type} tuvo un error durante la inicialización, inténtelo nuevamente más tarde.", "worker_timeout": "El convertidor {type} está tardando más de lo esperado en inicializarse. Espere unos momentos más o actualice la página.", "audio": "audio", "doc": "documento", "image": "imagen" } }, "settings": { "title": "Ajustes", "errors": { "save_failed": "¡No se han podido guardar los ajustes!" }, "appearance": { "title": "Apariencia", "brightness_theme": "Tema", "brightness_description": "¿Prefieres una flash-bang soleada o una silenciosa y solitaria noche?", "light": "Claro", "dark": "Oscuro", "effect_settings": "Efectos", "effect_description": "¿Prefieres efectos en la interfaz o una experiencia más estática?", "enable": "Habilitar", "disable": "Deshabilitar" }, "conversion": { "title": "Conversión", "advanced_settings": "Configuraciones avanzadas", "filename_format": "Formato del nombre de archivo", "filename_description": "Esto va a determinar el nombre del archivo al ser descargado sin incluir la extensión. Puedes poner las siguientes plantillas en el formato, las cuales serán reemplazadas con la información que les corresponde: %name% para el nombre original, %extension% para la extensión original del archivo y %date% para la fecha de cuando el archivo fue convertido.", "placeholder": "VERT_%name%", "default_format": "Formato de conversión predeterminado", "default_format_description": "Esto cambiará el formato predeterminado seleccionado cuando subes un archivo de este tipo.", "default_format_image": "Imágenes", "default_format_video": "Vídeos", "default_format_audio": "Audio", "default_format_document": "Documentos", "metadata": "Metadatos del archivo", "metadata_description": "Esto cambia si los metadatos (EXIF, información de la canción, etc.) del archivo original se conservan en los archivos convertidos.", "keep": "Mantener", "remove": "Eliminar", "quality": "Calidad de la conversión", "quality_description": "Esto cambia la calidad por defecto de los archivos convertidos (en su categoría). Valores más altos pueden resultar en tiempos de conversión y tamaños de archivo más largos.", "quality_video": "Esto cambia la calidad por defecto de los vídeos convertidos. Valores más altos pueden resultar en tiempos de conversión y tamaños de archivo más largos.", "quality_audio": "Audio (kbps)", "quality_images": "Imagen (%)", "rate": "Tasa de muestreo (Hz)" }, "vertd": { "title": "Conversión de vídeo", "status": "estado:", "loading": "cargando...", "available": "disponible, id del commit {commitId}", "unavailable": "no disponible (¿has comprobado la url?)", "description": "vertd es un proyecto que actúa como un servidor intermediario (\"wrapper\") para FFmpeg. Permite convertir vídeos sin dejar de lado la conveniente interfaz web de VERT y, a la vez, aprovecha la potencia de tu GPU para hacerlo lo más rápido posible.", "hosting_info": "Alojamos una instancia pública para tu conveniencia, pero es bastante fácil alojar una propia en tu PC o servidor si sabes lo que estás haciendo. Puedes descargar los binarios del servidor [vertd_link]aquí[/vertd_link]. ¡El proceso de instalación será más fácil en el futuro, así que mantente atento!", "instance": "Instancia", "url_placeholder": "Ejemplo: http://localhost:24153", "conversion_speed": "Velocidad de conversión", "speed_description": "Esto describe el equilibrio entre velocidad y calidad. Velocidades más rápidas resultarán en una calidad más baja, pero harán el trabajo más rápido.", "speeds": { "very_slow": "Extremadamente lento", "slower": "Muy lento", "slow": "Lento", "medium": "Medio", "fast": "Rápido", "ultra_fast": "Súper rápido" }, "auto_instance": "Automático (recomendado)", "eu_instance": "Falkenstein, Alemania", "us_instance": "Washington, EE. UU.", "custom_instance": "Personalizado" }, "privacy": { "title": "Privacidad", "plausible_title": "Analíticas de Plausible", "plausible_description": "Usamos [plausible_link]Plausible[/plausible_link], una herramienta de analíticas orientada a la privacidad para recopilar estadísticas completamente anónimas. Toda la información que recopilamos es anonimizada y agregada, y en ningún momento se envía ni se almacena información que permita identificarte. Puedes ver las estadísticas [analytics_link]aquí[/analytics_link] y excluirte de ellas a continuación:", "opt_in": "Participar", "opt_out": "No participar", "cache_title": "Administración de caché", "cache_description": "Guardamos en caché los archivos del convertidor en su navegador para que no tenga que volver a descargarlos cada vez, mejorando el rendimiento y reduciendo el uso de datos.", "refresh_cache": "Actualizar caché", "clear_cache": "Borrar caché", "files_cached": "{size} ({count} archivos)", "loading_cache": "Cargando...", "total_size": "Tamaño total", "files_cached_label": "Archivos en caché", "cache_cleared": "¡Caché borrada exitosamente!" }, "language": { "title": "Lenguaje", "description": "Selecciona el lenguaje que prefieres usar para la interfaz de VERT." } }, "about": { "title": "Acerca de", "why": { "title": "¿Por qué VERT?", "description": "Los conversores de archivos siempre nos han decepcionado. Son feos, están llenos de anuncios y, lo más importante, son lentos. Decidimos solucionar este problema de una vez por todas creando una alternativa que resuelve todo eso, y más.

Todos los archivos (exceptuando vídeos) se convierten directamente en tu dispositivo; esto significa que no hay demoras por subir o bajar archivos de un servidor, y nunca tenemos acceso a los archivos que conviertes.

Los vídeos se suben a nuestro servidor ultra rápido equipado con una RTX 4000 Ada. Tus vídeos permanecen allí durante una hora si no los conviertes. Si los conviertes, el archivo se guarda durante una hora, o hasta que lo descargues. Luego, el archivo se elimina del servidor." }, "sponsors": { "title": "Patrocinadores", "description": "¿Quieres apoyarnos? Contacta a un desarrollador en el servidor de [discord_link]Discord[/discord_link] o envía un correo a", "email_copied": "¡Email copiado al portapapeles!" }, "resources": { "title": "Recursos", "discord": "Discord", "source": "Fuente", "email": "Email" }, "donate": { "title": "Donar a VERT", "description": "Con tu apoyo, podemos seguir manteniendo y mejorando VERT.", "one_time": "Una sola vez", "monthly": "Mensual", "custom": "Personalizado", "pay_now": "Pagar ahora", "donate_amount": "Donar ${amount} USD", "thank_you": "¡Gracias por tu donación!", "payment_failed": "Pago fallido: {message}{period} No se ha efectuado ningún cargo.", "donation_error": "Ha ocurrido un error al procesar tu donación. Por favor, inténtalo de nuevo más tarde.", "payment_error": "Ha ocurrido un error al obtener los detalles del pago. Por favor, inténtalo de nuevo más tarde." }, "credits": { "title": "Créditos", "contact_team": "Si te gustaría contactar al equipo de desarrollo, por favor usa el email que se encuentra en la tarjeta de \"Recursos\".", "notable_contributors": "Colaboradores destacados", "notable_description": "Queremos dar las gracias a las siguientes personas por sus importantes contribuciones a VERT.", "github_contributors": "Contribuidores de GitHub", "github_description": "¡Muchas gracias a todos los que han contribuido! [github_link]¿Quieres contribuir también?[/github_link]", "no_contributors": "Parece que nadie ha contribuido todavía... [contribute_link]¡Sé el primero en hacerlo![/contribute_link]", "libraries": "Librerías", "libraries_description": "Muchas gracias a FFmpeg (audio, vídeo), ImageMagick (imágenes) y Pandoc (documentos) por mantener librerías excelentes por tantos años. VERT depende de ellas para proporcionar tus conversiones.", "roles": { "lead_developer": "Líder de desarrollo; implementación del backend de conversión e interfaz", "developer": "Desarrollador; implementación de la interfaz", "designer": "Diseñador; UX, branding y marketing", "docker_ci": "Mantenimiento del soporte para Docker y CI", "former_cofounder": "Excofundador; diseñador" } }, "errors": { "github_contributors": "Ocurrió un error mientras se obtenían los contribuidores de GitHub." } }, "workers": { "errors": { "general": "Ocurrió un error mientras se convertía {file}: {message}", "cancel": "Error al cancelar la conversión para {file}: {message}", "magick": "Ocurrió un error en el módulo de Magick, la conversión de imágenes puede que no funcione correctamente.", "ffmpeg": "No se pudo cargar FFmpeg, algunas funciones podrían no funcionar.", "no_audio": "No se encontró una pista de audio.", "invalid_rate": "La tasa de muestreo especificada no es válida: {rate}Hz" } } } ================================================ FILE: messages/fr.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Transférer", "convert": "Convertir", "settings": "Paramètres", "about": "A propos", "toggle_theme": "Changer de thème" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Code source", "discord_server": "Serveur Discord" }, "upload": { "title": "Le convertisseur de fichiers que vous allez adorer.", "subtitle": "Tout le traitement des images, des fichiers audio et des documents s'effectue sur votre appareil. Les vidéos sont converties sur nos serveurs ultra-rapides. Aucune limite de taille de fichier, aucune publicité et entièrement open source.", "uploader": { "text": "Déposer ou cliquer pour {action}", "convert": "convertir" }, "cards": { "title": "VERT supports...", "images": "Images", "audio": "Audio", "documents": "Documents", "video": "Video", "video_server_processing": "Serveur pris en charge", "local_supported": "Prise en charge locale", "status": { "text": "Status: {status}", "ready": "Prêt", "not_ready": "Pas encore prêt", "not_initialized": "non initialisé", "downloading": "en cours de téléchargement...", "initializing": "initialisation...", "unknown": "status inconnu" }, "supported_formats": "Formats supportés:" }, "tooltip": { "partial_support": "Ce format ne peut être converti qu'en {direction}.", "direction_input": "Entrée (de)", "direction_output": "Sortie (vers)", "video_server_processing": "Les vidéos sont téléchargées sur un serveur pour un traitement par défaut, découvrez comment les configurer localement ici." } }, "convert": { "panel": { "convert_all": "Convertir tout", "download_all": "Télécharger l'ensemble au format .zip", "remove_all": "Supprimer tous les fichiers", "set_all_to": "Tout configurer sur", "na": "N/A" }, "dropdown": { "audio": "Audio", "video": "Video", "doc": "Document", "image": "Image", "placeholder": "Format de recherche" }, "tooltips": { "unknown_file": "Type de fichier inconnu", "audio_file": "Fichier audio", "video_file": "Fichier vidéo", "document_file": "Fichier document", "image_file": "Fichier image", "convert_file": "Convertir ce fichier", "download_file": "Télécharger ce fichier" }, "errors": { "cant_convert": "Nous ne pouvons pas convertir ce fichier", "vertd_server": "Que fais-tu ? Tu es censé exécuter sur le serveur vertd !", "unsupported_format": "Seuls les fichiers image, vidéo, audio et document sont pris en charge", "vertd_not_found": "Impossible de trouver l'instance vertd pour démarrer la conversion vidéo. Êtes-vous sûr que l'URL de l'instance est correctement définie ?", "worker_downloading": "Le convertisseur de {type} est en cours d'initialisation, veuillez patienter quelques instants.", "worker_error": "Le convertisseur de {type} a rencontré une erreur lors de l'initialisation, veuillez réessayer plus tard.", "worker_timeout": "Le convertisseur de {type} prend plus de temps que prévu pour s'initialiser, veuillez patienter quelques instants de plus ou actualiser la page.", "audio": "audio", "doc": "document", "image": "image" } }, "settings": { "title": "Paramètres", "errors": { "save_failed": "Echec lors de l'enregistrement des préférences !" }, "appearance": { "title": "Appearance", "brightness_theme": "Luminosité du thème", "brightness_description": "Envie d'une soirée ensoleillée ou d'une nuit tranquille et solitaire ?", "light": "Lumineux", "dark": "Sombre", "effect_settings": "Paramètres des effets", "effect_description": "Vous aimez les effets sophistiqués ou préférez une expérience plus statique ?", "enable": "Activer", "disable": "Désactiver" }, "conversion": { "title": "Conversion", "advanced_settings": "Paramètres avancés", "filename_format": "Format du nom de fichier", "filename_description": "Cela déterminera le nom du fichier lors du téléchargement, sans inclure l'extension du fichier. Vous pouvez mettre les modèles suivants dans le format, qui seront remplacés par les informations pertinentes: %name% pour le nom du fichier d'origine, %extension% pour l'extension du fichier d'origine et %date% pour une chaîne de date indiquant quand le fichier a été converti.", "placeholder": "VERT_%name%", "default_format": "Format de conversion par défaut", "default_format_enable": "Activer", "default_format_disable": "Désactiver", "default_format_description": "Cela modifiera le format par défaut sélectionné lorsque vous téléchargez un fichier de ce type de format.", "default_format_image": "Images", "default_format_video": "Videos", "default_format_audio": "Audio", "default_format_document": "Documents", "metadata": "Métadonnées du fichier", "metadata_description": "Cela modifie si les métadonnées (EXIF, informations sur la chanson, etc.) du fichier d'origine sont conservées dans les fichiers convertis.", "keep": "Conserver", "remove": "Retirer", "quality": "Qualité de conversion", "quality_description": "Cela modifie la qualité de sortie par défaut des fichiers convertis (de son format). Des valeurs plus élevées peuvent entraîner des temps de conversion et une taille de fichier plus longs.", "quality_video": "Cela modifie la qualité de sortie par défaut des fichiers vidéo convertis. Des valeurs plus élevées peuvent allonger le temps de conversion et la taille du fichier.", "quality_audio": "Audio (kbps)", "quality_images": "Image (%)", "rate": "Taux d'échantillonnage (Hz)" }, "vertd": { "title": "Conversion vidéo", "status": "status:", "loading": "Chargement...", "available": "disponible, identifiant de validation {commitId}", "unavailable": "indisponible (l'url est-elle correcte ?)", "description": "Le projet vertd est un serveur de wrapper utilisant FFmpeg. Il vous permet de convertir des vidéos grâce à l'interface web pratique de VERT'tout en exploitant la puissance de votre GPU pour une exécution rapide.", "hosting_info": "Nous hébergeons une instance publique pour vous faciliter la tâche, mais il est assez facile d'héberger la vôtre sur votre PC ou votre serveur si vous savez ce que vous faites. Vous pouvez télécharger les binaires pour serveur [vertd_link]ici[/vertd_link] - le processus de mise en place deviendra plus facile à l'avenir, alors restez à l'écoute !", "instance_url": "URL de l'instance", "url_placeholder": "Exemple: http://localhost:24153", "conversion_speed": "Vitesse de conversion", "speed_description": "Ceci décrit le compromis entre vitesse et qualité. Des vitesses plus élevées entraîneront une qualité moindre, mais permettront d'effectuer le travail plus rapidement.", "speeds": { "very_slow": "Très lent", "slower": "Plus lent", "slow": "Lent", "medium": "Moyen", "fast": "Rapide", "ultra_fast": "Ultra Rapide" } }, "privacy": { "title": "Confidentialité", "plausible_title": "Analyses plausibles", "plausible_description": "Nous utilisons [plausible_link]Plausible[/plausible_link], un outil d'analyse axé sur la confidentialité, pour recueillir des statistiques totalement anonymes. Toutes les données sont anonymisées et agrégées, et aucune information identifiable n'est transmise ni stockée. Vous pouvez consulter les analyses [analytics_link]ici[/analytics_link] et choisir de vous désinscrire ci-dessous.", "opt_in": "Inscription", "opt_out": "Désinscription" }, "language": { "title": "Langue", "description": "Sélectionnez votre langue préférée pour l'interface de VERT" } }, "about": { "title": "A propos", "why": { "title": "Pourquoi VERT?", "description": "Les convertisseurs de fichiers nous ont toujours déçus. Ils sont laids, infestés de publicités et, surtout, lents. Nous avons décidé de résoudre ce problème une fois pour toutes en créant une alternative qui résout tous ces problèmes, et bien plus encore.

Tous les fichiers non vidéo sont entièrement convertis sur l'appareil; cela signifie qu'il n'y a aucun délai entre l'envoi et la réception des fichiers depuis un serveur, et nous ne pouvons jamais espionner les fichiers que vous convertissez.

Les fichiers vidéo sont téléchargés sur notre serveur RTX 4000 Ada ultra-rapide. Vos vidéos y restent pendant une heure si vous ne les convertissez pas. Si vous convertissez le fichier, la vidéo restera sur le serveur pendant une heure, ou jusqu'à son téléchargement. Le fichier sera ensuite supprimé de notre serveur." }, "sponsors": { "title": "Sponsors", "description": "Envie de nous soutenir? Contactez un développeur sur le serveur [discord_link]Discord[/discord_link], ou envoyez un courriel à", "email_copied": "Courriel copié dans le presse-papiers !" }, "resources": { "title": "Resources", "discord": "Discord", "source": "Source", "email": "Courriel" }, "donate": { "title": "Faire un don à VERT", "description": "Avec votre soutien, nous pouvons continuer à maintenir et à améliorer VERT.", "one_time": "Une fois", "monthly": "Mensuel", "custom": "Personnaliser", "pay_now": "Payer maintenant", "donate_amount": "Faire un don de ${amount} USD", "thank_you": "Merci pour votre don!", "payment_failed": "Paiement échoué: {message}{period} Vous n'avez pas été facturé.", "donation_error": "Une erreur s'est produite lors du traitement de votre don. Veuillez réessayer ultérieurement.", "payment_error": "Erreur lors de la récupération des informations de paiement. Veuillez réessayer ultérieurement." }, "credits": { "title": "Credits", "contact_team": "Si vous souhaitez contacter l'équipe de développement, veuillez utiliser le courriel figurant sur la carte \"Resources\".", "notable_contributors": "Contributeurs notables", "notable_description": "Nous tenons à remercier ces personnes pour leurs contributions majeures à VERT.", "github_contributors": "Les contributeurs de GitHub", "github_description": "Un grand merci à toutes ces personnes pour leur aide ! [github_link]Vous voulez aussi aider ?[/github_link]", "no_contributors": "Il semble que personne n'ait encore contribué... [contribute_link]soyez le premier à contribuer ![/contribute_link]", "libraries": "Bibliothèques", "libraries_description": "un grand merci à FFmpeg (audio, video), ImageMagick (images) et Pandoc (documents) pour avoir maintenu d'aussi excellentes bibliothèques pendant tant d'années, VERT compte sur eux pour vous fournir vos conversions.", "roles": { "lead_developer": "Lead developer; conversion backend, UI implementation", "developer": "Developer; UI implementation", "designer": "Designer; UX, branding, marketing", "docker_ci": "Maintaining Docker & CI support", "former_cofounder": "Former co-founder & designer" } }, "errors": { "github_contributors": "Erreur lors de la récupération des contributeurs GitHub" } }, "workers": { "errors": { "general": "Erreur de conversion{file}: {message}", "cancel": "Erreur lors de l'annulation de la conversion pour {file}: {message}", "magick": "Erreur depuis Magick Worker, la conversion d'image peut ne pas fonctionner comme prévu.", "ffmpeg": "Erreur lors du chargement de ffmpeg, certaines fonctionnalités peuvent ne pas fonctionner.", "no_audio": "Aucun flux audio détécté.", "invalid_rate": "Taux d'échantillonnage spécifié non valide: {rate}Hz" } } } ================================================ FILE: messages/hr.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Prenesi", "convert": "Pretvori", "settings": "Postavke", "about": "O Stranici", "toggle_theme": "Promjeni izgled" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Source kod", "discord_server": "Discord server" }, "upload": { "title": "Pretvarač datoteka koji ćeš obožavati.", "subtitle": "Cijelokupna obrada slika, zvuka i dokumenata se odvija na vašem uređaju. Videozapisi se pretvaraju na našim izrazito brzim serverima. Nema nikakvih ograničenja veličine niti reklama i potpuno je open source.", "uploader": { "text": "Ubaci ili klikni da {action}", "convert": "pretvori" }, "cards": { "title": "VERT podržava...", "images": "Slike", "audio": "Audio", "documents": "Dokumente", "video": "Video", "video_server_processing": "Server podržan", "local_supported": "Lokalno podržano", "status": { "text": "Status: {status}", "ready": "spremno", "not_ready": "nespremno", "not_initialized": "nije inicijalizirano", "downloading": "preuzimanje...", "initializing": "inicijaliziranje...", "unknown": "nepoznati status" }, "supported_formats": "Podržani formati:" }, "tooltip": { "partial_support": "Ovaj format se može pretvoriti u {direction}.", "direction_input": "ulaz (iz)", "direction_output": "izlaz (u)", "video_server_processing": "Videozapisi se uobičajeno prenose na servere za obradu, nauči ovdje kako namjestiti da se događa lokalno." } }, "convert": { "panel": { "convert_all": "Pretvori sve", "download_all": "Preuzmi sve kao .zip", "remove_all": "Makni sve datoteke", "set_all_to": "Stavi sve na", "na": "N/A" }, "dropdown": { "audio": "Audio", "video": "Video", "doc": "Dokument", "image": "Slika", "placeholder": "Potraži format" }, "tooltips": { "unknown_file": "Nepoznat tip datoteke", "audio_file": "Audio datoteka", "video_file": "Video datoteka", "document_file": "Dokument", "image_file": "Datoteka slike", "convert_file": "Pretvori ovu datoteku", "download_file": "Preuzmi ovu datoteku" }, "errors": { "cant_convert": "Ne možemo pretvoriti ovu datoteku.", "vertd_server": "Sunce ti žarko, što ti radiš!? Moraš pokrenuti vertd server!", "unsupported_format": "Podržane su samo slike, videozapisi, audio i dokumenti", "vertd_not_found": "Nismo mogli pronači vertd da započnemo pretvaranje. Jeste li sigurni da je URL točno postavljen?", "worker_downloading": "{type} pretvarač se trenutno koristi, molimo pričekajte malo.", "worker_error": "{type} pretvaraču se javila pogreška pri inicijalizaciji, molimo pokušajte ponovno kasnije.", "worker_timeout": "{type} pretvaraču treba duže nego očekivano da se inicijalizira, molimo još malo pričekajte ili osvježite stranicu.", "audio": "audio", "doc": "dokument", "image": "slika" } }, "settings": { "title": "Postavke", "errors": { "save_failed": "Spremanje postavki nije uspjelo!" }, "appearance": { "title": "Izgled", "brightness_theme": "Svjetlina", "brightness_description": "Želite li da Vas Sunce oslijepi ili tihu umirujuću noć?", "light": "Svijetlo", "dark": "Tamno", "effect_settings": "Efekti", "effect_description": "Želite li zapanjujuće efekte ili miran doživljaj?", "enable": "Uključeno", "disable": "Isključeno" }, "conversion": { "title": "Pretvaranje", "filename_format": "Način imenovanja datoteke", "filename_description": "Ovo će odrediti ime datoteke pri preuzimanju, ali ne i nastavak. Možete staviti navedene prijedloge u način imenovanja, koji će biti zamijenjeni sa relevatnim informacijama: %name% za originalni naziv datoteke, %extension% za originalni nastavak, i %date% za datum kada je datoteka bila pretvorena.", "placeholder": "VERT_%name%", "default_format": "Zadan format za pretvaranje", "default_format_description": "Ovo će promijeniti zadani format koji je izabran kada prenesete datoteku te vrste.", "default_format_image": "Slike", "default_format_video": "Videozapisi", "default_format_audio": "Audio", "default_format_document": "Dokumenti", "metadata": "Metapodatci datoteke", "metadata_description": "Ovo mijenja spremaju li se ikakvi metapodatci (EXIF, informacije o pjesmi, itd.) sa originalne datoteke na pretvorenu datoteku", "keep": "Ostavi", "remove": "Obriši", "quality": "Kvaliteta pretvaranja", "quality_description": "Ovo mijenja zadanu izlaznu kvalitetu pretvorene datoteke (u svojoj kategoriji). Veći iznosi mogu uzrokovati duže vrijeme za pretvaranje i veličinu.", "quality_video": "Ovo mijenja zadanu izlaznu kvalitetu pretvoranog videozapisa. Veći iznosi mogu uzrokovati duže vrijeme za pretvaranje i veličinu.", "quality_audio": "Audio (kbps)", "quality_images": "Slika (%)", "rate": "Sample rate (Hz)" }, "vertd": { "title": "Pretvaranje videozapisa", "status": "status:", "loading": "učitavanje...", "available": "dostupno, commit id {commitId}", "unavailable": "nedostupno (Je li URL točan?)", "description": "vertd projekt je serverski omot za FFmpeg. Ovo omogućuje da pretvarate videozapise sa lakoćom VERTovog web sučelja, dok još uvijek možete iskoristiti snagu vašeg GPU da odradi što brže moguće.", "hosting_info": "Mi držimo javnu instancu za Vašu lakoću, ali je veoma lako hostati na Vašem računalu ili serveru ako znate što radite. Možete preuzeti serverske programe [vertd_link]ovdje[/vertd_link] - Proces namještanja će biti lakši u budućnosti, pa njuškajte malo za nove vijesti!", "instance_url": "URL instance", "url_placeholder": "Na primjer: http://localhost:24153", "conversion_speed": "Brzina pretvaranja", "speed_description": "Ovo opisuje kompromis između brzine i kvalitete. Većom brzinom će izaći manja kvaliteta, ali će se posao brže odraditi.", "speeds": { "very_slow": "Jako Sporo", "slower": "Sporije", "slow": "Sporo", "medium": "Umjereno", "fast": "Brzo", "ultra_fast": "Veoma Brzo" } }, "privacy": { "title": "Privatnost", "plausible_title": "Plausible analitike", "plausible_description": "Mi koristimo [plausible_link]Plausible[/plausible_link], alat za analitiku koji je fokusiran na privatnost, da prikupimo potpuno anonimne statistike. Svi podatci su anonimizirani i prikupljeni bez ikakvih identificirajućih informacija spremljeno i poslano. Možete vidjeti analitike [analytics_link]ovdje[/analytics_link] i izabrati da ne sudjelujete ispod.", "opt_in": "Sudjelujem", "opt_out": "Ne sudjelujem" }, "language": { "title": "Jezik", "description": "Izaberi svoj preferirani jezik za VERTovo sučelje." } }, "about": { "title": "O stranici", "why": { "title": "Zašto baš VERT?", "description": "Pretvarači datoteka su nas uvijek razočarali. Izuzetno su ružni, prepuni reklama, i najvažnije; spori! Odlučili smo riješiti problem jednom i zauvijek praveći alternativu koja riješava sve ove probleme, i više.

Sve datoteke koji nisu videozapisi su pretvoreni direktno na Vašem uređaju; To znači da nema nikakve stanke između slanja i primanja datoteka sa servera, i nikada ne dobijemo šansu gurati nos u vaše datoteke koje pretvarate.

Videozapisi se prenose na naše izuzetno brze RTX 4000 Ada servere. Vaši videozapisi tamo ostano sat vremena ako ih ne pretvorite. Ako ih i pretvorite, videozapis će ostati na serveru na sat vremena, ili dok se ne preuzme. Datoteka će zatim biti obrisana sa našeg servera." }, "sponsors": { "title": "Sponzori", "description": "Želite li nas podržati? Kontaktirajte developera na [discord_link]Discord[/discord_link] serveru, ili pošaljite mail na", "email_copied": "Email kopiran u međuspremnik!" }, "resources": { "title": "Resursi", "discord": "Discord", "source": "Source kod", "email": "Email" }, "donate": { "title": "Donirajte nam", "description": "Sa vašom podrškom mi možemo nastaviti održavati i poboljšavati VERT.", "one_time": "Jednokratno", "monthly": "Mjesečno", "custom": "Prilagođeno", "pay_now": "Plati sada", "donate_amount": "Doniraj ${amount} USD", "thank_you": "Hvala Vam na Vašoj donaciji!!", "payment_failed": "Plaćanje neuspjelo: {message}{period} Niste naplaćeni.", "donation_error": "Dogodila se pogreška pri obradi donacije. Molimo pokušajte kasnije.", "payment_error": "Dogodila se pogreška pri prihvaćanju detalja o naplati. Molimo pokušajte kasnije." }, "credits": { "title": "Zasluge", "contact_team": "Ako želite kontaktirati developere, molimo koristite email koji se nalazi u odjeljku \"resursi\".", "notable_contributors": "Značajni suradnici", "notable_description": "Želimo zahvaliti ovim ljudima za njihove ogromne doprinose VERTu.", "github_contributors": "GitHub suradnici", "github_description": "Velike zahvale svim ovim ljudima koji su nam pomogli! [github_link]Želiš nam i ti pomoći?[/github_link]", "no_contributors": "Čini se kako nitko nije još doprinio... [contribute_link]budite prvi koji će doprinjeti![/contribute_link]", "libraries": "Biblioteke", "libraries_description": "Velike zahvale prema FFmpeg (audio, video), ImageMagick (slike) i Pandoc (dokumenti) što su održavali tako odlične biblioteke svih ovih godina. VERT se oslanja na njih da bi Vam pružili pretvorbu.", "roles": { "lead_developer": "Glavni developer; Pretvarački backend, UI implementacija", "developer": "Developer; UI implementacija", "designer": "Dizajner; UX, branding, marketing", "docker_ci": "Održavanje Dockera i CI support", "former_cofounder": "Prijašnji suosnivač i dizajner" } }, "errors": { "github_contributors": "Pogreška pri prikupljanju GitHub suradnika" } }, "workers": { "errors": { "general": "Pogreška pri pretvaranju {file}: {message}", "magick": "Pogreška sa Magick radnikom, pretvorba slike možda neće raditi kao očekivano.", "ffmpeg": "Greška pri učitavanju ffmpeg, neke značajke možda neće raditi.", "no_audio": "Nije pronađen audio.", "invalid_rate": "Upisan nevažeći sample rate: {rate}Hz!" } } } ================================================ FILE: messages/id.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Unggah", "convert": "Konversi", "settings": "Pengaturan", "about": "Tentang", "toggle_theme": "Ganti Tema" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Kode sumber", "discord_server": "Peladen Discord" }, "upload": { "title": "Konverter berkas andalanmu.", "subtitle": "Semua gambar, audio, dan pemrosesan dokumen dilakukan pada perangkatmu. Video dikonversi pada peladen kilat kami. Tidak ada batas ukuran berkas, tidak ada iklan, dan benar-benar sumber terbuka.", "uploader": { "text": "Jatuhkan dan klik untuk {action}", "convert": "Konversi", "jpegify": "jpegify" }, "cards": { "title": "Dapat Ditangani VERT ...", "images": "Gambar", "audio": "Audio", "documents": "Dokumen", "video": "Video", "video_server_processing": "Proses di Server", "local_supported": "Proses di Lokal", "status": { "text": "Status: {status}", "ready": "siap", "not_ready": "belum siap", "not_initialized": "tidak terinisialisasi", "downloading": "mengunduh...", "initializing": "menginisialisasi...", "unknown": "status tidak diketahui" }, "supported_formats": "Format yang didukung:" }, "tooltip": { "partial_support": "Format ini hanya dapat dikonversi ke {direction}.", "direction_input": "sumber asal (dari)", "direction_output": "target (ke)", "video_server_processing": "Video upload ke server untuk diproses secara baku, belajar bagaimana mengaturnya di sini." } }, "convert": { "external_warning": { "title": "Peringatan server eksternal", "text": "Jika kamu memilih untuk mengonversi ke format video, berkas tersebut akan diunggah ke server eksternal untuk dikonversi. Apakah kamu ingin melanjutkan?", "yes": "Ya", "no": "Tidak" }, "panel": { "convert_all": "Konversi semua", "download_all": "Unduh semua sebagai .zip", "remove_all": "Hapus semua berkas", "set_all_to": "Atur semua ke", "na": "N/A" }, "dropdown": { "audio": "Audio", "video": "Video", "doc": "Dokumen", "image": "Gambar", "placeholder": "Cari format" }, "tooltips": { "unknown_file": "Jenis berkas tidak diketahui", "audio_file": "Berkas audio", "video_file": "Berkas video", "document_file": "Berkas dokumen", "image_file": "Berkas gambar", "convert_file": "Konversi berkas ini", "download_file": "Unduh berkas ini" }, "errors": { "cant_convert": "Kami tidak dapat mengonversi berkas ini.", "vertd_server": "apa yang kamu lakukan..? kamu seharusnya menjalankan peladen vertd!", "vertd_generic_body": "Terjadi galat saat mencoba mengonversi video kamu. Apakah kamu ingin mengirimkan video ini ke pengembang untuk membantu memperbaiki kutu ini? Hanya berkas video kamu yang akan dikirim. Tidak ada data identifikasi yang diunggah.", "vertd_generic_title": "Konversi video galat", "vertd_generic_yes": "Kirim video", "vertd_generic_no": "Jangan kirim", "vertd_failed_to_keep": "Gagal menyimpan video di peladen: {error}", "unsupported_format": "Hanya berkas gambar, video, audio, dan dokumen yang didukung", "vertd_not_found": "Tidak dapat menemukan layanan vertd untuk memulai konversi video. Apakah URL layanan sudah diatur dengan benar?", "worker_downloading": "Konverter {type} sedang diinisialisasi, harap tunggu beberapa saat.", "worker_error": "Konverter {type} mengalami kesalahan saat inisialisasi, coba lagi nanti.", "worker_timeout": "Konverter {type} memerlukan waktu lebih lama dari perkiraan untuk inisialisasi, harap tunggu beberapa saat lagi atau segarkan halaman.", "audio": "audio", "doc": "dokumen", "image": "gambar" } }, "settings": { "title": "Pengaturan", "errors": { "save_failed": "Gagal menyimpan pengaturan!" }, "appearance": { "title": "Tampilan", "brightness_theme": "Tema kecerahan", "brightness_description": "Ingin suasana terang benderang, atau malam yang sunyi?", "light": "Terang", "dark": "Gelap", "effect_settings": "Pengaturan efek", "effect_description": "Ingin efek keren, atau tampilan yang lebih sederhana?", "enable": "Aktifkan", "disable": "Nonaktifkan" }, "conversion": { "title": "Konversi", "advanced_settings": "Pengaturan lanjutan", "filename_format": "Format nama berkas", "filename_description": "Ini akan menentukan nama berkas saat diunduh, tidak termasuk ekstensi berkas. Kamu dapat menggunakan template berikut dalam format, yang akan diganti dengan informasi terkait: %name% untuk nama berkas asli, %extension% untuk ekstensi berkas asli, dan %date% untuk tanggal saat berkas dikonversi.", "placeholder": "VERT_%name%", "default_format": "Format konversi baku", "default_format_description": "Ini akan mengubah format baku yang dipilih saat kamu mengunggah berkas dengan tipe tersebut.", "default_format_image": "Gambar", "default_format_video": "Video", "default_format_audio": "Audio", "default_format_document": "Dokumen", "metadata": "Metadata berkas", "metadata_description": "Menentukan apakah metadata (EXIF, info lagu, dll.) dari berkas asli akan dipertahankan di berkas hasil konversi.", "keep": "Pertahankan", "remove": "Hapus", "quality": "Kualitas konversi", "quality_description": "Mengubah kualitas keluaran baku berkas hasil konversi. Nilai yang lebih tinggi dapat menghasilkan waktu konversi dan ukuran berkas yang lebih besar.", "quality_video": "Mengubah kualitas keluaran baku berkas video hasil konversi. Nilai yang lebih tinggi dapat memperpanjang waktu dan ukuran berkas.", "quality_audio": "Audio (kbps)", "quality_images": "Gambar (%)", "rate": "Laju sampel (Hz)" }, "vertd": { "title": "Konversi video", "status": "status:", "loading": "memuat...", "available": "tersedia, commit id {commitId}", "unavailable": "tidak tersedia (apakah URL sudah benar?)", "description": "Proyek vertd adalah server wrapper untuk FFmpeg. Ini memungkinkan kamu mengonversi video melalui antarmuka web VERT, sambil memanfaatkan kekuatan GPU untuk mempercepat proses.", "hosting_info": "Kami menyediakan instance publik untuk kemudahanmu, tetapi kamu juga bisa dengan mudah meng-host sendiri di PC atau server jika tahu caranya. Kamu dapat mengunduh binary server [vertd_link]di sini[/vertd_link] - proses penyiapan akan semakin mudah di masa depan, jadi tetap pantau!", "instance": "Instance", "url_placeholder": "Contoh: http://localhost:24153", "conversion_speed": "Kecepatan konversi", "speed_description": "Menjelaskan kompromi antara kecepatan dan kualitas. Kecepatan lebih tinggi menghasilkan kualitas lebih rendah, tetapi proses lebih cepat.", "speeds": { "very_slow": "Sangat Lambat", "slower": "Agak Lambat", "slow": "Lambat", "medium": "Sedang", "fast": "Cepat", "ultra_fast": "Sangat Cepat" }, "auto_instance": "Otomatis (disarankan)", "eu_instance": "Falkenstein, Jerman", "us_instance": "Washington, AS", "custom_instance": "Kustom" }, "privacy": { "title": "Privasi & data", "plausible_title": "Analitik Plausible", "plausible_description": "Kami menggunakan [plausible_link]Plausible[/plausible_link], alat analitik yang berfokus pada privasi, untuk mengumpulkan statistik anonim sepenuhnya. Semua data dianonimkan dan diagregasi, tanpa informasi yang dapat diidentifikasi. Kamu dapat melihat analitiknya [analytics_link]di sini[/analytics_link] dan memilih untuk keluar di bawah.", "opt_in": "Ikut serta", "opt_out": "Tidak ikut", "cache_title": "Manajemen cache", "cache_description": "Kami menyimpan berkas konverter di browser agar kamu tidak perlu mengunduh ulang setiap kali, meningkatkan performa dan menghemat data.", "refresh_cache": "Segarkan cache", "clear_cache": "Hapus cache", "files_cached": "{size} ({count} berkas)", "loading_cache": "Memuat...", "total_size": "Total Ukuran", "files_cached_label": "File Tersimpan", "cache_cleared": "Cache berhasil dihapus!", "cache_clear_error": "Gagal menghapus cache." }, "language": { "title": "Bahasa", "description": "Pilih bahasa pilihanmu untuk antarmuka VERT." } }, "about": { "title": "Tentang", "why": { "title": "Mengapa VERT?", "description": "Konverter berkas selalu mengecewakan kami. Mereka jelek, penuh iklan, dan yang paling penting; lambat. Kami memutuskan untuk menyelesaikan masalah ini sekali untuk selamanya dengan membuat alternatif yang memperbaiki semua masalah itu, dan lebih banyak lagi.

Semua berkas non-video dikonversi sepenuhnya di perangkat; artinya tidak ada jeda antara pengiriman dan penerimaan berkas, dan kami tidak pernah melihat berkas yang kamu konversi.

File video diunggah ke server RTX 4000 Ada super cepat kami. Videomu akan tetap di sana selama satu jam jika tidak dikonversi. Jika dikonversi, video akan bertahan satu jam atau hingga diunduh. Setelah itu, berkas akan dihapus dari server kami." }, "sponsors": { "title": "Sponsor", "description": "Ingin mendukung kami? Hubungi pengembang di server [discord_link]Discord[/discord_link], atau kirim email ke", "email_copied": "Email disalin ke clipboard!" }, "resources": { "title": "Sumber daya", "discord": "Discord", "source": "Sumber", "email": "Email" }, "donate": { "title": "Donasi untuk VERT", "description": "Dengan dukunganmu, kami dapat terus memelihara dan meningkatkan VERT.", "one_time": "Sekali", "monthly": "Bulanan", "custom": "Kustom", "pay_now": "Bayar sekarang", "donate_amount": "Donasi ${amount} USD", "thank_you": "Terima kasih atas donasimu!", "payment_failed": "Pembayaran gagal: {message}{period} Kamu tidak dikenai biaya.", "donation_error": "Terjadi kesalahan saat memproses donasi. Coba lagi nanti.", "payment_error": "Kesalahan mengambil detail pembayaran. Coba lagi nanti." }, "credits": { "title": "Kredit", "contact_team": "Jika kamu ingin menghubungi tim pengembang, gunakan email yang ada di kartu \"Sumber Daya\".", "notable_contributors": "Kontributor penting", "notable_description": "Kami ingin berterima kasih kepada orang-orang ini atas kontribusi besar mereka untuk VERT.", "github_contributors": "Kontributor GitHub", "github_description": "[jpegify_link]Terima kasih[/jpegify_link] banyak kepada semua orang yang telah membantu! [github_link]Ingin membantu juga?[/github_link]", "no_contributors": "Sepertinya belum ada yang berkontribusi... [contribute_link]jadilah yang pertama berkontribusi![/contribute_link]", "libraries": "Pustaka", "libraries_description": "Terima kasih besar kepada FFmpeg (audio, video), ImageMagick (gambar), dan Pandoc (dokumen) atas pemeliharaannya selama bertahun-tahun. VERT bergantung pada mereka untuk menyediakan konversi berkas.", "roles": { "lead_developer": "Pengembang utama; backend konversi, implementasi UI", "developer": "Pengembang; implementasi UI", "designer": "Desainer; UX, branding, pemasaran", "docker_ci": "Pemeliharaan Docker & CI", "former_cofounder": "Mantan co-founder & desainer" } }, "errors": { "github_contributors": "Kesalahan mengambil kontributor GitHub" } }, "workers": { "errors": { "general": "Kesalahan mengonversi {file}: {message}", "cancel": "Kesalahan membatalkan konversi untuk {file}: {message}", "magick": "Kesalahan di worker Magick, konversi gambar mungkin tidak berfungsi dengan benar.", "ffmpeg": "Kesalahan memuat ffmpeg, beberapa fitur mungkin tidak berfungsi.", "no_audio": "Tidak ditemukan aliran audio.", "invalid_rate": "Laju sampel tidak valid: {rate}Hz" } }, "jpegify": { "title": "JPEGIFY RAHASIA!!!", "subtitle": "(psst... jangan beri tahu siapa pun!)", "button": "JPEGIFY {compression}%!!!", "download": "Unduh", "delete": "Hapus" } } ================================================ FILE: messages/it.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Carica", "convert": "Converti", "settings": "Impostazioni", "about": "Informazioni", "toggle_theme": "Cambia tema" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Codice sorgente", "discord_server": "Server Discord" }, "upload": { "title": "Il convertitore di file che amerai.", "subtitle": "Tutta l'elaborazione di immagini, audio e documenti avviene sul tuo dispositivo. I video sono convertiti sui nostri server velocissimi. Nessun limite di dimensione, nessuna pubblicità e completamente open source.", "uploader": { "text": "Trascina o clicca per {action}", "convert": "convertire" }, "cards": { "title": "VERT supporta...", "images": "Immagini", "audio": "Audio", "documents": "Documenti", "video": "Video", "video_server_processing": "Supportato da server", "local_supported": "Supportato in locale", "status": { "text": "Stato: {status}", "ready": "pronto", "not_ready": "non pronto", "not_initialized": "non inizializzato", "downloading": "download in corso...", "initializing": "inizializzazione in corso...", "unknown": "stato sconosciuto" }, "supported_formats": "Formati supportati:" }, "tooltip": { "partial_support": "Questo formato può essere convertito solo come {direction}.", "direction_input": "input (da)", "direction_output": "output (a)", "video_server_processing": "Per impostazione predefinita, i video vengono caricati su un server per l'elaborazione. Scopri come configurarlo in locale qui." } }, "convert": { "external_warning": { "title": "Avviso server esterno", "text": "Se scegli di convertire in un formato video, quei file verranno caricati su un server esterno per essere convertiti. Vuoi continuare?", "yes": "Sì", "no": "No" }, "panel": { "convert_all": "Converti tutti", "download_all": "Scarica tutti come .zip", "remove_all": "Rimuovi tutti i file", "set_all_to": "Imposta tutti a", "na": "N/D" }, "dropdown": { "audio": "Audio", "video": "Video", "doc": "Documento", "image": "Immagine", "placeholder": "Cerca formato" }, "tooltips": { "unknown_file": "Tipo di file sconosciuto", "audio_file": "File audio", "video_file": "File video", "document_file": "File documento", "image_file": "File immagine", "convert_file": "Converti questo file", "download_file": "Scarica questo file" }, "errors": { "cant_convert": "Non possiamo convertire questo file.", "vertd_server": "cosa stai facendo...? dovresti eseguire il server vertd!", "vertd_generic_body": "Si è verificato un errore durante il tentativo di conversione del tuo video. Vuoi inviare questo video agli sviluppatori per aiutare a risolvere questo bug? Verrà inviato solo il tuo file video. Nessun identificatore sarà caricato.", "vertd_generic_title": "Errore di conversione video", "vertd_generic_yes": "Invia video", "vertd_generic_no": "Non inviare", "vertd_failed_to_keep": "Impossibile mantenere il video sul server: {error}", "unsupported_format": "Sono supportati solo file immagine, video, audio e documento", "vertd_not_found": "Impossibile trovare l'istanza vertd per avviare la conversione video. Sei sicuro che l'URL dell'istanza sia impostato correttamente?", "worker_downloading": "Il convertitore {type} è attualmente in fase di inizializzazione, attendi qualche istante.", "worker_error": "Il convertitore {type} ha avuto un errore durante l'inizializzazione, riprova più tardi.", "worker_timeout": "Il convertitore {type} sta impiegando più del previsto per inizializzare, attendi ancora qualche istante o aggiorna la pagina.", "audio": "audio", "doc": "documento", "image": "immagine" } }, "settings": { "title": "Impostazioni", "errors": { "save_failed": "Impossibile salvare le impostazioni!" }, "appearance": { "title": "Aspetto", "brightness_theme": "Tema luminosità", "brightness_description": "Vuoi un lampo di sole, o una tranquilla notte solitaria?", "light": "Chiaro", "dark": "Scuro", "effect_settings": "Impostazioni effetti", "effect_description": "Desideri effetti *fancy*, o un'esperienza più statica?", "enable": "Abilita", "disable": "Disabilita" }, "conversion": { "title": "Conversione", "advanced_settings": "Impostazioni avanzate", "filename_format": "Formato nome file", "filename_description": "Questo determinerà il nome del file al momento del download, esclusa l'estensione del file. È possibile inserire i seguenti *template* nel formato, che verranno sostituiti con le informazioni pertinenti: %name% per il nome del file originale, %extension% per l'estensione del file originale e %date% per una *stringa* di data di quando il file è stato convertito.", "placeholder": "VERT_%name%", "default_format": "Formato di conversione predefinito", "default_format_description": "Questo cambierà il formato predefinito selezionato quando carichi un file di questo tipo.", "default_format_image": "Immagini", "default_format_video": "Video", "default_format_audio": "Audio", "default_format_document": "Documenti", "metadata": "Metadati del file", "metadata_description": "Questo cambia se eventuali metadati (EXIF, informazioni sul brano, ecc.) del file originale vengono conservati nei file convertiti.", "keep": "Mantieni", "remove": "Rimuovi", "quality": "Qualità di conversione", "quality_description": "Questo cambia la qualità di output predefinita dei file convertiti (nella sua categoria). Valori più alti possono comportare tempi di conversione più lunghi e dimensioni maggiori.", "quality_video": "Questo cambia la qualità di output predefinita dei file video convertiti. Valori più alti possono comportare tempi di conversione più lunghi e dimensioni maggiori.", "quality_audio": "Audio (kbps)", "quality_images": "Immagine (%)", "rate": "Frequenza di campionamento (Hz)" }, "vertd": { "title": "Conversione video", "status": "stato:", "loading": "caricamento...", "available": "disponibile, ID commit {commitId}", "unavailable": "non disponibile (l'URL è corretto?)", "description": "Il progetto vertd è un *server wrapper* per FFmpeg. Questo ti permette di convertire video attraverso la comodità dell'interfaccia web di VERT, pur essendo in grado di sfruttare la potenza della tua GPU per farlo il più rapidamente possibile.", "hosting_info": "Ospitiamo un'istanza pubblica per la tua comodità, ma è abbastanza facile ospitarne una tua sul tuo PC o server se sai cosa stai facendo. Puoi scaricare i binari del server [vertd_link]qui[/vertd_link] - il processo di configurazione diventerà più semplice in futuro, quindi resta sintonizzato!", "instance": "Istanza", "url_placeholder": "Esempio: http://localhost:24153", "conversion_speed": "Velocità di conversione", "speed_description": "Questo descrive il compromesso tra velocità e qualità. Velocità maggiori si tradurranno in una qualità inferiore, ma completeranno il lavoro più velocemente.", "speeds": { "very_slow": "Molto Lento", "slower": "Più Lento", "slow": "Lento", "medium": "Medio", "fast": "Veloce", "ultra_fast": "Ultra Veloce" }, "auto_instance": "Automatico (consigliato)", "eu_instance": "Falkenstein, Germania", "us_instance": "Washington, USA", "custom_instance": "Personalizzato" }, "privacy": { "title": "Privacy e dati", "plausible_title": "Statistiche Plausible", "plausible_description": "Utilizziamo [plausible_link]Plausible[/plausible_link], uno strumento di analisi focalizzato sulla privacy, per raccogliere statistiche completamente anonime. Tutti i dati sono anonimizzati e aggregati e nessuna informazione identificabile viene mai inviata o archiviata. Puoi visualizzare le statistiche [analytics_link]qui[/analytics_link] e scegliere di disattivare il tracciamento qui sotto.", "opt_in": "Attiva tracciamento", "opt_out": "Disattiva tracciamento", "cache_title": "Gestione della cache", "cache_description": "Memorizziamo i file del convertitore nella cache del tuo *browser* in modo che tu non debba riscaricarli ogni volta, migliorando le prestazioni e riducendo l'utilizzo dei dati.", "refresh_cache": "Aggiorna cache", "clear_cache": "Cancella cache", "files_cached": "{size} ({count} file)", "loading_cache": "Caricamento...", "total_size": "Dimensione Totale", "files_cached_label": "File in Cache", "cache_cleared": "Cache cancellata con successo!", "cache_clear_error": "Impossibile cancellare la cache." }, "language": { "title": "Lingua", "description": "Seleziona la tua lingua preferita per l'interfaccia di VERT." } }, "about": { "title": "Informazioni", "why": { "title": "Perché VERT?", "description": "I convertitori di file ci hanno sempre deluso. Sono brutti, pieni di pubblicità e, soprattutto, lenti. Abbiamo deciso di risolvere questo problema una volta per tutte creando un'alternativa che risolve tutti questi problemi e non solo.

Tutti i file non video vengono convertiti completamente sul dispositivo; questo significa che non ci sono ritardi tra l'invio e la ricezione dei file da un server e non possiamo mai spiare i file che converti.

I file video vengono caricati sul nostro velocissimo server RTX 4000 Ada. I tuoi video rimangono lì per un'ora se non li converti. Se converti il file, il video rimarrà sul server per un'ora o fino a quando non viene scaricato. Il file verrà quindi eliminato dal nostro server." }, "sponsors": { "title": "Sponsor", "description": "Vuoi sostenerci? Contatta uno sviluppatore nel server [discord_link]Discord[/discord_link] o invia un'e-mail a", "email_copied": "E-mail copiata negli appunti!" }, "resources": { "title": "Risorse", "discord": "Discord", "source": "Sorgente", "email": "E-mail" }, "donate": { "title": "Fai una donazione a VERT", "description": "Con il tuo supporto, possiamo continuare a mantenere e migliorare VERT.", "one_time": "Una tantum", "monthly": "Mensile", "custom": "Personalizzato", "pay_now": "Paga ora", "donate_amount": "Dona ${amount} USD", "thank_you": "Grazie per la tua donazione!", "payment_failed": "Pagamento fallito: {message}{period} Non ti è stato addebitato nulla.", "donation_error": "Si è verificato un errore durante l'elaborazione della tua donazione. Riprova più tardi.", "payment_error": "Errore nel recupero dei dettagli di pagamento. Riprova più tardi." }, "credits": { "title": "Crediti", "contact_team": "Se desideri contattare il team di sviluppo, utilizza l'e-mail che trovi sulla scheda \"Risorse\".", "notable_contributors": "Contributori di rilievo", "notable_description": "Vorremmo ringraziare queste persone per i loro importanti contributi a VERT.", "github_contributors": "Contributori GitHub", "github_description": "Un grande grazie a tutte queste persone per aver dato una mano! [github_link]Vuoi aiutare anche tu?[/github_link]", "no_contributors": "Sembra che nessuno abbia ancora contribuito... [contribute_link]sii il primo a contribuire![/contribute_link]", "libraries": "Librerie", "libraries_description": "Un grande ringraziamento a FFmpeg (audio, video), ImageMagick (immagini) e Pandoc (documenti) per aver mantenuto librerie così eccellenti per così tanti anni. VERT si affida a loro per fornirti le tue conversioni.", "roles": { "lead_developer": "Sviluppatore principale; backend di conversione, implementazione UI", "developer": "Sviluppatore; implementazione UI", "designer": "Designer; UX, branding, marketing", "docker_ci": "Manutenzione del supporto Docker e CI", "former_cofounder": "Ex co-fondatore e designer" } }, "errors": { "github_contributors": "Errore nel recupero dei contributori GitHub" } }, "workers": { "errors": { "general": "Errore durante la conversione di {file}: {message}", "cancel": "Errore durante l'annullamento della conversione per {file}: {message}", "magick": "Errore nel *worker* Magick, la conversione delle immagini potrebbe non funzionare come previsto.", "ffmpeg": "Errore durante il caricamento di ffmpeg, alcune funzionalità potrebbero non funzionare.", "no_audio": "Nessuno *stream* audio trovato.", "invalid_rate": "Frequenza di campionamento specificata non valida: {rate}Hz" } } } ================================================ FILE: messages/ja.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "アップロード", "convert": "変換", "settings": "設定", "about": "について", "toggle_theme": "テーマを切り替える" }, "footer": { "copyright": "© {year} VERT.", "source_code": "ソースコード", "discord_server": "Discordサーバー" }, "upload": { "title": "きっと気に入るファイル変換ツール。", "subtitle": "すべての画像・音声・ドキュメント処理はデバイス上で行われます。動画は超高速サーバーで変換されます。ファイルサイズ制限なし、広告なし、完全オープンソース。", "uploader": { "text": "ドロップまたはクリックして{action}", "convert": "変換" }, "cards": { "title": "VERTがサポートしている形式", "images": "画像", "audio": "音声", "documents": "ドキュメント", "video": "動画", "video_server_processing": "サーバー対応", "local_supported": "ローカル対応", "status": { "text": "ステータス: {status}", "ready": "準備完了", "not_ready": "未準備", "not_initialized": "未初期化", "downloading": "ダウンロード中...", "initializing": "初期化中...", "unknown": "不明なステータス" }, "supported_formats": "対応フォーマット:" }, "tooltip": { "partial_support": "このフォーマットは{direction}としてのみ変換可能です。", "direction_input": "入力(変換元)", "direction_output": "出力(変換先)", "video_server_processing": "動画はデフォルトでサーバーにアップロードされて処理されます。ローカルで設定する方法はこちら。" } }, "convert": { "external_warning": { "title": "外部サーバーの警告", "text": "動画フォーマットへの変換を選択すると、ファイルは外部サーバーにアップロードされて変換されます。続行しますか?", "yes": "はい", "no": "いいえ" }, "panel": { "convert_all": "すべて変換", "download_all": "すべてを.zipでダウンロード", "remove_all": "すべてのファイルを削除", "set_all_to": "すべてを設定", "na": "該当なし" }, "dropdown": { "audio": "音声", "video": "動画", "doc": "ドキュメント", "image": "画像", "placeholder": "フォーマットを検索" }, "tooltips": { "unknown_file": "不明なファイルタイプ", "audio_file": "音声ファイル", "video_file": "動画ファイル", "document_file": "ドキュメントファイル", "image_file": "画像ファイル", "convert_file": "このファイルを変換", "download_file": "このファイルをダウンロード" }, "errors": { "cant_convert": "このファイルを変換できません。", "vertd_server": "何してるの..? vertdサーバーを起動する必要があります!", "unsupported_format": "画像、動画、音声、ドキュメントのみ対応しています", "vertd_not_found": "動画変換を開始するためのvertdインスタンスが見つかりません。URLが正しいか確認してください。", "worker_downloading": "{type}コンバーターを初期化中です。少々お待ちください。", "worker_error": "{type}コンバーターの初期化中にエラーが発生しました。後でもう一度お試しください。", "worker_timeout": "{type}コンバーターの初期化に予想以上の時間がかかっています。もう少しお待ちいただくか、ページを更新してください。", "audio": "音声", "doc": "ドキュメント", "image": "画像" } }, "settings": { "title": "設定", "errors": { "save_failed": "設定の保存に失敗しました!" }, "appearance": { "title": "外観", "brightness_theme": "明るさテーマ", "brightness_description": "まぶしい昼間か、静かな夜か?", "light": "ライト", "dark": "ダーク", "effect_settings": "エフェクト設定", "effect_description": "派手な効果にしますか?それとも静的な体験にしますか?", "enable": "有効", "disable": "無効" }, "conversion": { "title": "変換", "advanced_settings": "詳細設定", "filename_format": "ファイル名フォーマット", "filename_description": "これはダウンロード時のファイル名を決定します(拡張子を除く)。以下のテンプレートを使用できます:%name%(元のファイル名)、%extension%(元の拡張子)、%date%(変換日時)。", "placeholder": "VERT_%name%", "default_format": "デフォルト変換フォーマット", "default_format_description": "このファイルタイプをアップロードしたときに自動で選択される形式を変更します。", "default_format_image": "画像", "default_format_video": "動画", "default_format_audio": "音声", "default_format_document": "ドキュメント", "metadata": "ファイルメタデータ", "metadata_description": "変換後のファイルに元のメタデータ(EXIF、曲情報など)を保持するかどうかを変更します。", "keep": "保持", "remove": "削除", "quality": "変換品質", "quality_description": "出力ファイルの品質を変更します。値が高いほど処理時間とファイルサイズが増加します。", "quality_video": "動画変換の品質を変更します。高品質ほど変換時間とサイズが増加します。", "quality_audio": "音声(kbps)", "quality_images": "画像(%)", "rate": "サンプリングレート(Hz)" }, "vertd": { "title": "動画変換", "status": "ステータス:", "loading": "読み込み中...", "available": "利用可能(コミットID {commitId})", "unavailable": "利用不可(URLが正しいですか?)", "description": "vertdプロジェクトはFFmpegのサーバーラッパーです。これにより、GPUの性能を活かして高速に変換しつつ、VERTのウェブインターフェイスから簡単に動画を変換できます。", "hosting_info": "私たちは利便性のために公開インスタンスをホストしていますが、自分のPCやサーバーでも簡単にホストできます。バイナリは[vertd_link]こちら[/vertd_link]からダウンロードできます。今後さらにセットアップが簡単になる予定です!", "instance": "インスタンス", "url_placeholder": "例: http://localhost:24153", "conversion_speed": "変換速度", "speed_description": "速度と品質のバランスを設定します。高速化すると品質が低下しますが、処理は速くなります。", "speeds": { "very_slow": "非常に遅い", "slower": "かなり遅い", "slow": "遅い", "medium": "普通", "fast": "速い", "ultra_fast": "超高速" }, "auto_instance": "自動(推奨)", "eu_instance": "ドイツ・ファルケンシュタイン", "us_instance": "アメリカ・ワシントン", "custom_instance": "カスタム" }, "privacy": { "title": "プライバシーとデータ", "plausible_title": "Plausible解析", "plausible_description": "私たちはプライバシー重視の解析ツール[plausible_link]Plausible[/plausible_link]を使用しています。すべてのデータは匿名化・集計され、個人情報は一切収集・保存されません。統計情報は[analytics_link]こちら[/analytics_link]で確認でき、以下でオプトアウト可能です。", "opt_in": "参加する", "opt_out": "参加しない", "cache_title": "キャッシュ管理", "cache_description": "コンバーターファイルをブラウザにキャッシュして再ダウンロードを防ぎ、パフォーマンスを向上させます。", "refresh_cache": "キャッシュを更新", "clear_cache": "キャッシュをクリア", "files_cached": "{size}({count}ファイル)", "loading_cache": "読み込み中...", "total_size": "合計サイズ", "files_cached_label": "キャッシュ済みファイル", "cache_cleared": "キャッシュが正常にクリアされました!" }, "language": { "title": "言語", "description": "VERTインターフェイスの表示言語を選択してください。" } }, "about": { "title": "について", "why": { "title": "なぜVERT?", "description": "従来のファイルコンバーターにはいつもがっかりしてきました。見た目が悪く、広告だらけで、そして何より遅い。私たちはそれらの問題をすべて解決するためにVERTを作りました。

動画以外のファイルは完全にデバイス上で変換されるため、サーバーとのやり取りによる遅延もなく、あなたのファイルを覗き見ることもありません。

動画は超高速RTX 4000 Adaサーバーで処理され、変換しなかった場合は1時間以内に削除されます。変換された動画も1時間またはダウンロード完了後に削除されます。" }, "sponsors": { "title": "スポンサー", "description": "私たちを支援したい場合は、[discord_link]Discord[/discord_link]サーバーで開発者に連絡するか、以下のメールアドレスまでご連絡ください。", "email_copied": "メールアドレスをコピーしました!" }, "resources": { "title": "リソース", "discord": "Discord", "source": "ソース", "email": "メール" }, "donate": { "title": "VERTを支援する", "description": "あなたの支援でVERTの維持と改善を続けられます。", "one_time": "一度きり", "monthly": "毎月", "custom": "カスタム", "pay_now": "今すぐ支払う", "donate_amount": "${amount} USDを寄付", "thank_you": "ご支援ありがとうございます!", "payment_failed": "支払いに失敗しました: {message}{period} 請求は行われていません。", "donation_error": "寄付の処理中にエラーが発生しました。後でもう一度お試しください。", "payment_error": "支払い情報の取得中にエラーが発生しました。後でもう一度お試しください。" }, "credits": { "title": "クレジット", "contact_team": "開発チームに連絡したい場合は、「リソース」カードに記載されたメールをご利用ください。", "notable_contributors": "特筆すべき貢献者", "notable_description": "VERTに大きく貢献してくださった方々に感謝します。", "github_contributors": "GitHubの貢献者", "github_description": "多くの方々に感謝します![github_link]あなたも参加してみませんか?[/github_link]", "no_contributors": "まだ誰も貢献していないようです… [contribute_link]最初の貢献者になりましょう![/contribute_link]", "libraries": "ライブラリ", "libraries_description": "長年にわたり優れたライブラリを提供してくれているFFmpeg(音声・動画)、ImageMagick(画像)、Pandoc(ドキュメント)に感謝します。VERTはこれらに依存して動作しています。", "roles": { "lead_developer": "リード開発者;変換バックエンド、UI実装", "developer": "開発者;UI実装", "designer": "デザイナー;UX、ブランディング、マーケティング", "docker_ci": "DockerとCIの保守担当", "former_cofounder": "元共同創設者・デザイナー" } }, "errors": { "github_contributors": "GitHub貢献者の取得エラー" } }, "workers": { "errors": { "general": "{file}の変換エラー:{message}", "cancel": "{file}の変換キャンセルエラー:{message}", "magick": "Magickワーカーでエラーが発生しました。画像変換が正常に動作しない可能性があります。", "ffmpeg": "ffmpegの読み込みエラー。一部の機能が動作しない可能性があります。", "no_audio": "音声ストリームが見つかりません。", "invalid_rate": "無効なサンプリングレートが指定されました: {rate}Hz" } } } ================================================ FILE: messages/ko.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "업로드", "convert": "변환", "settings": "설정", "about": "정보", "toggle_theme": "테마 전환" }, "footer": { "copyright": "© {year} VERT.", "source_code": "소스 코드", "discord_server": "Discord 서버", "privacy_policy": "개인정보 처리방침" }, "upload": { "title": "이 파일 변환기,\n 마음에 드실 거예요.", "subtitle": "모든 이미지, 오디오, 문서 처리는 사용자의 기기에서 이루어집니다. 동영상은 매우 빠른 VERT 전용 서버에서 변환됩니다. 광고나 파일 크기 제한이 전혀 없는 완전한 오픈 소스입니다.", "uploader": { "text": "드래그하거나 클릭해서 {action}", "convert": "변환하기" }, "cards": { "title": "VERT가 지원하는 포맷들", "images": "이미지", "audio": "오디오", "documents": "문서", "video": "동영상", "video_server_processing": "서버 지원", "local_supported": "로컬 지원", "status": { "text": "상태: {status}", "ready": "준비됨", "not_ready": "준비되지 않음", "not_initialized": "준비 안됨", "downloading": "다운로드중...", "initializing": "준비중...", "unknown": "알 수 없음" }, "supported_formats": "지원 포맷:" }, "tooltip": { "partial_support": "이 형식은 {direction}으로만 변환할 수 있습니다.", "direction_input": "입력 (from)", "direction_output": "출력 (to)", "video_server_processing": "동영상은 기본적으로 처리를 위해 서버로 업로드됩니다. 로컬로 처리하도록 설정하는 방법은 여기에서 확인하세요." } }, "convert": { "archive_file": { "extracting": "ZIP파일 감지됨: {filename}", "extracted": "{filename}압축 파일에서 {extract_count}개의 파일을 풀었습니다. {ignore_count}개 항목은 무시되었습니다.", "extract_error": "{filename}압축 파일 풀던 중 오류 발생: {error}" }, "external_warning": { "title": "외부 서버 경고", "text": "동영상 형식으로 변환을 선택하면 해당 파일은 변환을 위해 지정한 외부 서버로 업로드됩니다. 계속하시겠습니까?", "yes": "계속", "no": "아니오" }, "panel": { "convert_all": "모두 변환", "download_all": ".zip으로 다운로드", "remove_all": "모든 파일 삭제", "set_all_to": "모두 다음으로 설정", "na": "N/A" }, "dropdown": { "audio": "오디오", "video": "비디오", "doc": "문서", "image": "이미지", "placeholder": "포맷 검색" }, "tooltips": { "unknown_file": "알 수 없는 파일 포맷", "audio_file": "오디오 파일", "video_file": "비디오 파일", "document_file": "문서 파일", "image_file": "이미지 파일", "convert_file": "파일 변환하기", "download_file": "파일 다운로드" }, "errors": { "cant_convert": "이 파일을 변환할 수 없습니다.", "vertd_server": "뭐 하는거임? vertd 서버부터 실행하셈", "vertd_generic_view": "오류 세부정보 보기", "vertd_generic_body": "비디오 변환 중 오류가 발생했습니다. 이 비디오를 개발자에게 전송해서 이 버그를 수정하는 데 도움을 주시겠습니까? 오직 비디오 파일만 전송됩니다. 익명으로 처리되며, 다른 개인 정보는 포함되지 않습니다.", "vertd_generic_title": "비디오 변환 오류", "vertd_generic_yes": "비디오 전송", "vertd_generic_no": "전송 안 함", "vertd_failed_to_keep": "영상을 서버에 저장하는데 실패했습니다: {error}", "vertd_details": "오류 세부정보 보기", "vertd_details_body": "제출을 누르면, 검토를 위해 항상 보고되는 오류 로그와 함께 동영상도 첨부됩니다. 아래 정보는 우리가 자동으로 받는 로그입니다:", "vertd_details_footer": "이 정보는 문제 해결 목적으로만 사용되며 절대 공유되지 않습니다. 자세한 내용은 [privacy_link]개인정보 처리방침[/privacy_link]을 확인하세요.", "vertd_details_job_id": "작업 ID: {jobId}", "vertd_details_from": "원본 포맷: {from}", "vertd_details_to": "변환 포맷: {to}", "vertd_details_error_message": "오류 메시지: [view_link]오류 로그 보기[/view_link]", "vertd_details_close": "닫기", "unsupported_format": "이미지, 비디오, 오디오 및 문서 파일만 지원됩니다.", "format_output_only": "이 포맷은 현재 입력으로 사용할 수 없으며 (변환된)출력으로만 사용할 수 있습니다.", "vertd_not_found": "비디오 변환을 시작할 vertd 인스턴스를 찾을 수 없습니다. 인스턴스 URL이 올바르게 설정되었는지 확인해주세요.", "worker_downloading": "현재 {type} 변환기를 준비하고 있습니다. 잠시 기다려 주십시오.", "worker_error": "현재 {type} 변환기 준비 중 오류가 발생했습니다. 나중에 다시 시도해 주십시오.", "worker_timeout": "{type} 변환기를 준비하는데 예상보다 오래 걸리고 있습니다. 잠시 더 기다리거나 페이지를 새로고침해 주세요.", "audio": "오디오", "doc": "문서", "image": "이미지" } }, "settings": { "title": "설정", "errors": { "save_failed": "현재 설정을 저장하는데 실패했습니다" }, "appearance": { "title": "테마", "brightness_theme": "테마 변경", "brightness_description": "걍 알아서", "light": "라이트 모드", "dark": "다크 모드", "effect_settings": "이펙트(효과) 설정", "effect_description": "동적인 애니메이션이나 이펙트, 아님 정적인거?", "enable": "켜기", "disable": "끄기" }, "conversion": { "title": "변환", "advanced_settings": "고급 설정", "filename_format": "파일 이름 형식", "filename_description": "다운로드할 파일의 이름을 설정합니다. 파일 확장자(포맷)는 포함되지 않습니다. 다음 템플릿을 형식에 넣을 수 있으며, 관련 정보로 대체됩니다: %name% 원본 파일 이름, %extension% 원본 파일 확장자, %date% 파일이 변환된 날짜 문자열.", "placeholder": "VERT_%name%", "default_format": "기본 변환 형식", "default_format_description": "파일 유형의 파일을 업로드할 때 선택되는 기본 형식을 변경합니다.", "default_format_image": "이미지", "default_format_video": "비디오", "default_format_audio": "오디오", "default_format_document": "문서", "metadata": "파일 메타데이터", "metadata_description": "원본 파일의 메타데이터(EXIF, 노래 정보 등)가 변환된 파일에 유지되는지 선택할 수 있습니다.", "keep": "유지", "remove": "제거", "quality": "변환 품질", "quality_description": "변환된 파일의 기본 출력 품질을 변경합니다(카테고리 내에서). 더 높은 값은 더 긴 변환 시간과 파일 크기를 초래할 수 있습니다.", "quality_video": "변환된 비디오 파일의 기본 출력 품질을 변경합니다. 높은 값은 더 긴시간과 파일 크기를 초래할 수 있습니다.", "quality_audio": "오디오 (kbps)", "quality_images": "이미지 (%)", "rate": "샘플링 주파수 (Hz)" }, "vertd": { "title": "비디오 변환 서버", "status": "상태:", "loading": "로딩중...", "available": "사용 가능, 커밋 ID {commitId}", "unavailable": "사용 불가 (URL를 다시 확인해주세요.)", "description": "vertd 프로젝트는 FFmpeg를 위한 서버 래퍼입니다. 이를 통해 VERT의 웹 인터페이스를 통해 비디오를 변환할 수 있으며, GPU를 활용하여 가능한 한 빠르게 작업을 수행할 수 있습니다.", "hosting_info": "편의를 위해 공개 인스턴스를 호스팅하지만, PC나 서버에서 직접 호스팅하는 것도 매우 쉽습니다. 서버 바이너리를 [vertd_link]여기[/vertd_link]에서 다운로드할 수 있습니다. 이 설정 프로세스는 앞으로 더 쉬워질 것이므로 기대해 주세요!", "instance": "인스턴스", "url_placeholder": "예시: http://localhost:24153", "conversion_speed": "변환 속도", "speed_description": "이는 속도와 품질 사이의 균형을 설명합니다. 속도를 높일수록 품질은 낮아지지만 작업 속도는 더 빨라집니다.", "speeds": { "very_slow": "매우 느림", "slower": "느림", "slow": "조금 느림", "medium": "보통", "fast": "빠름", "ultra_fast": "매우 빠름" }, "auto_instance": "자동 (권장됨)", "eu_instance": "Falkenstein, Germany", "us_instance": "Washington, USA", "custom_instance": "사용자 지정" }, "privacy": { "title": "개인정보 및 데이터", "plausible_title": "Plausible analytics", "plausible_description": "우리는 개인정보 보호에 초점을 둔 분석 도구인 [plausible_link]Plausible[/plausible_link]를 사용해 완전히 익명화된 통계를 수집합니다. 모든 데이터는 익명화되어 집계되며, 식별 가능한 정보는 전송되거나 보관되지 않습니다. 분석 결과는 [analytics_link]여기[/analytics_link]에서 확인할 수 있고, 아래에서 수집을 거부(opt-out)할 수 있습니다", "opt_in": "수락", "opt_out": "거부", "cache_title": "캐시 정리", "cache_description": "브라우저에 변환기 파일을 캐시하여 매번 다시 다운로드할 필요가 없도록 하여 최적화와 데이터 사용량을 줄입니다.", "refresh_cache": "캐시 새로고침", "clear_cache": "캐시 지우기", "files_cached": "{size} ({count} files)", "loading_cache": "로딩중...", "total_size": "총 크기", "files_cached_label": "캐시된 파일", "cache_cleared": "캐시를 성공적으로 지웠습니다!", "cache_clear_error": "캐시를 지우는 중 오류가 발생했습니다" }, "language": { "title": "언어", "description": "선호하시는 언어를 선택하세요." } }, "about": { "title": "정보", "why": { "title": "왜 VERT인가?", "description": "파일 변환기들은 항상 저희 기대치에 충족하지 못했습니다. 못생긴 UI에, 광고로 떡칠하고, 그리고 가장 중요한 것은 느리다는겁니다. 그래서 저희가 이 모든 문제를 한 번에 해결할 대안을 직접 만들기로 했습니다. 기존 변환기들의 단점을 해결한 것은 물론이고, 그 이상의 기능도 제공하죠

동영상을 제외한 모든 파일은 사용자의 기기에서 바로 변환됩니다. 즉, 서버로 파일을 보냈다가 다시 받는 시간이 전혀 필요 없고, 저희가 여러분의 파일을 엿볼 일도 전혀 없다는 뜻입니다.

예외적으로 동영상 파일은 초고속 RTX 4000 Ada 서버로 업로드됩니다. 변환하지 않으면 영상은 서버에 1시간 동안 유지됩니다. 변환한 경우에도 영상은 서버에 최대 1시간 또는 다운로드될 때까지 보관되며, 그 후 서버에서 삭제됩니다." }, "sponsors": { "title": "후원자", "description": "지원하고 싶으신가요? [discord_link]Discord[/discord_link] 서버의 개발자에게 문의하시거나, 다음 이메일로 보내주세요:", "email_copied": "클립보드에 이메일 주소가 복사되었습니다!" }, "resources": { "title": "Resources", "discord": "Discord", "source": "소스 코드", "email": "이메일" }, "donate": { "title": "VERT에 기부하기", "description": "여러분의 후원으로 VERT를 지속적으로 유지하고 개발할 수 있습니다.", "one_time": "일회성", "monthly": "매월", "custom": "사용자 지정", "pay_now": "지금 결제하기", "donate_amount": "${amount} USD 후원하기", "thank_you": "후원해주셔서 감사합니다!", "payment_failed": "결제 실패: {message}{period} 요금이 청구되지 않았습니다.", "donation_error": "결제 처리 중 오류가 발생했습니다. 나중에 다시 시도해 주세요.", "payment_error": "결제 세부정보를 가져오는 중 오류가 발생했습니다. 나중에 다시 시도해 주세요." }, "credits": { "title": "Credits", "contact_team": "개발팀에 연락하시려면 \"Resources\" 카드에 있는 이메일로 연락해 주세요.", "notable_contributors": "주요 기여자", "notable_description": "VERT에 크게 기여해 주신 분들께 정말 감사드립니다.", "github_contributors": "GitHub 기여자", "github_description": "도와주신 모든 분들께 진심으로 감사드립니다! [github_link]기여하기[/github_link]", "no_contributors": "아직 기여한 사람이 없는 것 같습니다... [contribute_link]첫 번째 기여자가 되어보세요![/contribute_link]", "libraries": "라이브러리들", "libraries_description": "수년 동안 훌륭한 라이브러리를 유지해 주신 FFmpeg (오디오, 비디오), ImageMagick (이미지) 및 Pandoc (문서)에 진심으로 감사드립니다. VERT는 위 라이브러리들을 사용하여 변환을 제공합니다.", "roles": { "lead_developer": "총괄 개발자; 변환 백엔드, UI 구현", "developer": "개발자; UI 구현", "designer": "디자이너; UX, 브랜딩, 마케팅", "docker_ci": "Docker 및 CI 지원 유지", "former_cofounder": "전 공동 창립자 및 디자이너" } }, "errors": { "github_contributors": "Error, Github 기여자 불러오기 실패" } }, "workers": { "errors": { "general": "{file}파일을 변환하는데 오류 발생: {message}", "cancel": "{file}파일 변환 취소 중 오류 발생: {message}", "magick": "Magick 작업에서 오류 발생, 이미지 변환이 예상대로 작동하지 않을 수 있습니다.", "ffmpeg": "FFmpeg 로드 중 오류 발생, 일부 기능이 예상대로 작동하지 않을 수 있습니다.", "pandoc": "Pandoc 작업 로드 중 오류 발생, 문서 변환이 예상대로 작동하지 않을 수 있습니다.", "no_audio": "오디오 스트림을 찾을 수 없습니다.", "invalid_rate": "지정된 샘플 레이트가 유효하지 않습니다: {rate}Hz" } }, "privacy": { "title": "개인정보 처리방침", "summary": { "title": "요약", "description": "VERT의 개인정보 처리방침은 매우 간단합니다: 우리는 귀하에 대한 데이터를 수집하거나 보관하지 않습니다. 우리는 쿠키나 유저를 추적하지 않으며,, 모든 변환(비디오 제외)은 귀하의 브라우저에서 로컬로 수행됩니다. 비디오는 다운로드 후 또는 1시간 후에 삭제되며, 귀하가 명시적으로 보관을 허용한 경우에만 문제 해결을 위해 사용됩니다. VERT는 웹사이트 호스팅을 위한 Coolify 인스턴스와 비디오 변환을 위한 vertd, 완전히 익명화되고 집계된 분석을 위한 Plausible 인스턴스를 자체 호스팅합니다.

이는 [vert_link]vert.sh[/vert_link]의 공식 VERT 인스턴스에만 적용될 수 있습니다. 타사 인스턴스는 귀하의 데이터를 다르게 처리할 수 있습니다." }, "conversions": { "title": "변환", "description": "대부분의 변환(이미지, 문서, 오디오)은 관련 도구의 WebAssembly 버전(예: ImageMagick, Pandoc, FFmpeg)을 사용하여 여러분의 기기에서 로컬로 수행됩니다. 즉, 파일이 기기를 떠나지 않으며 우리가 파일에 접근할 일은 없습니다.

동영상 변환은 더 높은 연산 성능이 필요하고 아직 브라우저에서 충분히 빠르게 처리하기 어려워 서버에서 수행됩니다. VERT로 변환한 동영상은 다운로드 후 또는 1시간이 지나면 삭제되며, 문제 해결만을 위해 더 오래 보관하도록 명시적으로 허용한 경우에만 예외적으로 보관됩니다." }, "conversion_errors": { "title": "변환 오류", "description": "비디오 변환이 실패할 경우, 문제 진단을 위해 일부 익명 데이터를 수집할 수 있습니다. 이 데이터에는 다음이 포함될 수 있습니다:", "list_job_id": "작업 ID (익명화된 파일 이름)", "list_format_from": "변환 전 포맷", "list_format_to": "변환 후 포맷", "list_stderr": "작업의 FFmpeg stderr 출력 (오류 메시지)", "list_video": "실제 비디오 파일 (명시적 권한이 부여된 경우)", "footer": "이 정보는 오직 변환 문제를 진단하기 위해서만 사용됩니다. 실제 비디오 파일은 귀하가 수락한 경우에만 수집되며, 그 경우에도 오직 문제 해결을 위해서만 사용됩니다." }, "analytics": { "title": "분석", "description": "저희는 완전히 익명화되고 집계된 분석을 위해 Plausible을 자체 호스팅합니다. Plausible는 쿠키를 사용하지 않으며 모든 주요 개인정보 보호 규정(GDPR/CCPA/PECR)을 준수합니다. \"개인정보 및 데이터\" 섹션에서 [settings_link]설정[/settings_link]을 통해 분석을 선택 해제할 수 있으며, Plausible의 개인정보 보호 관행에 대한 자세한 내용은 [plausible_link]여기[/plausible_link]에서 확인할 수 있습니다." }, "local_storage": { "title": "Local Storage", "description": "브라우저의 로컬 스토리지를 사용해 설정을 저장하고, 반복적인 GitHub API 요청을 줄이기 위해 \"정보\" 섹션의 GitHub 기여자 목록을 브라우저의 세션 스토리지에 임시로 저장합니다. 어떤 개인 데이터도 저장되거나 전송되지 않습니다.

사용되는 변환 도구(FFmpeg, ImageMagick, Pandoc)의 WebAssembly 버전도 사용자가 처음 웹사이트를 방문할 때 브라우저에 로컬로 저장되므로, 매번 다시 다운로드할 필요가 없습니다. 어떤 개인 정보나 데이터도 저장되거나 전송되지 않습니다. 이 데이터는 언제든지 [settings_link]설정[/settings_link]의 \"개인정보 및 데이터\" 섹션에서 확인하거나 삭제할 수 있습니다." }, "contact": { "title": "문의하기", "description": "질문이 있으시면 다음 이메일로 문의해 주세요: [email_link]hello@vert.sh[/email_link]. 서드파티 VERT 인스턴스를 사용 중인 경우 해당 인스턴스의 호스트에게 문의해 주세요." }, "last_updated": "Last updated: 2025-10-19" } } ================================================ FILE: messages/pt-BR.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Upload", "convert": "Converter", "settings": "Ajustes", "about": "Sobre", "toggle_theme": "Alternar tema" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Código fonte", "discord_server": "Servidor Discord", "privacy_policy": "Política de privacidade" }, "upload": { "title": "O conversor de arquivos que você vai adorar.", "subtitle": "Todo processamento de imagens, audio e documentos é feito no seu dispositivo. Vídeos são convertidos em nossos servidores ultrarrápidos. Sem limite de tamanho de arquivo, sem anúncios e totalmente de código aberto.", "uploader": { "text": "Arraste ou clique para {action}", "convert": "converter" }, "cards": { "title": "VERT suporta...", "images": "Imagens", "audio": "Áudio", "documents": "Documentos", "video": "Vídeo", "video_server_processing": "Suportado pelo servidor", "local_supported": "Suportado localmente", "status": { "text": "Status: {status}", "ready": "pronto", "not_ready": "não pronto", "not_initialized": "não inicializado", "downloading": "baixando...", "initializing": "inicializando...", "unknown": "status desconhecido" }, "supported_formats": "Formatos suportados:" }, "tooltip": { "partial_support": "Este formato só pode ser convertido como {direction}.", "direction_input": "entrada (de)", "direction_output": "saída (para)", "video_server_processing": "Uploads de vídeo para um servidor para processamento por padrão, saiba como configurá-lo localmente aqui." } }, "convert": { "archive_file": { "extract": "Extrair arquivo", "extracting": "Arquivo detectado: {filename}", "extracted": "Extraídos {extract_count} arquivos de {filename}. {ignore_count} itens foram ignorados.", "detected": "Arquivos {type} detectados em {filename}.", "audio": "áudio", "video": "vídeo", "doc": "documento", "image": "imagem", "extract_error": "Erro ao extrair {filename}: {error}" }, "large_file_warning": "Devido a limitações do navegador/dispositivo, a conversão de vídeo para áudio está desativada para este arquivo, pois ele é maior que {limit}GB. Recomendamos usar Firefox ou Safari para arquivos deste tamanho, pois eles têm menos limitações.", "external_warning": { "title": "Aviso de servidor externo", "text": "Se você escolher converter para um formato de vídeo, esses arquivos serão enviados para um servidor externo para conversão. Deseja continuar?", "yes": "Sim", "no": "Não" }, "panel": { "convert_all": "Converter tudo", "download_all": "Baixar tudo como .zip", "remove_all": "Remover todos os arquivos", "set_all_to": "Definir todos para", "na": "N/D" }, "dropdown": { "audio": "Áudio", "video": "ídeo", "doc": "Documento", "image": "Imagem", "placeholder": "Pesquisar formato", "no_formats": "Nenhum formato disponível", "no_results": "Nenhum formato corresponde à sua pesquisa" }, "tooltips": { "unknown_file": "Tipo de arquivo desconhecido", "audio_file": "Arquivo de áudio", "video_file": "Arquivo de vídeo", "document_file": "Arquivo de documento", "image_file": "Arquivo de imagem", "convert_file": "Converter este arquivo", "download_file": "Baixar este arquivo" }, "errors": { "cant_convert": "Não podemos converter este arquivo.", "vertd_server": "O quê você está fazendo..? você deveria executar o servidor vertd!", "vertd_generic_view": "Ver detalhes do erro", "vertd_generic_body": "Ocorreu um erro ao tentar converter seu vídeo. Gostaria de enviar este vídeo para os desenvolvedores para ajudar a corrigir este bug? Apenas seu arquivo de vídeo será enviado. Nenhum identificador será carregado.", "vertd_generic_title": "Erro de conversão de vídeo", "vertd_generic_yes": "Enviar vídeo", "vertd_generic_no": "Não enviar", "vertd_failed_to_keep": "Falha ao manter o vídeo no servidor: {error}", "vertd_details": "Ver detalhes do erro", "vertd_details_body": "Se você pressionar enviar, seu vídeo também será anexado junto com o registro de erros que sempre é relatado para nós para revisão. As seguintes informações são o registro que recebemos automaticamente:", "vertd_details_footer": "Estas informações serão usadas apenas para fins de solução de problemas e nunca serão compartilhadas. Veja nossa [privacy_link]política de privacidade[/privacy_link] para mais detalhes.", "vertd_details_job_id": "ID do trabalho: {jobId}", "vertd_details_from": "Formato de origem: {from}", "vertd_details_to": "Formato de destino: {to}", "vertd_details_error_message": "Mensagem de erro: [view_link]Ver registros de erro[/view_link]", "vertd_details_close": "Fechar", "vertd_ratelimit": "Seu vídeo, '{filename}', falhou na conversão algumas vezes. Para evitar sobrecarga do servidor, novas tentativas de conversão para este arquivo foram temporariamente bloqueadas. Por favor, tente novamente mais tarde.", "unsupported_format": "Apenas arquivos de imagem, vídeo, áudio e documento são suportados", "format_output_only": "Este formato atualmente só pode ser usado como saída (convertido para), não como entrada.", "vertd_not_found": "Não foi possível encontrar a instância vertd para iniciar a conversão de vídeo. Você tem certeza de que a URL da instância está configurada corretamente?", "worker_downloading": "O conversor {type} está sendo inicializado, por favor, aguarde alguns momentos.", "worker_error": "O conversor {type} teve um erro durante a inicialização, por favor, tente novamente mais tarde.", "worker_timeout": "O conversor {type} está demorando mais do que o esperado para inicializar, por favor, aguarde mais alguns momentos ou atualize a página.", "audio": "áudio", "doc": "documento", "image": "imagem" } }, "settings": { "title": "Configurações", "errors": { "save_failed": "Falha ao salvar as configurações!" }, "appearance": { "title": "Aparência", "brightness_theme": "Tema de exibição", "brightness_description": "Quer um visual brilhante e ensolarado, ou uma noite tranquila e solitária?", "light": "Claro", "dark": "Escuro", "effect_settings": "Configurações de efeitos", "effect_description": "Você gostaria de efeitos sofisticados ou uma experiência mais estática?", "enable": "Ativar", "disable": "Desativar" }, "conversion": { "title": "Conversão", "advanced_settings": "Configurações avançadas", "filename_format": "Formato do nome do arquivo", "filename_description": "Isso determinará o nome do arquivo no download, não incluindo a extensão do arquivo. Você pode colocar os seguintes modelos no formato, que serão substituídos pelas informações relevantes: %name% para o nome original do arquivo, %extension% para a extensão original do arquivo e %date% para uma string de data de quando o arquivo foi convertido.", "placeholder": "VERT_%name%", "default_format": "Formato de conversão padrão", "default_format_enable": "Habilitar", "default_format_disable": "Desabilitar", "default_format_description": "Isso mudará o formato padrão selecionado quando você enviar um arquivo deste tipo.", "default_format_image": "Imagens", "default_format_video": "ídeos", "default_format_audio": "Áudio", "default_format_document": "Documentos", "metadata": "Metadados do arquivo", "metadata_description": "Isso altera se algum metadado (EXIF, informações da música, etc.) no arquivo original é preservado nos arquivos convertidos.", "keep": "Manter", "remove": "Remover", "quality": "Qualidade da conversão", "quality_description": "Isso altera a qualidade de saída padrão dos arquivos convertidos (em sua categoria). Valores mais altos podem resultar em tempos de conversão mais longos e tamanho de arquivo maior.", "quality_video": "Isso altera a qualidade de saída padrão dos arquivos de vídeo convertidos. Valores mais altos podem resultar em tempos de conversão mais longos e tamanho de arquivo maior.", "quality_audio": "Áudio (kbps)", "quality_images": "Imagem (%)", "rate": "Taxa de amostragem (Hz)" }, "vertd": { "title": "Conversão de vídeo", "status": "status:", "loading": "carregando...", "available": "disponível, id do commit {commitId}", "unavailable": "indisponível (a url está correta?)", "description": "O projeto vertd é um wrapper de servidor para FFmpeg. Isso permite que você converta vídeos através da conveniência da interface web do VERT, enquanto ainda pode aproveitar o poder da sua GPU para fazer isso o mais rápido possível.", "hosting_info": "Hospedamos uma instância pública para sua conveniência, mas é bastante fácil hospedar a sua própria em seu PC ou servidor se você souber o que está fazendo. Você pode baixar os binários do servidor [vertd_link]aqui[/vertd_link] - o processo de configuração disso ficará mais fácil no futuro, então fique atento!", "instance": "Instância", "url_placeholder": "Exemplo: http://localhost:24153", "conversion_speed": "Velocidade de conversão", "speed_description": "Isso descreve a troca entre velocidade e qualidade. Velocidades mais rápidas resultarão em qualidade inferior, mas farão o trabalho mais rapidamente.", "speeds": { "very_slow": "Muito lento", "slower": "Mais lento", "slow": "Lento", "medium": "Médio", "fast": "Rápido", "ultra_fast": "Ultra rápido" }, "auto_instance": "Automático (recomendado)", "eu_instance": "Falkenstein, Alemanha", "us_instance": "Washington, EUA", "custom_instance": "Personalizado" }, "privacy": { "title": "Privacidade e dados", "plausible_title": "Analytics Plausible", "plausible_description": "Nós usamos [plausible_link]Plausible[/plausible_link], uma ferramenta de análise focada em privacidade, para coletar estatísticas completamente anônimas. Todos os dados são anonimizados e agregados, e nenhuma informação identificável é enviada ou armazenada. Você pode visualizar as análises [analytics_link]aqui[/analytics_link] e escolher optar por não participar abaixo.", "opt_in": "Aceitar", "opt_out": "Recusar", "cache_title": "Gerenciamento de cache", "cache_description": "Armazenamos em cache os arquivos do conversor no seu navegador para que você não precise baixá-los novamente toda vez, melhorando o desempenho e reduzindo o uso de dados.", "refresh_cache": "Atualizar cache", "clear_cache": "Limpar cache", "files_cached": "{size} ({count} arquivos)", "loading_cache": "Carregando...", "total_size": "Tamanho total", "files_cached_label": "Arquivos em cache", "cache_cleared": "Cache limpo com sucesso!", "cache_clear_error": "Falha ao limpar o cache.", "site_data_title": "Gerenciamento de dados do site", "site_data_description": "Limpe todos os dados do site, incluindo configurações e arquivos em cache, redefinindo o VERT para seu estado padrão e recarregando a página.", "clear_all_data": "Limpar todos os dados do site", "clear_all_data_confirm_title": "Limpar todos os dados do site?", "clear_all_data_confirm": "Isso irá redefinir todas as configurações e cache, e então recarregar a página. Esta ação não pode ser desfeita.", "clear_all_data_cancel": "Cancelar", "all_data_cleared": "Todos os dados do site foram limpos! Recarregando a página...", "all_data_clear_error": "Falha ao limpar todos os dados do site." }, "language": { "title": "Idioma", "description": "Selecione seu idioma preferido para a interface do VERT." } }, "about": { "title": "Sobre", "why": { "title": "Por que usar o VERT?", "description": "Os conversores de arquivos sempre nos decepcionaram. Eles são feios, cheios de anúncios e, o mais importante, lentos. Decidimos resolver esse problema de uma vez por todas, criando uma alternativa que resolve todos esses problemas e mais.

Todos os arquivos que não são de vídeo são convertidos completamente no dispositivo; isso significa que não há atraso entre o envio e o recebimento dos arquivos de um servidor, e nunca bisbilhotamos os arquivos que você converte.

Os arquivos de vídeo são enviados para o nosso servidor RTX 4000 Ada super rápido. Seus vídeos permanecem lá por uma hora se você não os converter. Se você converter o arquivo, o vídeo permanecerá no servidor por uma hora ou até ser baixado. O arquivo será então excluído do nosso servidor." }, "sponsors": { "title": "Patrocinadores", "description": "Quer nos apoiar? Entre em contato com um desenvolvedor no servidor [discord_link]Discord[/discord_link], ou envie um email para", "email_copied": "Email copiado para a área de transferência!" }, "resources": { "title": "Recursos", "discord": "Discord", "source": "Código fonte", "email": "Email" }, "donate": { "title": "Doar para o VERT", "description": "Com seu apoio, podemos continuar mantendo e melhorando o VERT.", "one_time": "Única vez", "monthly": "Mensal", "custom": "Personalizado", "pay_now": "Pagar agora", "donate_amount": "Doar ${amount} USD", "thank_you": "Obrigado pela sua doação!", "payment_failed": "Falha no pagamento: {message}{period} Você não foi cobrado.", "donation_error": "Ocorreu um erro ao processar sua doação. Por favor, tente novamente mais tarde.", "payment_error": "Erro ao buscar detalhes do pagamento. Por favor, tente novamente mais tarde.", "donation_notice_official": "Suas doações aqui vão para a instância oficial do VERT (vert.sh) e ajudam a apoiar o desenvolvimento do projeto.", "donation_notice_unofficial": "Suas doações aqui vão para o operador desta instância do VERT. Se você deseja apoiar os desenvolvedores oficiais do VERT, por favor visite [official_link]vert.sh[/official_link] em vez disso." }, "credits": { "title": "Créditos", "contact_team": "Se você gostaria de contatar a equipe de desenvolvimento, por favor use o email encontrado no cartão \"Recursos\".", "notable_contributors": "Contribuidores notáveis", "notable_description": "Gostaríamos de agradecer a essas pessoas por suas grandes contribuições ao VERT.", "github_contributors": "Contribuidores do GitHub", "github_description": "Um grande obrigado a todas essas pessoas por ajudarem! [github_link]Quer ajudar também?[/github_link]", "no_contributors": "Parece que ninguém contribuiu ainda... [contribute_link]seja o primeiro a contribuir![/contribute_link]", "libraries": "Bibliotecas", "libraries_description": "Um grande obrigado ao FFmpeg (áudio, vídeo), ImageMagick (imagens) e Pandoc (documentos) por manterem bibliotecas tão excelentes por tantos anos. O VERT depende deles para fornecer suas conversões.", "roles": { "lead_developer": "Desenvolvedor principal; backend de conversão, implementação da interface do usuário", "developer": "Desenvolvedor; implementação da interface do usuário", "designer": "Designer; UX, branding, marketing", "docker_ci": "Manutenção do Docker e suporte CI", "former_cofounder": "Ex-cofundador e designer" } }, "errors": { "github_contributors": "Erro ao buscar contribuintes do GitHub" } }, "workers": { "errors": { "general": "Erro ao converter {file}: {message}", "cancel": "Erro ao cancelar a conversão de {file}: {message}", "magick": "Erro no worker Magick, a conversão de imagens pode não funcionar como esperado.", "ffmpeg": "Erro ao carregar FFmpeg, alguns recursos podem não funcionar como esperado.", "pandoc": "Erro ao carregar o worker Pandoc, a conversão de documentos pode não funcionar como esperado.", "no_audio": "Nenhum fluxo de áudio encontrado.", "invalid_rate": "Taxa de amostragem especificada inválida: {rate}Hz", "file_too_large": "Este arquivo excede o limite de {limit}GB do navegador / dispositivo. Tente Firefox ou Safari para converter este arquivo grande, que normalmente têm limites mais altos." } }, "privacy": { "title": "Política de Privacidade", "summary": { "title": "Resumo", "description": "A política de privacidade do VERT é muito simples: não coletamos nem armazenamos nenhum dado sobre você. Não usamos cookies ou rastreadores, a análise é completamente privada e todas as conversões (exceto vídeos) acontecem localmente no seu navegador. Os vídeos são excluídos após serem baixados ou após uma hora, a menos que você dê permissão explícita para armazená-los; eles serão usados apenas para fins de solução de problemas. O VERT hospeda uma instância Coolify para hospedar o site e o vertd (para conversão de vídeo), e uma instância Plausible para análises completamente anônimas e agregadas. Usamos Stripe para processar doações, que pode coletar alguns dados usados para prevenção de fraudes.

Observe que isso pode se aplicar apenas à instância oficial do VERT em [vert_link]vert.sh[/vert_link]; instâncias de terceiros podem lidar com seus dados de maneira diferente." }, "conversions": { "title": "Conversões", "description": "A maioria das conversões (imagens, documentos, áudio) acontece inteiramente localmente no seu dispositivo usando versões WebAssembly das ferramentas relevantes (por exemplo, ImageMagick, Pandoc, FFmpeg). Isso significa que seus arquivos nunca saem do seu dispositivo e nunca teremos acesso a eles.

As conversões de vídeo são realizadas em nossos servidores porque exigem mais poder de processamento e ainda não podem ser feitas muito rapidamente no navegador. Os vídeos que você converte com o VERT são excluídos após serem baixados ou após uma hora, a menos que você dê permissão explícita para armazená-los por mais tempo, apenas para fins de solução de problemas." }, "donations": { "title": "Doações", "description": "Usamos Stripe na página [about_link]sobre[/about_link] para coletar doações. O Stripe pode coletar certas informações sobre o pagamento e o dispositivo para prevenção de fraudes, conforme descrito na [stripe_link]documentação deles sobre detecção avançada de fraudes[/stripe_link]. As solicitações de rede externas para o Stripe são adiadas e só são feitas depois que você clica no botão para pagar." }, "conversion_errors": { "title": "Erros de Conversão", "description": "Quando uma conversão de vídeo falha, podemos coletar alguns dados anônimos para nos ajudar a diagnosticar o problema. Esses dados podem incluir:", "list_job_id": "O ID do trabalho, que é o nome do arquivo anonimizado", "list_format_from": "O formato do qual você converteu", "list_format_to": "O formato para o qual você converteu", "list_stderr": "A saída stderr do FFmpeg do seu trabalho (mensagem de erro)", "list_video": "O arquivo de vídeo real (se for dada permissão explícita)", "footer": "Essas informações são usadas exclusivamente para o propósito de diagnosticar problemas de conversão. O arquivo de vídeo real só será coletado se você nos der permissão para isso, onde será usado apenas para solução de problemas." }, "analytics": { "title": "Analytics", "description": "Hospedamos uma instância Plausible para análises completamente anônimas e agregadas. O Plausible não usa cookies e está em conformidade com todas as principais regulamentações de privacidade (GDPR/CCPA/PECR). Você pode optar por não participar das análises na seção \"Privacidade e dados\" em [settings_link]configurações[/settings_link] e ler mais sobre as práticas de privacidade do Plausible [plausible_link]aqui[/plausible_link]." }, "local_storage": { "title": "Armazenamento local", "description": "Usamos o armazenamento local do seu navegador para salvar suas configurações, e o armazenamento de sessão do seu navegador para armazenar temporariamente a lista de colaboradores do GitHub para a seção \"Sobre\" para reduzir solicitações repetidas à API do GitHub. Nenhum dado pessoal é armazenado ou transmitido.

As versões WebAssembly das ferramentas de conversão que usamos (FFmpeg, ImageMagick, Pandoc) também são armazenadas localmente no seu navegador quando você visita o site pela primeira vez, para que você não precise baixá-las novamente a cada visita. Nenhum dado pessoal é armazenado ou transmitido. Você pode visualizar ou excluir esses dados a qualquer momento na seção \"Privacidade e dados\" em [settings_link]configurações[/settings_link]." }, "contact": { "title": "Contato", "description": "Para perguntas, envie um e-mail para: [email_link]hello@vert.sh[/email_link]. Se você estiver usando uma instância de terceiros do VERT, entre em contato com o host dessa instância." }, "last_updated": "Última atualização: 2025-11-27" } } ================================================ FILE: messages/tr.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "Yükle", "convert": "Dönüştür", "settings": "Ayarlar", "about": "Hakkımızda", "toggle_theme": "Temayı değiştir" }, "footer": { "copyright": "© {year} VERT.", "source_code": "Kaynak kodu", "discord_server": "Discord sunucusu" }, "upload": { "title": "Sevdiğiniz dosya dönüştürücü.", "subtitle": "Tüm görüntü, ses ve belge işlemleri cihazınızda gerçekleştirilir. Videolar, ışık hızındaki sunucularımızda dönüştürülür. Dosya boyutu sınırı ve reklam yoktur. Tamamen açık kaynaklıdır.", "uploader": { "text": "{action} için sürükleyip bırakın veya dosya seçin", "convert": "dönüştürmek" }, "cards": { "title": "VERT'in desteklediği formatlar...", "images": "Görsel", "audio": "Ses", "documents": "Belge", "video": "Video", "video_server_processing": "Sunucuda gerçekleşir", "local_supported": "Cihazınızda gerçekleşir", "status": { "text": "Durum: {status}", "ready": "hazır", "not_ready": "hazır değil", "not_initialized": "başlatılmamış", "downloading": "indiriliyor...", "initializing": "başlatılıyor...", "unknown": "bilinmeyen durum" }, "supported_formats": "Desteklenen formatlar:" }, "tooltip": { "partial_support": "Bu format yalnızca şu şekilde dönüştürülebilir: {direction}.", "direction_input": "kaynak", "direction_output": "çıktı", "video_server_processing": "Videolar varsayılan olarak işlenmek üzere sunucuya yüklenir. Yerel olarak nasıl ayarlayacağınızı buradan öğrenebilirsiniz." } }, "convert": { "external_warning": { "title": "Harici sunucu uyarısı", "text": "Video formatına dönüştürmeyi seçerseniz, bu dosyalar dönüştürülmek üzere harici bir sunucuya yüklenecektir. Devam etmek istiyor musunuz?", "yes": "Evet", "no": "Hayır" }, "panel": { "convert_all": "Tümünü dönüştür", "download_all": "Tümünü .zip olarak indir", "remove_all": "Tüm dosyaları kaldır", "set_all_to": "Tümünü ayarla", "na": "N/A" }, "dropdown": { "audio": "Ses", "video": "Video", "doc": "Belge", "image": "Görsel", "placeholder": "Format ara" }, "tooltips": { "unknown_file": "Bilinmeyen dosya türü", "audio_file": "Ses dosyası", "video_file": "Video dosyası", "document_file": "Belge dosyası", "image_file": "Görsel dosyası", "convert_file": "Bu dosyayı dönüştür", "download_file": "Bu dosyayı indir" }, "errors": { "cant_convert": "Bu dosyayı dönüştüremiyoruz.", "vertd_server": "Ne yapıyorsun..? vertd sunucusunu çalıştırman gerekiyordu!", "unsupported_format": "Yalnızca görüntü, video, ses ve belge dosyaları desteklenir.", "vertd_not_found": "Video dönüştürme işlemini başlatmak için vertd örneği bulunamadı. Sunucu URL’sinin doğru ayarlandığından emin misiniz?", "worker_downloading": "{type} dönüştürme işlemi şu anda başlatılıyor, lütfen birkaç saniye bekleyin.", "worker_error": "{type} dönüştürme işlemi başlatılırken bir hata oluştu, lütfen daha sonra tekrar deneyin.", "worker_timeout": "{type} dönüştürme işlemi beklenenden daha uzun sürüyor, lütfen biraz daha bekleyin veya sayfayı yenileyin.", "audio": "ses", "doc": "belge", "image": "görsel" } }, "settings": { "title": "Ayarlar", "errors": { "save_failed": "Ayarlar kaydedilirken hata oluştu!" }, "appearance": { "title": "Görünüm", "brightness_theme": "Tema seçimi", "brightness_description": "Güneşli bir gün mü istersiniz, yoksa sessiz ve yalnız bir gece mi?", "light": "Açık", "dark": "Koyu", "effect_settings": "Efekt ayarları", "effect_description": "Süslü efektler mi istersiniz, yoksa daha sade bir deneyim mi?", "enable": "Etkinleştir", "disable": "Devre dışı bırak" }, "conversion": { "title": "Dönüştürme", "advanced_settings": "Gelişmiş ayarlar", "filename_format": "Dosya adı formatı", "filename_description": "Bu ayar, dosya uzantısını etkilemeden indirilen dosyanın adını belirleyecektir. Aşağıdaki şablonları formata ekleyebilirsiniz, bunlar ilgili bilgilerle değiştirilecektir: orijinal dosya adı için %name%, orijinal dosya uzantısı için %extension% ve dosyanın dönüştürüldüğü tarihin tarih için %date%.", "placeholder": "VERT_%name%", "default_format": "Varsayılan dönüştürme formatı", "default_format_description": "Bu ayar, bu dosya türünde bir dosya yüklediğinizde seçili olan varsayılan formatı değiştirecektir.", "default_format_image": "Görsel", "default_format_video": "Video", "default_format_audio": "Ses", "default_format_document": "Belge", "metadata": "Dosya metadata", "metadata_description": "Bu ayar, orijinal dosyadaki meta verilerin (EXIF, şarkı bilgileri vb.) dönüştürülen dosyalarda korunup korunmayacağını değiştirir.", "keep": "Sakla", "remove": "Kaldır", "quality": "Dönüştürme kalitesi", "quality_description": "Bu, dönüştürülen dosyaların (kendi kategorisinde) varsayılan çıktı kalitesini değiştirir. Yüksek değerler, uzun dönüştürme sürelerine ve büyük dosya boyutuna neden olabilir.", "quality_video": "Bu, dönüştürülen videoların varsayılan çıktı kalitesini değiştirir. Yüksek değerler, uzun dönüştürme sürelerine ve büyük dosya boyutuna neden olabilir.", "quality_audio": "Ses (kbps)", "quality_images": "Görsel (%)", "rate": "Örnekleme oranı (Hz)" }, "vertd": { "title": "Video dönüştürme", "status": "durum:", "loading": "yükleniyor...", "available": "uygun, işlem no: {commitId}", "unavailable": "uygun değil (url doğru mu?)", "description": "vertd projesi, FFmpeg için bir sunucu sarmalayıcısıdır (server wrapper). Bu ayar, VERT'in web arayüzünün kullanım kolaylığı ile videoları dönüştürmenize olanak sağlarken, ekran kartınızın gücünden yararlanarak işlemi mümkün olan en hızlı şekilde yapmanızı sağlar.", "hosting_info": "Kolaylık sağlaması açısından herkese açık bir dönüştürücü sunuyoruz, ancak kendi bilgisayarınızda veya sunucunuzda kendi dönüştürücünüzü kurmak da oldukça kolaydır. Sunucu binary dosyalarını [vertd_link]buradan[/vertd_link] indirebilirsiniz. Kurulum işlemini gelecekte daha kolay hale getirmeye çalışıyoruz, bu nedenle bizi takip etmeyi unutmayın!", "instance": "Sunucu", "url_placeholder": "Örneğin: http://localhost:24153", "conversion_speed": "Dönüştürme hızı", "speed_description": "Bu ayar, hız ve kalite arasındaki dengeyi belirlemenizi sağlar. Yüksek hızlar, düşük kaliteye neden olur ancak işlem daha hızlı tamamlanır.", "speeds": { "very_slow": "En Yavaş", "slower": "Daha Yavaş", "slow": "Yavaş", "medium": "Orta", "fast": "Hızlı", "ultra_fast": "En Hızlı" }, "auto_instance": "Otomatik (önerilen)", "eu_instance": "Falkenstein, Germany", "us_instance": "Washington, USA", "custom_instance": "Özel" }, "privacy": { "title": "Gizlilik & kişisel veriler", "plausible_title": "Plausible analytics", "plausible_description": "Tamamen anonim istatistikler toplamak için gizliliğe odaklı bir analiz aracı olan [plausible_link]Plausible[/plausible_link]’ı kullanıyoruz. Tüm veriler anonimleştirilmiş ve birleştirilmiş şekilde işlenir; hiçbir kişisel veya tanımlanabilir bilgi gönderilmez ya da saklanmaz. Analitik verilerini [analytics_link]buradan[/analytics_link] görüntüleyebilir ve aşağıdan devre dışı bırakmayı seçebilirsiniz.", "opt_in": "Etkinleştir", "opt_out": "Devre dışı bırak", "cache_title": "Önbellek yönetimi", "cache_description": "Dönüştürücü dosyalarını tarayıcınızda önbelleğe alırız, böylece her seferinde yeniden indirmenize gerek kalmaz, performans artar ve veri kullanımı azalır.", "refresh_cache": "Önbelleği Yenile", "clear_cache": "Önbelleği Temizle", "files_cached": "{size} ({count} dosya)", "loading_cache": "Yükleniyor...", "total_size": "Toplam Boyut", "files_cached_label": "Önbelleğe Alınan Dosyalar", "cache_cleared": "Önbellek başarıyla temizlendi." }, "language": { "title": "Dil", "description": "VERT arayüzü için tercih ettiğiniz dili seçin." } }, "about": { "title": "Hakkımızda", "why": { "title": "Neden VERT?", "description": "Dosya dönüştürücüler bizi her zaman hayal kırıklığına uğratmıştır. Çoğu dönüştürücü site, kötü ve reklamlarla dolu arayüze sahiptir ve en önemlisi yavaştır. Tüm bu sorunları ve daha fazlasını çözen bir alternatif oluşturarak bu sorunu sonsuza kadar çözmeye karar verdik.

Video dışındaki tüm dosyalar tamamen cihazınızda dönüştürülür; bu, sunucuya dosya yükleme ve sunucudan dosya indirme sırasında gecikme olmaması ve dönüştürdüğünüz dosyaların asla başka biri tarafından görüntülenememesi anlamına gelir.

Video dosyaları, ışık hızındaki RTX 4000 Ada sunucumuza yüklenir. Videolarınızı dönüştürseniz de dönüştürmeseniz de bir saat sonra sunucularımızdan silinir. Video dönüştürme işlemi gerçekleştirirseniz, bir saat içinde dönüştürülmüş dosyayı indirebilirsiniz. Dosya daha sonra sunucumuzdan silinir." }, "sponsors": { "title": "Sponsorlar", "description": "Bizi desteklemek ister misiniz? [discord_link]Discord[/discord_link] sunucumuzda bir geliştiriciyle iletişime geçin veya şu adrese e-posta gönderin:", "email_copied": "E-posta kopyalandı!" }, "resources": { "title": "Bağlantılar", "discord": "Discord", "source": "GitHub", "email": "E-posta" }, "donate": { "title": "VERT'e bağış yapın", "description": "Desteğinizle VERT'i çalıştırmaya ve geliştirmeye devam edebiliriz.", "one_time": "Tek seferlik", "monthly": "Aylık", "custom": "Özel", "pay_now": "Ödeme yap", "donate_amount": "${amount} USD Bağış Yap", "thank_you": "Bağışınız için teşekkür ederiz!", "payment_failed": "Ödeme başarısız: {message}{period} Kartınızdan para çekilmedi.", "donation_error": "Bağışınız işlenirken bir hata oluştu. Lütfen daha sonra tekrar deneyin.", "payment_error": "Ödeme bilgileri alınırken hata oluştu. Lütfen daha sonra tekrar deneyin." }, "credits": { "title": "Katkıda bulunanlar", "contact_team": "Geliştirme ekibiyle iletişime geçmek isterseniz, \"Bağlantılar\" kısmında bulunan e-posta adresini kullanabilirsiniz.", "notable_contributors": "Önemli katılımcılar", "notable_description": "VERT'e sağladıkları büyük katkılardan dolayı bu kişilere teşekkür ederiz.", "github_contributors": "GitHub katılımcıları", "github_description": "Yardımcı olan herkese çok teşekkürler! [github_link]Sen de yardım etmek ister misin?[/github_link]", "no_contributors": "Henüz kimse katkıda bulunmamış gibi görünüyor... [contribute_link]ilk katkıda bulunan sen ol![/contribute_link]", "libraries": "Kütüphaneler", "libraries_description": "Bu mükemmel kütüphaneleri yıllardır geliştirdikleri için FFmpeg (ses, video), ImageMagick (görseller) ve Pandoc (belgeler)'a çok teşekkür ederiz. VERT, dönüştürme işlemleri için bu kütüphaneleri kullanmaktadır.", "roles": { "lead_developer": "Lead developer; conversion backend, UI implementation", "developer": "Developer; UI implementation", "designer": "Designer; UX, branding, marketing", "docker_ci": "Maintaining Docker & CI support", "former_cofounder": "Former co-founder & designer" } }, "errors": { "github_contributors": "GitHub katılımcılarını yüklerken hata oluştu" } }, "workers": { "errors": { "general": "{file} dönüştürülürken hata oluştu: {message}", "cancel": "{file} için dönüştürme işlemi iptal edilirken hata oluştu: {message}", "magick": "Magick işlemi sırasında hata oluştu, görsel dönüştürme işlemi beklendiği gibi çalışmayabilir.", "ffmpeg": "ffmpeg yüklenirken hata oluştu, bazı özellikler çalışmayabilir.", "no_audio": "Ses akışı bulunamadı.", "invalid_rate": "Geçersiz örnekleme hızı: {rate}Hz" } } } ================================================ FILE: messages/zh-Hans.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "上传", "convert": "转换", "settings": "设置", "about": "关于", "toggle_theme": "切换主题" }, "footer": { "copyright": "© {year} VERT.", "source_code": "源代码", "discord_server": "Discord 服务器", "privacy_policy": "隐私政策" }, "upload": { "title": "你一定会喜欢的文件转换工具。", "subtitle": "所有图片、音频和文档处理都在你的设备上进行。视频通过超快速服务器转换。无文件大小限制,无广告,完全开源。", "uploader": { "text": "拖放或点击以{action}", "convert": "转换" }, "cards": { "title": "VERT 支持...", "images": "图片", "audio": "音频", "documents": "文档", "video": "视频", "video_server_processing": "服务器支持", "local_supported": "本地支持", "status": { "text": "状态:{status}", "ready": "就绪", "not_ready": "未就绪", "not_initialized": "未初始化", "downloading": "下载中...", "initializing": "初始化中...", "unknown": "未知状态" }, "supported_formats": "支持的格式:" }, "tooltip": { "partial_support": "此格式仅可作为{direction}进行转换。", "direction_input": "输入(来源)", "direction_output": "输出(目标)", "video_server_processing": "视频默认上传到服务器进行处理,点击这里了解如何在本地设置。" } }, "convert": { "archive_file": { "extracting": "检测到 ZIP 压缩包:{filename}", "extracted": "从 {filename} 中提取了 {extract_count} 个文件。{ignore_count} 个项目被忽略。", "extract_error": "提取 {filename} 时出错:{error}" }, "large_file_warning": "由于浏览器/设备限制,此文件大于 {limit}GB,视频转音频功能已禁用。我们建议使用 Firefox 或 Safari 处理此大小的文件,因为它们的限制较少。", "external_warning": { "title": "外部服务器警告", "text": "如果你选择转换为视频格式,这些文件将被上传到外部服务器进行转换。是否继续?", "yes": "是", "no": "否" }, "panel": { "convert_all": "全部转换", "download_all": "下载全部为 .zip", "remove_all": "删除所有文件", "set_all_to": "全部设置为", "na": "不适用" }, "dropdown": { "audio": "音频", "video": "视频", "doc": "文档", "image": "图片", "placeholder": "搜索格式" }, "tooltips": { "unknown_file": "未知文件类型", "audio_file": "音频文件", "video_file": "视频文件", "document_file": "文档文件", "image_file": "图片文件", "convert_file": "转换此文件", "download_file": "下载此文件" }, "errors": { "cant_convert": "无法转换此文件。", "vertd_server": "你在做什么...?你应该运行 vertd 服务器!", "vertd_generic_view": "查看错误详情", "vertd_generic_body": "尝试转换视频时发生错误。你想将此视频提交给开发者以帮助修复此错误吗?只会发送你的视频文件,不会上传任何标识符。", "vertd_generic_title": "视频转换错误", "vertd_generic_yes": "提交视频", "vertd_generic_no": "不提交", "vertd_failed_to_keep": "无法在服务器上保留视频:{error}", "vertd_details": "查看错误详情", "vertd_details_body": "如果你按下提交,你的视频也会被附加在错误日志旁边,日志会自动报告给我们审核。以下信息是我们自动接收的日志:", "vertd_details_footer": "此信息仅用于故障排查,绝不会被共享。查看我们的[privacy_link]隐私政策[/privacy_link]了解更多详情。", "vertd_details_job_id": "任务 ID:{jobId}", "vertd_details_from": "来源格式:{from}", "vertd_details_to": "目标格式:{to}", "vertd_details_error_message": "错误消息:[view_link]查看错误日志[/view_link]", "vertd_details_close": "关闭", "unsupported_format": "仅支持图片、视频、音频和文档文件", "format_output_only": "此格式目前只能用作输出(转换目标),不能用作输入。", "vertd_not_found": "未找到 vertd 实例来开始视频转换。请确保实例 URL 设置正确。", "worker_downloading": "{type}转换器正在初始化,请稍候。", "worker_error": "{type}转换器初始化时出错,请稍后重试。", "worker_timeout": "{type}转换器初始化时间超出预期,请再等待一会儿或刷新页面。", "audio": "音频", "doc": "文档", "image": "图片" } }, "settings": { "title": "设置", "errors": { "save_failed": "保存设置失败!" }, "appearance": { "title": "外观", "brightness_theme": "亮度主题", "brightness_description": "想要阳光明媚的闪光弹,还是宁静孤独的夜晚?", "light": "浅色", "dark": "深色", "effect_settings": "效果设置", "effect_description": "你想要华丽的效果,还是更静态的体验?", "enable": "启用", "disable": "禁用" }, "conversion": { "title": "转换", "advanced_settings": "高级设置", "filename_format": "文件名格式", "filename_description": "这将决定下载时的文件名,不包括文件扩展名。你可以在格式中使用以下模板,它们将被替换为相关信息:%name%表示原始文件名,%extension%表示原始文件扩展名,%date%表示文件转换时的日期字符串。", "placeholder": "VERT_%name%", "default_format": "默认转换格式", "default_format_description": "这将更改上传此文件类型时自动选择的默认格式。", "default_format_image": "图片", "default_format_video": "视频", "default_format_audio": "音频", "default_format_document": "文档", "metadata": "文件元数据", "metadata_description": "这将更改转换后的文件是否保留原始文件的元数据(EXIF、歌曲信息等)。", "keep": "保留", "remove": "删除", "quality": "转换质量", "quality_description": "更改输出文件的质量。值越高,处理时间和文件大小越大。", "quality_video": "更改视频转换的质量。质量越高,转换时间和文件大小越大。", "quality_audio": "音频(kbps)", "quality_images": "图片(%)", "rate": "采样率(Hz)" }, "vertd": { "title": "视频转换", "status": "状态:", "loading": "加载中...", "available": "可用(提交 ID {commitId})", "unavailable": "不可用(URL 正确吗?)", "description": "vertd项目是 FFmpeg 的服务器包装器。这允许你通过 VERT 网页界面方便地转换视频,同时仍能利用 GPU 的强大性能以尽可能快的速度完成转换。", "hosting_info": "我们为你提供了一个公共实例以方便使用,但如果你知道如何操作,在自己的电脑或服务器上托管也很容易。你可以在[vertd_link]这里[/vertd_link]下载服务器二进制文件 - 设置过程将来会变得更简单,敬请期待!", "instance": "实例", "url_placeholder": "例如:http://localhost:24153", "conversion_speed": "转换速度", "speed_description": "这描述了速度和质量之间的权衡。速度越快质量越低,但完成工作的速度更快。", "speeds": { "very_slow": "非常慢", "slower": "较慢", "slow": "慢", "medium": "中等", "fast": "快", "ultra_fast": "超快" }, "auto_instance": "自动(推荐)", "eu_instance": "德国法尔肯施泰因", "us_instance": "美国华盛顿", "custom_instance": "自定义" }, "privacy": { "title": "隐私与数据", "plausible_title": "Plausible 分析", "plausible_description": "我们使用[plausible_link]Plausible[/plausible_link],一个注重隐私的分析工具,来收集完全匿名的统计数据。所有数据都是匿名和聚合的,不会发送或存储任何可识别信息。你可以在[analytics_link]这里[/analytics_link]查看分析数据,并在下方选择退出。", "opt_in": "选择加入", "opt_out": "选择退出", "cache_title": "缓存管理", "cache_description": "我们在浏览器中缓存转换器文件,这样你就不必每次都重新下载,从而提高性能并减少数据使用。", "refresh_cache": "刷新缓存", "clear_cache": "清除缓存", "files_cached": "{size}({count}个文件)", "loading_cache": "加载中...", "total_size": "总大小", "files_cached_label": "已缓存文件", "cache_cleared": "缓存已成功清除!", "cache_clear_error": "清除缓存失败。", "site_data_title": "网站数据管理", "site_data_description": "清除所有网站数据,包括设置和缓存文件,将 VERT 重置为默认状态并重新加载页面。", "clear_all_data": "清除所有网站数据", "clear_all_data_confirm_title": "清除所有网站数据?", "clear_all_data_confirm": "这将重置所有设置和缓存,然后重新加载页面。此操作无法撤消。", "clear_all_data_cancel": "取消", "all_data_cleared": "所有网站数据已清除!正在重新加载页面...", "all_data_clear_error": "清除所有网站数据失败。" }, "language": { "title": "语言", "description": "选择 VERT 界面的首选语言。" } }, "about": { "title": "关于", "why": { "title": "为什么选择 VERT?", "description": "文件转换器一直让我们失望。它们很丑陋,充满广告,最重要的是;很慢。我们决定通过制作一个解决所有这些问题的替代方案,一劳永逸地解决这个问题。

所有非视频文件都完全在设备上转换;这意味着不需要在服务器之间发送和接收文件的延迟,而且我们永远不会窥探你转换的文件。

视频文件会上传到我们超快速的 RTX 4000 Ada 服务器。如果你不转换视频,它们会在服务器上保留一小时。如果你转换文件,视频将在服务器上保留一小时,或直到下载完成。然后文件将从我们的服务器中删除。" }, "sponsors": { "title": "赞助商", "description": "想支持我们吗?请在[discord_link]Discord[/discord_link]服务器上联系开发者,或发送电子邮件至", "email_copied": "电子邮件已复制到剪贴板!" }, "resources": { "title": "资源", "discord": "Discord", "source": "源代码", "email": "电子邮件" }, "donate": { "title": "捐赠给 VERT", "description": "有了你的支持,我们可以继续维护和改进 VERT。", "one_time": "一次性", "monthly": "每月", "custom": "自定义", "pay_now": "立即支付", "donate_amount": "捐赠 ${amount} 美元", "thank_you": "感谢你的捐赠!", "payment_failed": "支付失败:{message}{period}你未被收费。", "donation_error": "处理捐赠时出错。请稍后重试。", "payment_error": "获取支付详情时出错。请稍后重试。" }, "credits": { "title": "致谢", "contact_team": "如果你想联系开发团队,请使用“资源”卡片上的电子邮件。", "notable_contributors": "杰出贡献者", "notable_description": "我们要感谢这些人对 VERT 的重大贡献。", "github_contributors": "GitHub 贡献者", "github_description": "非常感谢所有这些人的帮助![github_link]也想帮忙吗?[/github_link]", "no_contributors": "似乎还没有人贡献……[contribute_link]成为第一个贡献者![/contribute_link]", "libraries": "库", "libraries_description": "非常感谢 FFmpeg(音频、视频)、ImageMagick(图片)和 Pandoc(文档)多年来维护如此出色的库。VERT 依赖它们为你提供转换服务。", "roles": { "lead_developer": "首席开发者;转换后端、UI 实现", "developer": "开发者;UI 实现", "designer": "设计师;用户体验、品牌、营销", "docker_ci": "维护 Docker 和 CI 支持", "former_cofounder": "前联合创始人和设计师" } }, "errors": { "github_contributors": "获取 GitHub 贡献者时出错" } }, "workers": { "errors": { "general": "转换 {file} 时出错:{message}", "cancel": "取消转换 {file} 时出错:{message}", "magick": "Magick worker 出错,图片转换可能无法正常工作。", "ffmpeg": "加载 ffmpeg 时出错,某些功能可能无法工作。", "pandoc": "加载 Pandoc worker 时出错,文档转换可能无法正常工作。", "no_audio": "未找到音频流。", "invalid_rate": "指定的采样率无效:{rate}Hz", "file_too_large": "此文件超过 {limit}GB 浏览器/设备限制。请尝试使用 Firefox 或 Safari 转换此大文件,它们通常具有更高的限制。" } }, "privacy": { "title": "隐私政策", "summary": { "title": "摘要", "description": "VERT 的隐私政策非常简单:我们根本不收集或存储你的任何数据。我们不使用 cookie 或跟踪器,分析是完全私密的,所有转换(视频除外)都在你的浏览器本地进行。视频在下载后或一小时后删除,除非你明确授权存储;它只会用于故障排查。VERT 自托管 Coolify 实例用于托管网站和 vertd(用于视频转换),以及用于完全匿名和聚合分析的 Plausible 实例。

请注意,这可能仅适用于[vert_link]vert.sh[/vert_link]的官方 VERT 实例;第三方实例可能以不同方式处理你的数据。" }, "conversions": { "title": "转换", "description": "大多数转换(图片、文档、音频)完全在你的设备上本地使用相关工具的 WebAssembly 版本(例如 ImageMagick、Pandoc、FFmpeg)进行。这意味着你的文件永远不会离开你的设备,我们也永远无法访问它们。

视频转换在我们的服务器上进行,因为它们需要更多的处理能力,并且目前无法在浏览器上非常快速地完成。你使用 VERT 转换的视频在下载后或一小时后删除,除非你明确授权我们将它们存储更长时间,纯粹用于故障排查。" }, "conversion_errors": { "title": "转换错误", "description": "当视频转换失败时,我们可能会收集一些匿名数据以帮助我们诊断问题。这些数据可能包括:", "list_job_id": "任务 ID,即匿名化的文件名", "list_format_from": "你转换的来源格式", "list_format_to": "你转换的目标格式", "list_stderr": "你任务的 FFmpeg stderr 输出(错误消息)", "list_video": "实际视频文件(如果明确授权)", "footer": "此信息仅用于诊断转换问题。只有在你明确授权的情况下,才会收集实际视频文件,并且仅用于故障排查。" }, "analytics": { "title": "分析", "description": "我们自托管 Plausible 实例用于完全匿名和聚合的分析。Plausible 不使用 cookie,并符合所有主要隐私法规(GDPR/CCPA/PECR)。你可以在[settings_link]设置[/settings_link]的“隐私与数据”部分选择退出分析,并在[plausible_link]这里[/plausible_link]阅读更多关于 Plausible 隐私实践的信息。" }, "local_storage": { "title": "本地存储", "description": "我们使用浏览器的本地存储来保存你的设置,使用浏览器的会话存储来临时存储“关于”部分的 GitHub 贡献者列表,以减少重复的 GitHub API 请求。不会存储或传输任何个人数据。

我们使用的转换工具的 WebAssembly 版本(FFmpeg、ImageMagick、Pandoc)也会在你首次访问网站时本地存储在浏览器中,这样你就不需要每次访问时都重新下载它们。不会存储或传输任何个人数据。你可以随时在[settings_link]设置[/settings_link]的“隐私与数据”部分查看或删除这些数据。" }, "contact": { "title": "联系", "description": "如有问题,请发送电子邮件至:[email_link]hello@vert.sh[/email_link]。如果你使用的是第三方 VERT 实例,请联系该实例的托管者。" }, "last_updated": "最后更新:2025-10-19" } } ================================================ FILE: messages/zh-Hant.json ================================================ { "$schema": "https://inlang.com/schema/inlang-message-format", "navbar": { "upload": "上傳", "convert": "轉換", "settings": "設定", "about": "關於", "toggle_theme": "切換主題" }, "footer": { "copyright": "© {year} VERT.", "source_code": "原始碼", "discord_server": "Discord 伺服器", "privacy_policy": "隱私權政策" }, "upload": { "title": "你一定會喜歡的檔案轉換工具。", "subtitle": "所有圖片、音訊和文件處理都在你的裝置上進行。影片透過超快速伺服器轉換。無檔案大小限制,無廣告,完全開源。", "uploader": { "text": "拖放或點擊以{action}", "convert": "轉換" }, "cards": { "title": "VERT 支援...", "images": "圖片", "audio": "音訊", "documents": "文件", "video": "影片", "video_server_processing": "伺服器支援", "local_supported": "本機支援", "status": { "text": "狀態:{status}", "ready": "就緒", "not_ready": "未就緒", "not_initialized": "未初始化", "downloading": "下載中...", "initializing": "初始化中...", "unknown": "未知狀態" }, "supported_formats": "支援的格式:" }, "tooltip": { "partial_support": "此格式僅可作為{direction}進行轉換。", "direction_input": "輸入(來源)", "direction_output": "輸出(目標)", "video_server_processing": "影片預設上傳到伺服器進行處理,點擊這裡了解如何在本機設定。" } }, "convert": { "archive_file": { "extracting": "偵測到 ZIP 壓縮檔:{filename}", "extracted": "從 {filename} 中提取了 {extract_count} 個檔案。{ignore_count} 個項目被忽略。", "extract_error": "提取 {filename} 時出錯:{error}" }, "large_file_warning": "由於瀏覽器/裝置限制,此檔案大於 {limit}GB,影片轉音訊功能已停用。我們建議使用 Firefox 或 Safari 處理此大小的檔案,因為它們的限制較少。", "external_warning": { "title": "外部伺服器警告", "text": "如果你選擇轉換為影片格式,這些檔案將被上傳到外部伺服器進行轉換。是否繼續?", "yes": "是", "no": "否" }, "panel": { "convert_all": "全部轉換", "download_all": "下載全部為 .zip", "remove_all": "刪除所有檔案", "set_all_to": "全部設定為", "na": "不適用" }, "dropdown": { "audio": "音訊", "video": "影片", "doc": "文件", "image": "圖片", "placeholder": "搜尋格式" }, "tooltips": { "unknown_file": "未知檔案類型", "audio_file": "音訊檔案", "video_file": "影片檔案", "document_file": "文件檔案", "image_file": "圖片檔案", "convert_file": "轉換此檔案", "download_file": "下載此檔案" }, "errors": { "cant_convert": "無法轉換此檔案。", "vertd_server": "你在做什麼...?你應該執行 vertd 伺服器!", "vertd_generic_view": "檢視錯誤詳情", "vertd_generic_body": "嘗試轉換影片時發生錯誤。你想將此影片提交給開發者以協助修復此錯誤嗎?只會傳送你的影片檔案,不會上傳任何識別碼。", "vertd_generic_title": "影片轉換錯誤", "vertd_generic_yes": "提交影片", "vertd_generic_no": "不提交", "vertd_failed_to_keep": "無法在伺服器上保留影片:{error}", "vertd_details": "檢視錯誤詳情", "vertd_details_body": "如果你按下提交,你的影片也會被附加在錯誤日誌旁邊,日誌會自動報告給我們審核。以下資訊是我們自動接收的日誌:", "vertd_details_footer": "此資訊僅用於故障排除,絕不會被分享。檢視我們的[privacy_link]隱私權政策[/privacy_link]以了解更多詳情。", "vertd_details_job_id": "任務 ID:{jobId}", "vertd_details_from": "來源格式:{from}", "vertd_details_to": "目標格式:{to}", "vertd_details_error_message": "錯誤訊息:[view_link]檢視錯誤日誌[/view_link]", "vertd_details_close": "關閉", "unsupported_format": "僅支援圖片、影片、音訊和文件檔案", "format_output_only": "此格式目前只能用作輸出(轉換目標),不能用作輸入。", "vertd_not_found": "未找到 vertd 執行個體來開始影片轉換。請確保執行個體 URL 設定正確。", "worker_downloading": "{type}轉換器正在初始化,請稍候。", "worker_error": "{type}轉換器初始化時出錯,請稍後重試。", "worker_timeout": "{type}轉換器初始化時間超出預期,請再等待一會兒或重新整理頁面。", "audio": "音訊", "doc": "文件", "image": "圖片" } }, "settings": { "title": "設定", "errors": { "save_failed": "儲存設定失敗!" }, "appearance": { "title": "外觀", "brightness_theme": "亮度主題", "brightness_description": "想要陽光明媚的閃光彈,還是寧靜孤獨的夜晚?", "light": "淺色", "dark": "深色", "effect_settings": "效果設定", "effect_description": "你想要華麗的效果,還是更靜態的體驗?", "enable": "啟用", "disable": "停用" }, "conversion": { "title": "轉換", "advanced_settings": "進階設定", "filename_format": "檔案名稱格式", "filename_description": "這將決定下載時的檔案名稱,不包括副檔名。你可以在格式中使用以下範本,它們將被替換為相關資訊:%name%表示原始檔案名稱,%extension%表示原始副檔名,%date%表示檔案轉換時的日期字串。", "placeholder": "VERT_%name%", "default_format": "預設轉換格式", "default_format_description": "這將更改上傳此檔案類型時自動選擇的預設格式。", "default_format_image": "圖片", "default_format_video": "影片", "default_format_audio": "音訊", "default_format_document": "文件", "metadata": "檔案中繼資料", "metadata_description": "這將更改轉換後的檔案是否保留原始檔案的中繼資料(EXIF、歌曲資訊等)。", "keep": "保留", "remove": "移除", "quality": "轉換品質", "quality_description": "更改輸出檔案的品質。值越高,處理時間和檔案大小越大。", "quality_video": "更改影片轉換的品質。品質越高,轉換時間和檔案大小越大。", "quality_audio": "音訊(kbps)", "quality_images": "圖片(%)", "rate": "取樣率(Hz)" }, "vertd": { "title": "影片轉換", "status": "狀態:", "loading": "載入中...", "available": "可用(提交 ID {commitId})", "unavailable": "不可用(URL 正確嗎?)", "description": "vertd專案是 FFmpeg 的伺服器包裝器。這允許你透過 VERT 網頁介面方便地轉換影片,同時仍能利用 GPU 的強大效能以儘可能快的速度完成轉換。", "hosting_info": "我們為你提供了一個公共執行個體以方便使用,但如果你知道如何操作,在自己的電腦或伺服器上託管也很容易。你可以在[vertd_link]這裡[/vertd_link]下載伺服器二進位檔案 - 設定程序將來會變得更簡單,敬請期待!", "instance": "執行個體", "url_placeholder": "例如:http://localhost:24153", "conversion_speed": "轉換速度", "speed_description": "這描述了速度和品質之間的權衡。速度越快品質越低,但完成工作的速度更快。", "speeds": { "very_slow": "非常慢", "slower": "較慢", "slow": "慢", "medium": "中等", "fast": "快", "ultra_fast": "超快" }, "auto_instance": "自動(建議)", "eu_instance": "德國法爾肯施泰因", "us_instance": "美國華盛頓", "custom_instance": "自訂" }, "privacy": { "title": "隱私權與資料", "plausible_title": "Plausible 分析", "plausible_description": "我們使用[plausible_link]Plausible[/plausible_link],一個注重隱私權的分析工具,來收集完全匿名的統計資料。所有資料都是匿名和彙總的,不會傳送或儲存任何可識別資訊。你可以在[analytics_link]這裡[/analytics_link]檢視分析資料,並在下方選擇退出。", "opt_in": "選擇加入", "opt_out": "選擇退出", "cache_title": "快取管理", "cache_description": "我們在瀏覽器中快取轉換器檔案,這樣你就不必每次都重新下載,從而提高效能並減少資料使用。", "refresh_cache": "重新整理快取", "clear_cache": "清除快取", "files_cached": "{size}({count}個檔案)", "loading_cache": "載入中...", "total_size": "總大小", "files_cached_label": "已快取檔案", "cache_cleared": "快取已成功清除!", "cache_clear_error": "清除快取失敗。", "site_data_title": "網站資料管理", "site_data_description": "清除所有網站資料,包括設定和快取檔案,將 VERT 重置為預設狀態並重新載入頁面。", "clear_all_data": "清除所有網站資料", "clear_all_data_confirm_title": "清除所有網站資料?", "clear_all_data_confirm": "這將重置所有設定和快取,然後重新載入頁面。此操作無法復原。", "clear_all_data_cancel": "取消", "all_data_cleared": "所有網站資料已清除!正在重新載入頁面...", "all_data_clear_error": "清除所有網站資料失敗。" }, "language": { "title": "語言", "description": "選擇 VERT 介面的偏好語言。" } }, "about": { "title": "關於", "why": { "title": "為什麼選擇 VERT?", "description": "檔案轉換器一直讓我們失望。它們很醜陋,充滿廣告,最重要的是;很慢。我們決定透過製作一個解決所有這些問題的替代方案,一勞永逸地解決這個問題。

所有非影片檔案都完全在裝置上轉換;這意味著不需要在伺服器之間傳送和接收檔案的延遲,而且我們永遠不會窺探你轉換的檔案。

影片檔案會上傳到我們超快速的 RTX 4000 Ada 伺服器。如果你不轉換影片,它們會在伺服器上保留一小時。如果你轉換檔案,影片將在伺服器上保留一小時,或直到下載完成。然後檔案將從我們的伺服器中刪除。" }, "sponsors": { "title": "贊助商", "description": "想支援我們嗎?請在[discord_link]Discord[/discord_link]伺服器上聯絡開發者,或傳送電子郵件至", "email_copied": "電子郵件已複製到剪貼簿!" }, "resources": { "title": "資源", "discord": "Discord", "source": "原始碼", "email": "電子郵件" }, "donate": { "title": "捐贈給 VERT", "description": "有了你的支援,我們可以繼續維護和改進 VERT。", "one_time": "一次性", "monthly": "每月", "custom": "自訂", "pay_now": "立即付款", "donate_amount": "捐贈 ${amount} 美元", "thank_you": "感謝你的捐贈!", "payment_failed": "付款失敗:{message}{period}你未被收費。", "donation_error": "處理捐贈時出錯。請稍後重試。", "payment_error": "取得付款詳情時出錯。請稍後重試。" }, "credits": { "title": "致謝", "contact_team": "如果你想聯絡開發團隊,請使用「資源」卡片上的電子郵件。", "notable_contributors": "傑出貢獻者", "notable_description": "我們要感謝這些人對 VERT 的重大貢獻。", "github_contributors": "GitHub 貢獻者", "github_description": "非常感謝所有這些人的協助![github_link]也想幫忙嗎?[/github_link]", "no_contributors": "似乎還沒有人貢獻……[contribute_link]成為第一個貢獻者![/contribute_link]", "libraries": "程式庫", "libraries_description": "非常感謝 FFmpeg(音訊、影片)、ImageMagick(圖片)和 Pandoc(文件)多年來維護如此出色的程式庫。VERT 依賴它們為你提供轉換服務。", "roles": { "lead_developer": "首席開發者;轉換後端、UI 實作", "developer": "開發者;UI 實作", "designer": "設計師;使用者體驗、品牌、行銷", "docker_ci": "維護 Docker 和 CI 支援", "former_cofounder": "前共同創辦人和設計師" } }, "errors": { "github_contributors": "取得 GitHub 貢獻者時出錯" } }, "workers": { "errors": { "general": "轉換 {file} 時出錯:{message}", "cancel": "取消轉換 {file} 時出錯:{message}", "magick": "Magick worker 出錯,圖片轉換可能無法正常運作。", "ffmpeg": "載入 ffmpeg 時出錯,某些功能可能無法運作。", "pandoc": "載入 Pandoc worker 時出錯,文件轉換可能無法正常運作。", "no_audio": "未找到音訊串流。", "invalid_rate": "指定的取樣率無效:{rate}Hz", "file_too_large": "此檔案超過 {limit}GB 瀏覽器/裝置限制。請嘗試使用 Firefox 或 Safari 轉換此大型檔案,它們通常具有較高的限制。" } }, "privacy": { "title": "隱私權政策", "summary": { "title": "摘要", "description": "VERT 的隱私權政策非常簡單:我們根本不收集或儲存你的任何資料。我們不使用 cookie 或追蹤器,分析是完全私密的,所有轉換(影片除外)都在你的瀏覽器本機進行。影片在下載後或一小時後刪除,除非你明確授權儲存;它只會用於故障排除。VERT 自託管 Coolify 執行個體用於託管網站和 vertd(用於影片轉換),以及用於完全匿名和彙總分析的 Plausible 執行個體。

請注意,這可能僅適用於[vert_link]vert.sh[/vert_link]的官方 VERT 執行個體;第三方執行個體可能以不同方式處理你的資料。" }, "conversions": { "title": "轉換", "description": "大多數轉換(圖片、文件、音訊)完全在你的裝置上本機使用相關工具的 WebAssembly 版本(例如 ImageMagick、Pandoc、FFmpeg)進行。這意味著你的檔案永遠不會離開你的裝置,我們也永遠無法存取它們。

影片轉換在我們的伺服器上進行,因為它們需要更多的處理能力,並且目前無法在瀏覽器上非常快速地完成。你使用 VERT 轉換的影片在下載後或一小時後刪除,除非你明確授權我們將它們儲存更長時間,純粹用於故障排除。" }, "conversion_errors": { "title": "轉換錯誤", "description": "當影片轉換失敗時,我們可能會收集一些匿名資料以協助我們診斷問題。這些資料可能包括:", "list_job_id": "任務 ID,即匿名化的檔案名稱", "list_format_from": "你轉換的來源格式", "list_format_to": "你轉換的目標格式", "list_stderr": "你任務的 FFmpeg stderr 輸出(錯誤訊息)", "list_video": "實際影片檔案(如果明確授權)", "footer": "此資訊僅用於診斷轉換問題。只有在你明確授權的情況下,才會收集實際影片檔案,並且僅用於故障排除。" }, "analytics": { "title": "分析", "description": "我們自託管 Plausible 執行個體用於完全匿名和彙總的分析。Plausible 不使用 cookie,並符合所有主要隱私權法規(GDPR/CCPA/PECR)。你可以在[settings_link]設定[/settings_link]的「隱私權與資料」部分選擇退出分析,並在[plausible_link]這裡[/plausible_link]閱讀更多關於 Plausible 隱私權實務的資訊。" }, "local_storage": { "title": "本機儲存", "description": "我們使用瀏覽器的本機儲存來儲存你的設定,使用瀏覽器的工作階段儲存來暫時儲存「關於」部分的 GitHub 貢獻者清單,以減少重複的 GitHub API 請求。不會儲存或傳輸任何個人資料。

我們使用的轉換工具的 WebAssembly 版本(FFmpeg、ImageMagick、Pandoc)也會在你首次造訪網站時本機儲存在瀏覽器中,這樣你就不需要每次造訪時都重新下載它們。不會儲存或傳輸任何個人資料。你可以隨時在[settings_link]設定[/settings_link]的「隱私權與資料」部分檢視或刪除這些資料。" }, "contact": { "title": "聯絡", "description": "如有問題,請傳送電子郵件至:[email_link]hello@vert.sh[/email_link]。如果你使用的是第三方 VERT 執行個體,請聯絡該執行個體的託管者。" }, "last_updated": "最後更新:2025-10-19" } } ================================================ FILE: nginx/default-ssl.conf ================================================ server { listen 80; server_name vert; # Redirect all HTTP traffic to HTTPS return 301 https://$host$request_uri; } server { listen 443 ssl; server_name vert; ssl_certificate /etc/ssl/vert/vert.crt; ssl_certificate_key /etc/ssl/vert/vert.key; root /usr/share/nginx/html; index index.html; client_max_body_size 10M; # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; location / { try_files $uri $uri/ /index.html; } error_page 404 /index.html; } ================================================ FILE: nginx/default.conf ================================================ server { listen 80; listen [::]:80; server_name vert; root /usr/share/nginx/html; index index.html; client_max_body_size 10M; # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; location / { try_files $uri $uri/ /index.html; } error_page 404 /index.html; } ================================================ FILE: package.json ================================================ { "name": "vert", "version": "0.0.1", "type": "module", "scripts": { "dev": "vite dev", "build": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "lint": "prettier --check . && eslint ." }, "devDependencies": { "@inlang/paraglide-js": "^2.5.0", "@poppanator/sveltekit-svg": "^5.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.49.0", "@sveltejs/vite-plugin-svelte": "^4.0.4", "@types/eslint": "^9.6.1", "@types/sanitize-html": "^2.16.0", "autoprefixer": "^10.4.22", "css-select": "5.1.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^2.46.1", "globals": "^15.15.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.6.14", "sass": "^1.94.2", "svelte": "^5.43.14", "svelte-check": "^4.3.4", "tailwindcss": "^3.4.18", "typescript": "^5.9.3", "typescript-eslint": "^8.47.0", "vite": "^5.4.21", "vite-plugin-top-level-await": "^1.6.0" }, "dependencies": { "@bjorn3/browser_wasi_shim": "^0.4.2", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", "@fontsource/azeret-mono": "^5.2.11", "@fontsource/lexend": "^5.2.11", "@fontsource/radio-canada-big": "^5.2.7", "@imagemagick/magick-wasm": "^0.0.37", "@stripe/stripe-js": "^8.5.2", "byte-data": "^19.0.1", "client-zip": "^2.5.0", "clsx": "^2.1.1", "fflate": "^0.8.2", "lucide-svelte": "^0.554.0", "music-metadata": "^11.10.3", "overlayscrollbars": "^2.12.0", "overlayscrollbars-svelte": "^0.5.5", "p-queue": "^9.0.1", "riff-file": "^1.0.3", "sanitize-html": "^2.17.0", "svelte-stripe": "^1.4.0", "vert-wasm": "^0.0.2", "vite-plugin-wasm": "^3.5.0" }, "trustedDependencies": [ "@parcel/watcher", "@swc/core" ] } ================================================ FILE: postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; ================================================ FILE: project.inlang/.gitignore ================================================ cache ================================================ FILE: project.inlang/project_id ================================================ ff77Td2rnvEqQyzBYT ================================================ FILE: project.inlang/settings.json ================================================ { "$schema": "https://inlang.com/schema/project-settings", "baseLocale": "en", "locales": [ "en", "es", "fr", "de", "it", "ba", "hr", "tr", "ja", "ko", "el", "id", "zh-Hans", "zh-Hant", "pt-BR" ], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" ], "plugin.inlang.messageFormat": { "pathPattern": "./messages/{locale}.json" } } ================================================ FILE: src/app.d.ts ================================================ import "@poppanator/sveltekit-svg/dist/svg"; type EventPayload = { readonly n: string; readonly u: Location["href"]; readonly d: Location["hostname"]; readonly r: Document["referrer"] | null; readonly w: Window["innerWidth"]; readonly h: 1 | 0; readonly p?: string; }; type CallbackArgs = { readonly status: number; }; type EventOptions = { /** * Callback called when the event is successfully sent. */ readonly callback?: (args: CallbackArgs) => void; /** * Properties to be bound to the event. */ readonly props?: { readonly [propName: string]: string | number | boolean }; }; declare global { interface Window { plausible: TrackEvent; } const __COMMIT_HASH__: string; } /** * Options used when initializing the tracker. */ export type PlausibleInitOptions = { /** * If true, pageviews will be tracked when the URL hash changes. * Enable this if you are using a frontend that uses hash-based routing. */ readonly hashMode?: boolean; /** * Set to true if you want events to be tracked when running the site locally. */ readonly trackLocalhost?: boolean; /** * The domain to bind the event to. * Defaults to `location.hostname` */ readonly domain?: Location["hostname"]; /** * The API host where the events will be sent. * Defaults to `'https://plausible.io'` */ readonly apiHost?: string; }; /** * Data passed to Plausible as events. */ export type PlausibleEventData = { /** * The URL to bind the event to. * Defaults to `location.href`. */ readonly url?: Location["href"]; /** * The referrer to bind the event to. * Defaults to `document.referrer` */ readonly referrer?: Document["referrer"] | null; /** * The current device's width. * Defaults to `window.innerWidth` */ readonly deviceWidth?: Window["innerWidth"]; }; /** * Options used when tracking Plausible events. */ export type PlausibleOptions = PlausibleInitOptions & PlausibleEventData; /** * Tracks a custom event. * * Use it to track your defined goals by providing the goal's name as `eventName`. * * ### Example * ```js * import Plausible from 'plausible-tracker' * * const { trackEvent } = Plausible() * * // Tracks the 'signup' goal * trackEvent('signup') * * // Tracks the 'Download' goal passing a 'method' property. * trackEvent('Download', { props: { method: 'HTTP' } }) * ``` * * @param eventName - Name of the event to track * @param options - Event options. * @param eventData - Optional event data to send. Defaults to the current page's data merged with the default options provided earlier. */ type TrackEvent = ( eventName: string, options?: EventOptions, eventData?: PlausibleOptions, ) => void; /** * Manually tracks a page view. * * ### Example * ```js * import Plausible from 'plausible-tracker' * * const { trackPageview } = Plausible() * * // Track a page view * trackPageview() * ``` * * @param eventData - Optional event data to send. Defaults to the current page's data merged with the default options provided earlier. * @param options - Event options. */ type TrackPageview = ( eventData?: PlausibleOptions, options?: EventOptions, ) => void; /** * Cleans up all event listeners attached. */ type Cleanup = () => void; /** * Tracks the current page and all further pages automatically. * * Call this if you don't want to manually manage pageview tracking. * * ### Example * ```js * import Plausible from 'plausible-tracker' * * const { enableAutoPageviews } = Plausible() * * // This tracks the current page view and all future ones as well * enableAutoPageviews() * ``` * * The returned value is a callback that removes the added event listeners and restores `history.pushState` * ```js * import Plausible from 'plausible-tracker' * * const { enableAutoPageviews } = Plausible() * * const cleanup = enableAutoPageviews() * * // Remove event listeners and restore `history.pushState` * cleanup() * ``` */ type EnableAutoPageviews = () => Cleanup; /** * Tracks all outbound link clicks automatically * * Call this if you don't want to manually manage these links. * * 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. * * Optionally takes the same parameters as [`MutationObserver.observe`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe). * * ### Example * ```js * import Plausible from 'plausible-tracker' * * const { enableAutoOutboundTracking } = Plausible() * * // This tracks all the existing and future outbound links on your page. * enableAutoOutboundTracking() * ``` * * The returned value is a callback that removes the added event listeners and disconnects the observer * ```js * import Plausible from 'plausible-tracker' * * const { enableAutoOutboundTracking } = Plausible() * * const cleanup = enableAutoOutboundTracking() * * // Remove event listeners and disconnect the observer * cleanup() * ``` */ type EnableAutoOutboundTracking = ( targetNode?: Node & ParentNode, observerInit?: MutationObserverInit, ) => Cleanup; /** * Initializes the tracker with your default values. * * ### Example (es module) * ```js * import Plausible from 'plausible-tracker' * * const { enableAutoPageviews, trackEvent } = Plausible({ * domain: 'my-app-domain.com', * hashMode: true * }) * * enableAutoPageviews() * * function onUserRegister() { * trackEvent('register') * } * ``` * * ### Example (commonjs) * ```js * var Plausible = require('plausible-tracker'); * * var { enableAutoPageviews, trackEvent } = Plausible({ * domain: 'my-app-domain.com', * hashMode: true * }) * * enableAutoPageviews() * * function onUserRegister() { * trackEvent('register') * } * ``` * * @param defaults - Default event parameters that will be applied to all requests. */ declare global { namespace App { // interface Error {} // interface Locals {} // interface PageData {} // interface PageState {} // interface Platform {} } } declare module "svelte/elements" { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface HTMLAttributes { [key: `event-${string}`]: string | undefined | null; } } export {}; ================================================ FILE: src/app.html ================================================ %sveltekit.head%
%sveltekit.body%
================================================ FILE: src/hooks.server.ts ================================================ import type { Handle } from "@sveltejs/kit"; import { paraglideMiddleware } from "$lib/paraglide/server"; // creating a handle to use the paraglide middleware const paraglideHandle: Handle = ({ event, resolve }) => paraglideMiddleware( event.request, ({ request: localizedRequest, locale }) => { event.request = localizedRequest; return resolve(event, { transformPageChunk: ({ html }) => { return html.replace("%lang%", locale); }, }); }, ); export const handle: Handle = paraglideHandle; ================================================ FILE: src/hooks.ts ================================================ import type { Reroute } from "@sveltejs/kit"; import { deLocalizeUrl } from "$lib/paraglide/runtime"; export const reroute: Reroute = (request) => { return deLocalizeUrl(request.url).pathname; }; ================================================ FILE: src/lib/assets/style/host-grotesk.css ================================================ @font-face { font-family: "Host Grotesk"; font-style: normal; font-weight: 400; src: url("$lib/assets/font/HostGrotesk-Regular.woff2") format("woff2"); } @font-face { font-family: "Host Grotesk"; font-style: italic; font-weight: 400; src: url("$lib/assets/font/HostGrotesk-Italic.woff2") format("woff2"); } @font-face { font-family: "Host Grotesk"; font-style: normal; font-weight: 500; src: url("$lib/assets/font/HostGrotesk-Medium.woff2") format("woff2"); } @font-face { font-family: "Host Grotesk"; font-style: italic; font-weight: 500; src: url("$lib/assets/font/HostGrotesk-MediumItalic.woff2") format("woff2"); } @font-face { font-family: "Host Grotesk"; font-style: normal; font-weight: 600; src: url("$lib/assets/font/HostGrotesk-SemiBold.woff2") format("woff2"); } @font-face { font-family: "Host Grotesk"; font-style: italic; font-weight: 600; src: url("$lib/assets/font/HostGrotesk-SemiBoldItalic.woff2") format("woff2"); } ================================================ FILE: src/lib/components/functional/ConversionPanel.svelte ================================================
{#if $isMobile} {:else} {/if}

{m["convert.panel.set_all_to"]()}

{#if files.files.length > 0 && files.files.every((f) => f.converters.length) && files.files.every((f) => JSON.stringify(f.converters) === JSON.stringify(files.files[0].converters))} files.files.forEach((f) => { f.to = r; f.result = null; })} {categories} dropdownSize={"large"} /> {:else} {/if}
{#if files.files.length > 50}
{progress}/{length}
{/if}
================================================ FILE: src/lib/components/functional/Dialog.svelte ================================================

{title}

{#if typeof message === "string"}

{message}

{:else} {@const MessageComponent = message}
{/if}
{#each buttons as { text, action }, i} {/each}
================================================ FILE: src/lib/components/functional/Dropdown.svelte ================================================
{#if open}
{#each options as option} {/each}
{/if}
================================================ FILE: src/lib/components/functional/FancyInput.svelte ================================================
{#if prefix}
{prefix}
{/if} {#if extension}
{extension}
{/if}
================================================ FILE: src/lib/components/functional/FancyMenu.svelte ================================================ ================================================ FILE: src/lib/components/functional/FormatDropdown.svelte ================================================
{#if open}
{}} id="format-search" autocomplete="off" /> {#if searchQuery} {filteredData.formats.length} {filteredData.formats.length === 1 ? "result" : "results"} {/if}
{#each filteredData.categories as category} {/each}
{#if filteredData.formats.length > 0} {#each filteredData.formats as format} {/each} {:else}
{searchQuery ? m["convert.dropdown.no_results"]() : m["convert.dropdown.no_formats"]()}
{/if}
{#if file?.name.toLowerCase().endsWith(".zip")}
{/if}
{/if}
================================================ FILE: src/lib/components/functional/Uploader.svelte ================================================ ================================================ FILE: src/lib/components/functional/VertdError.svelte ================================================

{m["convert.errors.vertd_generic_body"]()}

================================================ FILE: src/lib/components/functional/VertdErrorDetails.svelte ================================================

{@html sanitize(m["convert.errors.vertd_details_body"]())}

{@html sanitize(m["convert.errors.vertd_details_job_id"]({ jobId: additional.jobId, }))}

{@html sanitize(m["convert.errors.vertd_details_from"]({ from: additional.from, }))}

{@html sanitize(m["convert.errors.vertd_details_to"]({ to: additional.to }))}

{@html sanitize(link( ["view_link"], m["convert.errors.vertd_details_error_message"](), [ URL.createObjectURL( new Blob([additional.errorMessage], { type: "text/plain", }), ), ], [true], ["text-blue-500 font-normal"], ))}

{@html sanitize(link( ["privacy_link"], m["convert.errors.vertd_details_footer"](), "/privacy", [true], ))}

================================================ FILE: src/lib/components/layout/Dialogs.svelte ================================================ {#if dialogList.length > 0}
{#each dialogList as dialog, i} {#if i === 0} {/if} {/each}
{/if} ================================================ FILE: src/lib/components/layout/Footer.svelte ================================================ ================================================ FILE: src/lib/components/layout/Gradients.svelte ================================================ {#if page.url.pathname === "/"}
{/if}
{#if page.url.pathname === "/convert/" && files.files.length === 1} {@const bgMask = "linear-gradient(to top, transparent 5%, rgba(0, 0, 0, 0.5) 100%)"}
{/if} ================================================ FILE: src/lib/components/layout/MobileLogo.svelte ================================================ ================================================ FILE: src/lib/components/layout/Navbar/Base.svelte ================================================ {#snippet link(item: (typeof items)[0], index: number)} {@const Icon = item.icon}
{#key item.name}
{#if item.badge}
{#key item.badge}
{item.badge}
{/key}
{/if}
{/key}
{/snippet}
{@const linkRect = linkRects.at(selectedIndex) || linkRects[0]} {#if linkRect && isInitialized}
{/if} {#each items as item, i (item.url)} {@render link(item, i)} {/each}
================================================ FILE: src/lib/components/layout/Navbar/Desktop.svelte ================================================ ================================================ FILE: src/lib/components/layout/Navbar/Mobile.svelte ================================================
================================================ FILE: src/lib/components/layout/Navbar/index.ts ================================================ export { default as Desktop } from "./Desktop.svelte"; export { default as Mobile } from "./Mobile.svelte"; ================================================ FILE: src/lib/components/layout/PageContent.svelte ================================================
{#key page.url.pathname}
{@render children()}
{/key}
================================================ FILE: src/lib/components/layout/Toasts.svelte ================================================
{#each ToastManager.toasts as toast (toast.id)}
{/each}
================================================ FILE: src/lib/components/layout/UploadRegion.svelte ================================================ {#if $dropping}
{/if} ================================================ FILE: src/lib/components/layout/index.ts ================================================ export { default as UploadRegion } from "./UploadRegion.svelte"; export { default as Gradients } from "./Gradients.svelte"; export { default as Toasts } from "./Toasts.svelte"; export { default as Dialogs } from "./Dialogs.svelte"; export { default as PageContent } from "./PageContent.svelte"; export { default as MobileLogo } from "./MobileLogo.svelte"; export { default as Footer } from "./Footer.svelte"; ================================================ FILE: src/lib/components/visual/Panel.svelte ================================================
{@render children?.()}
================================================ FILE: src/lib/components/visual/ProgressBar.svelte ================================================
================================================ FILE: src/lib/components/visual/Toast.svelte ================================================

{title || message}

{#if typeof message !== "string"} {@const MessageComponent = message}
{/if}
================================================ FILE: src/lib/components/visual/Tooltip.svelte ================================================ {@render children()} {#if showTooltip} {text} {/if} ================================================ FILE: src/lib/components/visual/effects/ProgressiveBlur.svelte ================================================
{#each blurSteps as { blurIntensity, mask }, index}
{/each}
================================================ FILE: src/lib/components/visual/svg/Logo.svelte ================================================ ================================================ FILE: src/lib/components/visual/svg/LogoBeta.svelte ================================================ ================================================ FILE: src/lib/components/visual/svg/VertVBig.svelte ================================================ ================================================ FILE: src/lib/converters/converter.svelte.ts ================================================ import type { VertFile } from "$lib/types"; export type WorkerStatus = "not-ready" | "downloading" | "ready" | "error"; export class FormatInfo { public name: string; constructor( name: string, public fromSupported = true, public toSupported = true, public isNative = true, ) { this.name = name; if (!this.name.startsWith(".")) { this.name = `.${this.name}`; } if (!this.fromSupported && !this.toSupported) { throw new Error("Format must support at least one direction"); } } } /** * Base class for all converters. */ export class Converter { /** * The public name of the converter. */ public name: string = "Unknown"; /** * List of supported formats. */ public supportedFormats: FormatInfo[] = []; public status: WorkerStatus = $state("not-ready"); public readonly reportsProgress: boolean = false; private timeoutId?: NodeJS.Timeout; constructor(public readonly timeout: number = 10) { this.startTimeout(); } private startTimeout() { this.timeoutId = setTimeout(() => { if (this.status !== "ready") this.status = "not-ready"; }, this.timeout * 1000); } protected clearTimeout() { if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = undefined; } } /** * Convert a file to a different format. * @param input The input file. * @param to The format to convert to. Includes the dot. */ public async convert( // eslint-disable-next-line @typescript-eslint/no-unused-vars input: VertFile, // eslint-disable-next-line @typescript-eslint/no-unused-vars to: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars ...args: any[] ): Promise { throw new Error("Not implemented"); } /** * Cancel the active conversion of a file. * @param input The input file. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars public async cancel(input: VertFile): Promise { throw new Error("Not implemented"); } public async valid(): Promise { return true; } public formatStrings(predicate?: (f: FormatInfo) => boolean) { if (predicate) { return this.supportedFormats.filter(predicate).map((f) => f.name); } return this.supportedFormats.map((f) => f.name); } } ================================================ FILE: src/lib/converters/ffmpeg.svelte.ts ================================================ import { VertFile } from "$lib/types"; import { Converter, FormatInfo } from "./converter.svelte"; import { FFmpeg } from "@ffmpeg/ffmpeg"; import { browser } from "$app/environment"; import { error, log } from "$lib/util/logger"; import { m } from "$lib/paraglide/messages"; import { Settings } from "$lib/sections/settings/index.svelte"; import { ToastManager } from "$lib/util/toast.svelte"; // TODO: differentiate in UI? (not native formats) const videoFormats = [ "mkv", "mp4", "avi", "mov", "webm", "ts", "mts", "m2ts", "wmv", "mpg", "mpeg", "flv", "f4v", "vob", "m4v", "3gp", "3g2", "mxf", "ogv", "rm", "rmvb", "divx", ]; export class FFmpegConverter extends Converter { private ffmpeg: FFmpeg = null!; public name = "ffmpeg"; public ready = $state(false); private activeConversions = new Map(); public supportedFormats = [ new FormatInfo("mp3", true, true), new FormatInfo("wav", true, true), new FormatInfo("flac", true, true), new FormatInfo("ogg", true, true), new FormatInfo("mogg", true, false), new FormatInfo("oga", true, true), new FormatInfo("opus", true, true), new FormatInfo("aac", true, true), new FormatInfo("alac", true, true), // outputted as m4a new FormatInfo("m4a", true, true), // can be alac new FormatInfo("caf", true, false), // can be alac new FormatInfo("wma", true, true), new FormatInfo("amr", true, true), new FormatInfo("ac3", true, true), new FormatInfo("aiff", true, true), new FormatInfo("aifc", true, true), new FormatInfo("aif", true, true), new FormatInfo("mp1", true, false), new FormatInfo("mp2", true, true), new FormatInfo("mpc", true, false), // unknown if it works, can't find sample file but ffmpeg should support i think? //new FormatInfo("raw", true, false), // usually pcm new FormatInfo("dsd", true, false), // dsd new FormatInfo("dsf", true, false), // dsd new FormatInfo("dff", true, false), // dsd new FormatInfo("mqa", true, false), new FormatInfo("au", true, true), new FormatInfo("m4b", true, true), new FormatInfo("voc", true, true), new FormatInfo("weba", true, true), ...videoFormats.map((f) => new FormatInfo(f, true, true, false)), ]; public readonly reportsProgress = true; constructor() { super(); log(["converters", this.name], `created converter`); if (!browser) return; try { // this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance this.ffmpeg = new FFmpeg(); (async () => { const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm"; this.status = "downloading"; await this.ffmpeg.load({ coreURL: `${baseURL}/ffmpeg-core.js`, wasmURL: `${baseURL}/ffmpeg-core.wasm`, }); this.status = "ready"; })(); } catch (err) { error(["converters", this.name], `Error loading ffmpeg: ${err}`); this.status = "error"; ToastManager.add({ type: "error", message: m["workers.errors.ffmpeg"](), }); } } public async convert(input: VertFile, to: string): Promise { if (!to.startsWith(".")) to = `.${to}`; const isAlac = to === ".alac"; if (isAlac) to = ".m4a"; let conversionError: string | null = null; const ffmpeg = await this.setupFFmpeg(input); this.activeConversions.set(input.id, ffmpeg); // listen for errors during conversion const errorListener = (l: { message: string }) => { const msg = l.message; if ( msg.includes("Specified sample rate") && msg.includes("is not supported") ) { const rate = Settings.instance.settings.ffmpegCustomSampleRate; conversionError = m["workers.errors.invalid_rate"]({ rate, }); } else if (msg.includes("Stream map '0:a:0' matches no streams.")) { conversionError = m["workers.errors.no_audio"](); } else if ( msg.includes("Error initializing output stream") || msg.includes("Error while opening encoder") || msg.includes("Error while opening decoder") || (msg.includes("Error") && msg.includes("stream")) || msg.includes("Conversion failed!") ) { // other general errors if (!conversionError) conversionError = msg; } }; ffmpeg.on("log", errorListener); const buf = new Uint8Array(await input.file.arrayBuffer()); await ffmpeg.writeFile("input", buf); log( ["converters", this.name], `wrote ${input.name} to ffmpeg virtual fs`, ); const command = await this.buildConversionCommand( ffmpeg, input, to, isAlac, ); log(["converters", this.name], `FFmpeg command: ${command.join(" ")}`); await ffmpeg.exec(command); log(["converters", this.name], "executed ffmpeg command"); if (conversionError) { ffmpeg.off("log", errorListener); ffmpeg.terminate(); throw new Error(conversionError); } const output = (await ffmpeg.readFile( "output" + to, )) as unknown as Uint8Array; if (!output || output.length === 0) { ffmpeg.off("log", errorListener); ffmpeg.terminate(); throw new Error("empty file returned"); } const outputFileName = input.name.split(".").slice(0, -1).join(".") + to; log( ["converters", this.name], `read ${outputFileName} from ffmpeg virtual fs`, ); ffmpeg.off("log", errorListener); ffmpeg.terminate(); const outBuf = new Uint8Array(output).buffer.slice(0); return new VertFile(new File([outBuf], outputFileName), to); } public async cancel(input: VertFile): Promise { const ffmpeg = this.activeConversions.get(input.id); if (!ffmpeg) { error( ["converters", this.name], `no active conversion found for file ${input.name}`, ); return; } log( ["converters", this.name], `cancelling conversion for file ${input.name}`, ); ffmpeg.terminate(); this.activeConversions.delete(input.id); } private async setupFFmpeg(input: VertFile): Promise { const ffmpeg = new FFmpeg(); ffmpeg.on("progress", (progress) => { input.progress = progress.progress * 100; }); ffmpeg.on("log", (l) => { log(["converters", this.name], l.message); }); const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm"; await ffmpeg.load({ coreURL: `${baseURL}/ffmpeg-core.js`, wasmURL: `${baseURL}/ffmpeg-core.wasm`, }); return ffmpeg; } private async detectAudioBitrate(ffmpeg: FFmpeg): Promise { const args = [ "-v", "quiet", "-select_streams", "a:0", "-show_entries", "stream=bit_rate", "-of", "default=noprint_wrappers=1:nokey=1", "input", ]; try { let bitrate: number | null = null; const bitrateListener = (event: { message: string }) => { if (bitrate !== null) return; const n = parseInt(event.message.trim(), 10); if (!n) return; bitrate = Math.round(n / 1000); log( ["converters", this.name], `Detected stream audio bitrate: ${bitrate} kbps`, ); }; ffmpeg.on("log", bitrateListener); try { await ffmpeg.ffprobe.call(ffmpeg, args); return bitrate; } finally { ffmpeg.off("log", bitrateListener); } } catch { return null; } } private async detectAudioSampleRate( ffmpeg: FFmpeg, ): Promise { const args = [ "-v", "quiet", "-select_streams", "a:0", "-show_entries", "stream=sample_rate", "-of", "default=noprint_wrappers=1:nokey=1", "input", ]; try { let sampleRate: number | null = null; const sampleRateListener = (event: { message: string }) => { if (sampleRate !== null) return; const n = parseInt(event.message.trim(), 10); if (!n) return; sampleRate = n; log( ["converters", this.name], `Detected stream audio sample rate: ${sampleRate} Hz`, ); }; ffmpeg.on("log", sampleRateListener); try { await ffmpeg.ffprobe.call(ffmpeg, args); return sampleRate; } finally { ffmpeg.off("log", sampleRateListener); } } catch { return null; } } private async buildConversionCommand( ffmpeg: FFmpeg, input: VertFile, to: string, isAlac: boolean = false, ): Promise { const inputFormat = input.from.slice(1); const outputFormat = to.slice(1); const m4a = isAlac || to === ".m4a"; const lossless = [ "flac", "m4a", "caf", "alac", "wav", "dsd", "dsf", "dff", ]; const userSetting = Settings.instance.settings.ffmpegQuality; const userSampleRate = Settings.instance.settings.ffmpegSampleRate; const customSampleRate = Settings.instance.settings.ffmpegCustomSampleRate ?? 44100; const keepMetadata = Settings.instance.settings.metadata; let audioBitrateArgs: string[] = []; let sampleRateArgs: string[] = []; let metadataArgs: string[] = []; let m4aArgs: string[] = []; log(["converters", this.name], `keep metadata: ${keepMetadata}`); if (!keepMetadata) { metadataArgs = [ "-map_metadata", // remove metadata "-1", "-map_chapters", // remove chapters "-1", "-map", // remove cover art "a", ]; } const isLosslessToLossy = lossless.includes(inputFormat) && !lossless.includes(outputFormat); if (userSetting !== "auto") { // user's setting audioBitrateArgs = ["-b:a", `${userSetting}k`]; log( ["converters", this.name], `using user setting for audio bitrate: ${userSetting}`, ); } else { // detect bitrate of original file and use if (isLosslessToLossy) { // use safe default audioBitrateArgs = ["-b:a", "128k"]; log( ["converters", this.name], `converting from lossless to lossy, using default audio bitrate: 128k`, ); } else { const inputBitrate = await this.detectAudioBitrate(ffmpeg); audioBitrateArgs = inputBitrate ? ["-b:a", `${inputBitrate}k`] : []; log( ["converters", this.name], `using detected audio bitrate: ${inputBitrate}k`, ); } } // sample rate setting if (userSampleRate !== "auto") { const rate = userSampleRate === "custom" ? customSampleRate.toString() : userSampleRate; sampleRateArgs = ["-ar", rate]; log( ["converters", this.name], `using user setting for sample rate: ${rate}`, ); } else { // detect sample rate of original file and use if (isLosslessToLossy) { // use safe default const defaultRate = to === ".opus" ? "48000" : "44100"; log( ["converters", this.name], `converting from lossless to lossy, using default sample rate: ${defaultRate}Hz`, ); sampleRateArgs = ["-ar", defaultRate]; } else { let inputSampleRate = await this.detectAudioSampleRate(ffmpeg); if (to === ".opus" && inputSampleRate === 44100) { // special case: opus does not support 44100Hz which is more common - adjust to 48000Hz log( ["converters", this.name], "conversion to opus with 44100Hz sample rate detected, adjusting to 48000Hz", ); inputSampleRate = 48000; } sampleRateArgs = inputSampleRate ? ["-ar", inputSampleRate.toString()] : []; log( ["converters", this.name], `using detected audio sample rate: ${inputSampleRate}Hz`, ); } } // video to audio if (videoFormats.includes(inputFormat)) { log( ["converters", this.name], `Converting video ${input.from} to audio ${to}`, ); return [ "-i", "input", "-map", "0:a:0", ...metadataArgs, ...audioBitrateArgs, ...sampleRateArgs, "output" + to, ]; } // audio to video if (videoFormats.includes(outputFormat)) { log( ["converters", this.name], `Converting audio ${input.from} to video ${to}`, ); const hasAlbumArt = keepMetadata ? await this.extractAlbumArt(ffmpeg) : false; const codecArgs = toArgs(to, isAlac); if (hasAlbumArt) { log( ["converters", this.name], "Using album art as video background", ); return [ "-loop", "1", "-i", "cover.jpg", "-i", "input", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-shortest", "-pix_fmt", "yuv420p", "-r", "1", ...codecArgs, ...metadataArgs, ...audioBitrateArgs, ...sampleRateArgs, "output" + to, ]; } else { log(["converters", this.name], "Using solid color background"); return [ "-f", "lavfi", "-i", "color=c=black:s=512x512:rate=1", "-i", "input", "-shortest", "-pix_fmt", "yuv420p", "-r", "1", ...toArgs(to, isAlac), ...metadataArgs, ...audioBitrateArgs, ...sampleRateArgs, "output" + to, ]; } } // audio to audio log( ["converters", this.name], `Converting audio ${input.from} to audio ${to}`, ); const { audio: audioCodec } = getCodecs(to, isAlac); if (m4a && keepMetadata) m4aArgs = ["-c:v", "copy"]; // for album art return [ "-i", "input", ...m4aArgs, "-c:a", audioCodec, ...metadataArgs, ...audioBitrateArgs, ...sampleRateArgs, "output" + to, ]; } private async extractAlbumArt(ffmpeg: FFmpeg): Promise { // extract using stream mapping (should work for most) if ( await this.tryExtractAlbumArt(ffmpeg, [ "-i", "input", "-map", "0:1", "-c:v", "copy", "-update", "1", "cover.jpg", ]) ) { log( ["converters", this.name], "Successfully extracted album art from stream 0:1", ); return true; } // fallback: extract without stream mapping (this probably won't happen) if ( await this.tryExtractAlbumArt(ffmpeg, [ "-i", "input", "-an", "-c:v", "copy", "-update", "1", "cover.jpg", ]) ) { log( ["converters", this.name], "Successfully extracted album art (fallback method)", ); return true; } log( ["converters", this.name], "No album art found, will create solid color background", ); return false; } private async tryExtractAlbumArt( ffmpeg: FFmpeg, command: string[], ): Promise { try { await ffmpeg.exec(command); const coverData = await ffmpeg.readFile("cover.jpg"); return !!(coverData && (coverData as Uint8Array).length > 0); } catch { return false; } } } // and here i was, thinking i'd be done with ffmpeg after finishing vertd // but OH NO we just HAD to have someone suggest to allow album art video generation. // // i hate you SO much. // - love, maddie const toArgs = (ext: string, isAlac: boolean = false): string[] => { const codecs = getCodecs(ext, isAlac); const args = ["-c:v", codecs.video]; switch (codecs.video) { case "libx264": { args.push( "-preset", "ultrafast", "-crf", "18", "-tune", "stillimage", ); break; } case "libvpx": { args.push("-c:v", "libvpx-vp9"); break; } case "mpeg2video": { // for mpeg, mpg, vob, mxf if (ext === ".mxf") args.push("-ar", "48000"); // force 48kHz sample rate break; } } args.push("-c:a", codecs.audio); if (codecs.audio === "aac") args.push("-strict", "experimental"); if (ext === ".divx") args.unshift("-f", "avi"); if (ext === ".mxf") args.push("-strict", "unofficial"); return args; }; const getCodecs = ( ext: string, isAlac: boolean = false, ): { video: string; audio: string } => { switch (ext) { // video <-> audio case ".mp4": case ".mkv": case ".mov": case ".mts": case ".ts": case ".m2ts": case ".flv": case ".f4v": case ".m4v": case ".3gp": case ".3g2": return { video: "libx264", audio: "aac" }; case ".wmv": return { video: "wmv2", audio: "wmav2" }; case ".webm": case ".ogv": return { video: ext === ".webm" ? "libvpx" : "libtheora", audio: "libvorbis", }; case ".avi": case ".divx": return { video: "mpeg4", audio: "libmp3lame" }; case ".mpg": case ".mpeg": case ".vob": return { video: "mpeg2video", audio: "mp2" }; case ".mxf": return { video: "mpeg2video", audio: "pcm_s16le" }; // audio case ".mp3": return { video: "libx264", audio: "libmp3lame" }; case ".flac": return { video: "libx264", audio: "flac" }; case ".wav": return { video: "libx264", audio: "pcm_s16le" }; case ".ogg": case ".oga": return { video: "libx264", audio: "libvorbis" }; case ".opus": return { video: "libx264", audio: "libopus" }; case ".aac": return { video: "libx264", audio: "aac" }; case ".m4a": return { video: "libx264", audio: isAlac ? "alac" : "aac", }; case ".alac": return { video: "libx264", audio: "alac" }; case ".wma": return { video: "libx264", audio: "wmav2" }; default: return { video: "libx264", audio: "aac" }; } }; export const CONVERSION_BITRATES = [ "auto", 320, 256, 192, 128, 96, 64, 32, ] as const; export type ConversionBitrate = (typeof CONVERSION_BITRATES)[number]; export const SAMPLE_RATES = [ "auto", "custom", "48000", "44100", "32000", "22050", "16000", "11025", "8000", ] as const; export type SampleRate = (typeof SAMPLE_RATES)[number]; ================================================ FILE: src/lib/converters/index.ts ================================================ import type { Categories } from "$lib/types"; import type { Converter } from "./converter.svelte"; import { FFmpegConverter } from "./ffmpeg.svelte"; import { PandocConverter } from "./pandoc.svelte"; import { VertdConverter } from "./vertd.svelte"; import { MagickConverter } from "./magick.svelte"; import { DISABLE_ALL_EXTERNAL_REQUESTS } from "$lib/util/consts"; const getConverters = (): Converter[] => { const converters: Converter[] = [ new MagickConverter(), new FFmpegConverter(), ]; if (!DISABLE_ALL_EXTERNAL_REQUESTS) { converters.push(new VertdConverter()); } converters.push(new PandocConverter()); return converters; }; export const converters = getConverters(); export function getConverterByFormat(format: string) { for (const converter of converters) { if (converter.supportedFormats.some((f) => f.name === format)) { return converter; } } return null; } export const categories: Categories = { image: { formats: [""], canConvertTo: [] }, video: { formats: [""], canConvertTo: ["audio"] }, audio: { formats: [""], canConvertTo: ["video"] }, doc: { formats: [""], canConvertTo: [] }, }; categories.audio.formats = converters .find((c) => c.name === "ffmpeg") ?.supportedFormats.filter((f) => f.toSupported && f.isNative) .map((f) => f.name) || []; categories.video.formats = converters .find((c) => c.name === "vertd") ?.supportedFormats.filter((f) => f.toSupported && f.isNative) .map((f) => f.name) || []; categories.image.formats = converters .find((c) => c.name === "imagemagick") ?.formatStrings((f) => f.toSupported) || []; categories.doc.formats = converters .find((c) => c.name === "pandoc") ?.supportedFormats.filter((f) => f.toSupported && f.isNative) .map((f) => f.name) || []; export const byNative = (format: string) => { return (a: Converter, b: Converter) => { const aFormat = a.supportedFormats.find((f) => f.name === format); const bFormat = b.supportedFormats.find((f) => f.name === format); if (aFormat && bFormat) { return aFormat.isNative ? -1 : 1; } return 0; }; }; ================================================ FILE: src/lib/converters/magick-automated.ts ================================================ import { FormatInfo } from "./converter.svelte"; // formats added from maya's somewhat automated testing // placed into this file to easily differentiate (and also clean up the main magick file) // some formats also have a comment from what i saw during testing export const imageFormats = [ new FormatInfo("a", false, true), new FormatInfo("aai", true, true), new FormatInfo("ai", false, true), new FormatInfo("art", false, true), new FormatInfo("avs", true, true), new FormatInfo("b", false, true), new FormatInfo("bgr", false, true), new FormatInfo("bgra", false, true), new FormatInfo("bgro", false, true), new FormatInfo("bmp2", true, true), new FormatInfo("bmp3", true, true), new FormatInfo("brf", false, true), new FormatInfo("cal", false, true), new FormatInfo("cals", false, true), new FormatInfo("cin", true, true), // not ideal (made the image more "shadowy"?) new FormatInfo("cip", false, true), new FormatInfo("cmyk", false, true), new FormatInfo("cmyka", false, true), new FormatInfo("dcx", true, true), new FormatInfo("dds", true, true), new FormatInfo("dpx", true, true), new FormatInfo("dxt1", true, true), new FormatInfo("dxt5", true, true), new FormatInfo("epdf", false, true), new FormatInfo("epi", false, true), new FormatInfo("eps2", false, true), new FormatInfo("eps3", false, true), new FormatInfo("epsf", false, true), new FormatInfo("epsi", false, true), new FormatInfo("ept", false, true), new FormatInfo("ept2", false, true), new FormatInfo("ept3", false, true), new FormatInfo("exr", true, true), new FormatInfo("farbfeld", true, true), new FormatInfo("fax", true, true), // not ideal (image became super long for some reason) new FormatInfo("ff", true, true), new FormatInfo("fit", true, true), // not ideal (grayscale) new FormatInfo("fits", true, true), // not ideal (grayscale) new FormatInfo("fl32", true, true), new FormatInfo("fts", true, true), // not ideal (grayscale) new FormatInfo("ftxt", false, true), new FormatInfo("g", false, true), new FormatInfo("g3", true, true), // not ideal (image became super long for some reason) new FormatInfo("g4", false, true), new FormatInfo("gif87", true, true), new FormatInfo("gray", false, true), new FormatInfo("graya", false, true), new FormatInfo("group4", false, true), new FormatInfo("hrz", true, true), new FormatInfo("icb", true, true), new FormatInfo("icon", true, true), new FormatInfo("info", false, true), new FormatInfo("ipl", true, true), new FormatInfo("isobrl", false, true), new FormatInfo("isobrl6", false, true), new FormatInfo("j2c", true, true), new FormatInfo("j2k", true, true), new FormatInfo("jng", true, true), new FormatInfo("jp2", true, true), new FormatInfo("jpc", true, true), new FormatInfo("jpm", true, true), new FormatInfo("jps", true, true), //new FormatInfo("json", false, true), new FormatInfo("map", false, true), new FormatInfo("miff", true, true), new FormatInfo("mng", true, true), new FormatInfo("mono", false, true), new FormatInfo("mtv", true, true), new FormatInfo("o", false, true), new FormatInfo("otb", true, true), // not ideal (completely black and white - maybe format is like that) new FormatInfo("pal", false, true), new FormatInfo("palm", true, true), // not ideal (screwed up colours) new FormatInfo("pam", true, true), new FormatInfo("pcd", true, true), // not ideal (turned big, bg orange, and colour just shifted? - maybe format) new FormatInfo("pcds", true, true), // not ideal (turned big, bg orange, and colour just shifted? - maybe format) new FormatInfo("pcl", false, true), new FormatInfo("pct", true, true), new FormatInfo("pcx", true, true), new FormatInfo("pdb", true, true), // not ideal (completely black and white - maybe format is like that) // new FormatInfo("pdf", false, true), // new FormatInfo("pdfa", false, true), new FormatInfo("pgx", true, true), // not ideal (grayscale - maybe format is like that) new FormatInfo("phm", true, true), new FormatInfo("picon", true, true), // not ideal (smudged out colours - format probably) new FormatInfo("pict", true, true), new FormatInfo("pjpeg", true, true), new FormatInfo("png00", true, true), new FormatInfo("png24", true, true), new FormatInfo("png32", true, true), new FormatInfo("png48", true, true), new FormatInfo("png64", true, true), new FormatInfo("png8", true, true), new FormatInfo("ps", false, true), new FormatInfo("ps1", false, true), new FormatInfo("ps2", false, true), new FormatInfo("ps3", false, true), new FormatInfo("psb", true, true), new FormatInfo("ptif", true, true), new FormatInfo("qoi", true, true), new FormatInfo("r", false, true), new FormatInfo("ras", true, true), new FormatInfo("rgb", false, true), new FormatInfo("rgba", false, true), new FormatInfo("rgbo", false, true), new FormatInfo("rgf", true, true), // not ideal (completely black and white - maybe format is like that) new FormatInfo("sgi", true, true), new FormatInfo("six", true, true), new FormatInfo("sixel", true, true), new FormatInfo("sparse-color", false, true), new FormatInfo("strimg", false, true), new FormatInfo("sun", true, true), new FormatInfo("svgz", false, true), new FormatInfo("tga", true, true), new FormatInfo("tiff64", true, true), //new FormatInfo("txt", true, true), new FormatInfo("ubrl", false, true), new FormatInfo("ubrl6", false, true), new FormatInfo("uil", false, true), new FormatInfo("uyvy", false, true), new FormatInfo("vda", true, true), new FormatInfo("vicar", true, true), // not ideal (grayscale - maybe format is like that) new FormatInfo("viff", true, true), new FormatInfo("vips", true, true), new FormatInfo("vst", true, true), new FormatInfo("wbmp", true, true), // not ideal (completely black and white - maybe format is like that) new FormatInfo("wpg", true, true), new FormatInfo("xbm", true, true), // not ideal (completely black and white - maybe format is like that) new FormatInfo("xpm", true, true), new FormatInfo("xv", true, true), //new FormatInfo("yaml", false, true), new FormatInfo("ycbcr", false, true), new FormatInfo("ycbcra", false, true), new FormatInfo("yuv", false, true), ]; ================================================ FILE: src/lib/converters/magick.svelte.ts ================================================ import { browser } from "$app/environment"; import { error, log } from "$lib/util/logger"; import { m } from "$lib/paraglide/messages"; import { VertFile, type WorkerMessage } from "$lib/types"; import MagickWorker from "$lib/workers/magick?worker&url"; import { Converter, FormatInfo } from "./converter.svelte"; import { imageFormats } from "./magick-automated"; import { Settings } from "$lib/sections/settings/index.svelte"; import magickWasm from "@imagemagick/magick-wasm/magick.wasm?url"; import { ToastManager } from "$lib/util/toast.svelte"; export class MagickConverter extends Converter { public name = "imagemagick"; public ready = $state(false); public wasm: ArrayBuffer = null!; private activeConversions = new Map(); public supportedFormats = [ // manually tested formats new FormatInfo("png", true, true), new FormatInfo("jpeg", true, true), new FormatInfo("jpg", true, true), new FormatInfo("webp", true, true), new FormatInfo("gif", true, true), new FormatInfo("svg", true, true), new FormatInfo("jxl", true, true), new FormatInfo("avif", true, true), new FormatInfo("heic", true, false), // seems to be unreliable? HEIC/HEIF is very weird if it will actually work new FormatInfo("heif", true, false), // TODO: .ico files can encode multiple images at various // sizes, bitdepths, etc. we should support that in future new FormatInfo("ico", true, true), new FormatInfo("bmp", true, true), new FormatInfo("cur", true, true), new FormatInfo("ani", true, false), new FormatInfo("icns", true, false), new FormatInfo("nef", true, false), new FormatInfo("cr2", true, false), new FormatInfo("hdr", true, true), new FormatInfo("jpe", true, true), new FormatInfo("mat", true, true), new FormatInfo("pbm", true, true), new FormatInfo("pfm", true, true), new FormatInfo("pgm", true, true), new FormatInfo("pnm", true, true), new FormatInfo("ppm", true, true), new FormatInfo("tiff", true, true), new FormatInfo("jfif", true, true), new FormatInfo("eps", false, true), new FormatInfo("psd", true, true), // raw camera formats new FormatInfo("arw", true, false), new FormatInfo("tif", true, true), new FormatInfo("dng", true, false), new FormatInfo("xcf", true, false), new FormatInfo("rw2", true, false), new FormatInfo("raf", true, false), new FormatInfo("orf", true, false), new FormatInfo("pef", true, false), new FormatInfo("mos", true, false), new FormatInfo("raw", true, false), new FormatInfo("dcr", true, false), new FormatInfo("crw", true, false), new FormatInfo("cr3", true, false), new FormatInfo("3fr", true, false), new FormatInfo("erf", true, false), new FormatInfo("mrw", true, false), new FormatInfo("mef", true, false), new FormatInfo("nrw", true, false), new FormatInfo("srw", true, false), new FormatInfo("sr2", true, false), new FormatInfo("srf", true, false), // formats added from maya's somewhat automated testing ...imageFormats, ]; public readonly reportsProgress = false; constructor() { super(); log(["converters", this.name], `created converter`); if (!browser) return; this.initializeWasm(); } private async initializeWasm() { try { this.status = "downloading"; const response = await fetch(magickWasm); if (!response.ok) { throw new Error( `Failed to fetch WASM: ${response.status} ${response.statusText}`, ); } this.wasm = await response.arrayBuffer(); this.status = "ready"; } catch (err) { this.status = "error"; error( ["converters", this.name], `Failed to load ImageMagick WASM: ${err}`, ); ToastManager.add({ type: "error", message: m["workers.errors.magick"](), }); } } public async convert( input: VertFile, to: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: any[] ): Promise { let compression: number | undefined = args.at(0); if (!compression) { compression = Settings.instance.settings.magickQuality ?? 100; log( ["converters", this.name], `using user setting for quality: ${compression}%`, ); } log(["converters", this.name], `converting ${input.name} to ${to}`); // handle converting from SVG manually because magick-wasm doesn't support it if (input.from === ".svg") { try { const blob = await this.svgToImage(input); const pngFile = new VertFile( new File([blob], input.name.replace(/\.svg$/i, ".png")), input.to, ); if (to === ".png") return pngFile; // if target is png, return it directly return await this.convert(pngFile, to, ...args); // otherwise, recursively convert png to user's target format } catch (err) { error( ["converters", this.name], `SVG conversion failed: ${err}`, ); throw err; } } const worker = new Worker(MagickWorker, { type: "module", }); this.activeConversions.set(input.id, worker); try { await Promise.race([ this.waitForMessage(worker, "ready"), new Promise((_, reject) => setTimeout( () => reject( new Error( "Magick worker ready timeout after 10 seconds", ), ), 10000, ), ), ]); const loadMsg: WorkerMessage = { type: "load", wasm: this.wasm, id: input.id, }; worker.postMessage(loadMsg); await Promise.race([ this.waitForMessage(worker, "loaded"), new Promise((_, reject) => setTimeout( () => reject( new Error( "Magick worker initialization timeout after 30 seconds", ), ), 30000, ), ), ]); // every other format handled by magick worker const keepMetadata: boolean = Settings.instance.settings.metadata ?? true; log(["converters", this.name], `keep metadata: ${keepMetadata}`); const convertMsg: WorkerMessage = { type: "convert", id: input.id, input: { file: input.file, name: input.name, from: input.from, to: input.to, }, to, compression, keepMetadata, }; worker.postMessage(convertMsg); const res = await this.waitForMessage(worker); if (res.type === "finished") { log( ["converters", this.name], `converted ${input.name} to ${to}`, ); return new VertFile( new File([res.output as unknown as BlobPart], input.name), res.zip ? ".zip" : to, ); } if (res.type === "error") { throw new Error(res.error); } throw new Error("Unknown message type"); } finally { this.activeConversions.delete(input.id); worker.terminate(); } } public async cancel(input: VertFile): Promise { const worker = this.activeConversions.get(input.id); if (!worker) { error( ["converters", this.name], `no active conversion found for file ${input.name}`, ); return; } log( ["converters", this.name], `cancelling conversion for file ${input.name}`, ); worker.terminate(); this.activeConversions.delete(input.id); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private waitForMessage(worker: Worker, type?: string): Promise { return new Promise((resolve, reject) => { const onMessage = (e: MessageEvent) => { if (type && e.data.type === type) { worker.removeEventListener("message", onMessage); worker.removeEventListener("error", onError); resolve(e.data); } else if (!type) { worker.removeEventListener("message", onMessage); worker.removeEventListener("error", onError); resolve(e.data); } else if (e.data.type === "error") { worker.removeEventListener("message", onMessage); worker.removeEventListener("error", onError); reject(new Error(e.data.error)); } }; const onError = (e: ErrorEvent) => { worker.removeEventListener("message", onMessage); worker.removeEventListener("error", onError); reject(new Error(`Worker error: ${e.message}`)); }; worker.addEventListener("message", onMessage); worker.addEventListener("error", onError); }); } private async svgToImage(input: VertFile): Promise { log(["converters", this.name], `converting SVG to image (PNG)`); const svgText = await input.file.text(); const svgBlob = new Blob([svgText], { type: "image/svg+xml" }); const svgUrl = URL.createObjectURL(svgBlob); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Failed to get canvas context"); const img = new Image(); // try to extract dimensions from SVG, and if not fallback to default let width = 512; let height = 512; const widthMatch = svgText.match(/width=["'](\d+)["']/); const heightMatch = svgText.match(/height=["'](\d+)["']/); const viewBoxMatch = svgText.match( /viewBox=["'][^"']*\s+(\d+)\s+(\d+)["']/, ); if (widthMatch && heightMatch) { width = parseInt(widthMatch[1]); height = parseInt(heightMatch[1]); } else if (viewBoxMatch) { width = parseInt(viewBoxMatch[1]); height = parseInt(viewBoxMatch[2]); } return new Promise((resolve, reject) => { img.onload = () => { try { canvas.width = img.naturalWidth || width; canvas.height = img.naturalHeight || height; ctx.drawImage(img, 0, 0); canvas.toBlob((blob) => { URL.revokeObjectURL(svgUrl); if (blob) { resolve(blob); } else { reject( new Error("Failed to convert canvas to Blob"), ); } }, "image/png"); } catch (err) { URL.revokeObjectURL(svgUrl); reject(err); } }; img.onerror = () => { URL.revokeObjectURL(svgUrl); reject(new Error("Failed to load SVG image")); }; img.src = svgUrl; }); } } ================================================ FILE: src/lib/converters/pandoc.svelte.ts ================================================ import { VertFile, type WorkerMessage } from "$lib/types"; import { Converter, FormatInfo } from "./converter.svelte"; import { browser } from "$app/environment"; import PandocWorker from "$lib/workers/pandoc?worker&url"; import { error, log } from "$lib/util/logger"; import { ToastManager } from "$lib/util/toast.svelte"; import { m } from "$lib/paraglide/messages"; export class PandocConverter extends Converter { public name = "pandoc"; public ready = $state(false); public wasm: ArrayBuffer = null!; private activeConversions = new Map(); constructor() { super(); if (!browser) return; (async () => { try { this.status = "downloading"; this.wasm = await fetch("/pandoc.wasm").then((r) => r.arrayBuffer(), ); this.status = "ready"; } catch (err) { this.status = "error"; error( ["converters", this.name], `Failed to load Pandoc worker: ${err}`, ); ToastManager.add({ type: "error", message: m["workers.errors.pandoc"](), }); } })(); } public async convert(file: VertFile, to: string): Promise { const worker = new Worker(PandocWorker, { type: "module", }); this.activeConversions.set(file.id, worker); const loadMsg: WorkerMessage = { type: "load", wasm: this.wasm, id: file.id, }; worker.postMessage(loadMsg); await waitForMessage(worker, "loaded"); const convertMsg: WorkerMessage = { type: "convert", to, input: { file: file.file, name: file.name, from: file.from, to, }, compression: null, id: file.id, }; worker.postMessage(convertMsg); const result = await waitForMessage(worker); if (result.type === "error") { worker.terminate(); // throw new Error(result.error); const error = result.error.toString(); switch (result.errorKind) { case "PandocUnknownReaderError": { throw new Error( `${file.from} is not a supported input format for documents.`, ); } case "PandocUnknownWriterError": { throw new Error( `${to} is not a supported output format for documents.`, ); } case "PandocParseError": { if (error.includes("JSON missing pandoc-api-version")) { throw new Error( `This JSON file is not a pandoc-converted JSON file. It must be converted with pandoc / VERT to be converted again.`, ); } } // eslint-disable-next-line no-fallthrough default: if (result.errorKind) throw new Error( `[${result.errorKind}] ${result.error}`, ); else throw new Error(result.error); } } if (!to.startsWith(".")) to = `.${to}`; this.activeConversions.delete(file.id); worker.terminate(); return new VertFile( new File([result.output], file.name), result.isZip ? ".zip" : to, ); } public async cancel(input: VertFile): Promise { const worker = this.activeConversions.get(input.id); if (!worker) { error( ["converters", this.name], `no active conversion found for file ${input.name}`, ); return; } log( ["converters", this.name], `cancelling conversion for file ${input.name}`, ); worker.terminate(); this.activeConversions.delete(input.id); } public supportedFormats = [ new FormatInfo("docx", true, true), new FormatInfo("doc", true, true), new FormatInfo("md", true, true), new FormatInfo("html", true, true), new FormatInfo("rtf", true, true), new FormatInfo("csv", true, true), new FormatInfo("tsv", true, true), new FormatInfo("json", true, true), // must be a pandoc-converted json new FormatInfo("rst", true, true), new FormatInfo("epub", true, true), new FormatInfo("odt", true, true), new FormatInfo("docbook", true, true), ]; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function waitForMessage(worker: Worker, type?: string): Promise { return new Promise((resolve) => { const onMessage = (e: MessageEvent) => { if (type && e.data.type === type) { worker.removeEventListener("message", onMessage); resolve(e.data); } else { worker.removeEventListener("message", onMessage); resolve(e.data); } }; worker.addEventListener("message", onMessage); }); } ================================================ FILE: src/lib/converters/vertd.svelte.ts ================================================ import VertdErrorComponent from "$lib/components/functional/VertdError.svelte"; import { error, log } from "$lib/util/logger"; import { m } from "$lib/paraglide/messages"; import { Settings } from "$lib/sections/settings/index.svelte"; import { VertdInstance } from "$lib/sections/settings/vertdSettings.svelte"; import { VertFile } from "$lib/types"; import { Converter, FormatInfo } from "./converter.svelte"; import { PUB_DISABLE_FAILURE_BLOCKS } from "$env/static/public"; interface UploadResponse { id: string; auth: string; from: string; to: null; completed: false; totalFrames: number; } interface RouteRequestMap { "/api/keep": { id: string; token: string; }; } interface RouteResponseMap { "/api/upload": UploadResponse; "/api/version": string; "/api/keep": void; } export const vertdFetch: { ( url: U, options: RequestInit, body: RouteRequestMap[U], ): Promise; >( url: U, options: RequestInit, ): Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any } = async (url: any, options: RequestInit, body?: any) => { const domain = await VertdInstance.instance.url(); // if there is a body, insert a Content-Type: application/json header if (body) { options.headers = { "Content-Type": "application/json", ...(options.headers || {}), }; options.body = JSON.stringify(body); } const res = await fetch(domain + url, options); const text = await res.text(); let json = null; try { json = JSON.parse(text); } catch { throw new Error(text); } if (json.type === "error") { throw new Error(json.data); } return json.data; }; // ws types export type ConversionSpeed = | "verySlow" | "slower" | "slow" | "medium" | "fast" | "ultraFast"; interface StartJobMessage { type: "startJob"; data: { token: string; jobId: string; to: string; speed: ConversionSpeed; keepMetadata: boolean; }; } interface ErrorMessage { type: "error"; data: { message: string; }; } interface ProgressMessage { type: "progressUpdate"; data: ProgressData; } interface CompletedMessage { type: "jobFinished"; data: { jobId: string; }; } interface CancelJobMessage { type: "cancelJob"; data: { jobId: string; token: string; }; } interface JobCancelledMessage { type: "jobCancelled"; data: { jobId: string; }; } interface FpsProgress { type: "fps"; data: number; } interface FrameProgress { type: "frame"; data: number; } type ProgressData = FpsProgress | FrameProgress; type VertdMessage = | StartJobMessage | ErrorMessage | ProgressMessage | CancelJobMessage | JobCancelledMessage | CompletedMessage; const progressEstimates = { upload: 25, convert: 50, download: 25, }; const progressEstimate = ( progress: number, type: keyof typeof progressEstimates, ) => { const previousValues = Object.values(progressEstimates) .filter((_, i) => i < Object.keys(progressEstimates).indexOf(type)) .reduce((a, b) => a + b, 0); return progress * progressEstimates[type] + previousValues; }; const uploadFile = async (file: VertFile): Promise => { const apiUrl = await VertdInstance.instance.url(); const formData = new FormData(); formData.append("file", file.file, file.name); const xhr = new XMLHttpRequest(); xhr.open("POST", `${apiUrl}/api/upload`, true); return new Promise((resolve, reject) => { xhr.upload.addEventListener("progress", (e) => { console.log(e); if (e.lengthComputable) { file.progress = progressEstimate(e.loaded / e.total, "upload"); } }); console.log("meow"); xhr.onload = () => { try { console.log("xhr.responseText"); const res = JSON.parse(xhr.responseText); if (res.type === "error") { reject(res.data); return; } resolve(res.data); } catch { console.log(xhr.responseText); reject(xhr.statusText); } }; xhr.onerror = () => { console.log(xhr.statusText); reject(xhr.statusText); }; xhr.send(formData); console.log("sent!"); }); }; const downloadFile = async (url: string, file: VertFile): Promise => { const xhr = new XMLHttpRequest(); xhr.open("GET", url, true); xhr.responseType = "blob"; return new Promise((resolve, reject) => { xhr.addEventListener("progress", (e) => { if (e.lengthComputable) { file.progress = progressEstimate( e.loaded / e.total, "download", ); } }); xhr.onload = () => { if (xhr.status === 200) { resolve(xhr.response); } else { reject(xhr.statusText); } }; xhr.onerror = () => { reject(xhr.statusText); }; xhr.send(); }); }; export class VertdConverter extends Converter { public name = "vertd"; public ready = $state(false); public reportsProgress = true; private activeConversions = new Map< string, { ws: WebSocket; jobId: string; token: string; } >(); public supportedFormats = [ new FormatInfo("mkv", true, true), new FormatInfo("mp4", true, true), new FormatInfo("webm", true, true), new FormatInfo("avi", true, true), new FormatInfo("wmv", true, true), new FormatInfo("mov", true, true), new FormatInfo("gif", true, true), new FormatInfo("mts", true, true), new FormatInfo("ts", true, true), new FormatInfo("m2ts", true, true), new FormatInfo("mpg", true, true), new FormatInfo("mpeg", true, true), new FormatInfo("flv", true, true), new FormatInfo("f4v", true, true), new FormatInfo("vob", true, true), new FormatInfo("m4v", true, true), new FormatInfo("3gp", true, true), new FormatInfo("3g2", true, true), new FormatInfo("mxf", true, true), new FormatInfo("ogv", true, true), new FormatInfo("rm", true, false), new FormatInfo("rmvb", true, false), new FormatInfo("h264", true, true), new FormatInfo("divx", true, true), new FormatInfo("swf", true, true), new FormatInfo("amv", true, true), new FormatInfo("asf", true, true), new FormatInfo("nut", true, true), ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any private log: (...msg: any[]) => void = () => {}; constructor() { super(); this.log = (msg) => log(["converters", this.name], msg); this.log("created converter"); this.log("not rly sure how to implement this :P"); this.status = "ready"; } private blocked(hash: string): boolean { let blockedHashes = Settings.instance.settings.vertdBlockedHashes; // ensure it's a map // this might fix the "e.get" isn't a function error, but i can't reproduce it if (!(blockedHashes instanceof Map) || blockedHashes === null) { blockedHashes = new Map(Object.entries(blockedHashes || {})); Settings.instance.settings.vertdBlockedHashes = blockedHashes; Settings.instance.save(); } const now = new Date(); const dates = blockedHashes.get(hash) || []; const filteredDates = dates.filter( (date) => now.getTime() - date.getTime() < 60 * 60 * 1000, ); if (filteredDates.length === 0) { blockedHashes.delete(hash); return false; } blockedHashes.set(hash, filteredDates); Settings.instance.save(); return filteredDates.length >= 3; } private failure(hash: string): void { let blockedHashes = Settings.instance.settings.vertdBlockedHashes; // same as above (blocked()) if (!(blockedHashes instanceof Map) || blockedHashes === null) { blockedHashes = new Map(Object.entries(blockedHashes || {})); Settings.instance.settings.vertdBlockedHashes = blockedHashes; Settings.instance.save(); } const now = new Date(); const dates = blockedHashes.get(hash) || []; dates.push(now); blockedHashes.set(hash, dates); Settings.instance.save(); } public async convert(input: VertFile, to: string): Promise { if (to.startsWith(".")) to = to.slice(1); let hash: string; if (PUB_DISABLE_FAILURE_BLOCKS === "false") { hash = await input.hash(); if (this.blocked(hash)) { this.log(`conversion blocked for file ${input.name}`); throw new Error( m["convert.errors.vertd_ratelimit"]({ filename: input.name, }), ); } } const uploadRes = await uploadFile(input); const apiUrl = await VertdInstance.instance.url(); return new Promise((resolve, reject) => { const protocol = apiUrl.startsWith("https") ? "wss:" : "ws:"; const ws = new WebSocket( `${protocol}//${apiUrl.replace("http://", "").replace("https://", "")}/api/ws`, ); this.activeConversions.set(input.id, { ws, jobId: uploadRes.id, token: uploadRes.auth, }); ws.onopen = () => { const speed = Settings.instance.settings.vertdSpeed; const keepMetadata = Settings.instance.settings.metadata; this.log("opened ws connection to vertd"); const msg: StartJobMessage = { type: "startJob", data: { jobId: uploadRes.id, token: uploadRes.auth, to, speed, keepMetadata, }, }; ws.send(JSON.stringify(msg)); this.log("sent startJob message"); }; ws.onmessage = async (e) => { const msg: VertdMessage = JSON.parse(e.data); this.log(`received message ${msg.type}`); switch (msg.type) { case "progressUpdate": { const data = msg.data; if (data.type !== "frame") break; const frame = data.data; input.progress = progressEstimate( frame / uploadRes.totalFrames, "convert", ); break; } case "jobFinished": { this.log("job finished"); ws.close(); this.activeConversions.delete(input.id); const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`; this.log(`downloading from ${url}`); // const res = await fetch(url).then((res) => res.blob()); const res = await downloadFile(url, input); resolve(new VertFile(new File([res], input.name), to)); break; } case "jobCancelled": { this.log("job cancelled"); ws.close(); this.activeConversions.delete(input.id); reject("Conversion cancelled"); break; } case "error": { this.log(`error: ${msg.data.message}`); this.activeConversions.delete(input.id); if (hash) this.failure(hash); reject({ component: VertdErrorComponent, additional: { jobId: uploadRes.id, auth: uploadRes.auth, from: input.from, to: to, errorMessage: msg.data.message, }, }); } } }; }); } public async cancel(input: VertFile): Promise { const activeConversion = this.activeConversions.get(input.id); if (!activeConversion) { error( ["converters", this.name], `no active conversion found for file ${input.name}`, ); return; } log( ["converters", this.name], `cancelling conversion for file ${input.name}`, ); const { ws, jobId, token } = activeConversion; if (ws.readyState === WebSocket.OPEN) { const cancelMsg: CancelJobMessage = { type: "cancelJob", data: { jobId, token, }, }; ws.send(JSON.stringify(cancelMsg)); this.log("sent cancelJob message"); } ws.close(); this.activeConversions.delete(input.id); } public async valid(): Promise { if (!(await VertdInstance.instance.url())) { return false; } try { await vertdFetch("/api/version", { method: "GET", }); return true; } catch (e) { this.log(e as unknown as string); return false; } } } ================================================ FILE: src/lib/css/app.scss ================================================ @tailwind base; @tailwind components; @tailwind utilities; @import url(@fontsource/radio-canada-big/600.css); @import url("$lib/assets/style/host-grotesk.css"); :root { --font-body: "Host Grotesk", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; --font-display: "Radio Canada Big", var(--font-body); --transition: 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 ); } @mixin light { // general --accent-pink: hsl(302, 100%, 76%); --accent-pink-alt: hsl(302, 100%, 50%); --accent-pink-muted: hsl(302, 98%, 42%); --accent-red: hsl(348, 100%, 80%); --accent-red-alt: hsl(348, 100%, 50%); --accent-purple: hsl(264, 100%, 81%); --accent-purple-alt: hsl(264, 100%, 50%); --accent-blue: hsl(220, 100%, 78%); --accent-blue-alt: hsl(220, 100%, 50%); --accent-green: hsl(140, 70%, 74%); --accent-green-alt: hsl(140, 66%, 55%); --accent: var(--accent-pink); --accent-alt: var(--accent-pink-alt); --accent-pink-transparent: hsla(303, 100%, 50%, 0); --accent-red-transparent: hsla(348, 100%, 50%, 0); --accent-purple-transparent: hsla(264, 100%, 50%, 0); --accent-blue-transparent: hsla(220, 100%, 50%, 0); --accent-green-transparent: hsla(140, 70%, 30%, 0); // foregrounds --fg: hsl(0, 0%, 0%); --fg-muted: hsla(0, 0%, 0%, 0.6); --fg-on-accent: hsl(0, 0%, 0%); --fg-on-badge: hsl(0, 0%, 0%); // readable version of the accent color --fg-accent: var(--accent-pink-muted); --fg-failure: var(--accent-red-alt); // backgrounds --bg: hsl(0, 0%, 95%); --bg-gradient-from: var(--accent-pink); --bg-gradient-to: hsla(303, 100%, 50%, 0); --bg-gradient-pink-from: var(--accent-pink); --bg-gradient-pink-to: hsla(303, 100%, 50%, 0); --bg-gradient-pink-alt-from: var(--accent-pink); --bg-gradient-pink-alt-to: hsl(303, 100%, 91%); --bg-gradient-red-from: var(--accent-red); --bg-gradient-red-to: hsla(348, 100%, 50%, 0); --bg-gradient-red-alt-from: var(--accent-red); --bg-gradient-red-alt-to: hsl(348, 100%, 91%); --bg-gradient-purple-from: var(--accent-purple); --bg-gradient-purple-to: hsla(264, 100%, 50%, 0); --bg-gradient-purple-alt-from: var(--accent-purple); --bg-gradient-purple-alt-to: hsl(264, 100%, 91%); --bg-gradient-blue-from: var(--accent-blue); --bg-gradient-blue-to: hsla(220, 100%, 50%, 0); --bg-gradient-blue-alt-from: var(--accent-blue); --bg-gradient-blue-alt-to: hsl(220, 100%, 91%); --bg-gradient-green-from: var(--accent-green); --bg-gradient-green-to: hsla(140, 70%, 30%, 0); --bg-gradient-green-alt-from: var(--accent-green-alt); --bg-gradient-green-alt-to: hsl(140, 70%, 91%); --bg-gradient-image-from: hsla(0, 0%, 95%, 0.5); --bg-gradient-image-to: hsla(0, 0%, 95%, 1); --bg-gradient: linear-gradient( to bottom, var(--bg-gradient-from), var(--bg-gradient-to) 100% ); --bg-gradient-pink: linear-gradient( to bottom, var(--bg-gradient-pink-from), var(--bg-gradient-pink-to) 25% ); --bg-gradient-pink-alt: linear-gradient( to top, var(--bg-gradient-pink-alt-from), var(--bg-gradient-pink-alt-to) 100% ); --bg-gradient-red: linear-gradient( to bottom, var(--bg-gradient-red-from), var(--bg-gradient-red-to) 25% ); --bg-gradient-red-alt: linear-gradient( to top, var(--bg-gradient-red-alt-from), var(--bg-gradient-red-alt-to) 100% ); --bg-gradient-purple: linear-gradient( to bottom, var(--bg-gradient-purple-from), var(--bg-gradient-purple-to) 25% ); --bg-gradient-purple-alt: linear-gradient( to top, var(--bg-gradient-purple-alt-from), var(--bg-gradient-purple-alt-to) 100% ); --bg-gradient-blue: linear-gradient( to bottom, var(--bg-gradient-blue-from), var(--bg-gradient-blue-to) 25% ); --bg-gradient-blue-alt: linear-gradient( to top, var(--bg-gradient-blue-alt-from), var(--bg-gradient-blue-alt-to) 100% ); --bg-gradient-green: linear-gradient( to bottom, var(--bg-gradient-green-from), var(--bg-gradient-green-to) 25% ); --bg-gradient-green-alt: linear-gradient( to top, var(--bg-gradient-green-alt-from), var(--bg-gradient-green-alt-to) 100% ); --bg-gradient-image: linear-gradient( to bottom, var(--bg-gradient-image-from), var(--bg-gradient-image-to) 100% ); --bg-panel: hsl(0, 0%, 100%); --bg-panel-highlight: hsl(0, 0%, 92%); --bg-separator: hsla(0, 0%, 0%, 0.2); --bg-button: var(--bg-panel-highlight); --bg-badge: var(--accent-pink); --bg-input: #e0e0e0; --shadow-panel: 0 2px 4px 0 hsla(0, 0%, 0%, 0.15); } @mixin dark { // general --accent-pink: hsl(302, 100%, 76%); --accent-pink-alt: hsl(302, 100%, 50%); --accent-red: hsl(348, 100%, 80%); --accent-red-alt: hsl(348, 100%, 50%); --accent-purple: hsl(264, 100%, 81%); --accent-purple-alt: hsl(264, 100%, 50%); --accent-blue: hsl(220, 100%, 78%); --accent-blue-alt: hsl(220, 100%, 50%); --accent: var(--accent-pink); --accent-alt: var(--accent-pink-alt); --accent-green: hsl(140, 70%, 74%); --accent-green-alt: hsl(140, 64%, 42%); --accent-pink-transparent: hsla(303, 100%, 50%, 0); --accent-red-transparent: hsla(348, 100%, 50%, 0); --accent-purple-transparent: hsla(264, 100%, 50%, 0); --accent-blue-transparent: hsla(220, 100%, 50%, 0); --accent-green-transparent: hsla(140, 70%, 30%, 0); // foregrounds --fg: hsl(0, 0%, 100%); --fg-muted: hsla(0, 0%, 100%, 0.65); --fg-on-accent: hsl(0, 0%, 0%); --fg-on-badge: hsl(0, 0%, 0%); --fg-accent: var(--accent); --fg-failure: var(--accent-red); // backgrounds --bg: hsl(220, 5%, 15%); --bg-gradient-from: hsla(303, 100%, 50%, 0.1); --bg-gradient-to: hsla(303, 100%, 50%, 0); --bg-gradient-pink-from: hsla(303, 100%, 50%, 0.1); --bg-gradient-pink-to: hsla(303, 100%, 50%, 0); --bg-gradient-pink-alt-from: var(--accent-pink); --bg-gradient-pink-alt-to: hsl(303, 100%, 91%); --bg-gradient-red-from: hsla(348, 100%, 50%, 0.1); --bg-gradient-red-to: hsla(348, 100%, 50%, 0); --bg-gradient-red-alt-from: var(--accent-red); --bg-gradient-red-alt-to: hsl(348, 100%, 91%); --bg-gradient-purple-from: hsla(264, 100%, 50%, 0.1); --bg-gradient-purple-to: hsla(264, 100%, 50%, 0); --bg-gradient-purple-alt-from: var(--accent-purple); --bg-gradient-purple-alt-to: hsl(264, 100%, 91%); --bg-gradient-blue-from: hsla(220, 100%, 50%, 0.1); --bg-gradient-blue-to: hsla(220, 100%, 50%, 0); --bg-gradient-blue-alt-from: var(--accent-blue); --bg-gradient-blue-alt-to: hsl(220, 100%, 91%); --bg-gradient-green-from: hsla(140, 70%, 30%, 0.1); --bg-gradient-green-to: hsla(140, 70%, 30%, 0); --bg-gradient-green-alt-from: var(--accent-green-alt); --bg-gradient-green-alt-to: hsl(140, 70%, 91%); --bg-gradient-image-from: hsla(220, 5%, 12%, 0.5); --bg-gradient-image-to: hsla(220, 5%, 12%, 1); --bg-gradient: linear-gradient( to bottom, var(--bg-gradient-from), var(--bg-gradient-to) 100% ); --bg-gradient-pink: linear-gradient( to bottom, var(--bg-gradient-pink-from), var(--bg-gradient-pink-to) 25% ); --bg-gradient-pink-alt: linear-gradient( to top, var(--bg-gradient-pink-alt-from), var(--bg-gradient-pink-alt-to) 100% ); --bg-gradient-red: linear-gradient( to bottom, var(--bg-gradient-red-from), var(--bg-gradient-red-to) 25% ); --bg-gradient-red-alt: linear-gradient( to top, var(--bg-gradient-red-alt-from), var(--bg-gradient-red-alt-to) 100% ); --bg-gradient-purple: linear-gradient( to bottom, var(--bg-gradient-purple-from), var(--bg-gradient-purple-to) 25% ); --bg-gradient-purple-alt: linear-gradient( to top, var(--bg-gradient-purple-alt-from), var(--bg-gradient-purple-alt-to) 100% ); --bg-gradient-blue: linear-gradient( to bottom, var(--bg-gradient-blue-from), var(--bg-gradient-blue-to) 25% ); --bg-gradient-blue-alt: linear-gradient( to top, var(--bg-gradient-blue-alt-from), var(--bg-gradient-blue-alt-to) 100% ); --bg-gradient-green: linear-gradient( to bottom, var(--bg-gradient-green-from), var(--bg-gradient-green-to) 25% ); --bg-gradient-green-alt: linear-gradient( to top, var(--bg-gradient-green-alt-from), var(--bg-gradient-green-alt-to) 100% ); --bg-gradient-image: linear-gradient( to bottom, var(--bg-gradient-image-from), var(--bg-gradient-image-to) 100% ); --bg-panel: hsl(220, 4%, 24%); --bg-panel-highlight: hsl(220, 2%, 32%); --bg-separator: hsl(220, 4%, 28%); --bg-button: hsl(220, 6%, 34%); --bg-badge: var(--accent-pink); --shadow-panel: 0 4px 6px 0 hsla(0, 0%, 0%, 0.15); color-scheme: dark; } @media (prefers-color-scheme: dark) { :root { @include dark; } } @media (prefers-color-scheme: light) { :root { @include light; } } :root.light { @include light; } :root.dark { @include dark; } body { @apply text-foreground font-body font-semibold overflow-x-hidden; width: 100vw; background-color: var(--bg); background-size: 100vw 100vh; } ::selection, ::-moz-selection { @apply bg-accent-blue text-on-accent; } .hoverable { @apply hover:scale-105 duration-200; will-change: transform; } .hoverable-md { @apply hover:scale-110 duration-200; will-change: transform; } .hoverable-lg { @apply hover:scale-[1.15] duration-200; will-change: transform; } .selected { @apply bg-accent-purple !text-black; } @layer components { select { @apply appearance-none; } .btn { @apply bg-button flex items-center justify-center overflow-hidden relative cursor-pointer px-6 h-14 rounded-full font-medium focus:!outline-none hover:scale-105 duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none hoverable; transition: opacity 0.2s ease, transform 0.2s ease, background-color 0.2s ease; } .btn.highlight { @apply bg-accent text-on-accent; } h1, h2, h3, h4, h5, h6 { @apply font-display font-semibold; } code { @apply font-mono bg-gray-200 rounded-md px-1 dynadark:bg-panel-alt dynadark:text-white; } p a { @apply text-accent underline; } input[type="text"], select.dropdown { @apply w-full p-3 rounded-lg bg-panel border-2 border-button pl-3 pr-[4rem]; } input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } input[type="number"] { -moz-appearance: textfield; appearance: textfield; } input[type="text"]::placeholder { @apply text-muted font-normal; } input[type="text"]:focus { @apply outline outline-accent outline-2; } input[type="range"] { @apply appearance-none bg-panel h-2 rounded-lg; } input[type="range"]::-webkit-slider-thumb { @apply appearance-none w-4 h-4 bg-accent rounded-full cursor-pointer; } input[type="range"]::-moz-range-thumb { @apply w-4 h-4 bg-accent rounded-full cursor-pointer; } hr { @apply border-separator; } } ================================================ FILE: src/lib/sections/about/Credits.svelte ================================================ {#snippet contributor( name: string, github: string, avatar: string, role?: string, smaller?: boolean, )}
{name} {#if role}

{name}

{role}

{/if}
{/snippet}

{m["about.credits.title"]()}

{m["about.credits.contact_team"]()}

{#each mainContribs as contrib} {@const { name, github, avatar, role } = contrib} {@render contributor(name, github, avatar, role)} {/each}

{m["about.credits.notable_contributors"]()}

{m["about.credits.notable_description"]()}

{#each notableContribs as contrib} {@const { name, github, avatar, role } = contrib} {@render contributor(name, github, avatar, role, true)} {/each}
{#if !DISABLE_ALL_EXTERNAL_REQUESTS}

{m["about.credits.github_contributors"]()}

{#if ghContribs && ghContribs.length > 0}

{@html sanitize( link( "github_link", m["about.credits.github_description"](), GITHUB_URL_VERT, true, ), )}

{:else}

{@html sanitize( link( "contribute_link", m["about.credits.no_contributors"](), GITHUB_URL_VERT, true, ), )}

{/if}
{#if ghContribs && ghContribs.length > 0}
{#each ghContribs as contrib} {@const { name, github, avatar } = contrib} {@render contributor(name, github, avatar)} {/each}
{/if}

{m["about.credits.libraries"]()}

{m["about.credits.libraries_description"]()}

{/if}
================================================ FILE: src/lib/sections/about/Donate.svelte ================================================

{m["about.donate.title"]()}

{m["about.donate.description"]()}

{#each presetAmounts as preset, i} {/each}
{ if (e.key === "Enter") { paymentClick(); } }} onclick={paymentClick} class={clsx( "btn flex-1 p-3 relative rounded-3xl bg-accent-red border-2 border-accent-red h-14 text-black", { "h-[450px] rounded-2xl bg-transparent cursor-auto !scale-100 -mt-10 -mb-2": paymentState !== "prepay", "!scale-100": !$effects, }, )} style="transition: height {payDuration}ms {transition}, border-radius {payDuration}ms {transition}, background-color {payDuration}ms {transition}, transform {payDuration}ms {transition}, margin {payDuration}ms {transition}; will-change: height, border-radius, background-color, transform, margin;" >
{#if paymentState !== "prepay"}
{#if stripe && clientSecret} { enablePay = e.detail.complete; }} /> {/if}
{:else}
{m["about.donate.pay_now"]()}
{/if}

{#if isOfficial} {m["about.donate.donation_notice_official"]()} {:else} {@html sanitize( link( "official_link", m["about.donate.donation_notice_unofficial"](), "https://vert.sh", true, "", ), )} {/if}

================================================ FILE: src/lib/sections/about/Resources.svelte ================================================

{m["about.resources.title"]()}

================================================ FILE: src/lib/sections/about/Sponsors.svelte ================================================

{m["about.sponsors.title"]()}

{@html sanitize(link( "discord_link", m["about.sponsors.description"](), DISCORD_URL, true ))} !

================================================ FILE: src/lib/sections/about/Why.svelte ================================================

{m["about.why.title"]()}

{@html sanitize(m["about.why.description"]())}

================================================ FILE: src/lib/sections/about/index.ts ================================================ export { default as Credits } from "./Credits.svelte"; export { default as Donate } from "./Donate.svelte"; export { default as Resources } from "./Resources.svelte"; export { default as Why } from "./Why.svelte"; export { default as Sponsors } from "./Sponsors.svelte"; ================================================ FILE: src/lib/sections/settings/Appearance.svelte ================================================

{m["settings.appearance.title"]()}

{m["settings.appearance.brightness_theme"]()}

{m["settings.appearance.brightness_description"]()}

{m["settings.appearance.effect_settings"]()}

{m["settings.appearance.effect_description"]()}

{m["settings.language.title"]()} {#if currentLocale !== "en"} (Language){/if}

{m["settings.language.description"]()}

================================================ FILE: src/lib/sections/settings/Conversion.svelte ================================================

{m["settings.conversion.title"]()}

{m["settings.conversion.filename_format"]()}

{@html sanitize(m["settings.conversion.filename_description"]())}

{m["settings.conversion.default_format"]()}

{m[ "settings.conversion.default_format_description" ]()}

{m[ "settings.conversion.default_format_image" ]()}

{m[ "settings.conversion.default_format_audio" ]()}

{m[ "settings.conversion.default_format_video" ]()}

{m[ "settings.conversion.default_format_document" ]()}

{m["settings.conversion.metadata"]()}

{m[ "settings.conversion.metadata_description" ]()}

{m["settings.conversion.quality"]()}

{m[ "settings.conversion.quality_description" ]()}

{m[ "settings.conversion.quality_images" ]()}

{m[ "settings.conversion.quality_audio" ]()}

b.toString(), )} selected={settings.ffmpegQuality.toString()} onselect={(option: string) => (settings.ffmpegQuality = option as ConversionBitrate)} settingsStyle />

{m["settings.conversion.rate"]()}

r.toString(), )} selected={settings.ffmpegSampleRate.toString()} onselect={(option: string) => { settings.ffmpegSampleRate = option as SampleRate; }} settingsStyle />

  

================================================ FILE: src/lib/sections/settings/Privacy.svelte ================================================

{m["settings.privacy.title"]()}

{#if !DISABLE_ALL_EXTERNAL_REQUESTS}

{m["settings.privacy.plausible_title"]()}

{@html link( ["plausible_link", "analytics_link"], m["settings.privacy.plausible_description"](), [ "https://plausible.io/privacy-focused-web-analytics", "https://ats.vert.sh/vert.sh", ], )}

{/if}

{m["settings.privacy.cache_title"]()}

{m["settings.privacy.cache_description"]()}

{m["settings.privacy.total_size"]()}
{#if isLoadingCache} {m["settings.privacy.loading_cache"]()} {:else} {cacheInfo ? swManager.formatSize(cacheInfo.totalSize) : "0 B"} {/if}
{m["settings.privacy.files_cached_label"]()}
{#if isLoadingCache} {m["settings.privacy.loading_cache"]()} {:else} {cacheInfo?.fileCount ?? 0} {/if}

{m["settings.privacy.site_data_title"]()}

{m["settings.privacy.site_data_description"]()}

================================================ FILE: src/lib/sections/settings/Vertd.svelte ================================================

{m["settings.vertd.title"]()}

{m["settings.vertd.status"]()} {vertdCommit ? vertdCommit === "loading" ? m["settings.vertd.loading"]() : m["settings.vertd.available"]({ commitId: vertdCommit }) : m["settings.vertd.unavailable"]()}

{@html sanitize(m["settings.vertd.description"]())}

{@html sanitize(link( "vertd_link", m["settings.vertd.hosting_info"](), GITHUB_URL_VERTD, ))}

{m["settings.vertd.instance"]()}

{ let inner: VertdInner; switch (selected) { case m["settings.vertd.auto_instance"](): inner = { type: "auto" }; break; case m["settings.vertd.eu_instance"](): inner = { type: "eu" }; break; case m["settings.vertd.us_instance"](): inner = { type: "us" }; break; case m["settings.vertd.custom_instance"](): inner = { type: "custom", }; break; default: inner = { type: "auto" }; } VertdInstance.instance.set(inner); }} selected={(() => { switch (VertdInstance.instance.innerData().type) { case "auto": return m["settings.vertd.auto_instance"](); case "eu": return m["settings.vertd.eu_instance"](); case "us": return m["settings.vertd.us_instance"](); case "custom": return m[ "settings.vertd.custom_instance" ](); } })()} settingsStyle /> {#if VertdInstance.instance.innerData().type === "custom"} {/if}

{m["settings.vertd.conversion_speed"]()}

{m["settings.vertd.speed_description"]()}

{ switch (settings.vertdSpeed) { case "verySlow": return m[ "settings.vertd.speeds.very_slow" ](); case "slower": return m["settings.vertd.speeds.slower"](); case "slow": return m["settings.vertd.speeds.slow"](); case "medium": return m["settings.vertd.speeds.medium"](); case "fast": return m["settings.vertd.speeds.fast"](); case "ultraFast": return m[ "settings.vertd.speeds.ultra_fast" ](); } })()} onselect={(selected) => { switch (selected) { case m["settings.vertd.speeds.very_slow"](): settings.vertdSpeed = "verySlow"; break; case m["settings.vertd.speeds.slower"](): settings.vertdSpeed = "slower"; break; case m["settings.vertd.speeds.slow"](): settings.vertdSpeed = "slow"; break; case m["settings.vertd.speeds.medium"](): settings.vertdSpeed = "medium"; break; case m["settings.vertd.speeds.fast"](): settings.vertdSpeed = "fast"; break; case m["settings.vertd.speeds.ultra_fast"](): settings.vertdSpeed = "ultraFast"; break; } }} />
================================================ FILE: src/lib/sections/settings/index.svelte.ts ================================================ import { PUB_VERTD_URL } from "$env/static/public"; import type { ConversionBitrate } from "$lib/converters/ffmpeg.svelte"; import type { ConversionSpeed } from "$lib/converters/vertd.svelte"; import { VertdInstance } from "./vertdSettings.svelte"; export { default as Appearance } from "./Appearance.svelte"; export { default as Conversion } from "./Conversion.svelte"; export { default as Vertd } from "./Vertd.svelte"; export { default as Privacy } from "./Privacy.svelte"; // TODO: clean up settings & button code (componetize) export interface DefaultFormats { image: string; video: string; audio: string; document: string; } export interface ISettings { filenameFormat: string; defaultFormat: DefaultFormats; useDefaultFormat: boolean; metadata: boolean; plausible: boolean; vertdURL: string; vertdSpeed: ConversionSpeed; // videos magickQuality: number; // images ffmpegQuality: ConversionBitrate; // audio (or audio <-> video) ffmpegSampleRate: string; // audio (or audio <-> video) ffmpegCustomSampleRate: number; // audio (or audio <-> video) - only used when ffmpegSampleRate is "custom" vertdBlockedHashes: Map; // hashes of files blocked from vertd conversion } export class Settings { public static instance = new Settings(); public settings: ISettings = $state({ filenameFormat: "VERT_%name%", defaultFormat: { image: ".png", video: ".mp4", audio: ".mp3", document: ".docx", }, useDefaultFormat: false, metadata: true, plausible: true, vertdURL: PUB_VERTD_URL, vertdSpeed: "slow", magickQuality: 100, ffmpegQuality: "auto", ffmpegSampleRate: "auto", ffmpegCustomSampleRate: 44100, vertdBlockedHashes: new Map(), }); public save() { localStorage.setItem("settings", JSON.stringify(this.settings)); VertdInstance.instance.save(); } public load() { try { VertdInstance.instance.load(); const ls = localStorage.getItem("settings"); if (!ls) return; const settings: ISettings = JSON.parse(ls); const vertdBlockedHashes = new Map( Object.entries( settings.vertdBlockedHashes || this.settings.vertdBlockedHashes, ), ); settings.vertdBlockedHashes = vertdBlockedHashes; this.settings = { ...this.settings, ...settings, }; } catch { // ignore errors, use default settings } } } ================================================ FILE: src/lib/sections/settings/vertdSettings.svelte.ts ================================================ import { ip, type IpInfo } from "$lib/util/ip"; import { Settings } from "./index.svelte"; import { PUB_VERTD_URL } from "$env/static/public"; const LOCATIONS = [ { latitude: 49.0976, longitude: 12.4869, url: "https://eu.vertd.vert.sh", }, { latitude: 47.6587, longitude: -117.426, url: "https://usa.vertd.vert.sh", }, ]; const toRad = (value: number) => (value * Math.PI) / 180; const haversine = (lat1: number, lon1: number, lat2: number, lon2: number) => { const R = 6371; // km const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const d = R * c; return d; }; export type VertdInner = | { type: "auto" } | { type: "eu" } | { type: "us" } | { type: "custom" }; export class VertdInstance { public static instance = new VertdInstance(); private cachedIp = $state(null); private inner = $state({ type: "auto", }); public save() { localStorage.setItem("vertdInstance", JSON.stringify(this.inner)); } public load() { const ls = localStorage.getItem("vertdInstance"); // if custom vertd url and no saved setting, default to the custom url if (!ls) { const isCustomUrl = PUB_VERTD_URL && PUB_VERTD_URL !== "https://vertd.vert.sh"; if (isCustomUrl) { this.inner = { type: "custom" }; return; } } if (!ls) return; const inner: VertdInner = JSON.parse(ls); this.inner = { ...this.inner, ...inner, }; } public innerData() { return this.inner; } public set(inner: VertdInner) { this.inner = inner; this.save(); } public async url() { const reachable = async (url: string) => { try { const res = await fetch(url + "/api/version", { method: "GET", cache: "no-store", }); return res.ok; } catch { return false; } }; switch (this.inner.type) { case "auto": { if (!this.cachedIp) this.cachedIp = await ip(); const ipInfo = this.cachedIp; const primary = this.geographicallyOptimalInstance(ipInfo); // try primary (closest) first if (await reachable(primary)) return primary; // fall back to other locations for (const location of LOCATIONS) { if (location.url === primary) continue; if (await reachable(location.url)) return location.url; } // if none are reachable, fall back to custom return Settings.instance.settings.vertdURL; } case "eu": { return "https://eu.vertd.vert.sh"; } case "us": { return "https://usa.vertd.vert.sh"; } case "custom": { return Settings.instance.settings.vertdURL; } } } private geographicallyOptimalInstance(ip: IpInfo) { let bestLocation = LOCATIONS[0]; let bestDistance = haversine( ip.latitude, ip.longitude, bestLocation.latitude, bestLocation.longitude, ); for (let i = 1; i < LOCATIONS.length; i++) { const location = LOCATIONS[i]; const distance = haversine( ip.latitude, ip.longitude, location.latitude, location.longitude, ); if (distance < bestDistance) { bestDistance = distance; bestLocation = location; } } return bestLocation.url; } } ================================================ FILE: src/lib/store/DialogProvider.ts ================================================ import type { Component } from "svelte"; import { writable } from "svelte/store"; type DialogType = "success" | "error" | "info" | "warning"; type BaseDialog = { id: number; title: string; buttons: { text: string; action: () => void; }[]; type: DialogType; }; export type StringDialog = BaseDialog & { message: string; }; export type ComponentDialog = BaseDialog & { message: Component>; additional: T; }; export type Dialog = StringDialog | ComponentDialog; export type DialogProps = { id: number; title: string; type: DialogType; buttons: { text: string; action: () => void; }[]; additional: T; }; const dialogs = writable([]); let dialogId = 0; function addDialog( title: string, message: string | Component, buttons: BaseDialog["buttons"], type: DialogType, additional?: unknown, ): number { const id = dialogId++; if (typeof message === "string") { const newDialog: StringDialog = { id, title, message, buttons, type, }; dialogs.update((currentDialogs) => [...currentDialogs, newDialog]); } else { const newDialog: ComponentDialog = { id, title, message, buttons, type, additional, }; dialogs.update((currentDialogs) => [...currentDialogs, newDialog]); } return id; } function removeDialog(id: number) { dialogs.update((currentDialogs) => currentDialogs.filter((dialog) => dialog.id !== id), ); } export { dialogs, addDialog, removeDialog }; ================================================ FILE: src/lib/store/index.svelte.ts ================================================ import { browser } from "$app/environment"; import { byNative, converters } from "$lib/converters"; import { error, log } from "$lib/util/logger"; import { VertFile } from "$lib/types"; import { parseBlob, selectCover } from "music-metadata"; import { writable } from "svelte/store"; import { addDialog } from "./DialogProvider"; import PQueue from "p-queue"; import { getLocale, setLocale } from "$lib/paraglide/runtime"; import { m } from "$lib/paraglide/messages"; import sanitizeHtml from "sanitize-html"; import { ToastManager } from "$lib/util/toast.svelte"; import { GB } from "$lib/util/consts"; class Files { public files = $state([]); public requiredConverters = $derived( Array.from(new Set(files.files.map((f) => f.converters).flat())), ); public ready = $derived( this.files.length === 0 ? false : this.requiredConverters.every((f) => f?.status === "ready") && this.files.every((f) => !f.processing), ); public results = $derived( this.files.length === 0 ? false : this.files.every((f) => f.result), ); private thumbnailQueue = new PQueue({ concurrency: browser ? navigator.hardwareConcurrency || 4 : 4, }); private _addThumbnail = async (file: VertFile) => { this.thumbnailQueue.add(async () => { const isAudio = converters .find((c) => c.name === "ffmpeg") ?.supportedFormats.filter((f) => f.isNative) .map((f) => f.name) ?.includes(file.from.toLowerCase()); const isVideo = converters .find((c) => c.name === "vertd") ?.supportedFormats.filter((f) => f.isNative) .map((f) => f.name) ?.includes(file.from.toLowerCase()); try { if (isAudio) { // try to get the thumbnail from the audio via music-metadata const { common } = await parseBlob(file.file, { skipPostHeaders: true, }); const cover = selectCover(common.picture); if (cover) { const arrayBuffer = cover.data.buffer instanceof ArrayBuffer ? cover.data.buffer : new Uint8Array(cover.data).buffer; const blob = new Blob([new Uint8Array(arrayBuffer)], { type: cover.format, }); file.blobUrl = URL.createObjectURL(blob); } } else if (isVideo) { // video file.blobUrl = await this._generateThumbnailFromMedia( file.file, true, ); } else { // image file.blobUrl = await this._generateThumbnailFromMedia( file.file, false, ); } } catch (e) { error(["files"], e); } }); }; private async _generateThumbnailFromMedia( file: File, isVideo: boolean, ): Promise { const maxSize = 180; const mediaElement = isVideo ? document.createElement("video") : new Image(); mediaElement.src = URL.createObjectURL(file); await new Promise((resolve, reject) => { if (isVideo) { const video = mediaElement as HTMLVideoElement; // seek to 10% of video time or 2 seconds in video.onloadeddata = () => { const seekTime = Math.min(video.duration * 0.1, 2); video.currentTime = seekTime; }; video.onseeked = resolve; video.onerror = reject; } else { (mediaElement as HTMLImageElement).onload = resolve; (mediaElement as HTMLImageElement).onerror = reject; } }); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return undefined; const width = isVideo ? (mediaElement as HTMLVideoElement).videoWidth : (mediaElement as HTMLImageElement).width; const height = isVideo ? (mediaElement as HTMLVideoElement).videoHeight : (mediaElement as HTMLImageElement).height; const scale = Math.max(maxSize / width, maxSize / height); canvas.width = width * scale; canvas.height = height * scale; ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height); // check if completely transparent const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const isTransparent = Array.from(imageData.data).every((value, index) => { return (index + 1) % 4 !== 0 || value === 0; }); if (isTransparent) { canvas.remove(); return undefined; } const url = canvas.toDataURL(); canvas.remove(); return url; } private async _handleZipFile(file: File): Promise { try { log(["files"], `extracting zip file: ${file.name}`); ToastManager.add({ type: "info", message: m["convert.archive_file.extracting"]({ filename: file.name, }), }); const { extractZip } = await import("$lib/util/zip"); const entries = await extractZip(file); const totalEntries = entries.length; log(["files"], `extracted ${totalEntries} files from zip`); // check if all files in zip use the same converter and are compatible const convertersUsed = new Set(); let incompatibleFiles = false; for (const { filename } of entries) { const format = "." + filename.split(".").pop()?.toLowerCase(); if (!format || format === ".zip") { incompatibleFiles = true; continue; } const converter = converters .sort(byNative(format)) .find((c) => c.formatStrings().includes(format)); if (converter) convertersUsed.add(converter.name); else incompatibleFiles = true; } const converterCount = convertersUsed.size; const canConvertAsOne = converterCount === 1 && !incompatibleFiles; log( ["files"], `extracted ${entries.length} files from zip (converters: ${converterCount}, compatible: ${canConvertAsOne})`, ); if (canConvertAsOne) { // all files use same converter - add zip as a single VertFile file const vf = new VertFile(file, ".zip"); vf.converters = converters.filter( (c) => c.name === Array.from(convertersUsed)[0], ); const converterName = vf.converters[0].name; const type = converterName === "imagemagick" ? "image" : converterName === "ffmpeg" ? "audio" : converterName === "pandoc" ? "doc" : "video"; this.files.push(vf); this._addThumbnail(vf); ToastManager.add({ type: "success", message: m["convert.archive_file.detected"]({ type: m[`convert.archive_file.${type}`](), filename: file.name, }), }); } else { // mixed converters/incompatible files - extract all individually for (const { filename, data } of entries) { this._add( new File([new Uint8Array(data)], filename, { type: "application/octet-stream", }), ); } ToastManager.add({ type: "success", message: m["convert.archive_file.extracted"]({ filename: file.name, extract_count: entries.length, ignore_count: 0, }), }); } } catch (e) { error(["files"], `error processing zip file: ${e}`); throw e; } } private _warningShown = false; private async _add(file: VertFile | File) { if (file instanceof VertFile) { this.files.push(file); this._addThumbnail(file); } else { // if zip, extract and add contents const isZip = file.name.toLowerCase().endsWith(".zip") || file.type === "application/zip" || file.type === "application/x-zip-compressed"; if (isZip) { try { await this._handleZipFile(file); return; } catch (err) { error(["files"], `error extracting zip file: ${err}`); ToastManager.add({ type: "error", message: m["convert.archive_file.extract_error"]({ filename: file.name, error: String(err), }), }); return; } } // regular files const format = "." + file.name.split(".").pop()?.toLowerCase(); if (!format) { log(["files"], `no extension found for ${file.name}`); return; } const converter = converters .sort(byNative(format)) .find((converter) => converter.formatStrings().includes(format)); if (!converter) { log(["files"], `no converter found for ${file.name}`); this.files.push(new VertFile(file, format)); return; } const to = converter.formatStrings().find((f) => f !== format); if (!to) { log(["files"], `no output format found for ${file.name}`); return; } const vf = new VertFile(file, to); this.files.push(vf); this._addThumbnail(vf); const convName = converter.name; if (file.size > MAX_ARRAY_BUFFER_SIZE && convName === "vertd") { ToastManager.add({ type: "warning", message: m["convert.large_file_warning"]({ limit: (MAX_ARRAY_BUFFER_SIZE / GB).toFixed(2), }), durations: { stay: 10000, }, }); } const isVideo = convName === "vertd"; const acceptedExternalWarning = localStorage.getItem("acceptedExternalWarning") === "true"; if (isVideo && !acceptedExternalWarning && !this._warningShown) { this._warningShown = true; const title = m["convert.external_warning.title"](); const message = m["convert.external_warning.text"](); const buttons = [ { text: m["convert.external_warning.no"](), action: () => { this.files = [ ...this.files.filter( (f) => !f.converters.map((c) => c.name).includes("vertd"), ), ]; this._warningShown = false; }, }, { text: m["convert.external_warning.yes"](), action: () => { localStorage.setItem("acceptedExternalWarning", "true"); this._warningShown = false; }, }, ]; addDialog(title, message, buttons, "warning"); } } } public add(file: VertFile | null | undefined): void; public add(file: File | null | undefined): void; public add(file: File[] | null | undefined): void; public add(file: VertFile[] | null | undefined): void; public add(file: FileList | null | undefined): void; public add( file: VertFile | File | VertFile[] | File[] | FileList | null | undefined, ) { if (!file) return; if (Array.isArray(file) || file instanceof FileList) { for (const f of file) { this._add(f); } } else { this._add(file); } } public async convertAll() { const promiseFns = this.files.map((f) => () => f.convert()); const coreCount = navigator.hardwareConcurrency || 4; const queue = new PQueue({ concurrency: coreCount }); await Promise.all(promiseFns.map((fn) => queue.add(fn))); } public async downloadAll() { if (files.files.length === 0) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const dlFiles: any[] = []; for (let i = 0; i < files.files.length; i++) { const file = files.files[i]; const result = file.result; if (!result) { error(["files"], "No result found"); continue; } let to = result.to; if (!to.startsWith(".")) to = `.${to}`; dlFiles.push({ name: file.file.name.replace(/\.[^/.]+$/, "") + to, lastModified: Date.now(), input: await result.file.arrayBuffer(), }); } const { downloadZip } = await import("client-zip"); const blob = await downloadZip(dlFiles, "converted.zip").blob(); const url = URL.createObjectURL(blob); const settings = JSON.parse(localStorage.getItem("settings") ?? "{}"); const filenameFormat = settings.filenameFormat || "VERT_%name%"; const format = (name: string) => { const date = new Date().toISOString(); return name .replace(/%date%/g, date) .replace(/%name%/g, "Multi") .replace(/%extension%/g, ""); }; const a = document.createElement("a"); a.href = url; a.download = `${format(filenameFormat)}.zip`; a.click(); URL.revokeObjectURL(url); a.remove(); } } export function setTheme(themeTo: "light" | "dark") { document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.add(themeTo); localStorage.setItem("theme", themeTo); log(["theme"], `set to ${themeTo}`); theme.set(themeTo); // Lock dark reader if it's set to dark mode if (themeTo === "dark") { const lock = document.createElement("meta"); lock.name = "darkreader-lock"; document.head.appendChild(lock); } else { const lock = document.querySelector('meta[name="darkreader-lock"]'); if (lock) lock.remove(); } } export function setEffects(effectsEnabled: boolean) { localStorage.setItem("effects", effectsEnabled.toString()); log(["effects"], `set to ${effectsEnabled}`); effects.set(effectsEnabled); } export const files = new Files(); export const showGradient = writable(true); export const gradientColor = writable(""); export const goingLeft = writable(false); export const dropping = writable(false); export const vertdLoaded = writable(false); export const dropdownStates = writable>({}); export const isMobile = writable(false); export const effects = writable(true); export const theme = writable<"light" | "dark">("light"); export const locale = writable(getLocale()); export const availableLocales = { en: "English", es: "Español", fr: "Français", de: "Deutsch", it: "Italiano", ba: "Bosanski", hr: "Hrvatski", id: "Bahasa Indonesia", tr: "Türkçe", ja: "日本語", ko: "한국어", el: "Ελληνικά", "zh-Hans": "简体中文", "zh-Hant": "繁體中文", "pt-BR": "Português (Brasil)", }; export function updateLocale(newLocale: string) { if (!Object.keys(availableLocales).includes(newLocale)) newLocale = "en"; log(["locale"], `set to ${newLocale}`); localStorage.setItem("locale", newLocale); // @ts-expect-error shush setLocale(newLocale, { reload: false }); // @ts-expect-error shush locale.set(newLocale); } export function link( tag: string | string[], text: string, links: string | string[], newTab?: boolean | boolean[], className?: string | string[], ) { if (!text) return ""; const tags = Array.isArray(tag) ? tag : [tag]; const linksArr = Array.isArray(links) ? links : [links]; const newTabArr = Array.isArray(newTab) ? newTab : [newTab]; const classArr = Array.isArray(className) ? className : [className]; let result = text; tags.forEach((t, i) => { const link = linksArr[i] ?? "#"; const target = newTabArr[i] ? 'target="_blank" rel="noopener noreferrer"' : ""; const cls = classArr[i] ? `class="${classArr[i]}"` : ""; const regex = new RegExp(`\\[${t}\\](.*?)\\[\\/${t}\\]`, "g"); result = result.replace( regex, (_, inner) => `${inner}`, ); }); return result; } export function sanitize( html: string, allowedTags: string[] = ["a", "b", "code", "br"], ): string { return sanitizeHtml(html, { allowedTags: allowedTags, allowedAttributes: { a: ["href", "target", "rel", "class"], "*": ["class"], }, allowedSchemes: ["http", "https", "mailto", "blob"], }); } /** * Binary search for a max value without knowing the exact value, only that it * can be under or over It dose not test every number but instead looks for * 1,2,4,8,16,32,64,128,96,95 to figure out that you thought about #96 from * 0-infinity * * @example findFirstPositive(x => matchMedia(`(max-resolution: ${x}dpi)`).matches) * @author Jimmy Wärting * @see {@link https://stackoverflow.com/a/72124984/1008999} * @param {function} f The function to run the test on (should return truthy or falsy values) * @param {bigint} [b=1] Where to start looking from * @param {function} d privately used to calculate the next value to test * @returns {bigint} Integer */ function findFirstPositive( f: (x: bigint) => number, b = 1n, d = (e: bigint, g: bigint, c?: bigint): bigint => g < e ? -1n : 0 < f((c = (e + g) >> 1n)) ? c == e || 0 >= f(c - 1n) ? c : d(e, c - 1n) : d(c + 1n, g), ): bigint { for (; 0 >= f(b); b <<= 1n); return d(b >> 1n, b) - 1n; } export const getMaxArrayBufferSize = (): number => { if (typeof window === "undefined") return 2 * GB; // default for SSR // check cache first const cached = localStorage.getItem("maxArrayBufferSize"); if (cached) { const parsed = Number(cached); log(["converters"], `using cached max ArrayBuffer size: ${parsed} bytes`); if (!isNaN(parsed) && parsed > 0) return parsed; } // detect max size using binary search const maxSize = findFirstPositive((x) => { try { new ArrayBuffer(Number(x)); return 0; // false = can allocate } catch { return 1; // true = cannot allocate } }); const result = Number(maxSize); localStorage.setItem("maxArrayBufferSize", result.toString()); log(["converters"], `detected max ArrayBuffer size: ${result} bytes`); return result; }; export const MAX_ARRAY_BUFFER_SIZE = getMaxArrayBufferSize(); ================================================ FILE: src/lib/types/conversion-worker.ts ================================================ import { VertFile } from "./file.svelte"; interface ConvertMessage { type: "convert"; input: { file: File; name: string; from: string; to: string; } | VertFile; to: string; compression: number | null; keepMetadata?: boolean; } interface FinishedMessage { type: "finished"; output: ArrayBufferLike | Uint8Array; zip?: boolean; } interface LoadMessage { type: "load"; wasm: ArrayBuffer; } interface LoadedMessage { type: "loaded"; } interface ReadyMessage { type: "ready"; } interface ErrorMessage { type: "error"; error: string; } export type WorkerMessage = ( | ConvertMessage | FinishedMessage | LoadMessage | LoadedMessage | ReadyMessage | ErrorMessage ) & { id: string; // unused? rn just using file id, probably meant to be incrementing w/ every message posted? }; ================================================ FILE: src/lib/types/file.svelte.ts ================================================ import { byNative, converters } from "$lib/converters"; import type { Converter } from "$lib/converters/converter.svelte"; import { m } from "$lib/paraglide/messages"; import { ToastManager } from "$lib/util/toast.svelte"; import type { Component } from "svelte"; import { MAX_ARRAY_BUFFER_SIZE } from "$lib/store/index.svelte"; export class VertFile { public id: string = Math.random().toString(36).slice(2, 8); public readonly file: File; public get from() { return ("." + this.file.name.split(".").pop() || "").toLowerCase(); } public get name() { return this.file.name; } public progress = $state(0); public result = $state(null); public to = $state(""); public blobUrl = $state(); public processing = $state(false); public cancelled = $state(false); public converters: Converter[] = []; public isZip = $state(() => this.from === ".zip"); public findConverters(supportedFormats: string[] = [this.from]) { const converter = this.converters .filter((converter) => converter .formatStrings() .map((f) => supportedFormats.includes(f)), ) .sort(byNative(this.from)); return converter; } public findConverter() { // zip will always only be added if there's one converter that supports all files - handled in store's _handleZipFile() if (this.isZip()) return this.converters[0]; const converter = this.converters.find((converter) => { if ( !converter.formatStrings().includes(this.from) || !converter.formatStrings().includes(this.to) ) { return false; } const theirFrom = converter.supportedFormats.find( (f) => f.name === this.from, ); const theirTo = converter.supportedFormats.find( (f) => f.name === this.to, ); if (!theirFrom || !theirTo) return false; if (!theirFrom.isNative && !theirTo.isNative) return false; return true; }); return converter; } public isLarge(): boolean { return this.file.size > MAX_ARRAY_BUFFER_SIZE; } public supportsStreaming(): boolean { // only vertd (video/gif -> video/gif) supports streaming // rest of converters need entire file in memory, limited by ArrayBuffer limits const converter = this.findConverter(); return converter?.name === "vertd"; } constructor(file: File, to: string, blobUrl?: string) { const ext = file.name.split(".").pop(); const newFile = new File( [file.slice(0, file.size, file.type)], `${file.name.split(".").slice(0, -1).join(".")}.${ext?.toLowerCase()}`, ); this.file = newFile; this.to = to.startsWith(".") ? to : `.${to}`; this.converters = converters.filter((c) => c.formatStrings().includes(this.from), ); this.convert = this.convert.bind(this); this.download = this.download.bind(this); this.blobUrl = blobUrl; } // eslint-disable-next-line @typescript-eslint/no-explicit-any public async convert(...args: any[]) { if (!this.converters.length) throw new Error("No converters found"); const converter = this.findConverter(); if (!converter) throw new Error("No converter found"); this.result = null; this.progress = 0; this.processing = true; this.cancelled = false; let res; try { // for zips: extract > convert each > re-zip // else convert normally res = this.isZip() ? await this.convertZip(converter) : await converter.convert(this, this.to, ...args); this.result = res; } catch (err) { if (!this.cancelled) this.toastErr(err); this.result = null; } this.processing = false; return res; } private async convertZip(converter: Converter): Promise { const { extractZip, createZip } = await import("$lib/util/zip"); const { default: PQueue } = await import("p-queue"); const entries = await extractZip(this.file); const totalFiles = entries.length; const fileProgress: number[] = new Array(totalFiles).fill(0); const convertedFiles: File[] = []; const queue = new PQueue({ concurrency: navigator.hardwareConcurrency || 4, }); const updateProgress = () => { const totalProgress = fileProgress.reduce((sum, p) => sum + p, 0); this.progress = Math.round(totalProgress / totalFiles); }; // convert all files in the zip await queue.addAll( entries.map(({ filename, data }, index) => async () => { if (this.cancelled) { throw new Error("Conversion cancelled"); } const file = new File([new Uint8Array(data)], filename, { type: "application/octet-stream", }); const tempVFile = new VertFile(file, this.to); tempVFile.converters = [converter]; if (converter.reportsProgress) { // track progress of individual files const progressInterval = setInterval(() => { fileProgress[index] = tempVFile.progress; updateProgress(); }, 100); try { const converted = await converter.convert( tempVFile, this.to, ); let outputExt = this.to; if (!outputExt.startsWith(".")) outputExt = `.${outputExt}`; convertedFiles[index] = new File( [await converted.file.arrayBuffer()], converted.name, ); fileProgress[index] = 100; updateProgress(); } finally { clearInterval(progressInterval); } } else { // else track progress via completions only const converted = await converter.convert( tempVFile, this.to, ); let outputExt = this.to; if (!outputExt.startsWith(".")) outputExt = `.${outputExt}`; convertedFiles[index] = new File( [await converted.file.arrayBuffer()], converted.name, ); fileProgress[index] = 100; updateProgress(); } }), ); // return zip of converted files const resultArray = await createZip(convertedFiles); const outputFilename = this.file.name.replace(/\.[^/.]+$/, ".zip"); const resultFile = new File( [new Uint8Array(resultArray)], outputFilename, ); return new VertFile(resultFile, ".zip"); } public async cancel() { if (!this.processing) return; const converter = this.findConverter(); if (!converter) throw new Error("No converter found"); this.cancelled = true; try { await converter.cancel(this); this.processing = false; this.result = null; } catch (err) { this.toastErr(err); } } private toastErr(err: unknown) { type ToastMsg = { component: Component; additional: unknown; }; const castedErr = err as Error | string | ToastMsg; let toastMsg: string | ToastMsg = ""; if (typeof castedErr === "string") { toastMsg = castedErr; } else if (castedErr instanceof Error) { toastMsg = castedErr.message; } else { toastMsg = castedErr; } // ToastManager.add({ // type: "error", // message: // typeof toastMsg === "string" // ? m["workers.errors.general"]({ // file: this.file.name, // message: toastMsg, // }) // : toastMsg, // }); if (typeof toastMsg === "string") { ToastManager.add({ type: "error", message: m["workers.errors.general"]({ file: this.file.name, message: toastMsg, }), }); } else { ToastManager.add({ type: "error", message: toastMsg.component, additional: toastMsg.additional, }); } } public async download() { if (!this.result) throw new Error("No result found"); // give the freedom to the converter to set the extension (ie. pandoc uses this to output zips) let to = this.result.to; if (!to.startsWith(".")) to = `.${to}`; const settings = JSON.parse(localStorage.getItem("settings") ?? "{}"); const filenameFormat = settings.filenameFormat || "VERT_%name%"; const format = (name: string) => { const date = new Date().toISOString(); const baseName = this.file.name.replace(/\.[^/.]+$/, ""); const originalExtension = this.file.name.split(".").pop()!; return name .replace(/%date%/g, date) .replace(/%name%/g, baseName) .replace(/%extension%/g, originalExtension); }; const blob = URL.createObjectURL( new Blob([await this.result.file.arrayBuffer()], { // type: to.slice(1), type: "application/octet-stream", // use generic type to prevent browsers changing extension }), ); const a = document.createElement("a"); a.href = blob; a.download = `${format(filenameFormat)}${to}`; // force it to not open in a new tab a.target = "_blank"; a.style.display = "none"; a.click(); URL.revokeObjectURL(blob); a.remove(); } public hash(): Promise { const stream = this.file.stream(); const hashes = new Set(); const reader = stream.getReader(); return new Promise((resolve, reject) => { function processChunk() { reader.read().then(({ done, value }) => { if (done) { const combinedHash = Array.from(hashes).sort().join(""); resolve(combinedHash); return; } crypto.subtle .digest("SHA-256", value) .then((hashBuffer) => { const hashArray = Array.from( new Uint8Array(hashBuffer), ); const hashHex = hashArray .map((b) => b.toString(16).padStart(2, "0")) .join(""); hashes.add(hashHex); processChunk(); }) .catch((err) => { reject(err); }); }); } processChunk(); }); } } export interface Categories { [key: string]: { formats: string[]; canConvertTo?: string[]; }; } ================================================ FILE: src/lib/types/index.ts ================================================ export * from "./file.svelte"; export * from "./util"; export * from "./conversion-worker"; ================================================ FILE: src/lib/types/util.ts ================================================ export type OmitBetterStrict = T extends unknown ? Pick> : never; ================================================ FILE: src/lib/util/animation.ts ================================================ import { isMobile, effects } from "$lib/store/index.svelte"; import type { AnimationConfig, FlipParams } from "svelte/animate"; import { cubicOut } from "svelte/easing"; import { fade as svelteFade, fly as svelteFly, type FadeParams, type FlyParams, } from "svelte/transition"; // Subscribe to stores let effectsEnabled = true; let isMobileDevice = false; export function initStores() { effects.subscribe((value) => { effectsEnabled = value; }); isMobile.subscribe((value) => { isMobileDevice = value; }); } export const transition = "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)"; export const duration = 500; export function fade(node: HTMLElement, options: FadeParams) { if (!effectsEnabled) return {}; const animation = svelteFade(node, options); return animation; } export function fly(node: HTMLElement, options: FlyParams) { if (!effectsEnabled || isMobileDevice) return {}; const animation = svelteFly(node, options); return animation; } // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export function is_function(thing: unknown): thing is Function { return typeof thing === "function"; } type Params = FlipParams & {}; /** * The flip function calculates the start and end position of an element and animates between them, translating the x and y values. * `flip` stands for [First, Last, Invert, Play](https://aerotwist.com/blog/flip-your-animations/). * * https://svelte.dev/docs/svelte-animate#flip */ export function flip( node: HTMLElement, { from, to }: { from: DOMRect; to: DOMRect }, params: Params = {}, ): AnimationConfig { const style = getComputedStyle(node); const transform = style.transform === "none" ? "" : style.transform; const [ox, oy] = style.transformOrigin.split(" ").map(parseFloat); const dx = from.left + (from.width * ox) / to.width - (to.left + ox); const dy = from.top + (from.height * oy) / to.height - (to.top + oy); const { delay = 0, duration = (d) => Math.sqrt(d) * 120, easing = cubicOut, } = params; return { delay, duration: is_function(duration) ? duration(Math.sqrt(dx * dx + dy * dy)) : duration, easing, css: (_t, u) => { const x = u * dx; const y = u * dy; // const sx = scale ? t + (u * from.width) / to.width : 1; // const sy = scale ? t + (u * from.height) / to.height : 1; return `transform: ${transform} translate(${x}px, ${y}px);`; }, }; } ================================================ FILE: src/lib/util/consts.ts ================================================ import { PUB_DISABLE_ALL_EXTERNAL_REQUESTS, PUB_ENV } from "$env/static/public"; export const GITHUB_URL_VERT = "https://github.com/VERT-sh/VERT"; export const GITHUB_URL_VERTD = "https://github.com/VERT-sh/vertd"; export const GITHUB_API_URL = "https://api.github.com/repos/VERT-sh/VERT"; export const DISCORD_URL = "https://discord.gg/kqevGxYPak"; export const VERT_NAME = PUB_ENV === "development" ? "VERT Local" : PUB_ENV === "nightly" ? "VERT Nightly" : "VERT.sh"; export const CONTACT_EMAIL = "hello@vert.sh"; // i'm not entirely sure this should be in consts.ts, but it is technically a constant as .env is static for VERT export const DISABLE_ALL_EXTERNAL_REQUESTS = PUB_DISABLE_ALL_EXTERNAL_REQUESTS === "true"; export const GB = 1024 * 1024 * 1024; ================================================ FILE: src/lib/util/ip.ts ================================================ import { browser } from "$app/environment"; export interface IpInfo { ip: string; network: string; version: string; city: string; region: string; region_code: string; country: string; country_name: string; country_code: string; country_code_iso3: string; country_capital: string; country_tld: string; continent_code: string; in_eu: boolean; postal: string; latitude: number; longitude: number; timezone: string; utc_offset: string; country_calling_code: string; currency: string; currency_name: string; languages: string; country_area: number; country_population: number; asn: string; org: string; } export const ip = async (): Promise => { try { if (browser) { const item = localStorage.getItem("ipinfo"); if (item) { return JSON.parse(item); } } const res = await fetch("https://ipapi.co/json/").then((r) => r.json()); if (browser) { localStorage.setItem("ipinfo", JSON.stringify(res)); } return res; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_) { return { ip: "127.0.0.1", asn: "AS0", city: "Localhost", continent_code: "NA", country: "US", country_calling_code: "+1", country_capital: "Washington", country_code: "US", country_code_iso3: "USA", country_name: "United States", country_population: 0, currency: "USD", currency_name: "Dollar", languages: "en-US,es-US,haw,fr", latitude: 0, longitude: 0, network: "Unknown", postal: "00000", region: "Local", region_code: "LOC", country_area: 0, timezone: "America/New_York", utc_offset: "-0500", version: "IPv4", in_eu: false, org: "Localhost", country_tld: ".us", }; } }; ================================================ FILE: src/lib/util/logger.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { browser } from "$app/environment"; const randomColorFromStr = (str: string) => { // generate a pleasant color from a string, using HSL let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } const h = hash % 360; return `hsl(${h}, 75%, 71%)`; }; const whiteOrBlack = (hsl: string) => { // determine if the text should be white or black based on the background color const [, , l] = hsl .replace("hsl(", "") .replace(")", "") .split(",") .map((v) => parseInt(v)); return l > 70 ? "black" : "white"; }; export const log = (prefix: string | string[], ...args: any[]) => { const prefixes = Array.isArray(prefix) ? prefix : [prefix]; if (!browser) return console.log(prefixes.map((p) => `[${p}]`).join(" "), ...args); const prefixesWithMeta = prefixes.map((p) => ({ prefix: p, bgColor: randomColorFromStr(p), textColor: whiteOrBlack(randomColorFromStr(p)), })); console.log( `%c${prefixesWithMeta.map(({ prefix }) => prefix).join(" %c")}`, ...prefixesWithMeta.map( ({ bgColor, textColor }, i) => `color: ${textColor}; background-color: ${bgColor}; margin-left: ${i === 0 ? 0 : -6}px; padding: 0px 4px 0 4px; border-radius: 0px 9999px 9999px 0px;`, ), ...args, ); }; export const error = (prefix: string | string[], ...args: any[]) => { const prefixes = Array.isArray(prefix) ? prefix : [prefix]; if (!browser) return console.error(prefixes.map((p) => `[${p}]`).join(" "), ...args); const prefixesWithMeta = prefixes.map((p) => ({ prefix: p, bgColor: randomColorFromStr(p), textColor: whiteOrBlack(randomColorFromStr(p)), })); console.error( `%c${prefixesWithMeta.map(({ prefix }) => prefix).join(" %c")}`, ...prefixesWithMeta.map( ({ bgColor, textColor }, i) => `color: ${textColor}; background-color: ${bgColor}; margin-left: ${i === 0 ? 0 : -6}px; padding: 0px 4px 0 4px; border-radius: 0px 9999px 9999px 0px;`, ), ...args, ); }; ================================================ FILE: src/lib/util/parse/ani.ts ================================================ // THIS CODE IS FROM https://github.com/captbaritone/webamp/blob/15b0312cb794973a0e615d894df942452e920c36/packages/ani-cursor/src/parser.ts // LICENSED UNDER MIT. (c) Jordan Eldredge and Webamp contributors // this code is ripped from their project because i didn't want to // re-invent the wheel, BUT the library they provide (ani-cursor) // doesn't expose the internals. import { RIFFFile } from "riff-file"; import { unpackArray, unpackString } from "byte-data"; type Chunk = { format: string; chunkId: string; chunkData: { start: number; end: number; }; subChunks: Chunk[]; }; // https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3 type AniMetadata = { cbSize: number; // Data structure size (in bytes) nFrames: number; // Number of images (also known as frames) stored in the file nSteps: number; // Number of frames to be displayed before the animation repeats iWidth: number; // Width of frame (in pixels) iHeight: number; // Height of frame (in pixels) iBitCount: number; // Number of bits per pixel nPlanes: number; // Number of color planes iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units) bfAttributes: number; // ANI attribute bit flags }; type ParsedAni = { rate: number[] | null; seq: number[] | null; images: Uint8Array[]; metadata: AniMetadata; artist: string | null; title: string | null; }; const DWORD = { bits: 32, be: false, signed: false, fp: false }; export function parseAni(arr: Uint8Array): ParsedAni { const riff = new RIFFFile(); riff.setSignature(arr); const signature = riff.signature as Chunk; if (signature.format !== "ACON") { throw new Error( `Expected format. Expected "ACON", got "${signature.format}"`, ); } // Helper function to get a chunk by chunkId and transform it if it's non-null. function mapChunk( chunkId: string, mapper: (chunk: Chunk) => T, ): T | null { const chunk = riff.findChunk(chunkId) as Chunk | null; return chunk == null ? null : mapper(chunk); } function readImages(chunk: Chunk, frameCount: number): Uint8Array[] { return chunk.subChunks.slice(0, frameCount).map((c) => { if (c.chunkId !== "icon") { throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`); } return arr.slice(c.chunkData.start, c.chunkData.end); }); } const metadata = mapChunk("anih", (c) => { const words = unpackArray( arr, DWORD, c.chunkData.start, c.chunkData.end, ); return { cbSize: words[0], nFrames: words[1], nSteps: words[2], iWidth: words[3], iHeight: words[4], iBitCount: words[5], nPlanes: words[6], iDispRate: words[7], bfAttributes: words[8], }; }); if (metadata == null) { throw new Error("Did not find anih"); } const rate = mapChunk("rate", (c) => { return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); }); // chunkIds are always four chars, hence the trailing space. const seq = mapChunk("seq ", (c) => { return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); }); const lists = riff.findChunk("LIST", true) as Chunk[] | null; const imageChunk = lists?.find((c) => c.format === "fram"); if (imageChunk == null) { throw new Error("Did not find fram LIST"); } let images = readImages(imageChunk, metadata.nFrames); let title = null; let artist = null; const infoChunk = lists?.find((c) => c.format === "INFO"); if (infoChunk != null) { infoChunk.subChunks.forEach((c) => { switch (c.chunkId) { case "INAM": title = unpackString( arr, c.chunkData.start, c.chunkData.end, ); break; case "IART": artist = unpackString( arr, c.chunkData.start, c.chunkData.end, ); break; case "LIST": // Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason? if (c.format === "fram") { images = readImages(c, metadata.nFrames); } break; default: // Unexpected subchunk } }); } return { images, rate, seq, metadata, artist, title }; } ================================================ FILE: src/lib/util/parse/icns/index.ts ================================================ ================================================ FILE: src/lib/util/sw.ts ================================================ import { browser } from "$app/environment"; export interface CacheInfo { totalSize: number; fileCount: number; files: Array<{ url: string; size: number; type: string; }>; } class ServiceWorkerManager { private registration: ServiceWorkerRegistration | null = null; private initialized = false; async init(): Promise { if (!browser || !("serviceWorker" in navigator) || this.initialized) { return; } try { this.registration = await navigator.serviceWorker.register( "/sw.js", { scope: "/", }, ); this.initialized = true; } catch (error) { console.error( "[SW Manager] service worker registration failed:", error, ); } } async getCacheInfo(): Promise { if (!this.registration || !navigator.serviceWorker.controller) { console.warn( "[SW Manager] no service worker available for cache info", ); return { totalSize: 0, fileCount: 0, files: [] }; } return new Promise((resolve, reject) => { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = (event) => { resolve(event.data); }; setTimeout(() => { reject(new Error("Timeout waiting for cache info")); }, 5000); navigator.serviceWorker?.controller?.postMessage( { type: "GET_CACHE_INFO" }, [messageChannel.port2], ); }); } async clearCache(): Promise { if (!this.registration || !navigator.serviceWorker.controller) { throw new Error("No service worker available for cache clearing"); } return new Promise((resolve, reject) => { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = (event) => { if (event.data.success) { resolve(); } else { reject( new Error(event.data.error || "Failed to clear cache"), ); } }; setTimeout(() => { reject(new Error("Timeout waiting for cache clear")); }, 10000); navigator.serviceWorker?.controller?.postMessage( { type: "CLEAR_CACHE" }, [messageChannel.port2], ); }); } formatSize(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; } } export const swManager = new ServiceWorkerManager(); // Auto-initialize when imported if (browser) { swManager.init(); } ================================================ FILE: src/lib/util/toast.svelte.ts ================================================ import type { Component } from "svelte"; export type ToastType = "success" | "error" | "info" | "warning"; // export interface Toast< // T = unknown, // U extends string | ToastComponent = string | ToastComponent, // > { // id: number; // type: ToastType; // message: U; // disappearing: boolean; // durations: { // enter: number; // stay: number; // exit: number; // }; // additional: U extends string ? undefined : T; // } type BaseToast = { id: number; type: ToastType; disappearing: boolean; durations: { enter: number; stay: number; exit: number; }; }; export type StringToast = BaseToast & { message: string; }; export type ComponentToast = BaseToast & { message: ToastComponent; additional: T; }; export type Toast = StringToast | ComponentToast; export type ToastProps = Omit, "disappearing">; export type ToastExports = { title?: string; }; export type ToastComponent = Component, ToastExports>; // export interface ToastOptions { // type?: ToastType; // message: string | ToastComponent; // disappearing?: boolean; // durations?: { // enter?: number; // stay?: number; // exit?: number; // }; // additional?: T; // } type RecursivePartial = { [P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial[] : T[P] extends object | undefined ? RecursivePartial : T[P]; }; type BaseToastOptions = Omit, "id"> & { disappearing?: boolean; }; export type StringToastOptions = BaseToastOptions & { message: string; }; export type ComponentToastOptions = BaseToastOptions & { message: ToastComponent; additional: T; }; export type ToastOptions = | StringToastOptions | ComponentToastOptions; // const toasts = writable([]); // let toastId = 0; // function addToast( // type: ToastType, // message: string | Component, // disappearing?: boolean, // durations?: { enter: number; stay: number; exit: number }, // ) { // const id = toastId++; // durations = durations ?? { // enter: 300, // stay: disappearing || disappearing === undefined ? 5000 : 86400000, // 24h cause why not // exit: 500, // }; // const newToast: Toast = { // id, // type, // message, // disappearing: disappearing ?? true, // durations, // }; // toasts.update((currentToasts) => [...currentToasts, newToast]); // setTimeout( // () => { // removeToast(id); // }, // durations.enter + durations.stay + durations.exit, // ); // return id; // } // function removeToast(id: number) { // toasts.update((currentToasts) => // currentToasts.filter((toast) => toast.id !== id), // ); // } // export { toasts, addToast, removeToast }; // const DURATION_DEFAULTS = { // enter: 300, // stay: 5000, // exit: 500, // }; const durationDefault = (disappearing: boolean) => ({ enter: 300, stay: disappearing ? 5000 : 86400000, // 24h cause why not exit: 500, }); // const toastState = { // toasts: $state([]), // }; class ToastState { private pId = $state(0); private pToasts = $state[]>([]); public add(toast: Toast) { this.pToasts.push(toast as Toast); } public remove(id: number) { this.pToasts = this.pToasts.filter((toast) => toast.id !== id); } public id(): number { return this.pId++; } public get toasts() { return this.pToasts; } } export class ToastManager { static pToasts = new ToastState(); public static add(toastOptions: ToastOptions): number { const id = this.pToasts.id(); const { type = "info", disappearing = true, durations: d = durationDefault(toastOptions.disappearing ?? true), } = toastOptions; const durations = { ...durationDefault(disappearing), ...d, }; if (typeof toastOptions.message === "string") { const newToast: StringToast = { id, type, message: toastOptions.message, disappearing, durations, }; this.pToasts.add(newToast); } else { const newToast: ComponentToast = { id, type, message: toastOptions.message, disappearing, durations, additional: (toastOptions as ComponentToastOptions) .additional, }; this.pToasts.add(newToast); } setTimeout( () => { this.remove(id); }, durations.enter + durations.stay + durations.exit, ); return id; } public static remove(id: number) { this.pToasts.remove(id); } public static get toasts() { return this.pToasts.toasts; } } ================================================ FILE: src/lib/util/zip.ts ================================================ import { error, log } from "$lib/util/logger"; import { unzip } from "fflate"; import { downloadZip } from "client-zip"; export interface ZipEntry { filename: string; data: Uint8Array; } export async function extractZip(file: File): Promise { log(["zip"], `extracting zip: ${file.name}`); const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); return new Promise((resolve, reject) => { unzip(uint8Array, (err, unzipped) => { if (err) { error(["zip"], `failed to extract zip: ${err.message}`); reject(new Error(`Failed to extract zip: ${err.message}`)); return; } const entries = Object.entries(unzipped) .filter(([filename]) => !ignoreEntry(filename)) .map(([filename, data]) => ({ filename, data: new Uint8Array(data), })); log(["zip"], `extracted ${entries.length} entries from ${file.name}`); resolve(entries); }); }); } export async function createZip(files: File[]): Promise { log(["zip"], `creating zip with ${files.length} files`); const zipBlob = await downloadZip(files).blob(); return new Uint8Array(await zipBlob.arrayBuffer()); } export function ignoreEntry(filename: string): boolean { return ( filename.startsWith(".") || filename.includes("/__MACOSX/") || filename.endsWith("/") ); } ================================================ FILE: src/lib/workers/magick.ts ================================================ import { initializeImageMagick, MagickFormat, MagickImage, MagickImageCollection, MagickReadSettings, type IMagickImage, } from "@imagemagick/magick-wasm"; import { makeZip } from "client-zip"; import { parseAni } from "$lib/util/parse/ani"; import { parseIcns } from "vert-wasm"; import type { WorkerMessage } from "$lib/types"; let magickInitialized = false; self.postMessage({ type: "ready", id: "0" }); const handleMessage = async ( message: WorkerMessage, ): Promise> => { switch (message.type) { case "load": { try { if (!message.wasm || !(message.wasm instanceof ArrayBuffer)) { throw new Error( `Invalid WASM data: ${typeof message.wasm}`, ); } const wasmBytes = new Uint8Array(message.wasm); await initializeImageMagick(wasmBytes); magickInitialized = true; return { type: "loaded" }; } catch (error) { return { type: "error", error: `error loading magick-wasm: ${(error as Error).message}`, }; } } case "convert": { if (!magickInitialized) { return { type: "error", error: "magick-wasm not initialized" }; } const compression: number | undefined = message.compression ?? undefined; const keepMetadata: boolean = message.keepMetadata ?? true; if (!message.to.startsWith(".")) message.to = `.${message.to}`; message.to = message.to.toLowerCase(); if (message.to === ".jfif") message.to = ".jpeg"; let from = message.input.from; if (from === ".jfif") from = ".jpeg"; if (from === ".fit") from = ".fits"; const buffer = await message.input.file.arrayBuffer(); // special ico handling to split them all into separate images if (from === ".ico") { const imgs = MagickImageCollection.create(); while (true) { try { const img = MagickImage.create( new Uint8Array(buffer), new MagickReadSettings({ format: MagickFormat.Ico, frameIndex: imgs.length, }), ); imgs.push(img); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_) { break; } } if (imgs.length === 0) { return { type: "error", error: `Failed to read ICO -- no images found inside?`, }; } const convertedImgs: Uint8Array[] = []; await Promise.all( imgs.map(async (img, i) => { const output = await magickConvert( img, message.to, keepMetadata, compression, ); convertedImgs[i] = output; }), ); const zip = makeZip( convertedImgs.map( (img, i) => new File( [new Uint8Array(img)], `image${i}.${message.to.slice(1)}`, ), ), "images.zip", ); // read the ReadableStream to the end const zipBytes = await readToEnd(zip.getReader()); imgs.dispose(); return { type: "finished", output: zipBytes, zip: true, }; } else if (from === ".ani") { console.log("Parsing ANI file"); try { const parsedAni = parseAni(new Uint8Array(buffer)); const files: File[] = []; await Promise.all( parsedAni.images.map(async (img, i) => { const blob = await magickConvert( MagickImage.create( img, new MagickReadSettings({ format: MagickFormat.Ico, }), ), message.to, keepMetadata, compression, ); files.push( new File( [new Uint8Array(blob)], `image${i}${message.to}`, ), ); }), ); const zip = makeZip(files, "images.zip"); const zipBytes = await readToEnd(zip.getReader()); return { type: "finished", output: zipBytes, zip: true, }; } catch (e) { console.error(e); } } else if (from === ".icns") { const icns: Uint8Array[] = parseIcns(new Uint8Array(buffer)); if (typeof icns === "string") { return { type: "error", error: `Failed to read ICNS -- ${icns}`, }; } const formats = [ MagickFormat.Png, MagickFormat.Jpeg, MagickFormat.Rgba, MagickFormat.Rgb, ]; const outputs: Uint8Array[] = []; for (const file of icns) { for (const format of formats) { try { const img = MagickImage.create( file, new MagickReadSettings({ format: format, }), ); const converted = await magickConvert( img, message.to, keepMetadata, compression, ); outputs.push(converted); break; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_) { continue; } } } const zip = makeZip( outputs.map( (img, i) => new File( [new Uint8Array(img)], `image${i}.${message.to.slice(1)}`, ), ), "images.zip", ); const zipBytes = await readToEnd(zip.getReader()); return { type: "finished", output: zipBytes, zip: true, }; } // build frames of animated formats (webp/gif) // APNG does not work on magick-wasm since it needs ffmpeg built-in (not in magick-wasm) - handle in ffmpeg if ( (from === ".webp" || from === ".gif") && (message.to === ".gif" || message.to === ".webp") ) { const collection = MagickImageCollection.create( new Uint8Array(buffer), ); const format = message.to === ".gif" ? MagickFormat.Gif : MagickFormat.WebP; const result = await new Promise((resolve) => { collection.write(format, (output) => { resolve(structuredClone(output)); }); }); collection.dispose(); return { type: "finished", output: result, }; } const img = MagickImage.create( new Uint8Array(buffer), new MagickReadSettings({ format: from.slice(1).toUpperCase() as MagickFormat, }), ); const converted = await magickConvert( img, message.to, keepMetadata, compression, ); return { type: "finished", output: converted, }; } default: return { type: "error", error: `Unknown message type: ${message.type}`, }; } }; const readToEnd = async (reader: ReadableStreamDefaultReader) => { const chunks: Uint8Array[] = []; let done = false; while (!done) { const { value, done: d } = await reader.read(); if (value) chunks.push(value); done = d; } const blob = new Blob( chunks.map((chunk) => new Uint8Array(chunk)), { type: "application/zip" }, ); const arrayBuffer = await blob.arrayBuffer(); return new Uint8Array(arrayBuffer); }; const magickConvert = async ( img: IMagickImage, to: string, keepMetadata: boolean, compression?: number, ) => { let fmt = to.slice(1).toUpperCase(); if (fmt === "JFIF") fmt = "JPEG"; // ICO size clamp to avoid WidthOrHeightExceedsLimit if (fmt === "ICO") { const max = 256; const w = img.width; const h = img.height; if (w > max || h > max) { const scale = max / Math.max(w, h); const newW = Math.max(1, Math.round(w * scale)); const newH = Math.max(1, Math.round(h * scale)); img.resize(newW, newH); } } const result = await new Promise((resolve, reject) => { try { // magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480) if (compression) img.quality = compression; if (!keepMetadata) img.strip(); img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { resolve(structuredClone(o)); }); } catch (error) { reject(error); } }); return result; }; onmessage = async (e) => { const message = e.data; try { const res = await handleMessage(message); if (!res) return; postMessage({ ...res, id: message.id, }); } catch (e) { postMessage({ type: "error", error: e, id: message.id, }); } }; ================================================ FILE: src/lib/workers/pandoc.ts ================================================ import type { WorkerMessage } from "$lib/types"; import * as wasiShim from "@bjorn3/browser_wasi_shim"; import * as zip from "client-zip"; self.onmessage = async (e) => { const message = e.data; try { const res = await handleMessage(message); if (!res) return; self.postMessage({ ...res, id: message.id, }); } catch (e) { self.postMessage({ type: "error", error: e, id: message.id, }); } }; let wasm: ArrayBuffer = null!; type Format = | ".md" | ".docx" | ".csv" | ".tsv" | ".json" | ".doc" | ".rtf" | ".rst" | ".epub" | ".odt" | ".docbook" | ".html" | ".markdown"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleMessage = async (message: WorkerMessage): Promise => { switch (message.type) { case "load": { wasm = message.wasm; postMessage({ type: "loaded", id: "0" }); break; } case "convert": { try { const { to: ext, input } = message; const file = input.file as File; const to = ext as Format; if (to === ".rtf") { throw new Error( "Converting into RTF is currently not supported.", ); } const buf = new Uint8Array(await file.arrayBuffer()); const args = `-f ${formatToReader(`.${file.name.split(".").pop() || ""}` as Format)} -t ${formatToReader(to)} --extract-media=.`; const [result, stderr, zip] = await pandoc( args, buf, file.name, to, ); if (result.length === 0) { return { type: "error", error: stderr .replaceAll("\\n", "\n") .replaceAll('\\"', '"') .split('"') .slice(1, -1) .join('"'), errorKind: stderr.split(" ")[0], }; } return { type: "finished", output: result, isZip: zip, }; } catch (e) { console.error(e); return { type: "error", error: e }; } } } }; const formatToReader = (format: Format): string => { switch (format) { case ".md": case ".markdown": return "markdown"; case ".doc": case ".docx": return "docx"; case ".csv": return "csv"; case ".tsv": return "tsv"; case ".docbook": return "docbook"; case ".epub": return "epub"; case ".html": return "html"; case ".json": return "json"; case ".odt": return "odt"; case ".rtf": return "rtf"; case ".rst": return "rst"; } throw new Error(`Unsupported format: ${format}`); }; async function pandoc( args_str: string, in_data: Uint8Array, in_name: string, out_ext: string, ): Promise<[Uint8Array, string, boolean]> { if (!wasm) throw new Error("WASM not loaded"); let stderr = ""; const args = ["pandoc.wasm", "+RTS", "-H64m", "-RTS"]; const env: string[] = []; const in_file = new wasiShim.File(in_data, { readonly: true, }); const out_file = new wasiShim.File(new Uint8Array(), { readonly: false, }); const map = new Map([ ["in", in_file], ["out", out_file], ]); const root = new wasiShim.PreopenDirectory("/", map); const fds = [ new wasiShim.OpenFile( new wasiShim.File(new Uint8Array(), { readonly: true }), ), wasiShim.ConsoleStdout.lineBuffered((msg) => { console.log(`[WASI stdout] ${msg}`); }), wasiShim.ConsoleStdout.lineBuffered((msg) => { console.warn(`[WASI stderr] ${msg}`); stderr += msg + "\n"; }), root, new wasiShim.PreopenDirectory("/tmp", new Map()), ]; const wasi = new wasiShim.WASI(args, env, fds, { debug: false }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { instance }: { instance: any } = await WebAssembly.instantiate( wasm, { wasi_snapshot_preview1: wasi.wasiImport, }, ); wasi.initialize(instance); instance.exports.__wasm_call_ctors(); function memory_data_view() { return new DataView(instance.exports.memory.buffer); } const argc_ptr = instance.exports.malloc(4); memory_data_view().setUint32(argc_ptr, args.length, true); const argv = instance.exports.malloc(4 * (args.length + 1)); for (let i = 0; i < args.length; ++i) { const arg = instance.exports.malloc(args[i].length + 1); new TextEncoder().encodeInto( args[i], new Uint8Array(instance.exports.memory.buffer, arg, args[i].length), ); memory_data_view().setUint8(arg + args[i].length, 0); memory_data_view().setUint32(argv + 4 * i, arg, true); } memory_data_view().setUint32(argv + 4 * args.length, 0, true); const argv_ptr = instance.exports.malloc(4); memory_data_view().setUint32(argv_ptr, argv, true); instance.exports.hs_init_with_rtsopts(argc_ptr, argv_ptr); const args_ptr = instance.exports.malloc(args_str.length); new TextEncoder().encodeInto( args_str, new Uint8Array( instance.exports.memory.buffer, args_ptr, args_str.length, ), ); instance.exports.wasm_main(args_ptr, args_str.length); // list all files in / const openedPath = root.dir.path_open(0, BigInt(0), 0).fd_obj; const dirRet = openedPath.path_lookup(".", 0); const dir = dirRet.inode_obj; if (dir) { const opened = dir.path_open(0, BigInt(0), 0).fd_obj; if (!opened) { return [out_file.data, stderr, false]; } const fs = readRecursive(opened); // const media = fs.get("media"); // if (media && media.type === "folder") { // const file = new File( // [out_file.data], // `${in_name.split(".").slice(0, -1).join(".")}${out_ext}`, // ); // const zipped = await zipFiles(file, media.entries); // return [zipped, stderr, true]; // } // filter to folders const folders = [...fs.entries()].filter( (f) => f[0] !== "in" && f[0] !== "out", ); if (folders.length > 0) { const file = new File( [new Uint8Array(Array.from(out_file.data))], `${in_name.split(".").slice(0, -1).join(".")}${out_ext}`, ); const filteredMap = new Map(); for (const [name, entry] of folders) { filteredMap.set(name, entry); } const zipped = await zipFiles(file, filteredMap); return [zipped, stderr, true]; } } return [out_file.data, stderr, false]; } const zipFiles = async ( output: File, entries: PandocEntries, ): Promise => { const zipFormatted = pandocToFiles(entries); const zipped = zip.makeZip([...zipFormatted, output]); // read the ReadableStream to the end const reader = zipped.getReader(); const chunks: Uint8Array[] = []; let done = false; while (!done) { const { done: d, value } = await reader.read(); done = d; if (value) { chunks.push(value); } } const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return result; }; const pandocToFiles = (entries: PandocEntries, parent = ""): File[] => { const flattened: File[] = []; for (const [name, entry] of entries) { const fullPath = parent ? `${parent}/${name}` : name; if (entry.type === "folder") { const nestedFiles = pandocToFiles(entry.entries, fullPath); flattened.push(...nestedFiles); } else { const file = new File([new Uint8Array(Array.from(entry.data))], fullPath); flattened.push(file); } } return flattened; }; const readRecursive = (fd: wasiShim.Fd): PandocEntries => { const entries = new Map(); const stat = fd.fd_filestat_get().filestat; if (!stat) return entries; // eslint-disable-next-line @typescript-eslint/no-explicit-any const dir: any = fd.path_lookup(".", 0).inode_obj; if (!dir) return entries; const dirEntries: Map = dir.contents; const results = readRecursiveInternal(dirEntries); for (const [name, entry] of results) { entries.set(name, entry); } return entries; }; const readRecursiveInternal = ( contents: Map, ): PandocEntries => { const entries = new Map(); for (const [name, entry] of contents) { if (entry instanceof wasiShim.File) { const file: PandocFile = { data: entry.data, type: "file", }; entries.set(name, file); } else { const folder: PandocFolder = { entries: readRecursiveInternal( entry.contents as unknown as Map< string, wasiShim.File | wasiShim.Directory >, ), type: "folder", }; entries.set(name, folder); } } return entries; }; type PandocEntries = Map; interface PandocFile { data: Uint8Array; type: "file"; } interface PandocFolder { entries: PandocEntries; type: "folder"; } type PandocFsEntry = PandocFile | PandocFolder; ================================================ FILE: src/routes/+layout.server.ts ================================================ export const load = () => { const isAprilFools = new Date().getDate() === 1 && new Date().getMonth() === 3; return { isAprilFools }; }; ================================================ FILE: src/routes/+layout.svelte ================================================ {VERT_NAME} {#if enablePlausible} {/if} {#if data.isAprilFools} {/if} {#key $locale}
handleDrag(e, true)} ondragover={(e) => handleDrag(e, true)} ondragleave={(e) => handleDrag(e, false)} role="region" >
{/key} ================================================ FILE: src/routes/+layout.ts ================================================ import { browser } from "$app/environment"; export const load = ({ data }) => { if (!browser) return data; window.plausible = window.plausible || ((_, opts) => { opts?.callback?.({ status: 200, }); }); return data; }; export const prerender = true; export const trailingSlash = "always"; ================================================ FILE: src/routes/+page.svelte ================================================

{m["upload.title"]()}

{m["upload.subtitle"]()}


{m["upload.cards.title"]()}

{#if browser} {#each Object.entries(worker) as [key, s], i} {@const Icon = s.icon}
{s.title}
{#if key === "Video"}

{m[ "upload.cards.video_server_processing" ]()} *

{:else}

{m[ "upload.cards.local_supported" ]()}

{/if}

{@html sanitize(m["upload.cards.status.text"]({ status: getStatusText(s.status), }))}

{m[ "upload.cards.supported_formats" ]()} 

{#each s.formats.split(", ") as format, index} {@const isPartial = format.endsWith("*")} {@const formatName = isPartial ? format.slice(0, -1) : format} {#if isPartial} {formatName}* {:else} {formatName} {/if} {#if index < s.formats.split(", ").length - 1} {/if} {/each}

{#if showBlur[i]}
{/if}
{/each} {/if}
================================================ FILE: src/routes/about/+page.svelte ================================================

{m["about.title"]()}

{#if donationsEnabled} {/if}
================================================ FILE: src/routes/convert/+page.svelte ================================================ {#snippet fileItem(file: VertFile, index: number)} {@const currentConverter = file.findConverter()} {@const isImage = currentConverter?.name === "imagemagick"} {@const isAudio = currentConverter?.name === "ffmpeg"} {@const isVideo = currentConverter?.name === "vertd"} {@const isDocument = currentConverter?.name === "pandoc"}
{#if !converters.length} {:else if isAudio} {:else if isVideo} {:else if isDocument} {:else} {/if}
{#if file.processing} {:else}

{file.name}

{/if}
{#if !currentConverter} {#if file.name.startsWith("vertd")}

{m["convert.errors.cant_convert"]()}

{m["convert.errors.vertd_server"]()}

{:else}

{m["convert.errors.cant_convert"]()}

{m["convert.errors.unsupported_format"]()}

{/if} {:else} {@const formatInfo = currentConverter.supportedFormats.find( (f) => f.name === file.from, )} {@const isLarge = file.isLarge()} {#if formatInfo && !formatInfo.fromSupported}

{m["convert.errors.cant_convert"]()}

{m["convert.errors.format_output_only"]()}

{:else if isLarge && !file.supportsStreaming()}

{m["convert.errors.cant_convert"]()}

{m["workers.errors.file_too_large"]({ limit: (MAX_ARRAY_BUFFER_SIZE / GB).toFixed(2), })}

{:else if currentConverter.status === "downloading"}

{m["convert.errors.cant_convert"]()}

{m["convert.errors.worker_downloading"]({ type: isAudio ? m["convert.errors.audio"]() : isVideo ? "Video" : isDocument ? m["convert.errors.doc"]() : m["convert.errors.image"](), })}

{:else if currentConverter.status === "error"}

{m["convert.errors.cant_convert"]()}

{m["convert.errors.worker_error"]({ type: isAudio ? m["convert.errors.audio"]() : isVideo ? "Video" : isDocument ? m["convert.errors.doc"]() : m["convert.errors.image"](), })}

{:else if currentConverter.status === "not-ready"}

{m["convert.errors.cant_convert"]()}

{m["convert.errors.worker_timeout"]({ type: isAudio ? m["convert.errors.audio"]() : isVideo ? "Video" : isDocument ? m["convert.errors.doc"]() : m["convert.errors.image"](), })}

{:else if isVideo && !$vertdLoaded && !isAudio && !isImage && !isDocument}

{m["convert.errors.cant_convert"]()}

{m["convert.errors.vertd_not_found"]()}

{:else}
{#if file.blobUrl} {file.name} {:else}
{#if isAudio} {:else if isVideo} {:else if isDocument} {:else} {/if}
{/if}
handleSelect(option, file)} {file} />
{/if} {/if}
{/snippet}
{#each files.files as file, i (file.id)} {#if files.files.length >= 2 && i === 1} {/if} {@render fileItem(file, i)} {#if files.files.length < 2} {/if} {/each} {#if files.files.length === 0} {/if}
================================================ FILE: src/routes/privacy/+page.svelte ================================================

{m["privacy.title"]()}

{m["privacy.summary.title"]()}

{@html sanitize( link( ["vert_link"], m["privacy.summary.description"](), ["https://vert.sh"], [true], ), )}

{m["privacy.conversions.title"]()}

{@html sanitize(m["privacy.conversions.description"]())}

{m["privacy.donations.title"]()}

{@html sanitize( link( ["about_link", "stripe_link"], m["privacy.donations.description"](), ["/about", "https://stripe.com/docs/disputes/prevention/advanced-fraud-detection"], [false, true], ), )}

{m["privacy.conversion_errors.title"]()}

{m["privacy.conversion_errors.description"]()}
  • {m["privacy.conversion_errors.list_job_id"]()}
  • {m["privacy.conversion_errors.list_format_from"]()}
  • {m["privacy.conversion_errors.list_format_to"]()}
  • {m["privacy.conversion_errors.list_stderr"]()}
  • {m["privacy.conversion_errors.list_video"]()}
{m["privacy.conversion_errors.footer"]()}

{m["privacy.analytics.title"]()}

{@html sanitize( link( ["settings_link", "plausible_link"], m["privacy.analytics.description"](), [ "/settings", "https://plausible.io/privacy-focused-web-analytics", ], [false, true], ), )}

{m["privacy.local_storage.title"]()}

{@html sanitize( link( ["settings_link"], m["privacy.local_storage.description"](), ["/settings"], [false], ), )}

{m["privacy.contact.title"]()}

{@html sanitize( link( ["email_link"], m["privacy.contact.description"](), ["mailto:hello@vert.sh"], [false], ), )}

{m["privacy.last_updated"]()}

================================================ FILE: src/routes/settings/+page.svelte ================================================

{m["settings.title"]()}

{#if !DISABLE_ALL_EXTERNAL_REQUESTS} {:else if PUB_PLAUSIBLE_URL} {/if}
{#if PUB_PLAUSIBLE_URL && !DISABLE_ALL_EXTERNAL_REQUESTS} {/if}
================================================ FILE: static/manifest.json ================================================ { "name": "VERT", "short_name": "VERT", "description": "The file converter you'll love", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#F2ABEE", "icons": [ { "src": "lettermark.jpg", "sizes": "192x192", "type": "image/jpeg" }, { "src": "lettermark.jpg", "sizes": "512x512", "type": "image/jpeg" }, { "src": "lettermark_maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "lettermark_maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] } ================================================ FILE: static/pandoc.wasm ================================================ [File too large to display: 50.4 MB] ================================================ FILE: static/robots.txt ================================================ User-agent: * Allow: / Sitemap: https://vert.sh/sitemap.xml ================================================ FILE: static/sitemap.xml ================================================ https://vert.sh/ 2025-10-17T19:23:05+00:00 1.00 https://vert.sh/convert/ 2025-10-17T19:23:05+00:00 0.80 https://vert.sh/settings/ 2025-10-17T19:23:05+00:00 0.80 https://vert.sh/about/ 2025-10-17T19:23:05+00:00 0.80 https://vert.sh/privacy/ 2025-10-17T19:23:05+00:00 0.80 ================================================ FILE: static/sw.js ================================================ const CACHE_NAME = "vert-wasm-cache-v2"; // updated when workers update const WASM_FILES = [ "/pandoc.wasm", "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.js", "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.wasm", ]; const WASM_URL_PATTERNS = [ /\/src\/lib\/workers\/.*\.js$/, // dev mode worker files /\/assets\/.*worker.*\.js$/, // prod worker files /magick.*\.wasm$/, // magick-wasm (unneeded?) ]; function shouldCacheUrl(url) { const urlObj = new URL(url); if (WASM_FILES.includes(urlObj.pathname) || WASM_FILES.includes(url)) { return true; } return WASM_URL_PATTERNS.some( (pattern) => pattern.test(urlObj.pathname) || pattern.test(url), ); } self.addEventListener("install", (event) => { console.log("[SW] installing service worker"); event.waitUntil( caches.open(CACHE_NAME).then((cache) => { const staticFiles = WASM_FILES.filter((file) => file.startsWith("/"), ); if (staticFiles.length > 0) { console.log("[SW] pre-caching static files:", staticFiles); return cache.addAll(staticFiles).catch((err) => { console.warn("[SW] failed to pre-cache some files:", err); }); } }), ); self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if ( cacheName !== CACHE_NAME && cacheName.startsWith("vert-wasm-cache") ) { console.log("[SW] deleting old cache:", cacheName); return caches.delete(cacheName); } }), ); }) .then(() => { return self.clients.claim(); }), ); }); self.addEventListener("fetch", (event) => { const request = event.request; if (!shouldCacheUrl(request.url)) { return; // Let the request go through normally if not a target URL } // else intercept request event.respondWith( caches.match(request).then((cachedResponse) => { if (cachedResponse) { console.log("[SW] serving from cache:", request.url); return cachedResponse; } console.log("[SW] fetching and caching:", request.url); return fetch(request) .then((response) => { if (!response.ok) { console.warn( "[SW] not caching failed response:", response.status, request.url, ); return response; } const responseToCache = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache .put(request, responseToCache) .then(() => { console.log( "[SW] cached successfully:", request.url, ); }) .catch((err) => { console.warn( "[SW] failed to cache:", request.url, err, ); }); }); return response; }) .catch((err) => { console.error("[SW] fetch failed for:", request.url, err); throw err; }); }), ); }); self.addEventListener("message", (event) => { if (!event.data) return; const type = event.data.type; if (type === "GET_CACHE_INFO") { event.waitUntil( caches.open(CACHE_NAME).then(async (cache) => { const keys = await cache.keys(); let totalSize = 0; const files = []; for (const request of keys) { try { const response = await cache.match(request); if (response) { const blob = await response.blob(); const size = blob.size; totalSize += size; files.push({ url: request.url, size: size, type: response.headers.get("content-type") || "unknown", }); } } catch (err) { console.warn( "[SW] failed to get info for cached file:", request.url, err, ); } } event.ports[0].postMessage({ totalSize, fileCount: files.length, files, }); }), ); } if (type === "CLEAR_CACHE") { event.waitUntil( caches .delete(CACHE_NAME) .then(() => { console.log("[SW] cache cleared"); return caches.open(CACHE_NAME); }) .then(() => { event.ports[0].postMessage({ success: true }); }) .catch((err) => { console.error("[SW] failed to clear cache:", err); event.ports[0].postMessage({ success: false, error: err.message, }); }), ); } }); ================================================ FILE: svelte.config.js ================================================ import adapter from "@sveltejs/adapter-static"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; /** @type {import('@sveltejs/kit').Config} */ const config = { // Consult https://svelte.dev/docs/kit/integrations // for more information about preprocessors preprocess: vitePreprocess(), kit: { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://svelte.dev/docs/kit/adapters for more information about adapters. adapter: adapter(), paths: { relative: false, }, env: { publicPrefix: "PUB_", privatePrefix: "PRI_", }, }, }; export default config; ================================================ FILE: tailwind.config.ts ================================================ import type { Config } from "tailwindcss"; import plugin from "tailwindcss/plugin"; export default { content: ["./src/**/*.{html,js,svelte,ts}"], theme: { extend: { backgroundColor: { panel: "var(--bg-panel)", "panel-highlight": "var(--bg-panel-highlight)", separator: "var(--bg-separator)", button: "var(--bg-button)", "panel-alt": "var(--bg-button)", badge: "var(--bg-badge)", }, borderColor: { separator: "var(--bg-separator)", button: "var(--bg-button)", }, textColor: { foreground: "var(--fg)", muted: "var(--fg-muted)", accent: "var(--fg-accent)", failure: "var(--fg-failure)", "on-accent": "var(--fg-on-accent)", "on-badge": "var(--fg-on-badge)", }, colors: { accent: "var(--accent)", "accent-alt": "var(--accent-alt)", "accent-pink": "var(--accent-pink)", "accent-pink-alt": "var(--accent-pink-alt)", "accent-red": "var(--accent-red)", "accent-red-alt": "var(--accent-red-alt)", "accent-purple-alt": "var(--accent-purple-alt)", "accent-purple": "var(--accent-purple)", "accent-blue": "var(--accent-blue)", "accent-blue-alt": "var(--accent-blue-alt)", "accent-green": "var(--accent-green)", "accent-green-alt": "var(--accent-green-alt)", }, boxShadow: { panel: "var(--shadow-panel)", }, fontFamily: { display: "var(--font-display)", body: "var(--font-body)", }, blur: { xs: "2px", }, borderRadius: { "2.5xl": "1.25rem", }, }, }, plugins: [ plugin(function ({ addVariant }) { addVariant("dynadark", [ ":root:not(.light).dark &", "@media (prefers-color-scheme: dark) { :root:not(.light) &", ]); }), ], } satisfies Config; ================================================ FILE: tsconfig.json ================================================ { "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "moduleResolution": "bundler" } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes // from the referenced tsconfig.json - TypeScript does not merge them in } ================================================ FILE: vite.config.ts ================================================ import { paraglideVitePlugin } from "@inlang/paraglide-js"; import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig, type PluginOption } from "vite"; import svg from "@poppanator/sveltekit-svg"; import wasm from "vite-plugin-wasm"; import { execSync } from "child_process"; // coollify removes the .git folder but exposes commit via SOURCE_COMMIT env variable let commitHash = process.env.SOURCE_COMMIT ? process.env.SOURCE_COMMIT.substring(0, 7) // shorten it lol : "unknown"; if (commitHash === "unknown") { try { commitHash = execSync("git rev-parse --short HEAD").toString().trim(); } catch (e) { console.warn(`Could not determine Git commit hash: ${e}`); commitHash = "unknown"; } } export default defineConfig(({ command }) => { const plugins: PluginOption[] = [ sveltekit(), paraglideVitePlugin({ project: "./project.inlang", outdir: "./src/lib/paraglide", strategy: ["localStorage", "preferredLanguage", "baseLocale"], }), svg({ includePaths: ["./src/lib/assets"], svgoOptions: { multipass: true, plugins: [ { name: "preset-default", params: { overrides: { removeViewBox: false } }, }, { name: "removeAttrs", params: { attrs: "(fill|stroke)" } }, ], }, }), ]; if (command === "serve") { plugins.unshift(wasm()); } return { plugins, worker: { plugins: () => [wasm()], format: "es", }, optimizeDeps: { exclude: ["@ffmpeg/core-mt", "@ffmpeg/ffmpeg", "@ffmpeg/util"], }, css: { preprocessorOptions: { scss: { api: "modern", }, }, }, build: { target: "esnext", }, define: { __COMMIT_HASH__: JSON.stringify(commitHash), }, }; });