Merge branch 'nullptr/experimental-audio-to-video'

This commit is contained in:
JovannMC 2025-05-28 19:15:04 +03:00
commit 5da55a56a1
No known key found for this signature in database
7 changed files with 188 additions and 53 deletions

View File

@ -5,8 +5,9 @@ export class FormatInfo {
constructor( constructor(
name: string, name: string,
public fromSupported: boolean, public fromSupported = true,
public toSupported: boolean, public toSupported = true,
public isNative = true,
) { ) {
this.name = name; this.name = name;
if (!this.name.startsWith(".")) { if (!this.name.startsWith(".")) {

View File

@ -5,6 +5,8 @@ import { browser } from "$app/environment";
import { error, log } from "$lib/logger"; import { error, log } from "$lib/logger";
import { addToast } from "$lib/store/ToastProvider"; import { addToast } from "$lib/store/ToastProvider";
const videoFormats = ["mp4", "mkv", "avi", "mov", "webm"];
export class FFmpegConverter extends Converter { export class FFmpegConverter extends Converter {
private ffmpeg: FFmpeg = null!; private ffmpeg: FFmpeg = null!;
public name = "ffmpeg"; public name = "ffmpeg";
@ -17,10 +19,11 @@ export class FFmpegConverter extends Converter {
new FormatInfo("ogg", true, true), new FormatInfo("ogg", true, true),
new FormatInfo("aac", true, true), new FormatInfo("aac", true, true),
new FormatInfo("m4a", true, true), new FormatInfo("m4a", true, true),
...videoFormats.map((f) => new FormatInfo(f, true, true, false)),
new FormatInfo("wma", true, true), new FormatInfo("wma", true, true),
new FormatInfo("amr", true, true), new FormatInfo("amr", true, true),
new FormatInfo("ac3", true, true), new FormatInfo("ac3", true, true),
new FormatInfo("alac", true, false), new FormatInfo("alac", true, true),
new FormatInfo("aiff", true, true), new FormatInfo("aiff", true, true),
]; ];
@ -57,6 +60,17 @@ export class FFmpegConverter extends Converter {
ffmpeg.on("progress", (progress) => { ffmpeg.on("progress", (progress) => {
input.progress = progress.progress * 100; input.progress = progress.progress * 100;
}); });
ffmpeg.on("log", (l) => {
log(["converters", this.name], l.message);
if (l.message.includes("Stream map '0:a:0' matches no streams.")) {
error(
["converters", this.name],
`No audio stream found in ${input.name}.`,
);
addToast("error", `No audio stream found in ${input.name}.`);
}
});
const baseURL = const baseURL =
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm"; "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
await ffmpeg.load({ await ffmpeg.load({
@ -69,7 +83,37 @@ export class FFmpegConverter extends Converter {
["converters", this.name], ["converters", this.name],
`wrote ${input.name} to ffmpeg virtual fs`, `wrote ${input.name} to ffmpeg virtual fs`,
); );
await ffmpeg.exec(["-i", "input", "output" + to]); if (videoFormats.includes(input.from.slice(1))) {
// create an audio track from the video
await ffmpeg.exec(["-i", "input", "-map", "0:a:0", "output" + to]);
} else if (videoFormats.includes(to.slice(1))) {
// nab the album art
await ffmpeg.exec([
"-i",
"input",
"-an",
"-vcodec",
"copy",
"cover.png",
]);
const cmd = [
"-i",
"input",
"-i",
"cover.png",
"-loop",
"1",
"-pix_fmt",
"yuv420p",
...toArgs(to),
"output" + to,
];
console.log(cmd);
await ffmpeg.exec(cmd);
} else {
await ffmpeg.exec(["-i", "input", "output" + to]);
}
log(["converters", this.name], `executed ffmpeg command`); log(["converters", this.name], `executed ffmpeg command`);
const output = (await ffmpeg.readFile( const output = (await ffmpeg.readFile(
"output" + to, "output" + to,
@ -82,3 +126,49 @@ export class FFmpegConverter extends Converter {
return new VertFile(new File([output], input.name), to); return new VertFile(new File([output], input.name), to);
} }
} }
// 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): string[] => {
const encoder = getEncoder(ext);
const args = ["-c:v", encoder];
switch (encoder) {
case "libx264": {
args.push(
"-preset",
"ultrafast",
"-crf",
"18",
"-tune",
"stillimage",
"-c:a",
"aac",
);
break;
}
case "libvpx": {
args.push("-c:v", "libvpx-vp9", "-c:a", "libvorbis");
break;
}
}
return args;
};
const getEncoder = (ext: string): string => {
switch (ext) {
case ".mkv":
case ".mp4":
case ".avi":
case ".mov":
return "libx264";
case ".webm":
return "libvpx";
default:
return "copy";
}
};

View File

@ -1,4 +1,5 @@
import type { Categories } from "$lib/types"; import type { Categories } from "$lib/types";
import type { Converter } from "./converter.svelte";
import { FFmpegConverter } from "./ffmpeg.svelte"; import { FFmpegConverter } from "./ffmpeg.svelte";
import { PandocConverter } from "./pandoc.svelte"; import { PandocConverter } from "./pandoc.svelte";
import { VertdConverter } from "./vertd.svelte"; import { VertdConverter } from "./vertd.svelte";
@ -47,3 +48,14 @@ categories.docs.formats =
.find((c) => c.name === "pandoc") .find((c) => c.name === "pandoc")
?.formatStrings((f) => f.toSupported) ?.formatStrings((f) => f.toSupported)
.filter((f) => f !== ".pdf") || []; .filter((f) => f !== ".pdf") || [];
export const byNative = (format: string) => {
return (a: Converter, b: Converter) => {
const aFormat = a.supportedFormats.find((f) => f.name === format);
const bFormat = b.supportedFormats.find((f) => f.name === format);
if (aFormat && bFormat) {
return aFormat.isNative ? -1 : 1;
}
return 0;
};
};

View File

@ -17,11 +17,11 @@ export class VipsConverter extends Converter {
public ready = $state(false); public ready = $state(false);
public supportedFormats = [ public supportedFormats = [
new FormatInfo("png", true, true), new FormatInfo("png"),
new FormatInfo("jpeg", true, true), new FormatInfo("jpeg"),
new FormatInfo("jpg", true, true), new FormatInfo("jpg"),
new FormatInfo("webp", true, true), new FormatInfo("webp"),
new FormatInfo("gif", true, true), new FormatInfo("gif"),
new FormatInfo("heic", true, false), new FormatInfo("heic", true, false),
new FormatInfo("ico", true, false), new FormatInfo("ico", true, false),
new FormatInfo("bmp", true, false), new FormatInfo("bmp", true, false),
@ -30,8 +30,8 @@ export class VipsConverter extends Converter {
new FormatInfo("icns", true, false), new FormatInfo("icns", true, false),
new FormatInfo("nef", true, false), new FormatInfo("nef", true, false),
new FormatInfo("cr2", true, false), new FormatInfo("cr2", true, false),
new FormatInfo("hdr", true, true), new FormatInfo("hdr"),
new FormatInfo("jpe", true, true), new FormatInfo("jpe"),
new FormatInfo("dng", true, false), new FormatInfo("dng", true, false),
new FormatInfo("mat", true, true), new FormatInfo("mat", true, true),
new FormatInfo("pbm", true, true), new FormatInfo("pbm", true, true),
@ -40,10 +40,10 @@ export class VipsConverter extends Converter {
new FormatInfo("pnm", true, true), new FormatInfo("pnm", true, true),
new FormatInfo("ppm", false, true), new FormatInfo("ppm", false, true),
new FormatInfo("raw", false, true), new FormatInfo("raw", false, true),
new FormatInfo("tif", true, true), new FormatInfo("tif"),
new FormatInfo("tiff", true, true), new FormatInfo("tiff"),
new FormatInfo("jfif", true, true), new FormatInfo("jfif"),
new FormatInfo("avif", true, true), new FormatInfo("avif"),
]; ];
public readonly reportsProgress = false; public readonly reportsProgress = false;

View File

@ -1,5 +1,5 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { converters } from "$lib/converters"; import { byNative, converters } from "$lib/converters";
import { error, log } from "$lib/logger"; import { error, log } from "$lib/logger";
import { VertFile } from "$lib/types"; import { VertFile } from "$lib/types";
import { parseBlob, selectCover } from "music-metadata"; import { parseBlob, selectCover } from "music-metadata";
@ -32,11 +32,13 @@ class Files {
this.thumbnailQueue.add(async () => { this.thumbnailQueue.add(async () => {
const isAudio = converters const isAudio = converters
.find((c) => c.name === "ffmpeg") .find((c) => c.name === "ffmpeg")
?.formatStrings() ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
?.includes(file.from.toLowerCase()); ?.includes(file.from.toLowerCase());
const isVideo = converters const isVideo = converters
.find((c) => c.name === "vertd") .find((c) => c.name === "vertd")
?.formatStrings() ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
?.includes(file.from.toLowerCase()); ?.includes(file.from.toLowerCase());
try { try {
@ -120,11 +122,11 @@ class Files {
log(["files"], `no extension found for ${file.name}`); log(["files"], `no extension found for ${file.name}`);
return; return;
} }
const converter = converters.find((c) => const converter = converters
c .sort(byNative(format))
.formatStrings() .find((converter) =>
.includes(format || ".somenonexistentextension"), converter.formatStrings().includes(format),
); );
if (!converter) { if (!converter) {
log(["files"], `no converter found for ${file.name}`); log(["files"], `no converter found for ${file.name}`);
this.files.push(new VertFile(file, format)); this.files.push(new VertFile(file, format));

View File

@ -1,4 +1,4 @@
import { converters } from "$lib/converters"; import { byNative, converters } from "$lib/converters";
import type { Converter } from "$lib/converters/converter.svelte"; import type { Converter } from "$lib/converters/converter.svelte";
import { error } from "$lib/logger"; import { error } from "$lib/logger";
import { addToast } from "$lib/store/ToastProvider"; import { addToast } from "$lib/store/ToastProvider";
@ -27,18 +27,35 @@ export class VertFile {
public converters: Converter[] = []; public converters: Converter[] = [];
public findConverters(supportedFormats: string[] = [this.from]) { public findConverters(supportedFormats: string[] = [this.from]) {
const converter = this.converters.filter((converter) => const converter = this.converters
converter.formatStrings().map((f) => supportedFormats.includes(f)), .filter((converter) =>
); converter
.formatStrings()
.map((f) => supportedFormats.includes(f)),
)
.sort(byNative(this.from));
return converter; return converter;
} }
public findConverter() { public findConverter() {
const converter = this.converters.find( const converter = this.converters.find((converter) => {
(converter) => if (
converter.formatStrings().includes(this.from) && !converter.formatStrings().includes(this.from) ||
converter.formatStrings().includes(this.to), !converter.formatStrings().includes(this.to)
); ) {
return false;
}
const theirFrom = converter.supportedFormats.find(
(f) => f.name === this.from,
);
const theirTo = converter.supportedFormats.find(
(f) => f.name === this.to,
);
if (!theirFrom || !theirTo) return false;
if (!theirFrom.isNative && !theirTo.isNative) return false;
return true;
});
return converter; return converter;
} }

View File

@ -5,7 +5,7 @@
import Panel from "$lib/components/visual/Panel.svelte"; import Panel from "$lib/components/visual/Panel.svelte";
import ProgressBar from "$lib/components/visual/ProgressBar.svelte"; import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
import Tooltip from "$lib/components/visual/Tooltip.svelte"; import Tooltip from "$lib/components/visual/Tooltip.svelte";
import { categories, converters } from "$lib/converters"; import { categories, converters, byNative } from "$lib/converters";
import { import {
effects, effects,
files, files,
@ -60,21 +60,30 @@
$effect(() => { $effect(() => {
// Set gradient color depending on the file types // Set gradient color depending on the file types
// TODO: if more file types added, add a "fileType" property to the file object // TODO: if more file types added, add a "fileType" property to the file object
const allAudio = files.files.every( const allAudio = files.files.every((file) => {
(file) => file.findConverter()?.name === "ffmpeg", const converter = file
); .findConverters()
const allImages = files.files.every( .sort(byNative(file.from))[0];
(file) => return converter?.name === "ffmpeg";
file.findConverter()?.name !== "ffmpeg" && });
file.findConverter()?.name !== "vertd", const allImages = files.files.every((file) => {
); const converter = file
const allVideos = files.files.every( .findConverters()
(file) => file.findConverter()?.name === "vertd", .sort(byNative(file.from))[0];
); return converter?.name === "libvips";
});
const allDocuments = files.files.every( const allVideos = files.files.every((file) => {
(file) => file.findConverter()?.name === "pandoc", const converter = file
); .findConverters()
.sort(byNative(file.from))[0];
return converter?.name === "vertd";
});
const allDocuments = files.files.every((file) => {
const converter = file
.findConverters()
.sort(byNative(file.from))[0];
return converter?.name === "pandoc";
});
if (files.files.length === 1 && files.files[0].blobUrl && !allVideos) { if (files.files.length === 1 && files.files[0].blobUrl && !allVideos) {
showGradient.set(false); showGradient.set(false);
@ -84,7 +93,7 @@
if ( if (
files.files.length === 0 || files.files.length === 0 ||
(!allAudio && !allImages && !allVideos) (!allAudio && !allImages && !allVideos && !allDocuments)
) { ) {
gradientColor.set(""); gradientColor.set("");
} else { } else {
@ -112,19 +121,23 @@
)} )}
{@const isAudio = converters {@const isAudio = converters
.find((c) => c.name === "ffmpeg") .find((c) => c.name === "ffmpeg")
?.formatStrings((f) => f.fromSupported) ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from)} .includes(file.from)}
{@const isVideo = converters {@const isVideo = converters
.find((c) => c.name === "vertd") .find((c) => c.name === "vertd")
?.formatStrings((f) => f.fromSupported) ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from)} .includes(file.from)}
{@const isImage = converters {@const isImage = converters
.find((c) => c.name === "libvips") .find((c) => c.name === "libvips")
?.formatStrings((f) => f.fromSupported) ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from)} .includes(file.from)}
{@const isDocument = converters {@const isDocument = converters
.find((c) => c.name === "pandoc") .find((c) => c.name === "pandoc")
?.formatStrings((f) => f.fromSupported) ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from)} .includes(file.from)}
<Panel class="p-5 flex flex-col min-w-0 gap-4 relative"> <Panel class="p-5 flex flex-col min-w-0 gap-4 relative">
<div class="flex-shrink-0 h-8 w-full flex items-center gap-2"> <div class="flex-shrink-0 h-8 w-full flex items-center gap-2">