@@ -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 00000000..ffebf834
--- /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 2779fd5f..bec76257 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 07829480..0467adf3 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 (get(turnstileEnabled)) {
- if (!get(turnstileSolved)) {
- return {
- status: "error",
- error: {
- code: "error.captcha_ongoing"
- }
- } as CobaltErrorResponse;
- }
-
- 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 (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"),
+ if (!get(turnstileEnabled)) {
+ return;
}
+ if (!get(turnstileSolved)) {
+ try {
+ await waitForTurnstile();
+ } catch {
+ return {
+ status: "error",
+ error: {
+ code: "error.captcha_too_long"
+ }
+ } as CobaltErrorResponse;
+ }
+ }
+
+ 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}`;
+ }
+ }
+}
+
+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 00000000..b2dfbbb4
--- /dev/null
+++ b/web/src/lib/api/saving-handler.ts
@@ -0,0 +1,148 @@
+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"),
+
+ filenameStyle: getSetting("save", "filenameStyle"),
+ disableMetadata: getSetting("save", "disableMetadata"),
+
+ audioBitrate: getSetting("save", "audioBitrate"),
+ audioFormat: getSetting("save", "audioFormat"),
+ tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
+ youtubeDubLang: getSetting("save", "youtubeDubLang"),
+ youtubeBetterAudio: getSetting("save", "youtubeBetterAudio"),
+
+ youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
+ videoQuality: getSetting("save", "videoQuality"),
+ youtubeHLS: getSetting("save", "youtubeHLS"),
+
+ convertGif: getSetting("save", "convertGif"),
+ allowH265: getSetting("save", "allowH265"),
+ }
+
+ 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 8f8dd595..4e186f93 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,8 @@ const device = {
supports: {
share: false,
directDownload: false,
+ haptics: false,
+ defaultLocalProcessing: false,
},
userAgent: "sveltekit server",
}
@@ -32,6 +38,9 @@ if (browser) {
const iPhone = ua.includes("iphone os");
const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0;
+ const iosVersion = Number(ua.match(/iphone os (\d+)_/)?.[1]);
+ const modernIOS = iPhone && iosVersion >= 18;
+
const iOS = iPhone || iPad;
const android = ua.includes("android") || ua.includes("diordna");
@@ -42,11 +51,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 +78,16 @@ 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
+ // on desktop & in firefox on android
+ // (first stage of rollout)
+ defaultLocalProcessing: !device.is.mobile ||
+ (device.is.android && !device.browser.chrome),
};
device.userAgent = navigator.userAgent;
diff --git a/web/src/lib/download.ts b/web/src/lib/download.ts
index 7a126a92..bce6cfa2 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 cfad460f..98a405d6 100644
--- a/web/src/lib/env.ts
+++ b/web/src/lib/env.ts
@@ -14,6 +14,8 @@ const variables = {
PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),
PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),
DEFAULT_API: getEnv('DEFAULT_API'),
+ // temporary variable until webcodecs features are ready for testing
+ ENABLE_WEBCODECS: !!getEnv('ENABLE_WEBCODECS'),
}
const contacts = {
@@ -55,7 +57,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 00000000..cac2d78b
--- /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 4f540cf5..e888926a 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,13 +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({
+ this.libav = constructor({
+ ...options,
+ variant: undefined,
yesthreads: true,
base: '/_libav'
});
@@ -57,60 +71,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);
+ const totalInputSize = files.reduce((a, b) => a + b.size, 0);
+ const storage = await Storage.init(totalInputSize);
- // 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));
- }
-
- 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 +105,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 +113,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 +143,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/defaults.ts b/web/src/lib/settings/defaults.ts
index a4448aaa..54a96994 100644
--- a/web/src/lib/settings/defaults.ts
+++ b/web/src/lib/settings/defaults.ts
@@ -1,35 +1,44 @@
+import { device } from "$lib/device";
import { defaultLocale } from "$lib/i18n/translations";
import type { CobaltSettings } from "$lib/types/settings";
const defaultSettings: CobaltSettings = {
- schemaVersion: 4,
+ schemaVersion: 5,
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 || false,
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",
youtubeVideoCodec: "h264",
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 5e11bbad..47c21be6 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 4054a0b4..3d372823 100644
--- a/web/src/lib/settings/migrate.ts
+++ b/web/src/lib/settings/migrate.ts
@@ -1,9 +1,10 @@
import type { RecursivePartial } from "$lib/types/generic";
import type {
+ PartialSettings,
AllPartialSettingsWithSchema,
CobaltSettingsV3,
CobaltSettingsV4,
- PartialSettings,
+ CobaltSettingsV5,
} from "$lib/types/settings";
import { getBrowserLanguage } from "$lib/settings/youtube-lang";
@@ -40,6 +41,45 @@ 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;
+ },
};
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {
diff --git a/web/src/lib/state/omnibox.ts b/web/src/lib/state/omnibox.ts
index f3b0be03..7895c09f 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 00000000..73ba39ea
--- /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 00000000..9d720bf2
--- /dev/null
+++ b/web/src/lib/state/task-manager/current-tasks.ts
@@ -0,0 +1,38 @@
+import { readable, type Updater } from "svelte/store";
+
+import type { CobaltWorkerProgress } from "$lib/types/workers";
+import type { CobaltCurrentTasks, CobaltCurrentTaskItem } from "$lib/types/task-manager";
+
+let update: (_: Updater) => void;
+
+export const currentTasks = readable(
+ {},
+ (_, _update) => { update = _update }
+);
+
+export function addWorkerToQueue(workerId: string, item: CobaltCurrentTaskItem) {
+ update(tasks => {
+ tasks[workerId] = item;
+ return tasks;
+ });
+}
+
+export function removeWorkerFromQueue(id: string) {
+ update(tasks => {
+ delete tasks[id];
+ return tasks;
+ });
+}
+
+export function updateWorkerProgress(workerId: string, progress: CobaltWorkerProgress) {
+ update(allTasks => {
+ allTasks[workerId].progress = progress;
+ return allTasks;
+ });
+}
+
+export function clearCurrentTasks() {
+ update(() => {
+ return {};
+ });
+}
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 00000000..f638eda8
--- /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 73c53fe8..6bff35d6 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 00000000..8687eb3c
--- /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 function init(expectedSize?: number): Promise {
+ if (OPFSStorage.isAvailable()) {
+ return OPFSStorage.init();
+ }
+
+ if (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 00000000..2652c8cb
--- /dev/null
+++ b/web/src/lib/storage/memory.ts
@@ -0,0 +1,91 @@
+import { AbstractStorage } from "./storage";
+
+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, crypto.randomUUID());
+ }
+
+ #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 isAvailable() {
+ return true;
+ }
+}
diff --git a/web/src/lib/storage/opfs.ts b/web/src/lib/storage/opfs.ts
new file mode 100644
index 00000000..484ae174
--- /dev/null
+++ b/web/src/lib/storage/opfs.ts
@@ -0,0 +1,71 @@
+import { AbstractStorage } from "./storage";
+
+const COBALT_PROCESSING_DIR = "cobalt-processing-data";
+
+export class OPFSStorage extends AbstractStorage {
+ #root;
+ #handle;
+ #io;
+
+ 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(crypto.randomUUID(), { 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 isAvailable() {
+ if (typeof navigator === 'undefined')
+ return false;
+
+ return 'storage' in navigator && 'getDirectory' in navigator.storage;
+ }
+}
+
+export const removeFromFileStorage = async (filename: string) => {
+ if (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 (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 00000000..5b50eceb
--- /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 isAvailable(): boolean {
+ 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 00000000..bd79269f
--- /dev/null
+++ b/web/src/lib/task-manager/queue.ts
@@ -0,0 +1,230 @@
+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 type { CobaltQueueItem } from "$lib/types/queue";
+import type { CobaltCurrentTasks } from "$lib/types/task-manager";
+import type { CobaltPipelineItem, CobaltPipelineResultFileType } from "$lib/types/workers";
+import type { CobaltLocalProcessingResponse, CobaltSaveRequestBody } from "$lib/types/api";
+
+export const getMediaType = (type: string) => {
+ const kind = type.split('/')[0];
+
+ // can't use .includes() here for some reason
+ if (kind === "video" || kind === "audio" || kind === "image") {
+ return kind;
+ }
+}
+
+export const createRemuxPipeline = (file: File) => {
+ const parentId = crypto.randomUUID();
+ const mediaType = getMediaType(file.type);
+
+ const pipeline: CobaltPipelineItem[] = [{
+ worker: "remux",
+ workerId: crypto.randomUUID(),
+ 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 mediaIcons: { [key: string]: CobaltPipelineResultFileType } = {
+ merge: "video",
+ mute: "video",
+ audio: "audio",
+ gif: "image",
+ remux: "video"
+}
+
+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");
+ }
+
+ ffargs.push(
+ ...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : [])
+ );
+
+ return ffargs;
+}
+
+const makeAudioArgs = (info: CobaltLocalProcessingResponse) => {
+ if (!info.audio) {
+ return;
+ }
+
+ const ffargs = [
+ "-vn",
+ ...(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 || crypto.randomUUID();
+ 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: crypto.randomUUID(),
+ parentId,
+ workerArgs: {
+ url: tunnel,
+ },
+ });
+ }
+
+ 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: crypto.randomUUID(),
+ 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: mediaIcons[info.type],
+ });
+
+ 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 00000000..021fda0d
--- /dev/null
+++ b/web/src/lib/task-manager/run-worker.ts
@@ -0,0 +1,57 @@
+import { get } from "svelte/store";
+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,
+ /*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 00000000..1da004ad
--- /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 00000000..43d75cbe
--- /dev/null
+++ b/web/src/lib/task-manager/runners/ffmpeg.ts
@@ -0,0 +1,100 @@
+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',
+ 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);
+ } 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,
+ }
+ });
+
+ 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 00000000..f774ea88
--- /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 00000000..8d1fb8cf
--- /dev/null
+++ b/web/src/lib/task-manager/workers/fetch.ts
@@ -0,0 +1,97 @@
+import * as Storage from "$lib/storage";
+
+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) {
+ 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 00000000..7568fb8a
--- /dev/null
+++ b/web/src/lib/task-manager/workers/ffmpeg.ts
@@ -0,0 +1,119 @@
+import LibAVWrapper from "$lib/libav";
+import type { FileInfo } from "$lib/types/libav";
+
+const ffmpeg = async (variant: string, files: File[], args: string[], output: FileInfo) => {
+ 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 });
+
+ 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");
+ }
+
+ 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);
+ }
+}
diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts
index b2e47a40..6e030856 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,43 @@ type CobaltTunnelResponse = {
status: CobaltResponseType.Tunnel,
} & CobaltPartialURLResponse;
+export const CobaltFileMetadataKeys = [
+ 'album',
+ 'copyright',
+ 'title',
+ 'artist',
+ 'track',
+ 'date'
+];
+
+export type CobaltFileMetadata = Record<
+ typeof CobaltFileMetadataKeys[number], string | undefined
+>;
+
+export type CobaltLocalProcessingType = 'merge' | 'mute' | 'audio' | 'gif' | 'remux';
+
+export type CobaltLocalProcessingResponse = {
+ status: CobaltResponseType.LocalProcessing,
+
+ type: CobaltLocalProcessingType,
+ service: string,
+ tunnel: string[],
+
+ output: {
+ type: string, // mimetype
+ filename: string,
+ metadata?: CobaltFileMetadata,
+ },
+
+ audio?: {
+ copy: boolean,
+ format: string,
+ bitrate: string,
+ },
+
+ isHLS?: boolean,
+}
+
export type CobaltFileUrlType = "redirect" | "tunnel";
export type CobaltSession = {
@@ -52,7 +92,6 @@ export type CobaltServerInfo = {
version: string,
url: string,
startTime: string,
- durationLimit: number,
turnstileSitekey?: string,
services: string[]
},
@@ -63,10 +102,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 d07740d6..aa2472c8 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/libav.ts b/web/src/lib/types/libav.ts
index eed54edf..ef7ac9a6 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 00000000..fcd92b2a
--- /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 00000000..e542808b
--- /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 93958e88..37454183 100644
--- a/web/src/lib/types/settings.ts
+++ b/web/src/lib/types/settings.ts
@@ -2,14 +2,16 @@ 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";
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 type CobaltSettings = CobaltSettingsV4;
+export type CobaltSettings = CobaltSettingsV5;
-export type AnyCobaltSettings = CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
+export type AnyCobaltSettings = CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
export type PartialSettings = RecursivePartial;
diff --git a/web/src/lib/types/settings/v5.ts b/web/src/lib/types/settings/v5.ts
new file mode 100644
index 00000000..dee2e8ee
--- /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/task-manager.ts b/web/src/lib/types/task-manager.ts
new file mode 100644
index 00000000..61882cc6
--- /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 00000000..56e44baa
--- /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"] 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 00000000..609f651e
--- /dev/null
+++ b/web/src/lib/util.ts
@@ -0,0 +1,28 @@
+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") {
+ return [
+ '-metadata',
+ // eslint-disable-next-line no-control-regex
+ `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`
+ ]
+ }
+ return [];
+ });
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 1400bc6d..827e183d 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -1,13 +1,16 @@
@@ -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 4b264897..ca8be65e 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 e28df42b..0e706175 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/donate/+page.svelte b/web/src/routes/donate/+page.svelte
index 66ae677b..02099c0e 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 4e929b09..213b5498 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}
-
-