Files
apuc 04a7b31a75 Веб-приложение диагностики по видео (ФПГ GigaDoc)
- Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ
- Понятный диагноз с сохранением в SQLite и share-ссылками
- Экспорт PDF/DOCX, скачивание записанного видео
- Камерное сканирование на мобильных (canvas + MediaRecorder)
- Секреты вынесены в переменные окружения
2026-09-07 19:01:06 +03:00

770 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO -->
<title>Диагностика по видео — оценка здоровья по лицу | med.my-dpr.ru</title>
<meta name="description" content="Загрузите видео с лицом и получите оценку состояния здоровья: пульс, давление, ИМТ, холестерин, уровень стресса и другие показатели за 30 секунд.">
<meta name="keywords" content="диагностика по видео, оценка здоровья, анализ лица, пульс, давление, ИМТ, холестерин, ФПГ, фотоплетизмография">
<meta name="author" content="med.my-dpr.ru">
<meta name="robots" content="index, follow">
<meta name="theme-color" content="#0014FC">
<!-- Open Graph (Facebook, VK, Telegram, WhatsApp) -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Диагностика по видео">
<meta property="og:title" content="Диагностика по видео — оценка здоровья по лицу">
<meta property="og:description" content="Загрузите видео с лицом и получите оценку состояния здоровья: пульс, давление, ИМТ, холестерин, уровень стресса и другие показатели за 30 секунд.">
<meta property="og:url" content="https://med.my-dpr.ru/">
<meta property="og:image" content="https://med.my-dpr.ru/static/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Диагностика по видео — оценка здоровья по лицу">
<meta property="og:locale" content="ru_RU">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Диагностика по видео — оценка здоровья по лицу">
<meta name="twitter:description" content="Загрузите видео с лицом и получите оценку состояния здоровья: пульс, давление, ИМТ, холестерин, уровень стресса и другие показатели за 30 секунд.">
<meta name="twitter:image" content="https://med.my-dpr.ru/static/og-image.png">
<!-- Favicon (эмодзи-иконка) -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🩺</text></svg>">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #061A40 0%, #0B4F9C 50%, #2D9CDB 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.card {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
margin-bottom: 20px;
}
h1 {
color: #fff;
text-align: center;
margin-bottom: 20px;
font-size: 28px;
}
h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.subtitle {
color: rgba(255,255,255,0.9);
text-align: center;
margin-bottom: 25px;
font-size: 15px;
}
.upload-area {
border: 2px dashed #ccc;
border-radius: 12px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
}
.upload-area:hover, .upload-area.dragover {
border-color: #0014FC;
background: #f0f2ff;
}
.upload-area .icon { font-size: 48px; margin-bottom: 10px; }
.upload-area p { color: #666; margin: 5px 0; }
.upload-area .hint { font-size: 13px; color: #999; }
input[type="file"] { display: none; }
.btn {
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
color: #fff;
border: none;
padding: 12px 30px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
margin-top: 15px;
}
.btn:hover { filter: brightness(1.15); }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.btn-secondary {
background: #eee;
color: #333;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.3s;
}
.btn-secondary:hover { background: #ddd; }
/* Кнопки скачивания */
.download-buttons {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.btn-download {
flex: 1;
min-width: 140px;
text-align: center;
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
color: #fff;
border: none;
padding: 12px 20px;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
transition: background 0.3s;
text-decoration: none;
display: inline-block;
}
.btn-download:hover { filter: brightness(1.15); }
.btn-download:active { transform: scale(0.98); }
/* Сканирование с камеры */
.scan-section { display: none; margin-bottom: 15px; }
.scan-desc { color: #666; font-size: 14px; margin-bottom: 15px; }
.scan-btn {
width: 100%;
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
color: #fff;
border: none;
padding: 16px 20px;
border-radius: 12px;
font-size: 17px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 52px;
box-shadow: 0 4px 15px rgba(102,126,234,0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.scan-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(102,126,234,0.5); }
.scan-btn:active { transform: scale(0.98); }
.scan-btn:disabled { background: #ccc; box-shadow: none; cursor: not-allowed; }
.scan-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(10, 10, 30, 0.92);
z-index: 1000;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 20px;
}
.scan-modal.open { display: flex; }
.scan-video-wrap {
width: 280px;
height: 280px;
border-radius: 50%;
overflow: hidden;
border: 4px solid #0014FC;
background: #000;
box-shadow: 0 0 40px rgba(102,126,234,0.5);
position: relative;
}
.scan-video {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.scan-progress { color: #fff; margin-top: 20px; font-size: 16px; text-align: center; }
.scan-progress-bar {
width: 280px;
height: 8px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
margin-top: 10px;
overflow: hidden;
}
.scan-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #061A40, #0B4F9C, #2D9CDB);
transition: width 0.2s;
border-radius: 4px;
}
.scan-cancel {
margin-top: 20px;
background: rgba(255,255,255,0.12);
color: #fff;
border: 1px solid rgba(255,255,255,0.35);
padding: 12px 30px;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
transition: background 0.3s;
}
.scan-cancel:hover { background: rgba(255,255,255,0.2); }
.scan-hint { color: rgba(255,255,255,0.7); margin-top: 15px; font-size: 13px; text-align: center; max-width: 300px; }
/* Прогресс */
.progress-container {
display: none;
margin-top: 20px;
}
.progress-bar {
width: 100%;
height: 20px;
background: #eee;
border-radius: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #061A40, #0B4F9C, #2D9CDB);
transition: width 0.3s;
border-radius: 10px;
}
.progress-text {
text-align: center;
margin-top: 10px;
color: #666;
font-size: 14px;
}
/* Результат */
.result { display: none; }
.summary-box {
background: #f0f4ff;
border-left: 4px solid #0014FC;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 16px;
line-height: 1.6;
}
.metrics-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.metrics-table th, .metrics-table td {
padding: 10px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
.metrics-table th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
.metrics-table tr:hover { background: #fafafa; }
.status-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.status-ok { background: #d4edda; color: #155724; }
.status-warning { background: #fff3cd; color: #856404; }
.status-danger { background: #f8d7da; color: #721c24; }
.status-info { background: #d1ecf1; color: #0c5460; }
.warnings-box {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.warnings-box h3 { color: #856404; margin-bottom: 10px; font-size: 16px; }
.warnings-box ul { padding-left: 20px; }
.warnings-box li { margin: 5px 0; color: #856404; }
.advice-box {
background: #d4edda;
border-left: 4px solid #28a745;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.advice-box h3 { color: #155724; margin-bottom: 10px; font-size: 16px; }
.advice-box ul { padding-left: 20px; }
.advice-box li { margin: 5px 0; color: #155724; }
.disclaimer {
background: #f8f9fa;
border-radius: 8px;
padding: 12px 15px;
font-size: 13px;
color: #888;
margin-top: 20px;
}
.error-box {
background: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 20px;
color: #721c24;
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { display: none; }
/* Заголовок результата */
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
gap: 10px;
}
/* ===== Мобильная версия ===== */
@media (max-width: 600px) {
body { padding: 12px; }
h1 { font-size: 22px; margin-bottom: 12px; }
.subtitle { font-size: 14px; margin-bottom: 18px; }
.card { padding: 18px; border-radius: 14px; margin-bottom: 14px; }
h2 { font-size: 18px; }
.upload-area { padding: 25px 15px; }
.upload-area .icon { font-size: 40px; }
.btn, .btn-secondary { width: 100%; padding: 14px 20px; font-size: 16px; min-height: 48px; }
.download-buttons { flex-direction: column; }
.btn-download { width: 100%; min-width: 0; padding: 14px 20px; font-size: 16px; min-height: 48px; }
.result-header { flex-direction: column; align-items: stretch; }
.result-header h2 { text-align: center; }
.metrics-table thead { display: none; }
.metrics-table, .metrics-table tbody, .metrics-table tr, .metrics-table td { display: block; width: 100%; }
.metrics-table tr { border: 1px solid #eee; border-radius: 10px; margin-bottom: 10px; padding: 10px 12px; }
.metrics-table td { border: none; padding: 5px 0; }
.metrics-table td:first-child { font-weight: 600; color: #555; }
.summary-box, .warnings-box, .advice-box { padding: 12px 14px; font-size: 15px; }
.progress-text { font-size: 13px; }
}
</style>
</head>
<body>
<div class="container">
<h1>🩺 Диагностика по видео</h1>
<p class="subtitle">Загрузите видео с лицом — получите оценку состояния здоровья</p>
<div class="card scan-section" id="scanSection">
<h2>📷 Сканирование</h2>
<p class="scan-desc">Наведите камеру на лицо — мы сделаем 300 кадров и проведём анализ</p>
<button class="scan-btn" id="scanBtn">📷 Сканировать</button>
</div>
<div class="card">
<h2>Загрузка видео</h2>
<div class="upload-area" id="uploadArea">
<div class="icon">📹</div>
<p><strong>Нажмите или перетащите видео сюда</strong></p>
<p class="hint">MP4, AVI, MOV, MKV • до 100 МБ • 10–15 секунд • лицо анфас</p>
<input type="file" id="fileInput" accept="video/*">
</div>
<div style="text-align:center">
<button class="btn" id="analyzeBtn" disabled>🔍 Анализировать</button>
</div>
<div class="progress-container" id="progressContainer">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">Подготовка...</div>
</div>
</div>
<div class="card result" id="resultCard">
<div class="result-header">
<h2 style="margin:0">📋 Результат анализа</h2>
<button class="btn-secondary" onclick="location.reload()">🔄 Новый анализ</button>
</div>
<div id="resultContent"></div>
</div>
</div>
<!-- Модал сканирования с камеры -->
<div class="scan-modal" id="scanModal">
<div class="scan-video-wrap">
<video class="scan-video" id="scanVideo" autoplay playsinline muted></video>
</div>
<div class="scan-progress">
<div id="scanProgressText">Кадров: 0 / 300 (0%)</div>
<div class="scan-progress-bar">
<div class="scan-progress-fill" id="scanProgressFill"></div>
</div>
</div>
<button class="scan-cancel" id="scanCancelBtn">✖ Отмена</button>
<div class="scan-hint">Держите лицо в круге, смотрите прямо в камеру, не двигайтесь</div>
</div>
<script>
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const analyzeBtn = document.getElementById('analyzeBtn');
const progressContainer = document.getElementById('progressContainer');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const resultCard = document.getElementById('resultCard');
const resultContent = document.getElementById('resultContent');
let selectedFile = null;
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) handleFile(e.target.files[0]);
});
function handleFile(file) {
selectedFile = file;
uploadArea.querySelector('p strong').textContent = `📁 ${file.name} (${(file.size/1024/1024).toFixed(1)} МБ)`;
uploadArea.querySelector('.hint').textContent = 'Готово к анализу';
analyzeBtn.disabled = false;
}
analyzeBtn.addEventListener('click', () => {
if (!selectedFile) return;
sendVideo(selectedFile, selectedFile.name);
});
async function sendVideo(blob, filename) {
analyzeBtn.disabled = true;
progressContainer.style.display = 'block';
resultCard.style.display = 'none';
setProgress(5, 'Загрузка видео...');
const formData = new FormData();
formData.append('video', blob, filename);
// Автоповтор при обрыве соединения (нестабильная мобильная сеть)
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const response = await fetch('/analyze', {
method: 'POST',
body: formData
});
if (!response.ok) {
const err = await response.json();
const e = new Error(err.error || 'Ошибка анализа');
e.videoUrl = err.video_url;
throw e;
}
setProgress(90, 'Формирование результата...');
const data = await response.json();
if (data.error) {
showError(data.error);
return;
}
setProgress(100, 'Готово!');
setTimeout(() => {
progressContainer.style.display = 'none';
renderResult(data);
}, 300);
return;
} catch (err) {
// TypeError "Load failed" = обрыв соединения — пробуем ещё раз
if (err instanceof TypeError && attempt < MAX_ATTEMPTS) {
setProgress(5, `Соединение прервано, повторная попытка (${attempt}/${MAX_ATTEMPTS - 1})...`);
await new Promise(r => setTimeout(r, 1500));
continue;
}
if (err instanceof TypeError) {
showError('Соединение прервано при загрузке видео. Проверьте интернет и попробуйте ещё раз.');
} else {
showError(err.message, err.videoUrl);
}
return;
}
}
}
function setProgress(pct, text) {
progressFill.style.width = pct + '%';
progressText.textContent = text;
}
function showError(msg, videoUrl) {
progressContainer.style.display = 'none';
resultCard.style.display = 'block';
let html = `<div class="error-box"><strong>Ошибка:</strong> ${msg}</div>`;
if (videoUrl) {
html += `<div style="text-align:center; margin-top:15px">
<a class="btn-download" href="${videoUrl}">🎬 Скачать записанное видео</a>
</div>`;
}
resultContent.innerHTML = html;
analyzeBtn.disabled = false;
}
function renderResult(data) {
resultCard.style.display = 'block';
let html = '';
// Кнопки скачивания (вверху блока результатов)
if (data.share_id) {
html += `
<div class="download-buttons">
<a class="btn-download" href="/download/${data.share_id}/pdf">📄 Скачать PDF</a>
<a class="btn-download" href="/download/${data.share_id}/docx">📝 Скачать DOCX</a>
${data.video_url ? `<a class="btn-download" href="${data.video_url}">🎬 Скачать видео</a>` : ''}
<a class="btn-download" href="https://max.ru/miac_dnr_bot" target="_blank" rel="noopener">🏥 Запись на диспанцеризацию</a>
</div>`;
}
// Сводка
html += `<div class="summary-box">${data.summary}</div>`;
// Предупреждения
if (data.warnings && data.warnings.length > 0) {
html += `<div class="warnings-box"><h3>⚠️ Требует внимания</h3><ul>`;
data.warnings.forEach(w => html += `<li>${w}</li>`);
html += `</ul></div>`;
}
// Таблица показателей
html += `<h2>Показатели</h2>`;
html += `<table class="metrics-table"><thead><tr><th>Показатель</th><th>Значение</th><th>Оценка</th></tr></thead><tbody>`;
data.metrics.forEach(m => {
const statusText = {
'ok': 'В норме',
'warning': 'Обратить внимание',
'danger': 'Отклонение',
'info': 'Информация'
}[m.status] || m.status;
html += `<tr>
<td>${m.name}</td>
<td><strong>${m.value}</strong>${m.comment ? `<br><small style="color:#888">${m.comment}</small>` : ''}</td>
<td><span class="status-badge status-${m.status}">${statusText}</span></td>
</tr>`;
});
html += `</tbody></table>`;
// Рекомендации
if (data.advice && data.advice.length > 0) {
html += `<div class="advice-box"><h3>💡 Рекомендации</h3><ul>`;
data.advice.forEach(a => html += `<li>${a}</li>`);
html += `</ul></div>`;
}
html += `<div class="disclaimer">⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. Для точной оценки обратитесь к врачу.</div>`;
// Кнопка "Поделиться"
if (data.share_url) {
html += `
<div style="text-align:center; margin-top:20px">
<button class="btn" onclick="shareResult('${data.share_url}', this)">🔗 Поделиться результатом</button>
</div>`;
}
resultContent.innerHTML = html;
resultCard.scrollIntoView({ behavior: 'smooth' });
}
function shareResult(url, btn) {
navigator.clipboard.writeText(url).then(() => {
const oldText = btn.textContent;
btn.textContent = '✅ Ссылка скопирована!';
setTimeout(() => btn.textContent = oldText, 2000);
});
}
// ─── Сканирование с камеры ───
const scanSection = document.getElementById('scanSection');
const scanBtn = document.getElementById('scanBtn');
const scanModal = document.getElementById('scanModal');
const scanVideo = document.getElementById('scanVideo');
const scanProgressText = document.getElementById('scanProgressText');
const scanProgressFill = document.getElementById('scanProgressFill');
const scanCancelBtn = document.getElementById('scanCancelBtn');
const SCAN_FRAMES = 310; // чуть больше 300, чтобы в файле гарантированно было >= 300 кадров
const SCAN_FPS = 30;
const FRAME_INTERVAL = 1000 / SCAN_FPS;
let mediaStream = null;
let mediaRecorder = null;
let scanCanvas = null;
let scanCtx = null;
let scanFrameCount = 0;
let scanRafId = null;
let scanLastFrameTime = 0;
let scanChunks = [];
let scanActive = false;
// Показываем кнопку сканирования только на мобильных устройствах
const isMobile = window.matchMedia('(max-width: 768px)').matches || ('ontouchstart' in window);
if (isMobile) {
scanSection.style.display = 'block';
}
scanBtn.addEventListener('click', startScan);
scanCancelBtn.addEventListener('click', cancelScan);
async function startScan() {
if (scanActive) return;
scanActive = true;
scanBtn.disabled = true;
// Включаем фронтальную камеру
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'user',
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
});
} catch (err) {
scanActive = false;
scanBtn.disabled = false;
showError('Не удалось получить доступ к камере: ' + (err.message || 'разрешите доступ'));
return;
}
scanModal.classList.add('open');
scanVideo.srcObject = mediaStream;
try { await scanVideo.play(); } catch (e) {}
scanChunks = [];
scanFrameCount = 0;
scanLastFrameTime = 0;
updateScanProgress(0);
// Canvas 720×720 — квадрат, лицо целиком влезает无论是 portrait или landscape.
// Серверное extract_frames масштабирует до 1280×720 для SDK.
scanCanvas = document.createElement('canvas');
scanCanvas.width = 720;
scanCanvas.height = 720;
scanCtx = scanCanvas.getContext('2d');
let captureStream;
try {
captureStream = scanCanvas.captureStream(SCAN_FPS);
} catch (e) {
// Fallback: записываем напрямую с камеры
captureStream = mediaStream;
}
const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4']
.find(t => MediaRecorder.isTypeSupported(t)) || '';
mediaRecorder = new MediaRecorder(captureStream, {
mimeType: mimeType || undefined,
videoBitsPerSecond: 1500000 // 1.5 Мбит/с — маленький файл, качество достаточное
});
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) scanChunks.push(e.data); };
mediaRecorder.onstop = () => {
const blob = new Blob(scanChunks, { type: mediaRecorder.mimeType || 'video/webm' });
const ext = blob.type.includes('mp4') ? 'mp4' : 'webm';
scanModal.classList.remove('open');
stopCamera();
scanActive = false;
scanBtn.disabled = false;
sendVideo(blob, `scan.${ext}`);
};
mediaRecorder.start();
// Рисуем кадры с ограничением ~30 fps, считаем до 300
const drawFrame = (ts) => {
if (!scanActive) return;
if (scanFrameCount >= SCAN_FRAMES) {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
return;
}
if (ts - scanLastFrameTime >= FRAME_INTERVAL) {
// Квадратный center-crop: берём центральную квадратную область из видео.
const vw = scanVideo.videoWidth;
const vh = scanVideo.videoHeight;
if (vw && vh) {
const side = Math.min(vw, vh);
const sx = (vw - side) / 2;
const sy = (vh - side) / 2;
scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 720, 720);
} else {
scanCtx.drawImage(scanVideo, 0, 0, 720, 720);
}
scanFrameCount++;
scanLastFrameTime = ts;
updateScanProgress(scanFrameCount);
}
scanRafId = requestAnimationFrame(drawFrame);
};
scanRafId = requestAnimationFrame(drawFrame);
}
function updateScanProgress(count) {
const pct = Math.min(100, Math.round(count / SCAN_FRAMES * 100));
scanProgressText.textContent = `Кадров: ${count} / ${SCAN_FRAMES} (${pct}%)`;
scanProgressFill.style.width = pct + '%';
}
function stopCamera() {
if (mediaStream) {
mediaStream.getTracks().forEach(t => t.stop());
mediaStream = null;
}
if (scanRafId) cancelAnimationFrame(scanRafId);
scanRafId = null;
scanVideo.srcObject = null;
}
function cancelScan() {
scanActive = false;
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.onstop = null;
mediaRecorder.stop();
}
stopCamera();
scanModal.classList.remove('open');
scanBtn.disabled = false;
}
</script>
</body>
</html>