From 7fa327aa2777f7644a05d68333154d9e7a790aa5 Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Fri, 26 Jun 2026 18:11:07 +0800 Subject: [PATCH 1/3] fix(p1): resolve 4 P1 technical debt issues - P1-1: CORS configuration security - use DEBUG mode to differentiate production vs development CORS settings - P1-2: Implement token refresh logic in RefreshTokenUseCase - Add get_session_by_refresh_token to SessionStore - Verify session validity and expiry - Generate new access token on refresh - P1-3: Fix database connection leak in worker ingest task - Add proper try-except-finally block - Ensure db.close() is always called - P1-4: Implement real media metadata extraction - Use ffprobe for video metadata - Use Pillow for image metadata - Return empty dict on failure (no mock data) --- apps/api/main.py | 33 +++- apps/worker/worker_app/tasks/ingest.py | 162 +++++++++++++++++--- packages/adapters/redis/session_store.py | 41 +++++ packages/application/auth/login_use_case.py | 74 +++++++-- 4 files changed, 267 insertions(+), 43 deletions(-) mode change 100644 => 100755 apps/api/main.py mode change 100644 => 100755 apps/worker/worker_app/tasks/ingest.py mode change 100644 => 100755 packages/adapters/redis/session_store.py mode change 100644 => 100755 packages/application/auth/login_use_case.py diff --git a/apps/api/main.py b/apps/api/main.py old mode 100644 new mode 100755 index 865c89184..b8dc0d2fd --- a/apps/api/main.py +++ b/apps/api/main.py @@ -30,13 +30,32 @@ app.add_exception_handler(StarletteHTTPException, http_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(Exception, general_exception_handler) -app.add_middleware( - CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +# CORS 配置:根据 DEBUG 模式区分 +# 生产环境:allow_credentials=True 时不能使用通配符 "*" +if settings.DEBUG: + # 开发环境:允许所有来源(方便本地调试) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) +else: + # 生产环境:严格限制来源和方法 + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=[ + "Authorization", + "Content-Type", + "X-Request-ID", + "X-Correlation-ID", + ], + ) + app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(RequestLoggingMiddleware) diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py old mode 100644 new mode 100755 index f6ffe9965..36e441db3 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -1,4 +1,10 @@ +import subprocess from datetime import datetime, timezone +from typing import Optional + +from celery import Celery +from celery.app.task import Task +from celery.utils.log import get_task_logger from worker_app.celery_app import celery_app from worker_app.core.asset_types import infer_mime_type_from_storage_key @@ -10,6 +16,87 @@ from packages.adapters.sqlalchemy_impl import ( ) from packages.domain import Asset, AssetStatus, IngestJobStatus +logger = get_task_logger(__name__) + + +def extract_media_metadata(file_url: str, media_type: str) -> dict: + """ + 提取媒体文件的元数据。 + + Args: + file_url: 媒体文件 URL 或本地路径 + media_type: 媒体类型 (video, audio, image) + + Returns: + 提取的元数据字典,失败时返回空字典 + """ + metadata = {} + + try: + if media_type == "video": + # 使用 ffprobe 提取视频元数据 + cmd = [ + "ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + file_url, + ] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + import json as json_lib + + probe_data = json_lib.loads(result.stdout) + + # 提取视频流信息 + for stream in probe_data.get("streams", []): + if stream.get("codec_type") == "video": + metadata["width"] = int(stream.get("width", 0)) + metadata["height"] = int(stream.get("height", 0)) + metadata["codec"] = stream.get("codec_name", "") + metadata["fps"] = eval(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0 + break + + # 提取格式信息 + format_info = probe_data.get("format", {}) + metadata["duration"] = float(format_info.get("duration", 0)) + metadata["size_bytes"] = int(format_info.get("size", 0)) + metadata["bitrate"] = int(format_info.get("bit_rate", 0)) + + elif media_type == "image": + # 使用 Pillow 提取图片元数据 + try: + from PIL import Image + + with Image.open(file_url) as img: + metadata["width"] = img.width + metadata["height"] = img.height + metadata["format"] = img.format + metadata["mode"] = img.mode + if hasattr(img, "_getexif") and img._getexif(): + exif = img._getexif() + if exif: + metadata["exif"] = {k: str(v) for k, v in exif.items() if isinstance(v, (str, int, float))} + except ImportError: + logger.warning("Pillow not available for image metadata extraction") + except Exception as e: + logger.warning(f"Failed to extract image metadata: {e}") + + except subprocess.TimeoutExpired: + logger.warning(f"Timeout extracting metadata from {file_url}") + except FileNotFoundError: + logger.warning(f"ffprobe not found, cannot extract video metadata") + except Exception as e: + logger.warning(f"Failed to extract metadata: {e}") + + return metadata + @celery_app.task(name="worker.ingest_asset") def ingest_asset(job_id: str) -> dict: @@ -18,34 +105,49 @@ def ingest_asset(job_id: str) -> dict: Steps: 1. Fetch IngestJob from repository - 2. Extract metadata from storage_key (placeholder: mock metadata) + 2. Extract metadata from storage_key 3. Create Asset entity 4. Update IngestJob status to COMPLETED 5. Return result """ db = SessionLocal() - job_repo = SQLAlchemyIngestJobRepository(db) - asset_repo = SQLAlchemyAssetRepository(db) - - job = job_repo.get(job_id) - if job is None: - return {"status": "failed", "error": "job not found"} - try: + job_repo = SQLAlchemyIngestJobRepository(db) + asset_repo = SQLAlchemyAssetRepository(db) + + job = job_repo.get(job_id) + if job is None: + return {"status": "failed", "error": "job not found"} + # Update job status to PROCESSING job.status = IngestJobStatus.PROCESSING job.updated_at = datetime.now(timezone.utc) job_repo.update(job) + db.commit() - # Mock metadata extraction (in real implementation: use ffprobe, Pillow, etc.) + # Extract real metadata from media file filename = job.storage_key.split("/")[-1] mime_type = infer_mime_type_from_storage_key(job.storage_key) - metadata = { - "duration": 10.5, - "width": 1920, - "height": 1080, - "size_bytes": 1024000, - } + + # Determine media type from mime_type + media_type = "video" + if mime_type.startswith("image/"): + media_type = "image" + elif mime_type.startswith("audio/"): + media_type = "audio" + + # Extract metadata (returns empty dict on failure) + storage_url = job.storage_key # Assuming storage_key is usable as URL/path + metadata = extract_media_metadata(storage_url, media_type) + + # Fill in defaults if metadata extraction failed + if not metadata: + metadata = { + "duration": 0, + "width": 0, + "height": 0, + "size_bytes": 0, + } # Create Asset asset = Asset.create( @@ -56,10 +158,10 @@ def ingest_asset(job_id: str) -> dict: storage_key=job.storage_key, mime_type=mime_type, metadata=metadata, - file_size=int(metadata["size_bytes"]), - duration=float(metadata["duration"]), - width=int(metadata["width"]), - height=int(metadata["height"]), + file_size=int(metadata.get("size_bytes", 0)), + duration=float(metadata.get("duration", 0)), + width=int(metadata.get("width", 0)), + height=int(metadata.get("height", 0)), status=AssetStatus.READY, ) asset_repo.create(asset) @@ -70,21 +172,33 @@ def ingest_asset(job_id: str) -> dict: job.updated_at = datetime.now(timezone.utc) job_repo.update(job) + db.commit() + return { "status": "completed", "job_id": job.id, "asset_id": asset.id, } except Exception as e: + db.rollback() + logger.error(f"Failed to ingest asset {job_id}: {e}") + # Update job status to FAILED - job.status = IngestJobStatus.FAILED - job.error_message = str(e) - job.updated_at = datetime.now(timezone.utc) - job_repo.update(job) + try: + job_repo = SQLAlchemyIngestJobRepository(db) + job = job_repo.get(job_id) + if job: + job.status = IngestJobStatus.FAILED + job.error_message = str(e) + job.updated_at = datetime.now(timezone.utc) + job_repo.update(job) + db.commit() + except Exception: + db.rollback() return { "status": "failed", - "job_id": job.id, + "job_id": job_id, "error": str(e), } finally: diff --git a/packages/adapters/redis/session_store.py b/packages/adapters/redis/session_store.py old mode 100644 new mode 100755 index 1378ca8c8..e252a38a3 --- a/packages/adapters/redis/session_store.py +++ b/packages/adapters/redis/session_store.py @@ -28,6 +28,9 @@ class NoopSessionStore: def get_session(self, session_id: str) -> Optional[dict]: return None + def get_session_by_refresh_token(self, refresh_token: str) -> Optional[dict]: + return None + def get_refresh_token(self, session_id: str) -> Optional[str]: return None @@ -78,6 +81,10 @@ class SessionStore: """生成 refresh_token key""" return f"refresh_token:{session_id}" + def _refresh_token_to_session_key(self, refresh_token: str) -> str: + """生成 refresh_token -> session_id 的反向映射 key""" + return f"refresh_token_map:{refresh_token}" + def _user_sessions_key(self, user_id: str) -> str: """生成用户所有 Session 的 key""" return f"user_sessions:{user_id}" @@ -127,6 +134,10 @@ class SessionStore: refresh_token_key = self._refresh_token_key(session_id) self.redis.setex(refresh_token_key, expires_in_seconds, refresh_token) + # 保存 refresh_token -> session_id 的反向映射 + refresh_token_map_key = self._refresh_token_to_session_key(refresh_token) + self.redis.setex(refresh_token_map_key, expires_in_seconds, session_id) + # 添加到用户的 Session 集合 user_sessions_key = self._user_sessions_key(user_id) self.redis.sadd(user_sessions_key, session_id) @@ -158,6 +169,30 @@ class SessionStore: print(f"Failed to get session: {e}") return None + def get_session_by_refresh_token(self, refresh_token: str) -> Optional[dict]: + """ + 通过 refresh_token 获取 Session + + Args: + refresh_token: 刷新令牌 + + Returns: + Session 数据,如果不存在返回 None + """ + try: + # 先通过反向映射找到 session_id + refresh_token_map_key = self._refresh_token_to_session_key(refresh_token) + session_id = self.redis.get(refresh_token_map_key) + + if not session_id: + return None + + # 再获取完整的 session 数据 + return self.get_session(session_id) + except Exception as e: + print(f"Failed to get session by refresh_token: {e}") + return None + def get_refresh_token(self, session_id: str) -> Optional[str]: """ 获取 refresh_token @@ -227,8 +262,14 @@ class SessionStore: # 删除 refresh_token refresh_token_key = self._refresh_token_key(session_id) + refresh_token = self.redis.get(refresh_token_key) self.redis.delete(refresh_token_key) + # 删除反向映射 + if refresh_token: + refresh_token_map_key = self._refresh_token_to_session_key(refresh_token) + self.redis.delete(refresh_token_map_key) + # 从用户 Session 集合中移除 user_sessions_key = self._user_sessions_key(user_id) self.redis.srem(user_sessions_key, session_id) diff --git a/packages/application/auth/login_use_case.py b/packages/application/auth/login_use_case.py old mode 100644 new mode 100755 index 409f4fed6..3aed170fc --- a/packages/application/auth/login_use_case.py +++ b/packages/application/auth/login_use_case.py @@ -10,6 +10,7 @@ from typing import Optional import jwt as pyjwt from packages.adapters.redis import get_session_store +from packages.adapters.redis.session_store import SessionStore, NoopSessionStore from packages.domain.auth import jwt_service, password_hasher LEGACY_SHA256_HEX_LENGTH = 64 @@ -68,7 +69,7 @@ class LoginUseCase: def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None): self.user_repository = user_repository - self.session_store = session_store or get_session_store() + self.session_store: SessionStore | NoopSessionStore = session_store or get_session_store() self.jwt_secret_key = jwt_secret_key or jwt_service.config.SECRET_KEY def execute(self, request: LoginRequest) -> tuple[Optional[LoginResponse], Optional[str]]: @@ -171,8 +172,10 @@ class RefreshTokenRequest: class RefreshTokenUseCase: """刷新令牌用例""" - def __init__(self, user_repository): + def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None): self.user_repository = user_repository + self.session_store: SessionStore | NoopSessionStore = session_store or get_session_store() + self.jwt_secret_key = jwt_secret_key or jwt_service.config.SECRET_KEY def execute(self, request: RefreshTokenRequest) -> tuple[Optional[LoginResponse], Optional[str]]: """ @@ -188,18 +191,65 @@ class RefreshTokenUseCase: if not request.refresh_token: return None, "Refresh token is required" - # 1. 查找 session(通过遍历所有 session) - # 注意:这里为了简化,先用遍历实现,生产环境应该用 refresh_token -> session_id 的索引 - session = None - session_id = None + # 1. 通过 refresh_token 查找 session + session = self.session_store.get_session_by_refresh_token(request.refresh_token) + if not session: + return None, "Invalid or expired refresh token" - # 这是一个简化实现,实际应该在 SessionStore 中添加 find_by_refresh_token 方法 - # 这里我们假设 refresh_token 就是 session_id(简化处理) - # 生产环境需要更复杂的映射 + # 2. 检查 session 是否过期 + session_id = session.get("session_id") + if not session_id: + return None, "Invalid session data" - # 临时方案:从 Redis 获取(需要在 session_store 中添加方法) - # 现在先返回错误,提示需要实现 - return None, "Refresh token implementation pending (需要完善 session_store)" + # 3. 检查 session 是否在有效期内 + expires_at_str = session.get("expires_at") + if expires_at_str: + expires_at = datetime.fromisoformat(expires_at_str) + if datetime.now(timezone.utc) > expires_at: + # session 已过期,删除它 + self.session_store.delete_session(session_id) + return None, "Session has expired, please login again" + + # 4. 获取用户信息 + user_id = session.get("user_id") + if not user_id: + return None, "Invalid session: missing user_id" + + user = self.user_repository.get(user_id) + if not user: + return None, "User not found" + + # 5. 生成新的 access_token + now = datetime.now(timezone.utc) + access_token_payload = { + "sub": user.id, + "sid": session_id, + "type": "user_auth", + "iat": now, + "exp": now + timedelta(minutes=jwt_service.config.ACCESS_TOKEN_EXPIRE_MINUTES), + } + access_token = pyjwt.encode( + access_token_payload, + self.jwt_secret_key, + algorithm=jwt_service.config.ALGORITHM, + ) + + # 6. 更新 session 的最后活跃时间 + self.session_store.update_last_active(session_id) + + # 7. 返回新的登录响应(refresh_token 保持不变) + return ( + LoginResponse( + access_token=access_token, + refresh_token=request.refresh_token, # 保持原有的 refresh_token + user_id=user.id, + email=user.email, + username=user.username, + display_name=user.display_name, + expires_in=jwt_service.config.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ), + None, + ) except Exception as e: return None, f"Token refresh failed: {str(e)}" -- 2.54.0 From 62ec1473eacd9e15749bb4082eb2117cc058ba3b Mon Sep 17 00:00:00 2001 From: AI Agent Date: Fri, 26 Jun 2026 19:54:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=B4=A0=E6=9D=90AI=E5=88=86=E7=B1=BB=E5=92=8C?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E8=B4=A8=E9=87=8F=E8=AF=84=E4=BC=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker/worker_app/tasks/asset_analyzer.py | 793 ++++++++++++++++++ .../worker/worker_app/tasks/classification.py | 64 +- apps/worker/worker_app/tasks/ingest.py | 48 +- requirements.txt | 4 + 4 files changed, 890 insertions(+), 19 deletions(-) create mode 100755 apps/worker/worker_app/tasks/asset_analyzer.py mode change 100644 => 100755 apps/worker/worker_app/tasks/classification.py mode change 100644 => 100755 requirements.txt diff --git a/apps/worker/worker_app/tasks/asset_analyzer.py b/apps/worker/worker_app/tasks/asset_analyzer.py new file mode 100755 index 000000000..b90a696a4 --- /dev/null +++ b/apps/worker/worker_app/tasks/asset_analyzer.py @@ -0,0 +1,793 @@ +""" +视频素材分析器 - 基于 FFmpeg + NumPy 的轻量级智能分析 + +提供: +1. 素材分类 - 基于视频特征的多维度分析 +2. 质量评分 - 基于分辨率、帧率、码率、清晰度、稳定性的综合评分 +""" + +from __future__ import annotations + +import json +import logging +import math +import os +import subprocess +import tempfile +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +from packages.domain.classification import AssetClassification + +logger = logging.getLogger(__name__) + + +@dataclass +class VideoInfo: + """视频基本信息""" + width: int = 0 + height: int = 0 + fps: float = 0.0 + duration: float = 0.0 + bitrate: int = 0 + codec: str = "" + has_audio: bool = False + file_size: int = 0 + + +@dataclass +class ColorAnalysis: + """色彩分析结果""" + dominant_hue: float = 0.0 # 主色调 (0-360) + green_ratio: float = 0.0 # 绿色占比 + warm_ratio: float = 0.0 # 暖色调占比 + cool_ratio: float = 0.0 # 冷色调占比 + avg_saturation: float = 0.0 + avg_brightness: float = 0.0 + + +@dataclass +class MotionAnalysis: + """运动分析结果""" + motion_score: float = 0.0 # 运动幅度 (0-1) + scene_changes: int = 0 # 场景切换次数 + + +@dataclass +class AudioAnalysis: + """音频分析结果""" + has_audio: bool = False + speech_ratio: float = 0.0 # 人声比例 + music_ratio: float = 0.0 # 音乐比例 + ambient_ratio: float = 0.0 # 环境音比例 + + +@dataclass +class ClassificationResult: + """分类结果""" + category: AssetClassification + confidence: float + scores: dict[str, float] = field(default_factory=dict) + + +@dataclass +class QualityScore: + """质量评分结果""" + total: float + resolution_score: float = 0.0 + fps_score: float = 0.0 + bitrate_score: float = 0.0 + clarity_score: float = 0.0 + stability_score: float = 0.0 + + +class AssetAnalyzer: + """ + 轻量级视频素材分析器 + + 使用 FFmpeg + NumPy 进行视频特征分析,不依赖外部 AI API。 + """ + + def __init__(self, video_path: str, temp_dir: str | None = None): + """ + 初始化分析器 + + Args: + video_path: 视频文件路径 + temp_dir: 临时目录,用于存储提取的帧 + """ + self.video_path = video_path + self._video_info: VideoInfo | None = None + self._frames: list[np.ndarray] | None = None + self._temp_dir = temp_dir or tempfile.mkdtemp(prefix="asset_analyzer_") + + def __del__(self): + """清理临时文件""" + self._cleanup_temp_dir() + + def _cleanup_temp_dir(self): + """清理临时目录""" + try: + import shutil + if os.path.exists(self._temp_dir): + shutil.rmtree(self._temp_dir) + except Exception: + pass + + def get_video_info(self) -> VideoInfo: + """获取视频基本信息""" + if self._video_info is not None: + return self._video_info + + info = VideoInfo() + + try: + cmd = [ + "ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + self.video_path, + ] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + data = json.loads(result.stdout) + streams = data.get("streams", []) + format_info = data.get("format", {}) + + for stream in streams: + if stream.get("codec_type") == "video": + info.width = int(stream.get("width", 0)) + info.height = int(stream.get("height", 0)) + info.codec = stream.get("codec_name", "") + + # 解析帧率 + fps_str = stream.get("r_frame_rate", "0/1") + if "/" in fps_str: + num, denom = fps_str.split("/") + info.fps = float(num) / float(denom) if float(denom) != 0 else 0.0 + else: + info.fps = float(fps_str) + + elif stream.get("codec_type") == "audio": + info.has_audio = True + + info.duration = float(format_info.get("duration", 0)) + info.bitrate = int(format_info.get("bit_rate", 0)) + info.file_size = int(format_info.get("size", 0)) + + except Exception as e: + logger.warning(f"Failed to get video info: {e}") + + self._video_info = info + return info + + def extract_frames(self, count: int = 10) -> list[np.ndarray]: + """ + 从视频中均匀抽取帧 + + Args: + count: 抽取的帧数 + + Returns: + 帧数据列表 (RGB 格式) + """ + if self._frames is not None: + return self._frames + + frames = [] + info = self.get_video_info() + + if info.duration <= 0: + logger.warning("Video duration is 0, cannot extract frames") + return frames + + try: + # 计算采样间隔 + interval = max(1.0, info.duration / count) + + for i in range(count): + timestamp = i * interval + + # 提取单帧为 PNG + output_path = os.path.join(self._temp_dir, f"frame_{i:03d}.png") + cmd = [ + "ffmpeg", + "-y", # 覆盖输出文件 + "-ss", str(timestamp), + "-i", self.video_path, + "-vframes", "1", + "-q:v", "2", # 高质量 + "-f", "image2", + output_path, + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0 and os.path.exists(output_path): + # 读取帧并转换为 numpy 数组 + img = self._load_image_as_array(output_path) + if img is not None: + frames.append(img) + + except Exception as e: + logger.warning(f"Failed to extract frames: {e}") + + self._frames = frames + return frames + + def _load_image_as_array(self, path: str) -> np.ndarray | None: + """加载图片为 numpy 数组 (RGB 格式)""" + try: + from PIL import Image + img = Image.open(path) + if img.mode != "RGB": + img = img.convert("RGB") + return np.array(img) + except Exception as e: + logger.warning(f"Failed to load image {path}: {e}") + return None + + def analyze_color_distribution(self, frames: list[np.ndarray] | None = None) -> ColorAnalysis: + """ + 分析色彩分布 (HSV 空间) + + Returns: + ColorAnalysis 对象 + """ + if frames is None: + frames = self.extract_frames() + + if not frames: + return ColorAnalysis() + + result = ColorAnalysis() + all_hsv = [] + + try: + for frame in frames: + # RGB 转 HSV + rgb = frame.astype(float) / 255.0 + r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2] + + maxc = np.maximum(np.maximum(r, g), b) + minc = np.minimum(np.minimum(r, g), b) + v = maxc + s = np.where(maxc > 0, (maxc - minc) / maxc, 0) + + # 计算色相 + rc = np.where(maxc == r, (maxc - g - (maxc - b)) / (maxc - minc + 1e-10), 0) + gc = np.where(maxc == g, 2.0 + (maxc - b - (maxc - r)) / (maxc - minc + 1e-10), 0) + bc = np.where(maxc == b, 4.0 + (maxc - r - (maxc - g)) / (maxc - minc + 1e-10), 0) + h = (rc + gc + bc) * 60 + h = np.where(h < 0, h + 360, h) + + frame_hsv = np.stack([h.flatten(), s.flatten(), v.flatten()], axis=1) + all_hsv.append(frame_hsv) + + if all_hsv: + all_hsv = np.vstack(all_hsv) + + # 主色调 + result.dominant_hue = float(np.median(all_hsv[:, 0])) + + # 饱和度 + result.avg_saturation = float(np.mean(all_hsv[:, 1])) + + # 亮度 + result.avg_brightness = float(np.mean(all_hsv[:, 2])) + + # 计算颜色比例 + # 绿色: 60-180 度 + green_mask = (all_hsv[:, 0] >= 60) & (all_hsv[:, 0] <= 180) + result.green_ratio = float(np.mean(green_mask)) + + # 暖色调 (红/黄/橙): 0-60, 300-360 度 + warm_mask = (all_hsv[:, 0] <= 60) | (all_hsv[:, 0] >= 300) + result.warm_ratio = float(np.mean(warm_mask)) + + # 冷色调 (蓝/青): 180-300 度 + cool_mask = (all_hsv[:, 0] >= 180) & (all_hsv[:, 0] <= 300) + result.cool_ratio = float(np.mean(cool_mask)) + + except Exception as e: + logger.warning(f"Failed to analyze color distribution: {e}") + + return result + + def analyze_motion(self, frames: list[np.ndarray] | None = None) -> MotionAnalysis: + """ + 分析画面运动幅度 + + Returns: + MotionAnalysis 对象 + """ + if frames is None: + frames = self.extract_frames() + + if len(frames) < 2: + return MotionAnalysis() + + result = MotionAnalysis() + motion_scores = [] + scene_changes = 0 + + try: + for i in range(len(frames) - 1): + # 计算相邻帧差异 + diff = np.abs(frames[i + 1].astype(float) - frames[i].astype(float)) + mean_diff = np.mean(diff) / 255.0 + + motion_scores.append(mean_diff) + + # 检测场景切换 (帧差异 > 30%) + if mean_diff > 0.3: + scene_changes += 1 + + if motion_scores: + # 使用中位数避免异常值影响 + result.motion_score = float(np.median(motion_scores)) + # 归一化到 0-1 + result.motion_score = min(1.0, result.motion_score * 5) + result.scene_changes = scene_changes + + except Exception as e: + logger.warning(f"Failed to analyze motion: {e}") + + return result + + def analyze_audio(self) -> AudioAnalysis: + """ + 分析音频特征 + + Returns: + AudioAnalysis 对象 + """ + result = AudioAnalysis() + info = self.get_video_info() + + if not info.has_audio: + return result + + result.has_audio = True + + try: + # 提取音频并分析频率特征 + audio_path = os.path.join(self._temp_dir, "audio.wav") + cmd = [ + "ffmpeg", + "-y", + "-i", self.video_path, + "-vn", # 不要视频 + "-ac", "1", # 单声道 + "-ar", "8000", # 降低采样率 + "-f", "wav", + audio_path, + ] + + result_audio = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result_audio.returncode == 0 and os.path.exists(audio_path): + # 读取音频数据 + import struct + with open(audio_path, "rb") as f: + # 跳过 WAV 头 + f.read(44) + audio_data = f.read() + + if len(audio_data) >= 2: + # 转换为 numpy 数组 + audio_samples = np.array( + struct.unpack(f"<{len(audio_data)//2}h", audio_data), + dtype=float + ) + audio_samples = audio_samples / 32768.0 + + if len(audio_samples) > 0: + # 简单频谱分析 + fft = np.abs(np.fft.rfft(audio_samples[:min(len(audio_samples), 8000)])) + freqs = np.fft.rfftfreq(min(len(audio_samples), 8000), 1/8000) + + # 人声频率: 300-3400 Hz + speech_mask = (freqs >= 300) & (freqs <= 3400) + speech_energy = np.mean(fft[speech_mask]) if speech_mask.any() else 0 + + # 音乐低频: 60-250 Hz + bass_mask = (freqs >= 60) & (freqs <= 250) + bass_energy = np.mean(fft[bass_mask]) if bass_mask.any() else 0 + + # 环境音 (高频): > 4000 Hz + high_mask = freqs > 4000 + high_energy = np.mean(fft[high_mask]) if high_mask.any() else 0 + + total_energy = speech_energy + bass_energy + high_energy + 1e-10 + + result.speech_ratio = float(speech_energy / total_energy) + result.music_ratio = float(bass_energy / total_energy) + result.ambient_ratio = float(high_energy / total_energy) + + except Exception as e: + logger.warning(f"Failed to analyze audio: {e}") + + return result + + def classify(self) -> ClassificationResult: + """ + 综合分析得出分类结果 + + Returns: + ClassificationResult 对象 + """ + # 提取分析数据 + frames = self.extract_frames() + color = self.analyze_color_distribution(frames) + motion = self.analyze_motion(frames) + audio = self.analyze_audio() + + # 计算各类别得分 + scores = self._calculate_category_scores(color, motion, audio) + + # 找最高分 + if not scores: + return ClassificationResult( + category=AssetClassification.OTHER, + confidence=0.3, + scores={}, + ) + + best_category = max(scores.items(), key=lambda x: x[1]) + category = AssetClassification(best_category[0]) + confidence = min(0.95, max(0.3, best_category[1])) + + return ClassificationResult( + category=category, + confidence=confidence, + scores=scores, + ) + + def _calculate_category_scores( + self, + color: ColorAnalysis, + motion: MotionAnalysis, + audio: AudioAnalysis, + ) -> dict[str, float]: + """ + 计算各类别的置信度得分 + + Args: + color: 色彩分析结果 + motion: 运动分析结果 + audio: 音频分析结果 + + Returns: + 各类别得分字典 + """ + scores = {} + + # 1. 风景 (scenic) - 绿色、户外、自然 + scenic_score = 0.0 + if color.green_ratio > 0.3: + scenic_score += 0.4 * color.green_ratio + if color.avg_saturation > 0.3: + scenic_score += 0.2 * color.avg_saturation + if color.avg_brightness > 0.4: + scenic_score += 0.2 + if motion.motion_score > 0.1 and motion.motion_score < 0.5: + scenic_score += 0.2 # 适度运动(如云朵、树叶) + if not audio.has_audio or audio.ambient_ratio > 0.5: + scenic_score += 0.2 # 自然环境音 + scores[AssetClassification.SCENIC.value] = min(1.0, scenic_score) + + # 2. 产品 (product) - 中等亮度、均匀色彩、低运动 + product_score = 0.0 + if 0.3 < color.avg_brightness < 0.7: + product_score += 0.3 + if color.avg_saturation < 0.5: + product_score += 0.2 + if motion.motion_score < 0.15: + product_score += 0.4 # 低运动 = 产品展示 + if color.cool_ratio > 0.3: + product_score += 0.2 # 冷色调 = 科技感 + scores[AssetClassification.PRODUCT.value] = min(1.0, product_score) + + # 3. 人物 (person) - 中等运动、有时有人声 + person_score = 0.0 + if 0.1 < motion.motion_score < 0.4: + person_score += 0.3 # 适度运动 + if audio.has_audio and audio.speech_ratio > 0.3: + person_score += 0.5 # 有人声 + if color.avg_brightness > 0.3: + person_score += 0.2 + scores[AssetClassification.PERSON.value] = min(1.0, person_score) + + # 4. 动物 (animal) - 高运动、有时自然音 + animal_score = 0.0 + if motion.motion_score > 0.3: + animal_score += 0.4 # 高运动 + if motion.scene_changes > 2: + animal_score += 0.2 + if audio.has_audio and (audio.ambient_ratio > 0.3 or audio.speech_ratio > 0.2): + animal_score += 0.3 + scores[AssetClassification.ANIMAL.value] = min(1.0, animal_score) + + # 5. 美食 (food) - 暖色调、高饱和度 + food_score = 0.0 + if color.warm_ratio > 0.4: + food_score += 0.5 + if color.avg_saturation > 0.5: + food_score += 0.3 + if 0.4 < color.avg_brightness < 0.8: + food_score += 0.2 + scores[AssetClassification.FOOD.value] = min(1.0, food_score) + + # 6. 科技 (tech) - 冷色调、低饱和度、低运动 + tech_score = 0.0 + if color.cool_ratio > 0.4: + tech_score += 0.4 + if color.avg_saturation < 0.4: + tech_score += 0.3 + if motion.motion_score < 0.2: + tech_score += 0.3 + scores[AssetClassification.TECH.value] = min(1.0, tech_score) + + # 7. 运动 (sport) - 高运动 + sport_score = 0.0 + if motion.motion_score > 0.4: + sport_score += 0.6 + if motion.scene_changes > 3: + sport_score += 0.2 + if color.avg_brightness > 0.4: + sport_score += 0.2 + scores[AssetClassification.SPORT.value] = min(1.0, sport_score) + + # 8. 音乐 (music) - 有节奏性音乐 + music_score = 0.0 + if audio.has_audio and audio.music_ratio > 0.4: + music_score += 0.6 + # 纯视觉判断:色彩丰富但非自然 + if color.avg_saturation > 0.5 and color.green_ratio < 0.2: + music_score += 0.3 + scores[AssetClassification.MUSIC.value] = min(1.0, music_score) + + # 9. 其他 (other) - 默认最低分 + scores[AssetClassification.OTHER.value] = 0.1 + + return scores + + def calculate_quality_score(self) -> QualityScore: + """ + 计算视频质量综合评分 (0-100) + + 评分维度: + 1. 分辨率得分 (25分) + 2. 帧率得分 (20分) + 3. 码率得分 (20分) + 4. 清晰度得分 (20分) - Laplacian 方差 + 5. 稳定性得分 (15分) - 帧间位移方差 + """ + info = self.get_video_info() + frames = self.extract_frames() + + # 1. 分辨率得分 + resolution_score = self._score_resolution(info.width, info.height) + + # 2. 帧率得分 + fps_score = self._score_framerate(info.fps) + + # 3. 码率得分 + bitrate_score = self._score_bitrate(info.bitrate) + + # 4. 清晰度得分 + clarity_score = self._score_clarity(frames) + + # 5. 稳定性得分 + stability_score = self._score_stability(frames) + + total = resolution_score + fps_score + bitrate_score + clarity_score + stability_score + + return QualityScore( + total=round(min(100, max(0, total)), 1), + resolution_score=resolution_score, + fps_score=fps_score, + bitrate_score=bitrate_score, + clarity_score=clarity_score, + stability_score=stability_score, + ) + + def _score_resolution(self, width: int, height: int) -> float: + """分辨率评分 (满分 25)""" + pixels = width * height + + if pixels >= 3840 * 2160: # 4K + return 25.0 + elif pixels >= 2560 * 1440: # 2K + return 22.0 + elif pixels >= 1920 * 1080: # 1080p + return 20.0 + elif pixels >= 1280 * 720: # 720p + return 15.0 + elif pixels >= 854 * 480: # 480p + return 8.0 + else: + return 3.0 + + def _score_framerate(self, fps: float) -> float: + """帧率评分 (满分 20)""" + if fps >= 60: + return 20.0 + elif fps >= 30: + return 15.0 + elif fps >= 24: + return 10.0 + elif fps >= 15: + return 7.0 + else: + return 5.0 + + def _score_bitrate(self, bitrate: int) -> float: + """码率评分 (满分 20)""" + bitrate_mbps = bitrate / 1_000_000 + + if bitrate_mbps > 10: + return 20.0 + elif bitrate_mbps >= 5: + return 15.0 + elif bitrate_mbps >= 2: + return 10.0 + elif bitrate_mbps >= 0.5: + return 5.0 + else: + return 3.0 + + def _score_clarity(self, frames: list[np.ndarray]) -> float: + """ + 清晰度评分 (满分 20) + + 使用 Laplacian 方差评估画面清晰度 + 高方差 = 细节丰富 = 高分 + """ + if not frames: + return 10.0 # 默认中等分 + + try: + variances = [] + + for frame in frames[:5]: # 只分析前 5 帧 + if len(frame.shape) == 3: + # 转灰度 + gray = np.dot(frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8) + else: + gray = frame + + # Laplacian 算子 + laplacian = np.array([ + [0, 1, 0], + [1, -4, 1], + [0, 1, 0] + ], dtype=np.float32) + + # 手动计算卷积 + from scipy import signal + laplacian_img = signal.convolve2d(gray.astype(float), laplacian, mode='same') + variance = np.var(laplacian_img) + variances.append(variance) + + # 归一化方差到 0-20 分 + avg_variance = np.mean(variances) + # 根据经验值调整 + score = min(20.0, avg_variance / 100) + return float(score) + + except ImportError: + # 如果没有 scipy,使用简化方法 + return 10.0 + except Exception: + return 10.0 + + def _score_stability(self, frames: list[np.ndarray]) -> float: + """ + 稳定性评分 (满分 15) + + 分析帧间位移方差 + 画面稳定 = 高分 + 剧烈抖动 = 低分 + """ + if len(frames) < 2: + return 10.0 # 默认中等分 + + try: + displacements = [] + + for i in range(len(frames) - 1): + # 缩小帧以加速处理 + scale = 0.25 + frame1_small = np.array( + np.array(frames[i]).resize( + int(frames[i].shape[1] * scale), + int(frames[i].shape[0] * scale) + ) + ) + frame2_small = np.array( + np.array(frames[i + 1]).resize( + int(frames[i + 1].shape[1] * scale), + int(frames[i + 1].shape[0] * scale) + ) + ) + + # 简单位移检测:灰度差 + gray1 = np.mean(frame1_small, axis=2) if len(frame1_small.shape) == 3 else frame1_small + gray2 = np.mean(frame2_small, axis=2) if len(frame2_small.shape) == 3 else frame2_small + + diff = np.abs(gray2.astype(float) - gray1.astype(float)) + displacement = np.mean(diff) / 255.0 + displacements.append(displacement) + + # 高位移方差 = 不稳定 + if displacements: + displacement_variance = np.var(displacements) + # 归一化 + instability = min(1.0, displacement_variance * 10) + score = 15.0 * (1.0 - instability) + return float(max(0.0, score)) + + return 10.0 + + except Exception: + return 10.0 + + +def classify_asset_real(video_path: str) -> tuple[str, float]: + """ + 真实分类入口函数 + + Args: + video_path: 视频文件路径 + + Returns: + (分类类别, 置信度) + """ + try: + analyzer = AssetAnalyzer(video_path) + result = analyzer.classify() + return result.category.value, result.confidence + except Exception as e: + logger.warning(f"Classification failed, using fallback: {e}") + return AssetClassification.OTHER.value, 0.3 + + +def calculate_quality_score_real(video_path: str) -> float: + """ + 质量评分入口函数 + + Args: + video_path: 视频文件路径 + + Returns: + 质量评分 (0-100) + """ + try: + analyzer = AssetAnalyzer(video_path) + result = analyzer.calculate_quality_score() + return result.total + except Exception as e: + logger.warning(f"Quality scoring failed, using fallback: {e}") + return 50.0 diff --git a/apps/worker/worker_app/tasks/classification.py b/apps/worker/worker_app/tasks/classification.py old mode 100644 new mode 100755 index 7d00ce342..88a409c5b --- a/apps/worker/worker_app/tasks/classification.py +++ b/apps/worker/worker_app/tasks/classification.py @@ -1,17 +1,23 @@ -from worker_app.celery_app import celery_app -from worker_app.db import SessionLocal +from celery import Task +from celery.utils.log import get_task_logger from packages.adapters.sqlalchemy_impl.classification_job_repository import ( SQLAlchemyClassificationJobRepository, ) +from packages.adapters.sqlalchemy_impl.asset_repository import ( + SQLAlchemyAssetRepository, +) from packages.domain import ( - AssetClassification, ClassificationJob, ClassificationJobStatus, + ClassificationStatus, ) +from .asset_analyzer import classify_asset_real + +logger = get_task_logger(__name__) + -@celery_app.task(name="worker.classify_asset") def classify_asset(job_id: str) -> dict: """ Classify asset task. @@ -19,14 +25,18 @@ def classify_asset(job_id: str) -> dict: Steps: 1. Fetch ClassificationJob from repository 2. Fetch Asset from repository - 3. Run classification model (placeholder: mock classification) + 3. Run real classification based on video features 4. Update ClassificationJob with result - 5. Return result + 5. Update Asset with classification and status + 6. Return result """ + from worker_app.db import SessionLocal + # 创建数据库 session 和 repository session = SessionLocal() try: job_repo = SQLAlchemyClassificationJobRepository(session) + asset_repo = SQLAlchemyAssetRepository(session) job = job_repo.get(job_id) if job is None: @@ -38,32 +48,56 @@ def classify_asset(job_id: str) -> dict: job_repo.update(job) session.commit() - # Mock classification (in real implementation: use ML model, vision API, etc.) - # For now, randomly classify based on asset_id hash - asset_id_hash = sum(ord(c) for c in job.asset_id) - classifications = list(AssetClassification) - classification = classifications[asset_id_hash % len(classifications)] - confidence = 0.85 + # Get the asset to find the video path + asset = asset_repo.get(job.asset_id) + if asset is None: + raise ValueError(f"Asset not found: {job.asset_id}") - # Update job status to COMPLETED + # Determine media path from storage_key + # In production, this would be a full URL/path to the media file + video_path = asset.storage_key + + # Run real classification + classification, confidence = classify_asset_real(video_path) + + # Update job with classification result job.status = ClassificationJobStatus.COMPLETED - job.classification = classification.value + job.classification = classification job.confidence = confidence job_repo.update(job) + + # Update asset with classification status and result + asset.classification_status = ClassificationStatus.COMPLETED + asset_repo.update(asset) + session.commit() + logger.info( + f"Classification completed for asset {asset.id}: " + f"category={classification}, confidence={confidence}" + ) + return { "status": "completed", "job_id": job.id, - "classification": classification.value, + "classification": classification, "confidence": confidence, } except Exception as e: session.rollback() + logger.error(f"Classification failed for job {job_id}: {e}") + # Update job status to FAILED job.status = ClassificationJobStatus.FAILED job.error_message = str(e) job_repo.update(job) + + # Update asset classification status to FAILED + asset = asset_repo.get(job.asset_id) + if asset: + asset.classification_status = ClassificationStatus.FAILED + asset_repo.update(asset) + session.commit() return { diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index 36e441db3..3c8f650da 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -16,6 +16,8 @@ from packages.adapters.sqlalchemy_impl import ( ) from packages.domain import Asset, AssetStatus, IngestJobStatus +from .asset_analyzer import calculate_quality_score_real + logger = get_task_logger(__name__) @@ -60,7 +62,12 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict: metadata["width"] = int(stream.get("width", 0)) metadata["height"] = int(stream.get("height", 0)) metadata["codec"] = stream.get("codec_name", "") - metadata["fps"] = eval(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0 + fps_str = stream.get("r_frame_rate", "0/1") + if "/" in fps_str: + num, denom = fps_str.split("/") + metadata["fps"] = float(num) / float(denom) if float(denom) != 0 else 0.0 + else: + metadata["fps"] = float(fps_str) break # 提取格式信息 @@ -98,6 +105,28 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict: return metadata +def calculate_quality_score(video_path: str, media_type: str) -> float: + """ + 计算视频质量评分 + + Args: + video_path: 视频文件路径 + media_type: 媒体类型 + + Returns: + 质量评分 (0-100),失败时返回默认值 50.0 + """ + if media_type != "video": + # 非视频文件使用默认评分 + return 50.0 + + try: + return calculate_quality_score_real(video_path) + except Exception as e: + logger.warning(f"Failed to calculate quality score: {e}") + return 50.0 + + @celery_app.task(name="worker.ingest_asset") def ingest_asset(job_id: str) -> dict: """ @@ -106,9 +135,10 @@ def ingest_asset(job_id: str) -> dict: Steps: 1. Fetch IngestJob from repository 2. Extract metadata from storage_key - 3. Create Asset entity - 4. Update IngestJob status to COMPLETED - 5. Return result + 3. Calculate quality score for video assets + 4. Create Asset entity + 5. Update IngestJob status to COMPLETED + 6. Return result """ db = SessionLocal() try: @@ -140,6 +170,10 @@ def ingest_asset(job_id: str) -> dict: storage_url = job.storage_key # Assuming storage_key is usable as URL/path metadata = extract_media_metadata(storage_url, media_type) + # Calculate quality score for video assets + quality_score = calculate_quality_score(storage_url, media_type) + logger.info(f"Calculated quality score for {filename}: {quality_score}") + # Fill in defaults if metadata extraction failed if not metadata: metadata = { @@ -162,7 +196,10 @@ def ingest_asset(job_id: str) -> dict: duration=float(metadata.get("duration", 0)), width=int(metadata.get("width", 0)), height=int(metadata.get("height", 0)), + fps=float(metadata.get("fps", 0)) if metadata.get("fps") else None, + codec=metadata.get("codec"), status=AssetStatus.READY, + quality_score=quality_score, ) asset_repo.create(asset) @@ -174,10 +211,13 @@ def ingest_asset(job_id: str) -> dict: db.commit() + logger.info(f"Asset ingested: id={asset.id}, name={filename}, quality_score={quality_score}") + return { "status": "completed", "job_id": job.id, "asset_id": asset.id, + "quality_score": quality_score, } except Exception as e: db.rollback() diff --git a/requirements.txt b/requirements.txt old mode 100644 new mode 100755 index dcf12d7cb..186a8703c --- a/requirements.txt +++ b/requirements.txt @@ -44,3 +44,7 @@ oss2==2.18.4 # 任务队列 celery==5.4.0 + +# 数据分析(用于视频质量评估) +numpy>=1.24.0 +scipy>=1.10.0 -- 2.54.0 From b32ae1428899c8f1d6f8407202b5d313f65ba083 Mon Sep 17 00:00:00 2001 From: CI Test Date: Fri, 26 Jun 2026 20:52:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DAI=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E6=A8=A1=E5=9D=97resize=20bug=E5=92=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0max=5Fframes=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/worker/worker_app/tasks/asset_analyzer.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/worker/worker_app/tasks/asset_analyzer.py b/apps/worker/worker_app/tasks/asset_analyzer.py index b90a696a4..71b1dbbcf 100755 --- a/apps/worker/worker_app/tasks/asset_analyzer.py +++ b/apps/worker/worker_app/tasks/asset_analyzer.py @@ -171,7 +171,7 @@ class AssetAnalyzer: self._video_info = info return info - def extract_frames(self, count: int = 10) -> list[np.ndarray]: + def extract_frames(self, count: int = 10, max_frames: int = 30) -> list[np.ndarray]: """ 从视频中均匀抽取帧 @@ -720,17 +720,15 @@ class AssetAnalyzer: for i in range(len(frames) - 1): # 缩小帧以加速处理 scale = 0.25 + new_h = int(frames[i].shape[0] * scale) + new_w = int(frames[i].shape[1] * scale) frame1_small = np.array( - np.array(frames[i]).resize( - int(frames[i].shape[1] * scale), - int(frames[i].shape[0] * scale) - ) + Image.fromarray(frames[i]).resize((new_w, new_h)) ) + new_h2 = int(frames[i + 1].shape[0] * scale) + new_w2 = int(frames[i + 1].shape[1] * scale) frame2_small = np.array( - np.array(frames[i + 1]).resize( - int(frames[i + 1].shape[1] * scale), - int(frames[i + 1].shape[0] * scale) - ) + Image.fromarray(frames[i + 1]).resize((new_w2, new_h2)) ) # 简单位移检测:灰度差 -- 2.54.0