add graphicsmagick

This commit is contained in:
C4illin 2024-05-24 22:58:25 +02:00
parent 3e2338a7c5
commit 4cf06d9406
9 changed files with 706 additions and 87 deletions

View file

@ -1,5 +1,3 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1-debian as base
WORKDIR /app
@ -28,14 +26,20 @@ RUN cd /temp/prod && bun install --frozen-lockfile --production
# copy production dependencies and source code into final image
FROM base AS release
# install additional dependencies
RUN rm -rf /var/lib/apt/lists/partial && apt-get update -o Acquire::CompressionTypes::Order::=gz \
&& apt-get install -y \
pandoc \
texlive-latex-recommended \
ffmpeg \
graphicsmagick \
ghostscript
COPY --from=install /temp/prod/node_modules node_modules
# COPY --from=prerelease /app/src/index.tsx /app/src/
# COPY --from=prerelease /app/package.json .
COPY . .
# install additional dependencies
RUN apt-get update && apt-get install -y pandoc texlive-latex-recommended ffmpeg
# run the app
USER bun
EXPOSE 3000/tcp

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# ConvertX
A self-hosted online file converter. Supports 708 different formats.
## Features
- Convert files to different formats
- Password protection
- Multiple accounts
## Converters supported
| Converter | Use case | Converts from | Converts to |
|----------------|---------------|---------------|-------------|
| Sharp | Images (fast) | 7 | 6 |
| Pandoc | Documents | 43 | 65 |
| GraphicsMagick | Images | 166 | 133 |
| FFmpeg | Video | 461 | 170 |
## Deployment
```yml
# docker-compose.yml
services:
convertx:
image: ghcr.io/c4illin/convertx:master
ports:
- "3000:3000"
environment: # Defaults are listed below
- ACCOUNT_REGISTRATION=false # true or false
volumes:
- /path/you/want:/app/data
```
<!-- or
```bash
docker run ghcr.io/c4illin/convertx:master -p 3000:3000 -e ACCOUNT_REGISTRATION=false -v /path/you/want:/app/data
``` -->
Then visit `http://localhost:3000` in your browser and create your account.

View file

@ -2,8 +2,8 @@
"name": "convertx-frontend",
"version": "1.0.50",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --hot src/index.tsx"
"dev": "bun run --watch src/index.tsx",
"hot": "bun run --hot src/index.tsx"
},
"dependencies": {
"@elysiajs/cookie": "^0.8.0",

View file

@ -659,16 +659,34 @@ export async function convert(
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
options?: any,
) {
let command = "ffmpeg";
return exec(
`ffmpeg -f "${fileType}" -i "${filePath}" -f "${convertTo}" "${targetPath}"`,
(error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return;
}
const notWorking = ["bmp"];
if (!(fileType in notWorking)) {
command += ` -f "${fileType}"`;
}
command += ` -i "${filePath}"`;
if (!(convertTo in notWorking)) {
command += ` -f "${convertTo}"`;
}
command += " ${targetPath}";
return exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return;
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
},
);
}
});
}

View file

