feat(plans): add edit plan preview workflow
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "006_add_edit_plan_tables"
|
||||
down_revision = "005_add_generated_video_management_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"edit_templates",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("workspace_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"),
|
||||
sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"edit_plans",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("workspace_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("template_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("asset_library_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("title_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True),
|
||||
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"edit_plan_clips",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("edit_plan_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("asset_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("start_time", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("duration", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("reason", sa.Text(), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column("generation_tasks", sa.Column("edit_plan_id", sa.String(32), nullable=False, server_default=""))
|
||||
op.create_index("ix_generation_tasks_edit_plan_id", "generation_tasks", ["edit_plan_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_edit_plan_id", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "edit_plan_id")
|
||||
op.drop_table("edit_plan_clips")
|
||||
op.drop_table("edit_plans")
|
||||
op.drop_table("edit_templates")
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.generated_videos import router as generated_videos_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
@@ -36,6 +37,9 @@ api_router.include_router(
|
||||
project_titles_router,
|
||||
tags=["标题库"],
|
||||
)
|
||||
api_router.include_router(
|
||||
edit_plans_router,
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["任务中心"],
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_asset_repository, get_db_session, get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.edit_plan import CreateEditPlanRequest, EditPlanClipResponse, EditPlanResponse, EditTemplateResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, EditPlanModel, EditTemplateModel
|
||||
from packages.domain import AssetStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/edit-plans", tags=["剪辑计划"])
|
||||
|
||||
|
||||
def _ensure_project(project_id: str, workspace_id: str, user: AuthenticatedUser, project_repository, member_repository):
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None or project.workspace_id != workspace_id:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_workspace_member(workspace_id, user, member_repository)
|
||||
return project
|
||||
|
||||
|
||||
def _default_template(session: Session, workspace_id: str, project_id: str, user_id: str) -> EditTemplateModel:
|
||||
template = session.query(EditTemplateModel).filter(
|
||||
EditTemplateModel.project_id == project_id,
|
||||
EditTemplateModel.is_active.is_(True),
|
||||
).order_by(EditTemplateModel.created_at.asc()).first()
|
||||
if template is not None:
|
||||
return template
|
||||
template = EditTemplateModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
name="基础节奏模板",
|
||||
description="自动选择可用视频素材,按上传顺序生成三段式剪辑计划。",
|
||||
target_duration=30,
|
||||
clip_count=3,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return template
|
||||
|
||||
|
||||
def _to_template_response(template: EditTemplateModel) -> EditTemplateResponse:
|
||||
return EditTemplateResponse(
|
||||
id=template.id,
|
||||
workspace_id=template.workspace_id,
|
||||
project_id=template.project_id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
target_duration=float(template.target_duration or 0),
|
||||
clip_count=int(template.clip_count or 0),
|
||||
is_active=bool(template.is_active),
|
||||
created_at=template.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_plan_response(plan: EditPlanModel, clips: list[EditPlanClipModel], asset_names: dict[str, str]) -> EditPlanResponse:
|
||||
return EditPlanResponse(
|
||||
id=plan.id,
|
||||
workspace_id=plan.workspace_id,
|
||||
project_id=plan.project_id,
|
||||
template_id=plan.template_id,
|
||||
asset_library_id=plan.asset_library_id,
|
||||
title_id=plan.title_id,
|
||||
status=plan.status,
|
||||
summary=plan.summary,
|
||||
clips=[EditPlanClipResponse(
|
||||
id=clip.id,
|
||||
asset_id=clip.asset_id,
|
||||
asset_name=asset_names.get(clip.asset_id, clip.asset_id),
|
||||
sequence=clip.sequence,
|
||||
start_time=float(clip.start_time or 0),
|
||||
duration=float(clip.duration or 0),
|
||||
reason=clip.reason,
|
||||
) for clip in clips],
|
||||
created_at=plan.created_at,
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[EditTemplateResponse])
|
||||
def list_edit_templates(
|
||||
project_id: str,
|
||||
workspace_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> list[EditTemplateResponse]:
|
||||
_ensure_project(project_id, workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
template = _default_template(session, workspace_id, project_id, authenticated_user.user.id)
|
||||
templates = session.query(EditTemplateModel).filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True)).all()
|
||||
return [_to_template_response(item) for item in templates or [template]]
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanResponse)
|
||||
def create_edit_plan(
|
||||
project_id: str,
|
||||
request: CreateEditPlanRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
_ensure_project(project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
template = session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first() if request.template_id else None
|
||||
if template is None:
|
||||
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
|
||||
assets = [asset for asset in asset_repository.list_by_library(request.asset_library_id) if asset.status == AssetStatus.READY and asset.mime_type.startswith("video/")]
|
||||
if not assets:
|
||||
raise HTTPException(status_code=422, detail="素材库暂无可用于剪辑计划的视频素材")
|
||||
selected = sorted(assets, key=lambda asset: (-(asset.quality_score or 0), asset.created_at))[: max(1, int(template.clip_count or 3))]
|
||||
plan = EditPlanModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=project_id,
|
||||
template_id=template.id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
title_id=request.title_id,
|
||||
status="draft",
|
||||
summary=f"按《{template.name}》自动选择 {len(selected)} 段素材,预计生成约 {int(template.target_duration or 30)} 秒成片。",
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(plan)
|
||||
clips: list[EditPlanClipModel] = []
|
||||
clip_duration = max(1, float(template.target_duration or 30) / len(selected))
|
||||
for index, asset in enumerate(selected, start=1):
|
||||
clip = EditPlanClipModel(
|
||||
id=uuid4().hex,
|
||||
edit_plan_id=plan.id,
|
||||
asset_id=asset.id,
|
||||
sequence=index,
|
||||
start_time=0,
|
||||
duration=min(float(asset.duration or clip_duration), clip_duration),
|
||||
reason="优先选择已就绪、质量分较高的视频素材。",
|
||||
)
|
||||
session.add(clip)
|
||||
clips.append(clip)
|
||||
session.commit()
|
||||
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in selected})
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=EditPlanResponse)
|
||||
def get_edit_plan(
|
||||
project_id: str,
|
||||
plan_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
plan = session.query(EditPlanModel).filter(EditPlanModel.id == plan_id, EditPlanModel.project_id == project_id).first()
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail="Edit plan not found")
|
||||
_ensure_project(project_id, plan.workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
clips = session.query(EditPlanClipModel).filter(EditPlanClipModel.edit_plan_id == plan.id).order_by(EditPlanClipModel.sequence.asc()).all()
|
||||
assets = asset_repository.list_by_library(plan.asset_library_id)
|
||||
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in assets})
|
||||
@@ -41,6 +41,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
edit_plan_id=task.edit_plan_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
@@ -114,6 +115,7 @@ def create_generation_task(
|
||||
asset_library_id=request.asset_library_id,
|
||||
strategy_id=request.strategy_id or _select_title_id(project_title_repository, request.project_id),
|
||||
voice_library_id=request.voice_library_id,
|
||||
edit_plan_id=request.edit_plan_id,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -130,6 +130,7 @@ def retry_project_task(
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
edit_plan_id=task.edit_plan_id,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
))
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EditTemplateResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
name: str
|
||||
description: str
|
||||
target_duration: float
|
||||
clip_count: int
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class EditPlanClipResponse(BaseModel):
|
||||
id: str
|
||||
asset_id: str
|
||||
asset_name: str
|
||||
sequence: int
|
||||
start_time: float
|
||||
duration: float
|
||||
reason: str
|
||||
|
||||
|
||||
class EditPlanResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
template_id: str
|
||||
asset_library_id: str
|
||||
title_id: str = ""
|
||||
status: str
|
||||
summary: str
|
||||
clips: list[EditPlanClipResponse] = Field(default_factory=list)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class CreateEditPlanRequest(BaseModel):
|
||||
workspace_id: str
|
||||
asset_library_id: str
|
||||
template_id: str = ""
|
||||
title_id: str = ""
|
||||
@@ -7,6 +7,7 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
asset_library_id: str = Field(..., min_length=1)
|
||||
strategy_id: str = ""
|
||||
voice_library_id: str = ""
|
||||
edit_plan_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
|
||||
|
||||
@@ -17,6 +18,7 @@ class GenerationTaskResponse(BaseModel):
|
||||
asset_library_id: str
|
||||
strategy_id: str
|
||||
voice_library_id: str
|
||||
edit_plan_id: str
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
@@ -11,7 +11,7 @@ type WorkspaceResponse = { id?: string; workspace_id?: string };
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type AssetListResponse = { items: Array<{ name: string; status: string; mime_type?: string; file_type?: string }> };
|
||||
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null; strategy_id?: string | null };
|
||||
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null; strategy_id?: string | null; edit_plan_id?: string | null };
|
||||
type ProjectTitleResponse = { id: string; text: string; usage_count: number };
|
||||
type GeneratedVideoResponse = { id: string; name: string; file_url: string; file_size: number };
|
||||
|
||||
@@ -116,16 +116,22 @@ test.describe('Core generation and download flow', () => {
|
||||
await page.getByText(projectTitleText).click();
|
||||
|
||||
await expect(page.getByText(/素材准备度:/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: '发起生成' })).toBeEnabled({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: '生成剪辑计划预览' })).toBeEnabled({ timeout: 20_000 });
|
||||
await page.getByRole('button', { name: '生成剪辑计划预览' }).click();
|
||||
await expect(page.getByText('剪辑计划预览')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/自动选择/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/e2e-generation-source\.mp4/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: '确认计划并发起生成' })).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
const createTaskResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/generation/tasks') && response.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: '发起生成' }).click();
|
||||
await page.getByRole('button', { name: '确认计划并发起生成' }).click();
|
||||
const createTaskResponse = await createTaskResponsePromise;
|
||||
expect(createTaskResponse.status(), await createTaskResponse.text()).toBe(200);
|
||||
const createdTask = (await createTaskResponse.json()) as GenerationTaskResponse;
|
||||
expect(createdTask.edit_plan_id || '').not.toBe('');
|
||||
|
||||
await expect(page.getByText(/任务状态:生成完成/)).toBeVisible({ timeout: 90_000 });
|
||||
await expect(page.getByText(/生成失败|生成任务查询失败|生成结果查询失败/)).toHaveCount(0);
|
||||
@@ -166,6 +172,7 @@ test.describe('Core generation and download flow', () => {
|
||||
const reviewedVideoData = (await reviewedVideo.json()) as { review_status: string; generation_params: Record<string, unknown> };
|
||||
expect(reviewedVideoData.review_status).toBe('approved');
|
||||
expect(reviewedVideoData.generation_params.title_id).toBe(titleData.id);
|
||||
expect(reviewedVideoData.generation_params.edit_plan_id).toBe(createdTask.edit_plan_id);
|
||||
const downloadUrlResponse = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}/download-url`, { headers });
|
||||
expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe(200);
|
||||
const downloadData = (await downloadUrlResponse.json()) as { download_url: string };
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface EditTemplateItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface EditPlanClipItem {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
asset_name: string;
|
||||
sequence: number;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface EditPlanItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
template_id: string;
|
||||
asset_library_id: string;
|
||||
title_id: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
clips: EditPlanClipItem[];
|
||||
}
|
||||
|
||||
export const getEditTemplates = async (projectId: string, workspaceId: string): Promise<EditTemplateItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/edit-plans/templates`, { params: { workspace_id: workspaceId } });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createEditPlan = async ({ projectId, data }: { projectId: string; data: {
|
||||
workspace_id: string;
|
||||
asset_library_id: string;
|
||||
template_id?: string;
|
||||
title_id?: string;
|
||||
} }): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.post(`/projects/${projectId}/edit-plans`, data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -7,6 +7,7 @@ export interface GenerationTaskItem {
|
||||
asset_library_id: string;
|
||||
strategy_id?: string | null;
|
||||
voice_library_id?: string | null;
|
||||
edit_plan_id?: string | null;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
result_count: number;
|
||||
@@ -37,6 +38,7 @@ export const createGenerationTask = async (data: {
|
||||
asset_library_id: string;
|
||||
strategy_id?: string;
|
||||
voice_library_id?: string;
|
||||
edit_plan_id?: string;
|
||||
created_by_user_id?: string;
|
||||
}): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.post('/generation/tasks', data);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Alert, Button, Card, Form, Input, Select, Space, Typography, message } from 'antd';
|
||||
import { Alert, Button, Card, Form, Input, List, Select, Space, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createGenerationTask, getGeneratedVideoDownloadUrl, getGenerationResults, getGenerationTask } from '@/api/generation';
|
||||
import { getAssetLibraries, getProjectAssetDiagnosis } from '@/api/assets';
|
||||
import { getProject } from '@/api/projects';
|
||||
import { getProjectTitles } from '@/api/projectTitles';
|
||||
import { createEditPlan, getEditTemplates, EditPlanItem } from '@/api/editPlans';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const humanizeGenerationStatus = (status: string) => {
|
||||
@@ -50,6 +51,7 @@ const ProjectGeneration: React.FC = () => {
|
||||
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string>(routedWorkspaceId || storedWorkspaceId);
|
||||
const workspaceId = routedWorkspaceId || storedWorkspaceId || resolvedWorkspaceId;
|
||||
const [taskId, setTaskId] = useState('');
|
||||
const [editPlan, setEditPlan] = useState<EditPlanItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -93,6 +95,12 @@ const ProjectGeneration: React.FC = () => {
|
||||
enabled: !!projectId,
|
||||
});
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ['edit-templates', projectId, workspaceId],
|
||||
queryFn: () => getEditTemplates(projectId, workspaceId),
|
||||
enabled: !!projectId && !!workspaceId,
|
||||
});
|
||||
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ['generation-task', taskId],
|
||||
queryFn: () => getGenerationTask(taskId),
|
||||
@@ -118,6 +126,15 @@ const ProjectGeneration: React.FC = () => {
|
||||
onError: (error: any) => message.error(humanizeGenerationError(error.response?.data?.detail || '创建生成任务失败')),
|
||||
});
|
||||
|
||||
const editPlanMutation = useMutation({
|
||||
mutationFn: createEditPlan,
|
||||
onSuccess: (plan) => {
|
||||
setEditPlan(plan);
|
||||
message.success('剪辑计划已生成,请确认后再发起生成');
|
||||
},
|
||||
onError: (error: any) => message.error(error.response?.data?.detail || '生成剪辑计划失败'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (taskQuery.data?.status === 'completed') {
|
||||
resultsQuery.refetch();
|
||||
@@ -139,6 +156,11 @@ const ProjectGeneration: React.FC = () => {
|
||||
[titlesQuery.data]
|
||||
);
|
||||
|
||||
const templateOptions = useMemo(
|
||||
() => (templatesQuery.data || []).map((item) => ({ label: `${item.name} (${item.clip_count}段/${Math.round(item.target_duration)}秒)`, value: item.id })),
|
||||
[templatesQuery.data]
|
||||
);
|
||||
|
||||
const failedReason = taskQuery.data?.status === 'failed'
|
||||
? humanizeGenerationError(taskQuery.data.error_message)
|
||||
: '';
|
||||
@@ -207,6 +229,7 @@ const ProjectGeneration: React.FC = () => {
|
||||
asset_library_id: values.asset_library_id,
|
||||
voice_library_id: values.voice_library_id || '',
|
||||
strategy_id: values.title_id || values.strategy_id || '',
|
||||
edit_plan_id: editPlan?.id || '',
|
||||
created_by_user_id: user?.id || '',
|
||||
})
|
||||
}
|
||||
@@ -220,14 +243,54 @@ const ProjectGeneration: React.FC = () => {
|
||||
<Form.Item label="标题" name="title_id">
|
||||
<Select allowClear options={titleOptions} placeholder="可选:选择标题库中的标题" loading={titlesQuery.isLoading} />
|
||||
</Form.Item>
|
||||
<Form.Item label="剪辑模板" name="template_id">
|
||||
<Select allowClear options={templateOptions} placeholder="选择用于生成剪辑计划的模板" loading={templatesQuery.isLoading} />
|
||||
</Form.Item>
|
||||
<Form.Item label="策略 ID" name="strategy_id">
|
||||
<Input placeholder="可选:例如 default-strategy" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={generationMutation.isPending} disabled={!canSubmitGeneration}>发起生成</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const values = form.getFieldsValue();
|
||||
if (!values.asset_library_id) {
|
||||
message.warning('请先选择素材库');
|
||||
return;
|
||||
}
|
||||
editPlanMutation.mutate({
|
||||
projectId,
|
||||
data: {
|
||||
workspace_id: workspaceId,
|
||||
asset_library_id: values.asset_library_id,
|
||||
template_id: values.template_id || '',
|
||||
title_id: values.title_id || '',
|
||||
},
|
||||
});
|
||||
}}
|
||||
loading={editPlanMutation.isPending}
|
||||
disabled={!canSubmitGeneration}
|
||||
>
|
||||
生成剪辑计划预览
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={generationMutation.isPending} disabled={!canSubmitGeneration || !editPlan}>确认计划并发起生成</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{editPlan && (
|
||||
<Card size="small" style={{ marginTop: 16 }} title="剪辑计划预览">
|
||||
<Typography.Paragraph>{editPlan.summary}</Typography.Paragraph>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={editPlan.clips}
|
||||
renderItem={(clip) => (
|
||||
<List.Item>
|
||||
<Typography.Text>{clip.sequence}. {clip.asset_name} / {Math.round(clip.duration)}秒 / {clip.reason}</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{taskQuery.data && (
|
||||
<Alert
|
||||
style={{ marginTop: 16 }}
|
||||
|
||||
@@ -245,6 +245,7 @@ def generate_video(task_id: str) -> dict:
|
||||
"asset_library_id": task.asset_library_id,
|
||||
"voice_library_id": task.voice_library_id,
|
||||
"title_id": task.strategy_id,
|
||||
"edit_plan_id": task.edit_plan_id,
|
||||
"output_width": OUTPUT_WIDTH,
|
||||
"output_height": OUTPUT_HEIGHT,
|
||||
"output_fps": OUTPUT_FPS,
|
||||
|
||||
@@ -16,6 +16,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
strategy_id=task.strategy_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
edit_plan_id=task.edit_plan_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
@@ -40,6 +41,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
strategy_id=model.strategy_id,
|
||||
asset_library_id=model.asset_library_id,
|
||||
voice_library_id=model.voice_library_id,
|
||||
edit_plan_id=getattr(model, "edit_plan_id", "") or "",
|
||||
status=GenerationTaskStatus(model.status),
|
||||
progress=model.progress,
|
||||
result_count=int(model.result_count or 0),
|
||||
@@ -58,6 +60,8 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"GenerationTask {task.id} not found")
|
||||
model.voice_library_id = task.voice_library_id
|
||||
model.edit_plan_id = task.edit_plan_id
|
||||
model.status = task.status.value
|
||||
model.progress = task.progress
|
||||
model.result_count = task.result_count
|
||||
|
||||
@@ -131,6 +131,50 @@ class ProjectTitleModel(Base):
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditTemplateModel(Base):
|
||||
__tablename__ = "edit_templates"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
workspace_id = Column(String(32), nullable=False, index=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
name = Column(String(120), nullable=False)
|
||||
description = Column(Text, nullable=False, default="")
|
||||
target_duration = Column(Float, nullable=False, default=30)
|
||||
clip_count = Column(Integer, nullable=False, default=3)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_by_user_id = Column(String(32), nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditPlanModel(Base):
|
||||
__tablename__ = "edit_plans"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
workspace_id = Column(String(32), nullable=False, index=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
template_id = Column(String(32), nullable=False, index=True)
|
||||
asset_library_id = Column(String(32), nullable=False, index=True)
|
||||
title_id = Column(String(32), nullable=False, default="")
|
||||
status = Column(String(20), nullable=False, default="draft", index=True)
|
||||
summary = Column(Text, nullable=False, default="")
|
||||
created_by_user_id = Column(String(32), nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditPlanClipModel(Base):
|
||||
__tablename__ = "edit_plan_clips"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
edit_plan_id = Column(String(32), nullable=False, index=True)
|
||||
asset_id = Column(String(32), nullable=False, index=True)
|
||||
sequence = Column(Integer, nullable=False)
|
||||
start_time = Column(Float, nullable=False, default=0)
|
||||
duration = Column(Float, nullable=False, default=0)
|
||||
reason = Column(Text, nullable=False, default="")
|
||||
|
||||
|
||||
class IngestJobModel(Base):
|
||||
__tablename__ = "ingest_jobs"
|
||||
|
||||
@@ -170,6 +214,7 @@ class GenerationTaskModel(Base):
|
||||
strategy_id = Column(String(32), nullable=False, default="")
|
||||
asset_library_id = Column(String(32), nullable=False, index=True)
|
||||
voice_library_id = Column(String(32), nullable=False, default="")
|
||||
edit_plan_id = Column(String(32), nullable=False, default="", index=True)
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
progress = Column(Float, nullable=False, default=0.0)
|
||||
result_count = Column(Float, nullable=False, default=0)
|
||||
|
||||
@@ -13,6 +13,7 @@ class CreateGenerationTaskCommand:
|
||||
asset_library_id: str
|
||||
strategy_id: str = ""
|
||||
voice_library_id: str = ""
|
||||
edit_plan_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
|
||||
|
||||
@@ -27,6 +28,7 @@ class CreateGenerationTaskUseCase:
|
||||
asset_library_id=command.asset_library_id,
|
||||
strategy_id=command.strategy_id,
|
||||
voice_library_id=command.voice_library_id,
|
||||
edit_plan_id=command.edit_plan_id,
|
||||
created_by_user_id=command.created_by_user_id,
|
||||
)
|
||||
return self.generation_task_repository.create(task)
|
||||
|
||||
@@ -22,6 +22,7 @@ class GenerationTask:
|
||||
asset_library_id: str
|
||||
strategy_id: str = ""
|
||||
voice_library_id: str = ""
|
||||
edit_plan_id: str = ""
|
||||
status: GenerationTaskStatus = GenerationTaskStatus.PENDING
|
||||
progress: float = 0.0
|
||||
result_count: int = 0
|
||||
@@ -40,6 +41,7 @@ class GenerationTask:
|
||||
*,
|
||||
strategy_id: str = "",
|
||||
voice_library_id: str = "",
|
||||
edit_plan_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
) -> "GenerationTask":
|
||||
if not workspace_id.strip():
|
||||
@@ -55,5 +57,6 @@ class GenerationTask:
|
||||
asset_library_id=asset_library_id.strip(),
|
||||
strategy_id=strategy_id.strip(),
|
||||
voice_library_id=voice_library_id.strip(),
|
||||
edit_plan_id=edit_plan_id.strip(),
|
||||
created_by_user_id=created_by_user_id.strip(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user