feat(assets): add project material diagnosis

This commit is contained in:
Xiaoxia AI
2026-06-23 20:14:03 +08:00
parent 798a4ce711
commit d052645307
7 changed files with 357 additions and 2 deletions
+5
View File
@@ -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",
+167
View File
@@ -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)
+31
View File
@@ -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]
+3
View File
@@ -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);
});
+21
View File
@@ -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<ProjectAssetDiagnosis> => {
const response = await apiClient.get(`/projects/${projectId}/asset-diagnosis`);
return response.data;
};
export const getAssetLibraries = async (projectId: string): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get('/asset-libraries', {
params: { project_id: projectId },
+60 -2
View File
@@ -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 = () => {
<Button icon={<PlusOutlined />} onClick={() => setCreateLibraryOpen(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => { librariesQuery.refetch(); assetsQuery.refetch(); }}>
<Button icon={<ReloadOutlined />} onClick={() => { librariesQuery.refetch(); assetsQuery.refetch(); diagnosisQuery.refetch(); }}>
</Button>
</Space>
@@ -550,6 +559,55 @@ const ProjectAssets: React.FC = () => {
</Card>
)}
{diagnosisQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材诊断加载失败" />
)}
{diagnosisQuery.data && (
<Card size="small" style={{ marginBottom: 16 }} title="素材智能诊断">
<Row gutter={[12, 12]}>
<Col xs={24} md={6}>
<Card size="small">
<div style={{ fontSize: 28, fontWeight: 700 }}>{diagnosisQuery.data.readiness_score}</div>
<div>{diagnosisQuery.data.readiness_label}</div>
</Card>
</Col>
<Col xs={24} md={18}>
<Space size={[8, 8]} wrap>
<Tag color="blue"> {diagnosisQuery.data.total_assets}</Tag>
<Tag color="green">Ready {diagnosisQuery.data.ready_assets}</Tag>
<Tag color="purple"> {diagnosisQuery.data.video_assets}</Tag>
<Tag color="orange"> {diagnosisQuery.data.image_assets}</Tag>
<Tag color="cyan"> {diagnosisQuery.data.voice_assets}</Tag>
<Tag color="gold"> {diagnosisQuery.data.estimated_video_count}</Tag>
</Space>
<div style={{ marginTop: 12 }}>
<Space size={[8, 8]} wrap>
{diagnosisQuery.data.smart_views.map((item) => (
<Tag key={item.key}>{item.label}{item.count}</Tag>
))}
</Space>
</div>
</Col>
</Row>
{diagnosisQuery.data.gaps.length ? (
<Space direction="vertical" style={{ marginTop: 12, width: '100%' }}>
{diagnosisQuery.data.gaps.map((gap) => (
<Alert
key={gap.key}
type={gap.severity === 'critical' ? 'error' : gap.severity === 'warning' ? 'warning' : 'info'}
showIcon
message={gap.message}
description={gap.recommendation}
/>
))}
</Space>
) : (
<Alert style={{ marginTop: 12 }} type="success" showIcon message="素材准备度良好,可以进入生成流程" />
)}
</Card>
)}
<Dragger name="file" multiple customRequest={customUpload} showUploadList={false} disabled={!workspaceId || !libraryId} style={{ marginBottom: 24 }}>
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text"></p>
+70
View File
@@ -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