1.2: add multi-format

This commit is contained in:
split / May 2024-07-12 01:49:22 -07:00
parent 47245c9e84
commit 7792898653
Signed by: split
GPG key ID: C325C61F0BF517C0
19 changed files with 210 additions and 47 deletions

View file

@ -8,4 +8,5 @@ README.md
.prettierignore .prettierignore
.gitignore .gitignore
.env.example .env.example
.env
.svelte-kit .svelte-kit

View file

@ -17,8 +17,14 @@ USERINFO__ROUTE=
# Identifier you'd like to use to link avatars with # Identifier you'd like to use to link avatars with
USERINFO__IDENTIFIER=preferred_username USERINFO__IDENTIFIER=preferred_username
# Comma-separated list of allowed image types # Comma-separated list of any extra formats you'd like to be available
ALLOWED_TYPES=image/jpg,image/jpeg,image/png,image/png,image/webp,image/gif IMAGES__EXTRA_OUTPUT_FORMATS=jpeg
# Comma-separated list of output resolutions you'd like to be available - Default 1024,512,256,128,64,32
IMAGES__OUTPUT_RESOLUTIONS=1024,512,256,128,64,48,32
# Default selected resolution - Default 512
IMAGES__DEFAULT_RESOLUTION=512
# Comma-separated list of permitted image mimetypes
IMAGES__ALLOWED_INPUT_FORMATS=image/jpg,image/jpeg,image/png,image/webp,image/gif
# Prisma database URL # Prisma database URL
DATABASE_URL=file:../.data/data.db DATABASE_URL=file:../.data/data.db

View file

@ -34,7 +34,7 @@ FROM base2 AS release
COPY --from=prisma /usr/src/app/prisma prisma COPY --from=prisma /usr/src/app/prisma prisma
COPY --from=prisma /usr/src/app/node_modules node_modules COPY --from=prisma /usr/src/app/node_modules node_modules
COPY --from=build /usr/src/app/build build COPY --from=build /usr/src/app/build build
COPY --from=build /usr/src/app/static static COPY --from=build /usr/src/app/assets assets
COPY --from=build /usr/src/app/package.json . COPY --from=build /usr/src/app/package.json .
EXPOSE 3000/tcp EXPOSE 3000/tcp

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

BIN
bun.lockb

Binary file not shown.

View file

