fix(web): restore project workspace context
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled

This commit is contained in:
Xiaoxia AI
2026-06-22 13:19:45 +08:00
parent 04861a48cb
commit a1cf1ddd9d
11 changed files with 510 additions and 34 deletions
+25 -18
View File
@@ -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)
+5
View File
@@ -13,6 +13,11 @@ export interface CreateProjectRequest {
description?: string;
}
export const getProject = async (projectId: string): Promise<ProjectItem> => {
const response = await apiClient.get(`/projects/${projectId}`);
return response.data;
};
export const getProjects = async (workspaceId: string): Promise<ProjectItem[]> => {
const response = await apiClient.get('/projects', { params: { workspace_id: workspaceId } });
return response.data.items;
+22 -4
View File
@@ -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<string>(routedWorkspaceId || storedWorkspaceId);
const workspaceId = routedWorkspaceId || storedWorkspaceId || resolvedWorkspaceId;
const [libraryId, setLibraryId] = useState<string>('');
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 && (
<Alert
style={{ marginBottom: 16 }}
type="error"
type="warning"
showIcon
message="缺少工作空间上下文"
description="请从工作空间详情页进入项目素材管理,不要直接打开项目地址。"
message="正在恢复工作空间上下文"
description="如果你刷新或直接打开了项目素材页,系统会根据项目自动恢复所属工作空间。"
/>
)}
+144
View File
@@ -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 3FFmpeg 按 EditPlan 拼接。
4. Week 4:前端生成页接入、smoke、发布。
+154
View File
@@ -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 3BGM/转场/水印真实 FFmpeg 合成。
4. Week 4:脚本到剪辑计划、前端智能生成入口。
5. Week 5:公网 smoke、监控、发布。
@@ -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)
@@ -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(
+2 -2
View File
@@ -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",
+10
View File
@@ -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
+111
View File
@@ -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()
+14
View File
@@ -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)