Spaces:
Paused
Paused
| document.addEventListener('DOMContentLoaded', () => { | |
| // DOM Elements | |
| const uploadForm = document.getElementById('upload-form'); | |
| const promptInput = document.getElementById('prompt-input'); | |
| const beforeInput = document.getElementById('video-before'); | |
| const afterInput = document.getElementById('video-after'); | |
| const beforeZone = document.getElementById('upload-before-zone'); | |
| const afterZone = document.getElementById('upload-after-zone'); | |
| const beforeFileName = document.getElementById('before-file-name'); | |
| const afterFileName = document.getElementById('after-file-name'); | |
| const speedModeSelect = document.getElementById('speed-mode-select'); | |
| const fpsSelect = document.getElementById('fps-select'); | |
| const geminiKeyInput = document.getElementById('gemini-key-input'); | |
| const uploadBtn = document.getElementById('upload-btn'); | |
| const startBtn = document.getElementById('start-btn'); | |
| const resetBtn = document.getElementById('reset-btn'); | |
| const processingView = document.getElementById('processing-view'); | |
| const progressStatus = document.getElementById('progress-status'); | |
| const progressFill = document.getElementById('progress-fill'); | |
| const progressPct = document.getElementById('progress-pct'); | |
| const backendStatusText = document.getElementById('backend-status-text'); | |
| const resultsPlaceholder = document.getElementById('results-placeholder'); | |
| const resultsView = document.getElementById('results-view'); | |
| const outVideoBefore = document.getElementById('out-video-before'); | |
| const outVideoAfter = document.getElementById('out-video-after'); | |
| const assetCardsContainer = document.getElementById('asset-cards-container'); | |
| let pollInterval = null; | |
| // --- Load saved Gemini API Key --- | |
| const savedKey = localStorage.getItem('gemini_api_key'); | |
| if (savedKey) { | |
| geminiKeyInput.value = savedKey; | |
| } | |
| // --- File Drag & Drop Listeners --- | |
| setupDragAndDrop(beforeZone, beforeInput, beforeFileName, "Before Renting Video Selected"); | |
| setupDragAndDrop(afterZone, afterInput, afterFileName, "After Renting Video Selected"); | |
| function setupDragAndDrop(zone, input, display, successMsg) { | |
| zone.addEventListener('click', () => input.click()); | |
| input.addEventListener('change', (e) => { | |
| if (input.files.length > 0) { | |
| display.textContent = `${input.files[0].name} (${(input.files[0].size / 1e6).toFixed(1)} MB)`; | |
| zone.style.borderColor = 'var(--success)'; | |
| } | |
| }); | |
| zone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| zone.classList.add('drag-over'); | |
| }); | |
| zone.addEventListener('dragleave', () => { | |
| zone.classList.remove('drag-over'); | |
| }); | |
| zone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| zone.classList.remove('drag-over'); | |
| if (e.dataTransfer.files.length > 0) { | |
| input.files = e.dataTransfer.files; | |
| display.textContent = `${input.files[0].name} (${(input.files[0].size / 1e6).toFixed(1)} MB)`; | |
| zone.style.borderColor = 'var(--success)'; | |
| } | |
| }); | |
| } | |
| // --- Upload Videos --- | |
| uploadForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| if (beforeInput.files.length === 0 || afterInput.files.length === 0) { | |
| alert('Please select both "Before" and "After" renting videos first.'); | |
| return; | |
| } | |
| const prompt = promptInput.value.trim(); | |
| if (!prompt) { | |
| alert('Please define target inspection items prompt.'); | |
| return; | |
| } | |
| const formData = new FormData(); | |
| formData.append('video_before', beforeInput.files[0]); | |
| formData.append('video_after', afterInput.files[0]); | |
| formData.append('prompt', prompt); | |
| try { | |
| uploadBtn.disabled = true; | |
| uploadBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Uploading...'; | |
| backendStatusText.textContent = "Uploading videos..."; | |
| const response = await fetch('/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(data.error || 'Upload failed.'); | |
| } | |
| uploadBtn.innerHTML = '<i class="fa-solid fa-check"></i> Uploaded!'; | |
| uploadBtn.style.background = 'var(--success)'; | |
| // Enable Start button | |
| startBtn.removeAttribute('disabled'); | |
| startBtn.classList.remove('btn-disabled'); | |
| backendStatusText.textContent = "Ready to analyze"; | |
| alert('Videos uploaded successfully. You can now start the evaluation.'); | |
| } catch (error) { | |
| console.error(error); | |
| alert(`Error: ${error.message}`); | |
| uploadBtn.disabled = false; | |
| uploadBtn.innerHTML = '<i class="fa-solid fa-arrow-up-from-bracket"></i> Upload Videos'; | |
| backendStatusText.textContent = "Upload failed"; | |
| } | |
| }); | |
| // --- Start Tracking / Evaluation --- | |
| startBtn.addEventListener('click', async () => { | |
| const prompt = promptInput.value.trim(); | |
| const geminiKey = geminiKeyInput.value.trim(); | |
| const speedMode = speedModeSelect.value; | |
| const fps = fpsSelect.value; | |
| // Securely cache Gemini API key in browser | |
| if (geminiKey) { | |
| localStorage.setItem('gemini_api_key', geminiKey); | |
| } else { | |
| localStorage.removeItem('gemini_api_key'); | |
| } | |
| try { | |
| startBtn.disabled = true; | |
| startBtn.classList.add('btn-disabled'); | |
| processingView.classList.remove('hidden'); | |
| backendStatusText.textContent = "Running evaluation..."; | |
| const response = await fetch('/process', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| prompt: prompt, | |
| gemini_api_key: geminiKey, | |
| speed_mode: speedMode, | |
| fps: parseFloat(fps) | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(data.error || 'Failed to start process.'); | |
| } | |
| // Start polling status | |
| startStatusPolling(); | |
| } catch (error) { | |
| console.error(error); | |
| alert(`Error: ${error.message}`); | |
| processingView.classList.add('hidden'); | |
| startBtn.disabled = false; | |
| startBtn.classList.remove('btn-disabled'); | |
| backendStatusText.textContent = "Error occurred"; | |
| } | |
| }); | |
| // --- Reset --- | |
| resetBtn.addEventListener('click', async () => { | |
| if (confirm('Are you sure you want to reset and clear all data?')) { | |
| try { | |
| await fetch('/reset', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ force: true }) | |
| }); | |
| // Clear intervals | |
| clearInterval(pollInterval); | |
| pollInterval = null; | |
| // Reset inputs and displays | |
| uploadForm.reset(); | |
| beforeInput.value = ''; | |
| afterInput.value = ''; | |
| beforeZone.style.borderColor = 'var(--border-color)'; | |
| afterZone.style.borderColor = 'var(--border-color)'; | |
| beforeFileName.textContent = 'Drag & drop or click to upload (.mp4)'; | |
| afterFileName.textContent = 'Drag & drop or click to upload (.mp4)'; | |
| // Retain Gemini Key | |
| const savedKey = localStorage.getItem('gemini_api_key'); | |
| if (savedKey) { | |
| geminiKeyInput.value = savedKey; | |
| } | |
| uploadBtn.disabled = false; | |
| uploadBtn.innerHTML = '<i class="fa-solid fa-arrow-up-from-bracket"></i> Upload Videos'; | |
| uploadBtn.style.background = 'var(--primary)'; | |
| startBtn.disabled = true; | |
| startBtn.classList.add('btn-disabled'); | |
| processingView.classList.add('hidden'); | |
| resultsView.classList.add('hidden'); | |
| resultsPlaceholder.classList.remove('hidden'); | |
| outVideoBefore.src = ''; | |
| outVideoAfter.src = ''; | |
| assetCardsContainer.innerHTML = ''; | |
| backendStatusText.textContent = "System Ready"; | |
| } catch (error) { | |
| console.error(error); | |
| alert('Reset failed.'); | |
| } | |
| } | |
| }); | |
| // --- Status Polling --- | |
| function startStatusPolling() { | |
| if (pollInterval) clearInterval(pollInterval); | |
| pollInterval = setInterval(async () => { | |
| try { | |
| const response = await fetch('/status'); | |
| const data = await response.json(); | |
| if (data.error) { | |
| clearInterval(pollInterval); | |
| alert(`Evaluation Error: ${data.error}`); | |
| processingView.classList.add('hidden'); | |
| startBtn.disabled = false; | |
| startBtn.classList.remove('btn-disabled'); | |
| backendStatusText.textContent = "Analysis Failed"; | |
| return; | |
| } | |
| // Update progress overlay | |
| progressStatus.textContent = data.status; | |
| progressFill.style.width = `${data.percent}%`; | |
| progressPct.textContent = `${data.percent}%`; | |
| if (!data.running && data.percent === 100 && data.result) { | |
| clearInterval(pollInterval); | |
| pollInterval = null; | |
| displayResults(data.result); | |
| } | |
| } catch (error) { | |
| console.error('Polling error:', error); | |
| } | |
| }, 1500); | |
| } | |
| // --- Display Results --- | |
| function displayResults(result) { | |
| // Hide processing and placeholder | |
| processingView.classList.add('hidden'); | |
| resultsPlaceholder.classList.add('hidden'); | |
| resultsView.classList.remove('hidden'); | |
| backendStatusText.textContent = "Evaluation Complete"; | |
| // Render Summary Banner | |
| const summaryBanner = document.getElementById('summary-banner'); | |
| if (summaryBanner && result.summary) { | |
| const s = result.summary; | |
| // Format elapsed time (e.g. "45.2s" or "1m 15s") | |
| let timeStr = ""; | |
| if (s.elapsed_time < 60) { | |
| timeStr = `${s.elapsed_time}s`; | |
| } else { | |
| const mins = Math.floor(s.elapsed_time / 60); | |
| const secs = Math.round(s.elapsed_time % 60); | |
| timeStr = `${mins}m ${secs}s`; | |
| } | |
| // Compensation judgment logic: if any item is Missing or Damaged, tenant must pay compensation | |
| const compensationRequired = (s.statuses.Missing || 0) > 0 || (s.statuses["Potential Damage / Altered"] || 0) > 0; | |
| const verdictTitle = compensationRequired ? "Tenant Must Pay Compensation" : "No Compensation Required"; | |
| const verdictColor = compensationRequired ? "var(--danger)" : "var(--success)"; | |
| const verdictIcon = compensationRequired ? "fa-solid fa-circle-exclamation" : "fa-solid fa-circle-check"; | |
| const verdictDesc = compensationRequired | |
| ? "Damage or missing assets detected. Tenant is liable for compensation." | |
| : "All tracked assets are intact. No compensation required."; | |
| // Create unique list of all discovered item labels | |
| const uniqueLabels = Array.from(new Set([...s.before_items, ...s.after_items])); | |
| const labelsHtml = uniqueLabels.length > 0 | |
| ? uniqueLabels.map(lbl => `<span class="discovered-tag">${lbl}</span>`).join('') | |
| : '<span class="text-secondary">None</span>'; | |
| summaryBanner.innerHTML = ` | |
| <div class="summary-card highlight" style="grid-column: span 2; border-color: ${verdictColor}; background: linear-gradient(135deg, rgba(20, 24, 33, 0.85), ${compensationRequired ? 'rgba(255, 82, 82, 0.05)' : 'rgba(0, 219, 139, 0.05)'});"> | |
| <span class="summary-card-val" style="color: ${verdictColor}; font-size: 1.35rem; display: flex; align-items: center; gap: 8px;"> | |
| <i class="${verdictIcon}"></i> ${verdictTitle} | |
| </span> | |
| <span class="summary-card-label" style="margin-top: 5px;">AI Final Verdict</span> | |
| <span style="font-size: 0.82rem; color: var(--text-secondary); margin-top: 4px; line-height: 1.3;">${verdictDesc}</span> | |
| </div> | |
| <div class="summary-card"> | |
| <span class="summary-card-val time">${timeStr}</span> | |
| <span class="summary-card-label">Time Taken</span> | |
| </div> | |
| <div class="summary-card"> | |
| <span class="summary-card-val before">${s.total_before}</span> | |
| <span class="summary-card-label">Assets (Before)</span> | |
| </div> | |
| <div class="summary-card"> | |
| <span class="summary-card-val after">${s.total_after}</span> | |
| <span class="summary-card-label">Assets (After)</span> | |
| </div> | |
| <div class="summary-card"> | |
| <span class="summary-card-val missing">${s.statuses.Missing || 0}</span> | |
| <span class="summary-card-label">Missing Items</span> | |
| </div> | |
| <div class="summary-card"> | |
| <span class="summary-card-val" style="color: var(--warning);">${s.statuses["Potential Damage / Altered"] || 0}</span> | |
| <span class="summary-card-label">Damaged Items</span> | |
| </div> | |
| <div class="summary-card"> | |
| <span class="summary-card-val" style="color: var(--success);">${(s.statuses.Intact || 0) + (s.statuses["Fair / Minor Change"] || 0)}</span> | |
| <span class="summary-card-label">Intact / Fair</span> | |
| </div> | |
| <div class="discovered-labels-list"> | |
| <strong><i class="fa-solid fa-list-check"></i> Discovered Items:</strong> | |
| ${labelsHtml} | |
| </div> | |
| `; | |
| } | |
| // Set video sources (add timestamp parameter to bypass browser cache) | |
| const t = new Date().getTime(); | |
| outVideoBefore.src = `${result.annotated_before}?t=${t}`; | |
| outVideoAfter.src = `${result.annotated_after}?t=${t}`; | |
| outVideoBefore.load(); | |
| outVideoAfter.load(); | |
| // Render Asset Cards | |
| assetCardsContainer.innerHTML = ''; | |
| result.report.forEach(item => { | |
| const card = document.createElement('div'); | |
| card.className = 'asset-card'; | |
| // Top Row of card: Info, Crops, and Status | |
| const mainDiv = document.createElement('div'); | |
| mainDiv.className = 'asset-card-main'; | |
| // 1. Info section | |
| const infoDiv = document.createElement('div'); | |
| infoDiv.className = 'asset-info'; | |
| const labelH3 = document.createElement('h3'); | |
| labelH3.className = 'asset-label'; | |
| labelH3.textContent = item.label; | |
| const scoreSpan = document.createElement('span'); | |
| scoreSpan.className = 'asset-score'; | |
| scoreSpan.textContent = item.before_crop && item.after_crop ? `Similarity: ${item.similarity}%` : ''; | |
| infoDiv.appendChild(labelH3); | |
| infoDiv.appendChild(scoreSpan); | |
| mainDiv.appendChild(infoDiv); | |
| // 2. Before Crop | |
| const beforeCropDiv = createCropView(item.before_crop, "Before"); | |
| mainDiv.appendChild(beforeCropDiv); | |
| // 3. After Crop | |
| const afterCropDiv = createCropView(item.after_crop, "After"); | |
| mainDiv.appendChild(afterCropDiv); | |
| // 4. Status Badge | |
| const badgeDiv = document.createElement('div'); | |
| const badge = document.createElement('span'); | |
| badge.className = `status-badge ${getBadgeClass(item.status)}`; | |
| badge.textContent = item.status; | |
| badgeDiv.appendChild(badge); | |
| mainDiv.appendChild(badgeDiv); | |
| card.appendChild(mainDiv); | |
| // Bottom Row of card: Explanation (AI-generated or fallback) | |
| if (item.explanation) { | |
| const explanationDiv = document.createElement('div'); | |
| explanationDiv.className = 'asset-explanation'; | |
| const icon = document.createElement('i'); | |
| // Use robot icon if Gemini was used, sparkles icon if local CV fallback was used | |
| icon.className = item.ai_evaluated ? 'fa-solid fa-robot explanation-icon' : 'fa-solid fa-wand-magic-sparkles explanation-icon'; | |
| const textSpan = document.createElement('span'); | |
| textSpan.textContent = item.explanation; | |
| explanationDiv.appendChild(icon); | |
| explanationDiv.appendChild(textSpan); | |
| card.appendChild(explanationDiv); | |
| } | |
| assetCardsContainer.appendChild(card); | |
| }); | |
| } | |
| function createCropView(src, label) { | |
| const container = document.createElement('div'); | |
| container.className = 'crop-img-container'; | |
| const title = document.createElement('span'); | |
| title.textContent = label; | |
| container.appendChild(title); | |
| const frame = document.createElement('div'); | |
| frame.className = 'crop-frame'; | |
| if (src) { | |
| const img = document.createElement('img'); | |
| // Add cache-busting timestamp | |
| img.src = `${src}?t=${new Date().getTime()}`; | |
| img.alt = label; | |
| frame.appendChild(img); | |
| } else { | |
| const icon = document.createElement('i'); | |
| icon.className = 'fa-solid fa-circle-question crop-missing-icon'; | |
| frame.appendChild(icon); | |
| } | |
| container.appendChild(frame); | |
| return container; | |
| } | |
| function getBadgeClass(status) { | |
| switch (status) { | |
| case 'Intact': | |
| return 'badge-intact'; | |
| case 'Fair / Minor Change': | |
| return 'badge-fair'; | |
| case 'Potential Damage / Altered': | |
| return 'badge-damaged'; | |
| case 'Missing': | |
| return 'badge-missing'; | |
| case 'New Item': | |
| return 'badge-new'; | |
| default: | |
| return ''; | |
| } | |
| } | |
| }); | |