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": {
|
"zip_file": {
|
||||||
"extracting": "Detected ZIP archive: {filename}",
|
"extracting": "Detected ZIP archive: {filename}",
|
||||||
"extracted": "Extracted {extract_count} files from {filename}. {ignore_count} items were ignored.",
|
"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}"
|
"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.",
|
"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)
|
// 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
|
// some formats also have a comment from what i saw during testing
|
||||||
export const imageFormats = [
|
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("a", false, true),
|
||||||
new FormatInfo("aai", true, true),
|
new FormatInfo("aai", true, true),
|
||||||
new FormatInfo("ai", false, true),
|
new FormatInfo("ai", false, true),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import PQueue from "p-queue";
|
||||||
import { getLocale, setLocale } from "$lib/paraglide/runtime";
|
import { getLocale, setLocale } from "$lib/paraglide/runtime";
|
||||||
import { m } from "$lib/paraglide/messages";
|
import { m } from "$lib/paraglide/messages";
|
||||||
import sanitizeHtml from "sanitize-html";
|
import sanitizeHtml from "sanitize-html";
|
||||||
import { unzip } from "fflate";
|
|
||||||
import { ToastManager } from "$lib/toast/index.svelte";
|
import { ToastManager } from "$lib/toast/index.svelte";
|
||||||
import { GB } from "$lib/consts";
|
import { GB } from "$lib/consts";
|
||||||
|
|
||||||
|
|
@ -142,7 +141,6 @@ class Files {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _warningShown = false;
|
|
||||||
private async _handleZipFile(file: File): Promise<void> {
|
private async _handleZipFile(file: File): Promise<void> {
|
||||||
try {
|
try {
|
||||||
log(["files"], `extracting zip file: ${file.name}`);
|
log(["files"], `extracting zip file: ${file.name}`);
|
||||||
|
|
@ -152,61 +150,95 @@ class Files {
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const uint8Array = new Uint8Array(arrayBuffer);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const { extractZip } = await import("$lib/zip");
|
||||||
unzip(uint8Array, (err, unzipped) => {
|
const entries = await extractZip(file);
|
||||||
if (err) {
|
|
||||||
error(
|
|
||||||
["files"],
|
|
||||||
`failed to extract zip: ${err.message}`,
|
|
||||||
);
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemCount = Object.keys(unzipped).length;
|
const totalEntries = entries.length;
|
||||||
let ignoreCount = 0;
|
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)) {
|
for (const { filename } of entries) {
|
||||||
if (
|
const format = "." + filename.split(".").pop()?.toLowerCase();
|
||||||
filename.startsWith(".") ||
|
if (!format || format === ".zip") {
|
||||||
filename.includes("/__MACOSX/") ||
|
incompatibleFiles = true;
|
||||||
filename.endsWith("/")
|
continue;
|
||||||
) {
|
}
|
||||||
ignoreCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Array.from(data);
|
const converter = converters
|
||||||
const extractedFile = new File(
|
.sort(byNative(format))
|
||||||
[new Uint8Array(buffer)],
|
.find((c) => c.formatStrings().includes(format));
|
||||||
filename,
|
|
||||||
{ type: "application/octet-stream" },
|
|
||||||
);
|
|
||||||
this._add(extractedFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
ToastManager.add({
|
if (converter) convertersUsed.add(converter.name);
|
||||||
type: "success",
|
else incompatibleFiles = true;
|
||||||
message: m["convert.zip_file.extracted"]({
|
}
|
||||||
filename: file.name,
|
|
||||||
extract_count: itemCount - ignoreCount,
|
|
||||||
ignore_count: ignoreCount,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
} catch (e) {
|
||||||
error(["files"], `error processing zip file: ${e}`);
|
error(["files"], `error processing zip file: ${e}`);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _warningShown = false;
|
||||||
private async _add(file: VertFile | File) {
|
private async _add(file: VertFile | File) {
|
||||||
if (file instanceof VertFile) {
|
if (file instanceof VertFile) {
|
||||||
this.files.push(file);
|
this.files.push(file);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export class VertFile {
|
||||||
|
|
||||||
public converters: Converter[] = [];
|
public converters: Converter[] = [];
|
||||||
|
|
||||||
|
public isZip = $state(() => this.from === ".zip");
|
||||||
|
|
||||||
public findConverters(supportedFormats: string[] = [this.from]) {
|
public findConverters(supportedFormats: string[] = [this.from]) {
|
||||||
const converter = this.converters
|
const converter = this.converters
|
||||||
.filter((converter) =>
|
.filter((converter) =>
|
||||||
|
|
@ -42,6 +44,9 @@ export class VertFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
public findConverter() {
|
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) => {
|
const converter = this.converters.find((converter) => {
|
||||||
if (
|
if (
|
||||||
!converter.formatStrings().includes(this.from) ||
|
!converter.formatStrings().includes(this.from) ||
|
||||||
|
|
@ -101,18 +106,65 @@ export class VertFile {
|
||||||
this.cancelled = false;
|
this.cancelled = false;
|
||||||
let res;
|
let res;
|
||||||
try {
|
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;
|
this.result = res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!this.cancelled) {
|
if (!this.cancelled) this.toastErr(err);
|
||||||
this.toastErr(err);
|
|
||||||
}
|
|
||||||
this.result = null;
|
this.result = null;
|
||||||
}
|
}
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
return res;
|
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() {
|
public async cancel() {
|
||||||
if (!this.processing) return;
|
if (!this.processing) return;
|
||||||
const converter = this.findConverter();
|
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);
|
} else showGradient.set(true);
|
||||||
|
|
||||||
gradientColor.set(type);
|
gradientColor.set(type);
|
||||||
|
|
||||||
// TODO: filter out categories that cant be converted between
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue