diff --git a/apps/api/app/api/routes/ingest_jobs.py b/apps/api/app/api/routes/ingest_jobs.py index 04ad38749..963ee182c 100644 --- a/apps/api/app/api/routes/ingest_jobs.py +++ b/apps/api/app/api/routes/ingest_jobs.py @@ -9,6 +9,26 @@ from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase router = APIRouter() +@router.get("/{job_id}", response_model=IngestJobResponse) +def get_ingest_job( + job_id: str, + ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository), +) -> IngestJobResponse: + job = ingest_job_repository.get(job_id) + if job is None: + raise ValueError(f"IngestJob {job_id} not found") + return IngestJobResponse( + id=job.id, + workspace_id=job.workspace_id, + project_id=job.project_id, + library_id=job.library_id, + storage_key=job.storage_key, + status=job.status.value, + error_message=job.error_message, + result_asset_id=job.result_asset_id, + ) + + @router.post("", response_model=IngestJobResponse) def submit_ingest_job( request: SubmitIngestJobRequest, diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index c0a3d2820..491bd15e5 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -1,9 +1,9 @@ -from fastapi import APIRouter, Depends, UploadFile, File, Form +from fastapi import APIRouter, Depends, File, Form, UploadFile from uuid import uuid4 from app.core.celery_app import celery_app -from app.dependencies import get_ingest_job_repository from app.core.storage import MinIOService, get_minio_service +from app.dependencies import get_ingest_job_repository from app.schemas.upload import UploadAssetResponse from packages.adapters.sqlalchemy_impl import SQLAlchemyIngestJobRepository from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase @@ -16,64 +16,20 @@ async def upload_asset( file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"), workspace_id: str = Form(..., description="工作空间 ID"), project_id: str = Form(..., description="项目 ID"), - library_id: str = Form(..., description="资产库 ID"), + library_id: str = Form(..., description="素材库 ID"), ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository), storage_service: MinIOService = Depends(get_minio_service), ) -> UploadAssetResponse: - """ - 上传素材文件并触发导入流水线。 - - ## 功能说明 - - 1. **接收文件**:支持 multipart/form-data 上传 - 2. **存储到 MinIO**:自动存储到对象存储 - 3. **生成存储键**:格式为 `uploads/{id}/{filename}` - 4. **提交导入任务**:创建 IngestJob 记录 - 5. **异步处理**:通过 Celery 队列处理 - - ## 支持的文件类型 - - - **视频**:MP4, MOV, AVI, MKV 等 - - **音频**:MP3, WAV, AAC 等 - - **图片**:JPG, PNG, GIF, WebP 等 - - ## 请求示例 - - ```bash - curl -X POST "http://localhost:8000/api/v1/upload" \ - -H "Content-Type: multipart/form-data" \ - -F "file=@/path/to/video.mp4" \ - -F "workspace_id=ws_123" \ - -F "project_id=proj_456" \ - -F "library_id=lib_789" - ``` - - ## 响应说明 - - - `storage_key`: 文件在 MinIO 中的存储路径 - - `ingest_job_id`: 导入任务 ID,用于追踪处理状态 - - `url`: 文件的公开访问 URL - - ## 后续流程 - - 上传成功后,系统会: - 1. 自动提取文件元数据(时长、分辨率等) - 2. 生成缩略图 - 3. 进行场景分割(视频) - 4. 创建 Asset 记录 - """ - # Generate storage key + """上传素材文件并触发导入流水线。""" file_id = uuid4().hex[:8] storage_key = f"uploads/{file_id}/{file.filename}" - - # Upload file to MinIO + file_url = storage_service.upload_file( file.file, storage_key, content_type=file.content_type or "application/octet-stream", ) - - # Submit ingest job + use_case = SubmitIngestJobUseCase(ingest_job_repository) job = use_case.execute( SubmitIngestJobCommand( @@ -83,9 +39,9 @@ async def upload_asset( storage_key=storage_key, ) ) - + celery_app.send_task("worker.ingest_asset", args=[job.id]) - + return UploadAssetResponse( storage_key=storage_key, ingest_job_id=job.id, diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts new file mode 100644 index 000000000..525e43c07 --- /dev/null +++ b/apps/web/src/api/assets.ts @@ -0,0 +1,70 @@ +/** + * 素材相关 API + */ +import apiClient from './client'; + +export interface AssetItem { + id: string; + workspace_id: string; + project_id: string; + library_id: string; + name: string; + storage_key: string; + mime_type: string; + metadata: Record; +} + +export interface AssetLibraryItem { + id: string; + workspace_id: string; + project_id: string; + name: string; + kind: 'video' | 'voice'; +} + +export interface IngestJob { + id: string; + workspace_id: string; + project_id: string; + library_id: string; + storage_key: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + error_message: string; + result_asset_id: string; +} + +export const getAssetLibraries = async (projectId: string): Promise => { + const response = await apiClient.get('/asset-libraries', { + params: { project_id: projectId }, + }); + return response.data.items; +}; + +export const createAssetLibrary = async (data: { + workspace_id: string; + project_id: string; + name: string; + kind: 'video' | 'voice'; +}): Promise => { + const response = await apiClient.post('/asset-libraries', data); + return response.data; +}; + +export const getAssets = async (libraryId: string): Promise => { + const response = await apiClient.get('/assets', { + params: { library_id: libraryId }, + }); + return response.data.items; +}; + +export const uploadAsset = async (formData: FormData): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => { + const response = await apiClient.post('/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; +}; + +export const getIngestJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/ingest-jobs/${jobId}`); + return response.data; +}; diff --git a/apps/web/src/api/workspace.ts b/apps/web/src/api/workspace.ts index c6aef3fb9..f869ab3ef 100644 --- a/apps/web/src/api/workspace.ts +++ b/apps/web/src/api/workspace.ts @@ -3,7 +3,6 @@ */ import apiClient from './client'; -// 类型定义 export interface Workspace { id: string; name: string; @@ -32,19 +31,16 @@ export interface InviteMemberRequest { role: 'admin' | 'member' | 'viewer'; } -// 获取工作空间列表 export const getWorkspaces = async (): Promise => { const response = await apiClient.get('/workspaces'); return response.data; }; -// 获取工作空间详情 export const getWorkspace = async (id: string): Promise => { const response = await apiClient.get(`/workspaces/${id}`); return response.data; }; -// 创建工作空间 export const createWorkspace = async ( data: CreateWorkspaceRequest ): Promise => { @@ -52,13 +48,11 @@ export const createWorkspace = async ( return response.data; }; -// 获取成员列表 export const getMembers = async (workspaceId: string): Promise => { const response = await apiClient.get(`/workspaces/${workspaceId}/members`); return response.data; }; -// 邀请成员 export const inviteMember = async ( workspaceId: string, data: InviteMemberRequest @@ -67,7 +61,6 @@ export const inviteMember = async ( return response.data; }; -// 移除成员 export const removeMember = async ( workspaceId: string, memberId: string @@ -76,7 +69,6 @@ export const removeMember = async ( return response.data; }; -// 更新成员角色 export const updateMemberRole = async ( workspaceId: string, memberId: string, @@ -88,7 +80,6 @@ export const updateMemberRole = async ( return response.data; }; -// 离开工作空间 export const leaveWorkspace = async (workspaceId: string): Promise<{ message: string }> => { const response = await apiClient.post(`/workspaces/${workspaceId}/leave`); return response.data; diff --git a/apps/web/src/pages/workspace/ProjectAssets.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx new file mode 100644 index 000000000..c678fd833 --- /dev/null +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Alert, Button, Card, Col, Empty, Form, Input, Modal, Row, Select, Space, Table, Tag, Upload, message } from 'antd'; +import { InboxOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createAssetLibrary, getAssetLibraries, getAssets, getIngestJob, uploadAsset } from '@/api/assets'; + +const { Dragger } = Upload; + +const ProjectAssets: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const projectId = id || ''; + const [libraryId, setLibraryId] = useState(''); + const [workspaceId, setWorkspaceId] = useState('demo-workspace'); + const [createLibraryOpen, setCreateLibraryOpen] = useState(false); + const [uploading, setUploading] = useState(false); + const [jobId, setJobId] = useState(''); + const [form] = Form.useForm(); + + const librariesQuery = useQuery({ + queryKey: ['asset-libraries', projectId], + queryFn: () => getAssetLibraries(projectId), + enabled: !!projectId, + }); + + useEffect(() => { + if (!libraryId && librariesQuery.data?.length) { + setLibraryId(librariesQuery.data[0].id); + } + }, [librariesQuery.data, libraryId]); + + const assetsQuery = useQuery({ + queryKey: ['assets', libraryId], + queryFn: () => getAssets(libraryId), + enabled: !!libraryId, + }); + + const ingestJobQuery = useQuery({ + queryKey: ['ingest-job', jobId], + queryFn: () => getIngestJob(jobId), + enabled: !!jobId, + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === 'completed' || status === 'failed' ? false : 2000; + }, + }); + + useEffect(() => { + const status = ingestJobQuery.data?.status; + if (status === 'completed') { + setUploading(false); + assetsQuery.refetch(); + message.success('素材导入完成'); + } + if (status === 'failed') { + setUploading(false); + message.error(ingestJobQuery.data?.error_message || '素材导入失败'); + } + }, [ingestJobQuery.data?.status]); + + const createLibraryMutation = useMutation({ + mutationFn: createAssetLibrary, + onSuccess: (library) => { + message.success('素材库创建成功'); + setCreateLibraryOpen(false); + librariesQuery.refetch(); + setLibraryId(library.id); + form.resetFields(); + }, + onError: () => message.error('素材库创建失败'), + }); + + const libraryOptions = useMemo( + () => (librariesQuery.data || []).map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })), + [librariesQuery.data] + ); + + const columns = [ + { title: '名称', dataIndex: 'name', key: 'name' }, + { title: '类型', dataIndex: 'mime_type', key: 'mime_type', render: (v: string) => {v} }, + { title: '存储键', dataIndex: 'storage_key', key: 'storage_key', ellipsis: true }, + ]; + + const customUpload = async (options: any) => { + const { file, onSuccess, onError } = options; + if (!libraryId) { + message.warning('请先选择或创建素材库'); + return; + } + try { + setUploading(true); + const formData = new FormData(); + formData.append('file', file); + formData.append('workspace_id', workspaceId); + formData.append('project_id', projectId); + formData.append('library_id', libraryId); + const result = await uploadAsset(formData); + setJobId(result.ingest_job_id); + onSuccess(result); + message.info('文件已上传,正在导入处理中'); + } catch (error) { + setUploading(false); + onError(error); + message.error('上传失败'); + } + }; + + return ( +
+ + + + setWorkspaceId(e.target.value)} /> + + + + + +