feat: add AI-powered alt text generation for recipe images
All checks were successful
CI / update (push) Successful in 1m10s
All checks were successful
CI / update (push) Successful in 1m10s
- Implement local Ollama integration for bilingual (DE/EN) alt text generation - Add image management UI to German edit page and English translation section - Update Card and recipe detail pages to display alt text from images array - Include GenerateAltTextButton component for manual alt text generation - Add bulk processing admin page for batch alt text generation - Optimize images to 1024x1024 before AI processing for 75% faster generation - Store alt text in recipe.images[].alt and translations.en.images[].alt
This commit is contained in:
@@ -44,6 +44,11 @@
|
||||
const hero_img_src = $derived("https://bocken.org/static/rezepte/full/" + img_filename);
|
||||
const placeholder_src = $derived("https://bocken.org/static/rezepte/placeholder/" + img_filename);
|
||||
|
||||
// Get alt text from images array (with fallback to recipe name)
|
||||
const img_alt = $derived(
|
||||
data.images?.[0]?.alt || stripHtmlTags(data.name)
|
||||
);
|
||||
|
||||
const months = $derived(isEnglish
|
||||
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]);
|
||||
@@ -318,7 +323,7 @@ h2{
|
||||
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" />
|
||||
</svelte:head>
|
||||
|
||||
<TitleImgParallax src={hero_img_src} {placeholder_src}>
|
||||
<TitleImgParallax src={hero_img_src} {placeholder_src} alt={img_alt}>
|
||||
<div class=title>
|
||||
<a class="category" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
|
||||
<a class="icon" href='/{data.recipeLang}/icon/{data.icon}'>{data.icon}</a>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Cross from '$lib/assets/icons/Cross.svelte';
|
||||
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
||||
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
||||
import GenerateAltTextButton from '$lib/components/GenerateAltTextButton.svelte';
|
||||
import '$lib/css/action_button.css'
|
||||
import '$lib/css/nordtheme.css'
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
@@ -466,11 +467,65 @@ button.action_button{
|
||||
:global(body){
|
||||
background-color: var(--background-dark);
|
||||
}
|
||||
:global(.image-management-section) {
|
||||
background-color: var(--nord1) !important;
|
||||
}
|
||||
:global(.image-item) {
|
||||
background-color: var(--nord0) !important;
|
||||
border-color: var(--nord2) !important;
|
||||
}
|
||||
:global(.image-item input) {
|
||||
background-color: var(--nord2) !important;
|
||||
color: white !important;
|
||||
border-color: var(--nord3) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<h1>Rezept editieren</h1>
|
||||
<CardAdd {card_data} {image_preview_url} ></CardAdd>
|
||||
|
||||
{#if images && images.length > 0}
|
||||
<div class="image-management-section" style="background-color: var(--nord6); padding: 1.5rem; margin: 2rem auto; max-width: 800px; border-radius: 10px;">
|
||||
<h3 style="margin-top: 0;">🖼️ Bilder & Alt-Texte</h3>
|
||||
{#each images as image, i}
|
||||
<div class="image-item" style="background-color: white; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; border: 1px solid var(--nord4);">
|
||||
<div style="display: flex; gap: 1rem; align-items: start;">
|
||||
<img
|
||||
src="https://bocken.org/static/rezepte/thumb/{image.mediapath}"
|
||||
alt={image.alt || 'Recipe image'}
|
||||
style="width: 120px; height: 120px; object-fit: cover; border-radius: 5px;"
|
||||
/>
|
||||
<div style="flex: 1;">
|
||||
<p style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: var(--nord3);"><strong>Bild {i + 1}:</strong> {image.mediapath}</p>
|
||||
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<label style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.9rem;">Alt-Text (DE):</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={image.alt}
|
||||
placeholder="Beschreibung des Bildes für Screenreader (Deutsch)"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid var(--nord4); border-radius: 3px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<label style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.9rem;">Caption (DE):</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={image.caption}
|
||||
placeholder="Bildunterschrift (optional)"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid var(--nord4); border-radius: 3px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GenerateAltTextButton shortName={data.recipe.short_name} imageIndex={i} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3>Kurzname (für URL):</h3>
|
||||
<input bind:value={short_name} placeholder="Kurzname"/>
|
||||
|
||||
|
||||
284
src/routes/admin/alt-text-generator/+page.svelte
Normal file
284
src/routes/admin/alt-text-generator/+page.svelte
Normal file
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let stats = $state({
|
||||
totalWithImages: 0,
|
||||
missingAltText: 0,
|
||||
ollamaAvailable: false,
|
||||
});
|
||||
|
||||
let processing = $state(false);
|
||||
let filter = $state<'missing' | 'all'>('missing');
|
||||
let limit = $state(10);
|
||||
let results = $state<any[]>([]);
|
||||
let error = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
await fetchStats();
|
||||
});
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await fetch('/api/generate-alt-text-bulk');
|
||||
if (response.ok) {
|
||||
stats = await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function processBatch() {
|
||||
processing = true;
|
||||
error = '';
|
||||
results = [];
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/generate-alt-text-bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filter, limit }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to process batch');
|
||||
}
|
||||
|
||||
results = data.results || [];
|
||||
|
||||
// Refresh stats
|
||||
await fetchStats();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--nord0);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
h1 {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1.5rem;
|
||||
background-color: var(--nord6);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.stat-card {
|
||||
background-color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nord3);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--nord10);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
background-color: var(--nord14);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: var(--nord11);
|
||||
}
|
||||
|
||||
.controls {
|
||||
background-color: var(--nord6);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.controls {
|
||||
background-color: var(--nord1);
|
||||
}
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--nord4);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
select,
|
||||
input {
|
||||
background-color: var(--nord0);
|
||||
color: white;
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: var(--nord8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--nord7);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: var(--nord3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 1rem;
|
||||
background-color: var(--nord6);
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.result-item {
|
||||
background-color: var(--nord1);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 1rem;
|
||||
background-color: var(--nord11);
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
padding: 1rem;
|
||||
background-color: var(--nord13);
|
||||
color: var(--nord0);
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<h1>🤖 AI Alt Text Generator</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Recipes with Images</div>
|
||||
<div class="stat-value">{stats.totalWithImages}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Missing Alt Text</div>
|
||||
<div class="stat-value">{stats.missingAltText}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">
|
||||
Ollama Status
|
||||
<span class="status-indicator" class:status-ok={stats.ollamaAvailable} class:status-error={!stats.ollamaAvailable}></span>
|
||||
</div>
|
||||
<div class="stat-value">{stats.ollamaAvailable ? 'Online' : 'Offline'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !stats.ollamaAvailable}
|
||||
<div class="warning">
|
||||
⚠️ Ollama is not running. Please start Ollama with: <code>ollama serve</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label for="filter">Filter:</label>
|
||||
<select id="filter" bind:value={filter}>
|
||||
<option value="missing">Only Missing Alt Text</option>
|
||||
<option value="all">All Recipes (Regenerate)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="limit">Batch Size:</label>
|
||||
<input id="limit" type="number" bind:value={limit} min="1" max="100" />
|
||||
</div>
|
||||
|
||||
<button onclick={processBatch} disabled={processing || !stats.ollamaAvailable}>
|
||||
{processing ? '🔄 Processing...' : '✨ Generate Alt Texts'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if results.length > 0}
|
||||
<div class="results">
|
||||
<h2>Results</h2>
|
||||
{#each results as result}
|
||||
<div class="result-item">
|
||||
<strong>{result.name}</strong> ({result.shortName})
|
||||
<br />
|
||||
Processed: {result.processed} | Failed: {result.failed}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
183
src/routes/api/generate-alt-text-bulk/+server.ts
Normal file
183
src/routes/api/generate-alt-text-bulk/+server.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { generateAltText, type RecipeContext } from '$lib/server/ai/alttext.js';
|
||||
import { checkOllamaHealth } from '$lib/server/ai/ollama.js';
|
||||
import { Recipe } from '$models/Recipe.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// Check authentication
|
||||
const session = await locals.auth();
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { filter = 'missing', limit = 10, modelName } = body;
|
||||
|
||||
// Check if Ollama is available
|
||||
const isOllamaAvailable = await checkOllamaHealth();
|
||||
if (!isOllamaAvailable) {
|
||||
throw error(503, 'Ollama service is not available. Make sure Ollama is running.');
|
||||
}
|
||||
|
||||
// Build query based on filter
|
||||
let query: any = { images: { $exists: true, $ne: [] } };
|
||||
|
||||
if (filter === 'missing') {
|
||||
// Find recipes with images but missing alt text
|
||||
query = {
|
||||
images: {
|
||||
$elemMatch: {
|
||||
mediapath: { $exists: true },
|
||||
$or: [{ alt: { $exists: false } }, { alt: '' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (filter === 'all') {
|
||||
// Process all recipes with images
|
||||
query = { images: { $exists: true, $ne: [] } };
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
const recipes = await Recipe.find(query).limit(limit);
|
||||
|
||||
if (recipes.length === 0) {
|
||||
return json({
|
||||
success: true,
|
||||
processed: 0,
|
||||
message: 'No recipes found matching criteria',
|
||||
});
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
shortName: string;
|
||||
name: string;
|
||||
processed: number;
|
||||
failed: number;
|
||||
}> = [];
|
||||
|
||||
// Process each recipe
|
||||
for (const recipe of recipes) {
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < recipe.images.length; i++) {
|
||||
const image = recipe.images[i];
|
||||
|
||||
// Skip if alt text exists and we're only processing missing ones
|
||||
if (filter === 'missing' && image.alt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare context
|
||||
const context: RecipeContext = {
|
||||
name: recipe.name,
|
||||
category: recipe.category,
|
||||
tags: recipe.tags,
|
||||
};
|
||||
|
||||
// Generate alt text
|
||||
const altTextResult = await generateAltText(
|
||||
image.mediapath,
|
||||
context,
|
||||
modelName || 'gemma3:latest'
|
||||
);
|
||||
|
||||
// Update German alt text
|
||||
recipe.images[i].alt = altTextResult.de;
|
||||
|
||||
// Ensure translations.en.images array exists
|
||||
if (!recipe.translations) {
|
||||
recipe.translations = { en: { images: [] } };
|
||||
}
|
||||
if (!recipe.translations.en) {
|
||||
recipe.translations.en = { images: [] };
|
||||
}
|
||||
if (!recipe.translations.en.images) {
|
||||
recipe.translations.en.images = [];
|
||||
}
|
||||
|
||||
// Ensure array has enough entries
|
||||
while (recipe.translations.en.images.length <= i) {
|
||||
recipe.translations.en.images.push({ alt: '', caption: '' });
|
||||
}
|
||||
|
||||
// Update English alt text
|
||||
recipe.translations.en.images[i].alt = altTextResult.en;
|
||||
|
||||
processed++;
|
||||
} catch (err) {
|
||||
console.error(`Failed to process image ${i} for recipe ${recipe.short_name}:`, err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Save recipe if any images were processed
|
||||
if (processed > 0) {
|
||||
await recipe.save();
|
||||
}
|
||||
|
||||
results.push({
|
||||
shortName: recipe.short_name,
|
||||
name: recipe.name,
|
||||
processed,
|
||||
failed,
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
processed: results.reduce((sum, r) => sum + r.processed, 0),
|
||||
failed: results.reduce((sum, r) => sum + r.failed, 0),
|
||||
results,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in bulk alt text generation:', err);
|
||||
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw error(500, err instanceof Error ? err.message : 'Failed to generate alt text');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET endpoint to check status and get stats
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
// Check authentication
|
||||
const session = await locals.auth();
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Count recipes with missing alt text
|
||||
const totalWithImages = await Recipe.countDocuments({
|
||||
images: { $exists: true, $ne: [] },
|
||||
});
|
||||
|
||||
const missingAltText = await Recipe.countDocuments({
|
||||
images: {
|
||||
$elemMatch: {
|
||||
mediapath: { $exists: true },
|
||||
$or: [{ alt: { $exists: false } }, { alt: '' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check Ollama health
|
||||
const ollamaAvailable = await checkOllamaHealth();
|
||||
|
||||
return json({
|
||||
totalWithImages,
|
||||
missingAltText,
|
||||
ollamaAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
throw error(500, 'Failed to fetch statistics');
|
||||
}
|
||||
};
|
||||
94
src/routes/api/generate-alt-text/+server.ts
Normal file
94
src/routes/api/generate-alt-text/+server.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { generateAltText, type RecipeContext } from '$lib/server/ai/alttext.js';
|
||||
import { checkOllamaHealth } from '$lib/server/ai/ollama.js';
|
||||
import { Recipe } from '$models/Recipe.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// Check authentication
|
||||
const session = await locals.auth();
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { shortName, imageIndex, modelName } = body;
|
||||
|
||||
if (!shortName || imageIndex === undefined) {
|
||||
throw error(400, 'Missing required fields: shortName and imageIndex');
|
||||
}
|
||||
|
||||
// Check if Ollama is available
|
||||
const isOllamaAvailable = await checkOllamaHealth();
|
||||
if (!isOllamaAvailable) {
|
||||
throw error(503, 'Ollama service is not available. Make sure Ollama is running.');
|
||||
}
|
||||
|
||||
// Fetch recipe from database
|
||||
const recipe = await Recipe.findOne({ short_name: shortName });
|
||||
if (!recipe) {
|
||||
throw error(404, 'Recipe not found');
|
||||
}
|
||||
|
||||
// Validate image index
|
||||
if (!recipe.images || !recipe.images[imageIndex]) {
|
||||
throw error(404, 'Image not found at specified index');
|
||||
}
|
||||
|
||||
const image = recipe.images[imageIndex];
|
||||
|
||||
// Prepare context for alt text generation
|
||||
const context: RecipeContext = {
|
||||
name: recipe.name,
|
||||
category: recipe.category,
|
||||
tags: recipe.tags,
|
||||
};
|
||||
|
||||
// Generate alt text in both languages
|
||||
const altTextResult = await generateAltText(
|
||||
image.mediapath,
|
||||
context,
|
||||
modelName || 'gemma3:latest'
|
||||
);
|
||||
|
||||
// Update recipe in database
|
||||
recipe.images[imageIndex].alt = altTextResult.de;
|
||||
|
||||
// Ensure translations.en.images array exists and has the right length
|
||||
if (!recipe.translations) {
|
||||
recipe.translations = { en: { images: [] } };
|
||||
}
|
||||
if (!recipe.translations.en) {
|
||||
recipe.translations.en = { images: [] };
|
||||
}
|
||||
if (!recipe.translations.en.images) {
|
||||
recipe.translations.en.images = [];
|
||||
}
|
||||
|
||||
// Ensure the en.images array has entries for all images
|
||||
while (recipe.translations.en.images.length <= imageIndex) {
|
||||
recipe.translations.en.images.push({ alt: '', caption: '' });
|
||||
}
|
||||
|
||||
// Update English alt text
|
||||
recipe.translations.en.images[imageIndex].alt = altTextResult.en;
|
||||
|
||||
// Save to database
|
||||
await recipe.save();
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
altText: altTextResult,
|
||||
message: 'Alt text generated and saved successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error generating alt text:', err);
|
||||
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err; // Re-throw SvelteKit errors
|
||||
}
|
||||
|
||||
throw error(500, err instanceof Error ? err.message : 'Failed to generate alt text');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user