592 lines
24 KiB
Python
592 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Веб-интерфейс для анализа видео через SDK ФПГ (GigaDoc).
|
||
|
||
Пользователь загружает видео, система извлекает кадры,
|
||
отправляет их в SDK и показывает понятный диагноз.
|
||
|
||
Запуск: python3 app.py
|
||
Открыть: http://localhost:5000
|
||
"""
|
||
|
||
import asyncio
|
||
import glob
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
from flask import Flask, Response, jsonify, render_template, request, send_file
|
||
|
||
# Импортируем модули интерпретации и хранения
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from interpret import interpret_result
|
||
import storage
|
||
import export
|
||
|
||
# ─── Конфигурация ─────────────────────────────────────────────
|
||
# Секреты читаются из переменных окружения (не хранятся в репозитории).
|
||
# На сервере задаются в /etc/systemd/system/med.service (Environment=).
|
||
# Хосты стенда можно переопределить через env (FPG_REST_HOST и т.д.).
|
||
# ВАЖНО: хост может содержать не только домен, но и путь
|
||
# (например "domain.com/v1/giga") — это учитывается при сборке URL.
|
||
OAUTH_URL = os.environ.get("FPG_OAUTH_URL", "https://oauth.sbermed.ai/token")
|
||
CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front_dnr")
|
||
CLIENT_SECRET = os.environ.get("FPG_CLIENT_SECRET", "")
|
||
|
||
|
||
def split_host_path(host: str) -> tuple:
|
||
"""Разделить 'host/path' на (scheme, hostname, base_path).
|
||
|
||
Поддерживает варианты: "domain.com", "domain.com/v1/giga",
|
||
"https://domain.com/v1/giga". Схема возвращается без "://".
|
||
"""
|
||
host = host.strip().rstrip("/")
|
||
scheme = ""
|
||
if "://" in host:
|
||
scheme, host = host.split("://", 1)
|
||
parts = host.split("/", 1)
|
||
hostname = parts[0]
|
||
base_path = "/" + parts[1] if len(parts) > 1 else ""
|
||
return scheme, hostname, base_path
|
||
|
||
|
||
REST_HOST = os.environ.get("FPG_REST_HOST", "gigadoc-dnr-sdk.sbermed.ai")
|
||
_rest_scheme, _rest_hostname, _rest_base_path = split_host_path(REST_HOST)
|
||
REST_BASE = f"{_rest_scheme or 'https'}://{_rest_hostname}{_rest_base_path}"
|
||
|
||
WS_HOST = os.environ.get("FPG_WS_HOST", "gigadoc-dnr-sdk.sbermed.ai")
|
||
WS_PORT = int(os.environ.get("FPG_WS_PORT", "8443"))
|
||
_ws_scheme, WS_HOSTNAME, WS_BASE_PATH = split_host_path(WS_HOST)
|
||
WS_PATH_TEMPLATE = f"{WS_BASE_PATH}/api/v2/ws/predict/{{uid}}/image"
|
||
WS_ORIGIN = os.environ.get("FPG_WS_ORIGIN", "https://gigadoc-dnr-sdk.sbermed.ai")
|
||
|
||
DEVICE_ID = "web_prod"
|
||
FORM_FACTOR = "web_prod"
|
||
|
||
# Параметры обработки видео
|
||
FRAMES_COUNT = 300 # количество кадров для анализа
|
||
FPS = 30 # частота кадров
|
||
FRAME_WIDTH = 1280 # ширина кадра
|
||
FRAME_HEIGHT = 720 # высота кадра
|
||
JPEG_QUALITY = 5 # качество JPEG (ffmpeg q:v, 2-31, меньше = лучше)
|
||
|
||
# Временная директория для загрузок
|
||
UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "fpg_uploads")
|
||
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 МБ
|
||
|
||
# Базовый URL для share-ссылок и OG-тегов.
|
||
# По умолчанию — прод-домен; для локального запуска задайте BASE_URL,
|
||
# например https://192.168.31.248:18018
|
||
BASE_URL = os.environ.get("BASE_URL", "https://med.my-dpr.ru").rstrip("/")
|
||
|
||
# CORS для интеграции с WordPress (кросс-доменные запросы с сайта на API).
|
||
# Список разрешённых origin через запятую, по умолчанию "*" (любой).
|
||
CORS_ORIGINS = [o.strip() for o in os.environ.get("CORS_ORIGINS", "*").split(",") if o.strip()]
|
||
|
||
|
||
# Обработчики ошибок: всегда возвращаем JSON, чтобы фронтенд
|
||
# не получал HTML-страницу (иначе response.json() падает с "Unexpected token '<'").
|
||
@app.errorhandler(413)
|
||
def too_large(e):
|
||
return jsonify({"error": "Видео слишком большое (максимум 100 МБ)"}), 413
|
||
|
||
|
||
@app.errorhandler(404)
|
||
def not_found(e):
|
||
return jsonify({"error": "Не найдено"}), 404
|
||
|
||
|
||
@app.errorhandler(500)
|
||
def server_error(e):
|
||
logger.error(f"Необработанная ошибка: {e}")
|
||
return jsonify({"error": "Внутренняя ошибка сервера"}), 500
|
||
|
||
|
||
@app.after_request
|
||
def add_cors_headers(response):
|
||
origin = request.headers.get("Origin")
|
||
if "*" in CORS_ORIGINS:
|
||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||
elif origin and origin in CORS_ORIGINS:
|
||
response.headers["Access-Control-Allow-Origin"] = origin
|
||
response.headers["Vary"] = "Origin"
|
||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||
return response
|
||
|
||
# ─── Логирование ──────────────────────────────────────────────
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
datefmt="%Y-%m-%d %H:%M:%S",
|
||
)
|
||
logger = logging.getLogger("fpg_app")
|
||
|
||
|
||
# ─── HTML-шаблон ──────────────────────────────────────────────
|
||
# ─── SDK-функции ──────────────────────────────────────────────
|
||
def get_token():
|
||
"""Получить access token."""
|
||
import requests
|
||
resp = requests.post(
|
||
OAUTH_URL,
|
||
data={
|
||
"grant_type": "client_credentials",
|
||
"client_id": CLIENT_ID,
|
||
"client_secret": CLIENT_SECRET,
|
||
},
|
||
timeout=15,
|
||
)
|
||
if resp.status_code != 200:
|
||
raise RuntimeError(f"Ошибка авторизации: {resp.status_code}")
|
||
return resp.json()["access_token"]
|
||
|
||
|
||
def create_session(token: str) -> str:
|
||
"""Создать сессию, вернуть uid."""
|
||
import requests
|
||
resp = requests.post(
|
||
f"{REST_BASE}/api/v1/predict/new",
|
||
params={"device_id": DEVICE_ID, "form_factor": FORM_FACTOR},
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
timeout=15,
|
||
)
|
||
if resp.status_code != 200:
|
||
raise RuntimeError(f"Ошибка создания сессии: {resp.status_code}")
|
||
return resp.json()["uid"]
|
||
|
||
|
||
def delete_session(token: str, uid: str):
|
||
"""Удалить сессию."""
|
||
import requests
|
||
try:
|
||
requests.delete(
|
||
f"{REST_BASE}/api/v1/predict/{uid}",
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
timeout=15,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
|
||
"""Отправить кадры через WebSocket, вернуть результат."""
|
||
import websockets
|
||
|
||
uri = f"wss://{WS_HOSTNAME}:{WS_PORT}{WS_PATH_TEMPLATE.format(uid=uid)}"
|
||
interval = 1.0 / fps
|
||
|
||
try:
|
||
async with websockets.connect(
|
||
uri,
|
||
open_timeout=10,
|
||
subprotocols=[f"bearer.{token}"],
|
||
origin=WS_ORIGIN,
|
||
extra_headers={
|
||
"Cache-Control": "no-cache",
|
||
"Pragma": "no-cache",
|
||
},
|
||
compression="deflate",
|
||
ping_interval=20,
|
||
ping_timeout=20,
|
||
max_size=None,
|
||
) as ws:
|
||
result = None
|
||
last_progress = -1
|
||
for i, frame_file in enumerate(frame_files):
|
||
frame_start = time.time()
|
||
with open(frame_file, "rb") as f:
|
||
img_data = f.read()
|
||
await ws.send(img_data)
|
||
|
||
try:
|
||
resp = await asyncio.wait_for(ws.recv(), timeout=2.0)
|
||
data = json.loads(resp)
|
||
progress = data.get("progress", 0.0)
|
||
# Логируем прогресс при изменении (для диагностики)
|
||
pct = int(progress * 100)
|
||
if pct != last_progress:
|
||
logger.info(
|
||
f" [{i+1}/{len(frame_files)}] progress={progress:.2f} "
|
||
f"face_ok={data.get('face_ok')}"
|
||
)
|
||
last_progress = pct
|
||
if progress >= 1.0:
|
||
result = data
|
||
break
|
||
except (asyncio.TimeoutError, json.JSONDecodeError):
|
||
pass
|
||
|
||
elapsed = time.time() - frame_start
|
||
if elapsed < interval:
|
||
await asyncio.sleep(interval - elapsed)
|
||
|
||
if result is None:
|
||
logger.warning(
|
||
f"Финальный результат не получен после {len(frame_files)} кадров"
|
||
)
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"WebSocket error: {e}")
|
||
return None
|
||
|
||
|
||
def probe_video(video_path: str) -> dict:
|
||
"""Получить информацию о видео через ffprobe (кодек, цвета, поворот, FPS)."""
|
||
cmd = [
|
||
"ffprobe", "-v", "error",
|
||
"-select_streams", "v:0",
|
||
"-show_entries",
|
||
"stream=codec_name,width,height,pix_fmt,avg_frame_rate,color_space,color_transfer,color_primaries",
|
||
"-of", "json",
|
||
video_path,
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"Ошибка чтения видео: {result.stderr[-300:]}")
|
||
try:
|
||
data = json.loads(result.stdout)
|
||
except json.JSONDecodeError:
|
||
return {}
|
||
streams = data.get("streams") or [{}]
|
||
info = streams[0] if streams else {}
|
||
|
||
# Поворот: в ffmpeg 5+ это stream_side_data (Display Matrix),
|
||
# в 4.x — тег rotate. Пробуем оба варианта.
|
||
try:
|
||
r = subprocess.run(
|
||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||
"-show_entries", "stream_side_data=rotation", "-of", "json", video_path],
|
||
capture_output=True, text=True, timeout=30,
|
||
)
|
||
if r.returncode == 0:
|
||
sd = (json.loads(r.stdout).get("streams") or [{}])[0].get("side_data_list") or []
|
||
for item in sd:
|
||
if "rotation" in item:
|
||
info["rotation"] = item["rotation"]
|
||
except Exception:
|
||
pass
|
||
if "rotation" not in info:
|
||
try:
|
||
r = subprocess.run(
|
||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||
"-show_entries", "stream_tags=rotate", "-of", "json", video_path],
|
||
capture_output=True, text=True, timeout=30,
|
||
)
|
||
if r.returncode == 0:
|
||
tags = (json.loads(r.stdout).get("streams") or [{}])[0].get("tags") or {}
|
||
if "rotate" in tags:
|
||
info["rotation"] = tags["rotate"]
|
||
except Exception:
|
||
pass
|
||
return info
|
||
|
||
|
||
_FFMPEG_MAJOR = None
|
||
|
||
|
||
def ffmpeg_major_version() -> int:
|
||
"""Версия ffmpeg (кэшируется). Нужна для выбора параметров colorspace."""
|
||
global _FFMPEG_MAJOR
|
||
if _FFMPEG_MAJOR is None:
|
||
try:
|
||
r = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, timeout=10)
|
||
m = re.search(r"ffmpeg version (\d+)\.", r.stdout)
|
||
_FFMPEG_MAJOR = int(m.group(1)) if m else 0
|
||
except Exception:
|
||
_FFMPEG_MAJOR = 0
|
||
return _FFMPEG_MAJOR
|
||
|
||
|
||
def extract_frames(video_path: str, work_dir: str) -> list:
|
||
"""Извлечь кадры из видео в JPEG с нормализацией.
|
||
|
||
Телефонные видео отличаются от записанных через сайт:
|
||
- поворот (rotation metadata) — ffmpeg применяет autorotate по умолчанию;
|
||
- HDR/P3 (iPhone: bt2020 + smpte2084) — конвертируем в bt709, иначе кадры
|
||
выходят с искажёнными цветами и SDK не находит лицо;
|
||
- переменный FPS — нормализуем до 30.
|
||
"""
|
||
frames_dir = os.path.join(work_dir, "frames")
|
||
os.makedirs(frames_dir, exist_ok=True)
|
||
|
||
info = probe_video(video_path)
|
||
color_space = info.get("color_space") or ""
|
||
color_transfer = info.get("color_transfer") or ""
|
||
color_primaries = info.get("color_primaries") or ""
|
||
|
||
filters = []
|
||
|
||
# HDR/P3 → SDR (bt709): реальная конверсия цветов (не только теги).
|
||
# Без неё кадры из iPhone-видео (bt2020/P3, HDR) выглядят «выцветшими»
|
||
# и SDK не находит лицо.
|
||
is_hdr = color_transfer in ("smpte2084", "arib-std-b67") or color_primaries in ("bt2020", "smpte432")
|
||
if is_hdr:
|
||
if ffmpeg_major_version() >= 5:
|
||
# ffmpeg 5+ знает smpte2084/arib-std-b67 — точная конверсия.
|
||
ispace = color_space if color_space in ("bt2020nc", "bt2020c") else "bt2020nc"
|
||
itrc = color_transfer if color_transfer in ("smpte2084", "arib-std-b67") else "bt709"
|
||
iprimaries = color_primaries if color_primaries in ("bt2020", "smpte432") else "bt2020"
|
||
else:
|
||
# ffmpeg 4.x: нет smpte2084/arib-std-b67 в colorspace (tonemap даёт
|
||
# чёрный кадр) — используем приблизительные значения bt2020.
|
||
ispace, itrc, iprimaries = "bt2020nc", "bt2020-10", "bt2020"
|
||
filters.append(f"colorspace=bt709:ispace={ispace}:itrc={itrc}:iprimaries={iprimaries}")
|
||
|
||
# Масштабируем до 1280x720 (с сохранением пропорций и добавлением полей).
|
||
filters.append(
|
||
f"scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,"
|
||
f"pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2"
|
||
)
|
||
|
||
# Нормализуем FPS (телефонные видео часто с переменным FPS).
|
||
filters.append("fps=30")
|
||
|
||
# 8-бит yuv420p (SDK ожидает такой формат).
|
||
filters.append("format=yuv420p")
|
||
|
||
cmd = [
|
||
"ffmpeg", "-y",
|
||
"-i", video_path,
|
||
"-color_primaries", "bt709",
|
||
"-color_trc", "bt709",
|
||
"-colorspace", "bt709",
|
||
"-vf", ",".join(filters),
|
||
"-q:v", str(JPEG_QUALITY),
|
||
"-t", "20", # берём первые 20 секунд (достаточно для 300 кадров)
|
||
"-frames:v", str(FRAMES_COUNT),
|
||
os.path.join(frames_dir, "frame_%04d.jpg"),
|
||
]
|
||
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"Ошибка извлечения кадров: {result.stderr[-500:]}")
|
||
|
||
frames = sorted(glob.glob(os.path.join(frames_dir, "*.jpg")))
|
||
if not frames:
|
||
raise RuntimeError("Не удалось извлечь кадры из видео")
|
||
return frames
|
||
|
||
|
||
# ─── Flask-маршруты ───────────────────────────────────────────
|
||
@app.route("/")
|
||
def index():
|
||
return render_template("index.html")
|
||
|
||
|
||
@app.route("/r/<share_id>")
|
||
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
|
||
|
||
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)
|
||
|
||
|
||
@app.route("/download/<share_id>/pdf")
|
||
def download_pdf(share_id):
|
||
"""Скачать результат в PDF."""
|
||
try:
|
||
data = export.generate_pdf(share_id)
|
||
except ValueError:
|
||
return "Результат не найден", 404
|
||
except Exception as e:
|
||
logger.error(f"Ошибка генерации PDF для {share_id}: {e}")
|
||
return "Ошибка генерации PDF", 500
|
||
|
||
return Response(
|
||
data,
|
||
mimetype="application/pdf",
|
||
headers={"Content-Disposition": f'attachment; filename="diagnostic_{share_id}.pdf"'},
|
||
)
|
||
|
||
|
||
@app.route("/download/<share_id>/docx")
|
||
def download_docx(share_id):
|
||
"""Скачать результат в DOCX."""
|
||
try:
|
||
data = export.generate_docx(share_id)
|
||
except ValueError:
|
||
return "Результат не найден", 404
|
||
except Exception as e:
|
||
logger.error(f"Ошибка генерации DOCX для {share_id}: {e}")
|
||
return "Ошибка генерации DOCX", 500
|
||
|
||
return Response(
|
||
data,
|
||
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
headers={"Content-Disposition": f'attachment; filename="diagnostic_{share_id}.docx"'},
|
||
)
|
||
|
||
|
||
@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)
|
||
if request.method == "OPTIONS":
|
||
return "", 204
|
||
|
||
if "video" not in request.files:
|
||
return jsonify({"error": "Видео не загружено"}), 400
|
||
|
||
video = request.files["video"]
|
||
if video.filename == "":
|
||
return jsonify({"error": "Файл не выбран"}), 400
|
||
|
||
# Проверяем расширение
|
||
allowed = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v"}
|
||
ext = os.path.splitext(video.filename)[1].lower()
|
||
if ext not in allowed:
|
||
return jsonify({"error": f"Неподдерживаемый формат. Разрешены: {', '.join(sorted(allowed))}"}), 400
|
||
|
||
# Создаём рабочую директорию
|
||
job_id = uuid.uuid4().hex[:8]
|
||
work_dir = os.path.join(UPLOAD_DIR, job_id)
|
||
os.makedirs(work_dir, exist_ok=True)
|
||
|
||
video_path = os.path.join(work_dir, f"input{ext}")
|
||
try:
|
||
video.save(video_path)
|
||
except Exception as e:
|
||
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:
|
||
# 1. Извлекаем кадры
|
||
logger.info(f"[{job_id}] Извлечение кадров из {video.filename}")
|
||
frames = extract_frames(video_path, work_dir)
|
||
logger.info(f"[{job_id}] Извлечено кадров: {len(frames)}")
|
||
|
||
# 2. Авторизация
|
||
logger.info(f"[{job_id}] Авторизация")
|
||
token = get_token()
|
||
|
||
# 3. Создание сессии
|
||
logger.info(f"[{job_id}] Создание сессии")
|
||
uid = create_session(token)
|
||
|
||
# 4. Отправка кадров через WebSocket
|
||
logger.info(f"[{job_id}] Отправка кадров через WebSocket")
|
||
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. Интерпретация
|
||
logger.info(f"[{job_id}] Интерпретация результата")
|
||
interpretation = interpret_result(result)
|
||
|
||
# 6. Сохранение результата
|
||
logger.info(f"[{job_id}] Сохранение результата")
|
||
share_id = storage.save_result(interpretation, result)
|
||
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
|
||
|
||
finally:
|
||
# 6. Удаление сессии
|
||
if token and uid:
|
||
try:
|
||
delete_session(token, uid)
|
||
except Exception:
|
||
pass
|
||
# Очистка временных файлов
|
||
try:
|
||
shutil.rmtree(work_dir, ignore_errors=True)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
if __name__ == "__main__":
|
||
print("=" * 60)
|
||
print("🩺 Диагностика по видео — ФПГ")
|
||
print(f"Откройте в браузере: http://localhost:5000")
|
||
print("=" * 60)
|
||
|
||
# HTTPS (для доступа к камере из локальной сети нужен безопасный контекст).
|
||
# Задайте CERT_FILE и KEY_FILE (самоподписанный сертификат) — сервер поднимется по HTTPS.
|
||
cert_file = os.environ.get("CERT_FILE")
|
||
key_file = os.environ.get("KEY_FILE")
|
||
ssl_context = None
|
||
if cert_file and key_file:
|
||
ssl_context = (cert_file, key_file)
|
||
print(f"HTTPS включён: {cert_file}")
|
||
|
||
app.run(host="0.0.0.0", port=18018, debug=False, ssl_context=ssl_context)
|