From 41485072bffa6ec28bdb68ba44ee245f48b31bb7 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Fri, 21 Aug 2026 13:37:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20sync=5Fevents=20=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E8=A1=A5=20JOIN=20sync=5Fvideos=20=E5=8F=96=20camera=5Fname(?= =?UTF-8?q?=E4=BF=AE=201054=20=E6=8A=A5=E9=94=99)=EF=BC=9Bfeat(=E7=BC=A9?= =?UTF-8?q?=E7=95=A5=E5=9B=BE):=20Oracle=20=E5=A4=84=E7=90=86=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E5=90=8E=E6=8A=BD=E9=A6=96=E5=B8=A7=E5=AD=98=20thumbs?= =?UTF-8?q?/{id}.jpg=20+=20/api/oracle/video/{id}/thumb=20=E5=B8=A6=20toke?= =?UTF-8?q?n=20=E6=8E=A5=E5=8F=A3=EF=BC=9Bfam-ui=20=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=98=BE=E7=A4=BA=E7=BC=A9=E7=95=A5=E5=9B=BE?= =?UTF-8?q?(oracle=5Furl+token)=EF=BC=9Bstart=5Fui.sh=20=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/fam_edge/api_gateway/api_gateway.py | 16 +++++++++- fam-edge/src/fam_edge/video_processor.py | 32 +++++++++++++++++++ fam-ui/config/config.yaml | 6 +++- fam-ui/src/app.py | 19 +++++++++-- scripts/start_ui.sh | 5 +++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/fam-edge/src/fam_edge/api_gateway/api_gateway.py b/fam-edge/src/fam_edge/api_gateway/api_gateway.py index 0a41f76..28efd4e 100644 --- a/fam-edge/src/fam_edge/api_gateway/api_gateway.py +++ b/fam-edge/src/fam_edge/api_gateway/api_gateway.py @@ -12,7 +12,7 @@ API-Gateway - Flask 蓝图(新架构 v2) """ import os -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, send_file from ..logger import setup_logger from .. import state @@ -116,6 +116,20 @@ def chat_ask(): return jsonify({"answer": answer, "provider": provider}), 200 +@api_bp.route('/api/oracle/video//thumb', methods=['GET']) +def video_thumb(video_id): + """返回视频首帧缩略图(token 校验,不公网裸奔)。 + + 缩略图由 video_processor 处理成功后生成于 /opt/fam-edge/thumbs/{video_id}.jpg。 + """ + if not _check_token(): + return jsonify({"error": "unauthorized"}), 401 + path = f"/opt/fam-edge/thumbs/{video_id}.jpg" + if not os.path.isfile(path): + return jsonify({"error": "thumb_not_found"}), 404 + return send_file(path, mimetype='image/jpeg') + + @api_bp.route('/health', methods=['GET']) def health(): """健康检查""" diff --git a/fam-edge/src/fam_edge/video_processor.py b/fam-edge/src/fam_edge/video_processor.py index 916707d..efec9a2 100644 --- a/fam-edge/src/fam_edge/video_processor.py +++ b/fam-edge/src/fam_edge/video_processor.py @@ -140,6 +140,10 @@ class VideoProcessor: }) self.db.mark_video_processed(video_id, summary, norm_events, people, provider) + # 抽首帧生成缩略图(供前端展示;失败不影响主流程) + row = self.db.get_video_by_id(video_id) + if row and row['local_path']: + self._generate_thumb(video_id, row['local_path']) # 更新 people 表(标签级,待 person_service 合并) for p in people: @@ -147,3 +151,31 @@ class VideoProcessor: self.db.upsert_person(p, source='llm') logger.info(f"[video_id={video_id}] 已落库: summary={len(summary)}字, " f"events={len(norm_events)}, people={people}") + + def _generate_thumb(self, video_id: int, video_path: str) -> bool: + """抽视频首帧生成 JPEG 缩略图(/opt/fam-edge/thumbs/{video_id}.jpg)""" + try: + import cv2 + db_path = self.config.get('oracle_db', {}).get( + 'path', '/opt/fam-edge/data/oracle.db') + thumbs_dir = os.path.abspath(os.path.join( + os.path.dirname(db_path), '..', 'thumbs')) + os.makedirs(thumbs_dir, exist_ok=True) + out = os.path.join(thumbs_dir, f"{video_id}.jpg") + cap = cv2.VideoCapture(video_path) + try: + ok, frame = cap.read() + finally: + cap.release() + if not ok or frame is None: + logger.warning(f"抽帧失败 video_id={video_id}: 无法读取首帧") + return False + h, w = frame.shape[:2] + if w > 640: + frame = cv2.resize(frame, (640, int(h * 640 / w))) + cv2.imwrite(out, frame, [cv2.IMWRITE_JPEG_QUALITY, 65]) + logger.info(f"缩略图已生成: {out}") + return True + except Exception as e: + logger.warning(f"抽帧异常 video_id={video_id}: {e}") + return False diff --git a/fam-ui/config/config.yaml b/fam-ui/config/config.yaml index f063bf0..e5120fe 100644 --- a/fam-ui/config/config.yaml +++ b/fam-ui/config/config.yaml @@ -1,8 +1,12 @@ # FAM-UI 配置文件 (NAS 端) - 新架构 v2(2026-08-21) -# NAS 仅作管理后台,前端读本地 MariaDB 同步镜像,不再读取关键帧图片。 +# NAS 仅作管理后台,前端读本地 MariaDB 同步镜像;视频缩略图经 Oracle 带 token 接口获取。 core_url: "http://127.0.0.1:8000" +# 甲骨文 FAM-Edge 地址(视频缩略图接口,与同步同 token) +oracle_url: "http://129.146.203.203:5000" +oracle_token: "${ORACLE_SYNC_TOKEN}" + database: host: "127.0.0.1" port: 3306 diff --git a/fam-ui/src/app.py b/fam-ui/src/app.py index bf48d92..02ff341 100644 --- a/fam-ui/src/app.py +++ b/fam-ui/src/app.py @@ -25,6 +25,8 @@ from config_loader import load_config _cfg = load_config() _core_url = _cfg.get('core_url', 'http://127.0.0.1:8000') +_oracle_url = (_cfg.get('oracle_url') or '').rstrip('/') +_oracle_token = _cfg.get('oracle_token') or '' _db_cfg = _cfg.get('database', {}) esc = _html.escape @@ -518,8 +520,18 @@ if page == "🕒 事件时间轴": f'{esc(p)}' for p in str(provider).split(',')) summary = vid.get('summary_json') or '暂无全局摘要' + # 视频缩略图(Oracle 带 token 接口,无图时优雅降级) + thumb_html = '' + if _oracle_url: + thumb_html = ( + f'') st.markdown( f'
' + f'{thumb_html}' f'
' f'{esc(vid.get("camera_name") or "未知摄像头")}' f'会话 #{vid["id"]}' @@ -534,8 +546,11 @@ if page == "🕒 事件时间轴": try: cursor = conn.cursor() cursor.execute( - """SELECT ts, description, person_list_json, is_attention_event, camera_name - FROM sync_events WHERE video_id=%s ORDER BY ts ASC""", + """SELECT e.ts, e.description, e.person_list_json, + e.is_attention_event, v.camera_name + FROM sync_events e + JOIN sync_videos v ON e.video_id = v.id + WHERE e.video_id=%s ORDER BY e.ts ASC""", (selected_id,)) events = cursor.fetchall() finally: diff --git a/scripts/start_ui.sh b/scripts/start_ui.sh index 2a63ecf..668ee9a 100755 --- a/scripts/start_ui.sh +++ b/scripts/start_ui.sh @@ -14,6 +14,11 @@ fi cd "$APP_DIR" +# 注入项目级 .env(ORACLE_SYNC_TOKEN 等),供 config.yaml ${VAR} 解析 +if [ -f "$(dirname "$APP_DIR")/.env" ]; then + source "$(dirname "$APP_DIR")/.env" +fi + # 检查虚拟环境 if [ -d "venv" ]; then source venv/bin/activate