feat: 实现真实素材AI分类和视频质量评估 #19
+791
@@ -0,0 +1,791 @@
|
||||
"""
|
||||
视频素材分析器 - 基于 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, max_frames: int = 30) -> 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
|
||||
new_h = int(frames[i].shape[0] * scale)
|
||||
new_w = int(frames[i].shape[1] * scale)
|
||||
frame1_small = np.array(
|
||||
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(
|
||||
Image.fromarray(frames[i + 1]).resize((new_w2, new_h2))
|
||||
)
|
||||
|
||||
# 简单位移检测:灰度差
|
||||
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
|
||||
Regular → Executable
+49
-15
@@ -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 {
|
||||
|
||||
Regular → Executable
+149
-81
@@ -1,8 +1,10 @@
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
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
|
||||
@@ -14,71 +16,117 @@ from packages.adapters.sqlalchemy_impl import (
|
||||
)
|
||||
from packages.domain import Asset, AssetStatus, IngestJobStatus
|
||||
|
||||
from .asset_analyzer import calculate_quality_score_real
|
||||
|
||||
def extract_media_metadata(file_url: str, mime_type: str) -> dict:
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
"""
|
||||
Extract metadata from media file.
|
||||
|
||||
提取媒体文件的元数据。
|
||||
|
||||
Args:
|
||||
file_url: URL or path to the media file
|
||||
mime_type: MIME type of the file
|
||||
|
||||
file_url: 媒体文件 URL 或本地路径
|
||||
media_type: 媒体类型 (video, audio, image)
|
||||
|
||||
Returns:
|
||||
Dictionary containing metadata (duration, width, height, etc.)
|
||||
提取的元数据字典,失败时返回空字典
|
||||
"""
|
||||
metadata = {}
|
||||
|
||||
if mime_type.startswith("video/"):
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
if media_type == "video":
|
||||
# 使用 ffprobe 提取视频元数据
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
file_url,
|
||||
]
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", file_url],
|
||||
capture_output=True, text=True, timeout=30
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
data = json.loads(result.stdout)
|
||||
video_stream = next((s for s in data.get("streams", []) if s["codec_type"] == "video"), None)
|
||||
if video_stream:
|
||||
metadata["width"] = video_stream.get("width")
|
||||
metadata["height"] = video_stream.get("height")
|
||||
metadata["codec"] = video_stream.get("codec_name")
|
||||
format_info = data.get("format", {})
|
||||
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", "")
|
||||
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
|
||||
|
||||
# 提取格式信息
|
||||
format_info = probe_data.get("format", {})
|
||||
metadata["duration"] = float(format_info.get("duration", 0))
|
||||
metadata["size_bytes"] = int(format_info.get("size", 0))
|
||||
except Exception:
|
||||
pass
|
||||
elif mime_type.startswith("image/"):
|
||||
try:
|
||||
from PIL import Image
|
||||
import requests
|
||||
from io import BytesIO
|
||||
response = requests.get(file_url, timeout=10)
|
||||
img = Image.open(BytesIO(response.content))
|
||||
metadata["width"] = img.width
|
||||
metadata["height"] = img.height
|
||||
metadata["format"] = img.format
|
||||
# Estimate size
|
||||
metadata["size_bytes"] = len(response.content)
|
||||
except Exception:
|
||||
pass
|
||||
elif mime_type.startswith("audio/"):
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", file_url],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0:
|
||||
data = json.loads(result.stdout)
|
||||
format_info = data.get("format", {})
|
||||
metadata["duration"] = float(format_info.get("duration", 0))
|
||||
metadata["size_bytes"] = int(format_info.get("size", 0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
@@ -87,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:
|
||||
@@ -106,26 +155,32 @@ def ingest_asset(job_id: str) -> dict:
|
||||
job_repo.update(job)
|
||||
db.commit()
|
||||
|
||||
# Extract real metadata from storage
|
||||
# Extract real metadata from media file
|
||||
filename = job.storage_key.split("/")[-1]
|
||||
mime_type = infer_mime_type_from_storage_key(job.storage_key)
|
||||
|
||||
# Get file URL for metadata extraction
|
||||
# In production, this would be a presigned URL or internal storage path
|
||||
file_url = job.storage_key # Use storage_key as path for ffprobe
|
||||
|
||||
# Extract metadata using appropriate tool
|
||||
metadata = extract_media_metadata(file_url, mime_type)
|
||||
|
||||
# Fallback for missing metadata
|
||||
|
||||
# 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)
|
||||
|
||||
# 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:
|
||||
# Log warning but continue with basic asset creation
|
||||
metadata = {
|
||||
"duration": 0,
|
||||
"width": None,
|
||||
"height": None,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"size_bytes": 0,
|
||||
"extraction_failed": True
|
||||
}
|
||||
|
||||
# Create Asset
|
||||
@@ -137,40 +192,53 @@ def ingest_asset(job_id: str) -> dict:
|
||||
storage_key=job.storage_key,
|
||||
mime_type=mime_type,
|
||||
metadata=metadata,
|
||||
file_size=metadata.get("size_bytes", 0),
|
||||
duration=metadata.get("duration", 0),
|
||||
width=metadata.get("width"),
|
||||
height=metadata.get("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)),
|
||||
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)
|
||||
db.commit()
|
||||
|
||||
# Update job status to COMPLETED
|
||||
job.status = IngestJobStatus.COMPLETED
|
||||
job.result_asset_id = asset.id
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
job_repo.update(job)
|
||||
|
||||
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()
|
||||
logger.error(f"Failed to ingest asset {job_id}: {e}")
|
||||
|
||||
# Update job status to FAILED
|
||||
if job:
|
||||
job.status = IngestJobStatus.FAILED
|
||||
job.error_message = str(e)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
job_repo.update(job)
|
||||
db.commit()
|
||||
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 if job else job_id,
|
||||
"job_id": job_id,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
|
||||
Regular → Executable
+41
@@ -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)
|
||||
|
||||
Regular → Executable
+4
@@ -44,3 +44,7 @@ oss2==2.18.4
|
||||
|
||||
# 任务队列
|
||||
celery==5.4.0
|
||||
|
||||
# 数据分析(用于视频质量评估)
|
||||
numpy>=1.24.0
|
||||
scipy>=1.10.0
|
||||
|
||||
Reference in New Issue
Block a user