mirror of https://github.com/VERT-sh/VERT.git
feat: AIO zip conversion
This commit is contained in:
parent
990027d61b
commit
7ad8619780
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
);
|
||||
}
|
||||
|
|
@ -127,8 +127,6 @@
|
|||
} else showGradient.set(true);
|
||||
|
||||
gradientColor.set(type);
|
||||
|
||||
// TODO: filter out categories that cant be converted between
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue