Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 318cffd35c |
Generated
+14
-7
@@ -1848,9 +1848,10 @@
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
@@ -1937,9 +1938,10 @@
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
@@ -3111,9 +3113,10 @@
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
@@ -4454,9 +4457,10 @@
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
@@ -5004,9 +5008,10 @@
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
@@ -5019,9 +5024,10 @@
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -5729,9 +5735,10 @@
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
|
||||
@@ -71,5 +71,4 @@ export interface VideoItem {
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
duplicate_rate?: number
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
duplicate_rate: video.duplicate_rate,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
|
||||
const draggingTitleRef = useRef(false)
|
||||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||||
const handleTitlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !playerContainerRef.current) return
|
||||
@@ -153,45 +152,32 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
},
|
||||
[onTitlePositionChange],
|
||||
)
|
||||
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
|
||||
if (titleDragRef.current) {
|
||||
const handleTitlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current || !onTitlePositionChange || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const xpct = (relX / rect.width) * 100
|
||||
const ypct = (relY / rect.height) * 100
|
||||
titleDragRef.current.style.left = `${xpct}%`
|
||||
titleDragRef.current.style.top = `${ypct}%`
|
||||
}
|
||||
}, [])
|
||||
const handleTitlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current) return
|
||||
draggingTitleRef.current = false
|
||||
// 拖拽结束时才调用 onTitlePositionChange 保存最终位置
|
||||
if (onTitlePositionChange && playerContainerRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const posX = Math.round((relX / rect.width) * playRes.width)
|
||||
const posY = Math.round((relY / rect.height) * playRes.height)
|
||||
onTitlePositionChange(posX, posY)
|
||||
}
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
try {
|
||||
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
|
||||
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const posX = Math.round((relX / rect.width) * playRes.width)
|
||||
const posY = Math.round((relY / rect.height) * playRes.height)
|
||||
onTitlePositionChange(posX, posY)
|
||||
},
|
||||
[onTitlePositionChange, playRes.width, playRes.height],
|
||||
)
|
||||
const handleTitlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current) return
|
||||
draggingTitleRef.current = false
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
try {
|
||||
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
|
||||
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
@@ -608,7 +594,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}),
|
||||
pointerEvents: "auto",
|
||||
cursor: onTitlePositionChange ? "grab" : "default",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
@@ -616,7 +601,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
padding: "8px 12px",
|
||||
boxShadow: "inset 0 0 0 16px transparent",
|
||||
}}
|
||||
ref={titleDragRef}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
|
||||
@@ -81,7 +81,7 @@ export const TITLE_PRESETS = [
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
WebkitTextStroke: "1px #000000",
|
||||
fontSize: "32px",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -92,14 +92,14 @@ export const TITLE_PRESETS = [
|
||||
fontWeight: 700,
|
||||
color: "#d4a843",
|
||||
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
|
||||
fontSize: "32px",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fresh_minimal",
|
||||
label: "清新简约",
|
||||
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "32px" },
|
||||
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
|
||||
},
|
||||
{
|
||||
key: "variety_show",
|
||||
@@ -110,14 +110,14 @@ export const TITLE_PRESETS = [
|
||||
color: "#ff4081",
|
||||
WebkitTextStroke: "1.5px #ffffff",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
fontSize: "32px",
|
||||
fontSize: "22px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
label: "商务极简",
|
||||
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "32px" },
|
||||
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
|
||||
},
|
||||
{
|
||||
key: "retro_film",
|
||||
@@ -127,7 +127,7 @@ export const TITLE_PRESETS = [
|
||||
fontWeight: 400,
|
||||
color: "#e8d5b7",
|
||||
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
||||
fontSize: "32px",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -138,7 +138,7 @@ export const TITLE_PRESETS = [
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "32px",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -149,7 +149,7 @@ export const TITLE_PRESETS = [
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
||||
fontSize: "32px",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1739,31 +1739,30 @@
|
||||
|
||||
.xx-title-preset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
background: #3a3a3a;
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-title-preset-card:hover {
|
||||
border-color: #666;
|
||||
background: #4a4a4a;
|
||||
border-color: var(--primary-200);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active {
|
||||
border-color: #409eff;
|
||||
background: #4a4a4a;
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
height: 18px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-xs);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: var(--transition-all);
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
@@ -185,6 +187,8 @@
|
||||
height: 22px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||
border-radius: var(--radius-xs);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
@@ -258,8 +262,11 @@
|
||||
.xx-product-thumb {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
background: var(--color-gray-950);
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.xx-product-thumb-bg {
|
||||
@@ -289,6 +296,8 @@
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--font-size-md);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
@@ -497,6 +506,8 @@
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
animation: player-fade-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@@ -535,6 +546,8 @@
|
||||
background: var(--color-gray-950);
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 60vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.xx-player-video-wrap video {
|
||||
@@ -556,6 +569,8 @@
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--text-inverse);
|
||||
font-size: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition-all);
|
||||
@@ -652,6 +667,8 @@
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-size-md);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition-all);
|
||||
|
||||
@@ -314,13 +314,10 @@ class VideoDeduplicator:
|
||||
project_id: str,
|
||||
current_video_id: str | None,
|
||||
session: Session,
|
||||
*,
|
||||
user_id: str = "",
|
||||
) -> float:
|
||||
"""计算当前视频与用户库内已有视频的最高相似度百分比。
|
||||
"""计算当前视频与项目内已有视频的最高相似度百分比。
|
||||
|
||||
优先按 user_id 全局比较(跨项目),user_id 为空时回退到项目级比较。
|
||||
遍历最近 200 个其他有指纹的视频,对每个计算相似度:
|
||||
遍历项目内所有其他有指纹的视频,对每个计算相似度:
|
||||
- MD5 精确匹配 → 100%
|
||||
- pHash 相似度 → (1.0 - avg_distance / 64) * 100
|
||||
取最高值作为 duplicate_rate(0~100)。
|
||||
@@ -328,34 +325,23 @@ class VideoDeduplicator:
|
||||
|
||||
Args:
|
||||
fingerprint: 当前视频的指纹
|
||||
project_id: 项目 ID(user_id 为空时的回退范围)
|
||||
project_id: 项目 ID
|
||||
current_video_id: 当前视频 ID(排除自身,可为 None)
|
||||
session: 数据库会话
|
||||
user_id: 用户 ID(优先按用户全局比较)
|
||||
|
||||
Returns:
|
||||
duplicate_rate: 0~100 的浮点数
|
||||
"""
|
||||
# 限制查询最近 200 个视频,避免大库内存溢出
|
||||
# 限制查询最近 100 个视频,避免大项目内存溢出
|
||||
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)
|
||||
|
||||
# 排除当前视频自身(记录可能已写入 DB,必须在查询层排除)
|
||||
if current_video_id:
|
||||
query = query.filter(GeneratedVideoModel.id != current_video_id)
|
||||
|
||||
recent_models = query.order_by(GeneratedVideoModel.generated_at.desc()).limit(200).all()
|
||||
recent_models = (
|
||||
session.query(GeneratedVideoModel)
|
||||
.filter(GeneratedVideoModel.project_id == project_id)
|
||||
.order_by(GeneratedVideoModel.generated_at.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
existing_videos = [video_repo._to_domain(m) for m in recent_models]
|
||||
|
||||
|
||||
@@ -123,13 +123,7 @@ def create_video_record_and_dedup(
|
||||
|
||||
# 计算重复率百分比(与项目内所有已有视频对比取最高相似度)
|
||||
try:
|
||||
dup_rate = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
video_id,
|
||||
session,
|
||||
user_id=user_id,
|
||||
)
|
||||
dup_rate = deduplicator.compute_duplicate_rate(fingerprint, project_id, video_id, session)
|
||||
generated_video.duplicate_rate = dup_rate
|
||||
logger.info("Duplicate rate for %s: %.2f%%", video_id, dup_rate)
|
||||
except Exception as rate_err:
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""测试 create_video_record_and_dedup 传递 user_id 到查重逻辑.
|
||||
|
||||
验证 P0 修复:查重范围从项目级扩大到用户级。
|
||||
dedup_helpers 必须把 user_id 传给 compute_duplicate_rate。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
class TestDedupHelpersUserIdPassthrough:
|
||||
"""验证 dedup_helpers 把 user_id 传递给 compute_duplicate_rate."""
|
||||
|
||||
def test_user_id_passed_to_compute_duplicate_rate(self):
|
||||
"""create_video_record_and_dedup 必须传 user_id 给 compute_duplicate_rate."""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
session = MagicMock()
|
||||
mock_video_repo = MagicMock()
|
||||
|
||||
mock_fingerprint = MagicMock()
|
||||
mock_fingerprint.to_dict.return_value = {"md5": "test", "keyframe_phashes": ["aa"]}
|
||||
|
||||
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
|
||||
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=mock_video_repo,
|
||||
),
|
||||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||||
):
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-001",
|
||||
project_id="proj-001",
|
||||
user_id="user-abc",
|
||||
batch_id="",
|
||||
file_url="https://example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=15.0,
|
||||
video_path="/tmp/fake_video.mp4",
|
||||
mode="smart",
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 验证 compute_duplicate_rate 被调用且 user_id 正确传递
|
||||
mock_deduplicator.compute_duplicate_rate.assert_called_once()
|
||||
call_kwargs = mock_deduplicator.compute_duplicate_rate.call_args
|
||||
assert (
|
||||
call_kwargs.kwargs.get("user_id") == "user-abc"
|
||||
), f"user_id 应传递给 compute_duplicate_rate,实际: {call_kwargs}"
|
||||
|
||||
def test_empty_user_id_still_works(self):
|
||||
"""user_id 为空时仍然正常执行(回退到 project 级比较)."""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
session = MagicMock()
|
||||
mock_video_repo = MagicMock()
|
||||
|
||||
mock_fingerprint = MagicMock()
|
||||
mock_fingerprint.to_dict.return_value = {"md5": "test"}
|
||||
|
||||
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
|
||||
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=mock_video_repo,
|
||||
),
|
||||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||||
):
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-002",
|
||||
project_id="proj-002",
|
||||
user_id="",
|
||||
batch_id="",
|
||||
file_url="https://example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="smart",
|
||||
session=session,
|
||||
)
|
||||
|
||||
mock_deduplicator.compute_duplicate_rate.assert_called_once()
|
||||
call_kwargs = mock_deduplicator.compute_duplicate_rate.call_args
|
||||
assert call_kwargs.kwargs.get("user_id") == ""
|
||||
|
||||
def test_duplicate_rate_saved_to_video_record(self):
|
||||
"""compute_duplicate_rate 的返回值应写入 generated_video.duplicate_rate."""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
session = MagicMock()
|
||||
mock_video_repo = MagicMock()
|
||||
|
||||
mock_fingerprint = MagicMock()
|
||||
mock_fingerprint.to_dict.return_value = {"md5": "test"}
|
||||
|
||||
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
|
||||
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=mock_video_repo,
|
||||
),
|
||||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||||
):
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-003",
|
||||
project_id="proj-003",
|
||||
user_id="user-xyz",
|
||||
batch_id="",
|
||||
file_url="https://example.com/v.mp4",
|
||||
file_size=2048,
|
||||
duration=20.0,
|
||||
video_path="/tmp/fake2.mp4",
|
||||
mode="smart",
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 验证 update 被调用(包含 duplicate_rate 的记录)
|
||||
mock_video_repo.update.assert_called_once()
|
||||
updated_video = mock_video_repo.update.call_args[0][0]
|
||||
assert updated_video.duplicate_rate == 78.5
|
||||
@@ -56,10 +56,9 @@ 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
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = (
|
||||
[]
|
||||
)
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 0.0
|
||||
@@ -74,6 +73,7 @@ class TestComputeDuplicateRate:
|
||||
session = MagicMock()
|
||||
|
||||
existing = self._make_existing_video("existing1", {"md5": "exact_match_md5", "keyframe_phashes": ["aa"]})
|
||||
# Create a mock model with the domain attributes
|
||||
mock_model = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model.id = existing.id
|
||||
mock_model.project_id = existing.project_id
|
||||
@@ -83,12 +83,10 @@ class TestComputeDuplicateRate:
|
||||
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 the session.query chain
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 100.0
|
||||
@@ -115,10 +113,9 @@ class TestComputeDuplicateRate:
|
||||
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
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
# hamming distance = 2, similarity = (1 - 2/64) * 100 = 96.875
|
||||
@@ -143,10 +140,9 @@ class TestComputeDuplicateRate:
|
||||
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
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 0.0
|
||||
@@ -176,89 +172,15 @@ class TestComputeDuplicateRate:
|
||||
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 = [
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model1,
|
||||
mock_model2,
|
||||
]
|
||||
session.query.return_value = query_mock
|
||||
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)
|
||||
|
||||
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")
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
rate = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
"proj1",
|
||||
"vid1",
|
||||
session,
|
||||
user_id="user1",
|
||||
)
|
||||
|
||||
# 应通过 user_id 过滤,且匹配到跨项目视频
|
||||
assert rate == 100.0
|
||||
|
||||
def test_user_id_empty_falls_back_to_project(self):
|
||||
"""user_id 为空时应回退到 project_id 过滤."""
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint()
|
||||
session = MagicMock()
|
||||
|
||||
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
|
||||
|
||||
rate = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
"proj1",
|
||||
"vid1",
|
||||
session,
|
||||
user_id="",
|
||||
)
|
||||
|
||||
assert rate == 0.0
|
||||
# 验证使用的是 project_id 过滤(回退路径)
|
||||
# 通过检查 filter 被调用时的参数来间接验证
|
||||
|
||||
|
||||
class TestDuplicateRateAPI:
|
||||
"""Test that duplicate_rate is returned in API responses."""
|
||||
|
||||
Reference in New Issue
Block a user