feat: AIO zip conversion

This commit is contained in:
Maya 2025-11-21 20:02:51 +03:00
parent 990027d61b
commit 7ad8619780
No known key found for this signature in database
6 changed files with 186 additions and 51 deletions

View File

@ -50,6 +50,11 @@
"zip_file": {
"extracting": "Detected ZIP archive: {filename}",
"extracted": "Extracted {extract_count} files from {filename}. {ignore_count} items were ignored.",
"detected": "Detected {type} files in {filename}.",
"audio": "audio",
"video": "video",
"doc": "document",
"image": "image",
"extract_error": "Error extracting {filename}: {error}"
},
"large_file_warning": "Due to browser / device limitations, video to audio conversion is disabled for this file as it is larger than {limit}GB. We recommend using Firefox or Safari for files of this size since they have less limitations.",

View File

@ -4,7 +4,6 @@ import { FormatInfo } from "./converter.svelte";
// placed into this file to easily differentiate (and also clean up the main magick file)
// some formats also have a comment from what i saw during testing
export const imageFormats = [
// TODO: yaml, json, txt - not sure when these are used (just contains image info seemingly?), probably will remove cause its not very useful for 99.99% of people
new FormatInfo("a", false, true),
new FormatInfo("aai", true, true),
new FormatInfo("ai", false, true),

View File

@ -9,7 +9,6 @@ import PQueue from "p-queue";
import { getLocale, setLocale } from "$lib/paraglide/runtime";
import { m } from "$lib/paraglide/messages";
import sanitizeHtml from "sanitize-html";
import { unzip } from "fflate";
import { ToastManager } from "$lib/toast/index.svelte";
import { GB } from "$lib/consts";
@ -142,7 +141,6 @@ class Files {
return url;
}
private _warningShown = false;
private async _handleZipFile(file: File): Promise<void> {
try {
log(["files"], `extracting zip file: ${file.name}`);
@ -152,61 +150,95 @@ class Files {
filename: file.name,
}),
});
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
return new Promise((resolve, reject) => {
unzip(uint8Array, (err, unzipped) => {
if (err) {
error(
["files"],
`failed to extract zip: ${err.message}`,
);
reject(err);
return;
}
const { extractZip } = await import("$lib/zip");
const entries = await extractZip(file);
const itemCount = Object.keys(unzipped).length;
let ignoreCount = 0;
const totalEntries = entries.length;
log(["files"], `extracted ${totalEntries} files from zip`);
log(["files"], `extracted ${itemCount} files from zip`);
// check if all files in zip use the same converter and are compatible
const convertersUsed = new Set<string>();
let incompatibleFiles = false;
for (const [filename, data] of Object.entries(unzipped)) {
if (
filename.startsWith(".") ||
filename.includes("/__MACOSX/") ||
filename.endsWith("/")
) {
ignoreCount++;
continue;
}
for (const { filename } of entries) {
const format = "." + filename.split(".").pop()?.toLowerCase();
if (!format || format === ".zip") {
incompatibleFiles = true;
continue;
}
const buffer = Array.from(data);
const extractedFile = new File(
[new Uint8Array(buffer)],
filename,
{ type: "application/octet-stream" },
);
this._add(extractedFile);
}
const converter = converters
.sort(byNative(format))
.find((c) => c.formatStrings().includes(format));
ToastManager.add({
type: "success",
message: m["convert.zip_file.extracted"]({
filename: file.name,
extract_count: itemCount - ignoreCount,
ignore_count: ignoreCount,
}),
});
if (converter) convertersUsed.add(converter.name);
else incompatibleFiles = true;
}
resolve();
const converterCount = convertersUsed.size;
const canConvertAsOne =
converterCount === 1 && !incompatibleFiles;
log(
["files"],
`extracted ${entries.length} files from zip (converters: ${converterCount}, compatible: ${canConvertAsOne})`,
);
// TODO: allow user to extract zip if they want to convert individuals (along with image sequence option) in dropdown
if (canConvertAsOne) {
// all files use same converter - add zip as a single VertFile file
const vf = new VertFile(file, ".zip");
vf.converters = converters.filter(
(c) => c.name === Array.from(convertersUsed)[0],
);
const converterName = vf.converters[0].name;
const type =
converterName === "imagemagick"
? "image"
: converterName === "ffmpeg"
? "audio"
: converterName === "pandoc"
? "doc"
: "video";
this.files.push(vf);
this._addThumbnail(vf);
ToastManager.add({
type: "success",
message: m["convert.zip_file.detected"]({
type: m[`convert.zip_file.${type}`](),
filename: file.name,
}),
});
});
} else {
// mixed converters/incompatible files - extract all individually
for (const { filename, data } of entries) {
this._add(
new File([new Uint8Array(data)], filename, {
type: "application/octet-stream",
}),
);
}
ToastManager.add({
type: "success",
message: m["convert.zip_file.extracted"]({
filename: file.name,
extract_count: entries.length,
ignore_count: 0,
}),
});
}
} catch (e) {
error(["files"], `error processing zip file: ${e}`);
throw e;
}
}
private _warningShown = false;
private async _add(file: VertFile | File) {
if (file instanceof VertFile) {
this.files.push(file);

View File

@ -30,6 +30,8 @@ export class VertFile {
public converters: Converter[] = [];
public isZip = $state(() => this.from === ".zip");
public findConverters(supportedFormats: string[] = [this.from]) {
const converter = this.converters
.filter((converter) =>
@ -42,6 +44,9 @@ export class VertFile {
}
public findConverter() {
// zip will always only be added if there's one converter that supports all files - handled in store's _handleZipFile()
if (this.isZip()) return this.converters[0];
const converter = this.converters.find((converter) => {
if (
!converter.formatStrings().includes(this.from) ||
@ -101,18 +106,65 @@ export class VertFile {
this.cancelled = false;
let res;
try {
res = await converter.convert(this, this.to, ...args);
// for zips: extract > convert each > re-zip
// else convert normally
res = this.isZip()
? await this.convertZip(converter)
: await converter.convert(this, this.to, ...args);
this.result = res;
} catch (err) {
if (!this.cancelled) {
this.toastErr(err);
}
if (!this.cancelled) this.toastErr(err);
this.result = null;
}
this.processing = false;
return res;
}
private async convertZip(converter: Converter): Promise<VertFile> {
const { extractZip, createZip } = await import("$lib/zip");
const entries = await extractZip(this.file);
const totalFiles = entries.length;
let processedFiles = 0;
const convertedFiles: File[] = [];
// convert all files in the zip
for (const { filename, data } of entries) {
if (this.cancelled) {
throw new Error("Conversion cancelled");
}
const file = new File([new Uint8Array(data)], filename, {
type: "application/octet-stream",
});
const tempVFile = new VertFile(file, this.to);
tempVFile.converters = [converter];
const converted = await converter.convert(tempVFile, this.to);
let outputExt = this.to;
if (!outputExt.startsWith(".")) outputExt = `.${outputExt}`;
const baseName = filename.replace(/\.[^/.]+$/, "");
const outputFilename = `${baseName}${outputExt}`;
convertedFiles.push(
new File([await converted.file.arrayBuffer()], outputFilename),
);
processedFiles++;
this.progress = Math.round((processedFiles / totalFiles) * 100); // TODO: show live progress between all files rather than per file
}
// return zip of converted files
const resultArray = await createZip(convertedFiles);
const outputFilename = this.file.name.replace(/\.[^/.]+$/, ".zip");
const resultFile = new File(
[new Uint8Array(resultArray)],
outputFilename,
);
return new VertFile(resultFile, ".zip");
}
public async cancel() {
if (!this.processing) return;
const converter = this.findConverter();

49
src/lib/zip.ts Normal file
View File

@ -0,0 +1,49 @@
import { error, log } from "$lib/logger";
import { unzip } from "fflate";
import { downloadZip } from "client-zip";
export interface ZipEntry {
filename: string;
data: Uint8Array;
}
export async function extractZip(file: File): Promise<ZipEntry[]> {
log(["zip"], `extracting zip: ${file.name}`);
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
return new Promise((resolve, reject) => {
unzip(uint8Array, (err, unzipped) => {
if (err) {
error(["zip"], `failed to extract zip: ${err.message}`);
reject(new Error(`Failed to extract zip: ${err.message}`));
return;
}
const entries = Object.entries(unzipped)
.filter(([filename]) => !ignoreEntry(filename))
.map(([filename, data]) => ({
filename,
data: new Uint8Array(data),
}));
log(["zip"], `extracted ${entries.length} entries from ${file.name}`);
resolve(entries);
});
});
}
export async function createZip(files: File[]): Promise<Uint8Array> {
log(["zip"], `creating zip with ${files.length} files`);
const zipBlob = await downloadZip(files).blob();
return new Uint8Array(await zipBlob.arrayBuffer());
}
export function ignoreEntry(filename: string): boolean {
return (
filename.startsWith(".") ||
filename.includes("/__MACOSX/") ||
filename.endsWith("/")
);
}

View File

@ -127,8 +127,6 @@
} else showGradient.set(true);
gradientColor.set(type);
// TODO: filter out categories that cant be converted between
});
</script>