@@ -41,7 +41,6 @@
gap: calc(var(--small-padding) * 2);
padding: var(--big-padding);
font-weight: 500;
- background: var(--primary);
color: var(--button-text);
border-radius: var(--border-radius);
overflow: hidden;
@@ -66,6 +65,7 @@
align-items: center;
padding: var(--small-padding);
border-radius: 5px;
+ background: var(--icon-color);
}
.subnav-tab .tab-icon :global(svg) {
@@ -75,6 +75,19 @@
width: 20px;
}
+ .subnav-tab:not(.active) .tab-icon {
+ background: rgba(0, 0, 0, 0.05);
+ box-shadow: var(--button-box-shadow);
+ }
+
+ :global([data-theme="dark"]) .subnav-tab:not(.active) .tab-icon {
+ background: rgba(255, 255, 255, 0.1);
+ }
+
+ .subnav-tab:not(.active) .tab-icon :global(svg) {
+ stroke: var(--icon-color);
+ }
+
.subnav-tab-chevron :global(svg) {
display: none;
stroke-width: 2px;
@@ -93,8 +106,10 @@
}
}
- .subnav-tab:active {
- background: var(--button-hover-transparent);
+ .subnav-tab:active,
+ .subnav-tab:focus:hover:not(.active) {
+ background: var(--button-press-transparent);
+ box-shadow: var(--button-box-shadow);
}
.subnav-tab.active {
@@ -118,7 +133,7 @@
.subnav-tab:not(:last-child) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
- box-shadow: 48px 3px 0px -1.8px var(--button-stroke);
+ box-shadow: 48px 3px 0px -2px var(--button-stroke);
}
.subnav-tab:not(:first-child) {
diff --git a/web/src/fonts/noto-mono-cobalt.css b/web/src/fonts/noto-mono-cobalt.css
new file mode 100644
index 0000000000000000000000000000000000000000..ffebf8343f27f4d8ed1b42b4ef0028c54c4178cb
--- /dev/null
+++ b/web/src/fonts/noto-mono-cobalt.css
@@ -0,0 +1,7 @@
+@font-face {
+ font-family: "Noto Sans Mono";
+ font-style: normal;
+ font-display: swap;
+ font-weight: 400;
+ src: url(/fonts/noto-mono-cobalt.woff2) format("woff2");
+}
diff --git a/web/src/lib/api/api-url.ts b/web/src/lib/api/api-url.ts
index 2779fd5f0a221b2845606d320df734c61fd7b95a..bec762570bbdf90f7765dc6c26b3615f39a11d4e 100644
--- a/web/src/lib/api/api-url.ts
+++ b/web/src/lib/api/api-url.ts
@@ -1,6 +1,6 @@
+import env from "$lib/env";
import { get } from "svelte/store";
import settings from "$lib/state/settings";
-import env, { defaultApiURL } from "$lib/env";
export const currentApiURL = () => {
const processingSettings = get(settings).processing;
@@ -10,9 +10,5 @@ export const currentApiURL = () => {
return new URL(customInstanceURL).origin;
}
- if (env.DEFAULT_API) {
- return new URL(env.DEFAULT_API).origin;
- }
-
- return new URL(defaultApiURL).origin;
+ return new URL(env.DEFAULT_API!).origin;
}
diff --git a/web/src/lib/api/api.ts b/web/src/lib/api/api.ts
index 078294802ac737bb456a174f2d9b6a0f9cb36878..0467adf3bdd49b7d41c5144bbe7c7fbd7a30b7dc 100644
--- a/web/src/lib/api/api.ts
+++ b/web/src/lib/api/api.ts
@@ -1,7 +1,6 @@
import { get } from "svelte/store";
import settings from "$lib/state/settings";
-import lazySettingGetter from "$lib/settings/lazy-get";
import { getSession, resetSession } from "$lib/api/session";
import { currentApiURL } from "$lib/api/api-url";
@@ -10,64 +9,62 @@ import cachedInfo from "$lib/state/server-info";
import { getServerInfo } from "$lib/api/server-info";
import type { Optional } from "$lib/types/generic";
-import type { CobaltAPIResponse, CobaltErrorResponse } from "$lib/types/api";
+import type { CobaltAPIResponse, CobaltErrorResponse, CobaltSaveRequestBody } from "$lib/types/api";
+
+const waitForTurnstile = async () => {
+ return await new Promise((resolve, reject) => {
+ const unsub = turnstileSolved.subscribe((solved) => {
+ if (solved) {
+ unsub();
+ resolve(true);
+ }
+ });
+
+ // wait for turnstile to finish for 15 seconds
+ setTimeout(() => {
+ unsub();
+ reject(false);
+ }, 15 * 1000)
+ });
+}
const getAuthorization = async () => {
const processing = get(settings).processing;
+ if (processing.enableCustomApiKey && processing.customApiKey.length > 0) {
+ return `Api-Key ${processing.customApiKey}`;
+ }
+
+ if (!get(turnstileEnabled)) {
+ return;
+ }
- if (get(turnstileEnabled)) {
- if (!get(turnstileSolved)) {
+ if (!get(turnstileSolved)) {
+ try {
+ await waitForTurnstile();
+ } catch {
return {
status: "error",
error: {
- code: "error.captcha_ongoing"
+ code: "error.captcha_too_long"
}
} as CobaltErrorResponse;
}
+ }
- const session = await getSession();
+ const session = await getSession();
- if (session) {
- if ("error" in session) {
- if (session.error.code !== "error.api.auth.not_configured") {
- return session;
- }
- } else {
- return `Bearer ${session.token}`;
+ if (session) {
+ if ("error" in session) {
+ if (session.error.code !== "error.api.auth.not_configured") {
+ return session;
}
+ } else {
+ return `Bearer ${session.token}`;
}
}
-
- if (processing.enableCustomApiKey && processing.customApiKey.length > 0) {
- return `Api-Key ${processing.customApiKey}`;
- }
}
-const request = async (url: string, justRetried = false) => {
- const getSetting = lazySettingGetter(get(settings));
-
- const requestBody = {
- url,
-
- downloadMode: getSetting("save", "downloadMode"),
- audioBitrate: getSetting("save", "audioBitrate"),
- audioFormat: getSetting("save", "audioFormat"),
- tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
- youtubeDubLang: getSetting("save", "youtubeDubLang"),
-
- youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
- videoQuality: getSetting("save", "videoQuality"),
- youtubeHLS: getSetting("save", "youtubeHLS"),
-
- filenameStyle: getSetting("save", "filenameStyle"),
- disableMetadata: getSetting("save", "disableMetadata"),
-
- twitterGif: getSetting("save", "twitterGif"),
- tiktokH265: getSetting("save", "tiktokH265"),
-
- alwaysProxy: getSetting("privacy", "alwaysProxy"),
- }
-
+const request = async (requestBody: CobaltSaveRequestBody, justRetried = false) => {
await getServerInfo();
const getCachedInfo = get(cachedInfo);
@@ -125,25 +122,13 @@ const request = async (url: string, justRetried = false) => {
&& !justRetried
) {
resetSession();
- await waitForTurnstile().catch(() => {});
- return request(url, true);
+ await getAuthorization();
+ return request(requestBody, true);
}
return response;
}
-const waitForTurnstile = async () => {
- await getAuthorization();
- return new Promise
(resolve => {
- const unsub = turnstileSolved.subscribe(solved => {
- if (solved) {
- unsub();
- resolve();
- }
- });
- });
-}
-
const probeCobaltTunnel = async (url: string) => {
const request = await fetch(`${url}&p=1`).catch(() => {});
if (request?.status === 200) {
diff --git a/web/src/lib/api/saving-handler.ts b/web/src/lib/api/saving-handler.ts
new file mode 100644
index 0000000000000000000000000000000000000000..94c0319e9c73ae7dfb02c9493d0aa76c2fa0c540
--- /dev/null
+++ b/web/src/lib/api/saving-handler.ts
@@ -0,0 +1,151 @@
+import env from "$lib/env";
+import API from "$lib/api/api";
+import settings from "$lib/state/settings";
+import lazySettingGetter from "$lib/settings/lazy-get";
+
+import { get } from "svelte/store";
+import { t } from "$lib/i18n/translations";
+import { downloadFile } from "$lib/download";
+import { createDialog } from "$lib/state/dialogs";
+import { downloadButtonState } from "$lib/state/omnibox";
+import { createSavePipeline } from "$lib/task-manager/queue";
+
+import type { CobaltSaveRequestBody } from "$lib/types/api";
+
+type SavingHandlerArgs = {
+ url?: string,
+ request?: CobaltSaveRequestBody,
+ oldTaskId?: string
+}
+
+export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerArgs) => {
+ downloadButtonState.set("think");
+
+ const error = (errorText: string) => {
+ return createDialog({
+ id: "save-error",
+ type: "small",
+ meowbalt: "error",
+ buttons: [
+ {
+ text: get(t)("button.gotit"),
+ main: true,
+ action: () => {},
+ },
+ ],
+ bodyText: errorText,
+ });
+ }
+
+ const getSetting = lazySettingGetter(get(settings));
+
+ if (!request && !url) return;
+
+ const selectedRequest = request || {
+ url: url!,
+
+ // not lazy cuz default depends on device capabilities
+ localProcessing: get(settings).save.localProcessing,
+
+ alwaysProxy: getSetting("save", "alwaysProxy"),
+ downloadMode: getSetting("save", "downloadMode"),
+
+ subtitleLang: getSetting("save", "subtitleLang"),
+ filenameStyle: getSetting("save", "filenameStyle"),
+ disableMetadata: getSetting("save", "disableMetadata"),
+
+ audioFormat: getSetting("save", "audioFormat"),
+ audioBitrate: getSetting("save", "audioBitrate"),
+ tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
+ youtubeDubLang: getSetting("save", "youtubeDubLang"),
+ youtubeBetterAudio: getSetting("save", "youtubeBetterAudio"),
+
+ videoQuality: getSetting("save", "videoQuality"),
+ youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
+ youtubeVideoContainer: getSetting("save", "youtubeVideoContainer"),
+ youtubeHLS: env.ENABLE_DEPRECATED_YOUTUBE_HLS ? getSetting("save", "youtubeHLS") : undefined,
+
+ allowH265: getSetting("save", "allowH265"),
+ convertGif: getSetting("save", "convertGif"),
+ }
+
+ const response = await API.request(selectedRequest);
+
+ if (!response) {
+ downloadButtonState.set("error");
+ return error(get(t)("error.api.unreachable"));
+ }
+
+ if (response.status === "error") {
+ downloadButtonState.set("error");
+
+ return error(
+ get(t)(response.error.code, response?.error?.context)
+ );
+ }
+
+ if (response.status === "redirect") {
+ downloadButtonState.set("done");
+
+ return downloadFile({
+ url: response.url,
+ urlType: "redirect",
+ });
+ }
+
+ if (response.status === "tunnel") {
+ downloadButtonState.set("check");
+
+ const probeResult = await API.probeCobaltTunnel(response.url);
+
+ if (probeResult === 200) {
+ downloadButtonState.set("done");
+
+ return downloadFile({
+ url: response.url,
+ });
+ } else {
+ downloadButtonState.set("error");
+ return error(get(t)("error.tunnel.probe"));
+ }
+ }
+
+ if (response.status === "local-processing") {
+ downloadButtonState.set("done");
+ return createSavePipeline(response, selectedRequest, oldTaskId);
+ }
+
+ if (response.status === "picker") {
+ downloadButtonState.set("done");
+ const buttons = [
+ {
+ text: get(t)("button.done"),
+ main: true,
+ action: () => { },
+ },
+ ];
+
+ if (response.audio) {
+ const pickerAudio = response.audio;
+ buttons.unshift({
+ text: get(t)("button.download.audio"),
+ main: false,
+ action: () => {
+ downloadFile({
+ url: pickerAudio,
+ });
+ },
+ });
+ }
+
+ return createDialog({
+ id: "download-picker",
+ type: "picker",
+ items: response.picker,
+ buttons,
+ });
+ }
+
+ downloadButtonState.set("error");
+ return error(get(t)("error.api.unknown_response"));
+}
diff --git a/web/src/lib/device.ts b/web/src/lib/device.ts
index 8f8dd5950c801bc6898b1d4b48c0d8857d44660a..2b8dc894b5265d99fa4984d158f0deccee43988c 100644
--- a/web/src/lib/device.ts
+++ b/web/src/lib/device.ts
@@ -14,6 +14,10 @@ const device = {
android: false,
mobile: false,
},
+ browser: {
+ chrome: false,
+ webkit: false,
+ },
prefers: {
language: "en",
reducedMotion: false,
@@ -22,6 +26,9 @@ const device = {
supports: {
share: false,
directDownload: false,
+ haptics: false,
+ defaultLocalProcessing: false,
+ multithreading: false,
},
userAgent: "sveltekit server",
}
@@ -32,6 +39,9 @@ if (browser) {
const iPhone = ua.includes("iphone os");
const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0;
+ const iosVersion = Number(ua.match(/version\/(\d+)/)?.[1]);
+ const modernIOS = iPhone && iosVersion >= 18;
+
const iOS = iPhone || iPad;
const android = ua.includes("android") || ua.includes("diordna");
@@ -42,11 +52,22 @@ if (browser) {
};
device.is = {
+ mobile: iOS || android,
+ android,
+
iPhone,
iPad,
iOS,
- android,
- mobile: iOS || android,
+ };
+
+ device.browser = {
+ chrome: ua.includes("chrome/"),
+ webkit: ua.includes("applewebkit/")
+ && ua.includes("version/")
+ && ua.includes("safari/")
+ // this is the version of webkit that's hardcoded into chrome
+ // and indicates that the browser is not actually webkit
+ && !ua.includes("applewebkit/537.36")
};
device.prefers = {
@@ -58,6 +79,14 @@ if (browser) {
device.supports = {
share: navigator.share !== undefined,
directDownload: !(installed && iOS),
+
+ // not sure if vibrations feel the same on android,
+ // so they're enabled only on ios 18+ for now
+ haptics: modernIOS,
+
+ // enable local processing by default everywhere but android chrome
+ defaultLocalProcessing: !(device.is.android && device.browser.chrome),
+ multithreading: !iOS || iosVersion >= 18,
};
device.userAgent = navigator.userAgent;
diff --git a/web/src/lib/download.ts b/web/src/lib/download.ts
index 7a126a92f62eb3a5d581075f39c6523e675a84e6..bce6cfa2b1a0fc3adc2ca45c174c4c6de2582f5f 100644
--- a/web/src/lib/download.ts
+++ b/web/src/lib/download.ts
@@ -42,16 +42,12 @@ export const openFile = (file: File) => {
a.href = url;
a.download = file.name;
a.click();
- URL.revokeObjectURL(url);
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export const shareFile = async (file: File) => {
return await navigator?.share({
- files: [
- new File([file], file.name, {
- type: file.type,
- }),
- ],
+ files: [ file ],
});
}
@@ -110,9 +106,23 @@ export const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {
try {
if (file) {
+ // 256mb cuz ram limit per tab is 384mb,
+ // and other stuff (such as libav) might have used some ram too
+ const iosFileShareSizeLimit = 1024 * 1024 * 256;
+
+ // this is required because we can't share big files
+ // on ios due to a very low ram limit
+ if (device.is.iOS) {
+ if (file.size < iosFileShareSizeLimit) {
+ return shareFile(file);
+ } else {
+ return openFile(file);
+ }
+ }
+
if (pref === "share" && device.supports.share) {
return shareFile(file);
- } else if (pref === "download" && device.supports.directDownload) {
+ } else if (pref === "download") {
return openFile(file);
}
}
diff --git a/web/src/lib/env.ts b/web/src/lib/env.ts
index cfad460f218f4313cace05a6cb0d61a0f70d48fa..5821699cb1f0fb2e4188632562744d2eb3df2aeb 100644
--- a/web/src/lib/env.ts
+++ b/web/src/lib/env.ts
@@ -9,11 +9,18 @@ const getEnv = (_key: string) => {
}
}
+const getEnvBool = (key: string) => {
+ const value = getEnv(key);
+ return value && ['1', 'true'].includes(value.toLowerCase());
+}
+
const variables = {
HOST: getEnv('HOST'),
PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),
PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),
DEFAULT_API: getEnv('DEFAULT_API'),
+ ENABLE_WEBCODECS: getEnvBool('ENABLE_WEBCODECS'),
+ ENABLE_DEPRECATED_YOUTUBE_HLS: getEnvBool('ENABLE_DEPRECATED_YOUTUBE_HLS'),
}
const contacts = {
@@ -32,12 +39,12 @@ const donate = {
stripe: "https://donate.stripe.com/3cs2cc6ew1Qda4wbII",
liberapay: "https://liberapay.com/imput/donate",
crypto: {
- ethereum: "0x4B4cF23051c78c7A7E0eA09d39099621c46bc302",
- monero: "4B1SNB6s8Pq1hxjNeKPEe8Qa8EP3zdL16Sqsa7QDoJcUecKQzEj9BMxWnEnTGu12doKLJBKRDUqnn6V9qfSdXpXi3Nw5Uod",
- solana: "LJx4mxhvLJqDs65u4kxNgoKYGbZFfGCKGQjNApvfB7h",
- litecoin: "ltc1qvp0xhrk2m7pa6p6z844qcslfyxv4p3vf95rhna",
- bitcoin: "bc1qlvcnlnyzfsgnuxyxsv3k0p0q0yln0azjpadyx4",
- ton: "UQA3SO-hHZq1oCCT--u6or6ollB8fd2o52aD8mXiLk9iDZd3",
+ ethereum: "0xDA47A671B2411468E8320916C3e57D2F60FE7197",
+ monero: "463y93PsQDTYGVPAHUNcjiYDsxWjn7bL2FS9GYXjetEH5XEoNKB7kCHHQXsuoebbSv8RqGspo61pxhMQQrudDky2AfTGbs3",
+ solana: "BWPQpPvSyfauUm1BwmV55qE1vJT56Pc6qHrNFzCmtmFJ",
+ litecoin: "ltc1qfdemqtfsj7pgnfmtv7n5agtrh0yzwk2pzgr96y",
+ bitcoin: "bc1qeqd27qknt3fwvuzpvv2ne730klggggwcqm43yq",
+ ton: "UQBosUGIkvZcV8k02bdm-lRFLXrlr1A_sdO1FnXhAsUOLx1S",
},
other: {
boosty: "https://boosty.to/wukko/donate",
@@ -55,7 +62,7 @@ const docs = {
apiLicense: "https://github.com/imputnet/cobalt/blob/main/api/LICENSE",
};
-const defaultApiURL = "https://api.cobalt.tools";
+const officialApiURL = "https://api.cobalt.tools";
-export { donate, defaultApiURL, contacts, partners, siriShortcuts, docs };
+export { donate, officialApiURL, contacts, partners, siriShortcuts, docs };
export default variables;
diff --git a/web/src/lib/haptics.ts b/web/src/lib/haptics.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cac2d78b793a17eed9dff4684f9594943216b872
--- /dev/null
+++ b/web/src/lib/haptics.ts
@@ -0,0 +1,43 @@
+import { get } from "svelte/store";
+import { device } from "$lib/device";
+import settings from "$lib/state/settings";
+
+const canUseHaptics = () => {
+ return device.supports.haptics && !get(settings).accessibility.disableHaptics;
+}
+
+export const hapticSwitch = () => {
+ if (!canUseHaptics()) return;
+
+ try {
+ const label = document.createElement("label");
+ label.ariaHidden = "true";
+ label.style.display = "none";
+
+ const input = document.createElement("input");
+ input.type = "checkbox";
+ input.setAttribute("switch", "");
+ label.appendChild(input);
+
+ document.head.appendChild(label);
+ label.click();
+ document.head.removeChild(label);
+ } catch {
+ // ignore
+ }
+}
+
+export const hapticConfirm = () => {
+ if (!canUseHaptics()) return;
+
+ hapticSwitch();
+ setTimeout(() => hapticSwitch(), 120);
+}
+
+export const hapticError = () => {
+ if (!canUseHaptics()) return;
+
+ hapticSwitch();
+ setTimeout(() => hapticSwitch(), 120);
+ setTimeout(() => hapticSwitch(), 240);
+}
diff --git a/web/src/lib/libav.ts b/web/src/lib/libav.ts
index 4f540cf5e5fe27ddfb13a7ddec00b2cab3ca3781..6c154022202e798d3b7bb1072a3175d9a7e84fa2 100644
--- a/web/src/lib/libav.ts
+++ b/web/src/lib/libav.ts
@@ -1,8 +1,9 @@
-import mime from "mime";
+import * as Storage from "$lib/storage";
import LibAV, { type LibAV as LibAVInstance } from "@imput/libav.js-remux-cli";
-import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, FileInfo, RenderParams } from "./types/libav";
+import EncodeLibAV from "@imput/libav.js-encode-cli";
+
import type { FfprobeData } from "fluent-ffmpeg";
-import { browser } from "$app/environment";
+import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, RenderParams } from "$lib/types/libav";
export default class LibAVWrapper {
libav: Promise | null;
@@ -11,14 +12,26 @@ export default class LibAVWrapper {
constructor(onProgress?: FFmpegProgressCallback) {
this.libav = null;
- this.concurrency = Math.min(4, browser ? navigator.hardwareConcurrency : 0);
+ this.concurrency = Math.min(4, navigator.hardwareConcurrency || 0);
this.onProgress = onProgress;
}
- init() {
+ init(options?: LibAV.LibAVOpts) {
+ const variant = options?.variant || 'remux';
+ let constructor: typeof LibAV.LibAV;
+
+ if (variant === 'remux') {
+ constructor = LibAV.LibAV;
+ } else if (variant === 'encode') {
+ constructor = EncodeLibAV.LibAV;
+ } else {
+ throw "invalid variant";
+ }
+
if (this.concurrency && !this.libav) {
- this.libav = LibAV.LibAV({
- yesthreads: true,
+ this.libav = constructor({
+ ...options,
+ variant: undefined,
base: '/_libav'
});
}
@@ -57,60 +70,32 @@ export default class LibAVWrapper {
}
}
- static getExtensionFromType(blob: Blob) {
- const extensions = mime.getAllExtensions(blob.type);
- const overrides = ['mp3', 'mov'];
-
- if (!extensions)
- return;
-
- for (const override of overrides)
- if (extensions?.has(override))
- return override;
-
- return [...extensions][0];
- }
-
- async render({ blob, output, args }: RenderParams) {
+ async render({ files, output, args }: RenderParams) {
if (!this.libav) throw new Error("LibAV wasn't initialized");
const libav = await this.libav;
- const inputKind = blob.type.split("/")[0];
- const inputExtension = LibAVWrapper.getExtensionFromType(blob);
-
- if (inputKind !== "video" && inputKind !== "audio") return;
- if (!inputExtension) return;
- const input: FileInfo = {
- kind: inputKind,
- extension: inputExtension,
+ if (!(output.format && output.type)) {
+ throw new Error("output's format or type is missing");
}
- if (!output) output = input;
-
- output.type = mime.getType(output.extension);
- if (!output.type) return;
-
- const outputName = `output.${output.extension}`;
+ const outputName = `output.${output.format}`;
+ const ffInputs = [];
try {
- await libav.mkreadaheadfile("input", blob);
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+
+ await libav.mkreadaheadfile(`input${i}`, file);
+ ffInputs.push('-i', `input${i}`);
+ }
- // https://github.com/Yahweasel/libav.js/blob/7d359f69/docs/IO.md#block-writer-devices
await libav.mkwriterdev(outputName);
await libav.mkwriterdev('progress.txt');
- const MB = 1024 * 1024;
- const chunks: Uint8Array[] = [];
- const chunkSize = Math.min(512 * MB, blob.size);
-
- // since we expect the output file to be roughly the same size
- // as the original, preallocate its size for the output
- for (let toAllocate = blob.size; toAllocate > 0; toAllocate -= chunkSize) {
- chunks.push(new Uint8Array(chunkSize));
- }
+ const totalInputSize = files.reduce((a, b) => a + b.size, 0);
+ const storage = await Storage.init(totalInputSize);
- let actualSize = 0;
- libav.onwrite = (name, pos, data) => {
+ libav.onwrite = async (name, pos, data) => {
if (name === 'progress.txt') {
try {
return this.#emitProgress(data);
@@ -119,26 +104,7 @@ export default class LibAVWrapper {
}
} else if (name !== outputName) return;
- const writeEnd = pos + data.length;
- if (writeEnd > chunkSize * chunks.length) {
- chunks.push(new Uint8Array(chunkSize));
- }
-
- const chunkIndex = pos / chunkSize | 0;
- const offset = pos - (chunkSize * chunkIndex);
-
- if (offset + data.length > chunkSize) {
- chunks[chunkIndex].set(
- data.subarray(0, chunkSize - offset), offset
- );
- chunks[chunkIndex + 1].set(
- data.subarray(chunkSize - offset), 0
- );
- } else {
- chunks[chunkIndex].set(data, offset);
- }
-
- actualSize = Math.max(writeEnd, actualSize);
+ await storage.write(data, pos);
};
await libav.ffmpeg([
@@ -146,40 +112,24 @@ export default class LibAVWrapper {
'-loglevel', 'error',
'-progress', 'progress.txt',
'-threads', this.concurrency.toString(),
- '-i', 'input',
+ ...ffInputs,
...args,
outputName
]);
- // if we didn't need as much space as we allocated for some reason,
- // shrink the buffers so that we don't inflate the file with zeroes
- const outputView: Uint8Array[] = [];
+ const file = Storage.retype(await storage.res(), output.type);
+ if (file.size === 0) return;
- for (let i = 0; i < chunks.length; ++i) {
- outputView.push(
- chunks[i].subarray(
- 0, Math.min(chunkSize, actualSize)
- )
- );
-
- actualSize -= chunkSize;
- if (actualSize <= 0) {
- break;
- }
- }
-
- const renderBlob = new Blob(
- outputView,
- { type: output.type }
- );
-
- if (renderBlob.size === 0) return;
- return renderBlob;
+ return file;
} finally {
try {
await libav.unlink(outputName);
await libav.unlink('progress.txt');
- await libav.unlinkreadaheadfile("input");
+
+ await Promise.allSettled(
+ files.map((_, i) =>
+ libav.unlinkreadaheadfile(`input${i}`)
+ ));
} catch { /* catch & ignore */ }
}
}
@@ -192,7 +142,7 @@ export default class LibAVWrapper {
const entries = Object.fromEntries(
text.split('\n')
.filter(a => a)
- .map(a => a.split('=', ))
+ .map(a => a.split('='))
);
const status: FFmpegProgressStatus = (() => {
diff --git a/web/src/lib/settings/audio-sub-language.ts b/web/src/lib/settings/audio-sub-language.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b2cfe7bce99ba9883c99aa95318677f01ded002b
--- /dev/null
+++ b/web/src/lib/settings/audio-sub-language.ts
@@ -0,0 +1,86 @@
+import { t as translation } from "$lib/i18n/translations";
+import type { FromReadable } from "$lib/types/generic";
+
+const languages = [
+ // most popular languages are first, according to
+ // https://en.wikipedia.org/wiki/List_of_languages_by_number_of_native_speakers
+ "en", "es", "pt", "fr", "ru",
+ "zh", "vi", "hi", "bn", "ja",
+
+ "af", "am", "ar", "as", "az",
+ "be", "bg", "bs", "ca", "cs",
+ "da", "de", "el", "et", "eu",
+ "fa", "fi", "fil", "gl", "gu",
+ "hr", "hu", "hy", "id", "is",
+ "it", "iw", "ka", "kk", "ko",
+ "km", "kn", "ky", "lo", "lt",
+ "lv", "mk", "ml", "mn", "mr",
+ "ms", "my", "no", "ne", "nl",
+ "or", "pa", "pl", "ro", "si",
+ "sk", "sl", "sq", "sr", "sv",
+ "sw", "ta", "te", "th", "tr",
+ "uk", "ur", "uz", "zh-Hans",
+ "zh-Hant", "zh-CN", "zh-HK",
+ "zh-TW", "zu"
+];
+
+export const youtubeDubLanguages = ["original", ...languages] as const;
+export const subtitleLanguages = ["none", ...languages] as const;
+
+export type YoutubeDubLang = typeof youtubeDubLanguages[number];
+export type SubtitleLang = typeof subtitleLanguages[number];
+
+type TranslationFunction = FromReadable;
+
+const namedLanguages = (
+ languages: typeof youtubeDubLanguages | typeof subtitleLanguages,
+ t: TranslationFunction,
+) => {
+ return languages.reduce((obj, lang) => {
+ let name: string;
+
+ switch (lang) {
+ case "original":
+ name = t("settings.youtube.dub.original");
+ break;
+ case "none":
+ name = t("settings.subtitles.none");
+ break;
+ default: {
+ let intlName;
+ try {
+ intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang);
+ } catch { /* */ };
+ name = `${intlName || "unknown"} (${lang})`;
+ break;
+ }
+ }
+
+ return {
+ ...obj,
+ [lang]: name,
+ };
+ }, {}) as Record;
+}
+
+export const namedYoutubeDubLanguages = (t: TranslationFunction) => {
+ return namedLanguages(youtubeDubLanguages, t);
+}
+
+export const namedSubtitleLanguages = (t: TranslationFunction) => {
+ return namedLanguages(subtitleLanguages, t);
+}
+
+export const getBrowserLanguage = (): YoutubeDubLang => {
+ if (typeof navigator !== 'undefined') {
+ const browserLanguage = navigator.language as YoutubeDubLang;
+ if (youtubeDubLanguages.includes(browserLanguage)) {
+ return browserLanguage;
+ }
+ const shortened = browserLanguage.split('-')[0] as YoutubeDubLang;
+ if (youtubeDubLanguages.includes(shortened)) {
+ return shortened;
+ }
+ }
+ return "original";
+}
diff --git a/web/src/lib/settings/defaults.ts b/web/src/lib/settings/defaults.ts
index a4448aaa84c2faf1ec014d79adc450b84aaf0036..ce3114ecca16c11cd76d7e30b3c74e2640da20b8 100644
--- a/web/src/lib/settings/defaults.ts
+++ b/web/src/lib/settings/defaults.ts
@@ -1,35 +1,47 @@
+import { device } from "$lib/device";
import { defaultLocale } from "$lib/i18n/translations";
import type { CobaltSettings } from "$lib/types/settings";
const defaultSettings: CobaltSettings = {
- schemaVersion: 4,
+ schemaVersion: 6,
advanced: {
debug: false,
+ useWebCodecs: false,
},
appearance: {
theme: "auto",
language: defaultLocale,
autoLanguage: true,
+ hideRemuxTab: false,
+ },
+ accessibility: {
reduceMotion: false,
reduceTransparency: false,
+ disableHaptics: false,
+ dontAutoOpenQueue: false,
},
save: {
+ alwaysProxy: false,
+ localProcessing:
+ device.supports.defaultLocalProcessing ? "preferred" : "disabled",
audioBitrate: "128",
audioFormat: "mp3",
disableMetadata: false,
downloadMode: "auto",
- filenameStyle: "classic",
+ filenameStyle: "basic",
savingMethod: "download",
- tiktokH265: false,
+ allowH265: false,
tiktokFullAudio: false,
- twitterGif: true,
+ convertGif: true,
videoQuality: "1080",
+ subtitleLang: "none",
youtubeVideoCodec: "h264",
+ youtubeVideoContainer: "auto",
youtubeDubLang: "original",
youtubeHLS: false,
+ youtubeBetterAudio: false,
},
privacy: {
- alwaysProxy: false,
disableAnalytics: false,
},
processing: {
diff --git a/web/src/lib/settings/lazy-get.ts b/web/src/lib/settings/lazy-get.ts
index 5e11bbad36112e807127f23451cb1ea758d2996b..47c21be6f046ed7199d7fee9913ff3f9bcf5d0e3 100644
--- a/web/src/lib/settings/lazy-get.ts
+++ b/web/src/lib/settings/lazy-get.ts
@@ -1,5 +1,5 @@
+import defaults from "$lib/settings/defaults";
import type { CobaltSettings } from "$lib/types/settings";
-import defaults from "./defaults";
export default function lazySettingGetter(settings: CobaltSettings) {
// Returns the setting value only if it differs from the default.
diff --git a/web/src/lib/settings/migrate.ts b/web/src/lib/settings/migrate.ts
index 4054a0b4b570d3960da0d636cc7f9b3da4344640..d033a6f9135d7728ef870cc12f46cf0dabadd739 100644
--- a/web/src/lib/settings/migrate.ts
+++ b/web/src/lib/settings/migrate.ts
@@ -1,11 +1,13 @@
import type { RecursivePartial } from "$lib/types/generic";
import type {
+ PartialSettings,
AllPartialSettingsWithSchema,
CobaltSettingsV3,
CobaltSettingsV4,
- PartialSettings,
+ CobaltSettingsV5,
+ CobaltSettingsV6,
} from "$lib/types/settings";
-import { getBrowserLanguage } from "$lib/settings/youtube-lang";
+import { getBrowserLanguage } from "$lib/settings/audio-sub-language";
type Migrator = (s: AllPartialSettingsWithSchema) => AllPartialSettingsWithSchema;
@@ -40,6 +42,59 @@ const migrations: Record = {
return out as AllPartialSettingsWithSchema;
},
+
+ [5]: (settings: AllPartialSettingsWithSchema) => {
+ const out = settings as RecursivePartial;
+ out.schemaVersion = 5;
+
+ if (settings?.save) {
+ if ("tiktokH265" in settings.save) {
+ out.save!.allowH265 = settings.save.tiktokH265;
+ delete settings.save.tiktokH265;
+ }
+ if ("twitterGif" in settings.save) {
+ out.save!.convertGif = settings.save.twitterGif;
+ delete settings.save.twitterGif;
+ }
+ }
+
+ if (settings?.privacy) {
+ if ("alwaysProxy" in settings.privacy) {
+ out.save ??= {};
+ out.save.alwaysProxy = settings.privacy.alwaysProxy;
+ delete settings.privacy.alwaysProxy;
+ }
+ }
+
+ if (settings?.appearance) {
+ if ("reduceMotion" in settings.appearance) {
+ out.accessibility ??= {};
+ out.accessibility.reduceMotion = settings.appearance.reduceMotion;
+ delete settings.appearance.reduceMotion;
+ }
+ if ("reduceTransparency" in settings.appearance) {
+ out.accessibility ??= {};
+ out.accessibility.reduceTransparency = settings.appearance.reduceTransparency;
+ delete settings.appearance.reduceTransparency;
+ }
+ }
+
+ return out as AllPartialSettingsWithSchema;
+ },
+
+ [6]: (settings: AllPartialSettingsWithSchema) => {
+ const out = settings as RecursivePartial;
+ out.schemaVersion = 6;
+
+ if (settings?.save) {
+ if ("localProcessing" in settings.save) {
+ out.save!.localProcessing =
+ settings.save.localProcessing ? "preferred" : "disabled";
+ }
+ }
+
+ return out as AllPartialSettingsWithSchema;
+ },
};
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {
diff --git a/web/src/lib/settings/validate.ts b/web/src/lib/settings/validate.ts
index 02467d2ea001139d064d14bdedcdb9ae08ab865e..a711f9ae0e9bc0ad7af796f004e5eb7e6b94ce9d 100644
--- a/web/src/lib/settings/validate.ts
+++ b/web/src/lib/settings/validate.ts
@@ -1,5 +1,5 @@
import type { Optional } from '$lib/types/generic';
-import defaultSettings from './defaults'
+import defaultSettings from '$lib/settings/defaults';
import {
downloadModeOptions,
filenameStyleOptions,
@@ -9,7 +9,7 @@ import {
youtubeVideoCodecOptions,
type PartialSettings,
} from '$lib/types/settings';
-import { youtubeLanguages } from './youtube-lang';
+import { youtubeDubLanguages } from '$lib/settings/audio-sub-language';
function validateTypes(input: unknown, reference = defaultSettings as unknown) {
if (typeof input === 'undefined')
@@ -81,7 +81,7 @@ export function validateSettings(settings: PartialSettings) {
[ settings?.save?.videoQuality , videoQualityOptions ],
[ settings?.save?.youtubeVideoCodec, youtubeVideoCodecOptions ],
[ settings?.save?.savingMethod , savingMethodOptions ],
- [ settings?.save?.youtubeDubLang , youtubeLanguages ]
+ [ settings?.save?.youtubeDubLang , youtubeDubLanguages ]
])
);
}
diff --git a/web/src/lib/settings/youtube-lang.ts b/web/src/lib/settings/youtube-lang.ts
deleted file mode 100644
index 9c470547bd950c97f8c249d40470cfe8f1124e2b..0000000000000000000000000000000000000000
--- a/web/src/lib/settings/youtube-lang.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-export const youtubeLanguages = [
- "original",
- "af",
- "am",
- "ar",
- "as",
- "az",
- "be",
- "bg",
- "bn",
- "bs",
- "ca",
- "cs",
- "da",
- "de",
- "el",
- "en",
- "es",
- "et",
- "eu",
- "fa",
- "fi",
- "fil",
- "fr",
- "gl",
- "gu",
- "hi",
- "hr",
- "hu",
- "hy",
- "id",
- "is",
- "it",
- "iw",
- "ja",
- "ka",
- "kk",
- "km",
- "kn",
- "ko",
- "ky",
- "lo",
- "lt",
- "lv",
- "mk",
- "ml",
- "mn",
- "mr",
- "ms",
- "my",
- "no",
- "ne",
- "nl",
- "or",
- "pa",
- "pl",
- "pt",
- "ro",
- "ru",
- "si",
- "sk",
- "sl",
- "sq",
- "sr",
- "sv",
- "sw",
- "ta",
- "te",
- "th",
- "tr",
- "uk",
- "ur",
- "uz",
- "vi",
- "zh",
- "zh-Hans",
- "zh-Hant",
- "zh-CN",
- "zh-HK",
- "zh-TW",
- "zu"
-] as const;
-
-export type YoutubeLang = typeof youtubeLanguages[number];
-
-export const namedYoutubeLanguages = () => {
- return youtubeLanguages.reduce((obj, lang) => {
- const intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang);
-
- let name = `${intlName} (${lang})`;
- if (lang === "original") {
- name = lang;
- }
-
- return {
- ...obj,
- [lang]: name,
- };
- }, {}) as Record;
-}
-
-export const getBrowserLanguage = (): YoutubeLang => {
- if (typeof navigator === 'undefined')
- return "original";
-
- const browserLanguage = navigator.language as YoutubeLang;
- if (youtubeLanguages.includes(browserLanguage))
- return browserLanguage;
-
- const shortened = browserLanguage.split('-')[0] as YoutubeLang;
- if (youtubeLanguages.includes(shortened))
- return shortened;
-
- return "original";
-}
diff --git a/web/src/lib/state/omnibox.ts b/web/src/lib/state/omnibox.ts
index f3b0be03b07ad9d7066663cc6410000cb54de288..7895c09fa16b41069479966455a3fbf50ac8f951 100644
--- a/web/src/lib/state/omnibox.ts
+++ b/web/src/lib/state/omnibox.ts
@@ -1,3 +1,5 @@
import { writable } from "svelte/store";
+import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
export const link = writable("");
+export const downloadButtonState = writable("idle");
diff --git a/web/src/lib/state/queue-visibility.ts b/web/src/lib/state/queue-visibility.ts
new file mode 100644
index 0000000000000000000000000000000000000000..73ba39eaf2398d3089d99a3e037f1d310e2ca90d
--- /dev/null
+++ b/web/src/lib/state/queue-visibility.ts
@@ -0,0 +1,11 @@
+import settings from "$lib/state/settings";
+import { get, writable } from "svelte/store";
+
+export const queueVisible = writable(false);
+
+export const openQueuePopover = () => {
+ const visible = get(queueVisible);
+ if (!visible && !get(settings).accessibility.dontAutoOpenQueue) {
+ return queueVisible.update(v => !v);
+ }
+}
diff --git a/web/src/lib/state/task-manager/current-tasks.ts b/web/src/lib/state/task-manager/current-tasks.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aadd931ee9140cf6eae1dbdeedd001a493c13782
--- /dev/null
+++ b/web/src/lib/state/task-manager/current-tasks.ts
@@ -0,0 +1,32 @@
+import { readonly, writable } from "svelte/store";
+
+import type { CobaltWorkerProgress } from "$lib/types/workers";
+import type { CobaltCurrentTasks, CobaltCurrentTaskItem } from "$lib/types/task-manager";
+
+const currentTasks_ = writable({});
+export const currentTasks = readonly(currentTasks_);
+
+export function addWorkerToQueue(workerId: string, item: CobaltCurrentTaskItem) {
+ currentTasks_.update(tasks => {
+ tasks[workerId] = item;
+ return tasks;
+ });
+}
+
+export function removeWorkerFromQueue(id: string) {
+ currentTasks_.update(tasks => {
+ delete tasks[id];
+ return tasks;
+ });
+}
+
+export function updateWorkerProgress(workerId: string, progress: CobaltWorkerProgress) {
+ currentTasks_.update(allTasks => {
+ allTasks[workerId].progress = progress;
+ return allTasks;
+ });
+}
+
+export function clearCurrentTasks() {
+ currentTasks_.set({});
+}
diff --git a/web/src/lib/state/task-manager/queue.ts b/web/src/lib/state/task-manager/queue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f638eda8688b1c9b7418fb86dcd718c037e439e0
--- /dev/null
+++ b/web/src/lib/state/task-manager/queue.ts
@@ -0,0 +1,123 @@
+import { readable, type Updater } from "svelte/store";
+
+import { schedule } from "$lib/task-manager/scheduler";
+import { clearFileStorage, removeFromFileStorage } from "$lib/storage/opfs";
+import { clearCurrentTasks, removeWorkerFromQueue } from "$lib/state/task-manager/current-tasks";
+
+import type { CobaltQueue, CobaltQueueItem, CobaltQueueItemRunning, UUID } from "$lib/types/queue";
+
+const clearPipelineCache = (queueItem: CobaltQueueItem) => {
+ if (queueItem.state === "running") {
+ for (const [ workerId, item ] of Object.entries(queueItem.pipelineResults)) {
+ removeFromFileStorage(item.name);
+ delete queueItem.pipelineResults[workerId];
+ }
+ } else if (queueItem.state === "done") {
+ removeFromFileStorage(queueItem.resultFile.name);
+ }
+
+ return queueItem;
+}
+
+let update: (_: Updater) => void;
+
+export const queue = readable(
+ {},
+ (_, _update) => { update = _update }
+);
+
+export function addItem(item: CobaltQueueItem) {
+ update(queueData => {
+ queueData[item.id] = item;
+ return queueData;
+ });
+
+ schedule();
+}
+
+export function itemError(id: UUID, workerId: UUID, error: string) {
+ update(queueData => {
+ if (queueData[id]) {
+ queueData[id] = clearPipelineCache(queueData[id]);
+
+ queueData[id] = {
+ ...queueData[id],
+ state: "error",
+ errorCode: error,
+ }
+ }
+ return queueData;
+ });
+
+ removeWorkerFromQueue(workerId);
+ schedule();
+}
+
+export function itemDone(id: UUID, file: File) {
+ update(queueData => {
+ if (queueData[id]) {
+ queueData[id] = clearPipelineCache(queueData[id]);
+
+ queueData[id] = {
+ ...queueData[id],
+ state: "done",
+ resultFile: file,
+ }
+ }
+ return queueData;
+ });
+
+ schedule();
+}
+
+export function pipelineTaskDone(id: UUID, workerId: UUID, file: File) {
+ update(queueData => {
+ const item = queueData[id];
+
+ if (item && item.state === 'running') {
+ item.pipelineResults[workerId] = file;
+ }
+
+ return queueData;
+ });
+
+ removeWorkerFromQueue(workerId);
+ schedule();
+}
+
+export function itemRunning(id: UUID) {
+ update(queueData => {
+ const data = queueData[id] as CobaltQueueItemRunning;
+
+ if (data) {
+ data.state = 'running';
+ data.pipelineResults ??= {};
+ }
+
+ return queueData;
+ });
+
+ schedule();
+}
+
+export function removeItem(id: UUID) {
+ update(queueData => {
+ const item = queueData[id];
+
+ for (const worker of item.pipeline) {
+ removeWorkerFromQueue(worker.workerId);
+ }
+ clearPipelineCache(item);
+
+ delete queueData[id];
+ return queueData;
+ });
+
+ schedule();
+}
+
+export function clearQueue() {
+ update(() => ({}));
+ clearCurrentTasks();
+ clearFileStorage();
+}
diff --git a/web/src/lib/state/theme.ts b/web/src/lib/state/theme.ts
index 73c53fe88b382e3d6d1ff293f0af6181276a6351..6bff35d6dfd0fc3f7cb4f71f4864ae6df2d9d606 100644
--- a/web/src/lib/state/theme.ts
+++ b/web/src/lib/state/theme.ts
@@ -42,6 +42,12 @@ export default derived(
) as Readable>
export const statusBarColors = {
- "dark": "#000000",
- "light": "#ffffff"
+ mobile: {
+ dark: "#000000",
+ light: "#ffffff"
+ },
+ desktop: {
+ dark: "#131313",
+ light: "#f4f4f4"
+ }
}
diff --git a/web/src/lib/storage/index.ts b/web/src/lib/storage/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0d2c3b0dd46f302206fe5b568247bd467de79aa0
--- /dev/null
+++ b/web/src/lib/storage/index.ts
@@ -0,0 +1,19 @@
+import type { AbstractStorage } from "./storage";
+import { MemoryStorage } from "./memory";
+import { OPFSStorage } from "./opfs";
+
+export async function init(expectedSize?: number): Promise {
+ if (await OPFSStorage.isAvailable()) {
+ return OPFSStorage.init();
+ }
+
+ if (await MemoryStorage.isAvailable()) {
+ return MemoryStorage.init(expectedSize || 0);
+ }
+
+ throw "no storage method is available";
+}
+
+export function retype(file: File, type: string) {
+ return new File([ file ], file.name, { type });
+}
diff --git a/web/src/lib/storage/memory.ts b/web/src/lib/storage/memory.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fc462f0bac75b4f014e41784e015302c594992bd
--- /dev/null
+++ b/web/src/lib/storage/memory.ts
@@ -0,0 +1,92 @@
+import { AbstractStorage } from "./storage";
+import { uuid } from "$lib/util";
+
+export class MemoryStorage extends AbstractStorage {
+ #chunkSize: number;
+ #actualSize: number = 0;
+ #chunks: Uint8Array[] = [];
+
+ constructor(chunkSize: number) {
+ super();
+ this.#chunkSize = chunkSize;
+ }
+
+ static async init(expectedSize: number) {
+ const MB = 1024 * 1024;
+ const chunkSize = Math.min(512 * MB, expectedSize);
+
+ const storage = new this(chunkSize);
+
+ // since we expect the output file to be roughly the same size
+ // as inputs, preallocate its size for the output
+ for (
+ let toAllocate = expectedSize;
+ toAllocate > 0;
+ toAllocate -= chunkSize
+ ) {
+ storage.#chunks.push(new Uint8Array(chunkSize));
+ }
+
+ return storage;
+ }
+
+ async res() {
+ // if we didn't need as much space as we allocated for some reason,
+ // shrink the buffers so that we don't inflate the file with zeroes
+ const outputView: Uint8Array[] = [];
+
+ for (let i = 0; i < this.#chunks.length; ++i) {
+ outputView.push(
+ this.#chunks[i].subarray(
+ 0,
+ Math.min(this.#chunkSize, this.#actualSize),
+ ),
+ );
+
+ this.#actualSize -= this.#chunkSize;
+ if (this.#actualSize <= 0) {
+ break;
+ }
+ }
+
+ return new File(outputView, uuid());
+ }
+
+ #expand(size: number) {
+ while (size > this.#chunkSize * this.#chunks.length) {
+ this.#chunks.push(new Uint8Array(this.#chunkSize));
+ }
+ }
+
+ async write(data: Uint8Array | Int8Array, pos: number) {
+ const writeEnd = pos + data.length;
+ this.#expand(writeEnd);
+
+ const chunkIndex = pos / this.#chunkSize | 0;
+ const offset = pos - (this.#chunkSize * chunkIndex);
+
+ if (offset + data.length > this.#chunkSize) {
+ this.#chunks[chunkIndex].set(
+ data.subarray(0, this.#chunkSize - offset),
+ offset,
+ );
+ this.#chunks[chunkIndex + 1].set(
+ data.subarray(this.#chunkSize - offset),
+ 0,
+ );
+ } else {
+ this.#chunks[chunkIndex].set(data, offset);
+ }
+
+ this.#actualSize = Math.max(writeEnd, this.#actualSize);
+ return data.length;
+ }
+
+ async destroy() {
+ this.#chunks = [];
+ }
+
+ static async isAvailable() {
+ return true;
+ }
+}
diff --git a/web/src/lib/storage/opfs.ts b/web/src/lib/storage/opfs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d745cb345b8a4b32380d7c620a498835340a5279
--- /dev/null
+++ b/web/src/lib/storage/opfs.ts
@@ -0,0 +1,104 @@
+import { AbstractStorage } from "./storage";
+import { uuid } from "$lib/util";
+
+const COBALT_PROCESSING_DIR = "cobalt-processing-data";
+
+export class OPFSStorage extends AbstractStorage {
+ #root;
+ #handle;
+ #io;
+
+ static #isAvailable?: boolean;
+
+ constructor(root: FileSystemDirectoryHandle, handle: FileSystemFileHandle, reader: FileSystemSyncAccessHandle) {
+ super();
+ this.#root = root;
+ this.#handle = handle;
+ this.#io = reader;
+ }
+
+ static async init() {
+ const root = await navigator.storage.getDirectory();
+ const cobaltDir = await root.getDirectoryHandle(COBALT_PROCESSING_DIR, { create: true });
+ const handle = await cobaltDir.getFileHandle(uuid(), { create: true });
+ const reader = await handle.createSyncAccessHandle();
+
+ return new this(cobaltDir, handle, reader);
+ }
+
+ async res() {
+ // await for compat with ios 15
+ await this.#io.flush();
+ await this.#io.close();
+ return await this.#handle.getFile();
+ }
+
+ async write(data: Uint8Array | Int8Array, offset: number) {
+ return this.#io.write(data, { at: offset })
+ }
+
+ async destroy() {
+ await this.#root.removeEntry(this.#handle.name);
+ }
+
+ static async #computeIsAvailable() {
+ let tempFile = uuid(), ok = true;
+
+ if (typeof navigator === 'undefined')
+ return false;
+
+ if ('storage' in navigator && 'getDirectory' in navigator.storage) {
+ try {
+ const root = await navigator.storage.getDirectory();
+ const handle = await root.getFileHandle(tempFile, { create: true });
+ const syncAccess = await handle.createSyncAccessHandle();
+ syncAccess.close();
+ } catch {
+ ok = false;
+ }
+
+ try {
+ const root = await navigator.storage.getDirectory();
+ await root.removeEntry(tempFile, { recursive: true });
+ } catch {
+ ok = false;
+ }
+
+ return ok;
+ }
+
+ return false;
+ }
+
+ static async isAvailable() {
+ if (this.#isAvailable === undefined) {
+ this.#isAvailable = await this.#computeIsAvailable();
+ }
+
+ return this.#isAvailable;
+ }
+}
+
+export const removeFromFileStorage = async (filename: string) => {
+ if (await OPFSStorage.isAvailable()) {
+ const root = await navigator.storage.getDirectory();
+
+ try {
+ const cobaltDir = await root.getDirectoryHandle(COBALT_PROCESSING_DIR);
+ await cobaltDir.removeEntry(filename);
+ } catch {
+ // catch and ignore
+ }
+ }
+}
+
+export const clearFileStorage = async () => {
+ if (await OPFSStorage.isAvailable()) {
+ const root = await navigator.storage.getDirectory();
+ try {
+ await root.removeEntry(COBALT_PROCESSING_DIR, { recursive: true });
+ } catch {
+ // ignore the error because the dir might be missing and that's okay!
+ }
+ }
+}
diff --git a/web/src/lib/storage/storage.ts b/web/src/lib/storage/storage.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5ffc71dbff0f902e029e5838c64f34914b434bac
--- /dev/null
+++ b/web/src/lib/storage/storage.ts
@@ -0,0 +1,13 @@
+export abstract class AbstractStorage {
+ static init(_expected_size: number): Promise {
+ throw "init() call on abstract implementation";
+ }
+
+ static async isAvailable(): Promise {
+ return false;
+ }
+
+ abstract res(): Promise;
+ abstract write(data: Uint8Array | Int8Array, offset: number): Promise;
+ abstract destroy(): Promise;
+};
diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2d43933354552bd07d44a790f93a777c9958fb5b
--- /dev/null
+++ b/web/src/lib/task-manager/queue.ts
@@ -0,0 +1,247 @@
+import { get } from "svelte/store";
+import { t } from "$lib/i18n/translations";
+import { ffmpegMetadataArgs } from "$lib/util";
+import { createDialog } from "$lib/state/dialogs";
+import { addItem } from "$lib/state/task-manager/queue";
+import { openQueuePopover } from "$lib/state/queue-visibility";
+import { uuid } from "$lib/util";
+
+import type { CobaltQueueItem } from "$lib/types/queue";
+import type { CobaltCurrentTasks } from "$lib/types/task-manager";
+import { resultFileTypes, type CobaltPipelineItem, type CobaltPipelineResultFileType } from "$lib/types/workers";
+import type { CobaltLocalProcessingResponse, CobaltSaveRequestBody } from "$lib/types/api";
+
+export const getMediaType = (type: string) => {
+ const kind = type.split('/')[0] as CobaltPipelineResultFileType;
+
+ if (resultFileTypes.includes(kind)) {
+ return kind;
+ }
+}
+
+export const createRemuxPipeline = (file: File) => {
+ const parentId = uuid();
+ const mediaType = getMediaType(file.type);
+
+ const pipeline: CobaltPipelineItem[] = [{
+ worker: "remux",
+ workerId: uuid(),
+ parentId,
+ workerArgs: {
+ files: [file],
+ ffargs: [
+ "-c", "copy",
+ "-map", "0"
+ ],
+ output: {
+ type: file.type,
+ format: file.name.split(".").pop(),
+ },
+ },
+ }];
+
+ if (mediaType) {
+ addItem({
+ id: parentId,
+ state: "waiting",
+ pipeline,
+ filename: file.name,
+ mimeType: file.type,
+ mediaType,
+ });
+
+ openQueuePopover();
+ }
+}
+
+const makeRemuxArgs = (info: CobaltLocalProcessingResponse) => {
+ const ffargs = ["-c:v", "copy"];
+
+ if (["merge", "remux"].includes(info.type)) {
+ ffargs.push("-c:a", "copy");
+ } else if (info.type === "mute") {
+ ffargs.push("-an");
+ }
+
+ if (info.output.subtitles) {
+ ffargs.push(
+ "-c:s",
+ info.output.filename.endsWith(".mp4") ? "mov_text" : "webvtt"
+ );
+ }
+
+ ffargs.push(
+ ...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : [])
+ );
+
+ return ffargs;
+}
+
+const makeAudioArgs = (info: CobaltLocalProcessingResponse) => {
+ if (!info.audio) {
+ return;
+ }
+
+ const ffargs = [];
+
+ if (info.audio.cover && info.audio.format === "mp3") {
+ ffargs.push(
+ "-map", "0",
+ "-map", "1",
+ ...(info.audio.cropCover ? [
+ "-c:v", "mjpeg",
+ "-vf", "scale=-1:720,crop=720:720",
+ ] : [
+ "-c:v", "copy",
+ ]),
+ );
+ } else {
+ ffargs.push("-vn");
+ }
+
+ ffargs.push(
+ ...(info.audio.copy ? ["-c:a", "copy"] : ["-b:a", `${info.audio.bitrate}k`]),
+ ...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : [])
+ );
+
+ if (info.audio.format === "mp3" && info.audio.bitrate === "8") {
+ ffargs.push("-ar", "12000");
+ }
+
+ if (info.audio.format === "opus") {
+ ffargs.push("-vbr", "off")
+ }
+
+ const outFormat = info.audio.format === "m4a" ? "ipod" : info.audio.format;
+
+ ffargs.push('-f', outFormat);
+ return ffargs;
+}
+
+const makeGifArgs = () => {
+ return [
+ "-vf",
+ "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
+ "-loop", "0",
+ "-f", "gif"
+ ];
+}
+
+const showError = (errorCode: string) => {
+ return createDialog({
+ id: "pipeline-error",
+ type: "small",
+ meowbalt: "error",
+ buttons: [
+ {
+ text: get(t)("button.gotit"),
+ main: true,
+ action: () => {},
+ },
+ ],
+ bodyText: get(t)(`error.${errorCode}`),
+ });
+}
+
+export const createSavePipeline = (
+ info: CobaltLocalProcessingResponse,
+ request: CobaltSaveRequestBody,
+ oldTaskId?: string
+) => {
+ // this is a pre-queue part of processing,
+ // so errors have to be returned via a regular dialog
+
+ if (!info.output?.filename || !info.output?.type) {
+ return showError("pipeline.missing_response_data");
+ }
+
+ const parentId = oldTaskId || uuid();
+ const pipeline: CobaltPipelineItem[] = [];
+
+ // reverse is needed for audio (second item) to be downloaded first
+ const tunnels = info.tunnel.reverse();
+
+ for (const tunnel of tunnels) {
+ pipeline.push({
+ worker: "fetch",
+ workerId: uuid(),
+ parentId,
+ workerArgs: {
+ url: tunnel,
+ },
+ });
+ }
+
+ if (info.type !== "proxy") {
+ let ffargs: string[];
+ let workerType: 'encode' | 'remux';
+
+ if (["merge", "mute", "remux"].includes(info.type)) {
+ workerType = "remux";
+ ffargs = makeRemuxArgs(info);
+ } else if (info.type === "audio") {
+ const args = makeAudioArgs(info);
+
+ if (!args) {
+ return showError("pipeline.missing_response_data");
+ }
+
+ workerType = "encode";
+ ffargs = args;
+ } else if (info.type === "gif") {
+ workerType = "encode";
+ ffargs = makeGifArgs();
+ } else {
+ console.error("unknown work type: " + info.type);
+ return showError("pipeline.missing_response_data");
+ }
+
+ pipeline.push({
+ worker: workerType,
+ workerId: uuid(),
+ parentId,
+ dependsOn: pipeline.map(w => w.workerId),
+ workerArgs: {
+ files: [],
+ ffargs,
+ output: {
+ type: info.output.type,
+ format: info.output.filename.split(".").pop(),
+ },
+ },
+ });
+ }
+
+ addItem({
+ id: parentId,
+ state: "waiting",
+ pipeline,
+ canRetry: true,
+ originalRequest: request,
+ filename: info.output.filename,
+ mimeType: info.output.type,
+ mediaType: getMediaType(info.output.type) || "file",
+ });
+
+ openQueuePopover();
+}
+
+export const getProgress = (item: CobaltQueueItem, currentTasks: CobaltCurrentTasks): number => {
+ if (item.state === 'done' || item.state === 'error') {
+ return 1;
+ } else if (item.state === 'waiting') {
+ return 0;
+ }
+
+ let sum = 0;
+ for (const worker of item.pipeline) {
+ if (item.pipelineResults[worker.workerId]) {
+ sum += 1;
+ } else {
+ const task = currentTasks[worker.workerId];
+ sum += (task?.progress?.percentage || 0) / 100;
+ }
+ }
+
+ return sum / item.pipeline.length;
+}
diff --git a/web/src/lib/task-manager/run-worker.ts b/web/src/lib/task-manager/run-worker.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b9994661ea5fefb8588644b294f516734918f28d
--- /dev/null
+++ b/web/src/lib/task-manager/run-worker.ts
@@ -0,0 +1,59 @@
+import { get } from "svelte/store";
+import { device } from "$lib/device";
+import { queue, itemError } from "$lib/state/task-manager/queue";
+
+import { runFFmpegWorker } from "$lib/task-manager/runners/ffmpeg";
+import { runFetchWorker } from "$lib/task-manager/runners/fetch";
+
+import type { CobaltPipelineItem } from "$lib/types/workers";
+
+export const killWorker = (worker: Worker, unsubscribe: () => void, interval?: NodeJS.Timeout) => {
+ unsubscribe();
+ worker.terminate();
+ if (interval) clearInterval(interval);
+}
+
+export const startWorker = async ({ worker, workerId, dependsOn, parentId, workerArgs }: CobaltPipelineItem) => {
+ let files: File[] = [];
+
+ switch (worker) {
+ case "remux":
+ case "encode": {
+ if (workerArgs.files) {
+ files = workerArgs.files;
+ }
+
+ const parent = get(queue)[parentId];
+ if (parent?.state === "running" && dependsOn) {
+ for (const workerId of dependsOn) {
+ const file = parent.pipelineResults[workerId];
+ if (!file) {
+ return itemError(parentId, workerId, "queue.ffmpeg.no_args");
+ }
+
+ files.push(file);
+ }
+ }
+
+ if (files.length > 0 && workerArgs.ffargs && workerArgs.output) {
+ await runFFmpegWorker(
+ workerId,
+ parentId,
+ files,
+ workerArgs.ffargs,
+ workerArgs.output,
+ worker,
+ device.supports.multithreading,
+ /*resetStartCounter=*/true,
+ );
+ } else {
+ itemError(parentId, workerId, "queue.ffmpeg.no_args");
+ }
+ break;
+ }
+
+ case "fetch":
+ await runFetchWorker(workerId, parentId, workerArgs.url);
+ break;
+ }
+}
diff --git a/web/src/lib/task-manager/runners/fetch.ts b/web/src/lib/task-manager/runners/fetch.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1da004ad588310ce9365de58bcc018d34e3ccc86
--- /dev/null
+++ b/web/src/lib/task-manager/runners/fetch.ts
@@ -0,0 +1,49 @@
+import FetchWorker from "$lib/task-manager/workers/fetch?worker";
+
+import { killWorker } from "$lib/task-manager/run-worker";
+import { updateWorkerProgress } from "$lib/state/task-manager/current-tasks";
+import { pipelineTaskDone, itemError, queue } from "$lib/state/task-manager/queue";
+
+import type { CobaltQueue, UUID } from "$lib/types/queue";
+
+export const runFetchWorker = async (workerId: UUID, parentId: UUID, url: string) => {
+ const worker = new FetchWorker();
+
+ const unsubscribe = queue.subscribe((queue: CobaltQueue) => {
+ if (!queue[parentId]) {
+ killWorker(worker, unsubscribe);
+ }
+ });
+
+ worker.postMessage({
+ cobaltFetchWorker: {
+ url
+ }
+ });
+
+ worker.onmessage = (event) => {
+ const eventData = event.data.cobaltFetchWorker;
+ if (!eventData) return;
+
+ if (eventData.progress) {
+ updateWorkerProgress(workerId, {
+ percentage: eventData.progress,
+ size: eventData.size,
+ })
+ }
+
+ if (eventData.result) {
+ killWorker(worker, unsubscribe);
+ return pipelineTaskDone(
+ parentId,
+ workerId,
+ eventData.result,
+ );
+ }
+
+ if (eventData.error) {
+ killWorker(worker, unsubscribe);
+ return itemError(parentId, workerId, eventData.error);
+ }
+ }
+}
diff --git a/web/src/lib/task-manager/runners/ffmpeg.ts b/web/src/lib/task-manager/runners/ffmpeg.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0a83f3fb3615fe14d59d9fb06971648d9aac45f1
--- /dev/null
+++ b/web/src/lib/task-manager/runners/ffmpeg.ts
@@ -0,0 +1,106 @@
+import FFmpegWorker from "$lib/task-manager/workers/ffmpeg?worker";
+
+import { killWorker } from "$lib/task-manager/run-worker";
+import { updateWorkerProgress } from "$lib/state/task-manager/current-tasks";
+import { pipelineTaskDone, itemError, queue } from "$lib/state/task-manager/queue";
+
+import type { FileInfo } from "$lib/types/libav";
+import type { CobaltQueue } from "$lib/types/queue";
+
+let startAttempts = 0;
+
+export const runFFmpegWorker = async (
+ workerId: string,
+ parentId: string,
+ files: File[],
+ args: string[],
+ output: FileInfo,
+ variant: 'remux' | 'encode',
+ yesthreads: boolean,
+ resetStartCounter = false,
+) => {
+ const worker = new FFmpegWorker();
+
+ // sometimes chrome refuses to start libav wasm,
+ // so we check if it started, try 10 more times if not, and kill self if it still doesn't work
+ // TODO: fix the underlying issue because this is ridiculous
+
+ if (resetStartCounter) startAttempts = 0;
+
+ let bumpAttempts = 0;
+ const startCheck = setInterval(async () => {
+ bumpAttempts++;
+
+ if (bumpAttempts === 10) {
+ startAttempts++;
+ if (startAttempts <= 10) {
+ killWorker(worker, unsubscribe, startCheck);
+ return await runFFmpegWorker(
+ workerId, parentId,
+ files, args, output,
+ variant, yesthreads
+ );
+ } else {
+ killWorker(worker, unsubscribe, startCheck);
+ return itemError(parentId, workerId, "queue.worker_didnt_start");
+ }
+ }
+ }, 500);
+
+ const unsubscribe = queue.subscribe((queue: CobaltQueue) => {
+ if (!queue[parentId]) {
+ killWorker(worker, unsubscribe, startCheck);
+ }
+ });
+
+ worker.postMessage({
+ cobaltFFmpegWorker: {
+ variant,
+ files,
+ args,
+ output,
+ yesthreads,
+ }
+ });
+
+ worker.onerror = (e) => {
+ console.error("ffmpeg worker crashed:", e);
+ killWorker(worker, unsubscribe, startCheck);
+
+ return itemError(parentId, workerId, "queue.generic_error");
+ };
+
+ let totalDuration: number | null = null;
+
+ worker.onmessage = (event) => {
+ const eventData = event.data.cobaltFFmpegWorker;
+ if (!eventData) return;
+
+ clearInterval(startCheck);
+
+ if (eventData.progress) {
+ if (eventData.progress.duration) {
+ totalDuration = eventData.progress.duration;
+ }
+
+ updateWorkerProgress(workerId, {
+ percentage: totalDuration ? (eventData.progress.durationProcessed / totalDuration) * 100 : 0,
+ size: eventData.progress.size,
+ })
+ }
+
+ if (eventData.render) {
+ killWorker(worker, unsubscribe, startCheck);
+ return pipelineTaskDone(
+ parentId,
+ workerId,
+ eventData.render,
+ );
+ }
+
+ if (eventData.error) {
+ killWorker(worker, unsubscribe, startCheck);
+ return itemError(parentId, workerId, eventData.error);
+ }
+ };
+}
diff --git a/web/src/lib/task-manager/scheduler.ts b/web/src/lib/task-manager/scheduler.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f774ea8805b802aaab9b7644c67dfd2e51fb4cd4
--- /dev/null
+++ b/web/src/lib/task-manager/scheduler.ts
@@ -0,0 +1,80 @@
+import { get } from "svelte/store";
+import { startWorker } from "$lib/task-manager/run-worker";
+import { addWorkerToQueue, currentTasks } from "$lib/state/task-manager/current-tasks";
+import { itemDone, itemError, itemRunning, queue } from "$lib/state/task-manager/queue";
+
+import type { CobaltPipelineItem } from "$lib/types/workers";
+
+const startPipeline = (pipelineItem: CobaltPipelineItem) => {
+ addWorkerToQueue(pipelineItem.workerId, {
+ type: pipelineItem.worker,
+ parentId: pipelineItem.parentId,
+ });
+
+ itemRunning(pipelineItem.parentId);
+ startWorker(pipelineItem);
+}
+
+// this is really messy, sorry to whoever
+// reads this in the future (probably myself)
+export const schedule = () => {
+ const queueItems = get(queue);
+ const ongoingTasks = get(currentTasks);
+
+ for (const task of Object.values(queueItems)) {
+ if (task.state === "running") {
+ const finalWorker = task.pipeline[task.pipeline.length - 1];
+
+ // if all workers are completed, then return the
+ // the final file and go to the next task
+ if (Object.keys(task.pipelineResults).length === task.pipeline.length) {
+ // remove the final file from pipeline results, so that it doesn't
+ // get deleted when we clean up the intermediate files
+ const finalFile = task.pipelineResults[finalWorker.workerId];
+ delete task.pipelineResults[finalWorker.workerId];
+
+ if (finalFile) {
+ itemDone(task.id, finalFile);
+ } else {
+ itemError(task.id, finalWorker.workerId, "queue.no_final_file");
+ }
+
+ continue;
+ }
+
+ // if current worker is completed, but there are more workers,
+ // then start the next one and wait to be called again
+ for (const worker of task.pipeline) {
+ if (task.pipelineResults[worker.workerId] || ongoingTasks[worker.workerId]) {
+ continue;
+ }
+
+ const needsToWait = worker.dependsOn?.some(id => !task.pipelineResults[id]);
+ if (needsToWait) {
+ break;
+ }
+
+ startPipeline(worker);
+ }
+
+ // break because we don't want to start next tasks before this one is done
+ // it's necessary because some tasks might take some time before being marked as running
+ break;
+ }
+
+ // start the nearest waiting task and wait to be called again
+ else if (task.state === "waiting" && task.pipeline.length > 0 && Object.keys(ongoingTasks).length === 0) {
+ // this is really bad but idk how to prevent tasks from running simultaneously
+ // on retry if a later task is running & user restarts an old task
+ for (const task of Object.values(queueItems)) {
+ if (task.state === "running") return;
+ }
+
+ startPipeline(task.pipeline[0]);
+
+ // break because we don't want to start next tasks before this one is done
+ // it's necessary because some tasks might take some time before being marked as running
+ break;
+ }
+ }
+}
diff --git a/web/src/lib/task-manager/workers/fetch.ts b/web/src/lib/task-manager/workers/fetch.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d1bf4d8ad5fa38fd2b31a4b19562ccf02cc5df6c
--- /dev/null
+++ b/web/src/lib/task-manager/workers/fetch.ts
@@ -0,0 +1,106 @@
+import * as Storage from "$lib/storage";
+
+const networkErrors = [
+ "TypeError: Failed to fetch",
+ "TypeError: network error",
+];
+
+let attempts = 0;
+
+const fetchFile = async (url: string) => {
+ const error = async (code: string, retry: boolean = true) => {
+ attempts++;
+
+ // try 3 more times before actually failing
+ if (retry && attempts <= 3) {
+ await fetchFile(url);
+ } else {
+ self.postMessage({
+ cobaltFetchWorker: {
+ error: code,
+ }
+ });
+ return self.close();
+ }
+ };
+
+ try {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ return error("queue.fetch.bad_response");
+ }
+
+ const contentType = response.headers.get('Content-Type')
+ || 'application/octet-stream';
+
+ const contentLength = response.headers.get('Content-Length');
+ const estimatedLength = response.headers.get('Estimated-Content-Length');
+
+ let expectedSize;
+
+ if (contentLength) {
+ expectedSize = +contentLength;
+ } else if (estimatedLength) {
+ expectedSize = +estimatedLength;
+ }
+
+ const reader = response.body?.getReader();
+
+ const storage = await Storage.init(expectedSize);
+
+ if (!reader) {
+ return error("queue.fetch.no_file_reader");
+ }
+
+ let receivedBytes = 0;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ await storage.write(value, receivedBytes);
+ receivedBytes += value.length;
+
+ if (expectedSize) {
+ self.postMessage({
+ cobaltFetchWorker: {
+ progress: Math.round((receivedBytes / expectedSize) * 100),
+ size: receivedBytes,
+ }
+ });
+ }
+ }
+
+ if (receivedBytes === 0) {
+ return error("queue.fetch.empty_tunnel");
+ }
+
+ const file = Storage.retype(await storage.res(), contentType);
+
+ if (contentLength && Number(contentLength) !== file.size) {
+ return error("queue.fetch.corrupted_file", false);
+ }
+
+ self.postMessage({
+ cobaltFetchWorker: {
+ result: file
+ }
+ });
+ } catch (e) {
+ // retry several times if the error is network-related
+ if (networkErrors.includes(String(e))) {
+ return error("queue.fetch.network_error");
+ }
+ console.error("error from the fetch worker:");
+ console.error(e);
+ return error("queue.fetch.crashed", false);
+ }
+}
+
+self.onmessage = async (event: MessageEvent) => {
+ if (event.data.cobaltFetchWorker) {
+ await fetchFile(event.data.cobaltFetchWorker.url);
+ self.close();
+ }
+}
diff --git a/web/src/lib/task-manager/workers/ffmpeg.ts b/web/src/lib/task-manager/workers/ffmpeg.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7f7577f287e680416b6d7dd9a92ea975947e3a37
--- /dev/null
+++ b/web/src/lib/task-manager/workers/ffmpeg.ts
@@ -0,0 +1,133 @@
+import LibAVWrapper from "$lib/libav";
+import type { FileInfo } from "$lib/types/libav";
+
+const ffmpeg = async (
+ variant: string,
+ files: File[],
+ args: string[],
+ output: FileInfo,
+ yesthreads: boolean = false,
+) => {
+ if (!(files && output && args)) {
+ self.postMessage({
+ cobaltFFmpegWorker: {
+ error: "queue.ffmpeg.no_args",
+ }
+ });
+ return;
+ }
+
+ const ff = new LibAVWrapper((progress) => {
+ self.postMessage({
+ cobaltFFmpegWorker: {
+ progress: {
+ durationProcessed: progress.out_time_sec,
+ speed: progress.speed,
+ size: progress.total_size,
+ currentFrame: progress.frame,
+ fps: progress.fps,
+ }
+ }
+ })
+ });
+
+ ff.init({ variant, yesthreads });
+
+ const error = (code: string) => {
+ self.postMessage({
+ cobaltFFmpegWorker: {
+ error: code,
+ }
+ });
+ ff.terminate();
+ }
+
+ try {
+ // probing just the first file in files array (usually audio) for duration progress
+ const probeFile = files[0];
+ if (!probeFile) {
+ return error("queue.ffmpeg.probe_failed");
+ }
+
+ let file_info;
+
+ try {
+ file_info = await ff.probe(probeFile);
+ } catch (e) {
+ console.error("error from ffmpeg worker @ file_info:");
+ if (e instanceof Error && e?.message?.toLowerCase().includes("out of memory")) {
+ console.error(e);
+
+ error("queue.ffmpeg.out_of_memory");
+ return self.close();
+ } else {
+ console.error(e);
+ return error("queue.ffmpeg.probe_failed");
+ }
+ }
+
+ if (!file_info?.format) {
+ return error("queue.ffmpeg.no_input_format");
+ }
+
+ // handle the edge case when a video doesn't have an audio track
+ // but user still tries to extract it
+ if (files.length === 1 && file_info.streams?.length === 1) {
+ if (output.type?.startsWith("audio") && file_info.streams[0].codec_type !== "audio") {
+ return error("queue.ffmpeg.no_audio_channel");
+ }
+ }
+
+ self.postMessage({
+ cobaltFFmpegWorker: {
+ progress: {
+ duration: Number(file_info.format.duration),
+ }
+ }
+ });
+
+ for (const file of files) {
+ if (!file.type) {
+ return error("queue.ffmpeg.no_input_type");
+ }
+ }
+
+ let render;
+
+ try {
+ render = await ff.render({
+ files,
+ output,
+ args,
+ });
+ } catch (e) {
+ console.error("error from the ffmpeg worker @ render:");
+ console.error(e);
+ // TODO: more granular error codes
+ return error("queue.ffmpeg.crashed");
+ }
+
+ if (!render) {
+ return error("queue.ffmpeg.no_render");
+ }
+
+ await ff.terminate();
+
+ self.postMessage({
+ cobaltFFmpegWorker: {
+ render
+ }
+ });
+ } catch (e) {
+ console.error("error from the ffmpeg worker:")
+ console.error(e);
+ return error("queue.ffmpeg.crashed");
+ }
+}
+
+self.onmessage = async (event: MessageEvent) => {
+ const ed = event.data.cobaltFFmpegWorker;
+ if (ed?.variant && ed?.files && ed?.args && ed?.output) {
+ await ffmpeg(ed.variant, ed.files, ed.args, ed.output, ed.yesthreads);
+ }
+}
diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts
index b2e47a40ee5736590279444aa908225e0c9502b4..f127d02e3da4d97338923ff164c80d7f7a36ac13 100644
--- a/web/src/lib/types/api.ts
+++ b/web/src/lib/types/api.ts
@@ -1,8 +1,11 @@
+import type { CobaltSettings } from "$lib/types/settings";
+
enum CobaltResponseType {
Error = 'error',
Picker = 'picker',
Redirect = 'redirect',
Tunnel = 'tunnel',
+ LocalProcessing = 'local-processing',
}
export type CobaltErrorResponse = {
@@ -40,6 +43,50 @@ type CobaltTunnelResponse = {
status: CobaltResponseType.Tunnel,
} & CobaltPartialURLResponse;
+export const CobaltFileMetadataKeys = [
+ 'album',
+ 'composer',
+ 'genre',
+ 'copyright',
+ 'title',
+ 'artist',
+ 'album_artist',
+ 'track',
+ 'date',
+ 'sublanguage',
+];
+
+export type CobaltFileMetadata = Record<
+ typeof CobaltFileMetadataKeys[number], string | undefined
+>;
+
+export type CobaltLocalProcessingType = 'merge' | 'mute' | 'audio' | 'gif' | 'remux' | 'proxy';
+
+export type CobaltLocalProcessingResponse = {
+ status: CobaltResponseType.LocalProcessing,
+
+ type: CobaltLocalProcessingType,
+ service: string,
+ tunnel: string[],
+
+ output: {
+ type: string, // mimetype
+ filename: string,
+ metadata?: CobaltFileMetadata,
+ subtitles?: boolean,
+ },
+
+ audio?: {
+ copy: boolean,
+ format: string,
+ bitrate: string,
+ cover?: boolean,
+ cropCover?: boolean,
+ },
+
+ isHLS?: boolean,
+}
+
export type CobaltFileUrlType = "redirect" | "tunnel";
export type CobaltSession = {
@@ -52,7 +99,6 @@ export type CobaltServerInfo = {
version: string,
url: string,
startTime: string,
- durationLimit: number,
turnstileSitekey?: string,
services: string[]
},
@@ -63,10 +109,17 @@ export type CobaltServerInfo = {
}
}
+// TODO: strict partial
+// this allows for extra properties, which is not ideal,
+// but i couldn't figure out how to make a strict partial :(
+export type CobaltSaveRequestBody =
+ { url: string } & Partial>;
+
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;
export type CobaltAPIResponse = CobaltErrorResponse
| CobaltPickerResponse
| CobaltRedirectResponse
- | CobaltTunnelResponse;
+ | CobaltTunnelResponse
+ | CobaltLocalProcessingResponse;
diff --git a/web/src/lib/types/changelogs.ts b/web/src/lib/types/changelogs.ts
index d07740d680f46dd0cf33b66e2bde24686fc9e485..aa2472c85233ff817a8b0c5721f800ccd4d4b5f9 100644
--- a/web/src/lib/types/changelogs.ts
+++ b/web/src/lib/types/changelogs.ts
@@ -1,5 +1,3 @@
-import type { SvelteComponent } from "svelte"
-
export interface ChangelogMetadata {
title: string,
date: string,
@@ -14,6 +12,6 @@ export interface MarkdownMetadata {
};
export type ChangelogImport = {
- default: SvelteComponent,
+ default: ConstructorOfATypedSvelteComponent,
metadata: ChangelogMetadata
};
\ No newline at end of file
diff --git a/web/src/lib/types/generic.ts b/web/src/lib/types/generic.ts
index 598441060da33890d89f0ef84434f9fc855883b6..19f78db238758490650bc1097f7d1adc9949ea0b 100644
--- a/web/src/lib/types/generic.ts
+++ b/web/src/lib/types/generic.ts
@@ -1,3 +1,5 @@
+import type { Readable } from "svelte/store";
+
// more readable version of recursive partial taken from stackoverflow:
// https://stackoverflow.com/a/51365037
export type RecursivePartial = {
@@ -10,3 +12,4 @@ export type RecursivePartial = {
export type DefaultImport = () => Promise<{ default: T }>;
export type Optional = T | undefined;
export type Writeable = { -readonly [P in keyof T]: T[P] };
+export type FromReadable = T extends Readable ? U : never;
diff --git a/web/src/lib/types/libav.ts b/web/src/lib/types/libav.ts
index eed54edf1acb70e62c93b530954c884fa0e055ad..ef7ac9a699939bdaee09daa51b87909f052c5d4b 100644
--- a/web/src/lib/types/libav.ts
+++ b/web/src/lib/types/libav.ts
@@ -1,18 +1,14 @@
-export type InputFileKind = "video" | "audio";
-
export type FileInfo = {
- type?: string | null,
- kind: InputFileKind,
- extension: string,
+ type?: string,
+ format?: string,
}
export type RenderParams = {
- blob: Blob,
- output?: FileInfo,
+ files: File[],
+ output: FileInfo,
args: string[],
}
-
export type FFmpegProgressStatus = "continue" | "end" | "unknown";
export type FFmpegProgressEvent = {
status: FFmpegProgressStatus,
diff --git a/web/src/lib/types/omnibox.ts b/web/src/lib/types/omnibox.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fcd92b2a692cedab21c0dd542540ad2f3b99c8b4
--- /dev/null
+++ b/web/src/lib/types/omnibox.ts
@@ -0,0 +1 @@
+export type CobaltDownloadButtonState = "idle" | "think" | "check" | "done" | "error";
diff --git a/web/src/lib/types/queue.ts b/web/src/lib/types/queue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e542808b09bdcc46453d927fd376e96c85add1b3
--- /dev/null
+++ b/web/src/lib/types/queue.ts
@@ -0,0 +1,42 @@
+import type { CobaltSaveRequestBody } from "$lib/types/api";
+import type { CobaltPipelineItem, CobaltPipelineResultFileType } from "$lib/types/workers";
+
+export type UUID = string;
+
+type CobaltQueueBaseItem = {
+ id: UUID,
+ pipeline: CobaltPipelineItem[],
+ canRetry?: boolean,
+ originalRequest?: CobaltSaveRequestBody,
+ filename: string,
+ mimeType?: string,
+ mediaType: CobaltPipelineResultFileType,
+};
+
+type CobaltQueueItemWaiting = CobaltQueueBaseItem & {
+ state: "waiting",
+};
+
+export type CobaltQueueItemRunning = CobaltQueueBaseItem & {
+ state: "running",
+ pipelineResults: Record,
+};
+
+type CobaltQueueItemDone = CobaltQueueBaseItem & {
+ state: "done",
+ resultFile: File,
+};
+
+type CobaltQueueItemError = CobaltQueueBaseItem & {
+ state: "error",
+ errorCode: string,
+};
+
+export type CobaltQueueItem = CobaltQueueItemWaiting
+ | CobaltQueueItemRunning
+ | CobaltQueueItemDone
+ | CobaltQueueItemError;
+
+export type CobaltQueue = {
+ [id: UUID]: CobaltQueueItem,
+};
diff --git a/web/src/lib/types/settings.ts b/web/src/lib/types/settings.ts
index 93958e88ffeb69e8f5b7c9c5fa5457dd61b67bce..1d184eec87e4bda4d32379aa2c360393bcf7451e 100644
--- a/web/src/lib/types/settings.ts
+++ b/web/src/lib/types/settings.ts
@@ -2,14 +2,18 @@ import type { RecursivePartial } from "$lib/types/generic";
import type { CobaltSettingsV2 } from "$lib/types/settings/v2";
import type { CobaltSettingsV3 } from "$lib/types/settings/v3";
import type { CobaltSettingsV4 } from "$lib/types/settings/v4";
+import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
+import type { CobaltSettingsV6 } from "$lib/types/settings/v6";
export * from "$lib/types/settings/v2";
export * from "$lib/types/settings/v3";
export * from "$lib/types/settings/v4";
+export * from "$lib/types/settings/v5";
+export * from "$lib/types/settings/v6";
-export type CobaltSettings = CobaltSettingsV4;
+export type CobaltSettings = CobaltSettingsV6;
-export type AnyCobaltSettings = CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
+export type AnyCobaltSettings = CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
export type PartialSettings = RecursivePartial;
diff --git a/web/src/lib/types/settings/v3.ts b/web/src/lib/types/settings/v3.ts
index 7d02f2dae929aae4dc4184190d380249e4aa0ea8..31e223c999deb6f491e8edd23093a504edbe6e9c 100644
--- a/web/src/lib/types/settings/v3.ts
+++ b/web/src/lib/types/settings/v3.ts
@@ -1,9 +1,9 @@
-import type { YoutubeLang } from "$lib/settings/youtube-lang";
+import type { YoutubeDubLang } from "$lib/settings/audio-sub-language";
import { type CobaltSettingsV2 } from "$lib/types/settings/v2";
export type CobaltSettingsV3 = Omit & {
schemaVersion: 3,
save: Omit & {
- youtubeDubLang: YoutubeLang;
+ youtubeDubLang: YoutubeDubLang;
};
};
diff --git a/web/src/lib/types/settings/v5.ts b/web/src/lib/types/settings/v5.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dee2e8ee50094728e01088387f1f65159f609e9d
--- /dev/null
+++ b/web/src/lib/types/settings/v5.ts
@@ -0,0 +1,25 @@
+import { type CobaltSettingsV4 } from "$lib/types/settings/v4";
+
+export type CobaltSettingsV5 = Omit & {
+ schemaVersion: 5,
+ appearance: Omit & {
+ hideRemuxTab: boolean,
+ },
+ accessibility: {
+ reduceMotion: boolean;
+ reduceTransparency: boolean;
+ disableHaptics: boolean;
+ dontAutoOpenQueue: boolean;
+ },
+ advanced: CobaltSettingsV4['advanced'] & {
+ useWebCodecs: boolean;
+ },
+ privacy: Omit,
+ save: Omit & {
+ alwaysProxy: boolean;
+ localProcessing: boolean;
+ allowH265: boolean;
+ convertGif: boolean;
+ youtubeBetterAudio: boolean;
+ },
+};
diff --git a/web/src/lib/types/settings/v6.ts b/web/src/lib/types/settings/v6.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0a933e679b04a360f3e0bd28d962f5710c0c86e6
--- /dev/null
+++ b/web/src/lib/types/settings/v6.ts
@@ -0,0 +1,14 @@
+import type { SubtitleLang } from "$lib/settings/audio-sub-language";
+import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
+
+export const youtubeVideoContainerOptions = ["auto", "mp4", "webm", "mkv"] as const;
+export const localProcessingOptions = ["disabled", "preferred", "forced"] as const;
+
+export type CobaltSettingsV6 = Omit & {
+ schemaVersion: 6,
+ save: Omit & {
+ localProcessing: typeof localProcessingOptions[number],
+ youtubeVideoContainer: typeof youtubeVideoContainerOptions[number];
+ subtitleLang: SubtitleLang,
+ },
+};
diff --git a/web/src/lib/types/task-manager.ts b/web/src/lib/types/task-manager.ts
new file mode 100644
index 0000000000000000000000000000000000000000..61882cc6375de9627bfb2031f0b7d80345683b4b
--- /dev/null
+++ b/web/src/lib/types/task-manager.ts
@@ -0,0 +1,12 @@
+import type { CobaltPipelineItem, CobaltWorkerProgress } from "$lib/types/workers";
+import type { UUID } from "./queue";
+
+export type CobaltCurrentTaskItem = {
+ type: CobaltPipelineItem['worker'],
+ parentId: UUID,
+ progress?: CobaltWorkerProgress,
+}
+
+export type CobaltCurrentTasks = {
+ [id: UUID]: CobaltCurrentTaskItem,
+}
diff --git a/web/src/lib/types/workers.ts b/web/src/lib/types/workers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b65abf69d2a07e566a88105ef9758b8f7ab504ea
--- /dev/null
+++ b/web/src/lib/types/workers.ts
@@ -0,0 +1,43 @@
+import type { FileInfo } from "$lib/types/libav";
+import type { UUID } from "./queue";
+
+export const resultFileTypes = ["video", "audio", "image", "file"] as const;
+
+export type CobaltPipelineResultFileType = typeof resultFileTypes[number];
+
+export type CobaltWorkerProgress = {
+ percentage?: number,
+ speed?: number,
+ size: number,
+};
+
+type CobaltFFmpegWorkerArgs = {
+ files: File[],
+ ffargs: string[],
+ output: FileInfo,
+};
+
+type CobaltPipelineItemBase = {
+ workerId: UUID,
+ parentId: UUID,
+ dependsOn?: UUID[],
+};
+
+type CobaltRemuxPipelineItem = CobaltPipelineItemBase & {
+ worker: "remux",
+ workerArgs: CobaltFFmpegWorkerArgs,
+}
+
+type CobaltEncodePipelineItem = CobaltPipelineItemBase & {
+ worker: "encode",
+ workerArgs: CobaltFFmpegWorkerArgs,
+}
+
+type CobaltFetchPipelineItem = CobaltPipelineItemBase & {
+ worker: "fetch",
+ workerArgs: { url: string },
+}
+
+export type CobaltPipelineItem = CobaltEncodePipelineItem
+ | CobaltRemuxPipelineItem
+ | CobaltFetchPipelineItem;
diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4e8331662b41616783233334eca2a00d1a3edb56
--- /dev/null
+++ b/web/src/lib/util.ts
@@ -0,0 +1,52 @@
+import { CobaltFileMetadataKeys, type CobaltFileMetadata } from "$lib/types/api";
+
+export const formatFileSize = (size: number | undefined) => {
+ size ||= 0;
+
+ // gigabyte, megabyte, kilobyte, byte
+ const units = ['G', 'M', 'K', ''];
+ while (size >= 1024 && units.length > 1) {
+ size /= 1024;
+ units.pop();
+ }
+
+ const roundedSize = size.toFixed(2);
+ const unit = units[units.length - 1] + "B";
+ return `${roundedSize} ${unit}`;
+}
+
+export const ffmpegMetadataArgs = (metadata: CobaltFileMetadata) =>
+ Object.entries(metadata).flatMap(([name, value]) => {
+ if (CobaltFileMetadataKeys.includes(name) && typeof value === "string") {
+ if (name === "sublanguage") {
+ return [
+ '-metadata:s:s:0',
+ // eslint-disable-next-line no-control-regex
+ `language=${value.replace(/[\u0000-\u0009]/g, "")}`
+ ]
+ }
+ return [
+ '-metadata',
+ // eslint-disable-next-line no-control-regex
+ `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`
+ ]
+ }
+ return [];
+ });
+
+const digit = () => '0123456789abcdef'[Math.random() * 16 | 0];
+export const uuid = () => {
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
+ return crypto.randomUUID();
+ }
+
+ const digits = Array.from({length: 32}, digit);
+ digits[12] = '4';
+ digits[16] = '89ab'[Math.random() * 4 | 0];
+
+ return digits
+ .join('')
+ .match(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/)!
+ .slice(1)
+ .join('-');
+}
diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte
index a317760a68cf75636678a30f610c3efc11cdeaac..f535b1764896c68adf7111367bbfffd0061f8cfe 100644
--- a/web/src/routes/+error.svelte
+++ b/web/src/routes/+error.svelte
@@ -1,14 +1,14 @@
@@ -56,16 +68,26 @@
{/if}
{#if device.is.mobile}
-
+
+ {:else}
+
{/if}
- {#if env.PLAUSIBLE_ENABLED}
+ {#if plausibleLoaded || (browser && env.PLAUSIBLE_ENABLED && !$settings.privacy.disableAnalytics)}
+ >
{/if}
@@ -74,21 +96,27 @@
data-theme={browser ? $currentTheme : undefined}
lang={$locale}
>
+ {#if preloadAssets}
+ ??
+ {/if}
- {#if $updated}
-
- {/if}
{#if device.is.iPhone && app.is.installed}
{/if}
+ {#if $updated}
+
+ {/if}
+
{#if ($turnstileEnabled && $page.url.pathname === "/") || $turnstileCreated}
@@ -99,162 +127,6 @@
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
index 4b2648970c6bde7ae83b8d072da16a3781b4ae98..ca8be65eb935878c5ae1f8288f07262d2e15c737 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -17,7 +17,6 @@
id="cobalt-save"
tabindex="-1"
data-first-focus
- data-focus-ring-hidden
>
@@ -47,9 +46,9 @@
#terms-note {
bottom: 0;
color: var(--gray);
- font-size: 13px;
+ font-size: 12px;
text-align: center;
- padding-bottom: var(--padding);
+ padding-bottom: 6px;
font-weight: 500;
}
diff --git a/web/src/routes/about/+layout.svelte b/web/src/routes/about/+layout.svelte
index e28df42b0b6ea8e968b4afc20c933cee459b36c4..0e706175724c59ecf192d941e1689325a258d996 100644
--- a/web/src/routes/about/+layout.svelte
+++ b/web/src/routes/about/+layout.svelte
@@ -8,9 +8,9 @@
import IconLock from "@tabler/icons-svelte/IconLock.svelte";
import IconComet from "@tabler/icons-svelte/IconComet.svelte";
- import IconLicense from "@tabler/icons-svelte/IconLicense.svelte";
import IconChecklist from "@tabler/icons-svelte/IconChecklist.svelte";
import IconUsersGroup from "@tabler/icons-svelte/IconUsersGroup.svelte";
+ import IconHeartHandshake from "@tabler/icons-svelte/IconHeartHandshake.svelte";
-
+
diff --git a/web/src/routes/about/[page]/+page.ts b/web/src/routes/about/[page]/+page.ts
index be5c7a3350499783b72afe4a35f1381d9903ac72..24d0b169684fb61e1827bab57cf0606cdd37c92f 100644
--- a/web/src/routes/about/[page]/+page.ts
+++ b/web/src/routes/about/[page]/+page.ts
@@ -1,14 +1,13 @@
-import type { ComponentType, SvelteComponent } from 'svelte';
-import { get } from 'svelte/store';
-import { error } from '@sveltejs/kit';
+import locale from "$lib/i18n/locale";
+import { get } from "svelte/store";
+import { error } from "@sveltejs/kit";
+import { defaultLocale } from "$lib/i18n/translations";
-import type { PageLoad } from './$types';
+import type { Component } from "svelte";
+import type { PageLoad } from "./$types";
+import type { DefaultImport } from "$lib/types/generic";
-import locale from '$lib/i18n/locale';
-import type { DefaultImport } from '$lib/types/generic';
-import { defaultLocale } from '$lib/i18n/translations';
-
-const pages = import.meta.glob('$i18n/*/about/*.md');
+const pages = import.meta.glob("$i18n/*/about/*.md");
export const load: PageLoad = async ({ params }) => {
const getPage = (locale: string) => Object.keys(pages).find(
@@ -17,13 +16,11 @@ export const load: PageLoad = async ({ params }) => {
const componentPath = getPage(get(locale)) || getPage(defaultLocale);
if (componentPath) {
- type Component = ComponentType
;
const componentImport = pages[componentPath] as DefaultImport;
-
return { component: (await componentImport()).default }
}
- error(404, 'Not found');
+ error(404, 'Not found');
};
export const prerender = true;
diff --git a/web/src/routes/donate/+page.svelte b/web/src/routes/donate/+page.svelte
index 66ae677bb85ee6ec9d15366e08a65509ada87daa..02099c0ebdc6b5953e9d26f21820a4c3f1c5766f 100644
--- a/web/src/routes/donate/+page.svelte
+++ b/web/src/routes/donate/+page.svelte
@@ -31,7 +31,7 @@
-
+
{$t("donate.body.motivation")}
{$t("donate.body.no_bullshit")}
{$t("donate.body.keep_going")}
diff --git a/web/src/routes/remux/+page.svelte b/web/src/routes/remux/+page.svelte
index 4e929b0925da4b7394327302ef4e77c3b0a334f3..213b5498ff41a7cfe80aab4c7e72c69bc568cb52 100644
--- a/web/src/routes/remux/+page.svelte
+++ b/web/src/routes/remux/+page.svelte
@@ -1,13 +1,7 @@
@@ -207,23 +36,13 @@
/>
-
-
+
+
-
-
-
- {#if processing}
- {#if progress && speed}
-
-
-
-
- processing ({progress}%, {speed}x)...
-
- {:else}
- processing...
- {/if}
- {:else}
- done!
- {/if}
-
-