Files
coverify/src/App.vue
T
2025-12-20 18:54:17 +01:00

399 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, useTemplateRef, computed } from "vue";
import LucideIcon from "./components/LucideIcon.vue";
enum Filter {
None = "None",
Gray = "Gray",
Sepia = "Sepia",
Invert = "Invert",
}
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 visible = ref<boolean>(false);
const srcData = ref<string | null>(null);
const srcWidth = ref<number>(0);
const srcHeight = ref<number>(0);
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 paPos = ref<PAPosition>(PAPosition.BR);
const paScale = ref<number>(0.2);
const paMargin = ref<number>(0.05);
const targetSize = computed<number>(() => {
if (!srcWidth.value || !srcHeight.value) {
return 100;
}
return Math.min(srcWidth.value, srcHeight.value);
});
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 = () => {
image.value.src = reader.result as string;
};
reader.readAsDataURL(input.value.files[0]);
}
function imageOnLoad() {
srcWidth.value = image.value?.width ?? 0;
srcHeight.value = image.value?.height ?? 0;
if (!srcWidth.value || !srcHeight.value) {
return;
}
srcLoaded.value = true;
setTimeout(draw);
}
function randomElement(items) {
return items[Math.floor(Math.random() * items.length)];
}
function randomize() {
centerX.value = Math.random();
centerY.value = Math.random();
zoom.value = 1 + Math.random() * 3;
filter.value = randomElement(Object.values(Filter));
paPos.value = randomElement(Object.values(PAPosition));
paScale.value = 0.1 + Math.random() * 0.2;
paMargin.value = Math.random() * 0.1;
draw();
}
function reset() {
centerX.value = 0.5;
centerY.value = 0.5;
zoom.value = 1;
filter.value = Filter.None;
paPos.value = PAPosition.BR;
paScale.value = 0.2;
paMargin.value = 0.05;
draw();
}
function newImage() {
srcLoaded.value = false;
reset();
}
function download() {
const link = document.createElement("a");
link.download = "coverify.png";
link.href = canvas.value.toDataURL();
link.click();
}
function applyFilter(f: Filter, r: number, g: number, b: number) {
switch (f) {
case Filter.Gray:
const gray = 0.21 * r + 0.72 * g + 0.07 * b;
return [gray, gray, gray];
case Filter.Invert:
return [255 - r, 255 - g, 255 - b];
case Filter.Sepia:
return [
Math.min(255, Math.round(r * 0.393 + g * 0.769 + b * 0.189)),
Math.min(255, Math.round(r * 0.349 + g * 0.686 + b * 0.168)),
Math.min(255, Math.round(r * 0.272 + g * 0.534 + b * 0.131)),
];
}
return [r, g, b];
}
function draw() {
const ctx = canvas.value?.getContext("2d");
if (!ctx) {
return;
}
canvas.value.width = targetSize.value;
canvas.value.height = targetSize.value;
ctx.clearRect(0, 0, targetSize.value, targetSize.value);
const imgWidth = srcWidth.value * zoom.value;
const imgHeight = srcHeight.value * zoom.value;
const dx = (targetSize.value - imgWidth) * centerX.value;
const dy = (targetSize.value - imgHeight) * centerY.value;
ctx.drawImage(image.value, dx, dy, imgWidth, imgHeight);
const imageData = ctx.getImageData(
0,
0,
targetSize.value,
targetSize.value,
);
const pixelData = imageData.data;
for (let i = 0; i < pixelData.length; i += 4) {
const r1 = pixelData[i];
const g1 = pixelData[i + 1];
const b1 = pixelData[i + 2];
const [r2, g2, b2] = applyFilter(filter.value, r1, g1, b1);
pixelData[i] = r2;
pixelData[i + 1] = g2;
pixelData[i + 2] = b2;
pixelData[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
if (paPos.value !== PAPosition.None) {
const paWidth = targetSize.value * paScale.value;
const paHeight = paWidth * PA_RATIO;
let padx = paMargin.value * targetSize.value;
let pady = paMargin.value * targetSize.value;
if (
paPos.value === PAPosition.BC ||
paPos.value === PAPosition.BL ||
paPos.value === PAPosition.BR
) {
pady = targetSize.value - paHeight - pady;
}
if (paPos.value === PAPosition.BR || paPos.value === PAPosition.TR) {
padx = targetSize.value - paWidth - padx;
} else if (
paPos.value === PAPosition.BC ||
paPos.value === PAPosition.TC
) {
padx = (targetSize.value - paWidth) * 0.5;
}
ctx.drawImage(parentalAdvisory.value, padx, pady, paWidth, paHeight);
}
}
onMounted(() => {
setTimeout(() => {
visible.value = true;
});
});
</script>
<template>
<main :style="{ display: visible ? 'inherit' : 'none' }">
<h1>
<LucideIcon name="disc-3" />
Coverify
</h1>
<p><i>Create album covers from mundane photos</i></p>
<img
ref="image"
style="display: none"
:src="srcData"
@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">
<LucideIcon name="dices" /> Randomize
</button>
<button @click="reset">
<LucideIcon name="rotate-ccw" /> Reset
</button>
<button @click="newImage">
<LucideIcon name="trash-2" /> New picture
</button>
<button @click="download">
<LucideIcon name="arrow-down-to-line" /> Download
</button>
</div>
<br />
<table class="config">
<colgroup>
<col style="width: 25%" />
<col />
<col style="width: 10%" />
</colgroup>
<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="draw"
/>
</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="draw"
/>
</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="draw"
/>
</td>
<td>{{ (zoom * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="filter">Filter:</label></td>
<td>
<select id="filter" v-model="filter" @change="draw">
<option
v-for="value in Object.values(Filter)"
:key="`filter-${value}`"
>
{{ value }}
</option>
</select>
</td>
<td></td>
</tr>
<tr>
<td><label for="pa-pos">Sticker Position:</label></td>
<td>
<select id="pa-pos" v-model="paPos" @change="draw">
<option
v-for="value in Object.values(PAPosition)"
:key="`pa-pos-${value}`"
>
{{ value }}
</option>
</select>
</td>
<td></td>
</tr>
<tr v-if="paPos !== PAPosition.None">
<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="draw"
/>
</td>
<td>{{ (paScale * 100).toFixed(0) }}%</td>
</tr>
<tr v-if="paPos !== PAPosition.None">
<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="draw"
/>
</td>
<td>{{ (paMargin * 100).toFixed(0) }}%</td>
</tr>
</table>
</template>
<br />
<hr />
<small class="footer">
<LucideIcon name="at-sign" />
<a href="https://github.com/klemek" target="_blank">klemek</a>
-
<LucideIcon name="github" />
<a href="https://github.com/klemek/coverify" target="_blank"
>Repository</a
>
- 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>