Pokkhrong Rasee
Deploy Image Captioning Web App with FastAPI and Docker to Hugging Face Space
d1d7665
Raw
History Blame Contribute Delete
9.75 kB
/**
* app.js — Robust Frontend logic for Dual Model Image Captioning Web App
*/
// Helper to get DOM elements safely
const getElem = (id) => document.getElementById(id);
let currentFile = null;
// ============================================================
// CHECK MODEL STATUS
// ============================================================
async function checkModelStatus() {
const modelBadge = getElem('model-badge');
if (!modelBadge) return;
try {
const res = await fetch('/api/status');
const data = await res.json();
modelBadge.classList.remove('hidden');
if (data.status === 'ready') {
const hasFt = data.has_finetuned ? 'Fine-Tuned & Pretrained Available' : 'Pretrained Available';
modelBadge.textContent = `⚡ Status: Ready (${hasFt}) | Device: ${data.device}`;
modelBadge.className = 'model-badge ready';
} else {
modelBadge.textContent = '⏳ Models loading...';
modelBadge.className = 'model-badge loading';
}
} catch (e) {
console.error('Status check failed:', e);
}
}
// Get selected model mode
function getSelectedModelChoice() {
const radio = document.querySelector('input[name="model_choice"]:checked');
return radio ? radio.value : 'both';
}
// Get selected language choice
function getSelectedLangChoice() {
const radio = document.querySelector('input[name="lang_choice"]:checked');
return radio ? radio.value : 'both';
}
// ============================================================
// UI VISIBILITY HELPERS
// ============================================================
function showResultArea() {
const dropZone = getElem('drop-zone');
const resultArea = getElem('result-area');
const resultsGrid = getElem('results-grid');
const loadingSpinner = getElem('loading-spinner');
if (dropZone) dropZone.classList.add('hidden');
if (resultArea) resultArea.classList.remove('hidden');
if (resultsGrid) resultsGrid.innerHTML = '';
if (loadingSpinner) loadingSpinner.classList.add('active');
}
function showDropZone() {
const dropZone = getElem('drop-zone');
const resultArea = getElem('result-area');
const fileInput = getElem('file-input');
if (resultArea) resultArea.classList.add('hidden');
if (dropZone) dropZone.classList.remove('hidden');
if (fileInput) fileInput.value = '';
currentFile = null;
hideError();
}
function showError(message) {
const errorMsg = getElem('error-msg');
if (errorMsg) {
errorMsg.textContent = message;
errorMsg.classList.remove('hidden');
}
}
function hideError() {
const errorMsg = getElem('error-msg');
if (errorMsg) errorMsg.classList.add('hidden');
}
// ============================================================
// HANDLE FILE
// ============================================================
function handleFile(file) {
if (!file.type.startsWith('image/')) {
showError('Please upload an image file (JPEG, PNG, GIF, WebP).');
return;
}
currentFile = file;
hideError();
const previewImg = getElem('preview-img');
const reader = new FileReader();
reader.onload = (e) => {
if (previewImg) previewImg.src = e.target.result;
showResultArea();
generateCaption(file);
};
reader.readAsDataURL(file);
}
// ============================================================
// GENERATE CAPTION (API CALL)
// ============================================================
async function generateCaption(file) {
const loadingSpinner = getElem('loading-spinner');
const resultsGrid = getElem('results-grid');
const modelChoice = getSelectedModelChoice();
const langChoice = getSelectedLangChoice();
const formData = new FormData();
formData.append('file', file);
formData.append('model_choice', modelChoice);
formData.append('language', langChoice);
if (loadingSpinner) loadingSpinner.classList.add('active');
if (resultsGrid) resultsGrid.innerHTML = '';
try {
const res = await fetch('/api/predict', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Inference failed');
}
if (loadingSpinner) loadingSpinner.classList.remove('active');
renderResults(data.results, langChoice);
} catch (err) {
if (loadingSpinner) loadingSpinner.classList.remove('active');
showError(err.message);
console.error('Prediction error:', err);
}
}
// ============================================================
// RENDER RESULTS GRID
// ============================================================
function renderResults(results, langChoice = 'both') {
let resultsGrid = getElem('results-grid');
// Fallback: if results-grid is missing, create it dynamically inside result-area
if (!resultsGrid) {
const resultArea = getElem('result-area');
if (resultArea) {
resultsGrid = document.createElement('div');
resultsGrid.id = 'results-grid';
resultsGrid.className = 'results-grid';
const clearBtn = getElem('clear-btn');
if (clearBtn) {
resultArea.insertBefore(resultsGrid, clearBtn);
} else {
resultArea.appendChild(resultsGrid);
}
}
}
if (!resultsGrid) return;
resultsGrid.innerHTML = '';
if (!results) return;
const keys = Object.keys(results);
const isGrid = keys.length > 1;
if (isGrid) {
resultsGrid.classList.add('dual-grid');
} else {
resultsGrid.classList.remove('dual-grid');
}
keys.forEach(key => {
const item = results[key];
const isFineTuned = key === 'fine-tuned';
const card = document.createElement('div');
card.className = `caption-card ${isFineTuned ? 'fine-tuned-card' : 'pretrained-card'}`;
const captions = item.captions || [];
const captionsTh = item.captions_th || [];
const captionsHtml = captions
.map((c, i) => {
const thText = captionsTh[i] || '';
let contentHtml = '';
if (langChoice === 'en') {
contentHtml = `<span class="text text-en">${c}</span>`;
} else if (langChoice === 'th') {
contentHtml = `<span class="text text-th">${thText || c}</span>`;
} else {
// Both
contentHtml = `
<div class="text-group">
<span class="text text-en">${c}</span>
${thText ? `<span class="text text-th">🇹🇭 ${thText}</span>` : ''}
</div>
`;
}
return `
<li class="caption-item">
<span class="num">${i + 1}</span>
${contentHtml}
</li>
`;
})
.join('');
card.innerHTML = `
<div class="caption-header">
<span class="model-title">${item.label}</span>
<span class="tag ${isFineTuned ? 'tag-ft' : 'tag-pre'}">${isFineTuned ? '🎯 Fine-Tuned' : '🌐 Pretrained'}</span>
</div>
<ol class="captions-list">
${captionsHtml}
</ol>
`;
resultsGrid.appendChild(card);
});
}
// ============================================================
// INITIALIZATION ON DOM READY
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
checkModelStatus();
const dropZone = getElem('drop-zone');
const fileInput = getElem('file-input');
const clearBtn = getElem('clear-btn');
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
dropZone.addEventListener('click', () => {
if (fileInput) fileInput.click();
});
}
if (fileInput) {
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
}
if (clearBtn) {
clearBtn.addEventListener('click', showDropZone);
}
// Re-trigger generation if user switches model choice or language radio buttons while viewing results
const retrigger = () => {
const resultArea = getElem('result-area');
if (currentFile && resultArea && !resultArea.classList.contains('hidden')) {
generateCaption(currentFile);
}
};
document.querySelectorAll('input[name="model_choice"]').forEach(radio => {
radio.addEventListener('change', retrigger);
});
document.querySelectorAll('input[name="lang_choice"]').forEach(radio => {
radio.addEventListener('change', retrigger);
});
});
// Global drag behaviors
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', (e) => e.preventDefault());