feat(ai): 自由创作视频生成全量移植(/free-create)——Seedance 文/图生视频+按时长计费+人物素材库

后端:
- free_video.py: 提交/轮询/收藏/软删全链路,复用 AITask(新增 is_deleted/is_favorited, migration 0022)
- video_pricing.py: 按时长 token 计费(×1.10 buffer+clamp); video_errors.py 错误归一; media_probe.py 时长探测
- catalog/volcano: Seedance free-video 模型接入+seed(migration 0023)
- 素材库: FreeAssetGroup/FreeAsset(火山 Assets API 引用登记, migration 0009)+ 上传/轮询/删除接口
- settings: FREE_VIDEO_MAX_CONCURRENT 团队并发闸(默认3); CELERY_TASK_ALWAYS_EAGER 本地联调开关(生产恒关)
- 测试: test_free_video.py 新增; billing/products/projects tests 配套调整

前端:
- /free-create 页面+components/free-create/ 全套(输入栏/@mention 素材引用/生成卡/视频详情弹窗/素材库弹窗)
- api.ts/types.ts 扩展 free-video 与 free-assets 接口; 路由/侧边栏入口接入

bug/: 测试清单 (11)(12) 与截图归档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-06 10:58:36 +08:00
co-authored by Claude Fable 5
parent 94202e7bce
commit c5f7eb2acc
45 changed files with 5186 additions and 21 deletions
+66
View File
@@ -17,6 +17,11 @@ import type {
AuthPayload,
BillingSummary,
BillingTrend,
FreeAssetGroup,
FreeAssetItem,
FreeVideoRef,
FreeVideoTask,
FreeVideoUploadResult,
Ledger,
LoginSession,
Invitation,
@@ -660,6 +665,67 @@ export const api = {
generateImageStatus(ids: string[]) {
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
},
// —— 自由创作·视频生成 ——
// 提交秒回(火山 create 同步、慢轮询交给 worker 兜底 + 前端主动 poll);校验失败 400 带中文 detail。
submitFreeVideo(payload: {
prompt: string;
mode: "universal" | "keyframe";
model?: string;
aspect_ratio?: string;
resolution?: string;
duration?: number;
seed?: number;
generate_audio?: boolean;
references?: FreeVideoRef[];
}) {
return request<{ task: FreeVideoTask }>("/api/ai/free-video/", { method: "POST", body: JSON.stringify(payload) });
},
freeVideoTasks(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
`/api/ai/free-video/?offset=${offset}&page_size=${pageSize}`
);
},
// web 进程内单次轮询+终态化(幂等):前端渐进轮询打这里,本地无 worker 也能收尾
pollFreeVideo(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/poll/`, { method: "POST" });
},
toggleFreeVideoFavorite(id: string) {
return request<{ is_favorited: boolean }>(`/api/ai/free-video/${id}/favorite/`, { method: "POST" });
},
deleteFreeVideo(id: string) {
return request<void>(`/api/ai/free-video/${id}/`, { method: "DELETE" });
},
uploadFreeVideoRef(formData: FormData) {
return request<FreeVideoUploadResult>("/api/ai/free-video/upload/", { method: "POST", body: formData });
},
// 自由创作·人物素材库(火山 Assets API asset:// 引用登记)
freeAssetGroups() {
return request<{ results: FreeAssetGroup[] }>("/api/assets/free-groups/");
},
createFreeAssetGroup(payload: { name: string; description?: string }) {
return request<{ group: FreeAssetGroup }>("/api/assets/free-groups/", { method: "POST", body: JSON.stringify(payload) });
},
freeAssetGroup(id: string) {
return request<{ group: FreeAssetGroup; assets: FreeAssetItem[] }>(`/api/assets/free-groups/${id}/`);
},
updateFreeAssetGroup(id: string, payload: { name?: string; description?: string }) {
return request<{ group: FreeAssetGroup }>(`/api/assets/free-groups/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteFreeAssetGroup(id: string) {
return request<void>(`/api/assets/free-groups/${id}/`, { method: "DELETE" });
},
uploadFreeAsset(groupId: string, formData: FormData) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-groups/${groupId}/assets/`, { method: "POST", body: formData });
},
renameFreeAsset(id: string, name: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
},
deleteFreeAsset(id: string) {
return request<void>(`/api/assets/free-assets/${id}/`, { method: "DELETE" });
},
pollFreeAsset(id: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/poll/`, { method: "POST" });
},
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
},