@ -0,0 +1,335 @@
import { exec } from "node:child_process";
export const properties = {
from: {
image: [
"3fr",
"8bim",
"8bimtext",
"8bimwtext",
"app1",
"app1jpeg",
"art",
"arw",
"avs",
"b",
"bie",
"bigtiff",
"bmp",
"c",
"cals",
"caption",
"cin",
"cmyk",
"cmyka",
"cr2",
"crw",
"cur",
"cut",
"dcm",
"dcr",
"dcx",
"dng",
"dpx",
"epdf",
"epi",
"eps",
"epsf",
"epsi",
"ept",
"ept2",
"ept3",
"erf",
"exif",
"fax",
"file",
"fits",
"fractal",
"ftp",
"g",
"gif",
"gif87",
"gradient",
"gray",
"graya",
"heic",
"heif",
"hrz",
"http",
"icb",
"icc",
"icm",
"ico",
"icon",
"identity",
"image",
"iptc",
"iptctext",
"iptcwtext",
"jbg",
"jbig",
"jng",
"jnx",
"jpeg",
"jpg",
"k",
"k25",
"kdc",
"label",
"m",
"mac",
"map",
"mat",
"mef",
"miff",
"mng",
"mono",
"mpc",
"mrw",
"msl",
"mtv",
"mvg",
"nef",
"null",
"o",
"orf",
"otb",
"p7",
"pal",
"palm",
"pam",
"pbm",
"pcd",
"pcds",
"pct",
"pcx",
"pdb",
"pdf",
"pef",
"pfa",
"pfb",
"pgm",
"picon",
"pict",
"pix",
"plasma",
"png",
"png00",
"png24",
"png32",
"png48",
"png64",
"png8",
"pnm",
"ppm",
"ps",
"ptif",
"pwp",
"r",
"raf",
"ras",
"rgb",
"rgba",
"rla",
"rle",
"sct",
"sfw",
"sgi",
"sr2",
"srf",
"stegano",
"sun",
"svg",
"svgz",
"text",
"tga",
"tiff",
"tile",
"tim",
"topol",
"ttf",
"txt",
"uyvy",
"vda",
"vicar",
"vid",
"viff",
"vst",
"wbmp",
"webp",
"wmf",
"wpg",
"x3f",
"xbm",
"xc",
"xcf",
"xmp",
"xpm",
"xv",
"xwd",
"y",
"yuv",
],
},
to: {
image: [
"8bim",
"8bimtext",
"8bimwtext",
"app1",
"app1jpeg",
"art",
"avs",
"b",
"bie",
"bigtiff",
"bmp",
"bmp2",
"bmp3",
"brf",
"c",
"cals",
"cin",
"cmyk",
"cmyka",
"dcx",
"dpx",
"epdf",
"epi",
"eps",
"eps2",
"eps3",
"epsf",
"epsi",
"ept",
"ept2",
"ept3",
"exif",
"fax",
"fits",
"g",
"gif",
"gif87",
"gray",
"graya",
"histogram",
"html",
"icb",
"icc",
"icm",
"info",
"iptc",
"iptctext",
"iptcwtext",
"isobrl",
"isobrl6",
"jbg",
"jbig",
"jng",
"jpeg",
"jpg",
"k",
"m",
"m2v",
"map",
"mat",
"matte",
"miff",
"mng",
"mono",
"mpc",
"mpeg",
"mpg",
"msl",
"mtv",
"mvg",
"null",
"o",
"otb",
"p7",
"pal",
"pam",
"pbm",
"pcd",
"pcds",
"pcl",
"pct",
"pcx",
"pdb",
"pdf",
"pgm",
"picon",
"pict",
"png",
"png00",
"png24",
"png32",
"png48",
"png64",
"png8",
"pnm",
"ppm",
"preview",
"ps",
"ps2",
"ps3",
"ptif",
"r",
"ras",
"rgb",
"rgba",
"sgi",
"shtml",
"sun",
"text",
"tga",
"tiff",
"txt",
"ubrl",
"ubrl6",
"uil",
"uyvy",
"vda",
"vicar",
"vid",
"viff",
"vst",
"wbmp",
"webp",
"x",
"xbm",
"xmp",
"xpm",
"xv",
"xwd",
"y",
"yuv",
],
},
};
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
options?: any,
) {
return exec(
`gm convert "${filePath}" "${targetPath}"`,
(error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return;
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
},
);
}

View file

