fix(web): restore project workspace context
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="如果你刷新或直接打开了项目素材页,系统会根据项目自动恢复所属工作空间。"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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、发布。
|
||||
@@ -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、监控、发布。
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user