diff --git a/eslint.config.js b/eslint.config.js index b45523b..91dafe0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -31,9 +31,9 @@ export default ts.config( ignores: ["build/", ".svelte-kit/", "dist/"], }, { - files: ["**/*.ts", "**/*.svelte.ts"], + files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte"], rules: { - "no-at-html-tags": "off", + "svelte/no-at-html-tags": "off", }, }, ); diff --git a/messages/en.json b/messages/en.json index fdd4785..644fb3d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -85,8 +85,9 @@ "settings": { "settings": "Settings", "title": "File conversion settings", - "description": "Change the conversion settings for {filename}, which is using {converter}. These settings may not be available for all formats. This is an early beta and may have some issues.", + "description": "Change the conversion settings for {filename} with the selected converter. These settings may not be available for all formats. This is an early beta and may have some issues.", "none": "No settings available for this format.", + "converter": "Converter", "image": { "quality": "Quality", "depth": "Color depth", diff --git a/src/lib/components/functional/SettingsModal.svelte b/src/lib/components/functional/SettingsModal.svelte index 36a3174..caf417e 100644 --- a/src/lib/components/functional/SettingsModal.svelte +++ b/src/lib/components/functional/SettingsModal.svelte @@ -17,19 +17,40 @@ let { file, onclose }: Props = $props(); - let settings = $state({}); + const getCurrentConverter = ( + vertFile: VertFile, + converterOverride?: string, + ) => { + const converterName = + converterOverride || vertFile.conversionSettings.converter; + const availableConverters = vertFile.isZip() + ? vertFile.converters + : vertFile.findConverters(); + + if (converterName) { + const selectedConverter = + availableConverters.find((c) => c.name === converterName) || + vertFile.converters.find((c) => c.name === converterName); + if (selectedConverter) return selectedConverter; + } + + return vertFile.isZip() + ? vertFile.converters[0] + : vertFile.findConverters()[0]; + }; + + let settings = $derived({ + converter: file ? getCurrentConverter(file)?.name : undefined, + }); const handleSettingChange = (key: string, value: any) => { if (!file) return; settings[key] = value; }; - const applySettings = async () => { - onclose?.(); + const applySettings = async (converterName: string) => { if (!file) return; - const converter = file.isZip() - ? file.converters[0] - : file.findConverters()[0]; + const converter = getCurrentConverter(file, converterName); if (!converter) { log( ["settings", "modal"], @@ -61,7 +82,10 @@ }, { text: "Apply", - action: applySettings, + action: () => { + applySettings(settings.converter!); + onclose?.(); + }, primary: true, }, ]} @@ -69,171 +93,196 @@ >
{#if file} + {@const currentConverter = getCurrentConverter(file)} + {@const availableConverters = file.isZip() + ? file.converters + : file.findConverters()} +

+ {@html sanitize( + m["convert.settings.description"]({ + converter: currentConverter?.name || "unknown", + filename: file.name, + }), + )} +

+ +
+

+ {m["convert.settings.converter"]()} +

+ ({ + value: converter.name, + label: converter.name, + }))} + selected={settings.converter || currentConverter?.name} + settingsStyle + onselect={(value) => { + settings = { converter: value }; // TODO: dont think i need to add the converter here + }} + /> +
- {#await file.getAvailableSettings(file) then availableSettings} -
-

- {@html sanitize( - m["convert.settings.description"]({ - converter: file.isZip() - ? file.converters[0].name - : file.findConverters()[0].name || - "unknown", - filename: file.name, - }), - )} -

- - {#if availableSettings.length === 0} -

- {m["convert.settings.none"]()} -

- {:else} -
- {#each availableSettings as setting (setting.key)} -
-

- {setting.label} -

- - {#if setting.description} -

- {setting.description} + {#key settings} + {#await file.getAvailableSettings(file, settings.converter) then availableSettings} +

+ {#if availableSettings.length === 0} +

+ {m["convert.settings.none"]()} +

+ {:else} +
+ {#each availableSettings as setting (setting.key)} +
+

+ {setting.label}

- {/if} + + {#if setting.description} +

+ {setting.description} +

+ {/if} - {#if setting.type === "select"} - - typeof opt === "string" - ? { - value: opt, - label: opt, - } - : opt, - ) || []} - selected={settings[setting.key] ?? - file.conversionSettings[ + {#if setting.type === "select"} + + typeof opt === "string" + ? { + value: opt, + label: opt, + } + : opt, + ) || []} + selected={settings[ setting.key ] ?? - setting.default} - settingsStyle - onselect={(value) => - handleSettingChange( - setting.key, - value, - )} - disabled={setting.disabled} - /> - {#if setting.hasCustomInput} - {@const disabled = - (settings[setting.key] ?? file.conversionSettings[ setting.key - ]) !== "custom"} - - handleSettingChange( - setting.customInputKey!, - e.currentTarget.value, - )} - /> - {/if} - {:else if setting.type === "boolean"} - - handleSettingChange( - setting.key, - e.currentTarget.checked, - )} - disabled={setting.disabled} - /> - {:else if setting.type === "range"} - {@const rangeValue = (settings[ - setting.key - ] ?? - file.conversionSettings[ - setting.key - ] ?? - setting.default ?? - setting.min ?? - 0) as number} - {@const rangeLabel = - setting.options?.[rangeValue] - ?.label ?? rangeValue} -
- { - const nextValue = - e.currentTarget - .valueAsNumber; + setting.default} + settingsStyle + onselect={(value) => handleSettingChange( setting.key, - nextValue, - ); - }} + value, + )} disabled={setting.disabled} /> - - {rangeLabel} - -
- {:else} - + handleSettingChange( + setting.customInputKey!, + e.currentTarget + .value, + )} + /> + {/if} + {:else if setting.type === "boolean"} + + handleSettingChange( + setting.key, + e.currentTarget.checked, + )} + disabled={setting.disabled} + /> + {:else if setting.type === "range"} + {@const rangeValue = (settings[ + setting.key + ] ?? file.conversionSettings[ setting.key ] ?? - setting.default} - placeholder={setting.placeholder} - oninput={(e) => - handleSettingChange( - setting.key, - e.currentTarget.value, - )} - disabled={setting.disabled} - /> - {/if} -
- {/each} -
- {/if} -
- {/await} + setting.default ?? + setting.min ?? + 0) as number} + {@const rangeLabel = + setting.options?.[rangeValue] + ?.label ?? rangeValue} +
+ { + const nextValue = + e.currentTarget + .valueAsNumber; + handleSettingChange( + setting.key, + nextValue, + ); + }} + disabled={setting.disabled} + /> + + {rangeLabel} + +
+ {:else} + + handleSettingChange( + setting.key, + e.currentTarget.value, + )} + disabled={setting.disabled} + /> + {/if} +
+ {/each} +
+ {/if} +
+ {/await} + {/key} {/if}
diff --git a/src/lib/converters/index.ts b/src/lib/converters/index.ts index e0b3491..aa95fc5 100644 --- a/src/lib/converters/index.ts +++ b/src/lib/converters/index.ts @@ -7,6 +7,7 @@ import { MagickConverter } from "./magick.svelte"; import { DISABLE_ALL_EXTERNAL_REQUESTS } from "$lib/util/consts"; import { MediabunnyConverter } from "./mediabunny.svelte"; +// TODO: change this to include category with initialization to replace converterCategories and maybe categories as well const getConverters = (): Converter[] => { const converters: Converter[] = [ new MagickConverter(), @@ -21,6 +22,12 @@ const getConverters = (): Converter[] => { }; export const converters = getConverters(); +export const converterCategories = { + image: ["imagemagick"], + video: ["mediabunny", "vertd"], + audio: ["ffmpeg"], + doc: ["pandoc"], +} export function getConverterByFormat(format: string) { for (const converter of converters) { @@ -40,13 +47,13 @@ export const categories: Categories = { categories.audio.formats = converters - .find((c) => c.name === "ffmpeg") + .find((c) => converterCategories.audio.includes(c.name)) ?.supportedFormats.filter((f) => f.toSupported && f.isNative) .map((f) => f.name) || []; categories.video.formats = [ ...new Set( converters - .filter((c) => c.name === "mediabunny" || c.name === "vertd") + .filter((c) => converterCategories.video.includes(c.name)) .flatMap((c) => c.supportedFormats .filter((f) => f.toSupported && f.isNative) @@ -56,11 +63,11 @@ categories.video.formats = [ ]; categories.image.formats = converters - .find((c) => c.name === "imagemagick") + .find((c) => converterCategories.image.includes(c.name)) ?.formatStrings((f) => f.toSupported) || []; categories.doc.formats = converters - .find((c) => c.name === "pandoc") + .find((c) => converterCategories.doc.includes(c.name)) ?.supportedFormats.filter((f) => f.toSupported && f.isNative) .map((f) => f.name) || []; diff --git a/src/lib/converters/mediabunny.svelte.ts b/src/lib/converters/mediabunny.svelte.ts index f5339fa..75d11e1 100644 --- a/src/lib/converters/mediabunny.svelte.ts +++ b/src/lib/converters/mediabunny.svelte.ts @@ -23,6 +23,12 @@ import { Converter, FormatInfo, type WorkerStatus } from "./converter.svelte"; import { ToastManager } from "$lib/util/toast.svelte"; import { error, log } from "$lib/util/logger"; import { registerFlacEncoder } from "@mediabunny/flac-encoder"; +import { m } from "$lib/paraglide/messages"; +import type { + SettingDefinition, + ConversionSettings, +} from "$lib/types/conversion-settings"; +import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.svelte"; // codec compatibility object, based on docs // https://mediabunny.dev/guide/supported-formats-and-codecs#compatibility-table @@ -209,6 +215,152 @@ export class MediabunnyConverter extends Converter { registerAc3Encoder(); } + public async getAvailableSettings(): Promise { + // TODO: maybe have a slider for conversion speed/quality like vertd + + const fps: SettingDefinition = { + key: "fps", + label: m["convert.settings.video.fps"](), + 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"](), + 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: "1280x720", label: "1280x720" }, + { value: "1920x1080", label: "1920x1080" }, + { value: "2560x1440", label: "2560x1440" }, + { value: "3840x2160", label: "3840x2160" }, + ], + hasCustomInput: true, + customInputKey: "customResolution", + placeholder: m["convert.settings.video.resolution_placeholder"](), + }; + + // TODO: allow CRF for consistent quality? + const videoBitrate: SettingDefinition = { + key: "videoBitrate", + label: m["convert.settings.video.video_bitrate"](), + 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 audioBitrate: SettingDefinition = { + key: "audioBitrate", + label: m["convert.settings.video.audio_bitrate"](), + 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"](), + 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 + + return [ + videoBitrate, + resolution, + fps, + metadata, + audioBitrate, + sampleRate, + ]; + } + + public async getDefaultSettings(): Promise { + const defaults: ConversionSettings = {}; + const settings = await this.getAvailableSettings(); + settings.forEach((setting) => { + defaults[setting.key] = setting.default; + }); + return defaults; + } + public async convert(file: VertFile, to: string): Promise { const input = new Input({ // TODO: add settings & special handling for certain formats & codecs diff --git a/src/lib/store/index.svelte.ts b/src/lib/store/index.svelte.ts index b873a57..b1aa096 100644 --- a/src/lib/store/index.svelte.ts +++ b/src/lib/store/index.svelte.ts @@ -1,5 +1,5 @@ import { browser } from "$app/environment"; -import { byNative, converters } from "$lib/converters"; +import { byNative, converterCategories, converters } from "$lib/converters"; import { error, log } from "$lib/util/logger"; import { VertFile } from "$lib/types"; import { parseBlob, selectCover } from "music-metadata"; @@ -36,12 +36,12 @@ class Files { private _addThumbnail = async (file: VertFile) => { this.thumbnailQueue.add(async () => { const isAudio = converters - .find((c) => c.name === "ffmpeg") + .find((c) => converterCategories.audio.includes(c.name)) ?.supportedFormats.filter((f) => f.isNative) .map((f) => f.name) ?.includes(file.from.toLowerCase()); const isVideo = converters - .find((c) => c.name === "vertd") + .find((c) => converterCategories.video.includes(c.name)) ?.supportedFormats.filter((f) => f.isNative) .map((f) => f.name) ?.includes(file.from.toLowerCase()); @@ -291,7 +291,7 @@ class Files { this._addThumbnail(vf); const convName = converter.name; - if (file.size > MAX_ARRAY_BUFFER_SIZE && convName === "vertd") { + if (file.size > MAX_ARRAY_BUFFER_SIZE && (converterCategories.video.includes(convName))) { ToastManager.add({ type: "warning", message: m["convert.large_file_warning"]({ @@ -303,10 +303,11 @@ class Files { }); } - const isVideo = convName === "vertd"; + // TODO: only show if vertd is needed/requested + const isServerVideo = convName === "vertd"; const acceptedExternalWarning = localStorage.getItem("acceptedExternalWarning") === "true"; - if (isVideo && !acceptedExternalWarning && !this._warningShown) { + if (isServerVideo && !acceptedExternalWarning && !this._warningShown) { this._warningShown = true; const title = m["convert.external_warning.title"](); const message = m["convert.external_warning.text"](); diff --git a/src/lib/types/file.svelte.ts b/src/lib/types/file.svelte.ts index e4976e5..c68f60c 100644 --- a/src/lib/types/file.svelte.ts +++ b/src/lib/types/file.svelte.ts @@ -40,10 +40,15 @@ export class VertFile { public isZip = $state(() => this.from === ".zip"); - public getAvailableSettings(input: VertFile): Promise { - const converter = this.findConverters()[0]; - if (!converter) return Promise.resolve([]); - return converter.getAvailableSettings(input); + public getAvailableSettings( + input: VertFile, + converter: string | undefined = this.conversionSettings.converter, + ): Promise { + const converterInstance = this.converters.find( + (c) => c.name === converter, + ); + if (!converterInstance) return Promise.resolve([]); + return converterInstance.getAvailableSettings(input); } public findConverters(supportedFormats: string[] = [this.from]) { @@ -133,10 +138,19 @@ export class VertFile { // eslint-disable-next-line @typescript-eslint/no-explicit-any public async convert(...args: any[]) { if (!this.converters.length) throw new Error("No converters found"); - const converter = this.isZip() - ? this.converters[0] - : this.findConverters()[0]; + + const customConverter = this.converters.find( + (c) => c.name === this.conversionSettings.converter, + ); + const converter = + customConverter || + (this.isZip() // TODO: not sure if the zip needs to be changed now + ? this.converters[0] + : this.findConverters([this.from, this.to])[0]); + log(["file", "convert"], `using converter: ${converter.name}`); + if (!converter) throw new Error("No converter found"); + this.result = null; this.progress = 0; this.processing = true; @@ -255,9 +269,9 @@ export class VertFile { public async cancel() { if (!this.processing) return; - const converter = this.isZip() - ? this.converters[0] - : this.findConverters()[0]; + const converter = this.converters.find( + (c) => c.name === this.conversionSettings.converter, + ); if (!converter) throw new Error("No converter found"); this.cancelled = true; try { diff --git a/src/routes/convert/+page.svelte b/src/routes/convert/+page.svelte index 8160afc..206b6e7 100644 --- a/src/routes/convert/+page.svelte +++ b/src/routes/convert/+page.svelte @@ -5,7 +5,11 @@ import Panel from "$lib/components/visual/Panel.svelte"; import ProgressBar from "$lib/components/visual/ProgressBar.svelte"; import Tooltip from "$lib/components/visual/Tooltip.svelte"; - import { categories, converters } from "$lib/converters"; + import { + categories, + converterCategories, + converters, + } from "$lib/converters"; import { effects, files, @@ -35,6 +39,22 @@ let processedFileIds = $state(new Set()); + const getCurrentConverter = (file: VertFile) => { + const converterName = file.conversionSettings.converter; + const availableConverters = file.isZip() + ? file.converters + : file.findConverters(); + + if (converterName) { + const selectedConverter = + availableConverters.find((c) => c.name === converterName) || + file.converters.find((c) => c.name === converterName); + if (selectedConverter) return selectedConverter; + } + + return file.isZip() ? file.converters[0] : file.findConverters()[0]; + }; + $effect(() => { if (!Settings.instance.settings || files.files.length === 0) return; @@ -42,14 +62,19 @@ const settings = Settings.instance.settings; if (processedFileIds.has(file.id)) return; - const converter = file.isZip() ? file.converters[0] : file.findConverters()[0]; + const converter = getCurrentConverter(file); if (!converter) return; + // Initialize converter in settings if not already set + if (!file.conversionSettings.converter) + file.conversionSettings.converter = converter.name; + + let category: string | undefined; - const isImage = converter.name === "imagemagick"; - const isAudio = converter.name === "ffmpeg"; - const isVideo = converter.name === "vertd"; - const isDocument = converter.name === "pandoc"; + const isImage = converterCategories.image.includes(converter.name); + const isAudio = converterCategories.audio.includes(converter.name); + const isVideo = converterCategories.video.includes(converter.name); + const isDocument = converterCategories.doc.includes(converter.name); if (isImage) category = "image"; else if (isAudio) category = "audio"; @@ -108,16 +133,19 @@ let type = ""; if (files.files.length) { const converters = files.files.map( - (file) => (file.isZip() ? file.converters[0] : file.findConverters()[0])?.name, + (file) => getCurrentConverter(file)?.name, ); const uniqueTypes = new Set(converters); if (uniqueTypes.size === 1) { const onlyType = converters[0]; - if (onlyType === "imagemagick") type = "blue"; - else if (onlyType === "ffmpeg") type = "purple"; - else if (onlyType === "vertd") type = "red"; - else if (onlyType === "pandoc") type = "green"; + if (converterCategories.image.includes(onlyType)) type = "blue"; + else if (converterCategories.audio.includes(onlyType)) + type = "purple"; + else if (converterCategories.video.includes(onlyType)) + type = "red"; + else if (converterCategories.doc.includes(onlyType)) + type = "green"; } } @@ -130,11 +158,12 @@ {#snippet fileItem(file: VertFile, index: number)} - {@const currentConverter = file.isZip() ? file.converters[0] : file.findConverters()[0]} - {@const isImage = currentConverter?.name === "imagemagick"} - {@const isAudio = currentConverter?.name === "ffmpeg"} - {@const isVideo = currentConverter?.name === "vertd"} - {@const isDocument = currentConverter?.name === "pandoc"} + {@const currentConverter = getCurrentConverter(file)} + {@const name = currentConverter?.name || "unknown"} + {@const isImage = converterCategories.image.includes(name)} + {@const isAudio = converterCategories.audio.includes(name)} + {@const isVideo = converterCategories.video.includes(name)} + {@const isDocument = converterCategories.doc.includes(name)}
{#if !converters.length}