/* Med Diagnostics — логика сервиса (адаптирована для WordPress) */ (function () { 'use strict'; var API_URL = (window.MED_DIAG && MED_DIAG.apiUrl) || 'https://med.my-dpr.ru'; var PROXY_URL = (window.MED_DIAG && MED_DIAG.proxyUrl) || ''; var MODE = (window.MED_DIAG && MED_DIAG.mode) || 'proxy'; var USE_PROXY = (MODE === 'proxy') && !!PROXY_URL; // Абсолютный URL: если ссылка относительная — добавляем домен API function absUrl(url) { if (!url) return url; return url.indexOf('http') === 0 ? url : API_URL + url; } // URL для скачивания: в режиме прокси — через этот же WordPress (PHP → внутренний IP) function downloadUrl(path) { if (!path) return path; if (path.indexOf('http') === 0) return path; return USE_PROXY ? PROXY_URL + path : API_URL + path; } // Экранирование текста перед вставкой в innerHTML: сообщение об ошибке // может прийти из ответа сервера (HTML-страница nginx/PHP и т.п.). function escapeHtml(text) { return String(text === undefined || text === null ? '' : text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // Безопасный разбор ответа: если сервер вернул не JSON (например, // HTML-страницу ошибки), не падаем с "Unexpected token '<'". async function parseJson(response) { var ct = (response.headers.get('content-type') || '').toLowerCase(); if (ct.indexOf('application/json') !== -1) { return await response.json(); } // HTML-ответ превращаем в короткое текстовое сообщение var text = (await response.text()).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); if (text.length > 300) text = text.slice(0, 300) + '…'; return { error: text || ('Ошибка сервера (HTTP ' + response.status + ')') }; } var uploadArea = document.getElementById('uploadArea'); var fileInput = document.getElementById('fileInput'); var analyzeBtn = document.getElementById('analyzeBtn'); var progressContainer = document.getElementById('progressContainer'); var progressFill = document.getElementById('progressFill'); var progressText = document.getElementById('progressText'); var resultCard = document.getElementById('resultCard'); var resultContent = document.getElementById('resultContent'); if (!uploadArea || !fileInput || !analyzeBtn) return; // шорткод не выведен var selectedFile = null; uploadArea.addEventListener('click', function () { fileInput.click(); }); uploadArea.addEventListener('dragover', function (e) { e.preventDefault(); uploadArea.classList.add('dragover'); }); uploadArea.addEventListener('dragleave', function () { uploadArea.classList.remove('dragover'); }); uploadArea.addEventListener('drop', function (e) { e.preventDefault(); uploadArea.classList.remove('dragover'); if (e.dataTransfer.files.length > 0) { handleFile(e.dataTransfer.files[0]); } }); fileInput.addEventListener('change', function (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('.med-hint').textContent = 'Готово к анализу'; analyzeBtn.disabled = false; } analyzeBtn.addEventListener('click', function () { 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, 'Загрузка видео...'); var formData = new FormData(); formData.append('video', blob, filename); // В режиме прокси шлём на этот же WordPress (PHP перешлёт на внутренний IP бэкенда), // иначе — напрямую на публичный URL API. var analyzeUrl = USE_PROXY ? PROXY_URL + '/analyze' : API_URL + '/analyze'; // Автоповтор при обрыве соединения (нестабильная мобильная сеть) var MAX_ATTEMPTS = 3; for (var attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { var response = await fetch(analyzeUrl, { method: 'POST', body: formData }); if (!response.ok) { var err = await parseJson(response); var e = new Error(err.error || 'Ошибка анализа'); e.videoUrl = err.video_url; throw e; } setProgress(90, 'Формирование результата...'); var data = await parseJson(response); if (data.error) { showError(data.error); return; } setProgress(100, 'Готово!'); document.getElementById('scanLoader').classList.remove('open'); setTimeout(function () { 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(function (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) { document.getElementById('scanLoader').classList.remove('open'); progressContainer.style.display = 'none'; resultCard.style.display = 'block'; var html = '
Ошибка: ' + escapeHtml(msg) + '
'; if (videoUrl) { html += '
' + '🎬 Скачать записанное видео' + '
'; } resultContent.innerHTML = html; analyzeBtn.disabled = false; } function renderResult(data) { document.getElementById('scanLoader').classList.remove('open'); resultCard.style.display = 'block'; var html = ''; // Кнопки скачивания (вверху блока результатов) if (data.share_id) { html += '
' + '📄 Скачать PDF' + '📝 Скачать DOCX'; if (data.video_url) { html += '🎬 Скачать видео'; } html += '🏥 Запись на диспанцеризацию' + '
'; } // Сводка html += '
' + data.summary + '
'; // Предупреждения if (data.warnings && data.warnings.length > 0) { html += '

⚠️ Требует внимания

'; } // Таблица показателей html += '

Показатели

'; html += ''; data.metrics.forEach(function (m) { var statusText = { 'ok': 'В норме', 'warning': 'Обратить внимание', 'danger': 'Отклонение', 'info': 'Информация' }[m.status] || m.status; html += '' + '' + '' + '' + ''; }); html += '
ПоказательЗначениеОценка
' + m.name + '' + m.value + '' + (m.comment ? '
' + m.comment + '' : '') + '
' + statusText + '
'; // Рекомендации if (data.advice && data.advice.length > 0) { html += '

💡 Рекомендации

'; } html += '
⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. Для точной оценки обратитесь к врачу.
'; // Кнопка "Поделиться" if (data.share_url) { html += '
' + '' + '
'; } resultContent.innerHTML = html; resultCard.scrollIntoView({ behavior: 'smooth' }); } // Поделиться (глобальная функция для inline-onclick) window.MED_DIAG_SHARE = function (url, btn) { navigator.clipboard.writeText(url).then(function () { var oldText = btn.textContent; btn.textContent = '✅ Ссылка скопирована!'; setTimeout(function () { btn.textContent = oldText; }, 2000); }); }; // ─── Сканирование с камеры ─── var scanSection = document.getElementById('scanSection'); var scanBtn = document.getElementById('scanBtn'); var scanModal = document.getElementById('scanModal'); var scanVideo = document.getElementById('scanVideo'); var scanProgressText = document.getElementById('scanProgressText'); var scanProgressFill = document.getElementById('scanProgressFill'); var scanCancelBtn = document.getElementById('scanCancelBtn'); var SCAN_FRAMES = 310; // чуть больше 300, чтобы в файле гарантированно было >= 300 кадров var SCAN_FPS = 30; var FRAME_INTERVAL = 1000 / SCAN_FPS; var mediaStream = null; var mediaRecorder = null; var scanCanvas = null; var scanCtx = null; var scanFrameCount = 0; var scanRafId = null; var scanLastFrameTime = 0; var scanChunks = []; var scanActive = false; // Показываем кнопку сканирования на всех устройствах if (scanSection) { scanSection.style.display = 'block'; } if (scanBtn) scanBtn.addEventListener('click', startScan); if (scanCancelBtn) 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: 1280 } }, 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 1080×1080 — квадрат, лицо целиком влезает в portrait и landscape. // Серверное extract_frames масштабирует до 1280×720 для SDK. scanCanvas = document.createElement('canvas'); scanCanvas.width = 1080; scanCanvas.height = 1080; scanCtx = scanCanvas.getContext('2d'); var captureStream; try { captureStream = scanCanvas.captureStream(SCAN_FPS); } catch (e) { // Fallback: записываем напрямую с камеры captureStream = mediaStream; } var mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'] .find(function (t) { return MediaRecorder.isTypeSupported(t); }) || ''; mediaRecorder = new MediaRecorder(captureStream, { mimeType: mimeType || undefined, videoBitsPerSecond: 1500000 // 1.5 Мбит/с — маленький файл, качество достаточное }); mediaRecorder.ondataavailable = function (e) { if (e.data.size > 0) scanChunks.push(e.data); }; mediaRecorder.onstop = function () { var blob = new Blob(scanChunks, { type: mediaRecorder.mimeType || 'video/webm' }); var ext = blob.type.indexOf('mp4') !== -1 ? 'mp4' : 'webm'; scanModal.classList.remove('open'); stopCamera(); scanActive = false; scanBtn.disabled = false; // Показываем лоадер обработки document.getElementById('scanLoader').classList.add('open'); sendVideo(blob, 'scan.' + ext); }; mediaRecorder.start(); // Рисуем кадры с ограничением ~30 fps, считаем до 300 var drawFrame = function (ts) { if (!scanActive) return; if (scanFrameCount >= SCAN_FRAMES) { if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop(); } return; } if (ts - scanLastFrameTime >= FRAME_INTERVAL) { // Квадратный center-crop с запасом 10%: берём чуть меньший // квадрат, чтобы голова гарантированно помещалась целиком // (иначе SDK не находит лицо из-за обрезанной головы). var vw = scanVideo.videoWidth; var vh = scanVideo.videoHeight; if (vw && vh) { var side = Math.min(vw, vh) * 0.9; var sx = (vw - side) / 2; var sy = (vh - side) / 2; scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 1080, 1080); } else { scanCtx.drawImage(scanVideo, 0, 0, 1080, 1080); } scanFrameCount++; scanLastFrameTime = ts; updateScanProgress(scanFrameCount); } scanRafId = requestAnimationFrame(drawFrame); }; scanRafId = requestAnimationFrame(drawFrame); } function updateScanProgress(count) { var 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(function (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; } })();