Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/components/EditorSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePaletteStore } from "~~/stores/paletteStore"
import { useHistoryStore } from "~~/stores/historyStore"

import type { TabsItem } from "@nuxt/ui"
import ImageToLogo from "./ImageToLogo.vue"

const fontStore = useFontStore()
const optionsStore = useOptionsStore()
Expand All @@ -31,6 +32,11 @@ const tabs = [
icon: "i-lucide-brush",
slot: "character-editor" as const
},
{
label: "Font Image Upload",
icon: "i-lucide-file-image",
slot: "font-image-editor" as const
},
{
label: "Logo Upload",
icon: "i-lucide-file-image",
Expand Down Expand Up @@ -181,6 +187,9 @@ const tabs = [
<template #image-editor>
<ImageToLogo />
</template>
<template #font-image-editor>
<ImageToFont />
</template>
<template #custom-graphics>
<CustomGraphics />
</template>
Expand Down
265 changes: 265 additions & 0 deletions app/components/ImageToFont.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref } from "vue"
import { useFontStore } from "../../stores/fontStore"

interface ApiResponse {
success: boolean
error?: string
data?: {
metadata: string
characterCount: number
characters: Array<{
index: number
width: number
height: number
pixels: number[][]
}>
}
tilesAdded?: number
timing?: {
totalTimeMs: number
processingTimeMs: number
}
}

const file = ref<File | null>(null)
const imageUrl = ref<string | null>(null)
const previewUrl = ref<string | null>(null)
const isLoading = ref(false)
const isPreviewLoading = ref(false)
const error = ref<string | null>(null)

// Get the font store
const fontStore = useFontStore()

function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
file.value = input.files[0] as File
// Clear any previous errors when selecting a new file
error.value = null
// Create object URL for the image preview
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
imageUrl.value = URL.createObjectURL(file.value)
// Clear any previous preview
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
console.log("📂 Image File selected:", file.value)

// Automatically generate preview
generatePreview()
}
}

async function generatePreview() {
if (!file.value) {
error.value = "Please select an image first"
return
}

isPreviewLoading.value = true
error.value = null

try {
// Convert file to base64
const reader = new FileReader()
const imageDataPromise = new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error("Failed to read file"))
})
reader.readAsDataURL(file.value)

const imageData = await imageDataPromise

// Call the image-preview API endpoint
const response = await fetch("/api/image-preview", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
imageData,
width: 192,
height: 180
})
})

if (response.ok) {
// Create blob URL for the preview image
const blob = await response.blob()
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = URL.createObjectURL(blob)
console.log("✅ Preview generated successfully")
} else {
const errorText = await response.text()
error.value = `Failed to generate preview: ${errorText}`
}
} catch (err) {
console.error("Error generating preview:", err)
error.value =
err instanceof Error ? err.message : "Failed to generate preview"
} finally {
isPreviewLoading.value = false
}
}

async function addToFont() {
if (!file.value) {
error.value = "Please select an image first"
return
}

isLoading.value = true
error.value = null

try {
// Convert file to base64
const reader = new FileReader()
const imageDataPromise = new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error("Failed to read file"))
})
reader.readAsDataURL(file.value)

const imageData = await imageDataPromise

// Call the image-to-font API endpoint
const response = (await $fetch("/api/image-to-font", {
method: "POST",
body: {
imageData,
width: 192,
height: 180,
characterMapping: Array.from({length: 160}, (_, i) => i),
fontData: {
metadata: fontStore.metadata,
characterCount: fontStore.characterCount,
characters: fontStore.characters
}
}
})) as ApiResponse

if (response.success && response.data) {
// Update the font store with the new data
fontStore.setFontData(response.data)

// Show success message
console.log(
`✅ Successfully added ${response.tilesAdded || 96} tiles to the font`
)
if (response.timing?.processingTimeMs) {
console.log(
`⏱️ Processing took ${response.timing.processingTimeMs.toFixed(2)}ms`
)
}
} else {
error.value = response.error || "Failed to process image"
}
} catch (err) {
console.error("Error processing image:", err)
error.value = err instanceof Error ? err.message : "Failed to process image"
} finally {
isLoading.value = false
}
}
</script>

<template>
<div class="flex flex-col xl:flex-row gap-4 items-center xl:items-start">
<div class="flex flex-col gap-4 w-full">
<div class="flex gap-4 items-center">
<UInput
icon="i-lucide-folder-open"
variant="soft"
type="file"
accept=".bmp,.png,.jpg,.jpeg,.webp,.gif,.tiff"
:ui="{
root: 'w-full',
base: 'file:mr-2 file:bg-primary-400 file:cursor-pointer file:text-inverted file:py-1 file:px-2 file:rounded-md',
leadingIcon: 'text-primary-400'
}"
@change="handleFileChange"
/>
</div>
<!-- Image previews -->
<div class="flex xl:flex-row gap-4">
<!-- Original image -->
<div class="flex flex-col gap-2 items-center w-full">
<h4 class="text-sm font-medium text-muted">Original Image</h4>
<img
v-if="imageUrl"
:src="imageUrl"
alt="Selected image preview"
class="w-sm rounded-md shadow-md"
/>
<div
v-else
class="w-sm aspect-square bg-neutral-200/10 border-neutral-200/10 border-dashed border-2 rounded-md shadow-md"
>
<div class="flex items-center justify-center h-full">
<UIcon name="i-lucide-image" class="text-neutral-400" />
</div>
</div>
</div>
<div class="flex flex-col gap-2 items-center w-full">
<h4 class="text-sm font-medium text-muted">Font Preview</h4>
<div
v-if="isPreviewLoading"
class="w-sm aspect-square bg-neutral-200/10 border-neutral-200/10 border-dashed border-2 rounded-md shadow-md flex items-center justify-center"
>
<UIcon
name="i-lucide-loader-2"
class="text-neutral-400 animate-spin"
/>
</div>
<img
v-else-if="previewUrl"
:src="previewUrl"
alt="Processed image preview"
class="w-sm rounded-md shadow-md bg-neutral-200/10"
style="image-rendering: pixelated"
/>
<div
v-else
class="w-sm aspect-square bg-neutral-200/10 border-neutral-200/10 border-dashed border-2 rounded-md shadow-md"
>
<div class="flex items-center justify-center h-full">
<UIcon name="i-lucide-eye" class="text-neutral-400" />
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<UButton
:disabled="!file || isLoading"
:loading="isLoading"
variant="solid"
class="justify-center"
@click="addToFont"
>
{{ isLoading ? "Processing..." : "Add to font" }}
</UButton>
<div v-if="error" class="text-red-500 text-sm">{{ error }}</div>
</div>
<div class="flex flex-col gap-2 text-text text-sm max-w-[50ch] w-fit">
<p>
Upload an image to be used as the font portion of the OSD. You can use your favorite image editor to create the font instead of painting pixel by pixel.
</p>
<p class="text-success">
To avoid scaling artifacts the font image should be 192x180 pixels. Each character is 12x18 pixels in size.
</p>
<p class="text-error">
This operation WILL replace the entirety of the font section, including custom graphics. It will not replace the logo portion of the OSD font.
</p>
<p class="text-error">
If you want to use custom graphics along with a font image, apply the font image first, then apply your custom graphics.
</p>
</div>
</div>
</template>