feat: working mvp
This commit is contained in:
+8
-7
@@ -8,13 +8,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- card related -->
|
<!-- card related -->
|
||||||
<!--
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta property="og:title" content="">
|
<meta property="og:title" content="Coverify" />
|
||||||
<meta property="og:description" content="">
|
<meta
|
||||||
<meta property="og:image" content="https://.../preview_640x320.jpg">
|
property="og:description"
|
||||||
<meta property="org:url" content="https://...">
|
content="Create album covers from mundane photos"
|
||||||
-->
|
/>
|
||||||
|
<meta property="org:url" content="https://klemek.github.io/coverify/" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+356
-12
@@ -1,8 +1,187 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, useTemplateRef, computed } from "vue";
|
||||||
import LucideIcon from "./components/LucideIcon.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 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(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -17,6 +196,159 @@ onMounted(() => {
|
|||||||
<LucideIcon name="disc-3" />
|
<LucideIcon name="disc-3" />
|
||||||
Coverify
|
Coverify
|
||||||
</h1>
|
</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 />
|
<br />
|
||||||
<hr />
|
<hr />
|
||||||
<small class="footer">
|
<small class="footer">
|
||||||
@@ -24,24 +356,36 @@ onMounted(() => {
|
|||||||
<a href="https://github.com/klemek" target="_blank">klemek</a>
|
<a href="https://github.com/klemek" target="_blank">klemek</a>
|
||||||
-
|
-
|
||||||
<LucideIcon name="github" />
|
<LucideIcon name="github" />
|
||||||
<a href="https://github.com/klemek/coverify" target="_blank">Repository</a>
|
<a href="https://github.com/klemek/coverify" target="_blank"
|
||||||
|
>Repository</a
|
||||||
|
>
|
||||||
- 2025
|
- 2025
|
||||||
</small>
|
</small>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.button {
|
h1 {
|
||||||
display: block;
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons > button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: min(40vh, 100%);
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-decoration: none;
|
|
||||||
padding: 1em;
|
|
||||||
margin-bottom: 0.75em;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
background-color: var(--background);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.333em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
href?: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<component
|
|
||||||
:is="href ? 'a' : 'div'"
|
|
||||||
:class="`button ${color ? 'b-' + color + ' ' + color : ''}`"
|
|
||||||
>
|
|
||||||
<slot></slot>
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.button {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 1em;
|
|
||||||
margin-bottom: 0.75em;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
background-color: var(--background);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.333em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
background-color: var(--background-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from "vue";
|
import { computed } from "vue";
|
||||||
import * as icons from "lucide-vue-next";
|
import * as icons from "lucide-vue-next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,11 +25,6 @@ function kebab2camel(kebab: string): string {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
console.log(kebab2camel(props.name));
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-expect-error: cannot infer type of all exported data
|
|
||||||
const icon = computed(() => icons[kebab2camel(props.name)]);
|
const icon = computed(() => icons[kebab2camel(props.name)]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Reference in New Issue
Block a user