mirror of https://github.com/VERT-sh/VERT.git
1075 lines
27 KiB
TypeScript
1075 lines
27 KiB
TypeScript
import VertdErrorComponent from "$lib/components/functional/popups/VertdError.svelte";
|
|
import { error, log } from "$lib/util/logger";
|
|
import { m } from "$lib/paraglide/messages";
|
|
import { Settings } from "$lib/sections/settings/index.svelte";
|
|
import { VertdInstance } from "$lib/sections/settings/vertdSettings.svelte";
|
|
import { VertFile } from "$lib/types";
|
|
import { Converter, FormatInfo } from "./converter.svelte";
|
|
import { PUB_DISABLE_FAILURE_BLOCKS } from "$env/static/public";
|
|
import { ToastManager } from "$lib/util/toast.svelte";
|
|
import { converters } from "./index";
|
|
import type {
|
|
SettingDefinition,
|
|
ConversionSettings,
|
|
} from "$lib/types/conversion-settings";
|
|
import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.svelte";
|
|
import { formatBytes } from "$lib/util/file";
|
|
|
|
interface UploadResponse {
|
|
id: string;
|
|
auth: string;
|
|
from: string;
|
|
to: null;
|
|
completed: false;
|
|
totalFrames: number;
|
|
}
|
|
|
|
interface RouteRequestMap {
|
|
"/api/keep": {
|
|
id: string;
|
|
token: string;
|
|
};
|
|
}
|
|
|
|
interface CodecsResponse {
|
|
videoCodecs: string[];
|
|
audioCodecs: string[];
|
|
}
|
|
|
|
interface RouteResponseMap {
|
|
"/api/upload": UploadResponse;
|
|
"/api/version": string;
|
|
"/api/size_limit": number | null;
|
|
"/api/keep": void;
|
|
"/api/codecs": string[]; // get list of all codecs supported by vertd server
|
|
[key: `/api/codecs/${string}`]: unknown; // list of codecs for this format
|
|
[key: `/api/codecs/support/${string}`]: unknown; // list of formats supporting this codec -- i dont know how this works in ts, i have to put both as unknown since this and /api/codecs are different types (CodecsResponse & string[]) and TS errors
|
|
[key: `/api/confirm/${string}/${string}`]: void; // confirm download - job id, token
|
|
}
|
|
|
|
export const vertdFetch: {
|
|
<U extends keyof RouteRequestMap>(
|
|
url: U,
|
|
options: RequestInit,
|
|
body: RouteRequestMap[U],
|
|
): Promise<RouteResponseMap[U]>;
|
|
<U extends Exclude<keyof RouteResponseMap, keyof RouteRequestMap>>(
|
|
url: U,
|
|
options: RequestInit,
|
|
): Promise<RouteResponseMap[U]>;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} = async (url: any, options: RequestInit, body?: any) => {
|
|
const domain = await VertdInstance.instance.url();
|
|
|
|
// if there is a body, insert a Content-Type: application/json header
|
|
if (body) {
|
|
options.headers = {
|
|
"Content-Type": "application/json",
|
|
...(options.headers || {}),
|
|
};
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
|
|
const res = await fetch(domain + url, options);
|
|
|
|
const text = await res.text();
|
|
const normalizedText = text.replace(/^\uFEFF/, "").trim();
|
|
|
|
if (!normalizedText.length) {
|
|
if (!res.ok) throw new Error(`vertd request failed (${res.status})`);
|
|
return undefined as RouteResponseMap[typeof url];
|
|
}
|
|
|
|
let json: unknown = null;
|
|
try {
|
|
json = JSON.parse(normalizedText);
|
|
} catch {
|
|
throw new Error(normalizedText);
|
|
}
|
|
|
|
if (json && typeof json === "object" && "type" in json && "data" in json) {
|
|
const envelope = json as { type: string; data: unknown };
|
|
|
|
if (envelope.type === "error") {
|
|
if (typeof envelope.data === "string")
|
|
throw new Error(envelope.data);
|
|
throw new Error(JSON.stringify(envelope.data));
|
|
}
|
|
|
|
if (envelope.type === "success")
|
|
return envelope.data as RouteResponseMap[typeof url];
|
|
}
|
|
|
|
if (!res.ok) throw new Error(normalizedText);
|
|
|
|
return json as RouteResponseMap[typeof url];
|
|
};
|
|
|
|
// ws types
|
|
|
|
export type ConversionSpeed =
|
|
| "verySlow"
|
|
| "slower"
|
|
| "slow"
|
|
| "medium"
|
|
| "fast"
|
|
| "ultraFast";
|
|
|
|
const vertdSpeedValues: ConversionSpeed[] = [
|
|
"verySlow",
|
|
"slower",
|
|
"slow",
|
|
"medium",
|
|
"fast",
|
|
"ultraFast",
|
|
];
|
|
|
|
interface StartJobMessage {
|
|
type: "startJob";
|
|
data: {
|
|
token: string;
|
|
jobId: string;
|
|
to: string;
|
|
settings: ConversionSettings;
|
|
};
|
|
}
|
|
|
|
interface ErrorMessage {
|
|
type: "error";
|
|
data: {
|
|
message: string;
|
|
};
|
|
}
|
|
|
|
interface ProgressMessage {
|
|
type: "progressUpdate";
|
|
data: ProgressData;
|
|
}
|
|
|
|
interface CompletedMessage {
|
|
type: "jobFinished";
|
|
data: {
|
|
jobId: string;
|
|
};
|
|
}
|
|
|
|
interface CancelJobMessage {
|
|
type: "cancelJob";
|
|
data: {
|
|
jobId: string;
|
|
token: string;
|
|
};
|
|
}
|
|
|
|
interface JobCancelledMessage {
|
|
type: "jobCancelled";
|
|
data: {
|
|
jobId: string;
|
|
};
|
|
}
|
|
|
|
interface JobRetriedMessage {
|
|
type: "jobRetried";
|
|
data: {
|
|
jobId: string;
|
|
};
|
|
}
|
|
|
|
interface FpsProgress {
|
|
type: "fps";
|
|
data: number;
|
|
}
|
|
|
|
interface FrameProgress {
|
|
type: "frame";
|
|
data: number;
|
|
}
|
|
|
|
type ProgressData = FpsProgress | FrameProgress;
|
|
|
|
type VertdMessage =
|
|
| StartJobMessage
|
|
| ErrorMessage
|
|
| ProgressMessage
|
|
| CancelJobMessage
|
|
| JobCancelledMessage
|
|
| JobRetriedMessage
|
|
| CompletedMessage;
|
|
|
|
const progressEstimates = {
|
|
upload: 25,
|
|
convert: 50,
|
|
download: 25,
|
|
};
|
|
|
|
const progressEstimate = (
|
|
progress: number,
|
|
type: keyof typeof progressEstimates,
|
|
) => {
|
|
const previousValues = Object.values(progressEstimates)
|
|
.filter((_, i) => i < Object.keys(progressEstimates).indexOf(type))
|
|
.reduce((a, b) => a + b, 0);
|
|
return progress * progressEstimates[type] + previousValues;
|
|
};
|
|
|
|
const processSettings = (settings: ConversionSettings): ConversionSettings => {
|
|
const newSettings = { ...settings };
|
|
|
|
if (newSettings.fps === "custom") {
|
|
newSettings.fps = newSettings.customFps;
|
|
delete newSettings.customFps;
|
|
}
|
|
if (newSettings.resolution === "custom") {
|
|
newSettings.resolution = newSettings.customResolution;
|
|
delete newSettings.customResolution;
|
|
}
|
|
if (newSettings.videoBitrate === "custom") {
|
|
newSettings.videoBitrate = newSettings.customVideoBitrate;
|
|
delete newSettings.customVideoBitrate;
|
|
}
|
|
if (newSettings.audioBitrate === "custom") {
|
|
newSettings.audioBitrate = newSettings.customAudioBitrate;
|
|
delete newSettings.customAudioBitrate;
|
|
}
|
|
if (newSettings.sampleRate === "custom") {
|
|
newSettings.sampleRate = newSettings.customSampleRate;
|
|
delete newSettings.customSampleRate;
|
|
}
|
|
|
|
return newSettings;
|
|
};
|
|
|
|
interface UploadTask {
|
|
promise: Promise<UploadResponse>;
|
|
abort: () => void;
|
|
}
|
|
|
|
const createUploadTask = async (file: VertFile): Promise<UploadTask> => {
|
|
const apiUrl = await VertdInstance.instance.url();
|
|
const formData = new FormData();
|
|
formData.append("file", file.file, file.name);
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("POST", `${apiUrl}/api/upload`, true);
|
|
|
|
const promise = new Promise<UploadResponse>((resolve, reject) => {
|
|
xhr.upload.addEventListener("progress", (e) => {
|
|
console.log(e);
|
|
if (e.lengthComputable) {
|
|
file.progress = progressEstimate(e.loaded / e.total, "upload");
|
|
}
|
|
});
|
|
|
|
console.log("meow");
|
|
|
|
xhr.onload = () => {
|
|
try {
|
|
console.log("xhr.responseText");
|
|
const res = JSON.parse(xhr.responseText);
|
|
if (res.type === "error") {
|
|
reject(res.data);
|
|
return;
|
|
}
|
|
resolve(res.data);
|
|
} catch {
|
|
console.log(xhr.responseText);
|
|
reject(xhr.statusText);
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => {
|
|
console.log(xhr.statusText);
|
|
reject(xhr.statusText);
|
|
};
|
|
|
|
xhr.onabort = () => {
|
|
reject(new Error("Conversion cancelled"));
|
|
};
|
|
|
|
xhr.send(formData);
|
|
console.log("sent!");
|
|
});
|
|
|
|
return {
|
|
promise,
|
|
abort: () => xhr.abort(),
|
|
};
|
|
};
|
|
|
|
const downloadFile = async (url: string, file: VertFile): Promise<Blob> => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("GET", url, true);
|
|
xhr.responseType = "blob";
|
|
|
|
return new Promise((resolve, reject) => {
|
|
xhr.addEventListener("progress", (e) => {
|
|
if (e.lengthComputable) {
|
|
file.progress = progressEstimate(
|
|
e.loaded / e.total,
|
|
"download",
|
|
);
|
|
}
|
|
});
|
|
|
|
xhr.onload = () => {
|
|
if (xhr.status === 200) {
|
|
resolve(xhr.response);
|
|
} else {
|
|
reject(xhr.statusText);
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => {
|
|
reject(xhr.statusText);
|
|
};
|
|
|
|
xhr.send();
|
|
});
|
|
};
|
|
|
|
export class VertdConverter extends Converter {
|
|
public name = "vertd";
|
|
public ready = $state(false);
|
|
public reportsProgress = true;
|
|
|
|
private activeConversions = new Map<
|
|
string,
|
|
{
|
|
ws: WebSocket;
|
|
jobId: string;
|
|
token: string;
|
|
}
|
|
>();
|
|
|
|
private activeUploads = new Map<string, UploadTask>();
|
|
|
|
private cancelledConversions = new Set<string>();
|
|
|
|
public supportedFormats = [
|
|
new FormatInfo("mp4", true, true),
|
|
new FormatInfo("mkv", true, true),
|
|
new FormatInfo("webm", true, true),
|
|
new FormatInfo("avi", true, true),
|
|
new FormatInfo("wmv", true, true),
|
|
new FormatInfo("mov", true, true),
|
|
new FormatInfo("gif", true, true),
|
|
new FormatInfo("apng", true, true),
|
|
new FormatInfo("webp", true, true),
|
|
new FormatInfo("mts", true, true),
|
|
new FormatInfo("ts", true, true),
|
|
new FormatInfo("m2ts", true, true),
|
|
new FormatInfo("mpg", true, true),
|
|
new FormatInfo("mpeg", true, true),
|
|
new FormatInfo("flv", true, true),
|
|
new FormatInfo("f4v", true, true),
|
|
new FormatInfo("vob", true, true),
|
|
new FormatInfo("m4v", true, true),
|
|
new FormatInfo("3gp", true, true),
|
|
new FormatInfo("3g2", true, true),
|
|
new FormatInfo("mxf", true, true),
|
|
new FormatInfo("ogv", true, true),
|
|
new FormatInfo("rm", true, false),
|
|
new FormatInfo("rmvb", true, false),
|
|
new FormatInfo("h264", true, true),
|
|
new FormatInfo("divx", true, true),
|
|
new FormatInfo("swf", true, true),
|
|
new FormatInfo("amv", true, true),
|
|
new FormatInfo("asf", true, true),
|
|
new FormatInfo("nut", true, true),
|
|
];
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private log: (...msg: any[]) => void = () => {};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private error: (...msg: any[]) => void = () => {};
|
|
|
|
private codecs: CodecsResponse = { videoCodecs: [], audioCodecs: [] };
|
|
|
|
constructor() {
|
|
super();
|
|
this.log = (msg) => log(["converters", this.name], msg);
|
|
this.error = (msg) => error(["converters", this.name], msg);
|
|
this.log("created converter");
|
|
this.log("not rly sure how to implement this :P");
|
|
this.status = "ready";
|
|
}
|
|
|
|
private async getServerSizeLimit(apiUrl: string): Promise<number | null> {
|
|
const cacheKey = `vertd:size-limit:${apiUrl}`;
|
|
|
|
if (typeof sessionStorage !== "undefined") {
|
|
try {
|
|
const cached = sessionStorage.getItem(cacheKey);
|
|
if (cached !== null) {
|
|
const parsedCached = Number(cached);
|
|
if (Number.isFinite(parsedCached) && parsedCached > 0) {
|
|
this.log(
|
|
`using cached vertd size limit: ${parsedCached} bytes`,
|
|
);
|
|
return parsedCached;
|
|
}
|
|
sessionStorage.removeItem(cacheKey);
|
|
}
|
|
} catch (e) {
|
|
this.error(
|
|
`failed to read vertd size limit from sessionStorage: ${e}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const limit = await vertdFetch("/api/size_limit", {
|
|
method: "GET",
|
|
});
|
|
const parsed = Number(limit);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
|
|
if (typeof sessionStorage !== "undefined") {
|
|
try {
|
|
sessionStorage.setItem(cacheKey, parsed.toString());
|
|
} catch (e) {
|
|
this.error(
|
|
`failed to cache vertd size limit in sessionStorage: ${e}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
this.log(`fetched vertd size limit: ${parsed} bytes`);
|
|
return parsed;
|
|
} catch (e) {
|
|
this.error(`failed to fetch vertd size limit: ${e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private blocked(hash: string): boolean {
|
|
let blockedHashes = Settings.instance.settings.vertdBlockedHashes;
|
|
|
|
// ensure it's a map
|
|
// this might fix the "e.get" isn't a function error, but i can't reproduce it
|
|
if (!(blockedHashes instanceof Map) || blockedHashes === null) {
|
|
blockedHashes = new Map(Object.entries(blockedHashes || {}));
|
|
Settings.instance.settings.vertdBlockedHashes = blockedHashes;
|
|
Settings.instance.save();
|
|
}
|
|
|
|
const now = new Date();
|
|
const dates = blockedHashes.get(hash) || [];
|
|
const filteredDates = dates.filter(
|
|
(date) => now.getTime() - date.getTime() < 60 * 60 * 1000,
|
|
);
|
|
|
|
if (filteredDates.length === 0) {
|
|
blockedHashes.delete(hash);
|
|
return false;
|
|
}
|
|
|
|
blockedHashes.set(hash, filteredDates);
|
|
|
|
Settings.instance.save();
|
|
|
|
return filteredDates.length >= 3;
|
|
}
|
|
|
|
private failure(hash: string): void {
|
|
let blockedHashes = Settings.instance.settings.vertdBlockedHashes;
|
|
|
|
// same as above (blocked())
|
|
if (!(blockedHashes instanceof Map) || blockedHashes === null) {
|
|
blockedHashes = new Map(Object.entries(blockedHashes || {}));
|
|
Settings.instance.settings.vertdBlockedHashes = blockedHashes;
|
|
Settings.instance.save();
|
|
}
|
|
|
|
const now = new Date();
|
|
const dates = blockedHashes.get(hash) || [];
|
|
dates.push(now);
|
|
blockedHashes.set(hash, dates);
|
|
Settings.instance.save();
|
|
}
|
|
|
|
public async getAvailableSettings(
|
|
input: VertFile,
|
|
): Promise<SettingDefinition[]> {
|
|
// video - bitrate, fps, resolution, trim, crop, rotate, flip/flop, audio settings?
|
|
|
|
const qualityOptions = [
|
|
{
|
|
value: 0, // very slow
|
|
label: m["convert.settings.video.speed.very_slow"](),
|
|
},
|
|
{
|
|
value: 1, // slower
|
|
label: m["convert.settings.video.speed.slower"](),
|
|
},
|
|
{
|
|
value: 2, // slow
|
|
label: m["convert.settings.video.speed.slow"](),
|
|
},
|
|
{
|
|
value: 3, // medium
|
|
label: m["convert.settings.video.speed.medium"](),
|
|
},
|
|
{
|
|
value: 4, // faster
|
|
label: m["convert.settings.video.speed.fast"](),
|
|
},
|
|
{
|
|
value: 5, // fastest
|
|
label: m["convert.settings.video.speed.ultra_fast"](),
|
|
},
|
|
];
|
|
|
|
// get codecs for this format from vertd
|
|
try {
|
|
const targetFormat = input.to.replace(/^\./, "");
|
|
const codecsJson = await vertdFetch(`/api/codecs/${targetFormat}`, {
|
|
method: "GET",
|
|
});
|
|
|
|
const previousCodecs = JSON.stringify(this.codecs);
|
|
const newCodecs = JSON.stringify(codecsJson);
|
|
|
|
if (previousCodecs !== newCodecs) {
|
|
this.codecs = codecsJson as CodecsResponse;
|
|
this.log(`updated codecs from vertd: ${newCodecs}`);
|
|
}
|
|
} catch (e) {
|
|
this.error(`failed to fetch codecs from vertd: ${e}`);
|
|
throw e;
|
|
}
|
|
|
|
// get default vertd speed
|
|
const defaultSpeed = Settings.instance.settings.vertdSpeed;
|
|
const defaultSpeedIndex = vertdSpeedValues.indexOf(defaultSpeed);
|
|
const qualitySpeedRange: SettingDefinition = {
|
|
key: "vertdSpeed",
|
|
label: m["convert.settings.video.speed.title"](),
|
|
description: m["convert.settings.video.speed.description"](),
|
|
type: "range",
|
|
min: 0,
|
|
max: qualityOptions.length - 1,
|
|
step: 1,
|
|
default: defaultSpeedIndex !== -1 ? defaultSpeedIndex : 3,
|
|
options: qualityOptions.map((option, index) => ({
|
|
value: index,
|
|
label: option.label,
|
|
})),
|
|
forceFullWidth: true,
|
|
};
|
|
|
|
const fps: SettingDefinition = {
|
|
key: "fps",
|
|
label: m["convert.settings.video.fps.label"](),
|
|
type: "select",
|
|
default: "auto",
|
|
options: [
|
|
{ value: "auto", label: m["convert.settings.common.auto"]() },
|
|
{
|
|
value: "custom",
|
|
label: m["convert.settings.common.custom"](),
|
|
},
|
|
{ value: "24", label: "24" },
|
|
{ value: "30", label: "30" },
|
|
{ value: "60", label: "60" },
|
|
{ value: "120", label: "120" },
|
|
{ value: "144", label: "144" },
|
|
{ value: "240", label: "240" },
|
|
],
|
|
hasCustomInput: true,
|
|
customInputKey: "customFps",
|
|
placeholder: m["convert.settings.video.fps.placeholder"](),
|
|
};
|
|
|
|
const resolution: SettingDefinition = {
|
|
key: "resolution",
|
|
label: m["convert.settings.video.resolution.label"](),
|
|
type: "select",
|
|
default: "auto",
|
|
options: [
|
|
{ value: "auto", label: m["convert.settings.common.auto"]() },
|
|
{
|
|
value: "custom",
|
|
label: m["convert.settings.common.custom"](),
|
|
},
|
|
{ value: "426x240", label: "426x240" },
|
|
{ value: "640x360", label: "640x360" },
|
|
{ value: "854x480", label: "854x480" },
|
|
{ value: "720x1280", label: "720x1280 (V)" },
|
|
{ value: "1280x720", label: "1280x720" },
|
|
{ value: "1080x1920", label: "1080x1920 (V)" },
|
|
{ value: "1920x1080", label: "1920x1080" },
|
|
{ value: "2160x3840", label: "2160x3840 (V)" },
|
|
{ value: "3840x2160", label: "3840x2160" },
|
|
],
|
|
hasCustomInput: true,
|
|
customInputKey: "customResolution",
|
|
placeholder: m["convert.settings.video.resolution.placeholder"](),
|
|
};
|
|
|
|
const videoCodec: SettingDefinition = {
|
|
key: "videoCodec",
|
|
label: m["convert.settings.video.codec.video"](),
|
|
type: "select",
|
|
default: "auto",
|
|
options: [
|
|
{ value: "auto", label: m["convert.settings.common.auto"]() },
|
|
...(this.codecs.videoCodecs || []).map((codec) => ({
|
|
value: codec,
|
|
label: codec,
|
|
})),
|
|
],
|
|
};
|
|
|
|
// TODO: allow CRF for consistent quality?
|
|
const videoBitrate: SettingDefinition = {
|
|
key: "videoBitrate",
|
|
label: m["convert.settings.video.bitrate.video"](),
|
|
type: "select",
|
|
default: "auto",
|
|
options: [
|
|
{ value: "auto", label: m["convert.settings.common.auto"]() },
|
|
{
|
|
value: "custom",
|
|
label: m["convert.settings.common.custom"](),
|
|
},
|
|
{ value: "1000", label: "1000 kbps" },
|
|
{ value: "2500", label: "2500 kbps" },
|
|
{ value: "5000", label: "5000 kbps" },
|
|
{ value: "8000", label: "8000 kbps" },
|
|
{ value: "12000", label: "12000 kbps" },
|
|
{ value: "18000", label: "18000 kbps" },
|
|
],
|
|
hasCustomInput: true,
|
|
customInputKey: "customVideoBitrate",
|
|
placeholder: m["convert.settings.video.bitrate.placeholder"](),
|
|
};
|
|
|
|
/*
|
|
* audio settings
|
|
*/
|
|
const audioCodec: SettingDefinition = {
|
|
key: "audioCodec",
|
|
label: m["convert.settings.video.codec.audio"](),
|
|
type: "select",
|
|
default: "auto",
|
|
options: [
|
|
{ value: "auto", label: m["convert.settings.common.auto"]() },
|
|
...(this.codecs.audioCodecs || []).map((codec) => ({
|
|
value: codec,
|
|
label: codec,
|
|
})),
|
|
],
|
|
};
|
|
|
|
const audioBitrate: SettingDefinition = {
|
|
key: "audioBitrate",
|
|
label: m["convert.settings.video.bitrate.audio"](),
|
|
type: "select",
|
|
default: "auto",
|
|
options: CONVERSION_BITRATES.map((b) => ({
|
|
value: b.toString(),
|
|
label:
|
|
b === "auto"
|
|
? m["convert.settings.common.auto"]()
|
|
: b === "custom"
|
|
? m["convert.settings.common.custom"]()
|
|
: `${b} kbps`,
|
|
})),
|
|
hasCustomInput: true,
|
|
customInputKey: "customAudioBitrate",
|
|
placeholder: m["convert.settings.audio.bitrate.placeholder"](),
|
|
};
|
|
|
|
const sampleRate: SettingDefinition = {
|
|
key: "sampleRate",
|
|
label: m["convert.settings.audio.sample_rate.label"](),
|
|
type: "select",
|
|
default: "auto",
|
|
options: SAMPLE_RATES.map((r) => ({
|
|
value: r.toString(),
|
|
label:
|
|
r === "auto"
|
|
? m["convert.settings.common.auto"]()
|
|
: r === "custom"
|
|
? m["convert.settings.common.custom"]()
|
|
: `${r} Hz`,
|
|
})),
|
|
hasCustomInput: true,
|
|
customInputKey: "customSampleRate",
|
|
placeholder: m["convert.settings.audio.sample_rate.placeholder"](),
|
|
};
|
|
|
|
/*
|
|
* common
|
|
*/
|
|
const metadata: SettingDefinition = {
|
|
key: "metadata",
|
|
label: m["convert.settings.common.metadata"](),
|
|
type: "boolean",
|
|
default: true,
|
|
};
|
|
|
|
// trim/crop/rotate - also have another ui for this prob
|
|
|
|
const animatedImages = [".gif", ".webp", ".apng"];
|
|
if (animatedImages.includes(input.from)) {
|
|
return [fps, resolution, metadata];
|
|
} else {
|
|
return [
|
|
qualitySpeedRange,
|
|
videoCodec,
|
|
audioCodec,
|
|
videoBitrate,
|
|
audioBitrate,
|
|
fps,
|
|
sampleRate,
|
|
resolution,
|
|
metadata,
|
|
];
|
|
}
|
|
}
|
|
|
|
public async getDefaultSettings(
|
|
input: VertFile,
|
|
): Promise<ConversionSettings> {
|
|
const defaults: ConversionSettings = {};
|
|
const settings = await this.getAvailableSettings(input);
|
|
settings.forEach((setting) => {
|
|
defaults[setting.key] = setting.default;
|
|
});
|
|
|
|
return defaults;
|
|
}
|
|
|
|
public async convert(
|
|
input: VertFile,
|
|
to: string,
|
|
settings: ConversionSettings,
|
|
): Promise<VertFile> {
|
|
if (to.startsWith(".")) to = to.slice(1);
|
|
|
|
let fileUpload = input;
|
|
const conversionSettings = // vertd expects object not string json
|
|
Object.keys(settings).length > 0
|
|
? settings // user-provided settings
|
|
: await this.getDefaultSettings(input); // use defaults if not provided
|
|
|
|
// if converting animated webp to video, first convert to gif
|
|
// ffmpeg (in vertd) doesn't support decoding animated webp still.. while supporting encoding animated webp for some reason
|
|
// https://trac.ffmpeg.org/ticket/4907
|
|
if (input.from === ".webp") {
|
|
this.log(`animated webp detected, converting to gif first`);
|
|
const magickConverter = converters.find(
|
|
(c) => c.name === "imagemagick",
|
|
);
|
|
if (magickConverter) {
|
|
try {
|
|
fileUpload = await magickConverter.convert(
|
|
input,
|
|
".gif",
|
|
conversionSettings,
|
|
100,
|
|
);
|
|
this.log(`successfully converted webp to gif`);
|
|
} catch (e) {
|
|
this.error(`failed to convert webp to gif: ${e}`);
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
let hash: string;
|
|
if (PUB_DISABLE_FAILURE_BLOCKS === "false") {
|
|
hash = await fileUpload.hash();
|
|
|
|
if (this.blocked(hash)) {
|
|
this.log(`conversion blocked for file ${input.name}`);
|
|
throw new Error(
|
|
m["convert.errors.vertd.ratelimit"]({
|
|
filename: input.name,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
const apiUrl = await VertdInstance.instance.url();
|
|
const sizeLimit =
|
|
(await this.getServerSizeLimit(apiUrl)) || Number.POSITIVE_INFINITY; // fall back to no limit just in case - server will block if too large anyways
|
|
if (sizeLimit !== null && fileUpload.file.size > sizeLimit) {
|
|
this.log(
|
|
`blocked upload for ${input.name}: ${fileUpload.file.size} bytes exceeds server limit of ${sizeLimit} bytes`,
|
|
);
|
|
throw new Error(
|
|
m["convert.errors.vertd.file_too_large"]({
|
|
fileSize: formatBytes(fileUpload.file.size),
|
|
limit: formatBytes(sizeLimit),
|
|
}),
|
|
);
|
|
}
|
|
|
|
const uploadTask = await createUploadTask(fileUpload);
|
|
this.activeUploads.set(input.id, uploadTask);
|
|
|
|
let uploadRes: UploadResponse;
|
|
try {
|
|
uploadRes = await uploadTask.promise;
|
|
} finally {
|
|
this.activeUploads.delete(input.id);
|
|
}
|
|
|
|
if (this.cancelledConversions.has(input.id))
|
|
throw new Error("Conversion cancelled");
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const protocol = apiUrl.startsWith("https") ? "wss:" : "ws:";
|
|
const ws = new WebSocket(
|
|
`${protocol}//${apiUrl.replace("http://", "").replace("https://", "")}/api/ws`,
|
|
);
|
|
|
|
const connectTimeout = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
this.activeConversions.delete(input.id);
|
|
ws.close();
|
|
reject(new Error("vertd websocket connection timeout"));
|
|
}, 30000);
|
|
|
|
const rejectConversion = (reason: unknown) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(connectTimeout);
|
|
this.cancelledConversions.delete(input.id);
|
|
this.activeUploads.delete(input.id);
|
|
this.activeConversions.delete(input.id);
|
|
if (
|
|
ws.readyState === WebSocket.CONNECTING ||
|
|
ws.readyState === WebSocket.OPEN
|
|
) {
|
|
ws.close();
|
|
}
|
|
reject(reason);
|
|
};
|
|
|
|
const resolveConversion = (value: VertFile) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(connectTimeout);
|
|
this.cancelledConversions.delete(input.id);
|
|
this.activeUploads.delete(input.id);
|
|
this.activeConversions.delete(input.id);
|
|
resolve(value);
|
|
};
|
|
|
|
this.activeConversions.set(input.id, {
|
|
ws,
|
|
jobId: uploadRes.id,
|
|
token: uploadRes.auth,
|
|
});
|
|
|
|
ws.onopen = () => {
|
|
if (this.cancelledConversions.has(input.id)) {
|
|
rejectConversion(new Error("Conversion cancelled"));
|
|
return;
|
|
}
|
|
|
|
clearTimeout(connectTimeout);
|
|
this.log(
|
|
`opened ws connection to vertd for file ${input.name}`,
|
|
);
|
|
const msg: StartJobMessage = {
|
|
type: "startJob",
|
|
data: {
|
|
jobId: uploadRes.id,
|
|
token: uploadRes.auth,
|
|
to,
|
|
settings: processSettings(conversionSettings),
|
|
},
|
|
};
|
|
ws.send(JSON.stringify(msg));
|
|
this.log(JSON.stringify(msg, null, 2));
|
|
this.log(`sent startJob message for file ${input.name}`);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
this.error(`ws error for file ${input.name}`);
|
|
rejectConversion(new Error("vertd websocket error"));
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
if (settled) return;
|
|
if (this.cancelledConversions.has(input.id)) {
|
|
rejectConversion(new Error("Conversion cancelled"));
|
|
return;
|
|
}
|
|
|
|
this.error(
|
|
`ws closed unexpectedly for file ${input.name} (code: ${event.code})`,
|
|
);
|
|
rejectConversion(
|
|
new Error("vertd websocket closed unexpectedly"),
|
|
);
|
|
};
|
|
|
|
ws.onmessage = async (e) => {
|
|
let msg: VertdMessage;
|
|
try {
|
|
if (typeof e.data !== "string") {
|
|
rejectConversion(
|
|
new Error("invalid websocket payload type"),
|
|
);
|
|
return;
|
|
}
|
|
msg = JSON.parse(e.data);
|
|
} catch {
|
|
rejectConversion(new Error("invalid websocket payload"));
|
|
return;
|
|
}
|
|
|
|
this.log(`received message ${msg.type} for file ${input.name}`);
|
|
switch (msg.type) {
|
|
case "progressUpdate": {
|
|
const data = msg.data;
|
|
if (data.type !== "frame") break;
|
|
const frame = data.data;
|
|
input.progress = progressEstimate(
|
|
frame / uploadRes.totalFrames,
|
|
"convert",
|
|
);
|
|
break;
|
|
}
|
|
|
|
case "jobRetried": {
|
|
this.log(`job retrying for file ${input.name}`);
|
|
ToastManager.add({
|
|
type: "error",
|
|
message: m["convert.errors.vertd.retry"]({
|
|
filename: input.name,
|
|
}),
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "jobFinished": {
|
|
this.log(`job finished for file ${input.name}`);
|
|
try {
|
|
const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`;
|
|
this.log(`downloading from ${url}`);
|
|
const res = await downloadFile(url, input);
|
|
|
|
// confirm download to clean up on server
|
|
try {
|
|
await vertdFetch(
|
|
`/api/confirm/${msg.data.jobId}/${uploadRes.auth}`,
|
|
{
|
|
method: "GET",
|
|
},
|
|
);
|
|
this.log(
|
|
`confirmed download for file ${input.name}`,
|
|
);
|
|
} catch (e) {
|
|
this.error(`failed to confirm download: ${e}`);
|
|
}
|
|
|
|
resolveConversion(
|
|
new VertFile(new File([res], input.name), to),
|
|
);
|
|
} catch (e) {
|
|
if (hash) this.failure(hash);
|
|
rejectConversion(e);
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "jobCancelled": {
|
|
this.log("job cancelled");
|
|
ws.close();
|
|
rejectConversion("Conversion cancelled");
|
|
break;
|
|
}
|
|
|
|
case "error": {
|
|
this.error(`error: ${msg.data.message}`);
|
|
if (hash) this.failure(hash);
|
|
|
|
rejectConversion({
|
|
component: VertdErrorComponent,
|
|
additional: {
|
|
jobId: uploadRes.id,
|
|
auth: uploadRes.auth,
|
|
from: input.from,
|
|
to: to,
|
|
errorMessage: msg.data.message,
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
public async cancel(input: VertFile): Promise<void> {
|
|
this.cancelledConversions.add(input.id);
|
|
|
|
const activeUpload = this.activeUploads.get(input.id);
|
|
if (activeUpload) {
|
|
this.log(`cancelling upload for file ${input.name}`);
|
|
activeUpload.abort();
|
|
this.activeUploads.delete(input.id);
|
|
}
|
|
|
|
const activeConversion = this.activeConversions.get(input.id);
|
|
if (!activeConversion) {
|
|
if (!activeUpload)
|
|
this.error(`no active conversion found for file ${input.name}`);
|
|
return;
|
|
}
|
|
|
|
log(
|
|
["converters", this.name],
|
|
`cancelling conversion for file ${input.name}`,
|
|
);
|
|
|
|
const { ws, jobId, token } = activeConversion;
|
|
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
const cancelMsg: CancelJobMessage = {
|
|
type: "cancelJob",
|
|
data: {
|
|
jobId,
|
|
token,
|
|
},
|
|
};
|
|
ws.send(JSON.stringify(cancelMsg));
|
|
this.log(`sent cancelJob message for file ${input.name}`);
|
|
}
|
|
|
|
ws.close();
|
|
this.activeConversions.delete(input.id);
|
|
}
|
|
|
|
public async valid(): Promise<boolean> {
|
|
if (!(await VertdInstance.instance.url())) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await vertdFetch("/api/version", {
|
|
method: "GET",
|
|
});
|
|
return true;
|
|
} catch (e) {
|
|
this.log(e as unknown as string);
|
|
return false;
|
|
}
|
|
}
|
|
}
|