From f210bb886e6df75bd53ebf36b28551bfaf46468c Mon Sep 17 00:00:00 2001 From: Maya Date: Sat, 9 May 2026 20:16:27 +0300 Subject: [PATCH] feat: image sequences --- .../functional/FormatDropdown.svelte | 19 +- src/lib/converters/ffmpeg.animated.ts | 52 ++++ src/lib/converters/ffmpeg.codecs.ts | 135 +++++++++++ src/lib/converters/ffmpeg.svelte.ts | 228 +++++------------- src/lib/converters/mediabunny.svelte.ts | 2 +- src/lib/converters/vertd.svelte.ts | 2 +- src/lib/sections/settings/Conversion.svelte | 2 +- src/lib/sections/settings/index.svelte.ts | 2 +- src/lib/store/index.svelte.ts | 7 + src/lib/types/file.svelte.ts | 76 +++--- 10 files changed, 317 insertions(+), 208 deletions(-) create mode 100644 src/lib/converters/ffmpeg.animated.ts create mode 100644 src/lib/converters/ffmpeg.codecs.ts diff --git a/src/lib/components/functional/FormatDropdown.svelte b/src/lib/components/functional/FormatDropdown.svelte index 2644196..7f37791 100644 --- a/src/lib/components/functional/FormatDropdown.svelte +++ b/src/lib/components/functional/FormatDropdown.svelte @@ -41,8 +41,20 @@ let searchQuery = $state(""); let rootCategory: string | null = null; - let imageSequence = $state(false); - let imageSequenceFPS = $state(15); + // svelte-ignore state_referenced_locally + let imageSequence = $state( + file?.conversionSettings?.imageSequence ?? false, + ); + // svelte-ignore state_referenced_locally + let imageSequenceFPS = $state( + file?.conversionSettings?.imageSequenceFPS ?? 15, + ); + + $effect(() => { + if (!file) return; + file.conversionSettings.imageSequence = imageSequence; + file.conversionSettings.imageSequenceFPS = imageSequenceFPS; + }); const normalize = (str: string) => str.replace(/^\./, "").toLowerCase(); @@ -59,7 +71,7 @@ // if imageSequence is checked, filter image category to animated formats only if (imageSequence && cat === "image") { - const animatedFormats = [".webp", ".gif"]; // .apng not supported by magick-wasm rn + const animatedFormats = [".webp", ".gif", ".apng"]; // .apng not supported by magick-wasm rn formats = formats.filter((f) => animatedFormats.includes(f)); } @@ -252,7 +264,6 @@ onselect?.(allUnfilteredFormats[0]); } else { // no formats available, keeping previous selection - // i feel like this is all very scuffed and we need a better search and filtering system } } diff --git a/src/lib/converters/ffmpeg.animated.ts b/src/lib/converters/ffmpeg.animated.ts new file mode 100644 index 0000000..5a6f5fc --- /dev/null +++ b/src/lib/converters/ffmpeg.animated.ts @@ -0,0 +1,52 @@ +import { toArgs, animatedImageFormats } from "$lib/converters/ffmpeg.codecs"; +import type { ConversionSettings } from "$lib/types/conversion-settings"; + +export function buildImageSequenceCommand( + outputFormat: string, + settings: ConversionSettings, + isAlac: boolean, +): string[] { + const to = `.${outputFormat}`; + const codecArgs = toArgs(to, isAlac); + const baseArgs = [ + "-f", + "concat", + "-safe", + "0", + "-i", + "frames.txt", + ...codecArgs, + ]; + const scaleFilter = "scale=trunc(iw/2)*2:trunc(ih/2)*2"; + const isAnimatedImage = animatedImageFormats.includes(outputFormat); + + if ( + outputFormat === "mp4" || + outputFormat === "mkv" || + outputFormat === "mov" + ) { + baseArgs.push("-vf", scaleFilter, "-pix_fmt", "yuv420p"); + } else if (outputFormat === "webm") { + baseArgs.push( + "-vf", + scaleFilter, + "-pix_fmt", + "yuva420p", + "-auto-alt-ref", + "0", + ); + } else if (outputFormat === "gif") { + baseArgs.push( + "-filter_complex", + `fps=${settings.imageSequenceFPS || 15},${scaleFilter},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`, + ); + } else if (isAnimatedImage) { + baseArgs.push("-vf", scaleFilter); + if (outputFormat === "apng") baseArgs.push("-plays", "0"); + } else { + baseArgs.push("-vf", scaleFilter, "-pix_fmt", "yuv420p"); + } + + baseArgs.push("output" + to); + return baseArgs; +} diff --git a/src/lib/converters/ffmpeg.codecs.ts b/src/lib/converters/ffmpeg.codecs.ts new file mode 100644 index 0000000..30a83eb --- /dev/null +++ b/src/lib/converters/ffmpeg.codecs.ts @@ -0,0 +1,135 @@ +// prettier-ignore +export const CONVERSION_BITRATES = ["auto", "custom", 16, 32, 64, 96, 128, 160, 192, 256, 320] as const; + +// prettier-ignore +export const SAMPLE_RATES = ["auto", "custom", 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, 96000,] as const; + +// prettier-ignore +export const videoFormats = ["mkv", "mp4", "avi", "mov", "webm", "ts", "mts", "m2ts", "wmv", "mpg", "mpeg", "flv", "f4v", "vob", "m4v", "3gp", "3g2", "mxf", "ogv", "rm", "rmvb", "divx"]; + +// prettier-ignore +export const animatedImageFormats = ["gif", "webp", "apng"]; + +// prettier-ignore +export const lossless = ["flac", "m4a", "caf", "alac", "wav", "dsd", "dsf", "dff"]; + +export const getCodecs = ( + ext: string, + isAlac: boolean = false, +): { video: string; audio: string } => { + switch (ext) { + // video <-> audio + case ".mp4": + case ".mkv": + case ".mov": + case ".mts": + case ".ts": + case ".m2ts": + case ".flv": + case ".f4v": + case ".m4v": + case ".3gp": + case ".3g2": + return { video: "libx264", audio: "aac" }; + case ".wmv": + return { video: "wmv2", audio: "wmav2" }; + case ".webm": + case ".ogv": + return { + video: ext === ".webm" ? "libvpx" : "libtheora", + audio: "libvorbis", + }; + case ".avi": + case ".divx": + return { video: "mpeg4", audio: "libmp3lame" }; + case ".mpg": + case ".mpeg": + case ".vob": + return { video: "mpeg2video", audio: "mp2" }; + case ".mxf": + return { video: "mpeg2video", audio: "pcm_s16le" }; + + // audio + case ".mp3": + return { video: "libx264", audio: "libmp3lame" }; + case ".flac": + return { video: "libx264", audio: "flac" }; + case ".wav": + return { video: "libx264", audio: "pcm_s16le" }; + case ".ogg": + case ".oga": + return { video: "libx264", audio: "libvorbis" }; + case ".opus": + return { video: "libx264", audio: "libopus" }; + case ".aac": + return { video: "libx264", audio: "aac" }; + case ".m4a": + return { + video: "libx264", + audio: isAlac ? "alac" : "aac", + }; + case ".alac": + return { video: "libx264", audio: "alac" }; + case ".wma": + return { video: "libx264", audio: "wmav2" }; + + // animated images + case ".gif": + case ".webp": + //case ".apng": + return { video: ext.slice(1), audio: "none" }; + + default: + return { video: "copy", audio: "copy" }; + } +}; + +// and here i was, thinking i'd be done with ffmpeg after finishing vertd +// but OH NO we just HAD to have someone suggest to allow album art video generation. +// +// i hate you SO much. +// - love, maddie +export const toArgs = (ext: string, isAlac: boolean = false): string[] => { + const codecs = getCodecs(ext, isAlac); + const args = ["-c:v", codecs.video]; + + switch (codecs.video) { + case "libx264": { + args.push( + "-preset", + "ultrafast", + "-crf", + "18", + "-tune", + "stillimage", + ); + break; + } + + case "libvpx": { + args.push("-c:v", "libvpx-vp9"); + break; + } + + case "mpeg2video": { + // for mpeg, mpg, vob, mxf + if (ext === ".mxf") args.push("-ar", "48000"); // force 48kHz sample rate + break; + } + } + + // only add audio codec if not a no-audio format + if (codecs.audio !== "none") { + args.push("-c:a", codecs.audio); + } + + if (codecs.audio === "aac") args.push("-strict", "experimental"); + + if (ext === ".divx") args.unshift("-f", "avi"); + if (ext === ".mxf") args.push("-strict", "unofficial"); + + return args; +}; + +export type ConversionBitrate = (typeof CONVERSION_BITRATES)[number]; +export type SampleRate = (typeof SAMPLE_RATES)[number]; diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts index 30825b1..9c9c94f 100644 --- a/src/lib/converters/ffmpeg.svelte.ts +++ b/src/lib/converters/ffmpeg.svelte.ts @@ -6,37 +6,21 @@ import { error, log } from "$lib/util/logger"; import { m } from "$lib/paraglide/messages"; import { Settings } from "$lib/sections/settings/index.svelte"; import { ToastManager } from "$lib/util/toast.svelte"; +import { + videoFormats, + getCodecs, + toArgs, + lossless, + CONVERSION_BITRATES, + SAMPLE_RATES, +} from "./ffmpeg.codecs"; +import { buildImageSequenceCommand } from "./ffmpeg.animated"; import type { SettingDefinition, ConversionSettings, } from "$lib/types/conversion-settings"; // TODO: differentiate in UI? (not native formats) -const videoFormats = [ - "mkv", - "mp4", - "avi", - "mov", - "webm", - "ts", - "mts", - "m2ts", - "wmv", - "mpg", - "mpeg", - "flv", - "f4v", - "vob", - "m4v", - "3gp", - "3g2", - "mxf", - "ogv", - "rm", - "rmvb", - "divx", -]; - export class FFmpegConverter extends Converter { private ffmpeg: FFmpeg = null!; public name = "ffmpeg"; @@ -415,23 +399,62 @@ export class FFmpegConverter extends Converter { const inputFormat = input.from.slice(1); const outputFormat = to.slice(1); const m4a = isAlac || to === ".m4a"; + const isImageSequence = input.isZip() && settings.imageSequence; - const lossless = [ - "flac", - "m4a", - "caf", - "alac", - "wav", - "dsd", - "dsf", - "dff", - ]; const userBitrate = settings.bitrate; const customBitrate = settings.customBitrate; const userSampleRate = settings.sampleRate; const customSampleRate = settings.customSampleRate; const keepMetadata = settings.metadata; + // image sequences -> animated image // video + if (isImageSequence) { + this.log(`converting image sequence ${input.name} to ${to}`); + + const { extractZip } = await import("$lib/util/file"); + const entries = (await extractZip(input.file)).sort((a, b) => + a.filename.localeCompare(b.filename, undefined, { + numeric: true, + sensitivity: "base", + }), + ); + + if (!entries.length) + throw new Error("No images found in zip archive"); + + const imageFiles: Array<{ name: string }> = []; + + for (const [index, entry] of entries.entries()) { + const fileName = + entry.filename.split("/").pop() ?? entry.filename; + const ext = fileName.split(".").pop()?.toLowerCase(); + if (!ext) continue; + + const paddedName = `img${String(index + 1).padStart(5, "0")}.${ext}`; + await ffmpeg.writeFile(paddedName, entry.data); + imageFiles.push({ name: paddedName }); + } + + if (!imageFiles.length) + throw new Error("No images found in zip archive"); + + const listContent = imageFiles + .map( + (image) => + `file '${image.name}'\nduration ${1 / (settings.imageSequenceFPS || 15)}`, + ) + .join("\n"); + await ffmpeg.writeFile( + "frames.txt", + `${listContent}\nfile '${imageFiles[imageFiles.length - 1].name}'\n`, + ); + this.log(`wrote ${imageFiles.length} images to ffmpeg virtual fs`); + + return buildImageSequenceCommand(outputFormat, settings, isAlac); + } + + // else normal single file conversion + let audioBitrateArgs: string[] = []; let sampleRateArgs: string[] = []; let channelsArgs: string[] = []; @@ -683,138 +706,3 @@ export class FFmpegConverter extends Converter { } } } - -// and here i was, thinking i'd be done with ffmpeg after finishing vertd -// but OH NO we just HAD to have someone suggest to allow album art video generation. -// -// i hate you SO much. -// - love, maddie -const toArgs = (ext: string, isAlac: boolean = false): string[] => { - const codecs = getCodecs(ext, isAlac); - const args = ["-c:v", codecs.video]; - - switch (codecs.video) { - case "libx264": { - args.push( - "-preset", - "ultrafast", - "-crf", - "18", - "-tune", - "stillimage", - ); - break; - } - - case "libvpx": { - args.push("-c:v", "libvpx-vp9"); - break; - } - - case "mpeg2video": { - // for mpeg, mpg, vob, mxf - if (ext === ".mxf") args.push("-ar", "48000"); // force 48kHz sample rate - break; - } - } - - args.push("-c:a", codecs.audio); - - if (codecs.audio === "aac") args.push("-strict", "experimental"); - - if (ext === ".divx") args.unshift("-f", "avi"); - if (ext === ".mxf") args.push("-strict", "unofficial"); - - return args; -}; - -const getCodecs = ( - ext: string, - isAlac: boolean = false, -): { video: string; audio: string } => { - switch (ext) { - // video <-> audio - case ".mp4": - case ".mkv": - case ".mov": - case ".mts": - case ".ts": - case ".m2ts": - case ".flv": - case ".f4v": - case ".m4v": - case ".3gp": - case ".3g2": - return { video: "libx264", audio: "aac" }; - case ".wmv": - return { video: "wmv2", audio: "wmav2" }; - case ".webm": - case ".ogv": - return { - video: ext === ".webm" ? "libvpx" : "libtheora", - audio: "libvorbis", - }; - case ".avi": - case ".divx": - return { video: "mpeg4", audio: "libmp3lame" }; - case ".mpg": - case ".mpeg": - case ".vob": - return { video: "mpeg2video", audio: "mp2" }; - case ".mxf": - return { video: "mpeg2video", audio: "pcm_s16le" }; - - // audio - case ".mp3": - return { video: "libx264", audio: "libmp3lame" }; - case ".flac": - return { video: "libx264", audio: "flac" }; - case ".wav": - return { video: "libx264", audio: "pcm_s16le" }; - case ".ogg": - case ".oga": - return { video: "libx264", audio: "libvorbis" }; - case ".opus": - return { video: "libx264", audio: "libopus" }; - case ".aac": - return { video: "libx264", audio: "aac" }; - case ".m4a": - return { - video: "libx264", - audio: isAlac ? "alac" : "aac", - }; - case ".alac": - return { video: "libx264", audio: "alac" }; - case ".wma": - return { video: "libx264", audio: "wmav2" }; - - default: - return { video: "libx264", audio: "aac" }; - } -}; - -export const CONVERSION_BITRATES = [ - "auto", - "custom", - 320, - 256, - 192, - 128, - 96, - 64, - 32, -] as const; -export type ConversionBitrate = (typeof CONVERSION_BITRATES)[number]; - -export const SAMPLE_RATES = [ - "auto", - "custom", - 48000, - 44100, - 32000, - 22050, - 16000, - 11025, - 8000, -] as const; -export type SampleRate = (typeof SAMPLE_RATES)[number]; diff --git a/src/lib/converters/mediabunny.svelte.ts b/src/lib/converters/mediabunny.svelte.ts index 6546509..fe1c36e 100644 --- a/src/lib/converters/mediabunny.svelte.ts +++ b/src/lib/converters/mediabunny.svelte.ts @@ -28,7 +28,7 @@ import type { SettingDefinition, ConversionSettings, } from "$lib/types/conversion-settings"; -import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.svelte"; +import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.codecs"; import { ToastManager } from "$lib/util/toast.svelte"; import { browser } from "$app/environment"; diff --git a/src/lib/converters/vertd.svelte.ts b/src/lib/converters/vertd.svelte.ts index f693cd8..2d73716 100644 --- a/src/lib/converters/vertd.svelte.ts +++ b/src/lib/converters/vertd.svelte.ts @@ -15,7 +15,7 @@ import type { SettingDefinition, ConversionSettings, } from "$lib/types/conversion-settings"; -import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.svelte"; +import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.codecs"; import { formatBytes } from "$lib/util/file"; interface UploadResponse { diff --git a/src/lib/sections/settings/Conversion.svelte b/src/lib/sections/settings/Conversion.svelte index 3a442bd..4c21da2 100644 --- a/src/lib/sections/settings/Conversion.svelte +++ b/src/lib/sections/settings/Conversion.svelte @@ -13,7 +13,7 @@ type ConversionBitrate, SAMPLE_RATES, type SampleRate, - } from "$lib/converters/ffmpeg.svelte"; + } from "$lib/converters/ffmpeg.codecs"; import { m } from "$lib/paraglide/messages"; import Dropdown from "$lib/components/functional/Dropdown.svelte"; import FancyInput from "$lib/components/functional/FancyInput.svelte"; diff --git a/src/lib/sections/settings/index.svelte.ts b/src/lib/sections/settings/index.svelte.ts index ccfa0fd..e3f450e 100644 --- a/src/lib/sections/settings/index.svelte.ts +++ b/src/lib/sections/settings/index.svelte.ts @@ -1,5 +1,5 @@ import { PUB_VERTD_URL } from "$env/static/public"; -import type { ConversionBitrate } from "$lib/converters/ffmpeg.svelte"; +import type { ConversionBitrate } from "$lib/converters/ffmpeg.codecs"; import type { ConversionSpeed } from "$lib/converters/vertd.svelte"; import { readSettings } from "$lib/util/settings"; import { VertdInstance } from "./vertdSettings.svelte"; diff --git a/src/lib/store/index.svelte.ts b/src/lib/store/index.svelte.ts index 55b40b5..1faa370 100644 --- a/src/lib/store/index.svelte.ts +++ b/src/lib/store/index.svelte.ts @@ -263,6 +263,13 @@ class Files { this.files.push(vf); this._addThumbnail(vf); + // set converter + // TODO: this is weird, we rely on conversionSettings for the right converter but zip archives obv dont have settings to change + vf.conversionSettings = { + ...vf.conversionSettings, + converter: vf.converters[0].name, + }; + ToastManager.add({ type: "success", message: m["convert.archive_file.detected"]({ diff --git a/src/lib/types/file.svelte.ts b/src/lib/types/file.svelte.ts index 0783287..57828b2 100644 --- a/src/lib/types/file.svelte.ts +++ b/src/lib/types/file.svelte.ts @@ -175,33 +175,48 @@ export class VertFile { if (!this.converters.length) throw new Error("No converters found"); - const customConverter = this.converters.find( - (c) => c.name === this.conversionSettings.converter, - ); - let converter = customConverter; + let converter: Converter | undefined; + const isImageSequence = + this.conversionSettings.imageSequence && this.isZip(); - if (!converter) { - const compatibleConverters = this.findConverters([ - this.from, - this.to, - ]); - if (compatibleConverters.length) { - converter = compatibleConverters[0]; - log( - ["file", "convert"], - `found compatible converter: ${converter.name}`, - ); - } else { - log( - ["file", "convert"], - `no compatible converter found for ${this.from} to ${this.to}`, + // force ffmpeg for image sequences + // TODO: should allow vertd as well probably(?) but maybe in the future + if (isImageSequence) { + converter = converters.find((c) => c.name === "ffmpeg"); + if (!converter) { + throw new Error( + "FFmpeg converter not found for image sequence conversion", ); } } else { - log( - ["file", "convert"], - `using custom converter from settings: ${converter.name}`, + const customConverter = this.converters.find( + (c) => c.name === this.conversionSettings.converter, ); + converter = customConverter; + + if (!converter) { + const compatibleConverters = this.findConverters([ + this.from, + this.to, + ]); + if (compatibleConverters.length) { + converter = compatibleConverters[0]; + log( + ["file", "convert"], + `found compatible converter: ${converter.name}`, + ); + } else { + log( + ["file", "convert"], + `no compatible converter found for ${this.from} to ${this.to}`, + ); + } + } else { + log( + ["file", "convert"], + `using custom converter from settings: ${converter.name}`, + ); + } } if (!converter) throw new Error("No converter found"); @@ -224,14 +239,15 @@ export class VertFile { try { // for zips: extract > convert each > re-zip // else convert normally - res = this.isZip() - ? await this.convertZip(converter) - : await converter.convert( - this, - this.to, - this.conversionSettings, - ...args, - ); + res = + this.isZip() && !this.conversionSettings.imageSequence + ? await this.convertZip(converter) + : await converter.convert( + this, + this.to, + this.conversionSettings, + ...args, + ); this.result = res; if (this.fallbackToastId !== null) { ToastManager.remove(this.fallbackToastId);