diff --git a/alembic/versions/031_add_file_hash_to_assets_and_ing_jobs.py b/alembic/versions/031_add_file_hash_to_assets_and_ing_jobs.py new file mode 100644 index 000000000..3af61f178 --- /dev/null +++ b/alembic/versions/031_add_file_hash_to_assets_and_ing_jobs.py @@ -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") diff --git a/alembic/versions/032_add_asset_select_mode_to_generation_tasks.py b/alembic/versions/032_add_asset_select_mode_to_generation_tasks.py new file mode 100644 index 000000000..842863817 --- /dev/null +++ b/alembic/versions/032_add_asset_select_mode_to_generation_tasks.py @@ -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") diff --git a/alembic/versions/033_add_batch_id_to_generation_tasks.py b/alembic/versions/033_add_batch_id_to_generation_tasks.py new file mode 100644 index 000000000..843dfab63 --- /dev/null +++ b/alembic/versions/033_add_batch_id_to_generation_tasks.py @@ -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") diff --git a/apps/api/app/api/routes/chunked_upload.py b/apps/api/app/api/routes/chunked_upload.py index 058b1a884..cf8aaae4c 100644 --- a/apps/api/app/api/routes/chunked_upload.py +++ b/apps/api/app/api/routes/chunked_upload.py @@ -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]) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index cfd47edbd..b4ece7f3e 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -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]) diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index 304afefe9..7679630c3 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -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( diff --git a/apps/api/app/schemas/chunked_upload.py b/apps/api/app/schemas/chunked_upload.py index 8c5cbef78..884b1f481 100644 --- a/apps/api/app/schemas/chunked_upload.py +++ b/apps/api/app/schemas/chunked_upload.py @@ -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_id(duplicated=true 时返回)") diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 8e1fc7a74..e75479954 100644 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -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)。""" diff --git a/apps/api/app/schemas/upload.py b/apps/api/app/schemas/upload.py index 90a7a026d..b9763606c 100644 --- a/apps/api/app/schemas/upload.py +++ b/apps/api/app/schemas/upload.py @@ -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_id(duplicated=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_id(duplicated=true 时返回)") diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index e7129fff7..80aef0597 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -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 做缩略图(是视频文件, 无法渲染) 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 (
+ {/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */} + +
+ + {/* 背景圆环 */} + + {/* 进度圆弧 */} + + +
+ {uploadProgress}% + 上传中… +
+
+
+ {/* 两栏布局 */}
{/* ─── 左侧:素材库列表 ─── */} diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index 3333f1306..63e46e653 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -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); +} diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 6e9dc0342..1c6ad79ea 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -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 = { voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)", voice_pip: "linear-gradient(135deg, #10b981, #059669)", }; -const MODE_ABBRS: Record = { - pip: "PIP", - one_take: "ONE", - voice_over: "VOI", - voice_pip: "VP", -}; - /* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */ const VOICE_GENDER_ICON: Record = { female: "🎀", @@ -122,6 +115,8 @@ const GeneratePage: React.FC = () => { /* ── 素材 ── */ const [selectedMaterials, setSelectedMaterials] = useState([]); + /* 素材选择模式:手动选择 / 自动匹配 */ + 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(""); 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"} + 🎬

{tpl.name}

- {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "} {tpl.estimated_duration}s · {tpl.segments.length}片段

{tpl.tags.length > 0 && ( @@ -708,11 +739,31 @@ const GeneratePage: React.FC = () => {
); - /** 步骤 2:选择素材 */ + /** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */ const renderStep2 = () => (

📦 选择素材

-
+ + {/* ── 模式切换 Tab ── */} +
+ + +
+ + {/* ── 素材库选择(两种模式共用) ── */} +
-
- - 已选 {selectedMaterials.length} 个素材 - - - 系统将自动选择最合适的素材 - -
- {/* 素材列表 */} -
- {materialsLoading ? ( - - 加载素材中… - - ) : materials.length === 0 ? ( - - 暂无素材,请先在素材库中上传 - - ) : ( -
- {materials.map((m) => { - const checked = selectedMaterials.includes(m.id); - return ( - - ); - })} + {/* ── 手动选择模式 ── */} + {materialMode === "manual" && ( + <> +
+ + 已选 {selectedMaterials.length} 个素材 +
- )} -
+ + {/* 素材列表 */} +
+ {materialsLoading ? ( + + 加载素材中… + + ) : materials.length === 0 ? ( + + 暂无素材,请先在素材库中上传 + + ) : ( +
+ {materials.map((m) => { + const checked = selectedMaterials.includes(m.id); + return ( + + ); + })} +
+ )} +
+ + )} + + {/* ── 自动匹配模式 ── */} + {materialMode === "auto" && ( +
+
🤖
+
+

智能素材匹配

+

+ 系统将根据所选模板和标题,从素材库中自动分析并匹配最合适的素材进行视频生成。 + 无需手动挑选,AI + 会综合素材质量、时长、内容相关性等维度进行智能筛选。 +

+
+ 📊 质量评分筛选 + 🎯 内容相关性匹配 + ⏱️ 时长智能分配 +
+
+ {materialsLoading ? ( + + 扫描素材库中… + + ) : ( + + 当前素材库共 {materials.length} 个素材可供匹配 + + )} +
+ )}
); @@ -1270,7 +1369,9 @@ const GeneratePage: React.FC = () => {
素材 - {selectedMaterials.length} 个素材 + {materialMode === "auto" + ? "自动匹配" + : `${selectedMaterials.length} 个素材`}
@@ -1281,6 +1382,29 @@ const GeneratePage: React.FC = () => { 配音 {getVoiceName()}
+
+ 生成数量 + +
+ + {generateCount} + + 条视频 +
+
+
{/* 生成进度 */} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 82052f16b..8f915d49e 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -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; +} diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx index d0aa34e58..b992c853f 100644 --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -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 (
-
-

{error?.message || "加载失败"}

+
{is404 ? "🔍" : "❌"}
+

+ {is404 + ? "成片库功能正在建设中,敬请期待" + : errorMsg || "加载失败,请稍后重试"} +