feat(assets): add project material diagnosis
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user