From a1cf1ddd9dc7bbaeea91c376fc03ce427c3b12ab Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Mon, 22 Jun 2026 13:19:45 +0800 Subject: [PATCH] fix(web): restore project workspace context --- apps/api/app/api/routes/projects.py | 43 +++-- apps/web/src/api/projects.ts | 5 + .../web/src/pages/workspace/ProjectAssets.tsx | 26 ++- docs/PHASE8-DESIGN.md | 144 ++++++++++++++++ docs/PHASE9-DESIGN.md | 154 ++++++++++++++++++ .../adapters/in_memory/project_repository.py | 7 + .../sqlalchemy_impl/project_repository.py | 26 +-- packages/application/__init__.py | 4 +- packages/application/projects.py | 10 ++ scripts/smoke_public_upload_flow.py | 111 +++++++++++++ tests/integration/test_projects.py | 14 ++ 11 files changed, 510 insertions(+), 34 deletions(-) create mode 100644 docs/PHASE8-DESIGN.md create mode 100644 docs/PHASE9-DESIGN.md create mode 100644 scripts/smoke_public_upload_flow.py diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py index 7ac381ff3..e84785c94 100644 --- a/apps/api/app/api/routes/projects.py +++ b/apps/api/app/api/routes/projects.py @@ -6,17 +6,39 @@ from app.schemas.project import ( ListProjectsResponse, ProjectResponse, ) -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from packages.application import ( CreateProjectCommand, CreateProjectUseCase, + GetProjectUseCase, ListProjectsUseCase, ) router = APIRouter() +def _to_project_response(item) -> ProjectResponse: + return ProjectResponse( + id=item.id, + workspace_id=item.workspace_id, + name=item.name, + description=item.description, + ) + + +@router.get("/{project_id}", response_model=ProjectResponse) +def get_project( + project_id: str, + project_repository: Any = Depends(get_project_repository), +) -> ProjectResponse: + use_case = GetProjectUseCase(project_repository) + project = use_case.execute(project_id) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + return _to_project_response(project) + + @router.get("", response_model=ListProjectsResponse) def list_projects( workspace_id: str, @@ -24,17 +46,7 @@ def list_projects( ) -> ListProjectsResponse: use_case = ListProjectsUseCase(project_repository) projects = use_case.execute(workspace_id) - return ListProjectsResponse( - items=[ - ProjectResponse( - id=item.id, - workspace_id=item.workspace_id, - name=item.name, - description=item.description, - ) - for item in projects - ] - ) + return ListProjectsResponse(items=[_to_project_response(item) for item in projects]) @router.post("", response_model=ProjectResponse) @@ -50,9 +62,4 @@ def create_project( description=request.description, ) ) - return ProjectResponse( - id=project.id, - workspace_id=project.workspace_id, - name=project.name, - description=project.description, - ) + return _to_project_response(project) diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts index f9919cc9f..4c9707fa5 100644 --- a/apps/web/src/api/projects.ts +++ b/apps/web/src/api/projects.ts @@ -13,6 +13,11 @@ export interface CreateProjectRequest { description?: string; } +export const getProject = async (projectId: string): Promise => { + const response = await apiClient.get(`/projects/${projectId}`); + return response.data; +}; + export const getProjects = async (workspaceId: string): Promise => { const response = await apiClient.get('/projects', { params: { workspace_id: workspaceId } }); return response.data.items; diff --git a/apps/web/src/pages/workspace/ProjectAssets.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx index 1f7f54a08..a07524ca5 100644 --- a/apps/web/src/pages/workspace/ProjectAssets.tsx +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -30,6 +30,7 @@ import { uploadAsset, type AssetItem, } from '@/api/assets'; +import { getProject } from '@/api/projects'; const { Dragger } = Upload; @@ -85,7 +86,8 @@ const ProjectAssets: React.FC = () => { const projectId = id || ''; const routedWorkspaceId = (location.state as { workspaceId?: string } | null)?.workspaceId || ''; const storedWorkspaceId = projectId ? sessionStorage.getItem(`project-workspace:${projectId}`) || '' : ''; - const workspaceId = routedWorkspaceId || storedWorkspaceId; + const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState(routedWorkspaceId || storedWorkspaceId); + const workspaceId = routedWorkspaceId || storedWorkspaceId || resolvedWorkspaceId; const [libraryId, setLibraryId] = useState(''); const [createLibraryOpen, setCreateLibraryOpen] = useState(false); const [uploading, setUploading] = useState(false); @@ -104,9 +106,25 @@ const ProjectAssets: React.FC = () => { useEffect(() => { if (projectId && routedWorkspaceId) { sessionStorage.setItem(`project-workspace:${projectId}`, routedWorkspaceId); + setResolvedWorkspaceId(routedWorkspaceId); } }, [projectId, routedWorkspaceId]); + const projectQuery = useQuery({ + queryKey: ['project', projectId], + queryFn: () => getProject(projectId), + enabled: !!projectId && !workspaceId, + retry: false, + }); + + useEffect(() => { + const projectWorkspaceId = projectQuery.data?.workspace_id; + if (projectId && projectWorkspaceId) { + sessionStorage.setItem(`project-workspace:${projectId}`, projectWorkspaceId); + setResolvedWorkspaceId(projectWorkspaceId); + } + }, [projectId, projectQuery.data?.workspace_id]); + const librariesQuery = useQuery({ queryKey: ['asset-libraries', projectId], queryFn: () => getAssetLibraries(projectId), @@ -450,10 +468,10 @@ const ProjectAssets: React.FC = () => { {!workspaceId && ( )} diff --git a/docs/PHASE8-DESIGN.md b/docs/PHASE8-DESIGN.md new file mode 100644 index 000000000..3db1c0ba5 --- /dev/null +++ b/docs/PHASE8-DESIGN.md @@ -0,0 +1,144 @@ +# Phase 8: 自动剪辑模板与编排引擎设计 + +> 状态:规划中 +> 基线:Phase 7 已完成上传 → 分类 → 生成 → 下载主链路 +> 原则:Clean Architecture、单一环境真源、生产不构建、未实现不假入口 + +## 一、目标 + +Phase 8 的目标是把当前“用户上传素材后手动发起生成”的 MVP,升级为“按模板自动编排素材并生成成片”的自动剪辑 1.0。 + +一句话:用户选择项目、素材库和剪辑模板,系统自动选素材、排顺序、生成剪辑计划,并交给 FFmpeg Worker 输出成片。 + +## 二、不做范围 + +- 不做复杂 AI 脚本理解。 +- 不做数字人、直播切片、自动带货文案生成。 +- 不做真实支付/订阅闭环。 +- 不做假模板市场或 mock 运营后台。 + +## 三、核心能力 + +1. 剪辑模板管理 + - 内置模板:基础混剪、产品展示、探店短片、口播素材拼接。 + - 模板字段:比例、目标时长、片段数量、片段时长、分类偏好、转场策略、音乐策略。 + +2. 自动选片 + - 基于素材分类、质量分、时长、类型筛选候选素材。 + - 生成候选片段列表,不直接修改原素材。 + +3. 剪辑计划 + - 新增 `EditPlan` 聚合,保存自动编排结果。 + - 新增 `EditPlanClip`,描述每个片段的素材、入点、出点、顺序、转场。 + - 生成任务从“直接 FFmpeg”升级为“按 EditPlan 执行 FFmpeg”。 + +4. 前端工作流 + - 项目生成页新增模板选择。 + - 生成前展示剪辑计划预览。 + - 用户确认后创建生成任务。 + - 不支持的模板能力显示“暂未开放”,不得 fake success。 + +## 四、架构设计 + +### Domain + +- `EditTemplate` +- `EditPlan` +- `EditPlanClip` +- `TemplateKind` +- `ClipSelectionRule` + +### Ports + +- `EditTemplateRepository` +- `EditPlanRepository` +- `ClipSelector` +- `EditPlanRenderer` + +### Application Use Cases + +- `ListEditTemplatesUseCase` +- `PreviewEditPlanUseCase` +- `CreateEditPlanUseCase` +- `CreateGenerationTaskFromEditPlanUseCase` + +### Adapters + +- SQLAlchemy repositories 保存模板与剪辑计划。 +- Rule-based `ClipSelector` 先实现确定性选片。 +- FFmpeg renderer 根据 `EditPlan` 拼接视频。 + +### API + +- `GET /api/v1/edit-templates` +- `POST /api/v1/edit-plans/preview` +- `POST /api/v1/edit-plans` +- `GET /api/v1/edit-plans/{id}` +- `POST /api/v1/generation/from-edit-plan` + +## 五、数据库迁移 + +新增 Alembic migration: + +- `edit_templates` +- `edit_plans` +- `edit_plan_clips` + +迁移规则: + +- Alembic 是唯一迁移真源。 +- 禁止生产自动建表。 +- 新字段必须进入 schema guard。 + +## 六、前端页面 + +- `ProjectGeneration.tsx` + - 模板选择 + - 参数输入 + - 预览剪辑计划 + - 创建生成任务 + +- 新增 `EditPlanPreview.tsx` + - 片段顺序 + - 素材名 + - 分类标签 + - 预计时长 + - 缺素材提示 + +## 七、测试与验收 + +### 单元测试 + +- 模板参数校验。 +- 自动选片规则。 +- 剪辑计划生成。 +- FFmpeg 命令计划生成。 + +### 集成测试 + +- 创建模板 → 上传素材 → 分类 → 预览计划 → 创建计划 → 生成任务。 + +### 公网 smoke + +- 注册登录。 +- 创建工作空间/项目/素材库。 +- 上传小视频。 +- 创建 edit plan。 +- 生成 MP4。 +- 下载验证 `Content-Type: video/mp4`。 + +## 八、发布门禁 + +- `npm run type-check` +- `npm run build` +- `pytest` 覆盖新增 use case/repository/API。 +- Alembic preflight 通过。 +- 公网 smoke 通过。 +- 生产部署继续 artifact/image 模式,生产机器不做前端构建。 + +## 九、里程碑 + +1. Week 1:领域模型、迁移、repository、模板列表 API。 +2. Week 2:自动选片与剪辑计划 preview。 +3. Week 3:FFmpeg 按 EditPlan 拼接。 +4. Week 4:前端生成页接入、smoke、发布。 diff --git a/docs/PHASE9-DESIGN.md b/docs/PHASE9-DESIGN.md new file mode 100644 index 000000000..e48f423c7 --- /dev/null +++ b/docs/PHASE9-DESIGN.md @@ -0,0 +1,154 @@ +# Phase 9: 智能自动剪辑与内容增强设计 + +> 状态:规划中 +> 前置:Phase 8 模板编排引擎上线并通过公网 smoke +> 原则:能力逐项真实接入,未接入能力不得在 UI 中假装可用 + +## 一、目标 + +Phase 9 的目标是把 Phase 8 的规则模板自动剪辑,升级为带字幕、配音、BGM、转场和内容理解的智能自动剪辑 2.0。 + +一句话:用户输入剪辑目标或脚本,系统自动理解素材、生成剪辑计划、补字幕/配音/BGM,并输出可下载成片。 + +## 二、不做范围 + +- 不做无门禁的外部 AI 大模型调用。 +- 不做真实支付前的计费扣费闭环。 +- 不做未经审核的公网模板市场。 +- 不把“实验能力”包装为稳定生产功能。 + +## 三、核心能力 + +1. 字幕能力 + - ASR 转写音频。 + - 生成 SRT/VTT。 + - FFmpeg 烧录字幕或输出外挂字幕。 + +2. 配音能力 + - TTS 生成配音音频。 + - 音频与视频时长对齐。 + - SMTP 仍保持独立,不与 TTS 混用配置。 + +3. BGM 与音频混合 + - BGM 资源库。 + - 自动音量 ducking。 + - 视频原声、配音、BGM 三轨混合。 + +4. 转场与包装 + - 基础转场:淡入淡出、交叉溶解、硬切。 + - 片头/片尾占位。 + - Logo/水印可配置。 + +5. 智能脚本到剪辑计划 + - 用户输入脚本/主题。 + - 系统提取段落、关键词、时长目标。 + - 匹配素材分类与标签。 + - 生成 `EditPlan`。 + +## 四、架构设计 + +### Domain + +- `Transcript` +- `SubtitleTrack` +- `VoiceoverTrack` +- `MusicTrack` +- `AudioMixPlan` +- `ScriptBrief` +- `ContentIntent` + +### Ports + +- `SpeechToTextService` +- `TextToSpeechService` +- `MusicRepository` +- `ContentUnderstandingService` +- `SubtitleRenderer` +- `AudioMixer` + +### Application Use Cases + +- `CreateTranscriptUseCase` +- `GenerateSubtitleUseCase` +- `CreateVoiceoverUseCase` +- `CreateAudioMixPlanUseCase` +- `CreateEditPlanFromScriptUseCase` +- `RenderEnhancedVideoUseCase` + +### Adapters + +- ASR/TTS 适配器必须配置驱动。 +- 缺少凭证时只能返回明确未配置错误,不 fallback 假数据。 +- FFmpeg audio/video renderer 负责真实合成。 + +### API + +- `POST /api/v1/transcripts` +- `GET /api/v1/transcripts/{id}` +- `POST /api/v1/subtitles` +- `POST /api/v1/voiceovers` +- `POST /api/v1/audio-mix-plans` +- `POST /api/v1/edit-plans/from-script` + +## 五、配置与环境 + +- 新增外部服务配置必须进入 `.env.example`、`docs/CONFIGURATION.md`、release env validation。 +- CI 不依赖真实外部 ASR/TTS 凭证。 +- 真实外部 smoke 仅在配置完整的 staging/production 上运行。 +- 生产仍使用独立 production DB/Redis/Generated 路径。 + +## 六、数据与任务 + +- 长任务全部走 Celery Worker。 +- 每个增强步骤必须有可查询状态和错误信息。 +- 任务状态禁止只靠前端轮询猜测。 +- 生成文件必须持久化到 OSS 或 production generated 路径。 + +## 七、前端页面 + +- 生成页增加“智能生成”模式。 +- 新增脚本输入区域。 +- 新增字幕/配音/BGM 开关。 +- 未配置能力显示“服务未配置/暂未开放”。 +- 结果页显示字幕、配音、BGM、转场使用情况。 + +## 八、安全与权限 + +- 所有 workspace/project/media/generation/enhancement API 必须校验 workspace 权限。 +- 上传和生成需限制文件大小、类型和任务频率。 +- 外部 AI 请求不得记录用户原文到公开日志。 +- 不打印密钥,只显示 configured/missing 状态。 + +## 九、测试与验收 + +### 单元测试 + +- ASR/TTS adapter 配置缺失行为。 +- 字幕时间轴生成。 +- 音频混合计划。 +- 脚本到 edit plan 的确定性规则。 + +### 集成测试 + +- 上传视频 → 生成 transcript → 字幕 → edit plan → enhanced generation。 + +### 公网 smoke + +- 使用一段小视频和短脚本。 +- 输出 MP4。 +- 验证 `video/mp4`、文件头、生成任务 completed、字幕/音轨 metadata。 + +## 十、发布门禁 + +- 所有新增能力必须有 feature flag 或配置门禁。 +- 没配置外部服务时 UI 显示未配置,不显示假成功。 +- Alembic、pytest、前端 type-check/build、公网 smoke 全部通过。 +- 生产部署继续禁止生产机前端构建。 + +## 十一、里程碑 + +1. Week 1:字幕/ASR 数据模型、任务状态、配置门禁。 +2. Week 2:TTS 配音与音频混合计划。 +3. Week 3:BGM/转场/水印真实 FFmpeg 合成。 +4. Week 4:脚本到剪辑计划、前端智能生成入口。 +5. Week 5:公网 smoke、监控、发布。 diff --git a/packages/adapters/in_memory/project_repository.py b/packages/adapters/in_memory/project_repository.py index 848aa122a..fd4589ccf 100644 --- a/packages/adapters/in_memory/project_repository.py +++ b/packages/adapters/in_memory/project_repository.py @@ -10,6 +10,13 @@ class InMemoryProjectRepository: def list_by_workspace(self, workspace_id: str) -> list[Project]: return list(self._items.get(workspace_id, [])) + def find_by_id(self, project_id: str) -> Project | None: + for items in self._items.values(): + for project in items: + if project.id == project_id: + return project + return None + def create(self, project: Project) -> Project: items = self._items.setdefault(project.workspace_id, []) items.append(project) diff --git a/packages/adapters/sqlalchemy_impl/project_repository.py b/packages/adapters/sqlalchemy_impl/project_repository.py index 02c614841..7bc78f603 100644 --- a/packages/adapters/sqlalchemy_impl/project_repository.py +++ b/packages/adapters/sqlalchemy_impl/project_repository.py @@ -8,18 +8,24 @@ class SQLAlchemyProjectRepository: def __init__(self, session: Session): self.session = session + def _to_entity(self, model: ProjectModel) -> Project: + return Project( + id=model.id, + workspace_id=model.workspace_id, + name=model.name, + description=model.description, + created_at=model.created_at, + ) + def list_by_workspace(self, workspace_id: str) -> list[Project]: models = self.session.query(ProjectModel).filter(ProjectModel.workspace_id == workspace_id).all() - return [ - Project( - id=model.id, - workspace_id=model.workspace_id, - name=model.name, - description=model.description, - created_at=model.created_at, - ) - for model in models - ] + return [self._to_entity(model) for model in models] + + def find_by_id(self, project_id: str) -> Project | None: + model = self.session.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if model is None: + return None + return self._to_entity(model) def create(self, project: Project) -> Project: model = ProjectModel( diff --git a/packages/application/__init__.py b/packages/application/__init__.py index c8f35cb83..960a30c11 100644 --- a/packages/application/__init__.py +++ b/packages/application/__init__.py @@ -22,7 +22,7 @@ from .generation_tasks import ( GetGenerationTaskUseCase, ) from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase -from .projects import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase +from .projects import CreateProjectCommand, CreateProjectUseCase, GetProjectUseCase, ListProjectsUseCase __all__ = [ "CreateAssetCommand", @@ -35,7 +35,7 @@ __all__ = [ "CreateProjectUseCase", "GetGeneratedVideoDownloadUrlUseCase", "GetGeneratedVideoUseCase", - "GetGenerationTaskUseCase", + "GetProjectUseCase", "ListAssetLibrariesUseCase", "ListAssetsUseCase", "ListGeneratedVideosByTaskUseCase", diff --git a/packages/application/projects.py b/packages/application/projects.py index 16033f9d5..f9ac955b7 100644 --- a/packages/application/projects.py +++ b/packages/application/projects.py @@ -23,6 +23,16 @@ class ListProjectsUseCase: return self.project_repository.list_by_workspace(workspace_id.strip()) +class GetProjectUseCase: + def __init__(self, project_repository: ProjectRepository): + self.project_repository = project_repository + + def execute(self, project_id: str) -> Project | None: + if not project_id.strip(): + raise ValueError("project_id 不能为空") + return self.project_repository.find_by_id(project_id.strip()) + + class CreateProjectUseCase: def __init__(self, project_repository: ProjectRepository): self.project_repository = project_repository diff --git a/scripts/smoke_public_upload_flow.py b/scripts/smoke_public_upload_flow.py new file mode 100644 index 000000000..10aa7170e --- /dev/null +++ b/scripts/smoke_public_upload_flow.py @@ -0,0 +1,111 @@ +"""Public upload smoke for the SaaS Web-domain API path. + +This intentionally uses https://saas.xiaoxiajianji.com/api/v1 so the Web nginx +proxy is tested together with the API upload path. +""" + +from __future__ import annotations + +import io +import time +import uuid + +import requests + +BASE_URL = "https://saas.xiaoxiajianji.com/api/v1" +PASSWORD = "SmokePass123!" + + +def _json_or_raise(name: str, response: requests.Response) -> dict: + print(f"{name}={response.status_code}") + if response.status_code >= 400: + print(response.text[:1000]) + response.raise_for_status() + if not response.content: + return {} + return response.json() + + +def main() -> None: + session = requests.Session() + suffix = uuid.uuid4().hex[:10] + email = f"smoke-upload-{suffix}@example.com" + username = f"smoke_upload_{suffix}" + + _json_or_raise( + "register", + session.post( + f"{BASE_URL}/auth/register", + json={"email": email, "username": username, "password": PASSWORD, "full_name": "Upload Smoke"}, + timeout=30, + ), + ) + login = _json_or_raise( + "login", + session.post(f"{BASE_URL}/auth/login", json={"email": email, "password": PASSWORD}, timeout=30), + ) + headers = {"Authorization": f"Bearer {login['access_token']}"} + + workspace = _json_or_raise( + "workspace", + session.post(f"{BASE_URL}/workspaces", json={"name": "Upload Smoke Workspace"}, headers=headers, timeout=30), + ) + workspace_id = workspace.get("id") or workspace["workspace_id"] + + project = _json_or_raise( + "project", + session.post( + f"{BASE_URL}/projects", + json={"workspace_id": workspace_id, "name": "Upload Smoke Project", "description": "smoke"}, + headers=headers, + timeout=30, + ), + ) + project_id = project["id"] + + library = _json_or_raise( + "library", + session.post( + f"{BASE_URL}/asset-libraries", + json={ + "workspace_id": workspace_id, + "project_id": project_id, + "name": "Smoke Video Library", + "kind": "video", + }, + headers=headers, + timeout=30, + ), + ) + library_id = library["id"] + + upload = _json_or_raise( + "upload", + session.post( + f"{BASE_URL}/upload", + files={"file": ("smoke.txt", io.BytesIO(b"xiaoxia upload smoke"), "text/plain")}, + data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id}, + headers=headers, + timeout=60, + ), + ) + + job_id = upload["ingest_job_id"] + latest_job = {} + for attempt in range(10): + latest_job = _json_or_raise( + f"ingest_{attempt}", + session.get(f"{BASE_URL}/ingest-jobs/{job_id}", headers=headers, timeout=30), + ) + if latest_job.get("status") in {"completed", "failed"}: + break + time.sleep(1) + + if latest_job.get("status") == "failed": + raise RuntimeError(f"ingest job failed: {latest_job.get('error_message')}") + + print("public_upload_flow=ok") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 77342dfcf..b0fe52dcd 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -11,6 +11,7 @@ from packages.application import ( CreateAssetUseCase, CreateProjectCommand, CreateProjectUseCase, + GetProjectUseCase, ListAssetLibrariesUseCase, ListAssetsUseCase, ListProjectsUseCase, @@ -34,6 +35,19 @@ def test_create_and_list_projects(): assert items[0].id == project.id +def test_get_project_by_id_restores_workspace_context(): + repository = InMemoryProjectRepository() + create_use_case = CreateProjectUseCase(repository) + get_use_case = GetProjectUseCase(repository) + + project = create_use_case.execute(CreateProjectCommand(workspace_id="ws-1", name="Demo Project")) + + retrieved = get_use_case.execute(project.id) + assert retrieved is not None + assert retrieved.id == project.id + assert retrieved.workspace_id == "ws-1" + + def test_create_and_list_asset_libraries(): repository = InMemoryAssetLibraryRepository() create_use_case = CreateAssetLibraryUseCase(repository)