Files
coverify/src/App.vue
T
klemek 2c32144c6c
Lint / Oxlint (push) Successful in 6m5s
Lint / TypeScript (push) Successful in 5m21s
Lint / ESLint (push) Successful in 6m5s
Deploy / Build (push) Successful in 5m17s
Deploy / Deploy to Stapler (push) Successful in 5m10s
refactor: refresh from template
2026-05-02 21:58:04 +02:00

434 lines
11 KiB
Vue

<script setup lang="ts">
import { createIcons, icons } from "lucide";
import { ref, onMounted, useTemplateRef, nextTick, onUpdated } from "vue";
enum Filter {
None = "None",
Gray = "Gray",
Sepia = "Sepia",
Invert = "Invert",
Blur = "Blur",
Brightness = "Brightness",
Contrast = "Contrast",
HueShift = "Hue shift",
Saturate = "Saturate",
}
enum PAPosition {
None = "None",
BL = "Bottom Left",
BC = "Bottom Center",
BR = "Bottom Right",
TL = "Top Left",
TC = "Top Center",
TR = "Top Right",
}
const PA_RATIO = 404 / 646;
const PREVIEW_SIZE = 512;
const visible = ref<boolean>(false);
const srcData = ref<string | null>(null);
const srcLoaded = ref<boolean>(false);
const centerX = ref<number>(0.5);
const centerY = ref<number>(0.5);
const zoom = ref<number>(1);
const filter = ref<Filter>(Filter.None);
const filterValue = ref<number>(1);
const paPos = ref<PAPosition>(PAPosition.BR);
const paScale = ref<number>(0.2);
const paMargin = ref<number>(0.05);
const targetSize = ref<number>(1024);
const input = useTemplateRef<HTMLInputElement>("input");
const image = useTemplateRef<HTMLImageElement>("image");
const parentalAdvisory = useTemplateRef<HTMLImageElement>("parentalAdvisory");
const canvas = useTemplateRef<HTMLCanvasElement>("canvas");
function openImage() {
if (!input.value?.files?.length) {
return;
}
const reader = new FileReader();
reader.onload = () => {
srcData.value = reader.result as string;
};
reader.readAsDataURL(input.value.files[0] as Blob);
}
function imageOnLoad() {
srcLoaded.value = true;
setTimeout(draw);
}
function randomElement<T>(items: T[]): T {
// @ts-expect-error: arbitrary type bullshit
return items[Math.floor(Math.random() * items.length)];
}
function randomize() {
centerX.value = 0.25 + Math.random() * 0.5;
centerY.value = 0.25 + Math.random() * 0.5;
zoom.value = 1 + Math.random();
filter.value = randomElement(Object.values(Filter));
filterValue.value = 0.5 + Math.random() * 2;
paPos.value = randomElement(Object.values(PAPosition));
paScale.value = 0.1 + Math.random() * 0.2;
paMargin.value = Math.random() * 0.1;
asyncDraw();
}
function reset() {
centerX.value = 0.5;
centerY.value = 0.5;
zoom.value = 1;
filter.value = Filter.None;
filterValue.value = 1;
paPos.value = PAPosition.BR;
paScale.value = 0.2;
paMargin.value = 0.05;
asyncDraw();
}
function newImage() {
srcLoaded.value = false;
reset();
}
function download() {
const link = document.createElement("a");
link.download = `coverify-${new Date().getTime().toString()}.png`;
if (targetSize.value !== PREVIEW_SIZE) {
draw(targetSize.value);
}
link.href = canvas.value?.toDataURL() ?? "#";
link.click();
}
const drawTimeout = ref<number | undefined>(undefined);
function asyncDraw() {
clearTimeout(drawTimeout.value);
drawTimeout.value = setTimeout(draw);
}
function draw(size = PREVIEW_SIZE) {
if (!canvas.value) {
return;
}
const ctx = canvas.value.getContext("2d");
if (!ctx || !image.value) {
return;
}
canvas.value.width = size;
canvas.value.height = size;
ctx.clearRect(0, 0, size, size);
const imgRatio = image.value.height / image.value.width;
const widthFirst = image.value.width < image.value.height;
const imgWidth = size * (widthFirst ? 1 : 1 / imgRatio) * zoom.value;
const imgHeight = size * (widthFirst ? imgRatio : 1) * zoom.value;
const dx = (size - imgWidth) * centerX.value;
const dy = (size - imgHeight) * centerY.value;
switch (filter.value) {
case Filter.Gray:
ctx.filter = `grayscale(${(filterValue.value * 100).toFixed(2)}%)`;
break;
case Filter.Sepia:
ctx.filter = `sepia(${(filterValue.value * 100).toFixed(2)}%)`;
break;
case Filter.Invert:
ctx.filter = `invert(${(filterValue.value * 100).toFixed(2)}%)`;
break;
case Filter.Blur:
ctx.filter = `blur(${((filterValue.value * 2 * size) / PREVIEW_SIZE).toFixed(2)}px)`;
break;
case Filter.Brightness:
ctx.filter = `brightness(${(filterValue.value * 100).toFixed(2)}%)`;
break;
case Filter.Contrast:
ctx.filter = `contrast(${(filterValue.value * 100).toFixed(2)}%)`;
break;
case Filter.HueShift:
ctx.filter = `hue-rotate(${(filterValue.value * 360).toFixed(2)}deg)`;
break;
case Filter.Saturate:
ctx.filter = `saturate(${(filterValue.value * 100).toFixed(2)}%)`;
break;
}
ctx.drawImage(image.value, dx, dy, imgWidth, imgHeight);
ctx.filter = "none";
if (paPos.value !== PAPosition.None && parentalAdvisory.value) {
const paWidth = size * paScale.value;
const paHeight = paWidth * PA_RATIO;
let padx = paMargin.value * size;
let pady = paMargin.value * size;
if (
paPos.value === PAPosition.BC ||
paPos.value === PAPosition.BL ||
paPos.value === PAPosition.BR
) {
pady = size - paHeight - pady;
}
if (paPos.value === PAPosition.BR || paPos.value === PAPosition.TR) {
padx = size - paWidth - padx;
} else if (paPos.value === PAPosition.BC || paPos.value === PAPosition.TC) {
padx = (size - paWidth) * 0.5;
}
ctx.drawImage(parentalAdvisory.value, padx, pady, paWidth, paHeight);
}
}
async function updateIcons() {
await nextTick();
createIcons({
icons,
nameAttr: "icon",
attrs: {
width: "1.1em",
height: "1.1em",
},
});
}
onMounted(() => {
setTimeout(() => {
visible.value = true;
});
});
onUpdated(updateIcons);
</script>
<template>
<main :style="{ display: visible ? 'inherit' : 'none' }">
<h1>
<i icon="disc-3"></i>
Coverify
</h1>
<p><i>Create album covers from mundane photos</i></p>
<img
ref="image"
style="display: none"
:src="srcData ?? undefined"
@load="imageOnLoad"
/>
<img
ref="parentalAdvisory"
style="display: none"
src="./parental_advisory.png"
/>
<input
v-if="!srcLoaded"
ref="input"
type="file"
accept="image/*"
@change="openImage"
/>
<canvas
v-show="srcLoaded"
ref="canvas"
:width="targetSize"
:height="targetSize"
></canvas>
<br />
<template v-if="srcLoaded">
<div class="buttons">
<button @click="randomize"><i icon="dices"></i> Randomize</button>
<button @click="reset"><i icon="rotate-ccw"></i> Reset</button>
<button @click="newImage"><i icon="trash-2"></i> New picture</button>
<button @click="download">
<i icon="arrow-down-to-line"></i> Download
</button>
</div>
<br />
<table class="config">
<colgroup>
<col style="width: 25%" />
<col />
<col style="width: 10%" />
</colgroup>
<tbody>
<tr>
<td><label for="center-x">Center X:</label></td>
<td>
<input
id="center-x"
v-model="centerX"
type="range"
min="0"
max="1"
step="0.01"
@input="asyncDraw"
/>
</td>
<td>{{ (centerX * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="center-y">Center Y:</label></td>
<td>
<input
id="center-y"
v-model="centerY"
type="range"
min="0"
max="1"
step="0.01"
@input="asyncDraw"
/>
</td>
<td>{{ (centerY * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="zoom">Zoom:</label></td>
<td>
<input
id="zoom"
v-model="zoom"
type="range"
min="1"
max="4"
step="0.01"
@input="asyncDraw"
/>
</td>
<td>{{ (zoom * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="filter">Filter:</label></td>
<td>
<select id="filter" v-model="filter" @change="asyncDraw">
<option
v-for="value in Object.values(Filter)"
:key="`filter-${value}`"
>
{{ value }}
</option>
</select>
</td>
<td></td>
</tr>
<tr>
<td><label for="filter-value">Filter Value:</label></td>
<td>
<input
id="filter-value"
v-model="filterValue"
type="range"
min="0"
max="3"
step="0.01"
@input="asyncDraw"
/>
</td>
<td>{{ (filterValue * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="pa-pos">Sticker Position:</label></td>
<td>
<select id="pa-pos" v-model="paPos" @change="asyncDraw">
<option
v-for="value in Object.values(PAPosition)"
:key="`pa-pos-${value}`"
>
{{ value }}
</option>
</select>
</td>
<td></td>
</tr>
<tr>
<td><label for="pa-scale">Sticker Scale:</label></td>
<td>
<input
id="pa-scale"
v-model="paScale"
type="range"
min="0.10"
max="0.30"
step="0.01"
@input="asyncDraw"
/>
</td>
<td>{{ (paScale * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="pa-margin">Sticker Margin:</label></td>
<td>
<input
id="pa-margin"
v-model="paMargin"
type="range"
min="0"
max="0.1"
step="0.01"
@input="asyncDraw"
/>
</td>
<td>{{ (paMargin * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="size">Image Size:</label></td>
<td>
<select id="size" v-model="targetSize" @change="asyncDraw">
<option :value="512">512x512</option>
<option :value="1024">1024x1024</option>
<option :value="2048">2048x2048</option>
<option :value="4096">4096x4096</option>
</select>
</td>
<td></td>
</tr>
</tbody>
</table>
</template>
<br />
<hr />
<small class="footer">
<i icon="at-sign"></i>&nbsp;
<a href="https://git.klemek.fr/klemek" target="_blank">Kleπek</a>&nbsp;
|&nbsp; <i icon="git-branch"></i>&nbsp;
<a href="https://git.klemek.fr/klemek/coverify" target="_blank"
>Repository</a
>&nbsp; |&nbsp; <i icon="copyright"></i>&nbsp;2025
</small>
</main>
</template>
<style scoped>
h1 {
margin-top: 0;
}
.buttons {
display: flex;
width: 100%;
}
.buttons > button {
flex: 1;
}
canvas {
width: min(40vh, 100%);
margin: auto;
}
select,
input {
width: 100%;
}
.button:hover {
background-color: var(--background-secondary);
}
.footer {
opacity: 50%;
}
</style>