23fe5f9822
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 56s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Validate - Style (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m27s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
305 lines
11 KiB
Python
Executable File
305 lines
11 KiB
Python
Executable File
"""查重辅助函数 — 渲染阶段指纹/查重预计算 + 兼容旧入库函数。
|
||
|
||
#2024: Worker 渲染+上传完成后**不直接创建 GeneratedVideo 成品记录**,改为:
|
||
1. ``compute_render_fingerprint_and_dedup``: 从本地视频计算指纹+查重(历史+批次),
|
||
返回可序列化 dict(含 fingerprint_chunks),由 worker 写入
|
||
``GenerationTask.extra_meta["rendered_output"]``;
|
||
2. ``create_video_record_and_dedup``: 保留兼容——当传入 ``video_path`` 时会从本地视频
|
||
计算指纹+查重并直接创建 GeneratedVideo 记录(供测试/旧路径使用);
|
||
当仅传 ``pre_dedup_result`` 时复用预计算结果,不再访问本地视频。
|
||
|
||
finalize 入口走 ``packages/application/generated_video_finalize.py`` 的
|
||
``finalize_generated_video``,不依赖本模块中数据库以外的 worker-only 逻辑。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _safe_parse_fps(raw) -> float:
|
||
if raw is None:
|
||
return 25.0
|
||
if isinstance(raw, (int, float)):
|
||
return float(raw)
|
||
s = str(raw).strip()
|
||
if "/" in s:
|
||
try:
|
||
num, den = s.split("/", 1)
|
||
return float(num) / float(den) if float(den) != 0 else 25.0
|
||
except (ValueError, ZeroDivisionError):
|
||
pass
|
||
try:
|
||
return float(s)
|
||
except (ValueError, TypeError):
|
||
return 25.0
|
||
|
||
|
||
def _compute_from_local(
|
||
*,
|
||
video_path: str,
|
||
generation_task_id: str,
|
||
project_id: str,
|
||
user_id: str,
|
||
batch_id: str,
|
||
session: Session,
|
||
) -> dict:
|
||
"""从本地视频计算指纹+查重,返回可序列化结果 dict(不创建 DB 记录)。"""
|
||
from video_processing.dedup import VideoDeduplicator
|
||
from video_processing.ffmpeg_utils import probe_video_info
|
||
|
||
result: dict = {
|
||
"fingerprint_dict": None,
|
||
"fingerprint_chunks": None,
|
||
"duration": 0.0,
|
||
"width": 1280,
|
||
"height": 720,
|
||
"fps": 25.0,
|
||
"is_duplicate": False,
|
||
"duplicate_of": None,
|
||
"duplicate_rate": None,
|
||
"match_count": None,
|
||
"visual_similarity": None,
|
||
"video_fingerprint_md5": "",
|
||
"batch_similarity": None,
|
||
}
|
||
try:
|
||
info = probe_video_info(video_path)
|
||
result["duration"] = float(info.get("duration") or 0.0)
|
||
result["width"] = int(info.get("width") or 1280)
|
||
result["height"] = int(info.get("height") or 720)
|
||
result["fps"] = _safe_parse_fps(info.get("fps"))
|
||
except Exception as info_err:
|
||
logger.warning("probe_video_info failed for task %s: %s", generation_task_id, info_err)
|
||
|
||
try:
|
||
deduplicator = VideoDeduplicator()
|
||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||
fp_dict = fingerprint.to_dict()
|
||
result["fingerprint_dict"] = fp_dict
|
||
result["video_fingerprint_md5"] = fingerprint.md5 or ""
|
||
result["fingerprint_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 fingerprint.chunks
|
||
]
|
||
|
||
# 用 placeholder_id 占位(还没有真正的 video_id,不影响查重逻辑——
|
||
# 因为查重排除的是 GeneratedVideo 表中的记录)
|
||
placeholder_id = f"pre-{generation_task_id}"
|
||
duration_sec = fingerprint.duration if fingerprint.duration else 0
|
||
duplicate_result = deduplicator.check_duplicate(
|
||
fingerprint,
|
||
project_id,
|
||
session,
|
||
scope="user",
|
||
user_id=user_id,
|
||
duration_sec=duration_sec,
|
||
exclude_video_id=placeholder_id,
|
||
)
|
||
batch_sim: float | None = None
|
||
if not duplicate_result and batch_id:
|
||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, placeholder_id, session)
|
||
if duplicate_result:
|
||
batch_sim = float(duplicate_result.get("similarity", 0.0))
|
||
result["batch_similarity"] = batch_sim
|
||
if duplicate_result:
|
||
result["is_duplicate"] = True
|
||
result["duplicate_of"] = duplicate_result["duplicate_of"]
|
||
else:
|
||
result["is_duplicate"] = False
|
||
|
||
try:
|
||
rate_result = deduplicator.compute_duplicate_rate(
|
||
fingerprint,
|
||
project_id,
|
||
placeholder_id,
|
||
session,
|
||
scope="user",
|
||
user_id=user_id,
|
||
)
|
||
result["duplicate_rate"] = rate_result.get("duplicate_rate")
|
||
result["match_count"] = rate_result.get("match_count")
|
||
result["visual_similarity"] = rate_result.get("visual_similarity")
|
||
except Exception as rate_err:
|
||
logger.warning("compute_duplicate_rate failed for task %s: %s", generation_task_id, rate_err)
|
||
except Exception as fp_err:
|
||
logger.warning("Fingerprint compute failed for task %s: %s", generation_task_id, fp_err)
|
||
|
||
return result
|
||
|
||
|
||
def compute_render_fingerprint_and_dedup(
|
||
*,
|
||
video_path: str,
|
||
generation_task_id: str,
|
||
project_id: str,
|
||
user_id: str,
|
||
batch_id: str,
|
||
mode: str,
|
||
session: Session,
|
||
) -> dict:
|
||
"""渲染+上传完成后的预计算:计算指纹+历史/批次查重,返回可序列化 dict。
|
||
|
||
**不创建 GeneratedVideo 记录**。结果由调用方写入 extra_meta["rendered_output"],
|
||
finalize 时复用。mode 参数保留签名一致性(查重结果中不直接使用)。
|
||
"""
|
||
_ = mode # 保留在签名里便于调用方对齐;查重结果不含 mode
|
||
return _compute_from_local(
|
||
video_path=video_path,
|
||
generation_task_id=generation_task_id,
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
batch_id=batch_id,
|
||
session=session,
|
||
)
|
||
|
||
|
||
def create_video_record_and_dedup(
|
||
*,
|
||
generation_task_id: str,
|
||
project_id: str,
|
||
user_id: str = "",
|
||
batch_id: str,
|
||
file_url: str,
|
||
file_size: int,
|
||
duration: float | None = None,
|
||
video_path: str | None,
|
||
mode: str,
|
||
session: Session,
|
||
width: int = 1280,
|
||
height: int = 720,
|
||
fps: float = 25.0,
|
||
name: str = "",
|
||
thumbnail_url: str = "",
|
||
pre_fingerprint_dict: dict | None = None,
|
||
pre_fingerprint_chunks: list[dict] | None = None,
|
||
pre_dedup_result: dict | None = None,
|
||
) -> dict:
|
||
"""创建 GeneratedVideo 记录 + 可选查重。
|
||
|
||
两种用法:
|
||
- 传入 ``video_path``(非 None):从本地视频计算指纹+查重,直接创建记录(旧路径/测试)。
|
||
- 仅传入 ``pre_*``:复用 worker 预计算结果,不访问本地视频(finalize 用)。
|
||
|
||
Returns:
|
||
{"video_id", "video_count", "is_duplicate", "batch_similarity", "duplicate_of"}
|
||
"""
|
||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||
SQLAlchemyGeneratedVideoRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
|
||
from packages.domain.generated_video import GeneratedVideo
|
||
|
||
try:
|
||
video_id = uuid4().hex
|
||
video_name = name.strip() if name else f"generated-{generation_task_id[:8]}.mp4"
|
||
|
||
# 决定查重/元信息来源
|
||
if video_path:
|
||
pre = _compute_from_local(
|
||
video_path=video_path,
|
||
generation_task_id=generation_task_id,
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
batch_id=batch_id,
|
||
session=session,
|
||
)
|
||
else:
|
||
pre = dict(pre_dedup_result or {})
|
||
pre.setdefault("fingerprint_dict", pre_fingerprint_dict)
|
||
pre.setdefault("fingerprint_chunks", pre_fingerprint_chunks)
|
||
pre.setdefault("is_duplicate", False)
|
||
pre.setdefault("duplicate_of", None)
|
||
pre.setdefault("duplicate_rate", None)
|
||
pre.setdefault("match_count", None)
|
||
pre.setdefault("visual_similarity", None)
|
||
pre.setdefault("batch_similarity", None)
|
||
|
||
used_duration = float(duration if duration is not None else pre.get("duration", 0.0))
|
||
used_width = int(pre.get("width", width) or width)
|
||
used_height = int(pre.get("height", height) or height)
|
||
used_fps = float(pre.get("fps", fps) or fps)
|
||
|
||
generated_video = GeneratedVideo(
|
||
id=video_id,
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
generation_task_id=generation_task_id,
|
||
name=video_name,
|
||
file_url=file_url,
|
||
file_size=file_size,
|
||
duration=used_duration,
|
||
width=used_width,
|
||
height=used_height,
|
||
fps=used_fps,
|
||
status="completed",
|
||
generation_params={"mode": mode},
|
||
thumbnail_url=thumbnail_url or None,
|
||
video_fingerprint=pre.get("fingerprint_dict"),
|
||
is_duplicate=bool(pre.get("is_duplicate", False)),
|
||
duplicate_of=pre.get("duplicate_of"),
|
||
duplicate_rate=pre.get("duplicate_rate"),
|
||
match_count=pre.get("match_count"),
|
||
visual_similarity=pre.get("visual_similarity"),
|
||
)
|
||
|
||
# 写分片指纹表
|
||
chunks = pre.get("fingerprint_chunks")
|
||
if chunks:
|
||
try:
|
||
chunk_models = [
|
||
VideoFingerprintChunkModel(
|
||
id=uuid4().hex,
|
||
video_id=video_id,
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
start_time_ms=int(c.get("start_time_ms", 0)),
|
||
end_time_ms=int(c.get("end_time_ms", 0)),
|
||
phash_binary=str(c.get("phash_binary", "")),
|
||
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
|
||
frame_count=int(c.get("frame_count", 0)),
|
||
)
|
||
for c in chunks
|
||
if isinstance(c, dict)
|
||
]
|
||
if chunk_models:
|
||
# 幂等:先清理旧分片
|
||
session.query(VideoFingerprintChunkModel).filter(
|
||
VideoFingerprintChunkModel.video_id == video_id
|
||
).delete(synchronize_session=False)
|
||
session.bulk_save_objects(chunk_models)
|
||
except Exception as chunk_err:
|
||
logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err)
|
||
|
||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||
repo.create(generated_video)
|
||
session.commit()
|
||
return {
|
||
"video_id": video_id,
|
||
"video_count": 1,
|
||
"is_duplicate": bool(pre.get("is_duplicate", False)),
|
||
"batch_similarity": pre.get("batch_similarity"),
|
||
"duplicate_of": pre.get("duplicate_of"),
|
||
}
|
||
except Exception as e:
|
||
logger.error("Failed to create video record for task %s: %s", generation_task_id, e)
|
||
session.rollback()
|
||
return {
|
||
"video_id": "",
|
||
"video_count": 0,
|
||
"is_duplicate": False,
|
||
"batch_similarity": None,
|
||
"duplicate_of": None,
|
||
}
|