@ -9,10 +9,15 @@ import {
} from "./pandoc";
import {
properties as propertiesFfmpeg,
convert as convertFfmpeg,
properties as propertiesFFmpeg,
convert as convertFFmpeg,
} from "./ffmpeg";
import {
properties as propertiesGraphicsmagick,
convert as convertGraphicsmagick,
} from "./graphicsmagick";
const properties: {
[key: string]: {
properties: {
@ -48,9 +53,13 @@ const properties: {
properties: propertiesPandoc,
converter: convertPandoc,
},
graphicsmagick: {
properties: propertiesGraphicsmagick,
converter: convertGraphicsmagick,
},
ffmpeg: {
properties: propertiesFfmpeg,
converter: convertFfmpeg,
properties: propertiesFFmpeg,
converter: convertFFmpeg,
},
};
@ -86,6 +95,7 @@ export async function mainConverter(
for (const key in converterObj.properties.from) {
if (
// HOW??
converterObj.properties.from[key].includes(fileType) &&
converterObj.properties.to[key].includes(convertTo)
) {
@ -122,7 +132,7 @@ export async function mainConverter(
}
}
const possibleConversions: { [key: string]: { [key: string]: string[] } } = {};
const possibleTargets: { [key: string]: { [key: string]: string[] } } = {};
for (const converterName in properties) {
const converterProperties = properties[converterName]?.properties;
@ -137,33 +147,47 @@ for (const converterName in properties) {
}
for (const extension of converterProperties.from[key] ?? []) {
if (!possibleConversions[extension]) {
possibleConversions[extension] = {};
if (!possibleTargets[extension]) {
possibleTargets[extension] = {};
}
possibleConversions[extension][converterName] =
possibleTargets[extension][converterName] =
converterProperties.to[key] || [];
}
}
}
// // save all possible conversions to a file
// import fs from "fs";
// import path from "path";
// import { FormatEnum } from "sharp";
// fs.writeFileSync(
// path.join(__dirname, ".", "possibleConversions.json"),
// JSON.stringify(possibleConversions),
// );
export const getPossibleConversions = (
export const getPossibleTargets = (
from: string,
): { [key: string]: string[] } => {
const fromClean = normalizeFiletype(from);
return possibleConversions[fromClean] || {};
return possibleTargets[fromClean] || {};
};
const allTargets: { [key: string]: string[]} = {};
const possibleInputs: string[] = [];
for (const converterName in properties) {
const converterProperties = properties[converterName]?.properties;
if (!converterProperties) {
continue;
}
for (const key in converterProperties.from) {
for (const extension of converterProperties.from[key] ?? []) {
if (!possibleInputs.includes(extension)) {
possibleInputs.push(extension);
}
}
}
}
possibleInputs.sort();
export const getPossibleInputs = () => {
return possibleInputs;
};
const allTargets: { [key: string]: string[] } = {};
for (const converterName in properties) {
const converterProperties = properties[converterName]?.properties;
@ -184,3 +208,50 @@ for (const converterName in properties) {
export const getAllTargets = () => {
return allTargets;
};
const allInputs: { [key: string]: string[] } = {};
for (const converterName in properties) {
const converterProperties = properties[converterName]?.properties;
if (!converterProperties) {
continue;
}
for (const key in converterProperties.from) {
if (allInputs[converterName]) {
allInputs[converterName].push(...converterProperties.from[key]);
} else {
allInputs[converterName] = converterProperties.from[key];
}
}
}
export const getAllInputs = (converter: string) => {
return allInputs[converter] || [];
};
// // count the number of unique formats
// const uniqueFormats = new Set();
// for (const converterName in properties) {
// const converterProperties = properties[converterName]?.properties;
// if (!converterProperties) {
// continue;
// }
// for (const key in converterProperties.from) {
// for (const extension of converterProperties.from[key] ?? []) {
// uniqueFormats.add(extension);
// }
// }
// for (const key in converterProperties.to) {
// for (const extension of converterProperties.to[key] ?? []) {
// uniqueFormats.add(extension);
// }
// }
// }
// // print the number of unique Inputs and Outputs
// console.log(`Unique Formats: ${uniqueFormats.size}`);

File diff suppressed because one or more lines are too long

View file

@ -10,8 +10,10 @@ import { BaseHtml } from "./components/base";
import { Header } from "./components/header";
import {
mainConverter,
getPossibleConversions,
getPossibleTargets,
getPossibleInputs,
getAllTargets,
getAllInputs,
} from "./converters/main";
import { normalizeFiletype } from "./helpers/normalizeFiletype";
@ -19,7 +21,7 @@ const db = new Database("./data/mydb.sqlite", { create: true });
const uploadsDir = "./data/uploads/";
const outputDir = "./data/output/";
const accountRegistration =
const ACCOUNT_REGISTRATION =
process.env.ACCOUNT_REGISTRATION === "true" || false;
// fileNames: fileNames,
@ -50,6 +52,8 @@ CREATE TABLE IF NOT EXISTS jobs (
FOREIGN KEY (user_id) REFERENCES users(id)
);`);
let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false;
interface IUser {
id: number;
email: string;
@ -94,7 +98,54 @@ const app = new Elysia()
prefix: "/",
}),
)
.get("/register", () => {
.get("/setup", ({ redirect }) => {
if (!FIRST_RUN) {
return redirect("/login");
}
return (
<BaseHtml title="ConvertX | Setup">
<main class="container">
<h1>Welcome to ConvertX</h1>
<article>
<header>Create your account</header>
<form method="post" action="/register">
<fieldset>
<label>
Email/Username
<input
type="email"
name="email"
placeholder="Email"
required
/>
</label>
<label>
Password
<input
type="password"
name="password"
placeholder="Password"
required
/>
</label>
</fieldset>
<input type="submit" value="Create account" />
</form>
<footer>
Report any issues on{" "}
<a href="https://github.com/C4illin/ConvertX">GitHub</a>.
</footer>
</article>
</main>
</BaseHtml>
);
})
.get("/register", ({ redirect }) => {
if (!ACCOUNT_REGISTRATION) {
return redirect("/login");
}
return (
<BaseHtml title="ConvertX | Register">
<Header />
@ -130,7 +181,15 @@ const app = new Elysia()
})
.post(
"/register",
async ({ body, set, jwt, cookie: { auth } }) => {
async ({ body, set, redirect, jwt, cookie: { auth } }) => {
if (!ACCOUNT_REGISTRATION && !FIRST_RUN) {
return redirect("/login");
}
if (FIRST_RUN) {
FIRST_RUN = false;
}
const existingUser = await db
.query("SELECT * FROM users WHERE email = ?")
.get(body.email);
@ -171,20 +230,18 @@ const app = new Elysia()
sameSite: "strict",
});
// redirect to home
set.status = 302;
set.headers = {
Location: "/",
};
redirect("/");
},
{ body: t.Object({ email: t.String(), password: t.String() }) },
)
.get("/login", async ({ jwt, redirect, cookie: { auth } }) => {
console.log("login handler");
if (FIRST_RUN) {
return redirect("/setup");
}
// if already logged in, redirect to home
if (auth?.value) {
const user = await jwt.verify(auth.value);
console.log(user);
if (user) {
return redirect("/");
@ -296,6 +353,10 @@ const app = new Elysia()
return redirect("/login");
})
.get("/", async ({ jwt, redirect, cookie: { auth, jobId } }) => {
if (FIRST_RUN) {
return redirect("/setup");
}
if (!auth?.value) {
return redirect("/login");
}
@ -347,8 +408,20 @@ const app = new Elysia()
<main class="container">
<article>
<h1>Convert</h1>
<table id="file-list" />
<div style={{ maxHeight: "50vh", overflowY: "auto" }}>
<table id="file-list" class="striped" />
</div>
<input type="file" name="file" multiple />
{/* <label for="convert_from">Convert from</label> */}
<select name="convert_from" aria-label="Convert from" required>
<option selected disabled value="">
Convert from
</option>
{getPossibleInputs().map((input) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<option>{input}</option>
))}
</select>
</article>
<form method="post" action="/convert">
<input type="hidden" name="file_names" id="file_names" />
@ -378,21 +451,22 @@ const app = new Elysia()
.post(
"/conversions",
({ body }) => {
console.log(body);
return (
<select name="convert_to" aria-label="Convert to" required>
<option selected disabled value="">
Convert to
</option>
{Object.entries(getPossibleConversions(body.fileType)).map(([converter, targets]) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<optgroup label={converter}>
{targets.map((target) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<option value={`${target},${converter}`}>{target}</option>
))}
</optgroup>
))}
{Object.entries(getPossibleTargets(body.fileType)).map(
([converter, targets]) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<optgroup label={converter}>
{targets.map((target) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<option value={`${target},${converter}`}>{target}</option>
))}
</optgroup>
),
)}
</select>
);
},
@ -513,7 +587,9 @@ const app = new Elysia()
);
}
const convertTo = normalizeFiletype(body.convert_to.split(",")[0] as string);
const convertTo = normalizeFiletype(
body.convert_to.split(",")[0] as string,
);
const converterName = body.convert_to.split(",")[1];
const fileNames = JSON.parse(body.file_names) as string[];
@ -532,26 +608,35 @@ const app = new Elysia()
);
// Start the conversion process in the background
Promise.all(fileNames.map(async (fileName) => {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.split(".").pop() as string;
const fileType = normalizeFiletype(fileTypeOrig);
const newFileName = fileName.replace(fileTypeOrig, convertTo);
const targetPath = `${userOutputDir}${newFileName}`;
Promise.all(
fileNames.map(async (fileName) => {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.split(".").pop() as string;
const fileType = normalizeFiletype(fileTypeOrig);
const newFileName = fileName.replace(fileTypeOrig, convertTo);
const targetPath = `${userOutputDir}${newFileName}`;
await mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName);
query.run(jobId.value, fileName, newFileName);
}))
.then(() => {
// All conversions are done, update the job status to 'completed'
db.run(
"UPDATE jobs SET status = 'completed' WHERE id = ?",
jobId.value,
);
})
.catch((error) => {
console.error('Error in conversion process:', error);
});
await mainConverter(
filePath,
fileType,
convertTo,
targetPath,
{},
converterName,
);
query.run(jobId.value, fileName, newFileName);
}),
)
.then(() => {
// All conversions are done, update the job status to 'completed'
db.run(
"UPDATE jobs SET status = 'completed' WHERE id = ?",
jobId.value,
);
})
.catch((error) => {
console.error("Error in conversion process:", error);
});
// Redirect the client immediately
return redirect(`/results/${jobId.value}`);
@ -564,20 +649,16 @@ const app = new Elysia()
},
)
.get("/test", async ({ jwt, redirect, cookie: { auth } }) => {
console.log("results page");
if (!auth?.value) {
console.log("no auth value");
return redirect("/login");
}
const user = await jwt.verify(auth.value);
if (!user) {
console.log("no user");
return redirect("/login");
}
const userJobs = db
let userJobs = db
.query("SELECT * FROM jobs WHERE user_id = ?")
.all(user.id) as IJobs[];
@ -589,6 +670,9 @@ const app = new Elysia()
job.finished_files = files.length;
}
// filter out jobs with no files
userJobs = userJobs.filter((job) => job.num_files > 0);
return (
<BaseHtml title="ConvertX | Results">
<Header loggedIn />
@ -741,6 +825,62 @@ const app = new Elysia()
return Bun.file(filePath);
},
)
.get("/converters", async ({ params, jwt, redirect, cookie: { auth } }) => {
if (!auth?.value) {
return redirect("/login");
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect("/login");
}
return (
<BaseHtml title="ConvertX | Converters">
<Header loggedIn />
<main class="container">
<article>
<h1>Converters</h1>
<table>
<thead>
<tr>
<th>Converter</th>
<th>From (Count)</th>
<th>To (Count)</th>
</tr>
</thead>
<tbody>
{Object.entries(getAllTargets()).map(([converter, targets]) => {
const inputs = getAllInputs(converter);
return (
<tr key={converter}>
<td>{converter}</td>
<td>
Count: {inputs.length}
<ul>
{inputs.map((input, index) => (
<li key={index}>{input}</li>
))}
</ul>
</td>
<td>
Count: {targets.length}
<ul>
{targets.map((target, index) => (
<li key={index}>{target}</li>
))}
</ul>
</td>
</tr>
);
})}
</tbody>
</table>
</article>
</main>
</BaseHtml>
);
})
.get(
"/zip/:userId/:jobId",
async ({ params, jwt, redirect, cookie: { auth } }) => {

View file

@ -5,6 +5,8 @@ let fileType;
const selectElem = document.querySelector("select[name='convert_to']");
const convertFromSelect = document.querySelector("select[name='convert_from']");
// Add a 'change' event listener to the file input element
fileInput.addEventListener("change", (e) => {
console.log(e.target.files);
@ -20,8 +22,8 @@ fileInput.addEventListener("change", (e) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${file.name}</td>
<td>${(file.size / 1024 / 1024).toFixed(2)} MB</td>
<td><button class="secondary" onclick="deleteRow(this)">x</button></td>
<td>${(file.size / 1024).toFixed(2)} kB</td>
<td><a class="secondary" onclick="deleteRow(this)" style="cursor: pointer">Remove</a></td>
`;
if (!fileType) {
@ -30,6 +32,15 @@ fileInput.addEventListener("change", (e) => {
fileInput.setAttribute("accept", `.${fileType}`);
setTitle();
// choose the option that matches the file type
for (const option of convertFromSelect.children) {
console.log(option.value);
if (option.value === fileType) {
option.selected = true;
}
}
fetch("/conversions", {
method: "POST",
body: JSON.stringify({ fileType: fileType }),
@ -57,7 +68,7 @@ fileInput.addEventListener("change", (e) => {
const setTitle = () => {
const title = document.querySelector("h1");
title.textContent = `Convert ${fileType ? `.${fileType}` : "___"}`;
title.textContent = `Convert ${fileType ? `.${fileType}` : ""}`;
};
// Add a onclick for the delete button