feat: warn of large file sizes due to browser limits

browsers / devices will have different arraybuffer sizes. if exceeded, block conversion or disable certain ones (video to audio)
This commit is contained in:
Maya 2025-10-27 22:16:41 +03:00
parent 8a958aed46
commit 0e5f549804
No known key found for this signature in database
7 changed files with 125 additions and 5 deletions

View File

@ -52,6 +52,7 @@
"extracted": "Extracted {extract_count} files from {filename}. {ignore_count} items were ignored.",
"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?",
@ -255,7 +256,8 @@
"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"
"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": {

View File

@ -7,6 +7,7 @@
import { ChevronDown, SearchIcon } from "lucide-svelte";
import { onMount } from "svelte";
import { quintOut } from "svelte/easing";
import type { VertFile } from "$lib/types";
type Props = {
categories: Categories;
@ -15,6 +16,7 @@
onselect?: (option: string) => void;
disabled?: boolean;
dropdownSize?: "default" | "large" | "small";
file?: VertFile;
};
let {
@ -24,6 +26,7 @@
onselect,
disabled,
dropdownSize = "default",
file,
}: Props = $props();
let open = $state(false);
let dropdown = $state<HTMLDivElement>();
@ -67,6 +70,15 @@
);
if (from === ".gif") finalCategories.push("video");
// filter out categories that can't handle large files (due to browser/device limitations)
if (file && file.isLarge()) {
// if file is large video, disable audio conversion
if (rootCategory === "video")
finalCategories = finalCategories.filter(
(cat) => cat !== "audio",
);
}
return finalCategories;
});

View File

@ -15,3 +15,5 @@ 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;

View File

@ -11,6 +11,7 @@ import { m } from "$lib/paraglide/messages";
import sanitizeHtml from "sanitize-html";
import { unzip } from "fflate";
import { ToastManager } from "$lib/toast/index.svelte";
import { GB } from "$lib/consts";
class Files {
public files = $state<VertFile[]>([]);
@ -259,7 +260,20 @@ class Files {
this.files.push(vf);
this._addThumbnail(vf);
const isVideo = converter.name === "vertd";
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) {
@ -481,3 +495,66 @@ export function sanitize(
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();

View File

@ -1,5 +1,4 @@
import type { Component } from "svelte";
import { writable } from "svelte/store";
export type ToastType = "success" | "error" | "info" | "warning";

View File

@ -1,9 +1,9 @@
import { byNative, converters } from "$lib/converters";
import type { Converter } from "$lib/converters/converter.svelte";
import { error } from "$lib/logger";
import { m } from "$lib/paraglide/messages";
import { ToastManager } from "$lib/toast/index.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);
@ -63,6 +63,17 @@ export class VertFile {
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(

View File

@ -5,7 +5,7 @@
import Panel from "$lib/components/visual/Panel.svelte";
import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
import Tooltip from "$lib/components/visual/Tooltip.svelte";
import { categories, converters, byNative } from "$lib/converters";
import { categories, converters } from "$lib/converters";
import {
effects,
files,
@ -29,6 +29,8 @@
} from "lucide-svelte";
import { m } from "$lib/paraglide/messages";
import { Settings } from "$lib/sections/settings/index.svelte";
import { MAX_ARRAY_BUFFER_SIZE } from "$lib/store/index.svelte";
import { GB } from "$lib/consts";
let processedFileIds = $state(new Set<string>());
@ -220,6 +222,7 @@
{@const formatInfo = currentConverter.supportedFormats.find(
(f) => f.name === file.from,
)}
{@const isLarge = file.isLarge()}
{#if formatInfo && !formatInfo.fromSupported}
<div
class="h-full flex flex-col text-center justify-center text-failure"
@ -231,6 +234,19 @@
{m["convert.errors.format_output_only"]()}
</p>
</div>
{:else if isLarge && !file.supportsStreaming()}
<div
class="h-full flex flex-col text-center justify-center text-failure"
>
<p class="font-body font-bold">
{m["convert.errors.cant_convert"]()}
</p>
<p class="font-normal">
{m["workers.errors.file_too_large"]({
limit: (MAX_ARRAY_BUFFER_SIZE / GB).toFixed(2),
})}
</p>
</div>
{:else if currentConverter.status === "downloading"}
<div
class="h-full flex flex-col text-center justify-center text-failure"
@ -347,6 +363,7 @@
bind:selected={file.to}
onselect={(option) =>
handleSelect(option, file)}
{file}
/>
<div
class="w-full flex items-center justify-between"