diff --git a/alembic/versions/006_add_edit_plan_tables.py b/alembic/versions/006_add_edit_plan_tables.py new file mode 100644 index 000000000..182d2405b --- /dev/null +++ b/alembic/versions/006_add_edit_plan_tables.py @@ -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") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index a29372ec9..17a421028 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -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=["任务中心"], diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py new file mode 100644 index 000000000..c36503454 --- /dev/null +++ b/apps/api/app/api/routes/edit_plans.py @@ -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}) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index a75ce895e..9781d0468 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -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, ) ) diff --git a/apps/api/app/api/routes/task_center.py b/apps/api/app/api/routes/task_center.py index 647d30fc1..dacc79ae3 100644 --- a/apps/api/app/api/routes/task_center.py +++ b/apps/api/app/api/routes/task_center.py @@ -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]) diff --git a/apps/api/app/schemas/edit_plan.py b/apps/api/app/schemas/edit_plan.py new file mode 100644 index 000000000..4610e4bac --- /dev/null +++ b/apps/api/app/schemas/edit_plan.py @@ -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 = "" diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index bb93e3221..372680677 100644 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -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 diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index b4bcfc21c..690d993ea 100644 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -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 }; 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 }; diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts new file mode 100644 index 000000000..bbfe3bfc1 --- /dev/null +++ b/apps/web/src/api/editPlans.ts @@ -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 => { + 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 => { + const response = await apiClient.post(`/projects/${projectId}/edit-plans`, data); + return response.data; +}; diff --git a/apps/web/src/api/generation.ts b/apps/web/src/api/generation.ts index 7856deaeb..7d07a23df 100644 --- a/apps/web/src/api/generation.ts +++ b/apps/web/src/api/generation.ts @@ -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 => { const response = await apiClient.post('/generation/tasks', data); diff --git a/apps/web/src/pages/workspace/ProjectGeneration.tsx b/apps/web/src/pages/workspace/ProjectGeneration.tsx index 73f90c1c2..639144701 100644 --- a/apps/web/src/pages/workspace/ProjectGeneration.tsx +++ b/apps/web/src/pages/workspace/ProjectGeneration.tsx @@ -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(routedWorkspaceId || storedWorkspaceId); const workspaceId = routedWorkspaceId || storedWorkspaceId || resolvedWorkspaceId; const [taskId, setTaskId] = useState(''); + const [editPlan, setEditPlan] = useState(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 = () => { + - + + + {editPlan && ( + + {editPlan.summary} + ( + + {clip.sequence}. {clip.asset_name} / {Math.round(clip.duration)}秒 / {clip.reason} + + )} + /> + + )} + {taskQuery.data && ( 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, diff --git a/packages/adapters/sqlalchemy_impl/generation_task_repository.py b/packages/adapters/sqlalchemy_impl/generation_task_repository.py index bffa31d6d..2358ac690 100644 --- a/packages/adapters/sqlalchemy_impl/generation_task_repository.py +++ b/packages/adapters/sqlalchemy_impl/generation_task_repository.py @@ -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 diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 89b4cfee5..9217703b4 100644 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -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) diff --git a/packages/application/generation_tasks.py b/packages/application/generation_tasks.py index cea6e7594..7ff8e3c7f 100644 --- a/packages/application/generation_tasks.py +++ b/packages/application/generation_tasks.py @@ -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) diff --git a/packages/domain/generation_task.py b/packages/domain/generation_task.py index d9710e689..4527d4a1b 100644 --- a/packages/domain/generation_task.py +++ b/packages/domain/generation_task.py @@ -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(), )