feat: image sequences

This commit is contained in:
Maya 2026-05-09 20:16:27 +03:00
parent 95ab5092cf
commit f210bb886e
No known key found for this signature in database
10 changed files with 317 additions and 208 deletions

View File

@ -41,8 +41,20 @@
let searchQuery = $state(""); let searchQuery = $state("");
let rootCategory: string | null = null; let rootCategory: string | null = null;
let imageSequence = $state(false); // svelte-ignore state_referenced_locally
let imageSequenceFPS = $state(15); 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(); const normalize = (str: string) => str.replace(/^\./, "").toLowerCase();
@ -59,7 +71,7 @@
// if imageSequence is checked, filter image category to animated formats only // if imageSequence is checked, filter image category to animated formats only
if (imageSequence && cat === "image") { 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)); formats = formats.filter((f) => animatedFormats.includes(f));
} }
@ -252,7 +264,6 @@
onselect?.(allUnfilteredFormats[0]); onselect?.(allUnfilteredFormats[0]);
} else { } else {
// no formats available, keeping previous selection // no formats available, keeping previous selection
// i feel like this is all very scuffed and we need a better search and filtering system // i feel like this is all very scuffed and we need a better search and filtering system
} }
} }

View File

@ -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;
}

View File

@ -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];

View File

@ -6,37 +6,21 @@ import { error, log } from "$lib/util/logger";
import { m } from "$lib/paraglide/messages"; import { m } from "$lib/paraglide/messages";
import { Settings } from "$lib/sections/settings/index.svelte"; import { Settings } from "$lib/sections/settings/index.svelte";
import { ToastManager } from "$lib/util/toast.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 { import type {
SettingDefinition, SettingDefinition,
ConversionSettings, ConversionSettings,
} from "$lib/types/conversion-settings"; } from "$lib/types/conversion-settings";
// TODO: differentiate in UI? (not native formats) // 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 { export class FFmpegConverter extends Converter {
private ffmpeg: FFmpeg = null!; private ffmpeg: FFmpeg = null!;
public name = "ffmpeg"; public name = "ffmpeg";
@ -415,23 +399,62 @@ export class FFmpegConverter extends Converter {
const inputFormat = input.from.slice(1); const inputFormat = input.from.slice(1);
const outputFormat = to.slice(1); const outputFormat = to.slice(1);
const m4a = isAlac || to === ".m4a"; 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 userBitrate = settings.bitrate;
const customBitrate = settings.customBitrate; const customBitrate = settings.customBitrate;
const userSampleRate = settings.sampleRate; const userSampleRate = settings.sampleRate;
const customSampleRate = settings.customSampleRate; const customSampleRate = settings.customSampleRate;
const keepMetadata = settings.metadata; 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 audioBitrateArgs: string[] = [];
let sampleRateArgs: string[] = []; let sampleRateArgs: string[] = [];
let channelsArgs: 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];

View File

@ -28,7 +28,7 @@ import type {
SettingDefinition, SettingDefinition,
ConversionSettings, ConversionSettings,
} from "$lib/types/conversion-settings"; } 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 { ToastManager } from "$lib/util/toast.svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";

View File

@ -15,7 +15,7 @@ import type {
SettingDefinition, SettingDefinition,
ConversionSettings, ConversionSettings,
} from "$lib/types/conversion-settings"; } 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"; import { formatBytes } from "$lib/util/file";
interface UploadResponse { interface UploadResponse {

View File

@ -13,7 +13,7 @@
type ConversionBitrate, type ConversionBitrate,
SAMPLE_RATES, SAMPLE_RATES,
type SampleRate, type SampleRate,
} from "$lib/converters/ffmpeg.svelte"; } from "$lib/converters/ffmpeg.codecs";
import { m } from "$lib/paraglide/messages"; import { m } from "$lib/paraglide/messages";
import Dropdown from "$lib/components/functional/Dropdown.svelte"; import Dropdown from "$lib/components/functional/Dropdown.svelte";
import FancyInput from "$lib/components/functional/FancyInput.svelte"; import FancyInput from "$lib/components/functional/FancyInput.svelte";

View File

@ -1,5 +1,5 @@
import { PUB_VERTD_URL } from "$env/static/public"; 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 type { ConversionSpeed } from "$lib/converters/vertd.svelte";
import { readSettings } from "$lib/util/settings"; import { readSettings } from "$lib/util/settings";
import { VertdInstance } from "./vertdSettings.svelte"; import { VertdInstance } from "./vertdSettings.svelte";

View File

@ -263,6 +263,13 @@ class Files {
this.files.push(vf); this.files.push(vf);
this._addThumbnail(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({ ToastManager.add({
type: "success", type: "success",
message: m["convert.archive_file.detected"]({ message: m["convert.archive_file.detected"]({

View File

@ -175,33 +175,48 @@ export class VertFile {
if (!this.converters.length) throw new Error("No converters found"); if (!this.converters.length) throw new Error("No converters found");
const customConverter = this.converters.find( let converter: Converter | undefined;
(c) => c.name === this.conversionSettings.converter, const isImageSequence =
); this.conversionSettings.imageSequence && this.isZip();
let converter = customConverter;
if (!converter) { // force ffmpeg for image sequences
const compatibleConverters = this.findConverters([ // TODO: should allow vertd as well probably(?) but maybe in the future
this.from, if (isImageSequence) {
this.to, converter = converters.find((c) => c.name === "ffmpeg");
]); if (!converter) {
if (compatibleConverters.length) { throw new Error(
converter = compatibleConverters[0]; "FFmpeg converter not found for image sequence conversion",
log(
["file", "convert"],
`found compatible converter: ${converter.name}`,
);
} else {
log(
["file", "convert"],
`no compatible converter found for ${this.from} to ${this.to}`,
); );
} }
} else { } else {
log( const customConverter = this.converters.find(
["file", "convert"], (c) => c.name === this.conversionSettings.converter,
`using custom converter from settings: ${converter.name}`,
); );
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"); if (!converter) throw new Error("No converter found");
@ -224,14 +239,15 @@ export class VertFile {
try { try {
// for zips: extract > convert each > re-zip // for zips: extract > convert each > re-zip
// else convert normally // else convert normally
res = this.isZip() res =
? await this.convertZip(converter) this.isZip() && !this.conversionSettings.imageSequence
: await converter.convert( ? await this.convertZip(converter)
this, : await converter.convert(
this.to, this,
this.conversionSettings, this.to,
...args, this.conversionSettings,
); ...args,
);
this.result = res; this.result = res;
if (this.fallbackToastId !== null) { if (this.fallbackToastId !== null) {
ToastManager.remove(this.fallbackToastId); ToastManager.remove(this.fallbackToastId);