Merge develop into main - v0.1.124
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h17m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h17m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 45h15m38s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped

This commit is contained in:
CI Test
2026-07-07 15:36:36 +08:00
33 changed files with 1797 additions and 142 deletions
@@ -0,0 +1,33 @@
"""Add file_hash to assets and ingest_jobs
Revision ID: 031
Revises: 030
Create Date: 2026-07-07
为素材去重检测功能添加 file_hash 字段。
"""
import sqlalchemy as sa
from alembic import op
revision = "031"
down_revision = "030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("assets", sa.Column("file_hash", sa.String(64), nullable=True))
op.create_index(op.f("ix_assets_file_hash"), "assets", ["file_hash"])
op.add_column("ingest_jobs", sa.Column("file_hash", sa.String(64), nullable=True))
op.create_index(op.f("ix_ingest_jobs_file_hash"), "ingest_jobs", ["file_hash"])
def downgrade() -> None:
op.drop_index(op.f("ix_ingest_jobs_file_hash"), table_name="ingest_jobs")
op.drop_column("ingest_jobs", "file_hash")
op.drop_index(op.f("ix_assets_file_hash"), table_name="assets")
op.drop_column("assets", "file_hash")
@@ -0,0 +1,28 @@
"""Add asset_select_mode to generation_tasks
Revision ID: 032
Revises: 031
Create Date: 2026-07-07
素材库自动匹配功能:为 generation_tasks 表添加 asset_select_mode 字段。
"""
import sqlalchemy as sa
from alembic import op
revision = "032"
down_revision = "031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("asset_select_mode", sa.String(20), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "asset_select_mode")
@@ -0,0 +1,31 @@
"""Add batch_id to generation_tasks
Revision ID: 033
Revises: 032
Create Date: 2026-07-07
视频查重功能:为 generation_tasks 表添加 batch_id 字段,
用于关联同一次批量生成请求中的多个任务。
"""
import sqlalchemy as sa
from alembic import op
revision = "033"
down_revision = "032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("batch_id", sa.String(32), nullable=False, server_default=""),
)
op.create_index(op.f("ix_generation_tasks_batch_id"), "generation_tasks", ["batch_id"])
def downgrade() -> None:
op.drop_index(op.f("ix_generation_tasks_batch_id"), table_name="generation_tasks")
op.drop_column("generation_tasks", "batch_id")
+26
View File
@@ -19,6 +19,7 @@ from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
@@ -360,6 +361,7 @@ async def complete_chunked_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ChunkedUploadCompleteResponse:
@@ -423,6 +425,29 @@ async def complete_chunked_upload(
content_type=meta["content_type"],
)
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
if request.file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=request.library_id,
file_hash=request.file_hash,
)
if existing is not None:
logger.info(
"素材去重命中(chunked): library=%s hash=%s existing_asset=%s",
request.library_id,
request.file_hash,
existing.id,
)
meta["status"] = "completed"
_save_upload_meta(upload_id, meta)
return ChunkedUploadCompleteResponse(
storage_key=storage_key,
ingest_job_id="",
url=file_url,
duplicated=True,
asset_id=existing.id,
)
# Create ingest job
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -430,6 +455,7 @@ async def complete_chunked_upload(
project_id=meta["project_id"],
library_id=meta["library_id"],
storage_key=storage_key,
file_hash=request.file_hash,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
+86 -17
View File
@@ -1,3 +1,5 @@
import random
import uuid
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
@@ -14,6 +16,7 @@ from app.schemas.generated_video import (
ListGeneratedVideosResponse,
)
from app.schemas.generation_task import (
BatchGenerationTaskResponse,
CreateGenerationTaskRequest,
GenerationTaskResponse,
ListGenerationTasksResponse,
@@ -51,6 +54,8 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
title_ids=task.title_ids,
voice_ids=task.voice_ids,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -85,6 +90,49 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
)
def _select_assets_from_library(
assets: list,
mode: str,
count: int,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
Args:
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
Returns:
选中的素材 ID 列表
"""
ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
if not ready_video_assets:
return []
if mode == "random":
selected = (
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
)
return [a.id for a in selected]
if mode == "smart":
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
sorted_assets = sorted(
ready_video_assets,
key=lambda a: (
a.quality_score if a.quality_score is not None else 0.0,
a.duration if a.duration is not None else 0.0,
),
reverse=True,
)
selected = sorted_assets if count <= 0 else sorted_assets[:count]
return [a.id for a in selected]
# 默认 all 模式:返回全部 ready 视频素材
return [a.id for a in ready_video_assets]
def _resolve_project_and_library(
request: CreateGenerationTaskRequest,
project_repository: Any,
@@ -122,7 +170,7 @@ def _resolve_project_and_library(
return project_id, asset_library_id
@router.post("/tasks", response_model=GenerationTaskResponse)
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -130,12 +178,13 @@ def create_generation_task(
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> GenerationTaskResponse:
) -> BatchGenerationTaskResponse:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
resolved_asset_ids: list[str] = list(request.asset_ids)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
@@ -144,23 +193,42 @@ def create_generation_task(
assets = asset_repository.find_by_library(asset_library_id)
_ensure_library_has_ready_video_assets(assets)
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
if not resolved_asset_ids:
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=request.asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
count = request.count
created_tasks = []
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
for _ in range(count):
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
)
)
)
celery_app.send_task("worker.generate_video", args=[task.id])
return _to_generation_task_response(task)
celery_app.send_task("worker.generate_video", args=[task.id])
created_tasks.append(task)
items = [_to_generation_task_response(t) for t in created_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
@router.get("/tasks", response_model=ListGenerationTasksResponse)
@@ -237,6 +305,7 @@ def retry_generation_task(
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
+49
View File
@@ -8,6 +8,7 @@ from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
@@ -99,6 +100,7 @@ def _submit_ingest_job(
library_id: str,
storage_key: str,
ingest_job_repository: Any,
file_hash: str = "",
) -> Any:
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -106,6 +108,7 @@ def _submit_ingest_job(
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
file_hash=file_hash,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
@@ -176,6 +179,7 @@ async def complete_direct_upload(
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadCompleteResponse:
"""确认浏览器直传完成并创建导入任务。"""
@@ -199,11 +203,32 @@ async def complete_direct_upload(
if not file_exists:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
if request.file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=request.library_id,
file_hash=request.file_hash,
)
if existing is not None:
logger.info(
"素材去重命中: library=%s hash=%s existing_asset=%s",
request.library_id,
request.file_hash,
existing.id,
)
return DirectUploadCompleteResponse(
storage_key=normalized_key,
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
)
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
)
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
@@ -218,15 +243,38 @@ async def upload_asset(
project_id: str = Form(..., min_length=1, description="项目 ID"),
library_id: str = Form(..., min_length=1, description="素材库 ID"),
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
if file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=library_id,
file_hash=file_hash,
)
if existing is not None:
logger.info(
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
library_id,
file_hash,
existing.id,
)
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id="",
url="",
duplicated=True,
asset_id=existing.id,
)
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(file.content_type)
@@ -255,6 +303,7 @@ async def upload_asset(
library_id=library_id,
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
file_hash=file_hash,
)
return UploadAssetResponse(
+3
View File
@@ -36,9 +36,12 @@ class ChunkedUploadStatusResponse(BaseModel):
class ChunkedUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1, description="Project ID")
library_id: str = Field(..., min_length=1, description="Asset library ID")
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class ChunkedUploadCompleteResponse(BaseModel):
storage_key: str = Field(..., description="Storage key")
ingest_job_id: str = Field(..., description="Ingest job ID")
url: str = Field(..., description="File URL")
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
+19
View File
@@ -21,6 +21,16 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── 批量生成 ──
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
# ── 素材库自动匹配 ──
asset_select_mode: str = Field(
default="all",
description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
)
asset_select_count: int = Field(
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -46,12 +56,21 @@ class GenerationTaskResponse(BaseModel):
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
status: str
progress: float
result_count: int
error_message: str
class BatchGenerationTaskResponse(BaseModel):
"""批量生成任务响应。"""
items: list[GenerationTaskResponse]
total: int
class ListGenerationTasksResponse(BaseModel):
"""用户级生成任务列表响应(跨 project)。"""
+13 -6
View File
@@ -6,12 +6,7 @@ class UploadAssetRequest(BaseModel):
project_id: str = Field(..., min_length=1, description="项目 ID")
library_id: str = Field(..., min_length=1, description="素材库 ID")
class UploadAssetResponse(BaseModel):
storage_key: str
ingest_job_id: str
url: str = Field(..., description="Public URL of uploaded file")
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadPrepareRequest(BaseModel):
@@ -20,6 +15,7 @@ class DirectUploadPrepareRequest(BaseModel):
filename: str = Field(..., min_length=1, max_length=255)
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
file_size: int = Field(..., gt=0)
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadPrepareResponse(BaseModel):
@@ -35,8 +31,19 @@ class DirectUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1)
library_id: str = Field(..., min_length=1)
storage_key: str = Field(..., min_length=1, max_length=255)
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadCompleteResponse(BaseModel):
storage_key: str
ingest_job_id: str
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
class UploadAssetResponse(BaseModel):
storage_key: str
ingest_job_id: str
url: str = Field(..., description="Public URL of uploaded file")
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
+61 -4
View File
@@ -110,14 +110,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
item.classification_status ?? undefined,
);
const metadata = item.metadata || {};
const kind = inferKind(item.mime_type || "");
return {
id: item.id,
name: item.name,
kind: inferKind(item.mime_type || ""),
kind,
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
thumbUrl:
(item.thumbnail_url as string | undefined) ||
(item.file_url as string | undefined) ||
(metadata.thumbnail_url as string | undefined),
(metadata.thumbnail_url as string | undefined) ||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
fileUrl:
(item.file_url as string | undefined) ||
(metadata.file_url as string | undefined),
@@ -361,6 +363,7 @@ const AssetLibrary: React.FC = () => {
/* 上传 */
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
/* 新建素材库 */
const [createModalOpen, setCreateModalOpen] = useState(false);
@@ -434,11 +437,16 @@ const AssetLibrary: React.FC = () => {
}
setUploading(true);
setUploadProgress(0);
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`);
}
await uploadAssetDirect({ file, library_id: effectiveLibId });
await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
});
message.success(`"${file.name}" 上传成功`);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
@@ -446,6 +454,7 @@ const AssetLibrary: React.FC = () => {
message.error(`"${file.name}" 上传失败`);
} finally {
setUploading(false);
setUploadProgress(0);
}
return false;
};
@@ -532,6 +541,54 @@ const AssetLibrary: React.FC = () => {
return (
<div className="xx-assets-page">
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
<AntModal
open={uploading}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg
className="xx-upload-progress-ring"
viewBox="0 0 120 120"
width={120}
height={120}
>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
{/* 两栏布局 */}
<div className="xx-assets-layout">
{/* ─── 左侧:素材库列表 ─── */}
+37
View File
@@ -587,3 +587,40 @@
grid-template-columns: 1fr;
}
}
/* ─── 上传进度弹窗 ─── */
.xx-upload-progress-modal .ant-modal-content {
padding: 24px 16px 20px;
border-radius: 16px;
}
.xx-upload-progress-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.xx-upload-progress-ring {
display: block;
}
.xx-upload-progress-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.xx-upload-progress-pct {
font-size: 22px;
font-weight: 700;
color: var(--primary-color, #6366f1);
line-height: 1;
}
.xx-upload-progress-label {
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
+225 -101
View File
@@ -17,13 +17,13 @@ import {
ShareAltOutlined,
SaveOutlined,
PlusOutlined,
MinusOutlined,
CloseOutlined,
} from "@ant-design/icons";
import type { AssetItem } from "@/api/assets";
import { getAssets, getAssetLibraries } from "@/api/assets";
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
import { fetchPresetVoices } from "@/api/voices";
@@ -54,13 +54,6 @@ const MODE_GRADIENTS: Record<string, string> = {
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
};
const MODE_ABBRS: Record<string, string> = {
pip: "PIP",
one_take: "ONE",
voice_over: "VOI",
voice_pip: "VP",
};
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
const VOICE_GENDER_ICON: Record<string, string> = {
female: "🎀",
@@ -122,6 +115,8 @@ const GeneratePage: React.FC = () => {
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
/* 素材选择模式:手动选择 / 自动匹配 */
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual");
/* ── 标题 ── */
const [title, setTitle] = useState("");
@@ -146,6 +141,9 @@ const GeneratePage: React.FC = () => {
);
const [customVoiceText, setCustomVoiceText] = useState("");
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1);
/* ── 克隆声音 ── */
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("");
const [cloneModalOpen, setCloneModalOpen] = useState(false);
@@ -456,11 +454,17 @@ const GeneratePage: React.FC = () => {
}, [navigate]);
const handleGenerate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
title,
selectedTemplate,
selectedMaterials,
voiceMode,
});
if (!title.trim()) {
message.warning("请先选择或输入标题");
return;
}
if (selectedMaterials.length === 0) {
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材");
return;
}
@@ -498,6 +502,8 @@ const GeneratePage: React.FC = () => {
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
source_edit_plan_id: editPlanId || undefined,
@@ -560,20 +566,34 @@ const GeneratePage: React.FC = () => {
typeof setInterval
>;
} catch (err: unknown) {
console.error("生成失败:", err);
console.error("[handleGenerate] 生成失败:", err);
setGenerating(false);
// 提取 axios 响应中的后端错误信息
const axiosErr = err as {
response?: {
data?: { message?: string; error?: string; detail?: string };
data?: {
message?: string;
error?: string;
detail?: string;
msg?: string;
};
};
message?: string;
};
const backendMsg =
axiosErr.response?.data?.message ||
axiosErr.response?.data?.error ||
axiosErr.response?.data?.detail ||
axiosErr.response?.data?.msg ||
axiosErr.message ||
"";
message.error(backendMsg || "生成失败,请重试");
console.error(
"[handleGenerate] 错误信息:",
backendMsg,
"完整错误:",
axiosErr,
);
message.error(backendMsg || "生成失败,请检查网络后重试或联系管理员");
}
}, [
title,
@@ -590,6 +610,8 @@ const GeneratePage: React.FC = () => {
bgm,
editPlanId,
selectedTemplate,
generateCount,
materialMode,
]);
/* ── 步骤导航 ── */
@@ -598,7 +620,11 @@ const GeneratePage: React.FC = () => {
message.warning("请先选择一个模板");
return;
}
if (currentStep === 2 && selectedMaterials.length === 0) {
if (
currentStep === 2 &&
materialMode === "manual" &&
selectedMaterials.length === 0
) {
message.warning("请至少选择一个素材");
return;
}
@@ -609,7 +635,13 @@ const GeneratePage: React.FC = () => {
if (currentStep < 5) {
setCurrentStep((s) => s + 1);
}
}, [currentStep, selectedTemplate, selectedMaterials.length, title]);
}, [
currentStep,
selectedTemplate,
selectedMaterials.length,
title,
materialMode,
]);
const goPrev = useCallback(() => {
if (currentStep > 1) {
@@ -669,11 +701,10 @@ const GeneratePage: React.FC = () => {
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
}}
>
{MODE_ABBRS[tpl.mode] || "TPL"}
🎬
</div>
<h4>{tpl.name}</h4>
<p>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "}
{tpl.estimated_duration}s · {tpl.segments.length}
</p>
{tpl.tags.length > 0 && (
@@ -708,11 +739,31 @@ const GeneratePage: React.FC = () => {
</div>
);
/** 步骤 2:选择素材 */
/** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
const renderStep2 = () => (
<div className="xx-form-section">
<h3>📦 </h3>
<div className="xx-form-field">
{/* ── 模式切换 Tab ── */}
<div className="xx-material-mode-tabs">
<button
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
onClick={() => setMaterialMode("manual")}
type="button"
>
</button>
<button
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
onClick={() => setMaterialMode("auto")}
type="button"
>
</button>
</div>
{/* ── 素材库选择(两种模式共用) ── */}
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
<select
value={selectedLibraryId}
@@ -725,90 +776,138 @@ const GeneratePage: React.FC = () => {
))}
</select>
</div>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
{selectedMaterials.length}
</span>
<Text style={{ color: "var(--text-tertiary, #94a3b8)", fontSize: 13 }}>
</Text>
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : materials.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.map((m) => {
const checked = selectedMaterials.includes(m.id);
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
);
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
);
})}
{/* ── 手动选择模式 ── */}
{materialMode === "manual" && (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
{selectedMaterials.length}
</span>
</div>
)}
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
>
</Text>
) : materials.length === 0 ? (
<Text
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.map((m) => {
const checked = selectedMaterials.includes(m.id);
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
);
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
);
})}
</div>
)}
</div>
</>
)}
{/* ── 自动匹配模式 ── */}
{materialMode === "auto" && (
<div className="xx-auto-match-card">
<div className="xx-auto-match-icon">🤖</div>
<div className="xx-auto-match-body">
<h4 className="xx-auto-match-title"></h4>
<p className="xx-auto-match-desc">
AI
</p>
<div className="xx-auto-match-features">
<span className="xx-auto-match-feature">📊 </span>
<span className="xx-auto-match-feature">🎯 </span>
<span className="xx-auto-match-feature"> </span>
</div>
</div>
{materialsLoading ? (
<Text
style={{
color: "var(--text-secondary)",
fontSize: 12,
marginTop: 8,
}}
>
</Text>
) : (
<Text
style={{
color: "var(--text-tertiary, #94a3b8)",
fontSize: 12,
marginTop: 8,
}}
>
{materials.length}
</Text>
)}
</div>
)}
</div>
);
@@ -1270,7 +1369,9 @@ const GeneratePage: React.FC = () => {
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
{selectedMaterials.length}
{materialMode === "auto"
? "自动匹配"
: `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-summary-row">
@@ -1281,6 +1382,29 @@ const GeneratePage: React.FC = () => {
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{getVoiceName()}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={() => setGenerateCount((c) => Math.max(1, c - 1))}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={() => setGenerateCount((c) => Math.min(10, c + 1))}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
{/* 生成进度 */}
+138
View File
@@ -1074,3 +1074,141 @@
opacity: 1;
}
}
/* ── 生成数量步进器 ── */
.xx-count-stepper {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xx-count-stepper-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 8px;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #64748b);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.xx-count-stepper-btn:hover:not(:disabled) {
border-color: var(--primary-400, #818cf8);
color: var(--primary-600, #4f46e5);
background: var(--primary-50, #eef2ff);
}
.xx-count-stepper-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.xx-count-stepper-value {
min-width: 24px;
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-count-stepper-hint {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
margin-left: 2px;
}
/* ── 素材选择模式切换 Tab ── */
.xx-material-mode-tabs {
display: flex;
gap: 0;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 10px;
overflow: hidden;
margin-bottom: 4px;
}
.xx-material-mode-tab {
flex: 1;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
border: none;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #64748b);
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.xx-material-mode-tab:first-child {
border-right: 1px solid var(--border-primary, #e2e8f0);
}
.xx-material-mode-tab:hover {
background: var(--primary-50, #eef2ff);
color: var(--primary-600, #4f46e5);
}
.xx-material-mode-tab.active {
background: var(--primary-500, #6366f1);
color: #fff;
font-weight: 600;
}
/* ── 自动匹配卡片 ── */
.xx-auto-match-card {
margin-top: 14px;
padding: 20px;
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 14px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.xx-auto-match-icon {
font-size: 36px;
margin-bottom: 10px;
}
.xx-auto-match-body {
width: 100%;
}
.xx-auto-match-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin: 0 0 8px;
}
.xx-auto-match-desc {
font-size: 13px;
color: var(--text-secondary, #64748b);
line-height: 1.6;
margin: 0 0 14px;
}
.xx-auto-match-features {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.xx-auto-match-feature {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
color: var(--primary-600, #4f46e5);
background: rgba(255, 255, 255, 0.8);
border: 1px solid var(--border-light, #f1f5f9);
border-radius: 20px;
}
+10 -2
View File
@@ -737,11 +737,19 @@ const ProductLibrary: React.FC = () => {
// ── Error 状态 ──
if (isError) {
console.error("[ProductLibrary] 加载失败:", error);
const errorMsg = error?.message || "加载失败";
// 区分 404 和其他错误
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
return (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>{error?.message || "加载失败"}</p>
<div className="xx-products-empty-icon">{is404 ? "🔍" : "❌"}</div>
<p>
{is404
? "成片库功能正在建设中,敬请期待"
: errorMsg || "加载失败,请稍后重试"}
</p>
<Button
buttonType="primary"
buttonSize="sm"
+62
View File
@@ -206,6 +206,68 @@ class VideoDeduplicator:
return None
def check_batch_duplicate(
self,
fingerprint: VideoFingerprint,
batch_id: str,
current_video_id: str,
session: Session,
) -> Optional[dict]:
"""检查视频是否与同批次内其他视频重复。
逻辑与 check_duplicate 一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
Args:
fingerprint: 待检测视频的指纹
batch_id: 批次 ID
current_video_id: 当前视频 ID(排除自身)
session: 数据库会话
Returns:
重复信息字典,或 None 表示未找到重复
"""
video_repo = SQLAlchemyGeneratedVideoRepository(session)
batch_videos = video_repo.list_by_batch(batch_id)
for existing in batch_videos:
if existing.id == current_video_id:
continue
if not existing.video_fingerprint:
continue
ef = existing.video_fingerprint
if fingerprint.md5 == ef.get("md5"):
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_exact_md5_match",
"similarity": 1.0,
}
existing_phashes = ef.get("keyframe_phashes", [])
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:
continue
phash_similarity = 1.0 - (avg_distance / 64)
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_phash_similar",
"similarity": phash_similarity,
}
return None
@staticmethod
def _average_histogram_similarity(histograms_a: list[list[float]], histograms_b: list[list[float]]) -> float:
"""
+123 -12
View File
@@ -139,14 +139,16 @@ def _download_library_assets(
asset_library_id: str,
temp_path: Path,
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
asset_ids: list[str] | None = None,
) -> list[str]:
"""
从素材库下载所有视频素材
从素材库下载视频素材
Args:
asset_library_id: 素材库 ID
temp_path: 临时目录路径
video_extensions: 支持的视频扩展名
asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材
Returns:
下载成功的视频文件路径列表
@@ -161,16 +163,15 @@ def _download_library_assets(
try:
# 查询素材库中的视频素材
assets = (
session.query(AssetModel)
.filter(
AssetModel.asset_library_id == asset_library_id,
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
)
.order_by(AssetModel.created_at)
.all()
query = session.query(AssetModel).filter(
AssetModel.asset_library_id == asset_library_id,
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
)
# 如果指定了 asset_ids,则只下载这些素材
if asset_ids:
query = query.filter(AssetModel.id.in_(asset_ids))
assets = query.order_by(AssetModel.created_at).all()
if not assets:
logger.info(f"No video assets found in library {asset_library_id}")
@@ -259,6 +260,8 @@ def generate_video(self, task_id: str) -> dict:
asset_library_id = gen_task.asset_library_id
voice_library_id = gen_task.voice_library_id or ""
mode = gen_task.strategy_id or "one_take"
task_asset_ids = list(gen_task.asset_ids or [])
batch_id = getattr(gen_task, "batch_id", "") or ""
finally:
session.close()
@@ -275,8 +278,8 @@ def generate_video(self, task_id: str) -> dict:
temp_path = Path(temp_dir)
output_path = temp_path / output_name
# 从素材库下载视频素材
downloaded_videos = _download_library_assets(asset_library_id, temp_path)
# 从素材库下载视频素材(如果任务指定了 asset_ids 则只下载这些)
downloaded_videos = _download_library_assets(asset_library_id, temp_path, asset_ids=task_asset_ids or None)
audio_path = None
if voice_library_id:
@@ -297,6 +300,32 @@ def generate_video(self, task_id: str) -> dict:
file_size = output_path.stat().st_size
duration = _probe_duration(output_path)
# 上传到 OSS
bucket = _oss_bucket()
if bucket:
try:
bucket.put_object_from_file(storage_key, str(output_path))
except Exception as oss_err:
logger.warning(f"OSS upload failed: {oss_err}")
# 构建视频 URL
if bucket:
file_url = f"{PUBLIC_API_BASE_URL}/{storage_key}"
else:
file_url = f"{GENERATED_FILES_URL_PREFIX}/{task_id}/{output_name}"
# 创建 GeneratedVideo 记录 + 查重
_create_video_record_and_dedup(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=str(output_path),
mode=editing_mode.value,
)
return {
"status": "completed",
"task_id": task_id,
@@ -314,3 +343,85 @@ def generate_video(self, task_id: str) -> dict:
"task_id": task_id,
"error": str(error),
}
def _create_video_record_and_dedup(
*,
task_id: str,
project_id: str,
batch_id: str,
file_url: str,
file_size: int,
duration: float,
video_path: str,
mode: str,
) -> None:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。"""
from uuid import uuid4
from video_processing.dedup import VideoDeduplicator
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain import GeneratedVideo
session = SessionLocal()
try:
video_id = uuid4().hex
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
generation_task_id=task_id,
name=f"generated-{task_id[:8]}.mp4",
file_url=file_url,
file_size=file_size,
duration=duration,
width=OUTPUT_WIDTH,
height=OUTPUT_HEIGHT,
fps=OUTPUT_FPS,
status="completed",
generation_params={"mode": mode},
)
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
# 计算视频指纹
deduplicator = VideoDeduplicator()
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
except Exception as fp_err:
logger.warning(f"Fingerprint computation failed for {video_id}: {fp_err}")
session.commit()
return
generated_video.video_fingerprint = fingerprint.to_dict()
# (a) 历史成片查重
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
# (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(
f"Duplicate detected: {video_id} -> {duplicate_result['duplicate_of']} "
f"(reason={duplicate_result['reason']}, similarity={duplicate_result['similarity']:.3f})"
)
else:
generated_video.is_duplicate = False
generated_video.duplicate_of = None
video_repo.update(generated_video)
session.commit()
logger.info(f"GeneratedVideo record created: {video_id} (task={task_id}, dup={generated_video.is_duplicate})")
except Exception as e:
logger.error(f"Failed to create video record / dedup for task {task_id}: {e}")
session.rollback()
finally:
session.close()
+1
View File
@@ -179,6 +179,7 @@ def ingest_asset(job_id: str) -> dict:
width=int(metadata.get("width", 0)),
height=int(metadata.get("height", 0)),
status=AssetStatus.READY,
file_hash=job.file_hash,
)
asset_repo.create(asset)
+53
View File
@@ -274,6 +274,14 @@
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "file_hash",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(64)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -321,6 +329,13 @@
"name": "ix_assets_created_at",
"unique": false
},
{
"columns": [
"file_hash"
],
"name": "ix_assets_file_hash",
"unique": false
},
{
"columns": [
"file_type"
@@ -1502,6 +1517,22 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "asset_select_mode",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(20)",
"unique": false
},
{
"index": true,
"name": "batch_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -1527,6 +1558,13 @@
"name": "ix_generation_tasks_asset_library_id",
"unique": false
},
{
"columns": [
"batch_id"
],
"name": "ix_generation_tasks_batch_id",
"unique": false
},
{
"columns": [
"created_by_user_id"
@@ -1632,6 +1670,14 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": true,
"name": "file_hash",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(64)",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -1650,6 +1696,13 @@
}
],
"indexes": [
{
"columns": [
"file_hash"
],
"name": "ix_ingest_jobs_file_hash",
"unique": false
},
{
"columns": [
"library_id"
@@ -76,3 +76,16 @@ class InMemoryAssetRepository:
tag_set = set(tag_ids)
items = [a for a in self._assets.values() if tag_set.issubset(set(a.tag_ids))]
return items[skip : skip + limit]
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
if not file_hash:
return None
for asset in self._assets.values():
if asset.library_id == library_id and asset.file_hash == file_hash:
return asset
return None
@@ -83,6 +83,7 @@ class SQLAlchemyAssetRepository:
classification_result=(json.dumps(asset.metadata) if asset.metadata else None),
quality_score=asset.quality_score,
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
file_hash=asset.file_hash or None,
created_at=asset.created_at,
updated_at=now,
)
@@ -110,6 +111,7 @@ class SQLAlchemyAssetRepository:
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
model.quality_score = asset.quality_score
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
model.file_hash = asset.file_hash or model.file_hash
model.updated_at = datetime.now(timezone.utc)
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
@@ -230,6 +232,7 @@ class SQLAlchemyAssetRepository:
classification_status=ClassificationStatus(model.classification_status),
quality_score=model.quality_score,
uploaded_by_user_id=model.uploaded_by_user_id,
file_hash=model.file_hash or "",
metadata=metadata,
tag_ids=tag_ids,
created_at=model.created_at,
@@ -267,3 +270,23 @@ class SQLAlchemyAssetRepository:
return []
models = self.session.query(AssetModel).filter(AssetModel.id.in_(ids)).offset(skip).limit(limit).all()
return [self._to_domain(m) for m in models]
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
if not file_hash:
return None
model = (
self.session.query(AssetModel)
.filter(
AssetModel.asset_library_id == library_id,
AssetModel.file_hash == file_hash,
)
.first()
)
if model is None:
return None
return self._to_domain(model)
@@ -87,3 +87,39 @@ class SQLAlchemyGeneratedVideoRepository:
.all()
)
return [self.get(model.id) for model in models if self.get(model.id) is not None]
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
"""通过 batch_id 查找同批次生成的所有视频(跨 generation_task 关联查询)。"""
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
task_ids = (
self.session.query(GenerationTaskModel.id).filter(GenerationTaskModel.batch_id == batch_id).subquery()
)
models = (
self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id.in_(task_ids)).all()
)
return [self._to_domain(model) for model in models]
@staticmethod
def _to_domain(model: GeneratedVideoModel) -> GeneratedVideo:
return GeneratedVideo(
id=model.id,
project_id=model.project_id,
generation_task_id=model.generation_task_id,
name=model.name,
file_url=model.file_url,
file_size=int(model.file_size or 0),
duration=model.duration,
thumbnail_url=model.thumbnail_url,
width=int(model.width or 0),
height=int(model.height or 0),
fps=model.fps,
status=getattr(model, "status", "completed"),
review_status=getattr(model, "review_status", "pending_review"),
generation_params=json.loads(getattr(model, "generation_params", "{}") or "{}"),
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
is_duplicate=getattr(model, "is_duplicate", False),
duplicate_of=getattr(model, "duplicate_of", None),
generated_at=model.generated_at,
created_at=model.created_at,
)
@@ -25,6 +25,8 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
completed_at=model.completed_at,
created_by_user_id=model.created_by_user_id,
source_edit_plan_id=model.source_edit_plan_id or "",
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
created_at=model.created_at,
)
@@ -52,6 +54,8 @@ class SQLAlchemyGenerationTaskRepository:
completed_at=task.completed_at,
created_by_user_id=task.created_by_user_id,
source_edit_plan_id=task.source_edit_plan_id or None,
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
created_at=task.created_at,
)
self.session.add(model)
@@ -123,5 +127,7 @@ class SQLAlchemyGenerationTaskRepository:
model.started_at = task.started_at
model.completed_at = task.completed_at
model.source_edit_plan_id = task.source_edit_plan_id or None
model.asset_select_mode = task.asset_select_mode or ""
model.batch_id = task.batch_id or ""
self.session.commit()
return task
@@ -17,6 +17,7 @@ class SQLAlchemyIngestJobRepository:
status=job.status.value,
error_message=job.error_message,
result_asset_id=job.result_asset_id,
file_hash=job.file_hash,
created_at=job.created_at,
updated_at=job.updated_at,
)
@@ -36,6 +37,7 @@ class SQLAlchemyIngestJobRepository:
status=IngestJobStatus(model.status),
error_message=model.error_message,
result_asset_id=model.result_asset_id,
file_hash=model.file_hash or "",
created_at=model.created_at,
updated_at=model.updated_at,
)
@@ -51,6 +53,7 @@ class SQLAlchemyIngestJobRepository:
model.status = job.status.value
model.error_message = job.error_message
model.result_asset_id = job.result_asset_id
model.file_hash = job.file_hash
model.updated_at = job.updated_at
self.session.commit()
return job
@@ -85,6 +85,7 @@ class AssetModel(Base):
classification_result = Column(Text, nullable=True)
quality_score = Column(Float, nullable=True)
uploaded_by_user_id = Column(String(36), nullable=False)
file_hash = Column(String(64), nullable=True, index=True)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -210,6 +211,7 @@ class IngestJobModel(Base):
status = Column(String(20), nullable=False, default="pending")
error_message = Column(Text, nullable=False, default="")
result_asset_id = Column(String(32), nullable=False, default="")
file_hash = Column(String(64), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -251,6 +253,8 @@ class GenerationTaskModel(Base):
completed_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
source_edit_plan_id = Column(String(32), nullable=True, index=True)
asset_select_mode = Column(String(20), nullable=False, default="")
batch_id = Column(String(32), nullable=False, default="", index=True)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+4
View File
@@ -19,6 +19,8 @@ class CreateGenerationTaskCommand:
voice_ids: list[str] = field(default_factory=list)
created_by_user_id: str = ""
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
class CreateGenerationTaskUseCase:
@@ -44,6 +46,8 @@ class CreateGenerationTaskUseCase:
completed_at=None,
created_by_user_id=command.created_by_user_id,
source_edit_plan_id=command.source_edit_plan_id,
asset_select_mode=command.asset_select_mode,
batch_id=command.batch_id,
)
return self.generation_task_repository.create(task)
+2
View File
@@ -11,6 +11,7 @@ class SubmitIngestJobCommand:
project_id: str
library_id: str
storage_key: str
file_hash: str = ""
class SubmitIngestJobUseCase:
@@ -22,5 +23,6 @@ class SubmitIngestJobUseCase:
project_id=command.project_id,
library_id=command.library_id,
storage_key=command.storage_key,
file_hash=command.file_hash,
)
return self.ingest_job_repository.create(job)
+6
View File
@@ -161,6 +161,7 @@ class Asset:
classification_status: ClassificationStatus = ClassificationStatus.PENDING
quality_score: float | None = None
uploaded_by_user_id: str = ""
file_hash: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
tag_ids: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -187,6 +188,7 @@ class Asset:
classification_status: ClassificationStatus = ClassificationStatus.PENDING,
quality_score: float | None = None,
uploaded_by_user_id: str = "",
file_hash: str = "",
) -> "Asset":
clean_name = name.strip()
if not clean_name:
@@ -213,6 +215,7 @@ class Asset:
classification_status=classification_status,
quality_score=quality_score,
uploaded_by_user_id=uploaded_by_user_id.strip(),
file_hash=file_hash.strip(),
metadata=metadata or {},
tag_ids=[],
)
@@ -243,6 +246,7 @@ class IngestJob:
status: IngestJobStatus = IngestJobStatus.PENDING
error_message: str = ""
result_asset_id: str = ""
file_hash: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -252,6 +256,7 @@ class IngestJob:
project_id: str,
library_id: str,
storage_key: str,
file_hash: str = "",
) -> "IngestJob":
if not project_id.strip():
raise ValueError("project_id 不能为空")
@@ -264,4 +269,5 @@ class IngestJob:
project_id=project_id.strip(),
library_id=library_id.strip(),
storage_key=storage_key.strip(),
file_hash=file_hash.strip(),
)
+6
View File
@@ -43,6 +43,8 @@ class GenerationTask:
completed_at: datetime | None = None
source_edit_plan_id: str = ""
created_by_user_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
@@ -59,6 +61,8 @@ class GenerationTask:
voice_ids: list[str] | None = None,
created_by_user_id: str = "",
source_edit_plan_id: str = "",
asset_select_mode: str = "",
batch_id: str = "",
) -> "GenerationTask":
if not project_id.strip() and not template_id.strip():
raise ValueError("project_id 或 template_id 至少需要提供一个")
@@ -76,4 +80,6 @@ class GenerationTask:
voice_ids=list(voice_ids) if voice_ids else [],
created_by_user_id=created_by_user_id.strip(),
source_edit_plan_id=source_edit_plan_id.strip(),
asset_select_mode=asset_select_mode,
batch_id=batch_id,
)
+9
View File
@@ -93,3 +93,12 @@ class AssetRepository(ABC):
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
pass
@abstractmethod
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
pass
@@ -13,3 +13,5 @@ class GeneratedVideoRepository(Protocol):
def list_by_project(self, project_id: str) -> list[GeneratedVideo]: ...
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]: ...
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]: ...
+341
View File
@@ -0,0 +1,341 @@
"""
素材重复上传检测 单元测试
覆盖:
- 表单上传(multipart)命中去重 → 直接返回已有 asset_id,不上传 OSS
- 直传 OSS complete 命中去重 → 直接返回已有 asset_id,不创建 ingest job
- 未命中去重 → 正常创建 ingest job
- file_hash 为空 → 跳过去重检测
- IngestJob 透传 file_hash 到 Asset
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, IngestJob, Project
# ---------------------------------------------------------------------------
# Stub repositories
# ---------------------------------------------------------------------------
class StubProjectRepository:
def __init__(self, projects: dict | None = None):
self._projects = projects or {}
def get(self, project_id: str):
return self._projects.get(project_id)
def find_by_id(self, project_id: str):
return self._projects.get(project_id)
class StubAssetLibraryRepository:
def __init__(self, libraries: dict | None = None):
self._libraries = libraries or {}
def find_by_project(self, project_id: str, kind=None) -> list:
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
if kind is not None:
items = [lib for lib in items if lib.kind == kind]
return items
class StubAssetRepository:
"""支持 find_by_library_and_file_hash 去重检测。"""
def __init__(self, assets: list[Asset] | None = None):
self._assets = assets or []
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
for a in self._assets:
if a.library_id == library_id and a.file_hash == file_hash:
return a
return None
def create(self, asset: Asset) -> Asset:
self._assets.append(asset)
return asset
class StubIngestJobRepository:
def __init__(self):
self._jobs: dict[str, IngestJob] = {}
def create(self, job: IngestJob) -> IngestJob:
self._jobs[job.id] = job
return job
def get(self, job_id: str) -> IngestJob | None:
return self._jobs.get(job_id)
def update(self, job: IngestJob) -> IngestJob:
self._jobs[job.id] = job
return job
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
DUPE_HASH = "a" * 32
def _make_project(id: str = "proj-1", owner_user_id: str = "user-1") -> Project:
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
return AssetLibrary(id=id, name="Test Library", project_id=project_id, kind=AssetLibraryKind.VIDEO)
def _make_existing_asset(
id: str = "existing-asset-1",
library_id: str = "lib-1",
file_hash: str = DUPE_HASH,
) -> Asset:
return Asset(
id=id,
project_id="proj-1",
library_id=library_id,
name="existing.mp4",
storage_key="uploads/existing/existing.mp4",
mime_type="video/mp4",
file_hash=file_hash,
status=AssetStatus.READY,
)
def _build_app(
project_repo=None,
library_repo=None,
asset_repo=None,
ingest_repo=None,
storage=None,
):
from app.api.routes.upload import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
project_repo = project_repo or StubProjectRepository()
library_repo = library_repo or StubAssetLibraryRepository()
asset_repo = asset_repo or StubAssetRepository()
ingest_repo = ingest_repo or StubIngestJobRepository()
storage = storage or MagicMock()
storage.is_configured = True
storage._normalize_storage_key = lambda key: key
storage.file_exists = lambda key: True
storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4")
mock_user = MagicMock(spec=AuthenticatedUser)
mock_user.id = "user-1"
mock_user.email = "test@example.com"
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_project_repository] = lambda: project_repo
app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
app.dependency_overrides[get_asset_repository] = lambda: asset_repo
app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
app.dependency_overrides[get_storage_service] = lambda: storage
return app
def _client(**kwargs) -> TestClient:
return TestClient(_build_app(**kwargs))
# ---------------------------------------------------------------------------
# 测试用例
# ---------------------------------------------------------------------------
class TestMultipartUploadDedup:
"""表单上传(POST /api/v1/assets)去重检测。"""
def test_dedup_hit_returns_existing_asset(self):
"""file_hash 命中已有素材 → 返回 duplicated=true + asset_id,不上传 OSS。"""
project = _make_project()
library = _make_library()
existing = _make_existing_asset()
client = _client(
project_repo=StubProjectRepository({project.id: project}),
library_repo=StubAssetLibraryRepository({library.id: library}),
asset_repo=StubAssetRepository([existing]),
)
resp = client.post(
"/api/v1",
data={
"project_id": project.id,
"library_id": library.id,
"file_hash": DUPE_HASH,
},
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
)
assert resp.status_code == 200
body = resp.json()
assert body["duplicated"] is True
assert body["asset_id"] == existing.id
assert body["ingest_job_id"] == ""
def test_dedup_miss_creates_ingest_job(self):
"""file_hash 未命中 → 正常上传并创建 ingest job。"""
project = _make_project()
library = _make_library()
client = _client(
project_repo=StubProjectRepository({project.id: project}),
library_repo=StubAssetLibraryRepository({library.id: library}),
asset_repo=StubAssetRepository([]), # 无已有素材
)
resp = client.post(
"/api/v1",
data={
"project_id": project.id,
"library_id": library.id,
"file_hash": "b" * 32, # 新的 hash
},
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
)
assert resp.status_code == 200
body = resp.json()
assert body["duplicated"] is False
assert body["ingest_job_id"] != ""
def test_empty_hash_skips_dedup(self):
"""file_hash 为空 → 跳过去重检测,直接上传。"""
project = _make_project()
library = _make_library()
existing = _make_existing_asset()
client = _client(
project_repo=StubProjectRepository({project.id: project}),
library_repo=StubAssetLibraryRepository({library.id: library}),
asset_repo=StubAssetRepository([existing]),
)
resp = client.post(
"/api/v1",
data={
"project_id": project.id,
"library_id": library.id,
# 不传 file_hash
},
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
)
assert resp.status_code == 200
body = resp.json()
assert body["duplicated"] is False
class TestDirectUploadDedup:
"""直传 OSS completePOST /api/v1/direct/complete)去重检测。"""
def test_dedup_hit_returns_existing_asset(self):
"""complete 阶段 file_hash 命中 → 返回 duplicated=true。"""
project = _make_project()
library = _make_library()
existing = _make_existing_asset()
client = _client(
project_repo=StubProjectRepository({project.id: project}),
library_repo=StubAssetLibraryRepository({library.id: library}),
asset_repo=StubAssetRepository([existing]),
)
resp = client.post(
"/api/v1/direct/complete",
json={
"project_id": project.id,
"library_id": library.id,
"storage_key": "uploads/abc/test.mp4",
"file_hash": DUPE_HASH,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["duplicated"] is True
assert body["asset_id"] == existing.id
assert body["ingest_job_id"] == ""
def test_dedup_miss_creates_ingest_job(self):
"""complete 阶段 file_hash 未命中 → 创建 ingest job。"""
project = _make_project()
library = _make_library()
client = _client(
project_repo=StubProjectRepository({project.id: project}),
library_repo=StubAssetLibraryRepository({library.id: library}),
asset_repo=StubAssetRepository([]),
)
resp = client.post(
"/api/v1/direct/complete",
json={
"project_id": project.id,
"library_id": library.id,
"storage_key": "uploads/abc/test.mp4",
"file_hash": "c" * 32,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["duplicated"] is False
assert body["ingest_job_id"] != ""
class TestIngestJobFileHashPassthrough:
"""file_hash 从上传接口透传到 IngestJob。"""
def test_ingest_job_stores_file_hash(self):
"""上传时传入的 file_hash 应保存到 IngestJob 实体。"""
project = _make_project()
library = _make_library()
ingest_repo = StubIngestJobRepository()
client = _client(
project_repo=StubProjectRepository({project.id: project}),
library_repo=StubAssetLibraryRepository({library.id: library}),
asset_repo=StubAssetRepository([]),
ingest_repo=ingest_repo,
)
new_hash = "d" * 32
client.post(
"/api/v1",
data={
"project_id": project.id,
"library_id": library.id,
"file_hash": new_hash,
},
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
)
# 验证 IngestJob 存储了 file_hash
assert len(ingest_repo._jobs) == 1
job = list(ingest_repo._jobs.values())[0]
assert job.file_hash == new_hash
+176
View File
@@ -0,0 +1,176 @@
"""
素材库自动匹配 单元测试
覆盖:
- all 模式:返回全部 ready 视频素材 ID
- random 模式:随机选取 N 个
- smart 模式:按质量分/时长评分降序选取
- 无 ready 视频素材时返回空列表
- count=0 时返回全部(random/smart 模式)
- 非视频素材和非 ready 状态素材被过滤
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.generation_tasks import _select_assets_from_library
from packages.domain import Asset, AssetStatus
def _asset(
id: str,
name: str,
mime_type: str = "video/mp4",
status: AssetStatus = AssetStatus.READY,
quality_score: float | None = None,
duration: float | None = None,
) -> Asset:
a = Asset.create(
project_id="proj-1",
library_id="lib-1",
name=name,
storage_key=f"uploads/{name}",
mime_type=mime_type,
file_size=1024,
status=status,
quality_score=quality_score,
duration=duration,
)
# create() 会覆盖 id,手动设置
a.id = id
return a
class TestSelectAssetsAllMode:
"""all 模式:返回全部 ready 视频素材。"""
def test_returns_all_ready_video_assets(self):
assets = [
_asset("a1", "v1.mp4"),
_asset("a2", "v2.mp4"),
_asset("a3", "v3.mp4"),
]
result = _select_assets_from_library(assets, mode="all", count=0)
assert sorted(result) == ["a1", "a2", "a3"]
def test_ignores_count_in_all_mode(self):
assets = [
_asset("a1", "v1.mp4"),
_asset("a2", "v2.mp4"),
]
result = _select_assets_from_library(assets, mode="all", count=1)
assert len(result) == 2
def test_filters_non_video_assets(self):
assets = [
_asset("a1", "v1.mp4", mime_type="video/mp4"),
_asset("a2", "img.jpg", mime_type="image/jpeg"),
_asset("a3", "v2.mov", mime_type="video/quicktime"),
]
result = _select_assets_from_library(assets, mode="all", count=0)
assert sorted(result) == ["a1", "a3"]
def test_filters_non_ready_assets(self):
assets = [
_asset("a1", "v1.mp4", status=AssetStatus.READY),
_asset("a2", "v2.mp4", status=AssetStatus.UPLOADING),
_asset("a3", "v3.mp4", status=AssetStatus.PROCESSING),
]
result = _select_assets_from_library(assets, mode="all", count=0)
assert result == ["a1"]
def test_empty_library_returns_empty(self):
result = _select_assets_from_library([], mode="all", count=0)
assert result == []
def test_no_ready_video_returns_empty(self):
assets = [
_asset("a1", "v1.mp4", status=AssetStatus.UPLOADING),
_asset("a2", "img.jpg", mime_type="image/jpeg", status=AssetStatus.READY),
]
result = _select_assets_from_library(assets, mode="all", count=0)
assert result == []
class TestSelectAssetsRandomMode:
"""random 模式:随机选取 N 个。"""
def test_random_selects_exact_count(self):
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(10)]
result = _select_assets_from_library(assets, mode="random", count=3)
assert len(result) == 3
assert all(rid in [a.id for a in assets] for rid in result)
def test_random_count_zero_returns_all(self):
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(5)]
result = _select_assets_from_library(assets, mode="random", count=0)
assert len(result) == 5
def test_random_count_exceeds_total_returns_all(self):
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(3)]
result = _select_assets_from_library(assets, mode="random", count=100)
assert len(result) == 3
class TestSelectAssetsSmartMode:
"""smart 模式:按质量分/时长评分降序选取。"""
def test_smart_sorts_by_quality_score_desc(self):
assets = [
_asset("low", "low.mp4", quality_score=0.3),
_asset("high", "high.mp4", quality_score=0.9),
_asset("mid", "mid.mp4", quality_score=0.6),
]
result = _select_assets_from_library(assets, mode="smart", count=0)
assert result == ["high", "mid", "low"]
def test_smart_tiebreak_by_duration_desc(self):
assets = [
_asset("short", "short.mp4", quality_score=0.8, duration=10.0),
_asset("long", "long.mp4", quality_score=0.8, duration=60.0),
]
result = _select_assets_from_library(assets, mode="smart", count=0)
assert result == ["long", "short"]
def test_smart_with_count_limits_results(self):
assets = [
_asset("a1", "v1.mp4", quality_score=0.9),
_asset("a2", "v2.mp4", quality_score=0.7),
_asset("a3", "v3.mp4", quality_score=0.5),
]
result = _select_assets_from_library(assets, mode="smart", count=2)
assert result == ["a1", "a2"]
def test_smart_null_quality_treated_as_zero(self):
assets = [
_asset("scored", "scored.mp4", quality_score=0.5),
_asset("unscored", "unscored.mp4", quality_score=None),
]
result = _select_assets_from_library(assets, mode="smart", count=0)
assert result == ["scored", "unscored"]
def test_smart_count_zero_returns_all_sorted(self):
assets = [
_asset("a1", "v1.mp4", quality_score=0.1),
_asset("a2", "v2.mp4", quality_score=0.9),
_asset("a3", "v3.mp4", quality_score=0.5),
]
result = _select_assets_from_library(assets, mode="smart", count=0)
assert result == ["a2", "a3", "a1"]
class TestSelectAssetsDefaultMode:
"""默认模式(未知 mode 字符串)应回退到 all。"""
def test_unknown_mode_falls_back_to_all(self):
assets = [
_asset("a1", "v1.mp4"),
_asset("a2", "v2.mp4"),
]
result = _select_assets_from_library(assets, mode="unknown", count=0)
assert len(result) == 2
+168
View File
@@ -461,3 +461,171 @@ class TestVideoDeduplicatorCheckDuplicate:
assert result["similarity"] == 1.0 # avg_distance = 0
finally:
self._restore_repo(mod, orig)
class TestVideoDeduplicatorCheckBatchDuplicate:
"""VideoDeduplicator.check_batch_duplicate() 测试。
批次内查重逻辑与历史查重一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
"""
@pytest.fixture
def deduplicator(self):
return VideoDeduplicator()
@pytest.fixture
def mock_session(self):
return MagicMock()
def _make_batch_video(self, video_id, md5, phashes=None):
video = MagicMock()
video.id = video_id
video.video_fingerprint = {
"md5": md5,
"keyframe_phashes": phashes or [],
"color_histograms": [],
}
return video
def _patch_repo(self, mock_repo):
import apps.worker.video_processing.dedup as dedup_module
original = dedup_module.SQLAlchemyGeneratedVideoRepository
dedup_module.SQLAlchemyGeneratedVideoRepository = MagicMock(return_value=mock_repo)
return original, dedup_module
def _restore_repo(self, dedup_module, original):
dedup_module.SQLAlchemyGeneratedVideoRepository = original
def test_batch_exact_md5_match(self, deduplicator, mock_session):
"""批次内 MD5 完全匹配应返回 duplicate。"""
other = self._make_batch_video("vid-other", "abc123")
mock_repo = MagicMock()
mock_repo.list_by_batch.return_value = [other]
fingerprint = VideoFingerprint(
md5="abc123",
keyframe_phashes=["ff"],
color_histograms=[],
duration=10.0,
resolution=(1280, 720),
)
orig, mod = self._patch_repo(mock_repo)
try:
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_exact_md5_match"
assert result["similarity"] == 1.0
assert result["duplicate_of"] == "vid-other"
finally:
self._restore_repo(mod, orig)
def test_batch_phash_similar(self, deduplicator, mock_session):
"""批次内 pHash 距离 < 阈值应判定为重复。"""
other = self._make_batch_video("vid-other", "md5_diff", phashes=["abcdef01"])
mock_repo = MagicMock()
mock_repo.list_by_batch.return_value = [other]
fingerprint = VideoFingerprint(
md5="md5_new",
keyframe_phashes=["abcdef01"],
color_histograms=[],
duration=10.0,
resolution=(1280, 720),
)
orig, mod = self._patch_repo(mock_repo)
try:
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"
finally:
self._restore_repo(mod, orig)
def test_batch_excludes_self(self, deduplicator, mock_session):
"""批次查重应排除自身视频。"""
self_video = self._make_batch_video("vid-self", "abc123")
mock_repo = MagicMock()
mock_repo.list_by_batch.return_value = [self_video]
fingerprint = VideoFingerprint(
md5="abc123",
keyframe_phashes=["ff"],
color_histograms=[],
duration=10.0,
resolution=(1280, 720),
)
orig, mod = self._patch_repo(mock_repo)
try:
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
assert result is None
finally:
self._restore_repo(mod, orig)
def test_batch_no_match(self, deduplicator, mock_session):
"""批次内无重复时应返回 None。"""
other = self._make_batch_video("vid-other", "md5_a", phashes=["0000000000000000"])
mock_repo = MagicMock()
mock_repo.list_by_batch.return_value = [other]
fingerprint = VideoFingerprint(
md5="md5_b",
keyframe_phashes=["ffffffffffffffff"],
color_histograms=[],
duration=10.0,
resolution=(1280, 720),
)
orig, mod = self._patch_repo(mock_repo)
try:
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
assert result is None
finally:
self._restore_repo(mod, orig)
def test_batch_empty_returns_none(self, deduplicator, mock_session):
"""空批次应返回 None。"""
mock_repo = MagicMock()
mock_repo.list_by_batch.return_value = []
fingerprint = VideoFingerprint(
md5="abc",
keyframe_phashes=["ff"],
color_histograms=[],
duration=10.0,
resolution=(1280, 720),
)
orig, mod = self._patch_repo(mock_repo)
try:
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
assert result is None
finally:
self._restore_repo(mod, orig)
def test_batch_skips_no_fingerprint(self, deduplicator, mock_session):
"""批次内无指纹的视频应被跳过。"""
other = MagicMock()
other.id = "vid-other"
other.video_fingerprint = None
mock_repo = MagicMock()
mock_repo.list_by_batch.return_value = [other]
fingerprint = VideoFingerprint(
md5="abc",
keyframe_phashes=["ff"],
color_histograms=[],
duration=10.0,
resolution=(1280, 720),
)
orig, mod = self._patch_repo(mock_repo)
try:
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
assert result is None
finally:
self._restore_repo(mod, orig)