- Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ - Понятный диагноз с сохранением в SQLite и share-ссылками - Экспорт PDF/DOCX, скачивание записанного видео - Камерное сканирование на мобильных (canvas + MediaRecorder) - Секреты вынесены в переменные окружения
770 lines
32 KiB
HTML
770 lines
32 KiB
HTML
<!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> |