Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13809d20a5 | ||
|
|
25ed2e3899 | ||
|
|
ce67122035 | ||
|
|
61149c3b48 | ||
|
|
d81d5a4672 | ||
|
|
6ea71e1864 | ||
|
|
c6f658fb2f | ||
|
|
ea87de7d94 | ||
|
|
0cea5b8d0a | ||
|
|
dc2cf17f42 | ||
|
|
572ed6bd5e | ||
|
|
e269598077 | ||
|
|
2c753f3d40 |
No files matched your search
@@ -7,6 +7,7 @@ videos/
|
||||
venv/
|
||||
bin/
|
||||
certs/
|
||||
fonts/
|
||||
|
||||
# Логи (могут содержать токены SDK)
|
||||
*.log
|
||||
|
||||
@@ -23,7 +23,7 @@ import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request, send_file
|
||||
from flask import Flask, Response, jsonify, render_template, request
|
||||
|
||||
# Импортируем модули интерпретации и хранения
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -31,6 +31,14 @@ from interpret import interpret_result
|
||||
import storage
|
||||
import export
|
||||
|
||||
# Загружаем переменные из .env (если файл есть рядом с app.py).
|
||||
# Значения из реального окружения (systemd Environment=) имеют приоритет.
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ─── Конфигурация ─────────────────────────────────────────────
|
||||
# Секреты читаются из переменных окружения (не хранятся в репозитории).
|
||||
# На сервере задаются в /etc/systemd/system/med.service (Environment=).
|
||||
@@ -86,16 +94,6 @@ os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
VIDEO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "videos")
|
||||
os.makedirs(VIDEO_DIR, exist_ok=True)
|
||||
|
||||
# MIME-типы для скачивания видео
|
||||
VIDEO_MIME = {
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".m4v": "video/mp4",
|
||||
}
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 МБ
|
||||
|
||||
@@ -404,15 +402,21 @@ def shared_result(share_id):
|
||||
"""Публичная страница результата по ссылке (с OG-тегами для соцсетей)."""
|
||||
result = storage.get_result(share_id)
|
||||
if result is None:
|
||||
return render_template("result.html", result=None, error="Результат не найден", video_exists=False, base_url=BASE_URL), 404
|
||||
return render_template("result.html", result=None, error="Результат не найден", base_url=BASE_URL), 404
|
||||
if storage.is_expired(result["created_at"]):
|
||||
return render_template("result.html", result=None, error="Ссылка устарела. Результат доступен в течение 30 минут после сканирования.", base_url=BASE_URL), 410
|
||||
|
||||
video_exists = bool(glob.glob(os.path.join(VIDEO_DIR, f"{share_id}.*")))
|
||||
return render_template("result.html", result=result, error=None, video_exists=video_exists, base_url=BASE_URL)
|
||||
return render_template("result.html", result=result, error=None, base_url=BASE_URL)
|
||||
|
||||
|
||||
@app.route("/download/<share_id>/pdf")
|
||||
def download_pdf(share_id):
|
||||
"""Скачать результат в PDF."""
|
||||
result = storage.get_result(share_id)
|
||||
if result is None:
|
||||
return "Результат не найден", 404
|
||||
if storage.is_expired(result["created_at"]):
|
||||
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
|
||||
try:
|
||||
data = export.generate_pdf(share_id)
|
||||
except ValueError:
|
||||
@@ -431,6 +435,11 @@ def download_pdf(share_id):
|
||||
@app.route("/download/<share_id>/docx")
|
||||
def download_docx(share_id):
|
||||
"""Скачать результат в DOCX."""
|
||||
result = storage.get_result(share_id)
|
||||
if result is None:
|
||||
return "Результат не найден", 404
|
||||
if storage.is_expired(result["created_at"]):
|
||||
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
|
||||
try:
|
||||
data = export.generate_docx(share_id)
|
||||
except ValueError:
|
||||
@@ -446,23 +455,6 @@ def download_docx(share_id):
|
||||
)
|
||||
|
||||
|
||||
@app.route("/download/<video_id>/video")
|
||||
def download_video(video_id):
|
||||
"""Скачать видео, записанное при сканировании (то, что было в кружке)."""
|
||||
matches = glob.glob(os.path.join(VIDEO_DIR, f"{video_id}.*"))
|
||||
if not matches:
|
||||
return "Видео не найдено", 404
|
||||
video_path = matches[0]
|
||||
ext = os.path.splitext(video_path)[1].lower()
|
||||
mime = VIDEO_MIME.get(ext, "application/octet-stream")
|
||||
return send_file(
|
||||
video_path,
|
||||
mimetype=mime,
|
||||
as_attachment=True,
|
||||
download_name=f"scan_{video_id}{ext}",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/analyze", methods=["POST", "OPTIONS"])
|
||||
def analyze():
|
||||
# Preflight-запрос CORS (браузер шлёт OPTIONS перед кросс-доменным POST)
|
||||
@@ -494,14 +486,6 @@ def analyze():
|
||||
logger.error(f"[{job_id}] Ошибка сохранения видео: {e}")
|
||||
return jsonify({"error": f"Не удалось сохранить видео: {e}"}), 500
|
||||
|
||||
# Сохраняем копию записанного видео (то, что было в кружке) — даже если анализ упадёт
|
||||
saved_video = os.path.join(VIDEO_DIR, f"{job_id}{ext}")
|
||||
try:
|
||||
shutil.copy2(video_path, saved_video)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{job_id}] Не удалось сохранить видео: {e}")
|
||||
saved_video = None
|
||||
|
||||
token = None
|
||||
uid = None
|
||||
try:
|
||||
@@ -523,10 +507,8 @@ def analyze():
|
||||
result = asyncio.run(ws_stream(token, uid, frames))
|
||||
|
||||
if not result:
|
||||
video_url = f"/download/{job_id}/video" if saved_video else None
|
||||
return jsonify({
|
||||
"error": "Не удалось получить результат. Убедитесь, что на видео чётко видно лицо.",
|
||||
"video_url": video_url,
|
||||
}), 500
|
||||
|
||||
# 5. Интерпретация
|
||||
@@ -539,25 +521,14 @@ def analyze():
|
||||
share_url = f"{BASE_URL}/r/{share_id}"
|
||||
logger.info(f"[{job_id}] Результат сохранён: {share_url}")
|
||||
|
||||
# Переименовываем видео под share_id (чтобы ссылка была стабильной)
|
||||
if saved_video:
|
||||
final_video = os.path.join(VIDEO_DIR, f"{share_id}{ext}")
|
||||
try:
|
||||
os.rename(saved_video, final_video)
|
||||
saved_video = final_video
|
||||
except Exception as e:
|
||||
logger.warning(f"[{job_id}] Не удалось переименовать видео: {e}")
|
||||
|
||||
interpretation["share_id"] = share_id
|
||||
interpretation["share_url"] = share_url
|
||||
interpretation["video_url"] = f"/download/{share_id}/video" if saved_video else None
|
||||
|
||||
return jsonify(interpretation)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{job_id}] Ошибка: {e}")
|
||||
video_url = f"/download/{job_id}/video" if saved_video else None
|
||||
return jsonify({"error": str(e), "video_url": video_url}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
finally:
|
||||
# 6. Удаление сессии
|
||||
|
||||
@@ -21,15 +21,46 @@ from docx.shared import Pt, Cm, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
# --- Шрифты для кириллицы в PDF ---
|
||||
# Ищем шрифты в нескольких местах: Linux (сервер) и Windows (локально).
|
||||
import os as _os
|
||||
|
||||
_FONT_DIR = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "fonts")
|
||||
FONT_PATHS = {
|
||||
"DejaVuSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"DejaVuSans-Bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"DejaVuSans-Oblique": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
|
||||
"DejaVuSans": [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
_os.path.join(_FONT_DIR, "DejaVuSans.ttf"),
|
||||
"C:/Windows/Fonts/arial.ttf", # запасной на Windows (поддерживает кириллицу)
|
||||
],
|
||||
"DejaVuSans-Bold": [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
_os.path.join(_FONT_DIR, "DejaVuSans-Bold.ttf"),
|
||||
"C:/Windows/Fonts/arialbd.ttf",
|
||||
],
|
||||
"DejaVuSans-Oblique": [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
|
||||
_os.path.join(_FONT_DIR, "DejaVuSans-Oblique.ttf"),
|
||||
"C:/Windows/Fonts/ariali.ttf",
|
||||
],
|
||||
}
|
||||
|
||||
for name, path in FONT_PATHS.items():
|
||||
for name, paths in FONT_PATHS.items():
|
||||
for path in paths:
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont(name, path))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Регистрируем семейство, чтобы reportlab понимал, что DejaVuSans-Bold —
|
||||
# это жирный вариант DejaVuSans (иначе Paragraph падает с "Can't map family/bold/italic").
|
||||
try:
|
||||
pdfmetrics.registerFontFamily(
|
||||
'DejaVuSans',
|
||||
normal='DejaVuSans',
|
||||
bold='DejaVuSans-Bold',
|
||||
italic='DejaVuSans',
|
||||
boldItalic='DejaVuSans-Bold',
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -109,6 +140,15 @@ def generate_pdf(share_id) -> bytes:
|
||||
name="SmallRu", fontName="DejaVuSans", fontSize=8.5,
|
||||
leading=12, textColor=colors.grey,
|
||||
))
|
||||
# Стили ячеек таблицы (Paragraph, чтобы текст переносился и не перекрывался)
|
||||
styles.add(ParagraphStyle(
|
||||
name="CellRu", fontName="DejaVuSans", fontSize=9.5,
|
||||
leading=13, spaceAfter=0,
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
name="CellRuBold", fontName="DejaVuSans-Bold", fontSize=9.5,
|
||||
leading=13, spaceAfter=0, textColor=colors.white,
|
||||
))
|
||||
|
||||
story = []
|
||||
|
||||
@@ -135,7 +175,9 @@ def generate_pdf(share_id) -> bytes:
|
||||
metrics = _parse_json_list(result["metrics"])
|
||||
if metrics:
|
||||
story.append(Paragraph("Показатели", styles["H2Ru"]))
|
||||
data = [["Показатель", "Значение", "Оценка"]]
|
||||
data = [[Paragraph("Показатель", styles["CellRuBold"]),
|
||||
Paragraph("Значение", styles["CellRuBold"]),
|
||||
Paragraph("Оценка", styles["CellRuBold"])]]
|
||||
for m in metrics:
|
||||
value = m.get("value", "")
|
||||
comment = m.get("comment", "")
|
||||
@@ -143,9 +185,9 @@ def generate_pdf(share_id) -> bytes:
|
||||
value = f"{value} ({comment})"
|
||||
status = m.get("status", "info")
|
||||
data.append([
|
||||
m.get("name", ""),
|
||||
value,
|
||||
STATUS_TEXT.get(status, status),
|
||||
Paragraph(m.get("name", ""), styles["CellRu"]),
|
||||
Paragraph(value, styles["CellRu"]),
|
||||
Paragraph(STATUS_TEXT.get(status, status), styles["CellRu"]),
|
||||
])
|
||||
table = Table(data, colWidths=[55 * mm, 75 * mm, 50 * mm])
|
||||
table.setStyle(TableStyle([
|
||||
|
||||
+11
-61
@@ -30,25 +30,6 @@ def interpret_result(data: dict) -> dict:
|
||||
# ── Демография ─────────────────────────────────────────────
|
||||
gender = "мужчина" if data.get("is_male") else "женщина"
|
||||
age = data.get("age", 0)
|
||||
ethnicity_map = {
|
||||
"white": "европеоид",
|
||||
"black": "негроид",
|
||||
"asian": "монголоид",
|
||||
"unknown": "не определён",
|
||||
}
|
||||
ethnicity = ethnicity_map.get(data.get("ethnicity", ""), data.get("ethnicity", "не определён"))
|
||||
|
||||
emotion_map = {
|
||||
"happiness": "радость",
|
||||
"sadness": "грусть",
|
||||
"neutral": "спокойствие",
|
||||
"anger": "злость",
|
||||
"surprise": "удивление",
|
||||
"fear": "страх",
|
||||
"disgust": "отвращение",
|
||||
"contempt": "презрение",
|
||||
}
|
||||
emotion = emotion_map.get(data.get("emotion", ""), data.get("emotion", "не определена"))
|
||||
|
||||
metrics.append({
|
||||
"name": "Пол",
|
||||
@@ -62,18 +43,6 @@ def interpret_result(data: dict) -> dict:
|
||||
"status": "info",
|
||||
"comment": "Оценка по лицу",
|
||||
})
|
||||
metrics.append({
|
||||
"name": "Этнос",
|
||||
"value": ethnicity.capitalize(),
|
||||
"status": "info",
|
||||
"comment": "",
|
||||
})
|
||||
metrics.append({
|
||||
"name": "Эмоция",
|
||||
"value": emotion.capitalize(),
|
||||
"status": "info",
|
||||
"comment": "Эмоция в момент съёмки",
|
||||
})
|
||||
|
||||
# ── ИМТ ────────────────────────────────────────────────────
|
||||
bmi = data.get("bmi", 0)
|
||||
@@ -92,12 +61,16 @@ def interpret_result(data: dict) -> dict:
|
||||
"status": bmi_status,
|
||||
"comment": bmi_comment,
|
||||
})
|
||||
if bmi_status == "warning":
|
||||
warnings.append(f"Избыточный вес (ИМТ {bmi:.1f}) — рекомендуется контроль питания и физическая активность")
|
||||
advice.append("Снизить вес до нормы (ИМТ 18.5–24.9): сбалансированное питание, регулярные нагрузки")
|
||||
elif bmi_status == "danger":
|
||||
if bmi_status == "danger":
|
||||
warnings.append(f"Ожирение (ИМТ {bmi:.1f}) — рекомендуется консультация врача")
|
||||
advice.append("Обратиться к врачу для разработки программы снижения веса")
|
||||
elif bmi_status == "warning":
|
||||
if bmi < 18.5:
|
||||
warnings.append(f"Недостаточный вес (ИМТ {bmi:.1f}) — рекомендуется сбалансированное питание")
|
||||
advice.append("Набрать вес до нормы (ИМТ 18.5–24.9): полноценное питание, при необходимости консультация врача")
|
||||
else:
|
||||
warnings.append(f"Избыточный вес (ИМТ {bmi:.1f}) — рекомендуется контроль питания и физическая активность")
|
||||
advice.append("Снизить вес до нормы (ИМТ 18.5–24.9): сбалансированное питание, регулярные нагрузки")
|
||||
|
||||
# ── Диабет ─────────────────────────────────────────────────
|
||||
diabet = data.get("diabet", "unknown")
|
||||
@@ -277,20 +250,6 @@ def interpret_result(data: dict) -> dict:
|
||||
warnings.append("Повышенный уровень липопротеина (а) — фактор риска сердечно-сосудистых заболеваний")
|
||||
advice.append("Проконсультироваться с кардиологом")
|
||||
|
||||
# ── Дыхание ────────────────────────────────────────────────
|
||||
resp = data.get("respiratory", 0)
|
||||
if resp > 0:
|
||||
if 12 <= resp <= 20:
|
||||
resp_status, resp_comment = "ok", "Норма"
|
||||
else:
|
||||
resp_status, resp_comment = "warning", "Вне нормы"
|
||||
metrics.append({
|
||||
"name": "Частота дыхания",
|
||||
"value": f"{resp:.1f} вдохов/мин",
|
||||
"status": resp_status,
|
||||
"comment": resp_comment,
|
||||
})
|
||||
|
||||
# ── Стресс ─────────────────────────────────────────────────
|
||||
stress = data.get("stress", 0)
|
||||
if stress > 0:
|
||||
@@ -304,9 +263,9 @@ def interpret_result(data: dict) -> dict:
|
||||
stress_status, stress_comment = "danger", "Высокий уровень стресса"
|
||||
metrics.append({
|
||||
"name": "Уровень стресса",
|
||||
"value": f"{stress}",
|
||||
"value": stress_comment,
|
||||
"status": stress_status,
|
||||
"comment": stress_comment,
|
||||
"comment": "Оценка по Баевскому",
|
||||
})
|
||||
if stress_status == "warning":
|
||||
warnings.append("Повышенный уровень стресса")
|
||||
@@ -331,17 +290,8 @@ def interpret_result(data: dict) -> dict:
|
||||
"comment": rig_comment,
|
||||
})
|
||||
|
||||
# ── Моргания ───────────────────────────────────────────────
|
||||
blinks = data.get("blinks", 0)
|
||||
metrics.append({
|
||||
"name": "Моргания",
|
||||
"value": f"{blinks}",
|
||||
"status": "info",
|
||||
"comment": "За время сканирования",
|
||||
})
|
||||
|
||||
# ── Сводка ─────────────────────────────────────────────────
|
||||
summary = f"{gender.capitalize()}, {age} лет, {ethnicity}. Эмоция: {emotion}."
|
||||
summary = f"{gender.capitalize()}, {age} лет."
|
||||
|
||||
# Считаем проблемы
|
||||
problems = [m for m in metrics if m["status"] in ("warning", "danger")]
|
||||
|
||||
@@ -5,6 +5,7 @@ websockets==10.4
|
||||
requests==2.28.2
|
||||
reportlab==5.0.1
|
||||
python-docx==1.2.0
|
||||
python-dotenv==1.0.1
|
||||
|
||||
# Только для утилиты gigadoc_ws.py (не нужен для веб-приложения):
|
||||
# aiohttp
|
||||
+20
-12
@@ -16,13 +16,26 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.db")
|
||||
|
||||
# Первый ID результата
|
||||
FIRST_ID = 10005
|
||||
|
||||
# Срок жизни результата: доступен 30 минут, затем становится недоступным (не удаляется).
|
||||
RESULT_TTL = timedelta(minutes=30)
|
||||
|
||||
|
||||
def is_expired(created_at: str) -> bool:
|
||||
"""True, если результат старше RESULT_TTL (30 минут)."""
|
||||
try:
|
||||
created = datetime.fromisoformat(created_at)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return datetime.now() - created > RESULT_TTL
|
||||
|
||||
|
||||
def _connect():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
@@ -51,27 +64,22 @@ def init_db():
|
||||
|
||||
def save_result(interpretation: dict, raw_result: dict) -> str:
|
||||
"""
|
||||
Сохранить результат анализа. Возвращает share_id (числовой, начиная с 10005).
|
||||
Сохранить результат анализа. Возвращает share_id — случайный UUID
|
||||
(непредсказуемый, чтобы ссылки нельзя было перебирать).
|
||||
"""
|
||||
init_db()
|
||||
|
||||
conn = _connect()
|
||||
|
||||
# Следующий ID: MAX(id) + 1, но не меньше FIRST_ID
|
||||
row = conn.execute("SELECT MAX(id) AS max_id FROM results").fetchone()
|
||||
next_id = (row["max_id"] + 1) if row["max_id"] is not None else FIRST_ID
|
||||
if next_id < FIRST_ID:
|
||||
next_id = FIRST_ID
|
||||
|
||||
share_id = str(next_id)
|
||||
# Случайный UUID для публичной ссылки (id остаётся автоинкрементным для внутренней сортировки).
|
||||
share_id = uuid.uuid4().hex
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO results (id, share_id, created_at, raw_json, summary, warnings, metrics, advice)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO results (share_id, created_at, raw_json, summary, warnings, metrics, advice)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
next_id,
|
||||
share_id,
|
||||
datetime.now().isoformat(timespec="seconds"),
|
||||
json.dumps(raw_result, ensure_ascii=False),
|
||||
|
||||
+55
-6
@@ -347,6 +347,42 @@
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.hidden { display: none; }
|
||||
|
||||
/* Лоадер обработки (после сканирования) */
|
||||
.scan-loader {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.scan-loader.open {
|
||||
display: flex;
|
||||
}
|
||||
.scan-loader-content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
.scan-loader-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255,255,255,0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.scan-loader-text {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scan-loader-sub {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Заголовок результата */
|
||||
.result-header {
|
||||
display: flex;
|
||||
@@ -387,10 +423,11 @@
|
||||
|
||||
<div class="card scan-section" id="scanSection">
|
||||
<h2>📷 Сканирование</h2>
|
||||
<p class="scan-desc">Наведите камеру на лицо — мы сделаем 300 кадров и проведём анализ</p>
|
||||
<p class="scan-desc">Наведите камеру на лицо — мы запишем видео и проведём анализ</p>
|
||||
<button class="scan-btn" id="scanBtn">📷 Сканировать</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<div class="card">
|
||||
<h2>Загрузка видео</h2>
|
||||
<div class="upload-area" id="uploadArea">
|
||||
@@ -410,6 +447,7 @@
|
||||
<div class="progress-text" id="progressText">Подготовка...</div>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<div class="card result" id="resultCard">
|
||||
<div class="result-header">
|
||||
@@ -427,7 +465,7 @@
|
||||
<div class="scan-oval"></div>
|
||||
</div>
|
||||
<div class="scan-progress">
|
||||
<div id="scanProgressText">Кадров: 0 / 300 (0%)</div>
|
||||
<div id="scanProgressText">Прогресс: 0 / 300 (0%)</div>
|
||||
<div class="scan-progress-bar">
|
||||
<div class="scan-progress-fill" id="scanProgressFill"></div>
|
||||
</div>
|
||||
@@ -436,6 +474,15 @@
|
||||
<div class="scan-hint">Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон</div>
|
||||
</div>
|
||||
|
||||
<!-- Лоадер обработки -->
|
||||
<div class="scan-loader" id="scanLoader">
|
||||
<div class="scan-loader-content">
|
||||
<div class="scan-loader-spinner"></div>
|
||||
<div class="scan-loader-text">Обработка видео...</div>
|
||||
<div class="scan-loader-sub">Идёт анализ, это займёт несколько секунд</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const uploadArea = document.getElementById('uploadArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
@@ -522,6 +569,7 @@
|
||||
}
|
||||
|
||||
setProgress(100, 'Готово!');
|
||||
document.getElementById('scanLoader').classList.remove('open');
|
||||
setTimeout(() => {
|
||||
progressContainer.style.display = 'none';
|
||||
renderResult(data);
|
||||
@@ -550,6 +598,7 @@
|
||||
}
|
||||
|
||||
function showError(msg, videoUrl) {
|
||||
document.getElementById('scanLoader').classList.remove('open');
|
||||
progressContainer.style.display = 'none';
|
||||
resultCard.style.display = 'block';
|
||||
let html = `<div class="error-box"><strong>Ошибка:</strong> ${msg}</div>`;
|
||||
@@ -563,6 +612,7 @@
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
document.getElementById('scanLoader').classList.remove('open');
|
||||
resultCard.style.display = 'block';
|
||||
|
||||
let html = '';
|
||||
@@ -658,11 +708,8 @@
|
||||
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);
|
||||
@@ -727,6 +774,8 @@
|
||||
stopCamera();
|
||||
scanActive = false;
|
||||
scanBtn.disabled = false;
|
||||
// Показываем лоадер обработки
|
||||
document.getElementById('scanLoader').classList.add('open');
|
||||
sendVideo(blob, `scan.${ext}`);
|
||||
};
|
||||
mediaRecorder.start();
|
||||
|
||||
@@ -257,9 +257,6 @@
|
||||
<div class="download-buttons">
|
||||
<a class="btn-download" href="/download/{{ result.share_id }}/pdf">📄 Скачать PDF</a>
|
||||
<a class="btn-download" href="/download/{{ result.share_id }}/docx">📝 Скачать DOCX</a>
|
||||
{% if video_exists %}
|
||||
<a class="btn-download" href="/download/{{ result.share_id }}/video">🎬 Скачать видео</a>
|
||||
{% endif %}
|
||||
<a class="btn-download" href="https://max.ru/miac_dnr_bot" target="_blank" rel="noopener">🏥 Запись на диспанцеризацию</a>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user