@ -1,6 +1,6 @@
{ {
"name": "ava", "name": "ava",
"version": "1.1.1", "version": "1.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

3
src/app.d.ts vendored
View file

@ -8,6 +8,9 @@ declare global {
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
declare const __APP_NAME__: string;
declare const __APP_VERSION__: string;
} }
export {}; export {};

View file

@ -3,50 +3,161 @@ import { existsSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { prisma } from "./clientsingleton" import { prisma } from "./clientsingleton"
import configuration from "./configuration" import configuration from "./configuration"
import Sharp from "sharp" import Sharp, { type FormatEnum } from "sharp"
import mime from "mime" import mime from "mime"
// todo: make customizable // todo: make customizable
export const avatarDirectory = "./.data/avatars" export const avatarDirectory = "./.data/avatars"
export const defaultAvatarDirectory = "./static/default/" export const defaultAvatarDirectory = "./.data/defaultAvatar/"
export const renderSizes = [ 1024, 512, 256, 128, 64, 32 ]
export async function getPathToAvatarForUid(uid?: string, size: string = "512") { await mkdir(defaultAvatarDirectory, { recursive: true })
export const missingAvatarQueue = new Map<
string,
Promise<string>
>()
/**
* @description Generate an avatar at the selected size and format
* @param path Path to the avatar directory
* @param size Avatar size
* @param fmt Avatar format
* @returns Promise that resolves to the path of the newly-generated avatar
*/
export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sharp.FormatEnum) {
let qid = JSON.stringify([path, size, fmt])
if (missingAvatarQueue.has(qid))
return missingAvatarQueue.get(qid)!
let prom = new Promise<string>(async (res, rej) => {
// locate best quality currently available
const av = await readdir(path)
// this can probably be done better but I DON'T GIVE A FUCK !!!!
const pathToBestQualityImg =
path == defaultAvatarDirectory
? "./assets/default.png"
: join(
path,
av
.map(e => [parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [number, string])
.sort(([a],[b]) => b - a)
[0][1]
)
const buf = await Bun.file(pathToBestQualityImg).arrayBuffer()
res(writeAvatar(path, await renderAvatar(buf, size, fmt)))
missingAvatarQueue.delete(qid)
})
missingAvatarQueue.set(qid, prom)
return prom
}
/**
* @description Get the path of an avatar for a user
* @param uid UID of the user
* @param size Avatar size
* @param fmt Avatar format
* @returns Path to the avatar of a user
*/
export async function getPathToAvatarForUid(uid?: string, size: number = configuration.images.default_resolution, fmt?: string) {
if (uid?.includes("/")) if (uid?.includes("/"))
throw Error("UID cannot include /") throw Error("UID cannot include /")
// check if format is valid
if (![undefined, ...configuration.images.extra_output_types].includes(fmt as keyof FormatEnum))
return
// if no uid / no avatar folder then default to the default avatar directory
let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory
if (!existsSync(userAvatarDirectory)) if (!existsSync(userAvatarDirectory))
userAvatarDirectory = defaultAvatarDirectory userAvatarDirectory = defaultAvatarDirectory
let sizes = await readdir(userAvatarDirectory) // bind a makeMissing function
const targetAvatar = sizes.find(s => s.match(/(.*)\..*/)?.[1] == size) const makeMissing = generateMissingAvatar.bind(null, userAvatarDirectory, size, fmt as keyof FormatEnum)
// get directory to extract imgs from
let targetAvatarDirectory = join(userAvatarDirectory, fmt||"")
// if there's no images for the specified fmt, generate new ones
if (!existsSync(targetAvatarDirectory))
return makeMissing()
let sizes = await readdir(targetAvatarDirectory, {withFileTypes: true})
const targetAvatar = sizes.filter(e => e.isFile()).find(
s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size
)
if (targetAvatar) if (targetAvatar)
return join(userAvatarDirectory, targetAvatar) return join(targetAvatarDirectory, targetAvatar.name)
else if (configuration.images.output_resolutions.includes(size))
return makeMissing() // generate image at this size for the specified format
} }
export async function getPathToAvatarForIdentifier(identifier: string, size: string = "512") { export async function getPathToAvatarForIdentifier(identifier: string, size: number = configuration.images.default_resolution, fmt?: string) {
let user = await prisma.user.findFirst({ let user = await prisma.user.findFirst({
where: { where: {
identifier identifier
} }
}) })
return getPathToAvatarForUid(user?.userId, size) return getPathToAvatarForUid(user?.userId, size, fmt)
} }
export async function rerenderAvatar(bin: ArrayBuffer, squareSize: number) { /**
* @description Render an avatar at the specified size and format
* @param bin Image to rerender
* @param squareSize Target size of the new image
* @param format Image target format
* @returns Avatar buffer and other information which may be useful
*/
export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number, format?: keyof Sharp.FormatEnum) {
const opBegin = Date.now()
let img = Sharp(bin); let img = Sharp(bin);
let metadata = await img.metadata(); let metadata = await img.metadata();
squareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize) let realSquareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
img.resize({ img.resize({
width: squareSize, width: realSquareSize,
height: squareSize, height: realSquareSize,
fit: "cover" fit: "cover"
}) })
return img.toBuffer() if (format) img.toFormat(format)
return {
buf: await img.toBuffer(),
extension: format || metadata.format,
requestedFormat: format,
squareSize,
time: Date.now()-opBegin
}
}
export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>) {
const targetDir = join(
avatarDir,
...(
renderedAvatar.requestedFormat
? [ renderedAvatar.requestedFormat ]
: []
)
)
await mkdir(targetDir, {recursive: true})
const targetPath = join(
targetDir,
`${renderedAvatar.squareSize}.${renderedAvatar.extension}`
)
await Bun.write(
targetPath,
renderedAvatar.buf
)
return targetPath
} }
export async function setNewAvatar(uid: string, avatar?: File) { export async function setNewAvatar(uid: string, avatar?: File) {
@ -62,26 +173,27 @@ export async function setNewAvatar(uid: string, avatar?: File) {
// make a new directory // make a new directory
mkdir(userAvatarDirectory, { recursive: true }) mkdir(userAvatarDirectory, { recursive: true })
let time: Record<number, number> = {} let time: Record<number, Record<"input" | keyof Sharp.FormatEnum, number>> = {}
// render all images and write to disk // render all images and write to disk
let avatarData = await avatar.arrayBuffer() let avatarData = await avatar.arrayBuffer()
let fileExtension = mime.getExtension(avatar.type) for (let x of configuration.images.output_resolutions) {
for (let x of renderSizes) { time[x] = Object.fromEntries([
console.log(x) ["input", -1],
...configuration.images.extra_output_types
.map( e => [ e, -1 ] )
])
for (let t of [undefined, ...configuration.images.extra_output_types]) {
try { try {
let start = Date.now() const rendered = await renderAvatar(avatarData, x, t)
let rerenderedAvatarData = await rerenderAvatar(avatarData, x) await writeAvatar(userAvatarDirectory, rendered)
await Bun.write( time[x][t || "input"] = rendered.time
join(userAvatarDirectory, `${x}.${fileExtension}`),
rerenderedAvatarData
)
time[x] = Date.now()-start
} catch (e) { // clear pfp and throw if error encountered } catch (e) { // clear pfp and throw if error encountered
await rm(userAvatarDirectory, { recursive: true, force: true }) await rm(userAvatarDirectory, { recursive: true, force: true })
throw e throw e
} }
} }
}
return time return time
} }

View file

@ -1,3 +1,4 @@
import { format, type FormatEnum } from "sharp"
const configuration = { const configuration = {
oauth2: { oauth2: {
endpoints: { endpoints: {
@ -15,6 +16,23 @@ const configuration = {
route: process.env.USERINFO__ROUTE!, route: process.env.USERINFO__ROUTE!,
identifier: process.env.USERINFO__IDENTIFIER! identifier: process.env.USERINFO__IDENTIFIER!
}, },
allowed_types: process.env.ALLOWED_TYPES?.split(",") || [] images: {
permitted_input:
(
process.env.ALLOWED_TYPES
|| process.env.IMAGES__ALLOWED_INPUT_FORMATS
)?.split(",") || [],
default_resolution: parseInt((process.env.IMAGES__DEFAULT_RESOLUTION || "").toString(), 10) || 512,
extra_output_types:
process.env.IMAGES__EXTRA_OUTPUT_FORMATS
?.split(",")
.filter(e => e in format) as (keyof FormatEnum)[]
|| [],
output_resolutions:
process.env.IMAGES__OUTPUT_RESOLUTIONS
?.split(",")
.map(e => parseInt(e,10))
|| [ 1024, 512, 256, 128, 64, 32 ]
}
} }
export default configuration export default configuration

View file

@ -4,6 +4,8 @@
import ava from "../assets/ava_icon.svg?raw" import ava from "../assets/ava_icon.svg?raw"
import type { User } from "$lib/types"; import type { User } from "$lib/types";
export let data: { user?: User }; export let data: { user?: User };
const buildName = `${__APP_NAME__} ${__APP_VERSION__}`
</script> </script>
<svelte:head> <svelte:head>
<title>ava</title> <title>ava</title>
@ -50,12 +52,16 @@
nav > * { nav > * {
display: flex; /* Flexbox fixes everything! */ display: flex; /* Flexbox fixes everything! */
} }
a, small { a, small, footer {
color: var(--link) color: var(--link)
} }
code, pre { code, pre {
font-family: "Space Mono", monospace, monospace; font-family: "Space Mono", monospace, monospace;
} }
footer {
margin: 1em 0;
text-align: center;
}
</style> </style>
</svelte:head> </svelte:head>
<body> <body>
@ -69,4 +75,9 @@
</nav> </nav>
<slot /> <slot />
<footer>
{import.meta.env.DEV ? "[DEV]" : ""}
{buildName}
</footer>
</body> </body>

View file

@ -1,11 +1,15 @@
import { getPathToAvatarForIdentifier } from '$lib/avatars.js'; import { getPathToAvatarForIdentifier } from '$lib/avatars.js';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
export async function GET({ params : { identifier, size } }) { export async function GET({ params : { identifier, size }, url }) {
let avPath = await getPathToAvatarForIdentifier(identifier, size) let sz = size ? parseInt(size, 10) : undefined
if (sz && Number.isNaN(sz))
throw error(400, "Invalid number")
let avPath = await getPathToAvatarForIdentifier(identifier, sz, url.searchParams.get("format") || undefined)
if (!avPath) if (!avPath)
throw error(404, "Avatar at this size not found") throw error(404, "Avatar at this size not found, or this is an invalid format")
return new Response(Bun.file(avPath)) return new Response(Bun.file(avPath))
} }

View file

@ -1,7 +1,7 @@
import {getRequestUser, launchLogin} from "$lib/oidc" import {getRequestUser, launchLogin} from "$lib/oidc"
import configuration from "$lib/configuration.js"; import configuration from "$lib/configuration.js";
import { fail } from "@sveltejs/kit"; import { fail } from "@sveltejs/kit";
import { avatarDirectory, renderSizes, setNewAvatar } from "$lib/avatars.js"; import { avatarDirectory, setNewAvatar } from "$lib/avatars.js";
import { join } from "path"; import { join } from "path";
export async function load({ request, parent, url }) { export async function load({ request, parent, url }) {
const { user } = await parent(); const { user } = await parent();
@ -10,8 +10,8 @@ export async function load({ request, parent, url }) {
return { return {
url: url.toString(), url: url.toString(),
allowedImageTypes: configuration.allowed_types, allowedImageTypes: configuration.images.permitted_input,
renderSizes renderSizes: configuration.images.output_resolutions
} }
} }
@ -27,15 +27,17 @@ export const actions = {
newAvatar = submission.get("newAvatar") newAvatar = submission.get("newAvatar")
if (newAvatar !== undefined && !(newAvatar instanceof File)) if (newAvatar !== undefined && !(newAvatar instanceof File))
return fail(400, {success: false, error: "incorrect entry type"}) return fail(400, {success: false, error: "incorrect entry type"})
if (!configuration.allowed_types.includes(newAvatar.type)) if (!configuration.images.permitted_input.includes(newAvatar.type))
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`}) return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
} }
let time = await setNewAvatar(user.sub, newAvatar) let timing = await setNewAvatar(user.sub, newAvatar)
return { return {
success: true, success: true,
message: Object.entries(time) message: Object.entries(timing)
.map(([res, time]) => `${res}x${res} took ${time}ms to render`) .map(([res, time]) => `render ${res}x${res}: ${
Object.entries(time).map(([type, t]) => `${type}: ${t}ms`).join(", ")
}`)
.join("\n") .join("\n")
|| "No timing information available" || "No timing information available"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,6 +1,12 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import pkg from "./package.json"
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit()],
define: {
'__APP_NAME__': JSON.stringify(pkg.name),
'__APP_VERSION__': JSON.stringify(pkg.version)
}
}); });