From d05264530786166358719da4a5c72482591c4dd7 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Tue, 23 Jun 2026 20:14:03 +0800 Subject: [PATCH] feat(assets): add project material diagnosis --- apps/api/app/api/router.py | 5 + apps/api/app/api/routes/asset_diagnosis.py | 167 ++++++++++++++++++ apps/api/app/schemas/asset_diagnosis.py | 31 ++++ apps/web/e2e/core-upload.spec.ts | 3 + apps/web/src/api/assets.ts | 21 +++ .../web/src/pages/workspace/ProjectAssets.tsx | 62 ++++++- tests/unit/test_asset_diagnosis.py | 70 ++++++++ 7 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 apps/api/app/api/routes/asset_diagnosis.py create mode 100644 apps/api/app/schemas/asset_diagnosis.py create mode 100644 tests/unit/test_asset_diagnosis.py diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 269dca331..e4a39040c 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -1,3 +1,4 @@ +from app.api.routes.asset_diagnosis import router as asset_diagnosis_router from app.api.routes.asset_libraries import router as asset_libraries_router from app.api.routes.assets import router as assets_router from app.api.routes.auth import router as auth_router @@ -29,6 +30,10 @@ api_router.include_router( prefix="/projects", tags=["项目管理"], ) +api_router.include_router( + asset_diagnosis_router, + tags=["素材诊断"], +) api_router.include_router( asset_libraries_router, prefix="/asset-libraries", diff --git a/apps/api/app/api/routes/asset_diagnosis.py b/apps/api/app/api/routes/asset_diagnosis.py new file mode 100644 index 000000000..9b1ce607e --- /dev/null +++ b/apps/api/app/api/routes/asset_diagnosis.py @@ -0,0 +1,167 @@ +from typing import Any + +from app.api.routes.permissions import require_workspace_member +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import ( + get_asset_library_repository, + get_asset_repository, + get_project_repository, + get_workspace_member_repository, +) +from app.schemas.asset_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse +from fastapi import APIRouter, Depends, HTTPException + +from packages.domain import Asset, AssetLibraryKind, AssetStatus +from packages.ports.workspace_member_repository import WorkspaceMemberRepository + +router = APIRouter() + + +def _asset_kind(asset: Asset) -> str: + if asset.mime_type.startswith("video"): + return "video" + if asset.mime_type.startswith("audio"): + return "voice" + if asset.mime_type.startswith("image"): + return "image" + return asset.mime_type.split("/", 1)[0] + + +def _readiness_label(score: int) -> str: + if score >= 80: + return "素材充足" + if score >= 60: + return "基本可生成" + if score >= 40: + return "需要补素材" + return "暂不建议生成" + + +def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse: + ready_assets = [asset for asset in assets if asset.status == AssetStatus.READY] + video_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VIDEO] + image_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.IMAGE] + voice_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VOICE] + problem_assets = [asset for asset in assets if asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}] + unclassified_assets = [asset for asset in ready_assets if asset.classification_status.value in {"pending", "failed"}] + risky_assets = [asset for asset in ready_assets if asset.quality_score is not None and asset.quality_score < 60] + total_duration = round(sum(float(asset.duration or 0) for asset in video_assets), 2) + estimated_video_count = max(0, min(len(video_assets), int(total_duration // 5) if total_duration else len(video_assets))) + + score = 20 + if video_assets: + score += 30 + if len(video_assets) >= 3: + score += 15 + if total_duration >= 15: + score += 15 + if image_assets: + score += 5 + if voice_assets: + score += 5 + if not problem_assets: + score += 10 + score = max(0, min(100, score - min(25, len(risky_assets) * 5))) + + gaps: list[AssetGapItem] = [] + if not video_assets: + gaps.append( + AssetGapItem( + key="missing_video", + severity="critical", + message="缺少可用于生成的视频素材", + recommendation="至少上传 1 个已导入完成的视频素材;建议上传 3 个以上,生成效果更稳定。", + ) + ) + elif len(video_assets) < 3: + gaps.append( + AssetGapItem( + key="low_video_count", + severity="warning", + message="视频素材数量偏少", + recommendation="建议补充到 3 个以上视频素材,方便生成更多候选成片。", + ) + ) + if total_duration and total_duration < 15: + gaps.append( + AssetGapItem( + key="short_video_duration", + severity="warning", + message="可用视频总时长偏短", + recommendation="建议补充更多原始视频,至少达到 15 秒以上。", + ) + ) + if not voice_assets: + gaps.append( + AssetGapItem( + key="missing_voice", + severity="info", + message="暂未配置配音素材", + recommendation="如果本项目需要口播/旁白,请上传配音素材;纯画面生成可暂时忽略。", + ) + ) + if problem_assets: + gaps.append( + AssetGapItem( + key="not_ready_assets", + severity="warning", + message=f"有 {len(problem_assets)} 个素材尚未 ready", + recommendation="等待导入完成或删除失败素材后再生成。", + ) + ) + if risky_assets: + gaps.append( + AssetGapItem( + key="low_quality_assets", + severity="warning", + message=f"有 {len(risky_assets)} 个素材质量分偏低", + recommendation="优先使用清晰、稳定、时长充足的视频素材。", + ) + ) + + smart_views = [ + AssetSmartViewItem(key="recommended", label="推荐素材", count=len(video_assets), description="已导入完成、可参与生成的视频素材"), + AssetSmartViewItem(key="needs_attention", label="慎用素材", count=len(problem_assets) + len(risky_assets), description="导入未完成、失败或质量分偏低的素材"), + AssetSmartViewItem(key="unclassified", label="未分类素材", count=len(unclassified_assets), description="尚未完成分类或分类失败的 ready 素材"), + AssetSmartViewItem(key="recent", label="最近上传", count=min(len(assets), 10), description="最近进入素材库的素材,可用于快速复核"), + AssetSmartViewItem(key="voice", label="配音素材", count=len(voice_assets), description="可用于后续配音/旁白工作流的素材"), + ] + + return ProjectAssetDiagnosisResponse( + workspace_id=workspace_id, + project_id=project_id, + readiness_score=score, + readiness_label=_readiness_label(score), + total_assets=len(assets), + ready_assets=len(ready_assets), + video_assets=len(video_assets), + image_assets=len(image_assets), + voice_assets=len(voice_assets), + total_duration_seconds=total_duration, + estimated_video_count=estimated_video_count, + smart_views=smart_views, + gaps=gaps, + ) + + +@router.get("/projects/{project_id}/asset-diagnosis", response_model=ProjectAssetDiagnosisResponse) +def get_project_asset_diagnosis( + project_id: str, + 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), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), +) -> ProjectAssetDiagnosisResponse: + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository) + + libraries = asset_library_repository.list_by_project(project_id) + assets: list[Asset] = [] + for library in libraries: + assets.extend(asset_repository.list_by_library(library.id)) + + return _build_diagnosis(project.workspace_id, project_id, assets) + diff --git a/apps/api/app/schemas/asset_diagnosis.py b/apps/api/app/schemas/asset_diagnosis.py new file mode 100644 index 000000000..a167ed556 --- /dev/null +++ b/apps/api/app/schemas/asset_diagnosis.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel + + +class AssetSmartViewItem(BaseModel): + key: str + label: str + count: int + description: str + + +class AssetGapItem(BaseModel): + key: str + severity: str + message: str + recommendation: str + + +class ProjectAssetDiagnosisResponse(BaseModel): + workspace_id: str + project_id: str + readiness_score: int + readiness_label: str + total_assets: int + ready_assets: int + video_assets: int + image_assets: int + voice_assets: int + total_duration_seconds: float + estimated_video_count: int + smart_views: list[AssetSmartViewItem] + gaps: list[AssetGapItem] diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index f81a0cef5..1a1ce73f5 100644 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -108,6 +108,9 @@ test.describe('Core media upload flow', () => { .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/); await page.reload(); + await expect(page.getByText('素材智能诊断')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/推荐素材:1/)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('cell', { name: 'e2e-sample.MOV', exact: true })).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(/素材列表加载失败|上传失败/)).toHaveCount(0); }); diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index ff6c0008f..249f3c2e7 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -50,6 +50,27 @@ export interface ClassificationJob { error_message: string; } +export interface ProjectAssetDiagnosis { + workspace_id: string; + project_id: string; + readiness_score: number; + readiness_label: string; + total_assets: number; + ready_assets: number; + video_assets: number; + image_assets: number; + voice_assets: number; + total_duration_seconds: number; + estimated_video_count: number; + smart_views: Array<{ key: string; label: string; count: number; description: string }>; + gaps: Array<{ key: string; severity: 'critical' | 'warning' | 'info'; message: string; recommendation: string }>; +} + +export const getProjectAssetDiagnosis = async (projectId: string): Promise => { + const response = await apiClient.get(`/projects/${projectId}/asset-diagnosis`); + return response.data; +}; + export const getAssetLibraries = async (projectId: string): Promise => { const response = await apiClient.get('/asset-libraries', { params: { project_id: projectId }, diff --git a/apps/web/src/pages/workspace/ProjectAssets.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx index cb1c81512..640ab32c3 100644 --- a/apps/web/src/pages/workspace/ProjectAssets.tsx +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -24,6 +24,7 @@ import { createAssetLibrary, getAssetLibraries, getAssets, + getProjectAssetDiagnosis, getClassificationJob, getIngestJob, submitClassificationJob, @@ -153,6 +154,13 @@ const ProjectAssets: React.FC = () => { refetchOnWindowFocus: false, }); + const diagnosisQuery = useQuery({ + queryKey: ['project-asset-diagnosis', projectId], + queryFn: () => getProjectAssetDiagnosis(projectId), + enabled: !!projectId, + refetchOnWindowFocus: false, + }); + const ingestJobQuery = useQuery({ queryKey: ['ingest-job', ingestJobId], queryFn: () => getIngestJob(ingestJobId), @@ -177,12 +185,13 @@ const ProjectAssets: React.FC = () => { const status = ingestJobQuery.data?.status; if (status === 'completed') { assetsQuery.refetch(); + diagnosisQuery.refetch(); message.success('素材导入完成,已自动发起分类'); } if (status === 'failed') { message.error(ingestJobQuery.data?.error_message || '素材导入失败'); } - }, [ingestJobQuery.data?.status, assetsQuery]); + }, [ingestJobQuery.data?.status, assetsQuery, diagnosisQuery]); useEffect(() => { const status = classificationJobQuery.data?.status; @@ -487,7 +496,7 @@ const ProjectAssets: React.FC = () => { - @@ -550,6 +559,55 @@ const ProjectAssets: React.FC = () => { )} + {diagnosisQuery.isError && ( + + )} + + {diagnosisQuery.data && ( + + + + +
{diagnosisQuery.data.readiness_score}
+
{diagnosisQuery.data.readiness_label}
+
+ + + + 总素材 {diagnosisQuery.data.total_assets} + Ready {diagnosisQuery.data.ready_assets} + 视频 {diagnosisQuery.data.video_assets} + 图片 {diagnosisQuery.data.image_assets} + 配音 {diagnosisQuery.data.voice_assets} + 预计可生成 {diagnosisQuery.data.estimated_video_count} + +
+ + {diagnosisQuery.data.smart_views.map((item) => ( + {item.label}:{item.count} + ))} + +
+ +
+ {diagnosisQuery.data.gaps.length ? ( + + {diagnosisQuery.data.gaps.map((gap) => ( + + ))} + + ) : ( + + )} +
+ )} +

点击或拖拽文件到这里批量上传素材

diff --git a/tests/unit/test_asset_diagnosis.py b/tests/unit/test_asset_diagnosis.py new file mode 100644 index 000000000..ed62c46ad --- /dev/null +++ b/tests/unit/test_asset_diagnosis.py @@ -0,0 +1,70 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from app.api.routes.asset_diagnosis import _build_diagnosis +from packages.domain import Asset, AssetStatus, ClassificationStatus + + +def _asset(name: str, mime_type: str, *, status=AssetStatus.READY, duration=None, quality_score=None): + return Asset.create( + workspace_id="workspace-1", + project_id="project-1", + library_id="library-1", + name=name, + storage_key=f"uploads/{name}", + mime_type=mime_type, + file_size=1024, + duration=duration, + status=status, + classification_status=ClassificationStatus.COMPLETED, + quality_score=quality_score, + ) + + +def test_asset_diagnosis_reports_missing_video_gap(): + diagnosis = _build_diagnosis( + "workspace-1", + "project-1", + [_asset("voice.mp3", "audio/mpeg"), _asset("image.jpg", "image/jpeg")], + ) + + assert diagnosis.readiness_score < 80 + assert diagnosis.video_assets == 0 + assert any(gap.key == "missing_video" and gap.severity == "critical" for gap in diagnosis.gaps) + + +def test_asset_diagnosis_scores_ready_video_assets(): + diagnosis = _build_diagnosis( + "workspace-1", + "project-1", + [ + _asset("video-1.mp4", "video/mp4", duration=8), + _asset("video-2.mp4", "video/mp4", duration=8), + _asset("video-3.mov", "video/quicktime", duration=8), + _asset("voice.mp3", "audio/mpeg"), + ], + ) + + assert diagnosis.readiness_score >= 80 + assert diagnosis.video_assets == 3 + assert diagnosis.voice_assets == 1 + assert diagnosis.estimated_video_count == 3 + assert {item.key: item.count for item in diagnosis.smart_views}["recommended"] == 3 + + +def test_asset_diagnosis_flags_unready_and_low_quality_assets(): + diagnosis = _build_diagnosis( + "workspace-1", + "project-1", + [ + _asset("video.mp4", "video/mp4", duration=10, quality_score=40), + _asset("pending.mp4", "video/mp4", status=AssetStatus.UPLOADING), + ], + ) + + gap_keys = {gap.key for gap in diagnosis.gaps} + assert "not_ready_assets" in gap_keys + assert "low_quality_assets" in gap_keys + assert {item.key: item.count for item in diagnosis.smart_views}["needs_attention"] == 2