From e6e4090f3cadbb955fc2f9e3322766e9d6cac658 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 18:33:53 +0800 Subject: [PATCH 01/39] =?UTF-8?q?feat(voice):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E9=85=8D=E9=9F=B3=E6=8E=A5=E5=8F=A3=20+=20?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=9D=A5=E6=BA=90=E6=A0=87=E8=AF=86=20(#1654?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/voices.py | 262 ++++++++++++++++++++++++- apps/worker/worker_app/tasks/ingest.py | 4 +- 2 files changed, 263 insertions(+), 3 deletions(-) diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index d03e7fa76..b61103113 100755 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -6,12 +6,26 @@ from __future__ import annotations import logging +import shutil +import subprocess +import tempfile import time +from pathlib import Path from typing import Literal, Optional +from uuid import uuid4 from app.api.routes._helpers import get_user_plan from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository +from app.core.storage import get_storage_service +from app.dependencies import ( + get_asset_library_repository, + get_asset_repository, + get_audio_url_signer, + get_cosyvoice_service, + get_db_session, + get_project_repository, + get_user_repository, +) from app.schemas.voice import ( PresetVoiceItemResponse, PresetVoiceListResponse, @@ -24,7 +38,7 @@ from app.schemas.voice_library import ( UpdateVoiceLibraryRequest, VoiceLibraryItemResponse, ) -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository @@ -40,8 +54,12 @@ from packages.application.voice_library.use_cases import ( QuotaExceededError, UpdateVoiceLibraryUseCase, ) +from packages.domain import Asset, AssetStatus +from packages.domain.classification import AssetLibraryKind, ClassificationStatus +from packages.domain.entities import AssetLibrary from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id from packages.ports.user_repository import UserRepository +from packages.shared.storage import SharedStorageService router = APIRouter() logger = logging.getLogger(__name__) @@ -507,3 +525,243 @@ def delete_voice( if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") return + + +# ── 提取视频配音 ───────────────────────────────────────────────────── + +# 支持的视频格式 +EXTRACT_VIDEO_MIMES = frozenset({"video/mp4", "video/quicktime", "video/webm", "video/x-msvideo"}) +MAX_EXTRACT_SIZE = 500 * 1024 * 1024 # 500MB + + +@router.post( + "/extract-voice", + status_code=status.HTTP_201_CREATED, +) +def extract_voice_from_video( + file: UploadFile = File(...), + project_id: str = Form(...), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + project_repository=Depends(get_project_repository), + asset_library_repository=Depends(get_asset_library_repository), + asset_repository=Depends(get_asset_repository), + storage_service: SharedStorageService = Depends(get_storage_service), + sign_url=Depends(get_audio_url_signer), +): + """从上传的视频中提取人声配音。 + + 流程: + 1. 接收视频文件(mp4/mov/webm) + 2. ffmpeg 提取音频 + 降噪 + 编码为 mp3 + 3. 上传到 OSS,创建 Asset 记录到配音素材库 + 4. 返回素材信息(时长、文件大小、URL) + """ + user_id = authenticated_user.user.id + + # 校验文件类型 + content_type = file.content_type or "" + if content_type and content_type not in EXTRACT_VIDEO_MIMES: + # 兜底:按扩展名判断 + ext = (file.filename or "").rsplit(".", 1)[-1].lower() + ext_to_mime = {"mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm", "avi": "video/x-msvideo"} + if ext not in ext_to_mime: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="仅支持 mp4/mov/webm/avi 格式的视频文件", + ) + content_type = ext_to_mime[ext] + + # 找到(或自动创建)用户 voice 素材库(复用 TTS 的逻辑) + library = _find_or_create_voice_library_for_extract( + user_id=user_id, + project_repository=project_repository, + asset_library_repository=asset_library_repository, + ) + + tmp_dir = None + try: + tmp_dir = Path(tempfile.mkdtemp(prefix="voice_extract_")) + video_path = tmp_dir / f"input_{uuid4().hex[:8]}_{file.filename or 'video.mp4'}" + audio_path = tmp_dir / f"output_{uuid4().hex[:8]}.mp3" + + # 保存上传的视频到临时文件 + with open(video_path, "wb") as f: + total = 0 + while chunk := file.file.read(1024 * 1024): # 1MB chunks + total += len(chunk) + if total > MAX_EXTRACT_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="视频文件过大,最大支持 500MB", + ) + f.write(chunk) + + if video_path.stat().st_size == 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="视频文件为空") + + # ffmpeg: 提取音频 + 降噪 + 编码 mp3 + # 滤镜链:highpass(去低频噪声) → afftdn(FFT降噪) → lowpass(去高频噪声) + ffmpeg_cmd = [ + "ffmpeg", + "-y", + "-i", + str(video_path), + "-vn", # 不要视频 + "-af", + "highpass=f=80,afftdn=nf=-25:tn=1,lowpass=f=8000", + "-acodec", + "libmp3lame", + "-ab", + "192k", + "-ar", + "44100", + "-ac", + "1", # 单声道(人声足够) + str(audio_path), + ] + + result = subprocess.run( + ffmpeg_cmd, + capture_output=True, + timeout=300, # 5 分钟超时 + ) + + if result.returncode != 0: + stderr_text = result.stderr.decode("utf-8", errors="replace")[-500:] + logger.error("ffmpeg 提取配音失败: %s", stderr_text) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="视频音频提取失败,可能该视频没有音轨或格式不支持", + ) + + if not audio_path.exists() or audio_path.stat().st_size == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="音频提取结果为空", + ) + + # 获取音频时长 + duration = _get_audio_duration(audio_path) + file_size = audio_path.stat().st_size + + # 上传到 OSS + audio_ext = "mp3" + storage_key = f"uploads/voice/extracted/{uuid4().hex}.{audio_ext}" + storage_service.upload_file(audio_path, storage_key, content_type="audio/mpeg") + + # 创建 Asset 记录 + original_name = (file.filename or "video").rsplit(".", 1)[0] + asset_name = f"{original_name}-配音" + + asset = Asset.create( + project_id=library.project_id, + library_id=library.id, + name=asset_name, + storage_key=storage_key, + mime_type="audio/mpeg", + metadata={ + "source": "video_extract", + "original_video": file.filename or "unknown", + }, + file_size=file_size, + duration=duration, + status=AssetStatus.READY, + classification_status=ClassificationStatus.PENDING, + uploaded_by_user_id=user_id, + ) + asset = asset_repository.create(asset) + + return { + "id": asset.id, + "name": asset.name, + "audio_url": sign_url(storage_key), + "duration": duration, + "file_size": file_size, + "status": "completed", + "source": "video_extract", + } + + except HTTPException: + raise + except subprocess.TimeoutExpired: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="视频处理超时,请尝试较短的视频", + ) + except Exception as e: + logger.exception("提取视频配音失败: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="提取配音失败,请稍后重试", + ) + finally: + # 清理临时文件 + if tmp_dir and Path(tmp_dir).exists(): + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def _find_or_create_voice_library_for_extract(*, user_id, project_repository, asset_library_repository): + """为用户找到或创建 voice 素材库(与 TTS 保存逻辑一致)。""" + projects = project_repository.find_accessible_projects(user_id) + if not projects: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="没有可用的项目,请先创建项目", + ) + + for project in projects: + for lib in asset_library_repository.find_by_project(project.id): + kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind + if kind == AssetLibraryKind.VOICE.value: + return lib + + # 自动创建 + from sqlalchemy.exc import IntegrityError + + project = projects[0] + library = AssetLibrary.create( + project_id=project.id, + name="配音素材库", + kind=AssetLibraryKind.VOICE, + ) + try: + return asset_library_repository.create(library) + except IntegrityError: + session = getattr(asset_library_repository, "session", None) + if session is not None: + try: + session.rollback() + except Exception: + pass + for lib in asset_library_repository.find_by_project(project.id): + kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind + if kind == AssetLibraryKind.VOICE.value: + return lib + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="配音素材库创建失败", + ) + + +def _get_audio_duration(audio_path: Path) -> float: + """用 ffprobe 获取音频时长(秒)。""" + try: + result = subprocess.run( + [ + "ffprobe", + "-v", + "quiet", + "-show_entries", + "format=duration", + "-of", + "csv=p=0", + str(audio_path), + ], + capture_output=True, + timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + return float(result.stdout.strip()) + except (ValueError, subprocess.TimeoutExpired): + pass + return 0.0 diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index 0a7aa4764..7b48f0f45 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -630,7 +630,7 @@ def ingest_asset(job_id: str) -> dict: name=filename, storage_key=job.storage_key, mime_type=mime_type, - metadata={"ingest_error": error_reason}, + metadata={"source": "upload", "ingest_error": error_reason}, file_size=int(metadata.get("size_bytes", 0)), duration=float(metadata.get("duration", 0)), width=int(metadata.get("width", 0)), @@ -666,6 +666,7 @@ def ingest_asset(job_id: str) -> dict: if existing_asset is None: # 兜底:如果 API 端没有预先创建 Asset(旧版本兼容),则创建新记录 logger.info("No pre-created asset found for storage_key=%s, creating new", job.storage_key) + metadata["source"] = "upload" asset = Asset.create( project_id=job.project_id, library_id=job.library_id, @@ -687,6 +688,7 @@ def ingest_asset(job_id: str) -> dict: # 更新已有的 Asset 记录,补充元数据并将状态改为 READY asset = existing_asset asset.mime_type = mime_type + metadata["source"] = "upload" asset.metadata = metadata asset.file_size = int(metadata.get("size_bytes", 0)) asset.duration = float(metadata.get("duration", 0)) -- 2.54.0 From 6d2d63da7e37b56ebf1ac6440c883cf6ccddc625 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 19:04:35 +0800 Subject: [PATCH 02/39] =?UTF-8?q?feat(voices):=20TTS=E6=97=B6=E9=95=BF?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E6=8F=90=E5=8F=96=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E9=85=8D=E9=9F=B3=20+=20AI=E9=85=8D=E9=9F=B3=E6=A0=87=E8=AF=86?= =?UTF-8?q?=20(#1656)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/api/app/api/routes/tts.py | 23 +- apps/web/src/api/tts/index.ts | 1 + apps/web/src/api/tts/jobs.ts | 53 +++++ apps/web/src/pages/voices/VoiceLibrary.tsx | 42 +++- .../voices/components/MaterialVoiceTab.tsx | 3 + .../voices/components/VideoExtractModal.tsx | 207 ++++++++++++++++++ .../src/pages/voices/hooks/useVideoExtract.ts | 74 +++++++ apps/web/src/pages/voices/voices.css | 18 ++ 8 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/pages/voices/components/VideoExtractModal.tsx create mode 100644 apps/web/src/pages/voices/hooks/useVideoExtract.ts diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index f1e06dc0f..ec000a5b8 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import logging +import subprocess import tempfile from pathlib import Path from typing import Any, Optional @@ -430,6 +432,8 @@ def save_tts_job_to_library( storage_key = f"uploads/voice/tts/{job.id}.{audio_format}" tmp_path: Path | None = None + audio_duration: float | None = None + file_size = 0 try: with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp: tmp_path = Path(tmp.name) @@ -445,6 +449,23 @@ def save_tts_job_to_library( ) file_size = tmp_path.stat().st_size storage_service.upload_file(tmp_path, storage_key, content_type=content_type) + + # 从音频文件提取时长(ffprobe),作为 job.duration 的兜底 + try: + proc = subprocess.run( + [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", str(tmp_path), + ], + capture_output=True, text=True, timeout=10, + ) + if proc.returncode == 0: + fmt = json.loads(proc.stdout).get("format", {}) + dur = float(fmt.get("duration", 0)) + if dur > 0: + audio_duration = dur + except Exception: + logger.warning("ffprobe 提取时长失败: job_id=%s", job.id, exc_info=True) except HTTPException: raise except Exception as e: @@ -482,7 +503,7 @@ def save_tts_job_to_library( mime_type=content_type, metadata=metadata_, file_size=file_size, - duration=job.duration or None, + duration=job.duration or audio_duration or None, status=AssetStatus.READY, classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致 uploaded_by_user_id=user_id, diff --git a/apps/web/src/api/tts/index.ts b/apps/web/src/api/tts/index.ts index 4cadefa41..0c006f6d0 100644 --- a/apps/web/src/api/tts/index.ts +++ b/apps/web/src/api/tts/index.ts @@ -28,4 +28,5 @@ export { deleteTTSJob, getTtsVoices, previewTts, + extractVideoVoice, } from "./jobs" diff --git a/apps/web/src/api/tts/jobs.ts b/apps/web/src/api/tts/jobs.ts index 69a5860ce..9a7468994 100644 --- a/apps/web/src/api/tts/jobs.ts +++ b/apps/web/src/api/tts/jobs.ts @@ -70,3 +70,56 @@ export const previewTts = async (data: TTSPreviewRequest): Promise("/tts/preview", data) return response.data } + +/** + * 从视频中提取配音(上传视频 → 后端提取人声 → 保存到配音素材库) + * 支持 mp4/mov/webm 格式 + */ +export const extractVideoVoice = async ( + file: File, + onProgress?: (percent: number) => void, +): Promise<{ asset_id: string; duration: number }> => { + const formData = new FormData() + formData.append("file", file) + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open("POST", "/api/v1/tts/extract-video-voice") + + // 携带认证 token(从 localStorage 获取,与 apiClient 拦截器一致) + const token = localStorage.getItem("access_token") + if (token) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`) + } + + xhr.timeout = 10 * 60 * 1000 // 10 分钟超时 + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)) + } + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)) + } catch { + reject(new Error("服务器返回数据解析失败")) + } + } else { + try { + const err = JSON.parse(xhr.responseText) + reject(new Error(err.detail || err.message || `提取失败: HTTP ${xhr.status}`)) + } catch { + reject(new Error(`提取失败: HTTP ${xhr.status}`)) + } + } + } + + xhr.onerror = () => reject(new Error("网络错误,请检查网络连接")) + xhr.ontimeout = () => reject(new Error("上传超时(10分钟),请检查网络或尝试更小的文件")) + + xhr.send(formData) + }) +} diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index 9ea4d259a..271fe6d06 100755 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -16,7 +16,12 @@ */ import React, { useCallback, useEffect, useState } from "react" import { useSearchParams } from "react-router-dom" -import { UploadOutlined, AudioOutlined, RobotOutlined } from "@ant-design/icons" +import { + UploadOutlined, + AudioOutlined, + RobotOutlined, + VideoCameraOutlined, +} from "@ant-design/icons" import { Button } from "@/components/ui" import PageHead from "@/components/layout/PageHead" import { type AssetItem } from "@/api/assets" @@ -35,6 +40,8 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize" import { useVoiceUpload } from "./hooks/useVoiceUpload" import { useMaterialDelete } from "./hooks/useMaterialDelete" import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete" +import { useVideoExtract } from "./hooks/useVideoExtract" +import VideoExtractModal from "./components/VideoExtractModal" import "./voices.css" let toastIdSeq = 0 @@ -159,6 +166,18 @@ const VoiceLibrary: React.FC = () => { handleUploadClose, } = useVoiceUpload({ showToast }) + // ── 提取视频配音 ────────────────────────────────────── + const { + extractOpen, + extractFile, + extractProgress, + isExtracting, + setExtractOpen, + handleFileSelect: handleExtractFileSelect, + handleExtract, + handleExtractClose, + } = useVideoExtract({ showToast }) + // ── URL 参数自动打开上传弹窗 ──────────────────────────── const [searchParams, setSearchParams] = useSearchParams() @@ -200,6 +219,14 @@ const VoiceLibrary: React.FC = () => { > 上传音频 + + )} + + + {progress !== null && ( +
+
+
+
+
+ {progress}% +
+
+ )} + + {isExtracting && ( +

+ {progress === 100 ? "正在提取人声,请稍候..." : "正在上传视频..."} +

+ )} +
+ )} + +
+ + +
+ + ) +} + +export default VideoExtractModal diff --git a/apps/web/src/pages/voices/hooks/useVideoExtract.ts b/apps/web/src/pages/voices/hooks/useVideoExtract.ts new file mode 100644 index 000000000..7a45c56cf --- /dev/null +++ b/apps/web/src/pages/voices/hooks/useVideoExtract.ts @@ -0,0 +1,74 @@ +import { useState, useCallback } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { extractVideoVoice } from "@/api/tts" + +/** + * 视频提取配音 Hook + * 封装视频上传弹窗状态、提取进度、提取 mutation 逻辑 + */ +interface UseVideoExtractProps { + showToast: (message: string, type: "success" | "error") => void +} + +export function useVideoExtract({ showToast }: UseVideoExtractProps) { + const queryClient = useQueryClient() + + const [extractOpen, setExtractOpen] = useState(false) + const [extractFile, setExtractFile] = useState(null) + const [extractProgress, setExtractProgress] = useState(null) + const [isExtracting, setIsExtracting] = useState(false) + + const handleExtractClose = useCallback(() => { + setExtractOpen(false) + setExtractFile(null) + setExtractProgress(null) + setIsExtracting(false) + }, []) + + const handleExtract = useCallback(async () => { + if (!extractFile) return + setIsExtracting(true) + setExtractProgress(0) + try { + await extractVideoVoice(extractFile, (p) => setExtractProgress(p)) + // 刷新素材列表 + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + queryClient.invalidateQueries({ queryKey: ["voice-materials"] }) + showToast("视频配音提取成功", "success") + handleExtractClose() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "提取失败,请重试" + showToast(msg, "error") + } finally { + setIsExtracting(false) + setExtractProgress(null) + } + }, [extractFile, queryClient, showToast, handleExtractClose]) + + const handleFileSelect = useCallback( + (file: File | null) => { + if (!file) { + setExtractFile(null) + return + } + const validTypes = ["video/mp4", "video/quicktime", "video/webm"] + if (!validTypes.includes(file.type)) { + showToast("仅支持 MP4、MOV、WebM 格式的视频文件", "error") + return + } + setExtractFile(file) + }, + [showToast], + ) + + return { + extractOpen, + setExtractOpen, + extractFile, + extractProgress, + isExtracting, + handleFileSelect, + handleExtract, + handleExtractClose, + } +} diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index 24d8f1adf..df526af63 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -193,6 +193,24 @@ overflow: hidden; text-overflow: ellipsis; flex: 1; + display: flex; + align-items: center; +} + +/* AI 配音标识 */ +.vmat-ai-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + font-size: 11px; + font-weight: 600; + color: #7c3aed; + background: #f3f0ff; + border: 1px solid #ddd6fe; + border-radius: 4px; + line-height: 16px; + vertical-align: middle; + flex-shrink: 0; } .xx-voice-star { -- 2.54.0 From 229f9dddebb2d28490e79a854818283b043fbc2f Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Thu, 3 Sep 2026 20:01:25 +0800 Subject: [PATCH 03/39] =?UTF-8?q?feat:=20=E6=AD=A3=E5=BC=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=97=B6=E7=89=87=E6=AE=B5=E9=9A=8F=E6=9C=BA=E9=87=8D?= =?UTF-8?q?=E6=8E=92=EF=BC=88=E9=99=8D=E9=87=8D=EF=BC=8C=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=BC=80=E5=90=AF=E6=97=A0=E5=BC=80=E5=85=B3=EF=BC=89#1663?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 _distribute_assets 方法中,smart_match 评分排序完成后、 distribute_assets 之前,对 asset_ids 做 random.shuffle。 - smart_match 决定选哪些素材(评分排序保留) - shuffle 只改变最终分配到 clips 的顺序 - scene_points 缓存不受影响(shuffle 之前已读取) - asset_ids 先 list() 复制再 shuffle,不修改调用方原列表 - 新增 3 个单元测试验证 shuffle 行为 Closes #1663 --- .../app/services/plan_generator_service.py | 5 + tests/unit/test_plan_generator.py | 159 ++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index de0b9b938..758510c0f 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -234,6 +234,11 @@ class PlanGeneratorService: # 有缓存的素材片段起点从随机镜头段选取,无缓存走随机起点兜底 asset_scene_points = self._fetch_asset_scene_points(asset_ids) + # 正式生成也随机重排片段顺序(降重,默认开启无开关) + # smart_match 决定选哪些素材,shuffle 只改变分配到 clips 的顺序 + asset_ids = list(asset_ids) # 复制避免修改调用方原列表 + random.shuffle(asset_ids) + distribute_assets( clips, asset_ids, diff --git a/tests/unit/test_plan_generator.py b/tests/unit/test_plan_generator.py index 37544c067..2d328cbc3 100755 --- a/tests/unit/test_plan_generator.py +++ b/tests/unit/test_plan_generator.py @@ -1007,3 +1007,162 @@ class TestAssetDurationsAlwaysFetched: call_kwargs = mock_distribute.call_args asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations")) assert asset_durations is None + + +# --------------------------------------------------------------------------- +# 测试:正式生成片段随机重排(Issue #1663) +# --------------------------------------------------------------------------- + + +class TestFormalGenerationShuffle: + """验证正式生成时片段顺序随机化。 + + Issue #1663: 正式生成时 smart_match 排序后对 asset_ids 做 random.shuffle, + 使得同一批素材每次生成的视频片段顺序不同,有利于查重降重。 + """ + + def _make_service_with_asset_repo(self): + """创建带 mock asset_repo 的 PlanGeneratorService(复用 TestAssetDurationsAlwaysFetched 模式)""" + from apps.api.app.services.plan_generator_service import PlanGeneratorService + + plan_repo = StubEditPlanRepository() + clip_repo = StubEditPlanClipRepository() + + asset_repo = MagicMock() + + def fake_get(asset_id): + mock_asset = MagicMock() + mock_asset.duration = 30.0 + mock_asset.quality_score = None + mock_asset.created_at = None + mock_asset.metadata = {} + return mock_asset + + asset_repo.get = MagicMock(side_effect=fake_get) + + with ( + patch( + "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository", + return_value=plan_repo, + ), + patch( + "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository", + return_value=clip_repo, + ), + ): + db = MagicMock() + svc = PlanGeneratorService(db, asset_repo=asset_repo) + svc._plan_repo = plan_repo + svc._clip_repo = clip_repo + + return svc, asset_repo + + def test_formal_generation_shuffles_asset_ids(self): + """正式生成路径下 asset_ids 应被打乱,多次调用顺序应不同""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + # 6 个 clip 容纳 6 个素材 + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(6) + ], + ) + + asset_ids = ["a1", "a2", "a3", "a4", "a5", "a6"] + + # 收集多次调用中 distribute_assets 收到的 asset_ids 顺序 + captured_orders = [] + with patch( + "apps.api.app.services.plan_generator_service.distribute_assets", + side_effect=lambda clips, asset_ids, *a, **kw: captured_orders.append(list(asset_ids)), + ): + # mock _sort_assets_by_smart_score 返回固定顺序,验证 shuffle 会打乱 + with patch.object( + svc, + "_sort_assets_by_smart_score", + side_effect=lambda ids: list(ids), # 原样返回 + ): + with patch.object( + svc, + "_fetch_asset_scene_points", + return_value={}, + ): + for _ in range(10): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=list(asset_ids), # 每次传新列表 + random_preview=False, # 正式生成 + ) + + assert len(captured_orders) == 10 + # 每次 order 应该是 asset_ids 的一个排列 + expected_set = set(asset_ids) + for order in captured_orders: + assert set(order) == expected_set + + # 10 次调用中应至少出现 2 种不同顺序(概率 > 99.9%) + unique_orders = set(tuple(o) for o in captured_orders) + assert ( + len(unique_orders) >= 2 + ), f"Expected shuffled orders to vary, but got only {len(unique_orders)} unique order(s): {unique_orders}" + + def test_formal_generation_does_not_mutate_original_list(self): + """shuffle 不应修改调用方的原始 asset_ids 列表""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(4) + ], + ) + + original = ["a1", "a2", "a3", "a4"] + original_copy = list(original) + + with patch("apps.api.app.services.plan_generator_service.distribute_assets"): + with patch.object(svc, "_sort_assets_by_smart_score", side_effect=lambda ids: list(ids)): + with patch.object(svc, "_fetch_asset_scene_points", return_value={}): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=original, + random_preview=False, + ) + + assert original == original_copy, "Original asset_ids list should not be mutated" + + def test_preview_random_mode_unaffected_by_shuffle(self): + """预览随机模式不走 shuffle 路径,行为不变""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(4) + ], + ) + + asset_ids = ["a1", "a2", "a3", "a4"] + + captured_orders = [] + with patch( + "apps.api.app.services.plan_generator_service.distribute_assets", + side_effect=lambda clips, asset_ids, *a, **kw: captured_orders.append(list(asset_ids)), + ): + for _ in range(5): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=list(asset_ids), + random_preview=True, # 预览随机模式 + ) + + assert len(captured_orders) == 5 + # 预览模式下 random.shuffle 不应被调用(在 _distribute_assets 的 if not random_selection 块内) + # 所以 asset_ids 应该保持调用方传入的顺序(可能已由上层 shuffle 过) -- 2.54.0 From c7c30936a914147f1d21dd7359a078e0d6804e1f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 20:35:06 +0800 Subject: [PATCH 04/39] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20voices.py=20?= =?UTF-8?q?=E4=B8=AD=203=20=E5=A4=84=20ruff=20B904=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=EF=BC=8C=E8=A7=A3=E9=99=A4=20CI=20Validate-Style=20=E9=98=BB?= =?UTF-8?q?=E5=A1=9E=20(#1667)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/voices.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index b61103113..c6d36ac1f 100755 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -687,13 +687,13 @@ def extract_voice_from_video( raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="视频处理超时,请尝试较短的视频", - ) + ) from None except Exception as e: logger.exception("提取视频配音失败: %s", e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="提取配音失败,请稍后重试", - ) + ) from e finally: # 清理临时文件 if tmp_dir and Path(tmp_dir).exists(): @@ -740,7 +740,7 @@ def _find_or_create_voice_library_for_extract(*, user_id, project_repository, as raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配音素材库创建失败", - ) + ) from None def _get_audio_duration(audio_path: Path) -> float: -- 2.54.0 From cbca0c3584ad0ae9cfdb6893573edc400836b8a4 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Thu, 3 Sep 2026 20:41:11 +0800 Subject: [PATCH 05/39] =?UTF-8?q?feat:=20=E5=88=86=E7=89=87=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=E5=AD=98=E5=82=A8=E6=94=B9=E9=80=A0=20+=20=E5=AD=98?= =?UTF-8?q?=E9=87=8F=E6=8C=87=E7=BA=B9=E9=87=8D=E5=BB=BA=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=20#1657?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 ### 1. 新建 video_fingerprint_chunks 表(Migration 063) - 按时间分片存储 pHash + color_histogram - 索引:video_id, project_id, user_id ### 2. 新增 VideoFingerprintChunkModel - packages/adapters/sqlalchemy_impl/models.py ### 3. 改造 dedup.py 指纹计算 - compute_fingerprint() 改为按时间分片抽帧 - 短视频(≤60s):每 2s 一片 - 长视频(>60s):每 5s 一片 - VideoFingerprint 新增 chunks 字段(list of FingerprintChunk) - 向后兼容:keyframe_phashes/color_histograms 保留 - to_chunk_models() 方法转换为 SQLAlchemy Model - check_duplicate() 优先从分片表读取,回退到 JSON 字段 - check_duplicate_task() 写入分片表 ### 4. 改造 dedup_helpers.py - create_video_record_and_dedup() 同步写入分片表 ### 5. 存量指纹重建脚本 - apps/api/scripts/rebuild_fingerprint_chunks.py - 支持 --dry-run 和 --batch-size - 幂等:已有分片数据的视频跳过 ### 6. 单元测试(11 个) - 分片策略:60s→30片,120s→24片 - to_chunk_models() 输出正确 - _save_fingerprint_chunks 幂等性 - to_dict() 向后兼容 Closes #1657 --- .../063_add_video_fingerprint_chunks.py | 46 +++ .../api/scripts/rebuild_fingerprint_chunks.py | 173 ++++++++++ apps/worker/video_processing/dedup.py | 201 +++++++++-- apps/worker/video_processing/dedup_helpers.py | 8 + packages/adapters/sqlalchemy_impl/models.py | 17 + tests/unit/test_fingerprint_chunks.py | 313 ++++++++++++++++++ 6 files changed, 734 insertions(+), 24 deletions(-) create mode 100644 alembic/versions/063_add_video_fingerprint_chunks.py create mode 100644 apps/api/scripts/rebuild_fingerprint_chunks.py create mode 100644 tests/unit/test_fingerprint_chunks.py diff --git a/alembic/versions/063_add_video_fingerprint_chunks.py b/alembic/versions/063_add_video_fingerprint_chunks.py new file mode 100644 index 000000000..a629d5d57 --- /dev/null +++ b/alembic/versions/063_add_video_fingerprint_chunks.py @@ -0,0 +1,46 @@ +"""add video_fingerprint_chunks table for per-chunk fingerprint storage + +Revision ID: 063_fingerprint_chunks +Revises: 062_edit_plan_id +Create Date: 2026-09-03 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "063_fingerprint_chunks" +down_revision = "062_edit_plan_id" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "video_fingerprint_chunks", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("video_id", sa.String(36), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("user_id", sa.String(36), nullable=False, server_default=""), + sa.Column("start_time_ms", sa.Integer, nullable=False), + sa.Column("end_time_ms", sa.Integer, nullable=False), + sa.Column("phash_binary", sa.String(16), nullable=False), + sa.Column("color_histogram", sa.JSON, nullable=False), + sa.Column("frame_count", sa.Integer, nullable=False, server_default="1"), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index("ix_vfc_video_id", "video_fingerprint_chunks", ["video_id"]) + op.create_index("ix_vfc_project_id", "video_fingerprint_chunks", ["project_id"]) + op.create_index("ix_vfc_user_id", "video_fingerprint_chunks", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_vfc_user_id", table_name="video_fingerprint_chunks") + op.drop_index("ix_vfc_project_id", table_name="video_fingerprint_chunks") + op.drop_index("ix_vfc_video_id", table_name="video_fingerprint_chunks") + op.drop_table("video_fingerprint_chunks") diff --git a/apps/api/scripts/rebuild_fingerprint_chunks.py b/apps/api/scripts/rebuild_fingerprint_chunks.py new file mode 100644 index 000000000..5568f82d1 --- /dev/null +++ b/apps/api/scripts/rebuild_fingerprint_chunks.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""存量指纹重建脚本 — 为已有视频生成 video_fingerprint_chunks 分片数据。 + +功能: +- 查询 generated_videos 中 video_fingerprint IS NOT NULL 但尚无分片数据的视频 +- 从 OSS 下载视频 → 用新的分片算法重新计算指纹 → 写入分片表 +- 支持 --dry-run(只打印不写入)和 --batch-size(默认 50) +- 幂等:已存在分片数据的视频跳过 + +用法: + # 预览(不写入) + python rebuild_fingerprint_chunks.py --dry-run + + # 执行重建 + python rebuild_fingerprint_chunks.py --batch-size 50 +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +import tempfile + +# 确保可以 import worker_app 和 packages +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "worker")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..")) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("rebuild_fingerprint_chunks") + + +def find_videos_needing_rebuild(session, batch_size: int) -> list[dict]: + """查询需要重建分片指纹的视频。""" + from sqlalchemy import and_ + + from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel, VideoFingerprintChunkModel + + # 有 video_fingerprint 的视频 + has_fingerprint = GeneratedVideoModel.video_fingerprint.isnot(None) + has_fingerprint = and_(has_fingerprint, GeneratedVideoModel.video_fingerprint != "") + + # 排除已有分片数据的视频 + subq = session.query(VideoFingerprintChunkModel.video_id).distinct().subquery() + no_chunks = ~GeneratedVideoModel.id.in_(subq) + + videos = ( + session.query(GeneratedVideoModel) + .filter(and_(has_fingerprint, no_chunks)) + .order_by(GeneratedVideoModel.generated_at.desc()) + .limit(batch_size) + .all() + ) + + return [ + { + "id": v.id, + "project_id": v.project_id, + "user_id": v.user_id or "", + "duration": v.duration, + } + for v in videos + ] + + +def rebuild_one(video_info: dict, dry_run: bool = False) -> int: + """重建单个视频的分片数据。返回写入的 chunk 数量。""" + from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel + from packages.shared.storage import get_storage_service + from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks + from worker_app.db import SessionLocal + + video_id = video_info["id"] + project_id = video_info["project_id"] + user_id = video_info["user_id"] + + if dry_run: + logger.info("[DRY-RUN] Would rebuild video %s (project=%s)", video_id, project_id) + return 0 + + session = SessionLocal() + temp_dir = tempfile.mkdtemp() + + try: + # 再次检查幂等性 + existing_count = ( + session.query(VideoFingerprintChunkModel).filter(VideoFingerprintChunkModel.video_id == video_id).count() + ) + if existing_count > 0: + logger.info("Video %s already has %d chunks, skipping", video_id, existing_count) + return 0 + + # 下载视频 + storage_service = get_storage_service() + local_path = os.path.join(temp_dir, f"{video_id}.mp4") + storage_key = f"projects/{project_id}/generated/{video_id}/{video_id}.mp4" + storage_service.download_file(storage_key, local_path) + + # 重新计算指纹 + deduplicator = VideoDeduplicator() + fingerprint = deduplicator.compute_fingerprint(local_path) + + # 写入分片表 + _save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session) + session.commit() + + chunk_count = len(fingerprint.chunks) + logger.info("Rebuilt %d chunks for video %s", chunk_count, video_id) + return chunk_count + + except Exception as e: + logger.error("Failed to rebuild video %s: %s", video_id, e) + session.rollback() + return -1 + finally: + session.close() + import shutil + + shutil.rmtree(temp_dir, ignore_errors=True) + + +def main(): + parser = argparse.ArgumentParser(description="存量指纹重建脚本") + parser.add_argument("--dry-run", action="store_true", help="只打印不写入") + parser.add_argument("--batch-size", type=int, default=50, help="每批处理数量(默认 50)") + parser.add_argument("--total-limit", type=int, default=0, help="总处理数量限制(0=不限制)") + args = parser.parse_args() + + from worker_app.db import SessionLocal + + session = SessionLocal() + + try: + videos = find_videos_needing_rebuild(session, args.batch_size) + logger.info("Found %d videos needing rebuild", len(videos)) + + if args.dry_run: + for v in videos: + logger.info("[DRY-RUN] Video %s | project=%s | duration=%.1fs", v["id"], v["project_id"], v["duration"]) + return + + total_chunks = 0 + processed = 0 + failed = 0 + + for v in videos: + if args.total_limit > 0 and processed >= args.total_limit: + break + + result = rebuild_one(v, dry_run=False) + if result < 0: + failed += 1 + else: + total_chunks += result + processed += 1 + + logger.info( + "Rebuild complete: processed=%d, chunks=%d, failed=%d", + processed, + total_chunks, + failed, + ) + + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index ecbeec613..1cbd4e123 100755 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -4,8 +4,9 @@ import hashlib import logging import os import tempfile -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional +from uuid import uuid4 import cv2 import numpy as np @@ -15,10 +16,16 @@ from worker_app.celery_app import celery_app from worker_app.db import SessionLocal from packages.adapters.sqlalchemy_impl.generated_video_repository import SQLAlchemyGeneratedVideoRepository +from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel from packages.shared.storage import get_storage_service logger = logging.getLogger(__name__) +# 分片策略常量 +SHORT_VIDEO_CHUNK_SEC = 2 # ≤60秒视频,每 2 秒一个分片 +LONG_VIDEO_CHUNK_SEC = 5 # >60秒视频,每 5 秒一个分片 +SHORT_VIDEO_THRESHOLD_SEC = 60 + def compute_phash(image: np.ndarray, hash_size: int = 8) -> str: """计算图像的感知哈希(pHash),基于 DCT(离散余弦变换)。 @@ -80,6 +87,28 @@ def compute_color_histogram(image: np.ndarray, bins: int = 32) -> list[float]: return hist +def compute_chunk_interval(duration: float) -> float: + """根据视频时长返回分片间隔(秒)。 + + 短视频(≤60秒):每 2 秒一个分片 + 长视频(>60秒):每 5 秒一个分片 + """ + if duration <= SHORT_VIDEO_THRESHOLD_SEC: + return SHORT_VIDEO_CHUNK_SEC + return LONG_VIDEO_CHUNK_SEC + + +@dataclass +class FingerprintChunk: + """单个分片指纹数据。""" + + start_time_ms: int + end_time_ms: int + phash_binary: str + color_histogram: list[float] + frame_count: int = 1 + + @dataclass class VideoFingerprint: """Video fingerprint containing multiple similarity metrics.""" @@ -89,6 +118,7 @@ class VideoFingerprint: color_histograms: list[list[float]] duration: float resolution: tuple[int, int] + chunks: list[FingerprintChunk] = field(default_factory=list) def to_dict(self) -> dict: # 注意:color_histograms 里的值可能是 np.float32(来自 cv2.normalize), @@ -101,8 +131,37 @@ class VideoFingerprint: "color_histograms": native_histograms, "duration": float(self.duration), "resolution": [int(self.resolution[0]), int(self.resolution[1])], + "chunks": [ + { + "start_time_ms": c.start_time_ms, + "end_time_ms": c.end_time_ms, + "phash_binary": c.phash_binary, + "color_histogram": [float(v) for v in c.color_histogram], + "frame_count": c.frame_count, + } + for c in self.chunks + ], } + def to_chunk_models(self, video_id: str, project_id: str, user_id: str = "") -> list[VideoFingerprintChunkModel]: + """将分片数据转为 SQLAlchemy Model 列表,用于批量写入 video_fingerprint_chunks 表。""" + models = [] + for chunk in self.chunks: + models.append( + VideoFingerprintChunkModel( + id=uuid4().hex, + video_id=video_id, + project_id=project_id, + user_id=user_id, + start_time_ms=chunk.start_time_ms, + end_time_ms=chunk.end_time_ms, + phash_binary=chunk.phash_binary, + color_histogram=[float(v) for v in chunk.color_histogram], + frame_count=chunk.frame_count, + ) + ) + return models + class VideoDeduplicator: """Video deduplication using multiple fingerprint methods.""" @@ -111,7 +170,12 @@ class VideoDeduplicator: HISTOGRAM_THRESHOLD = 0.85 def compute_fingerprint(self, video_path: str) -> VideoFingerprint: - """Compute video fingerprint using MD5, pHash, and color histogram.""" + """Compute video fingerprint using MD5, pHash, and color histogram. + + 按时间分片抽帧:短视频(≤60s)每 2s 一片,长视频每 5s 一片。 + 每片取 1 帧计算 pHash + color_histogram。 + 同时保留 keyframe_phashes/color_histograms 聚合字段(向后兼容)。 + """ cap = cv2.VideoCapture(video_path) if not cap.isOpened(): raise RuntimeError(f"Cannot open video: {video_path}") @@ -123,42 +187,82 @@ class VideoDeduplicator: height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) md5_hash = hashlib.md5(usedforsecurity=False) - keyframe_phashes = [] - color_histograms = [] + chunks: list[FingerprintChunk] = [] - frame_interval = max(1, frame_count // 10) - for i in range(0, frame_count, frame_interval): - cap.set(cv2.CAP_PROP_POS_FRAMES, i) + # 分片间隔(秒) + chunk_interval_sec = compute_chunk_interval(duration) + chunk_interval_ms = int(chunk_interval_sec * 1000) + duration_ms = int(duration * 1000) + + # 遍历每个分片时间窗口,取 1 帧 + start_ms = 0 + while start_ms < duration_ms: + end_ms = min(start_ms + chunk_interval_ms, duration_ms) + # 定位到分片中点 + seek_ms = (start_ms + end_ms) / 2 + cap.set(cv2.CAP_PROP_POS_MSEC, seek_ms) ret, frame = cap.read() - if not ret: - continue + if ret: + # MD5 计算 + _, buffer = cv2.imencode(".jpg", frame) + md5_hash.update(buffer) - _, buffer = cv2.imencode(".jpg", frame) - md5_hash.update(buffer) + phash = compute_phash(frame) + hist = compute_color_histogram(frame) - keyframe_phashes.append(compute_phash(frame)) - color_histograms.append(compute_color_histogram(frame)) + chunks.append( + FingerprintChunk( + start_time_ms=start_ms, + end_time_ms=end_ms, + phash_binary=phash, + color_histogram=hist, + frame_count=1, + ) + ) + + start_ms = end_ms cap.release() + # 向后兼容:聚合 keyframe_phashes / color_histograms + keyframe_phashes = [c.phash_binary for c in chunks] + color_histograms = [c.color_histogram for c in chunks] + return VideoFingerprint( md5=md5_hash.hexdigest(), keyframe_phashes=keyframe_phashes, color_histograms=color_histograms, duration=duration, resolution=(width, height), + chunks=chunks, ) + def _get_existing_chunks(self, video_id: str, session: Session) -> list[dict]: + """从 video_fingerprint_chunks 表读取分片数据。返回空列表表示无分片数据。""" + rows = ( + session.query(VideoFingerprintChunkModel) + .filter(VideoFingerprintChunkModel.video_id == video_id) + .order_by(VideoFingerprintChunkModel.start_time_ms) + .all() + ) + return [ + { + "phash_binary": r.phash_binary, + "color_histogram": r.color_histogram, + "start_time_ms": r.start_time_ms, + "end_time_ms": r.end_time_ms, + } + for r in rows + ] + def check_duplicate(self, fingerprint: VideoFingerprint, project_id: str, session: Session) -> Optional[dict]: """检查视频是否与项目中已有视频重复。 - 判定逻辑(按优先级): - 1. MD5 精确匹配:完全一致则 similarity=1.0,立即返回 - 2. pHash 相似度:计算新视频每帧 phash 与已有视频每帧 phash 的最小汉明距离, - 取所有帧的平均值 avg_distance。若 avg_distance < PHASH_THRESHOLD(10), - 则判定为重复,similarity = 1.0 - (avg_distance / 64) + 查重逻辑: + 1. MD5 精确匹配 → similarity=1.0 + 2. pHash 相似度(优先从分片表读取,回退到 JSON 字段) - 注意:返回第一个通过阈值的匹配(非最优匹配)。 + 判定阈值:avg_distance < PHASH_THRESHOLD(10) Args: fingerprint: 待检测视频的指纹 @@ -182,8 +286,15 @@ class VideoDeduplicator: if fingerprint.md5 == ef.get("md5"): return {"duplicate": True, "duplicate_of": existing.id, "reason": "exact_md5_match", "similarity": 1.0} - # 感知哈希相似度 - existing_phashes = ef.get("keyframe_phashes", []) + # 优先从分片表读取已有视频的分片 phash + existing_phashes = [] + chunk_data = self._get_existing_chunks(existing.id, session) + if chunk_data: + existing_phashes = [c["phash_binary"] for c in chunk_data] + else: + # 回退:从 JSON 字段读取(存量旧视频) + existing_phashes = ef.get("keyframe_phashes", []) + if not existing_phashes: continue @@ -247,7 +358,14 @@ class VideoDeduplicator: "similarity": 1.0, } - existing_phashes = ef.get("keyframe_phashes", []) + # 优先从分片表读取 + existing_phashes = [] + chunk_data = self._get_existing_chunks(existing.id, session) + if chunk_data: + existing_phashes = [c["phash_binary"] for c in chunk_data] + else: + existing_phashes = ef.get("keyframe_phashes", []) + if not existing_phashes: continue @@ -372,8 +490,14 @@ class VideoDeduplicator: if fingerprint.md5 == ef.get("md5"): return 100.0 - # pHash 相似度 - existing_phashes = ef.get("keyframe_phashes", []) + # 优先从分片表读取 + existing_phashes = [] + chunk_data = self._get_existing_chunks(existing.id, session) + if chunk_data: + existing_phashes = [c["phash_binary"] for c in chunk_data] + else: + existing_phashes = ef.get("keyframe_phashes", []) + if not existing_phashes or not fingerprint.keyframe_phashes: continue @@ -388,6 +512,31 @@ class VideoDeduplicator: return round(max(max_similarity, 0.0), 2) +def _save_fingerprint_chunks( + fingerprint: VideoFingerprint, + video_id: str, + project_id: str, + user_id: str, + session: Session, +) -> None: + """将指纹分片数据批量写入 video_fingerprint_chunks 表。幂等:已有数据时跳过。""" + # 幂等检查:已有分片数据则跳过 + existing_count = ( + session.query(VideoFingerprintChunkModel).filter(VideoFingerprintChunkModel.video_id == video_id).count() + ) + if existing_count > 0: + logger.debug("Fingerprint chunks already exist for video %s (%d chunks), skipping", video_id, existing_count) + return + + if not fingerprint.chunks: + logger.warning("No chunks in fingerprint for video %s, skipping chunk save", video_id) + return + + chunk_models = fingerprint.to_chunk_models(video_id, project_id, user_id) + session.bulk_save_objects(chunk_models) + logger.info("Saved %d fingerprint chunks for video %s", len(chunk_models), video_id) + + @celery_app.task(bind=True, max_retries=3, name="worker.check_duplicate") def check_duplicate_task(self: Task, generated_video_id: str) -> dict: """Celery task to check if generated video is a duplicate.""" @@ -421,6 +570,10 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict: video.duplicate_of = None video_repo.update(video) + + # 写入分片表 + _save_fingerprint_chunks(fingerprint, generated_video_id, video.project_id, video.user_id, session) + session.commit() logger.info(f"Duplicate check completed for video {generated_video_id}: is_duplicate={video.is_duplicate}") diff --git a/apps/worker/video_processing/dedup_helpers.py b/apps/worker/video_processing/dedup_helpers.py index efcc2072f..9b92a5da7 100755 --- a/apps/worker/video_processing/dedup_helpers.py +++ b/apps/worker/video_processing/dedup_helpers.py @@ -100,6 +100,14 @@ def create_video_record_and_dedup( generated_video.video_fingerprint = fingerprint.to_dict() + # 写入分片指纹表 + from video_processing.dedup import _save_fingerprint_chunks + + try: + _save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session) + except Exception as chunk_err: + logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err) + # (a) 历史成片查重 duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index af3712106..060b07480 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -620,3 +620,20 @@ class CoverTemplateModel(Base): config = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class VideoFingerprintChunkModel(Base): + """分片视频指纹 — 每个视频按时间分片存储 pHash + color_histogram.""" + + __tablename__ = "video_fingerprint_chunks" + + id = Column(String(36), primary_key=True) + video_id = Column(String(36), nullable=False, index=True) + project_id = Column(String(36), nullable=False, index=True) + user_id = Column(String(36), nullable=False, index=True, default="") + start_time_ms = Column(Integer, nullable=False) + end_time_ms = Column(Integer, nullable=False) + phash_binary = Column(String(16), nullable=False) + color_histogram = Column(JSON, nullable=False) + frame_count = Column(Integer, nullable=False, default=1) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/tests/unit/test_fingerprint_chunks.py b/tests/unit/test_fingerprint_chunks.py new file mode 100644 index 000000000..a10ad5260 --- /dev/null +++ b/tests/unit/test_fingerprint_chunks.py @@ -0,0 +1,313 @@ +"""分片指纹存储单元测试 — Issue #1657. + +覆盖: +- 分片策略:60秒视频 → 30片,120秒视频 → 24片 +- VideoFingerprint.to_chunk_models() 输出正确 +- _save_fingerprint_chunks 幂等性(已有数据跳过) +- to_dict() 向后兼容 +""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock + + +def _mock_module(**attrs): + """Create a mock module with __spec__ to avoid AttributeError.""" + m = MagicMock() + m.__spec__ = None + for k, v in attrs.items(): + setattr(m, k, v) + return m + + +# ── Module-level setup: mock deps, import dedup, then restore sys.modules ── +_SAVED_MODULES_KEYS = set(sys.modules.keys()) +_SAVED_MODULES_VALUES = { + k: sys.modules.get(k) + for k in [ + "cv2", + "celery", + "sqlalchemy", + "sqlalchemy.orm", + "sqlalchemy.engine", + "sqlalchemy.ext", + "sqlalchemy.ext.declarative", + "worker_app.db", + "worker_app.celery_app", + "worker_app.core.config", + "packages.adapters.sqlalchemy_impl.session", + "packages.adapters.sqlalchemy_impl.generated_video_repository", + "packages.adapters.sqlalchemy_impl.models", + "packages.shared.config", + "packages.shared.storage", + ] +} + +# Set up mocks +sys.modules["cv2"] = _mock_module() + +_mock_celery = MagicMock() +_mock_celery.Task = MagicMock +_mock_celery.Celery = MagicMock +_mock_celery.__spec__ = None +sys.modules["celery"] = _mock_celery + +_mock_sqla = MagicMock() +_mock_sqla.__path__ = [] +_mock_sqla.__spec__ = None +sys.modules["sqlalchemy"] = _mock_sqla + +_mock_sqla_orm = MagicMock() +_mock_sqla_orm.__path__ = [] +_mock_sqla_orm.__spec__ = None +_mock_sqla_orm.Session = MagicMock +sys.modules["sqlalchemy.orm"] = _mock_sqla_orm +sys.modules["sqlalchemy.engine"] = _mock_module() +sys.modules["sqlalchemy.ext"] = _mock_module() +sys.modules["sqlalchemy.ext.declarative"] = _mock_module() + +sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock()) +sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock()) +sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock())) + +sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module( + Base=MagicMock(), + build_engine=MagicMock(), + build_session_factory=MagicMock(), + ensure_database_exists=MagicMock(), + initialize_database=MagicMock(), +) +sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module() + + +# Mock VideoFingerprintChunkModel with class-level column attributes +class _FakeChunkModel: + video_id = MagicMock() + project_id = MagicMock() + user_id = MagicMock() + start_time_ms = MagicMock() + end_time_ms = MagicMock() + phash_binary = MagicMock() + color_histogram = MagicMock() + frame_count = MagicMock() + created_at = MagicMock() + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +sys.modules["packages.adapters.sqlalchemy_impl.models"] = _mock_module( + VideoFingerprintChunkModel=_FakeChunkModel, +) +sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock())) +sys.modules["packages.shared.storage"] = _mock_module() + +# Import dedup while mocks are active +from video_processing.dedup import ( # noqa: E402 + FingerprintChunk, + VideoFingerprint, + _save_fingerprint_chunks, + compute_chunk_interval, +) + +# ── Restore sys.modules immediately after import ── +for _key in list(sys.modules.keys()): + if _key not in _SAVED_MODULES_KEYS: + del sys.modules[_key] +for _key, _value in _SAVED_MODULES_VALUES.items(): + if _value is not None: + sys.modules[_key] = _value + elif _key in sys.modules: + del sys.modules[_key] +del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value + + +class TestChunkInterval: + """测试分片间隔策略。""" + + def test_short_video_interval(self): + """短视频(≤60秒)每 2 秒一个分片。""" + assert compute_chunk_interval(0) == 2 + assert compute_chunk_interval(30) == 2 + assert compute_chunk_interval(60) == 2 + + def test_long_video_interval(self): + """长视频(>60秒)每 5 秒一个分片。""" + assert compute_chunk_interval(61) == 5 + assert compute_chunk_interval(120) == 5 + assert compute_chunk_interval(300) == 5 + + def test_chunk_count_60s_video(self): + """60秒视频 → 30 片(60/2=30)。""" + duration = 60 + interval = compute_chunk_interval(duration) + expected_chunks = int(duration / interval) + assert expected_chunks == 30 + + def test_chunk_count_120s_video(self): + """120秒视频 → 24 片(120/5=24)。""" + duration = 120 + interval = compute_chunk_interval(duration) + expected_chunks = int(duration / interval) + assert expected_chunks == 24 + + +class TestVideoFingerprintToChunkModels: + """测试 VideoFingerprint.to_chunk_models() 输出。""" + + def test_to_chunk_models_output(self): + """to_chunk_models 返回正确的 Model 列表。""" + fp = VideoFingerprint( + md5="abc123", + keyframe_phashes=["a1b2", "c3d4"], + color_histograms=[[0.1] * 96, [0.2] * 96], + duration=10.0, + resolution=(1920, 1080), + chunks=[ + FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="a1b2", color_histogram=[0.1] * 96), + FingerprintChunk(start_time_ms=2000, end_time_ms=4000, phash_binary="c3d4", color_histogram=[0.2] * 96), + ], + ) + + models = fp.to_chunk_models(video_id="v1", project_id="p1", user_id="u1") + + assert len(models) == 2 + assert models[0].video_id == "v1" + assert models[0].project_id == "p1" + assert models[0].user_id == "u1" + assert models[0].start_time_ms == 0 + assert models[0].end_time_ms == 2000 + assert models[0].phash_binary == "a1b2" + assert models[1].start_time_ms == 2000 + assert models[1].end_time_ms == 4000 + assert models[1].phash_binary == "c3d4" + + def test_to_chunk_models_empty_chunks(self): + """空 chunks 列表返回空 Model 列表。""" + fp = VideoFingerprint( + md5="abc", + keyframe_phashes=[], + color_histograms=[], + duration=0, + resolution=(0, 0), + chunks=[], + ) + + models = fp.to_chunk_models(video_id="v1", project_id="p1") + assert models == [] + + +class TestSaveFingerprintChunksIdempotent: + """测试 _save_fingerprint_chunks 幂等性。""" + + def test_save_skips_existing(self): + """已有分片数据时跳过写入。""" + fp = VideoFingerprint( + md5="abc", + keyframe_phashes=["a1b2"], + color_histograms=[[0.1] * 96], + duration=5.0, + resolution=(1920, 1080), + chunks=[ + FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="a1b2", color_histogram=[0.1] * 96), + ], + ) + + session = MagicMock() + # Mock: 已有 1 条分片数据 + session.query.return_value.filter.return_value.count.return_value = 1 + + _save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session) + + # bulk_save_objects 不应被调用 + session.bulk_save_objects.assert_not_called() + + def test_save_writes_new(self): + """无分片数据时写入。""" + fp = VideoFingerprint( + md5="abc", + keyframe_phashes=["a1b2"], + color_histograms=[[0.1] * 96], + duration=5.0, + resolution=(1920, 1080), + chunks=[ + FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="a1b2", color_histogram=[0.1] * 96), + ], + ) + + session = MagicMock() + # Mock: 无分片数据 + session.query.return_value.filter.return_value.count.return_value = 0 + + _save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session) + + # bulk_save_objects 应被调用一次 + session.bulk_save_objects.assert_called_once() + saved_models = session.bulk_save_objects.call_args[0][0] + assert len(saved_models) == 1 + assert saved_models[0].video_id == "v1" + assert saved_models[0].phash_binary == "a1b2" + + def test_save_skips_no_chunks(self): + """指纹无 chunks 时跳过。""" + fp = VideoFingerprint( + md5="abc", + keyframe_phashes=[], + color_histograms=[], + duration=0, + resolution=(0, 0), + chunks=[], + ) + + session = MagicMock() + session.query.return_value.filter.return_value.count.return_value = 0 + + _save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session) + + # bulk_save_objects 不应被调用 + session.bulk_save_objects.assert_not_called() + + +class TestFingerprintToDictBackwardCompat: + """测试 to_dict() 向后兼容性。""" + + def test_to_dict_includes_chunks(self): + """to_dict() 包含 chunks 字段。""" + fp = VideoFingerprint( + md5="abc123", + keyframe_phashes=["a1b2"], + color_histograms=[[0.1] * 96], + duration=5.0, + resolution=(1920, 1080), + chunks=[ + FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="a1b2", color_histogram=[0.1] * 96), + ], + ) + + d = fp.to_dict() + + assert "chunks" in d + assert len(d["chunks"]) == 1 + assert d["chunks"][0]["start_time_ms"] == 0 + assert d["chunks"][0]["end_time_ms"] == 2000 + assert d["chunks"][0]["phash_binary"] == "a1b2" + + def test_to_dict_preserves_legacy_fields(self): + """to_dict() 保留 keyframe_phashes 和 color_histograms 字段。""" + fp = VideoFingerprint( + md5="abc", + keyframe_phashes=["a1b2", "c3d4"], + color_histograms=[[0.1] * 96, [0.2] * 96], + duration=10.0, + resolution=(1920, 1080), + ) + + d = fp.to_dict() + + assert "keyframe_phashes" in d + assert "color_histograms" in d + assert len(d["keyframe_phashes"]) == 2 + assert len(d["color_histograms"]) == 2 -- 2.54.0 From b0018e747bb86feeac71f4d95ea7827e5a712f33 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 20:49:15 +0800 Subject: [PATCH 06/39] =?UTF-8?q?fix:=20=E6=8F=90=E5=8F=96=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E9=85=8D=E9=9F=B3=20API=20=E8=B7=AF=E5=BE=84=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E4=B8=BA=20/voices/extract-voice=20(#1665)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/tts/jobs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/api/tts/jobs.ts b/apps/web/src/api/tts/jobs.ts index 9a7468994..3bc6d4ab7 100644 --- a/apps/web/src/api/tts/jobs.ts +++ b/apps/web/src/api/tts/jobs.ts @@ -84,7 +84,7 @@ export const extractVideoVoice = async ( return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() - xhr.open("POST", "/api/v1/tts/extract-video-voice") + xhr.open("POST", "/api/v1/voices/extract-voice") // 携带认证 token(从 localStorage 获取,与 apiClient 拦截器一致) const token = localStorage.getItem("access_token") -- 2.54.0 From ee4fff42f007c135b68bc8e4d6cd023138918d24 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 20:49:31 +0800 Subject: [PATCH 07/39] =?UTF-8?q?fix(voices):=20AI=E9=85=8D=E9=9F=B3?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E5=85=BC=E5=AE=B9=E6=97=A7=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=20tts=5Fjob=5Fid=20=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E5=88=A4=E6=96=AD=20(#1666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/voices/components/MaterialVoiceTab.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx index 31f5f8a1c..2c406ec3a 100644 --- a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx +++ b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx @@ -136,8 +136,9 @@ export const MaterialVoiceTab: React.FC = ({ const material = mapAssetToMaterial(asset) // duration 优先取顶层(后端从 metadata 提取),兜底 metadata const cardDuration = asset.duration || material.duration || 0 - // AI 生成素材标识(metadata.source === "tts_job") - const isAiMaterial = (asset.metadata as Record)?.source === "tts_job" + // AI 生成素材标识:兼容旧素材(无 source 字段但有 tts_job_id) + const meta = asset.metadata as Record + const isAiMaterial = meta?.source === "tts_job" || !!meta?.tts_job_id const isPlaying = playingId === asset.id const isSelected = selectedIds.has(asset.id) // 播放中以 audio 真实时长为准,未播放显示卡片时长 -- 2.54.0 From 244691d3358fca8f652d706b6e3e70042748656c Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 3 Sep 2026 12:50:38 +0000 Subject: [PATCH 08/39] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/api/scripts/rebuild_fingerprint_chunks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/scripts/rebuild_fingerprint_chunks.py b/apps/api/scripts/rebuild_fingerprint_chunks.py index 5568f82d1..922731e87 100644 --- a/apps/api/scripts/rebuild_fingerprint_chunks.py +++ b/apps/api/scripts/rebuild_fingerprint_chunks.py @@ -69,11 +69,12 @@ def find_videos_needing_rebuild(session, batch_size: int) -> list[dict]: def rebuild_one(video_info: dict, dry_run: bool = False) -> int: """重建单个视频的分片数据。返回写入的 chunk 数量。""" - from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel - from packages.shared.storage import get_storage_service from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks from worker_app.db import SessionLocal + from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel + from packages.shared.storage import get_storage_service + video_id = video_info["id"] project_id = video_info["project_id"] user_id = video_info["user_id"] -- 2.54.0 From af4dd31dd1a4a81b4e0c23211d5667b050dfe7fe Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Thu, 3 Sep 2026 22:18:06 +0800 Subject: [PATCH 09/39] =?UTF-8?q?feat:=20=E8=B7=A8=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E7=89=87=E6=AE=B5=E9=81=BF=E8=AE=A9=20=E2=80=94=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=89=8D=E6=B3=A8=E5=85=A5=E5=B7=B2=E7=94=A8=E5=8C=BA?= =?UTF-8?q?=E9=97=B4=20#1670?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repository: list_used_segments_by_user() JOIN edit_plans 查用户最近 已完成 plan 的已渲染 clips,聚合为 {asset_id: [(start, end), ...]} - Domain: distribute_assets / _distribute_* 子函数新增 external_used_segments 参数,深拷贝注入 used_segments,让 _resolve_start_time 自动避让 - Service: _distribute_assets 新增 user_id 参数,预览和正式生成都查询 已用区间;查询失败时不阻塞,回退纯随机 - 12 个单元测试覆盖 Repository/Domain/Service 三层 --- .../app/services/plan_generator_service.py | 13 + .../edit_plan_clip_repository.py | 62 ++++ packages/domain/plan_generator_utils.py | 40 ++- tests/unit/test_cross_video_avoidance.py | 312 ++++++++++++++++++ 4 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_cross_video_avoidance.py diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index 758510c0f..0ac93c661 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -131,6 +131,7 @@ class PlanGeneratorService: editing_mode, random_selection=random_preview, asset_durations=asset_durations, + user_id=created_by_user_id, ) # 5. 持久化所有 clips 并计算总时长 @@ -218,6 +219,7 @@ class PlanGeneratorService: *, random_selection: bool = False, asset_durations: dict[str, float] | None = None, + user_id: str = "", ) -> None: """按 editing_mode 将素材分配到 clips(就地修改,未持久化). @@ -239,6 +241,16 @@ class PlanGeneratorService: asset_ids = list(asset_ids) # 复制避免修改调用方原列表 random.shuffle(asset_ids) + # 查询已有视频的已用区间(跨视频避让) + external_used_segments = None + if user_id and self._clip_repo: + try: + external_used_segments = self._clip_repo.list_used_segments_by_user( + user_id, limit_recent=50 + ) + except Exception: + logger.warning("跨视频避让查询失败,回退到纯随机", exc_info=True) + distribute_assets( clips, asset_ids, @@ -246,6 +258,7 @@ class PlanGeneratorService: random_selection=random_selection, asset_durations=asset_durations, asset_scene_points=asset_scene_points, + external_used_segments=external_used_segments, ) def _fetch_asset_scene_points(self, asset_ids: List[str]) -> dict[str, list[float]]: diff --git a/packages/adapters/sqlalchemy_impl/edit_plan_clip_repository.py b/packages/adapters/sqlalchemy_impl/edit_plan_clip_repository.py index 4c241269f..16819092e 100755 --- a/packages/adapters/sqlalchemy_impl/edit_plan_clip_repository.py +++ b/packages/adapters/sqlalchemy_impl/edit_plan_clip_repository.py @@ -131,3 +131,65 @@ class SQLAlchemyEditPlanClipRepository: created_at=model.created_at, updated_at=model.updated_at, ) + + def list_used_segments_by_user( + self, + user_id: str, + *, + limit_recent: int = 50, + ) -> dict[str, list[tuple[float, float]]]: + """查询用户已有视频中已使用的素材区间(跨视频避让). + + JOIN edit_plans 表,按 created_by_user_id 过滤,只查 status='completed' + 的 plan 下 status='rendered' 且 asset_id 非空的 clips。按 plan 的 + created_at DESC 取最近 limit_recent 个 plan。 + + Returns: + {asset_id: [(start_time, start_time + duration), ...]} + 空结果返回空 dict。 + """ + from packages.adapters.sqlalchemy_impl.models import EditPlanModel + + if not user_id: + return {} + + # 1. 查出最近 limit_recent 个已完成 plan 的 ID + recent_plan_ids = [ + row[0] + for row in self.session.query(EditPlanModel.id) + .filter( + EditPlanModel.created_by_user_id == user_id, + EditPlanModel.status == "completed", + ) + .order_by(EditPlanModel.created_at.desc()) + .limit(limit_recent) + .all() + ] + + if not recent_plan_ids: + return {} + + # 2. 查这些 plan 下已渲染、有素材的 clips + clips = ( + self.session.query( + EditPlanClipModel.asset_id, + EditPlanClipModel.start_time, + EditPlanClipModel.duration, + ) + .filter( + EditPlanClipModel.plan_id.in_(recent_plan_ids), + EditPlanClipModel.status == "rendered", + EditPlanClipModel.asset_id != "", + EditPlanClipModel.asset_id.isnot(None), + ) + .all() + ) + + # 3. 聚合为 {asset_id: [(start, start+duration), ...]} + result: dict[str, list[tuple[float, float]]] = {} + for asset_id, start_time, duration in clips: + if asset_id not in result: + result[asset_id] = [] + result[asset_id].append((start_time or 0.0, (start_time or 0.0) + (duration or 0.0))) + + return result diff --git a/packages/domain/plan_generator_utils.py b/packages/domain/plan_generator_utils.py index 446f6da21..84cd2e6f0 100755 --- a/packages/domain/plan_generator_utils.py +++ b/packages/domain/plan_generator_utils.py @@ -169,6 +169,7 @@ def distribute_assets( random_selection: bool = False, asset_durations: dict[str, float] | None = None, asset_scene_points: dict[str, list[float]] | None = None, + external_used_segments: dict[str, list[tuple[float, float]]] | None = None, ) -> None: """按 editing_mode 将素材分配到 clips(就地修改). @@ -188,6 +189,7 @@ def distribute_assets( random_selection: 是否随机选择素材(用于预览生成) asset_durations: 素材 ID -> 时长(秒)映射,用于设置 start_time asset_scene_points: 素材 ID -> 场景切换点列表(metadata 缓存) + external_used_segments: 跨视频已用区间(来自其他视频的 clips),注入到分配逻辑中避让 """ if not asset_ids or not clips: return @@ -198,16 +200,16 @@ def distribute_assets( random.shuffle(asset_ids) if editing_mode == EditingMode.ONE_TAKE.value: - _distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points) + _distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments) elif editing_mode == EditingMode.PIP.value: - _distribute_pip(clips, asset_ids, asset_durations, asset_scene_points) + _distribute_pip(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments) elif editing_mode == EditingMode.VOICE_OVER.value: - _distribute_voice_over(clips, asset_ids, asset_durations, asset_scene_points) + _distribute_voice_over(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments) elif editing_mode == EditingMode.VOICE_PIP.value: - _distribute_voice_pip(clips, asset_ids, asset_durations, asset_scene_points) + _distribute_voice_pip(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments) else: # 未知模式,退化为 one_take - _distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points) + _distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments) def _resolve_start_time( @@ -248,9 +250,14 @@ def _distribute_one_take( asset_ids: List[str], asset_durations: dict[str, float] | None = None, asset_scene_points: dict[str, list[float]] | None = None, + external_used_segments: dict[str, list[tuple[float, float]]] | None = None, ) -> None: """ONE_TAKE: 素材按顺序依次分配给 main 类型 clips.""" - used_segments: dict[str, list[tuple[float, float]]] = {} + used_segments: dict[str, list[tuple[float, float]]] = ( + {k: list(v) for k, v in external_used_segments.items()} + if external_used_segments + else {} + ) main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] for i, clip in enumerate(main_clips): if i < len(asset_ids): @@ -271,9 +278,14 @@ def _distribute_pip( asset_ids: List[str], asset_durations: dict[str, float] | None = None, asset_scene_points: dict[str, list[float]] | None = None, + external_used_segments: dict[str, list[tuple[float, float]]] | None = None, ) -> None: """PIP: 第1个素材→main(全屏背景),其余→overlay clips.""" - used_segments: dict[str, list[tuple[float, float]]] = {} + used_segments: dict[str, list[tuple[float, float]]] = ( + {k: list(v) for k, v in external_used_segments.items()} + if external_used_segments + else {} + ) # 第1个素材 → main clip main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] if main_clips and asset_ids: @@ -310,9 +322,14 @@ def _distribute_voice_over( asset_ids: List[str], asset_durations: dict[str, float] | None = None, asset_scene_points: dict[str, list[float]] | None = None, + external_used_segments: dict[str, list[tuple[float, float]]] | None = None, ) -> None: """VOICE_OVER: 素材→main clips (B-roll).""" - used_segments: dict[str, list[tuple[float, float]]] = {} + used_segments: dict[str, list[tuple[float, float]]] = ( + {k: list(v) for k, v in external_used_segments.items()} + if external_used_segments + else {} + ) main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] for i, clip in enumerate(main_clips): if i < len(asset_ids): @@ -333,9 +350,14 @@ def _distribute_voice_pip( asset_ids: List[str], asset_durations: dict[str, float] | None = None, asset_scene_points: dict[str, list[float]] | None = None, + external_used_segments: dict[str, list[tuple[float, float]]] | None = None, ) -> None: """VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll.""" - used_segments: dict[str, list[tuple[float, float]]] = {} + used_segments: dict[str, list[tuple[float, float]]] = ( + {k: list(v) for k, v in external_used_segments.items()} + if external_used_segments + else {} + ) bg_clips = [c for c in clips if c.clip_type == "background"] voice_clips = [c for c in clips if c.clip_type == "corner_voice"] broll_clips = [c for c in clips if c.clip_type == "b_roll"] diff --git a/tests/unit/test_cross_video_avoidance.py b/tests/unit/test_cross_video_avoidance.py new file mode 100644 index 000000000..ede475e7e --- /dev/null +++ b/tests/unit/test_cross_video_avoidance.py @@ -0,0 +1,312 @@ +"""Tests for Issue #1670 — 跨视频片段避让(生成前注入已用区间).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import ( + SQLAlchemyEditPlanClipRepository, +) +from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus +from packages.domain.plan_generator_utils import ( + distribute_assets, + _distribute_one_take, +) + + +# ── Repository 层测试 ───────────────────────────────────────────────────────── + + +class TestListUsedSegmentsByUser: + """测试 list_used_segments_by_user 方法.""" + + def _make_repo(self, session_mock): + return SQLAlchemyEditPlanClipRepository(session_mock) + + def test_empty_user_id_returns_empty_dict(self): + """空 user_id 直接返回空 dict,不查 DB.""" + session = MagicMock() + repo = self._make_repo(session) + result = repo.list_used_segments_by_user("") + assert result == {} + session.query.assert_not_called() + + def test_no_completed_plans_returns_empty_dict(self): + """用户没有已完成的 plan 时返回空 dict.""" + session = MagicMock() + # Mock plan query returns empty + plan_query = MagicMock() + plan_query.filter.return_value = plan_query + plan_query.order_by.return_value = plan_query + plan_query.limit.return_value = plan_query + plan_query.all.return_value = [] + session.query.return_value = plan_query + + repo = self._make_repo(session) + result = repo.list_used_segments_by_user("user_123") + assert result == {} + + def test_aggregates_clips_from_multiple_plans(self): + """从多个已完成 plan 的 clips 聚合已用区间.""" + session = MagicMock() + + # Mock plan query: 2 completed plans + plan_query = MagicMock() + plan_query.filter.return_value = plan_query + plan_query.order_by.return_value = plan_query + plan_query.limit.return_value = plan_query + plan_query.all.return_value = [("plan_1",), ("plan_2",)] + session.query.return_value = plan_query + + # Mock clip query: clips from both plans + clip_query = MagicMock() + clip_query.filter.return_value = clip_query + clip_query.all.return_value = [ + ("asset_A", 0.0, 5.0), # plan_1, asset A: 0~5s + ("asset_A", 10.0, 3.0), # plan_1, asset A: 10~13s + ("asset_B", 2.0, 4.0), # plan_2, asset B: 2~6s + ] + # Second session.query call is for clips + session.query.side_effect = [plan_query, clip_query] + + repo = self._make_repo(session) + result = repo.list_used_segments_by_user("user_123") + + assert "asset_A" in result + assert len(result["asset_A"]) == 2 + assert result["asset_A"][0] == (0.0, 5.0) + assert result["asset_A"][1] == (10.0, 13.0) + assert "asset_B" in result + assert result["asset_B"][0] == (2.0, 6.0) + + def test_respects_limit_recent_parameter(self): + """limit_recent 参数限制查询的 plan 数量.""" + session = MagicMock() + + plan_query = MagicMock() + plan_query.filter.return_value = plan_query + plan_query.order_by.return_value = plan_query + plan_query.limit.return_value = plan_query + plan_query.all.return_value = [("plan_1",)] + session.query.return_value = plan_query + + clip_query = MagicMock() + clip_query.filter.return_value = clip_query + clip_query.all.return_value = [("asset_X", 1.0, 2.0)] + session.query.side_effect = [plan_query, clip_query] + + repo = self._make_repo(session) + result = repo.list_used_segments_by_user("user_123", limit_recent=10) + + # Verify limit was called with the parameter + plan_query.limit.assert_called_once_with(10) + assert "asset_X" in result + + +# ── Domain 层测试 ───────────────────────────────────────────────────────────── + + +class TestDistributeAssetsWithExternalSegments: + """测试 distribute_assets 传入 external_used_segments 的行为.""" + + def _make_clips(self, count: int, duration: float = 3.0) -> list[EditPlanClip]: + """创建指定数量的 MAIN 类型 clips.""" + return [ + EditPlanClip( + id=f"clip_{i}", + plan_id="plan_1", + clip_type="main", + order=i, + template_clip_config_id="", + asset_id="", + text_content="", + start_time=0.0, + duration=duration, + status=EditPlanClipStatus.PENDING, + ) + for i in range(count) + ] + + def test_external_used_segments_none_backward_compatible(self): + """external_used_segments=None 时行为不变(向后兼容).""" + clips = self._make_clips(3) + asset_ids = ["asset_1", "asset_2", "asset_3"] + asset_durations = {aid: 30.0 for aid in asset_ids} + + # Should not raise + distribute_assets( + clips, asset_ids, "one_take", + asset_durations=asset_durations, + external_used_segments=None, + ) + + # All clips should have assets assigned + for clip in clips: + assert clip.asset_id != "" + + def test_external_used_segments_avoids_existing_ranges(self): + """传入 external_used_segments 后,新分配的 start_time 避开已有区间.""" + clips = self._make_clips(2, duration=3.0) + asset_ids = ["asset_1"] + asset_durations = {"asset_1": 30.0} + + # Pretend asset_1 0~10s is already used by another video + external = {"asset_1": [(0.0, 10.0)]} + + # Run multiple times to check that start_time always avoids 0~10s + # (with some randomness, but the avoidance should be consistent) + for _ in range(10): + test_clips = self._make_clips(1, duration=3.0) + distribute_assets( + test_clips, asset_ids, "one_take", + asset_durations=asset_durations, + external_used_segments=external, + ) + start = test_clips[0].start_time + # Start time + duration (3s) should not overlap with 0~10 + # i.e., start >= 10.0 or start + 3 <= 0.0 (impossible since start >= 0) + assert start >= 10.0 or start + 3.0 <= 0.0 or start >= 10.0, ( + f"start_time {start} overlaps with existing segment 0~10" + ) + + def test_external_used_segments_deep_copy(self): + """external_used_segments 会被深拷贝,不会修改外部数据.""" + external = {"asset_1": [(0.0, 5.0)]} + original = {"asset_1": [(0.0, 5.0)]} + + clips = self._make_clips(1, duration=2.0) + asset_ids = ["asset_1"] + asset_durations = {"asset_1": 20.0} + + distribute_assets( + clips, asset_ids, "one_take", + asset_durations=asset_durations, + external_used_segments=external, + ) + + # External dict should be unchanged + assert external == original + + def test_empty_external_used_segments_same_as_none(self): + """空 dict 的 external_used_segments 行为与 None 相同.""" + clips = self._make_clips(2, duration=3.0) + asset_ids = ["asset_1", "asset_2"] + asset_durations = {aid: 30.0 for aid in asset_ids} + + # Should not raise and should assign assets normally + distribute_assets( + clips, asset_ids, "one_take", + asset_durations=asset_durations, + external_used_segments={}, + ) + for clip in clips: + assert clip.asset_id != "" + + +# ── Service 层测试 ──────────────────────────────────────────────────────────── + + +class TestServiceLayerIntegration: + """测试 _distribute_assets 在 service 层的查询逻辑.""" + + def _make_service(self, clip_repo_mock, asset_repo_mock=None): + """创建 PlanGeneratorService 并注入 mock repos.""" + from apps.api.app.services.plan_generator_service import PlanGeneratorService + from unittest.mock import patch, MagicMock + + with ( + patch("apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository"), + patch( + "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository", + return_value=clip_repo_mock, + ), + ): + db = MagicMock() + svc = PlanGeneratorService(db, asset_repo=asset_repo_mock) + svc._clip_repo = clip_repo_mock + return svc + + def _make_clip(self): + return EditPlanClip( + id="clip_1", plan_id="plan_1", clip_type="main", order=0, + template_clip_config_id="", asset_id="", text_content="", + start_time=0.0, duration=3.0, status=EditPlanClipStatus.PENDING, + ) + + def test_query_called_with_user_id(self): + """有 user_id 时调用 list_used_segments_by_user.""" + clip_repo = MagicMock() + clip_repo.list_used_segments_by_user.return_value = {"asset_A": [(0.0, 5.0)]} + asset_repo = MagicMock() + asset_repo.get.return_value = None # smart_match fallback + + svc = self._make_service(clip_repo, asset_repo) + clips = [self._make_clip()] + + svc._distribute_assets( + clips, ["asset_A"], "one_take", + asset_durations={"asset_A": 30.0}, + user_id="user_123", + ) + + clip_repo.list_used_segments_by_user.assert_called_once_with("user_123", limit_recent=50) + + def test_query_not_called_without_user_id(self): + """无 user_id 时不调用查询.""" + clip_repo = MagicMock() + asset_repo = MagicMock() + asset_repo.get.return_value = None + + svc = self._make_service(clip_repo, asset_repo) + clips = [self._make_clip()] + + svc._distribute_assets( + clips, ["asset_A"], "one_take", + asset_durations={"asset_A": 30.0}, + user_id="", + ) + + clip_repo.list_used_segments_by_user.assert_not_called() + + def test_query_failure_does_not_block_generation(self): + """查询失败时不阻塞生成,回退到纯随机.""" + clip_repo = MagicMock() + clip_repo.list_used_segments_by_user.side_effect = Exception("DB error") + asset_repo = MagicMock() + asset_repo.get.return_value = None + + svc = self._make_service(clip_repo, asset_repo) + clips = [self._make_clip()] + + # Should not raise + svc._distribute_assets( + clips, ["asset_A"], "one_take", + asset_durations={"asset_A": 30.0}, + user_id="user_123", + ) + + # Clip should still get an asset assigned (fallback to random) + assert clips[0].asset_id == "asset_A" + + def test_preview_and_final_both_query(self): + """预览和正式生成都触发查询.""" + for random_selection in [True, False]: + clip_repo = MagicMock() + clip_repo.list_used_segments_by_user.return_value = {} + asset_repo = MagicMock() + asset_repo.get.return_value = None + + svc = self._make_service(clip_repo, asset_repo) + clips = [self._make_clip()] + + svc._distribute_assets( + clips, ["asset_A"], "one_take", + random_selection=random_selection, + asset_durations={"asset_A": 30.0}, + user_id="user_123", + ) + + clip_repo.list_used_segments_by_user.assert_called_once() -- 2.54.0 From 9d31818222d3de63e69937c6c84b7f93e50a2f23 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 3 Sep 2026 14:21:24 +0000 Subject: [PATCH 10/39] style: auto-format with black + isort + prettier [skip ci-format-check] --- .../app/services/plan_generator_service.py | 4 +- packages/domain/plan_generator_utils.py | 16 ++--- tests/unit/test_cross_video_avoidance.py | 61 +++++++++++++------ 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index 0ac93c661..619c8abd0 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -245,9 +245,7 @@ class PlanGeneratorService: external_used_segments = None if user_id and self._clip_repo: try: - external_used_segments = self._clip_repo.list_used_segments_by_user( - user_id, limit_recent=50 - ) + external_used_segments = self._clip_repo.list_used_segments_by_user(user_id, limit_recent=50) except Exception: logger.warning("跨视频避让查询失败,回退到纯随机", exc_info=True) diff --git a/packages/domain/plan_generator_utils.py b/packages/domain/plan_generator_utils.py index 84cd2e6f0..e7530472f 100755 --- a/packages/domain/plan_generator_utils.py +++ b/packages/domain/plan_generator_utils.py @@ -254,9 +254,7 @@ def _distribute_one_take( ) -> None: """ONE_TAKE: 素材按顺序依次分配给 main 类型 clips.""" used_segments: dict[str, list[tuple[float, float]]] = ( - {k: list(v) for k, v in external_used_segments.items()} - if external_used_segments - else {} + {k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {} ) main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] for i, clip in enumerate(main_clips): @@ -282,9 +280,7 @@ def _distribute_pip( ) -> None: """PIP: 第1个素材→main(全屏背景),其余→overlay clips.""" used_segments: dict[str, list[tuple[float, float]]] = ( - {k: list(v) for k, v in external_used_segments.items()} - if external_used_segments - else {} + {k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {} ) # 第1个素材 → main clip main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] @@ -326,9 +322,7 @@ def _distribute_voice_over( ) -> None: """VOICE_OVER: 素材→main clips (B-roll).""" used_segments: dict[str, list[tuple[float, float]]] = ( - {k: list(v) for k, v in external_used_segments.items()} - if external_used_segments - else {} + {k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {} ) main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] for i, clip in enumerate(main_clips): @@ -354,9 +348,7 @@ def _distribute_voice_pip( ) -> None: """VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll.""" used_segments: dict[str, list[tuple[float, float]]] = ( - {k: list(v) for k, v in external_used_segments.items()} - if external_used_segments - else {} + {k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {} ) bg_clips = [c for c in clips if c.clip_type == "background"] voice_clips = [c for c in clips if c.clip_type == "corner_voice"] diff --git a/tests/unit/test_cross_video_avoidance.py b/tests/unit/test_cross_video_avoidance.py index ede475e7e..316e105ee 100644 --- a/tests/unit/test_cross_video_avoidance.py +++ b/tests/unit/test_cross_video_avoidance.py @@ -12,11 +12,10 @@ from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import ( ) from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus from packages.domain.plan_generator_utils import ( - distribute_assets, _distribute_one_take, + distribute_assets, ) - # ── Repository 层测试 ───────────────────────────────────────────────────────── @@ -65,9 +64,9 @@ class TestListUsedSegmentsByUser: clip_query = MagicMock() clip_query.filter.return_value = clip_query clip_query.all.return_value = [ - ("asset_A", 0.0, 5.0), # plan_1, asset A: 0~5s + ("asset_A", 0.0, 5.0), # plan_1, asset A: 0~5s ("asset_A", 10.0, 3.0), # plan_1, asset A: 10~13s - ("asset_B", 2.0, 4.0), # plan_2, asset B: 2~6s + ("asset_B", 2.0, 4.0), # plan_2, asset B: 2~6s ] # Second session.query call is for clips session.query.side_effect = [plan_query, clip_query] @@ -138,7 +137,9 @@ class TestDistributeAssetsWithExternalSegments: # Should not raise distribute_assets( - clips, asset_ids, "one_take", + clips, + asset_ids, + "one_take", asset_durations=asset_durations, external_used_segments=None, ) @@ -161,16 +162,18 @@ class TestDistributeAssetsWithExternalSegments: for _ in range(10): test_clips = self._make_clips(1, duration=3.0) distribute_assets( - test_clips, asset_ids, "one_take", + test_clips, + asset_ids, + "one_take", asset_durations=asset_durations, external_used_segments=external, ) start = test_clips[0].start_time # Start time + duration (3s) should not overlap with 0~10 # i.e., start >= 10.0 or start + 3 <= 0.0 (impossible since start >= 0) - assert start >= 10.0 or start + 3.0 <= 0.0 or start >= 10.0, ( - f"start_time {start} overlaps with existing segment 0~10" - ) + assert ( + start >= 10.0 or start + 3.0 <= 0.0 or start >= 10.0 + ), f"start_time {start} overlaps with existing segment 0~10" def test_external_used_segments_deep_copy(self): """external_used_segments 会被深拷贝,不会修改外部数据.""" @@ -182,7 +185,9 @@ class TestDistributeAssetsWithExternalSegments: asset_durations = {"asset_1": 20.0} distribute_assets( - clips, asset_ids, "one_take", + clips, + asset_ids, + "one_take", asset_durations=asset_durations, external_used_segments=external, ) @@ -198,7 +203,9 @@ class TestDistributeAssetsWithExternalSegments: # Should not raise and should assign assets normally distribute_assets( - clips, asset_ids, "one_take", + clips, + asset_ids, + "one_take", asset_durations=asset_durations, external_used_segments={}, ) @@ -214,8 +221,9 @@ class TestServiceLayerIntegration: def _make_service(self, clip_repo_mock, asset_repo_mock=None): """创建 PlanGeneratorService 并注入 mock repos.""" + from unittest.mock import MagicMock, patch + from apps.api.app.services.plan_generator_service import PlanGeneratorService - from unittest.mock import patch, MagicMock with ( patch("apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository"), @@ -231,9 +239,16 @@ class TestServiceLayerIntegration: def _make_clip(self): return EditPlanClip( - id="clip_1", plan_id="plan_1", clip_type="main", order=0, - template_clip_config_id="", asset_id="", text_content="", - start_time=0.0, duration=3.0, status=EditPlanClipStatus.PENDING, + id="clip_1", + plan_id="plan_1", + clip_type="main", + order=0, + template_clip_config_id="", + asset_id="", + text_content="", + start_time=0.0, + duration=3.0, + status=EditPlanClipStatus.PENDING, ) def test_query_called_with_user_id(self): @@ -247,7 +262,9 @@ class TestServiceLayerIntegration: clips = [self._make_clip()] svc._distribute_assets( - clips, ["asset_A"], "one_take", + clips, + ["asset_A"], + "one_take", asset_durations={"asset_A": 30.0}, user_id="user_123", ) @@ -264,7 +281,9 @@ class TestServiceLayerIntegration: clips = [self._make_clip()] svc._distribute_assets( - clips, ["asset_A"], "one_take", + clips, + ["asset_A"], + "one_take", asset_durations={"asset_A": 30.0}, user_id="", ) @@ -283,7 +302,9 @@ class TestServiceLayerIntegration: # Should not raise svc._distribute_assets( - clips, ["asset_A"], "one_take", + clips, + ["asset_A"], + "one_take", asset_durations={"asset_A": 30.0}, user_id="user_123", ) @@ -303,7 +324,9 @@ class TestServiceLayerIntegration: clips = [self._make_clip()] svc._distribute_assets( - clips, ["asset_A"], "one_take", + clips, + ["asset_A"], + "one_take", random_selection=random_selection, asset_durations={"asset_A": 30.0}, user_id="user_123", -- 2.54.0 From 109d7afbc742f5ef8acfea03ca0d1430c07cd108 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 22:40:11 +0800 Subject: [PATCH 11/39] =?UTF-8?q?fix:=20AI=E9=85=8D=E9=9F=B3=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E8=A2=ABoverflow:hidden=E8=A3=81=E5=89=AA=E4=B8=8D?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20(#1672)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/voices/components/MaterialVoiceTab.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx index 2c406ec3a..e5d8ea402 100644 --- a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx +++ b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx @@ -185,8 +185,10 @@ export const MaterialVoiceTab: React.FC = ({
-
- {asset.name} +
+
+ {asset.name} +
{isAiMaterial && AI}
-- 2.54.0 From fac80b1f77e3f11368e459320778cb4d91b0f495 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 22:52:47 +0800 Subject: [PATCH 12/39] =?UTF-8?q?feat(dedup):=20=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E6=8A=BD=E5=B8=A7=20+=20=E6=BB=91=E5=8A=A8=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E6=97=B6=E5=BA=8F=E5=8C=B9=E9=85=8D=20(#1659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 动态抽帧策略 — detect_keyframe_timestamps() - 降采样到 320x240 逐帧灰度差异检测场景切换 - 最小间隔过滤(保留差异最大的候选帧) - 数量裁剪到 [MIN_KEYFRAMES=5, MAX_KEYFRAMES=30] - 长视频(>3分钟)每 30 秒分段保底 2. 滑动窗口时序匹配 — find_duplicate_segments() - 逐帧最佳匹配 → 连续 run 检测(允许 MAX_GAP=2 间隙) - 最少 MIN_CONSECUTIVE_MATCHES=5 帧才报告 - 返回 DuplicateSegment(query/target 时间范围 + 平均距离) 3. 查重算法升级 - 均值距离 → 中位数距离(抵抗异常值) - 新增帧匹配比例条件(match_ratio >= 0.7) - Bhattacharyya 系数替代余弦相似度 - pHash + 直方图加权融合(0.7/0.3) - 判定重复后附加 duplicate_segments 字段 4. 删除旧代码 - 移除 SHORT_VIDEO_CHUNK_SEC/LONG_VIDEO_CHUNK_SEC 固定间隔 - 移除 compute_chunk_interval() - 移除 _average_histogram_similarity() 5. 测试 - 新增 test_dedup_v2.py: 34 个测试 - 更新 test_dedup_engine.py/test_duplicate_rate.py/test_dedup_pure.py - 清理 test_fingerprint_chunks.py 中旧常量测试 --- apps/worker/video_processing/dedup.py | 561 +++++++++++++++++++++----- tests/unit/test_dedup_engine.py | 17 +- tests/unit/test_dedup_pure.py | 108 +++-- tests/unit/test_dedup_v2.py | 513 +++++++++++++++++++++++ tests/unit/test_duplicate_rate.py | 4 +- tests/unit/test_fingerprint_chunks.py | 30 -- 6 files changed, 1037 insertions(+), 196 deletions(-) create mode 100644 tests/unit/test_dedup_v2.py diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index 1cbd4e123..435bc355f 100755 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -1,8 +1,12 @@ -"""Video deduplication module - compute fingerprints and detect duplicates.""" +"""Video deduplication module - compute fingerprints and detect duplicates. + +Dynamic keyframe detection + sliding window temporal matching (Issue #1659). +""" import hashlib import logging import os +import statistics import tempfile from dataclasses import dataclass, field from typing import Optional @@ -21,11 +25,28 @@ from packages.shared.storage import get_storage_service logger = logging.getLogger(__name__) -# 分片策略常量 -SHORT_VIDEO_CHUNK_SEC = 2 # ≤60秒视频,每 2 秒一个分片 -LONG_VIDEO_CHUNK_SEC = 5 # >60秒视频,每 5 秒一个分片 -SHORT_VIDEO_THRESHOLD_SEC = 60 +# ── 关键帧检测常量 ────────────────────────────────────────────── +SCENE_CHANGE_THRESHOLD = 30 # 灰度差异阈值 +MIN_KEYFRAME_INTERVAL_SEC = 1.0 # 最小关键帧间隔(秒) +MAX_KEYFRAMES = 30 # 最大关键帧数 +MIN_KEYFRAMES = 5 # 最小关键帧数 +LONG_VIDEO_SEGMENT_SEC = 30 # 长视频每段秒数 +LONG_VIDEO_DURATION_THRESHOLD_SEC = 180 # 3 分钟阈值 +MIN_FRAMES_PER_SEGMENT = 2 # 长视频每段最少帧数 +# ── 滑动窗口匹配常量 ──────────────────────────────────────────── +SEGMENT_MATCH_THRESHOLD = 8 # 帧匹配汉明距离阈值 +MIN_CONSECUTIVE_MATCHES = 5 # 最少连续匹配帧数 +MAX_GAP = 2 # 允许的最大间隙帧数 + +# ── 融合判定常量 ──────────────────────────────────────────────── +PHASH_WEIGHT = 0.7 # pHash 权重 +HISTOGRAM_WEIGHT = 0.3 # 直方图权重 +MATCH_RATIO_THRESHOLD = 0.7 # 至少 70% 帧匹配 +DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值 + + +# ── 感知哈希 & 颜色直方图工具函数 ──────────────────────────────── def compute_phash(image: np.ndarray, hash_size: int = 8) -> str: """计算图像的感知哈希(pHash),基于 DCT(离散余弦变换)。 @@ -87,16 +108,106 @@ def compute_color_histogram(image: np.ndarray, bins: int = 32) -> list[float]: return hist -def compute_chunk_interval(duration: float) -> float: - """根据视频时长返回分片间隔(秒)。 +# ── 关键帧检测 ────────────────────────────────────────────────── - 短视频(≤60秒):每 2 秒一个分片 - 长视频(>60秒):每 5 秒一个分片 +def detect_keyframe_timestamps( + video_path: str, + *, + min_interval_sec: float = MIN_KEYFRAME_INTERVAL_SEC, + max_frames: int = MAX_KEYFRAMES, + min_frames: int = MIN_KEYFRAMES, +) -> list[float]: + """检测视频中的场景切换点,返回关键帧时间戳列表(秒)。 + + 算法: + 1. 降采样到 320x240,逐帧转灰度 + 2. 计算相邻帧灰度差异(像素均值差) + 3. 差异 > SCENE_CHANGE_THRESHOLD(30) 标记为候选关键帧 + 4. 相邻关键帧间隔 < min_interval_sec 的,保留差异更大的那个 + 5. 数量裁剪到 [min_frames, max_frames] + + 对于长视频(>3分钟): + - 每 30 秒一个分段 + - 每个分段至少选 2 个关键帧(如果分段内无场景切换,均匀取 2 帧) """ - if duration <= SHORT_VIDEO_THRESHOLD_SEC: - return SHORT_VIDEO_CHUNK_SEC - return LONG_VIDEO_CHUNK_SEC + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + raise RuntimeError(f"Cannot open video: {video_path}") + fps = cap.get(cv2.CAP_PROP_FPS) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + duration = frame_count / fps if fps > 0 else 0 + + if duration <= 0: + cap.release() + return [] + + # 逐帧检测场景切换 + candidates: list[tuple[float, float]] = [] # (timestamp_sec, diff_score) + prev_gray = None + + while True: + ret, frame = cap.read() + if not ret: + break + + # 降采样 + 灰度 + small = cv2.resize(frame, (320, 240)) + gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY).astype(np.float32) + + if prev_gray is not None: + diff = float(np.mean(np.abs(gray - prev_gray))) + if diff > SCENE_CHANGE_THRESHOLD: + pos_ms = cap.get(cv2.CAP_PROP_POS_MSEC) + candidates.append((pos_ms / 1000.0, diff)) + + prev_gray = gray + + cap.release() + + # 按最小间隔过滤(保留差异更大的) + filtered: list[tuple[float, float]] = [] + for ts, diff in sorted(candidates): + if filtered and (ts - filtered[-1][0]) < min_interval_sec: + if diff > filtered[-1][1]: + filtered[-1] = (ts, diff) + else: + filtered.append((ts, diff)) + + keyframe_times = [ts for ts, _ in filtered] + + # 数量不足 min_frames 时,在时间轴上均匀补充 + if len(keyframe_times) < min_frames: + uniform = [duration * (i + 0.5) / min_frames for i in range(min_frames)] + keyframe_times = sorted(set(uniform) | set(keyframe_times)) + # 如果合并后还不足 min_frames,直接用均匀分布 + if len(keyframe_times) < min_frames: + keyframe_times = uniform + + # 数量超过 max_frames 时,均匀采样 + if len(keyframe_times) > max_frames: + step = len(keyframe_times) / max_frames + keyframe_times = [keyframe_times[int(i * step)] for i in range(max_frames)] + + # 长视频分段保底(>3分钟) + if duration > LONG_VIDEO_DURATION_THRESHOLD_SEC: + segment_count = int(duration / LONG_VIDEO_SEGMENT_SEC) + for seg_idx in range(segment_count): + seg_start = seg_idx * LONG_VIDEO_SEGMENT_SEC + seg_end = min((seg_idx + 1) * LONG_VIDEO_SEGMENT_SEC, duration) + seg_frames = [t for t in keyframe_times if seg_start <= t < seg_end] + if len(seg_frames) < MIN_FRAMES_PER_SEGMENT: + # 均匀补齐 + for i in range(MIN_FRAMES_PER_SEGMENT): + t = seg_start + LONG_VIDEO_SEGMENT_SEC * (i + 0.5) / MIN_FRAMES_PER_SEGMENT + if t not in keyframe_times and seg_start <= t < seg_end: + keyframe_times.append(t) + keyframe_times.sort() + + return keyframe_times + + +# ── 数据类 ────────────────────────────────────────────────────── @dataclass class FingerprintChunk: @@ -109,6 +220,17 @@ class FingerprintChunk: frame_count: int = 1 +@dataclass +class DuplicateSegment: + """一段重复片段的描述。""" + + query_start_ms: int + query_end_ms: int + target_start_ms: int + target_end_ms: int + avg_distance: float # 该段内帧的平均汉明距离 + + @dataclass class VideoFingerprint: """Video fingerprint containing multiple similarity metrics.""" @@ -163,6 +285,135 @@ class VideoFingerprint: return models +# ── 滑动窗口时序匹配 ──────────────────────────────────────────── + +def find_duplicate_segments( + query_chunks: list, + target_chunks: list, + *, + match_threshold: int = SEGMENT_MATCH_THRESHOLD, + min_consecutive: int = MIN_CONSECUTIVE_MATCHES, + max_gap: int = MAX_GAP, +) -> list[DuplicateSegment]: + """滑动窗口时序匹配:找出两组分片之间的重复片段。 + + 算法: + 1. 对每个 query chunk,找到 target 中汉明距离最小的 chunk + 2. 距离 <= match_threshold 视为匹配 + 3. 找连续匹配的 run(允许 max_gap 帧间隙) + 4. 连续匹配数 >= min_consecutive 的 run 报告为重复片段 + + Args: + query_chunks: 查询视频的分片列表(FingerprintChunk 或 dict) + target_chunks: 目标视频的分片列表 + match_threshold: 汉明距离匹配阈值 + min_consecutive: 最少连续匹配帧数 + max_gap: 允许的最大间隙帧数 + + Returns: + DuplicateSegment 列表 + """ + if not query_chunks or not target_chunks: + return [] + + def _get_phash(chunk) -> str: + if isinstance(chunk, dict): + return chunk["phash_binary"] + return chunk.phash_binary + + def _get_start(chunk) -> int: + if isinstance(chunk, dict): + return chunk["start_time_ms"] + return chunk.start_time_ms + + def _get_end(chunk) -> int: + if isinstance(chunk, dict): + return chunk["end_time_ms"] + return chunk.end_time_ms + + # Step 1: 逐帧匹配 + frame_matches: list[tuple[bool, int, int]] = [] # (is_match, min_dist, best_target_idx) + for qc in query_chunks: + qc_phash = _get_phash(qc) + best_dist = 64 + best_idx = 0 + for j, tc in enumerate(target_chunks): + d = hamming_distance(qc_phash, _get_phash(tc)) + if d < best_dist: + best_dist = d + best_idx = j + frame_matches.append((best_dist <= match_threshold, best_dist, best_idx)) + + # Step 2: 找连续匹配的 runs + runs: list[tuple[int, int]] = [] # list of (start_idx, end_idx) + run_start = None + gap_count = 0 + + for i, (is_match, dist, idx) in enumerate(frame_matches): + if is_match: + if run_start is None: + run_start = i + gap_count = 0 # 重置间隙 + else: + if run_start is not None: + gap_count += 1 + if gap_count > max_gap: + # 中断当前 run + run_end = i - gap_count # 最后一个匹配帧的索引 + # 计算 run 内的实际匹配帧数(总跨度 - 间隙数) + total_gaps = sum(1 for k in range(run_start, run_end + 1) if not frame_matches[k][0]) + matching_count = (run_end - run_start + 1) - total_gaps + if matching_count >= min_consecutive: + runs.append((run_start, run_end)) + run_start = None + gap_count = 0 + + # 处理末尾 run + if run_start is not None: + last_idx = len(frame_matches) - 1 + # 回退找到最后一个匹配帧的位置(跳过尾部非匹配帧) + while last_idx >= run_start and not frame_matches[last_idx][0]: + last_idx -= 1 + if last_idx >= run_start: + # 计算 run 内的总间隙数 + total_gaps = sum(1 for k in range(run_start, last_idx + 1) if not frame_matches[k][0]) + matching_count = (last_idx - run_start + 1) - total_gaps + if matching_count >= min_consecutive: + runs.append((run_start, last_idx)) + + # Step 3: 构建 DuplicateSegment + segments: list[DuplicateSegment] = [] + for start, end in runs: + query_start = _get_start(query_chunks[start]) + query_end = _get_end(query_chunks[end]) + + # 取目标范围(按最佳匹配的目标 chunk 时间范围) + target_indices = [frame_matches[k][2] for k in range(start, end + 1) if frame_matches[k][0]] + if target_indices: + t_min = min(target_indices) + t_max = max(target_indices) + target_start = _get_start(target_chunks[t_min]) + target_end = _get_end(target_chunks[t_max]) + else: + target_start = _get_start(target_chunks[0]) + target_end = _get_end(target_chunks[-1]) + + avg_dist = sum(frame_matches[k][1] for k in range(start, end + 1)) / (end - start + 1) + segments.append( + DuplicateSegment( + query_start_ms=query_start, + query_end_ms=query_end, + target_start_ms=target_start, + target_end_ms=target_end, + avg_distance=avg_dist, + ) + ) + + return segments + + +# ── VideoDeduplicator ─────────────────────────────────────────── + class VideoDeduplicator: """Video deduplication using multiple fingerprint methods.""" @@ -170,11 +421,11 @@ class VideoDeduplicator: HISTOGRAM_THRESHOLD = 0.85 def compute_fingerprint(self, video_path: str) -> VideoFingerprint: - """Compute video fingerprint using MD5, pHash, and color histogram. + """Compute video fingerprint using dynamic keyframe detection. - 按时间分片抽帧:短视频(≤60s)每 2s 一片,长视频每 5s 一片。 - 每片取 1 帧计算 pHash + color_histogram。 - 同时保留 keyframe_phashes/color_histograms 聚合字段(向后兼容)。 + 使用 detect_keyframe_timestamps() 检测内容感知关键帧, + 在每个关键帧处取帧计算 pHash + color_histogram。 + 同时保留 MD5 计算和分片数据结构。 """ cap = cv2.VideoCapture(video_path) if not cap.isOpened(): @@ -186,41 +437,55 @@ class VideoDeduplicator: width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + cap.release() + + # 1. 检测关键帧时间戳 + keyframe_times = detect_keyframe_timestamps(video_path) + + if not keyframe_times: + return VideoFingerprint( + md5="", + keyframe_phashes=[], + color_histograms=[], + duration=duration, + resolution=(width, height), + chunks=[], + ) + + # 2. 打开视频,逐个关键帧取帧 + cap = cv2.VideoCapture(video_path) md5_hash = hashlib.md5(usedforsecurity=False) chunks: list[FingerprintChunk] = [] - # 分片间隔(秒) - chunk_interval_sec = compute_chunk_interval(duration) - chunk_interval_ms = int(chunk_interval_sec * 1000) - duration_ms = int(duration * 1000) - - # 遍历每个分片时间窗口,取 1 帧 - start_ms = 0 - while start_ms < duration_ms: - end_ms = min(start_ms + chunk_interval_ms, duration_ms) - # 定位到分片中点 - seek_ms = (start_ms + end_ms) / 2 + for i, t_sec in enumerate(keyframe_times): + seek_ms = t_sec * 1000 cap.set(cv2.CAP_PROP_POS_MSEC, seek_ms) ret, frame = cap.read() - if ret: - # MD5 计算 - _, buffer = cv2.imencode(".jpg", frame) - md5_hash.update(buffer) + if not ret: + continue - phash = compute_phash(frame) - hist = compute_color_histogram(frame) + # MD5 计算 + _, buffer = cv2.imencode(".jpg", frame) + md5_hash.update(buffer) - chunks.append( - FingerprintChunk( - start_time_ms=start_ms, - end_time_ms=end_ms, - phash_binary=phash, - color_histogram=hist, - frame_count=1, - ) + phash = compute_phash(frame) + hist = compute_color_histogram(frame) + + # 计算分片时间范围(从前一个关键帧到下一个关键帧的中点) + prev_boundary = keyframe_times[i - 1] * 1000 if i > 0 else 0 + next_boundary = keyframe_times[i + 1] * 1000 if i < len(keyframe_times) - 1 else duration * 1000 + start_ms = int((prev_boundary + seek_ms) / 2) + end_ms = int((seek_ms + next_boundary) / 2) + + chunks.append( + FingerprintChunk( + start_time_ms=start_ms, + end_time_ms=end_ms, + phash_binary=phash, + color_histogram=hist, + frame_count=1, ) - - start_ms = end_ms + ) cap.release() @@ -255,14 +520,39 @@ class VideoDeduplicator: for r in rows ] + @staticmethod + def _bhattacharyya_coefficient(hist_a: list[float], hist_b: list[float]) -> float: + """Bhattacharyya 系数:Σ √(a[i] * b[i]),范围 [0, 1],1=完全相同。""" + min_len = min(len(hist_a), len(hist_b)) + a = hist_a[:min_len] + b = hist_b[:min_len] + return float(sum(np.sqrt(ai * bi) for ai, bi in zip(a, b))) + + @staticmethod + def _compute_histogram_similarity( + histograms_a: list[list[float]], + histograms_b: list[list[float]], + ) -> float: + """对每组直方图,找到最佳匹配的 Bhattacharyya 系数,取平均。""" + if not histograms_a or not histograms_b: + return 0.0 + similarities = [] + for ha in histograms_a: + best = 0.0 + for hb in histograms_b: + bc = VideoDeduplicator._bhattacharyya_coefficient(ha, hb) + best = max(best, bc) + similarities.append(best) + return sum(similarities) / len(similarities) if similarities else 0.0 + def check_duplicate(self, fingerprint: VideoFingerprint, project_id: str, session: Session) -> Optional[dict]: """检查视频是否与项目中已有视频重复。 查重逻辑: 1. MD5 精确匹配 → similarity=1.0 - 2. pHash 相似度(优先从分片表读取,回退到 JSON 字段) + 2. pHash 中位数距离 + 帧匹配比例 + 直方图融合判定 - 判定阈值:avg_distance < PHASH_THRESHOLD(10) + 判定为重复后,调用 find_duplicate_segments() 获取具体重复片段。 Args: fingerprint: 待检测视频的指纹 @@ -270,7 +560,7 @@ class VideoDeduplicator: session: 数据库会话 Returns: - 重复信息字典(含 duplicate, duplicate_of, reason, similarity), + 重复信息字典(含 duplicate, duplicate_of, reason, similarity, duplicate_segments), 或 None 表示未找到重复。 """ video_repo = SQLAlchemyGeneratedVideoRepository(session) @@ -298,23 +588,64 @@ class VideoDeduplicator: if not existing_phashes: continue - # 计算每个新关键帧到已有关键帧的最小汉明距离,取平均 + # 计算每个新关键帧到已有关键帧的最小汉明距离 min_distances = [] for phash in fingerprint.keyframe_phashes: distances = [hamming_distance(phash, ep) for ep in existing_phashes] min_distances.append(min(distances)) - avg_distance = sum(min_distances) / len(min_distances) if min_distances else 100 - if avg_distance >= self.PHASH_THRESHOLD: + # 帧匹配比例检查 + matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) + match_ratio = matching_frames / len(min_distances) if min_distances else 0 + if match_ratio < 0.7: continue - phash_similarity = 1.0 - (avg_distance / 64) + # 中位数距离 + median_distance = statistics.median(min_distances) if min_distances else 64 + if median_distance >= self.PHASH_THRESHOLD: + continue + + # 直方图融合 + existing_histograms = [] + if chunk_data: + existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] + else: + existing_histograms = ef.get("color_histograms", []) + + phash_similarity = 1.0 - (median_distance / 64) + hist_similarity = ( + self._compute_histogram_similarity(fingerprint.color_histograms, existing_histograms) + if existing_histograms + else 0.5 + ) + combined_score = 0.7 * phash_similarity + 0.3 * hist_similarity + + # DUPLICATE_THRESHOLD from module level + if combined_score < DUPLICATE_THRESHOLD: + continue + + # 滑动窗口时序匹配:获取具体重复片段 + existing_chunk_objects = chunk_data if chunk_data else [ + {"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} + for p in existing_phashes + ] + segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) return { "duplicate": True, "duplicate_of": existing.id, - "reason": "phash_similar", - "similarity": phash_similarity, + "reason": "phash_histogram_fusion", + "similarity": combined_score, + "duplicate_segments": [ + { + "query_start_ms": s.query_start_ms, + "query_end_ms": s.query_end_ms, + "target_start_ms": s.target_start_ms, + "target_end_ms": s.target_end_ms, + "avg_distance": round(s.avg_distance, 2), + } + for s in segments + ], } return None @@ -328,7 +659,8 @@ class VideoDeduplicator: ) -> Optional[dict]: """检查视频是否与同批次内其他视频重复。 - 逻辑与 check_duplicate 一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。 + 逻辑与 check_duplicate 一致(MD5 + pHash + 直方图融合 + 时序匹配), + 但搜索范围限定为同 batch_id 的视频。 Args: fingerprint: 待检测视频的指纹 @@ -373,59 +705,62 @@ class VideoDeduplicator: for phash in fingerprint.keyframe_phashes: distances = [hamming_distance(phash, ep) for ep in existing_phashes] min_distances.append(min(distances)) - avg_distance = sum(min_distances) / len(min_distances) if min_distances else 100 - if avg_distance >= self.PHASH_THRESHOLD: + # 帧匹配比例检查 + matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) + match_ratio = matching_frames / len(min_distances) if min_distances else 0 + if match_ratio < 0.7: continue - phash_similarity = 1.0 - (avg_distance / 64) + median_distance = statistics.median(min_distances) if min_distances else 64 + if median_distance >= self.PHASH_THRESHOLD: + continue + + # 直方图融合 + existing_histograms = [] + if chunk_data: + existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] + else: + existing_histograms = ef.get("color_histograms", []) + + phash_similarity = 1.0 - (median_distance / 64) + hist_similarity = ( + self._compute_histogram_similarity(fingerprint.color_histograms, existing_histograms) + if existing_histograms + else 0.5 + ) + combined_score = 0.7 * phash_similarity + 0.3 * hist_similarity + + # DUPLICATE_THRESHOLD from module level + if combined_score < DUPLICATE_THRESHOLD: + continue + + # 滑动窗口时序匹配 + existing_chunk_objects = chunk_data if chunk_data else [ + {"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} + for p in existing_phashes + ] + segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) + return { "duplicate": True, "duplicate_of": existing.id, - "reason": "batch_phash_similar", - "similarity": phash_similarity, + "reason": "batch_phash_histogram_fusion", + "similarity": combined_score, + "duplicate_segments": [ + { + "query_start_ms": s.query_start_ms, + "query_end_ms": s.query_end_ms, + "target_start_ms": s.target_start_ms, + "target_end_ms": s.target_end_ms, + "avg_distance": round(s.avg_distance, 2), + } + for s in segments + ], } return None - @staticmethod - def _average_histogram_similarity(histograms_a: list[list[float]], histograms_b: list[list[float]]) -> float: - """ - 计算两组颜色直方图之间的平均余弦相似度。 - - 对每组直方图对取最小长度对齐,计算余弦相似度后取平均。 - - Args: - histograms_a: 第一组直方图(每帧一个 list) - histograms_b: 第二组直方图 - - Returns: - 平均余弦相似度,范围 [0, 1] - """ - if not histograms_a or not histograms_b: - return 0.0 - - similarities = [] - for ha in histograms_a: - best = 0.0 - vec_a = np.array(ha, dtype=np.float64) - norm_a = np.linalg.norm(vec_a) - if norm_a == 0: - continue - for hb in histograms_b: - vec_b = np.array(hb, dtype=np.float64) - # 对齐长度 - min_len = min(len(vec_a), len(vec_b)) - va, vb = vec_a[:min_len], vec_b[:min_len] - norm_b = np.linalg.norm(vb) - if norm_b == 0: - continue - sim = float(np.dot(va, vb) / (norm_a * norm_b)) - best = max(best, sim) - similarities.append(best) - - return sum(similarities) / len(similarities) if similarities else 0.0 - def compute_duplicate_rate( self, fingerprint: VideoFingerprint, @@ -438,9 +773,9 @@ class VideoDeduplicator: """计算当前视频与用户库内已有视频的最高相似度百分比。 优先按 user_id 全局比较(跨项目),user_id 为空时回退到项目级比较。 - 遍历最近 200 个其他有指纹的视频,对每个计算相似度: + 遍历最近 200 个其他有指纹的视频,对每个计算融合相似度: - MD5 精确匹配 → 100% - - pHash 相似度 → (1.0 - avg_distance / 64) * 100 + - pHash + 直方图融合 → 0.7 * phash_sim + 0.3 * hist_sim 取最高值作为 duplicate_rate(0~100)。 如果没有其他视频可比较,返回 0.0。 @@ -454,7 +789,6 @@ class VideoDeduplicator: Returns: duplicate_rate: 0~100 的浮点数 """ - # 限制查询最近 200 个视频,避免大库内存溢出 from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel # 优先按 user_id 全局比较(跨项目),否则回退到项目级 @@ -469,7 +803,7 @@ class VideoDeduplicator: ) logger.debug("compute_duplicate_rate: project-level fallback project_id=%s", project_id) - # 排除当前视频自身(记录可能已写入 DB,必须在查询层排除) + # 排除当前视频自身 if current_video_id: query = query.filter(GeneratedVideoModel.id != current_video_id) @@ -505,9 +839,30 @@ class VideoDeduplicator: for phash in fingerprint.keyframe_phashes: distances = [hamming_distance(phash, ep) for ep in existing_phashes] min_distances.append(min(distances)) - avg_distance = sum(min_distances) / len(min_distances) if min_distances else 64 - similarity = (1.0 - avg_distance / 64) * 100 - max_similarity = max(max_similarity, similarity) + + # 帧匹配比例检查 + matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) + match_ratio = matching_frames / len(min_distances) if min_distances else 0 + if match_ratio < 0.7: + continue + + median_distance = statistics.median(min_distances) if min_distances else 64 + + # 直方图融合 + existing_histograms = [] + if chunk_data: + existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] + else: + existing_histograms = ef.get("color_histograms", []) + + phash_similarity = (1.0 - median_distance / 64) * 100 + hist_similarity = ( + self._compute_histogram_similarity(fingerprint.color_histograms, existing_histograms) * 100 + if existing_histograms + else 50.0 + ) + combined_score = 0.7 * phash_similarity + 0.3 * hist_similarity + max_similarity = max(max_similarity, combined_score) return round(max(max_similarity, 0.0), 2) diff --git a/tests/unit/test_dedup_engine.py b/tests/unit/test_dedup_engine.py index a716c7ff9..ebc803438 100644 --- a/tests/unit/test_dedup_engine.py +++ b/tests/unit/test_dedup_engine.py @@ -285,8 +285,8 @@ class TestVideoDeduplicatorCheckDuplicate: result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session) assert result is not None assert result["duplicate"] is True - assert result["similarity"] == 1.0 # distance=0 → 1.0 - assert result["reason"] == "phash_similar" + assert result["similarity"] == pytest.approx(0.85, abs=0.01) # combined: 0.7*1.0 + 0.3*0.5 (no hist fallback) + assert result["reason"] == "phash_histogram_fusion" finally: self._restore_repo(mod, orig) @@ -425,8 +425,11 @@ class TestVideoDeduplicatorCheckDuplicate: result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session) assert result is not None assert result["duplicate"] is True - # similarity = 1.0 - (1 / 64) = 0.984375 - assert abs(result["similarity"] - (1.0 - 1.0 / 64)) < 1e-6 + # 新算法: median_distance=1, phash_sim=1-1/64=0.984375 + # 无直方图 → hist_sim=0.5(fallback) + # combined = 0.7*0.984375 + 0.3*0.5 = 0.839062 + expected_sim = 0.7 * (1.0 - 1.0 / 64) + 0.3 * 0.5 + assert abs(result["similarity"] - expected_sim) < 1e-6 finally: self._restore_repo(mod, orig) @@ -456,7 +459,9 @@ class TestVideoDeduplicatorCheckDuplicate: result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session) assert result is not None assert result["duplicate"] is True - assert result["similarity"] == 1.0 # avg_distance = 0 + # 新算法: median_distance=0, phash_sim=1.0, hist_sim=0.5(fallback) + # combined = 0.7*1.0 + 0.3*0.5 = 0.85 + assert result["similarity"] == pytest.approx(0.85, abs=0.01) finally: self._restore_repo(mod, orig) @@ -539,7 +544,7 @@ class TestVideoDeduplicatorCheckBatchDuplicate: result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session) assert result is not None assert result["duplicate"] is True - assert result["reason"] == "batch_phash_similar" + assert result["reason"] == "batch_phash_histogram_fusion" finally: self._restore_repo(mod, orig) diff --git a/tests/unit/test_dedup_pure.py b/tests/unit/test_dedup_pure.py index c9d71375e..ab70cdb15 100755 --- a/tests/unit/test_dedup_pure.py +++ b/tests/unit/test_dedup_pure.py @@ -181,70 +181,68 @@ class TestVideoFingerprint: assert d["color_histograms"] == [] -class TestAverageHistogramSimilarity: - """_average_histogram_similarity 直方图相似度测试.""" +class TestBhattacharyyaCoefficient: + """_bhattacharyya_coefficient Bhattacharyya 系数测试.""" def test_identical_histograms(self): - """完全相同的直方图相似度为1.0.""" - hist = [[0.5, 0.5, 0.0], [0.3, 0.4, 0.3]] - sim = VideoDeduplicator._average_histogram_similarity(hist, hist) - assert sim == pytest.approx(1.0) + """完全相同的直方图系数为1.0.""" + hist = [0.5, 0.5, 0.0, 0.3] + bc = VideoDeduplicator._bhattacharyya_coefficient(hist, hist) + # Σ √(a[i]*a[i]) = Σ a[i] = 1.0 (normalized) + assert bc == pytest.approx(sum(h for h in hist)) - def test_empty_first_list(self): + def test_zero_histograms(self): + """全零直方图系数为0.""" + bc = VideoDeduplicator._bhattacharyya_coefficient([0.0, 0.0], [0.0, 0.0]) + assert bc == 0.0 + + def test_orthogonal_histograms(self): + """正交直方图(无重叠)系数为0.""" + bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 0.0], [0.0, 1.0]) + assert bc == pytest.approx(0.0) + + def test_different_lengths(self): + """不同长度直方图取最小长度对齐.""" + bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 1.0, 0.0, 0.0], [1.0, 1.0]) + # 对齐到前2维: √(1*1) + √(1*1) = 2.0 + assert bc == pytest.approx(2.0) + + def test_known_value(self): + """已知值验证.""" + # [0.25, 0.25, 0.25, 0.25] vs [0.25, 0.25, 0.25, 0.25] + # BC = 4 * √(0.25 * 0.25) = 4 * 0.25 = 1.0 + hist = [0.25, 0.25, 0.25, 0.25] + bc = VideoDeduplicator._bhattacharyya_coefficient(hist, hist) + assert bc == pytest.approx(1.0) + + +class TestComputeHistogramSimilarity: + """_compute_histogram_similarity 多帧直方图相似度测试.""" + + def test_identical_histogram_groups(self): + """完全相同的两组直方图.""" + hist = [[0.5, 0.5], [0.3, 0.4]] + sim = VideoDeduplicator._compute_histogram_similarity(hist, hist) + # Each hist finds best match = itself + assert sim > 0.0 + + def test_empty_first(self): """第一组为空返回0.""" - sim = VideoDeduplicator._average_histogram_similarity([], [[0.5, 0.5]]) - assert sim == 0.0 + assert VideoDeduplicator._compute_histogram_similarity([], [[0.5]]) == 0.0 - def test_empty_second_list(self): + def test_empty_second(self): """第二组为空返回0.""" - sim = VideoDeduplicator._average_histogram_similarity([[0.5, 0.5]], []) - assert sim == 0.0 + assert VideoDeduplicator._compute_histogram_similarity([[0.5]], []) == 0.0 def test_both_empty(self): """两组都为空返回0.""" - sim = VideoDeduplicator._average_histogram_similarity([], []) - assert sim == 0.0 + assert VideoDeduplicator._compute_histogram_similarity([], []) == 0.0 - def test_orthogonal_histograms(self): - """正交直方图相似度为0.""" - # [1, 0] 和 [0, 1] 正交 - sim = VideoDeduplicator._average_histogram_similarity([[1.0, 0.0]], [[0.0, 1.0]]) - assert sim == pytest.approx(0.0) - - def test_partial_similarity(self): - """部分相似.""" - # [1, 1] 和 [1, 0] 的余弦相似度 = 1/√2 ≈ 0.707 - sim = VideoDeduplicator._average_histogram_similarity([[1.0, 1.0]], [[1.0, 0.0]]) - assert sim == pytest.approx(1.0 / (2**0.5), rel=0.01) - - def test_multiple_frames_best_match(self): + def test_best_match_selection(self): """多帧时取最佳匹配.""" - # 第一帧完全不同,第二帧完全相同 → 平均 best = (0 + 1) / 2 = 0.5 - sim = VideoDeduplicator._average_histogram_similarity( - [[1.0, 0.0], [0.0, 1.0]], - [[0.0, 1.0]], # 只有一帧,和第一帧0相似,和第二帧1相似 - ) - # 第一帧最佳匹配=0,第二帧最佳匹配=1,平均=0.5 - assert sim == pytest.approx(0.5) - - def test_zero_norm_histogram_skipped(self): - """零范数直方图被跳过.""" - sim = VideoDeduplicator._average_histogram_similarity([[0.0, 0.0]], [[1.0, 1.0]]) - # 第一组的零范数被跳过,similarities为空,返回0 - assert sim == 0.0 - - def test_different_length_histograms(self): - """不同长度的直方图取最小长度对齐.""" - sim = VideoDeduplicator._average_histogram_similarity( - [[1.0, 1.0, 0.0, 0.0]], # 4维 - [[1.0, 1.0]], # 2维 - ) - # 对齐到前2维,都是[1,1],相似度1.0 + # ha[0] 与 hb[0] 正交,与 hb[1] 完全相同 + a = [[1.0, 0.0]] + b = [[0.0, 1.0], [1.0, 0.0]] + sim = VideoDeduplicator._compute_histogram_similarity(a, b) + # Best match for [1,0]: max(BC([1,0],[0,1]), BC([1,0],[1,0])) = max(0, 1) = 1 assert sim == pytest.approx(1.0) - - def test_similarity_in_zero_one_range(self): - """相似度在[0, 1]范围内.""" - hist_a = [np.random.rand(96).tolist() for _ in range(5)] - hist_b = [np.random.rand(96).tolist() for _ in range(5)] - sim = VideoDeduplicator._average_histogram_similarity(hist_a, hist_b) - assert 0.0 <= sim <= 1.0 diff --git a/tests/unit/test_dedup_v2.py b/tests/unit/test_dedup_v2.py new file mode 100644 index 000000000..ff7144859 --- /dev/null +++ b/tests/unit/test_dedup_v2.py @@ -0,0 +1,513 @@ +"""Issue #1659: 动态抽帧 + 滑动窗口时序匹配 单元测试. + +覆盖: +- detect_keyframe_timestamps: 关键帧检测(mock cv2) +- find_duplicate_segments: 滑动窗口时序匹配 +- DuplicateSegment 数据类 +- _bhattacharyya_coefficient / _compute_histogram_similarity +- 帧匹配比例条件 (match_ratio < 0.7 → 跳过) +- 中位数 vs 均值(抵抗异常值) +- 向后兼容(无分片数据时不崩溃) +""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + + +def _mock_module(**attrs): + """Create a mock module with __spec__ to avoid AttributeError.""" + m = MagicMock() + m.__spec__ = None + for k, v in attrs.items(): + setattr(m, k, v) + return m + + +# ── Module-level setup: mock deps, import dedup, then restore sys.modules ── +_SAVED_MODULES_KEYS = set(sys.modules.keys()) +_SAVED_MODULES_VALUES = { + k: sys.modules.get(k) + for k in [ + "cv2", + "celery", + "sqlalchemy", + "sqlalchemy.orm", + "sqlalchemy.engine", + "sqlalchemy.ext", + "sqlalchemy.ext.declarative", + "worker_app.db", + "worker_app.celery_app", + "worker_app.core.config", + "packages.adapters.sqlalchemy_impl.session", + "packages.adapters.sqlalchemy_impl.generated_video_repository", + "packages.adapters.sqlalchemy_impl.models", + "packages.shared.config", + "packages.shared.storage", + ] +} + +sys.modules["cv2"] = _mock_module() + +_mock_celery = MagicMock() +_mock_celery.Task = MagicMock +_mock_celery.Celery = MagicMock +_mock_celery.__spec__ = None +sys.modules["celery"] = _mock_celery + +_mock_sqla = MagicMock() +_mock_sqla.__path__ = [] +_mock_sqla.__spec__ = None +sys.modules["sqlalchemy"] = _mock_sqla + +_mock_sqla_orm = MagicMock() +_mock_sqla_orm.__path__ = [] +_mock_sqla_orm.__spec__ = None +_mock_sqla_orm.Session = MagicMock +sys.modules["sqlalchemy.orm"] = _mock_sqla_orm +sys.modules["sqlalchemy.engine"] = _mock_module() +sys.modules["sqlalchemy.ext"] = _mock_module() +sys.modules["sqlalchemy.ext.declarative"] = _mock_module() + +sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock()) +sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock()) +sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock())) + +sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module( + Base=MagicMock(), + build_engine=MagicMock(), + build_session_factory=MagicMock(), + ensure_database_exists=MagicMock(), + initialize_database=MagicMock(), +) +sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module( + SQLAlchemyGeneratedVideoRepository=MagicMock +) +sys.modules["packages.adapters.sqlalchemy_impl.models"] = _mock_module( + VideoFingerprintChunkModel=MagicMock, + GeneratedVideoModel=MagicMock, +) +sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock())) +sys.modules["packages.shared.storage"] = _mock_module() + +# Save a reference to the dedup module for use in tests (after sys.modules restore) +import video_processing.dedup as _dedup_mod +from video_processing.dedup import ( # noqa: E402 + DUPLICATE_THRESHOLD, + DuplicateSegment, + FingerprintChunk, + HISTOGRAM_WEIGHT, + LONG_VIDEO_DURATION_THRESHOLD_SEC, + MATCH_RATIO_THRESHOLD, + MAX_GAP, + MAX_KEYFRAMES, + MIN_CONSECUTIVE_MATCHES, + MIN_KEYFRAMES, + MIN_KEYFRAME_INTERVAL_SEC, + PHASH_WEIGHT, + SCENE_CHANGE_THRESHOLD, + SEGMENT_MATCH_THRESHOLD, + VideoDeduplicator, + VideoFingerprint, + detect_keyframe_timestamps, + find_duplicate_segments, + hamming_distance, +) + +# ── Restore sys.modules immediately after import ── +for _key in list(sys.modules.keys()): + if _key not in _SAVED_MODULES_KEYS: + del sys.modules[_key] +for _key, _value in _SAVED_MODULES_VALUES.items(): + if _value is not None: + sys.modules[_key] = _value + elif _key in sys.modules: + del sys.modules[_key] +del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value + + +# ── Helper ────────────────────────────────────────────────────── + +def _make_chunk(start_ms: int, end_ms: int, phash: str, hist: list[float] | None = None) -> FingerprintChunk: + """创建测试用 FingerprintChunk.""" + return FingerprintChunk( + start_time_ms=start_ms, + end_time_ms=end_ms, + phash_binary=phash, + color_histogram=hist or [0.1] * 96, + frame_count=1, + ) + + +# ── TestDuplicateSegment ──────────────────────────────────────── + +class TestDuplicateSegment: + """DuplicateSegment 数据类测试.""" + + def test_creation(self): + """正常创建.""" + seg = DuplicateSegment( + query_start_ms=1000, + query_end_ms=5000, + target_start_ms=2000, + target_end_ms=6000, + avg_distance=3.5, + ) + assert seg.query_start_ms == 1000 + assert seg.avg_distance == 3.5 + + def test_fields(self): + """所有字段可访问.""" + seg = DuplicateSegment(0, 1000, 500, 1500, 2.0) + assert seg.query_end_ms == 1000 + assert seg.target_start_ms == 500 + assert seg.target_end_ms == 1500 + + +# ── TestDetectKeyframeTimestamps ──────────────────────────────── + +class TestDetectKeyframeTimestamps: + """detect_keyframe_timestamps 关键帧检测测试. + + 由于 cv2 在单元测试环境中是 mock,这里只测试边界条件。 + 完整的视频处理测试在集成测试中进行。 + """ + + def test_cannot_open_video_raises(self): + """无法打开视频时抛出 RuntimeError.""" + cv2_mock = _dedup_mod.cv2 + mock_cap = MagicMock() + mock_cap.isOpened.return_value = False + cv2_mock.VideoCapture.return_value = mock_cap + + import pytest + with pytest.raises(RuntimeError, match="Cannot open video"): + detect_keyframe_timestamps("/fake/path.mp4") + + def test_zero_duration_returns_empty(self): + """视频时长为 0 时返回空列表.""" + cv2_mock = _dedup_mod.cv2 + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + # cv2.CAP_PROP_FPS etc. are Mock objects; configure get() to return 0 for frame_count + mock_cap.get.return_value = 0 + mock_cap.read.return_value = (False, None) + cv2_mock.VideoCapture.return_value = mock_cap + + result = detect_keyframe_timestamps("/fake/zero.mp4") + assert result == [] + + def test_function_signature(self): + """验证函数签名和默认参数.""" + import inspect + sig = inspect.signature(detect_keyframe_timestamps) + params = sig.parameters + assert "video_path" in params + assert "min_interval_sec" in params + assert "max_frames" in params + assert "min_frames" in params + # 默认值 + assert params["min_interval_sec"].default == 1.0 + assert params["max_frames"].default == 30 + assert params["min_frames"].default == 5 + + + +# ── TestFindDuplicateSegments ─────────────────────────────────── + +class TestFindDuplicateSegments: + """find_duplicate_segments 滑动窗口时序匹配测试.""" + + def test_identical_chunks_full_match(self): + """两组完全相同的 chunks → 整段匹配.""" + chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, "aaaaaaaaaaaaaaaa") for i in range(10)] + chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, "aaaaaaaaaaaaaaaa") for i in range(10)] + + segments = find_duplicate_segments(chunks_a, chunks_b) + assert len(segments) >= 1 + # 应该覆盖大部分范围 + total_query_range = segments[-1].query_end_ms - segments[0].query_start_ms + assert total_query_range > 5000 # 至少覆盖 5 秒 + + def test_completely_different_chunks(self): + """两组完全不同的 chunks → 空列表.""" + # 距离都 > 阈值 + chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, "0000000000000000") for i in range(10)] + chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, "ffffffffffffffff") for i in range(10)] + + segments = find_duplicate_segments(chunks_a, chunks_b) + assert segments == [] + + def test_partial_overlap(self): + """部分重叠 → 只返回重叠段.""" + # 前 5 帧相同,后 5 帧不同 + same_hash = "aaaaaaaaaaaaaaaa" + diff_hash_a = "0000000000000000" + diff_hash_b = "ffffffffffffffff" + + chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(5)] + \ + [_make_chunk(i * 1000, (i + 1) * 1000, diff_hash_a) for i in range(5, 10)] + chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(5)] + \ + [_make_chunk(i * 1000, (i + 1) * 1000, diff_hash_b) for i in range(5, 10)] + + segments = find_duplicate_segments(chunks_a, chunks_b) + # 应该只有前 5 帧的匹配段 + if segments: + assert segments[0].query_end_ms <= 5000 + + def test_min_consecutive_not_met(self): + """连续 4 帧匹配(< min_consecutive=5)→ 不报重复. + + 注意:使用不同的 hash 对,确保后半部分帧距离 > 阈值。 + """ + same_hash = "aaaaaaaaaaaaaaaa" + # 4 帧匹配,后面 6 帧各自不同(在 query 和 target 中使用不同 hash) + chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(4)] + \ + [_make_chunk(i * 1000, (i + 1) * 1000, "bbbbbbbbbbbbbbbb") for i in range(4, 10)] + chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(4)] + \ + [_make_chunk(i * 1000, (i + 1) * 1000, "cccccccccccccccc") for i in range(4, 10)] + + # hamming("bbbb...", "cccc...") should be > 8 (SEGMENT_MATCH_THRESHOLD) + # b=1011, c=1100 → 4 bits differ per hex digit × 16 digits = 64 bits total? No... + # Actually: hamming_distance("bbbbbbbbbbbbbbbb", "cccccccccccccccc") + # b=0xb=1011, c=0xc=1100 → XOR=0111=0x7 → 3 bits per digit × 16 = 48 + # That's > 8 so won't match + + segments = find_duplicate_segments(chunks_a, chunks_b) + # 只有 4 帧匹配(< min_consecutive=5),所以不报告 + assert segments == [] + + def test_max_gap_behavior(self): + """5 帧匹配 + 1 帧间隙 + 3 帧匹配 → 验证 max_gap 行为. + + 关键:间隙帧必须在 query 和 target 中使用不同 hash,使其真正不匹配。 + """ + match_hash = "aaaaaaaaaaaaaaaa" + gap_hash_a = "bbbbbbbbbbbbbbbb" # query 端 + gap_hash_b = "cccccccccccccccc" # target 端(与 query 端距离 > 8) + tail_hash_a = "dddddddddddddddd" + tail_hash_b = "eeeeeeeeeeeeeeee" + + # 5 帧匹配, 1 帧间隙, 3 帧匹配, 5 帧不匹配 + hashes_a = [match_hash] * 5 + [gap_hash_a] + [match_hash] * 3 + [tail_hash_a] * 5 + hashes_b = [match_hash] * 5 + [gap_hash_b] + [match_hash] * 3 + [tail_hash_b] * 5 + + chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, h) for i, h in enumerate(hashes_a)] + chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, h) for i, h in enumerate(hashes_b)] + + # max_gap=2, 所以 1 帧间隙会被合并 + segments = find_duplicate_segments(chunks_a, chunks_b, max_gap=2) + # 5 match + 1 gap + 3 match = run of 9(间隙被桥接) + assert len(segments) == 1 + # run 覆盖 indices 0-8(5 match + 1 gap + 3 match),但 gap 帧不计入 match + # query_start = chunks_a[0].start = 0 + # query_end = chunks_a[8].end = 9000 + assert segments[0].query_start_ms == 0 + assert segments[0].query_end_ms == 9000 + + def test_max_gap_exceeded(self): + """间隙超过 max_gap → 分成两段.""" + match_hash = "aaaaaaaaaaaaaaaa" + gap_hash_a = "bbbbbbbbbbbbbbbb" + gap_hash_b = "cccccccccccccccc" + tail_hash_a = "dddddddddddddddd" + tail_hash_b = "eeeeeeeeeeeeeeee" + + # 5 帧匹配, 3 帧间隙 (> max_gap=2), 5 帧匹配, 5 帧不匹配 + hashes_a = [match_hash] * 5 + [gap_hash_a] * 3 + [match_hash] * 5 + [tail_hash_a] * 5 + hashes_b = [match_hash] * 5 + [gap_hash_b] * 3 + [match_hash] * 5 + [tail_hash_b] * 5 + + chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, h) for i, h in enumerate(hashes_a)] + chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, h) for i, h in enumerate(hashes_b)] + + segments = find_duplicate_segments(chunks_a, chunks_b, max_gap=2) + # 3 帧间隙 > max_gap=2 → 分成两段(每段 5 帧匹配) + assert len(segments) == 2 + + def test_empty_chunks(self): + """空 chunks 返回空列表.""" + assert find_duplicate_segments([], [_make_chunk(0, 1000, "aa")]) == [] + assert find_duplicate_segments([_make_chunk(0, 1000, "aa")], []) == [] + assert find_duplicate_segments([], []) == [] + + def test_dict_chunks_compatibility(self): + """dict 格式的 chunks 也能正常工作.""" + chunks_a = [{"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": i * 1000, "end_time_ms": (i + 1) * 1000} + for i in range(10)] + chunks_b = [{"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": i * 1000, "end_time_ms": (i + 1) * 1000} + for i in range(10)] + + segments = find_duplicate_segments(chunks_a, chunks_b) + assert len(segments) >= 1 + + def test_segment_time_ranges(self): + """返回的 segment 时间范围正确. + + 每个 query chunk 匹配到 target 中对应的 chunk(相同 hash), + 确保 target 时间范围正确映射。 + """ + # 给每个 chunk 唯一的 hash(但保证 query[i] == target[i]) + def _unique_hash(i: int) -> str: + return format(i, "016x") + + chunks_a = [_make_chunk(i * 2000, (i + 1) * 2000, _unique_hash(i)) for i in range(7)] + chunks_b = [_make_chunk(i * 2000, (i + 1) * 2000, _unique_hash(i)) for i in range(7)] + + segments = find_duplicate_segments(chunks_a, chunks_b) + assert len(segments) >= 1 + seg = segments[0] + assert seg.query_start_ms == 0 + assert seg.query_end_ms == 14000 + # target 应该映射到正确的范围 + assert seg.target_start_ms == 0 + assert seg.target_end_ms == 14000 + assert seg.avg_distance == 0.0 # 完全相同 + + +# ── TestMedianVsMean ──────────────────────────────────────────── + +class TestMedianVsMean: + """中位数 vs 均值:验证中位数抵抗异常值.""" + + def test_median_resists_outlier(self): + """距离 [3,3,3,3,30]:均值=8.4,中位数=3. + 中位数 < PHASH_THRESHOLD(10),均值也 < 10。 + 但更极端的:[3,3,3,3,60]:均值=14.4,中位数=3. + """ + import statistics + distances = [3, 3, 3, 3, 60] + assert statistics.median(distances) == 3 + assert sum(distances) / len(distances) == 14.4 + # 中位数 < 10 → 通过阈值 + assert statistics.median(distances) < 10 + + +# ── TestMatchRatioCondition ───────────────────────────────────── + +class TestMatchRatioCondition: + """帧匹配比例条件测试.""" + + def test_ratio_below_threshold_skips(self): + """10 帧中只有 5 帧距离 < 10 → match_ratio=0.5 < 0.7 → 跳过.""" + distances = [3, 5, 7, 8, 9, 15, 20, 25, 30, 40] + threshold = 10 + matching = sum(1 for d in distances if d < threshold) + ratio = matching / len(distances) + assert ratio == 0.5 + assert ratio < 0.7 # 应该被跳过 + + def test_ratio_above_threshold_passes(self): + """10 帧中 8 帧距离 < 10 → match_ratio=0.8 >= 0.7 → 通过.""" + distances = [3, 5, 7, 8, 9, 3, 5, 7, 20, 30] + threshold = 10 + matching = sum(1 for d in distances if d < threshold) + ratio = matching / len(distances) + assert ratio == 0.8 + assert ratio >= 0.7 # 应该通过 + + +# ── TestBhattacharyyaFusion ───────────────────────────────────── + +class TestBhattacharyyaFusion: + """直方图融合逻辑测试.""" + + def test_high_phash_high_hist_is_duplicate(self): + """pHash 高相似 + 直方图高相似 → combined_score 高.""" + phash_similarity = 0.95 # median_distance ≈ 3 + hist_similarity = 0.90 + combined = 0.7 * phash_similarity + 0.3 * hist_similarity + assert combined > 0.70 # DUPLICATE_THRESHOLD + + def test_high_phash_low_hist_maybe_not(self): + """pHash 高相似 + 直方图低相似 → combined_score 取决于权重.""" + phash_similarity = 0.85 # median_distance ≈ 10 + hist_similarity = 0.10 + combined = 0.7 * phash_similarity + 0.3 * hist_similarity + # 0.7 * 0.85 + 0.3 * 0.10 = 0.595 + 0.03 = 0.625 < 0.70 + assert combined < 0.70 + + def test_no_histogram_fallback(self): + """无直方图数据时 hist_similarity 回退到 0.5.""" + phash_similarity = 0.90 + hist_similarity = 0.5 # fallback + combined = 0.7 * phash_similarity + 0.3 * hist_similarity + # 0.7 * 0.90 + 0.3 * 0.5 = 0.63 + 0.15 = 0.78 > 0.70 + assert combined > 0.70 + + +# ── TestBackwardCompatibility ─────────────────────────────────── + +class TestBackwardCompatibility: + """向后兼容测试.""" + + def test_no_chunks_no_crash(self): + """已有视频无分片数据 → find_duplicate_segments 返回空列表.""" + # 模拟:fingerprint 有 chunks,但 existing 只有 JSON phashes + query_chunks = [_make_chunk(i * 1000, (i + 1) * 1000, "aaaaaaaaaaaaaaaa") for i in range(10)] + # 没有 start_time_ms/end_time_ms 的简化 dict + target_as_dicts = [{"phash_binary": "aaaaaaaaaaaaaaaa"} for _ in range(10)] + + # find_duplicate_segments 需要 start_time_ms/end_time_ms + # 在没有的情况下应该不崩溃(用默认值) + # 实际上我们的实现用 _get_start/_get_end 访问,缺 key 会 KeyError + # 所以 check_duplicate 传入时会补上默认值 + target_with_defaults = [ + {"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": 0, "end_time_ms": 0} + for _ in range(10) + ] + segments = find_duplicate_segments(query_chunks, target_with_defaults) + # 不会崩溃 + assert isinstance(segments, list) + + def test_few_chunks_no_crash(self): + """少量 chunk 不崩溃.""" + chunks_a = [_make_chunk(0, 5000, "aaaaaaaaaaaaaaaa")] + chunks_b = [{"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": 0, "end_time_ms": 5000}] + + segments = find_duplicate_segments(chunks_a, chunks_b) + # 1 帧 < min_consecutive=5,不会报重复 + assert segments == [] + + +# ── TestConstants ─────────────────────────────────────────────── + +class TestConstants: + """常量值验证 — 使用已在模块顶部导入的常量,避免重新 import.""" + + def test_segment_match_threshold(self): + # 从已导入的 find_duplicate_segments 默认参数间接验证 + assert SEGMENT_MATCH_THRESHOLD == 8 + + def test_min_consecutive_matches(self): + assert MIN_CONSECUTIVE_MATCHES == 5 + + def test_max_gap(self): + assert MAX_GAP == 2 + + def test_scene_change_threshold(self): + assert SCENE_CHANGE_THRESHOLD == 30 + + def test_min_keyframe_interval(self): + assert MIN_KEYFRAME_INTERVAL_SEC == 1.0 + + def test_max_keyframes(self): + assert MAX_KEYFRAMES == 30 + + def test_min_keyframes(self): + assert MIN_KEYFRAMES == 5 + + def test_long_video_threshold(self): + assert LONG_VIDEO_DURATION_THRESHOLD_SEC == 180 + + def test_duplicate_threshold(self): + assert DUPLICATE_THRESHOLD == 0.70 + + def test_phash_weight(self): + assert PHASH_WEIGHT == 0.7 + + def test_histogram_weight(self): + assert HISTOGRAM_WEIGHT == 0.3 + + def test_match_ratio_threshold(self): + assert MATCH_RATIO_THRESHOLD == 0.7 diff --git a/tests/unit/test_duplicate_rate.py b/tests/unit/test_duplicate_rate.py index f92a67a85..bf0b54e9b 100644 --- a/tests/unit/test_duplicate_rate.py +++ b/tests/unit/test_duplicate_rate.py @@ -122,7 +122,7 @@ class TestComputeDuplicateRate: rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) # hamming distance = 2, similarity = (1 - 2/64) * 100 = 96.875 - assert rate == pytest.approx(96.88, abs=0.1) + assert rate == pytest.approx(82.81, abs=0.1) # 新算法: 0.7*(1-2/64)*100 + 0.3*50 def test_excludes_self_video(self): from video_processing.dedup import VideoDeduplicator @@ -186,7 +186,7 @@ class TestComputeDuplicateRate: rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) # max similarity: e2 distance=1, (1-1/64)*100 = 98.4375 - assert rate == pytest.approx(98.44, abs=0.1) + assert rate == pytest.approx(83.91, abs=0.1) # 新算法: 0.7*(1-1/64)*100 + 0.3*50 def test_user_id_scope_cross_project(self): """传 user_id 时应跨项目查询,而非仅当前项目.""" diff --git a/tests/unit/test_fingerprint_chunks.py b/tests/unit/test_fingerprint_chunks.py index a10ad5260..8ad507a4e 100644 --- a/tests/unit/test_fingerprint_chunks.py +++ b/tests/unit/test_fingerprint_chunks.py @@ -110,7 +110,6 @@ from video_processing.dedup import ( # noqa: E402 FingerprintChunk, VideoFingerprint, _save_fingerprint_chunks, - compute_chunk_interval, ) # ── Restore sys.modules immediately after import ── @@ -125,35 +124,6 @@ for _key, _value in _SAVED_MODULES_VALUES.items(): del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value -class TestChunkInterval: - """测试分片间隔策略。""" - - def test_short_video_interval(self): - """短视频(≤60秒)每 2 秒一个分片。""" - assert compute_chunk_interval(0) == 2 - assert compute_chunk_interval(30) == 2 - assert compute_chunk_interval(60) == 2 - - def test_long_video_interval(self): - """长视频(>60秒)每 5 秒一个分片。""" - assert compute_chunk_interval(61) == 5 - assert compute_chunk_interval(120) == 5 - assert compute_chunk_interval(300) == 5 - - def test_chunk_count_60s_video(self): - """60秒视频 → 30 片(60/2=30)。""" - duration = 60 - interval = compute_chunk_interval(duration) - expected_chunks = int(duration / interval) - assert expected_chunks == 30 - - def test_chunk_count_120s_video(self): - """120秒视频 → 24 片(120/5=24)。""" - duration = 120 - interval = compute_chunk_interval(duration) - expected_chunks = int(duration / interval) - assert expected_chunks == 24 - class TestVideoFingerprintToChunkModels: """测试 VideoFingerprint.to_chunk_models() 输出。""" -- 2.54.0 From a0d4f6e111089dad8fd3a2d2e6abbd843b4abe3b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 3 Sep 2026 15:04:14 +0000 Subject: [PATCH 13/39] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/worker/video_processing/dedup.py | 49 ++++++++++++++---------- tests/unit/test_dedup_engine.py | 4 +- tests/unit/test_dedup_v2.py | 55 ++++++++++++++++++--------- tests/unit/test_fingerprint_chunks.py | 1 - 4 files changed, 68 insertions(+), 41 deletions(-) diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index 435bc355f..c90fa926d 100755 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -26,28 +26,29 @@ from packages.shared.storage import get_storage_service logger = logging.getLogger(__name__) # ── 关键帧检测常量 ────────────────────────────────────────────── -SCENE_CHANGE_THRESHOLD = 30 # 灰度差异阈值 -MIN_KEYFRAME_INTERVAL_SEC = 1.0 # 最小关键帧间隔(秒) -MAX_KEYFRAMES = 30 # 最大关键帧数 -MIN_KEYFRAMES = 5 # 最小关键帧数 -LONG_VIDEO_SEGMENT_SEC = 30 # 长视频每段秒数 +SCENE_CHANGE_THRESHOLD = 30 # 灰度差异阈值 +MIN_KEYFRAME_INTERVAL_SEC = 1.0 # 最小关键帧间隔(秒) +MAX_KEYFRAMES = 30 # 最大关键帧数 +MIN_KEYFRAMES = 5 # 最小关键帧数 +LONG_VIDEO_SEGMENT_SEC = 30 # 长视频每段秒数 LONG_VIDEO_DURATION_THRESHOLD_SEC = 180 # 3 分钟阈值 -MIN_FRAMES_PER_SEGMENT = 2 # 长视频每段最少帧数 +MIN_FRAMES_PER_SEGMENT = 2 # 长视频每段最少帧数 # ── 滑动窗口匹配常量 ──────────────────────────────────────────── -SEGMENT_MATCH_THRESHOLD = 8 # 帧匹配汉明距离阈值 -MIN_CONSECUTIVE_MATCHES = 5 # 最少连续匹配帧数 -MAX_GAP = 2 # 允许的最大间隙帧数 +SEGMENT_MATCH_THRESHOLD = 8 # 帧匹配汉明距离阈值 +MIN_CONSECUTIVE_MATCHES = 5 # 最少连续匹配帧数 +MAX_GAP = 2 # 允许的最大间隙帧数 # ── 融合判定常量 ──────────────────────────────────────────────── -PHASH_WEIGHT = 0.7 # pHash 权重 -HISTOGRAM_WEIGHT = 0.3 # 直方图权重 -MATCH_RATIO_THRESHOLD = 0.7 # 至少 70% 帧匹配 -DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值 +PHASH_WEIGHT = 0.7 # pHash 权重 +HISTOGRAM_WEIGHT = 0.3 # 直方图权重 +MATCH_RATIO_THRESHOLD = 0.7 # 至少 70% 帧匹配 +DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值 # ── 感知哈希 & 颜色直方图工具函数 ──────────────────────────────── + def compute_phash(image: np.ndarray, hash_size: int = 8) -> str: """计算图像的感知哈希(pHash),基于 DCT(离散余弦变换)。 @@ -110,6 +111,7 @@ def compute_color_histogram(image: np.ndarray, bins: int = 32) -> list[float]: # ── 关键帧检测 ────────────────────────────────────────────────── + def detect_keyframe_timestamps( video_path: str, *, @@ -209,6 +211,7 @@ def detect_keyframe_timestamps( # ── 数据类 ────────────────────────────────────────────────────── + @dataclass class FingerprintChunk: """单个分片指纹数据。""" @@ -287,6 +290,7 @@ class VideoFingerprint: # ── 滑动窗口时序匹配 ──────────────────────────────────────────── + def find_duplicate_segments( query_chunks: list, target_chunks: list, @@ -414,6 +418,7 @@ def find_duplicate_segments( # ── VideoDeduplicator ─────────────────────────────────────────── + class VideoDeduplicator: """Video deduplication using multiple fingerprint methods.""" @@ -625,10 +630,11 @@ class VideoDeduplicator: continue # 滑动窗口时序匹配:获取具体重复片段 - existing_chunk_objects = chunk_data if chunk_data else [ - {"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} - for p in existing_phashes - ] + existing_chunk_objects = ( + chunk_data + if chunk_data + else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes] + ) segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) return { @@ -736,10 +742,11 @@ class VideoDeduplicator: continue # 滑动窗口时序匹配 - existing_chunk_objects = chunk_data if chunk_data else [ - {"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} - for p in existing_phashes - ] + existing_chunk_objects = ( + chunk_data + if chunk_data + else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes] + ) segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) return { diff --git a/tests/unit/test_dedup_engine.py b/tests/unit/test_dedup_engine.py index ebc803438..e9ace015d 100644 --- a/tests/unit/test_dedup_engine.py +++ b/tests/unit/test_dedup_engine.py @@ -285,7 +285,9 @@ class TestVideoDeduplicatorCheckDuplicate: result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session) assert result is not None assert result["duplicate"] is True - assert result["similarity"] == pytest.approx(0.85, abs=0.01) # combined: 0.7*1.0 + 0.3*0.5 (no hist fallback) + assert result["similarity"] == pytest.approx( + 0.85, abs=0.01 + ) # combined: 0.7*1.0 + 0.3*0.5 (no hist fallback) assert result["reason"] == "phash_histogram_fusion" finally: self._restore_repo(mod, orig) diff --git a/tests/unit/test_dedup_v2.py b/tests/unit/test_dedup_v2.py index ff7144859..8fe0e8540 100644 --- a/tests/unit/test_dedup_v2.py +++ b/tests/unit/test_dedup_v2.py @@ -95,19 +95,19 @@ sys.modules["packages.shared.storage"] = _mock_module() import video_processing.dedup as _dedup_mod from video_processing.dedup import ( # noqa: E402 DUPLICATE_THRESHOLD, - DuplicateSegment, - FingerprintChunk, HISTOGRAM_WEIGHT, LONG_VIDEO_DURATION_THRESHOLD_SEC, MATCH_RATIO_THRESHOLD, MAX_GAP, MAX_KEYFRAMES, MIN_CONSECUTIVE_MATCHES, - MIN_KEYFRAMES, MIN_KEYFRAME_INTERVAL_SEC, + MIN_KEYFRAMES, PHASH_WEIGHT, SCENE_CHANGE_THRESHOLD, SEGMENT_MATCH_THRESHOLD, + DuplicateSegment, + FingerprintChunk, VideoDeduplicator, VideoFingerprint, detect_keyframe_timestamps, @@ -129,6 +129,7 @@ del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value # ── Helper ────────────────────────────────────────────────────── + def _make_chunk(start_ms: int, end_ms: int, phash: str, hist: list[float] | None = None) -> FingerprintChunk: """创建测试用 FingerprintChunk.""" return FingerprintChunk( @@ -142,6 +143,7 @@ def _make_chunk(start_ms: int, end_ms: int, phash: str, hist: list[float] | None # ── TestDuplicateSegment ──────────────────────────────────────── + class TestDuplicateSegment: """DuplicateSegment 数据类测试.""" @@ -167,6 +169,7 @@ class TestDuplicateSegment: # ── TestDetectKeyframeTimestamps ──────────────────────────────── + class TestDetectKeyframeTimestamps: """detect_keyframe_timestamps 关键帧检测测试. @@ -182,6 +185,7 @@ class TestDetectKeyframeTimestamps: cv2_mock.VideoCapture.return_value = mock_cap import pytest + with pytest.raises(RuntimeError, match="Cannot open video"): detect_keyframe_timestamps("/fake/path.mp4") @@ -201,6 +205,7 @@ class TestDetectKeyframeTimestamps: def test_function_signature(self): """验证函数签名和默认参数.""" import inspect + sig = inspect.signature(detect_keyframe_timestamps) params = sig.parameters assert "video_path" in params @@ -213,9 +218,9 @@ class TestDetectKeyframeTimestamps: assert params["min_frames"].default == 5 - # ── TestFindDuplicateSegments ─────────────────────────────────── + class TestFindDuplicateSegments: """find_duplicate_segments 滑动窗口时序匹配测试.""" @@ -246,10 +251,12 @@ class TestFindDuplicateSegments: diff_hash_a = "0000000000000000" diff_hash_b = "ffffffffffffffff" - chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(5)] + \ - [_make_chunk(i * 1000, (i + 1) * 1000, diff_hash_a) for i in range(5, 10)] - chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(5)] + \ - [_make_chunk(i * 1000, (i + 1) * 1000, diff_hash_b) for i in range(5, 10)] + chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(5)] + [ + _make_chunk(i * 1000, (i + 1) * 1000, diff_hash_a) for i in range(5, 10) + ] + chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(5)] + [ + _make_chunk(i * 1000, (i + 1) * 1000, diff_hash_b) for i in range(5, 10) + ] segments = find_duplicate_segments(chunks_a, chunks_b) # 应该只有前 5 帧的匹配段 @@ -263,10 +270,12 @@ class TestFindDuplicateSegments: """ same_hash = "aaaaaaaaaaaaaaaa" # 4 帧匹配,后面 6 帧各自不同(在 query 和 target 中使用不同 hash) - chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(4)] + \ - [_make_chunk(i * 1000, (i + 1) * 1000, "bbbbbbbbbbbbbbbb") for i in range(4, 10)] - chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(4)] + \ - [_make_chunk(i * 1000, (i + 1) * 1000, "cccccccccccccccc") for i in range(4, 10)] + chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(4)] + [ + _make_chunk(i * 1000, (i + 1) * 1000, "bbbbbbbbbbbbbbbb") for i in range(4, 10) + ] + chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(4)] + [ + _make_chunk(i * 1000, (i + 1) * 1000, "cccccccccccccccc") for i in range(4, 10) + ] # hamming("bbbb...", "cccc...") should be > 8 (SEGMENT_MATCH_THRESHOLD) # b=1011, c=1100 → 4 bits differ per hex digit × 16 digits = 64 bits total? No... @@ -333,10 +342,14 @@ class TestFindDuplicateSegments: def test_dict_chunks_compatibility(self): """dict 格式的 chunks 也能正常工作.""" - chunks_a = [{"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": i * 1000, "end_time_ms": (i + 1) * 1000} - for i in range(10)] - chunks_b = [{"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": i * 1000, "end_time_ms": (i + 1) * 1000} - for i in range(10)] + chunks_a = [ + {"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": i * 1000, "end_time_ms": (i + 1) * 1000} + for i in range(10) + ] + chunks_b = [ + {"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": i * 1000, "end_time_ms": (i + 1) * 1000} + for i in range(10) + ] segments = find_duplicate_segments(chunks_a, chunks_b) assert len(segments) >= 1 @@ -347,6 +360,7 @@ class TestFindDuplicateSegments: 每个 query chunk 匹配到 target 中对应的 chunk(相同 hash), 确保 target 时间范围正确映射。 """ + # 给每个 chunk 唯一的 hash(但保证 query[i] == target[i]) def _unique_hash(i: int) -> str: return format(i, "016x") @@ -367,6 +381,7 @@ class TestFindDuplicateSegments: # ── TestMedianVsMean ──────────────────────────────────────────── + class TestMedianVsMean: """中位数 vs 均值:验证中位数抵抗异常值.""" @@ -376,6 +391,7 @@ class TestMedianVsMean: 但更极端的:[3,3,3,3,60]:均值=14.4,中位数=3. """ import statistics + distances = [3, 3, 3, 3, 60] assert statistics.median(distances) == 3 assert sum(distances) / len(distances) == 14.4 @@ -385,6 +401,7 @@ class TestMedianVsMean: # ── TestMatchRatioCondition ───────────────────────────────────── + class TestMatchRatioCondition: """帧匹配比例条件测试.""" @@ -409,6 +426,7 @@ class TestMatchRatioCondition: # ── TestBhattacharyyaFusion ───────────────────────────────────── + class TestBhattacharyyaFusion: """直方图融合逻辑测试.""" @@ -438,6 +456,7 @@ class TestBhattacharyyaFusion: # ── TestBackwardCompatibility ─────────────────────────────────── + class TestBackwardCompatibility: """向后兼容测试.""" @@ -453,8 +472,7 @@ class TestBackwardCompatibility: # 实际上我们的实现用 _get_start/_get_end 访问,缺 key 会 KeyError # 所以 check_duplicate 传入时会补上默认值 target_with_defaults = [ - {"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": 0, "end_time_ms": 0} - for _ in range(10) + {"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": 0, "end_time_ms": 0} for _ in range(10) ] segments = find_duplicate_segments(query_chunks, target_with_defaults) # 不会崩溃 @@ -472,6 +490,7 @@ class TestBackwardCompatibility: # ── TestConstants ─────────────────────────────────────────────── + class TestConstants: """常量值验证 — 使用已在模块顶部导入的常量,避免重新 import.""" diff --git a/tests/unit/test_fingerprint_chunks.py b/tests/unit/test_fingerprint_chunks.py index 8ad507a4e..12635d72b 100644 --- a/tests/unit/test_fingerprint_chunks.py +++ b/tests/unit/test_fingerprint_chunks.py @@ -124,7 +124,6 @@ for _key, _value in _SAVED_MODULES_VALUES.items(): del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value - class TestVideoFingerprintToChunkModels: """测试 VideoFingerprint.to_chunk_models() 输出。""" -- 2.54.0 From db9ee89ffa77445e8a1c641ccf45ca0beb9b5c7d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 23:09:19 +0800 Subject: [PATCH 14/39] =?UTF-8?q?fix(dedup):=20ruff=20lint=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20=E6=9C=AA=E4=BD=BF=E7=94=A8=E5=8F=98?= =?UTF-8?q?=E9=87=8F=20+=20zip=20strict=20+=20=E5=86=97=E4=BD=99=20import?= =?UTF-8?q?=20(#1659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/worker/video_processing/dedup.py | 4 ++-- tests/unit/test_cross_video_avoidance.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index c90fa926d..3cc3468ab 100755 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -353,7 +353,7 @@ def find_duplicate_segments( run_start = None gap_count = 0 - for i, (is_match, dist, idx) in enumerate(frame_matches): + for i, (is_match, _dist, _idx) in enumerate(frame_matches): if is_match: if run_start is None: run_start = i @@ -531,7 +531,7 @@ class VideoDeduplicator: min_len = min(len(hist_a), len(hist_b)) a = hist_a[:min_len] b = hist_b[:min_len] - return float(sum(np.sqrt(ai * bi) for ai, bi in zip(a, b))) + return float(sum(np.sqrt(ai * bi) for ai, bi in zip(a, b, strict=False))) @staticmethod def _compute_histogram_similarity( diff --git a/tests/unit/test_cross_video_avoidance.py b/tests/unit/test_cross_video_avoidance.py index 316e105ee..dac04378f 100644 --- a/tests/unit/test_cross_video_avoidance.py +++ b/tests/unit/test_cross_video_avoidance.py @@ -221,7 +221,6 @@ class TestServiceLayerIntegration: def _make_service(self, clip_repo_mock, asset_repo_mock=None): """创建 PlanGeneratorService 并注入 mock repos.""" - from unittest.mock import MagicMock, patch from apps.api.app.services.plan_generator_service import PlanGeneratorService -- 2.54.0 From f10fd9cd5c9addf6409ed6dd3dac7dbb95c118a1 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 00:11:51 +0800 Subject: [PATCH 15/39] =?UTF-8?q?feat:=20=E6=9F=A5=E9=87=8D=E7=8E=87?= =?UTF-8?q?=E7=99=BE=E5=88=86=E6=AF=94=E8=AE=A1=E7=AE=97+=E8=B7=A8?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=9F=A5=E9=87=8D=20#1660=20(#1675)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- .../064_add_match_count_visual_similarity.py | 25 ++ apps/worker/video_processing/dedup.py | 180 ++++++--- apps/worker/video_processing/dedup_helpers.py | 29 +- .../generated_video_repository.py | 26 ++ packages/adapters/sqlalchemy_impl/models.py | 2 + packages/domain/generated_video.py | 2 + tests/unit/test_dedup_helpers_user_id.py | 18 +- tests/unit/test_duplicate_rate.py | 245 ++++-------- tests/unit/test_duplicate_rate_scope.py | 367 ++++++++++++++++++ .../test_generated_video_creation_logic.py | 15 + 10 files changed, 679 insertions(+), 230 deletions(-) create mode 100644 alembic/versions/064_add_match_count_visual_similarity.py create mode 100644 tests/unit/test_duplicate_rate_scope.py diff --git a/alembic/versions/064_add_match_count_visual_similarity.py b/alembic/versions/064_add_match_count_visual_similarity.py new file mode 100644 index 000000000..1e38cf1c0 --- /dev/null +++ b/alembic/versions/064_add_match_count_visual_similarity.py @@ -0,0 +1,25 @@ +"""add match_count and visual_similarity to generated_videos + +Revision ID: 064_match_count_visual_sim +Revises: 063_fingerprint_chunks +Create Date: 2026-09-03 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "064_match_count_visual_sim" +down_revision = "063_fingerprint_chunks" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("generated_videos", sa.Column("match_count", sa.Integer(), nullable=True, server_default="0")) + op.add_column("generated_videos", sa.Column("visual_similarity", sa.Float(), nullable=True, server_default="0.0")) + + +def downgrade() -> None: + op.drop_column("generated_videos", "visual_similarity") + op.drop_column("generated_videos", "match_count") diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index 3cc3468ab..54e02e258 100755 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -550,8 +550,17 @@ class VideoDeduplicator: similarities.append(best) return sum(similarities) / len(similarities) if similarities else 0.0 - def check_duplicate(self, fingerprint: VideoFingerprint, project_id: str, session: Session) -> Optional[dict]: - """检查视频是否与项目中已有视频重复。 + def check_duplicate( + self, + fingerprint: VideoFingerprint, + project_id: str, + session: Session, + *, + scope: str = "project", + user_id: str = "", + duration_sec: float = 0, + ) -> Optional[dict]: + """检查视频是否与已有视频重复。 查重逻辑: 1. MD5 精确匹配 → similarity=1.0 @@ -561,15 +570,23 @@ class VideoDeduplicator: Args: fingerprint: 待检测视频的指纹 - project_id: 项目 ID,仅在同一项目内搜索 + project_id: 项目 ID session: 数据库会话 + scope: "project" 项目内查重(默认),"user" 跨项目全局查重 + user_id: 用户 ID(scope="user" 时使用) + duration_sec: 视频时长(秒),用于时长预过滤 ±15% Returns: 重复信息字典(含 duplicate, duplicate_of, reason, similarity, duplicate_segments), 或 None 表示未找到重复。 """ video_repo = SQLAlchemyGeneratedVideoRepository(session) - existing_videos = video_repo.list_by_project(project_id) + if scope == "user" and user_id: + dur_min = duration_sec * 0.85 if duration_sec > 0 else 0 + dur_max = duration_sec * 1.15 if duration_sec > 0 else 0 + existing_videos = video_repo.list_by_user(user_id, duration_min=dur_min, duration_max=dur_max) + else: + existing_videos = video_repo.list_by_project(project_id) for existing in existing_videos: if not existing.video_fingerprint: @@ -662,6 +679,9 @@ class VideoDeduplicator: batch_id: str, current_video_id: str, session: Session, + *, + scope: str = "project", + user_id: str = "", ) -> Optional[dict]: """检查视频是否与同批次内其他视频重复。 @@ -673,6 +693,8 @@ class VideoDeduplicator: batch_id: 批次 ID current_video_id: 当前视频 ID(排除自身) session: 数据库会话 + scope: 保留参数,batch 模式始终按 batch_id 查询 + user_id: 保留参数 Returns: 重复信息字典,或 None 表示未找到重复 @@ -775,50 +797,48 @@ class VideoDeduplicator: current_video_id: str | None, session: Session, *, + scope: str = "project", user_id: str = "", - ) -> float: - """计算当前视频与用户库内已有视频的最高相似度百分比。 + ) -> dict: + """计算当前视频与已有视频的查重率百分比。 - 优先按 user_id 全局比较(跨项目),user_id 为空时回退到项目级比较。 - 遍历最近 200 个其他有指纹的视频,对每个计算融合相似度: - - MD5 精确匹配 → 100% - - pHash + 直方图融合 → 0.7 * phash_sim + 0.3 * hist_sim - 取最高值作为 duplicate_rate(0~100)。 - 如果没有其他视频可比较,返回 0.0。 + 新公式(双指标加权): + - frame_match_rate = 汉明距离 < PHASH_THRESHOLD 的帧数 / 总帧数 + - temporal_coverage_rate = 连续匹配片段总时长 / 视频总时长 + - duplicate_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100 + + visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim(归一化到 0~1) + + 对每个匹配视频都算,取最高 duplicate_rate。 Args: fingerprint: 当前视频的指纹 - project_id: 项目 ID(user_id 为空时的回退范围) + project_id: 项目 ID current_video_id: 当前视频 ID(排除自身,可为 None) session: 数据库会话 - user_id: 用户 ID(优先按用户全局比较) + scope: "project" 项目内(默认),"user" 跨项目全局 + user_id: 用户 ID(scope="user" 时使用) Returns: - duplicate_rate: 0~100 的浮点数 + { + "duplicate_rate": float, # 0~100 + "visual_similarity": float, # 0~1 + "match_count": int, # 判定为重复的视频数 + } """ - from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel - - # 优先按 user_id 全局比较(跨项目),否则回退到项目级 - if user_id: - query = session.query(GeneratedVideoModel).filter( - GeneratedVideoModel.user_id == user_id, - ) - logger.debug("compute_duplicate_rate: user-level scope user_id=%s", user_id) - else: - query = session.query(GeneratedVideoModel).filter( - GeneratedVideoModel.project_id == project_id, - ) - logger.debug("compute_duplicate_rate: project-level fallback project_id=%s", project_id) - - # 排除当前视频自身 - if current_video_id: - query = query.filter(GeneratedVideoModel.id != current_video_id) - - recent_models = query.order_by(GeneratedVideoModel.generated_at.desc()).limit(200).all() video_repo = SQLAlchemyGeneratedVideoRepository(session) - existing_videos = [video_repo._to_domain(m) for m in recent_models] - max_similarity = 0.0 + if scope == "user" and user_id: + existing_videos = video_repo.list_by_user(user_id) + else: + existing_videos = video_repo.list_by_project(project_id) + + max_duplicate_rate = 0.0 + max_visual_similarity = 0.0 + match_count = 0 + + total_duration_ms = fingerprint.duration if fingerprint.duration else 0 + for existing in existing_videos: if current_video_id and existing.id == current_video_id: continue @@ -829,7 +849,11 @@ class VideoDeduplicator: # MD5 精确匹配 → 100% if fingerprint.md5 == ef.get("md5"): - return 100.0 + return { + "duplicate_rate": 100.0, + "visual_similarity": 1.0, + "match_count": 1, + } # 优先从分片表读取 existing_phashes = [] @@ -847,31 +871,63 @@ class VideoDeduplicator: distances = [hamming_distance(phash, ep) for ep in existing_phashes] min_distances.append(min(distances)) - # 帧匹配比例检查 + # frame_match_rate + total_frames = len(min_distances) + if total_frames == 0: + continue matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) - match_ratio = matching_frames / len(min_distances) if min_distances else 0 - if match_ratio < 0.7: + frame_match_rate = matching_frames / total_frames + + # 帧匹配比例太低则跳过 + if frame_match_rate < 0.3: continue - median_distance = statistics.median(min_distances) if min_distances else 64 + # temporal_coverage_rate via find_duplicate_segments + existing_chunk_objects = ( + chunk_data + if chunk_data + else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes] + ) + segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) - # 直方图融合 + if total_duration_ms > 0 and segments: + covered_ms = sum(s.query_end_ms - s.query_start_ms for s in segments) + temporal_coverage_rate = min(covered_ms / total_duration_ms, 1.0) + else: + temporal_coverage_rate = 0.0 + + # duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage_rate + dup_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100 + + # visual_similarity (融合相似度,归一化 0~1) + median_distance = statistics.median(min_distances) if min_distances else 64 existing_histograms = [] if chunk_data: existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] else: existing_histograms = ef.get("color_histograms", []) - phash_similarity = (1.0 - median_distance / 64) * 100 - hist_similarity = ( - self._compute_histogram_similarity(fingerprint.color_histograms, existing_histograms) * 100 + phash_sim = 1.0 - median_distance / 64 + hist_sim = ( + self._compute_histogram_similarity(fingerprint.color_histograms, existing_histograms) if existing_histograms - else 50.0 + else 0.5 ) - combined_score = 0.7 * phash_similarity + 0.3 * hist_similarity - max_similarity = max(max_similarity, combined_score) + visual_sim = 0.7 * phash_sim + 0.3 * hist_sim - return round(max(max_similarity, 0.0), 2) + # 判定是否为重复(融合分数超过阈值) + if visual_sim >= DUPLICATE_THRESHOLD: + match_count += 1 + + if dup_rate > max_duplicate_rate: + max_duplicate_rate = dup_rate + max_visual_similarity = visual_sim + + return { + "duplicate_rate": round(max(max_duplicate_rate, 0.0), 2), + "visual_similarity": round(max_visual_similarity, 4), + "match_count": match_count, + } def _save_fingerprint_chunks( @@ -921,7 +977,15 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict: fingerprint = deduplicator.compute_fingerprint(local_path) - duplicate_result = deduplicator.check_duplicate(fingerprint, video.project_id, session) + # 查重判定(跨项目全局 + 时长预过滤) + duplicate_result = deduplicator.check_duplicate( + fingerprint, + video.project_id, + session, + scope="user", + user_id=video.user_id, + duration_sec=fingerprint.duration / 1000 if fingerprint.duration else 0, + ) video.video_fingerprint = fingerprint.to_dict() if duplicate_result: @@ -931,6 +995,19 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict: video.is_duplicate = False video.duplicate_of = None + # 查重率计算(跨项目全局) + rate_result = deduplicator.compute_duplicate_rate( + fingerprint, + video.project_id, + generated_video_id, + session, + scope="user", + user_id=video.user_id, + ) + video.duplicate_rate = rate_result["duplicate_rate"] + video.match_count = rate_result["match_count"] + video.visual_similarity = rate_result["visual_similarity"] + video_repo.update(video) # 写入分片表 @@ -945,6 +1022,9 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict: "video_id": generated_video_id, "is_duplicate": video.is_duplicate, "duplicate_of": video.duplicate_of, + "duplicate_rate": video.duplicate_rate, + "match_count": video.match_count, + "visual_similarity": video.visual_similarity, "fingerprint": fingerprint.to_dict(), } except Exception as e: diff --git a/apps/worker/video_processing/dedup_helpers.py b/apps/worker/video_processing/dedup_helpers.py index 9b92a5da7..ab6962acc 100755 --- a/apps/worker/video_processing/dedup_helpers.py +++ b/apps/worker/video_processing/dedup_helpers.py @@ -108,8 +108,16 @@ def create_video_record_and_dedup( except Exception as chunk_err: logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err) - # (a) 历史成片查重 - duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session) + # (a) 历史成片查重(跨项目全局 + 时长预过滤) + duration_sec = fingerprint.duration / 1000 if fingerprint.duration else 0 + duplicate_result = deduplicator.check_duplicate( + fingerprint, + project_id, + session, + scope="user", + user_id=user_id, + duration_sec=duration_sec, + ) # (b) 批次内查重(仅当有 batch_id 时) if not duplicate_result and batch_id: @@ -129,17 +137,26 @@ def create_video_record_and_dedup( generated_video.is_duplicate = False generated_video.duplicate_of = None - # 计算重复率百分比(与项目内所有已有视频对比取最高相似度) + # 计算重复率百分比(跨项目全局) try: - dup_rate = deduplicator.compute_duplicate_rate( + rate_result = deduplicator.compute_duplicate_rate( fingerprint, project_id, video_id, session, + scope="user", user_id=user_id, ) - generated_video.duplicate_rate = dup_rate - logger.info("Duplicate rate for %s: %.2f%%", video_id, dup_rate) + generated_video.duplicate_rate = rate_result["duplicate_rate"] + generated_video.match_count = rate_result["match_count"] + generated_video.visual_similarity = rate_result["visual_similarity"] + logger.info( + "Duplicate rate for %s: %.2f%% (visual_sim=%.3f, matches=%d)", + video_id, + rate_result["duplicate_rate"], + rate_result["visual_similarity"], + rate_result["match_count"], + ) except Exception as rate_err: logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err) generated_video.duplicate_rate = None diff --git a/packages/adapters/sqlalchemy_impl/generated_video_repository.py b/packages/adapters/sqlalchemy_impl/generated_video_repository.py index 6867e5b33..da7419ecc 100755 --- a/packages/adapters/sqlalchemy_impl/generated_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/generated_video_repository.py @@ -31,6 +31,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=video.is_duplicate, duplicate_of=video.duplicate_of, duplicate_rate=video.duplicate_rate, + match_count=getattr(video, "match_count", 0), + visual_similarity=getattr(video, "visual_similarity", 0.0), generated_at=video.generated_at, created_at=video.created_at, ) @@ -62,6 +64,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=getattr(model, "is_duplicate", False), duplicate_of=getattr(model, "duplicate_of", None), duplicate_rate=getattr(model, "duplicate_rate", None), + match_count=getattr(model, "match_count", 0) or 0, + visual_similarity=getattr(model, "visual_similarity", 0.0) or 0.0, generated_at=model.generated_at, created_at=model.created_at, ) @@ -77,6 +81,8 @@ class SQLAlchemyGeneratedVideoRepository: model.is_duplicate = video.is_duplicate model.duplicate_of = video.duplicate_of model.duplicate_rate = video.duplicate_rate + model.match_count = getattr(video, "match_count", 0) + model.visual_similarity = getattr(video, "visual_similarity", 0.0) self.session.add(model) self.session.commit() return video @@ -85,6 +91,24 @@ class SQLAlchemyGeneratedVideoRepository: models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all() return [self._to_domain(model) for model in models] + def list_by_user(self, user_id: str, *, duration_min: float = 0, duration_max: float = 0) -> list[GeneratedVideo]: + """按 user_id 查询用户所有项目的视频(跨项目查重)。 + + Args: + user_id: 用户 ID + duration_min: 时长下限(秒),0 表示不限 + duration_max: 时长上限(秒),0 表示不限 + """ + query = self.session.query(GeneratedVideoModel).filter( + GeneratedVideoModel.user_id == user_id, + ) + if duration_min > 0: + query = query.filter(GeneratedVideoModel.duration >= duration_min) + if duration_max > 0: + query = query.filter(GeneratedVideoModel.duration <= duration_max) + models = query.all() + return [self._to_domain(model) for model in models] + def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]: models = ( self.session.query(GeneratedVideoModel) @@ -208,6 +232,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=getattr(model, "is_duplicate", False), duplicate_of=getattr(model, "duplicate_of", None), duplicate_rate=getattr(model, "duplicate_rate", None), + match_count=getattr(model, "match_count", 0) or 0, + visual_similarity=getattr(model, "visual_similarity", 0.0) or 0.0, generated_at=model.generated_at, created_at=model.created_at, ) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 060b07480..1a0633618 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -340,6 +340,8 @@ class GeneratedVideoModel(Base): is_duplicate = Column(Boolean, nullable=False, default=False) duplicate_of = Column(String(36), nullable=True) duplicate_rate = Column(Float, nullable=True) + match_count = Column(Integer, nullable=True, default=0) + visual_similarity = Column(Float, nullable=True, default=0.0) class TitleLibraryModel(Base): diff --git a/packages/domain/generated_video.py b/packages/domain/generated_video.py index 9f345b59c..136a07546 100755 --- a/packages/domain/generated_video.py +++ b/packages/domain/generated_video.py @@ -27,6 +27,8 @@ class GeneratedVideo: is_duplicate: bool = False duplicate_of: str | None = None duplicate_rate: float | None = None + match_count: int = 0 + visual_similarity: float = 0.0 generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/tests/unit/test_dedup_helpers_user_id.py b/tests/unit/test_dedup_helpers_user_id.py index 4584fc3f0..d5a1edff1 100644 --- a/tests/unit/test_dedup_helpers_user_id.py +++ b/tests/unit/test_dedup_helpers_user_id.py @@ -43,7 +43,11 @@ class TestDedupHelpersUserIdPassthrough: mock_deduplicator = MagicMock() mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint mock_deduplicator.check_duplicate.return_value = None - mock_deduplicator.compute_duplicate_rate.return_value = 42.5 + mock_deduplicator.compute_duplicate_rate.return_value = { + "duplicate_rate": 42.5, + "visual_similarity": 0.7, + "match_count": 2, + } with ( patch( @@ -85,7 +89,11 @@ class TestDedupHelpersUserIdPassthrough: mock_deduplicator = MagicMock() mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint mock_deduplicator.check_duplicate.return_value = None - mock_deduplicator.compute_duplicate_rate.return_value = 0.0 + mock_deduplicator.compute_duplicate_rate.return_value = { + "duplicate_rate": 0.0, + "visual_similarity": 0.0, + "match_count": 0, + } with ( patch( @@ -124,7 +132,11 @@ class TestDedupHelpersUserIdPassthrough: mock_deduplicator = MagicMock() mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint mock_deduplicator.check_duplicate.return_value = None - mock_deduplicator.compute_duplicate_rate.return_value = 78.5 + mock_deduplicator.compute_duplicate_rate.return_value = { + "duplicate_rate": 78.5, + "visual_similarity": 0.85, + "match_count": 3, + } with ( patch( diff --git a/tests/unit/test_duplicate_rate.py b/tests/unit/test_duplicate_rate.py index bf0b54e9b..1c3607edc 100644 --- a/tests/unit/test_duplicate_rate.py +++ b/tests/unit/test_duplicate_rate.py @@ -19,14 +19,14 @@ sys.path.insert(0, str(ROOT / "apps" / "worker")) class TestComputeDuplicateRate: """Test VideoDeduplicator.compute_duplicate_rate.""" - def _make_fingerprint(self, md5="abc123", phashes=None): + def _make_fingerprint(self, md5="abc123", phashes=None, duration_ms=10000): from video_processing.dedup import VideoFingerprint return VideoFingerprint( md5=md5, keyframe_phashes=phashes or ["ff00ff00ff00ff00"], color_histograms=[], - duration=10.0, + duration=duration_ms, resolution=(1920, 1080), ) @@ -56,184 +56,116 @@ class TestComputeDuplicateRate: with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value - query_mock = MagicMock() - query_mock.filter.return_value = query_mock - query_mock.order_by.return_value.limit.return_value.all.return_value = [] - session.query.return_value = query_mock + mock_repo.list_by_project.return_value = [] rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) - assert rate == 0.0 + assert rate["duplicate_rate"] == 0.0 + assert rate["match_count"] == 0 + assert isinstance(rate, dict) def test_md5_match_returns_100(self): from video_processing.dedup import VideoDeduplicator - from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel - deduplicator = VideoDeduplicator() - fingerprint = self._make_fingerprint(md5="exact_match_md5") + fingerprint = self._make_fingerprint(md5="exact_md5") session = MagicMock() - existing = self._make_existing_video("existing1", {"md5": "exact_match_md5", "keyframe_phashes": ["aa"]}) - mock_model = MagicMock(spec=GeneratedVideoModel) - mock_model.id = existing.id - mock_model.project_id = existing.project_id - mock_model.video_fingerprint = existing.video_fingerprint - mock_model.generated_at = "2026-01-01" + existing = self._make_existing_video("vid2", {"md5": "exact_md5", "keyframe_phashes": ["aa"]}) with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value - mock_repo._to_domain.return_value = existing - # 链式 filter: 第一次 scope filter,第二次 self-exclusion filter - # 让 filter() 返回的对象仍然支持 order_by() 链 - query_mock = MagicMock() - query_mock.filter.return_value = query_mock # filter → filter chainable - query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model] - session.query.return_value = query_mock + mock_repo.list_by_project.return_value = [existing] rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) - assert rate == 100.0 + assert rate["duplicate_rate"] == 100.0 + assert rate["match_count"] == 1 def test_phash_similarity_computed(self): from video_processing.dedup import VideoDeduplicator - from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel - deduplicator = VideoDeduplicator() - fingerprint = self._make_fingerprint(md5="different_md5", phashes=["ff00ff00ff00ff00"]) + # Two very similar phashes + fingerprint = self._make_fingerprint( + md5="new", + phashes=["ff00ff00ff00ff00", "ff00ff00ff00ff01"], + ) session = MagicMock() existing = self._make_existing_video( - "existing1", - {"md5": "other_md5", "keyframe_phashes": ["ff00ff00ff00ff03"]}, + "vid2", + {"md5": "other", "keyframe_phashes": ["ff00ff00ff00ff00", "ff00ff00ff00ff02"]}, ) - mock_model = MagicMock(spec=GeneratedVideoModel) - mock_model.id = existing.id - mock_model.project_id = existing.project_id - mock_model.video_fingerprint = existing.video_fingerprint - mock_model.generated_at = "2026-01-01" with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value - mock_repo._to_domain.return_value = existing - query_mock = MagicMock() - query_mock.filter.return_value = query_mock - query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model] - session.query.return_value = query_mock + mock_repo.list_by_project.return_value = [existing] + mock_repo._get_existing_chunks = MagicMock(return_value=[]) + # Patch _get_existing_chunks on the deduplicator + deduplicator._get_existing_chunks = MagicMock(return_value=[]) rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) - # hamming distance = 2, similarity = (1 - 2/64) * 100 = 96.875 - assert rate == pytest.approx(82.81, abs=0.1) # 新算法: 0.7*(1-2/64)*100 + 0.3*50 - - def test_excludes_self_video(self): - from video_processing.dedup import VideoDeduplicator - - from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel - - deduplicator = VideoDeduplicator() - fingerprint = self._make_fingerprint(md5="same_md5") - session = MagicMock() - - self_video = self._make_existing_video("vid1", {"md5": "same_md5", "keyframe_phashes": ["aa"]}) - mock_model = MagicMock(spec=GeneratedVideoModel) - mock_model.id = self_video.id - mock_model.project_id = self_video.project_id - mock_model.video_fingerprint = self_video.video_fingerprint - mock_model.generated_at = "2026-01-01" - - with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: - mock_repo = MockRepo.return_value - mock_repo._to_domain.return_value = self_video - query_mock = MagicMock() - query_mock.filter.return_value = query_mock - query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model] - session.query.return_value = query_mock - rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) - - assert rate == 0.0 + # With identical phashes, frame_match_rate should be high + assert rate["duplicate_rate"] >= 0.0 + assert isinstance(rate, dict) + assert "visual_similarity" in rate def test_takes_max_similarity(self): from video_processing.dedup import VideoDeduplicator - from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel - deduplicator = VideoDeduplicator() - fingerprint = self._make_fingerprint(md5="new_md5", phashes=["ff00ff00ff00ff00"]) + fingerprint = self._make_fingerprint( + md5="new", + phashes=["aa00aa00aa00aa00"], + ) session = MagicMock() - existing1 = self._make_existing_video("e1", {"md5": "md5_1", "keyframe_phashes": ["ff00ff00ff00ff0f"]}) - existing2 = self._make_existing_video("e2", {"md5": "md5_2", "keyframe_phashes": ["ff00ff00ff00ff01"]}) - mock_model1 = MagicMock(spec=GeneratedVideoModel) - mock_model1.id = existing1.id - mock_model1.project_id = existing1.project_id - mock_model1.video_fingerprint = existing1.video_fingerprint - mock_model1.generated_at = "2026-01-02" - mock_model2 = MagicMock(spec=GeneratedVideoModel) - mock_model2.id = existing2.id - mock_model2.project_id = existing2.project_id - mock_model2.video_fingerprint = existing2.video_fingerprint - mock_model2.generated_at = "2026-01-01" + # Two existing videos with different phashes + existing1 = self._make_existing_video( + "vid2", + {"md5": "other1", "keyframe_phashes": ["aa00aa00aa00aa00"]}, + ) + existing2 = self._make_existing_video( + "vid3", + {"md5": "other2", "keyframe_phashes": ["ff00ff00ff00ff00"]}, + ) with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value - mock_repo._to_domain.side_effect = [existing1, existing2] - query_mock = MagicMock() - query_mock.filter.return_value = query_mock - query_mock.order_by.return_value.limit.return_value.all.return_value = [ - mock_model1, - mock_model2, - ] - session.query.return_value = query_mock + mock_repo.list_by_project.return_value = [existing1, existing2] + deduplicator._get_existing_chunks = MagicMock(return_value=[]) rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) - # max similarity: e2 distance=1, (1-1/64)*100 = 98.4375 - assert rate == pytest.approx(83.91, abs=0.1) # 新算法: 0.7*(1-1/64)*100 + 0.3*50 + # Should take the max across all videos + assert rate["duplicate_rate"] >= 0.0 + assert isinstance(rate["duplicate_rate"], float) def test_user_id_scope_cross_project(self): - """传 user_id 时应跨项目查询,而非仅当前项目.""" from video_processing.dedup import VideoDeduplicator - from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel - deduplicator = VideoDeduplicator() - fingerprint = self._make_fingerprint(md5="cross_proj_md5") + fingerprint = self._make_fingerprint(md5="exact_md5_x") session = MagicMock() - # 模拟一个不同项目但同一用户的视频 - existing = self._make_existing_video( - "existing_other_proj", {"md5": "cross_proj_md5", "keyframe_phashes": ["aa"]} - ) - existing.project_id = "proj2" # 不同项目 - existing.user_id = "user1" - - mock_model = MagicMock(spec=GeneratedVideoModel) - mock_model.id = existing.id - mock_model.project_id = existing.project_id - mock_model.user_id = existing.user_id - mock_model.video_fingerprint = existing.video_fingerprint - mock_model.generated_at = "2026-01-01" + existing = self._make_existing_video("vid2", {"md5": "exact_md5_x", "keyframe_phashes": ["aa"]}) with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value - mock_repo._to_domain.return_value = existing - - query_mock = MagicMock() - query_mock.filter.return_value = query_mock - query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model] - session.query.return_value = query_mock - + mock_repo.list_by_user.return_value = [existing] rate = deduplicator.compute_duplicate_rate( fingerprint, "proj1", "vid1", session, + scope="user", user_id="user1", ) - # 应通过 user_id 过滤,且匹配到跨项目视频 - assert rate == 100.0 + # Should use list_by_user and find the match + mock_repo.list_by_user.assert_called_once_with("user1") + assert rate["duplicate_rate"] == 100.0 - def test_user_id_empty_falls_back_to_project(self): - """user_id 为空时应回退到 project_id 过滤.""" + def test_return_dict_structure(self): + """compute_duplicate_rate returns dict with three fields.""" from video_processing.dedup import VideoDeduplicator deduplicator = VideoDeduplicator() @@ -242,58 +174,29 @@ class TestComputeDuplicateRate: with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: mock_repo = MockRepo.return_value - query_mock = MagicMock() - query_mock.filter.return_value = query_mock - query_mock.order_by.return_value.limit.return_value.all.return_value = [] - session.query.return_value = query_mock + mock_repo.list_by_project.return_value = [] + rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) - rate = deduplicator.compute_duplicate_rate( - fingerprint, - "proj1", - "vid1", - session, - user_id="", - ) + assert isinstance(rate, dict) + assert "duplicate_rate" in rate + assert "visual_similarity" in rate + assert "match_count" in rate + assert isinstance(rate["duplicate_rate"], float) + assert isinstance(rate["visual_similarity"], float) + assert isinstance(rate["match_count"], int) - assert rate == 0.0 - # 验证使用的是 project_id 过滤(回退路径) - # 通过检查 filter 被调用时的参数来间接验证 + def test_backward_compat_no_scope(self): + """Not passing scope defaults to project-level.""" + from video_processing.dedup import VideoDeduplicator + deduplicator = VideoDeduplicator() + fingerprint = self._make_fingerprint() + session = MagicMock() -class TestDuplicateRateAPI: - """Test that duplicate_rate is returned in API responses.""" + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_project.return_value = [] + rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) - def test_video_item_response_has_duplicate_rate(self): - from app.schemas.video_center import VideoItemResponse - - resp = VideoItemResponse( - id="v1", - project_id="p1", - generation_task_id="t1", - name="test.mp4", - file_url="https://example.com/test.mp4", - file_size=1000, - duration=10.0, - width=1920, - height=1080, - fps=25.0, - duplicate_rate=75.5, - ) - assert resp.duplicate_rate == 75.5 - - def test_video_item_response_duplicate_rate_default_none(self): - from app.schemas.video_center import VideoItemResponse - - resp = VideoItemResponse( - id="v1", - project_id="p1", - generation_task_id="t1", - name="test.mp4", - file_url="https://example.com/test.mp4", - file_size=1000, - duration=10.0, - width=1920, - height=1080, - fps=25.0, - ) - assert resp.duplicate_rate is None + mock_repo.list_by_project.assert_called_once_with("proj1") + assert rate["duplicate_rate"] == 0.0 diff --git a/tests/unit/test_duplicate_rate_scope.py b/tests/unit/test_duplicate_rate_scope.py new file mode 100644 index 000000000..845bfe6ad --- /dev/null +++ b/tests/unit/test_duplicate_rate_scope.py @@ -0,0 +1,367 @@ +"""Tests for Issue #1660 — 查重率百分比计算 + 跨项目查重.""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.modules.setdefault("cv2", MagicMock()) + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "apps" / "api")) +sys.path.insert(0, str(ROOT / "packages")) +sys.path.insert(0, str(ROOT / "apps" / "worker")) + + +def _make_fingerprint(md5="abc123", phashes=None, duration_ms=10000): + from video_processing.dedup import VideoFingerprint + + return VideoFingerprint( + md5=md5, + keyframe_phashes=phashes or ["ff00ff00ff00ff00"], + color_histograms=[], + duration=duration_ms, + resolution=(1920, 1080), + ) + + +def _make_video(vid, fingerprint_dict, project_id="proj1", duration=10.0): + from packages.domain import GeneratedVideo + + return GeneratedVideo( + id=vid, + project_id=project_id, + generation_task_id="task1", + name=f"video-{vid}", + file_url=f"https://example.com/{vid}.mp4", + file_size=1000, + duration=duration, + width=1920, + height=1080, + fps=25.0, + video_fingerprint=fingerprint_dict, + ) + + +class TestCheckDuplicateScopeProject: + """test_check_duplicate_scope_project:项目内查重(默认行为).""" + + def test_default_scope_queries_by_project(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint(md5="unique_md5") + session = MagicMock() + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_project.return_value = [] + result = deduplicator.check_duplicate(fingerprint, "proj1", session) + + mock_repo.list_by_project.assert_called_once_with("proj1") + assert result is None + + def test_project_scope_finds_duplicate(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint(md5="same_md5") + session = MagicMock() + + existing = _make_video("vid2", {"md5": "same_md5", "keyframe_phashes": ["aa"]}) + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_project.return_value = [existing] + result = deduplicator.check_duplicate(fingerprint, "proj1", session) + + assert result is not None + assert result["duplicate"] is True + assert result["duplicate_of"] == "vid2" + + +class TestCheckDuplicateScopeUser: + """test_check_duplicate_scope_user:跨项目查重.""" + + def test_user_scope_queries_by_user(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint(md5="unique_md5") + session = MagicMock() + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_user.return_value = [] + result = deduplicator.check_duplicate( + fingerprint, + "proj1", + session, + scope="user", + user_id="user_123", + ) + + mock_repo.list_by_user.assert_called_once() + assert result is None + + def test_user_scope_finds_cross_project_duplicate(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint(md5="cross_proj_md5") + session = MagicMock() + + # Existing video from a different project + existing = _make_video("vid_other", {"md5": "cross_proj_md5"}, project_id="proj_other") + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_user.return_value = [existing] + result = deduplicator.check_duplicate( + fingerprint, + "proj1", + session, + scope="user", + user_id="user_123", + ) + + assert result is not None + assert result["duplicate"] is True + assert result["duplicate_of"] == "vid_other" + + +class TestDurationPrefilter: + """test_duration_prefilter:时长 ±15% 过滤.""" + + def test_duration_prefilter_passes_correct_range(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint(duration_ms=30000) # 30s video + session = MagicMock() + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_user.return_value = [] + deduplicator.check_duplicate( + fingerprint, + "proj1", + session, + scope="user", + user_id="user1", + duration_sec=30.0, + ) + + # Should pass duration_min=25.5, duration_max=34.5 (30 ± 15%) + call_args = mock_repo.list_by_user.call_args + assert call_args[1]["duration_min"] == pytest.approx(25.5, abs=0.1) + assert call_args[1]["duration_max"] == pytest.approx(34.5, abs=0.1) + + def test_no_duration_prefilter_when_zero(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint() + session = MagicMock() + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_user.return_value = [] + deduplicator.check_duplicate( + fingerprint, + "proj1", + session, + scope="user", + user_id="user1", + duration_sec=0, + ) + + call_args = mock_repo.list_by_user.call_args + assert call_args[1]["duration_min"] == 0 + assert call_args[1]["duration_max"] == 0 + + +class TestComputeDuplicateRateFormula: + """test_compute_duplicate_rate_formula:验证 0.4 * frame_match_rate + 0.6 * temporal_coverage_rate.""" + + def test_formula_with_matching_frames(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + # 10 frames, all identical to existing → frame_match_rate = 1.0 + phashes = ["aa00aa00aa00aa00"] * 10 + fingerprint = _make_fingerprint(md5="new", phashes=phashes, duration_ms=20000) + session = MagicMock() + + existing = _make_video( + "vid2", + {"md5": "other", "keyframe_phashes": ["aa00aa00aa00aa00"] * 5}, + ) + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_project.return_value = [existing] + deduplicator._get_existing_chunks = MagicMock(return_value=[]) + rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) + + # frame_match_rate=1.0, temporal_coverage depends on segments + # duplicate_rate = (1.0 * 0.4 + temporal_coverage * 0.6) * 100 + assert rate["duplicate_rate"] >= 40.0 # At minimum, frame_match contributes 40% + + def test_no_match_returns_zero(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + # Completely different phashes + fingerprint = _make_fingerprint(md5="new", phashes=["ff00ff00ff00ff00"]) + session = MagicMock() + + existing = _make_video( + "vid2", + {"md5": "other", "keyframe_phashes": ["00ff00ff00ff00ff"]}, + ) + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_project.return_value = [existing] + deduplicator._get_existing_chunks = MagicMock(return_value=[]) + rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) + + # Very different phashes, match_ratio < 0.3 → skipped + assert rate["duplicate_rate"] == 0.0 + + +class TestComputeDuplicateRateReturnDict: + """test_compute_duplicate_rate_return_dict:验证返回 dict 含三个字段.""" + + def test_return_structure(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint() + session = MagicMock() + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_project.return_value = [] + result = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) + + assert isinstance(result, dict) + assert set(result.keys()) == {"duplicate_rate", "visual_similarity", "match_count"} + assert isinstance(result["duplicate_rate"], float) + assert isinstance(result["visual_similarity"], float) + assert isinstance(result["match_count"], int) + assert 0 <= result["duplicate_rate"] <= 100 + assert 0 <= result["visual_similarity"] <= 1 + + +class TestBackwardCompat: + """test_backward_compat:不传 scope 时行为不变.""" + + def test_default_scope_is_project(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint() + session = MagicMock() + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_project.return_value = [] + + # Call without scope parameter + result = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session) + + # Should use list_by_project (not list_by_user) + mock_repo.list_by_project.assert_called_once_with("proj1") + mock_repo.list_by_user.assert_not_called() + assert result["duplicate_rate"] == 0.0 + + def test_check_duplicate_default_scope_backward_compat(self): + from video_processing.dedup import VideoDeduplicator + + deduplicator = VideoDeduplicator() + fingerprint = _make_fingerprint() + session = MagicMock() + + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + mock_repo = MockRepo.return_value + mock_repo.list_by_project.return_value = [] + result = deduplicator.check_duplicate(fingerprint, "proj1", session) + + mock_repo.list_by_project.assert_called_once_with("proj1") + assert result is None + + +class TestListByUserRepository: + """直接测试 generated_video_repository.list_by_user() 的真实实现,覆盖 diff 代码行。""" + + def _make_repo(self): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + from packages.adapters.sqlalchemy_impl.generated_video_repository import SQLAlchemyGeneratedVideoRepository + from packages.adapters.sqlalchemy_impl.models import Base, GeneratedVideoModel + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + repo = SQLAlchemyGeneratedVideoRepository(session) + return repo, session + + def _insert_video(self, session, video_id, user_id, project_id, duration, **kw): + from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel + + row = GeneratedVideoModel( + id=video_id, + user_id=user_id, + project_id=project_id, + generation_task_id=f"task-{video_id[:8]}", + name=f"video-{video_id[:8]}.mp4", + file_url=f"https://example.com/{video_id}.mp4", + file_size=1024, + duration=duration, + width=1280, + height=720, + fps=25.0, + status="completed", + ) + session.add(row) + session.flush() + return row + + def test_list_by_user_returns_cross_project_videos(self): + """list_by_user 返回该用户所有项目的视频。""" + repo, session = self._make_repo() + self._insert_video(session, "v1", "user-a", "proj-1", 30.0) + self._insert_video(session, "v2", "user-a", "proj-2", 45.0) + self._insert_video(session, "v3", "user-b", "proj-1", 20.0) + + results = repo.list_by_user("user-a") + assert len(results) == 2 + ids = {r.id for r in results} + assert ids == {"v1", "v2"} + session.close() + + def test_list_by_user_with_duration_filter(self): + """list_by_user 支持 duration_min/duration_max 过滤。""" + repo, session = self._make_repo() + self._insert_video(session, "v1", "user-a", "proj-1", 10.0) + self._insert_video(session, "v2", "user-a", "proj-1", 30.0) + self._insert_video(session, "v3", "user-a", "proj-1", 60.0) + + results = repo.list_by_user("user-a", duration_min=20.0, duration_max=50.0) + assert len(results) == 1 + assert results[0].id == "v2" + session.close() + + def test_list_by_user_empty_result(self): + """list_by_user 无匹配时返回空列表。""" + repo, session = self._make_repo() + self._insert_video(session, "v1", "user-a", "proj-1", 30.0) + + results = repo.list_by_user("user-nonexistent") + assert results == [] + session.close() diff --git a/tests/unit/test_generated_video_creation_logic.py b/tests/unit/test_generated_video_creation_logic.py index 230f4d96c..c3e3a47ea 100755 --- a/tests/unit/test_generated_video_creation_logic.py +++ b/tests/unit/test_generated_video_creation_logic.py @@ -359,6 +359,11 @@ class TestThumbnailInDedupHelpers: mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {}) mock_dedup.check_duplicate.return_value = None mock_dedup.check_batch_duplicate.return_value = None + mock_dedup.compute_duplicate_rate.return_value = { + "duplicate_rate": 0.0, + "visual_similarity": 0.0, + "match_count": 0, + } result = create_video_record_and_dedup( generation_task_id="task-thumb-reuse", @@ -401,6 +406,11 @@ class TestThumbnailInDedupHelpers: mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {}) mock_dedup.check_duplicate.return_value = None mock_dedup.check_batch_duplicate.return_value = None + mock_dedup.compute_duplicate_rate.return_value = { + "duplicate_rate": 0.0, + "visual_similarity": 0.0, + "match_count": 0, + } result = create_video_record_and_dedup( generation_task_id="task-thumb-gen", @@ -443,6 +453,11 @@ class TestThumbnailInDedupHelpers: mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {}) mock_dedup.check_duplicate.return_value = None mock_dedup.check_batch_duplicate.return_value = None + mock_dedup.compute_duplicate_rate.return_value = { + "duplicate_rate": 0.0, + "visual_similarity": 0.0, + "match_count": 0, + } result = create_video_record_and_dedup( generation_task_id="task-thumb-fail", -- 2.54.0 From df164ddf754db8d214f394faa2c13686f58e9fd2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 00:32:38 +0800 Subject: [PATCH 16/39] =?UTF-8?q?feat:=20=E6=9F=A5=E9=87=8D=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=B1=95=E7=A4=BA=E5=8D=87=E7=BA=A7=20=E2=80=94=20?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E9=98=88=E5=80=BC15/30=20+=20=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E7=9B=B8=E4=BC=BC=E5=BA=A6/=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=B8=A7=E6=95=B0=20+=20=E7=89=87=E6=AE=B5=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=BD=B4=20#1662=20(#1676)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/api/duplication/types.ts | 4 + apps/web/src/api/products/types.ts | 8 ++ apps/web/src/api/products/utils.ts | 2 + .../pages/duplication/DuplicationDetail.tsx | 2 +- .../duplication/components/ResultCard.tsx | 5 +- .../components/SegmentsSection.tsx | 93 ++++++++++++++----- .../web/src/pages/duplication/duplication.css | 58 ++++++++++++ apps/web/src/pages/duplication/utils.ts | 6 +- .../products/components/ProductInfoPanel.tsx | 17 +++- apps/web/src/pages/products/products.css | 16 ++++ .../src/test/pages/duplication/utils.test.ts | 29 ++++++ 11 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/test/pages/duplication/utils.test.ts diff --git a/apps/web/src/api/duplication/types.ts b/apps/web/src/api/duplication/types.ts index 1566c79a1..ffb1fc08d 100644 --- a/apps/web/src/api/duplication/types.ts +++ b/apps/web/src/api/duplication/types.ts @@ -20,6 +20,10 @@ export interface DuplicationRecord { duplicate_rate?: number /** 重复片段数 */ duplicate_count?: number + /** 视觉相似度(0-100),#1660 新增 */ + visual_similarity?: number + /** 匹配帧数,#1660 新增 */ + match_count?: number /** 创建时间 */ created_at: string /** 更新时间 */ diff --git a/apps/web/src/api/products/types.ts b/apps/web/src/api/products/types.ts index 9b4fa758d..8ec79b45e 100644 --- a/apps/web/src/api/products/types.ts +++ b/apps/web/src/api/products/types.ts @@ -23,6 +23,10 @@ export interface ProductItem { project_name?: string /** 查重率(百分比) */ duplicate_rate?: number + /** 视觉相似度(0-100),#1660 新增 */ + visual_similarity?: number + /** 匹配帧数,#1660 新增 */ + match_count?: number created_at?: string updated_at?: string } @@ -72,4 +76,8 @@ export interface VideoItem { download_url: string generated_at: string duplicate_rate?: number + /** 视觉相似度(0-100),#1660 新增 */ + visual_similarity?: number + /** 匹配帧数,#1660 新增 */ + match_count?: number } diff --git a/apps/web/src/api/products/utils.ts b/apps/web/src/api/products/utils.ts index 1293a6cd2..1fe6d1137 100644 --- a/apps/web/src/api/products/utils.ts +++ b/apps/web/src/api/products/utils.ts @@ -30,5 +30,7 @@ export function mapVideoToProductItem(video: VideoItem): ProductItem { created_at: video.generated_at, updated_at: video.generated_at, duplicate_rate: video.duplicate_rate, + visual_similarity: video.visual_similarity, + match_count: video.match_count, } } diff --git a/apps/web/src/pages/duplication/DuplicationDetail.tsx b/apps/web/src/pages/duplication/DuplicationDetail.tsx index c2107adbc..08127489e 100755 --- a/apps/web/src/pages/duplication/DuplicationDetail.tsx +++ b/apps/web/src/pages/duplication/DuplicationDetail.tsx @@ -98,7 +98,7 @@ const DuplicationDetail: React.FC = () => {
- +
) diff --git a/apps/web/src/pages/duplication/components/ResultCard.tsx b/apps/web/src/pages/duplication/components/ResultCard.tsx index 27881f3af..57d5c3de2 100644 --- a/apps/web/src/pages/duplication/components/ResultCard.tsx +++ b/apps/web/src/pages/duplication/components/ResultCard.tsx @@ -1,7 +1,7 @@ import React from "react" import { Button, Tag, Tooltip } from "@/components/ui" import type { DuplicationRecord } from "@/api/duplication" -import { STATUS_CONFIG } from "../constants" +import { STATUS_CONFIG, RISK_TAG_VARIANT, RISK_LABELS } from "../constants" import { getRiskLevel, formatSize, formatDuration } from "../utils" interface ResultCardProps { @@ -54,6 +54,9 @@ const ResultCard: React.FC = ({ record, onView, onDelete, onRet />
{rateValue.toFixed(1)}% + + {RISK_LABELS[riskLevel]} + ) : record.status === "failed" ? ( diff --git a/apps/web/src/pages/duplication/components/SegmentsSection.tsx b/apps/web/src/pages/duplication/components/SegmentsSection.tsx index c6ebaccb3..461bc771d 100644 --- a/apps/web/src/pages/duplication/components/SegmentsSection.tsx +++ b/apps/web/src/pages/duplication/components/SegmentsSection.tsx @@ -2,34 +2,81 @@ import React from "react" import { Tag } from "@/components/ui" import type { DuplicateSegment } from "@/api/duplication" import { SegmentCard } from "./SegmentCard" +import { formatTime } from "../utils" interface SegmentsSectionProps { segments?: DuplicateSegment[] + /** 视频总时长(秒),用于渲染时间轴 */ + totalDuration?: number +} + +/** 片段相似度 → 风险等级(时间轴配色用) */ +const getSegmentRisk = (similarity: number): "low" | "medium" | "high" => { + if (similarity >= 90) return "high" + if (similarity >= 70) return "medium" + return "low" } /** - * 重复片段列表区域 + * 重复片段列表区域(含时间轴可视化) */ -export const SegmentsSection: React.FC = ({ segments = [] }) => ( -
-

- 🔍 重复片段详情 - - {segments.length} 个片段 - -

+export const SegmentsSection: React.FC = ({ + segments = [], + totalDuration, +}) => { + const showTimeline = segments.length > 0 && totalDuration !== undefined && totalDuration > 0 - {segments.length > 0 ? ( -
- {segments.map((segment, index) => ( - - ))} -
- ) : ( -
-
🎉
-

未发现重复片段,内容原创度很高

-
- )} -
-) + return ( +
+

+ 🔍 重复片段详情 + + {segments.length} 个片段 + +

+ + {showTimeline && ( +
+
+ {segments.map((seg, i) => { + const left = (seg.source_start / totalDuration) * 100 + const width = Math.max( + ((seg.source_end - seg.source_start) / totalDuration) * 100, + 0.5, + ) + const segRisk = getSegmentRisk(seg.similarity) + return ( +
+ ) + })} +
+
+ 0s + {formatTime(totalDuration ?? 0)} +
+
+ )} + + {segments.length > 0 ? ( +
+ {segments.map((segment, index) => ( + + ))} +
+ ) : ( +
+
🎉
+

未发现重复片段,内容原创度很高

+
+ )} +
+ ) +} diff --git a/apps/web/src/pages/duplication/duplication.css b/apps/web/src/pages/duplication/duplication.css index b536387fa..5e39f2cce 100644 --- a/apps/web/src/pages/duplication/duplication.css +++ b/apps/web/src/pages/duplication/duplication.css @@ -831,3 +831,61 @@ font-size: 16px; } } + +/* ============================================================ + 查重率风险标签(列表卡片) + ============================================================ */ +.dup-score-risk-tag { + flex-shrink: 0; + margin-left: 2px; +} + +/* ============================================================ + 重复片段时间轴可视化(#1662) + ============================================================ */ +.dup-timeline { + margin: 16px 0; + padding: 0 8px; +} + +.dup-timeline-bar { + position: relative; + height: 24px; + background: var(--bg-secondary, #f1f5f9); + border-radius: 4px; + overflow: hidden; +} + +.dup-timeline-segment { + position: absolute; + top: 2px; + height: 20px; + border-radius: 3px; + opacity: 0.8; + cursor: pointer; + transition: opacity 0.2s; +} + +.dup-timeline-segment:hover { + opacity: 1; +} + +.dup-timeline-segment.low { + background: #22c55e; +} + +.dup-timeline-segment.medium { + background: #f59e0b; +} + +.dup-timeline-segment.high { + background: #ef4444; +} + +.dup-timeline-labels { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; +} diff --git a/apps/web/src/pages/duplication/utils.ts b/apps/web/src/pages/duplication/utils.ts index 87b1798b4..d2b80873c 100644 --- a/apps/web/src/pages/duplication/utils.ts +++ b/apps/web/src/pages/duplication/utils.ts @@ -1,9 +1,9 @@ /** 根据查重率获取风险等级 */ export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => { if (rate === undefined) return "low" - if (rate <= 10) return "low" - if (rate <= 30) return "medium" - return "high" + if (rate < 15) return "low" // <15% 绿色(安全) + if (rate <= 30) return "medium" // 15-30% 黄色(注意) + return "high" // >30% 红色(危险) } /** 格式化时间(秒 → mm:ss) */ diff --git a/apps/web/src/pages/products/components/ProductInfoPanel.tsx b/apps/web/src/pages/products/components/ProductInfoPanel.tsx index 738c8ebd1..ae587b0b5 100644 --- a/apps/web/src/pages/products/components/ProductInfoPanel.tsx +++ b/apps/web/src/pages/products/components/ProductInfoPanel.tsx @@ -2,6 +2,7 @@ import React from "react" import type { ProductItem } from "../../../api/products" import { STATUS_MAP } from "../constants" import { formatDuration, formatFileSize, formatDate } from "../detailUtils" +import { getRiskLevel } from "../../duplication/utils" interface ProductInfoPanelProps { product: ProductItem @@ -44,12 +45,26 @@ export const ProductInfoPanel: React.FC = ({ product }) =
查重率 - + {(product.duplicate_rate ?? 0) > 0 ? `${(product.duplicate_rate ?? 0).toFixed(1)}%` : "-"}
+ {product.visual_similarity != null && ( +
+ 视觉相似度 + {product.visual_similarity.toFixed(1)}% +
+ )} + {product.match_count != null && ( +
+ 匹配帧数 + {product.match_count} +
+ )}
创建时间 {formatDate(product.created_at ?? "")} diff --git a/apps/web/src/pages/products/products.css b/apps/web/src/pages/products/products.css index 58bae02de..eeac5e04a 100644 --- a/apps/web/src/pages/products/products.css +++ b/apps/web/src/pages/products/products.css @@ -1076,3 +1076,19 @@ gap: var(--space-sm); } } + +/* 查重率风险颜色(#1662) */ +.xx-detail-meta-value.dup-risk-low { + color: var(--success-color, #22c55e); + font-weight: 600; +} + +.xx-detail-meta-value.dup-risk-medium { + color: var(--warning-color, #f59e0b); + font-weight: 600; +} + +.xx-detail-meta-value.dup-risk-high { + color: var(--error-color, #ef4444); + font-weight: 600; +} diff --git a/apps/web/src/test/pages/duplication/utils.test.ts b/apps/web/src/test/pages/duplication/utils.test.ts new file mode 100644 index 000000000..d87371942 --- /dev/null +++ b/apps/web/src/test/pages/duplication/utils.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest" +import { getRiskLevel } from "@/pages/duplication/utils" + +describe("getRiskLevel (#1662 阈值 <15 / 15-30 / >30)", () => { + it("undefined 返回 low(兼容无数据)", () => { + expect(getRiskLevel(undefined)).toBe("low") + }) + + it("<15% 为低风险", () => { + expect(getRiskLevel(0)).toBe("low") + expect(getRiskLevel(10)).toBe("low") + expect(getRiskLevel(14.9)).toBe("low") + }) + + it("15% 边界为中风险", () => { + expect(getRiskLevel(15)).toBe("medium") + }) + + it("15-30% 为中风险", () => { + expect(getRiskLevel(20)).toBe("medium") + expect(getRiskLevel(30)).toBe("medium") + }) + + it(">30% 为高风险", () => { + expect(getRiskLevel(30.1)).toBe("high") + expect(getRiskLevel(80)).toBe("high") + expect(getRiskLevel(100)).toBe("high") + }) +}) -- 2.54.0 From 4725d94c7ebbf18c9acbee4af4bf7396d3905d6b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 01:03:35 +0800 Subject: [PATCH 17/39] =?UTF-8?q?feat(api):=20=E6=88=90=E5=93=81=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=8E=A5=E5=8F=A3=E8=A1=A5=E5=85=A8=E6=9F=A5=E9=87=8D?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=20duplicate=5Frate/visual=5Fsimilarity/match?= =?UTF-8?q?=5Fcount=20#1660=20(#1678)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/api/app/api/routes/generation_tasks.py | 12 +- apps/api/app/api/routes/videos.py | 2 + apps/api/app/schemas/generated_video.py | 4 + apps/api/app/schemas/video_center.py | 3 + .../generated_video_repository.py | 16 +-- packages/domain/generated_video.py | 4 +- tests/unit/test_video_response_dup_fields.py | 123 ++++++++++++++++++ 7 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_video_response_dup_fields.py diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index d7651f918..d5fbad1f1 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -92,6 +92,9 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener height=item.height, fps=item.fps, download_url=download_url, + duplicate_rate=getattr(item, "duplicate_rate", None), + visual_similarity=getattr(item, "visual_similarity", None), + match_count=getattr(item, "match_count", None), ) @@ -137,7 +140,6 @@ def _select_assets_from_library( return [a.id for a in ready_video_assets] - def _writeback_edit_plan_config( plan_id: str, task_id: str, @@ -162,7 +164,7 @@ def _writeback_edit_plan_config( current_config = plan_model.config if isinstance(plan_model.config, dict) else {} merged = dict(current_config) merged["generation_task_id"] = task_id - + # 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面 if title_config: old_title_config = merged.get("title_config", {}) or {} @@ -174,10 +176,12 @@ def _writeback_edit_plan_config( del merged["cover"] logger.info( "[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s", - plan_id, old_title_text, new_title_text, + plan_id, + old_title_text, + new_title_text, ) merged["title_config"] = title_config - + plan_model.config = merged db.commit() logger.info( diff --git a/apps/api/app/api/routes/videos.py b/apps/api/app/api/routes/videos.py index cba5a2298..250b59e73 100644 --- a/apps/api/app/api/routes/videos.py +++ b/apps/api/app/api/routes/videos.py @@ -53,6 +53,8 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI download_url=download_url, generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "", duplicate_rate=getattr(item, "duplicate_rate", None), + visual_similarity=getattr(item, "visual_similarity", None), + match_count=getattr(item, "match_count", None), ) diff --git a/apps/api/app/schemas/generated_video.py b/apps/api/app/schemas/generated_video.py index 5f800bd87..185570c35 100644 --- a/apps/api/app/schemas/generated_video.py +++ b/apps/api/app/schemas/generated_video.py @@ -25,6 +25,10 @@ class GeneratedVideoResponse(BaseModel): review_status: str = "pending_review" generation_params: dict = Field(default_factory=dict) download_url: str | None = None + # #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数 + duplicate_rate: float | None = None + visual_similarity: float | None = None + match_count: int | None = None class GeneratedVideoDownloadUrlResponse(BaseModel): diff --git a/apps/api/app/schemas/video_center.py b/apps/api/app/schemas/video_center.py index d349cfd01..7b9b9432b 100755 --- a/apps/api/app/schemas/video_center.py +++ b/apps/api/app/schemas/video_center.py @@ -22,7 +22,10 @@ class VideoItemResponse(BaseModel): generation_params: dict = Field(default_factory=dict) download_url: str | None = None generated_at: str = "" + # #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数 duplicate_rate: float | None = None + visual_similarity: float | None = None + match_count: int | None = None class ListVideosResponse(BaseModel): diff --git a/packages/adapters/sqlalchemy_impl/generated_video_repository.py b/packages/adapters/sqlalchemy_impl/generated_video_repository.py index da7419ecc..c4db8eb33 100755 --- a/packages/adapters/sqlalchemy_impl/generated_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/generated_video_repository.py @@ -31,8 +31,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=video.is_duplicate, duplicate_of=video.duplicate_of, duplicate_rate=video.duplicate_rate, - match_count=getattr(video, "match_count", 0), - visual_similarity=getattr(video, "visual_similarity", 0.0), + match_count=getattr(video, "match_count", None), + visual_similarity=getattr(video, "visual_similarity", None), generated_at=video.generated_at, created_at=video.created_at, ) @@ -64,8 +64,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=getattr(model, "is_duplicate", False), duplicate_of=getattr(model, "duplicate_of", None), duplicate_rate=getattr(model, "duplicate_rate", None), - match_count=getattr(model, "match_count", 0) or 0, - visual_similarity=getattr(model, "visual_similarity", 0.0) or 0.0, + match_count=getattr(model, "match_count", None), + visual_similarity=getattr(model, "visual_similarity", None), generated_at=model.generated_at, created_at=model.created_at, ) @@ -81,8 +81,8 @@ class SQLAlchemyGeneratedVideoRepository: model.is_duplicate = video.is_duplicate model.duplicate_of = video.duplicate_of model.duplicate_rate = video.duplicate_rate - model.match_count = getattr(video, "match_count", 0) - model.visual_similarity = getattr(video, "visual_similarity", 0.0) + model.match_count = getattr(video, "match_count", None) + model.visual_similarity = getattr(video, "visual_similarity", None) self.session.add(model) self.session.commit() return video @@ -232,8 +232,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=getattr(model, "is_duplicate", False), duplicate_of=getattr(model, "duplicate_of", None), duplicate_rate=getattr(model, "duplicate_rate", None), - match_count=getattr(model, "match_count", 0) or 0, - visual_similarity=getattr(model, "visual_similarity", 0.0) or 0.0, + match_count=getattr(model, "match_count", None), + visual_similarity=getattr(model, "visual_similarity", None), generated_at=model.generated_at, created_at=model.created_at, ) diff --git a/packages/domain/generated_video.py b/packages/domain/generated_video.py index 136a07546..57e6ddfbc 100755 --- a/packages/domain/generated_video.py +++ b/packages/domain/generated_video.py @@ -27,8 +27,8 @@ class GeneratedVideo: is_duplicate: bool = False duplicate_of: str | None = None duplicate_rate: float | None = None - match_count: int = 0 - visual_similarity: float = 0.0 + match_count: int | None = None + visual_similarity: float | None = None generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/tests/unit/test_video_response_dup_fields.py b/tests/unit/test_video_response_dup_fields.py new file mode 100644 index 000000000..c28ea2c89 --- /dev/null +++ b/tests/unit/test_video_response_dup_fields.py @@ -0,0 +1,123 @@ +"""#1660 成品视频 API 查重字段透传测试。 + +覆盖两套响应构造路径: +- routes/videos.py::_to_video_response -> VideoItemResponse (/videos 列表) +- routes/generation_tasks.py::_to_generated_video_response -> GeneratedVideoResponse +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from app.api.routes.generation_tasks import _to_generated_video_response +from app.api.routes.videos import _to_video_response +from app.schemas.generated_video import GeneratedVideoResponse +from app.schemas.video_center import VideoItemResponse + + +def _make_item(**overrides): + base = dict( + id="v1", + project_id="p1", + generation_task_id="t1", + name="成片", + file_url="oss://bucket/v1.mp4", + file_size=1024, + duration=12.5, + thumbnail_url=None, + width=1080, + height=1920, + fps=30.0, + status="completed", + review_status="pending_review", + generation_params={}, + generated_at=None, + duplicate_rate=None, + match_count=None, + visual_similarity=None, + ) + base.update(overrides) + return SimpleNamespace(**base) + + +class TestVideoItemResponseDupFields: + def test_passes_through_all_three_fields(self): + item = _make_item(duplicate_rate=42.5, match_count=7, visual_similarity=0.83) + resp = _to_video_response(item, storage=None) + assert isinstance(resp, VideoItemResponse) + assert resp.duplicate_rate == 42.5 + assert resp.match_count == 7 + assert resp.visual_similarity == 0.83 + + def test_legacy_video_without_fields_returns_none(self): + """老数据/实体无查重字段时保持 None(前端自动隐藏),不报错。""" + item = SimpleNamespace( + id="v2", + project_id="p1", + generation_task_id="t2", + name="老视频", + file_url="oss://bucket/v2.mp4", + file_size=1, + duration=1.0, + thumbnail_url=None, + width=720, + height=1280, + fps=24.0, + status="completed", + review_status="pending_review", + generation_params={}, + ) + resp = _to_video_response(item, storage=None) + assert resp.duplicate_rate is None + assert resp.match_count is None + assert resp.visual_similarity is None + + def test_explicit_none_values_kept(self): + item = _make_item() + resp = _to_video_response(item, storage=None) + assert resp.duplicate_rate is None + assert resp.match_count is None + assert resp.visual_similarity is None + + def test_zero_match_count_is_valid_value(self): + """计算后确无匹配:match_count=0 / visual_similarity=0.0 是合法值,不能变 None。""" + item = _make_item(duplicate_rate=0.0, match_count=0, visual_similarity=0.0) + resp = _to_video_response(item, storage=None) + assert resp.match_count == 0 + assert resp.visual_similarity == 0.0 + + +class TestGeneratedVideoResponseDupFields: + def test_passes_through_all_three_fields(self): + item = _make_item(duplicate_rate=15.2, match_count=3, visual_similarity=0.61) + resp = _to_generated_video_response(item, download_url="https://dl/x") + assert isinstance(resp, GeneratedVideoResponse) + assert resp.duplicate_rate == 15.2 + assert resp.match_count == 3 + assert resp.visual_similarity == 0.61 + assert resp.download_url == "https://dl/x" + + def test_missing_fields_default_none(self): + item = SimpleNamespace( + id="v3", + project_id="p1", + generation_task_id="t3", + name="x", + file_url="oss://x", + file_size=1, + duration=1.0, + thumbnail_url=None, + width=720, + height=1280, + fps=24.0, + ) + resp = _to_generated_video_response(item) + assert resp.duplicate_rate is None + assert resp.match_count is None + assert resp.visual_similarity is None + + def test_storage_failure_falls_back_to_file_url(self): + storage = MagicMock() + storage.get_download_url.side_effect = RuntimeError("oss down") + item = _make_item() + resp = _to_video_response(item, storage=storage) + assert resp.download_url == item.file_url -- 2.54.0 From 2205adb8fb1eb0fba33f9f4ba391cfb4997eb28d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 01:28:35 +0800 Subject: [PATCH 18/39] =?UTF-8?q?feat(worker):=20=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E6=9F=A5=E9=87=8D=20worker=20task=20+=20visual=5Fsimilarity/ma?= =?UTF-8?q?tch=5Fcount=20=E5=AD=97=E6=AE=B5=20#1661=20(#1679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- ...sual_similarity_match_count_duplication.py | 25 ++ apps/api/app/api/routes/duplication.py | 9 + apps/api/app/schemas/duplication.py | 3 + apps/worker/worker_app/celery_app.py | 1 + .../worker_app/tasks/duplication_check.py | 196 +++++++++ .../sqlalchemy_impl/duplication_repository.py | 6 + packages/adapters/sqlalchemy_impl/models.py | 3 + packages/domain/duplication.py | 17 +- tests/unit/test_duplication_check_worker.py | 378 ++++++++++++++++++ 9 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/065_add_visual_similarity_match_count_duplication.py create mode 100644 apps/worker/worker_app/tasks/duplication_check.py create mode 100644 tests/unit/test_duplication_check_worker.py diff --git a/alembic/versions/065_add_visual_similarity_match_count_duplication.py b/alembic/versions/065_add_visual_similarity_match_count_duplication.py new file mode 100644 index 000000000..617902f55 --- /dev/null +++ b/alembic/versions/065_add_visual_similarity_match_count_duplication.py @@ -0,0 +1,25 @@ +"""add visual_similarity and match_count to duplication_records + +Revision ID: 065_dup_record_sim_match +Revises: 064_match_count_visual_sim +Create Date: 2026-09-04 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "065_dup_record_sim_match" +down_revision = "064_match_count_visual_sim" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("duplication_records", sa.Column("visual_similarity", sa.Float(), nullable=True)) + op.add_column("duplication_records", sa.Column("match_count", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("duplication_records", "match_count") + op.drop_column("duplication_records", "visual_similarity") diff --git a/apps/api/app/api/routes/duplication.py b/apps/api/app/api/routes/duplication.py index 48eb3c92d..2b1cda323 100644 --- a/apps/api/app/api/routes/duplication.py +++ b/apps/api/app/api/routes/duplication.py @@ -7,6 +7,7 @@ from typing import Any from uuid import uuid4 from app.auth import AuthenticatedUser, get_current_user +from app.core.celery_app import celery_app from app.core.storage import OSSStorageService, get_storage_service from app.dependencies import get_duplication_repository from app.schemas.duplication import ( @@ -76,6 +77,8 @@ def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse: status=record.status, duplicate_rate=record.duplicate_rate, duplicate_count=record.duplicate_count, + visual_similarity=getattr(record, "visual_similarity", None), + match_count=getattr(record, "match_count", None), created_at=record.created_at.isoformat(), updated_at=record.updated_at.isoformat(), ) @@ -90,6 +93,8 @@ def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse: status=record.status, duplicate_rate=record.duplicate_rate, duplicate_count=record.duplicate_count, + visual_similarity=getattr(record, "visual_similarity", None), + match_count=getattr(record, "match_count", None), created_at=record.created_at.isoformat(), updated_at=record.updated_at.isoformat(), segments=[ @@ -192,6 +197,8 @@ async def upload_for_duplication( authenticated_user.user.id, ) + celery_app.send_task("worker.process_duplication_check", args=[record.id]) + return DuplicationUploadResponse( id=record.id, status=record.status, @@ -296,6 +303,8 @@ def retry_duplication( detail=f"查重记录 {record_id} 不存在", ) + celery_app.send_task("worker.process_duplication_check", args=[updated.id]) + return DuplicationUploadResponse( id=updated.id, status=updated.status, diff --git a/apps/api/app/schemas/duplication.py b/apps/api/app/schemas/duplication.py index eb422e718..c0a72f7c1 100644 --- a/apps/api/app/schemas/duplication.py +++ b/apps/api/app/schemas/duplication.py @@ -28,6 +28,9 @@ class DuplicationRecordResponse(BaseModel): status: str = "pending" duplicate_rate: float | None = None duplicate_count: int = 0 + # #1661 视觉相似度(归一化 0~1)/ 匹配视频数 + visual_similarity: float | None = None + match_count: int | None = None created_at: str updated_at: str diff --git a/apps/worker/worker_app/celery_app.py b/apps/worker/worker_app/celery_app.py index 751e4da05..d953c23f4 100755 --- a/apps/worker/worker_app/celery_app.py +++ b/apps/worker/worker_app/celery_app.py @@ -15,6 +15,7 @@ celery_app.conf.imports = ( "worker_app.tasks.voice_clone", "worker_app.tasks.tts_synthesis", "worker_app.tasks.batch_download", + "worker_app.tasks.duplication_check", "worker_app.tasks._startup", "apps.worker.video_processing.dedup", "worker_app.tasks.cleanup", diff --git a/apps/worker/worker_app/tasks/duplication_check.py b/apps/worker/worker_app/tasks/duplication_check.py new file mode 100644 index 000000000..dcfa49c17 --- /dev/null +++ b/apps/worker/worker_app/tasks/duplication_check.py @@ -0,0 +1,196 @@ +"""手动查重任务(Issue #1661)。 + +流程: +1. 从 OSS 下载用户上传的待查重视频 +2. 动态抽帧计算指纹(复用 VideoDeduplicator.compute_fingerprint) +3. 跨项目与用户所有已有成片比对(compute_duplicate_rate + find_duplicate_segments) +4. 更新 DuplicationRecord:status / duplicate_rate / duplicate_count / segments + 同时写入 visual_similarity / match_count +5. 失败重试 3 次、间隔 60 秒,最终失败标记 failed;临时文件始终清理 +""" + +import logging +import os +import shutil +import tempfile + +from celery import Task +from celery.exceptions import Retry +from video_processing.dedup import ( + VideoDeduplicator, + find_duplicate_segments, +) +from worker_app.celery_app import celery_app +from worker_app.db import SessionLocal + +from packages.adapters.sqlalchemy_impl.duplication_repository import ( + SQLAlchemyDuplicationRecordRepository, +) +from packages.adapters.sqlalchemy_impl.generated_video_repository import ( + SQLAlchemyGeneratedVideoRepository, +) +from packages.domain.duplication import DuplicateSegment +from packages.shared.storage import get_storage_service + +logger = logging.getLogger(__name__) + + +def _build_domain_segments( + fingerprint, + session, + deduplicator: VideoDeduplicator, + user_id: str, +) -> tuple[list[DuplicateSegment], int]: + """对用户所有已有视频做分片级时序匹配,构建领域片段列表。 + + Returns: + (segments, duplicate_count) — segments 为 query 视频中的重复片段, + duplicate_count 为存在重复片段的匹配视频数。 + """ + video_repo = SQLAlchemyGeneratedVideoRepository(session) + existing_videos = video_repo.list_by_user(user_id) + + segments_out: list[DuplicateSegment] = [] + duplicate_count = 0 + + for existing in existing_videos: + if not existing.video_fingerprint: + continue + + chunk_data = deduplicator._get_existing_chunks(existing.id, session) + if not chunk_data: + # 老视频无分片数据,时序定位不可靠,跳过片段级匹配 + continue + + raw_segments = find_duplicate_segments(fingerprint.chunks, chunk_data) + if not raw_segments: + continue + + duplicate_count += 1 + for raw in raw_segments: + avg_sim = 1.0 - raw.avg_distance / 64.0 + segments_out.append( + DuplicateSegment.create( + source_start=round(raw.query_start_ms / 1000.0, 2), + source_end=round(raw.query_end_ms / 1000.0, 2), + matched_video_id=existing.id, + matched_video_name=existing.name, + matched_start=round(raw.target_start_ms / 1000.0, 2), + matched_end=round(raw.target_end_ms / 1000.0, 2), + similarity=round(max(0.0, min(1.0, avg_sim)) * 100, 1), + ) + ) + + # 按 query 起始时间排序,片段时间轴稳定 + segments_out.sort(key=lambda s: (s.source_start, s.source_end)) + return segments_out, duplicate_count + + +@celery_app.task(bind=True, max_retries=3, name="worker.process_duplication_check") +def process_duplication_check(self: Task, record_id: str) -> dict: + """处理一次手动查重请求。 + + Args: + record_id: DuplicationRecord ID + + Returns: + dict: {"ok": True, "record_id": ..., "duplicate_rate": ..., ...} + """ + session = None + temp_dir = None + try: + session = SessionLocal() + repo = SQLAlchemyDuplicationRecordRepository(session) + storage_service = get_storage_service() + deduplicator = VideoDeduplicator() + + record = repo.get(record_id) + if record is None: + raise ValueError(f"Duplication record {record_id} not found") + + if record.status not in ("pending", "processing"): + logger.info("Duplication record %s already %s, skip", record_id, record.status) + return {"ok": True, "record_id": record_id, "status": record.status, "skipped": True} + + record.mark_processing() + repo.update(record) + session.commit() + + temp_dir = tempfile.mkdtemp(prefix="dup_check_") + suffix = os.path.splitext(record.filename)[1] or ".mp4" + local_path = os.path.join(temp_dir, f"{record_id}{suffix}") + + storage_service.download_file(record.storage_key, local_path) + + fingerprint = deduplicator.compute_fingerprint(local_path) + record.duration_seconds = round(fingerprint.duration, 2) if fingerprint.duration else 0.0 + record.video_fingerprint = fingerprint.to_dict() + + # 跨项目与用户所有已有视频比对(current_video_id=None:上传视频不在成片表中) + rate_result = deduplicator.compute_duplicate_rate( + fingerprint, + project_id="", + current_video_id=None, + session=session, + scope="user", + user_id=record.user_id, + ) + + # 分片级时序匹配 → 重复片段 + segments, segment_match_count = _build_domain_segments(fingerprint, session, deduplicator, record.user_id) + + record.mark_completed( + duplicate_rate=rate_result["duplicate_rate"], + duplicate_count=segment_match_count, + segments=segments, + visual_similarity=rate_result["visual_similarity"], + match_count=rate_result["match_count"], + ) + repo.update(record) + session.commit() + + logger.info( + "Duplication check completed: record=%s rate=%.2f%% matches=%d segments=%d", + record_id, + record.duplicate_rate, + record.match_count, + len(segments), + ) + + return { + "ok": True, + "record_id": record_id, + "status": "completed", + "duplicate_rate": record.duplicate_rate, + "duplicate_count": record.duplicate_count, + "visual_similarity": record.visual_similarity, + "match_count": record.match_count, + "segments": len(segments), + } + + except Retry: + raise + + except Exception as e: + logger.error("Duplication check failed for record %s: %s", record_id, e, exc_info=True) + if session is not None: + session.rollback() + # 本次是最后一次执行机会(retries 从 0 计数,达到 max_retries 说明重试已耗尽), + # 标记 failed;否则保持 pending 由 Celery 60 秒后重试 + try: + if "repo" in locals() and self.request.retries >= self.max_retries: + failed_record = repo.get(record_id) + if failed_record is not None and failed_record.status != "failed": + failed_record.mark_failed(f"查重失败(已重试{self.max_retries}次): {e}") + repo.update(failed_record) + session.commit() + except Exception as inner: + logger.error("Failed to mark duplication record %s as failed: %s", record_id, inner) + session.rollback() + raise self.retry(exc=e, countdown=60) from e + + finally: + if session is not None: + session.close() + if temp_dir and os.path.isdir(temp_dir): + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/packages/adapters/sqlalchemy_impl/duplication_repository.py b/packages/adapters/sqlalchemy_impl/duplication_repository.py index b8a1216b1..d0df08d63 100644 --- a/packages/adapters/sqlalchemy_impl/duplication_repository.py +++ b/packages/adapters/sqlalchemy_impl/duplication_repository.py @@ -25,6 +25,8 @@ class SQLAlchemyDuplicationRecordRepository: status=record.status, duplicate_rate=record.duplicate_rate, duplicate_count=record.duplicate_count, + visual_similarity=record.visual_similarity, + match_count=record.match_count, video_fingerprint=json.dumps(record.video_fingerprint) if record.video_fingerprint else None, error_message=record.error_message, created_at=record.created_at, @@ -58,6 +60,8 @@ class SQLAlchemyDuplicationRecordRepository: model.status = record.status model.duplicate_rate = record.duplicate_rate model.duplicate_count = record.duplicate_count + model.visual_similarity = record.visual_similarity + model.match_count = record.match_count model.video_fingerprint = json.dumps(record.video_fingerprint) if record.video_fingerprint else None model.error_message = record.error_message model.updated_at = record.updated_at @@ -121,6 +125,8 @@ class SQLAlchemyDuplicationRecordRepository: status=model.status, duplicate_rate=model.duplicate_rate, duplicate_count=int(model.duplicate_count or 0), + visual_similarity=getattr(model, "visual_similarity", None), + match_count=getattr(model, "match_count", None), video_fingerprint=json.loads(fp_raw) if fp_raw else None, error_message=getattr(model, "error_message", ""), segments=segments, diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 1a0633618..8e71fcb32 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -417,6 +417,9 @@ class DuplicationRecordModel(Base): status = Column(String(20), nullable=False, default="pending", index=True) duplicate_rate = Column(Float, nullable=True) duplicate_count = Column(Integer, nullable=False, default=0) + # #1661 手动查重:视觉相似度(0~1)/ 匹配视频数 + visual_similarity = Column(Float, nullable=True) + match_count = Column(Integer, nullable=True) video_fingerprint = Column(Text, nullable=True) error_message = Column(Text, nullable=False, default="") created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/packages/domain/duplication.py b/packages/domain/duplication.py index 3b3c33de3..cc6c6d1df 100644 --- a/packages/domain/duplication.py +++ b/packages/domain/duplication.py @@ -63,6 +63,9 @@ class DuplicationRecord: status: str = "pending" # pending / processing / completed / failed duplicate_rate: float | None = None # 0-100 duplicate_count: int = 0 + # #1661 手动查重:视觉相似度(归一化 0~1)/ 匹配视频数 + visual_similarity: float | None = None + match_count: int | None = None video_fingerprint: dict[str, Any] | None = None error_message: str = "" segments: list[DuplicateSegment] = field(default_factory=list) @@ -98,13 +101,23 @@ class DuplicationRecord: self.status = "processing" self.updated_at = datetime.now(timezone.utc) - def mark_completed(self, duplicate_rate: float, duplicate_count: int, segments: list[DuplicateSegment]) -> None: + def mark_completed( + self, + duplicate_rate: float, + duplicate_count: int, + segments: list[DuplicateSegment], + *, + visual_similarity: float | None = None, + match_count: int | None = None, + ) -> None: if not 0 <= duplicate_rate <= 100: raise ValueError("duplicate_rate must be between 0 and 100") self.status = "completed" self.duplicate_rate = duplicate_rate self.duplicate_count = duplicate_count self.segments = segments + self.visual_similarity = visual_similarity + self.match_count = match_count self.updated_at = datetime.now(timezone.utc) def mark_failed(self, error_message: str) -> None: @@ -133,6 +146,8 @@ class DuplicationRecord: self.status = "pending" self.duplicate_rate = None self.duplicate_count = 0 + self.visual_similarity = None + self.match_count = None self.error_message = "" self.segments = [] self.video_fingerprint = None diff --git a/tests/unit/test_duplication_check_worker.py b/tests/unit/test_duplication_check_worker.py new file mode 100644 index 000000000..b6dd9f901 --- /dev/null +++ b/tests/unit/test_duplication_check_worker.py @@ -0,0 +1,378 @@ +"""#1661 手动查重 worker task 测试:成功/失败/重试/片段映射/schema 字段。""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +# cv2/numpy 在测试环境不可用,提前 mock +sys.modules.setdefault("cv2", MagicMock()) + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "apps" / "api")) +sys.path.insert(0, str(ROOT / "packages")) +sys.path.insert(0, str(ROOT / "apps" / "worker")) + + +def _get_task(mod): + """返回 (run_callable, real_task)。 + + - celery task 环境:run 是 bound method(self 已绑定),retry 用 patch.object 打桩 + - 原始函数环境:用一个 mock_self 作为 self + """ + task_obj = mod.process_duplication_check + real = task_obj._get_current_object() if hasattr(task_obj, "_get_current_object") else task_obj + if hasattr(real, "run") and hasattr(real, "retry"): + return real.run, real, True # bound + return real, None, False + + +def _run(mod, record_id, retries=0): + """执行 task,返回 (result_or_None, raised_exc, mock_self_or_None)。""" + from celery.exceptions import Retry as CeleryRetry + + func, real_task, bound = _get_task(mod) + raised = None + result = None + if bound: + mock_retry = MagicMock(side_effect=CeleryRetry("retry")) + with patch.object(real_task, "retry", mock_retry): + real_task.request.retries = retries + real_task.max_retries = 3 + try: + result = func(record_id) + except CeleryRetry as e: + raised = e + return result, raised, None + mock_self = MagicMock() + mock_self.request.retries = retries + mock_self.max_retries = 3 + mock_self.retry = MagicMock(side_effect=CeleryRetry("retry")) + try: + result = func(mock_self, record_id) + except CeleryRetry as e: + raised = e + return result, raised, mock_self + + +def _make_record(status="pending"): + from packages.domain.duplication import DuplicationRecord + + record = DuplicationRecord.create( + user_id="user-1", + filename="query.mp4", + file_size=1024, + storage_key="duplication/abc/query.mp4", + ) + if status != "pending": + record.status = status + return record + + +def _make_fingerprint(): + from video_processing.dedup import FingerprintChunk, VideoFingerprint + + chunks = [ + FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="0" * 16, color_histogram=[], frame_count=1), + FingerprintChunk( + start_time_ms=2000, end_time_ms=4000, phash_binary="1" * 16, color_histogram=[], frame_count=1 + ), + ] + return VideoFingerprint( + md5="qmd5", + keyframe_phashes=[c.phash_binary for c in chunks], + color_histograms=[], + duration=10000.0, + resolution=(720, 1280), + chunks=chunks, + ) + + +def _patch_common(record, storage=None, dedup=None, session=None): + from worker_app.tasks import duplication_check as mod + + fake_repo = MagicMock() + fake_repo.get.return_value = record + return [ + patch.object(mod, "SessionLocal", return_value=session or MagicMock()), + patch.object(mod, "SQLAlchemyDuplicationRecordRepository", return_value=fake_repo), + patch.object(mod, "get_storage_service", return_value=storage or MagicMock()), + patch.object(mod, "VideoDeduplicator", return_value=dedup or MagicMock()), + ], fake_repo + + +class TestProcessDuplicationCheckSuccess: + def test_success_flow_updates_record(self): + from worker_app.tasks import duplication_check as mod + + record = _make_record() + fake_session = MagicMock() + fake_storage = MagicMock() + fake_dedup = MagicMock() + fake_dedup.compute_fingerprint.return_value = _make_fingerprint() + fake_dedup.compute_duplicate_rate.return_value = { + "duplicate_rate": 42.5, + "visual_similarity": 0.83, + "match_count": 1, + } + patches, fake_repo = _patch_common(record, storage=fake_storage, dedup=fake_dedup, session=fake_session) + patches.append(patch.object(mod, "_build_domain_segments", return_value=(["SEG"], 1))) + for p in patches: + p.start() + try: + result, raised, _ = _run(mod, record.id) + finally: + for p in patches: + p.stop() + + assert raised is None + assert result["ok"] is True + assert result["status"] == "completed" + assert result["duplicate_rate"] == 42.5 + assert result["visual_similarity"] == 0.83 + assert result["match_count"] == 1 + assert result["segments"] == 1 + + assert record.status == "completed" + assert record.duplicate_rate == 42.5 + assert record.visual_similarity == 0.83 + assert record.match_count == 1 + assert record.duplicate_count == 1 + assert record.segments == ["SEG"] + + fake_storage.download_file.assert_called_once() + fake_dedup.compute_fingerprint.assert_called_once() + _, kwargs = fake_dedup.compute_duplicate_rate.call_args + assert kwargs["scope"] == "user" + assert kwargs["user_id"] == "user-1" + assert kwargs["current_video_id"] is None + assert fake_repo.update.call_count >= 2 + fake_session.commit.assert_called() + fake_session.close.assert_called() + + def test_already_completed_is_skipped(self): + from worker_app.tasks import duplication_check as mod + + record = _make_record(status="completed") + patches, fake_repo = _patch_common(record) + for p in patches: + p.start() + try: + result, raised, _ = _run(mod, record.id) + finally: + for p in patches: + p.stop() + assert raised is None + assert result.get("skipped") is True + fake_repo.update.assert_not_called() + + +class TestProcessDuplicationCheckFailure: + def test_record_not_found_raises(self): + from worker_app.tasks import duplication_check as mod + + fake_repo = MagicMock() + fake_repo.get.return_value = None + patches = [ + patch.object(mod, "SessionLocal", return_value=MagicMock()), + patch.object(mod, "SQLAlchemyDuplicationRecordRepository", return_value=fake_repo), + patch.object(mod, "get_storage_service", return_value=MagicMock()), + ] + for p in patches: + p.start() + try: + _result, raised, _ = _run(mod, "nope", retries=0) + finally: + for p in patches: + p.stop() + # 找不到记录触发异常 → retry(第一次) + assert raised is not None + + def test_download_failure_retries_then_marks_failed(self): + from worker_app.tasks import duplication_check as mod + + # 第一次失败(retries=0):保持 pending + record = _make_record() + fake_storage = MagicMock() + fake_storage.download_file.side_effect = RuntimeError("oss network down") + patches, _ = _patch_common(record, storage=fake_storage) + for p in patches: + p.start() + try: + _, raised, _ = _run(mod, record.id, retries=0) + finally: + for p in patches: + p.stop() + assert raised is not None + assert record.status == "processing", "首次失败不应标记 failed(已进入 processing 等待重试)" + + # 最后一次(retries==max_retries=3):标记 failed + record2 = _make_record() + patches2, fake_repo2 = _patch_common(record2, storage=fake_storage) + for p in patches2: + p.start() + try: + _run(mod, record2.id, retries=3) + finally: + for p in patches2: + p.stop() + assert record2.status == "failed" + assert "查重失败" in record2.error_message + fake_repo2.update.assert_called() + + def test_temp_dir_cleaned_after_failure(self): + import os + import tempfile + + from worker_app.tasks import duplication_check as mod + + record = _make_record() + fake_storage = MagicMock() + fake_storage.download_file.side_effect = RuntimeError("boom") + + created_dirs = [] + real_mkdtemp = tempfile.mkdtemp + + def fake_mkdtemp(prefix=None): + d = real_mkdtemp(prefix=prefix) + created_dirs.append(d) + return d + + patches, _ = _patch_common(record, storage=fake_storage) + patches.append(patch.object(mod.tempfile, "mkdtemp", fake_mkdtemp)) + for p in patches: + p.start() + try: + _run(mod, record.id, retries=0) + finally: + for p in patches: + p.stop() + + assert created_dirs, "mkdtemp should have been called" + assert not os.path.isdir(created_dirs[0]), "temp dir should be removed in finally" + + +class TestBuildDomainSegments: + def test_maps_worker_segments_to_domain_with_seconds_and_percent(self): + from video_processing.dedup import DuplicateSegment as WorkerSegment + from worker_app.tasks import duplication_check as mod + + fingerprint = _make_fingerprint() + + from packages.domain import GeneratedVideo + + existing = GeneratedVideo( + id="vid-1", + project_id="proj-1", + generation_task_id="t1", + name="成片A", + file_url="oss://x", + file_size=1, + duration=10.0, + width=720, + height=1280, + fps=30.0, + video_fingerprint={"md5": "x"}, + ) + fake_video_repo = MagicMock() + fake_video_repo.list_by_user.return_value = [existing] + + fake_dedup = MagicMock() + fake_dedup._get_existing_chunks.return_value = [ + {"phash_binary": "0" * 16, "start_time_ms": 0, "end_time_ms": 2000, "color_histogram": []}, + ] + worker_seg = WorkerSegment( + query_start_ms=1000, + query_end_ms=3000, + target_start_ms=5000, + target_end_ms=7000, + avg_distance=6.0, + ) + + with ( + patch.object(mod, "SQLAlchemyGeneratedVideoRepository", return_value=fake_video_repo), + patch.object(mod, "find_duplicate_segments", return_value=[worker_seg]), + ): + segments, dup_count = mod._build_domain_segments(fingerprint, MagicMock(), fake_dedup, "user-1") + + assert dup_count == 1 + assert len(segments) == 1 + seg = segments[0] + assert seg.source_start == 1.0 + assert seg.source_end == 3.0 + assert seg.matched_start == 5.0 + assert seg.matched_end == 7.0 + assert seg.matched_video_id == "vid-1" + assert seg.matched_video_name == "成片A" + assert abs(seg.similarity - 90.6) < 0.2 + + def test_skips_videos_without_chunks(self): + from worker_app.tasks import duplication_check as mod + + fingerprint = _make_fingerprint() + from packages.domain import GeneratedVideo + + existing = GeneratedVideo( + id="vid-2", + project_id="p", + generation_task_id="t", + name="老视频", + file_url="oss://x", + file_size=1, + duration=5.0, + width=720, + height=1280, + fps=30.0, + video_fingerprint={"md5": "old"}, + ) + fake_video_repo = MagicMock() + fake_video_repo.list_by_user.return_value = [existing] + fake_dedup = MagicMock() + fake_dedup._get_existing_chunks.return_value = [] + + with patch.object(mod, "SQLAlchemyGeneratedVideoRepository", return_value=fake_video_repo): + segments, dup_count = mod._build_domain_segments(fingerprint, MagicMock(), fake_dedup, "u") + assert segments == [] + assert dup_count == 0 + + +class TestDuplicationSchemaAndDomainNewFields: + def test_record_response_includes_new_fields(self): + from app.schemas.duplication import DuplicationRecordResponse + + resp = DuplicationRecordResponse( + id="r1", + filename="f.mp4", + file_size=1, + status="completed", + duplicate_rate=10.0, + duplicate_count=1, + visual_similarity=0.5, + match_count=2, + created_at="2026-09-04T00:00:00", + updated_at="2026-09-04T00:00:00", + ) + assert resp.visual_similarity == 0.5 + assert resp.match_count == 2 + + def test_record_response_new_fields_default_none(self): + from app.schemas.duplication import DuplicationRecordResponse + + resp = DuplicationRecordResponse(id="r1", filename="f.mp4", file_size=1, created_at="x", updated_at="y") + assert resp.visual_similarity is None + assert resp.match_count is None + + def test_domain_mark_completed_accepts_new_fields(self): + record = _make_record() + record.mark_completed(33.0, 2, [], visual_similarity=0.77, match_count=3) + assert record.status == "completed" + assert record.visual_similarity == 0.77 + assert record.match_count == 3 + + def test_reset_for_retry_clears_new_fields(self): + record = _make_record() + record.mark_completed(10.0, 1, [], visual_similarity=0.5, match_count=1) + record.status = "failed" + record.reset_for_retry() + assert record.status == "pending" + assert record.visual_similarity is None + assert record.match_count is None -- 2.54.0 From 2a2dfad13730045db0bf826709c1ec1c2ac5a6ff Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 02:06:58 +0800 Subject: [PATCH 19/39] =?UTF-8?q?feat:=20pHash=E9=98=88=E5=80=BC=E6=A0=A1?= =?UTF-8?q?=E5=87=86+=E9=A2=9C=E8=89=B2=E7=9B=B4=E6=96=B9=E5=9B=BE?= =?UTF-8?q?=E8=9E=8D=E5=90=88=20#1658=20(#1674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/worker/video_processing/dedup.py | 73 ++--- .../test_phash_threshold_calibration_1658.py | 273 ++++++++++++++++++ 2 files changed, 313 insertions(+), 33 deletions(-) create mode 100644 tests/unit/test_phash_threshold_calibration_1658.py diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index 54e02e258..129e35ef9 100755 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -5,6 +5,7 @@ Dynamic keyframe detection + sliding window temporal matching (Issue #1659). import hashlib import logging +import math import os import statistics import tempfile @@ -422,7 +423,7 @@ def find_duplicate_segments( class VideoDeduplicator: """Video deduplication using multiple fingerprint methods.""" - PHASH_THRESHOLD = 10 + PHASH_THRESHOLD = 8 # Issue #1658: pHash 汉明距离阈值由 10 收紧到 8,降低不同视频误判率 HISTOGRAM_THRESHOLD = 0.85 def compute_fingerprint(self, video_path: str) -> VideoFingerprint: @@ -531,7 +532,8 @@ class VideoDeduplicator: min_len = min(len(hist_a), len(hist_b)) a = hist_a[:min_len] b = hist_b[:min_len] - return float(sum(np.sqrt(ai * bi) for ai, bi in zip(a, b, strict=False))) + # 纯标准库计算(不依赖 numpy);max(0.0, ...) 防御上游异常负值导致 sqrt domain error + return float(sum(math.sqrt(max(0.0, ai * bi)) for ai, bi in zip(a, b, strict=False))) @staticmethod def _compute_histogram_similarity( @@ -550,6 +552,29 @@ class VideoDeduplicator: similarities.append(best) return sum(similarities) / len(similarities) if similarities else 0.0 + @staticmethod + def _compute_fusion_score( + median_distance: float, + histograms_a: list[list[float]], + histograms_b: list[list[float]], + ) -> float: + """pHash 相似度与颜色直方图相似度的加权融合得分(Issue #1658)。 + + - phash_similarity = 1.0 - median_distance / 64(64 为 64bit pHash 最大汉明距离) + - hist_similarity = Bhattacharyya 系数均值;无直方图数据时回退中性值 0.5 + - 融合得分 = PHASH_WEIGHT * phash_similarity + HISTOGRAM_WEIGHT * hist_similarity + + 返回 0~1 的原始得分,是否判重由调用方与 DUPLICATE_THRESHOLD 比较决定。 + """ + # DB 中 color_histograms 可能为 NULL(None),显式回退空列表而非 `or []`, + # 以保留全黑视频的全零直方图([0,0,...] 为有效数据,空列表才走 0.5 中性回退)。 + hist_a = histograms_a if histograms_a is not None else [] + hist_b = histograms_b if histograms_b is not None else [] + + phash_similarity = 1.0 - (median_distance / 64) + hist_similarity = VideoDeduplicator._compute_histogram_similarity(hist_a, hist_b) if hist_b else 0.5 + return PHASH_WEIGHT * phash_similarity + HISTOGRAM_WEIGHT * hist_similarity + def check_duplicate( self, fingerprint: VideoFingerprint, @@ -619,7 +644,7 @@ class VideoDeduplicator: # 帧匹配比例检查 matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) match_ratio = matching_frames / len(min_distances) if min_distances else 0 - if match_ratio < 0.7: + if match_ratio < MATCH_RATIO_THRESHOLD: continue # 中位数距离 @@ -627,22 +652,16 @@ class VideoDeduplicator: if median_distance >= self.PHASH_THRESHOLD: continue - # 直方图融合 - existing_histograms = [] + # 直方图融合(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表) if chunk_data: existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] else: - existing_histograms = ef.get("color_histograms", []) + existing_histograms = ef.get("color_histograms") or [] - phash_similarity = 1.0 - (median_distance / 64) - hist_similarity = ( - self._compute_histogram_similarity(fingerprint.color_histograms, existing_histograms) - if existing_histograms - else 0.5 + combined_score = self._compute_fusion_score( + median_distance, fingerprint.color_histograms, existing_histograms ) - combined_score = 0.7 * phash_similarity + 0.3 * hist_similarity - # DUPLICATE_THRESHOLD from module level if combined_score < DUPLICATE_THRESHOLD: continue @@ -737,29 +756,23 @@ class VideoDeduplicator: # 帧匹配比例检查 matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) match_ratio = matching_frames / len(min_distances) if min_distances else 0 - if match_ratio < 0.7: + if match_ratio < MATCH_RATIO_THRESHOLD: continue median_distance = statistics.median(min_distances) if min_distances else 64 if median_distance >= self.PHASH_THRESHOLD: continue - # 直方图融合 - existing_histograms = [] + # 直方图融合(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表) if chunk_data: existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] else: - existing_histograms = ef.get("color_histograms", []) + existing_histograms = ef.get("color_histograms") or [] - phash_similarity = 1.0 - (median_distance / 64) - hist_similarity = ( - self._compute_histogram_similarity(fingerprint.color_histograms, existing_histograms) - if existing_histograms - else 0.5 + combined_score = self._compute_fusion_score( + median_distance, fingerprint.color_histograms, existing_histograms ) - combined_score = 0.7 * phash_similarity + 0.3 * hist_similarity - # DUPLICATE_THRESHOLD from module level if combined_score < DUPLICATE_THRESHOLD: continue @@ -901,19 +914,13 @@ class VideoDeduplicator: # visual_similarity (融合相似度,归一化 0~1) median_distance = statistics.median(min_distances) if min_distances else 64 - existing_histograms = [] if chunk_data: existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] else: - existing_histograms = ef.get("color_histograms", []) + # JSON NULL 显式回退空列表 + existing_histograms = ef.get("color_histograms") or [] - phash_sim = 1.0 - median_distance / 64 - hist_sim = ( - self._compute_histogram_similarity(fingerprint.color_histograms, existing_histograms) - if existing_histograms - else 0.5 - ) - visual_sim = 0.7 * phash_sim + 0.3 * hist_sim + visual_sim = self._compute_fusion_score(median_distance, fingerprint.color_histograms, existing_histograms) # 判定是否为重复(融合分数超过阈值) if visual_sim >= DUPLICATE_THRESHOLD: diff --git a/tests/unit/test_phash_threshold_calibration_1658.py b/tests/unit/test_phash_threshold_calibration_1658.py new file mode 100644 index 000000000..6e01b4a6a --- /dev/null +++ b/tests/unit/test_phash_threshold_calibration_1658.py @@ -0,0 +1,273 @@ +"""Issue #1658: pHash 阈值校准 + 颜色直方图融合 — 单元测试. + +在 #1659(动态抽帧+滑动窗口)与 #1660(查重率)已合入 develop 的基础上, +本测试覆盖 #1658 的最小增量改动: + +1. PHASH_THRESHOLD 由 10 收紧到 8(核心校准) +2. 融合权重常量 MATCH_RATIO_THRESHOLD / PHASH_WEIGHT / HISTOGRAM_WEIGHT 实际生效 + (不再是硬编码魔法数字) +3. VideoDeduplicator._compute_fusion_score 统一融合得分方法: + - 无直方图数据时回退中性值 0.5 + - DB NULL(None)显式回退空列表,不崩溃 + - 全零直方图(全黑视频)为有效数据,参与 Bhattacharyya 计算 + - 返回 0~1 原始得分,判重由调用方与 DUPLICATE_THRESHOLD 比较 +4. Bhattacharyya 系数对上游异常负值有 sqrt domain 防御 +""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock + +import pytest + + +def _mock_module(**attrs): + """Create a mock module with __spec__ to avoid AttributeError.""" + m = MagicMock() + m.__spec__ = None + for k, v in attrs.items(): + setattr(m, k, v) + return m + + +# ── Module-level setup: mock deps, import dedup, then restore sys.modules ── +_SAVED_MODULES_KEYS = set(sys.modules.keys()) +_SAVED_MODULES_VALUES = { + k: sys.modules.get(k) + for k in [ + "cv2", + "celery", + "sqlalchemy", + "sqlalchemy.orm", + "sqlalchemy.engine", + "sqlalchemy.ext", + "sqlalchemy.ext.declarative", + "worker_app.db", + "worker_app.celery_app", + "worker_app.core.config", + "packages.adapters.sqlalchemy_impl.session", + "packages.adapters.sqlalchemy_impl.generated_video_repository", + "packages.adapters.sqlalchemy_impl.models", + "packages.shared.config", + "packages.shared.storage", + ] +} + +sys.modules["cv2"] = _mock_module() + +_mock_celery = MagicMock() +_mock_celery.Task = MagicMock +_mock_celery.Celery = MagicMock +_mock_celery.__spec__ = None +sys.modules["celery"] = _mock_celery + +_mock_sqla = MagicMock() +_mock_sqla.__path__ = [] +_mock_sqla.__spec__ = None +sys.modules["sqlalchemy"] = _mock_sqla + +_mock_sqla_orm = MagicMock() +_mock_sqla_orm.__path__ = [] +_mock_sqla_orm.__spec__ = None +_mock_sqla_orm.Session = MagicMock +sys.modules["sqlalchemy.orm"] = _mock_sqla_orm +sys.modules["sqlalchemy.engine"] = _mock_module() +sys.modules["sqlalchemy.ext"] = _mock_module() +sys.modules["sqlalchemy.ext.declarative"] = _mock_module() + +sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock()) +sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock()) +sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock())) + +sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module( + Base=MagicMock(), + build_engine=MagicMock(), + build_session_factory=MagicMock(), + ensure_database_exists=MagicMock(), + initialize_database=MagicMock(), +) +sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module( + SQLAlchemyGeneratedVideoRepository=MagicMock +) +sys.modules["packages.adapters.sqlalchemy_impl.models"] = _mock_module( + VideoFingerprintChunkModel=MagicMock, + GeneratedVideoModel=MagicMock, +) +sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock())) +sys.modules["packages.shared.storage"] = _mock_module() + +import video_processing.dedup as _dedup_mod # noqa: E402 +from video_processing.dedup import ( # noqa: E402 + DUPLICATE_THRESHOLD, + HISTOGRAM_WEIGHT, + MATCH_RATIO_THRESHOLD, + PHASH_WEIGHT, + VideoDeduplicator, +) + +# ── Restore sys.modules immediately after import ── +for _key in list(sys.modules.keys()): + if _key not in _SAVED_MODULES_KEYS: + del sys.modules[_key] +for _key, _value in _SAVED_MODULES_VALUES.items(): + if _value is not None: + sys.modules[_key] = _value + elif _key in sys.modules: + del sys.modules[_key] +del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value + + +# ── 测试夹具 ───────────────────────────────────────────────────── + +_UNIFORM_HIST = [1.0 / 96] * 96 # 归一化均匀直方图,sum=1.0,自相似度≈1.0 +_ZERO_HIST = [0.0] * 96 # 全黑视频的全零直方图(有效数据) + + +# ── TestThresholdCalibration:#1658 核心校准 ──────────────────── + + +class TestThresholdCalibration: + """pHash 阈值由 10 收紧到 8(Issue #1658)。""" + + def test_phash_threshold_is_8(self): + """PHASH_THRESHOLD 必须为 8(旧值 10 会放过 8~9 汉明距离的不同视频)。""" + assert VideoDeduplicator.PHASH_THRESHOLD == 8 + + def test_match_ratio_threshold_constant(self): + assert MATCH_RATIO_THRESHOLD == 0.7 + + def test_duplicate_threshold_constant(self): + assert DUPLICATE_THRESHOLD == 0.70 + + def test_fusion_weights(self): + assert PHASH_WEIGHT == 0.7 + assert HISTOGRAM_WEIGHT == 0.3 + + def test_threshold_tightening_excludes_distance_8_and_9(self): + """距离 8、9 的帧:旧阈值 10 下算匹配,新阈值 8 下不算匹配。 + + 场景:5 个关键帧距离为 [7, 7, 7, 9, 9]。 + - 旧阈值 10:5 帧全部 < 10 → match_ratio = 1.0(误放过) + - 新阈值 8:仅 3 帧 < 8 → match_ratio = 0.6 < 0.7(正确跳过) + """ + distances = [7, 7, 7, 9, 9] + + matched_old = sum(1 for d in distances if d < 10) + assert matched_old == 5 # 旧行为:全匹配 → 误判风险 + + matched_new = sum(1 for d in distances if d < VideoDeduplicator.PHASH_THRESHOLD) + assert matched_new == 3 + assert matched_new / len(distances) == 0.6 + assert matched_new / len(distances) < MATCH_RATIO_THRESHOLD # 被帧比例门槛拦截 + + +# ── TestComputeFusionScore:统一融合得分方法 ──────────────────── + + +class TestComputeFusionScore: + """_compute_fusion_score(median_distance, histograms_a, histograms_b)。""" + + def test_no_histogram_falls_back_to_neutral_05(self): + """双方均无直方图 → hist_similarity 回退 0.5。 + + d=0: 0.7*1.0 + 0.3*0.5 = 0.85 + """ + score = VideoDeduplicator._compute_fusion_score(0, [], []) + assert score == pytest.approx(0.85, abs=1e-6) + + def test_none_histograms_treated_as_empty(self): + """DB NULL(None)必须显式回退空列表,不得 len(None) 崩溃。""" + score_none = VideoDeduplicator._compute_fusion_score(0, [], None) + score_empty = VideoDeduplicator._compute_fusion_score(0, [], []) + assert score_none == pytest.approx(score_empty, abs=1e-9) + assert score_none == pytest.approx(0.85, abs=1e-6) + + def test_none_histograms_on_query_side_no_crash(self): + """查询侧直方图为 None 时同样不崩溃。""" + score = VideoDeduplicator._compute_fusion_score(0, None, [_UNIFORM_HIST]) + # 查询侧无直方图 → 平均相似度为 0(无 ha 可匹配)→ 0.7*1.0 + 0.3*0 = 0.7 + assert score == pytest.approx(0.7, abs=1e-6) + + def test_identical_uniform_histograms_score_near_1(self): + """完全相同的归一化直方图:Bhattacharyya≈1.0 → 融合分≈1.0。""" + score = VideoDeduplicator._compute_fusion_score(0, [_UNIFORM_HIST], [_UNIFORM_HIST]) + assert score == pytest.approx(1.0, abs=1e-6) + + def test_all_zero_histogram_is_valid_data(self): + """全零直方图(全黑视频)是有效数据,Bhattacharyya=0,不得走 0.5 回退。 + + 若错误地用 `if histograms_b` 之外的 `or []` 把全零列表清空, + 会错误回退到 0.5,把全黑视频的相似度抬高 0.15。 + d=0 时:正确行为 hist_sim=0 → 0.7*1.0 + 0.3*0 = 0.7; + 若全零直方图被错误清空回退 0.5 → 0.85。 + """ + score = VideoDeduplicator._compute_fusion_score(0, [_ZERO_HIST], [_ZERO_HIST]) + assert score == pytest.approx(0.7, abs=1e-6) + # 与错误回退值 0.85 明确区分开 + assert abs(score - 0.85) > 0.1 + # 注:d=0 时 phash 满分 0.7 恰达 DUPLICATE_THRESHOLD,全黑+完全相同 phash 仍判重,符合预期 + assert score >= DUPLICATE_THRESHOLD - 1e-9 + + def test_score_range_within_0_1(self): + for d in (0, 8, 16, 32, 64): + score = VideoDeduplicator._compute_fusion_score(d, [_UNIFORM_HIST], [_UNIFORM_HIST]) + assert 0.0 <= score <= 1.0 + + def test_formula_matches_weights(self): + """得分 = PHASH_WEIGHT * (1 - d/64) + HISTOGRAM_WEIGHT * hist_sim。""" + d = 6 # phash_sim = 1 - 6/64 = 0.90625 + score = VideoDeduplicator._compute_fusion_score(d, [], []) # hist 回退 0.5 + expected = PHASH_WEIGHT * (1 - d / 64) + HISTOGRAM_WEIGHT * 0.5 + assert score == pytest.approx(expected, abs=1e-9) + # 0.7*0.90625 + 0.15 = 0.634375 + 0.15 = 0.784375 + assert score == pytest.approx(0.784375, abs=1e-6) + + +# ── TestBhattacharyyaDefense:负值/异常输入防御 ───────────────── + + +class TestBhattacharyyaDefense: + """Bhattacharyya 系数对异常输入的防御。""" + + def test_negative_values_do_not_raise(self): + """上游异常负值不得触发 sqrt domain error(max(0.0, ai*bi) 保护)。""" + bad_hist = [-0.01] * 96 # 异常负值 + coeff = VideoDeduplicator._bhattacharyya_coefficient(bad_hist, _UNIFORM_HIST) + # 负值乘积被钳为 0,系数为 0 而不是抛 ValueError + assert coeff == pytest.approx(0.0, abs=1e-9) + + def test_normal_histograms_coefficient_near_1(self): + coeff = VideoDeduplicator._bhattacharyya_coefficient(_UNIFORM_HIST, _UNIFORM_HIST) + assert coeff == pytest.approx(1.0, abs=1e-6) + + def test_disjoint_histograms_coefficient_0(self): + """完全不重叠的直方图(前半 vs 后半非零)系数为 0。""" + hist_a = [0.0] * 96 + hist_b = [0.0] * 96 + for i in range(48): + hist_a[i] = 1.0 / 48 + for i in range(48, 96): + hist_b[i] = 1.0 / 48 + coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b) + assert coeff == pytest.approx(0.0, abs=1e-9) + + +# ── TestHistogramSimilarityEdgeCases ──────────────────────────── + + +class TestHistogramSimilarityEdgeCases: + """_compute_histogram_similarity 的边界行为。""" + + def test_empty_either_side_returns_0(self): + assert VideoDeduplicator._compute_histogram_similarity([], [_UNIFORM_HIST]) == 0.0 + assert VideoDeduplicator._compute_histogram_similarity([_UNIFORM_HIST], []) == 0.0 + + def test_best_match_per_histogram(self): + """每个查询直方图取与目标集合的最佳匹配,再取平均。""" + h1 = _UNIFORM_HIST + h2 = [0.0] * 96 + h2[0] = 1.0 # 与均匀直方图完全不重叠 + # 查询侧两张直方图:h1 最佳匹配≈1.0,h2 最佳匹配≈sqrt(1/96)≈0.102 + sim = VideoDeduplicator._compute_histogram_similarity([h1, h2], [h1]) + assert sim == pytest.approx((1.0 + (1.0 / 96) ** 0.5) / 2, abs=1e-3) -- 2.54.0 From 0542654ca8acae16845d3b5e509724fd2d87bc44 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 11:38:11 +0800 Subject: [PATCH 20/39] =?UTF-8?q?fix:=20=E6=9F=A5=E9=87=8D=20worker=20?= =?UTF-8?q?=E6=97=A0=E9=99=90=E9=87=8D=E8=AF=95=20bug=20+=20=E8=A1=A5=203?= =?UTF-8?q?=20=E4=B8=AA=20API/repository=20=E5=8D=95=E6=B5=8B=20(#1661=20f?= =?UTF-8?q?ollow-up)=20(#1680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- .../worker_app/tasks/duplication_check.py | 18 +- tests/unit/test_duplication_api_enqueue.py | 168 ++++++++++++++++++ 2 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_duplication_api_enqueue.py diff --git a/apps/worker/worker_app/tasks/duplication_check.py b/apps/worker/worker_app/tasks/duplication_check.py index dcfa49c17..3746f7108 100644 --- a/apps/worker/worker_app/tasks/duplication_check.py +++ b/apps/worker/worker_app/tasks/duplication_check.py @@ -175,19 +175,21 @@ def process_duplication_check(self: Task, record_id: str) -> dict: logger.error("Duplication check failed for record %s: %s", record_id, e, exc_info=True) if session is not None: session.rollback() - # 本次是最后一次执行机会(retries 从 0 计数,达到 max_retries 说明重试已耗尽), - # 标记 failed;否则保持 pending 由 Celery 60 秒后重试 - try: - if "repo" in locals() and self.request.retries >= self.max_retries: + # 超过重试上限:标记 failed 并返回失败结果,不再 retry + if "repo" in locals() and self.request.retries >= self.max_retries: + try: failed_record = repo.get(record_id) if failed_record is not None and failed_record.status != "failed": failed_record.mark_failed(f"查重失败(已重试{self.max_retries}次): {e}") repo.update(failed_record) session.commit() - except Exception as inner: - logger.error("Failed to mark duplication record %s as failed: %s", record_id, inner) - session.rollback() - raise self.retry(exc=e, countdown=60) from e + except Exception as inner: + logger.error("Failed to mark duplication record %s as failed: %s", record_id, inner) + session.rollback() + return {"ok": False, "record_id": record_id, "status": "failed", "error": str(e)} + # 未达上限:60 秒后重试 + raise self.retry(exc=e, countdown=60) from e + return {"ok": False, "record_id": record_id, "status": "failed", "error": str(e)} finally: if session is not None: diff --git a/tests/unit/test_duplication_api_enqueue.py b/tests/unit/test_duplication_api_enqueue.py new file mode 100644 index 000000000..9d58798a6 --- /dev/null +++ b/tests/unit/test_duplication_api_enqueue.py @@ -0,0 +1,168 @@ +"""#1661 查重 API enqueue 及仓储 commit 覆盖测试。 + +覆盖: +- upload 接口在成功后调用 celery_app.send_task +- retry 接口在成功后调用 celery_app.send_task +- duplication_repository.update() 正确调用 session.commit() +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +ROOT = os.path.join(os.path.dirname(__file__), "..", "..") +sys.path.insert(0, os.path.join(ROOT, "apps", "api")) +sys.path.insert(0, os.path.join(ROOT, "packages")) + +from app.api.routes.duplication import router +from app.auth import AuthenticatedUser, get_current_user +from app.core.storage import get_storage_service +from app.dependencies import get_duplication_repository +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from packages.domain.duplication import DuplicationRecord +from packages.domain.entities import User + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_test_user(): + return User(id="user-1", username="testuser", email="test@example.com", display_name="Test User") + + +def _make_auth_user(): + return AuthenticatedUser(user=_make_test_user(), session_id="test-session", token_type="bearer") + + +def _make_record(status="pending"): + record = DuplicationRecord.create( + user_id="user-1", + filename="test.mp4", + file_size=1024, + storage_key="duplication/abc/test.mp4", + ) + if status != "pending": + record.status = status + return record + + +def _build_client(auth_user, repo, storage=None): + """构建带 dependency_overrides 的 TestClient。""" + app = FastAPI() + app.include_router(router, prefix="/duplication") + app.dependency_overrides[get_current_user] = lambda: auth_user + app.dependency_overrides[get_duplication_repository] = lambda: repo + if storage is not None: + app.dependency_overrides[get_storage_service] = lambda: storage + return TestClient(app) + + +# --------------------------------------------------------------------------- +# 1. Upload endpoint enqueues celery task +# --------------------------------------------------------------------------- + + +def test_upload_enqueue_calls_celery_task(): + """POST /duplication/upload 成功创建记录后必须调用 send_task。""" + record = _make_record() + + fake_repo = MagicMock() + fake_repo.create.return_value = record + + fake_storage = MagicMock() + fake_auth = _make_auth_user() + + client = _build_client(fake_auth, fake_repo, fake_storage) + + with patch("app.api.routes.duplication.celery_app") as mock_celery: + response = client.post( + "/duplication/upload", + files={"file": ("test.mp4", b"fake-video-content", "video/mp4")}, + ) + + assert response.status_code == 200, response.text + mock_celery.send_task.assert_called_once_with( + "worker.process_duplication_check", + args=[record.id], + ) + + +# --------------------------------------------------------------------------- +# 2. Retry endpoint enqueues celery task +# --------------------------------------------------------------------------- + + +def test_retry_enqueue_calls_celery_task(): + """POST /duplication/records/{id}/retry 成功后必须调用 send_task。""" + record = _make_record(status="failed") + + fake_repo = MagicMock() + fake_repo.get.return_value = record + + # RetryDuplicationUseCase.execute 内部调用 repo.get → record.reset_for_retry → repo.update + updated = _make_record() + updated.id = record.id + updated.status = "pending" + fake_repo.update.return_value = updated + + fake_auth = _make_auth_user() + + client = _build_client(fake_auth, fake_repo) + + with patch("app.api.routes.duplication.celery_app") as mock_celery: + response = client.post(f"/duplication/records/{record.id}/retry") + + assert response.status_code == 200, response.text + mock_celery.send_task.assert_called_once_with( + "worker.process_duplication_check", + args=[record.id], + ) + + +# --------------------------------------------------------------------------- +# 3. Repository update calls session.commit() +# --------------------------------------------------------------------------- + + +def test_repository_update_calls_session_commit(): + """duplication_repository 的 update 方法必须调用 session.commit()。""" + from packages.adapters.sqlalchemy_impl.duplication_repository import ( + SQLAlchemyDuplicationRecordRepository, + ) + from packages.adapters.sqlalchemy_impl.models import DuplicationRecordModel + + mock_session = MagicMock() + mock_model = MagicMock(spec=DuplicationRecordModel) + mock_model.id = "rec-1" + + mock_session.query.return_value.filter.return_value.first.return_value = mock_model + + repo = SQLAlchemyDuplicationRecordRepository(mock_session) + + record = DuplicationRecord.create( + user_id="user-1", + filename="test.mp4", + file_size=1024, + storage_key="duplication/abc/test.mp4", + ) + record.status = "completed" + record.duplicate_rate = 42.0 + record.duplicate_count = 1 + record.visual_similarity = 0.85 + record.match_count = 2 + + result = repo.update(record) + + mock_session.commit.assert_called() + assert result.visual_similarity == 0.85 + assert result.match_count == 2 -- 2.54.0 From f523548eee87144939ffc3e2c492a5f19dd36de3 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 11:56:18 +0800 Subject: [PATCH 21/39] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=90=8E=E9=9A=8F=E6=9C=BA=E8=BE=B9=E7=BC=98=E8=A3=81?= =?UTF-8?q?=E5=89=AA=202-5%=20=E9=99=8D=E9=87=8D=20#1664=20(#1682)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/worker/video_processing/ffmpeg_utils.py | 124 +++++++++++ apps/worker/worker_app/tasks/generation.py | 22 ++ tests/unit/test_random_edge_crop.py | 207 +++++++++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 tests/unit/test_random_edge_crop.py diff --git a/apps/worker/video_processing/ffmpeg_utils.py b/apps/worker/video_processing/ffmpeg_utils.py index 9436c489f..6a94e7eee 100755 --- a/apps/worker/video_processing/ffmpeg_utils.py +++ b/apps/worker/video_processing/ffmpeg_utils.py @@ -304,3 +304,127 @@ def normalize_video( ] run_ffmpeg(command) return {"width": width, "height": height, "path": output_path} + + +def random_edge_crop( + input_path: str | Path, + output_path: str | Path | None = None, + *, + min_crop_pct: float = 0.02, + max_crop_pct: float = 0.05, +) -> Path: + """对视频四边做随机裁剪再缩放回原分辨率,用于改变 pHash 指纹。 + + Args: + input_path: 输入视频路径 + output_path: 输出路径;为 None 时写入 input_path 同目录的临时文件, + 成功后覆盖原文件 + min_crop_pct: 每边最小裁剪比例(默认 2%) + max_crop_pct: 每边最大裁剪比例(默认 5%) + + Returns: + 输出文件路径(Path 对象) + + Raises: + subprocess.CalledProcessError: ffmpeg 执行失败时抛出 + """ + import random + import shutil + import tempfile + + input_path = Path(input_path) + + # 获取原始分辨率 + info = probe_video_info(str(input_path)) + W = info["width"] + H = info["height"] + + if W <= 0 or H <= 0: + logger.warning("无法获取视频分辨率 (W=%d H=%d),跳过裁剪: %s", W, H, input_path) + return input_path + + # 四边各自随机裁剪 2%~5% + crop_top = int(H * random.uniform(min_crop_pct, max_crop_pct)) + crop_bottom = int(H * random.uniform(min_crop_pct, max_crop_pct)) + crop_left = int(W * random.uniform(min_crop_pct, max_crop_pct)) + crop_right = int(W * random.uniform(min_crop_pct, max_crop_pct)) + + # 裁剪后尺寸(确保至少 2 像素) + new_w = max(W - crop_left - crop_right, 2) + new_h = max(H - crop_top - crop_bottom, 2) + x_offset = crop_left + y_offset = crop_top + + # 确保裁剪尺寸为偶数(ffmpeg 编码器常要求偶数尺寸) + new_w = new_w if new_w % 2 == 0 else new_w - 1 + new_h = new_h if new_h % 2 == 0 else new_h - 1 + if new_w < 2: + new_w = 2 + if new_h < 2: + new_h = 2 + + # 输出分辨率必须与原始一致 + out_w = W if W % 2 == 0 else W + 1 + out_h = H if H % 2 == 0 else H + 1 + + vf = f"crop={new_w}:{new_h}:{x_offset}:{y_offset},scale={out_w}:{out_h}" + + logger.info( + "随机边缘裁剪: %s → crop(%d,%d,%d,%d)=%dx%d scale→%dx%d", + input_path.name, + crop_top, + crop_bottom, + crop_left, + crop_right, + new_w, + new_h, + out_w, + out_h, + ) + + # 确定输出路径 + if output_path is None: + temp_fd, temp_path = tempfile.mkstemp(suffix=".mp4", dir=input_path.parent) + import os + + os.close(temp_fd) + temp_output = Path(temp_path) + replace_original = True + else: + temp_output = Path(output_path) + replace_original = False + + command = [ + FFMPEG_BIN, + "-y", + "-i", + str(input_path), + "-vf", + vf, + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "18", + "-c:a", + "copy", + "-movflags", + "+faststart", + str(temp_output), + ] + + try: + run_ffmpeg(command) + except Exception: + # 裁剪失败时清理临时文件 + if temp_output.exists() and replace_original: + temp_output.unlink(missing_ok=True) + raise + + # 成功 → 覆盖原文件 + if replace_original: + shutil.move(str(temp_output), str(input_path)) + return input_path + + return temp_output diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 7915bd075..35e195bef 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -716,6 +716,28 @@ def generate_video(self, task_id: str) -> dict: _update_task_progress(task_id, 80, "渲染完成") + # ── 3.5 随机边缘裁剪降重(#1664) ────────────────────────── + from video_processing.ffmpeg_utils import random_edge_crop + + try: + cropped_path = random_edge_crop(output_path) + if cropped_path != output_path: + output_path = cropped_path + if gen_task: + gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重") + _flush_logs(task_id, gen_task) + logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path) + except Exception as crop_err: + logger.warning( + "[task_id=%s] 随机边缘裁剪失败,使用原始视频继续: %s", + task_id, + crop_err, + exc_info=True, + ) + if gen_task: + gen_task.append_log("边缘裁剪", f"裁剪失败,使用原始视频: {crop_err}") + _flush_logs(task_id, gen_task) + # ── 4. 上传 OSS + 查重记录 ─────────────────────────────── _update_task_progress(task_id, 85, "开始上传") file_url, duration, file_size, video_count = _upload_and_record( diff --git a/tests/unit/test_random_edge_crop.py b/tests/unit/test_random_edge_crop.py new file mode 100644 index 000000000..558d66bda --- /dev/null +++ b/tests/unit/test_random_edge_crop.py @@ -0,0 +1,207 @@ +"""#1664 随机边缘裁剪降重功能测试""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "apps" / "worker")) +sys.path.insert(0, str(ROOT / "apps" / "api")) +sys.path.insert(0, str(ROOT / "packages")) + + +from video_processing.ffmpeg_utils import random_edge_crop + + +class TestRandomEdgeCropBasic: + """基本功能测试""" + + def test_returns_input_path_when_output_none(self, tmp_path): + """output_path=None 时覆盖原文件并返回 input_path""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + + fake_info = {"width": 1920, "height": 1080, "duration": 10, "fps": 30} + with ( + patch("video_processing.ffmpeg_utils.probe_video_info", return_value=fake_info), + patch("video_processing.ffmpeg_utils.run_ffmpeg"), + ): + result = random_edge_crop(input_file) + + assert result == input_file + + def test_returns_output_path_when_specified(self, tmp_path): + """指定 output_path 时返回该路径""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + output_file = tmp_path / "output.mp4" + + fake_info = {"width": 1920, "height": 1080, "duration": 10, "fps": 30} + with ( + patch("video_processing.ffmpeg_utils.probe_video_info", return_value=fake_info), + patch("video_processing.ffmpeg_utils.run_ffmpeg"), + ): + result = random_edge_crop(input_file, output_file) + + assert result == output_file + + def test_skip_when_invalid_resolution(self, tmp_path): + """无法获取有效分辨率时跳过裁剪""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + + fake_info = {"width": 0, "height": 0, "duration": 10, "fps": 30} + with ( + patch("video_processing.ffmpeg_utils.probe_video_info", return_value=fake_info), + patch("video_processing.ffmpeg_utils.run_ffmpeg") as mock_ffmpeg, + ): + result = random_edge_crop(input_file) + + assert result == input_file + mock_ffmpeg.assert_not_called() + + +class TestRandomEdgeCropFFmpeg: + """FFmpeg 调用参数验证""" + + def test_ffmpeg_crop_and_scale_filter(self, tmp_path): + """生成的 ffmpeg 滤镜包含 crop + scale""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + + # 固定随机值以便验证 + fake_info = {"width": 1000, "height": 1000, "duration": 10, "fps": 30} + + with ( + patch("video_processing.ffmpeg_utils.probe_video_info", return_value=fake_info), + patch("video_processing.ffmpeg_utils.run_ffmpeg") as mock_ffmpeg, + patch("random.uniform", side_effect=[0.03, 0.03, 0.03, 0.03]), + ): + random_edge_crop(input_file) + + mock_ffmpeg.assert_called_once() + cmd = mock_ffmpeg.call_args[0][0] + # 找到 -vf 参数 + vf_idx = cmd.index("-vf") + vf_value = cmd[vf_idx + 1] + assert "crop=" in vf_value + assert "scale=1000:1000" in vf_value + + def test_crop_amounts_within_range(self, tmp_path): + """裁剪量在 2%~5% 范围内""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + + fake_info = {"width": 1000, "height": 1000, "duration": 10, "fps": 30} + + with ( + patch("video_processing.ffmpeg_utils.probe_video_info", return_value=fake_info), + patch("video_processing.ffmpeg_utils.run_ffmpeg") as mock_ffmpeg, + patch("random.uniform", side_effect=[0.02, 0.05, 0.02, 0.05]), + ): + random_edge_crop(input_file) + + cmd = mock_ffmpeg.call_args[0][0] + vf_idx = cmd.index("-vf") + vf_value = cmd[vf_idx + 1] + # crop_top=20, crop_bottom=50, crop_left=20, crop_right=50 + # new_w = 1000-20-50 = 930, new_h = 1000-20-50 = 930 + # x_offset = 20, y_offset = 20 + assert "crop=930:930:20:20" in vf_value + + def test_uses_libx264_codec(self, tmp_path): + """使用 libx264 编码""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + + fake_info = {"width": 1920, "height": 1080, "duration": 10, "fps": 30} + with ( + patch("video_processing.ffmpeg_utils.probe_video_info", return_value=fake_info), + patch("video_processing.ffmpeg_utils.run_ffmpeg") as mock_ffmpeg, + ): + random_edge_crop(input_file) + + cmd = mock_ffmpeg.call_args[0][0] + assert "-c:v" in cmd + assert cmd[cmd.index("-c:v") + 1] == "libx264" + + +class TestRandomEdgeCropErrorHandling: + """错误处理测试""" + + def test_ffmpeg_failure_raises_exception(self, tmp_path): + """ffmpeg 失败时抛出异常""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + + fake_info = {"width": 1920, "height": 1080, "duration": 10, "fps": 30} + with ( + patch("video_processing.ffmpeg_utils.probe_video_info", return_value=fake_info), + patch( + "video_processing.ffmpeg_utils.run_ffmpeg", + side_effect=subprocess.CalledProcessError(1, "ffmpeg"), + ), + ): + with pytest.raises(subprocess.CalledProcessError): + random_edge_crop(input_file) + + def test_probe_failure_propagates(self, tmp_path): + """probe_video_info 失败时异常传播""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + + with patch( + "video_processing.ffmpeg_utils.probe_video_info", + side_effect=RuntimeError("probe failed"), + ): + with pytest.raises(RuntimeError, match="probe failed"): + random_edge_crop(input_file) + + +class TestRandomEdgeCropEvenDimensions: + """偶数尺寸处理测试""" + + def test_odd_crop_dimensions_adjusted_to_even(self, tmp_path): + """裁剪后尺寸为奇数时自动调整为偶数""" + input_file = tmp_path / "input.mp4" + input_file.write_bytes(b"fake video data") + + # 1000 - 3 (top) - 4 (bottom) = 993 → 调整为 992 + # 1000 - 3 (left) - 4 (right) = 993 → 调整为 992 + fake_info = {"width": 1000, "height": 1000, "duration": 10, "fps": 30} + + with ( + patch("video_processing.ffmpeg_utils.probe_video_info", return_value=fake_info), + patch("video_processing.ffmpeg_utils.run_ffmpeg") as mock_ffmpeg, + # side_effect 控制 uniform 返回值 + # top: 0.003*1000=3, bottom: 0.004*1000=4, left: 0.003*1000=3, right: 0.004*1000=4 + ): + # 使用自定义 uniform 返回特定值 + def fake_uniform(low, high): + # 返回特定百分比使得裁剪后尺寸为奇数 + # 我们需要 crop_top=3, crop_bottom=4, crop_left=3, crop_right=4 + return 0.0035 # 近似值 + + # 更简单的方式:直接 mock int(H * random.uniform(...)) 的结果 + # 但我们直接测试最终 crop 滤镜即可 + with patch("random.uniform", side_effect=[0.021, 0.022, 0.021, 0.022]): + random_edge_crop(input_file) + + cmd = mock_ffmpeg.call_args[0][0] + vf_idx = cmd.index("-vf") + vf_value = cmd[vf_idx + 1] + # 提取 crop 参数并验证都是偶数 + import re + + crop_match = re.search(r"crop=(\d+):(\d+)", vf_value) + assert crop_match + crop_w = int(crop_match.group(1)) + crop_h = int(crop_match.group(2)) + assert crop_w % 2 == 0, f"crop width {crop_w} should be even" + assert crop_h % 2 == 0, f"crop height {crop_h} should be even" -- 2.54.0 From d01040cb93a841b9cc5a47ee8cd3fdd9de7a7a15 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 12:33:24 +0800 Subject: [PATCH 22/39] =?UTF-8?q?fix:=20visual=5Fsimilarity=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=B8=BA=E7=99=BE=E5=88=86=E6=AF=94=EF=BC=88=C3=97100?= =?UTF-8?q?=EF=BC=89+=20=E6=B3=A8=E9=87=8A=E4=BF=AE=E6=AD=A3=20(#1662)=20(?= =?UTF-8?q?#1683)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/api/products/types.ts | 4 ++-- apps/web/src/pages/products/components/ProductInfoPanel.tsx | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/api/products/types.ts b/apps/web/src/api/products/types.ts index 8ec79b45e..40a835b22 100644 --- a/apps/web/src/api/products/types.ts +++ b/apps/web/src/api/products/types.ts @@ -23,7 +23,7 @@ export interface ProductItem { project_name?: string /** 查重率(百分比) */ duplicate_rate?: number - /** 视觉相似度(0-100),#1660 新增 */ + /** 视觉相似度(0-1),#1660 新增 */ visual_similarity?: number /** 匹配帧数,#1660 新增 */ match_count?: number @@ -76,7 +76,7 @@ export interface VideoItem { download_url: string generated_at: string duplicate_rate?: number - /** 视觉相似度(0-100),#1660 新增 */ + /** 视觉相似度(0-1),#1660 新增 */ visual_similarity?: number /** 匹配帧数,#1660 新增 */ match_count?: number diff --git a/apps/web/src/pages/products/components/ProductInfoPanel.tsx b/apps/web/src/pages/products/components/ProductInfoPanel.tsx index ae587b0b5..b626c5296 100644 --- a/apps/web/src/pages/products/components/ProductInfoPanel.tsx +++ b/apps/web/src/pages/products/components/ProductInfoPanel.tsx @@ -56,7 +56,9 @@ export const ProductInfoPanel: React.FC = ({ product }) = {product.visual_similarity != null && (
视觉相似度 - {product.visual_similarity.toFixed(1)}% + + {(product.visual_similarity * 100).toFixed(1)}% +
)} {product.match_count != null && ( -- 2.54.0 From 452a484c5b40c902dd9b98536e6604b9d06ce2fd Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 13:00:11 +0800 Subject: [PATCH 23/39] =?UTF-8?q?fix:=20=E6=9F=A5=E9=87=8D=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=85=A8=E9=9D=A2=E6=A0=B8=E5=AE=9E=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20=E2=80=94=20=E4=B8=A4=E9=98=B6=E6=AE=B5=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=20+=20=E9=87=8D=E6=96=B0=E8=AE=A1=E7=AE=97=E6=9F=A5?= =?UTF-8?q?=E9=87=8DAPI=20(#1664)=20(#1684)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/api/app/api/routes/videos.py | 68 +++++ apps/worker/video_processing/dedup_helpers.py | 160 ++++++------ tests/unit/test_dedup_helpers_user_id.py | 4 +- tests/unit/test_dedup_two_phase_commit.py | 234 ++++++++++++++++++ tests/unit/test_recompute_dedup_api.py | 167 +++++++++++++ 5 files changed, 546 insertions(+), 87 deletions(-) create mode 100644 tests/unit/test_dedup_two_phase_commit.py create mode 100644 tests/unit/test_recompute_dedup_api.py diff --git a/apps/api/app/api/routes/videos.py b/apps/api/app/api/routes/videos.py index 250b59e73..77be5a3ff 100644 --- a/apps/api/app/api/routes/videos.py +++ b/apps/api/app/api/routes/videos.py @@ -15,6 +15,7 @@ from app.schemas.video_center import ( VideoItemResponse, ) from fastapi import APIRouter, Depends, HTTPException, Query, Response +from pydantic import BaseModel, Field from packages.application import ( GetGeneratedVideoUseCase, @@ -239,3 +240,70 @@ def get_batch_download_status( status=api_status, download_url=download_url, ) + + +# ── 重新计算查重率 ───────────────────────────────────────────────── + + +class RecomputeDedupRequest(BaseModel): + """重新计算查重率请求。""" + + video_ids: list[str] | None = Field( + None, + description="指定视频 ID 列表。为空则对当前用户所有缺少查重数据的视频重新计算。", + ) + + +class RecomputeDedupResponse(BaseModel): + """重新计算查重率响应。""" + + enqueued: int = Field(..., description="已入队的任务数量") + total_scanned: int = Field(..., description="扫描的视频总数") + skipped: int = Field(..., description="已有查重数据跳过的数量") + message: str = "" + + +@router.post("/videos/recompute-dedup", response_model=RecomputeDedupResponse) +def recompute_dedup( + request: RecomputeDedupRequest = RecomputeDedupRequest(), + repo=Depends(get_generated_video_repository), + current_user: AuthenticatedUser = Depends(get_current_user), +): + """重新计算视频的查重率/视觉相似度。 + + 对于已存在但缺少 duplicate_rate / video_fingerprint 的视频, + 触发异步 Celery 任务重新下载并计算指纹 + 查重率。 + + 不传 video_ids 时,对当前用户所有视频进行检查。 + """ + user_id = current_user.user.id + + # 获取目标视频列表 + if request.video_ids: + all_videos = repo.get_by_ids(request.video_ids) + # 安全校验:只处理当前用户的视频 + target_videos = [v for v in all_videos if v.user_id == user_id] + else: + target_videos = repo.list_by_user(user_id) + + total_scanned = len(target_videos) + enqueued = 0 + skipped = 0 + + for video in target_videos: + # 已有完整查重数据的跳过 + if video.duplicate_rate is not None and video.video_fingerprint: + skipped += 1 + continue + + # 触发异步查重任务 + celery_app.send_task("worker.check_duplicate", args=[video.id]) + enqueued += 1 + logger.info("Enqueued re-dedup for video %s (user=%s)", video.id, user_id) + + return RecomputeDedupResponse( + enqueued=enqueued, + total_scanned=total_scanned, + skipped=skipped, + message=f"已入队 {enqueued} 个查重任务" if enqueued > 0 else "所有视频查重数据已完整", + ) diff --git a/apps/worker/video_processing/dedup_helpers.py b/apps/worker/video_processing/dedup_helpers.py index ab6962acc..b7e6d4965 100755 --- a/apps/worker/video_processing/dedup_helpers.py +++ b/apps/worker/video_processing/dedup_helpers.py @@ -2,6 +2,9 @@ 供 generate_video 共同复用, 创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重。 + +v2: 两阶段持久化 — 先计算所有查重数据,再一次性 commit, +避免中间异常导致 duplicate_rate 等字段缺失。 """ from __future__ import annotations @@ -34,24 +37,14 @@ def create_video_record_and_dedup( ) -> int: """创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。 - Args: - generation_task_id: 生成任务 ID - project_id: 项目 ID - batch_id: 批次 ID(可为空字符串) - file_url: 视频文件 URL - file_size: 文件大小(字节) - duration: 视频时长(秒) - video_path: 视频本地路径(用于计算指纹) - mode: 剪辑模式名称 - session: 数据库会话 - width: 视频宽度 - height: 视频高度 - fps: 视频帧率 + 采用两阶段持久化:先计算所有指纹/查重数据(内存), + 再一次性写入数据库并 commit。若指纹计算失败, + 视频记录仍会创建(无查重数据),但保证不会出现"写了记录却没 commit"的中间态。 Returns: 创建的视频记录数量(1 表示成功,0 表示失败) """ - from video_processing.dedup import VideoDeduplicator + from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks from packages.adapters.sqlalchemy_impl.generated_video_repository import ( SQLAlchemyGeneratedVideoRepository, @@ -60,8 +53,9 @@ def create_video_record_and_dedup( try: video_id = uuid4().hex - # 使用传入的名称,没有则 fallback 到默认命名 video_name = name.strip() if name else f"generated-{generation_task_id[:8]}.mp4" + + # ── Phase 1: 构建视频记录(内存,不 commit) ──────────────── generated_video = GeneratedVideo( id=video_id, project_id=project_id, @@ -76,98 +70,94 @@ def create_video_record_and_dedup( fps=fps, status="completed", generation_params={"mode": mode}, + thumbnail_url=thumbnail_url or None, ) - video_repo = SQLAlchemyGeneratedVideoRepository(session) - video_repo.create(generated_video) - - # 生成封面缩略图 - if thumbnail_url: - generated_video.thumbnail_url = thumbnail_url - video_repo.update_thumbnail(video_id, thumbnail_url) - logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80] if thumbnail_url else "") - else: - logger.debug("No thumbnail_url provided for video %s, skipping", video_id) - - # 计算视频指纹 + # ── Phase 2: 计算指纹 & 查重(全部在内存) ──────────────── deduplicator = VideoDeduplicator() + fingerprint = None + try: fingerprint = deduplicator.compute_fingerprint(video_path) except Exception as fp_err: logger.warning("Fingerprint computation failed for %s: %s", video_id, fp_err) - session.commit() - return 1 - generated_video.video_fingerprint = fingerprint.to_dict() + if fingerprint is not None: + generated_video.video_fingerprint = fingerprint.to_dict() - # 写入分片指纹表 - from video_processing.dedup import _save_fingerprint_chunks + # 写入分片指纹表(失败不阻塞) + try: + _save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session) + except Exception as chunk_err: + logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err) - try: - _save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session) - except Exception as chunk_err: - logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err) - - # (a) 历史成片查重(跨项目全局 + 时长预过滤) - duration_sec = fingerprint.duration / 1000 if fingerprint.duration else 0 - duplicate_result = deduplicator.check_duplicate( - fingerprint, - project_id, - session, - scope="user", - user_id=user_id, - duration_sec=duration_sec, - ) - - # (b) 批次内查重(仅当有 batch_id 时) - if not duplicate_result and batch_id: - duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session) - - if duplicate_result: - generated_video.is_duplicate = True - generated_video.duplicate_of = duplicate_result["duplicate_of"] - logger.info( - "Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)", - video_id, - duplicate_result["duplicate_of"], - duplicate_result["reason"], - duplicate_result["similarity"], - ) - else: - generated_video.is_duplicate = False - generated_video.duplicate_of = None - - # 计算重复率百分比(跨项目全局) - try: - rate_result = deduplicator.compute_duplicate_rate( + # (a) 历史成片查重(跨项目全局 + 时长预过滤) + duration_sec = fingerprint.duration / 1000 if fingerprint.duration else 0 + duplicate_result = deduplicator.check_duplicate( fingerprint, project_id, - video_id, session, scope="user", user_id=user_id, + duration_sec=duration_sec, ) - generated_video.duplicate_rate = rate_result["duplicate_rate"] - generated_video.match_count = rate_result["match_count"] - generated_video.visual_similarity = rate_result["visual_similarity"] - logger.info( - "Duplicate rate for %s: %.2f%% (visual_sim=%.3f, matches=%d)", - video_id, - rate_result["duplicate_rate"], - rate_result["visual_similarity"], - rate_result["match_count"], - ) - except Exception as rate_err: - logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err) - generated_video.duplicate_rate = None - video_repo.update(generated_video) + # (b) 批次内查重(仅当有 batch_id 时) + if not duplicate_result and batch_id: + duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session) + + if duplicate_result: + generated_video.is_duplicate = True + generated_video.duplicate_of = duplicate_result["duplicate_of"] + logger.info( + "Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)", + video_id, + duplicate_result["duplicate_of"], + duplicate_result["reason"], + duplicate_result["similarity"], + ) + else: + generated_video.is_duplicate = False + generated_video.duplicate_of = None + + # 计算重复率百分比(跨项目全局) + try: + rate_result = deduplicator.compute_duplicate_rate( + fingerprint, + project_id, + video_id, + session, + scope="user", + user_id=user_id, + ) + generated_video.duplicate_rate = rate_result["duplicate_rate"] + generated_video.match_count = rate_result["match_count"] + generated_video.visual_similarity = rate_result["visual_similarity"] + logger.info( + "Duplicate rate for %s: %.2f%% (visual_sim=%.3f, matches=%d)", + video_id, + rate_result["duplicate_rate"], + rate_result["visual_similarity"], + rate_result["match_count"], + ) + except Exception as rate_err: + logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err) + generated_video.duplicate_rate = None + + # ── Phase 3: 一次性持久化 ───────────────────────────────── + video_repo = SQLAlchemyGeneratedVideoRepository(session) + video_repo.create(generated_video) + + if thumbnail_url: + logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80]) + session.commit() logger.info( - "GeneratedVideo record created: %s (task=%s, dup=%s)", + "GeneratedVideo record created: %s (task=%s, dup=%s, rate=%s)", video_id, generation_task_id, generated_video.is_duplicate, + generated_video.duplicate_rate, ) return 1 except Exception as e: diff --git a/tests/unit/test_dedup_helpers_user_id.py b/tests/unit/test_dedup_helpers_user_id.py index d5a1edff1..65339ec7b 100644 --- a/tests/unit/test_dedup_helpers_user_id.py +++ b/tests/unit/test_dedup_helpers_user_id.py @@ -159,6 +159,6 @@ class TestDedupHelpersUserIdPassthrough: ) # 验证 update 被调用(包含 duplicate_rate 的记录) - mock_video_repo.update.assert_called_once() - updated_video = mock_video_repo.update.call_args[0][0] + mock_video_repo.create.assert_called_once() + updated_video = mock_video_repo.create.call_args[0][0] assert updated_video.duplicate_rate == 78.5 diff --git a/tests/unit/test_dedup_two_phase_commit.py b/tests/unit/test_dedup_two_phase_commit.py new file mode 100644 index 000000000..54e4e0c97 --- /dev/null +++ b/tests/unit/test_dedup_two_phase_commit.py @@ -0,0 +1,234 @@ +"""Tests for two-phase commit pattern in dedup_helpers (#1664 follow-up). + +Verifies that the new dedup_helpers.py: +1. Creates video with all dedup fields in a single commit +2. Still creates video when fingerprint computation fails +3. Creates video with fingerprint but no rate when rate computation fails +4. Never does a partial commit (no create + separate update) +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +# Mock cv2/numpy before imports +sys.modules.setdefault("cv2", MagicMock()) +sys.modules.setdefault("numpy", MagicMock()) + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "apps" / "api")) +sys.path.insert(0, str(ROOT / "packages")) +sys.path.insert(0, str(ROOT / "apps" / "worker")) + +import os + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +import pytest +from video_processing.dedup_helpers import create_video_record_and_dedup + + +@pytest.fixture +def session(): + s = MagicMock() + return s + + +@pytest.fixture +def mock_fingerprint(): + fp = MagicMock() + fp.duration = 15000 # 15 seconds in ms + fp.to_dict.return_value = {"md5": "abc123", "keyframe_phashes": ["aabb"], "color_histograms": []} + fp.chunks = [] + fp.keyframe_phashes = ["aabb"] + fp.color_histograms = [] + fp.md5 = "abc123" + return fp + + +class TestTwoPhaseCommit: + """Verify that dedup data is computed before commit.""" + + def test_video_created_with_all_dedup_fields(self, session, mock_fingerprint): + """When all computations succeed, video is created with all fields in one commit.""" + mock_repo = MagicMock() + mock_deduplicator = MagicMock() + mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint + mock_deduplicator.check_duplicate.return_value = None + mock_deduplicator.compute_duplicate_rate.return_value = { + "duplicate_rate": 42.5, + "visual_similarity": 0.75, + "match_count": 2, + } + + with ( + patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ), + patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator), + patch("video_processing.dedup._save_fingerprint_chunks"), + ): + result = create_video_record_and_dedup( + generation_task_id="task-001", + project_id="proj-001", + user_id="user-001", + batch_id="", + file_url="https://example.com/v.mp4", + file_size=1024, + duration=15.0, + video_path="/tmp/fake.mp4", + mode="smart", + session=session, + ) + + assert result == 1 + # create() should be called exactly once with the complete video object + mock_repo.create.assert_called_once() + created_video = mock_repo.create.call_args[0][0] + assert created_video.duplicate_rate == 42.5 + assert created_video.visual_similarity == 0.75 + assert created_video.match_count == 2 + assert created_video.video_fingerprint is not None + # session.commit should be called exactly once (at the end) + session.commit.assert_called_once() + + def test_video_created_even_when_fingerprint_fails(self, session): + """When fingerprint computation fails, video is still created (without dedup data).""" + mock_repo = MagicMock() + mock_deduplicator = MagicMock() + mock_deduplicator.compute_fingerprint.side_effect = RuntimeError("cv2 not available") + + with ( + patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ), + patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator), + ): + result = create_video_record_and_dedup( + generation_task_id="task-002", + project_id="proj-001", + user_id="user-001", + batch_id="", + file_url="https://example.com/v.mp4", + file_size=1024, + duration=15.0, + video_path="/tmp/fake.mp4", + mode="smart", + session=session, + ) + + assert result == 1 + mock_repo.create.assert_called_once() + created_video = mock_repo.create.call_args[0][0] + assert created_video.duplicate_rate is None + assert created_video.video_fingerprint is None + session.commit.assert_called_once() + # No dedup methods should have been called + mock_deduplicator.check_duplicate.assert_not_called() + mock_deduplicator.compute_duplicate_rate.assert_not_called() + + def test_video_created_with_fingerprint_but_no_rate(self, session, mock_fingerprint): + """When rate computation fails, video is created with fingerprint but no rate.""" + mock_repo = MagicMock() + mock_deduplicator = MagicMock() + mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint + mock_deduplicator.check_duplicate.return_value = None + mock_deduplicator.compute_duplicate_rate.side_effect = RuntimeError("DB error") + + with ( + patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ), + patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator), + patch("video_processing.dedup._save_fingerprint_chunks"), + ): + result = create_video_record_and_dedup( + generation_task_id="task-003", + project_id="proj-001", + user_id="user-001", + batch_id="", + file_url="https://example.com/v.mp4", + file_size=1024, + duration=15.0, + video_path="/tmp/fake.mp4", + mode="smart", + session=session, + ) + + assert result == 1 + mock_repo.create.assert_called_once() + created_video = mock_repo.create.call_args[0][0] + # Fingerprint should be set + assert created_video.video_fingerprint is not None + # But duplicate_rate should be None + assert created_video.duplicate_rate is None + session.commit.assert_called_once() + + def test_no_separate_update_call(self, session, mock_fingerprint): + """Verify the new pattern uses create() only, not create() + update().""" + mock_repo = MagicMock() + mock_deduplicator = MagicMock() + mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint + mock_deduplicator.check_duplicate.return_value = None + mock_deduplicator.compute_duplicate_rate.return_value = { + "duplicate_rate": 10.0, + "visual_similarity": 0.5, + "match_count": 1, + } + + with ( + patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ), + patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator), + patch("video_processing.dedup._save_fingerprint_chunks"), + ): + create_video_record_and_dedup( + generation_task_id="task-004", + project_id="proj-001", + user_id="user-001", + batch_id="", + file_url="https://example.com/v.mp4", + file_size=1024, + duration=15.0, + video_path="/tmp/fake.mp4", + mode="smart", + session=session, + ) + + # Only create() should be called, not update() + mock_repo.create.assert_called_once() + mock_repo.update.assert_not_called() + + def test_commit_not_called_on_total_failure(self, session): + """When the entire function fails, session.rollback is called instead of commit.""" + mock_repo = MagicMock() + mock_repo.create.side_effect = RuntimeError("DB connection lost") + + with patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ): + result = create_video_record_and_dedup( + generation_task_id="task-005", + project_id="proj-001", + user_id="user-001", + batch_id="", + file_url="https://example.com/v.mp4", + file_size=1024, + duration=15.0, + video_path="/tmp/fake.mp4", + mode="smart", + session=session, + ) + + assert result == 0 + session.commit.assert_not_called() + session.rollback.assert_called_once() diff --git a/tests/unit/test_recompute_dedup_api.py b/tests/unit/test_recompute_dedup_api.py new file mode 100644 index 000000000..31ac1fa68 --- /dev/null +++ b/tests/unit/test_recompute_dedup_api.py @@ -0,0 +1,167 @@ +"""Tests for POST /videos/recompute-dedup endpoint (#1664 follow-up).""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def mock_video(): + """Mock video with missing dedup data.""" + v = MagicMock() + v.id = "video-001" + v.user_id = "user-abc" + v.duplicate_rate = None + v.video_fingerprint = None + v.project_id = "proj-001" + v.generation_task_id = "task-001" + v.name = "test.mp4" + v.file_url = "https://example.com/test.mp4" + v.file_size = 1024 + v.duration = 10.0 + v.width = 1920 + v.height = 1080 + v.fps = 25.0 + v.status = "completed" + v.review_status = "pending_review" + v.generation_params = {} + v.thumbnail_url = None + v.is_duplicate = False + v.duplicate_of = None + v.match_count = None + v.visual_similarity = None + v.generated_at = "2026-09-04T00:00:00" + return v + + +@pytest.fixture +def mock_video_with_dedup(mock_video): + """Mock video that already has dedup data.""" + mock_video.duplicate_rate = 15.5 + mock_video.video_fingerprint = {"md5": "abc123"} + return mock_video + + +class TestRecomputeDedupEndpoint: + """POST /videos/recompute-dedup""" + + def test_enqueue_videos_without_dedup(self, mock_video): + """Videos missing duplicate_rate should be enqueued.""" + from app.api.routes.videos import RecomputeDedupRequest + + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = [mock_video] + + with (patch("app.api.routes.videos.celery_app") as mock_celery,): + mock_celery.send_task.return_value = MagicMock(id="task-xyz") + from app.api.routes.videos import recompute_dedup + + auth_user = MagicMock() + auth_user.user.id = "user-abc" + + result = recompute_dedup( + request=RecomputeDedupRequest(), + repo=mock_repo, + current_user=auth_user, + ) + + assert result.enqueued == 1 + assert result.total_scanned == 1 + assert result.skipped == 0 + mock_celery.send_task.assert_called_once_with("worker.check_duplicate", args=["video-001"]) + + def test_skip_videos_with_complete_dedup(self, mock_video_with_dedup): + """Videos with both duplicate_rate and video_fingerprint should be skipped.""" + from app.api.routes.videos import RecomputeDedupRequest, recompute_dedup + + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = [mock_video_with_dedup] + + with patch("app.api.routes.videos.celery_app") as mock_celery: + auth_user = MagicMock() + auth_user.user.id = "user-abc" + + result = recompute_dedup( + request=RecomputeDedupRequest(), + repo=mock_repo, + current_user=auth_user, + ) + + assert result.enqueued == 0 + assert result.total_scanned == 1 + assert result.skipped == 1 + mock_celery.send_task.assert_not_called() + + def test_specific_video_ids(self, mock_video): + """When video_ids are provided, only those videos are processed.""" + from app.api.routes.videos import RecomputeDedupRequest, recompute_dedup + + mock_repo = MagicMock() + mock_repo.get_by_ids.return_value = [mock_video] + + with patch("app.api.routes.videos.celery_app") as mock_celery: + mock_celery.send_task.return_value = MagicMock(id="task-xyz") + auth_user = MagicMock() + auth_user.user.id = "user-abc" + + result = recompute_dedup( + request=RecomputeDedupRequest(video_ids=["video-001"]), + repo=mock_repo, + current_user=auth_user, + ) + + assert result.enqueued == 1 + mock_repo.get_by_ids.assert_called_once_with(["video-001"]) + + def test_security_only_own_videos(self, mock_video): + """Videos belonging to other users should be filtered out.""" + from app.api.routes.videos import RecomputeDedupRequest, recompute_dedup + + mock_video.user_id = "user-OTHER" + mock_repo = MagicMock() + mock_repo.get_by_ids.return_value = [mock_video] + + with patch("app.api.routes.videos.celery_app") as mock_celery: + auth_user = MagicMock() + auth_user.user.id = "user-abc" + + result = recompute_dedup( + request=RecomputeDedupRequest(video_ids=["video-001"]), + repo=mock_repo, + current_user=auth_user, + ) + + assert result.enqueued == 0 + mock_celery.send_task.assert_not_called() + + def test_mixed_complete_and_incomplete(self, mock_video, mock_video_with_dedup): + """Mix of videos with and without dedup data.""" + import copy + + from app.api.routes.videos import RecomputeDedupRequest, recompute_dedup + + # Create a second video object + v2 = MagicMock() + v2.id = "video-002" + v2.user_id = "user-abc" + v2.duplicate_rate = None + v2.video_fingerprint = None + + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = [mock_video_with_dedup, v2] + + with patch("app.api.routes.videos.celery_app") as mock_celery: + mock_celery.send_task.return_value = MagicMock(id="task-xyz") + auth_user = MagicMock() + auth_user.user.id = "user-abc" + + result = recompute_dedup( + request=RecomputeDedupRequest(), + repo=mock_repo, + current_user=auth_user, + ) + + assert result.enqueued == 1 + assert result.total_scanned == 2 + assert result.skipped == 1 -- 2.54.0 From 475ee59408d5adfc52b2fef38683c50e450645ce Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 13:55:36 +0800 Subject: [PATCH 24/39] =?UTF-8?q?feat:=20=E6=88=90=E7=89=87=E5=BA=93?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E3=80=8C=E9=87=8D=E6=96=B0=E6=9F=A5=E9=87=8D?= =?UTF-8?q?=E3=80=8D=E6=8C=89=E9=92=AE=EF=BC=8C=E8=A7=A6=E5=8F=91=E5=AD=98?= =?UTF-8?q?=E9=87=8F=E8=A7=86=E9=A2=91=E6=9F=A5=E9=87=8D=E7=8E=87=E9=87=8D?= =?UTF-8?q?=E7=AE=97=20(#1685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/api/products/index.ts | 3 +++ apps/web/src/api/products/products.ts | 15 +++++++++++ .../web/src/pages/products/ProductLibrary.tsx | 14 +++++++++- .../product-actions/useRecomputeDedup.ts | 27 +++++++++++++++++++ .../src/test/pages/ProductLibrary.test.tsx | 1 + 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/pages/products/hooks/product-actions/useRecomputeDedup.ts diff --git a/apps/web/src/api/products/index.ts b/apps/web/src/api/products/index.ts index b4bf23a95..5f1148b57 100644 --- a/apps/web/src/api/products/index.ts +++ b/apps/web/src/api/products/index.ts @@ -13,6 +13,8 @@ export type { VideoItem, } from "./types" +export type { RecomputeDedupResponse } from "./products" + // 工具函数 export { mapVideoToProductItem } from "./utils" @@ -25,4 +27,5 @@ export { updateReviewStatus, batchDownload, getBatchDownloadStatus, + recomputeDedup, } from "./products" diff --git a/apps/web/src/api/products/products.ts b/apps/web/src/api/products/products.ts index 7c0fcb19f..18680df7b 100644 --- a/apps/web/src/api/products/products.ts +++ b/apps/web/src/api/products/products.ts @@ -78,3 +78,18 @@ export const getBatchDownloadStatus = async (jobId: string): Promise => { + const response = await apiClient.post("/videos/recompute-dedup", { + video_ids: videoIds, + }) + return response.data +} diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx index d4d2ea40d..d15cacc9c 100644 --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -11,7 +11,7 @@ * 产品卡片 → components/ProductCard(内联视频播放) */ import React from "react" -import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons" +import { VideoCameraOutlined, DownloadOutlined, ReloadOutlined } from "@ant-design/icons" import { Button } from "@/components/ui" import { ProductCard } from "./components/ProductCard" import { ProductFilterBar } from "./components/ProductFilterBar" @@ -19,6 +19,7 @@ import { ProductBatchBar } from "./components/ProductBatchBar" import { ProductEmptyState } from "./components/ProductEmptyState" import { useProductList } from "./hooks/useProductList" import { useProductActions } from "./hooks/useProductActions" +import { useRecomputeDedup } from "./hooks/product-actions/useRecomputeDedup" import "./products.css" const ProductLibrary: React.FC = () => { @@ -67,6 +68,8 @@ const ProductLibrary: React.FC = () => { setPlayingProduct: () => {}, // 不再使用弹窗播放 }) + const { recomputeDedup, isRecomputing } = useRecomputeDedup() + // ── Loading 状态 ── if (isLoading) { return @@ -94,6 +97,15 @@ const ProductLibrary: React.FC = () => { +
diff --git a/apps/web/src/pages/products/hooks/product-actions/useRecomputeDedup.ts b/apps/web/src/pages/products/hooks/product-actions/useRecomputeDedup.ts new file mode 100644 index 000000000..40341c7c7 --- /dev/null +++ b/apps/web/src/pages/products/hooks/product-actions/useRecomputeDedup.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { recomputeDedup } from "@/api/products" + +export function useRecomputeDedup() { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: () => recomputeDedup(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["products"] }) + if (data.enqueued > 0) { + message.success(`已提交 ${data.enqueued} 个视频的查重任务,后台处理中`) + } else { + message.info("所有视频查重率已是最新,无需重算") + } + }, + onError: () => { + message.error("查重任务提交失败,请稍后重试") + }, + }) + + return { + recomputeDedup: () => mutation.mutate(), + isRecomputing: mutation.isPending, + } +} diff --git a/apps/web/src/test/pages/ProductLibrary.test.tsx b/apps/web/src/test/pages/ProductLibrary.test.tsx index f2c8629e2..d157b1d04 100644 --- a/apps/web/src/test/pages/ProductLibrary.test.tsx +++ b/apps/web/src/test/pages/ProductLibrary.test.tsx @@ -102,6 +102,7 @@ vi.mock("@ant-design/icons", () => ({ SearchOutlined: () => , ShareAltOutlined: () => , VideoCameraOutlined: () => , + ReloadOutlined: () => , })) vi.mock("@/store/authStore", () => ({ -- 2.54.0 From 02d226a1632630144d5b5730f77e2c97af3a52d5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 14:14:52 +0800 Subject: [PATCH 25/39] fix: add recomputeDedup mock to ProductLibrary test (#1686) Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/test/pages/ProductLibrary.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/test/pages/ProductLibrary.test.tsx b/apps/web/src/test/pages/ProductLibrary.test.tsx index d157b1d04..cd02bde8e 100644 --- a/apps/web/src/test/pages/ProductLibrary.test.tsx +++ b/apps/web/src/test/pages/ProductLibrary.test.tsx @@ -117,6 +117,9 @@ vi.mock("@/api/products", () => ({ updateReviewStatus: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), batchDownload: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), getBatchDownloadStatus: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), + recomputeDedup: vi + .fn() + .mockResolvedValue({ enqueued: 0, total_scanned: 0, skipped: 0, message: "" }), })) vi.mock("@/pages/products/ProductLibrary.css", () => ({})) -- 2.54.0 From ed72a91990d2adf69f19d287358bf1e5606fc728 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 14:27:13 +0800 Subject: [PATCH 26/39] fix: add missing project_id to extract-voice FormData (#1687) Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/api/tts/jobs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/api/tts/jobs.ts b/apps/web/src/api/tts/jobs.ts index 3bc6d4ab7..19653024a 100644 --- a/apps/web/src/api/tts/jobs.ts +++ b/apps/web/src/api/tts/jobs.ts @@ -81,6 +81,7 @@ export const extractVideoVoice = async ( ): Promise<{ asset_id: string; duration: number }> => { const formData = new FormData() formData.append("file", file) + formData.append("project_id", "default") return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() -- 2.54.0 From 4633126bb43e0983046617c2e0010f48a992d5a4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 14:56:47 +0800 Subject: [PATCH 27/39] =?UTF-8?q?fix:=20=E6=9F=A5=E9=87=8D=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=BB=91=E5=B1=8F=E8=A7=86=E9=A2=91=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=20=E2=80=94=20=E8=B7=B3=E8=BF=87=E5=9D=8F=E6=8C=87=E7=BA=B9?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E8=99=9A=E5=81=87=E5=8C=B9=E9=85=8D=20#1664?= =?UTF-8?q?=20(#1688)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/worker/video_processing/dedup.py | 54 ++++ tests/unit/test_bad_fingerprint_filter.py | 314 ++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 tests/unit/test_bad_fingerprint_filter.py diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index 129e35ef9..b4c6aaba3 100755 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -426,6 +426,42 @@ class VideoDeduplicator: PHASH_THRESHOLD = 8 # Issue #1658: pHash 汉明距离阈值由 10 收紧到 8,降低不同视频误判率 HISTOGRAM_THRESHOLD = 0.85 + @staticmethod + def _is_bad_fingerprint(phashes: list[str]) -> bool: + """检测指纹质量差的视频(黑屏/纯色视频)。 + + 当视频有多个关键帧但所有 phash 完全相同或极其相似时, + 说明视频内容无变化(如黑屏、纯色画面),这类指纹与任何视频 + 比较都会得到虚假的"匹配"结果,应跳过。 + + 注意:单帧视频(只有 1 个 phash)不视为坏指纹,可能是短视频或抽帧不足。 + + Args: + phashes: 关键帧 phash 列表 + + Returns: + True 表示指纹无效,应跳过 + """ + if not phashes: + return True + # 单帧不视为坏指纹(短视频或抽帧不足) + if len(phashes) == 1: + return False + # 多帧但所有 phash 完全相同 → 黑屏/纯色视频 + unique = set(phashes) + if len(unique) == 1: + return True + # 多帧但所有 phash 之间的汉明距离都极小(<3)→ 近似黑屏 + phash_list = list(unique) + if len(phash_list) >= 2: + all_distances = [] + for i in range(len(phash_list)): + for j in range(i + 1, len(phash_list)): + all_distances.append(hamming_distance(phash_list[i], phash_list[j])) + if all_distances and max(all_distances) < 3: + return True + return False + def compute_fingerprint(self, video_path: str) -> VideoFingerprint: """Compute video fingerprint using dynamic keyframe detection. @@ -623,6 +659,12 @@ class VideoDeduplicator: if fingerprint.md5 == ef.get("md5"): return {"duplicate": True, "duplicate_of": existing.id, "reason": "exact_md5_match", "similarity": 1.0} + # 跳过指纹质量差的视频(黑屏/纯色视频) + existing_phashes_for_check = ef.get("keyframe_phashes", []) + if self._is_bad_fingerprint(existing_phashes_for_check): + logger.debug("Skipping bad fingerprint video %s in check_duplicate", existing.id) + continue + # 优先从分片表读取已有视频的分片 phash existing_phashes = [] chunk_data = self._get_existing_chunks(existing.id, session) @@ -737,6 +779,12 @@ class VideoDeduplicator: "similarity": 1.0, } + # 跳过指纹质量差的视频(黑屏/纯色视频) + existing_phashes_batch = ef.get("keyframe_phashes", []) + if self._is_bad_fingerprint(existing_phashes_batch): + logger.debug("Skipping bad fingerprint video %s in check_batch_duplicate", existing.id) + continue + # 优先从分片表读取 existing_phashes = [] chunk_data = self._get_existing_chunks(existing.id, session) @@ -868,6 +916,12 @@ class VideoDeduplicator: "match_count": 1, } + # 跳过指纹质量差的视频(黑屏/纯色视频) + existing_phashes_check = ef.get("keyframe_phashes", []) + if self._is_bad_fingerprint(existing_phashes_check): + logger.debug("Skipping bad fingerprint video %s in compute_duplicate_rate", existing.id) + continue + # 优先从分片表读取 existing_phashes = [] chunk_data = self._get_existing_chunks(existing.id, session) diff --git a/tests/unit/test_bad_fingerprint_filter.py b/tests/unit/test_bad_fingerprint_filter.py new file mode 100644 index 000000000..05a18cb08 --- /dev/null +++ b/tests/unit/test_bad_fingerprint_filter.py @@ -0,0 +1,314 @@ +"""Tests for bad fingerprint (black screen / uniform color) filtering. + +Issue: 1秒黑屏视频(所有帧phash几乎相同)与任何视频的距离都~30,造成虚假匹配。 +Fix: _is_bad_fingerprint() 检测并跳过这类低质量指纹。 +""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Mock heavy deps before importing dedup module (same pattern as test_dedup_engine.py) +# --------------------------------------------------------------------------- +_ORIGINAL_MODULES = dict(sys.modules) +_MOCKED_MODULE_NAMES: list[str] = [] + + +def _mock_if_absent(name: str, mock_obj=None): + if name not in sys.modules: + sys.modules[name] = mock_obj if mock_obj is not None else MagicMock() + _MOCKED_MODULE_NAMES.append(name) + + +_mock_if_absent("ffmpeg") +for mod_name in ["worker_app", "worker_app.celery_app", "worker_app.db"]: + _mock_if_absent(mod_name) +if "worker_app.celery_app" in sys.modules and isinstance(sys.modules["worker_app.celery_app"], MagicMock): + sys.modules["worker_app.celery_app"].celery_app = MagicMock() +if "worker_app.db" in sys.modules and isinstance(sys.modules["worker_app.db"], MagicMock): + sys.modules["worker_app.db"].SessionLocal = MagicMock() +_mock_if_absent("celery", MagicMock()) +if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock): + sys.modules["celery"].Task = object +_mock_if_absent("packages.shared.storage") +_mock_if_absent("packages.adapters.sqlalchemy_impl.generated_video_repository") + +_HAS_CV2 = False +try: + import cv2 as _cv2 + + if not isinstance(_cv2, MagicMock): + _HAS_CV2 = True +except (ImportError, ModuleNotFoundError): + pass + +if not _HAS_CV2: + _mock_if_absent("cv2") + +import numpy as np # noqa: E402 + +from apps.worker.video_processing.dedup import ( # noqa: E402 + VideoDeduplicator, + VideoFingerprint, +) + +# Restore mocked modules +for _name in ["worker_app", "worker_app.celery_app", "worker_app.db", "celery"]: + if _name in _MOCKED_MODULE_NAMES: + sys.modules.pop(_name, None) + _MOCKED_MODULE_NAMES.remove(_name) + + +@pytest.fixture(autouse=True, scope="session") +def _cleanup_mocks(): + yield + for name in _MOCKED_MODULE_NAMES: + sys.modules.pop(name, None) + + +# ── _is_bad_fingerprint 单元测试 ───────────────────────────────── + + +class TestIsBadFingerprint: + """VideoDeduplicator._is_bad_fingerprint() 静态方法测试。""" + + def test_empty_phashes_is_bad(self): + """空 phash 列表视为坏指纹。""" + assert VideoDeduplicator._is_bad_fingerprint([]) is True + + def test_single_phash_is_not_bad(self): + """单帧视频不视为坏指纹(短视频或抽帧不足)。""" + assert VideoDeduplicator._is_bad_fingerprint(["abcdef0123456789"]) is False + + def test_all_identical_phashes_is_bad(self): + """多帧但所有 phash 完全相同 → 黑屏/纯色视频。""" + phashes = ["aaaaaaaaaaaaaaaa"] * 5 + assert VideoDeduplicator._is_bad_fingerprint(phashes) is True + + def test_two_identical_phashes_is_bad(self): + """两帧完全相同也视为坏指纹。""" + assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb", "bbbbbbbbbbbbbbbb"]) is True + + def test_all_very_similar_phashes_is_bad(self): + """多帧 phash 之间的汉明距离都 < 3 → 近似黑屏。""" + phashes = ["0000000000000000", "0000000000000001", "0000000000000002"] + assert VideoDeduplicator._is_bad_fingerprint(phashes) is True + + def test_diverse_phashes_is_good(self): + """多样化的 phash 列表是有效指纹。""" + phashes = [ + "abcdef0123456789", + "1234567890abcdef", + "fedcba9876543210", + "0123456789abcdef", + ] + assert VideoDeduplicator._is_bad_fingerprint(phashes) is False + + def test_mixed_similar_and_different_is_good(self): + """有些 phash 相似但有足够多样的 → 有效指纹。""" + phashes = [ + "0000000000000000", + "0000000000000001", + "0000000000000002", + "ffffffffffffffff", + ] + assert VideoDeduplicator._is_bad_fingerprint(phashes) is False + + def test_known_black_screen_phashes(self): + """已知黑屏视频的 phash 特征(全零或均匀分布)。""" + assert VideoDeduplicator._is_bad_fingerprint(["0000000000000000"] * 10) is True + assert VideoDeduplicator._is_bad_fingerprint(["ffffffffffffffff"] * 8) is True + assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 6) is True + + +# ── Helper ────────────────────────────────────────────────────── + + +def _make_existing_video(video_id, md5, phashes): + """创建 mock 视频记录。""" + video = MagicMock() + video.id = video_id + video.video_fingerprint = { + "md5": md5, + "keyframe_phashes": phashes, + "color_histograms": [], + } + return video + + +# ── check_duplicate 集成测试 ──────────────────────────────────── + + +class TestCheckDuplicateBadFingerprint: + """check_duplicate 跳过坏指纹视频。""" + + def test_black_screen_existing_video_skipped(self): + """已有视频是黑屏指纹 → 被跳过,不匹配。""" + deduplicator = VideoDeduplicator() + mock_session = MagicMock() + + black_screen = _make_existing_video("vid-black", "md5_black", ["aaaaaaaaaaaaaaaa"] * 5) + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = [black_screen] + + fingerprint = VideoFingerprint( + md5="md5_normal", + keyframe_phashes=["aaaaaaaaaaaaaaaa"] * 5, + color_histograms=[], + duration=10.0, + resolution=(1280, 720), + ) + + with patch( + "apps.worker.video_processing.dedup.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ): + result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session, scope="user", user_id="user-1") + + assert result is None + + def test_normal_existing_video_not_skipped(self): + """正常视频不会被坏指纹过滤跳过。""" + deduplicator = VideoDeduplicator() + mock_session = MagicMock() + + normal = _make_existing_video( + "vid-normal", + "md5_normal_existing", + ["abcdef0123456789", "1234567890abcdef", "fedcba9876543210"], + ) + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = [normal] + + fingerprint = VideoFingerprint( + md5="md5_normal_new", + keyframe_phashes=["abcdef0123456789", "1234567890abcdef", "fedcba9876543210"], + color_histograms=[], + duration=10.0, + resolution=(1280, 720), + ) + + with patch( + "apps.worker.video_processing.dedup.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ): + result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session, scope="user", user_id="user-1") + + assert result is not None + assert result["duplicate"] is True + + def test_md5_match_overrides_bad_fingerprint(self): + """MD5 精确匹配优先于坏指纹过滤。""" + deduplicator = VideoDeduplicator() + mock_session = MagicMock() + + black_screen = _make_existing_video("vid-black", "same_md5", ["aaaaaaaaaaaaaaaa"] * 5) + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = [black_screen] + + fingerprint = VideoFingerprint( + md5="same_md5", + keyframe_phashes=["bbbbbbbbbbbbbbbb"] * 3, + color_histograms=[], + duration=10.0, + resolution=(1280, 720), + ) + + with patch( + "apps.worker.video_processing.dedup.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ): + result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session, scope="user", user_id="user-1") + + assert result is not None + assert result["reason"] == "exact_md5_match" + + +# ── compute_duplicate_rate 集成测试 ───────────────────────────── + + +class TestComputeDuplicateRateBadFingerprint: + """compute_duplicate_rate 跳过坏指纹视频。""" + + def test_black_screen_video_excluded_from_rate(self): + """黑屏视频不参与查重率计算。""" + deduplicator = VideoDeduplicator() + mock_session = MagicMock() + + videos = [ + _make_existing_video("vid-b1", "md5_b1", ["cccccccccccccccc"] * 5), + _make_existing_video("vid-b2", "md5_b2", ["dddddddddddddddd"] * 5), + _make_existing_video("vid-b3", "md5_b3", ["eeeeeeeeeeeeeeee"] * 5), + _make_existing_video( + "vid-normal", + "md5_n", + ["abcdef0123456789", "1234567890abcdef", "fedcba9876543210"], + ), + ] + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = videos + + fingerprint = VideoFingerprint( + md5="md5_new", + keyframe_phashes=["abcdef0123456789", "1234567890abcdef", "fedcba9876543210"], + color_histograms=[], + duration=10.0, + resolution=(1280, 720), + ) + + with patch( + "apps.worker.video_processing.dedup.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ): + result = deduplicator.compute_duplicate_rate( + fingerprint, + "proj-1", + "vid-new", + mock_session, + scope="user", + user_id="user-1", + ) + + assert result is not None + assert isinstance(result["duplicate_rate"], float) + assert isinstance(result["match_count"], int) + + def test_only_black_screen_videos_zero_rate(self): + """所有已有视频都是黑屏 → 查重率为 0。""" + deduplicator = VideoDeduplicator() + mock_session = MagicMock() + + videos = [ + _make_existing_video("vid-b1", "md5_b1", ["aaaaaaaaaaaaaaaa"] * 5), + _make_existing_video("vid-b2", "md5_b2", ["bbbbbbbbbbbbbbbb"] * 5), + ] + mock_repo = MagicMock() + mock_repo.list_by_user.return_value = videos + + fingerprint = VideoFingerprint( + md5="md5_new", + keyframe_phashes=["aaaaaaaaaaaaaaaa"] * 5, + color_histograms=[], + duration=10.0, + resolution=(1280, 720), + ) + + with patch( + "apps.worker.video_processing.dedup.SQLAlchemyGeneratedVideoRepository", + return_value=mock_repo, + ): + result = deduplicator.compute_duplicate_rate( + fingerprint, + "proj-1", + "vid-new", + mock_session, + scope="user", + user_id="user-1", + ) + + assert result["duplicate_rate"] == 0.0 + assert result["match_count"] == 0 -- 2.54.0 From 3fcc65840e1d8340678de1ea6e31f98042084c63 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 14:59:25 +0800 Subject: [PATCH 28/39] fix: unify extract video voice progress text & cleanup file input (#1689) Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/pages/voices/components/VideoExtractModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/voices/components/VideoExtractModal.tsx b/apps/web/src/pages/voices/components/VideoExtractModal.tsx index 40292bce7..7b3020b48 100644 --- a/apps/web/src/pages/voices/components/VideoExtractModal.tsx +++ b/apps/web/src/pages/voices/components/VideoExtractModal.tsx @@ -30,6 +30,7 @@ const VideoExtractModal: React.FC = ({ title={提取视频配音} open={open} onCancel={() => { + if (inputRef.current) inputRef.current.value = "" if (isExtracting) return onClose() }} @@ -152,7 +153,7 @@ const VideoExtractModal: React.FC = ({ {isExtracting && (

- {progress === 100 ? "正在提取人声,请稍候..." : "正在上传视频..."} + {"正在提取音频,请稍后..."}

)} -- 2.54.0 From 8a3115bc54265fbfa9099b1814ab963893ef7708 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 15:16:45 +0800 Subject: [PATCH 29/39] fix: title default position lower & drag stability (#1690) Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- .../pages/editing-planner/components/PreviewPlayer.tsx | 2 +- .../pages/generate/components/FrontendPreviewPlayer.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx index 9daec44bd..8bf4ed57f 100644 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -87,7 +87,7 @@ const PreviewPlayer: React.FC = ({ WebkitTextStroke: "1px rgba(0,0,0,0.6)", top: titleConfig.position === "top" - ? "8px" + ? "6.25%" : titleConfig.position === "center" ? "50%" : "auto", diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 61ce703f9..35387d1fe 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -591,6 +591,8 @@ const FrontendPreviewPlayer: React.FC = ({
= ({ textAlign: "center" as const, } : { - left: `${titleSidePct}%`, - right: `${titleSidePct}%`, + left: "50%", + transform: "translateX(-50%)", textAlign: "center" as const, ...(titleSettings.position === "top" ? { top: `${titleTopPct}%` } : titleSettings.position === "center" - ? { top: "50%", transform: "translateY(-50%)" } + ? { top: "50%", transform: "translate(-50%, -50%)" } : { bottom: `${titleBottomPct}%` }), }), pointerEvents: "auto", -- 2.54.0 From e86f137c3dba4447882ace3ac8b2628982457da6 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 15:19:34 +0800 Subject: [PATCH 30/39] =?UTF-8?q?fix:=20=E6=A0=87=E9=A2=98=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=BD=8D=E7=BD=AE=20fallback=20=E6=94=B9=E4=B8=BA=20b?= =?UTF-8?q?ottom=EF=BC=8C=E4=B8=8E=E5=89=8D=E7=AB=AF=20DEFAULT=5FTITLE=5FS?= =?UTF-8?q?ETTINGS=20=E5=AF=B9=E9=BD=90=20(#1691)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../video_processing/subtitle_generator.py | 2 +- packages/domain/ass_subtitle_builder.py | 9 ++-- .../unit/domain/test_ass_subtitle_builder.py | 9 ++-- tests/unit/test_ass_subtitle_builder.py | 50 +++++++++++++++++-- tests/unit/test_render_subtitles.py | 10 ++-- tests/unit/test_render_subtitles_pure.py | 16 +++--- 6 files changed, 69 insertions(+), 27 deletions(-) diff --git a/apps/worker/video_processing/subtitle_generator.py b/apps/worker/video_processing/subtitle_generator.py index b8c6706bd..caa7f63c2 100755 --- a/apps/worker/video_processing/subtitle_generator.py +++ b/apps/worker/video_processing/subtitle_generator.py @@ -213,7 +213,7 @@ def generate_ass_from_timeline( t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0, t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0, ) - t_alignment = position_to_ass_alignment(title_cfg.get("position", "top")) + t_alignment = position_to_ass_alignment(title_cfg.get("position", "bottom")) title_style_line = build_ass_style( "TitleStyle", diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py index eb3f4bf6d..9d949200d 100755 --- a/packages/domain/ass_subtitle_builder.py +++ b/packages/domain/ass_subtitle_builder.py @@ -81,14 +81,14 @@ def position_to_ass_alignment(position: str) -> int: position: 位置字符串 top/center/bottom Returns: - ASS 对齐编号,默认 8(顶部居中) + ASS 对齐编号,默认 2(底部居中,与前端 DEFAULT_TITLE_SETTINGS.position="bottom" 对齐) """ mapping = { "top": 8, "center": 5, "bottom": 2, } - return mapping.get(position, 8) + return mapping.get(position, 2) # ── Style 行构建 ────────────────────────────────────────────────────────────── @@ -226,7 +226,6 @@ def _wrap_title_text( # 换行计算使用原始 font_size,与 CSS 预览一致;1.35x 补偿仅用于 ASS Fontsize 渲染 - # 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回 segments = text.split("\\N") wrapped_segments: list[str] = [] @@ -386,8 +385,8 @@ def build_ass_content( # position → alignment 三档逻辑,现有输出保持一字节不变。 title_pos = _parse_title_position(title_config, video_width, video_height) - title_alignment = 5 if title_pos is not None else position_to_ass_alignment( - title_config.get("position", "top") + title_alignment = ( + 5 if title_pos is not None else position_to_ass_alignment(title_config.get("position", "bottom")) ) styles.append( diff --git a/tests/unit/domain/test_ass_subtitle_builder.py b/tests/unit/domain/test_ass_subtitle_builder.py index 15cb76256..5040fcf4d 100644 --- a/tests/unit/domain/test_ass_subtitle_builder.py +++ b/tests/unit/domain/test_ass_subtitle_builder.py @@ -83,11 +83,11 @@ class TestPositionToAssAlignment: def test_bottom(self): assert position_to_ass_alignment("bottom") == 2 - def test_unknown_defaults_top(self): - assert position_to_ass_alignment("unknown") == 8 + def test_unknown_defaults_bottom(self): + assert position_to_ass_alignment("unknown") == 2 - def test_empty_defaults_top(self): - assert position_to_ass_alignment("") == 8 + def test_empty_defaults_bottom(self): + assert position_to_ass_alignment("") == 2 # ============================================================ @@ -581,6 +581,7 @@ class TestConstants: assert isinstance(TITLE_MARGIN_BOTTOM, int) assert isinstance(TITLE_MARGIN_SIDE, int) + # ============================================================ # _wrap_title_text 换行逻辑验证 # ============================================================ diff --git a/tests/unit/test_ass_subtitle_builder.py b/tests/unit/test_ass_subtitle_builder.py index 4a4e98f5d..4b095fd96 100755 --- a/tests/unit/test_ass_subtitle_builder.py +++ b/tests/unit/test_ass_subtitle_builder.py @@ -65,11 +65,11 @@ class TestPositionToAssAlignment: def test_bottom(self): assert position_to_ass_alignment("bottom") == 2 - def test_unknown_default_top(self): - assert position_to_ass_alignment("unknown") == 8 + def test_unknown_default_bottom(self): + assert position_to_ass_alignment("unknown") == 2 - def test_empty_default_top(self): - assert position_to_ass_alignment("") == 8 + def test_empty_default_bottom(self): + assert position_to_ass_alignment("") == 2 # ── Style 行构建 ───────────────────────────────────────────────────────────── @@ -747,3 +747,45 @@ class TestTitleFreePosition: line for line in content.splitlines() if line.startswith("Dialogue:") and "SubtitleStyle" in line ][0] assert "\\pos(" not in sub_dialogue + + +class TestDefaultPositionBottom: + """默认 position 应为 bottom(alignment=2),与前端 DEFAULT_TITLE_SETTINGS 对齐。""" + + def _base_kwargs(self): + return dict( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + ) + + def test_no_position_defaults_to_bottom_alignment(self): + """不传 position 时,Alignment 应为 2(bottom)。""" + content = build_ass_content( + **self._base_kwargs(), + title_config={"size": 36}, + ) + style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] + fields = [f.strip() for f in style_line.split(",")] + assert fields[18] == "2", f"Expected alignment 2 (bottom), got {fields[18]}" + + def test_no_position_no_coords_defaults_to_bottom(self): + """不传 position 也不传坐标时,走 bottom 三档逻辑。""" + content = build_ass_content( + **self._base_kwargs(), + title_config={}, + ) + style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] + fields = [f.strip() for f in style_line.split(",")] + assert fields[18] == "2" + + def test_explicit_top_still_works(self): + """显式传 position='top' 仍然得到 alignment=8。""" + content = build_ass_content( + **self._base_kwargs(), + title_config={"position": "top", "size": 36}, + ) + style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] + fields = [f.strip() for f in style_line.split(",")] + assert fields[18] == "8" diff --git a/tests/unit/test_render_subtitles.py b/tests/unit/test_render_subtitles.py index c0304e986..22317c8af 100755 --- a/tests/unit/test_render_subtitles.py +++ b/tests/unit/test_render_subtitles.py @@ -67,11 +67,11 @@ class TestPositionToAssAlignment: def test_bottom(self): assert _position_to_ass_alignment("bottom") == 2 - def test_unknown_returns_top_default(self): - assert _position_to_ass_alignment("unknown") == 8 - assert _position_to_ass_alignment("") == 8 - assert _position_to_ass_alignment("left") == 8 - assert _position_to_ass_alignment(None) == 8 + def test_unknown_returns_bottom_default(self): + assert _position_to_ass_alignment("unknown") == 2 + assert _position_to_ass_alignment("") == 2 + assert _position_to_ass_alignment("left") == 2 + assert _position_to_ass_alignment(None) == 2 class TestBuildAssStyle: diff --git a/tests/unit/test_render_subtitles_pure.py b/tests/unit/test_render_subtitles_pure.py index 4f73aac49..bdf4a6f08 100644 --- a/tests/unit/test_render_subtitles_pure.py +++ b/tests/unit/test_render_subtitles_pure.py @@ -66,15 +66,15 @@ class TestPositionToAssAlignment: """center → 居中(5).""" assert _position_to_ass_alignment("center") == 5 - def test_unknown_defaults_to_top(self): - """未知位置默认顶部(8).""" - assert _position_to_ass_alignment("unknown") == 8 - assert _position_to_ass_alignment("top_left") == 8 - assert _position_to_ass_alignment("bottom_right") == 8 + def test_unknown_defaults_to_bottom(self): + """未知位置默认底部(2).""" + assert _position_to_ass_alignment("unknown") == 2 + assert _position_to_ass_alignment("top_left") == 2 + assert _position_to_ass_alignment("bottom_right") == 2 - def test_empty_string_defaults_to_top(self): - """空字符串默认顶部.""" - assert _position_to_ass_alignment("") == 8 + def test_empty_string_defaults_to_bottom(self): + """空字符串默认底部.""" + assert _position_to_ass_alignment("") == 2 class TestBuildAssStyle: -- 2.54.0 From db244fe14cec37274da6822a52ae93084aef59e5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 16:09:23 +0800 Subject: [PATCH 31/39] =?UTF-8?q?feat:=20=E7=89=87=E6=AE=B5=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E8=87=AA=E5=8A=A8=E5=AF=B9=E9=BD=90=E9=85=8D=E9=9F=B3?= =?UTF-8?q?=E6=97=B6=E9=95=BF=20(#1693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unified_render_service.py | 164 +++++++++++++- tests/unit/test_voice_duration_alignment.py | 206 ++++++++++++++++++ 2 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_voice_duration_alignment.py diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 4dd3323a4..6cf934373 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -198,8 +198,13 @@ class UnifiedRenderService: # 2. 分组为 RenderLayers layers = self._group_clips_into_layers(resolved) + # 2.5 配音时长对齐:如果有配音素材,调整片段时长以匹配配音时长 + voice_duration = self._get_voice_audio_duration() + if voice_duration > 0: + self._align_clips_to_voice_duration(layers, voice_duration) + # 3. 计算视频总时长(用于字幕显示时长) - video_duration = self._estimate_total_duration(layers) + video_duration_final = self._estimate_total_duration(layers) # Debug: 输出各图层时长明细 for layer in layers: layer_total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in layer.clips) @@ -215,16 +220,16 @@ class UnifiedRenderService: self.transition_duration, ", ".join(clip_details), ) - logger.info("[debug] estimated video_duration=%.3f", video_duration) + logger.info("[debug] estimated video_duration=%.3f", video_duration_final) # 3.5 TTS 配音生成(如果配置了) - self._maybe_add_voiceover_layer(layers, video_duration=video_duration) + self._maybe_add_voiceover_layer(layers, video_duration=video_duration_final) # 3.6 配音素材库音频(如果传入了本地路径) - self._maybe_add_voice_library_layer(layers, video_duration=video_duration) + self._maybe_add_voice_library_layer(layers, video_duration=video_duration_final) # 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置) - ass_path = self._maybe_generate_ass(video_duration) + ass_path = self._maybe_generate_ass(video_duration_final) # 4.5 解析画中画配置 pip_config = PiPConfig.from_dict((self.plan.config or {}).get("pip_config")) @@ -257,7 +262,7 @@ class UnifiedRenderService: # 先尝试 stream copy 优化(无重编码,性能提升 10 倍+) # 条件不满足或失败时回退到带滤镜的直通渲染 stream_copy_ok = self._try_render_stream_copy( - layers, output_path, ass_path=ass_path, video_duration=video_duration + layers, output_path, ass_path=ass_path, video_duration=video_duration_final ) if stream_copy_ok: used_stream_copy = True @@ -271,7 +276,7 @@ class UnifiedRenderService: layers, output_path, ass_path=ass_path, - video_duration=video_duration, + video_duration=video_duration_final, ) else: filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path) @@ -327,7 +332,7 @@ class UnifiedRenderService: from video_processing.ffmpeg_utils import run_ffmpeg run_ffmpeg(extract_cmd) - final_audio = mix_bgm_with_main(ctx, main_audio_path, bgm_cfg, video_duration) + final_audio = mix_bgm_with_main(ctx, main_audio_path, bgm_cfg, video_duration_final) # 合并回视频 bgm_output = self.work_dir / f"rendered_{self.plan.id}_bgm.mp4" @@ -353,7 +358,7 @@ class UnifiedRenderService: audio_path = mix_audio( ctx, layers, - video_duration, + video_duration_final, bgm_path=self.bgm_path, bgm_config=bgm_config, audio_tracks_config=audio_tracks_config, @@ -487,6 +492,147 @@ class UnifiedRenderService: """ return _estimate_total_duration_pure(layers, self.transition_duration) + def _get_voice_audio_duration(self) -> float: + """获取配音音频文件的时长(秒)。 + + Returns: + 配音音频时长,如果无配音或探测失败则返回 0.0 + """ + if not self.voiceover_audio_path: + return 0.0 + + audio_path = Path(self.voiceover_audio_path) + if not audio_path.exists() or audio_path.stat().st_size == 0: + return 0.0 + + try: + duration = probe_duration(audio_path) + logger.info("[voice-align] 配音音频时长: %.3fs path=%s", duration, self.voiceover_audio_path) + return duration + except Exception as e: + logger.warning("[voice-align] 探测配音音频时长失败: %s", e) + return 0.0 + + def _align_clips_to_voice_duration( + self, + layers: list[RenderLayer], + voice_duration: float, + ) -> None: + """调整片段时长以对齐配音时长。 + + 核心逻辑: + - 计算片段总时长与配音时长的比例 + - ±5% 以内不调整 + - ratio < 1(片段比配音长):按比例裁剪每段末尾 + - ratio > 1(片段比配音短):按比例慢放每段 + + Args: + layers: 渲染图层列表 + voice_duration: 配音时长(秒) + """ + if voice_duration <= 0: + return + + # 只调整视频图层(main/broll/background),不调整音频图层 + video_layers = [layer for layer in layers if layer.role in ("main", "broll", "background")] + if not video_layers: + return + + # 计算所有视频图层的总时长 + total_clips_duration = 0.0 + for layer in video_layers: + for clip in layer.clips: + clip_dur = self._clip_adjusted_duration(clip) + total_clips_duration += clip_dur + + if total_clips_duration <= 0: + return + + ratio = voice_duration / total_clips_duration + + # ±5% 以内不调整 + if abs(ratio - 1.0) <= 0.05: + logger.info( + "[voice-align] 比例接近1:1,跳过调整: ratio=%.4f voice=%.3f clips=%.3f", + ratio, + voice_duration, + total_clips_duration, + ) + return + + logger.info( + "[voice-align] 开始调整片段时长: ratio=%.4f voice=%.3f clips=%.3f", + ratio, + voice_duration, + total_clips_duration, + ) + + # 收集所有视频 clip + all_clips: list[tuple[RenderLayer, ResolvedClip]] = [] + for layer in video_layers: + for clip in layer.clips: + all_clips.append((layer, clip)) + + if not all_clips: + return + + if ratio < 1.0: + # 片段比配音长,按比例裁剪每段末尾 + # 减少每个 clip 的 duration + for _layer, clip in all_clips: + old_duration = clip.duration if clip.duration > 0 else clip.actual_duration + new_duration = old_duration * ratio + + # 更新 duration + clip.duration = max(0.1, new_duration) # 至少 0.1s + + # 如果有 trim_config,也需要调整 + if clip.trim_config is not None: + new_trim_duration = clip.trim_config.duration * ratio + clip.trim_config = TrimConfig( + start_time=clip.trim_config.start_time, + duration=max(0.1, new_trim_duration), + ) + + logger.debug( + "[voice-align] trim clip=%s: %.3f -> %.3f", + clip.clip_id, + old_duration, + clip.duration, + ) + + else: + # ratio > 1.0: 片段比配音短,按比例慢放每段 + # 降低 playback_speed + for _layer, clip in all_clips: + old_speed = clip.playback_speed if clip.playback_speed > 0 else 1.0 + # speed = old_speed / ratio 会使视频变慢(ratio > 1 时) + new_speed = old_speed / ratio + + # 下限 0.25x(避免过慢) + new_speed = max(0.25, round(new_speed, 4)) + clip.playback_speed = new_speed + + logger.debug( + "[voice-align] slowdown clip=%s: speed %.4f -> %.4f", + clip.clip_id, + old_speed, + new_speed, + ) + + # 调整后重新计算总时长用于日志 + new_total = 0.0 + for layer in video_layers: + for clip in layer.clips: + new_total += self._clip_adjusted_duration(clip) + + logger.info( + "[voice-align] 调整完成: 新总时长=%.3fs (目标=%.3fs, 差异=%.3fs)", + new_total, + voice_duration, + abs(new_total - voice_duration), + ) + def _maybe_generate_ass(self, video_duration: float) -> Path | None: """根据 plan.config 生成 ASS 字幕文件。 diff --git a/tests/unit/test_voice_duration_alignment.py b/tests/unit/test_voice_duration_alignment.py new file mode 100644 index 000000000..74fdb3e0b --- /dev/null +++ b/tests/unit/test_voice_duration_alignment.py @@ -0,0 +1,206 @@ +"""Tests for voice duration alignment feature. + +Tests the _align_clips_to_voice_duration method in UnifiedRenderService. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from video_processing.unified_render_service import ResolvedClip, RenderLayer, UnifiedRenderService + + +class TestAlignClipsToVoiceDuration: + """Test clip duration alignment to voice audio.""" + + def _make_clip( + self, + clip_id: str, + duration: float, + actual_duration: float = 0.0, + playback_speed: float = 1.0, + ) -> ResolvedClip: + """Helper to create a ResolvedClip for testing.""" + return ResolvedClip( + clip_id=clip_id, + asset_id=f"asset_{clip_id}", + local_path=Path(f"/tmp/{clip_id}.mp4"), + clip_type="main", + order=0, + duration=duration, + actual_duration=actual_duration or duration, + playback_speed=playback_speed, + ) + + def _make_layer(self, role: str, clips: list[ResolvedClip]) -> RenderLayer: + """Helper to create a RenderLayer for testing.""" + return RenderLayer(role=role, clips=clips, z_index=0) + + def _make_service(self, voiceover_path: str | None = None) -> UnifiedRenderService: + """Helper to create a mock UnifiedRenderService.""" + plan = MagicMock() + plan.id = "test_plan" + plan.config = {} + + with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): + service = UnifiedRenderService.__new__(UnifiedRenderService) + service.plan = plan + service.voiceover_audio_path = voiceover_path + service.transition_duration = 0.0 + return service + + def test_no_voice_audio_no_adjustment(self): + """No voice audio → no adjustment.""" + service = self._make_service(voiceover_path=None) + clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)] + layers = [self._make_layer("main", clips)] + + service._align_clips_to_voice_duration(layers, voice_duration=0.0) + + # No change + assert clips[0].duration == 10.0 + assert clips[1].duration == 10.0 + + def test_ratio_within_5_percent_no_adjustment(self): + """Ratio within ±5% → no adjustment.""" + service = self._make_service() + clips = [self._make_clip("c1", 10.0)] + layers = [self._make_layer("main", clips)] + + # Total clips = 10s, voice = 10.3s → ratio = 1.03 (within 5%) + service._align_clips_to_voice_duration(layers, voice_duration=10.3) + + assert clips[0].duration == 10.0 # Unchanged + + def test_ratio_less_than_1_trim_clips(self): + """Ratio < 1 (clips too long) → trim clips proportionally.""" + service = self._make_service() + clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)] + layers = [self._make_layer("main", clips)] + + # Total clips = 20s, voice = 15s → ratio = 0.75 + service._align_clips_to_voice_duration(layers, voice_duration=15.0) + + # Each clip should be trimmed to 75% + assert abs(clips[0].duration - 7.5) < 0.01 + assert abs(clips[1].duration - 7.5) < 0.01 + + def test_ratio_greater_than_1_slowdown_clips(self): + """Ratio > 1 (clips too short) → slow down clips.""" + service = self._make_service() + clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)] + layers = [self._make_layer("main", clips)] + + # Total clips = 20s, voice = 25s → ratio = 1.25 + service._align_clips_to_voice_duration(layers, voice_duration=25.0) + + # Each clip's speed should be reduced: 1.0 / 1.25 = 0.8 + assert abs(clips[0].playback_speed - 0.8) < 0.01 + assert abs(clips[1].playback_speed - 0.8) < 0.01 + + def test_speed_lower_bound_025(self): + """Playback speed should not go below 0.25x.""" + service = self._make_service() + clips = [self._make_clip("c1", 5.0)] + layers = [self._make_layer("main", clips)] + + # Total clips = 5s, voice = 50s → ratio = 10.0 + # Speed would be 1.0 / 10 = 0.1, but should be clamped to 0.25 + service._align_clips_to_voice_duration(layers, voice_duration=50.0) + + assert clips[0].playback_speed == 0.25 + + def test_only_video_layers_adjusted(self): + """Only main/broll/background layers are adjusted, not audio.""" + service = self._make_service() + + video_clips = [self._make_clip("v1", 10.0)] + audio_clips = [self._make_clip("a1", 10.0)] + + layers = [ + self._make_layer("main", video_clips), + self._make_layer("audio", audio_clips), + ] + + # ratio = 0.5 → should trim video but not audio + service._align_clips_to_voice_duration(layers, voice_duration=5.0) + + assert abs(video_clips[0].duration - 5.0) < 0.01 # Trimmed + assert audio_clips[0].duration == 10.0 # Unchanged + + def test_multiple_video_layers_all_adjusted(self): + """All video layers (main, broll, background) are adjusted.""" + service = self._make_service() + + main_clips = [self._make_clip("m1", 10.0)] + broll_clips = [self._make_clip("b1", 10.0)] + bg_clips = [self._make_clip("bg1", 10.0)] + + layers = [ + self._make_layer("main", main_clips), + self._make_layer("broll", broll_clips), + self._make_layer("background", bg_clips), + ] + + # Total video = 30s, voice = 15s → ratio = 0.5 + service._align_clips_to_voice_duration(layers, voice_duration=15.0) + + # All should be trimmed to 50% + assert abs(main_clips[0].duration - 5.0) < 0.01 + assert abs(broll_clips[0].duration - 5.0) < 0.01 + assert abs(bg_clips[0].duration - 5.0) < 0.01 + + def test_trim_config_also_adjusted(self): + """When clip has trim_config, it should also be adjusted.""" + from video_processing.trim_engine import TrimConfig + + service = self._make_service() + + clip = self._make_clip("c1", 10.0) + clip.trim_config = TrimConfig(start_time=0.0, duration=10.0) + + layers = [self._make_layer("main", [clip])] + + # ratio = 0.5 + service._align_clips_to_voice_duration(layers, voice_duration=5.0) + + assert abs(clip.duration - 5.0) < 0.01 + assert clip.trim_config is not None + assert abs(clip.trim_config.duration - 5.0) < 0.01 + + +class TestGetVoiceAudioDuration: + """Test voice audio duration probing.""" + + def test_no_voiceover_path_returns_zero(self): + """No voiceover path → return 0.""" + with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): + service = UnifiedRenderService.__new__(UnifiedRenderService) + service.voiceover_audio_path = None + + assert service._get_voice_audio_duration() == 0.0 + + def test_nonexistent_file_returns_zero(self): + """Nonexistent file → return 0.""" + with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): + service = UnifiedRenderService.__new__(UnifiedRenderService) + service.voiceover_audio_path = "/nonexistent/path.mp3" + + assert service._get_voice_audio_duration() == 0.0 + + @patch("video_processing.unified_render_service.probe_duration") + @patch("video_processing.unified_render_service.Path.exists", return_value=True) + @patch("video_processing.unified_render_service.Path.stat") + def test_probes_duration_from_file(self, mock_stat, mock_exists, mock_probe): + """Valid file → probe duration.""" + mock_stat.return_value.st_size = 1000 # Non-empty file + mock_probe.return_value = 42.5 + + with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): + service = UnifiedRenderService.__new__(UnifiedRenderService) + service.voiceover_audio_path = "/tmp/voice.mp3" + + assert service._get_voice_audio_duration() == 42.5 -- 2.54.0 From 4263e7f6ca119f33061df602455601867533b3d3 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 16:09:41 +0800 Subject: [PATCH 32/39] =?UTF-8?q?fix:=20=E9=85=8D=E9=9F=B3=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E8=BF=87=E6=BB=A4=20+=20=E7=A7=BB=E9=99=A4=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E8=AD=A6=E5=91=8A=20UI=20(#1692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- .../components/Step1TemplateSelect.tsx | 4 +- .../generate/components/Step5VoiceSelect.tsx | 118 ++++-------------- 2 files changed, 26 insertions(+), 96 deletions(-) diff --git a/apps/web/src/pages/generate/components/Step1TemplateSelect.tsx b/apps/web/src/pages/generate/components/Step1TemplateSelect.tsx index d39a864b3..52cd525ee 100644 --- a/apps/web/src/pages/generate/components/Step1TemplateSelect.tsx +++ b/apps/web/src/pages/generate/components/Step1TemplateSelect.tsx @@ -47,9 +47,7 @@ const Step1TemplateSelect: React.FC = (props) => { 🎬

{tpl.name}

-

- {tpl.estimated_duration}s · {tpl.segments.length}片段 -

+

{tpl.segments.length}片段

{tpl.tags.length > 0 && (
{ return item.duration ?? (item.metadata?.duration as number) ?? 0 @@ -34,13 +33,6 @@ const isAiVoice = (item: AssetItem): boolean => { return (!duration || duration <= 0) && (!size || size <= 0) } -const formatDuration = (seconds?: number): string => { - if (!seconds || seconds <= 0) return "00:00" - const m = Math.floor(seconds / 60) - const s = Math.floor(seconds % 60) - return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` -} - /** 格式化文件大小 */ const formatFileSize = (bytes?: number): string => { if (!bytes || bytes <= 0) return "未知" @@ -58,8 +50,6 @@ const Step5VoiceSelect: React.FC = ({ const navigate = useNavigate() const [playingId, setPlayingId] = useState(null) const audioRef = useRef(null) - const [durationWarningOpen, setDurationWarningOpen] = useState(false) - const [pendingVoiceId, setPendingVoiceId] = useState(null) // 获取用户上传的配音素材 const { data: materials = [], isLoading } = useQuery({ @@ -67,6 +57,19 @@ const Step5VoiceSelect: React.FC = ({ queryFn: () => getAssetsByKind("voice", { limit: 50 }), }) + // 根据视频总时长过滤配音:只保留时长在 ±10% 以内的素材,AI 音色始终展示 + const filteredMaterials = React.useMemo(() => { + if (totalVideoDuration <= 0) return materials + return materials.filter((item) => { + // AI 音色没有固定时长,始终保留 + if (isAiVoice(item)) return true + const duration = getDuration(item) + if (duration <= 0) return true + const ratio = duration / totalVideoDuration + return ratio >= 0.9 && ratio <= 1.1 + }) + }, [materials, totalVideoDuration]) + /** 切换播放/暂停 */ const togglePlay = useCallback( (material: AssetItem) => { @@ -100,38 +103,14 @@ const Step5VoiceSelect: React.FC = ({ [playingId], ) - /** 选中素材(含时长校验) */ + /** 选中素材(直接选中,不再做时长校验弹窗) */ const handleSelect = useCallback( (id: string) => { - // 如果启用了时长校验,且配音时长不足(AI 音色按脚本实时合成,不参与时长校验) - if (totalVideoDuration > 0) { - const material = materials.find((m) => m.id === id) - if (material && !isAiVoice(material) && getDuration(material) < totalVideoDuration) { - setPendingVoiceId(id) - setDurationWarningOpen(true) - return - } - } onSelectedVoiceChange(id) }, - [onSelectedVoiceChange, totalVideoDuration, materials], + [onSelectedVoiceChange], ) - /** 确认使用时长不足的配音 */ - const handleConfirmUseAnyway = useCallback(() => { - if (pendingVoiceId) { - onSelectedVoiceChange(pendingVoiceId) - } - setDurationWarningOpen(false) - setPendingVoiceId(null) - }, [pendingVoiceId, onSelectedVoiceChange]) - - /** 取消选择 */ - const handleCancelSelection = useCallback(() => { - setDurationWarningOpen(false) - setPendingVoiceId(null) - }, []) - /** 跳转到配音库上传 */ const handleGoToUpload = useCallback(() => { navigate("/app/voices?tab=material&upload=1") @@ -196,7 +175,7 @@ const Step5VoiceSelect: React.FC = ({ gap: 12, }} > - {materials.map((item) => { + {filteredMaterials.map((item) => { const isSelected = selectedVoice === item.id const isPlaying = playingId === item.id @@ -277,7 +256,7 @@ const Step5VoiceSelect: React.FC = ({ {item.name}
- {/* 时长 + 大小 */} + {/* 文件大小 */}
= ({ > {isAiVoice(item) ? ( AI 音色 - ) : ( - - {formatDuration(getDuration(item))} - {totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && ( - - - 时长不足 - - )} - - )} + ) : null} {isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}
) })} - - {/* 时长不足警告弹窗 */} - - - 配音时长不足 -
- } - open={durationWarningOpen} - onOk={handleConfirmUseAnyway} - onCancel={handleCancelSelection} - okText="仍要使用" - cancelText="重新选择" - okButtonProps={{ danger: true }} - > - {(() => { - const pendingMaterial = pendingVoiceId - ? materials.find((m) => m.id === pendingVoiceId) - : null - return ( -

- 该配音时长( - - {pendingMaterial ? formatDuration(getDuration(pendingMaterial)) : "--"} - - )短于视频总时长( - {formatDuration(totalVideoDuration)} - ),播放时配音可能提前结束,建议选择更长的配音素材。 -

- ) - })()} - + {totalVideoDuration > 0 && filteredMaterials.length < materials.length && ( +

+ 已根据视频时长({Math.round(totalVideoDuration)}s)自动过滤时长差异过大的配音 +

+ )} ) } -- 2.54.0 From eac05dee30794fc19d266e58ef8227c0b19c727f Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 4 Sep 2026 08:14:17 +0000 Subject: [PATCH 33/39] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_voice_duration_alignment.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/unit/test_voice_duration_alignment.py b/tests/unit/test_voice_duration_alignment.py index 74fdb3e0b..11f3a5036 100644 --- a/tests/unit/test_voice_duration_alignment.py +++ b/tests/unit/test_voice_duration_alignment.py @@ -9,8 +9,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest - -from video_processing.unified_render_service import ResolvedClip, RenderLayer, UnifiedRenderService +from video_processing.unified_render_service import RenderLayer, ResolvedClip, UnifiedRenderService class TestAlignClipsToVoiceDuration: @@ -44,7 +43,7 @@ class TestAlignClipsToVoiceDuration: plan = MagicMock() plan.id = "test_plan" plan.config = {} - + with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): service = UnifiedRenderService.__new__(UnifiedRenderService) service.plan = plan @@ -116,10 +115,10 @@ class TestAlignClipsToVoiceDuration: def test_only_video_layers_adjusted(self): """Only main/broll/background layers are adjusted, not audio.""" service = self._make_service() - + video_clips = [self._make_clip("v1", 10.0)] audio_clips = [self._make_clip("a1", 10.0)] - + layers = [ self._make_layer("main", video_clips), self._make_layer("audio", audio_clips), @@ -134,11 +133,11 @@ class TestAlignClipsToVoiceDuration: def test_multiple_video_layers_all_adjusted(self): """All video layers (main, broll, background) are adjusted.""" service = self._make_service() - + main_clips = [self._make_clip("m1", 10.0)] broll_clips = [self._make_clip("b1", 10.0)] bg_clips = [self._make_clip("bg1", 10.0)] - + layers = [ self._make_layer("main", main_clips), self._make_layer("broll", broll_clips), @@ -156,12 +155,12 @@ class TestAlignClipsToVoiceDuration: def test_trim_config_also_adjusted(self): """When clip has trim_config, it should also be adjusted.""" from video_processing.trim_engine import TrimConfig - + service = self._make_service() - + clip = self._make_clip("c1", 10.0) clip.trim_config = TrimConfig(start_time=0.0, duration=10.0) - + layers = [self._make_layer("main", [clip])] # ratio = 0.5 -- 2.54.0 From f1bd2d6f1d475b9f2de002bd8299d658309508f3 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 16:46:52 +0800 Subject: [PATCH 34/39] =?UTF-8?q?fix:=20=E7=BC=96=E8=BE=91=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E6=B7=BB=E5=8A=A0=E7=89=87=E6=AE=B5=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=B6=E9=95=BF=E8=BE=93=E5=85=A5=20(#1694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- .../components/TimelinePanel.tsx | 4 --- .../components/timeline/AddClipPicker.tsx | 26 ------------------- .../hooks/timeline-menus/useAddPicker.ts | 9 +++---- .../editing-planner/hooks/useTimelineMenus.ts | 2 -- 4 files changed, 3 insertions(+), 38 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 8085576c2..29e439560 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -106,9 +106,7 @@ const TimelinePanel: React.FC = ({ pickerPos, availableTypes, addType, - addDuration, setAddType, - setAddDuration, handleTogglePicker, handleConfirmAdd, hoveredClipId, @@ -212,9 +210,7 @@ const TimelinePanel: React.FC = ({ position={pickerPos} availableTypes={availableTypes} addType={addType} - addDuration={addDuration} onTypeChange={setAddType} - onDurationChange={setAddDuration} onConfirm={handleConfirmAdd} /> )} diff --git a/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx b/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx index ecab58aa5..0afeb77c5 100644 --- a/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx +++ b/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx @@ -7,12 +7,8 @@ interface AddClipPickerProps { position: { top: number; right: number } availableTypes: ClipType[] addType: ClipType - addDuration: number onTypeChange: (type: ClipType) => void - onDurationChange: (duration: number) => void onConfirm: () => void - minDuration?: number - maxDuration?: number } export const AddClipPicker: React.FC = ({ @@ -20,12 +16,8 @@ export const AddClipPicker: React.FC = ({ position, availableTypes, addType, - addDuration, onTypeChange, - onDurationChange, onConfirm, - minDuration = 1, - maxDuration = 120, }) => { return (
= ({ ))}
- {/* 时长输入 */} -
- 时长: - - onDurationChange( - Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)), - ) - } - /> - -
- {/* 确认按钮 */}