Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2cea1831e | |||
| 62ab361940 | |||
| ed3ab34028 | |||
| df016b18fb | |||
| c6aac862b1 | |||
| 6232300fb1 | |||
| fe2ab121e7 | |||
| 84db18a959 | |||
| 9501afe9b3 | |||
| 7407ec2957 | |||
| e45426fe7d | |||
| c540d88306 | |||
| 5fb49480e5 | |||
| d73fb2cf2b | |||
| 0eb1262db6 | |||
| 9a6136c9fd | |||
| 63f138e2d6 | |||
| d898856f50 | |||
| acffa364b3 | |||
| 1931cab504 | |||
| 9f9fd1ccb8 | |||
| 9fd12c7fb3 | |||
| 4805e961f0 | |||
| 1aeeb23621 | |||
| 4a21b89a0b | |||
| 2982ef5259 | |||
| 5fff7e518d | |||
| a93149b8e5 | |||
| 21aaf642ec | |||
| 8d86707e2e | |||
| 752de50557 | |||
| ba6c5f2598 | |||
| 78d1c88ca1 | |||
| c88be032c1 | |||
| 7f767e2dd1 | |||
| ea93387f98 | |||
| 7e172c0907 | |||
| 6db705a05d | |||
| 1c8cb20373 | |||
| 74c458e370 | |||
| a7d942f705 | |||
| f867897348 | |||
| 3d1b739e7f | |||
| f268e208de | |||
| b05966ff48 | |||
| 79b82978d8 | |||
| 08b51ffa1d | |||
| 23d2406c27 | |||
| def6ee2363 | |||
| 2b5b650b9e | |||
| 2b8326987e | |||
| 2081c72be6 | |||
| a681844a44 |
@@ -0,0 +1 @@
|
||||
re-trigger
|
||||
+1
-1
@@ -1 +1 @@
|
||||
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
|
||||
trigger: 1784009947
|
||||
|
||||
+1031
-1072
File diff suppressed because one or more lines are too long
Executable → Regular
-1
@@ -45,7 +45,6 @@ from packages.application.template.use_cases import (
|
||||
CreateTemplateUseCase,
|
||||
DeleteCategoryUseCase,
|
||||
DeleteTemplateUseCase,
|
||||
GetTemplateUsageUseCase,
|
||||
GetTemplateUseCase,
|
||||
ListCategoriesUseCase,
|
||||
ListTagsUseCase,
|
||||
|
||||
@@ -86,7 +86,10 @@ async function createProject(
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
|
||||
data: {
|
||||
name: `Assets Test Proj ${suffix}`,
|
||||
description: "E2E assets test",
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
@@ -178,20 +181,30 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-load");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-load",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
|
||||
// 页面布局容器
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 左侧素材库列表
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||
@@ -211,23 +224,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-create",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击新建素材库
|
||||
await page.locator(".xx-asset-library-add").click();
|
||||
|
||||
// 弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "新建素材库" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 填写表单
|
||||
@@ -259,11 +282,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("切换不同素材库", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-switch");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-switch",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
|
||||
const videoLibName = "视频素材库 A";
|
||||
const imageLibName = "图片素材库 B";
|
||||
@@ -292,10 +317,16 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"demo_video.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击视频库,应显示素材
|
||||
const videoLibItem = page
|
||||
@@ -305,7 +336,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await expect(videoLibItem).toHaveClass(/active/);
|
||||
|
||||
// 验证视频素材出现
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 点击图片库,应切换且不显示视频
|
||||
const imageLibItem = page
|
||||
@@ -315,18 +348,22 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await expect(imageLibItem).toHaveClass(/active/);
|
||||
|
||||
// 空状态或图片库内容
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 素材搜索 ──────────────────────────────────────
|
||||
|
||||
test("素材搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-search");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-search",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -336,13 +373,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建两个不同名称的素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"apple_clip.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"banana_clip.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 确保在测试库中
|
||||
const libItem = page
|
||||
@@ -351,7 +408,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 两个素材都应可见
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
|
||||
// 搜索 apple,只显示 apple
|
||||
@@ -361,7 +420,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
// 清空搜索,两个都显示
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -369,11 +430,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材类型筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-filter");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-filter",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -383,12 +446,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建视频素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"video_clip.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -396,7 +472,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 素材应可见
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 筛选类型下拉存在
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||
@@ -407,11 +485,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-detail");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-detail",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -419,12 +499,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"详情测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"play_test.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -441,7 +534,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||
|
||||
// 播放弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 关闭弹窗
|
||||
@@ -453,11 +548,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-delete");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-delete",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -465,12 +562,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"删除测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"to_delete.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -491,14 +601,15 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await deleteBtn.click({ force: true });
|
||||
|
||||
// 确认对话框出现
|
||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
|
||||
const confirmModal = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确认删除" });
|
||||
await expect(confirmModal).toBeVisible();
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/assets/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
@@ -518,11 +629,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("批量删除素材", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-batch");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-batch",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -532,14 +645,41 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建多个素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_1.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_2.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_3.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -547,7 +687,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 所有素材应可见
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||
|
||||
@@ -567,7 +709,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await batchDeleteBtn.click();
|
||||
|
||||
// 确认对话框
|
||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
|
||||
const confirmPop = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确定删除" });
|
||||
await expect(confirmPop).toBeVisible();
|
||||
|
||||
// 确认删除
|
||||
@@ -595,17 +739,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("空素材库展示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-empty");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-empty",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -613,7 +765,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -251,6 +251,9 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||
});
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
|
||||
@@ -180,9 +180,11 @@ test.describe("Core media upload flow", () => {
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
|
||||
{
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify asset card shows status
|
||||
const assetCard = page
|
||||
|
||||
@@ -121,7 +121,11 @@ test.describe("去重流程", () => {
|
||||
"dup-load",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
|
||||
@@ -146,7 +150,11 @@ test.describe("去重流程", () => {
|
||||
"dup-upload-zone",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -156,12 +164,12 @@ test.describe("去重流程", () => {
|
||||
await expect(uploadZone).toBeVisible();
|
||||
|
||||
// 上传图标和文字
|
||||
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
|
||||
await expect(
|
||||
uploadZone.getByText("点击或拖拽视频文件到此区域"),
|
||||
).toBeVisible();
|
||||
|
||||
// 格式提示
|
||||
await expect(
|
||||
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
|
||||
).toBeVisible();
|
||||
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible();
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-upload-formats")).toBeVisible();
|
||||
@@ -184,7 +192,11 @@ test.describe("去重流程", () => {
|
||||
"dup-info",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -215,7 +227,11 @@ test.describe("去重流程", () => {
|
||||
"dup-list",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
|
||||
@@ -239,7 +255,11 @@ test.describe("去重流程", () => {
|
||||
"dup-list-empty",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -257,7 +277,11 @@ test.describe("去重流程", () => {
|
||||
"dup-filter",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -287,7 +311,11 @@ test.describe("去重流程", () => {
|
||||
"dup-nav",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -306,10 +334,8 @@ test.describe("去重流程", () => {
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-detail",
|
||||
);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-detail");
|
||||
|
||||
// 先上传一个文件进行查重,获取 record id
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -335,7 +361,11 @@ test.describe("去重流程", () => {
|
||||
const recordId = uploadData.id;
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
// 访问详情页
|
||||
await page.goto(`/app/duplication/${recordId}`);
|
||||
@@ -353,10 +383,8 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete",
|
||||
);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -419,10 +447,8 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete-ui",
|
||||
);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete-ui");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -443,14 +469,20 @@ test.describe("去重流程", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录卡片应存在
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 删除按钮存在
|
||||
@@ -467,12 +499,14 @@ test.describe("去重流程", () => {
|
||||
});
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
).catch(() => null);
|
||||
const deletePromise = page
|
||||
.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
await deleteBtn.click();
|
||||
|
||||
@@ -487,10 +521,8 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-retry",
|
||||
);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-retry");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -511,14 +543,20 @@ test.describe("去重流程", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录列表中至少有一条记录
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 验证记录卡片基本结构
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -258,7 +263,12 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 10,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -269,7 +279,12 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
segments: [
|
||||
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 10,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+112
-29
@@ -93,7 +93,8 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
resolution: "1080x1920",
|
||||
file_size: (5 + i) * 1024 * 1024,
|
||||
duplicate_rate: i * 5,
|
||||
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
video_url:
|
||||
status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
thumbnail_url: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
@@ -218,7 +219,11 @@ test.describe("作品库页面", () => {
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
|
||||
@@ -255,12 +260,24 @@ test.describe("作品库页面", () => {
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
|
||||
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
|
||||
{
|
||||
...mockProducts(1, ["processing"])[0],
|
||||
title: "处理中作品",
|
||||
id: `mock-prod-${Date.now()}-p`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["failed"])[0],
|
||||
title: "失败作品",
|
||||
id: `mock-prod-${Date.now()}-f`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
@@ -276,9 +293,9 @@ test.describe("作品库页面", () => {
|
||||
const completedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "已完成作品" });
|
||||
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
|
||||
"已完成",
|
||||
);
|
||||
await expect(
|
||||
completedCard.locator(".xx-product-status.completed"),
|
||||
).toHaveText("已完成");
|
||||
|
||||
const processingCard = page
|
||||
.locator(".xx-product-card")
|
||||
@@ -309,7 +326,11 @@ test.describe("作品库页面", () => {
|
||||
const productId = products[0].id;
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
// 直接访问详情页
|
||||
await page.goto(`/app/products/${productId}`);
|
||||
@@ -337,7 +358,11 @@ test.describe("作品库页面", () => {
|
||||
products[0].video_url = "https://example.com/test-video.mp4";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -356,7 +381,10 @@ test.describe("作品库页面", () => {
|
||||
// 播放弹窗出现 - 验证有视频元素或播放器容器
|
||||
// (通过 Mock 的 video_url,video 元素应能渲染)
|
||||
const videoEl = page.locator("video");
|
||||
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
const videoVisible = await videoEl
|
||||
.first()
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
// 或弹窗容器可见
|
||||
const modalVisible = await page
|
||||
.locator(".ant-modal-content")
|
||||
@@ -380,7 +408,11 @@ test.describe("作品库页面", () => {
|
||||
products[0].title = "下载测试作品";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -409,7 +441,11 @@ test.describe("作品库页面", () => {
|
||||
products[0].title = "处理中下载测试";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -484,7 +520,11 @@ test.describe("作品库页面", () => {
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -507,9 +547,12 @@ test.describe("作品库页面", () => {
|
||||
const { headers } = await createAuthedUser(request, "products-del-api");
|
||||
|
||||
// 测试删除不存在的产品,验证 API 端点存在
|
||||
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
|
||||
headers,
|
||||
});
|
||||
const resp = await request.delete(
|
||||
`${apiBase}/products/nonexistent-test-id`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
||||
// 404 表示资源不存在但端点存在
|
||||
@@ -529,7 +572,11 @@ test.describe("作品库页面", () => {
|
||||
// Mock 空列表
|
||||
await mockProductsApi(page, []);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
@@ -553,12 +600,24 @@ test.describe("作品库页面", () => {
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "苹果宣传视频",
|
||||
id: `mock-prod-${Date.now()}-apple`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "香蕉推广视频",
|
||||
id: `mock-prod-${Date.now()}-banana`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -566,7 +625,9 @@ test.describe("作品库页面", () => {
|
||||
});
|
||||
|
||||
// 两个作品都可见
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
||||
|
||||
// 搜索"苹果"
|
||||
@@ -576,7 +637,9 @@ test.describe("作品库页面", () => {
|
||||
|
||||
// 清空搜索
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("");
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("作品状态筛选", async ({ page, request }) => {
|
||||
@@ -587,12 +650,24 @@ test.describe("作品库页面", () => {
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "已完成筛选",
|
||||
id: `mock-prod-${Date.now()}-done`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["processing"])[0],
|
||||
title: "处理中筛选",
|
||||
id: `mock-prod-${Date.now()}-proc`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -629,7 +704,11 @@ test.describe("作品库页面", () => {
|
||||
products[2].title = "批量测试 3";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -653,8 +732,12 @@ test.describe("作品库页面", () => {
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
||||
|
||||
// 批量按钮存在
|
||||
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
|
||||
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量下载" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量删除" }),
|
||||
).toBeVisible();
|
||||
|
||||
// 取消选择
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -401,7 +406,10 @@ test.describe("个人设置 - 退出登录", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("登出 API - 正向", async ({ request }) => {
|
||||
const { headers, email } = await createAuthedUser(request, "profile-logout");
|
||||
const { headers, email } = await createAuthedUser(
|
||||
request,
|
||||
"profile-logout",
|
||||
);
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/logout`, {
|
||||
headers,
|
||||
|
||||
@@ -49,7 +49,9 @@ test.describe("注册页面", () => {
|
||||
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
|
||||
|
||||
// 标题/描述
|
||||
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("创建账户,开启智能视频创作之旅"),
|
||||
).toBeVisible();
|
||||
|
||||
// 表单字段
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
@@ -69,7 +71,10 @@ test.describe("注册页面", () => {
|
||||
await page.goto("/register");
|
||||
|
||||
// 直接点击注册按钮
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示必填错误
|
||||
await expect(page.getByText("请输入邮箱")).toBeVisible();
|
||||
@@ -86,7 +91,10 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示邮箱格式错误
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
||||
@@ -100,7 +108,10 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill("123");
|
||||
await page.getByLabel("确认密码").fill("123");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示密码长度错误
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
||||
@@ -114,7 +125,10 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill("Different123!");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示密码不一致错误
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
||||
@@ -128,7 +142,10 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
});
|
||||
@@ -154,10 +171,16 @@ test.describe("注册页面", () => {
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
const resp = await registerResponse;
|
||||
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
|
||||
expect(
|
||||
resp.ok(),
|
||||
`注册请求应返回 2xx,实际: ${resp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 注册成功后应跳转到登录页或显示成功消息
|
||||
// 页面应停留在可识别的状态(成功提示或跳转)
|
||||
@@ -199,14 +222,19 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示错误提示(通过 antd message 或表单错误)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
// 检查是否有错误消息
|
||||
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
|
||||
const hasError = await page
|
||||
.getByText(/注册失败|已注册|已存在|exists/)
|
||||
.isVisible();
|
||||
return hasError ? "error_shown" : "waiting";
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
@@ -254,7 +282,12 @@ test.describe("注册页面", () => {
|
||||
|
||||
// 注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username,
|
||||
display_name: "Reg Auth Test",
|
||||
},
|
||||
});
|
||||
|
||||
// 登录
|
||||
@@ -293,6 +326,8 @@ test.describe("注册页面", () => {
|
||||
// 注册页对已登录用户也可访问(注册页是公开页面)
|
||||
// 验证页面正常渲染
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -243,8 +248,13 @@ test.describe("订阅套餐页 - 升级交互", () => {
|
||||
const url = page.url();
|
||||
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
||||
expect(
|
||||
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
|
||||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
|
||||
url.includes("/subscription/upgrade") ||
|
||||
url.includes("/subscription") ||
|
||||
(await page
|
||||
.locator(".ant-modal, [role='dialog']")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)),
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
@@ -538,13 +548,16 @@ test.describe("订阅 - 支付流程", () => {
|
||||
test("创建支付订单 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-pay-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// 创建支付订单可能成功或接口不存在
|
||||
expect(
|
||||
@@ -560,12 +573,15 @@ test.describe("订阅 - 支付流程", () => {
|
||||
});
|
||||
|
||||
test("未登录创建订单 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,10 @@ test.describe("订阅过期处理", () => {
|
||||
// 免费用户可能不需要取消,返回 400 或类似错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
|
||||
expect(
|
||||
data.error?.message || data.detail || data.message,
|
||||
"应返回错误信息",
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -326,7 +331,9 @@ test.describe("模板库 - 模板展示", () => {
|
||||
if (await modal.isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal).toBeVisible();
|
||||
// 验证预览内容存在
|
||||
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(".xx-template-modal-title-row"),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -487,7 +494,10 @@ test.describe("模板库 - API 操作", () => {
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||||
expect(
|
||||
unfavResp.status() < 500,
|
||||
"取消收藏请求应返回 2xx 或 4xx",
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("获取模板详情 - 正向", async ({ request }) => {
|
||||
@@ -515,10 +525,9 @@ test.describe("模板库 - API 操作", () => {
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/templates/${created.id}`,
|
||||
{ headers },
|
||||
);
|
||||
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
|
||||
headers,
|
||||
});
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(created.id);
|
||||
|
||||
@@ -175,10 +175,9 @@ test.describe("认证流程", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[400, 422],
|
||||
"缺少用户名字段应返回 4xx 校验错误",
|
||||
).toContain(response.status());
|
||||
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
|
||||
response.status(),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 登录 ────────────────────────────────────────────
|
||||
@@ -230,7 +229,9 @@ test.describe("认证流程", () => {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
if (response.status() !== 429) break;
|
||||
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
|
||||
console.log(
|
||||
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 65_000));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -224,7 +229,11 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
test("编辑标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update");
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
const newName = `更新后的标题 ${Date.now()}`;
|
||||
const newText = "这是更新后的标题内容";
|
||||
@@ -256,7 +265,11 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
test("删除标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-delete");
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
||||
@@ -279,9 +292,21 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const titles = [
|
||||
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
|
||||
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
|
||||
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
|
||||
{
|
||||
name: `批量标题 1 ${suffix}`,
|
||||
text: `内容 1 ${suffix}`,
|
||||
category: "default",
|
||||
},
|
||||
{
|
||||
name: `批量标题 2 ${suffix}`,
|
||||
text: `内容 2 ${suffix}`,
|
||||
category: "种草",
|
||||
},
|
||||
{
|
||||
name: `批量标题 3 ${suffix}`,
|
||||
text: `内容 3 ${suffix}`,
|
||||
category: "知识",
|
||||
},
|
||||
];
|
||||
|
||||
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
||||
@@ -297,7 +322,9 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
|
||||
expect(
|
||||
Array.isArray(data) || data.success_count !== undefined,
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -326,10 +331,9 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
}
|
||||
// 如果创建失败(比如音频格式问题),测试也通过
|
||||
@@ -491,11 +495,15 @@ test.describe("声音克隆 - 上传区域", () => {
|
||||
});
|
||||
|
||||
// 尝试点击克隆新音色按钮
|
||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
|
||||
const cloneBtn = page.getByRole("button", {
|
||||
name: /克隆新音色|立即克隆|新建/,
|
||||
});
|
||||
if (await cloneBtn.isVisible()) {
|
||||
await cloneBtn.click();
|
||||
// 弹窗应该出现
|
||||
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
|
||||
const modal = page.locator(
|
||||
".ant-modal, .vc-edit-dialog, [role='dialog']",
|
||||
);
|
||||
if (await modal.first().isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal.first()).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -157,7 +162,9 @@ test.describe("音色库页面 - 页面加载", () => {
|
||||
});
|
||||
|
||||
// 验证搜索框存在
|
||||
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
|
||||
const searchInput = page.locator(
|
||||
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
|
||||
);
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
import apiClient from "./client";
|
||||
import { getOrCreateDefaultProject } from "./projects";
|
||||
|
||||
/** 素材元数据 */
|
||||
export interface AssetMetadata {
|
||||
/** 时长(秒) */
|
||||
duration?: number;
|
||||
/** 宽度(像素) */
|
||||
width?: number;
|
||||
/** 高度(像素) */
|
||||
height?: number;
|
||||
/** 比特率(bps) */
|
||||
bitrate?: number;
|
||||
/** 编码格式 */
|
||||
codec?: string;
|
||||
/** 帧率 */
|
||||
fps?: number;
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
/** 声道数 */
|
||||
channels?: number;
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 素材分类状态 */
|
||||
export type AssetClassificationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string;
|
||||
@@ -12,12 +38,14 @@ export interface AssetItem {
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
metadata: AssetMetadata;
|
||||
file_size?: number;
|
||||
file_url?: string;
|
||||
thumbnail_url?: string;
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number;
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
classification_status?: AssetClassificationStatus | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
@@ -167,7 +195,7 @@ export const createAsset = async (data: {
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
metadata?: AssetMetadata;
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data);
|
||||
return response.data;
|
||||
@@ -176,7 +204,7 @@ export const createAsset = async (data: {
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: Record<string, unknown> },
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data);
|
||||
return response.data;
|
||||
@@ -350,3 +378,52 @@ export const getClassificationJob = async (
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ─── 批量操作 ───────────────────────────────────────────────
|
||||
|
||||
/** 批量操作结果 */
|
||||
export interface BatchOperationResult {
|
||||
succeeded: string[];
|
||||
failed: string[];
|
||||
total: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
/** 批量删除素材 */
|
||||
export const batchDeleteAssets = async (
|
||||
assetIds: string[],
|
||||
): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-delete", {
|
||||
asset_ids: assetIds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
tags: string[];
|
||||
mode: "add" | "replace";
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-tag", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
category: string;
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
smart_view: "recommended" | "caution" | "high_risk";
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-mark", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* BGM 预设音乐 API
|
||||
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
/** BGM 风格分类 */
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
|
||||
|
||||
/** BGM 预设项 */
|
||||
export interface BgmPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: BgmCategory;
|
||||
/** 音频文件 URL */
|
||||
url: string;
|
||||
/** 时长(秒) */
|
||||
duration: number;
|
||||
/** 关键词标签 */
|
||||
tags: string[];
|
||||
/** 封面图 URL */
|
||||
cover_url?: string;
|
||||
}
|
||||
|
||||
/** BGM 预设列表查询参数 */
|
||||
export interface BgmPresetsQuery {
|
||||
category?: BgmCategory | string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/** BGM 混音配置(嵌入剪辑计划) */
|
||||
export interface BgmMixConfig {
|
||||
/** 是否启用 BGM */
|
||||
enabled: boolean;
|
||||
/** 选中的 BGM ID */
|
||||
music_id: string;
|
||||
/** BGM 音量 0-100 */
|
||||
volume: number;
|
||||
/** 淡入时长(秒) 0-3 */
|
||||
fade_in: number;
|
||||
/** 淡出时长(秒) 0-3 */
|
||||
fade_out: number;
|
||||
/** 人声闪避(sidechain) */
|
||||
voice_dodge: boolean;
|
||||
}
|
||||
|
||||
/** 默认 BGM 混音配置 */
|
||||
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
||||
enabled: false,
|
||||
music_id: "",
|
||||
volume: 50,
|
||||
fade_in: 0.5,
|
||||
fade_out: 0.5,
|
||||
voice_dodge: true,
|
||||
};
|
||||
|
||||
/* ──────────── API ──────────── */
|
||||
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (
|
||||
params?: BgmPresetsQuery,
|
||||
): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (params?.category) searchParams.category = params.category;
|
||||
if (params?.keyword) searchParams.keyword = params.keyword;
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams });
|
||||
return res.data?.data ?? res.data ?? [];
|
||||
};
|
||||
@@ -129,7 +129,8 @@ apiClient.interceptors.response.use(
|
||||
const safeExtractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
|
||||
+216
-32
@@ -4,6 +4,15 @@
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { AssetItem } from "./assets";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
|
||||
/* ============================================================
|
||||
* 后端 API 类型(严格匹配后端 Schema)
|
||||
@@ -13,6 +22,107 @@ import type { AssetItem } from "./assets";
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed";
|
||||
|
||||
/** 标题配置(对齐后端 title_config) */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
}
|
||||
|
||||
/** 片段 TTS 配置 */
|
||||
export interface SegmentTtsConfig {
|
||||
mode: string;
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed: number;
|
||||
pitch: number;
|
||||
volume: number;
|
||||
subtitle_sync: boolean;
|
||||
}
|
||||
|
||||
/** 片段裁剪配置 */
|
||||
export interface SegmentTrimConfig {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}
|
||||
|
||||
/** 片段转场配置 */
|
||||
export interface SegmentTransitionConfig {
|
||||
type: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 剪辑计划中的单个片段(config 内部 segments 项) */
|
||||
export interface EditPlanSegment {
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string;
|
||||
transition?: SegmentTransitionConfig;
|
||||
playback_speed?: number;
|
||||
tts_config?: SegmentTtsConfig;
|
||||
trim_config?: SegmentTrimConfig;
|
||||
}
|
||||
|
||||
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
|
||||
export interface EditPlanConfig {
|
||||
title_config?: TitleConfig;
|
||||
subtitle_config?: SubtitleConfig;
|
||||
bgm_config?: BgmConfig;
|
||||
estimated_duration?: number;
|
||||
segments?: EditPlanSegment[];
|
||||
watermark_config?: WatermarkConfig;
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
pip_config?: PipConfig;
|
||||
filter_config?: FilterConfig;
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
sticker_config?: StickerConfig;
|
||||
cover_config?: CoverConfig;
|
||||
/** 前端扩展:关联的素材 ID 列表 */
|
||||
asset_ids?: string[];
|
||||
/** 配音 ID */
|
||||
voice_id?: string;
|
||||
/** 克隆音色档案 ID */
|
||||
voice_clone_profile_id?: string;
|
||||
/** 自定义配音音频 URL */
|
||||
custom_audio_url?: string;
|
||||
/** 自定义配音文本 */
|
||||
custom_text?: string;
|
||||
/** 视频比例 */
|
||||
ratio?: string;
|
||||
/** 视频风格 */
|
||||
style?: string;
|
||||
/** 目标时长(秒) */
|
||||
duration?: number;
|
||||
/** 是否自动生成字幕 */
|
||||
auto_subtitles?: boolean;
|
||||
/** 是否启用 BGM */
|
||||
bgm?: boolean;
|
||||
/** 生成数量 */
|
||||
generate_count?: number;
|
||||
/** 素材模式 */
|
||||
material_mode?: string;
|
||||
}
|
||||
|
||||
/** 剪辑计划(后端响应) */
|
||||
export interface EditPlan {
|
||||
id: string;
|
||||
@@ -20,7 +130,7 @@ export interface EditPlan {
|
||||
name: string;
|
||||
status: EditPlanStatus;
|
||||
total_duration: number;
|
||||
config: Record<string, unknown>;
|
||||
config: EditPlanConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -29,7 +139,7 @@ export interface EditPlan {
|
||||
export interface CreateEditPlanRequest {
|
||||
template_id: string;
|
||||
name: string;
|
||||
config?: Record<string, unknown>;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||
source_edit_plan_id?: string;
|
||||
@@ -38,7 +148,7 @@ export interface CreateEditPlanRequest {
|
||||
/** 更新剪辑计划请求 */
|
||||
export interface UpdateEditPlanRequest {
|
||||
name?: string;
|
||||
config?: Record<string, unknown>;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
status?: EditPlanStatus;
|
||||
}
|
||||
@@ -80,6 +190,26 @@ export interface GenerationStatusResponse {
|
||||
clips: ClipStatusItem[];
|
||||
}
|
||||
|
||||
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
generation_task_id?: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
duration?: number;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
status: string;
|
||||
review_status?: string;
|
||||
download_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* AI 推荐 & 封面生成(任务 3.09)
|
||||
* ============================================================ */
|
||||
@@ -100,14 +230,14 @@ export interface AIRecommendClipItem {
|
||||
transition_effect: string;
|
||||
asset_id: string;
|
||||
start_time: number;
|
||||
config: Record<string, unknown>;
|
||||
config: EditPlanConfig;
|
||||
}
|
||||
|
||||
/** AI 推荐响应 */
|
||||
export interface AIRecommendResponse {
|
||||
plan_id: string;
|
||||
clips: AIRecommendClipItem[];
|
||||
config: Record<string, unknown>;
|
||||
config: EditPlanConfig;
|
||||
total_duration: number;
|
||||
confidence: number;
|
||||
}
|
||||
@@ -122,7 +252,15 @@ export interface GenerateCoverRequest {
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string;
|
||||
cover: Record<string, unknown>;
|
||||
cover: CoverResult;
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string;
|
||||
asset_id?: string;
|
||||
frame_time?: number;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -147,10 +285,27 @@ export interface EditPlanClip {
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** 转场效果 */
|
||||
/** 转场效果(14 种预设) */
|
||||
export interface TransitionEffect {
|
||||
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
|
||||
type:
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
duration: number; // 转场时长(秒)
|
||||
/** 播放速度倍率 */
|
||||
playback_speed?: number;
|
||||
}
|
||||
|
||||
/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */
|
||||
@@ -177,15 +332,30 @@ export interface MediaAsset {
|
||||
* API 函数 — 严格对接后端
|
||||
* ============================================================ */
|
||||
|
||||
/** 获取剪辑计划列表 */
|
||||
export async function getEditPlans(params?: {
|
||||
/** 剪辑计划列表查询参数 */
|
||||
export interface EditPlanListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
template_id?: string;
|
||||
status?: string;
|
||||
}): Promise<EditPlan[]> {
|
||||
const response = await apiClient.get("/edit-plans", { params });
|
||||
return response.data.items || [];
|
||||
}
|
||||
|
||||
/** 剪辑计划列表分页响应 */
|
||||
export interface EditPlanListResponse {
|
||||
items: EditPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 获取剪辑计划列表(支持分页和筛选) */
|
||||
export async function getEditPlans(
|
||||
params?: EditPlanListParams,
|
||||
): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 获取单个剪辑计划 */
|
||||
@@ -266,6 +436,14 @@ export async function getEditPlanGenerations(
|
||||
return response.data.items || [];
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(
|
||||
taskId: string,
|
||||
): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items || response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
@@ -297,26 +475,22 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" {
|
||||
}
|
||||
|
||||
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
const meta = (asset.metadata || {}) as Record<string, unknown>;
|
||||
const ext = asset as AssetItem & Record<string, unknown>;
|
||||
// 优先取顶层 duration,其次从 metadata 回退
|
||||
const metaDuration =
|
||||
typeof asset.metadata?.duration === "number"
|
||||
? asset.metadata.duration
|
||||
: undefined;
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
type: inferMediaType(asset.mime_type || ""),
|
||||
thumbnail_url:
|
||||
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
|
||||
duration:
|
||||
typeof ext.duration === "number"
|
||||
? ext.duration
|
||||
: typeof meta.duration === "number"
|
||||
? (meta.duration as number)
|
||||
: undefined,
|
||||
thumbnail_url: asset.thumbnail_url,
|
||||
duration: asset.duration ?? metaDuration,
|
||||
size: asset.file_size ?? undefined,
|
||||
tags: [],
|
||||
created_at: asset.created_at ?? "",
|
||||
quality_score: asset.quality_score ?? undefined,
|
||||
classification_status: (asset.classification_status ??
|
||||
undefined) as MediaAsset["classification_status"],
|
||||
classification_status: asset.classification_status ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,17 +498,27 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
* 常量
|
||||
* ============================================================ */
|
||||
|
||||
/** 转场效果选项 */
|
||||
/** 转场效果选项(14 种预设) */
|
||||
export const TRANSITION_OPTIONS: {
|
||||
value: TransitionEffect["type"];
|
||||
label: string;
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ value: "none", label: "无转场" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "dissolve", label: "溶解" },
|
||||
{ value: "wipe", label: "擦除" },
|
||||
{ value: "zoom", label: "缩放" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
{ value: "none", label: "无转场", icon: "⊘" },
|
||||
{ value: "cut", label: "硬切", icon: "✂" },
|
||||
{ value: "fade", label: "淡入淡出", icon: "◐" },
|
||||
{ value: "dissolve", label: "溶解", icon: "◈" },
|
||||
{ value: "zoom", label: "缩放", icon: "⊕" },
|
||||
{ value: "slide_left", label: "左滑", icon: "←" },
|
||||
{ value: "slide_right", label: "右滑", icon: "→" },
|
||||
{ value: "slide_up", label: "上滑", icon: "↑" },
|
||||
{ value: "slide_down", label: "下滑", icon: "↓" },
|
||||
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
|
||||
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
|
||||
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
|
||||
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
|
||||
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
|
||||
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
|
||||
];
|
||||
|
||||
/** 素材类型标签 */
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
@@ -72,6 +81,20 @@ export interface EditingTemplate {
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: TemplateSegment[];
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -95,6 +118,20 @@ export interface SaveTemplatePayload {
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: Omit<TemplateSegment, "id">[];
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
@@ -102,11 +139,23 @@ export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number;
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
/** 相关字段名 */
|
||||
field?: string;
|
||||
/** 期望值 */
|
||||
expected?: string | number;
|
||||
/** 实际值 */
|
||||
actual?: string | number;
|
||||
/** 建议值 */
|
||||
suggested?: string | number;
|
||||
}
|
||||
|
||||
/** 验证/生成响应 */
|
||||
export interface ValidateWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
details?: ValidationWarningDetails;
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
|
||||
+113
-14
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 成品相关 API
|
||||
* Phase 1 重构:去掉 projectId,成品直接归属用户
|
||||
* 成品 / 视频相关 API
|
||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import { getGenerationTaskResults } from "./editPlans";
|
||||
import type { GeneratedVideo } from "./editPlans";
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
@@ -14,33 +19,127 @@ export interface ProductItem {
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
project_id?: string;
|
||||
/** 所属项目名称 */
|
||||
project_name?: string;
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有成品 */
|
||||
export const getProducts = async (): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/products");
|
||||
return response.data.items || response.data || [];
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
project_id?: string;
|
||||
review_status?: ReviewStatus | "all";
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string;
|
||||
/** 进度百分比 */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 generation task 数据映射为 ProductItem 格式
|
||||
*/
|
||||
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.name || "未命名视频",
|
||||
video_url: task.file_url,
|
||||
thumbnail_url: task.thumbnail_url,
|
||||
duration_seconds: task.duration,
|
||||
file_size: task.file_size,
|
||||
resolution:
|
||||
task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||
status:
|
||||
task.status === "completed"
|
||||
? "completed"
|
||||
: task.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: task.review_status as ReviewStatus | undefined,
|
||||
project_id: task.project_id,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
|
||||
export const getProducts = async (
|
||||
params?: ProductListParams,
|
||||
): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/generation/tasks", { params });
|
||||
const tasks = response.data.items || response.data || [];
|
||||
return tasks.map(mapTaskToProductItem);
|
||||
};
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
/** 获取单个成品详情 — 通过 task ID 获取结果 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/products/${productId}`);
|
||||
return response.data;
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`);
|
||||
return mapTaskToProductItem(response.data);
|
||||
};
|
||||
|
||||
/** 删除成品 */
|
||||
/** 删除成品 — 删除 generation task */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/products/${productId}`);
|
||||
await apiClient.delete(`/generation/tasks/${productId}`);
|
||||
};
|
||||
|
||||
/** 获取成品下载链接 */
|
||||
/** 获取成品下载链接 — 从 generation task results 获取 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
const response = await apiClient.get(`/products/${productId}/download-url`);
|
||||
return response.data;
|
||||
const videos = await getGenerationTaskResults(productId);
|
||||
const video = videos[0];
|
||||
if (!video?.download_url) throw new Error("下载链接不可用");
|
||||
return { url: video.download_url, expires_at: "" };
|
||||
};
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /generation/tasks/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId);
|
||||
return { ...product, review_status: status };
|
||||
};
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (
|
||||
videoIds: string[],
|
||||
): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /generation/tasks/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
|
||||
return { job_id: `mock-${Date.now()}` };
|
||||
};
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (
|
||||
jobId: string,
|
||||
): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
|
||||
return { job_id: jobId, status: "processing", progress: 0 };
|
||||
};
|
||||
|
||||
+58
-12
@@ -1,31 +1,67 @@
|
||||
/**
|
||||
* 任务相关 API
|
||||
* 对接后端方案 A 扩展后的端点(PR #109)
|
||||
* - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式)
|
||||
* - GET /api/v1/tasks — 用户级任务列表(跨 project)
|
||||
* - POST /api/v1/tasks/{task_id}/retry — 简化重试
|
||||
* 对接后端任务中心 API:
|
||||
* - POST /api/v1/generation/tasks — 创建生成任务
|
||||
* - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选)
|
||||
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
|
||||
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 任务状态 */
|
||||
export type TaskStatus =
|
||||
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
/** 任务类型 */
|
||||
export type TaskType = "ingest" | "generation" | string;
|
||||
|
||||
/** 错误详情 */
|
||||
export interface TaskErrorInfo {
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
failed_step: string;
|
||||
stack_trace?: string;
|
||||
}
|
||||
|
||||
/** 任务条目(对应用户级 UserTaskResponse) */
|
||||
export interface TaskItem {
|
||||
id: string;
|
||||
task_type: "ingest" | "generation" | string;
|
||||
task_type: TaskType;
|
||||
project_id: string;
|
||||
template_id: string;
|
||||
status: string;
|
||||
template_id?: string;
|
||||
status: TaskStatus;
|
||||
progress: number;
|
||||
current_step: string;
|
||||
error_message: string;
|
||||
user_message: string;
|
||||
retryable: boolean;
|
||||
source_id: string;
|
||||
/** 错误详情(失败任务) */
|
||||
error_info?: TaskErrorInfo;
|
||||
/** 耗时(秒) */
|
||||
duration_seconds?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
/** 任务列表查询参数 */
|
||||
export interface TaskListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: TaskStatus | "all";
|
||||
task_type?: TaskType | "all";
|
||||
}
|
||||
|
||||
/** 任务列表分页响应 */
|
||||
export interface TaskListResponse {
|
||||
items: TaskItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 创建生成任务请求参数 */
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string;
|
||||
@@ -64,13 +100,23 @@ export const createGenerationTask = async (
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取当前用户的所有任务(跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks");
|
||||
return data.items || [];
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (
|
||||
params?: TaskListParams,
|
||||
): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取单个任务详情(用于轮询进度) */
|
||||
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks");
|
||||
return data.items || data || [];
|
||||
};
|
||||
|
||||
/** 获取单个任务详情(含 error_info) */
|
||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`);
|
||||
return data;
|
||||
|
||||
@@ -1,26 +1,112 @@
|
||||
/**
|
||||
* 模板相关 API
|
||||
* Phase 1 新增:全局模板库
|
||||
* 对接后端模板管理接口:
|
||||
* - GET /api/v1/templates — 模板列表(分页/筛选)
|
||||
* - GET /api/v1/templates/{id} — 模板详情
|
||||
* - POST /api/v1/templates/{id}/copy — 复制模板
|
||||
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
|
||||
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
|
||||
import type { EditPlanConfig } from "./editPlans";
|
||||
|
||||
/** 模板条目 */
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板条目(后端 TemplateResponse) */
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
/** 使用次数 */
|
||||
usage_count?: number;
|
||||
thumbnail_url?: string;
|
||||
preview_url?: string;
|
||||
is_active: boolean;
|
||||
is_favorite?: boolean;
|
||||
/** 素材规则(片段配置) */
|
||||
segments?: TemplateSegment[];
|
||||
/** 字幕样式 */
|
||||
subtitle_config?: SubtitleConfig;
|
||||
/** BGM 配置 */
|
||||
bgm_config?: BgmConfig;
|
||||
/** 标题配置 */
|
||||
title_config?: TitleConfig;
|
||||
/** 视频比例 */
|
||||
aspect_ratio?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 获取全局模板列表 */
|
||||
export const getTemplates = async (): Promise<TemplateItem[]> => {
|
||||
/** 模板片段(素材规则) */
|
||||
export interface TemplateSegment {
|
||||
id?: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 模板列表查询参数 */
|
||||
export interface TemplateListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
keyword?: string;
|
||||
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
|
||||
duration_range?: "short" | "medium" | "long";
|
||||
}
|
||||
|
||||
/** 模板列表分页响应 */
|
||||
export interface TemplateListResponse {
|
||||
items: TemplateItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划请求 */
|
||||
export interface GenerateFromTemplateRequest {
|
||||
asset_ids?: string[];
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
plan_id: string;
|
||||
template_id: string;
|
||||
status: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 复制模板响应 */
|
||||
export interface CopyTemplateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
source_template_id: string;
|
||||
}
|
||||
|
||||
/* ──────────── API 函数 ──────────── */
|
||||
|
||||
/** 获取模板列表(支持分页和筛选) */
|
||||
export const getTemplates = async (
|
||||
params?: TemplateListParams,
|
||||
): Promise<TemplateListResponse> => {
|
||||
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取模板列表(兼容旧接口,返回数组) */
|
||||
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get("/templates");
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
@@ -42,3 +128,46 @@ export const toggleFavoriteTemplate = async (
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 复制模板(创建副本到我的模板) */
|
||||
export const copyTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(
|
||||
`/templates/${templateId}/copy`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 从模板生成剪辑计划 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 模板分类选项 */
|
||||
export const TEMPLATE_CATEGORY_OPTIONS = [
|
||||
{ value: "", label: "全部分类" },
|
||||
{ value: "口播", label: "口播" },
|
||||
{ value: "种草", label: "种草" },
|
||||
{ value: "产品", label: "产品" },
|
||||
{ value: "品牌", label: "品牌" },
|
||||
{ value: "混剪", label: "混剪" },
|
||||
{ value: "Vlog", label: "Vlog" },
|
||||
];
|
||||
|
||||
/** 时长筛选选项 */
|
||||
export const TEMPLATE_DURATION_OPTIONS = [
|
||||
{ value: "", label: "全部时长" },
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
];
|
||||
|
||||
+63
-2
@@ -8,6 +8,18 @@ import apiClient from "./client";
|
||||
|
||||
/* ── 类型定义 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 元数据(合成时附带的扩展信息) */
|
||||
export interface TTSMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number;
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** TTS 合成请求参数 */
|
||||
export interface TTSSynthesizeRequest {
|
||||
text: string;
|
||||
@@ -18,7 +30,7 @@ export interface TTSSynthesizeRequest {
|
||||
voice_model?: string;
|
||||
voice_clone_profile_id?: string;
|
||||
format?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
metadata?: TTSMetadata;
|
||||
}
|
||||
|
||||
/** TTS 合成创建响应 */
|
||||
@@ -49,7 +61,7 @@ export interface TTSJob {
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: Record<string, unknown> | null;
|
||||
metadata_: TTSMetadata | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -140,3 +152,52 @@ export const saveTtsToLibrary = async (
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`);
|
||||
};
|
||||
|
||||
/* ── 音色列表 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 音色 */
|
||||
export interface TTSVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 音色分类标签:male/female/young/service/news/emotion */
|
||||
category?: string;
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
/** 试听 URL */
|
||||
preview_url?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 获取 TTS 音色列表 */
|
||||
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/* ── TTS 试听 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 试听请求参数 */
|
||||
export interface TTSPreviewRequest {
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed?: number;
|
||||
pitch?: number;
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
export interface TTSPreviewResponse {
|
||||
audio_url: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** TTS 试听 */
|
||||
export const previewTts = async (
|
||||
data: TTSPreviewRequest,
|
||||
): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>(
|
||||
"/tts/preview",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -36,6 +36,18 @@ export interface CreateVoiceCloneRequest {
|
||||
|
||||
/* ── 后端 API 类型 ────────────────────────────────────── */
|
||||
|
||||
/** 音色克隆元数据(克隆时附带的扩展信息) */
|
||||
export interface VoiceCloneMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number;
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
/** 音色 ID(克隆完成后分配) */
|
||||
voice_id?: string;
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 后端克隆档案响应 */
|
||||
export interface VoiceCloneProfile {
|
||||
id: string;
|
||||
@@ -51,7 +63,7 @@ export interface VoiceCloneProfile {
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: Record<string, unknown> | null;
|
||||
metadata_: VoiceCloneMetadata | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -80,7 +92,7 @@ export interface CreateVoiceCloneRequestFull {
|
||||
language?: string;
|
||||
gender?: string;
|
||||
max_retries?: number;
|
||||
metadata_?: Record<string, unknown>;
|
||||
metadata_?: VoiceCloneMetadata;
|
||||
}
|
||||
|
||||
/* ── 辅助函数 ─────────────────────────────────────────── */
|
||||
|
||||
@@ -384,7 +384,6 @@
|
||||
border-bottom: 1px solid var(--border-light) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@@ -409,7 +408,6 @@
|
||||
.xx-modal .ant-modal-header {
|
||||
padding: var(--space-md) !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -423,7 +421,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
|
||||
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
|
||||
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
/** 导航项类型 */
|
||||
@@ -80,6 +81,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
{
|
||||
key: "edit-plans",
|
||||
label: "剪辑计划",
|
||||
path: "/app/edit-plans",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
@@ -104,6 +111,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
{
|
||||
key: "tasks",
|
||||
label: "任务中心",
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
];
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -129,6 +142,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "edit-plans",
|
||||
label: "剪辑计划",
|
||||
path: "/app/edit-plans",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -181,6 +200,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "tasks",
|
||||
label: "任务中心",
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
|
||||
import {
|
||||
Upload,
|
||||
Modal as AntModal,
|
||||
message,
|
||||
Popconfirm,
|
||||
Drawer,
|
||||
Tag,
|
||||
Input as AntInput,
|
||||
Radio,
|
||||
Select as AntSelect,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
@@ -17,6 +27,11 @@ import {
|
||||
ExperimentOutlined,
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
TagsOutlined,
|
||||
FolderOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -27,8 +42,13 @@ import {
|
||||
deleteAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type AssetLibraryItem,
|
||||
type AssetItem as ApiAssetItem,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import "./assets.css";
|
||||
@@ -407,6 +427,33 @@ const AssetLibrary: React.FC = () => {
|
||||
/* 诊断中状态 — 记录正在诊断的素材 ID */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
|
||||
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false);
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false);
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false);
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false);
|
||||
|
||||
/* 批量打标签 */
|
||||
const [batchTagInput, setBatchTagInput] = useState("");
|
||||
const [batchTags, setBatchTags] = useState<string[]>([]);
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add");
|
||||
|
||||
/* 批量改分类 */
|
||||
const [batchCategory, setBatchCategory] = useState("");
|
||||
|
||||
/* 批量智能标记 */
|
||||
const [batchSmartView, setBatchSmartView] = useState<
|
||||
"recommended" | "caution" | "high_risk"
|
||||
>("recommended");
|
||||
|
||||
/* 操作结果 */
|
||||
const [operationResult, setOperationResult] =
|
||||
useState<BatchOperationResult | null>(null);
|
||||
const [operationTitle, setOperationTitle] = useState("");
|
||||
|
||||
/* 批量操作 loading */
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
/* 派生数据 */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets;
|
||||
@@ -563,19 +610,152 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteAsset(id);
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids);
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量删除");
|
||||
setResultDrawerOpen(true);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`);
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
setSelectedIds(new Set());
|
||||
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
|
||||
};
|
||||
|
||||
/* 批量打标签 */
|
||||
const handleBatchTag = async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签");
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量打标签");
|
||||
setResultDrawerOpen(true);
|
||||
setTagModalOpen(false);
|
||||
setBatchTags([]);
|
||||
setBatchTagInput("");
|
||||
setTagMode("add");
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`);
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 批量改分类 */
|
||||
const handleBatchClassify = async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类");
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量改分类");
|
||||
setResultDrawerOpen(true);
|
||||
setClassifyModalOpen(false);
|
||||
setBatchCategory("");
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材改为「${batchCategory}」`,
|
||||
);
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 批量智能标记 */
|
||||
const handleBatchMark = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量智能标记");
|
||||
setResultDrawerOpen(true);
|
||||
setMarkModalOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
const labelMap = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
};
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
);
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 标签输入处理 */
|
||||
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault();
|
||||
const tag = batchTagInput.trim();
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag]);
|
||||
}
|
||||
setBatchTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeBatchTag = (tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
// ── Loading 状态 ──
|
||||
@@ -769,6 +949,30 @@ const AssetLibrary: React.FC = () => {
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<TagsOutlined />}
|
||||
onClick={() => setTagModalOpen(true)}
|
||||
>
|
||||
打标签
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<FolderOutlined />}
|
||||
onClick={() => setClassifyModalOpen(true)}
|
||||
>
|
||||
改分类
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setMarkModalOpen(true)}
|
||||
>
|
||||
智能标记
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除 ${selectedIds.size} 个素材?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
@@ -898,6 +1102,207 @@ const AssetLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量打标签弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量打标签(${selectedIds.size} 个素材)`}
|
||||
open={tagModalOpen}
|
||||
onCancel={() => {
|
||||
setTagModalOpen(false);
|
||||
setBatchTags([]);
|
||||
setBatchTagInput("");
|
||||
}}
|
||||
onOk={handleBatchTag}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认打标签"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-tag-modal">
|
||||
<div className="xx-batch-tag-mode">
|
||||
<span className="xx-batch-tag-mode-label">模式:</span>
|
||||
<Radio.Group
|
||||
value={tagMode}
|
||||
onChange={(e) => setTagMode(e.target.value)}
|
||||
>
|
||||
<Radio value="add">追加标签</Radio>
|
||||
<Radio value="replace">替换全部标签</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="xx-batch-tag-input-row">
|
||||
<AntInput
|
||||
placeholder="输入标签后按 Enter 添加"
|
||||
value={batchTagInput}
|
||||
onChange={(e) => setBatchTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{batchTags.length > 0 && (
|
||||
<div className="xx-batch-tag-list">
|
||||
{batchTags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
closable
|
||||
onClose={() => removeBatchTag(tag)}
|
||||
color="blue"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tagMode === "replace" && batchTags.length > 0 && (
|
||||
<div className="xx-batch-tag-warning">
|
||||
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量改分类弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedIds.size} 个素材)`}
|
||||
open={classifyModalOpen}
|
||||
onCancel={() => {
|
||||
setClassifyModalOpen(false);
|
||||
setBatchCategory("");
|
||||
}}
|
||||
onOk={handleBatchClassify}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-classify-modal">
|
||||
<p className="xx-batch-classify-hint">
|
||||
将选中的 {selectedIds.size} 个素材统一修改为以下分类:
|
||||
</p>
|
||||
<AntSelect
|
||||
value={batchCategory || undefined}
|
||||
onChange={(v) => setBatchCategory(v)}
|
||||
placeholder="请选择分类"
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "person", label: "人物" },
|
||||
{ value: "scenic", label: "风景" },
|
||||
{ value: "product", label: "产品" },
|
||||
{ value: "food", label: "美食" },
|
||||
{ value: "animal", label: "动物" },
|
||||
{ value: "architecture", label: "建筑" },
|
||||
{ value: "other", label: "其他" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量智能标记弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量智能标记(${selectedIds.size} 个素材)`}
|
||||
open={markModalOpen}
|
||||
onCancel={() => setMarkModalOpen(false)}
|
||||
onOk={handleBatchMark}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认标记"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-mark-modal">
|
||||
<p className="xx-batch-mark-hint">
|
||||
将选中的 {selectedIds.size} 个素材标记为:
|
||||
</p>
|
||||
<Radio.Group
|
||||
value={batchSmartView}
|
||||
onChange={(e) => setBatchSmartView(e.target.value)}
|
||||
className="xx-batch-mark-options"
|
||||
>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="recommended">
|
||||
<Tag color="success">推荐</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
质量优良,可直接用于生产
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="caution">
|
||||
<Tag color="warning">慎用</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
存在一定问题,需人工审核后再使用
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="high_risk">
|
||||
<Tag color="error">高风险</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
存在严重问题,不建议使用
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 操作结果 Drawer ─── */}
|
||||
<Drawer
|
||||
title={`${operationTitle} — 操作结果`}
|
||||
open={resultDrawerOpen}
|
||||
onClose={() => {
|
||||
setResultDrawerOpen(false);
|
||||
setOperationResult(null);
|
||||
}}
|
||||
width={420}
|
||||
>
|
||||
{operationResult && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
<div className="xx-batch-result-stat">
|
||||
<span className="xx-batch-result-total">
|
||||
总计 {operationResult.total} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-batch-result-stat success">
|
||||
<CheckCircleOutlined />
|
||||
<span>成功 {operationResult.success_count} 个</span>
|
||||
</div>
|
||||
{operationResult.failure_count > 0 && (
|
||||
<div className="xx-batch-result-stat fail">
|
||||
<CloseCircleOutlined />
|
||||
<span>失败 {operationResult.failure_count} 个</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{operationResult.succeeded.length > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title success">
|
||||
<CheckCircleOutlined /> 成功列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{operationResult.succeeded.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operationResult.failed.length > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title fail">
|
||||
<CloseCircleOutlined /> 失败列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{operationResult.failed.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id fail">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -653,3 +653,175 @@
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ─── 批量打标签弹窗 ─── */
|
||||
|
||||
.xx-batch-tag-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-mode-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #111827);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xx-batch-tag-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-warning {
|
||||
padding: 10px 12px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: #c2410c;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ─── 批量改分类弹窗 ─── */
|
||||
|
||||
.xx-batch-classify-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-classify-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── 批量智能标记弹窗 ─── */
|
||||
|
||||
.xx-batch-mark-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-mark-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-batch-mark-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-mark-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.xx-batch-mark-desc {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ─── 操作结果 Drawer ─── */
|
||||
|
||||
.xx-batch-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.xx-batch-result-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.xx-batch-result-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.xx-batch-result-stat.success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.xx-batch-result-stat.fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.xx-batch-result-total {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-batch-result-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title.success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title.fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.xx-batch-result-ids {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.xx-batch-result-id {
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-batch-result-id.fail {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 剪辑计划管理页面
|
||||
* 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Tabs,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CloseCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getEditPlans,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
type EditPlan,
|
||||
type EditPlanStatus,
|
||||
type EditPlanListParams,
|
||||
} from "@/api/editPlans";
|
||||
import { getTemplatesList, type TemplateItem } from "@/api/templates";
|
||||
import "./edit-plans.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 状态 Tab 配置 */
|
||||
const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "editing", label: "编辑中" },
|
||||
{ key: "rendering", label: "渲染中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
];
|
||||
|
||||
/** 状态标签配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
EditPlanStatus,
|
||||
{ label: string; color: string; icon: React.ReactNode }
|
||||
> = {
|
||||
draft: {
|
||||
label: "草稿",
|
||||
color: "default",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
editing: {
|
||||
label: "编辑中",
|
||||
color: "processing",
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
rendering: {
|
||||
label: "渲染中",
|
||||
color: "warning",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
color: "success",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return "-";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m === 0) return `${s}秒`;
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return "-";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/* ──────────── 主组件 ──────────── */
|
||||
|
||||
export default function EditPlans() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">(
|
||||
"all",
|
||||
);
|
||||
const [templateFilter, setTemplateFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
// 查询参数
|
||||
const queryParams: EditPlanListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(statusFilter !== "all" && { status: statusFilter }),
|
||||
...(templateFilter !== "all" && { template_id: templateFilter }),
|
||||
};
|
||||
|
||||
// 获取剪辑计划列表
|
||||
const {
|
||||
data: planData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["edit-plans", queryParams],
|
||||
queryFn: () => getEditPlans(queryParams),
|
||||
refetchInterval: (query) => {
|
||||
// 有进行中的计划时自动刷新
|
||||
const plans = query.state.data?.items ?? [];
|
||||
const hasRunning = plans.some(
|
||||
(p) => p.status === "rendering" || p.status === "editing",
|
||||
);
|
||||
return hasRunning ? 5000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// 获取模板列表(用于筛选下拉)
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ["templates-list-simple"],
|
||||
queryFn: getTemplatesList,
|
||||
});
|
||||
|
||||
const plans = planData?.items ?? [];
|
||||
const total = planData?.total ?? 0;
|
||||
|
||||
// 模板名称映射
|
||||
const templateNameMap = new Map<string, string>();
|
||||
(templates ?? []).forEach((t: TemplateItem) => {
|
||||
templateNameMap.set(t.id, t.name);
|
||||
});
|
||||
|
||||
// 删除计划
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("剪辑计划已删除");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 重新生成
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: generateEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("已重新提交生成");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("重新生成失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 跳转到剪辑编辑器
|
||||
const handleEdit = useCallback(
|
||||
(plan: EditPlan) => {
|
||||
navigate(`/app/editing-planner?planId=${plan.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<EditPlan> = [
|
||||
{
|
||||
title: "计划名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (name: string, record: EditPlan) => (
|
||||
<Tooltip title={name}>
|
||||
<span className="plan-name" onClick={() => handleEdit(record)}>
|
||||
{name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "模板",
|
||||
dataIndex: "template_id",
|
||||
key: "template_id",
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (templateId: string) => {
|
||||
const name = templateNameMap.get(templateId);
|
||||
return (
|
||||
<Tag color="blue" className="plan-template-tag">
|
||||
{name || templateId.slice(0, 8)}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: EditPlanStatus) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
icon={config.icon}
|
||||
className="plan-status-tag"
|
||||
>
|
||||
{config.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "时长",
|
||||
dataIndex: "total_duration",
|
||||
key: "total_duration",
|
||||
width: 100,
|
||||
render: (seconds: number) => (
|
||||
<span className="plan-duration">{formatDuration(seconds)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 130,
|
||||
render: (time: string) => (
|
||||
<span className="plan-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 130,
|
||||
render: (time: string) => (
|
||||
<span className="plan-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 180,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: EditPlan) => (
|
||||
<div className="plan-actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{(record.status === "failed" || record.status === "completed") && (
|
||||
<Popconfirm
|
||||
title="确认重新生成"
|
||||
description="确定要重新生成这个剪辑计划吗?"
|
||||
onConfirm={() => regenerateMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={regenerateMutation.isPending}
|
||||
className="plan-action-btn plan-regenerate-btn"
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
|
||||
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
return (
|
||||
<div className="edit-plans-page">
|
||||
<div className="edit-plans-error">
|
||||
<CloseCircleOutlined />
|
||||
<p>加载剪辑计划失败</p>
|
||||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="edit-plans-page">
|
||||
{/* 页面标题 */}
|
||||
<div className="edit-plans-header">
|
||||
<div className="edit-plans-header-text">
|
||||
<h2>剪辑计划</h2>
|
||||
<p>管理所有剪辑计划,支持重新生成和编辑</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate("/app/templates")}>
|
||||
从模板创建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="edit-plans-filters">
|
||||
{/* 状态 Tab */}
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => {
|
||||
setStatusFilter(key as EditPlanStatus | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="edit-plans-status-tabs"
|
||||
/>
|
||||
|
||||
{/* 模板筛选 */}
|
||||
<Select
|
||||
value={templateFilter}
|
||||
onChange={(value) => {
|
||||
setTemplateFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "all", label: "全部模板" },
|
||||
...(templates ?? []).map((t: TemplateItem) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
})),
|
||||
]}
|
||||
style={{ minWidth: 180 }}
|
||||
placeholder="选择模板"
|
||||
className="edit-plans-template-filter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 计划表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={plans}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
className="edit-plans-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="edit-plans-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无剪辑计划</p>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => navigate("/app/templates")}
|
||||
>
|
||||
从模板创建
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 剪辑计划管理页面样式
|
||||
*/
|
||||
|
||||
/* ── 页面容器 ──────────────────────────────────────────── */
|
||||
.edit-plans-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── 页面头部 ──────────────────────────────────────────── */
|
||||
.edit-plans-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.edit-plans-header-text h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.edit-plans-header-text p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 筛选栏 ────────────────────────────────────────────── */
|
||||
.edit-plans-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-tab {
|
||||
padding: 8px 16px !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--primary-500, #6366f1) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-ink-bar {
|
||||
background: var(--primary-500, #6366f1) !important;
|
||||
}
|
||||
|
||||
.edit-plans-template-filter {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* ── 表格 ──────────────────────────────────────────────── */
|
||||
.edit-plans-table {
|
||||
background: var(--bg-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-tertiary, #f8fafc) !important;
|
||||
border-bottom: 1px solid var(--border-primary, #e2e8f0);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-tbody > tr > td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-hover, #f8fafc) !important;
|
||||
}
|
||||
|
||||
/* ── 计划名称 ──────────────────────────────────────────── */
|
||||
.plan-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.plan-name:hover {
|
||||
color: var(--primary-500, #6366f1);
|
||||
}
|
||||
|
||||
/* ── 状态标签 ──────────────────────────────────────────── */
|
||||
.plan-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-default {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-processing {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-success {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-warning {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ── 时长 ──────────────────────────────────────────────── */
|
||||
.plan-duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 时间 ──────────────────────────────────────────────── */
|
||||
.plan-time {
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 操作按钮 ──────────────────────────────────────────── */
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plan-action-btn {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.plan-action-btn.ant-btn-link {
|
||||
color: var(--primary-500, #6366f1);
|
||||
}
|
||||
|
||||
.plan-action-btn.ant-btn-link:hover {
|
||||
color: var(--primary-600, #4f46e5);
|
||||
}
|
||||
|
||||
.plan-regenerate-btn {
|
||||
color: var(--primary-500, #6366f1) !important;
|
||||
}
|
||||
|
||||
.plan-regenerate-btn:hover {
|
||||
color: var(--primary-600, #4f46e5) !important;
|
||||
}
|
||||
|
||||
/* ── 空状态 ────────────────────────────────────────────── */
|
||||
.edit-plans-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edit-plans-empty .anticon {
|
||||
font-size: 48px;
|
||||
color: var(--text-disabled, #cbd5e1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-empty p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 错误状态 ──────────────────────────────────────────── */
|
||||
.edit-plans-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.edit-plans-error .anticon {
|
||||
font-size: 48px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-error p {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 响应式 ────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.edit-plans-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit-plans-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-plans-template-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
* 剪辑计划编辑器 — V8 原型 1:1 还原
|
||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { message } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner";
|
||||
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
||||
@@ -28,9 +27,34 @@ import {
|
||||
generateCover,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type { TaskItem } from "@/api/tasks";
|
||||
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
|
||||
import type { ClipData, ClipType } from "./types";
|
||||
import type {
|
||||
ClipData,
|
||||
ClipType,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
TrimConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
TitleSettings,
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_TRANSITION,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
DEFAULT_WATERMARK,
|
||||
DEFAULT_INTRO_OUTRO,
|
||||
DEFAULT_PIP_CONFIG,
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "./types";
|
||||
import {
|
||||
ensureDefaultLibrary,
|
||||
getAssetsByKind,
|
||||
@@ -42,10 +66,23 @@ import MediaPanel from "./components/MediaPanel";
|
||||
import PreviewPlayer from "./components/PreviewPlayer";
|
||||
import TimelinePanel from "./components/TimelinePanel";
|
||||
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
|
||||
import BgmSelector from "./components/BgmSelector";
|
||||
import SubtitleStylePanel from "./components/SubtitleStylePanel";
|
||||
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
|
||||
import TransitionSelector from "./components/TransitionSelector";
|
||||
import SpeedPanel from "./components/SpeedPanel";
|
||||
import TtsPanel from "./components/TtsPanel";
|
||||
import WatermarkPanel from "./components/WatermarkPanel";
|
||||
import IntroOutroPanel from "./components/IntroOutroPanel";
|
||||
import PipConfigPanel from "./components/PipConfigPanel";
|
||||
import FilterPanel from "./components/FilterPanel";
|
||||
import GreenScreenPanel from "./components/GreenScreenPanel";
|
||||
import StickerPanel from "./components/StickerPanel";
|
||||
import CoverSelector from "./components/CoverSelector";
|
||||
import SaveModal from "./components/SaveModal";
|
||||
import GenerationProgressModal from "./components/GenerationProgressModal";
|
||||
import type { GenPhase } from "./components/GenerationProgressModal";
|
||||
import GenerationHistoryModal from "./components/GenerationHistoryModal";
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm";
|
||||
import "./EditingPlanner.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
@@ -107,7 +144,7 @@ const EditingPlanner: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
/* ── 标题/字幕/BGM 设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState({
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>({
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "top",
|
||||
@@ -120,17 +157,72 @@ const EditingPlanner: React.FC = () => {
|
||||
color: "#ffffff",
|
||||
});
|
||||
|
||||
const [subtitleSettings, setSubtitleSettings] = useState({
|
||||
enabled: true,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 16,
|
||||
animation: "none",
|
||||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>(
|
||||
{
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
},
|
||||
);
|
||||
|
||||
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
|
||||
...DEFAULT_BGM_MIX_CONFIG,
|
||||
});
|
||||
|
||||
const [bgmSettings, setBgmSettings] = useState({
|
||||
music: "none",
|
||||
/* ── Drawer 开关 ── */
|
||||
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false);
|
||||
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false);
|
||||
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false);
|
||||
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false);
|
||||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||||
const [transitionTargetClipId, setTransitionTargetClipId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
/** 当前正在调速的片段 ID */
|
||||
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
/** TTS 配音面板是否打开 */
|
||||
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false);
|
||||
/** 当前正在配置 TTS 的片段 ID */
|
||||
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null);
|
||||
|
||||
/* ── 水印 / 片头片尾 ── */
|
||||
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
|
||||
...DEFAULT_WATERMARK,
|
||||
});
|
||||
const [introOutroSettings, setIntroOutroSettings] =
|
||||
useState<IntroOutroConfig>({ ...DEFAULT_INTRO_OUTRO });
|
||||
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false);
|
||||
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 画中画 ── */
|
||||
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
||||
...DEFAULT_PIP_CONFIG,
|
||||
});
|
||||
const [pipDrawerOpen, setPipDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 滤镜调色 ── */
|
||||
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
});
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 绿幕抠像 ── */
|
||||
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
|
||||
...DEFAULT_CHROMA_KEY_CONFIG,
|
||||
});
|
||||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 贴纸 ── */
|
||||
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
|
||||
...DEFAULT_STICKER_CONFIG,
|
||||
});
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 封面 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
});
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 保存弹窗 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
@@ -139,15 +231,6 @@ const EditingPlanner: React.FC = () => {
|
||||
const [draftTags, setDraftTags] = useState("");
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
|
||||
/* ── 生成弹窗 ── */
|
||||
const [genModalOpen, setGenModalOpen] = useState(false);
|
||||
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
|
||||
const [genTask, setGenTask] = useState<TaskItem | null>(null);
|
||||
const [genSubmitting, setGenSubmitting] = useState(false);
|
||||
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
@@ -163,6 +246,19 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
/* ── 播放 ── */
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(40);
|
||||
const prevFrameTimeRef = useRef<number | null>(null);
|
||||
|
||||
/** 播放头跳转 */
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
setCurrentTime(Math.max(0, time));
|
||||
}, []);
|
||||
|
||||
/** 轨道缩放 */
|
||||
const handleZoomChange = useCallback((pps: number) => {
|
||||
setPixelsPerSecond(pps);
|
||||
}, []);
|
||||
|
||||
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
|
||||
const voiceMaterialsQuery = useQuery({
|
||||
@@ -256,16 +352,21 @@ const EditingPlanner: React.FC = () => {
|
||||
size: tpl.title_config.font_size,
|
||||
color: tpl.title_config.font_color || "#ffffff",
|
||||
}));
|
||||
setSubtitleSettings({
|
||||
setSubtitleSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.subtitle_config.enabled,
|
||||
position: tpl.subtitle_config.position,
|
||||
position: (tpl.subtitle_config.position ||
|
||||
"bottom") as SubtitleStyleConfig["position"],
|
||||
font: tpl.subtitle_config.font,
|
||||
size: tpl.subtitle_config.size,
|
||||
fontSize: tpl.subtitle_config.size,
|
||||
fontColor: tpl.subtitle_config.color || "#ffffff",
|
||||
animation: tpl.subtitle_config.animation,
|
||||
});
|
||||
setBgmSettings({
|
||||
music: tpl.bgm_config.music_id || "none",
|
||||
});
|
||||
}));
|
||||
setBgmSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.bgm_config.enabled,
|
||||
music_id: tpl.bgm_config.music_id || "",
|
||||
}));
|
||||
setDraftName(tpl.name);
|
||||
setDraftCategory(tpl.category);
|
||||
setDraftTags(tpl.tags.join(", "));
|
||||
@@ -279,6 +380,31 @@ const EditingPlanner: React.FC = () => {
|
||||
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0);
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
|
||||
|
||||
/* rAF 帧推进 — 播放时平滑更新播放头位置 */
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
prevFrameTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
let rafId: number;
|
||||
const tick = (timestamp: number) => {
|
||||
if (prevFrameTimeRef.current !== null) {
|
||||
const delta = (timestamp - prevFrameTimeRef.current) / 1000;
|
||||
setCurrentTime((prev) => {
|
||||
const next = prev + delta;
|
||||
return next >= totalDuration ? totalDuration : next;
|
||||
});
|
||||
}
|
||||
prevFrameTimeRef.current = timestamp;
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
prevFrameTimeRef.current = null;
|
||||
};
|
||||
}, [isPlaying, totalDuration]);
|
||||
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
if (currentFilter !== "全部" && t.category !== currentFilter) return false;
|
||||
if (
|
||||
@@ -349,6 +475,175 @@ const EditingPlanner: React.FC = () => {
|
||||
[clips.length, setClips],
|
||||
);
|
||||
|
||||
/* ── 裁剪更新:调整片段的 trim_config 和 duration ── */
|
||||
const handleClipTrim = useCallback(
|
||||
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId
|
||||
? { ...c, trim_config: trimConfig, duration: newDuration }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 片段分割:在指定比例位置将片段一分为二 ── */
|
||||
const handleClipSplit = useCallback(
|
||||
(clipId: string, splitRatio: number) => {
|
||||
setClips((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === clipId);
|
||||
if (idx === -1) return prev;
|
||||
const clip = prev[idx];
|
||||
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10;
|
||||
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev;
|
||||
|
||||
// 前半段
|
||||
const firstHalf: ClipData = {
|
||||
...clip,
|
||||
duration: splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
end_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// 后半段
|
||||
const secondHalf: ClipData = {
|
||||
...clip,
|
||||
id: `clip-${Date.now()}`,
|
||||
duration: clip.duration - splitPoint,
|
||||
startOffset: clip.startOffset + splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
start_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
order: (clip.order ?? idx) + 1,
|
||||
};
|
||||
|
||||
const updated = [...prev];
|
||||
updated[idx] = firstHalf;
|
||||
updated.splice(idx + 1, 0, secondHalf);
|
||||
return updated.map((c, i) => ({ ...c, order: i }));
|
||||
});
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 恢复片段原始长度 ── */
|
||||
const handleClipResetTrim = useCallback(
|
||||
(clipId: string) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== clipId || !c.trim_config) return c;
|
||||
const originalDuration =
|
||||
c.trim_config.original_duration ?? c.duration;
|
||||
return {
|
||||
...c,
|
||||
duration: originalDuration,
|
||||
trim_config: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 转场特效变更 ── */
|
||||
const handleTransitionChange = useCallback(
|
||||
(config: TransitionConfig) => {
|
||||
if (transitionTargetClipId) {
|
||||
// 更新指定片段的转场
|
||||
handleClipUpdate(transitionTargetClipId, { transition: config });
|
||||
}
|
||||
// 同时更新全局默认转场(供新片段使用)
|
||||
},
|
||||
[transitionTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开转场选择器 ── */
|
||||
const handleOpenTransitionDrawer = useCallback((clipId?: string) => {
|
||||
setTransitionTargetClipId(clipId ?? null);
|
||||
setTransitionDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── 调速变更 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(config: SpeedConfig) => {
|
||||
if (speedTargetClipId) {
|
||||
handleClipUpdate(speedTargetClipId, { speed: config });
|
||||
}
|
||||
},
|
||||
[speedTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开调速面板 ── */
|
||||
const handleOpenSpeedDrawer = useCallback((clipId: string) => {
|
||||
setSpeedTargetClipId(clipId);
|
||||
setSpeedDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── TTS 配音变更 ── */
|
||||
const handleTtsChange = useCallback(
|
||||
(ttsConfig: TtsConfig) => {
|
||||
if (!ttsTargetClipId) return;
|
||||
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
|
||||
},
|
||||
[ttsTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开 TTS 配音面板 ── */
|
||||
const handleOpenTtsDrawer = useCallback((clipId: string) => {
|
||||
setTtsTargetClipId(clipId);
|
||||
setTtsDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── 调速应用到所有片段 ── */
|
||||
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
|
||||
message.success("已应用到所有片段");
|
||||
}, []);
|
||||
|
||||
/* ── 水印配置变更 ── */
|
||||
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
|
||||
setWatermarkSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 片头片尾配置变更 ── */
|
||||
const handleIntroOutroChange = useCallback((config: IntroOutroConfig) => {
|
||||
setIntroOutroSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 画中画配置变更 ── */
|
||||
const handlePipChange = useCallback((config: PipConfig) => {
|
||||
setPipSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 滤镜调色配置变更 ── */
|
||||
const handleFilterChange = useCallback((config: FilterConfig) => {
|
||||
setFilterSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 绿幕抠像配置变更 ── */
|
||||
const handleChromaKeyChange = useCallback((config: ChromaKeyConfig) => {
|
||||
setChromaKeySettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 贴纸配置变更 ── */
|
||||
const handleStickerChange = useCallback((config: StickerConfig) => {
|
||||
setStickerSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 封面配置变更 ── */
|
||||
const handleCoverChange = useCallback((config: CoverConfig) => {
|
||||
setCoverSettings(config);
|
||||
}, []);
|
||||
|
||||
/* AI 封面生成 */
|
||||
const handleAiGenerateCover = async (
|
||||
coverType: "ai_frame" | "ai_regenerate",
|
||||
@@ -408,13 +703,13 @@ const EditingPlanner: React.FC = () => {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
color: "#ffffff",
|
||||
size: subtitleSettings.size,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.music !== "none",
|
||||
music_id: bgmSettings.music,
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
},
|
||||
estimated_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
@@ -422,7 +717,35 @@ const EditingPlanner: React.FC = () => {
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverSettings },
|
||||
};
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload);
|
||||
@@ -462,12 +785,13 @@ const EditingPlanner: React.FC = () => {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
size: subtitleSettings.size,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.music !== "none",
|
||||
music_id: bgmSettings.music,
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
},
|
||||
mode: currentMode,
|
||||
total_duration: totalDuration,
|
||||
@@ -479,7 +803,35 @@ const EditingPlanner: React.FC = () => {
|
||||
script_text: c.script_text,
|
||||
voice_asset_id: c.voice_asset_id,
|
||||
voice_file_url: c.voice_file_url,
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverSettings },
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
if (loadedTemplateId) {
|
||||
@@ -489,87 +841,6 @@ const EditingPlanner: React.FC = () => {
|
||||
navigate(`/app/generate?${params.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建生成任务(两步)
|
||||
* 1. generateFromTemplate — 通知后端基于模板生成视频
|
||||
* 2. createGenerationTask — 创建任务记录,返回精简响应
|
||||
* 再用 getTask 查询完整 TaskItem 供轮询使用
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!loadedTemplateId) return;
|
||||
setGenSubmitting(true);
|
||||
try {
|
||||
await generateFromTemplate(loadedTemplateId, {
|
||||
voiceover_duration: voiceoverDuration || totalDuration,
|
||||
});
|
||||
// 收集所有 voice 类型片段的配音素材 ID
|
||||
const voiceIds = clips
|
||||
.filter((c) => c.type === "voice" && c.voice_asset_id)
|
||||
.map((c) => c.voice_asset_id as string);
|
||||
const res = await createGenerationTask({
|
||||
template_id: loadedTemplateId,
|
||||
asset_ids: [],
|
||||
title_ids: [],
|
||||
voice_ids: voiceIds,
|
||||
});
|
||||
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
|
||||
const task = await getTask(res.id);
|
||||
setGenTask(task);
|
||||
setGenPhase("progress");
|
||||
message.info("生成任务已创建");
|
||||
} catch {
|
||||
message.error("创建生成任务失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 轮询生成任务状态(每 3 秒)
|
||||
* 仅在 genPhase === "progress" 且有任务 ID 时启动
|
||||
* 任务完成/失败时自动停止轮询
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (genPhase !== "progress" || !genTask?.id) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const t = await getTask(genTask.id);
|
||||
setGenTask(t);
|
||||
if (t.status === "completed") {
|
||||
setGenPhase("completed");
|
||||
clearInterval(timer);
|
||||
} else if (t.status === "failed") {
|
||||
setGenPhase("failed");
|
||||
clearInterval(timer);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [genPhase, genTask?.id]);
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!genTask?.id) return;
|
||||
setGenSubmitting(true);
|
||||
try {
|
||||
const t = await retryTask(genTask.id);
|
||||
setGenTask(t);
|
||||
setGenPhase("progress");
|
||||
} catch {
|
||||
message.error("重试失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelGen = () => {
|
||||
setGenModalOpen(false);
|
||||
setGenPhase("setup");
|
||||
setGenTask(null);
|
||||
setVoiceoverDuration(null);
|
||||
};
|
||||
|
||||
/* 查看生成历史 */
|
||||
const handleViewGenHistory = async () => {
|
||||
if (!loadedTemplateId) {
|
||||
@@ -678,7 +949,13 @@ const EditingPlanner: React.FC = () => {
|
||||
coverSchemes={COVER_SCHEMES}
|
||||
aiCoverLoading={aiCoverLoading}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
}}
|
||||
onClipSelect={handleClipSelect}
|
||||
onCoverSchemeChange={setCurrentCoverScheme}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
@@ -694,6 +971,14 @@ const EditingPlanner: React.FC = () => {
|
||||
onClipReorder={handleClipReorder}
|
||||
onClipRemove={handleClipRemove}
|
||||
onAddClip={handleAddClip}
|
||||
onClipTrim={handleClipTrim}
|
||||
onClipSplit={handleClipSplit}
|
||||
onClipResetTrim={handleClipResetTrim}
|
||||
currentTime={currentTime}
|
||||
pixelsPerSecond={pixelsPerSecond}
|
||||
onZoomChange={handleZoomChange}
|
||||
onSeek={handleSeek}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -710,16 +995,30 @@ const EditingPlanner: React.FC = () => {
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -758,20 +1057,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onCancel={() => setSaveModalOpen(false)}
|
||||
/>
|
||||
|
||||
<GenerationProgressModal
|
||||
open={genModalOpen}
|
||||
phase={genPhase}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={totalDuration}
|
||||
onDurationChange={setVoiceoverDuration}
|
||||
onGenerate={handleGenerate}
|
||||
task={genTask}
|
||||
submitting={genSubmitting}
|
||||
onCancel={handleCancelGen}
|
||||
onRetry={handleRetry}
|
||||
onClose={handleCancelGen}
|
||||
/>
|
||||
|
||||
{/* ═══ 生成历史弹窗 ═══ */}
|
||||
<GenerationHistoryModal
|
||||
open={genHistoryOpen}
|
||||
@@ -779,6 +1064,122 @@ const EditingPlanner: React.FC = () => {
|
||||
history={genHistory}
|
||||
onClose={() => setGenHistoryOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={() => setBgmDrawerOpen(false)}
|
||||
config={bgmSettings}
|
||||
onChange={setBgmSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 字幕样式配置 Drawer ═══ */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={() => setSubtitleDrawerOpen(false)}
|
||||
config={subtitleSettings}
|
||||
onChange={setSubtitleSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 转场特效选择器 Drawer ═══ */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={() => setTransitionDrawerOpen(false)}
|
||||
config={
|
||||
transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ??
|
||||
DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
}
|
||||
onChange={handleTransitionChange}
|
||||
title={transitionTargetClipId ? "片段转场设置" : "全局默认转场"}
|
||||
/>
|
||||
|
||||
{/* ═══ 片段调速面板 Drawer ═══ */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={() => setSpeedDrawerOpen(false)}
|
||||
config={
|
||||
clips.find((c) => c.id === speedTargetClipId)?.speed ??
|
||||
DEFAULT_SPEED
|
||||
}
|
||||
onChange={handleSpeedChange}
|
||||
onApplyAll={handleApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ TTS 配音面板 Drawer ═══ */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={() => setTtsDrawerOpen(false)}
|
||||
config={
|
||||
clips.find((c) => c.id === ttsTargetClipId)?.tts_config ??
|
||||
DEFAULT_TTS_CONFIG
|
||||
}
|
||||
onChange={handleTtsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ 水印配置面板 ═══ */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={() => setWatermarkDrawerOpen(false)}
|
||||
config={watermarkSettings}
|
||||
onChange={handleWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 片头片尾配置面板 ═══ */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={() => setIntroOutroDrawerOpen(false)}
|
||||
config={introOutroSettings}
|
||||
onChange={handleIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 画中画配置面板 ═══ */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={() => setPipDrawerOpen(false)}
|
||||
config={pipSettings}
|
||||
onChange={handlePipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 滤镜调色面板 ═══ */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={() => setFilterDrawerOpen(false)}
|
||||
config={filterSettings}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 绿幕抠像面板 ═══ */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={() => setChromaKeyDrawerOpen(false)}
|
||||
config={chromaKeySettings}
|
||||
onChange={handleChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 贴纸面板 ═══ */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={() => setStickerDrawerOpen(false)}
|
||||
config={stickerSettings}
|
||||
onChange={handleStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 封面选择器 ═══ */}
|
||||
<CoverSelector
|
||||
open={coverDrawerOpen}
|
||||
onClose={() => setCoverDrawerOpen(false)}
|
||||
config={coverSettings}
|
||||
onChange={handleCoverChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* BGM 选择器 — Drawer 形式
|
||||
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Drawer, Slider, Input, Tag, message } from "antd";
|
||||
import {
|
||||
getBgmPresets,
|
||||
type BgmPreset,
|
||||
type BgmCategory,
|
||||
type BgmMixConfig,
|
||||
DEFAULT_BGM_MIX_CONFIG,
|
||||
} from "@/api/bgm";
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
/* ──────────── 分类标签 ──────────── */
|
||||
const CATEGORY_LIST: {
|
||||
key: BgmCategory | "all";
|
||||
label: string;
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ key: "all", label: "全部", icon: "🎶" },
|
||||
{ key: "轻快", label: "轻快", icon: "🎉" },
|
||||
{ key: "治愈", label: "治愈", icon: "🌿" },
|
||||
{ key: "科技", label: "科技", icon: "🔬" },
|
||||
{ key: "电商", label: "电商", icon: "🛒" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface BgmSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: BgmMixConfig;
|
||||
onChange: (config: BgmMixConfig) => void;
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">(
|
||||
"all",
|
||||
);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {};
|
||||
if (activeCategory !== "all") params.category = activeCategory;
|
||||
if (keyword.trim()) params.keyword = keyword.trim();
|
||||
const data = await getBgmPresets(params);
|
||||
setPresets(data);
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeCategory, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets();
|
||||
}, [open, loadPresets]);
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
if (previewingId === bgm.id) {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
return;
|
||||
}
|
||||
audioRef.current?.pause();
|
||||
const audio = new Audio(bgm.url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => {});
|
||||
audio.onended = () => setPreviewingId(null);
|
||||
setPreviewingId(bgm.id);
|
||||
},
|
||||
[previewingId],
|
||||
);
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
music_id: bgm.id,
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 关闭时停止播放 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
/* ── 移除 BGM ── */
|
||||
const handleClear = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
onChange({ ...DEFAULT_BGM_MIX_CONFIG });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 当前选中的 BGM ── */
|
||||
const selectedBgm = presets.find((p) => p.id === config.music_id);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎵 BGM 音乐选择"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="bgm-selector-drawer"
|
||||
>
|
||||
{/* ── 搜索框 ── */}
|
||||
<div className="bgm-search-row">
|
||||
<Search
|
||||
placeholder="搜索 BGM 名称..."
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadPresets()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 分类标签 ── */}
|
||||
<div className="bgm-category-bar">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Tag
|
||||
key={cat.key}
|
||||
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
|
||||
onClick={() => setActiveCategory(cat.key)}
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── BGM 列表 ── */}
|
||||
<div className="bgm-list">
|
||||
{loading && <div className="bgm-loading">加载中...</div>}
|
||||
{!loading && presets.length === 0 && (
|
||||
<div className="bgm-empty">暂无 BGM 数据</div>
|
||||
)}
|
||||
{presets.map((bgm) => {
|
||||
const isSelected = config.music_id === bgm.id;
|
||||
const isPlaying = previewingId === bgm.id;
|
||||
return (
|
||||
<div
|
||||
key={bgm.id}
|
||||
className={`bgm-item${isSelected ? " selected" : ""}`}
|
||||
onClick={() => handleSelect(bgm)}
|
||||
>
|
||||
<div className="bgm-item-cover">
|
||||
{bgm.cover_url ? (
|
||||
<img src={bgm.cover_url} alt={bgm.name} />
|
||||
) : (
|
||||
<span className="bgm-item-cover-icon">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bgm-item-info">
|
||||
<div className="bgm-item-name">{bgm.name}</div>
|
||||
<div className="bgm-item-meta">
|
||||
<span className="bgm-item-category">{bgm.category}</span>
|
||||
<span className="bgm-item-duration">
|
||||
{Math.floor(bgm.duration / 60)}:
|
||||
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
{bgm.tags.length > 0 && (
|
||||
<div className="bgm-item-tags">
|
||||
{bgm.tags.slice(0, 3).map((t) => (
|
||||
<span key={t} className="bgm-item-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview(bgm);
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶️"}
|
||||
</button>
|
||||
{isSelected && <span className="bgm-item-check">✓</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 混音配置 ── */}
|
||||
{config.enabled && config.music_id && (
|
||||
<div className="bgm-mix-config">
|
||||
<div className="bgm-mix-header">
|
||||
<span>混音配置</span>
|
||||
<button className="bgm-mix-clear" onClick={handleClear}>
|
||||
移除 BGM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bgm-mix-selected">
|
||||
{selectedBgm
|
||||
? `当前:${selectedBgm.name}`
|
||||
: `当前:${config.music_id}`}
|
||||
</div>
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
音量 <span className="bgm-mix-value">{config.volume}%</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.volume}
|
||||
onChange={(v) => onChange({ ...config, volume: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡入 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡入{" "}
|
||||
<span className="bgm-mix-value">
|
||||
{config.fade_in.toFixed(1)}s
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_in}
|
||||
onChange={(v) => onChange({ ...config, fade_in: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡出 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡出{" "}
|
||||
<span className="bgm-mix-value">
|
||||
{config.fade_out.toFixed(1)}s
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_out}
|
||||
onChange={(v) => onChange({ ...config, fade_out: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 人声闪避 */}
|
||||
<div className="bgm-mix-field bgm-mix-toggle-row">
|
||||
<label className="bgm-mix-label">人声闪避(sidechain)</label>
|
||||
<div
|
||||
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
onChange({ ...config, voice_dodge: !config.voice_dodge })
|
||||
}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default BgmSelector;
|
||||
@@ -5,32 +5,30 @@
|
||||
import React, { useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { TemplateMode } from "@/api/editingPlanner";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
fontSize: number;
|
||||
fontColor: string;
|
||||
animation: string;
|
||||
mode?: string;
|
||||
stroke?: boolean;
|
||||
shadow?: boolean;
|
||||
asrLanguage?: string;
|
||||
}
|
||||
|
||||
interface BgmSettings {
|
||||
music: string;
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
volume?: number;
|
||||
fade_in?: number;
|
||||
fade_out?: number;
|
||||
voice_dodge?: boolean;
|
||||
}
|
||||
|
||||
interface ClipPropertiesPanelProps {
|
||||
@@ -45,6 +43,10 @@ interface ClipPropertiesPanelProps {
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
|
||||
/** 打开 BGM 选择器 Drawer */
|
||||
onOpenBgmDrawer?: () => void;
|
||||
/** 打开字幕样式配置 Drawer */
|
||||
onOpenSubtitleDrawer?: () => void;
|
||||
/** 配音素材列表(从配音素材库 API 获取) */
|
||||
voiceMaterials?: AssetItem[];
|
||||
/** 配音素材加载中 */
|
||||
@@ -53,6 +55,26 @@ interface ClipPropertiesPanelProps {
|
||||
onRefreshVoiceMaterials?: () => void;
|
||||
/** 为片段选择配音素材 */
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
|
||||
/** 打开转场特效选择器 Drawer */
|
||||
onOpenTransitionDrawer?: (clipId: string) => void;
|
||||
/** 打开片段调速面板 Drawer */
|
||||
onOpenSpeedDrawer?: (clipId: string) => void;
|
||||
/** 打开 TTS 配音面板 Drawer */
|
||||
onOpenTtsDrawer?: (clipId: string) => void;
|
||||
/** 打开水印设置面板 Drawer */
|
||||
onOpenWatermarkDrawer?: () => void;
|
||||
/** 打开片头片尾设置面板 Drawer */
|
||||
onOpenIntroOutroDrawer?: () => void;
|
||||
/** 打开画中画设置面板 Drawer */
|
||||
onOpenPipDrawer?: () => void;
|
||||
/** 打开滤镜调色面板 Drawer */
|
||||
onOpenFilterDrawer?: () => void;
|
||||
/** 打开绿幕抠像面板 Drawer */
|
||||
onOpenGreenScreenDrawer?: () => void;
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void;
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void;
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
@@ -78,14 +100,6 @@ const ANIMATION_OPTIONS = [
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
];
|
||||
|
||||
const BGM_OPTIONS = [
|
||||
{ value: "none", label: "无背景音乐" },
|
||||
{ value: "bgm_01", label: "🎵 轻快节奏" },
|
||||
{ value: "bgm_02", label: "🎵 温馨舒缓" },
|
||||
{ value: "bgm_03", label: "🎵 动感活力" },
|
||||
{ value: "bgm_04", label: "🎵 科技感" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
|
||||
* 不绑定字体,用户可自由搭配任意字体
|
||||
@@ -272,12 +286,24 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
currentMode,
|
||||
onTitleSettingsChange,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onBgmSettingsChange: _onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
onOpenBgmDrawer,
|
||||
onOpenSubtitleDrawer,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
onOpenWatermarkDrawer,
|
||||
onOpenIntroOutroDrawer,
|
||||
onOpenPipDrawer,
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -534,15 +560,17 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={32}
|
||||
value={subtitleSettings.size}
|
||||
min={12}
|
||||
max={48}
|
||||
value={subtitleSettings.fontSize}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ size: Number(e.target.value) })
|
||||
onSubtitleSettingsChange({
|
||||
fontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">
|
||||
{subtitleSettings.size}px
|
||||
{subtitleSettings.fontSize}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -563,6 +591,16 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 高级配置按钮 */}
|
||||
{onOpenSubtitleDrawer && (
|
||||
<button
|
||||
className="ep-advanced-btn"
|
||||
onClick={onOpenSubtitleDrawer}
|
||||
>
|
||||
🎨 高级字幕样式配置
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -574,20 +612,130 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
BGM 设置
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">背景音乐</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={bgmSettings.music}
|
||||
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
|
||||
>
|
||||
{BGM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{bgmSettings.enabled && bgmSettings.music_id ? (
|
||||
<div className="ep-bgm-current">
|
||||
<span className="ep-bgm-current-label">🎵 已选择 BGM</span>
|
||||
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
|
||||
{bgmSettings.volume !== undefined && (
|
||||
<span className="ep-bgm-current-vol">
|
||||
音量 {bgmSettings.volume}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ep-bgm-empty">未选择背景音乐</div>
|
||||
)}
|
||||
|
||||
{onOpenBgmDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
|
||||
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 水印设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🔖</span>
|
||||
水印设置
|
||||
</div>
|
||||
{onOpenWatermarkDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🔖</span>
|
||||
<span className="ep-advanced-btn-label">水印配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片头片尾设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎬</span>
|
||||
片头片尾
|
||||
</div>
|
||||
{onOpenIntroOutroDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">片头片尾配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 画中画 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
画中画
|
||||
</div>
|
||||
{onOpenPipDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">配置画中画图层</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 滤镜调色 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎨</span>
|
||||
滤镜调色
|
||||
</div>
|
||||
{onOpenFilterDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎨</span>
|
||||
<span className="ep-advanced-btn-label">配置滤镜调色</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 绿幕抠像 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🟩</span>
|
||||
绿幕抠像
|
||||
</div>
|
||||
{onOpenGreenScreenDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🟩</span>
|
||||
<span className="ep-advanced-btn-label">配置绿幕抠像</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 贴纸 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🏷️</span>
|
||||
贴纸
|
||||
</div>
|
||||
{onOpenStickerDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🏷️</span>
|
||||
<span className="ep-advanced-btn-label">配置贴纸花字</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 封面 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
封面
|
||||
</div>
|
||||
{onOpenCoverDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">选择视频封面</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
@@ -649,6 +797,71 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转场效果入口 */}
|
||||
{onOpenTransitionDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--transition"
|
||||
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">转场效果</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{(() => {
|
||||
const t = selectedClip.transition;
|
||||
if (!t || t.type === "none") return "无转场";
|
||||
const opt = TRANSITION_OPTIONS.find(
|
||||
(o) => o.value === t.type,
|
||||
);
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`;
|
||||
})()}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放速度入口 */}
|
||||
{onOpenSpeedDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--speed"
|
||||
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">⚡</span>
|
||||
<span className="ep-advanced-btn-label">播放速度</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{selectedClip.speed
|
||||
? `${selectedClip.speed.rate.toFixed(2)}x`
|
||||
: "1.00x"}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS 配音入口 */}
|
||||
{onOpenTtsDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--tts"
|
||||
onClick={() => onOpenTtsDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎙️</span>
|
||||
<span className="ep-advanced-btn-label">TTS 配音</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{(() => {
|
||||
const tts = selectedClip.tts_config;
|
||||
if (!tts || tts.mode === "none") return "无配音";
|
||||
if (tts.mode === "upload") return "上传配音";
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`;
|
||||
})()}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材起始时间 — 仅 voice 类型显示 */}
|
||||
{selectedClip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type { CoverConfig, CoverMode } from "../types";
|
||||
import { DEFAULT_COVER_CONFIG } from "../types";
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: CoverConfig;
|
||||
onChange: (config: CoverConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/** 封面模式标签 */
|
||||
const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
};
|
||||
|
||||
/** 封面模式图标 */
|
||||
const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
};
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 切换模式 */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 处理文件上传 */
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string;
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 拖拽上传 */
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileUpload(file);
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
/** 使用 AI 推荐时间 */
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" });
|
||||
}
|
||||
}, [config.ai_suggested_time, update]);
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 10);
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{/* 智能封面 */}
|
||||
{config.mode === "auto" && (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">
|
||||
推荐时间点:{formatTime(config.ai_suggested_time)}
|
||||
</div>
|
||||
<button
|
||||
className="cover-auto-use-btn"
|
||||
onClick={handleUseAiSuggestion}
|
||||
>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{config.mode === "frame" && (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">
|
||||
{formatTime(config.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">
|
||||
{formatTime(config.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => update({ frame_time: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 快捷时间点 */}
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio;
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="cover-quick-btn"
|
||||
onClick={() => update({ frame_time: t })}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">
|
||||
点击或拖拽上传封面图片
|
||||
</span>
|
||||
<span className="cover-upload-hint">
|
||||
支持 JPG / PNG,建议 16:9 比例
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img
|
||||
src={config.upload_url}
|
||||
alt="封面预览"
|
||||
className="cover-preview-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoverSelector;
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 滤镜调色配置面板
|
||||
* 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度)
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type { FilterConfig, FilterPreset } from "../types";
|
||||
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types";
|
||||
|
||||
interface FilterPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: FilterConfig;
|
||||
onChange: (config: FilterConfig) => void;
|
||||
}
|
||||
|
||||
/** 所有预设列表 */
|
||||
const PRESET_LIST: FilterPreset[] = [
|
||||
"none",
|
||||
"original",
|
||||
"fresh",
|
||||
"warm",
|
||||
"cool",
|
||||
"vintage",
|
||||
"cinema",
|
||||
"bw",
|
||||
"sunshine",
|
||||
"film",
|
||||
];
|
||||
|
||||
/** 预设对应的示例渐变色(用于视觉预览) */
|
||||
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
|
||||
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
|
||||
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
|
||||
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
|
||||
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
|
||||
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
|
||||
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
|
||||
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
|
||||
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
|
||||
};
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 选择预设时重置手动参数 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: FilterPreset) => {
|
||||
if (preset === "none") {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
|
||||
} else {
|
||||
onChange({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
enabled: config.enabled,
|
||||
preset,
|
||||
});
|
||||
}
|
||||
},
|
||||
[config.enabled, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="滤镜调色"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="filter-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="filter-header">
|
||||
<span className="filter-header-label">启用滤镜</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设滤镜选择 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">预设滤镜</div>
|
||||
<div className="filter-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<div
|
||||
className="filter-preset-preview"
|
||||
style={{ background: PRESET_GRADIENTS[p] }}
|
||||
/>
|
||||
<span className="filter-preset-label">
|
||||
{FILTER_PRESET_LABELS[p]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手动调节 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">手动调节</div>
|
||||
|
||||
{/* 亮度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">亮度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.brightness}
|
||||
onChange={(e) => update({ brightness: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.brightness}</span>
|
||||
</div>
|
||||
|
||||
{/* 对比度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">对比度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.contrast}
|
||||
onChange={(e) => update({ contrast: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.contrast}</span>
|
||||
</div>
|
||||
|
||||
{/* 饱和度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">饱和度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.saturation}
|
||||
onChange={(e) => update({ saturation: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.saturation}</span>
|
||||
</div>
|
||||
|
||||
{/* 色温 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">色温</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.temperature}
|
||||
onChange={(e) => update({ temperature: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.temperature}</span>
|
||||
</div>
|
||||
|
||||
{/* 色调 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">色调</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.tint}
|
||||
onChange={(e) => update({ tint: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.tint}</span>
|
||||
</div>
|
||||
|
||||
{/* 锐度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">锐度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.sharpness}
|
||||
onChange={(e) => update({ sharpness: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.sharpness}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览色块 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">效果预览</div>
|
||||
<div
|
||||
className="filter-preview-block"
|
||||
style={{
|
||||
background: PRESET_GRADIENTS[config.preset],
|
||||
filter: [
|
||||
`brightness(${100 + config.brightness}%)`,
|
||||
`contrast(${100 + config.contrast}%)`,
|
||||
`saturate(${100 + config.saturation}%)`,
|
||||
].join(" "),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="filter-footer">
|
||||
<button className="filter-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPanel;
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 绿幕抠像配置面板
|
||||
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types";
|
||||
import {
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
CHROMA_KEY_PRESET_LABELS,
|
||||
CHROMA_KEY_PRESET_COLORS,
|
||||
} from "../types";
|
||||
|
||||
interface GreenScreenPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: ChromaKeyConfig;
|
||||
onChange: (config: ChromaKeyConfig) => void;
|
||||
}
|
||||
|
||||
/** 预设列表 */
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = [
|
||||
"green",
|
||||
"blue",
|
||||
"red",
|
||||
"pure_green",
|
||||
"soft_green",
|
||||
];
|
||||
|
||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<ChromaKeyConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 选择颜色预设时同步更新 color 字段 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: ChromaKeyColorPreset) => {
|
||||
update({
|
||||
color_preset: preset,
|
||||
color: CHROMA_KEY_PRESET_COLORS[preset],
|
||||
});
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 自定义颜色变化时清除预设标记 */
|
||||
const handleColorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
update({ color: e.target.value });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="绿幕抠像"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="green-screen-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="green-header">
|
||||
<span className="green-header-label">启用绿幕抠像</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色预设 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">颜色预设</div>
|
||||
<div className="green-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<span
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">
|
||||
{CHROMA_KEY_PRESET_LABELS[p]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义颜色 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">自定义颜色</div>
|
||||
<div className="green-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="green-color-picker"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="green-color-hex"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div
|
||||
className="green-color-swatch"
|
||||
style={{ background: config.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参数调节 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">参数调节</div>
|
||||
|
||||
{/* 相似度 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">相似度</span>
|
||||
<span className="green-slider-value">{config.similarity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.similarity}
|
||||
onChange={(e) => update({ similarity: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大容忍的色差范围越广</div>
|
||||
</div>
|
||||
|
||||
{/* 边缘平滑 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">边缘平滑</span>
|
||||
<span className="green-slider-value">{config.blend}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.blend}
|
||||
onChange={(e) => update({ blend: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大边缘越柔和自然</div>
|
||||
</div>
|
||||
|
||||
{/* 溢色抑制 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">溢色抑制</span>
|
||||
<span className="green-slider-value">{config.spill}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.spill}
|
||||
onChange={(e) => update({ spill: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">去除边缘颜色溢出</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div
|
||||
className="green-preview-bg"
|
||||
style={{ background: config.color, opacity: 0.3 }}
|
||||
/>
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
</div>
|
||||
<div
|
||||
className="green-preview-edge"
|
||||
style={{
|
||||
borderColor: config.color,
|
||||
filter: `blur(${config.blend / 10}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="green-footer">
|
||||
<button className="green-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default GreenScreenPanel;
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 片头片尾配置面板 — Drawer 形式
|
||||
* 两个区块:片头(Intro)/ 片尾(Outro)
|
||||
* 每个区块支持:类型选择(无/视频/图片)、素材 URL、时长、过渡动画
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type {
|
||||
IntroOutroConfig,
|
||||
IntroOutroItem,
|
||||
IntroOutroKind,
|
||||
TransitionType,
|
||||
} from "../types";
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
|
||||
{ value: "none", label: "无", icon: "🚫" },
|
||||
{ value: "video", label: "视频", icon: "🎬" },
|
||||
{ value: "image", label: "图片", icon: "🖼️" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface IntroOutroPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: IntroOutroConfig;
|
||||
onChange: (config: IntroOutroConfig) => void;
|
||||
}
|
||||
|
||||
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 更新片头 ── */
|
||||
const handleIntroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, intro: { ...config.intro, ...partial } });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 更新片尾 ── */
|
||||
const handleOutroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, outro: { ...config.outro, ...partial } });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换片头类型 ── */
|
||||
const handleIntroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleIntroChange({ kind, url: kind === "none" ? undefined : "" });
|
||||
},
|
||||
[handleIntroChange],
|
||||
);
|
||||
|
||||
/* ── 切换片尾类型 ── */
|
||||
const handleOutroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleOutroChange({ kind, url: kind === "none" ? undefined : "" });
|
||||
},
|
||||
[handleOutroChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_INTRO_OUTRO });
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎬 片头片尾设置"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="intro-outro-panel-drawer"
|
||||
>
|
||||
{/* ═══ 片头区块 ═══ */}
|
||||
<div className="iop-block">
|
||||
<div className="iop-block-header">
|
||||
<span className="iop-block-icon">🎞️</span>
|
||||
<span className="iop-block-title">片头</span>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="iop-kind-row">
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleIntroKindChange(opt.value)}
|
||||
>
|
||||
<span className="iop-kind-icon">{opt.icon}</span>
|
||||
<span className="iop-kind-label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 视频/图片配置 */}
|
||||
{config.intro.kind !== "none" && (
|
||||
<>
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">
|
||||
{config.intro.kind === "video" ? "视频" : "图片"} URL
|
||||
</label>
|
||||
<input
|
||||
className="iop-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
config.intro.kind === "video"
|
||||
? "https://example.com/intro.mp4"
|
||||
: "https://example.com/intro.png"
|
||||
}
|
||||
value={config.intro.url ?? ""}
|
||||
onChange={(e) => handleIntroChange({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">显示时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={config.intro.duration}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({ duration: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{config.intro.duration}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">进入过渡动画</label>
|
||||
<select
|
||||
className="iop-select"
|
||||
value={config.intro.transition ?? "none"}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({
|
||||
transition: e.target.value as TransitionType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{config.intro.transition && config.intro.transition !== "none" && (
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">过渡时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.intro.transition_duration ?? 0.5}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({
|
||||
transition_duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片尾区块 ═══ */}
|
||||
<div className="iop-block">
|
||||
<div className="iop-block-header">
|
||||
<span className="iop-block-icon">🏁</span>
|
||||
<span className="iop-block-title">片尾</span>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="iop-kind-row">
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleOutroKindChange(opt.value)}
|
||||
>
|
||||
<span className="iop-kind-icon">{opt.icon}</span>
|
||||
<span className="iop-kind-label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 视频/图片配置 */}
|
||||
{config.outro.kind !== "none" && (
|
||||
<>
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">
|
||||
{config.outro.kind === "video" ? "视频" : "图片"} URL
|
||||
</label>
|
||||
<input
|
||||
className="iop-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
config.outro.kind === "video"
|
||||
? "https://example.com/outro.mp4"
|
||||
: "https://example.com/outro.png"
|
||||
}
|
||||
value={config.outro.url ?? ""}
|
||||
onChange={(e) => handleOutroChange({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">显示时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={config.outro.duration}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({ duration: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{config.outro.duration}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">退出过渡动画</label>
|
||||
<select
|
||||
className="iop-select"
|
||||
value={config.outro.transition ?? "none"}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({
|
||||
transition: e.target.value as TransitionType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{config.outro.transition && config.outro.transition !== "none" && (
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">过渡时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.outro.transition_duration ?? 0.5}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({
|
||||
transition_duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="iop-footer">
|
||||
<button className="iop-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntroOutroPanel;
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* 画中画配置面板 — Drawer 形式
|
||||
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
|
||||
*/
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type {
|
||||
PipConfig,
|
||||
PipLayer,
|
||||
PipGridPosition,
|
||||
PipAnimType,
|
||||
PipSlideDirection,
|
||||
} from "../types";
|
||||
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 九宫格位置 → 百分比坐标映射 */
|
||||
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
||||
top_left: { x: 5, y: 5 },
|
||||
top_center: { x: 37.5, y: 5 },
|
||||
top_right: { x: 70, y: 5 },
|
||||
center_left: { x: 5, y: 37.5 },
|
||||
center: { x: 37.5, y: 37.5 },
|
||||
center_right: { x: 70, y: 37.5 },
|
||||
bottom_left: { x: 5, y: 70 },
|
||||
bottom_center: { x: 37.5, y: 70 },
|
||||
bottom_right: { x: 70, y: 70 },
|
||||
};
|
||||
|
||||
/** 九宫格位置选项 */
|
||||
const GRID_POSITIONS: PipGridPosition[] = [
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"center_left",
|
||||
"center",
|
||||
"center_right",
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
];
|
||||
|
||||
/** 入场动画选项 */
|
||||
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade_in", label: "淡入" },
|
||||
{ value: "slide_in", label: "滑入" },
|
||||
];
|
||||
|
||||
/** 滑入方向选项 */
|
||||
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
||||
{ value: "left", label: "← 左" },
|
||||
{ value: "right", label: "→ 右" },
|
||||
{ value: "up", label: "↑ 上" },
|
||||
{ value: "down", label: "↓ 下" },
|
||||
];
|
||||
|
||||
/** 预览图层颜色池 */
|
||||
const LAYER_COLORS = [
|
||||
"rgba(22,119,255,0.5)",
|
||||
"rgba(82,196,26,0.5)",
|
||||
"rgba(250,173,20,0.5)",
|
||||
"rgba(255,77,79,0.5)",
|
||||
"rgba(114,46,209,0.5)",
|
||||
"rgba(19,194,194,0.5)",
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
|
||||
interface PipConfigPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: PipConfig;
|
||||
onChange: (config: PipConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/* ──────────── 辅助函数 ──────────── */
|
||||
|
||||
let layerIdCounter = 0;
|
||||
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`;
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
/** 当前选中图层 ID */
|
||||
const [selectedId, setSelectedId] = React.useState<string>("");
|
||||
|
||||
/** 当前选中图层 */
|
||||
const selectedLayer = useMemo(
|
||||
() => config.layers.find((l) => l.id === selectedId) ?? null,
|
||||
[config.layers, selectedId],
|
||||
);
|
||||
|
||||
/* ── 添加图层 ── */
|
||||
const handleAddLayer = useCallback(() => {
|
||||
const newLayer: PipLayer = {
|
||||
...DEFAULT_PIP_LAYER,
|
||||
id: genLayerId(),
|
||||
name: `图层 ${config.layers.length + 1}`,
|
||||
z_index: config.layers.length + 1,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
layers: [...config.layers, newLayer],
|
||||
});
|
||||
setSelectedId(newLayer.id);
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 删除图层 ── */
|
||||
const handleDeleteLayer = useCallback(
|
||||
(id: string) => {
|
||||
const newLayers = config.layers.filter((l) => l.id !== id);
|
||||
onChange({ ...config, layers: newLayers });
|
||||
if (selectedId === id) {
|
||||
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "");
|
||||
}
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
);
|
||||
|
||||
/* ── 更新图层 ── */
|
||||
const updateLayer = useCallback(
|
||||
(id: string, partial: Partial<PipLayer>) => {
|
||||
onChange({
|
||||
...config,
|
||||
layers: config.layers.map((l) =>
|
||||
l.id === id ? { ...l, ...partial } : l,
|
||||
),
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换启用 ── */
|
||||
const handleEnableToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
onChange({ ...config, enabled: checked });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_PIP_CONFIG });
|
||||
setSelectedId("");
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 九宫格点击 ── */
|
||||
const handleGridClick = useCallback(
|
||||
(pos: PipGridPosition) => {
|
||||
if (!selectedLayer) return;
|
||||
const coords = GRID_POSITION_MAP[pos];
|
||||
updateLayer(selectedLayer.id, {
|
||||
grid_position: pos,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
});
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
/* ── 宽高比锁定 ── */
|
||||
const handleWidthChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return;
|
||||
const partial: Partial<PipLayer> = { width: val };
|
||||
if (selectedLayer.aspect_lock) {
|
||||
// 保持宽高比 1:1(百分比相同)
|
||||
partial.height = val;
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial);
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return;
|
||||
const partial: Partial<PipLayer> = { height: val };
|
||||
if (selectedLayer.aspect_lock) {
|
||||
partial.width = val;
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial);
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🖼️ 画中画设置"
|
||||
placement="right"
|
||||
width={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="pip-config-panel-drawer"
|
||||
>
|
||||
{/* ═══ 顶部工具栏 ═══ */}
|
||||
<div className="pip-toolbar">
|
||||
<div className="pip-toolbar-left">
|
||||
<button className="pip-add-btn" onClick={handleAddLayer}>
|
||||
+ 添加图层
|
||||
</button>
|
||||
</div>
|
||||
<div className="pip-enable-switch">
|
||||
<span>启用</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={handleEnableToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
||||
<div className="pip-body">
|
||||
{/* 左侧图层列表 */}
|
||||
<div className="pip-layer-list">
|
||||
{config.layers.length === 0 ? (
|
||||
<div className="pip-layer-empty">暂无图层,点击上方添加</div>
|
||||
) : (
|
||||
config.layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(layer.id)}
|
||||
>
|
||||
{layer.thumbnail_url || layer.material_url ? (
|
||||
<img
|
||||
className="pip-layer-thumb"
|
||||
src={layer.thumbnail_url || layer.material_url}
|
||||
alt={layer.name}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="pip-layer-thumb"
|
||||
style={{
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="pip-layer-name">{layer.name}</span>
|
||||
<button
|
||||
className="pip-layer-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteLayer(layer.id);
|
||||
}}
|
||||
title="删除图层"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧配置区 */}
|
||||
<div className="pip-config-area">
|
||||
{!selectedLayer ? (
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── 迷你预览 ── */}
|
||||
<div className="pip-preview-box">
|
||||
{config.layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${layer.x}%`,
|
||||
top: `${layer.y}%`,
|
||||
width: `${layer.width}%`,
|
||||
height: `${layer.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: layer.opacity / 100,
|
||||
borderRadius: `${layer.border_radius}%`,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 素材类型 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, { material_type: "image" })
|
||||
}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, { material_type: "video" })
|
||||
}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 素材 URL ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{selectedLayer.material_type === "image" ? "图片" : "视频"}{" "}
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
selectedLayer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={selectedLayer.material_url}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
material_url: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 位置:九宫格 + 坐标 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div
|
||||
style={{ display: "flex", gap: 16, alignItems: "flex-start" }}
|
||||
>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => handleGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.x}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
x: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.y}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
y: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 尺寸 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
|
||||
宽
|
||||
</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={selectedLayer.width}
|
||||
onChange={(e) => handleWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.width}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
|
||||
高
|
||||
</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={selectedLayer.height}
|
||||
onChange={(e) => handleHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.height}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
aspect_lock: !selectedLayer.aspect_lock,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="pip-lock-icon">
|
||||
{selectedLayer.aspect_lock ? "🔒" : "🔓"}
|
||||
</span>
|
||||
<span>
|
||||
{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 圆角 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={selectedLayer.border_radius}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
border_radius: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.border_radius}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 透明度 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.opacity}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
opacity: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.opacity}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 时间 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={selectedLayer.start_time}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
start_time: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={selectedLayer.duration}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 入场动画 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={selectedLayer.animation}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
animation: e.target.value as PipAnimType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{selectedLayer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={selectedLayer.slide_direction}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
slide_direction: e.target.value as PipSlideDirection,
|
||||
})
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="pip-footer">
|
||||
<button className="pip-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipConfigPanel;
|
||||
@@ -4,25 +4,13 @@
|
||||
* 封面右侧竖排4个方案按钮
|
||||
*/
|
||||
import React from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types";
|
||||
|
||||
interface CoverScheme {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 片段调速面板 — Drawer 形式
|
||||
* 速度滑块(0.25x ~ 4x)+ 预设快捷按钮 + 音调修正开关
|
||||
* 支持应用到当前片段 / 所有片段
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Slider } from "antd";
|
||||
import type { SpeedConfig } from "../types";
|
||||
import { DEFAULT_SPEED } from "../types";
|
||||
|
||||
/* ──────────── 预设速度 ──────────── */
|
||||
const SPEED_PRESETS: { rate: number; label: string }[] = [
|
||||
{ rate: 0.5, label: "0.5x" },
|
||||
{ rate: 1.0, label: "1x" },
|
||||
{ rate: 1.5, label: "1.5x" },
|
||||
{ rate: 2.0, label: "2x" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface SpeedPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前片段调速配置 */
|
||||
config: SpeedConfig;
|
||||
onChange: (config: SpeedConfig) => void;
|
||||
/** 应用到所有片段 */
|
||||
onApplyAll?: (config: SpeedConfig) => void;
|
||||
}
|
||||
|
||||
const SpeedPanel: React.FC<SpeedPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
onApplyAll,
|
||||
}) => {
|
||||
/* ── 修改速度 ── */
|
||||
const handleChangeRate = useCallback(
|
||||
(rate: number) => {
|
||||
onChange({ ...config, rate });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换音调修正 ── */
|
||||
const handleTogglePitch = useCallback(() => {
|
||||
onChange({ ...config, pitchCorrection: !config.pitchCorrection });
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 选择预设 ── */
|
||||
const handlePreset = useCallback(
|
||||
(rate: number) => {
|
||||
onChange({ ...config, rate });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 应用到所有片段 ── */
|
||||
const handleApplyAll = useCallback(() => {
|
||||
onApplyAll?.(config);
|
||||
}, [config, onApplyAll]);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_SPEED });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 速度描述文字 ── */
|
||||
const speedLabel =
|
||||
config.rate < 1
|
||||
? "慢速(慢动作)"
|
||||
: config.rate === 1
|
||||
? "原速"
|
||||
: config.rate < 2
|
||||
? "快速"
|
||||
: "极速";
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="⚡ 片段调速"
|
||||
placement="right"
|
||||
width={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="speed-panel-drawer"
|
||||
>
|
||||
{/* ── 速度滑块 ── */}
|
||||
<div className="sp-speed-section">
|
||||
<div className="sp-speed-header">
|
||||
<span className="sp-speed-label">播放速度</span>
|
||||
<span className="sp-speed-value">{config.rate.toFixed(2)}x</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.05}
|
||||
value={config.rate}
|
||||
onChange={handleChangeRate}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
|
||||
/>
|
||||
<div className="sp-speed-marks">
|
||||
<span>0.25x</span>
|
||||
<span>1x</span>
|
||||
<span>2x</span>
|
||||
<span>4x</span>
|
||||
</div>
|
||||
<div className="sp-speed-desc">{speedLabel}</div>
|
||||
</div>
|
||||
|
||||
{/* ── 预设快捷按钮 ── */}
|
||||
<div className="sp-presets">
|
||||
<div className="sp-presets-label">快捷预设</div>
|
||||
<div className="sp-presets-row">
|
||||
{SPEED_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.rate}
|
||||
className={`sp-preset-btn${Math.abs(config.rate - p.rate) < 0.01 ? " active" : ""}`}
|
||||
onClick={() => handlePreset(p.rate)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 音调修正开关 ── */}
|
||||
<div className="sp-pitch-section">
|
||||
<div className="sp-pitch-info">
|
||||
<span className="sp-pitch-label">音调修正</span>
|
||||
<span className="sp-pitch-desc">
|
||||
{config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ep-toggle${config.pitchCorrection ? " active" : ""}`}
|
||||
onClick={handleTogglePitch}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="sp-footer">
|
||||
<button className="sp-reset-btn" onClick={handleReset}>
|
||||
重置原速
|
||||
</button>
|
||||
{onApplyAll && (
|
||||
<button className="sp-apply-all-btn" onClick={handleApplyAll}>
|
||||
应用到所有片段
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeedPanel;
|
||||
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* 贴纸配置面板
|
||||
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
|
||||
*/
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type {
|
||||
StickerConfig,
|
||||
StickerItem,
|
||||
StickerType,
|
||||
TextStickerPreset,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_STICKER_ITEM,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "../types";
|
||||
|
||||
interface StickerPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: StickerConfig;
|
||||
onChange: (config: StickerConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/** 常用 emoji 素材 */
|
||||
const EMOJI_LIST = [
|
||||
"😀",
|
||||
"😂",
|
||||
"🥰",
|
||||
"😎",
|
||||
"🤩",
|
||||
"😱",
|
||||
"🤔",
|
||||
"😴",
|
||||
"🥳",
|
||||
"😍",
|
||||
"❤️",
|
||||
"🔥",
|
||||
"⭐",
|
||||
"✨",
|
||||
"💯",
|
||||
"👍",
|
||||
"👏",
|
||||
"🎉",
|
||||
"🎵",
|
||||
"💪",
|
||||
"📌",
|
||||
"💡",
|
||||
"🎯",
|
||||
"✅",
|
||||
"❌",
|
||||
"⬆️",
|
||||
"⬇️",
|
||||
"➡️",
|
||||
"⭕",
|
||||
"🔔",
|
||||
];
|
||||
|
||||
/** 文字花字预设对应的 CSS 样式预览 */
|
||||
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
|
||||
normal: { color: "#fff", textShadow: "none" },
|
||||
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
|
||||
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
|
||||
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
|
||||
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
|
||||
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
|
||||
gradient: {
|
||||
color: "transparent",
|
||||
background: "linear-gradient(90deg,#f093fb,#f5576c)",
|
||||
WebkitBackgroundClip: "text",
|
||||
},
|
||||
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
|
||||
};
|
||||
|
||||
/** 生成唯一 ID */
|
||||
const genId = () =>
|
||||
`sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<StickerType>("emoji");
|
||||
|
||||
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null;
|
||||
|
||||
/** 更新单个贴纸 */
|
||||
const updateItem = useCallback(
|
||||
(id: string, partial: Partial<StickerItem>) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.map((s) =>
|
||||
s.id === id ? { ...s, ...partial } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/** 添加贴纸 */
|
||||
const addSticker = useCallback(
|
||||
(type: StickerType, content: string) => {
|
||||
const newItem: StickerItem = {
|
||||
...DEFAULT_STICKER_ITEM,
|
||||
id: genId(),
|
||||
type,
|
||||
content,
|
||||
duration: totalDuration > 0 ? totalDuration : 5,
|
||||
z_index: config.items.length + 1,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
items: [...config.items, newItem],
|
||||
});
|
||||
setSelectedId(newItem.id);
|
||||
},
|
||||
[config, onChange, totalDuration],
|
||||
);
|
||||
|
||||
/** 删除贴纸 */
|
||||
const removeSticker = useCallback(
|
||||
(id: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.filter((s) => s.id !== id),
|
||||
});
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
);
|
||||
|
||||
/** 重置所有 */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled });
|
||||
setSelectedId(null);
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 文字花字输入 */
|
||||
const [textInput, setTextInput] = useState("");
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="贴纸"
|
||||
placement="right"
|
||||
width={460}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="sticker-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="sticker-header">
|
||||
<span className="sticker-header-label">启用贴纸</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => onChange({ ...config, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型 Tab */}
|
||||
<div className="sticker-tabs">
|
||||
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`sticker-tab${activeTab === t ? " active" : ""}`}
|
||||
onClick={() => setActiveTab(t)}
|
||||
>
|
||||
{t === "emoji"
|
||||
? "表情贴纸"
|
||||
: t === "image"
|
||||
? "图片贴纸"
|
||||
: "文字花字"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容区 */}
|
||||
<div className="sticker-tab-content">
|
||||
{/* Emoji 素材库 */}
|
||||
{activeTab === "emoji" && (
|
||||
<div className="sticker-emoji-grid">
|
||||
{EMOJI_LIST.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
className="sticker-emoji-btn"
|
||||
onClick={() => addSticker("emoji", emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片贴纸 */}
|
||||
{activeTab === "image" && (
|
||||
<div className="sticker-image-input">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-url-input"
|
||||
placeholder="输入图片 URL 添加贴纸..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value.trim()) {
|
||||
addSticker("image", e.currentTarget.value.trim());
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="sticker-url-add-btn"
|
||||
onClick={() => {
|
||||
const input =
|
||||
document.querySelector<HTMLInputElement>(
|
||||
".sticker-url-input",
|
||||
);
|
||||
if (input?.value.trim()) {
|
||||
addSticker("image", input.value.trim());
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文字花字 */}
|
||||
{activeTab === "text" && (
|
||||
<div className="sticker-text-section">
|
||||
<div className="sticker-text-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-text-input"
|
||||
placeholder="输入文字内容..."
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="sticker-text-add-btn"
|
||||
disabled={!textInput.trim()}
|
||||
onClick={() => {
|
||||
if (textInput.trim()) {
|
||||
addSticker("text", textInput.trim());
|
||||
setTextInput("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="sticker-text-presets">
|
||||
<div className="sticker-preset-title">花字预设预览</div>
|
||||
<div className="sticker-preset-grid">
|
||||
{(
|
||||
Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]
|
||||
).map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="sticker-preset-preview"
|
||||
style={{
|
||||
...TEXT_PRESET_STYLES[p],
|
||||
background:
|
||||
p === "bubble"
|
||||
? "rgba(0,0,0,0.5)"
|
||||
: p === "gradient"
|
||||
? "linear-gradient(90deg,#f093fb,#f5576c)"
|
||||
: "#1a1a2e",
|
||||
}}
|
||||
>
|
||||
<span style={TEXT_PRESET_STYLES[p]}>示例</span>
|
||||
<div className="sticker-preset-name">
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 已添加贴纸列表 */}
|
||||
{config.items.length > 0 && (
|
||||
<div className="sticker-list-section">
|
||||
<div className="sticker-section-title">
|
||||
已添加贴纸 ({config.items.length})
|
||||
</div>
|
||||
<div className="sticker-list">
|
||||
{config.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<span className="sticker-list-icon">
|
||||
{item.type === "emoji"
|
||||
? item.content
|
||||
: item.type === "text"
|
||||
? "T"
|
||||
: "🖼"}
|
||||
</span>
|
||||
<span className="sticker-list-name">
|
||||
{item.type === "text"
|
||||
? item.content.slice(0, 10)
|
||||
: item.type === "emoji"
|
||||
? "表情贴纸"
|
||||
: "图片贴纸"}
|
||||
</span>
|
||||
<button
|
||||
className="sticker-list-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSticker(item.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选中贴纸的属性编辑 */}
|
||||
{selectedSticker && (
|
||||
<div className="sticker-props-section">
|
||||
<div className="sticker-section-title">属性调整</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 X</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.x}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, { x: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.x}%</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 Y</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.y}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, { y: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.y}%</span>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">大小</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={5}
|
||||
max={50}
|
||||
value={selectedSticker.width}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
width: Number(e.target.value),
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.width}%</span>
|
||||
</div>
|
||||
|
||||
{/* 旋转 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">旋转</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={selectedSticker.rotation}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
rotation: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.rotation}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">透明度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.opacity}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
opacity: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.opacity}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 时间 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">开始</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={selectedSticker.start_time}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
start_time: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-label">时长</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={selectedSticker.duration}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{selectedSticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={selectedSticker.text_preset}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
text_preset: e.target.value as TextStickerPreset,
|
||||
})
|
||||
}
|
||||
>
|
||||
{(
|
||||
Object.keys(
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
) as TextStickerPreset[]
|
||||
).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={selectedSticker.font_size}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
font_size: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.font_size}px
|
||||
</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={selectedSticker.text_color}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
text_color: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${selectedSticker.x}%`,
|
||||
top: `${selectedSticker.y}%`,
|
||||
width: `${selectedSticker.width}%`,
|
||||
height: `${selectedSticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
|
||||
opacity: selectedSticker.opacity / 100,
|
||||
fontSize:
|
||||
selectedSticker.type === "text"
|
||||
? `${selectedSticker.font_size}px`
|
||||
: undefined,
|
||||
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{selectedSticker.type === "emoji" && selectedSticker.content}
|
||||
{selectedSticker.type === "text" && selectedSticker.content}
|
||||
{selectedSticker.type === "image" && (
|
||||
<img
|
||||
src={selectedSticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="sticker-footer">
|
||||
<button className="sticker-reset-btn" onClick={handleReset}>
|
||||
清空所有贴纸
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default StickerPanel;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 字幕样式配置面板 — Drawer 形式
|
||||
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
|
||||
*/
|
||||
import React from "react";
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd";
|
||||
import type { Color } from "antd/es/color-picker";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type SubtitleMode = "manual" | "asr";
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
/** 是否启用字幕 */
|
||||
enabled: boolean;
|
||||
/** 字幕模式:手动输入 / ASR 自动识别 */
|
||||
mode: SubtitleMode;
|
||||
/** 字体大小 px */
|
||||
fontSize: number;
|
||||
/** 字体颜色 */
|
||||
fontColor: string;
|
||||
/** 描边 */
|
||||
stroke: boolean;
|
||||
/** 阴影 */
|
||||
shadow: boolean;
|
||||
/** 字幕位置 */
|
||||
position: "top" | "center" | "bottom";
|
||||
/** 字体 */
|
||||
font: string;
|
||||
/** 动画效果 */
|
||||
animation: string;
|
||||
/** ASR 语言(仅 ASR 模式) */
|
||||
asrLanguage: "zh" | "en";
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
};
|
||||
|
||||
/* ──────────── 选项常量 ──────────── */
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
];
|
||||
|
||||
const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
];
|
||||
|
||||
const ASR_LANGUAGE_OPTIONS = [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: SubtitleStyleConfig;
|
||||
onChange: (config: SubtitleStyleConfig) => void;
|
||||
}
|
||||
|
||||
const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = (partial: Partial<SubtitleStyleConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="💬 字幕样式配置"
|
||||
placement="right"
|
||||
width={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
>
|
||||
{/* ── 字幕开关 ── */}
|
||||
<div className="sub-field">
|
||||
<div className="sub-toggle-row">
|
||||
<span className="sub-label">启用字幕</span>
|
||||
<div
|
||||
className={`ep-toggle${config.enabled ? " active" : ""}`}
|
||||
onClick={() => update({ enabled: !config.enabled })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* ── 模式切换 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字幕来源</label>
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "manual" })}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "asr" })}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── ASR 语言(仅 ASR 模式) ── */}
|
||||
{config.mode === "asr" && (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">识别语言</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.asrLanguage}
|
||||
onChange={(v) => update({ asrLanguage: v })}
|
||||
options={ASR_LANGUAGE_OPTIONS}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 字体大小 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">
|
||||
字体大小 <span className="sub-value">{config.fontSize}px</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={12}
|
||||
max={48}
|
||||
value={config.fontSize}
|
||||
onChange={(v) => update({ fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字体颜色 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体颜色</label>
|
||||
<div className="sub-color-row">
|
||||
<ColorPicker
|
||||
value={config.fontColor}
|
||||
onChange={(_color: Color, hex: string) =>
|
||||
update({ fontColor: hex })
|
||||
}
|
||||
showText
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 字体 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.font}
|
||||
onChange={(v) => update({ font: v })}
|
||||
options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字幕位置 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">位置</label>
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
update({
|
||||
position: opt.value as SubtitleStyleConfig["position"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 描边 / 阴影 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">效果</label>
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
|
||||
onClick={() => update({ stroke: !config.stroke })}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
|
||||
onClick={() => update({ shadow: !config.shadow })}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 动画 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">动画效果</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.animation}
|
||||
onChange={(v) => update({ animation: v })}
|
||||
options={ANIMATION_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 预览 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">预览</label>
|
||||
<div className="sub-preview-box">
|
||||
<span
|
||||
className="sub-preview-text"
|
||||
style={{
|
||||
fontSize: `${Math.min(config.fontSize, 28)}px`,
|
||||
color: config.fontColor,
|
||||
fontFamily: config.font,
|
||||
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
|
||||
textShadow: config.shadow
|
||||
? "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
这是一段字幕预览
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtitleStylePanel;
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
|
||||
* 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单
|
||||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||||
* "+" 卡片 → 类型+时长选择器
|
||||
*
|
||||
* 裁剪交互:
|
||||
* - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点
|
||||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||||
*/
|
||||
import React, {
|
||||
useState,
|
||||
@@ -11,7 +16,8 @@ import React, {
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[];
|
||||
@@ -21,6 +27,26 @@ interface TimelinePanelProps {
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void;
|
||||
onClipRemove: (clipId: string) => void;
|
||||
onAddClip: (type: ClipType, duration: number) => void;
|
||||
/** 裁剪更新:调整片段的 trim_config 和 duration */
|
||||
onClipTrim?: (
|
||||
clipId: string,
|
||||
trimConfig: TrimConfig,
|
||||
newDuration: number,
|
||||
) => void;
|
||||
/** 在指定位置分割片段 */
|
||||
onClipSplit?: (clipId: string, splitRatio: number) => void;
|
||||
/** 恢复片段原始长度 */
|
||||
onClipResetTrim?: (clipId: string) => void;
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime?: number;
|
||||
/** 缩放:每秒像素数 */
|
||||
pixelsPerSecond?: number;
|
||||
/** 缩放变更回调 */
|
||||
onZoomChange?: (pps: number) => void;
|
||||
/** 播放头跳转回调 */
|
||||
onSeek?: (time: number) => void;
|
||||
/** 总时长(秒),可选(默认由 clips 计算) */
|
||||
totalDuration?: number;
|
||||
}
|
||||
|
||||
/** 片段类型图标 */
|
||||
@@ -35,6 +61,25 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
/** 裁剪拖拽方向 */
|
||||
type TrimDirection = "left" | "right";
|
||||
|
||||
/** 裁剪拖拽状态 */
|
||||
interface TrimDragState {
|
||||
clipId: string;
|
||||
direction: TrimDirection;
|
||||
startX: number;
|
||||
originalTrim: TrimConfig;
|
||||
originalDuration: number;
|
||||
}
|
||||
|
||||
/** 右键菜单状态 */
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
clipId: string;
|
||||
}
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
@@ -43,6 +88,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onClipReorder,
|
||||
onClipRemove,
|
||||
onAddClip,
|
||||
onClipTrim,
|
||||
onClipSplit,
|
||||
onClipResetTrim,
|
||||
currentTime = 0,
|
||||
pixelsPerSecond = 40,
|
||||
onZoomChange,
|
||||
onSeek,
|
||||
totalDuration: totalDurationProp,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
@@ -55,6 +108,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
right: 0,
|
||||
});
|
||||
|
||||
/* ── 裁剪拖拽状态 ── */
|
||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null);
|
||||
const [trimPreview, setTrimPreview] = useState<{
|
||||
clipId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
duration: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null);
|
||||
|
||||
/* ── 播放头拖拽状态 ── */
|
||||
const [playheadDragging, setPlayheadDragging] = useState(false);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
@@ -66,7 +141,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
[currentMode],
|
||||
);
|
||||
|
||||
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
|
||||
/* ── 默认添加类型:跟随模式 ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice";
|
||||
if (currentMode === "pip") return "pip";
|
||||
@@ -83,33 +158,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setAddType(defaultAddType);
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType]);
|
||||
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
|
||||
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
|
||||
const GAP = 6; // 面板与"+"卡片的间距
|
||||
|
||||
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
|
||||
/* ── 面板尺寸 ── */
|
||||
const PICKER_W = 240;
|
||||
const GAP = 6;
|
||||
|
||||
/* ── 计算 picker 初始位置 ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return;
|
||||
const rect = addCardRef.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
|
||||
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
|
||||
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
|
||||
const roughHeight = 180;
|
||||
let top = rect.top - GAP - roughHeight;
|
||||
if (top < 8) top = 8;
|
||||
|
||||
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
|
||||
let right = vw - rect.right;
|
||||
if (rect.right - PICKER_W < 8) {
|
||||
right = vw - PICKER_W - 8;
|
||||
}
|
||||
|
||||
setPickerPos({ top, right });
|
||||
}, []);
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showAddPicker) {
|
||||
// 打开面板时,默认选中当前模式下的第一个可用类型
|
||||
const defaultType =
|
||||
currentMode === "pip"
|
||||
? "pip"
|
||||
@@ -122,35 +192,27 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setShowAddPicker((v) => !v);
|
||||
};
|
||||
|
||||
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
|
||||
/* ── 渲染后精确边界校正 ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
|
||||
const pickerEl = pickerRef.current;
|
||||
const addRect = addCardRef.current.getBoundingClientRect();
|
||||
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
|
||||
const pickerH = pickerEl.offsetHeight;
|
||||
const vh = window.innerHeight;
|
||||
const vw = window.innerWidth;
|
||||
|
||||
/* 默认:面板在"+"按钮上方 */
|
||||
let top = addRect.top - GAP - pickerH;
|
||||
|
||||
/* 上方空间也不够(极端情况)→ 翻转到下方 */
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + GAP;
|
||||
/* 下方也溢出 → clamp */
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH;
|
||||
if (top < 8) top = 8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
|
||||
let right = vw - addRect.right;
|
||||
const pickerRect = pickerEl.getBoundingClientRect();
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - PICKER_W - 8;
|
||||
}
|
||||
|
||||
setPickerPos({ top, right });
|
||||
}, [showAddPicker]);
|
||||
|
||||
@@ -167,6 +229,70 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showAddPicker]);
|
||||
|
||||
/* ── 点击外部关闭右键菜单 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
contextMenuRef.current &&
|
||||
!contextMenuRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setContextMenu(null);
|
||||
}
|
||||
};
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [contextMenu]);
|
||||
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? 40;
|
||||
const totalDuration =
|
||||
totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0);
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const trackEl = trackRef.current;
|
||||
if (!trackEl) return;
|
||||
const rect = trackEl.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + trackEl.scrollLeft;
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration));
|
||||
onSeek?.(Math.round(time * 10) / 10);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setPlayheadDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [playheadDragging, pps, totalDuration, onSeek]);
|
||||
|
||||
/* ── 标尺点击跳转播放头 ── */
|
||||
const handleRulerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration));
|
||||
onSeek?.(Math.round(time * 10) / 10);
|
||||
},
|
||||
[pps, totalDuration, onSeek],
|
||||
);
|
||||
|
||||
/* ── 播放头拖拽开始 ── */
|
||||
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPlayheadDragging(true);
|
||||
}, []);
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = () => {
|
||||
onAddClip(addType, addDuration);
|
||||
@@ -175,6 +301,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
// 如果正在裁剪拖拽,不允许排序拖拽
|
||||
if (trimDrag) return;
|
||||
dragRef.current = idx;
|
||||
setDragIdx(idx);
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx));
|
||||
@@ -210,8 +338,132 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/* ── 裁剪手柄拖拽 ── */
|
||||
const handleTrimHandleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const clip = clips.find((c) => c.id === clipId);
|
||||
if (!clip) return;
|
||||
|
||||
const trim: TrimConfig = clip.trim_config ?? {
|
||||
start_time: 0,
|
||||
end_time: clip.duration,
|
||||
original_duration: clip.duration,
|
||||
};
|
||||
|
||||
setTrimDrag({
|
||||
clipId,
|
||||
direction,
|
||||
startX: e.clientX,
|
||||
originalTrim: { ...trim },
|
||||
originalDuration: clip.duration,
|
||||
});
|
||||
},
|
||||
[clips],
|
||||
);
|
||||
|
||||
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!trimDrag) return;
|
||||
|
||||
const PX_PER_SECOND = pixelsPerSecond ?? 40; // 与缩放级别同步
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - trimDrag.startX;
|
||||
const dtSec = dx / PX_PER_SECOND;
|
||||
const clip = clips.find((c) => c.id === trimDrag.clipId);
|
||||
if (!clip) return;
|
||||
|
||||
const origTrim = trimDrag.originalTrim;
|
||||
const origDur = origTrim.original_duration ?? trimDrag.originalDuration;
|
||||
let newStart = origTrim.start_time;
|
||||
let newEnd = origTrim.end_time;
|
||||
|
||||
if (trimDrag.direction === "left") {
|
||||
// 左手柄:调整入点
|
||||
newStart = Math.max(
|
||||
0,
|
||||
Math.min(origTrim.start_time + dtSec, newEnd - 1),
|
||||
);
|
||||
} else {
|
||||
// 右手柄:调整出点
|
||||
newEnd = Math.max(
|
||||
origTrim.start_time + 1,
|
||||
Math.min(origTrim.end_time + dtSec, origDur),
|
||||
);
|
||||
}
|
||||
|
||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10;
|
||||
|
||||
setTrimPreview({
|
||||
clipId: trimDrag.clipId,
|
||||
startTime: Math.round(newStart * 10) / 10,
|
||||
endTime: Math.round(newEnd * 10) / 10,
|
||||
duration: newDuration,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
||||
const newTrim: TrimConfig = {
|
||||
start_time: trimPreview.startTime,
|
||||
end_time: trimPreview.endTime,
|
||||
original_duration:
|
||||
trimDrag.originalTrim.original_duration ??
|
||||
trimDrag.originalDuration,
|
||||
};
|
||||
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration);
|
||||
}
|
||||
setTrimDrag(null);
|
||||
setTrimPreview(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]);
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/* ── 右键菜单操作 ── */
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
if (onClipSplit) {
|
||||
onClipSplit(contextMenu.clipId, 0.5); // 在中间分割
|
||||
}
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipSplit]);
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
if (onClipResetTrim) {
|
||||
onClipResetTrim(contextMenu.clipId);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipResetTrim]);
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
onClipRemove(contextMenu.clipId);
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipRemove]);
|
||||
|
||||
/* ── 时间标尺 ── */
|
||||
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
|
||||
const trackWidth = Math.max(totalDuration * pps, 300);
|
||||
const rulerMarks: number[] = [];
|
||||
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
@@ -224,6 +476,11 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/** 格式化裁剪时间(精确到0.1秒) */
|
||||
const formatTrimTime = (sec: number) => {
|
||||
return `${sec.toFixed(1)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
@@ -235,6 +492,33 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-timeline-actions">
|
||||
{/* 缩放控件 */}
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={10}
|
||||
max={120}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="ep-zoom-label">{pps}px/s</span>
|
||||
</div>
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
onClick={() => {
|
||||
@@ -260,21 +544,13 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 时间标尺 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-time-ruler">
|
||||
<div
|
||||
className="ep-time-ruler-inner"
|
||||
style={{ width: Math.max(clips.length * 108, 300) }}
|
||||
>
|
||||
<div className="ep-time-ruler" onClick={handleRulerClick}>
|
||||
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
|
||||
{rulerMarks.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="ep-time-mark"
|
||||
style={{
|
||||
left:
|
||||
totalDuration > 0
|
||||
? `${(t / totalDuration) * clips.length * 108}px`
|
||||
: `${t * 20}px`,
|
||||
}}
|
||||
style={{ left: `${t * pps}px` }}
|
||||
>
|
||||
{t}s
|
||||
</span>
|
||||
@@ -285,52 +561,151 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
|
||||
<div
|
||||
className="ep-clip-track"
|
||||
ref={trackRef}
|
||||
onDragOver={handleEmptyDragOver}
|
||||
>
|
||||
{/* 播放头 */}
|
||||
{currentMode !== "one_take" && totalDuration > 0 && (
|
||||
<div
|
||||
className="ep-playhead"
|
||||
style={{ left: currentTime * pps }}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
<div className="ep-playhead-handle" />
|
||||
</div>
|
||||
)}
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
<div className="ep-track-empty-icon">🎬</div>
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
>
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">
|
||||
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
|
||||
</div>
|
||||
clips.map((clip, idx) => {
|
||||
/* 转场指示器 */
|
||||
const trans = clip.transition;
|
||||
const showTransition = idx > 0 && trans && trans.type !== "none";
|
||||
const transOpt = showTransition
|
||||
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
|
||||
: undefined;
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">{clip.duration}s</span>
|
||||
</div>
|
||||
/* 速度徽章 */
|
||||
const speed = clip.speed;
|
||||
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01;
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
/* 裁剪状态 */
|
||||
const hasTrim = !!clip.trim_config;
|
||||
const isHovered = hoveredClipId === clip.id;
|
||||
|
||||
return (
|
||||
<React.Fragment key={clip.id}>
|
||||
{/* 转场指示器 */}
|
||||
{showTransition && transOpt && (
|
||||
<div
|
||||
className="ep-transition-indicator"
|
||||
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
|
||||
>
|
||||
<span className="ep-trans-icon">{transOpt.icon}</span>
|
||||
<span className="ep-trans-duration">
|
||||
{trans!.duration.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
|
||||
style={{ width: Math.max(clip.duration * pps, 60) }}
|
||||
draggable={!trimDrag}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, clip.id)}
|
||||
onMouseEnter={() => setHoveredClipId(clip.id)}
|
||||
onMouseLeave={() => setHoveredClipId(null)}
|
||||
>
|
||||
{/* 左裁剪手柄 */}
|
||||
{isHovered && onClipTrim && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-left"
|
||||
onMouseDown={(e) =>
|
||||
handleTrimHandleMouseDown(e, clip.id, "left")
|
||||
}
|
||||
title="拖动调整入点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">
|
||||
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
|
||||
</div>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">
|
||||
{clip.duration}s
|
||||
{hasTrim && (
|
||||
<span className="ep-trim-indicator" title="已裁剪">
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 速度徽章 */}
|
||||
{showSpeed && (
|
||||
<span className="ep-speed-badge">
|
||||
{speed!.rate.toFixed(1)}x
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 裁剪徽章 */}
|
||||
{hasTrim && (
|
||||
<span
|
||||
className="ep-trim-badge"
|
||||
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
|
||||
>
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 右裁剪手柄 */}
|
||||
{isHovered && onClipTrim && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-right"
|
||||
onMouseDown={(e) =>
|
||||
handleTrimHandleMouseDown(e, clip.id, "right")
|
||||
}
|
||||
title="拖动调整出点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
|
||||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||||
<div className="ep-track-add-card-wrapper">
|
||||
<div
|
||||
ref={addCardRef}
|
||||
@@ -344,7 +719,73 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
|
||||
{/* 裁剪预览 tooltip */}
|
||||
{trimPreview && (
|
||||
<div
|
||||
className="ep-trim-preview"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: trimPreview.x + 12,
|
||||
top: trimPreview.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">入点</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.startTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">出点</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.endTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row ep-trim-preview-duration">
|
||||
<span className="ep-trim-preview-label">时长</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="ep-context-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
>
|
||||
<div className="ep-context-menu-item" onClick={handleContextSplit}>
|
||||
<span className="ep-context-menu-icon">✂️</span>
|
||||
<span>分割片段</span>
|
||||
</div>
|
||||
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
|
||||
<div
|
||||
className="ep-context-menu-item"
|
||||
onClick={handleContextResetTrim}
|
||||
>
|
||||
<span className="ep-context-menu-icon">↩️</span>
|
||||
<span>恢复原始长度</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-context-menu-divider" />
|
||||
<div
|
||||
className="ep-context-menu-item ep-context-menu-item-danger"
|
||||
onClick={handleContextDelete}
|
||||
>
|
||||
<span className="ep-context-menu-icon">🗑️</span>
|
||||
<span>删除片段</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 */}
|
||||
{showAddPicker && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 转场特效选择器 — Drawer 形式
|
||||
* 14 种转场预设卡片网格 + 转场时长滑块
|
||||
* 支持全局默认转场 + 单个片段间独立设置
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Slider } from "antd";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { TransitionConfig, TransitionType } from "../types";
|
||||
import { DEFAULT_TRANSITION } from "../types";
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface TransitionSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前转场配置 */
|
||||
config: TransitionConfig;
|
||||
onChange: (config: TransitionConfig) => void;
|
||||
/** 标题提示(区分全局 / 片段间) */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const TransitionSelector: React.FC<TransitionSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
title = "转场特效",
|
||||
}) => {
|
||||
/* ── 选择转场类型 ── */
|
||||
const handleSelectType = useCallback(
|
||||
(type: TransitionType) => {
|
||||
onChange({ ...config, type });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 修改时长 ── */
|
||||
const handleChangeDuration = useCallback(
|
||||
(duration: number) => {
|
||||
onChange({ ...config, duration });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置为无转场 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TRANSITION });
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={`🎬 ${title}`}
|
||||
placement="right"
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="transition-selector-drawer"
|
||||
>
|
||||
{/* ── 时长滑块 ── */}
|
||||
<div className="ts-duration-section">
|
||||
<div className="ts-duration-header">
|
||||
<span className="ts-duration-label">转场时长</span>
|
||||
<span className="ts-duration-value">
|
||||
{config.duration.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.duration}
|
||||
onChange={handleChangeDuration}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(1)}s` }}
|
||||
/>
|
||||
<div className="ts-duration-marks">
|
||||
<span>0.3s</span>
|
||||
<span>1.0s</span>
|
||||
<span>2.0s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 转场类型卡片网格 ── */}
|
||||
<div className="ts-grid">
|
||||
{TRANSITION_OPTIONS.map((opt) => {
|
||||
const isActive = config.type === opt.value;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`ts-card${isActive ? " active" : ""}`}
|
||||
onClick={() => handleSelectType(opt.value)}
|
||||
>
|
||||
<div className="ts-card-icon">{opt.icon}</div>
|
||||
<div className="ts-card-name">{opt.label}</div>
|
||||
{isActive && <span className="ts-card-check">✓</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="ts-footer">
|
||||
<button className="ts-reset-btn" onClick={handleReset}>
|
||||
重置为无转场
|
||||
</button>
|
||||
<div className="ts-current">
|
||||
当前:
|
||||
{TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ??
|
||||
"无转场"}
|
||||
{" · "}
|
||||
{config.duration.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransitionSelector;
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* TTS 配音面板 — Drawer 形式
|
||||
* 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Drawer, Slider, message } from "antd";
|
||||
import type { TtsConfig, TtsMode } from "../types";
|
||||
import { DEFAULT_TTS_CONFIG } from "../types";
|
||||
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts";
|
||||
|
||||
/* ──────────── 音色卡片分类图标 ──────────── */
|
||||
const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
|
||||
male: { icon: "👨", label: "男声" },
|
||||
female: { icon: "👩", label: "女声" },
|
||||
young: { icon: "🧑", label: "少年" },
|
||||
service: { icon: "🎧", label: "客服" },
|
||||
news: { icon: "📰", label: "新闻" },
|
||||
emotion: { icon: "🎭", label: "情感" },
|
||||
};
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface TtsPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前片段 TTS 配置 */
|
||||
config: TtsConfig;
|
||||
onChange: (config: TtsConfig) => void;
|
||||
}
|
||||
|
||||
const TtsPanel: React.FC<TtsPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 音色列表 ── */
|
||||
const [voices, setVoices] = useState<TTSVoice[]>([]);
|
||||
const [voicesLoading, setVoicesLoading] = useState(false);
|
||||
|
||||
/* ── 试听状态 ── */
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
/* ── 加载音色列表 ── */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setVoicesLoading(true);
|
||||
getTtsVoices()
|
||||
.then((v) => setVoices(v))
|
||||
.catch(() => message.error("加载音色列表失败"))
|
||||
.finally(() => setVoicesLoading(false));
|
||||
}, [open]);
|
||||
|
||||
/* ── 切换配音模式 ── */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: TtsMode) => {
|
||||
onChange({ ...config, mode });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 文本输入 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value.slice(0, 5000);
|
||||
onChange({ ...config, text });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 选择音色 ── */
|
||||
const handleVoiceSelect = useCallback(
|
||||
(voiceId: string) => {
|
||||
onChange({ ...config, voice_id: voiceId });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 语速 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
onChange({ ...config, speed });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 语调 ── */
|
||||
const handlePitchChange = useCallback(
|
||||
(pitch: number) => {
|
||||
onChange({ ...config, pitch });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 音量 ── */
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number) => {
|
||||
onChange({ ...config, volume });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 字幕联动 ── */
|
||||
const handleSubtitleSyncToggle = useCallback(() => {
|
||||
onChange({ ...config, subtitle_sync: !config.subtitle_sync });
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!config.text.trim()) {
|
||||
message.warning("请先输入合成文本");
|
||||
return;
|
||||
}
|
||||
if (!config.voice_id) {
|
||||
message.warning("请先选择音色");
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: config.text.slice(0, 200), // 试听截取前200字
|
||||
voice_id: config.voice_id,
|
||||
speed: config.speed,
|
||||
pitch: config.pitch,
|
||||
});
|
||||
// 停止上一个
|
||||
audioRef.current?.pause();
|
||||
const audio = new Audio(res.audio_url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => message.error("播放失败"));
|
||||
audio.onended = () => {
|
||||
audioRef.current = null;
|
||||
};
|
||||
message.success("试听播放中");
|
||||
} catch {
|
||||
message.error("试听生成失败");
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TTS_CONFIG });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 关闭时停止音频 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
/* ── 音色分类分组 ── */
|
||||
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎙️ TTS 配音"
|
||||
placement="right"
|
||||
width={400}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="tts-panel-drawer"
|
||||
>
|
||||
{/* ── 配音模式切换 ── */}
|
||||
<div className="tts-mode-section">
|
||||
<div className="tts-mode-label">配音模式</div>
|
||||
<div className="tts-mode-group">
|
||||
{[
|
||||
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
|
||||
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
|
||||
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.mode}
|
||||
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m.mode)}
|
||||
>
|
||||
<span className="tts-mode-icon">{m.icon}</span>
|
||||
<span className="tts-mode-text">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TTS 配置(仅 tts 模式显示) ── */}
|
||||
{config.mode === "tts" && (
|
||||
<>
|
||||
{/* 文本输入 */}
|
||||
<div className="tts-text-section">
|
||||
<div className="tts-text-header">
|
||||
<span className="tts-text-label">合成文本</span>
|
||||
<span className="tts-text-count">{config.text.length}/5000</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="tts-text-input"
|
||||
placeholder="请输入需要合成的文本内容..."
|
||||
value={config.text}
|
||||
onChange={handleTextChange}
|
||||
maxLength={5000}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div className="tts-voice-section">
|
||||
<div className="tts-voice-label">
|
||||
选择音色
|
||||
{voicesLoading && (
|
||||
<span className="tts-voice-loading">加载中...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tts-voice-grid">
|
||||
{voiceCategories.map(([cat, info]) => {
|
||||
const voice = voices.find((v) => v.category === cat);
|
||||
const isSelected = voice && config.voice_id === voice.id;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
className={`tts-voice-card${isSelected ? " active" : ""}`}
|
||||
onClick={() => voice && handleVoiceSelect(voice.id)}
|
||||
disabled={!voice || voicesLoading}
|
||||
>
|
||||
<span className="tts-voice-card-icon">{info.icon}</span>
|
||||
<span className="tts-voice-card-name">
|
||||
{voice?.name || info.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语速滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">语速</span>
|
||||
<span className="tts-slider-value">
|
||||
{config.speed.toFixed(2)}x
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.05}
|
||||
value={config.speed}
|
||||
onChange={handleSpeedChange}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
|
||||
/>
|
||||
<div className="tts-slider-marks">
|
||||
<span>0.5x</span>
|
||||
<span>1.0x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语调滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">语调</span>
|
||||
<span className="tts-slider-value">
|
||||
{config.pitch > 0 ? "+" : ""}
|
||||
{config.pitch} 半音
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
value={config.pitch}
|
||||
onChange={handlePitchChange}
|
||||
tooltip={{ formatter: (v) => `${v}半音` }}
|
||||
/>
|
||||
<div className="tts-slider-marks">
|
||||
<span>-12</span>
|
||||
<span>0</span>
|
||||
<span>+12</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音量滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">音量</span>
|
||||
<span className="tts-slider-value">{config.volume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={config.volume}
|
||||
onChange={handleVolumeChange}
|
||||
tooltip={{ formatter: (v) => `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 试听按钮 */}
|
||||
<div className="tts-preview-section">
|
||||
<button
|
||||
className="tts-preview-btn"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? "⏳ 生成中..." : "🔊 试听"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 字幕联动 */}
|
||||
<div className="tts-subtitle-section">
|
||||
<div className="tts-subtitle-info">
|
||||
<span className="tts-subtitle-label">字幕联动</span>
|
||||
<span className="tts-subtitle-desc">
|
||||
{config.subtitle_sync
|
||||
? "TTS 文本自动同步到字幕"
|
||||
: "字幕需手动编辑"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ep-toggle${config.subtitle_sync ? " active" : ""}`}
|
||||
onClick={handleSubtitleSyncToggle}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 上传配音模式提示 ── */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="tts-upload-hint">
|
||||
<p>请在右侧面板的「配音素材」中选择已上传的配音文件。</p>
|
||||
<p>如需上传新配音,请前往配音素材库页面。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
{config.mode === "tts" && (
|
||||
<div className="tts-footer">
|
||||
<button className="tts-reset-btn" onClick={handleReset}>
|
||||
重置默认
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TtsPanel;
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* 水印配置面板 — Drawer 形式
|
||||
* 三个 Tab:图片水印 / 文字水印 / 滚动水印
|
||||
* 通用设置:位置、不透明度
|
||||
*/
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
WatermarkType,
|
||||
WatermarkPosition,
|
||||
ScrollDirection,
|
||||
} from "../types";
|
||||
import { DEFAULT_WATERMARK } from "../types";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
|
||||
{ key: "none", label: "无水印", icon: "🚫" },
|
||||
{ key: "image", label: "图片水印", icon: "🖼️" },
|
||||
{ key: "text", label: "文字水印", icon: "📝" },
|
||||
{ key: "scroll", label: "滚动水印", icon: "📜" },
|
||||
];
|
||||
|
||||
const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
|
||||
{ value: "top_left", label: "左上角" },
|
||||
{ value: "top_right", label: "右上角" },
|
||||
{ value: "bottom_left", label: "左下角" },
|
||||
{ value: "bottom_right", label: "右下角" },
|
||||
{ value: "center", label: "居中" },
|
||||
];
|
||||
|
||||
const SCROLL_DIRECTION_OPTIONS: {
|
||||
value: ScrollDirection;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "horizontal", label: "水平滚动" },
|
||||
{ value: "vertical", label: "垂直滚动" },
|
||||
{ value: "diagonal", label: "对角滚动" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface WatermarkPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: WatermarkConfig;
|
||||
onChange: (config: WatermarkConfig) => void;
|
||||
}
|
||||
|
||||
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 图片上传预览 URL(本地预览用) ── */
|
||||
const [localImageUrl, setLocalImageUrl] = useState<string>("");
|
||||
|
||||
/* ── 切换水印类型 ── */
|
||||
const handleTypeChange = useCallback(
|
||||
(type: WatermarkType) => {
|
||||
onChange({ ...DEFAULT_WATERMARK, type });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
/* ── 通用设置变更 ── */
|
||||
const handlePositionChange = useCallback(
|
||||
(position: WatermarkPosition) => {
|
||||
onChange({ ...config, position });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleOpacityChange = useCallback(
|
||||
(opacity: number) => {
|
||||
onChange({ ...config, opacity });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 图片水印设置 ── */
|
||||
const handleImageUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
setLocalImageUrl(url);
|
||||
onChange({ ...config, image_url: url });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleImageWidthChange = useCallback(
|
||||
(width: number) => {
|
||||
onChange({ ...config, width });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleImageHeightChange = useCallback(
|
||||
(height: number) => {
|
||||
onChange({ ...config, height });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 文字水印设置 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(text: string) => {
|
||||
onChange({ ...config, text });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleFontSizeChange = useCallback(
|
||||
(font_size: number) => {
|
||||
onChange({ ...config, font_size });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(color: string) => {
|
||||
onChange({ ...config, color });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 滚动水印设置 ── */
|
||||
const handleScrollDirectionChange = useCallback(
|
||||
(scroll_direction: ScrollDirection) => {
|
||||
onChange({ ...config, scroll_direction });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleScrollSpeedChange = useCallback(
|
||||
(scroll_speed: number) => {
|
||||
onChange({ ...config, scroll_speed });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
setLocalImageUrl("");
|
||||
onChange({ ...DEFAULT_WATERMARK });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 当前激活的 Tab ── */
|
||||
const activeTab = config.type;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🔖 水印设置"
|
||||
placement="right"
|
||||
width={400}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="watermark-panel-drawer"
|
||||
>
|
||||
{/* ── Tab 切换 ── */}
|
||||
<div className="wp-tabs">
|
||||
{WATERMARK_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
|
||||
onClick={() => handleTypeChange(tab.key)}
|
||||
>
|
||||
<span className="wp-tab-icon">{tab.icon}</span>
|
||||
<span className="wp-tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 无水印提示 ── */}
|
||||
{config.type === "none" && (
|
||||
<div className="wp-empty-hint">
|
||||
<span className="wp-empty-icon">🚫</span>
|
||||
<p>当前未启用水印</p>
|
||||
<p className="wp-empty-desc">选择上方标签启用水印功能</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 图片水印配置 ── */}
|
||||
{config.type === "image" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印图片 URL</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="text"
|
||||
placeholder="https://example.com/logo.png"
|
||||
value={localImageUrl || config.image_url || ""}
|
||||
onChange={(e) => handleImageUrlChange(e.target.value)}
|
||||
/>
|
||||
{(localImageUrl || config.image_url) && (
|
||||
<div className="wp-image-preview">
|
||||
<img
|
||||
src={localImageUrl || config.image_url}
|
||||
alt="水印预览"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">宽度(像素,0 表示自适应)</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={config.width ?? 0}
|
||||
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">高度(像素,0 表示自适应)</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={config.height ?? 0}
|
||||
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 文字水印配置 ── */}
|
||||
{config.type === "text" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印文字</label>
|
||||
<textarea
|
||||
className="wp-textarea"
|
||||
placeholder="输入水印文字内容"
|
||||
rows={3}
|
||||
value={config.text ?? ""}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">字号</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size ?? 24}
|
||||
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.font_size ?? 24}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">文字颜色</label>
|
||||
<div className="wp-color-row">
|
||||
<input
|
||||
className="wp-color-input"
|
||||
type="color"
|
||||
value={config.color ?? "#ffffff"}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
/>
|
||||
<span className="wp-color-value">
|
||||
{config.color ?? "#ffffff"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 滚动水印配置 ── */}
|
||||
{config.type === "scroll" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动文字</label>
|
||||
<textarea
|
||||
className="wp-textarea"
|
||||
placeholder="输入滚动水印文字"
|
||||
rows={2}
|
||||
value={config.text ?? ""}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动方向</label>
|
||||
<select
|
||||
className="wp-select"
|
||||
value={config.scroll_direction ?? "horizontal"}
|
||||
onChange={(e) =>
|
||||
handleScrollDirectionChange(e.target.value as ScrollDirection)
|
||||
}
|
||||
>
|
||||
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动速度</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={200}
|
||||
value={config.scroll_speed ?? 50}
|
||||
onChange={(e) =>
|
||||
handleScrollSpeedChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.scroll_speed ?? 50}px/s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">字号</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size ?? 24}
|
||||
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.font_size ?? 24}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">文字颜色</label>
|
||||
<div className="wp-color-row">
|
||||
<input
|
||||
className="wp-color-input"
|
||||
type="color"
|
||||
value={config.color ?? "#ffffff"}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
/>
|
||||
<span className="wp-color-value">
|
||||
{config.color ?? "#ffffff"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 通用设置(非 none 时显示) ── */}
|
||||
{config.type !== "none" && (
|
||||
<div className="wp-section wp-common-section">
|
||||
<div className="wp-section-divider" />
|
||||
<div className="wp-common-title">通用设置</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印位置</label>
|
||||
<select
|
||||
className="wp-select"
|
||||
value={config.position}
|
||||
onChange={(e) =>
|
||||
handlePositionChange(e.target.value as WatermarkPosition)
|
||||
}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">不透明度</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={config.opacity}
|
||||
onChange={(e) => handleOpacityChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{Math.round(config.opacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="wp-footer">
|
||||
<button className="wp-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkPanel;
|
||||
@@ -5,6 +5,508 @@
|
||||
|
||||
export type ClipType = "voice" | "pip";
|
||||
|
||||
/* ──────── 转场特效 ──────── */
|
||||
|
||||
/** 14 种转场类型 */
|
||||
export type TransitionType =
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
|
||||
/** 片段间转场配置 */
|
||||
export interface TransitionConfig {
|
||||
/** 转场类型 */
|
||||
type: TransitionType;
|
||||
/** 转场时长(秒),0.3 ~ 2.0 */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 默认转场配置 */
|
||||
export const DEFAULT_TRANSITION: TransitionConfig = {
|
||||
type: "none",
|
||||
duration: 0.5,
|
||||
};
|
||||
|
||||
/* ──────── 片段调速 ──────── */
|
||||
|
||||
/** 片段调速配置 */
|
||||
export interface SpeedConfig {
|
||||
/** 播放速度,0.25 ~ 4.0 */
|
||||
rate: number;
|
||||
/** 音调修正(变速不变调) */
|
||||
pitchCorrection: boolean;
|
||||
}
|
||||
|
||||
/** 默认调速配置 */
|
||||
export const DEFAULT_SPEED: SpeedConfig = {
|
||||
rate: 1.0,
|
||||
pitchCorrection: true,
|
||||
};
|
||||
|
||||
/* ──────── TTS 配音 ──────── */
|
||||
|
||||
/** 配音模式 */
|
||||
export type TtsMode = "none" | "upload" | "tts";
|
||||
|
||||
/** TTS 配音配置 */
|
||||
export interface TtsConfig {
|
||||
/** 配音模式 */
|
||||
mode: TtsMode;
|
||||
/** TTS 合成文本 */
|
||||
text: string;
|
||||
/** 音色 ID */
|
||||
voice_id: string;
|
||||
/** 语速 0.5 ~ 2.0 */
|
||||
speed: number;
|
||||
/** 语调(半音)-12 ~ +12 */
|
||||
pitch: number;
|
||||
/** 音量 0 ~ 100 */
|
||||
volume: number;
|
||||
/** 字幕联动 */
|
||||
subtitle_sync: boolean;
|
||||
}
|
||||
|
||||
/** 默认 TTS 配置 */
|
||||
export const DEFAULT_TTS_CONFIG: TtsConfig = {
|
||||
mode: "none",
|
||||
text: "",
|
||||
voice_id: "",
|
||||
speed: 1.0,
|
||||
pitch: 0,
|
||||
volume: 100,
|
||||
subtitle_sync: true,
|
||||
};
|
||||
|
||||
/* ──────── 裁剪配置 ──────── */
|
||||
|
||||
/** 片段裁剪配置 — 定义素材的入点/出点 */
|
||||
export interface TrimConfig {
|
||||
/** 入点(秒),素材原始时间轴上的起始位置 */
|
||||
start_time: number;
|
||||
/** 出点(秒),素材原始时间轴上的结束位置 */
|
||||
end_time: number;
|
||||
/** 素材原始总时长(秒),用于"恢复原始长度" */
|
||||
original_duration?: number;
|
||||
}
|
||||
|
||||
/* ──────── 水印配置 ──────── */
|
||||
|
||||
/** 水印类型 */
|
||||
export type WatermarkType = "none" | "image" | "text" | "scroll";
|
||||
|
||||
/** 水印位置 */
|
||||
export type WatermarkPosition =
|
||||
"top_left" | "top_right" | "bottom_left" | "bottom_right" | "center";
|
||||
|
||||
/** 滚动水印方向 */
|
||||
export type ScrollDirection = "horizontal" | "vertical" | "diagonal";
|
||||
|
||||
/** 水印配置 */
|
||||
export interface WatermarkConfig {
|
||||
/** 水印类型 */
|
||||
type: WatermarkType;
|
||||
/** 图片水印 URL */
|
||||
image_url?: string;
|
||||
/** 水印宽度(像素或百分比 0~1) */
|
||||
width?: number;
|
||||
/** 水印高度(像素或百分比 0~1) */
|
||||
height?: number;
|
||||
/** 水印位置 */
|
||||
position: WatermarkPosition;
|
||||
/** 水印不透明度 0~1 */
|
||||
opacity: number;
|
||||
/** 文字水印内容 */
|
||||
text?: string;
|
||||
/** 文字水印字号 */
|
||||
font_size?: number;
|
||||
/** 文字水印颜色 */
|
||||
color?: string;
|
||||
/** 滚动水印方向 */
|
||||
scroll_direction?: ScrollDirection;
|
||||
/** 滚动水印速度(像素/秒) */
|
||||
scroll_speed?: number;
|
||||
}
|
||||
|
||||
/** 默认水印配置 */
|
||||
export const DEFAULT_WATERMARK: WatermarkConfig = {
|
||||
type: "none",
|
||||
position: "bottom_right",
|
||||
opacity: 0.7,
|
||||
};
|
||||
|
||||
/* ──────── 片头片尾配置 ──────── */
|
||||
|
||||
/** 片头片尾素材类型 */
|
||||
export type IntroOutroKind = "none" | "video" | "image";
|
||||
|
||||
/** 片头/片尾单项配置 */
|
||||
export interface IntroOutroItem {
|
||||
/** 素材类型 */
|
||||
kind: IntroOutroKind;
|
||||
/** 素材 URL */
|
||||
url?: string;
|
||||
/** 显示时长(秒) */
|
||||
duration: number;
|
||||
/** 过渡动画 */
|
||||
transition?: TransitionType;
|
||||
/** 过渡时长(秒) */
|
||||
transition_duration?: number;
|
||||
}
|
||||
|
||||
/** 片头片尾完整配置 */
|
||||
export interface IntroOutroConfig {
|
||||
intro: IntroOutroItem;
|
||||
outro: IntroOutroItem;
|
||||
}
|
||||
|
||||
/** 默认片头片尾配置 */
|
||||
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
|
||||
intro: { kind: "none", duration: 3 },
|
||||
outro: { kind: "none", duration: 3 },
|
||||
};
|
||||
|
||||
/* ──────── 画中画配置 ──────── */
|
||||
|
||||
/** 九宫格位置 */
|
||||
export type PipGridPosition =
|
||||
| "top_left"
|
||||
| "top_center"
|
||||
| "top_right"
|
||||
| "center_left"
|
||||
| "center"
|
||||
| "center_right"
|
||||
| "bottom_left"
|
||||
| "bottom_center"
|
||||
| "bottom_right";
|
||||
|
||||
/** 入场动画类型 */
|
||||
export type PipAnimType = "none" | "fade_in" | "slide_in";
|
||||
|
||||
/** 入场方向 */
|
||||
export type PipSlideDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
/** 画中画图层 */
|
||||
export interface PipLayer {
|
||||
id: string;
|
||||
/** 图层名称(用户可编辑) */
|
||||
name: string;
|
||||
/** 素材类型 */
|
||||
material_type: "image" | "video";
|
||||
/** 素材 URL */
|
||||
material_url: string;
|
||||
/** 素材缩略图 */
|
||||
thumbnail_url?: string;
|
||||
/** 九宫格快捷位置 */
|
||||
grid_position: PipGridPosition;
|
||||
/** 精确 X 坐标(百分比 0~100) */
|
||||
x: number;
|
||||
/** 精确 Y 坐标(百分比 0~100) */
|
||||
y: number;
|
||||
/** 宽度(百分比 0~100,相对主画面) */
|
||||
width: number;
|
||||
/** 高度(百分比 0~100,相对主画面) */
|
||||
height: number;
|
||||
/** 锁定宽高比 */
|
||||
aspect_lock: boolean;
|
||||
/** 圆角(百分比 0~50) */
|
||||
border_radius: number;
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number;
|
||||
/** 开始时间(秒) */
|
||||
start_time: number;
|
||||
/** 持续时长(秒) */
|
||||
duration: number;
|
||||
/** 入场动画 */
|
||||
animation: PipAnimType;
|
||||
/** 入场方向 */
|
||||
slide_direction: PipSlideDirection;
|
||||
/** 图层顺序(z-index) */
|
||||
z_index: number;
|
||||
}
|
||||
|
||||
/** 画中画配置 */
|
||||
export interface PipConfig {
|
||||
/** 是否启用画中画 */
|
||||
enabled: boolean;
|
||||
/** 图层列表 */
|
||||
layers: PipLayer[];
|
||||
}
|
||||
|
||||
/** 默认 PiP 图层 */
|
||||
export const DEFAULT_PIP_LAYER: PipLayer = {
|
||||
id: "",
|
||||
name: "图层",
|
||||
material_type: "image",
|
||||
material_url: "",
|
||||
grid_position: "top_right",
|
||||
x: 70,
|
||||
y: 5,
|
||||
width: 25,
|
||||
height: 25,
|
||||
aspect_lock: true,
|
||||
border_radius: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
animation: "none",
|
||||
slide_direction: "right",
|
||||
z_index: 1,
|
||||
};
|
||||
|
||||
/** 默认 PiP 配置 */
|
||||
export const DEFAULT_PIP_CONFIG: PipConfig = {
|
||||
enabled: false,
|
||||
layers: [],
|
||||
};
|
||||
|
||||
/* ──────── 滤镜调色 ──────── */
|
||||
|
||||
/** 预设滤镜 */
|
||||
export type FilterPreset =
|
||||
| "none"
|
||||
| "original"
|
||||
| "fresh"
|
||||
| "warm"
|
||||
| "cool"
|
||||
| "vintage"
|
||||
| "cinema"
|
||||
| "bw"
|
||||
| "sunshine"
|
||||
| "film";
|
||||
|
||||
/** 预设滤镜标签 */
|
||||
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
|
||||
none: "无",
|
||||
original: "原片",
|
||||
fresh: "清新",
|
||||
warm: "暖调",
|
||||
cool: "冷色",
|
||||
vintage: "复古",
|
||||
cinema: "电影",
|
||||
bw: "黑白",
|
||||
sunshine: "暖阳",
|
||||
film: "胶片",
|
||||
};
|
||||
|
||||
/** 滤镜调色配置 */
|
||||
export interface FilterConfig {
|
||||
/** 是否启用滤镜 */
|
||||
enabled: boolean;
|
||||
/** 预设滤镜 */
|
||||
preset: FilterPreset;
|
||||
/** 亮度(-100 ~ 100) */
|
||||
brightness: number;
|
||||
/** 对比度(-100 ~ 100) */
|
||||
contrast: number;
|
||||
/** 饱和度(-100 ~ 100) */
|
||||
saturation: number;
|
||||
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
|
||||
temperature: number;
|
||||
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
|
||||
tint: number;
|
||||
/** 锐度(0 ~ 100) */
|
||||
sharpness: number;
|
||||
}
|
||||
|
||||
/** 默认滤镜调色配置 */
|
||||
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
|
||||
enabled: false,
|
||||
preset: "none",
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 0,
|
||||
tint: 0,
|
||||
sharpness: 0,
|
||||
};
|
||||
|
||||
/* ──────── 绿幕抠像 ──────── */
|
||||
|
||||
/** 绿幕抠像颜色预设 */
|
||||
export type ChromaKeyColorPreset =
|
||||
"green" | "blue" | "red" | "pure_green" | "soft_green";
|
||||
|
||||
/** 颜色预设标签 */
|
||||
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "绿",
|
||||
blue: "蓝",
|
||||
red: "红",
|
||||
pure_green: "精绿",
|
||||
soft_green: "柔绿",
|
||||
};
|
||||
|
||||
/** 颜色预设对应的默认色值 */
|
||||
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "#00FF00",
|
||||
blue: "#0000FF",
|
||||
red: "#FF0000",
|
||||
pure_green: "#00C800",
|
||||
soft_green: "#40E040",
|
||||
};
|
||||
|
||||
/** 绿幕抠像配置 */
|
||||
export interface ChromaKeyConfig {
|
||||
/** 是否启用绿幕抠像 */
|
||||
enabled: boolean;
|
||||
/** 颜色预设 */
|
||||
color_preset: ChromaKeyColorPreset;
|
||||
/** 抠像目标颜色(HEX) */
|
||||
color: string;
|
||||
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
|
||||
similarity: number;
|
||||
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
|
||||
blend: number;
|
||||
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
|
||||
spill: number;
|
||||
}
|
||||
|
||||
/** 默认绿幕抠像配置 */
|
||||
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
|
||||
enabled: false,
|
||||
color_preset: "green",
|
||||
color: "#00FF00",
|
||||
similarity: 30,
|
||||
blend: 10,
|
||||
spill: 20,
|
||||
};
|
||||
|
||||
/* ──────── 贴纸配置 ──────── */
|
||||
|
||||
/** 贴纸类型 */
|
||||
export type StickerType = "emoji" | "image" | "text";
|
||||
|
||||
/** 文字花字预设 */
|
||||
export type TextStickerPreset =
|
||||
| "normal" // 普通
|
||||
| "highlight" // 高亮
|
||||
| "bubble" // 气泡
|
||||
| "neon" // 霓虹
|
||||
| "shadow" // 投影
|
||||
| "outline" // 描边
|
||||
| "gradient" // 渐变
|
||||
| "handwrite"; // 手写
|
||||
|
||||
/** 贴纸项 */
|
||||
export interface StickerItem {
|
||||
id: string;
|
||||
/** 贴纸类型 */
|
||||
type: StickerType;
|
||||
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
|
||||
content: string;
|
||||
/** X 坐标(百分比 0~100) */
|
||||
x: number;
|
||||
/** Y 坐标(百分比 0~100) */
|
||||
y: number;
|
||||
/** 宽度(百分比 0~100) */
|
||||
width: number;
|
||||
/** 高度(百分比 0~100) */
|
||||
height: number;
|
||||
/** 旋转角度(度 -180~180) */
|
||||
rotation: number;
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number;
|
||||
/** 开始时间(秒) */
|
||||
start_time: number;
|
||||
/** 持续时长(秒,0 表示全程显示) */
|
||||
duration: number;
|
||||
/** 图层顺序 */
|
||||
z_index: number;
|
||||
/** 文字花字预设(仅 type=text 时有效) */
|
||||
text_preset: TextStickerPreset;
|
||||
/** 文字颜色(仅 type=text 时有效) */
|
||||
text_color: string;
|
||||
/** 文字大小(px,仅 type=text 时有效) */
|
||||
font_size: number;
|
||||
}
|
||||
|
||||
/** 贴纸配置 */
|
||||
export interface StickerConfig {
|
||||
enabled: boolean;
|
||||
items: StickerItem[];
|
||||
}
|
||||
|
||||
/** 默认贴纸项 */
|
||||
export const DEFAULT_STICKER_ITEM: StickerItem = {
|
||||
id: "",
|
||||
type: "emoji",
|
||||
content: "😀",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 15,
|
||||
height: 15,
|
||||
rotation: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 0,
|
||||
z_index: 1,
|
||||
text_preset: "normal",
|
||||
text_color: "#FFFFFF",
|
||||
font_size: 24,
|
||||
};
|
||||
|
||||
/** 默认贴纸配置 */
|
||||
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
|
||||
enabled: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
/** 文字花字预设标签 */
|
||||
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
|
||||
normal: "普通",
|
||||
highlight: "高亮",
|
||||
bubble: "气泡",
|
||||
neon: "霓虹",
|
||||
shadow: "投影",
|
||||
outline: "描边",
|
||||
gradient: "渐变",
|
||||
handwrite: "手写",
|
||||
};
|
||||
|
||||
/* ──────── 封面配置 ──────── */
|
||||
|
||||
/** 封面来源模式 */
|
||||
export type CoverMode = "auto" | "frame" | "upload";
|
||||
|
||||
/** 封面配置 */
|
||||
export interface CoverConfig {
|
||||
/** 是否启用自定义封面 */
|
||||
enabled: boolean;
|
||||
/** 封面来源模式 */
|
||||
mode: CoverMode;
|
||||
/** 抽帧时间点(秒,mode=frame 时使用) */
|
||||
frame_time: number;
|
||||
/** 上传的封面 URL(mode=upload 时使用) */
|
||||
upload_url: string;
|
||||
/** AI 智能推荐的抽帧时间(由后端分析得出) */
|
||||
ai_suggested_time: number | null;
|
||||
/** 封面缩略图 URL */
|
||||
thumbnail_url: string;
|
||||
}
|
||||
|
||||
/** 默认封面配置 */
|
||||
export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
enabled: false,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
};
|
||||
|
||||
/* ──────── 片段数据 ──────── */
|
||||
|
||||
export interface ClipData {
|
||||
id: string;
|
||||
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
|
||||
@@ -18,4 +520,31 @@ export interface ClipData {
|
||||
voice_asset_id?: string;
|
||||
/** 配音素材文件 URL(voice 类型片段使用) */
|
||||
voice_file_url?: string;
|
||||
/** 与前一片段之间的转场效果 */
|
||||
transition?: TransitionConfig;
|
||||
/** 播放速度配置 */
|
||||
speed?: SpeedConfig;
|
||||
/** TTS 配音配置 */
|
||||
tts_config?: TtsConfig;
|
||||
/** 裁剪配置 — 定义素材入点/出点 */
|
||||
trim_config?: TrimConfig;
|
||||
}
|
||||
|
||||
/* ──────── 标题设置 ──────── */
|
||||
|
||||
/**
|
||||
* 标题设置 — 对齐后端 title_config 字段
|
||||
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
|
||||
*/
|
||||
export interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
createEditPlan,
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
} from "@/api/editPlans";
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans";
|
||||
import { getEditingTemplates } from "@/api/editingPlanner";
|
||||
import { getTitles } from "@/api/titles";
|
||||
import apiClient from "@/api/client";
|
||||
@@ -152,6 +154,9 @@ const GeneratePage: React.FC = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@@ -197,8 +202,8 @@ const GeneratePage: React.FC = () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId);
|
||||
if (plan.name) setTitle(plan.name);
|
||||
const cfg = plan.config as Record<string, unknown>;
|
||||
if (cfg && Array.isArray(cfg.asset_ids)) {
|
||||
const cfg = plan.config;
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(
|
||||
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
|
||||
);
|
||||
@@ -472,7 +477,13 @@ const GeneratePage: React.FC = () => {
|
||||
setGenerateError(null);
|
||||
|
||||
try {
|
||||
const voiceConfig: Record<string, unknown> = {};
|
||||
const voiceConfig: Pick<
|
||||
EditPlanConfig,
|
||||
| "voice_id"
|
||||
| "voice_clone_profile_id"
|
||||
| "custom_audio_url"
|
||||
| "custom_text"
|
||||
> = {};
|
||||
if (voiceMode === "preset") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined;
|
||||
} else if (voiceMode === "clone") {
|
||||
@@ -518,6 +529,25 @@ const GeneratePage: React.FC = () => {
|
||||
setProgress(100);
|
||||
setGenerating(false);
|
||||
setGenerated(true);
|
||||
|
||||
// 获取生成的视频结果
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(
|
||||
data.generation_task_id,
|
||||
);
|
||||
setGeneratedVideos(videos);
|
||||
if (videos.length > 0) {
|
||||
setVideoUrl(
|
||||
videos[0].file_url || videos[0].download_url || "",
|
||||
);
|
||||
setThumbnailUrl(videos[0].thumbnail_url || "");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err);
|
||||
}
|
||||
}
|
||||
|
||||
message.success("视频生成完成!");
|
||||
return;
|
||||
}
|
||||
@@ -537,7 +567,8 @@ const GeneratePage: React.FC = () => {
|
||||
const safeExtract = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
@@ -597,7 +628,8 @@ const GeneratePage: React.FC = () => {
|
||||
const extractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
@@ -627,7 +659,8 @@ const GeneratePage: React.FC = () => {
|
||||
const safeExtractErr = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
@@ -708,6 +741,42 @@ const GeneratePage: React.FC = () => {
|
||||
materialMode,
|
||||
]);
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!generatedVideos.length) return;
|
||||
const video = generatedVideos[0];
|
||||
try {
|
||||
// 优先使用 download_url(签名 URL),回退到 file_url
|
||||
const url = video.download_url || video.file_url;
|
||||
if (url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = video.name || "generated-video.mp4";
|
||||
a.target = "_blank";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[下载失败]", err);
|
||||
message.error("下载失败,请重试");
|
||||
}
|
||||
}, [generatedVideos]);
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!generatedVideos.length) return;
|
||||
const video = generatedVideos[0];
|
||||
const shareUrl = video.file_url || window.location.href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
message.success("视频链接已复制到剪贴板");
|
||||
} catch {
|
||||
// fallback: 显示 URL 让用户手动复制
|
||||
message.info(`视频链接: ${shareUrl}`);
|
||||
}
|
||||
}, [generatedVideos]);
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const goNext = useCallback(() => {
|
||||
if (currentStep === 1 && !selectedTemplate) {
|
||||
@@ -1724,8 +1793,25 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-generate-preview">
|
||||
{/* 视频预览 */}
|
||||
<div className="xx-preview-video">
|
||||
{generated ? (
|
||||
<video src="" controls preload="none" />
|
||||
{generated && videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={thumbnailUrl || undefined}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : generated ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: 24,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
|
||||
<div>视频处理中,请稍候…</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="xx-play-btn" type="button">
|
||||
<PlayCircleOutlined />
|
||||
@@ -1778,12 +1864,18 @@ const GeneratePage: React.FC = () => {
|
||||
{/* 生成完成后显示下载/分享 */}
|
||||
{generated && (
|
||||
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
|
||||
<button className="xx-btn xx-btn-ghost">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
|
||||
<DownloadOutlined /> 下载视频
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
前往成片库 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
getProducts,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ProductItem as ApiProductItem,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products";
|
||||
import "./products.css";
|
||||
|
||||
@@ -53,6 +57,12 @@ interface ProductItem {
|
||||
fileSize: number; // MB
|
||||
videoUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
/** 复核状态 */
|
||||
reviewStatus?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
projectId?: string;
|
||||
/** 所属项目名称 */
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
@@ -108,6 +118,9 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
reviewStatus: item.review_status,
|
||||
projectId: item.project_id,
|
||||
projectName: item.project_name,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
@@ -135,6 +148,30 @@ const formatSize = (mb: number): string => {
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** 复核状态配置 */
|
||||
const reviewStatusConfig: Record<
|
||||
ReviewStatus,
|
||||
{ text: string; className: string }
|
||||
> = {
|
||||
pending_review: { text: "待复核", className: "review-pending" },
|
||||
approved: { text: "已通过", className: "review-approved" },
|
||||
rejected: { text: "需修改", className: "review-rejected" },
|
||||
};
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
const REVIEW_STATUS_CYCLE: ReviewStatus[] = [
|
||||
"pending_review",
|
||||
"approved",
|
||||
"rejected",
|
||||
];
|
||||
|
||||
/** 获取下一个复核状态 */
|
||||
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
|
||||
if (!current) return "approved";
|
||||
const idx = REVIEW_STATUS_CYCLE.indexOf(current);
|
||||
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length];
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* ProductCard 组件
|
||||
* ============================================================ */
|
||||
@@ -148,6 +185,7 @@ const ProductCard: React.FC<{
|
||||
onShare: (product: ProductItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPublish: (product: ProductItem) => void;
|
||||
onReviewStatusChange: (id: string) => void;
|
||||
}> = ({
|
||||
product,
|
||||
isSelected,
|
||||
@@ -158,6 +196,7 @@ const ProductCard: React.FC<{
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status];
|
||||
|
||||
@@ -201,6 +240,33 @@ const ProductCard: React.FC<{
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReviewStatusChange(product.id);
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReviewStatusChange(product.id);
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
@@ -517,7 +583,7 @@ const ProductLibrary: React.FC = () => {
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: getProducts,
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -536,11 +602,26 @@ const ProductLibrary: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
message.success("复核状态已更新");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败");
|
||||
},
|
||||
});
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const [filterTime, setFilterTime] = useState<string>("all");
|
||||
const [filterDuration, setFilterDuration] = useState<string>("all");
|
||||
const [filterProject, setFilterProject] = useState<string>("all");
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all");
|
||||
|
||||
/* 批量操作 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -596,6 +677,20 @@ const ProductLibrary: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
/* 项目筛选 */
|
||||
if (filterProject !== "all") {
|
||||
list = list.filter((p) => p.projectId === filterProject);
|
||||
}
|
||||
|
||||
/* 复核状态筛选 */
|
||||
if (filterReviewStatus !== "all") {
|
||||
if (filterReviewStatus === "none") {
|
||||
list = list.filter((p) => !p.reviewStatus);
|
||||
} else {
|
||||
list = list.filter((p) => p.reviewStatus === filterReviewStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase();
|
||||
@@ -603,7 +698,15 @@ const ProductLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [products, filterStatus, filterTime, filterDuration, searchText]);
|
||||
}, [
|
||||
products,
|
||||
filterStatus,
|
||||
filterTime,
|
||||
filterDuration,
|
||||
filterProject,
|
||||
filterReviewStatus,
|
||||
searchText,
|
||||
]);
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
@@ -679,24 +782,56 @@ const ProductLibrary: React.FC = () => {
|
||||
message.info("发布功能待后端 API 补齐");
|
||||
};
|
||||
|
||||
/* 批量下载 */
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus;
|
||||
const nextStatus = getNextReviewStatus(current);
|
||||
reviewMutation.mutate({ id, status: nextStatus });
|
||||
};
|
||||
|
||||
/* 批量下载 — 使用 batch-download API + 轮询 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(id);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
if (ids.length === 0) return;
|
||||
setBatchDownloading(true);
|
||||
try {
|
||||
// 发起批量下载任务
|
||||
const { job_id } = await batchDownload(ids);
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`);
|
||||
|
||||
// 轮询下载状态(最多 60 次,每次 2 秒)
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看");
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const status = await getBatchDownloadStatus(job_id);
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = status.download_url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
message.success(`已打包下载 ${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试");
|
||||
} else {
|
||||
// 继续轮询
|
||||
await poll();
|
||||
}
|
||||
};
|
||||
await poll();
|
||||
} catch {
|
||||
message.error("发起批量下载失败");
|
||||
} finally {
|
||||
setBatchDownloading(false);
|
||||
}
|
||||
message.success(`已下载 ${successCount}/${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/* 批量删除 */
|
||||
@@ -823,8 +958,9 @@ const ProductLibrary: React.FC = () => {
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBatchDownload}
|
||||
disabled={batchDownloading}
|
||||
>
|
||||
批量下载
|
||||
{batchDownloading ? "打包中..." : "批量下载"}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
@@ -904,6 +1040,36 @@ const ProductLibrary: React.FC = () => {
|
||||
{ value: "long", label: ">3分钟" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterProject}
|
||||
onChange={setFilterProject}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部项目" },
|
||||
...Array.from(
|
||||
new Map(
|
||||
products
|
||||
.filter((p) => p.projectId && p.projectName)
|
||||
.map((p) => [p.projectId!, p.projectName!] as const),
|
||||
),
|
||||
).map(([id, name]) => ({
|
||||
value: id as string,
|
||||
label: name as string,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterReviewStatus}
|
||||
onChange={setFilterReviewStatus}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部复核" },
|
||||
{ value: "none", label: "未设置" },
|
||||
{ value: "pending_review", label: "待复核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "需修改" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-products-filters-right">
|
||||
<span
|
||||
@@ -932,6 +1098,7 @@ const ProductLibrary: React.FC = () => {
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onReviewStatusChange={handleReviewStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -222,6 +222,42 @@
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 复核状态标签(右上角,位于已发布徽章下方) */
|
||||
.xx-product-review-tag {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-product-review-tag:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-pending {
|
||||
background: rgba(156, 163, 175, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-approved {
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-rejected {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* 缩略图区域 */
|
||||
.xx-product-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* 任务中心页面
|
||||
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Tabs,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
MinusCircleOutlined,
|
||||
RedoOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getTasks,
|
||||
retryTask,
|
||||
type TaskItem,
|
||||
type TaskStatus,
|
||||
type TaskListParams,
|
||||
} from "@/api/tasks";
|
||||
import "./tasks.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 状态 Tab 配置 */
|
||||
const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "waiting", label: "等待中" },
|
||||
{ key: "running", label: "进行中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
/** 类型筛选选项 */
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "generation", label: "生成任务" },
|
||||
{ value: "ingest", label: "素材导入" },
|
||||
];
|
||||
|
||||
/** 状态标签配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
TaskStatus,
|
||||
{ label: string; color: string; icon: React.ReactNode }
|
||||
> = {
|
||||
pending: {
|
||||
label: "等待中",
|
||||
color: "default",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
waiting: {
|
||||
label: "排队中",
|
||||
color: "processing",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
running: {
|
||||
label: "进行中",
|
||||
color: "processing",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
color: "success",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
cancelled: {
|
||||
label: "已取消",
|
||||
color: "default",
|
||||
icon: <MinusCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
/** 任务类型标签 */
|
||||
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
generation: { label: "生成任务", color: "blue" },
|
||||
ingest: { label: "素材导入", color: "green" },
|
||||
};
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化耗时 */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return "-";
|
||||
if (seconds < 60) return `${Math.round(seconds)}秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
if (minutes < 60) return `${minutes}分${secs}秒`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours}小时${mins}分`;
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return "-";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/* ──────────── 主组件 ──────────── */
|
||||
|
||||
export default function TaskCenter() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
|
||||
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 查询参数
|
||||
const queryParams: TaskListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(statusFilter !== "all" && { status: statusFilter }),
|
||||
...(typeFilter !== "all" && { task_type: typeFilter }),
|
||||
};
|
||||
|
||||
// 获取任务列表
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["tasks", queryParams],
|
||||
queryFn: () => getTasks(queryParams),
|
||||
refetchInterval: (query) => {
|
||||
// 有进行中的任务时自动刷新
|
||||
const tasks = query.state.data?.items ?? [];
|
||||
const hasRunning = tasks.some(
|
||||
(t) => t.status === "running" || t.status === "waiting",
|
||||
);
|
||||
return hasRunning ? 5000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// 重试任务
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
message.success("任务已重新提交");
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("重试失败,请检查任务状态");
|
||||
},
|
||||
});
|
||||
|
||||
// 展开查看详情
|
||||
const handleExpand = async (expanded: boolean, record: TaskItem) => {
|
||||
if (!expanded) {
|
||||
setExpandedTaskId(null);
|
||||
setExpandedTaskDetail(null);
|
||||
return;
|
||||
}
|
||||
setExpandedTaskId(record.id);
|
||||
// 如果是失败任务,获取详情(含 error_info)
|
||||
if (record.status === "failed" && record.error_info) {
|
||||
setExpandedTaskDetail(record);
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<TaskItem> = [
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<span className="task-id">{id.slice(0, 8)}...</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "task_type",
|
||||
key: "task_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const config = TYPE_LABELS[type] || { label: type, color: "default" };
|
||||
return <Tag color={config.color}>{config.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: TaskStatus, record: TaskItem) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
icon={config.icon}
|
||||
className="task-status-tag"
|
||||
>
|
||||
{config.label}
|
||||
{status === "running" && record.progress > 0 && (
|
||||
<span className="task-progress"> {record.progress}%</span>
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "当前步骤",
|
||||
dataIndex: "current_step",
|
||||
key: "current_step",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (step: string) => (
|
||||
<span className="task-step">{step || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "耗时",
|
||||
dataIndex: "duration_seconds",
|
||||
key: "duration_seconds",
|
||||
width: 100,
|
||||
render: (seconds: number) => (
|
||||
<span className="task-duration">{formatDuration(seconds)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 120,
|
||||
render: (time: string) => (
|
||||
<span className="task-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 100,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: TaskItem) => {
|
||||
if (record.status === "failed" && record.retryable) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认重试"
|
||||
description="确定要重试这个失败的任务吗?"
|
||||
onConfirm={() => retryMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
loading={retryMutation.isPending}
|
||||
className="task-retry-btn"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
if (record.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => {
|
||||
setExpandedTaskId(record.id);
|
||||
setExpandedTaskDetail(record);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <span className="task-action-placeholder">-</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 展开行渲染(错误详情)
|
||||
const expandedRowRender = (record: TaskItem) => {
|
||||
const detail = expandedTaskDetail || record;
|
||||
const errorInfo = detail.error_info;
|
||||
|
||||
if (!errorInfo && !detail.error_message) {
|
||||
return <div className="task-expand-empty">暂无错误详情</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task-error-detail">
|
||||
<div className="task-error-header">
|
||||
<ExclamationCircleOutlined className="task-error-icon" />
|
||||
<span>错误详情</span>
|
||||
</div>
|
||||
<div className="task-error-body">
|
||||
{errorInfo?.error_type && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">错误类型:</span>
|
||||
<Tag color="error">{errorInfo.error_type}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{(errorInfo?.error_message || detail.error_message) && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">错误信息:</span>
|
||||
<span className="task-error-message">
|
||||
{errorInfo?.error_message || detail.error_message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo?.failed_step && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">失败阶段:</span>
|
||||
<span>{errorInfo.failed_step}</span>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo?.stack_trace && (
|
||||
<div className="task-error-row task-error-stack">
|
||||
<span className="task-error-label">堆栈信息:</span>
|
||||
<pre>{errorInfo.stack_trace}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
return (
|
||||
<div className="task-center">
|
||||
<div className="task-error">
|
||||
<CloseCircleOutlined />
|
||||
<p>加载任务列表失败</p>
|
||||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task-center">
|
||||
{/* 页面标题 */}
|
||||
<div className="task-header">
|
||||
<h1 className="task-title">任务中心</h1>
|
||||
<p className="task-subtitle">查看和管理所有生成任务与素材导入任务</p>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="task-filters">
|
||||
{/* 状态 Tab */}
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => {
|
||||
setStatusFilter(key as TaskStatus | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="task-status-tabs"
|
||||
/>
|
||||
|
||||
{/* 类型筛选 */}
|
||||
<div className="task-type-filter">
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(value) => {
|
||||
setTypeFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={TYPE_OPTIONS}
|
||||
style={{ width: 140 }}
|
||||
placeholder="选择类型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender,
|
||||
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
|
||||
onExpand: handleExpand,
|
||||
rowExpandable: (record) =>
|
||||
record.status === "failed" &&
|
||||
(!!record.error_info || !!record.error_message),
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
className="task-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/* ──────────── 任务中心 ──────────── */
|
||||
|
||||
.task-center {
|
||||
padding: var(--space-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.task-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.task-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.task-filters {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-status-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-tab {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.task-type-filter {
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-md);
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.task-table {
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-table .ant-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.task-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 任务 ID */
|
||||
.task-id {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.task-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
font-size: var(--font-size-xs);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 当前步骤 */
|
||||
.task-step {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 耗时 */
|
||||
.task-duration {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 时间 */
|
||||
.task-time {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.task-retry-btn {
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.task-retry-btn:hover {
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.task-action-placeholder {
|
||||
color: var(--text-disabled, var(--text-secondary));
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* 展开行 - 错误详情 */
|
||||
.task-error-detail {
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.task-error-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-error-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.task-error-label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.task-error-message {
|
||||
color: var(--error-500, #ef4444);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-error-stack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-error-stack pre {
|
||||
margin: var(--space-xs) 0 0 0;
|
||||
padding: var(--space-sm);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
max-height: 200px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.task-expand-empty {
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.task-empty {
|
||||
padding: var(--space-2xl) 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-empty .anticon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-empty p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.task-error {
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error .anticon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.task-error p {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.task-center {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-type-filter {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-type-filter .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-nav {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-tab {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,42 @@
|
||||
/**
|
||||
* 模板库页面(升级版)— V21 设计系统
|
||||
* 任务 2.13:模板类型分类展示、模板预览功能、创建 EditPlan 入口
|
||||
*
|
||||
* - 按 EditTemplate 类型分组展示
|
||||
* - 缩略图 + 预览弹窗
|
||||
* - 创建 EditPlan 入口 UI
|
||||
* - 使用 useQuery 对接后端真实 API(api/templates.ts, api/editPlans.ts)
|
||||
* 对接后端模板管理 API:
|
||||
* - 分页查询(page/page_size/category/keyword/duration_range)
|
||||
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
|
||||
* - 复制模板 / 从模板生成剪辑计划
|
||||
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
||||
*/
|
||||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd";
|
||||
import {
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
InboxOutlined,
|
||||
SearchOutlined,
|
||||
CopyOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
getTemplates,
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
type TemplateItem,
|
||||
type TemplateListParams,
|
||||
type TemplateSegment,
|
||||
} from "@/api/templates";
|
||||
import { createEditPlan } from "@/api/editPlans";
|
||||
import "./templates.css";
|
||||
|
||||
/* ============================================================
|
||||
* 类型定义(对齐后端 EditTemplate / EditPlan / TemplateClipConfig)
|
||||
* 类型定义
|
||||
* ============================================================ */
|
||||
|
||||
/** 模板片段配置 */
|
||||
interface TemplateClipConfig {
|
||||
id: string;
|
||||
order: number;
|
||||
clipType: string;
|
||||
description: string;
|
||||
duration: number; // 秒
|
||||
}
|
||||
|
||||
/** 模板类型 */
|
||||
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog";
|
||||
|
||||
/** 模板数据(UI 层,映射自后端 TemplateItem) */
|
||||
interface EditTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
type: EditTemplateType;
|
||||
description: string;
|
||||
usageCount: number;
|
||||
isFavorite: boolean;
|
||||
thumbnailGradient: string;
|
||||
scriptContent: string;
|
||||
clipConfigs: TemplateClipConfig[];
|
||||
recommendedDuration: number; // 秒
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 映射:后端 TemplateItem → 前端 EditTemplate
|
||||
* ============================================================ */
|
||||
|
||||
/** 根据 category 推断模板类型 */
|
||||
const inferTemplateType = (category: string): EditTemplateType => {
|
||||
const map: Record<string, EditTemplateType> = {
|
||||
口播: "口播",
|
||||
种草: "种草",
|
||||
产品: "产品",
|
||||
品牌: "品牌",
|
||||
混剪: "混剪",
|
||||
Vlog: "Vlog",
|
||||
};
|
||||
return map[category] ?? "口播";
|
||||
};
|
||||
|
||||
/** 根据 category 生成占位渐变色 */
|
||||
const gradientForCategory = (category: string): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
种草: "linear-gradient(135deg, #10b981, #059669)",
|
||||
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||
};
|
||||
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
|
||||
};
|
||||
|
||||
/** 将后端 TemplateItem 映射为前端 EditTemplate */
|
||||
const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: inferTemplateType(item.category),
|
||||
description: item.description ?? "",
|
||||
usageCount: 0,
|
||||
isFavorite: item.is_favorite ?? false,
|
||||
thumbnailGradient: gradientForCategory(item.category),
|
||||
scriptContent: "",
|
||||
clipConfigs: [],
|
||||
recommendedDuration: item.target_duration ?? 0,
|
||||
tags: [item.category],
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
* 模板类型配置
|
||||
* ============================================================ */
|
||||
@@ -120,98 +56,116 @@ const TEMPLATE_TYPES: Array<{
|
||||
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
|
||||
];
|
||||
|
||||
/** 时长筛选选项 */
|
||||
const DURATION_OPTIONS: Array<{
|
||||
value: "" | "short" | "medium" | "long";
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "", label: "全部时长" },
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* 辅助函数
|
||||
* ============================================================ */
|
||||
|
||||
/** 获取类型对应颜色 */
|
||||
const getTypeColor = (type: EditTemplateType): string => {
|
||||
const getTypeColor = (type: string): string => {
|
||||
const found = TEMPLATE_TYPES.find((t) => t.type === type);
|
||||
return found?.color ?? "#6366f1";
|
||||
};
|
||||
|
||||
/** 获取片段类型标签 */
|
||||
const getClipTypeLabel = (clipType: string): string => clipType;
|
||||
|
||||
/** 获取片段类型颜色 */
|
||||
const getClipTypeColor = (clipType: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
开场: "#6366f1",
|
||||
产品展示: "#0ea5e9",
|
||||
卖点讲解: "#10b981",
|
||||
结尾: "#f59e0b",
|
||||
场景引入: "#8b5cf6",
|
||||
产品体验: "#ec4899",
|
||||
效果对比: "#14b8a6",
|
||||
总结推荐: "#f59e0b",
|
||||
悬念开场: "#6366f1",
|
||||
产品全景: "#0ea5e9",
|
||||
功能演示: "#10b981",
|
||||
技术规格: "#64748b",
|
||||
品牌起源: "#f59e0b",
|
||||
发展历程: "#0ea5e9",
|
||||
核心理念: "#8b5cf6",
|
||||
未来展望: "#10b981",
|
||||
知识点1: "#6366f1",
|
||||
知识点2: "#8b5cf6",
|
||||
痛点: "#ef4444",
|
||||
产品引入: "#10b981",
|
||||
使用展示: "#0ea5e9",
|
||||
效果: "#f59e0b",
|
||||
产品亮相: "#6366f1",
|
||||
外观对比: "#0ea5e9",
|
||||
性能测试: "#10b981",
|
||||
总结: "#f59e0b",
|
||||
悬念: "#6366f1",
|
||||
亮点: "#10b981",
|
||||
福利: "#f59e0b",
|
||||
引导: "#0ea5e9",
|
||||
高能开场: "#ef4444",
|
||||
过渡: "#64748b",
|
||||
高潮: "#ec4899",
|
||||
早安: "#f59e0b",
|
||||
出门: "#10b981",
|
||||
日常: "#0ea5e9",
|
||||
晚安: "#8b5cf6",
|
||||
/** 根据 category 生成占位渐变色 */
|
||||
const gradientForCategory = (category: string): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
种草: "linear-gradient(135deg, #10b981, #059669)",
|
||||
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||
};
|
||||
return colorMap[clipType] ?? "#6366f1";
|
||||
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
|
||||
};
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return "0秒";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m === 0) return `${s}秒`;
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||
};
|
||||
|
||||
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
|
||||
interface ConfigDisplayFields {
|
||||
font_size?: string | number;
|
||||
font_family?: string;
|
||||
color?: string;
|
||||
position?: string;
|
||||
volume?: string | number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** 格式化配置对象为可读文本 */
|
||||
const formatConfig = (config?: object): string => {
|
||||
if (!config || Object.keys(config).length === 0) return "默认";
|
||||
const c = config as ConfigDisplayFields;
|
||||
const parts: string[] = [];
|
||||
if (c.font_size) parts.push(`字号: ${c.font_size}`);
|
||||
if (c.font_family) parts.push(`字体: ${c.font_family}`);
|
||||
if (c.color) parts.push(`颜色: ${c.color}`);
|
||||
if (c.position) parts.push(`位置: ${c.position}`);
|
||||
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`);
|
||||
if (c.name) parts.push(String(c.name));
|
||||
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config);
|
||||
};
|
||||
|
||||
/** 素材类型标签 */
|
||||
const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
audio: "音频",
|
||||
voiceover: "配音",
|
||||
subtitle: "字幕",
|
||||
null: "不限",
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 预览弹窗组件
|
||||
* 模板详情弹窗组件
|
||||
* ============================================================ */
|
||||
|
||||
interface TemplatePreviewModalProps {
|
||||
template: EditTemplate;
|
||||
interface TemplateDetailModalProps {
|
||||
template: TemplateItem;
|
||||
isFavorite: boolean;
|
||||
onClose: () => void;
|
||||
onToggleFavorite: (id: string) => void;
|
||||
onUse: (template: EditTemplate) => void;
|
||||
onUse: (template: TemplateItem) => void;
|
||||
onCopy: (template: TemplateItem) => void;
|
||||
}
|
||||
|
||||
const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
template,
|
||||
isFavorite,
|
||||
onClose,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
onCopy,
|
||||
}) => {
|
||||
const totalDuration = template.clipConfigs.reduce(
|
||||
(sum, c) => sum + c.duration,
|
||||
const segments = template.segments ?? [];
|
||||
const totalSegmentDuration = segments.reduce(
|
||||
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||
<div className="xx-template-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="xx-template-modal xx-template-modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
className="xx-template-modal-close"
|
||||
@@ -224,15 +178,23 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
|
||||
"📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
?.icon ?? "📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
@@ -243,12 +205,12 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
<span
|
||||
className="xx-template-modal-type-badge"
|
||||
style={{
|
||||
color: getTypeColor(template.type),
|
||||
background: `${getTypeColor(template.type)}18`,
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon}{" "}
|
||||
{template.type}
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon}{" "}
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -256,66 +218,128 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
<p className="xx-template-modal-desc">{template.description}</p>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="xx-template-modal-tags">
|
||||
{template.tags.map((tag) => (
|
||||
<span key={tag} className="xx-template-modal-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 脚本内容 */}
|
||||
{template.scriptContent && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>📝 脚本内容</h4>
|
||||
<pre className="xx-template-modal-script">
|
||||
{template.scriptContent}
|
||||
</pre>
|
||||
{(template.tags?.length ?? 0) > 0 && (
|
||||
<div className="xx-template-modal-tags">
|
||||
{template.tags!.map((tag) => (
|
||||
<span key={tag} className="xx-template-modal-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频结构 */}
|
||||
{template.clipConfigs.length > 0 && (
|
||||
{/* 基本信息 */}
|
||||
<Descriptions
|
||||
column={2}
|
||||
size="small"
|
||||
className="xx-template-modal-desc-table"
|
||||
items={[
|
||||
{
|
||||
key: "duration",
|
||||
label: "目标时长",
|
||||
children: formatDuration(template.target_duration),
|
||||
},
|
||||
{
|
||||
key: "clips",
|
||||
label: "片段数量",
|
||||
children: `${template.clip_count} 个`,
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
label: "视频比例",
|
||||
children: template.aspect_ratio ?? "16:9",
|
||||
},
|
||||
{
|
||||
key: "usage",
|
||||
label: "使用次数",
|
||||
children: `${template.usage_count ?? 0} 次`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 素材规则(片段配置) */}
|
||||
{segments.length > 0 && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 视频结构</h4>
|
||||
<h4>🎬 素材规则</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{template.clipConfigs
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((clip) => (
|
||||
<div key={clip.id} className="xx-template-modal-clip-item">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg: TemplateSegment, idx: number) => (
|
||||
<div
|
||||
key={seg.id ?? idx}
|
||||
className="xx-template-modal-clip-item"
|
||||
>
|
||||
<span className="xx-template-modal-clip-order">
|
||||
#{seg.segment_order}
|
||||
</span>
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: getClipTypeColor(clip.clipType),
|
||||
background: `${getClipTypeColor(clip.clipType)}18`,
|
||||
color: seg.material_type
|
||||
? getTypeColor(seg.material_type)
|
||||
: "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
{getClipTypeLabel(clip.clipType)}
|
||||
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
|
||||
seg.material_type ??
|
||||
"不限"}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{clip.description}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{clip.duration}秒
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip
|
||||
title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}
|
||||
>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
总时长:{formatDuration(totalDuration)}
|
||||
{template.recommendedDuration !== totalDuration && (
|
||||
<span>
|
||||
{" "}
|
||||
· 推荐时长:{formatDuration(template.recommendedDuration)}
|
||||
</span>
|
||||
)}
|
||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 样式配置 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎨 样式配置</h4>
|
||||
<div className="xx-template-modal-style-grid">
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.subtitle_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">标题样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.title_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.bgm_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">视频比例</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{template.aspect_ratio ?? "16:9"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usageCount} 次</span>
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
@@ -326,15 +350,15 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={onClose}>
|
||||
取消
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => onUse(template)}
|
||||
>
|
||||
使用此模板
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,11 +372,11 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
* ============================================================ */
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: EditTemplate;
|
||||
template: TemplateItem;
|
||||
isFavorite: boolean;
|
||||
onPreview: (template: EditTemplate) => void;
|
||||
onPreview: (template: TemplateItem) => void;
|
||||
onToggleFavorite: (id: string, e: React.MouseEvent) => void;
|
||||
onUse: (template: EditTemplate) => void;
|
||||
onUse: (template: TemplateItem) => void;
|
||||
}
|
||||
|
||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
@@ -366,15 +390,29 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<div className="xx-template-card" onClick={() => onPreview(template)}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-template-thumb">
|
||||
<div
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}...
|
||||
</div>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}
|
||||
{(template.description ?? "").length > 80 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-preview-hint">点击预览</div>
|
||||
<div className="xx-template-thumb-meta">
|
||||
<span className="xx-template-thumb-duration">
|
||||
{formatDuration(template.target_duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||
<button
|
||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={(e) => onToggleFavorite(template.id, e)}
|
||||
@@ -390,17 +428,22 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<span
|
||||
className="xx-template-category-pill"
|
||||
style={{
|
||||
color: getTypeColor(template.type),
|
||||
background: `${getTypeColor(template.type)}18`,
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{template.type}
|
||||
{template.category}
|
||||
</span>
|
||||
{(template.tags ?? []).slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||
<div className="xx-template-meta">
|
||||
<span className="xx-template-usage">
|
||||
已使用 {template.usageCount} 次
|
||||
已使用 {template.usage_count ?? 0} 次
|
||||
</span>
|
||||
<button
|
||||
className="xx-template-use-btn"
|
||||
@@ -424,98 +467,158 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
const TemplateLibrary: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">(
|
||||
"全部",
|
||||
);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(
|
||||
const [durationRange, setDurationRange] = useState<
|
||||
"" | "short" | "medium" | "long"
|
||||
>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(12);
|
||||
|
||||
// 弹窗状态
|
||||
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
|
||||
null,
|
||||
);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// ── 获取模板列表 ──
|
||||
// ── 构建查询参数 ──
|
||||
const queryParams: TemplateListParams = useMemo(() => {
|
||||
const params: TemplateListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
if (activeType !== "全部") params.category = activeType;
|
||||
if (searchText.trim()) params.keyword = searchText.trim();
|
||||
if (durationRange) params.duration_range = durationRange;
|
||||
return params;
|
||||
}, [page, pageSize, activeType, searchText, durationRange]);
|
||||
|
||||
// ── 获取模板列表(后端分页 + 筛选) ──
|
||||
const {
|
||||
data: apiTemplates = [],
|
||||
data: templateData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<TemplateItem[], Error>({
|
||||
queryKey: ["templates"],
|
||||
queryFn: getTemplates,
|
||||
staleTime: 60_000,
|
||||
} = useQuery({
|
||||
queryKey: ["templates", queryParams],
|
||||
queryFn: () => getTemplates(queryParams),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// 将后端数据映射为前端 EditTemplate
|
||||
const templates = useMemo(
|
||||
() => apiTemplates.map(mapTemplateItemToEditTemplate),
|
||||
[apiTemplates],
|
||||
);
|
||||
const templates = templateData?.items ?? [];
|
||||
const totalTemplates = templateData?.total ?? 0;
|
||||
|
||||
// ── 收藏 mutation ──
|
||||
const favMutation = useMutation({
|
||||
mutationFn: toggleFavoriteTemplate,
|
||||
onSuccess: (_data, templateId) => {
|
||||
// 乐观更新:刷新模板列表
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
// 同时更新当前预览(如果有)
|
||||
if (previewTemplate && previewTemplate.id === templateId) {
|
||||
setPreviewTemplate((prev) =>
|
||||
prev ? { ...prev, isFavorite: !prev.isFavorite } : prev,
|
||||
prev ? { ...prev, is_favorite: !prev.is_favorite } : prev,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── 创建 EditPlan mutation ──
|
||||
const createPlanMutation = useMutation({
|
||||
mutationFn: (params: { template_id: string; name: string }) =>
|
||||
createEditPlan(params),
|
||||
// ── 复制模板 mutation ──
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: copyTemplate,
|
||||
onSuccess: (data) => {
|
||||
message.success(`模板「${data.name}」已复制到「我的模板」`);
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("复制模板失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// ── 从模板生成剪辑计划 mutation ──
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: ({ templateId, name }: { templateId: string; name: string }) =>
|
||||
generateFromTemplate(templateId, { name }),
|
||||
onSuccess: (data) => {
|
||||
message.success(`剪辑计划「${data.name}」已创建`);
|
||||
navigate("/app/edit-plans");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("生成剪辑计划失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
/** 切换收藏 */
|
||||
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
favMutation.mutate(id);
|
||||
};
|
||||
const toggleFavorite = useCallback(
|
||||
(id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
favMutation.mutate(id);
|
||||
},
|
||||
[favMutation],
|
||||
);
|
||||
|
||||
/** 过滤模板 */
|
||||
const filtered = useMemo(() => {
|
||||
return templates.filter((t) => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(t.description ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()) ||
|
||||
t.tags.some((tag) =>
|
||||
tag.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
const matchType = activeType === "全部" || t.type === activeType;
|
||||
return matchSearch && matchType;
|
||||
});
|
||||
}, [searchText, activeType, templates]);
|
||||
|
||||
/** 按类型分组 */
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const groups: Record<string, EditTemplate[]> = {};
|
||||
for (const tpl of filtered) {
|
||||
if (!groups[tpl.type]) groups[tpl.type] = [];
|
||||
groups[tpl.type].push(tpl);
|
||||
}
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
|
||||
/** 使用模板 → 创建 EditPlan */
|
||||
const handleUseTemplate = async (template: EditTemplate) => {
|
||||
/** 点击卡片 → 获取详情并展示弹窗 */
|
||||
const handlePreview = useCallback(async (template: TemplateItem) => {
|
||||
setDetailLoading(true);
|
||||
setPreviewTemplate(template);
|
||||
try {
|
||||
await createPlanMutation.mutateAsync({
|
||||
template_id: template.id,
|
||||
const detail = await getTemplate(template.id);
|
||||
setPreviewTemplate(detail);
|
||||
} catch {
|
||||
// 详情加载失败时使用列表数据
|
||||
message.warning("模板详情加载失败,显示摘要信息");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 复制模板 */
|
||||
const handleCopy = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
copyMutation.mutate(template.id);
|
||||
},
|
||||
[copyMutation],
|
||||
);
|
||||
|
||||
/** 使用模板 → 生成剪辑计划 */
|
||||
const handleUse = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
generateMutation.mutate({
|
||||
templateId: template.id,
|
||||
name: `基于「${template.name}」的剪辑计划`,
|
||||
});
|
||||
navigate("/app/editing-planner");
|
||||
} catch (err) {
|
||||
console.error("[TemplateLibrary] createEditPlan failed:", err);
|
||||
}
|
||||
};
|
||||
},
|
||||
[generateMutation, navigate],
|
||||
);
|
||||
|
||||
/** 搜索防抖处理 */
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(e.target.value);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 切换分类 */
|
||||
const handleCategoryChange = useCallback(
|
||||
(type: EditTemplateType | "全部") => {
|
||||
setActiveType(type);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 切换时长筛选 */
|
||||
const handleDurationChange = useCallback(
|
||||
(value: "" | "short" | "medium" | "long") => {
|
||||
setDurationRange(value);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
@@ -554,16 +657,12 @@ const TemplateLibrary: React.FC = () => {
|
||||
<h2>模板库</h2>
|
||||
<p>选择模板快速创建剪辑计划,支持自定义修改</p>
|
||||
</div>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/editing-planner")}
|
||||
>
|
||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
||||
+ 创建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
|
||||
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
|
||||
<div className="xx-templates-toolbar">
|
||||
<div className="xx-templates-search">
|
||||
<span className="xx-templates-search-icon">
|
||||
@@ -574,7 +673,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
type="text"
|
||||
placeholder="搜索模板名称、描述或标签..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-templates-categories">
|
||||
@@ -582,90 +681,94 @@ const TemplateLibrary: React.FC = () => {
|
||||
<button
|
||||
key={cat.type}
|
||||
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
|
||||
onClick={() => setActiveType(cat.type)}
|
||||
onClick={() => handleCategoryChange(cat.type)}
|
||||
>
|
||||
<span className="xx-templates-cat-icon">{cat.icon}</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 时长筛选 */}
|
||||
<div className="xx-templates-duration-filter">
|
||||
{DURATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleDurationChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 模板展示区 ────────────────────────────────────────── */}
|
||||
{filtered.length === 0 ? (
|
||||
{templates.length === 0 ? (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<h3>
|
||||
{searchText || activeType !== "全部"
|
||||
{searchText || activeType !== "全部" || durationRange
|
||||
? "未找到匹配的模板"
|
||||
: "暂无模板"}
|
||||
</h3>
|
||||
<p>
|
||||
{searchText || activeType !== "全部"
|
||||
{searchText || activeType !== "全部" || durationRange
|
||||
? "试试调整搜索条件或切换类型"
|
||||
: "点击上方「创建模板」开始创作"}
|
||||
</p>
|
||||
</div>
|
||||
) : activeType === "全部" ? (
|
||||
/* 全部类型 → 按类型分组展示 */
|
||||
<div className="xx-templates-grouped">
|
||||
{Object.entries(groupedTemplates).map(([type, tpls]) => {
|
||||
const typeConfig = TEMPLATE_TYPES.find((t) => t.type === type);
|
||||
return (
|
||||
<div key={type} className="xx-templates-group">
|
||||
<div className="xx-templates-group-header">
|
||||
<span className="xx-templates-group-icon">
|
||||
{typeConfig?.icon ?? "📋"}
|
||||
</span>
|
||||
<h3>{type}</h3>
|
||||
<span className="xx-templates-group-count">
|
||||
{tpls.length} 个模板
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-templates-grid">
|
||||
{tpls.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.isFavorite}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* 单类型 → 平铺网格 */
|
||||
<div className="xx-templates-grid">
|
||||
{filtered.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.isFavorite}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="xx-templates-grid">
|
||||
{templates.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.is_favorite ?? false}
|
||||
onPreview={handlePreview}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalTemplates > pageSize && (
|
||||
<div className="xx-templates-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={totalTemplates}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
showTotal={(total) => `共 ${total} 个模板`}
|
||||
onChange={(p) => setPage(p)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 预览弹窗 ──────────────────────────────────────────── */}
|
||||
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
|
||||
{previewTemplate && (
|
||||
<TemplatePreviewModal
|
||||
<TemplateDetailModal
|
||||
template={previewTemplate}
|
||||
isFavorite={previewTemplate.isFavorite}
|
||||
isFavorite={previewTemplate.is_favorite ?? false}
|
||||
onClose={() => setPreviewTemplate(null)}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
onUse={handleUse}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 详情加载中的提示(可选覆盖层) */}
|
||||
{detailLoading && previewTemplate && (
|
||||
<div className="xx-template-detail-loading">
|
||||
<LoadingOutlined /> 加载中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -130,12 +130,20 @@ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
|
||||
};
|
||||
};
|
||||
|
||||
/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
|
||||
interface VoiceAssetMetadata {
|
||||
gender: VoiceGender;
|
||||
description: string;
|
||||
duration: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style) */
|
||||
const buildMetadata = (data: {
|
||||
gender: VoiceGender;
|
||||
description: string;
|
||||
duration?: number;
|
||||
}): Record<string, unknown> => ({
|
||||
}): VoiceAssetMetadata => ({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration: data.duration || 0,
|
||||
|
||||
@@ -623,12 +623,20 @@ const getAudioDuration = (file: File): Promise<number> =>
|
||||
audio.src = url;
|
||||
});
|
||||
|
||||
/** 音色上传元数据(传递给 createAsset 的 metadata) */
|
||||
interface VoiceUploadMetadata {
|
||||
gender?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const buildVoiceMetadata = (data: {
|
||||
gender?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
}): Record<string, unknown> => {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
}): VoiceUploadMetadata => {
|
||||
const metadata: VoiceUploadMetadata = {};
|
||||
if (data.gender) metadata.gender = data.gender;
|
||||
if (data.description) metadata.description = data.description;
|
||||
if (data.duration) metadata.duration = Math.round(data.duration);
|
||||
|
||||
@@ -135,6 +135,13 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "tasks",
|
||||
lazy: () =>
|
||||
import("@/pages/tasks/TaskCenter").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "editing-planner",
|
||||
lazy: () =>
|
||||
@@ -149,6 +156,13 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "edit-plans",
|
||||
lazy: () =>
|
||||
import("@/pages/edit-plans/EditPlans").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: () =>
|
||||
|
||||
+697
@@ -0,0 +1,697 @@
|
||||
"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片.
|
||||
|
||||
基于 FFmpeg 实现两种拼接模式:
|
||||
1. **concat demuxer(stream copy)**:最快,所有视频编码参数必须一致
|
||||
2. **concat filter(重新编码)**:更灵活,支持不同分辨率/编码/帧率的视频
|
||||
|
||||
使用场景:
|
||||
- 多段素材按顺序合并成一个视频
|
||||
- 视频分割后重新拼接
|
||||
- 片头 + 正片 + 片尾拼接
|
||||
|
||||
降级策略:
|
||||
- 优先尝试 stream copy(速度快、无质量损失)
|
||||
- 参数不一致时自动降级到 concat filter
|
||||
- 某段视频失败时跳过,不阻断整体拼接
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
|
||||
|
||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
|
||||
|
||||
# concat demuxer 要求一致的参数列表
|
||||
CONCAT_DEMUXER_REQUIRED_PARAMS = [
|
||||
"codec_name", # 视频编码
|
||||
"width", # 宽度
|
||||
"height", # 高度
|
||||
"r_frame_rate", # 帧率
|
||||
"pix_fmt", # 像素格式
|
||||
"sample_rate", # 音频采样率
|
||||
"channels", # 音频声道数
|
||||
"audio_codec", # 音频编码
|
||||
]
|
||||
|
||||
|
||||
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConcatSegment:
|
||||
"""单个拼接片段."""
|
||||
|
||||
video_path: str # 视频文件路径
|
||||
start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取
|
||||
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
|
||||
has_audio: bool = True # 是否包含音频
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, seg: dict) -> "ConcatSegment":
|
||||
"""从字典创建拼接片段,带安全类型转换."""
|
||||
try:
|
||||
start_time = max(0.0, float(seg.get("start_time", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
start_time = 0.0
|
||||
|
||||
try:
|
||||
duration = max(0.0, float(seg.get("duration", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
duration = 0.0
|
||||
|
||||
return cls(
|
||||
video_path=str(seg.get("video_path", "")),
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
has_audio=bool(seg.get("has_audio", True)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConcatConfig:
|
||||
"""视频拼接配置."""
|
||||
|
||||
segments: list[ConcatSegment] = field(default_factory=list)
|
||||
output_width: int = 0 # 输出宽度(0=自动取第一段)
|
||||
output_height: int = 0 # 输出高度(0=自动取第一段)
|
||||
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
|
||||
force_reencode: bool = False # 强制重新编码(不用 stream copy)
|
||||
transition: str = "none" # 转场效果(none/crossfade)- 预留
|
||||
transition_duration: float = 0.3 # 转场时长
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, config: dict | None) -> "ConcatConfig":
|
||||
"""从配置字典创建 ConcatConfig."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
segments_raw = config.get("segments", [])
|
||||
segments: list[ConcatSegment] = []
|
||||
|
||||
if isinstance(segments_raw, list):
|
||||
for s in segments_raw:
|
||||
if isinstance(s, dict) and s.get("video_path"):
|
||||
try:
|
||||
seg = ConcatSegment.from_dict(s)
|
||||
if seg.video_path:
|
||||
segments.append(seg)
|
||||
except Exception:
|
||||
logger.warning("[concat] skip invalid segment: %s", s)
|
||||
continue
|
||||
|
||||
try:
|
||||
output_width = max(0, int(config.get("output_width", 0)))
|
||||
except (TypeError, ValueError):
|
||||
output_width = 0
|
||||
|
||||
try:
|
||||
output_height = max(0, int(config.get("output_height", 0)))
|
||||
except (TypeError, ValueError):
|
||||
output_height = 0
|
||||
|
||||
try:
|
||||
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
output_fps = 0.0
|
||||
|
||||
return cls(
|
||||
segments=segments,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
output_fps=output_fps,
|
||||
force_reencode=bool(config.get("force_reencode", False)),
|
||||
transition=str(config.get("transition", "none")),
|
||||
transition_duration=max(0.1, float(config.get("transition_duration", 0.3))),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_effect(self) -> bool:
|
||||
"""是否有有效片段需要拼接."""
|
||||
return len([s for s in self.segments if s.video_path]) >= 2
|
||||
|
||||
@property
|
||||
def total_segments(self) -> int:
|
||||
"""有效片段数量."""
|
||||
return len([s for s in self.segments if s.video_path])
|
||||
|
||||
|
||||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_video_path(video_path: str, work_dir: Path) -> None:
|
||||
"""校验视频文件路径安全性.
|
||||
|
||||
规则:
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是视频格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not video_path or not isinstance(video_path, str):
|
||||
raise PathSecurityError("视频路径不能为空")
|
||||
|
||||
# 本地路径(local:// 或相对路径 / 绝对路径)
|
||||
if video_path.startswith("local://") or not video_path.startswith(("http://", "https://", "oss://")):
|
||||
is_abs = video_path.startswith("/") and not video_path.startswith("local://")
|
||||
resolved_path = safe_resolve_path(
|
||||
video_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_VIDEO_EXTENSIONS,
|
||||
)
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
|
||||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||||
# 但检查扩展名
|
||||
else:
|
||||
path_part = video_path.split("?")[0].split("#")[0]
|
||||
ext = Path(path_part).suffix.lower()
|
||||
if ext and ext not in ALLOWED_VIDEO_EXTENSIONS:
|
||||
raise PathSecurityError(f"不允许的视频文件类型: {ext}")
|
||||
|
||||
|
||||
# ── 视频拼接引擎 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConcatEngine:
|
||||
"""视频拼接引擎 — 支持 stream copy 和重新编码两种模式."""
|
||||
|
||||
def __init__(self, work_dir: Path):
|
||||
self.work_dir = work_dir
|
||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── 主入口 ────────────────────────────────────────────────────────
|
||||
|
||||
def concat_videos(
|
||||
self,
|
||||
config: ConcatConfig,
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
"""拼接多段视频.
|
||||
|
||||
自动选择最优拼接策略:
|
||||
1. 所有片段参数一致 → concat demuxer(stream copy,最快)
|
||||
2. 参数不一致或有裁剪 → concat filter(重新编码)
|
||||
|
||||
Args:
|
||||
config: 拼接配置
|
||||
output_path: 输出文件路径
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
|
||||
if not valid_segments:
|
||||
raise ValueError("No valid video segments to concat")
|
||||
|
||||
# ── 安全校验:段数上限 ──
|
||||
if len(valid_segments) > MAX_CONCAT_SEGMENTS:
|
||||
raise ValueError(f"Too many concat segments: {len(valid_segments)} > {MAX_CONCAT_SEGMENTS}")
|
||||
|
||||
# ── 安全校验:所有视频路径白名单校验 ──
|
||||
safe_segments = []
|
||||
for seg in valid_segments:
|
||||
try:
|
||||
_validate_video_path(seg.video_path, self.work_dir)
|
||||
safe_segments.append(seg)
|
||||
except PathSecurityError as e:
|
||||
logger.warning("[concat] skip segment: path security check failed: %s", e)
|
||||
|
||||
if len(safe_segments) != len(valid_segments):
|
||||
valid_segments = safe_segments
|
||||
config.segments = safe_segments
|
||||
logger.info("[concat] %d segments passed security check", len(safe_segments))
|
||||
|
||||
if not valid_segments:
|
||||
raise ValueError("No valid video segments after security check")
|
||||
|
||||
if len(valid_segments) == 1:
|
||||
# 只有一段,直接复制
|
||||
import shutil
|
||||
|
||||
logger.info("[concat] single segment, copy directly")
|
||||
shutil.copy2(valid_segments[0].video_path, output_path)
|
||||
return output_path
|
||||
|
||||
# 判断能否用 stream copy
|
||||
can_stream_copy = self._can_use_stream_copy(config)
|
||||
|
||||
if can_stream_copy and not config.force_reencode:
|
||||
logger.info("[concat] using concat demuxer (stream copy)")
|
||||
try:
|
||||
return self._concat_demuxer(config, output_path)
|
||||
except Exception as e:
|
||||
logger.warning("[concat] demuxer failed, fallback to filter: %s", e)
|
||||
|
||||
# 降级到 concat filter
|
||||
logger.info("[concat] using concat filter (re-encode)")
|
||||
return self._concat_filter(config, output_path)
|
||||
|
||||
# ── 模式判断 ──────────────────────────────────────────────────────
|
||||
|
||||
def _can_use_stream_copy(self, config: ConcatConfig) -> bool:
|
||||
"""判断是否可以使用 concat demuxer(stream copy).
|
||||
|
||||
条件:
|
||||
1. 所有视频编码参数一致(分辨率、帧率、编码、像素格式)
|
||||
2. 所有音频参数一致(采样率、声道、编码)
|
||||
3. 没有设置 start_time 裁剪(或可以通过 concat demuxer 的 inpoint/outpoint 实现)
|
||||
4. 没有强制重新编码
|
||||
"""
|
||||
if config.force_reencode:
|
||||
return False
|
||||
|
||||
# 如果有转场效果,必须重新编码
|
||||
if config.transition != "none":
|
||||
return False
|
||||
|
||||
# 探测所有视频的参数
|
||||
video_infos = []
|
||||
for seg in config.segments:
|
||||
if not seg.video_path:
|
||||
continue
|
||||
try:
|
||||
info = probe_video_info(seg.video_path)
|
||||
video_infos.append(info)
|
||||
except Exception:
|
||||
logger.warning("[concat] probe failed for %s", seg.video_path[-40:])
|
||||
return False
|
||||
|
||||
if len(video_infos) < 2:
|
||||
return False
|
||||
|
||||
# 检查参数一致性
|
||||
base_info = video_infos[0]
|
||||
for info in video_infos[1:]:
|
||||
for param in CONCAT_DEMUXER_REQUIRED_PARAMS:
|
||||
base_val = base_info.get(param)
|
||||
curr_val = info.get(param)
|
||||
if base_val != curr_val:
|
||||
logger.debug(
|
||||
"[concat] param mismatch: %s (%s vs %s)",
|
||||
param,
|
||||
base_val,
|
||||
curr_val,
|
||||
)
|
||||
return False
|
||||
|
||||
# 检查是否有裁剪需求
|
||||
# concat demuxer 支持 inpoint/outpoint,所以有裁剪也可以用
|
||||
# 但为了简单和稳定性,有裁剪时也用 filter 模式
|
||||
# (inpoint/outpoint 不是所有格式都支持得好)
|
||||
has_trimming = any(seg.start_time > 0 or seg.duration > 0 for seg in config.segments if seg.video_path)
|
||||
if has_trimming:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# ── 模式1:concat demuxer(stream copy) ──────────────────────────
|
||||
|
||||
def _concat_demuxer(self, config: ConcatConfig, output_path: Path) -> Path:
|
||||
"""使用 concat demuxer 拼接(stream copy).
|
||||
|
||||
优点:速度极快,无质量损失
|
||||
缺点:要求所有视频参数完全一致
|
||||
"""
|
||||
# 生成 concat 文件列表
|
||||
list_file = self.work_dir / "concat_list.txt"
|
||||
lines = []
|
||||
for seg in config.segments:
|
||||
if not seg.video_path:
|
||||
continue
|
||||
# 路径转义:单引号替换为 '\''
|
||||
safe_path = str(seg.video_path).replace("'", "'\\''")
|
||||
lines.append(f"file '{safe_path}'")
|
||||
|
||||
list_file.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(list_file),
|
||||
"-c",
|
||||
"copy",
|
||||
"-copyts",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("[concat] demuxer: %d segments", config.total_segments)
|
||||
run_ffmpeg(command)
|
||||
return output_path
|
||||
|
||||
# ── 模式2:concat filter(重新编码) ──────────────────────────────
|
||||
|
||||
def _concat_filter(self, config: ConcatConfig, output_path: Path) -> Path:
|
||||
"""使用 concat filter 拼接(重新编码).
|
||||
|
||||
优点:支持不同参数的视频,支持裁剪
|
||||
缺点:需要重新编码,较慢
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
num_segments = len(valid_segments)
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
for seg in valid_segments:
|
||||
input_args.extend(["-i", seg.video_path])
|
||||
|
||||
# 确定输出参数
|
||||
output_width, output_height, output_fps = self._get_output_params(config)
|
||||
|
||||
# 构建 filter_complex
|
||||
filter_parts: list[str] = []
|
||||
concat_inputs = ""
|
||||
|
||||
for i, seg in enumerate(valid_segments):
|
||||
vid_label = f"v{i}"
|
||||
aud_label = f"a{i}"
|
||||
|
||||
seg_filters: list[str] = []
|
||||
|
||||
# 1. 裁剪(start_time + duration)
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
seg_filters.append(f"trim=start={start:.3f}:end={end:.3f}")
|
||||
else:
|
||||
seg_filters.append(f"trim=start={start:.3f}")
|
||||
seg_filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 音频同步裁剪
|
||||
if seg.has_audio:
|
||||
if seg.duration > 0:
|
||||
filter_parts.append(
|
||||
f"[{i}:a]atrim=start={start:.3f}:end={end:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]"
|
||||
)
|
||||
else:
|
||||
filter_parts.append(f"[{i}:a]atrim=start={start:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]")
|
||||
else:
|
||||
# 无音频时生成静音轨
|
||||
filter_parts.append(
|
||||
f"[{i}:v]trim=start={start:.3f}," f"setpts=PTS-STARTPTS, " f"aevalsrc=0:d={0.1}[{aud_label}]"
|
||||
)
|
||||
else:
|
||||
# 无裁剪,直接用原始标签
|
||||
if not seg.has_audio:
|
||||
# 无音频时需要生成静音
|
||||
try:
|
||||
dur = probe_duration(seg.video_path)
|
||||
except Exception:
|
||||
dur = 10.0
|
||||
filter_parts.append(f"aevalsrc=0:d={dur:.3f}:s=44100[{aud_label}]")
|
||||
|
||||
# 2. 缩放/帧率统一
|
||||
vf_parts = []
|
||||
if not seg_filters:
|
||||
vf_parts.append(f"[{i}:v]")
|
||||
else:
|
||||
vf_parts.append("")
|
||||
|
||||
# 分辨率统一
|
||||
if output_width and output_height:
|
||||
vf_parts.append(
|
||||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black"
|
||||
)
|
||||
|
||||
# 帧率统一
|
||||
if output_fps > 0:
|
||||
vf_parts.append(f"fps={output_fps}")
|
||||
|
||||
# 像素格式统一
|
||||
vf_parts.append("format=yuv420p")
|
||||
|
||||
if len(vf_parts) > 1 or (seg_filters and vf_parts):
|
||||
if seg_filters:
|
||||
# 先裁剪后缩放
|
||||
crop_str = "".join(seg_filters)
|
||||
scale_str = "".join(vf_parts[1:]) # 跳过空字符串
|
||||
if scale_str:
|
||||
filter_parts.append(f"[{i}:v]{crop_str},{scale_str}[{vid_label}]")
|
||||
else:
|
||||
filter_parts.append(f"[{i}:v]{crop_str}[{vid_label}]")
|
||||
else:
|
||||
filter_parts.append(f"{vf_parts[0]}{''.join(vf_parts[1:])}[{vid_label}]")
|
||||
else:
|
||||
if seg_filters:
|
||||
filter_parts.append(f"[{i}:v]{''.join(seg_filters)}[{vid_label}]")
|
||||
else:
|
||||
# 什么都不需要,直接用输入
|
||||
pass
|
||||
|
||||
# 拼接 concat 的输入标签
|
||||
if seg_filters or (output_width and output_height) or output_fps > 0:
|
||||
concat_inputs += f"[{vid_label}]"
|
||||
else:
|
||||
concat_inputs += f"[{i}:v]"
|
||||
|
||||
# 音频标签
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
# 已经生成了 aud_label
|
||||
pass
|
||||
elif not seg.has_audio:
|
||||
# 已经生成了静音 aud_label
|
||||
pass
|
||||
else:
|
||||
# 使用原始音频
|
||||
pass
|
||||
|
||||
# 简化处理:用更直接的方式构建 filter
|
||||
# 重新整理一下,确保所有输入都有对应的 v_i 和 a_i 标签
|
||||
filter_parts.clear()
|
||||
concat_inputs = "" # 按段交织: [v0][a0][v1][a1]...
|
||||
|
||||
for i, seg in enumerate(valid_segments):
|
||||
v_label = f"v{i}_in"
|
||||
a_label = f"a{i}_in"
|
||||
|
||||
# 视频处理链
|
||||
v_steps: list[str] = [f"[{i}:v]"]
|
||||
|
||||
# 裁剪
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
v_steps.append(f"trim=start={start:.3f}:end={end:.3f},")
|
||||
else:
|
||||
v_steps.append(f"trim=start={start:.3f},")
|
||||
v_steps.append("setpts=PTS-STARTPTS,")
|
||||
|
||||
# 缩放
|
||||
if output_width and output_height:
|
||||
v_steps.append(
|
||||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black,"
|
||||
)
|
||||
|
||||
# 帧率
|
||||
if output_fps > 0:
|
||||
v_steps.append(f"fps={output_fps},")
|
||||
|
||||
# 像素格式
|
||||
v_steps.append("format=yuv420p")
|
||||
|
||||
v_filter = "".join(v_steps) + f"[{v_label}]"
|
||||
filter_parts.append(v_filter)
|
||||
|
||||
# 音频处理链
|
||||
a_steps: list[str] = []
|
||||
if seg.has_audio:
|
||||
a_steps.append(f"[{i}:a]")
|
||||
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
a_steps.append(f"atrim=start={start:.3f}:end={end:.3f},")
|
||||
else:
|
||||
a_steps.append(f"atrim=start={start:.3f},")
|
||||
a_steps.append("asetpts=PTS-STARTPTS,")
|
||||
|
||||
a_steps.append("aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo")
|
||||
else:
|
||||
# 生成静音音频
|
||||
try:
|
||||
dur = probe_duration(seg.video_path)
|
||||
except Exception:
|
||||
dur = 10.0
|
||||
# 减去裁剪
|
||||
if seg.start_time > 0:
|
||||
dur = max(0.1, dur - seg.start_time)
|
||||
if seg.duration > 0 and seg.duration < dur:
|
||||
dur = seg.duration
|
||||
a_steps.append(f"aevalsrc=0:d={dur:.3f}:s=44100:c=stereo")
|
||||
|
||||
a_filter = "".join(a_steps) + f"[{a_label}]"
|
||||
filter_parts.append(a_filter)
|
||||
|
||||
# 按段交织排列(v_i, a_i),这是 FFmpeg concat filter 要求的顺序
|
||||
concat_inputs += f"[{v_label}][{a_label}]"
|
||||
|
||||
# concat filter: 输入按 [v0][a0][v1][a1]... 顺序
|
||||
filter_parts.append(f"{concat_inputs}" f"concat=n={num_segments}:v=1:a=1[vout][aout]")
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*input_args,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[concat] filter: %d segments, %dx%d, %.2f fps",
|
||||
num_segments,
|
||||
output_width,
|
||||
output_height,
|
||||
output_fps,
|
||||
)
|
||||
run_ffmpeg(command)
|
||||
return output_path
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────
|
||||
|
||||
def _get_output_params(self, config: ConcatConfig) -> tuple[int, int, float]:
|
||||
"""获取输出参数(宽、高、帧率).
|
||||
|
||||
优先级:
|
||||
1. config 中显式指定的
|
||||
2. 第一段视频的参数
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
|
||||
width = config.output_width
|
||||
height = config.output_height
|
||||
fps = config.output_fps
|
||||
|
||||
# 如果没有显式指定,用第一段的参数
|
||||
if (width == 0 or height == 0 or fps == 0) and valid_segments:
|
||||
try:
|
||||
info = probe_video_info(valid_segments[0].video_path)
|
||||
if width == 0:
|
||||
width = int(info.get("width", 1080))
|
||||
if height == 0:
|
||||
height = int(info.get("height", 1920))
|
||||
if fps == 0:
|
||||
fps_str = info.get("r_frame_rate", "30/1")
|
||||
if "/" in str(fps_str):
|
||||
num, den = str(fps_str).split("/")
|
||||
try:
|
||||
fps = float(num) / float(den)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
fps = 30.0
|
||||
else:
|
||||
fps = float(fps_str) if fps_str else 30.0
|
||||
except Exception:
|
||||
# 探测失败,用默认值
|
||||
if width == 0:
|
||||
width = 1080
|
||||
if height == 0:
|
||||
height = 1920
|
||||
if fps == 0:
|
||||
fps = 30.0
|
||||
|
||||
return width, height, fps
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def concat_video_files(
|
||||
video_paths: list[str],
|
||||
output_path: Path,
|
||||
*,
|
||||
work_dir: Path | None = None,
|
||||
force_reencode: bool = False,
|
||||
) -> Path:
|
||||
"""简单拼接多个视频文件.
|
||||
|
||||
Args:
|
||||
video_paths: 视频文件路径列表
|
||||
output_path: 输出路径
|
||||
work_dir: 工作目录(默认输出文件所在目录)
|
||||
force_reencode: 是否强制重新编码
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
if work_dir is None:
|
||||
work_dir = output_path.parent
|
||||
|
||||
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
|
||||
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
|
||||
|
||||
engine = ConcatEngine(work_dir)
|
||||
return engine.concat_videos(config, output_path)
|
||||
|
||||
|
||||
def concat_videos_from_config(
|
||||
config_dict: dict | None,
|
||||
output_path: Path,
|
||||
*,
|
||||
work_dir: Path,
|
||||
) -> Path | None:
|
||||
"""从配置字典执行视频拼接.
|
||||
|
||||
降级策略:配置无效或拼接失败时返回 None.
|
||||
"""
|
||||
config = ConcatConfig.from_config_dict(config_dict)
|
||||
if not config.has_effect:
|
||||
return None
|
||||
|
||||
try:
|
||||
engine = ConcatEngine(work_dir)
|
||||
return engine.concat_videos(config, output_path)
|
||||
except Exception as e:
|
||||
logger.error("[concat] concat failed: %s", e)
|
||||
return None
|
||||
@@ -1,23 +1,28 @@
|
||||
"""FFmpeg 工具函数 — 共享原语.
|
||||
"""FFmpeg 工具函数 — Worker 层.
|
||||
|
||||
提供 FFmpeg / FFprobe 调用、视频信息探测、视频标准化、xfade 转场滤镜构建
|
||||
等底层能力,供 UnifiedRenderService、VideoComposeService 等复用。
|
||||
业务相关的滤镜构建、视频探测、视频标准化等能力放在这里;
|
||||
底层原语(run_ffmpeg / 二进制路径 / 默认超时)已下沉到 packages/shared/ffmpeg_utils.py,
|
||||
本模块 re-export 保持向后兼容。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 底层原语从 shared 层导入,application 层和 worker 层共用同一份实现
|
||||
from shared.ffmpeg_utils import ( # noqa: F401
|
||||
DEFAULT_FFMPEG_TIMEOUT,
|
||||
FFMPEG_BIN,
|
||||
FFPROBE_BIN,
|
||||
run_ffmpeg,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
|
||||
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
|
||||
# ── 常量(Worker 层业务相关) ────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_OUTPUT_WIDTH = 1280
|
||||
DEFAULT_OUTPUT_HEIGHT = 720
|
||||
@@ -61,34 +66,29 @@ XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致 worker 永久阻塞
|
||||
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
|
||||
DEFAULT_FFMPEG_TIMEOUT = 1800
|
||||
|
||||
# ── FFprobe 探测 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_ffmpeg(
|
||||
def run_ffprobe(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool = True,
|
||||
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
|
||||
timeout: int = 30,
|
||||
) -> tuple[str, str]:
|
||||
"""执行 FFmpeg 命令。
|
||||
"""执行 FFprobe 命令。
|
||||
|
||||
Args:
|
||||
command: 完整的 ffmpeg 命令列表(含 "ffmpeg" 本身)
|
||||
command: 完整的 ffprobe 命令列表(含 "ffprobe" 本身)
|
||||
capture_output: 是否捕获 stdout/stderr
|
||||
timeout: 超时时间(秒),默认 1800s(30分钟);None 表示不设超时(不推荐)
|
||||
timeout: 超时时间(秒),默认 30s;None 表示不设超时
|
||||
|
||||
Returns:
|
||||
(stdout, stderr) 元组
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出,
|
||||
异常信息包含完整 stderr 以便排查。
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出,FFmpeg 进程会被 kill。
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
@@ -102,19 +102,18 @@ def run_ffmpeg(
|
||||
return (result.stdout or "", result.stderr or "")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
"FFmpeg 命令超时 (%ds): command=%s",
|
||||
"FFprobe 命令超时 (%ds): command=%s",
|
||||
timeout or -1,
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
)
|
||||
raise
|
||||
except subprocess.CalledProcessError as e:
|
||||
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
|
||||
stderr_text = (e.stderr or "").strip()
|
||||
logger.error(
|
||||
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
"FFprobe 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
e.returncode,
|
||||
" ".join(str(c) for c in command[:20]), # 截断过长的命令
|
||||
stderr_text[:5000], # 截断过长的 stderr
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
stderr_text[:5000],
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
"""多轨道混音引擎 — 支持多路音频独立音量调节与混合.
|
||||
|
||||
基于 FFmpeg amix / amerge 实现:
|
||||
- 支持任意数量音频轨道(原音、BGM、配音、音效等)
|
||||
- 每轨独立音量调节
|
||||
- 每轨独立淡入淡出
|
||||
- 每轨独立时间偏移(delay)
|
||||
- 总输出音量归一化补偿
|
||||
|
||||
作为 render_audio.py 的增强模块,在 mix_audio 后处理阶段被调用。
|
||||
与 bgm_mixer.py 的关系:
|
||||
- bgm_mixer 专注 BGM 单轨道的复杂处理(循环、人声闪避)
|
||||
- 本模块专注多路轨道的统一音量调节与混合
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from video_processing.render_audio import RenderContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
|
||||
TRACK_TYPE_BGM = "bgm" # 背景音乐
|
||||
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
|
||||
TRACK_TYPE_SFX = "sfx" # 音效
|
||||
TRACK_TYPE_AMBIENT = "ambient" # 环境音
|
||||
|
||||
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
|
||||
|
||||
# 各轨道默认音量(相对主音频)
|
||||
DEFAULT_VOLUMES = {
|
||||
TRACK_TYPE_MAIN: 1.0,
|
||||
TRACK_TYPE_BGM: 0.3,
|
||||
TRACK_TYPE_VOICEOVER: 1.0,
|
||||
TRACK_TYPE_SFX: 0.7,
|
||||
TRACK_TYPE_AMBIENT: 0.2,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioTrack:
|
||||
"""单条音频轨道配置."""
|
||||
|
||||
track_id: str # 轨道唯一标识
|
||||
track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient)
|
||||
audio_path: str # 音频文件路径
|
||||
volume: float = 1.0 # 音量 0.0 ~ 2.0
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长(秒)
|
||||
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
|
||||
duration: float = 0.0 # 持续时长(0表示到文件末尾)
|
||||
enabled: bool = True # 是否启用
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, track: dict) -> "AudioTrack":
|
||||
"""从字典创建 AudioTrack,带安全类型转换."""
|
||||
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
|
||||
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
|
||||
|
||||
try:
|
||||
volume = float(track.get("volume", default_vol))
|
||||
except (TypeError, ValueError):
|
||||
volume = default_vol
|
||||
volume = max(0.0, min(2.0, volume))
|
||||
|
||||
try:
|
||||
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
fade_in = 0.0
|
||||
|
||||
try:
|
||||
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
fade_out = 0.0
|
||||
|
||||
try:
|
||||
start_time = max(0.0, float(track.get("start_time", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
start_time = 0.0
|
||||
|
||||
try:
|
||||
duration = max(0.0, float(track.get("duration", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
duration = 0.0
|
||||
|
||||
return cls(
|
||||
track_id=str(track.get("track_id", "")),
|
||||
track_type=track_type,
|
||||
audio_path=str(track.get("audio_path", "")),
|
||||
volume=volume,
|
||||
fade_in=fade_in,
|
||||
fade_out=fade_out,
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
enabled=bool(track.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiTrackMixConfig:
|
||||
"""多轨道混音配置."""
|
||||
|
||||
tracks: list[AudioTrack] = field(default_factory=list)
|
||||
master_volume: float = 1.0 # 主输出音量
|
||||
normalize: bool = True # 是否自动归一化补偿
|
||||
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
|
||||
"""从 plan.config.audio_tracks 字典创建配置."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
tracks_raw = config.get("tracks", [])
|
||||
tracks: list[AudioTrack] = []
|
||||
|
||||
if isinstance(tracks_raw, list):
|
||||
for t in tracks_raw:
|
||||
if isinstance(t, dict) and t.get("audio_path"):
|
||||
try:
|
||||
track = AudioTrack.from_dict(t)
|
||||
if track.enabled and track.audio_path:
|
||||
tracks.append(track)
|
||||
except Exception:
|
||||
logger.warning("[multi-track] skip invalid track config: %s", t)
|
||||
continue
|
||||
|
||||
try:
|
||||
master_volume = float(config.get("master_volume", 1.0))
|
||||
master_volume = max(0.0, min(2.0, master_volume))
|
||||
except (TypeError, ValueError):
|
||||
master_volume = 1.0
|
||||
|
||||
return cls(
|
||||
tracks=tracks,
|
||||
master_volume=master_volume,
|
||||
normalize=bool(config.get("normalize", True)),
|
||||
max_output_volume=float(config.get("max_output_volume", 1.5)),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_effect(self) -> bool:
|
||||
"""是否有有效轨道需要混音."""
|
||||
return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0
|
||||
|
||||
|
||||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
|
||||
|
||||
|
||||
def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
|
||||
"""校验音频文件路径安全性.
|
||||
|
||||
规则:
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是音频格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not audio_path or not isinstance(audio_path, str):
|
||||
raise PathSecurityError("音频路径不能为空")
|
||||
|
||||
# 本地路径(local:// 或相对路径)
|
||||
if audio_path.startswith("local://") or not audio_path.startswith(("http://", "https://", "oss://")):
|
||||
is_abs = audio_path.startswith("/") and not audio_path.startswith("local://")
|
||||
resolved_path = safe_resolve_path(
|
||||
audio_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_AUDIO_EXTENSIONS,
|
||||
)
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
|
||||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||||
# 但检查扩展名
|
||||
else:
|
||||
# URL路径,检查扩展名白名单(取 ? 之前的部分)
|
||||
path_part = audio_path.split("?")[0].split("#")[0]
|
||||
from pathlib import Path as _P
|
||||
|
||||
ext = _P(path_part).suffix.lower()
|
||||
if ext and ext not in ALLOWED_AUDIO_EXTENSIONS:
|
||||
raise PathSecurityError(f"不允许的音频文件类型: {ext}")
|
||||
|
||||
|
||||
# ── 单轨道预处理 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _prepare_single_track(
|
||||
ctx: "RenderContext",
|
||||
track: AudioTrack,
|
||||
target_duration: float,
|
||||
output_path: Path,
|
||||
) -> bool:
|
||||
"""预处理单条轨道:音量 + 淡入淡出 + 时间偏移 + 截断.
|
||||
|
||||
生成一个精确对齐时间轴的音频文件,后续统一 amix 混音。
|
||||
|
||||
Returns:
|
||||
True 表示处理成功,False 表示失败(跳过)
|
||||
"""
|
||||
try:
|
||||
audio_dur = probe_duration(track.audio_path)
|
||||
except Exception:
|
||||
logger.warning("[multi-track] probe failed, skip track: %s", track.track_id)
|
||||
return False
|
||||
|
||||
if audio_dur <= 0:
|
||||
return False
|
||||
|
||||
# 计算实际有效时长
|
||||
effective_start = track.start_time
|
||||
if track.duration > 0:
|
||||
effective_dur = min(track.duration, audio_dur)
|
||||
else:
|
||||
effective_dur = audio_dur
|
||||
|
||||
# 如果轨道完全在视频时长之外,跳过
|
||||
if effective_start >= target_duration:
|
||||
return False
|
||||
if effective_start + effective_dur <= 0:
|
||||
return False
|
||||
|
||||
# 构建滤镜链
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# 1. 先截断到有效范围
|
||||
trim_start = 0.0 # 从源文件的哪个位置开始取
|
||||
if effective_start < 0:
|
||||
trim_start = -effective_start
|
||||
effective_start = 0.0
|
||||
|
||||
# 实际需要的源时长
|
||||
need_dur = min(effective_dur, target_duration - effective_start)
|
||||
if need_dur <= 0:
|
||||
return False
|
||||
|
||||
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_dur:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
# 2. 音量调节
|
||||
if abs(track.volume - 1.0) > 0.001:
|
||||
filter_parts.append(f"volume={track.volume:.3f}")
|
||||
|
||||
# 3. 淡入
|
||||
if track.fade_in > 0 and track.fade_in < need_dur:
|
||||
filter_parts.append(f"afade=t=in:st=0:d={track.fade_in:.3f}")
|
||||
|
||||
# 4. 淡出
|
||||
if track.fade_out > 0 and track.fade_out < need_dur:
|
||||
fade_start = need_dur - track.fade_out
|
||||
if fade_start > 0:
|
||||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={track.fade_out:.3f}")
|
||||
|
||||
# 5. 时间偏移(用 adelay 实现开头静音填充)
|
||||
if effective_start > 0.01:
|
||||
delay_ms = int(effective_start * 1000)
|
||||
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
|
||||
|
||||
# 6. 最终截断到目标总时长
|
||||
filter_parts.append(f"atrim=0:{target_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
filter_str = ",".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
track.audio_path,
|
||||
"-filter:a",
|
||||
filter_str,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[multi-track] prepare track: id=%s type=%s vol=%.2f start=%.2f dur=%.2f",
|
||||
track.track_id,
|
||||
track.track_type,
|
||||
track.volume,
|
||||
effective_start,
|
||||
need_dur,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("[multi-track] track prepare failed: %s, error=%s", track.track_id, e)
|
||||
return False
|
||||
|
||||
|
||||
# ── 多轨道混音主入口 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def mix_multi_track(
|
||||
ctx: "RenderContext",
|
||||
main_audio_path: Path,
|
||||
config: MultiTrackMixConfig,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""多轨道混音:主音频 + 多条附加轨道.
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
main_audio_path: 主音频文件路径(原音)
|
||||
config: 多轨道混音配置
|
||||
target_duration: 目标总时长
|
||||
|
||||
Returns:
|
||||
混音后的音频文件路径
|
||||
"""
|
||||
output_path = ctx.work_dir / f"multi_track_mix_{ctx.plan_id}.aac"
|
||||
|
||||
if target_duration <= 0:
|
||||
target_duration = 5.0
|
||||
|
||||
# ── 安全校验:轨道数量上限 ──
|
||||
enabled_tracks = [t for t in config.tracks if t.enabled and t.audio_path]
|
||||
if len(enabled_tracks) > MAX_AUDIO_TRACKS:
|
||||
logger.warning(
|
||||
"[multi-track] too many tracks: %d > %d, truncating to max",
|
||||
len(enabled_tracks),
|
||||
MAX_AUDIO_TRACKS,
|
||||
)
|
||||
enabled_tracks = enabled_tracks[:MAX_AUDIO_TRACKS]
|
||||
# 更新 config.tracks 为截断后的列表
|
||||
config.tracks = enabled_tracks
|
||||
|
||||
# ── 安全校验:所有音频路径白名单校验 ──
|
||||
# 主音频路径
|
||||
try:
|
||||
_validate_audio_path(str(main_audio_path), ctx.work_dir)
|
||||
except PathSecurityError as e:
|
||||
logger.error("[multi-track] main audio path security check failed: %s", e)
|
||||
raise
|
||||
|
||||
# 各轨道音频路径
|
||||
valid_tracks = []
|
||||
for track in enabled_tracks:
|
||||
try:
|
||||
_validate_audio_path(track.audio_path, ctx.work_dir)
|
||||
valid_tracks.append(track)
|
||||
except PathSecurityError as e:
|
||||
logger.warning("[multi-track] skip track %s: path security check failed: %s", track.track_id, e)
|
||||
|
||||
if len(valid_tracks) != len(enabled_tracks):
|
||||
config.tracks = valid_tracks
|
||||
logger.info("[multi-track] %d tracks passed security check", len(valid_tracks))
|
||||
|
||||
# 收集所有有效轨道(已预处理好的)
|
||||
prepared_tracks: list[Path] = []
|
||||
|
||||
# 主音频作为第0轨
|
||||
prepared_tracks.append(main_audio_path)
|
||||
|
||||
# 预处理每条附加轨道
|
||||
for i, track in enumerate(config.tracks):
|
||||
if not track.enabled or not track.audio_path:
|
||||
continue
|
||||
|
||||
track_out = ctx.work_dir / f"track_{i}_{ctx.plan_id}.aac"
|
||||
if _prepare_single_track(ctx, track, target_duration, track_out):
|
||||
prepared_tracks.append(track_out)
|
||||
|
||||
# 如果只有主音频,直接返回(无需混音)
|
||||
if len(prepared_tracks) <= 1:
|
||||
import shutil
|
||||
|
||||
shutil.copy2(main_audio_path, output_path)
|
||||
return output_path
|
||||
|
||||
# 使用 amix 混音
|
||||
num_inputs = len(prepared_tracks)
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
for tp in prepared_tracks:
|
||||
input_args.extend(["-i", str(tp)])
|
||||
|
||||
# amix 的 duration=first 以第一个输入(主音频)时长为准
|
||||
# normalize 补偿:amix 会把每路音量除以 N,需要乘回来
|
||||
# 但如果所有轨道都同时有声,可能会爆音,所以用 master_volume 控制
|
||||
if config.normalize:
|
||||
# 经验值:不是所有轨道都同时有声,补偿系数取 N * 0.7
|
||||
compensate = num_inputs * 0.7
|
||||
else:
|
||||
compensate = 1.0
|
||||
|
||||
final_volume = compensate * config.master_volume
|
||||
final_volume = min(final_volume, config.max_output_volume)
|
||||
|
||||
# 构建 filter_complex
|
||||
inputs_label = "".join(f"[{i}:a]" for i in range(num_inputs))
|
||||
filter_complex = (
|
||||
f"{inputs_label}amix=inputs={num_inputs}:duration=first:dropout_transition=0[outa];"
|
||||
f"[outa]volume={final_volume:.3f}[final]"
|
||||
)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*input_args,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[final]",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[multi-track] mix %d tracks, master_vol=%.2f compensate=%.2f final_vol=%.2f",
|
||||
num_inputs,
|
||||
config.master_volume,
|
||||
compensate,
|
||||
final_volume,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
except Exception as e:
|
||||
logger.error("[multi-track] mix failed, fallback to main audio only: %s", e)
|
||||
import shutil
|
||||
|
||||
shutil.copy2(main_audio_path, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
# ── 便捷函数:从 plan.config 快速混音 ───────────────────────────────────────
|
||||
|
||||
|
||||
def mix_audio_tracks_from_config(
|
||||
ctx: "RenderContext",
|
||||
main_audio_path: Path,
|
||||
audio_tracks_config: dict | None,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""从 plan.config.audio_tracks 配置执行多轨道混音.
|
||||
|
||||
降级策略:配置无效或混音失败时返回主音频。
|
||||
"""
|
||||
config = MultiTrackMixConfig.from_config_dict(audio_tracks_config)
|
||||
if not config.has_effect:
|
||||
return main_audio_path
|
||||
|
||||
return mix_multi_track(ctx, main_audio_path, config, target_duration)
|
||||
@@ -220,25 +220,64 @@ def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
|
||||
"""从 asset_id 解析到本地文件路径。
|
||||
|
||||
策略(按优先级):
|
||||
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 直接返回
|
||||
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 安全校验后返回
|
||||
2. 如果 work_dir 下已有缓存文件 → 返回缓存路径
|
||||
3. 从 OSS 下载到 work_dir/{hash}.mp4 → 返回下载路径
|
||||
4. 下载失败 → 返回 None
|
||||
|
||||
缓存策略:以 asset_id 的 SHA256 前 16 位为文件名,避免重复下载。
|
||||
"""
|
||||
# 1. 本地绝对路径
|
||||
if asset_id.startswith("/") and os.path.exists(asset_id):
|
||||
return Path(asset_id)
|
||||
|
||||
# 2. 缓存命中
|
||||
安全:
|
||||
- 本地绝对路径必须在 ASSET_ALLOWED_DIRS 环境变量指定的目录内
|
||||
- 文件名经过 sanitize,防止路径遍历
|
||||
- 禁止空字节、控制字符
|
||||
"""
|
||||
from video_processing.path_security import (
|
||||
PathSecurityError,
|
||||
get_allowed_local_dirs,
|
||||
is_in_allowed_dirs,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
if not asset_id or not isinstance(asset_id, str):
|
||||
return None
|
||||
|
||||
# 空字节检测
|
||||
if "\x00" in asset_id:
|
||||
logger.warning("asset_id 包含空字节,拒绝: %s", asset_id[:50])
|
||||
return None
|
||||
|
||||
# 1. 本地绝对路径 — 必须在允许的目录内
|
||||
if asset_id.startswith("/") and os.path.exists(asset_id):
|
||||
try:
|
||||
resolved = Path(asset_id).resolve()
|
||||
if is_in_allowed_dirs(resolved, get_allowed_local_dirs()):
|
||||
return resolved
|
||||
else:
|
||||
logger.warning(
|
||||
"本地素材路径不在允许目录内,拒绝: %s (allowed=%s)",
|
||||
asset_id[:80],
|
||||
get_allowed_local_dirs(),
|
||||
)
|
||||
return None
|
||||
except (OSError, PathSecurityError):
|
||||
return None
|
||||
|
||||
# 2. 缓存命中(使用 hash 而非原始 ID,防止路径遍历)
|
||||
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
|
||||
cached_path = work_dir / f"{cache_hash}.mp4"
|
||||
safe_name = sanitize_filename(cache_hash)
|
||||
cached_path = work_dir / f"{safe_name}.mp4"
|
||||
if cached_path.exists() and cached_path.stat().st_size > 0:
|
||||
return cached_path
|
||||
|
||||
# 3. 从 OSS 下载
|
||||
if download_asset(asset_id, cached_path):
|
||||
# 3. 从 OSS 下载(先标准化 key,防止路径遍历注入)
|
||||
safe_key = normalize_storage_key(asset_id)
|
||||
# 额外校验:存储键不能包含 ../ 或绝对路径
|
||||
if ".." in safe_key or safe_key.startswith("/"):
|
||||
logger.warning("asset_id 包含路径遍历模式,拒绝下载: %s", asset_id[:80])
|
||||
return None
|
||||
|
||||
if download_asset(safe_key, cached_path):
|
||||
return cached_path
|
||||
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""路径安全校验工具 — 路径遍历防护.
|
||||
|
||||
统一的文件路径安全校验方案,覆盖所有渲染管线中的路径处理场景:
|
||||
- 本地素材路径校验
|
||||
- local:// 路径 schema 校验
|
||||
- 工作目录内路径安全约束
|
||||
- 防止路径遍历攻击 (../)
|
||||
|
||||
防护要点:
|
||||
1. 所有用户可控路径必须在允许的目录内
|
||||
2. 解析符号链接后的真实路径仍需在允许目录内
|
||||
3. 禁止空路径、相对路径遍历、绝对路径逃逸
|
||||
4. 路径字符限制与规范化
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 最大路径长度
|
||||
MAX_PATH_LENGTH = 4096
|
||||
|
||||
# 允许的文件扩展名(渲染相关)
|
||||
ALLOWED_MEDIA_EXTENSIONS = {
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".webm",
|
||||
".flv",
|
||||
".wmv", # 视频
|
||||
".mp3",
|
||||
".wav",
|
||||
".aac",
|
||||
".ogg",
|
||||
".flac",
|
||||
".m4a",
|
||||
".wma", # 音频
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".bmp",
|
||||
".webp",
|
||||
".tiff", # 图片
|
||||
".srt",
|
||||
".ass",
|
||||
".vtt",
|
||||
".sub", # 字幕
|
||||
".txt",
|
||||
".json", # 文本/配置
|
||||
}
|
||||
|
||||
# local:// schema 前缀
|
||||
LOCAL_SCHEMA_PREFIX = "local://"
|
||||
|
||||
|
||||
class PathSecurityError(ValueError):
|
||||
"""路径安全校验失败."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def safe_resolve_path(
|
||||
input_path: str | Path,
|
||||
base_dir: str | Path,
|
||||
*,
|
||||
allow_outside: bool = False,
|
||||
allowed_extensions: set[str] | None = None,
|
||||
) -> Path:
|
||||
"""安全解析路径,确保最终路径在 base_dir 内.
|
||||
|
||||
Args:
|
||||
input_path: 输入路径(相对或绝对)
|
||||
base_dir: 基路径目录,解析后的路径必须在此目录内
|
||||
allow_outside: 是否允许路径在 base_dir 外(默认禁止)
|
||||
allowed_extensions: 允许的文件扩展名集合(None 表示不限制)
|
||||
|
||||
Returns:
|
||||
解析后的绝对路径 Path 对象
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if input_path is None:
|
||||
raise PathSecurityError("路径不能为空")
|
||||
|
||||
path_str = str(input_path).strip()
|
||||
if not path_str:
|
||||
raise PathSecurityError("路径不能为空")
|
||||
|
||||
if len(path_str) > MAX_PATH_LENGTH:
|
||||
raise PathSecurityError(f"路径过长 ({len(path_str)} > {MAX_PATH_LENGTH})")
|
||||
|
||||
# 空字节检测(必须在 Path() 之前)
|
||||
if "\x00" in path_str:
|
||||
raise PathSecurityError("路径包含空字节")
|
||||
|
||||
# 处理 local:// schema
|
||||
if path_str.startswith(LOCAL_SCHEMA_PREFIX):
|
||||
path_str = path_str[len(LOCAL_SCHEMA_PREFIX) :]
|
||||
# local:// 后必须是相对路径(相对于 base_dir),不能是绝对路径
|
||||
if os.path.isabs(path_str):
|
||||
raise PathSecurityError("local:// 路径不能是绝对路径")
|
||||
|
||||
# 规范化 base_dir
|
||||
base_dir = Path(base_dir).resolve()
|
||||
if not base_dir.is_dir():
|
||||
raise PathSecurityError(f"基路径不是有效目录: {base_dir}")
|
||||
|
||||
# 解析输入路径
|
||||
input_path_obj = Path(path_str)
|
||||
|
||||
# 如果是绝对路径且不允许外部路径
|
||||
if input_path_obj.is_absolute() and not allow_outside:
|
||||
raise PathSecurityError("禁止使用绝对路径(需在工作目录内)")
|
||||
|
||||
# 组合并解析为绝对路径
|
||||
if input_path_obj.is_absolute():
|
||||
full_path = input_path_obj.resolve()
|
||||
else:
|
||||
full_path = (base_dir / input_path_obj).resolve()
|
||||
|
||||
# 检查路径遍历 — 确保最终路径在 base_dir 内
|
||||
if not allow_outside:
|
||||
try:
|
||||
full_path.relative_to(base_dir)
|
||||
except ValueError:
|
||||
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
|
||||
|
||||
# 扩展名校验
|
||||
if allowed_extensions is not None:
|
||||
ext = full_path.suffix.lower()
|
||||
if ext and ext not in allowed_extensions:
|
||||
raise PathSecurityError(f"不允许的文件类型: {ext}")
|
||||
|
||||
# 检查危险路径模式
|
||||
_check_dangerous_patterns(full_path)
|
||||
|
||||
return full_path
|
||||
|
||||
|
||||
def _check_dangerous_patterns(path: Path) -> None:
|
||||
"""检查危险路径模式."""
|
||||
path_str = str(path)
|
||||
|
||||
# 检查空字节
|
||||
if "\x00" in path_str:
|
||||
raise PathSecurityError("路径包含空字节")
|
||||
|
||||
# 检查特殊设备文件(Linux)
|
||||
dangerous_prefixes = [
|
||||
"/proc/",
|
||||
"/sys/",
|
||||
"/dev/",
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
"/root/",
|
||||
"/boot/",
|
||||
"/var/run/",
|
||||
]
|
||||
for prefix in dangerous_prefixes:
|
||||
if path_str.startswith(prefix):
|
||||
raise PathSecurityError(f"禁止访问系统路径: {prefix}")
|
||||
|
||||
|
||||
def is_path_safe(
|
||||
input_path: str | Path,
|
||||
base_dir: str | Path,
|
||||
*,
|
||||
allow_outside: bool = False,
|
||||
) -> bool:
|
||||
"""便捷函数:检查路径是否安全,不抛异常."""
|
||||
try:
|
||||
safe_resolve_path(input_path, base_dir, allow_outside=allow_outside)
|
||||
return True
|
||||
except PathSecurityError:
|
||||
return False
|
||||
|
||||
|
||||
def validate_local_schema_path(
|
||||
schema_path: str,
|
||||
work_dir: str | Path,
|
||||
) -> Path:
|
||||
"""校验 local:// schema 路径,返回安全的本地路径.
|
||||
|
||||
local:// 路径规则:
|
||||
- 必须以 local:// 开头
|
||||
- 后面必须是相对路径
|
||||
- 最终解析后必须在 work_dir 内
|
||||
- 不允许 ../ 遍历
|
||||
|
||||
Args:
|
||||
schema_path: local:// 开头的路径
|
||||
work_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
解析后的安全路径
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not schema_path.startswith(LOCAL_SCHEMA_PREFIX):
|
||||
raise PathSecurityError(f"路径必须以 {LOCAL_SCHEMA_PREFIX} 开头")
|
||||
|
||||
return safe_resolve_path(schema_path, work_dir, allow_outside=False)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""清理文件名,移除危险字符.
|
||||
|
||||
保留:字母、数字、下划线、连字符、点、中文字符
|
||||
移除:路径分隔符、控制字符、特殊符号等
|
||||
"""
|
||||
import re
|
||||
|
||||
if not filename:
|
||||
return "unnamed"
|
||||
|
||||
# 移除路径分隔符和危险字符
|
||||
# 保留: 字母数字、中文字符、下划线、连字符、点、空格
|
||||
sanitized = re.sub(r'[\\/\x00-\x1f\x7f<>:"|?*]', "_", filename)
|
||||
|
||||
# 移除开头的点和连续的点(防止隐藏文件和路径遍历)
|
||||
while sanitized.startswith("."):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
# 限制长度
|
||||
if len(sanitized) > 255:
|
||||
name, ext = os.path.splitext(sanitized)
|
||||
sanitized = name[: 255 - len(ext)] + ext
|
||||
|
||||
# 空文件名兜底
|
||||
if not sanitized or sanitized == ".":
|
||||
sanitized = "unnamed"
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
# ── 允许目录配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_allowed_local_dirs() -> list[Path]:
|
||||
"""获取允许的本地素材目录列表(从环境变量读取).
|
||||
|
||||
环境变量 ASSET_ALLOWED_DIRS,多个目录用冒号分隔(Linux)或分号分隔(Windows)。
|
||||
默认包含 /tmp。
|
||||
|
||||
用于:
|
||||
- resolve_asset_path 本地绝对路径白名单
|
||||
- PiP local_path 类型白名单
|
||||
- 贴纸本地路径白名单
|
||||
"""
|
||||
env_dirs = os.environ.get("ASSET_ALLOWED_DIRS", "")
|
||||
dirs: list[Path] = []
|
||||
if env_dirs:
|
||||
import re
|
||||
|
||||
sep = ";" if os.name == "nt" else ":"
|
||||
for d in re.split(f"[{sep}]", env_dirs):
|
||||
d = d.strip()
|
||||
if d:
|
||||
try:
|
||||
dirs.append(Path(d).resolve())
|
||||
except OSError:
|
||||
pass
|
||||
# 默认允许 /tmp
|
||||
if not dirs:
|
||||
try:
|
||||
dirs.append(Path("/tmp").resolve()) # nosec B108
|
||||
except OSError:
|
||||
pass
|
||||
return dirs
|
||||
|
||||
|
||||
def is_in_allowed_dirs(path: str | Path, allowed_dirs: list[Path] | None = None) -> bool:
|
||||
"""检查路径是否在允许的目录列表内.
|
||||
|
||||
Args:
|
||||
path: 待检查的路径
|
||||
allowed_dirs: 允许的目录列表,None 则使用默认配置
|
||||
|
||||
Returns:
|
||||
True 表示在允许目录内
|
||||
"""
|
||||
if allowed_dirs is None:
|
||||
allowed_dirs = get_allowed_local_dirs()
|
||||
|
||||
try:
|
||||
resolved = Path(path).resolve()
|
||||
for allowed in allowed_dirs:
|
||||
try:
|
||||
resolved.relative_to(allowed)
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
@@ -465,18 +465,44 @@ class PiPEngine:
|
||||
layer: PiPLayerConfig,
|
||||
asset_path_map: dict[str, Path],
|
||||
) -> Path | None:
|
||||
"""验证图层素材是否可用,返回本地路径或None(降级跳过)."""
|
||||
"""验证图层素材是否可用,返回本地路径或None(降级跳过).
|
||||
|
||||
安全:
|
||||
- local_path 类型:必须在允许的目录内,防止路径遍历
|
||||
- url 类型:必须通过 SSRF 安全校验
|
||||
"""
|
||||
from video_processing.path_security import is_in_allowed_dirs
|
||||
from video_processing.url_security import UrlSecurityError, validate_url_safety
|
||||
|
||||
try:
|
||||
if layer.source_type == "local_path":
|
||||
path = Path(layer.source)
|
||||
if path.exists():
|
||||
return path
|
||||
if not layer.source:
|
||||
return None
|
||||
# 路径安全校验:必须在允许目录内
|
||||
src_path = Path(layer.source)
|
||||
if not src_path.exists():
|
||||
return None
|
||||
if not is_in_allowed_dirs(src_path):
|
||||
logger.warning(
|
||||
"PiP local_path 不在允许目录内,拒绝: %s",
|
||||
layer.source[:80],
|
||||
)
|
||||
return None
|
||||
return src_path.resolve()
|
||||
elif layer.source_type == "asset_id":
|
||||
if layer.source in asset_path_map:
|
||||
return asset_path_map[layer.source]
|
||||
return None
|
||||
elif layer.source_type == "url":
|
||||
# URL类型由调用者负责下载,这里返回标记
|
||||
return None # 暂时不支持直接URL
|
||||
# URL类型:先做SSRF安全校验,由调用者负责实际下载
|
||||
try:
|
||||
validate_url_safety(layer.source, purpose="pip_source")
|
||||
logger.info("PiP URL 安全校验通过: %s", layer.source[:80])
|
||||
except UrlSecurityError as e:
|
||||
logger.warning("PiP URL 安全校验失败: %s (error=%s)", layer.source[:80], e)
|
||||
return None
|
||||
# 暂时不支持直接URL下载,返回None表示降级跳过
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("PiP素材验证失败: %s", e)
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ def mix_audio(
|
||||
*,
|
||||
bgm_path: str | None = None,
|
||||
bgm_config: dict | None = None,
|
||||
audio_tracks_config: dict | None = None,
|
||||
) -> Path | None:
|
||||
"""音频后处理混音.
|
||||
|
||||
@@ -87,6 +88,8 @@ def mix_audio(
|
||||
4. 输出时长截断到 video_duration
|
||||
5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败
|
||||
6. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
|
||||
7. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
|
||||
8. 如果配置了降噪,最后应用降噪
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
@@ -94,6 +97,7 @@ def mix_audio(
|
||||
video_duration: 视频总时长(用于截断音频)
|
||||
bgm_path: BGM 音频本地路径,为 None 时不混入 BGM
|
||||
bgm_config: BGM 配置字典(volume/fade_in/fade_out/sidechain 等)
|
||||
audio_tracks_config: 多轨道音频配置(tracks/master_volume 等)
|
||||
|
||||
Returns:
|
||||
混音后的音频文件路径,无音频时返回 None
|
||||
@@ -157,10 +161,21 @@ def mix_audio(
|
||||
try:
|
||||
# 这里 main_audio 就是 output_path,先有主音频再混 BGM
|
||||
final_path = mix_bgm_with_main(ctx, output_path, bgm_cfg, video_duration)
|
||||
return _apply_noise_reduction_if_needed(ctx, final_path)
|
||||
output_path = final_path
|
||||
except Exception:
|
||||
logger.exception("[bgm] BGM 混音失败,回退到无 BGM 音频: plan_id=%s", ctx.plan_id)
|
||||
return _apply_noise_reduction_if_needed(ctx, output_path)
|
||||
|
||||
# ── 多轨道混音(配音/音效等) ──
|
||||
if audio_tracks_config and audio_tracks_config.get("enabled", False):
|
||||
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
|
||||
|
||||
try:
|
||||
tracks_config = audio_tracks_config.get("tracks_config") or audio_tracks_config
|
||||
multi_output = mix_audio_tracks_from_config(ctx, output_path, tracks_config, video_duration)
|
||||
if multi_output and multi_output != output_path:
|
||||
output_path = multi_output
|
||||
except Exception:
|
||||
logger.exception("[multi-track] 多轨道混音失败,回退: plan_id=%s", ctx.plan_id)
|
||||
|
||||
return _apply_noise_reduction_if_needed(ctx, output_path)
|
||||
|
||||
|
||||
@@ -392,10 +392,48 @@ class StickerEngine:
|
||||
)
|
||||
parsed_stickers.append((z, config))
|
||||
else:
|
||||
# 图片贴纸
|
||||
image_path = s.get("image_path", "") or s.get("image_url", "")
|
||||
if not image_path or not Path(image_path).exists():
|
||||
logger.warning("贴纸素材不存在,跳过: %s", image_path)
|
||||
# 图片贴纸 — 安全校验:区分本地路径和URL
|
||||
image_path = s.get("image_path", "")
|
||||
image_url = s.get("image_url", "")
|
||||
|
||||
safe_image_path: Path | None = None
|
||||
|
||||
if image_path:
|
||||
# 本地路径:路径遍历防护
|
||||
from video_processing.path_security import is_in_allowed_dirs
|
||||
|
||||
try:
|
||||
p = Path(image_path)
|
||||
if not p.exists():
|
||||
logger.warning("贴纸素材不存在,跳过: %s", image_path[:80])
|
||||
continue
|
||||
if not is_in_allowed_dirs(p):
|
||||
logger.warning("贴纸路径不在允许目录内,拒绝: %s", image_path[:80])
|
||||
continue
|
||||
safe_image_path = p.resolve()
|
||||
except Exception as e:
|
||||
logger.warning("贴纸路径校验失败,跳过: %s error=%s", image_path[:80], e)
|
||||
continue
|
||||
elif image_url:
|
||||
# URL:SSRF 安全校验(暂不自动下载,仅校验安全性)
|
||||
from video_processing.url_security import (
|
||||
UrlSecurityError,
|
||||
validate_url_safety,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_url_safety(image_url, purpose="sticker_image")
|
||||
except UrlSecurityError as e:
|
||||
logger.warning("贴纸URL安全校验失败,跳过: %s error=%s", image_url[:80], e)
|
||||
continue
|
||||
# URL 类型暂不支持自动下载,跳过
|
||||
logger.info("贴纸URL类型暂不支持自动下载,跳过: %s", image_url[:80])
|
||||
continue
|
||||
else:
|
||||
logger.warning("贴纸缺少 image_path 和 image_url,跳过")
|
||||
continue
|
||||
|
||||
if safe_image_path is None:
|
||||
continue
|
||||
|
||||
config = ImageStickerConfig(
|
||||
@@ -414,11 +452,11 @@ class StickerEngine:
|
||||
fade_in=float(s.get("fade_in", 0)),
|
||||
fade_out=float(s.get("fade_out", 0)),
|
||||
z_index=z,
|
||||
image_url=str(s.get("image_url", "")),
|
||||
image_url=image_url,
|
||||
)
|
||||
parsed_stickers.append((z, config))
|
||||
image_stickers.append(config)
|
||||
image_paths.append(image_path)
|
||||
image_paths.append(str(safe_image_path))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("贴纸配置解析失败,跳过: %s", e)
|
||||
|
||||
+687
@@ -0,0 +1,687 @@
|
||||
"""字幕渲染引擎 — 统一管理字幕样式配置与视频烧录.
|
||||
|
||||
与现有模块的关系:
|
||||
- render_subtitles.py:生成静态整段标题/字幕的 ASS 文件
|
||||
- subtitle_generator.py:从 ASR 时间轴生成 ASS 文件
|
||||
- 本模块:统一的字幕样式配置 + 烧录滤镜生成 + 多源字幕合并
|
||||
|
||||
支持的字幕来源:
|
||||
1. 静态标题/字幕(title_config / subtitle_config)
|
||||
2. ASR 自动字幕(asr_subtitle_timeline)
|
||||
3. 手动字幕(manual_subtitles 时间轴)
|
||||
|
||||
支持的样式配置:
|
||||
- 字体、字号、颜色
|
||||
- 描边(颜色、宽度)
|
||||
- 阴影(偏移、模糊、颜色)
|
||||
- 背景框(颜色、透明度、圆角、边距)
|
||||
- 位置(9宫格 + 自定义坐标)
|
||||
- 对齐方式
|
||||
- 动画(淡入淡出、滑入滑出、打字机)
|
||||
- 多行/换行规则
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
from video_processing.render_subtitles import generate_ass_subtitles
|
||||
from video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"}
|
||||
|
||||
# 9宫格位置映射(ASS alignment 编号)
|
||||
POSITION_ALIGNMENT = {
|
||||
"top_left": 7,
|
||||
"top_center": 8,
|
||||
"top_right": 9,
|
||||
"middle_left": 4,
|
||||
"center": 5,
|
||||
"middle_right": 6,
|
||||
"bottom_left": 1,
|
||||
"bottom_center": 2,
|
||||
"bottom_right": 3,
|
||||
}
|
||||
|
||||
# 位置简称兼容
|
||||
POSITION_ALIASES = {
|
||||
"top": "top_center",
|
||||
"bottom": "bottom_center",
|
||||
"middle": "center",
|
||||
"left": "middle_left",
|
||||
"right": "middle_right",
|
||||
}
|
||||
|
||||
DEFAULT_FONT = "思源黑体"
|
||||
DEFAULT_FONT_SIZE = 24
|
||||
DEFAULT_COLOR = "#FFFFFF"
|
||||
DEFAULT_STROKE_COLOR = "#000000"
|
||||
DEFAULT_STROKE_WIDTH = 1.5
|
||||
DEFAULT_POSITION = "bottom_center"
|
||||
DEFAULT_MAX_CHARS_PER_LINE = 20
|
||||
|
||||
|
||||
# ── 字幕样式配置 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleStyle:
|
||||
"""字幕样式配置."""
|
||||
|
||||
font_name: str = DEFAULT_FONT
|
||||
font_size: int = DEFAULT_FONT_SIZE
|
||||
font_color: str = DEFAULT_COLOR
|
||||
bold: bool = False
|
||||
italic: bool = False
|
||||
|
||||
# 描边
|
||||
stroke_enabled: bool = True
|
||||
stroke_color: str = DEFAULT_STROKE_COLOR
|
||||
stroke_width: float = DEFAULT_STROKE_WIDTH
|
||||
|
||||
# 阴影
|
||||
shadow_enabled: bool = False
|
||||
shadow_color: str = "#000000"
|
||||
shadow_offset_x: int = 2
|
||||
shadow_offset_y: int = 2
|
||||
shadow_blur: float = 0.0
|
||||
|
||||
# 背景框
|
||||
background_enabled: bool = False
|
||||
background_color: str = "#000000"
|
||||
background_opacity: float = 0.5 # 0.0 ~ 1.0
|
||||
background_padding: int = 8
|
||||
background_radius: int = 4
|
||||
|
||||
# 位置
|
||||
position: str = DEFAULT_POSITION # 9宫格位置名
|
||||
margin_v: int = 60 # 垂直边距
|
||||
margin_l: int = 40 # 左边距
|
||||
margin_r: int = 40 # 右边距
|
||||
|
||||
# 多行
|
||||
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE
|
||||
line_spacing: int = 0 # 行间距
|
||||
|
||||
# 动画
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长(秒)
|
||||
animation_type: str = "none" # none/fade/slide/typewriter
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
|
||||
"""从字典创建样式配置,带安全类型转换."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
def safe_str(key: str, default: str) -> str:
|
||||
val = config.get(key, default)
|
||||
return str(val) if val is not None else default
|
||||
|
||||
def safe_int(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(config.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(config.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_bool(key: str, default: bool) -> bool:
|
||||
return bool(config.get(key, default))
|
||||
|
||||
position = safe_str("position", DEFAULT_POSITION)
|
||||
position = POSITION_ALIASES.get(position, position)
|
||||
if position not in POSITION_ALIGNMENT:
|
||||
position = DEFAULT_POSITION
|
||||
|
||||
return cls(
|
||||
font_name=safe_str("font", DEFAULT_FONT),
|
||||
font_size=safe_int("size", DEFAULT_FONT_SIZE),
|
||||
font_color=safe_str("color", DEFAULT_COLOR),
|
||||
bold=safe_bool("bold", False),
|
||||
italic=safe_bool("italic", False),
|
||||
stroke_enabled=safe_bool("stroke_enabled", True),
|
||||
stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR),
|
||||
stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH),
|
||||
shadow_enabled=safe_bool("shadow_enabled", False),
|
||||
shadow_color=safe_str("shadow_color", "#000000"),
|
||||
shadow_offset_x=safe_int("shadow_offset_x", 2),
|
||||
shadow_offset_y=safe_int("shadow_offset_y", 2),
|
||||
shadow_blur=safe_float("shadow_blur", 0.0),
|
||||
background_enabled=safe_bool("background_enabled", False),
|
||||
background_color=safe_str("background_color", "#000000"),
|
||||
background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))),
|
||||
background_padding=safe_int("background_padding", 8),
|
||||
background_radius=safe_int("background_radius", 4),
|
||||
position=position,
|
||||
margin_v=safe_int("margin_v", 60),
|
||||
margin_l=safe_int("margin_l", 40),
|
||||
margin_r=safe_int("margin_r", 40),
|
||||
max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE),
|
||||
line_spacing=safe_int("line_spacing", 0),
|
||||
fade_in=max(0.0, safe_float("fade_in", 0.0)),
|
||||
fade_out=max(0.0, safe_float("fade_out", 0.0)),
|
||||
animation_type=safe_str("animation_type", "none"),
|
||||
)
|
||||
|
||||
@property
|
||||
def alignment(self) -> int:
|
||||
"""获取 ASS alignment 编号."""
|
||||
return POSITION_ALIGNMENT.get(self.position, 2)
|
||||
|
||||
@property
|
||||
def ass_font_color(self) -> str:
|
||||
"""ASS 格式颜色 &HAABBGGRR."""
|
||||
return _hex_to_ass_color(self.font_color)
|
||||
|
||||
@property
|
||||
def ass_stroke_color(self) -> str:
|
||||
return _hex_to_ass_color(self.stroke_color)
|
||||
|
||||
@property
|
||||
def ass_shadow_color(self) -> str:
|
||||
return _hex_to_ass_color(self.shadow_color)
|
||||
|
||||
@property
|
||||
def ass_background_color(self) -> str:
|
||||
"""背景框颜色(ASS BackColour),带透明度."""
|
||||
alpha_hex = _opacity_to_ass_alpha(self.background_opacity)
|
||||
color_bgr = _hex_to_ass_bgr(self.background_color)
|
||||
return f"&H{alpha_hex}{color_bgr}"
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _hex_to_ass_color(hex_color: str) -> str:
|
||||
"""HEX → ASS 颜色 &HAABBGGRR(默认不透明)."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
return "&H00FFFFFF"
|
||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
||||
return f"&H00{b.upper()}{g.upper()}{r.upper()}"
|
||||
|
||||
|
||||
def _hex_to_ass_bgr(hex_color: str) -> str:
|
||||
"""HEX → ASS BGR 部分(不含 alpha)."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
return "FFFFFF"
|
||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
||||
return f"{b.upper()}{g.upper()}{r.upper()}"
|
||||
|
||||
|
||||
def _opacity_to_ass_alpha(opacity: float) -> str:
|
||||
"""不透明度 → ASS alpha(00=不透明,FF=完全透明)."""
|
||||
alpha = 255 - int(opacity * 255)
|
||||
return f"{alpha:02X}"
|
||||
|
||||
|
||||
def _escape_ass_text(text: str) -> str:
|
||||
"""转义 ASS 文本特殊字符."""
|
||||
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
||||
text = text.replace("{", "(").replace("}", ")")
|
||||
return text
|
||||
|
||||
|
||||
def _format_ass_time(seconds: float) -> str:
|
||||
"""秒 → ASS 时间格式 H:MM:SS.cc."""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = seconds % 60
|
||||
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
||||
|
||||
|
||||
def _wrap_text(text: str, max_chars: int) -> list[str]:
|
||||
"""按字数换行,优先标点断开."""
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
lines: list[str] = []
|
||||
remaining = text
|
||||
|
||||
while len(remaining) > max_chars:
|
||||
break_point = max_chars
|
||||
punctuations = ",。!?、;:,.;:!?"
|
||||
|
||||
for i in range(max_chars, max_chars // 2, -1):
|
||||
if i < len(remaining) and remaining[i] in punctuations:
|
||||
break_point = i + 1
|
||||
break
|
||||
|
||||
lines.append(remaining[:break_point])
|
||||
remaining = remaining[break_point:]
|
||||
|
||||
if remaining:
|
||||
lines.append(remaining)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleSegment:
|
||||
"""单个字幕片段."""
|
||||
|
||||
start: float # 开始时间(秒)
|
||||
end: float # 结束时间(秒)
|
||||
text: str # 字幕文本
|
||||
style_name: str = "Default" # 使用的样式名
|
||||
|
||||
|
||||
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SubtitleRenderEngine:
|
||||
"""字幕渲染引擎 — 统一管理多源字幕的 ASS 文件生成.
|
||||
|
||||
支持合并多个字幕来源到同一个 ASS 文件:
|
||||
- 标题(顶部,单独样式)
|
||||
- 字幕(底部,单独样式)
|
||||
- ASR 时间轴字幕
|
||||
- 手动字幕
|
||||
|
||||
输出一个统一的 ASS 文件,供 FFmpeg subtitles filter 烧录。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_width: int = 1080,
|
||||
video_height: int = 1920,
|
||||
video_duration: float = 0.0,
|
||||
):
|
||||
self.video_width = video_width
|
||||
self.video_height = video_height
|
||||
self.video_duration = video_duration
|
||||
self._styles: dict[str, SubtitleStyle] = {}
|
||||
self._segments: list[SubtitleSegment] = []
|
||||
self._style_counter = 0
|
||||
|
||||
# ── 样式管理 ──────────────────────────────────────────────────────
|
||||
|
||||
def add_style(self, name: str, style: SubtitleStyle) -> str:
|
||||
"""注册一个样式,返回样式名."""
|
||||
self._styles[name] = style
|
||||
return name
|
||||
|
||||
def get_or_create_style(self, base_name: str, style: SubtitleStyle) -> str:
|
||||
"""获取或创建样式(避免重复)."""
|
||||
if base_name in self._styles:
|
||||
return base_name
|
||||
self._styles[base_name] = style
|
||||
return base_name
|
||||
|
||||
# ── 字幕源添加 ────────────────────────────────────────────────────
|
||||
|
||||
def add_title(self, text: str, style: SubtitleStyle | None = None) -> None:
|
||||
"""添加整段标题(显示整个视频时长)."""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle(
|
||||
position="top_center",
|
||||
font_size=48,
|
||||
bold=True,
|
||||
stroke_enabled=True,
|
||||
stroke_width=2.0,
|
||||
)
|
||||
style_name = self.get_or_create_style("TitleStyle", style)
|
||||
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=0.0,
|
||||
end=self.video_duration if self.video_duration > 0 else 9999.0,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
def add_subtitle_text(self, text: str, style: SubtitleStyle | None = None) -> None:
|
||||
"""添加整段字幕(显示整个视频时长)."""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("SubtitleStyle", style)
|
||||
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=0.0,
|
||||
end=self.video_duration if self.video_duration > 0 else 9999.0,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
def add_timeline_segments(
|
||||
self,
|
||||
segments: list[dict] | list[SubtitleSegment],
|
||||
style: SubtitleStyle | None = None,
|
||||
) -> None:
|
||||
"""添加时间轴字幕片段(ASR 或手动字幕).
|
||||
|
||||
segments 可以是:
|
||||
- SubtitleSegment 列表
|
||||
- dict 列表,每个 dict 含 start/end/text 字段
|
||||
"""
|
||||
if not segments:
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("Default", style)
|
||||
|
||||
for seg in segments:
|
||||
if isinstance(seg, SubtitleSegment):
|
||||
seg.style_name = style_name
|
||||
self._segments.append(seg)
|
||||
elif isinstance(seg, dict):
|
||||
try:
|
||||
start = float(seg.get("start", 0))
|
||||
end = float(seg.get("end", 0))
|
||||
text = str(seg.get("text", ""))
|
||||
if end > start and text.strip():
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=start,
|
||||
end=end,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
def add_asr_timeline(self, timeline: Any, style: SubtitleStyle | None = None) -> None:
|
||||
"""从 SubtitleTimeline 对象添加 ASR 字幕."""
|
||||
if not timeline or not hasattr(timeline, "segments") or not timeline.segments:
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("ASRStyle", style)
|
||||
|
||||
for seg in timeline.segments:
|
||||
if hasattr(seg, "start") and hasattr(seg, "end") and hasattr(seg, "text"):
|
||||
if seg.end > seg.start and seg.text.strip():
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=seg.start,
|
||||
end=seg.end,
|
||||
text=seg.text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
# ── ASS 文件生成 ──────────────────────────────────────────────────
|
||||
|
||||
def generate_ass(self, output_path: Path) -> Path:
|
||||
"""生成 ASS 字幕文件.
|
||||
|
||||
Returns:
|
||||
生成的文件路径;如果没有字幕内容,返回空文件。
|
||||
"""
|
||||
if not self._segments:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text("", encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
# 确保至少有 Default 样式
|
||||
if "Default" not in self._styles:
|
||||
self._styles["Default"] = SubtitleStyle()
|
||||
|
||||
# 生成样式行
|
||||
style_lines = []
|
||||
for name, style in self._styles.items():
|
||||
style_lines.append(self._build_ass_style_line(name, style))
|
||||
|
||||
# 生成事件行(按时间排序)
|
||||
self._segments.sort(key=lambda s: s.start)
|
||||
event_lines = []
|
||||
for seg in self._segments:
|
||||
event_lines.append(self._build_ass_event_line(seg))
|
||||
|
||||
# 组装文件
|
||||
ass_content = f"""[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: {self.video_width}
|
||||
PlayResY: {self.video_height}
|
||||
ScaledBorderAndShadow: yes
|
||||
WrapStyle: 2
|
||||
Encoding: UTF-8
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
{chr(10).join(style_lines)}
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
{chr(10).join(event_lines)}
|
||||
"""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(ass_content, encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
def _build_ass_style_line(self, name: str, style: SubtitleStyle) -> str:
|
||||
"""构建一条 ASS Style 行."""
|
||||
bold_val = -1 if style.bold else 0
|
||||
italic_val = -1 if style.italic else 0
|
||||
|
||||
# BorderStyle: 1=outline+shadow, 3=opaque box(背景框)
|
||||
if style.background_enabled:
|
||||
border_style = 3
|
||||
back_color = style.ass_background_color
|
||||
else:
|
||||
border_style = 1
|
||||
back_color = style.ass_shadow_color if style.shadow_enabled else style.ass_font_color
|
||||
|
||||
outline_val = style.stroke_width if style.stroke_enabled else 0.0
|
||||
shadow_val = style.shadow_offset_y if style.shadow_enabled else 0
|
||||
|
||||
return (
|
||||
f"Style: {name},{style.font_name},{style.font_size},{style.ass_font_color},"
|
||||
f"&H000000FF,{style.ass_stroke_color},{back_color},"
|
||||
f"{bold_val},{italic_val},0,0,100,100,0,0,"
|
||||
f"{border_style},{outline_val},{shadow_val},{style.alignment},"
|
||||
f"{style.margin_l},{style.margin_r},{style.margin_v},1"
|
||||
)
|
||||
|
||||
def _build_ass_event_line(self, seg: SubtitleSegment) -> str:
|
||||
"""构建一条 ASS Dialogue 事件行."""
|
||||
style = self._styles.get(seg.style_name, SubtitleStyle())
|
||||
max_chars = style.max_chars_per_line
|
||||
|
||||
# 自动换行
|
||||
lines = _wrap_text(seg.text, max_chars)
|
||||
display_text = "\\N".join(lines)
|
||||
|
||||
# 动画效果(淡入淡出)
|
||||
effect_tags = ""
|
||||
if style.fade_in > 0 or style.fade_out > 0:
|
||||
fade_in_ms = int(style.fade_in * 1000)
|
||||
fade_out_ms = int(style.fade_out * 1000)
|
||||
effect_tags = f"{{\\fad({fade_in_ms},{fade_out_ms})}}"
|
||||
|
||||
safe_text = _escape_ass_text(display_text)
|
||||
start_time = _format_ass_time(max(0, seg.start))
|
||||
end_time = _format_ass_time(max(seg.start + 0.1, seg.end))
|
||||
|
||||
return f"Dialogue: 0,{start_time},{end_time},{seg.style_name},,0,0,0,," f"{effect_tags}{safe_text}"
|
||||
|
||||
@property
|
||||
def has_subtitles(self) -> bool:
|
||||
"""是否有字幕内容."""
|
||||
return len(self._segments) > 0
|
||||
|
||||
|
||||
# ── 便捷函数:从 plan.config 快速生成 ASS ────────────────────────────────────
|
||||
|
||||
|
||||
def build_subtitles_from_plan(
|
||||
output_path: Path,
|
||||
plan_config: dict,
|
||||
*,
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
video_duration: float,
|
||||
asr_timeline: Any = None,
|
||||
) -> Path | None:
|
||||
"""从 plan.config 构建字幕 ASS 文件.
|
||||
|
||||
支持的配置项:
|
||||
- title_config: 标题配置(含 text/style)
|
||||
- subtitle_config: 字幕配置(含 text/style)
|
||||
- asr_subtitles: ASR 字幕开关 + 样式
|
||||
- manual_subtitles: 手动字幕片段列表
|
||||
|
||||
Returns:
|
||||
生成的 ASS 文件路径;如果没有任何字幕,返回 None
|
||||
"""
|
||||
engine = SubtitleRenderEngine(
|
||||
video_width=video_width,
|
||||
video_height=video_height,
|
||||
video_duration=video_duration,
|
||||
)
|
||||
|
||||
has_any = False
|
||||
|
||||
# 1. 标题
|
||||
title_cfg = plan_config.get("title_config") or {}
|
||||
if isinstance(title_cfg, dict):
|
||||
title_text = str(title_cfg.get("text", ""))
|
||||
title_enabled = title_cfg.get("enabled", True)
|
||||
if title_enabled and title_text.strip():
|
||||
style_dict = title_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
# 标题默认样式:顶部、大字号、粗体
|
||||
if style.position == DEFAULT_POSITION and style.font_size == DEFAULT_FONT_SIZE:
|
||||
style.position = "top_center"
|
||||
style.font_size = 48
|
||||
style.bold = True
|
||||
engine.add_title(title_text, style)
|
||||
has_any = True
|
||||
|
||||
# 2. 静态字幕
|
||||
sub_cfg = plan_config.get("subtitle_config") or {}
|
||||
if isinstance(sub_cfg, dict):
|
||||
sub_text = str(sub_cfg.get("text", ""))
|
||||
sub_enabled = sub_cfg.get("enabled", True)
|
||||
if sub_enabled and sub_text.strip():
|
||||
style_dict = sub_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_subtitle_text(sub_text, style)
|
||||
has_any = True
|
||||
|
||||
# 3. ASR 自动字幕
|
||||
asr_cfg = plan_config.get("asr_subtitles") or {}
|
||||
if isinstance(asr_cfg, dict) and asr_cfg.get("enabled", False):
|
||||
if asr_timeline is not None:
|
||||
style_dict = asr_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_asr_timeline(asr_timeline, style)
|
||||
has_any = has_any or engine.has_subtitles
|
||||
|
||||
# 4. 手动字幕
|
||||
manual_segs = plan_config.get("manual_subtitles") or []
|
||||
if isinstance(manual_segs, list) and manual_segs:
|
||||
style_dict = (plan_config.get("manual_subtitle_style") or {}) or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_timeline_segments(manual_segs, style)
|
||||
has_any = has_any or engine.has_subtitles
|
||||
|
||||
if not has_any:
|
||||
return None
|
||||
|
||||
return engine.generate_ass(output_path)
|
||||
|
||||
|
||||
# ── FFmpeg 烧录滤镜生成 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_subtitle_filter(
|
||||
ass_path: Path | str,
|
||||
*,
|
||||
video_input_label: str = "0:v",
|
||||
output_label: str = "subtitled",
|
||||
work_dir: Path | str | None = None,
|
||||
) -> str:
|
||||
"""生成 FFmpeg subtitles 滤镜字符串.
|
||||
|
||||
Args:
|
||||
ass_path: ASS 字幕文件路径
|
||||
video_input_label: 视频输入标签(如 "0:v" 或 "[v_out]")
|
||||
output_label: 输出标签
|
||||
work_dir: 工作目录(必填,用于路径安全校验,防止路径遍历绕过)
|
||||
|
||||
Returns:
|
||||
filter_complex 片段,如 "[0:v]subtitles=xxx.ass[subtitled]"
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 字幕路径不安全或 work_dir 未提供
|
||||
"""
|
||||
# ── 安全校验:字幕文件路径白名单 ──
|
||||
ass_path_str = str(ass_path)
|
||||
if work_dir is None or not str(work_dir).strip():
|
||||
raise PathSecurityError("work_dir 必须提供,不能为 None 或空")
|
||||
|
||||
_validate_subtitle_path(ass_path_str, Path(work_dir))
|
||||
|
||||
# FFmpeg subtitles filter 的路径需要转义:
|
||||
# - Windows 路径的 \ → /
|
||||
# - 冒号 : → \:
|
||||
# - 单引号 ' → '\''
|
||||
safe_path = ass_path_str.replace("\\", "/").replace(":", "\\:").replace("'", "'\\''")
|
||||
return f"{video_input_label}subtitles='{safe_path}'[{output_label}]"
|
||||
|
||||
|
||||
def _validate_subtitle_path(subtitle_path: str, work_dir: Path) -> None:
|
||||
"""校验字幕文件路径安全性.
|
||||
|
||||
规则:
|
||||
- 必须是本地路径(不支持远程URL字幕)
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是字幕格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not subtitle_path or not isinstance(subtitle_path, str):
|
||||
raise PathSecurityError("字幕路径不能为空")
|
||||
|
||||
# 不允许远程URL字幕(subtitles滤镜不支持远程加载,且有SSRF风险)
|
||||
if subtitle_path.startswith(("http://", "https://", "oss://")):
|
||||
raise PathSecurityError("不允许使用远程URL字幕文件")
|
||||
|
||||
is_abs = subtitle_path.startswith("/") and not subtitle_path.startswith("local://")
|
||||
|
||||
resolved_path = safe_resolve_path(
|
||||
subtitle_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_SUBTITLE_EXTENSIONS,
|
||||
)
|
||||
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
|
||||
@@ -28,7 +28,6 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.chroma_key_engine import apply_chroma_key_if_needed
|
||||
from video_processing.color_grade_engine import ColorGradeConfig, ColorGradeEngine
|
||||
from video_processing.ffmpeg_utils import (
|
||||
DEFAULT_FPS,
|
||||
@@ -46,7 +45,7 @@ from video_processing.render_audio import RenderContext, merge_audio_video, mix_
|
||||
from video_processing.render_subtitles import generate_ass_subtitles
|
||||
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
|
||||
from video_processing.speed_engine import SpeedConfig, SpeedEngine
|
||||
from video_processing.sticker_engine import StickerEngine, parse_stickers_from_config
|
||||
from video_processing.sticker_engine import StickerEngine
|
||||
from video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from video_processing.transition_engine import TransitionEngine
|
||||
from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_from_clip_config
|
||||
@@ -343,6 +342,7 @@ class UnifiedRenderService:
|
||||
else:
|
||||
config = self.plan.config or {}
|
||||
bgm_config = config.get("bgm", {}) or {}
|
||||
audio_tracks_config = config.get("audio_tracks") or {}
|
||||
noise_reduction_config = config.get("audio_noise_reduction")
|
||||
ctx = RenderContext(
|
||||
work_dir=self.work_dir,
|
||||
@@ -355,6 +355,7 @@ class UnifiedRenderService:
|
||||
video_duration,
|
||||
bgm_path=self.bgm_path,
|
||||
bgm_config=bgm_config,
|
||||
audio_tracks_config=audio_tracks_config,
|
||||
)
|
||||
t_audio_end = time.time()
|
||||
audio_mix_ms = int((t_audio_end - t_audio_start) * 1000)
|
||||
@@ -640,10 +641,8 @@ class UnifiedRenderService:
|
||||
|
||||
def _extract_audio(self, video_path: Path, output_path: Path) -> None:
|
||||
"""从视频中提取音频为16kHz单声道wav(ASR友好格式)。"""
|
||||
import subprocess
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
@@ -657,15 +656,10 @@ class UnifiedRenderService:
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"音频提取失败: {result.stderr[:200]}")
|
||||
try:
|
||||
run_ffmpeg(cmd, timeout=120)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"音频提取失败: {str(e)[:200]}") from e
|
||||
|
||||
def _maybe_add_voiceover_layer(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""URL 安全校验工具 — SSRF 防护(向后兼容层).
|
||||
|
||||
本模块为向后兼容而保留,实际实现已迁移至 packages.shared.url_security。
|
||||
所有符号均从该模块重新导出,请新代码直接 import packages.shared.url_security。
|
||||
"""
|
||||
|
||||
from packages.shared.url_security import ( # noqa: F401
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
ALLOWED_PORTS,
|
||||
ALLOWED_SCHEMES,
|
||||
ALLOWED_VIDEO_MIME_TYPES,
|
||||
DEFAULT_MAX_DOWNLOAD_SIZE,
|
||||
MAX_URL_LENGTH,
|
||||
TRUSTED_DOMAINS,
|
||||
UrlSecurityError,
|
||||
is_url_safe,
|
||||
safe_download_bytes,
|
||||
safe_download_file,
|
||||
validate_url_safety,
|
||||
)
|
||||
@@ -130,6 +130,8 @@ class AssetAnalyzer:
|
||||
info = VideoInfo()
|
||||
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffprobe
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
@@ -140,38 +142,31 @@ class AssetAnalyzer:
|
||||
"-show_streams",
|
||||
self.video_path,
|
||||
]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
stdout, _ = run_ffprobe(cmd, timeout=30)
|
||||
data = json.loads(stdout)
|
||||
streams = data.get("streams", [])
|
||||
format_info = data.get("format", {})
|
||||
|
||||
if result.returncode == 0:
|
||||
data = json.loads(result.stdout)
|
||||
streams = data.get("streams", [])
|
||||
format_info = data.get("format", {})
|
||||
for stream in streams:
|
||||
if stream.get("codec_type") == "video":
|
||||
info.width = int(stream.get("width", 0))
|
||||
info.height = int(stream.get("height", 0))
|
||||
info.codec = stream.get("codec_name", "")
|
||||
|
||||
for stream in streams:
|
||||
if stream.get("codec_type") == "video":
|
||||
info.width = int(stream.get("width", 0))
|
||||
info.height = int(stream.get("height", 0))
|
||||
info.codec = stream.get("codec_name", "")
|
||||
# 解析帧率
|
||||
fps_str = stream.get("r_frame_rate", "0/1")
|
||||
if "/" in fps_str:
|
||||
num, denom = fps_str.split("/")
|
||||
info.fps = float(num) / float(denom) if float(denom) != 0 else 0.0
|
||||
else:
|
||||
info.fps = float(fps_str)
|
||||
|
||||
# 解析帧率
|
||||
fps_str = stream.get("r_frame_rate", "0/1")
|
||||
if "/" in fps_str:
|
||||
num, denom = fps_str.split("/")
|
||||
info.fps = float(num) / float(denom) if float(denom) != 0 else 0.0
|
||||
else:
|
||||
info.fps = float(fps_str)
|
||||
elif stream.get("codec_type") == "audio":
|
||||
info.has_audio = True
|
||||
|
||||
elif stream.get("codec_type") == "audio":
|
||||
info.has_audio = True
|
||||
|
||||
info.duration = float(format_info.get("duration", 0))
|
||||
info.bitrate = int(format_info.get("bit_rate", 0))
|
||||
info.file_size = int(format_info.get("size", 0))
|
||||
info.duration = float(format_info.get("duration", 0))
|
||||
info.bitrate = int(format_info.get("bit_rate", 0))
|
||||
info.file_size = int(format_info.get("size", 0))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get video info: {e}")
|
||||
@@ -179,7 +174,7 @@ class AssetAnalyzer:
|
||||
self._video_info = info
|
||||
return info
|
||||
|
||||
def extract_frames(self, count: int = 10, max_frames: int = 30) -> list[np.ndarray]:
|
||||
def extract_frames(self, count: int = 10) -> list[np.ndarray]:
|
||||
"""
|
||||
从视频中均匀抽取帧
|
||||
|
||||
@@ -224,14 +219,14 @@ class AssetAnalyzer:
|
||||
output_path,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
if result.returncode == 0 and os.path.exists(output_path):
|
||||
try:
|
||||
run_ffmpeg(cmd, timeout=10)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if os.path.exists(output_path):
|
||||
# 读取帧并转换为 numpy 数组
|
||||
img = self._load_image_as_array(output_path)
|
||||
if img is not None:
|
||||
@@ -397,14 +392,19 @@ class AssetAnalyzer:
|
||||
audio_path,
|
||||
]
|
||||
|
||||
result_audio = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
if result_audio.returncode == 0 and os.path.exists(audio_path):
|
||||
try:
|
||||
run_ffmpeg(cmd, timeout=30)
|
||||
except Exception:
|
||||
# 音频提取失败,返回默认分析结果
|
||||
return AudioAnalysis(
|
||||
has_speech=False,
|
||||
speech_ratio=0.0,
|
||||
avg_volume=0.0,
|
||||
)
|
||||
|
||||
if os.path.exists(audio_path):
|
||||
# 读取音频数据
|
||||
import struct
|
||||
|
||||
|
||||
@@ -106,7 +106,16 @@ def _download_video_to_file(url: str, dest_path: str) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退到 HTTP 下载
|
||||
import urllib.request
|
||||
# 回退到 HTTP 下载(含 SSRF 防护 + 大小限制 + 类型校验)
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_VIDEO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
urllib.request.urlretrieve(url, dest_path) # nosec B310
|
||||
safe_download_file(
|
||||
url,
|
||||
dest_path,
|
||||
purpose="batch_video_download",
|
||||
allowed_mime_types=ALLOWED_VIDEO_MIME_TYPES | {"application/octet-stream"},
|
||||
timeout=300.0,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -115,16 +114,12 @@ def _compose_with_legacy_engine(task, job_service, job, plan_id: str, db) -> dic
|
||||
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
compose_cmd.command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3600,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
job_service.fail_job(job_id, f"FFmpeg 执行失败: {e.stderr[:500]}")
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
run_ffmpeg(compose_cmd.command, timeout=3600)
|
||||
except Exception as e:
|
||||
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
|
||||
job_service.fail_job(job_id, error_msg)
|
||||
raise
|
||||
|
||||
# 上传结果
|
||||
|
||||
@@ -257,7 +257,6 @@ def _render_with_legacy(
|
||||
) -> dict:
|
||||
"""旧引擎路径(VideoComposeService + FFmpeg filter_complex)。"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from apps.api.app.services.video_compose_service import VideoComposeService
|
||||
|
||||
@@ -278,16 +277,11 @@ def _render_with_legacy(
|
||||
|
||||
logger.info("执行 FFmpeg (legacy): plan_id=%s", plan_id)
|
||||
try:
|
||||
subprocess.run(
|
||||
compose_cmd.command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3600,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = f"FFmpeg 执行失败: {e.stderr[:500]}"
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
run_ffmpeg(compose_cmd.command, timeout=3600)
|
||||
except Exception as e:
|
||||
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
|
||||
logger.error("FFmpeg 执行失败(legacy): %s — %s", plan_id, error_msg)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
@@ -364,10 +364,19 @@ def _prepare_bgm_track(
|
||||
try:
|
||||
parsed = urlparse(audio_url)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
import urllib.request
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
|
||||
urllib.request.urlretrieve(audio_url, bgm_file) # nosec B310
|
||||
safe_download_file(
|
||||
audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
@@ -401,10 +410,19 @@ def _prepare_bgm_track(
|
||||
|
||||
preset = get_preset_bgm(preset_id)
|
||||
if preset and preset.audio_url:
|
||||
import urllib.request
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
|
||||
urllib.request.urlretrieve(preset.audio_url, bgm_file) # nosec B310
|
||||
safe_download_file(
|
||||
preset.audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_preset_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
@@ -415,29 +433,93 @@ def _prepare_bgm_track(
|
||||
return None
|
||||
|
||||
|
||||
def _verify_url_accessible(url: str, timeout: float = 10.0, retries: int = 2) -> bool:
|
||||
def _verify_url_accessible(
|
||||
url: str,
|
||||
timeout: float = 10.0,
|
||||
retries: int = 2,
|
||||
max_redirects: int = 5,
|
||||
) -> bool:
|
||||
"""HEAD 请求校验 URL 可访问(含重试,防止 OSS 抖动误报)。
|
||||
|
||||
安全增强:
|
||||
- 请求前先做 SSRF 安全校验(内网IP/回环地址/链路本地地址等)
|
||||
- scheme 仅允许 http/https
|
||||
- 端口仅允许 80/443
|
||||
- 手动跟随重定向,每一跳 URL 都做 SSRF 校验,避免重定向到内网地址绕过
|
||||
|
||||
Args:
|
||||
url: 待校验的 URL
|
||||
timeout: 单次请求超时时间(秒)
|
||||
retries: 最大重试次数(默认 2 次,首次失败后间隔 1s 重试)
|
||||
max_redirects: 最大重定向次数(默认 5 次)
|
||||
|
||||
Returns:
|
||||
True 表示 URL 可访问(HTTP 2xx/3xx),False 表示所有尝试均失败。
|
||||
True 表示 URL 可访问(HTTP 2xx/3xx),False 表示所有尝试均失败或安全校验不通过。
|
||||
"""
|
||||
import time
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from video_processing.url_security import UrlSecurityError, validate_url_safety
|
||||
|
||||
# P0-1 SSRF 防护:请求前先校验 URL 安全性
|
||||
try:
|
||||
validate_url_safety(url, purpose="url_verify")
|
||||
except UrlSecurityError as e:
|
||||
logger.warning("URL 安全校验失败,拒绝访问: url=%s error=%s", url[:80], e)
|
||||
return False
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1 + retries):
|
||||
try:
|
||||
req = urllib.request.Request(url, method="HEAD")
|
||||
|
||||
def _do_verify(current_url: str) -> bool:
|
||||
"""单次校验:手动跟随重定向,每跳都做 SSRF 检查."""
|
||||
redirect_count = 0
|
||||
url_being_checked = current_url
|
||||
|
||||
# 禁止自动重定向的 handler,手动控制每一跳
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: N802
|
||||
return None
|
||||
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
|
||||
while redirect_count <= max_redirects:
|
||||
# 每一跳都做 SSRF 安全校验
|
||||
try:
|
||||
safe_url = validate_url_safety(url_being_checked, purpose="url_verify")
|
||||
except UrlSecurityError as e:
|
||||
logger.warning(
|
||||
"URL校验跳转地址不安全: redirect=%d url=%s error=%s",
|
||||
redirect_count,
|
||||
url_being_checked,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
|
||||
req = urllib.request.Request(safe_url, method="HEAD")
|
||||
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
|
||||
|
||||
with opener.open(req, timeout=timeout) as resp: # noqa: S310
|
||||
if 200 <= resp.status < 300:
|
||||
return True
|
||||
if resp.status in (301, 302, 303, 307, 308):
|
||||
location = resp.headers.get("Location", "")
|
||||
if not location:
|
||||
raise Exception(f"HTTP {resp.status} 但无 Location 头")
|
||||
# 相对路径转绝对
|
||||
url_being_checked = urljoin(safe_url, location)
|
||||
redirect_count += 1
|
||||
continue
|
||||
if resp.status < 400:
|
||||
return True
|
||||
last_error = Exception(f"HTTP {resp.status}")
|
||||
raise Exception(f"HTTP {resp.status}")
|
||||
|
||||
raise Exception(f"重定向次数超过上限 ({max_redirects})")
|
||||
|
||||
for attempt in range(1 + retries):
|
||||
try:
|
||||
if _do_verify(url):
|
||||
return True
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
@@ -461,7 +543,6 @@ def _download_library_assets(
|
||||
asset_library_id: str = "",
|
||||
project_id: str = "",
|
||||
asset_ids: list[str] | None = None,
|
||||
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
|
||||
strict: bool = True,
|
||||
task_id: str = "",
|
||||
gen_task=None,
|
||||
@@ -480,7 +561,6 @@ def _download_library_assets(
|
||||
asset_library_id: 素材库 ID(可选,与 project_id 二选一)
|
||||
project_id: 项目 ID(可选,与 asset_library_id 二选一)
|
||||
asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材
|
||||
video_extensions: 支持的视频扩展名(保留兼容,当前按 file_type 过滤)
|
||||
strict: 严格模式(默认 True)。
|
||||
True — 任何素材下载失败立即抛 RuntimeError;
|
||||
False — 跳过失败素材,返回成功列表(调用方可通过日志感知失败)。
|
||||
|
||||
@@ -45,6 +45,8 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
try:
|
||||
if media_type == "video":
|
||||
# 使用 ffprobe 提取视频元数据
|
||||
from video_processing.ffmpeg_utils import run_ffprobe
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
@@ -55,16 +57,11 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
"-show_streams",
|
||||
file_url,
|
||||
]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
stdout, _ = run_ffprobe(cmd, timeout=30)
|
||||
import json as json_lib
|
||||
|
||||
probe_data = json_lib.loads(result.stdout)
|
||||
probe_data = json_lib.loads(stdout)
|
||||
|
||||
# 提取视频流信息
|
||||
for stream in probe_data.get("streams", []):
|
||||
@@ -83,6 +80,9 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
metadata["size_bytes"] = int(format_info.get("size", 0))
|
||||
metadata["bitrate"] = int(format_info.get("bit_rate", 0))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("视频元数据提取失败: %s", e)
|
||||
|
||||
elif media_type == "image":
|
||||
# 使用 Pillow 提取图片元数据
|
||||
try:
|
||||
|
||||
@@ -19,14 +19,12 @@ class VoiceExtractor:
|
||||
"""Extract voice tracks and background music from videos using FFmpeg."""
|
||||
|
||||
@staticmethod
|
||||
def _run_ffmpeg(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||
"""Run FFmpeg command and return result."""
|
||||
logger.info(f"Running FFmpeg: {chr(39).join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FFmpeg error: {result.stderr}")
|
||||
raise RuntimeError(f"FFmpeg failed: {result.stderr}")
|
||||
return result
|
||||
def _run_ffmpeg(cmd: list[str]) -> None:
|
||||
"""Run FFmpeg command using 统一 run_ffmpeg 工具."""
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
logger.info("Running FFmpeg: %s", " ".join(cmd[:10]))
|
||||
run_ffmpeg(cmd)
|
||||
|
||||
def extract_voice(
|
||||
self,
|
||||
|
||||
Regular → Executable
+10
-13
@@ -8,8 +8,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from subprocess import CalledProcessError, TimeoutExpired
|
||||
|
||||
from shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,7 +61,7 @@ class AudioMerger:
|
||||
output_path = os.path.join(temp_dir, f"merged.{output_format}")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
@@ -72,21 +74,16 @@ class AudioMerger:
|
||||
output_path,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FFmpeg 合并失败: stderr={result.stderr}")
|
||||
raise AudioMergeError(f"FFmpeg 合并失败: {result.stderr[:500]}")
|
||||
try:
|
||||
run_ffmpeg(cmd, timeout=120)
|
||||
except CalledProcessError as e:
|
||||
logger.error(f"FFmpeg 合并失败: stderr={e.stderr}")
|
||||
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}")
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
except TimeoutExpired:
|
||||
raise AudioMergeError("FFmpeg 合并超时(120 秒)")
|
||||
except AudioMergeError:
|
||||
raise
|
||||
|
||||
@@ -15,6 +15,7 @@ import httpx
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.text_splitter import split_text
|
||||
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -224,10 +225,13 @@ class TTSStreamingService:
|
||||
# ── 工具方法 ────────────────────────────────────────────
|
||||
|
||||
def _download_audio(self, url: str) -> bytes:
|
||||
"""下载音频数据。"""
|
||||
resp = httpx.get(url, timeout=60.0, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
"""下载音频数据(含 SSRF 防护 + 大小限制 + 重定向校验)。"""
|
||||
return safe_download_bytes(
|
||||
url,
|
||||
purpose="tts_streaming_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
async def _stream_audio_chunks(self, websocket: Any, audio_data: bytes) -> int:
|
||||
"""将音频数据分块通过 WebSocket 推送。
|
||||
|
||||
Executable → Regular
+23
-15
@@ -19,16 +19,19 @@ from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.application.cosyvoice_service import (
|
||||
CosyVoiceAuthError,
|
||||
CosyVoiceError,
|
||||
CosyVoiceService,
|
||||
)
|
||||
from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.audio_merger import AudioMerger
|
||||
from packages.application.tts_job.text_splitter import split_text
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
from packages.ports.tts_job_repository import TTSJobRepository
|
||||
from packages.shared.storage import SharedStorageService, get_shared_storage_service
|
||||
from packages.shared.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
UrlSecurityError,
|
||||
safe_download_bytes,
|
||||
safe_download_file,
|
||||
validate_url_safety,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,10 +99,13 @@ class TTSWorkflowService:
|
||||
content_type = content_type_map.get(audio_format, "application/octet-stream")
|
||||
|
||||
try:
|
||||
# 下载临时音频
|
||||
resp = httpx.get(temp_url, timeout=60.0, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
audio_data = resp.content
|
||||
# 安全下载临时音频(SSRF 防护 + 大小限制 + 重定向校验)
|
||||
audio_data = safe_download_bytes(
|
||||
temp_url,
|
||||
purpose="tts_audio_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
# 上传到 OSS
|
||||
file_obj = io.BytesIO(audio_data)
|
||||
@@ -463,13 +469,15 @@ class TTSWorkflowService:
|
||||
|
||||
total_duration += result.get("duration", 0.0)
|
||||
|
||||
# 下载分段音频到临时文件
|
||||
resp = httpx.get(audio_url, timeout=60.0, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
# 安全下载分段音频到临时文件(SSRF 防护 + 大小限制)
|
||||
seg_path = os.path.join(temp_dir, f"seg_{idx:03d}.{job.format}")
|
||||
with open(seg_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
safe_download_file(
|
||||
audio_url,
|
||||
seg_path,
|
||||
purpose="tts_segment_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
audio_paths.append(seg_path)
|
||||
|
||||
# 合并
|
||||
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
"""FFmpeg 共享工具 — packages/shared 层.
|
||||
|
||||
仅包含与业务无关的底层原语:FFmpeg/FFprobe 二进制路径、run_ffmpeg 执行器。
|
||||
业务相关的滤镜构建、视频探测等留在 apps/worker/video_processing/ffmpeg_utils.py。
|
||||
|
||||
application 层和 worker 层都可以引用本模块,避免跨层依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
|
||||
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
|
||||
|
||||
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致进程永久阻塞
|
||||
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
|
||||
DEFAULT_FFMPEG_TIMEOUT = 1800
|
||||
|
||||
|
||||
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_ffmpeg(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool = True,
|
||||
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
|
||||
) -> tuple[str, str]:
|
||||
"""执行 FFmpeg 命令(统一入口)。
|
||||
|
||||
Args:
|
||||
command: 完整的 ffmpeg 命令列表(含 "ffmpeg" 本身)
|
||||
capture_output: 是否捕获 stdout/stderr
|
||||
timeout: 超时时间(秒),默认 1800s(30分钟);None 表示不设超时(不推荐)
|
||||
|
||||
Returns:
|
||||
(stdout, stderr) 元组
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出,
|
||||
异常信息包含完整 stderr 以便排查。
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出,FFmpeg 进程会被 kill。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE if capture_output else None,
|
||||
stderr=subprocess.PIPE if capture_output else None,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return (result.stdout or "", result.stderr or "")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
"FFmpeg 命令超时 (%ds): command=%s",
|
||||
timeout or -1,
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
)
|
||||
raise
|
||||
except subprocess.CalledProcessError as e:
|
||||
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
|
||||
stderr_text = (e.stderr or "").strip()
|
||||
logger.error(
|
||||
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
e.returncode,
|
||||
" ".join(str(c) for c in command[:20]), # 截断过长的命令
|
||||
stderr_text[:5000], # 截断过长的 stderr
|
||||
)
|
||||
raise
|
||||
Executable
+542
@@ -0,0 +1,542 @@
|
||||
"""URL 安全校验工具 — SSRF 防护.
|
||||
|
||||
统一的外部 URL 安全校验方案,覆盖所有渲染管线和 TTS 中的外部下载场景。
|
||||
放在 packages/shared/ 作为单一来源,worker 和 application 层都可引用。
|
||||
|
||||
防护要点:
|
||||
1. Scheme 白名单:仅允许 http/https
|
||||
2. 主机 SSRF 防护:禁止内网 IP、回环地址、链路本地地址、元数据服务
|
||||
3. 端口白名单:仅允许 80/443(标准 HTTP/HTTPS)
|
||||
4. 域名校验:禁止 IP 直接访问(除非在白名单中)
|
||||
5. 重定向防护:手动跟随重定向,每次跳转前重新校验目标 URL
|
||||
6. 文件大小限制:流式下载,超过上限立即中断
|
||||
7. MIME 类型白名单:可选的内容类型校验
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 允许的 URL scheme
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
# 允许的端口(标准 HTTP/HTTPS)
|
||||
ALLOWED_PORTS = {80, 443}
|
||||
|
||||
# 可信域名白名单(可根据实际 OSS/CDN 域名配置)
|
||||
# 从环境变量读取,格式:"oss-cn-hangzhou.aliyuncs.com,cdn.example.com"
|
||||
# 默认空表示所有公网域名都允许,但仍会做 SSRF 检查
|
||||
TRUSTED_DOMAINS: set[str] = set()
|
||||
_env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "")
|
||||
if _env_trusted:
|
||||
TRUSTED_DOMAINS = {d.strip() for d in _env_trusted.split(",") if d.strip()}
|
||||
|
||||
# 是否允许 IP 直接访问(默认禁止,防止绕过 DNS 校验)
|
||||
ALLOW_DIRECT_IP = os.environ.get("URL_SECURITY_ALLOW_DIRECT_IP", "false").lower() == "true"
|
||||
|
||||
# 最大 URL 长度
|
||||
MAX_URL_LENGTH = 2048
|
||||
|
||||
# 单次下载最大文件大小(默认 200MB)
|
||||
DEFAULT_MAX_DOWNLOAD_SIZE = int(os.environ.get("URL_SECURITY_MAX_DOWNLOAD_MB", "200")) * 1024 * 1024
|
||||
|
||||
# 允许的音频 MIME 类型白名单
|
||||
ALLOWED_AUDIO_MIME_TYPES = {
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/pcm",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/m4a",
|
||||
"audio/x-m4a",
|
||||
"audio/mp4",
|
||||
"application/octet-stream", # 兼容一些 CDN 返回通用类型
|
||||
}
|
||||
|
||||
# 允许的视频 MIME 类型白名单
|
||||
ALLOWED_VIDEO_MIME_TYPES = {
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/x-matroska",
|
||||
"video/webm",
|
||||
"video/avi",
|
||||
"video/x-msvideo",
|
||||
"video/mpeg",
|
||||
"application/octet-stream",
|
||||
}
|
||||
|
||||
# 允许的图片 MIME 类型白名单
|
||||
ALLOWED_IMAGE_MIME_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
}
|
||||
|
||||
# 下载块大小
|
||||
_DOWNLOAD_CHUNK_SIZE = 8192
|
||||
|
||||
# 最大重定向次数
|
||||
_MAX_REDIRECTS = 5
|
||||
|
||||
# 文件魔数(文件头签名)表 — 用于 MIME 白名单校验后的二次真实性校验
|
||||
# key: MIME 类型,value: 签名列表,任一签名匹配即通过
|
||||
# 每条签名: list of (offset, bytes),所有条目都匹配才算该签名命中(支持多处联合匹配如 RIFF+WAVE)
|
||||
_MAGIC_NUMBERS: dict[str, list[list[tuple[int, bytes]]]] = {
|
||||
# ── 音频 ──
|
||||
"audio/mpeg": [
|
||||
[(0, b"ID3")], # ID3v2 标签
|
||||
[(0, b"\xff\xfb")], # MPEG1 Layer3
|
||||
[(0, b"\xff\xf3")], # MPEG2 Layer3
|
||||
[(0, b"\xff\xf2")], # MPEG2.5 Layer3
|
||||
[(0, b"\xff\xfa")], # MPEG1 Layer2
|
||||
[(0, b"\xff\xf9")], # 其他 MPEG ADTS
|
||||
],
|
||||
"audio/wav": [
|
||||
[(0, b"RIFF"), (8, b"WAVE")], # RIFF + WAVE
|
||||
],
|
||||
"audio/x-wav": [
|
||||
[(0, b"RIFF"), (8, b"WAVE")],
|
||||
],
|
||||
"audio/ogg": [
|
||||
[(0, b"OggS")],
|
||||
],
|
||||
"application/ogg": [
|
||||
[(0, b"OggS")],
|
||||
],
|
||||
"audio/flac": [
|
||||
[(0, b"fLaC")],
|
||||
],
|
||||
"audio/aac": [
|
||||
[(0, b"\xff\xf1")], # ADTS MPEG-4
|
||||
[(0, b"\xff\xf9")], # ADTS MPEG-2
|
||||
],
|
||||
"audio/aacp": [
|
||||
[(0, b"\xff\xf1")],
|
||||
[(0, b"\xff\xf9")],
|
||||
],
|
||||
"audio/mp4": [
|
||||
[(4, b"ftyp")], # ISO Base Media (M4A)
|
||||
],
|
||||
"audio/x-m4a": [
|
||||
[(4, b"ftyp")],
|
||||
],
|
||||
# ── 视频 ──
|
||||
"video/mp4": [
|
||||
[(4, b"ftyp")], # ISO Base Media (MP4)
|
||||
],
|
||||
"video/quicktime": [
|
||||
[(4, b"ftyp")],
|
||||
],
|
||||
"video/x-matroska": [
|
||||
[(0, b"\x1a\x45\xdf\xa3")], # EBML header
|
||||
],
|
||||
"video/webm": [
|
||||
[(0, b"\x1a\x45\xdf\xa3")],
|
||||
],
|
||||
"video/x-msvideo": [
|
||||
[(0, b"RIFF"), (8, b"AVI ")],
|
||||
],
|
||||
# ── 图片 ──
|
||||
"image/jpeg": [
|
||||
[(0, b"\xff\xd8\xff")],
|
||||
],
|
||||
"image/png": [
|
||||
[(0, b"\x89PNG\r\n\x1a\n")],
|
||||
],
|
||||
"image/gif": [
|
||||
[(0, b"GIF87a")],
|
||||
[(0, b"GIF89a")],
|
||||
],
|
||||
"image/webp": [
|
||||
[(0, b"RIFF"), (8, b"WEBP")],
|
||||
],
|
||||
"image/bmp": [
|
||||
[(0, b"BM")],
|
||||
],
|
||||
}
|
||||
|
||||
# 魔数校验最大读取字节数(文件头)
|
||||
_MAGIC_CHECK_READ_SIZE = 256
|
||||
|
||||
|
||||
def _validate_magic_number(file_path: str, allowed_mime_types: set[str]) -> None:
|
||||
"""校验文件头魔数是否与允许的 MIME 类型匹配.
|
||||
|
||||
读取文件前 256 字节,与 allowed_mime_types 对应格式的魔数逐一比对,
|
||||
任一类型匹配即通过;全部不匹配则抛出 UrlSecurityError。
|
||||
|
||||
仅当 allowed_mime_types 非空时执行;空文件视为不匹配。
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
allowed_mime_types: 允许的 MIME 类型集合
|
||||
|
||||
Raises:
|
||||
UrlSecurityError: 文件魔数与所有允许类型均不匹配
|
||||
"""
|
||||
# 收集所有允许类型对应的魔数签名
|
||||
signatures: list[list[tuple[int, bytes]]] = []
|
||||
for mime in allowed_mime_types:
|
||||
sigs = _MAGIC_NUMBERS.get(mime)
|
||||
if sigs:
|
||||
signatures.extend(sigs)
|
||||
|
||||
# 如果没有已知魔数(比如自定义 MIME),跳过校验不阻断
|
||||
if not signatures:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header = f.read(_MAGIC_CHECK_READ_SIZE)
|
||||
except OSError as e:
|
||||
raise UrlSecurityError(f"读取文件头失败: {e}") from e
|
||||
|
||||
if not header:
|
||||
raise UrlSecurityError("文件为空,无法校验格式")
|
||||
|
||||
# 任一签名匹配即通过
|
||||
for sig in signatures:
|
||||
match = True
|
||||
for offset, expected in sig:
|
||||
if offset + len(expected) > len(header):
|
||||
match = False
|
||||
break
|
||||
if header[offset : offset + len(expected)] != expected:
|
||||
match = False
|
||||
break
|
||||
if match:
|
||||
return
|
||||
|
||||
raise UrlSecurityError(
|
||||
f"文件魔数与允许的 MIME 类型不匹配,"
|
||||
f"允许类型: {sorted(allowed_mime_types)},"
|
||||
f"文件头前16字节: {header[:16].hex()}"
|
||||
)
|
||||
|
||||
|
||||
class UrlSecurityError(ValueError):
|
||||
"""URL 安全校验失败."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""禁止自动重定向的 handler,用于手动控制重定向以做安全校验."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: N802
|
||||
return None
|
||||
|
||||
|
||||
def validate_url_safety(url: str, *, purpose: str = "download") -> str:
|
||||
"""校验 URL 安全性,返回标准化后的 URL(供下游使用).
|
||||
|
||||
Args:
|
||||
url: 待校验的 URL
|
||||
purpose: 用途描述(用于日志),如 "bgm_download"、"tts_download"
|
||||
|
||||
Returns:
|
||||
标准化后的 URL
|
||||
|
||||
Raises:
|
||||
UrlSecurityError: URL 不安全
|
||||
"""
|
||||
if not url:
|
||||
raise UrlSecurityError("URL 为空")
|
||||
|
||||
if len(url) > MAX_URL_LENGTH:
|
||||
raise UrlSecurityError(f"URL 过长 ({len(url)} > {MAX_URL_LENGTH})")
|
||||
|
||||
# 解析 URL
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception as e:
|
||||
raise UrlSecurityError(f"URL 解析失败: {e}") from e
|
||||
|
||||
# 1. Scheme 校验
|
||||
if not parsed.scheme or parsed.scheme.lower() not in ALLOWED_SCHEMES:
|
||||
raise UrlSecurityError(f"不允许的 URL scheme: {parsed.scheme}")
|
||||
|
||||
# 2. 主机名校验
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise UrlSecurityError("URL 缺少主机名")
|
||||
|
||||
# 2.1 常见内网主机名前置拦截(防止 DNS rebinding 绕过)
|
||||
_check_internal_hostnames(hostname)
|
||||
|
||||
# 3. 端口校验
|
||||
port = parsed.port
|
||||
if port is not None and port not in ALLOWED_PORTS:
|
||||
raise UrlSecurityError(f"不允许的端口: {port}")
|
||||
|
||||
# 4. SSRF 防护 - 解析 IP 并检查
|
||||
try:
|
||||
# 先判断是否是 IP 地址
|
||||
ip_obj = None
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
pass # 不是 IP,继续走域名解析
|
||||
|
||||
if ip_obj is not None:
|
||||
# 是直接 IP 访问
|
||||
if not ALLOW_DIRECT_IP and not _is_trusted_ip(ip_obj):
|
||||
raise UrlSecurityError(f"禁止直接 IP 访问: {hostname}")
|
||||
_check_ssrf_ip(ip_obj)
|
||||
else:
|
||||
# 域名 — 解析 DNS 检查 SSRF
|
||||
_check_ssrf_domain(hostname)
|
||||
except UrlSecurityError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("URL 安全校验异常: url=%s purpose=%s error=%s", url[:80], purpose, e)
|
||||
raise UrlSecurityError(f"URL 安全校验异常: {e}") from e
|
||||
|
||||
# 5. 可信域名校验(如果配置了白名单)
|
||||
if TRUSTED_DOMAINS and not _is_trusted_domain(hostname):
|
||||
raise UrlSecurityError(f"域名不在可信白名单中: {hostname}")
|
||||
|
||||
logger.debug("URL 安全校验通过: url=%s purpose=%s", url[:80], purpose)
|
||||
return url
|
||||
|
||||
|
||||
def _check_internal_hostnames(hostname: str) -> None:
|
||||
"""前置检查常见内网/敏感主机名,防止 DNS 解析层绕过."""
|
||||
hostname_lower = hostname.lower()
|
||||
internal_hostnames = {
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"ip6-localhost",
|
||||
"ip6-loopback",
|
||||
"metadata",
|
||||
"metadata.google.internal",
|
||||
"169.254.169.254", # 云元数据服务
|
||||
}
|
||||
if hostname_lower in internal_hostnames:
|
||||
raise UrlSecurityError(f"禁止访问内部主机名: {hostname}")
|
||||
|
||||
# 检查以 .local / .internal 结尾的主机名
|
||||
if hostname_lower.endswith((".local", ".internal", ".localdomain")):
|
||||
raise UrlSecurityError(f"禁止访问内网域名: {hostname}")
|
||||
|
||||
|
||||
def _check_ssrf_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None:
|
||||
"""检查 IP 是否属于 SSRF 风险范围."""
|
||||
# 回环地址
|
||||
if ip_obj.is_loopback:
|
||||
raise UrlSecurityError(f"禁止访问回环地址: {ip_obj}")
|
||||
|
||||
# 私有地址(内网)
|
||||
if ip_obj.is_private:
|
||||
raise UrlSecurityError(f"禁止访问内网地址: {ip_obj}")
|
||||
|
||||
# 链路本地地址
|
||||
if ip_obj.is_link_local:
|
||||
raise UrlSecurityError(f"禁止访问链路本地地址: {ip_obj}")
|
||||
|
||||
# 组播地址
|
||||
if ip_obj.is_multicast:
|
||||
raise UrlSecurityError(f"禁止访问组播地址: {ip_obj}")
|
||||
|
||||
# 未指定地址(0.0.0.0 / ::)
|
||||
if ip_obj.is_unspecified:
|
||||
raise UrlSecurityError(f"禁止访问未指定地址: {ip_obj}")
|
||||
|
||||
# 保留地址
|
||||
if ip_obj.is_reserved:
|
||||
raise UrlSecurityError(f"禁止访问保留地址: {ip_obj}")
|
||||
|
||||
|
||||
def _check_ssrf_domain(hostname: str) -> None:
|
||||
"""对域名做 DNS 解析并检查所有解析结果的 IP 是否安全.
|
||||
|
||||
注意:这不能完全防止 DNS rebinding,但能防御大部分 SSRF 场景。
|
||||
"""
|
||||
try:
|
||||
# 解析所有地址
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
if not infos:
|
||||
raise UrlSecurityError(f"域名解析失败: {hostname}")
|
||||
|
||||
for info in infos:
|
||||
ip_str = info[4][0]
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(ip_str)
|
||||
_check_ssrf_ip(ip_obj)
|
||||
except ValueError:
|
||||
# 无法解析为 IP,跳过(不应该发生)
|
||||
continue
|
||||
except socket.gaierror as e:
|
||||
raise UrlSecurityError(f"域名解析失败: {hostname} ({e})") from e
|
||||
|
||||
|
||||
def _is_trusted_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""检查 IP 是否在可信列表中(目前通过环境变量配置域名,IP 级信任暂不开放)."""
|
||||
return False
|
||||
|
||||
|
||||
def _is_trusted_domain(hostname: str) -> bool:
|
||||
"""检查域名是否在可信白名单中(支持子域名匹配)."""
|
||||
hostname_lower = hostname.lower()
|
||||
if hostname_lower in TRUSTED_DOMAINS:
|
||||
return True
|
||||
# 检查子域名
|
||||
for domain in TRUSTED_DOMAINS:
|
||||
if hostname_lower.endswith("." + domain.lower()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_url_safe(url: str, *, purpose: str = "download") -> bool:
|
||||
"""便捷函数:检查 URL 是否安全,不抛异常."""
|
||||
try:
|
||||
validate_url_safety(url, purpose=purpose)
|
||||
return True
|
||||
except UrlSecurityError:
|
||||
return False
|
||||
|
||||
|
||||
# ── 安全下载 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def safe_download_file(
|
||||
url: str,
|
||||
dest_path: str,
|
||||
*,
|
||||
purpose: str = "download",
|
||||
max_size: int = DEFAULT_MAX_DOWNLOAD_SIZE,
|
||||
allowed_mime_types: set[str] | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> int:
|
||||
"""安全下载 URL 到本地文件。
|
||||
|
||||
包含防护:
|
||||
- SSRF 校验(初始 URL + 每次重定向后都校验)
|
||||
- 重定向次数限制 + 手动跟随(避免重定向绕过 SSRF)
|
||||
- 文件大小限制(流式读取,超过立即中断)
|
||||
- MIME 类型白名单(可选)
|
||||
- 文件头魔数校验(配合 MIME 白名单做二次真实性校验)
|
||||
|
||||
Args:
|
||||
url: 下载 URL
|
||||
dest_path: 目标文件路径
|
||||
purpose: 用途描述(日志用)
|
||||
max_size: 最大下载字节数,超过则中断并抛出 UrlSecurityError
|
||||
allowed_mime_types: 允许的 Content-Type 集合,None 表示不校验
|
||||
timeout: 单次请求超时(秒)
|
||||
|
||||
Returns:
|
||||
实际下载的字节数
|
||||
|
||||
Raises:
|
||||
UrlSecurityError: 安全校验失败
|
||||
"""
|
||||
current_url = url
|
||||
redirect_count = 0
|
||||
total_bytes = 0
|
||||
|
||||
# 使用不自动跟随重定向的 opener
|
||||
no_redirect_opener = urllib.request.build_opener(NoRedirectHandler())
|
||||
|
||||
while True:
|
||||
# 每次请求前都做 SSRF 校验(重定向目标也会校验)
|
||||
validate_url_safety(current_url, purpose=purpose)
|
||||
|
||||
req = urllib.request.Request(current_url, method="GET")
|
||||
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
|
||||
|
||||
try:
|
||||
resp = no_redirect_opener.open(req, timeout=timeout) # nosec B310
|
||||
except urllib.error.HTTPError as e:
|
||||
# 3xx 重定向
|
||||
if 300 <= e.code < 400 and e.headers.get("Location"):
|
||||
if redirect_count >= _MAX_REDIRECTS:
|
||||
raise UrlSecurityError(f"重定向次数超过限制 ({_MAX_REDIRECTS})") from e
|
||||
redirect_count += 1
|
||||
current_url = urljoin(current_url, e.headers["Location"])
|
||||
continue
|
||||
raise UrlSecurityError(f"HTTP 错误: {e.code} {e.reason}") from e
|
||||
except urllib.error.URLError as e:
|
||||
raise UrlSecurityError(f"URL 错误: {e.reason}") from e
|
||||
|
||||
try:
|
||||
# Content-Type 校验
|
||||
if allowed_mime_types is not None:
|
||||
content_type = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
||||
if content_type and content_type not in allowed_mime_types:
|
||||
raise UrlSecurityError(
|
||||
f"不允许的 Content-Type: {content_type}, " f"允许: {sorted(allowed_mime_types)}"
|
||||
)
|
||||
|
||||
# Content-Length 预检
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
if content_length and int(content_length) > max_size:
|
||||
raise UrlSecurityError(f"文件过大: {content_length} bytes > {max_size} bytes 上限")
|
||||
|
||||
# 流式下载,实时检查大小
|
||||
with open(dest_path, "wb") as f:
|
||||
while True:
|
||||
chunk = resp.read(_DOWNLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > max_size:
|
||||
raise UrlSecurityError(f"下载超过大小限制: {total_bytes} bytes > {max_size} bytes")
|
||||
f.write(chunk)
|
||||
|
||||
# 文件头魔数校验(MIME 白名单基础上的二次真实性校验)
|
||||
if allowed_mime_types is not None:
|
||||
_validate_magic_number(dest_path, allowed_mime_types)
|
||||
|
||||
return total_bytes
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
|
||||
def safe_download_bytes(
|
||||
url: str,
|
||||
*,
|
||||
purpose: str = "download",
|
||||
max_size: int = DEFAULT_MAX_DOWNLOAD_SIZE,
|
||||
allowed_mime_types: set[str] | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> bytes:
|
||||
"""安全下载 URL 并返回字节内容。
|
||||
|
||||
防护同 safe_download_file,但结果返回在内存中(适合小文件)。
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp()
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
safe_download_file(
|
||||
url,
|
||||
tmp_path,
|
||||
purpose=purpose,
|
||||
max_size=max_size,
|
||||
allowed_mime_types=allowed_mime_types,
|
||||
timeout=timeout,
|
||||
)
|
||||
with open(tmp_path, "rb") as f:
|
||||
return f.read()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
+50
-18
@@ -48,23 +48,55 @@ omit = [
|
||||
]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if __name__ == .__main__.:",
|
||||
"raise NotImplementedError",
|
||||
"pass",
|
||||
"if TYPE_CHECKING:",
|
||||
"class .*Protocol",
|
||||
"@abstractmethod",
|
||||
"raise AssertionError",
|
||||
"raise RuntimeError",
|
||||
"if 0:",
|
||||
"if __debug__:",
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 120
|
||||
exclude = [
|
||||
".git",
|
||||
".cache",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"node_modules",
|
||||
"alembic",
|
||||
".gitea",
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
]
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
|
||||
[tool.coverage.xml]
|
||||
output = "coverage.xml"
|
||||
[tool.ruff.lint]
|
||||
# 当前阶段:摸底模式,规则集与原flake8对齐
|
||||
# 后续迭代计划:
|
||||
# Phase 1: 修完 bugbear 后正式替换 flake8
|
||||
# Phase 2: 启用 UP(pyupgrade) + SIM(simplify)
|
||||
# Phase 3: 启用 RET(return) + ARG(unused-args)
|
||||
select = [
|
||||
"E", # pycodestyle errors(同flake8)
|
||||
"F", # pyflakes(同flake8)
|
||||
"W", # pycodestyle warnings(同flake8)
|
||||
"B", # flake8-bugbear(新增,摸底用)
|
||||
]
|
||||
# 与原 setup.cfg flake8 配置对齐,确保不新增阻断
|
||||
ignore = [
|
||||
"E203",
|
||||
"W503",
|
||||
"E501", # line-too-long(black管)
|
||||
"E302",
|
||||
"E402", # module-import-not-at-top(循环导入多)
|
||||
"E722", # bare-except
|
||||
"W291",
|
||||
"W293",
|
||||
"F401", # unused-import
|
||||
"F403",
|
||||
"F405",
|
||||
"F841", # unused-variable
|
||||
"B008", # do-not-perform-callback-from-arg(fastapi依赖注入)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401", "F403", "F405"]
|
||||
"tests/*" = ["E402", "F401", "F841"]
|
||||
"packages/ports/*" = ["E301", "E704"]
|
||||
"apps/*/migrations/*" = ["ALL"]
|
||||
"alembic/*" = ["ALL"]
|
||||
|
||||
Regular → Executable
+1
-1
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
pythonpath = . apps/api apps/worker
|
||||
pythonpath = . apps/api apps/worker packages
|
||||
testpaths = tests
|
||||
|
||||
# ===== 覆盖率配置 =====
|
||||
|
||||
Executable → Regular
+1
-1
@@ -13,7 +13,7 @@ uvicorn[standard]==0.32.0
|
||||
pydantic==2.9.0
|
||||
|
||||
# 认证核心
|
||||
pyjwt==2.9.0
|
||||
pyjwt==2.13.0
|
||||
bcrypt==4.2.0
|
||||
|
||||
# Redis
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
统一CI通知脚本 - 发送飞书卡片通知
|
||||
支持三种模式: start / success / failure
|
||||
包含: PR链接、耗时、失败阶段、分支、提交者、Run链接、Runner信息
|
||||
|
||||
用法:
|
||||
NOTIFY_MODE=start JOB_NAME="xxx" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=success JOB_NAME="xxx" JOB_DURATION="2m30s" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="xxx" FAILED_STEP="xxx" JOB_DURATION="2m30s" python3 scripts/ci_notify.py
|
||||
|
||||
环境变量:
|
||||
CI_NOTIFY_WEBHOOK - 飞书webhook地址 (必填)
|
||||
NOTIFY_MODE - 通知模式: start / success / failure (必填)
|
||||
JOB_NAME - Job名称 (必填)
|
||||
JOB_DURATION - 耗时,如"2m30s" (成功/失败时建议传)
|
||||
FAILED_STEP - 失败的步骤名 (失败时建议传)
|
||||
GITHUB_REF_NAME - 分支名
|
||||
GITHUB_SHA - commit SHA
|
||||
GITHUB_ACTOR - 提交者
|
||||
GITHUB_RUN_ID - Run ID
|
||||
GITHUB_REPOSITORY - 仓库路径
|
||||
GITHUB_EVENT_NAME - 事件类型 (pull_request / push / ...)
|
||||
GITHUB_PR_NUMBER - PR编号 (PR事件时)
|
||||
GITHUB_PR_TITLE - PR标题 (PR事件时)
|
||||
RUNNER_NAME - Runner名称 (可选,自动获取)
|
||||
|
||||
设计原则:
|
||||
1. 通知失败永远不阻断CI主流程(返回exit code 0)
|
||||
2. 标题包含"CI通知"/"CI告警"关键词,适配飞书webhook关键词校验
|
||||
3. 卡片信息尽量丰富,方便快速定位问题
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def get_env(name, default=""):
|
||||
"""读取环境变量"""
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def format_duration(seconds_str):
|
||||
"""将秒数格式化为易读形式"""
|
||||
try:
|
||||
seconds = int(float(seconds_str))
|
||||
mins = seconds // 60
|
||||
secs = seconds % 60
|
||||
if mins > 0:
|
||||
return f"{mins}m{secs}s"
|
||||
return f"{secs}s"
|
||||
except (ValueError, TypeError):
|
||||
return seconds_str or "未知"
|
||||
|
||||
|
||||
def classify_job(job_name):
|
||||
"""根据Job名称判断所属阶段"""
|
||||
name = job_name.lower()
|
||||
if any(k in name for k in ["validate", "lint", "unit test", "integration test"]):
|
||||
return "门禁检查"
|
||||
if any(k in name for k in ["build", "image"]):
|
||||
return "镜像构建"
|
||||
if any(k in name for k in ["deploy", "staging", "production"]):
|
||||
return "部署发布"
|
||||
if any(k in name for k in ["e2e", "test", "smoke"]):
|
||||
return "测试验证"
|
||||
return "其他"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
webhook = get_env("CI_NOTIFY_WEBHOOK")
|
||||
if not webhook:
|
||||
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
|
||||
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
|
||||
return 0
|
||||
|
||||
mode = get_env("NOTIFY_MODE", "failure").lower()
|
||||
job_name = get_env("JOB_NAME", "Unknown Job")
|
||||
duration = get_env("JOB_DURATION")
|
||||
if not duration:
|
||||
duration_sec = get_env("JOB_DURATION_SECONDS")
|
||||
duration = format_duration(duration_sec) if duration_sec else "计算中..."
|
||||
|
||||
failed_step = get_env("FAILED_STEP", "")
|
||||
branch = get_env("GITHUB_REF_NAME", "unknown")
|
||||
commit = get_env("GITHUB_SHA", "unknown")[:8]
|
||||
actor = get_env("GITHUB_ACTOR", "unknown")
|
||||
run_id = get_env("GITHUB_RUN_ID", "unknown")
|
||||
repo = get_env("GITHUB_REPOSITORY", "unknown")
|
||||
event_name = get_env("GITHUB_EVENT_NAME", "")
|
||||
pr_number = get_env("GITHUB_PR_NUMBER", "")
|
||||
pr_title = get_env("GITHUB_PR_TITLE", "")
|
||||
runner_name = get_env("RUNNER_NAME", "")
|
||||
|
||||
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
|
||||
job_stage = classify_job(job_name)
|
||||
|
||||
# 根据模式设置标题、状态、颜色
|
||||
# 注意:标题中必须包含飞书webhook配置的关键词,否则会报"Key Words Not Found"
|
||||
# 这里加入"CI通知"/"CI告警"关键词提高命中率
|
||||
if mode == "start":
|
||||
title = f"🔄 CI通知:{job_name} 开始构建"
|
||||
status = "blue"
|
||||
button_text = "查看进度"
|
||||
button_type = "primary"
|
||||
elif mode == "success":
|
||||
title = f"✅ CI通知:{job_name} 构建成功"
|
||||
status = "green"
|
||||
button_text = "查看详情"
|
||||
button_type = "primary"
|
||||
else: # failure
|
||||
title = f"❌ CI告警:{job_name} 构建失败"
|
||||
status = "red"
|
||||
button_text = "查看失败日志"
|
||||
button_type = "danger"
|
||||
|
||||
# 构建卡片内容 - 左侧标签+右侧值的结构化展示
|
||||
fields = []
|
||||
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**阶段**\n{job_stage}"}})
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**任务**\n{job_name}"}})
|
||||
|
||||
if mode != "start":
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{duration}"}})
|
||||
else:
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**状态**\n进行中"}})
|
||||
|
||||
if runner_name:
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Runner**\n{runner_name}"}})
|
||||
|
||||
if mode == "failure" and failed_step:
|
||||
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**失败步骤**\n{failed_step}"}})
|
||||
|
||||
# PR/分支信息
|
||||
if event_name == "pull_request" and pr_number:
|
||||
pr_url = f"https://git.xiaoxiajianji.com/{repo}/pulls/{pr_number}"
|
||||
pr_display = f"#{pr_number}"
|
||||
if pr_title:
|
||||
pr_display += f" {pr_title[:30]}"
|
||||
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**PR**\n[{pr_display}]({pr_url})"}})
|
||||
elif event_name == "push":
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}})
|
||||
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit}`"}})
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交者**\n{actor}"}})
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Run ID**\n{run_id}"}})
|
||||
|
||||
payload = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": title,
|
||||
},
|
||||
"status": status,
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"fields": fields,
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": button_text},
|
||||
"url": run_url,
|
||||
"type": button_type,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
webhook,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp_body = resp.read().decode("utf-8")
|
||||
# 飞书返回code=0表示成功
|
||||
try:
|
||||
result = json.loads(resp_body)
|
||||
if result.get("code", 0) != 0:
|
||||
print(f"通知发送告警: 飞书返回错误 - {result.get('msg', resp_body)}", file=sys.stderr)
|
||||
print(f"通知已发送 ({mode}) - 飞书返回非0,但不阻断CI流程")
|
||||
else:
|
||||
print(f"通知已发送 ({mode})")
|
||||
except json.JSONDecodeError:
|
||||
print(f"通知已发送 ({mode})")
|
||||
except Exception as e:
|
||||
print(f"通知发送告警: {e}", file=sys.stderr)
|
||||
|
||||
# 通知无论成功失败都不阻断CI主流程,统一返回0
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,483 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# CI Production 健康检查 + 自动回滚脚本(SSH 部署模式)
|
||||
# ===========================================
|
||||
#
|
||||
# 在 CI Runner 上执行,通过公网 URL 检查 Production 部署健康状态。
|
||||
# 不健康则通过 SSH 自动回滚到上一个版本的镜像。
|
||||
#
|
||||
# 用法:
|
||||
# ./ci_production_healthcheck.sh
|
||||
#
|
||||
# 环境变量:
|
||||
# PROD_API_URL - Production API 公网地址 (默认 https://api.xiaoxiajianji.com)
|
||||
# PROD_WEB_URL - Production Web 公网地址 (默认 https://saas.xiaoxiajianji.com)
|
||||
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 180)
|
||||
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
|
||||
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
||||
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
||||
#
|
||||
# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址
|
||||
# PRODUCTION_SSH_USER - SSH 用户名 (默认 root)
|
||||
# PRODUCTION_SSH_PORT - SSH 端口 (默认 22222)
|
||||
# PRODUCTION_SSH_KEY - SSH 私钥内容
|
||||
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
|
||||
#
|
||||
# GITHUB_SHA - 当前 commit SHA
|
||||
# GITHUB_REF_NAME - tag 名 (如 v0.1.100)
|
||||
# GITHUB_RUN_ID - CI Run ID
|
||||
# GITHUB_REPOSITORY - 仓库名
|
||||
# GITHUB_ACTOR - 提交者
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
|
||||
# 配置
|
||||
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
|
||||
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
|
||||
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-180}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
|
||||
|
||||
PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||||
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
|
||||
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
|
||||
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
# 颜色
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
||||
|
||||
# ===========================================
|
||||
# SSH 工具函数
|
||||
# ===========================================
|
||||
SSH_KEY_PATH=""
|
||||
|
||||
setup_ssh() {
|
||||
# 查找或创建 SSH 密钥
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/prod_deploy_key"
|
||||
mkdir -p "$HOME/.ssh"
|
||||
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$SSH_KEY_PATH"
|
||||
chmod 600 "$SSH_KEY_PATH"
|
||||
else
|
||||
log_error "没有可用的 SSH 密钥"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -p "$PRODUCTION_SSH_PORT" -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
log_info "SSH 已配置: ${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}:${PRODUCTION_SSH_PORT}"
|
||||
}
|
||||
|
||||
run_ssh() {
|
||||
local cmd="$1"
|
||||
ssh -p "$PRODUCTION_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
|
||||
"${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}" "$cmd"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 1. 记录部署前各服务的镜像版本(用于回滚)
|
||||
# ===========================================
|
||||
ROLLBACK_API_TAG=""
|
||||
ROLLBACK_WORKER_TAG=""
|
||||
ROLLBACK_WEB_TAG=""
|
||||
|
||||
save_rollback_target() {
|
||||
log_step "记录当前生产环境各服务镜像版本(回滚目标)..."
|
||||
|
||||
# 通过 SSH 获取当前运行的容器镜像
|
||||
local api_image worker_image web_image
|
||||
api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-production 2>/dev/null || echo ''")
|
||||
worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-production 2>/dev/null || echo ''")
|
||||
web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-production 2>/dev/null || echo ''")
|
||||
|
||||
# 提取 tag(镜像名是 xiaoxia-saas-api:v0.1.100 格式)
|
||||
ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "")
|
||||
ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "")
|
||||
ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "")
|
||||
|
||||
log_info " API: ${ROLLBACK_API_TAG:-未知}"
|
||||
log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}"
|
||||
log_info " Web: ${ROLLBACK_WEB_TAG:-未知}"
|
||||
|
||||
# 验证三个服务版本是否一致
|
||||
if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then
|
||||
if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then
|
||||
log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG"
|
||||
export ROLLBACK_TAG="$ROLLBACK_API_TAG"
|
||||
else
|
||||
log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本"
|
||||
export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG
|
||||
export ROLLBACK_TAG_MIXED="true"
|
||||
fi
|
||||
else
|
||||
log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 2. 健康检查(公网视角)
|
||||
# ===========================================
|
||||
health_check() {
|
||||
local timeout="$HEALTH_CHECK_TIMEOUT"
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
log_step "公网健康检查(超时 ${timeout}s)..."
|
||||
log_info " API: ${PROD_API_URL}/health"
|
||||
log_info " Web: ${PROD_WEB_URL}/"
|
||||
|
||||
local api_ok=false
|
||||
local web_ok=false
|
||||
local api_docs_ok=false
|
||||
local login_api_ok=false
|
||||
|
||||
while [ $(( $(date +%s) - start_time )) -lt "$timeout" ]; do
|
||||
# 检查 API health
|
||||
if [ "$api_ok" = false ] && curl -sf --max-time 10 "${PROD_API_URL}/health" >/dev/null 2>&1; then
|
||||
log_info "✅ API 健康检查通过"
|
||||
api_ok=true
|
||||
fi
|
||||
|
||||
# 检查 Web 首页
|
||||
if [ "$web_ok" = false ] && curl -sf --max-time 10 "$PROD_WEB_URL/" >/dev/null 2>&1; then
|
||||
log_info "✅ Web 前端检查通过"
|
||||
web_ok=true
|
||||
fi
|
||||
|
||||
# 检查 API docs
|
||||
if [ "$api_docs_ok" = false ]; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${PROD_API_URL}/docs" 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
log_info "✅ API Docs 检查通过"
|
||||
api_docs_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查登录 API
|
||||
if [ "$login_api_ok" = false ]; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
|
||||
"${PROD_API_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"smoke@test.com","password":"wrong"}' 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
|
||||
log_info "✅ 登录 API 检查通过(HTTP $HTTP_CODE,符合预期)"
|
||||
login_api_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 都通过了就退出
|
||||
if [ "$api_ok" = true ] && [ "$web_ok" = true ] && [ "$api_docs_ok" = true ] && [ "$login_api_ok" = true ]; then
|
||||
log_info "🎉 所有健康检查通过!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# 超时了
|
||||
log_error "❌ 健康检查超时 (${timeout}s)"
|
||||
[ "$api_ok" = false ] && log_error " - API health 未通过"
|
||||
[ "$web_ok" = false ] && log_error " - Web 前端未通过"
|
||||
[ "$api_docs_ok" = false ] && log_error " - API Docs 未通过"
|
||||
[ "$login_api_ok" = false ] && log_error " - 登录 API 未通过"
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 3. 执行回滚(SSH 重新部署旧版本)
|
||||
# ===========================================
|
||||
do_rollback() {
|
||||
log_step "执行回滚:通过 SSH 重新部署旧版本镜像..."
|
||||
|
||||
local rollback_tag="${ROLLBACK_TAG:-}"
|
||||
if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then
|
||||
log_error "没有可回滚的版本记录,无法自动回滚"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 如果版本不一致,用 API 的版本作为回滚目标
|
||||
if [ -z "$rollback_tag" ]; then
|
||||
rollback_tag="$ROLLBACK_API_TAG"
|
||||
fi
|
||||
|
||||
if [ -z "$rollback_tag" ]; then
|
||||
log_error "无法确定回滚版本"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "回滚目标版本: $rollback_tag"
|
||||
|
||||
# 通过 SSH 在生产服务器上执行回滚部署
|
||||
# 复用 Registry 方式部署脚本的逻辑,用旧版本 tag 重新部署
|
||||
local rollback_script=$(cat << 'ROLLBACK_EOF'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
IMAGE_TAG="$1"
|
||||
REGISTRY_TOKEN="$2"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
|
||||
|
||||
echo "=== Rollback to $IMAGE_TAG ==="
|
||||
|
||||
# 登录 Registry
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Pull 旧版本镜像
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API"
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER"
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB"
|
||||
|
||||
echo "Rollback images pulled."
|
||||
|
||||
# 停止当前容器
|
||||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 启动 API(回滚不跑 migration,因为新版本可能加了字段,回滚后代码是旧的但数据还在)
|
||||
echo "Starting API (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# 启动 Worker
|
||||
echo "Starting Worker (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# 启动 Web
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
|
||||
echo "Starting Web (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# 等待 API 健康
|
||||
echo "Waiting for API (rollback)..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||||
echo "API healthy (rollback)."
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 等待 Web 健康
|
||||
echo "Waiting for Web (rollback)..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
|
||||
echo "Web healthy (rollback)."
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "=== Rollback complete: $IMAGE_TAG ==="
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
|
||||
ROLLBACK_EOF
|
||||
)
|
||||
|
||||
# 将脚本 base64 编码后通过 SSH 执行
|
||||
local script_b64
|
||||
script_b64=$(echo "$rollback_script" | base64 -w 0)
|
||||
|
||||
log_info "在生产服务器上执行回滚脚本..."
|
||||
if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then
|
||||
log_info "✅ 回滚命令执行完成"
|
||||
return 0
|
||||
else
|
||||
log_error "❌ 回滚命令执行失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 4. 发送通知
|
||||
# ===========================================
|
||||
send_notification() {
|
||||
local status="$1" # success / failure / rollback
|
||||
local detail="$2"
|
||||
|
||||
if [ "${SKIP_NOTIFY:-false}" = "true" ]; then
|
||||
log_info "跳过通知(SKIP_NOTIFY=true)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local webhook="${CI_NOTIFY_WEBHOOK:-}"
|
||||
if [ -z "$webhook" ]; then
|
||||
log_warn "未配置 CI_NOTIFY_WEBHOOK,跳过通知"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then
|
||||
python3 "$SCRIPT_DIR/deploy_notify.py" \
|
||||
--status "$status" \
|
||||
--detail "$detail" \
|
||||
--webhook "$webhook" \
|
||||
--env production \
|
||||
2>/dev/null || log_warn "通知发送失败(非致命)"
|
||||
else
|
||||
log_warn "找不到 deploy_notify.py,跳过通知"
|
||||
fi
|
||||
|
||||
# 标记:通知已由健康检查脚本发出,避免 CI 兜底通知重复发送
|
||||
echo "$status" > /tmp/prod_deploy_notification_sent
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 主流程
|
||||
# ===========================================
|
||||
main() {
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CI Production 健康检查 + 自动回滚"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
local deploy_status="success"
|
||||
local deploy_detail=""
|
||||
|
||||
# 1. 设置 SSH
|
||||
if ! setup_ssh; then
|
||||
log_error "SSH 配置失败,无法执行回滚"
|
||||
fi
|
||||
|
||||
# 2. 记录部署前状态(回滚目标)
|
||||
save_rollback_target || true
|
||||
|
||||
# 3. 健康检查(公网视角)
|
||||
if ! health_check; then
|
||||
log_error "健康检查失败"
|
||||
deploy_status="failure"
|
||||
deploy_detail="公网健康检查超时,部署后服务未正常响应"
|
||||
|
||||
# 自动回滚
|
||||
if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then
|
||||
log_warn "开始自动回滚..."
|
||||
if do_rollback; then
|
||||
deploy_status="rollback"
|
||||
deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})"
|
||||
|
||||
# 回滚后再检查一下公网状态
|
||||
log_info "回滚完成,重新检查公网健康状态..."
|
||||
if health_check; then
|
||||
log_info "✅ 回滚后服务已恢复"
|
||||
deploy_detail="${deploy_detail},回滚后服务已恢复"
|
||||
else
|
||||
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
|
||||
deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查"
|
||||
fi
|
||||
else
|
||||
deploy_detail="健康检查失败且回滚失败,请手动排查"
|
||||
fi
|
||||
fi
|
||||
|
||||
send_notification "$deploy_status" "$deploy_detail"
|
||||
|
||||
# 失败时退出非零,让 CI Job 标记为失败
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. 成功
|
||||
log_info ""
|
||||
log_info "=================================="
|
||||
log_info " ✅ Production 部署成功!"
|
||||
log_info "=================================="
|
||||
|
||||
deploy_detail="部署成功,所有健康检查通过 (${GITHUB_REF_NAME:-未知版本})"
|
||||
send_notification "success" "$deploy_detail"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+250
@@ -0,0 +1,250 @@
|
||||
#!/bin/sh
|
||||
# ===========================================
|
||||
# Staging 部署脚本(SSH 模式)
|
||||
# ===========================================
|
||||
# 通过 SSH 在 staging 服务器上执行
|
||||
#
|
||||
# 环境变量:
|
||||
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
|
||||
# REGISTRY_TOKEN - Registry 访问令牌
|
||||
# REGISTRY - Registry 地址(默认 git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas)
|
||||
# REGISTRY_USER - Registry 用户名(默认 xiaoxia)
|
||||
# ENV_FILE - 环境变量文件路径
|
||||
# GENERATED_DIR - 生成文件目录
|
||||
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false)
|
||||
|
||||
set -eu
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Staging 部署 - $IMAGE_TAG"
|
||||
echo "=========================================="
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 新版本镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# Re-tag 成本地名
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "All images pulled and tagged."
|
||||
|
||||
# ---- 备份 legacy assets ----
|
||||
echo "Backing up legacy assets from current web container..."
|
||||
if docker inspect xiaoxia-web-staging >/dev/null 2>&1; then
|
||||
_tmpdir="/tmp/legacy-assets-$$"
|
||||
rm -rf "$_tmpdir"
|
||||
mkdir -p "$_tmpdir"
|
||||
docker cp xiaoxia-web-staging:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
|
||||
# 只有目录非空才拷贝,避免覆盖有内容的 legacy assets
|
||||
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
|
||||
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
|
||||
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
|
||||
fi
|
||||
rm -rf "$_tmpdir"
|
||||
else
|
||||
echo "No existing web container, skipping legacy assets backup"
|
||||
fi
|
||||
|
||||
# 清理 7 天前的 legacy assets
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ]; then
|
||||
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
echo "Legacy assets cleanup done (retain 7 days)"
|
||||
fi
|
||||
|
||||
# ---- 检查基础设施容器 ----
|
||||
echo "Checking infrastructure containers..."
|
||||
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 创建网络(不存在则创建) ----
|
||||
docker network create xiaoxia-net-staging 2>/dev/null || true
|
||||
|
||||
# ---- 数据库迁移 ----
|
||||
if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
else
|
||||
echo "Skipping migrations (SKIP_MIGRATION=true)"
|
||||
fi
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# ---- 启动 API ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web ----
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
echo "Web container: legacy assets mounted (fallback)"
|
||||
else
|
||||
echo "Web container: no legacy assets to mount"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-staging
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Staging deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
@@ -0,0 +1,473 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# CI Staging 健康检查 + 自动回滚脚本(SSH 部署模式)
|
||||
# ===========================================
|
||||
#
|
||||
# 在 CI Runner 上执行,通过公网 URL 检查 Staging 部署健康状态。
|
||||
# 不健康则通过 SSH 自动回滚到上一个版本的镜像。
|
||||
#
|
||||
# 用法:
|
||||
# ./ci_staging_healthcheck.sh
|
||||
#
|
||||
# 环境变量:
|
||||
# STAGING_API_URL - Staging API 地址 (默认 https://staging-api.xiaoxiajianji.com)
|
||||
# STAGING_WEB_URL - Staging Web 地址 (默认 https://staging.xiaoxiajianji.com)
|
||||
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 120)
|
||||
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
|
||||
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
||||
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
||||
#
|
||||
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167)
|
||||
# STAGING_SSH_USER - SSH 用户名 (默认 root)
|
||||
# STAGING_SSH_PORT - SSH 端口 (默认 22222)
|
||||
# STAGING_SSH_KEY - SSH 私钥内容
|
||||
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
|
||||
#
|
||||
# GITHUB_SHA - 当前 commit SHA
|
||||
# GITHUB_REF_NAME - 分支名
|
||||
# GITHUB_RUN_ID - CI Run ID
|
||||
# GITHUB_REPOSITORY - 仓库名
|
||||
# GITHUB_ACTOR - 提交者
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
|
||||
# 配置
|
||||
STAGING_API_URL="${STAGING_API_URL:-https://staging-api.xiaoxiajianji.com}"
|
||||
STAGING_WEB_URL="${STAGING_WEB_URL:-https://staging.xiaoxiajianji.com}"
|
||||
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
|
||||
|
||||
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
|
||||
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
|
||||
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
# 颜色
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
||||
|
||||
# ===========================================
|
||||
# SSH 工具函数
|
||||
# ===========================================
|
||||
SSH_KEY_PATH=""
|
||||
|
||||
setup_ssh() {
|
||||
# 查找或创建 SSH 密钥
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/staging_deploy_key"
|
||||
mkdir -p "$HOME/.ssh"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$SSH_KEY_PATH"
|
||||
chmod 600 "$SSH_KEY_PATH"
|
||||
else
|
||||
log_error "没有可用的 SSH 密钥"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -p "$STAGING_SSH_PORT" -H "$STAGING_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
log_info "SSH 已配置: ${STAGING_SSH_USER}@${STAGING_SSH_HOST}:${STAGING_SSH_PORT}"
|
||||
}
|
||||
|
||||
run_ssh() {
|
||||
local cmd="$1"
|
||||
ssh -p "$STAGING_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
|
||||
"${STAGING_SSH_USER}@${STAGING_SSH_HOST}" "$cmd"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 1. 记录部署前各服务的镜像版本(用于回滚)
|
||||
# ===========================================
|
||||
ROLLBACK_API_TAG=""
|
||||
ROLLBACK_WORKER_TAG=""
|
||||
ROLLBACK_WEB_TAG=""
|
||||
|
||||
save_rollback_target() {
|
||||
log_step "记录当前 staging 各服务镜像版本(回滚目标)..."
|
||||
|
||||
# 通过 SSH 获取当前运行的容器镜像
|
||||
local api_image worker_image web_image
|
||||
api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-staging 2>/dev/null || echo ''")
|
||||
worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-staging 2>/dev/null || echo ''")
|
||||
web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-staging 2>/dev/null || echo ''")
|
||||
|
||||
# 提取 tag(镜像名是 xiaoxia-saas-api:abc123 或 git.xiaoxiajianji.com/.../xiaoxia-saas-api:staging 格式)
|
||||
ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "")
|
||||
ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "")
|
||||
ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "")
|
||||
|
||||
log_info " API: ${ROLLBACK_API_TAG:-未知}"
|
||||
log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}"
|
||||
log_info " Web: ${ROLLBACK_WEB_TAG:-未知}"
|
||||
|
||||
# 验证三个服务版本是否一致
|
||||
if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then
|
||||
if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then
|
||||
log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG"
|
||||
export ROLLBACK_TAG="$ROLLBACK_API_TAG"
|
||||
else
|
||||
log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本"
|
||||
export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG
|
||||
export ROLLBACK_TAG_MIXED="true"
|
||||
fi
|
||||
else
|
||||
log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 2. 健康检查(公网视角)
|
||||
# ===========================================
|
||||
health_check() {
|
||||
local timeout="$HEALTH_CHECK_TIMEOUT"
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
log_step "公网健康检查(超时 ${timeout}s)..."
|
||||
log_info " API: ${STAGING_API_URL}/health"
|
||||
log_info " Web: ${STAGING_WEB_URL}/"
|
||||
|
||||
local api_ok=false
|
||||
local web_ok=false
|
||||
local api_docs_ok=false
|
||||
local login_api_ok=false
|
||||
|
||||
while [ $(( $(date +%s) - start_time )) -lt "$timeout" ]; do
|
||||
# 检查 API health
|
||||
if [ "$api_ok" = false ] && curl -sf --max-time 10 "${STAGING_API_URL}/health" >/dev/null 2>&1; then
|
||||
log_info "✅ API 健康检查通过"
|
||||
api_ok=true
|
||||
fi
|
||||
|
||||
# 检查 Web 首页
|
||||
if [ "$web_ok" = false ] && curl -sf --max-time 10 "$STAGING_WEB_URL/" >/dev/null 2>&1; then
|
||||
log_info "✅ Web 前端检查通过"
|
||||
web_ok=true
|
||||
fi
|
||||
|
||||
# 检查 API docs(服务完全启动的标志)
|
||||
if [ "$api_docs_ok" = false ]; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${STAGING_API_URL}/docs" 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
log_info "✅ API Docs 检查通过"
|
||||
api_docs_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查登录 API(业务逻辑正常的标志)
|
||||
if [ "$login_api_ok" = false ]; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
|
||||
"${STAGING_API_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"smoke@test.com","password":"wrong"}' 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
|
||||
log_info "✅ 登录 API 检查通过(HTTP $HTTP_CODE,符合预期)"
|
||||
login_api_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 都通过了就退出
|
||||
if [ "$api_ok" = true ] && [ "$web_ok" = true ] && [ "$api_docs_ok" = true ] && [ "$login_api_ok" = true ]; then
|
||||
log_info "🎉 所有健康检查通过!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# 超时了
|
||||
log_error "❌ 健康检查超时 (${timeout}s)"
|
||||
[ "$api_ok" = false ] && log_error " - API health 未通过"
|
||||
[ "$web_ok" = false ] && log_error " - Web 前端未通过"
|
||||
[ "$api_docs_ok" = false ] && log_error " - API Docs 未通过"
|
||||
[ "$login_api_ok" = false ] && log_error " - 登录 API 未通过"
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 3. 执行回滚(SSH 重新部署旧版本)
|
||||
# ===========================================
|
||||
do_rollback() {
|
||||
log_step "执行回滚:通过 SSH 重新部署旧版本镜像..."
|
||||
|
||||
local rollback_tag="${ROLLBACK_TAG:-}"
|
||||
if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then
|
||||
log_error "没有可回滚的版本记录,无法自动回滚"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 如果版本不一致,用 API 的版本作为回滚目标
|
||||
if [ -z "$rollback_tag" ]; then
|
||||
rollback_tag="$ROLLBACK_API_TAG"
|
||||
fi
|
||||
|
||||
if [ -z "$rollback_tag" ]; then
|
||||
log_error "无法确定回滚版本"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "回滚目标版本: $rollback_tag"
|
||||
|
||||
# 构建回滚脚本(直接部署旧版本镜像,不跑 migration)
|
||||
local rollback_script=$(cat << 'ROLLBACK_EOF'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
IMAGE_TAG="$1"
|
||||
REGISTRY_TOKEN="$2"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
echo "=== Rollback to $IMAGE_TAG ==="
|
||||
|
||||
# 登录 Registry
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Pull 旧版本镜像
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API"
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER"
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB"
|
||||
|
||||
echo "Rollback images pulled."
|
||||
|
||||
# 停止当前容器(回滚不跑 migration,避免数据问题)
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 启动 API(回滚不跑 migration)
|
||||
echo "Starting API (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# 启动 Worker
|
||||
echo "Starting Worker (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# 启动 Web
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
|
||||
echo "Starting Web (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# 等待 API 健康
|
||||
echo "Waiting for API (rollback)..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API healthy (rollback)."
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 等待 Web 健康
|
||||
echo "Waiting for Web (rollback)..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
|
||||
echo "Web healthy (rollback)."
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "=== Rollback complete: $IMAGE_TAG ==="
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
ROLLBACK_EOF
|
||||
)
|
||||
|
||||
# 将脚本 base64 编码后通过 SSH 执行
|
||||
local script_b64
|
||||
script_b64=$(echo "$rollback_script" | base64 -w 0)
|
||||
|
||||
log_info "在 staging 服务器上执行回滚脚本..."
|
||||
if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then
|
||||
log_info "✅ 回滚命令执行完成"
|
||||
return 0
|
||||
else
|
||||
log_error "❌ 回滚命令执行失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 4. 发送通知
|
||||
# ===========================================
|
||||
send_notification() {
|
||||
local status="$1" # success / failure / rollback
|
||||
local detail="$2"
|
||||
|
||||
if [ "${SKIP_NOTIFY:-false}" = "true" ]; then
|
||||
log_info "跳过通知(SKIP_NOTIFY=true)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local webhook="${CI_NOTIFY_WEBHOOK:-}"
|
||||
if [ -z "$webhook" ]; then
|
||||
log_warn "未配置 CI_NOTIFY_WEBHOOK,跳过通知"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then
|
||||
python3 "$SCRIPT_DIR/deploy_notify.py" \
|
||||
--status "$status" \
|
||||
--detail "$detail" \
|
||||
--webhook "$webhook" \
|
||||
--env staging \
|
||||
2>/dev/null || log_warn "通知发送失败(非致命)"
|
||||
else
|
||||
log_warn "找不到 deploy_notify.py,跳过通知"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 主流程
|
||||
# ===========================================
|
||||
main() {
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " CI Staging 健康检查 + 自动回滚(SSH模式)"
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
|
||||
local deploy_status="success"
|
||||
local deploy_detail=""
|
||||
|
||||
# 1. 设置 SSH
|
||||
if ! setup_ssh; then
|
||||
log_error "SSH 配置失败,无法执行回滚"
|
||||
fi
|
||||
|
||||
# 2. 记录部署前状态(回滚目标)
|
||||
save_rollback_target || true
|
||||
|
||||
# 3. 健康检查(公网视角)
|
||||
if ! health_check; then
|
||||
log_error "健康检查失败"
|
||||
deploy_status="failure"
|
||||
deploy_detail="公网健康检查超时,部署后服务未正常响应"
|
||||
|
||||
# 自动回滚
|
||||
if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then
|
||||
log_warn "开始自动回滚..."
|
||||
if do_rollback; then
|
||||
deploy_status="rollback"
|
||||
deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})"
|
||||
|
||||
# 回滚后再检查一下公网状态
|
||||
log_info "回滚完成,重新检查公网健康状态..."
|
||||
if health_check; then
|
||||
log_info "✅ 回滚后服务已恢复"
|
||||
deploy_detail="${deploy_detail},回滚后服务已恢复"
|
||||
else
|
||||
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
|
||||
deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查"
|
||||
fi
|
||||
else
|
||||
deploy_detail="健康检查失败且回滚失败,请手动排查"
|
||||
fi
|
||||
fi
|
||||
|
||||
send_notification "$deploy_status" "$deploy_detail"
|
||||
|
||||
# 失败时退出非零,让 CI Job 标记为失败
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. 成功
|
||||
log_info ""
|
||||
log_info "=================================="
|
||||
log_info " ✅ Staging 部署成功!"
|
||||
log_info "=================================="
|
||||
|
||||
deploy_detail="部署成功,所有健康检查通过 (${GITHUB_SHA:-未知版本})"
|
||||
send_notification "success" "$deploy_detail"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
部署通知脚本(支持 Staging / Production)
|
||||
|
||||
与 CI 通知(ci_notify_success.py / ci_notify_failure.py)卡片格式对齐。
|
||||
发送部署结果通知到飞书 webhook(卡片格式)。
|
||||
|
||||
支持三种状态:success / failure / rollback
|
||||
支持两种环境:staging / production
|
||||
|
||||
用法:
|
||||
python3 deploy_notify.py --status success --detail "部署成功" --env staging
|
||||
python3 deploy_notify.py --status rollback --detail "健康检查失败,已回滚" --env production
|
||||
python3 deploy_notify.py --status failure --detail "部署过程出错" --env production --failed-step "构建镜像"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
STATUS_CONFIG = {
|
||||
"success": {
|
||||
"emoji": "✅",
|
||||
"title_suffix": "部署成功",
|
||||
"color": "green",
|
||||
"button_text": "查看构建详情",
|
||||
"button_type": "primary",
|
||||
},
|
||||
"failure": {
|
||||
"emoji": "❌",
|
||||
"title_suffix": "部署失败",
|
||||
"color": "red",
|
||||
"button_text": "查看失败日志",
|
||||
"button_type": "danger",
|
||||
},
|
||||
"rollback": {
|
||||
"emoji": "↩️",
|
||||
"title_suffix": "部署已回滚",
|
||||
"color": "yellow",
|
||||
"button_text": "查看构建详情",
|
||||
"button_type": "primary",
|
||||
},
|
||||
}
|
||||
|
||||
ENV_CONFIG = {
|
||||
"staging": {
|
||||
"label": "Staging",
|
||||
"url_web": "https://staging.xiaoxiajianji.com",
|
||||
"url_api": "https://staging-api.xiaoxiajianji.com",
|
||||
},
|
||||
"production": {
|
||||
"label": "Production",
|
||||
"url_web": "https://saas.xiaoxiajianji.com",
|
||||
"url_api": "https://api.xiaoxiajianji.com",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_card(
|
||||
status: str,
|
||||
detail: str,
|
||||
env: str = "staging",
|
||||
duration: str = "",
|
||||
failed_step: str = "",
|
||||
pr_url: str = "",
|
||||
) -> dict:
|
||||
"""构建飞书卡片消息(与 ci_notify_*.py 风格一致)。"""
|
||||
cfg = STATUS_CONFIG.get(status, STATUS_CONFIG["failure"])
|
||||
env_cfg = ENV_CONFIG.get(env, ENV_CONFIG["staging"])
|
||||
title = f"{cfg['emoji']} {env_cfg['label']} {cfg['title_suffix']}"
|
||||
|
||||
commit = os.environ.get("GITHUB_SHA", "unknown")[:8]
|
||||
ref = os.environ.get("GITHUB_REF_NAME", "unknown")
|
||||
actor = os.environ.get("GITHUB_ACTOR", "system")
|
||||
run_id = os.environ.get("GITHUB_RUN_ID", "-")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas")
|
||||
|
||||
# 版本信息:tag 部署显示 tag,分支部署显示分支
|
||||
if ref.startswith("v"):
|
||||
version_info = f"版本 {ref}"
|
||||
else:
|
||||
version_info = ref
|
||||
|
||||
# 构建内容行(与 ci_notify_*.py 风格一致:**标签**: 值)
|
||||
lines = []
|
||||
|
||||
# 详情行(部署特有)
|
||||
if detail:
|
||||
lines.append(f"**详情**: {detail}")
|
||||
|
||||
lines.append(f"**环境**: {env_cfg['label']}")
|
||||
lines.append(f"**版本**: {version_info}")
|
||||
|
||||
# 失败阶段(失败/回滚时显示)
|
||||
if failed_step and status in ("failure", "rollback"):
|
||||
lines.append(f"**失败阶段**: {failed_step}")
|
||||
|
||||
# 耗时(可选)
|
||||
if duration:
|
||||
lines.append(f"**耗时**: {duration}")
|
||||
|
||||
lines.append(f"**提交**: {commit}")
|
||||
lines.append(f"**提交者**: {actor}")
|
||||
lines.append(f"**Run ID**: {run_id}")
|
||||
|
||||
elements = [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": "\n".join(lines),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# 查看详情按钮
|
||||
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
|
||||
if run_id and run_id != "-":
|
||||
elements.append(
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": cfg["button_text"]},
|
||||
"url": run_url,
|
||||
"type": cfg["button_type"],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# PR 链接(可选)
|
||||
if pr_url:
|
||||
elements.append(
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看 PR"},
|
||||
"url": pr_url,
|
||||
"type": "default",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# 访问地址(部署特有)
|
||||
elements.append(
|
||||
{
|
||||
"tag": "note",
|
||||
"elements": [
|
||||
{
|
||||
"tag": "plain_text",
|
||||
"content": f"Web: {env_cfg['url_web']} | API: {env_cfg['url_api']}",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": title,
|
||||
},
|
||||
"status": cfg["color"],
|
||||
},
|
||||
"elements": elements,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def send_notification(
|
||||
webhook: str,
|
||||
status: str,
|
||||
detail: str,
|
||||
env: str = "staging",
|
||||
duration: str = "",
|
||||
failed_step: str = "",
|
||||
pr_url: str = "",
|
||||
) -> bool:
|
||||
"""发送通知到 webhook。"""
|
||||
payload = build_card(status, detail, env, duration, failed_step, pr_url)
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
webhook,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp.read()
|
||||
print(f"通知已发送: {env} {status}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"通知发送失败: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="部署通知脚本")
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
required=True,
|
||||
choices=["success", "failure", "rollback"],
|
||||
help="部署状态",
|
||||
)
|
||||
parser.add_argument("--detail", default="", help="详情描述")
|
||||
parser.add_argument(
|
||||
"--env",
|
||||
default="staging",
|
||||
choices=["staging", "production"],
|
||||
help="部署环境 (默认 staging)",
|
||||
)
|
||||
parser.add_argument("--duration", default="", help="部署耗时")
|
||||
parser.add_argument("--failed-step", default="", help="失败阶段")
|
||||
parser.add_argument("--pr-url", default="", help="PR 链接")
|
||||
parser.add_argument(
|
||||
"--webhook",
|
||||
default=os.environ.get("CI_NOTIFY_WEBHOOK", ""),
|
||||
help="Webhook URL (也可通过 CI_NOTIFY_WEBHOOK 环境变量设置)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.webhook:
|
||||
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
|
||||
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
|
||||
return 0
|
||||
|
||||
send_notification(
|
||||
webhook=args.webhook,
|
||||
status=args.status,
|
||||
detail=args.detail,
|
||||
env=args.env,
|
||||
duration=args.duration,
|
||||
failed_step=args.failed_step,
|
||||
pr_url=args.pr_url,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
自动生成 CHANGELOG 条目。
|
||||
|
||||
用法:
|
||||
python3 scripts/generate_changelog.py v0.1.128 v0.1.129
|
||||
python3 scripts/generate_changelog.py v0.1.128 HEAD
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
REPO = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
|
||||
TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
|
||||
def gitea_api(path: str) -> dict | list:
|
||||
url = f"{GITEA_URL}/api/v1{path}"
|
||||
req = urllib.request.Request(url)
|
||||
if TOKEN:
|
||||
req.add_header("Authorization", f"token {TOKEN}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"API Error: {e.code} {e.reason}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def get_tag_date(tag: str) -> str:
|
||||
try:
|
||||
info = gitea_api(f"/repos/{REPO}/git/refs/tags/{tag}")
|
||||
if isinstance(info, dict):
|
||||
sha = info.get("object", {}).get("sha", "")
|
||||
if sha:
|
||||
commit = gitea_api(f"/repos/{REPO}/git/commits/{sha}")
|
||||
if isinstance(commit, dict):
|
||||
return commit.get("committer", {}).get("date", "")[:10]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def get_merged_prs_between(from_tag: str, to_tag: str) -> list[dict]:
|
||||
all_prs: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
prs = gitea_api(f"/repos/{REPO}/pulls?state=closed&sort=merged&direction=desc" f"&per_page=50&page={page}")
|
||||
if not isinstance(prs, list) or not prs:
|
||||
break
|
||||
all_prs.extend(prs)
|
||||
if len(prs) < 50:
|
||||
break
|
||||
page += 1
|
||||
if page > 10:
|
||||
break
|
||||
|
||||
merged = [pr for pr in all_prs if pr.get("merged_at")]
|
||||
from_date = get_tag_date(from_tag)
|
||||
to_date = get_tag_date(to_tag) if not to_tag.startswith("HEAD") else datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
if not from_date:
|
||||
return merged[:50]
|
||||
|
||||
result = []
|
||||
for pr in merged:
|
||||
merged_at = pr.get("merged_at", "")[:10]
|
||||
if from_date <= merged_at <= to_date:
|
||||
result.append(pr)
|
||||
return result
|
||||
|
||||
|
||||
def categorize_pr(title: str) -> tuple[str, str]:
|
||||
title = title.strip()
|
||||
lower = title.lower()
|
||||
|
||||
m = re.match(r"^(feat|fix|chore|perf|docs|refactor|test|ci|style|build|security)\s*[::]", title)
|
||||
if m:
|
||||
prefix = m.group(1)
|
||||
clean_title = title[m.end() :].strip()
|
||||
else:
|
||||
prefix = ""
|
||||
clean_title = title
|
||||
|
||||
if prefix in ("feat", "feature"):
|
||||
return "✨ 功能", clean_title
|
||||
elif prefix == "fix":
|
||||
return "🐛 Bug 修复", clean_title
|
||||
elif prefix in ("refactor", "chore", "style"):
|
||||
return "🔄 重构与清理", clean_title
|
||||
elif prefix in ("perf", "performance"):
|
||||
return "⚡ 性能优化", clean_title
|
||||
elif prefix == "security":
|
||||
return "🔒 安全修复", clean_title
|
||||
elif prefix == "docs":
|
||||
return "📝 文档", clean_title
|
||||
elif prefix == "test":
|
||||
return "🧪 测试", clean_title
|
||||
elif prefix in ("ci", "build"):
|
||||
return "🚀 CI/CD & 基础设施", clean_title
|
||||
else:
|
||||
if any(k in lower for k in ["安全", "security", "cve", "漏洞"]):
|
||||
return "🔒 安全修复", clean_title
|
||||
elif any(k in lower for k in ["修复", "bug"]):
|
||||
return "🐛 Bug 修复", clean_title
|
||||
elif any(k in lower for k in ["新增", "添加", "feat", "功能"]):
|
||||
return "✨ 功能", clean_title
|
||||
elif any(k in lower for k in ["ci", "构建", "workflow", "pipeline"]):
|
||||
return "🚀 CI/CD & 基础设施", clean_title
|
||||
elif any(k in lower for k in ["测试", "test", "e2e"]):
|
||||
return "🧪 测试", clean_title
|
||||
else:
|
||||
return "📌 其他", clean_title
|
||||
|
||||
|
||||
def generate_changelog(from_tag: str, to_tag: str, version: str = "") -> str:
|
||||
if not version:
|
||||
version = to_tag
|
||||
|
||||
prs = get_merged_prs_between(from_tag, to_tag)
|
||||
|
||||
categories: dict[str, list[tuple[int, str]]] = {}
|
||||
for pr in prs:
|
||||
cat, title = categorize_pr(pr["title"])
|
||||
pr_num = pr["number"]
|
||||
categories.setdefault(cat, []).append((pr_num, title))
|
||||
|
||||
order = [
|
||||
"🔒 安全修复",
|
||||
"✨ 功能",
|
||||
"🐛 Bug 修复",
|
||||
"⚡ 性能优化",
|
||||
"🔄 重构与清理",
|
||||
"📝 文档",
|
||||
"🧪 测试",
|
||||
"🚀 CI/CD & 基础设施",
|
||||
"📌 其他",
|
||||
]
|
||||
|
||||
date_str = get_tag_date(to_tag) if not to_tag.startswith("HEAD") else datetime.now().strftime("%Y-%m-%d")
|
||||
lines = [f"## [{version}] - {date_str}", ""]
|
||||
|
||||
for cat in order:
|
||||
items = categories.get(cat, [])
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"### {cat}")
|
||||
lines.append("")
|
||||
for num, title in sorted(items, key=lambda x: x[0]):
|
||||
short_title = title.split(" — ")[0].split(" - ")[0]
|
||||
if len(short_title) > 80:
|
||||
short_title = short_title[:77] + "..."
|
||||
lines.append(f"- #{num} {short_title}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(f"用法: {sys.argv[0]} <from_tag> <to_tag> [version]")
|
||||
sys.exit(1)
|
||||
|
||||
from_tag = sys.argv[1]
|
||||
to_tag = sys.argv[2]
|
||||
version = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
|
||||
changelog = generate_changelog(from_tag, to_tag, version)
|
||||
print(changelog)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+184
-74
@@ -1,9 +1,13 @@
|
||||
#!/bin/bash
|
||||
# 灰度发布脚本:通过Nginx权重调整流量比例
|
||||
# 灰度发布脚本:在生产服务器上启动 canary 版本,通过 Nginx 权重切流
|
||||
# 用法: ./scripts/gray_deploy.sh <版本号> <灰度百分比>
|
||||
#
|
||||
# 需要在目标服务器上执行,或通过SSH执行
|
||||
# 前提:服务器上运行两个版本的容器(stable + canary),Nginx做加权轮询
|
||||
# 前提:
|
||||
# - 在生产服务器上执行(或通过 SSH 管道执行)
|
||||
# - 当前已有全量运行的 production 容器
|
||||
# - Nginx 配置在 /etc/nginx/sites-enabled/00-xiaoxia-saas
|
||||
#
|
||||
# 灰度范围:API + Web(Worker 暂时全量升级,队列消费无法按比例切流)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -12,106 +16,212 @@ GRAY_PCT="${2:-10}"
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "用法: $0 <版本号> [灰度百分比]"
|
||||
echo "示例: $0 v0.1.129 5"
|
||||
echo "示例: $0 v0.1.130 5"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STABLE_VERSION="${STABLE_VERSION:-current}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
NGINX_CONF="${NGINX_CONF:-/etc/nginx/sites-enabled/00-xiaoxia-saas}"
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
|
||||
|
||||
echo "=========================================="
|
||||
# Canary 端口(与 production 错开)
|
||||
CANARY_API_PORT=18001
|
||||
CANARY_WEB_PORT=13002
|
||||
|
||||
echo "============================================"
|
||||
echo " 灰度发布"
|
||||
echo " 新版本: $VERSION"
|
||||
echo " 灰度比例: ${GRAY_PCT}%"
|
||||
echo " 稳定版本: $STABLE_VERSION"
|
||||
echo "=========================================="
|
||||
echo " Canary API 端口: $CANARY_API_PORT"
|
||||
echo " Canary Web 端口: $CANARY_WEB_PORT"
|
||||
echo "============================================"
|
||||
|
||||
# 1. 拉取新版本镜像
|
||||
# 1. 检查环境
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "错误: 环境文件不存在: $ENV_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$NGINX_CONF" ]]; then
|
||||
echo "错误: Nginx 配置不存在: $NGINX_CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 拉取新版本镜像
|
||||
echo ""
|
||||
echo ">>> 拉取新版本镜像..."
|
||||
for component in api worker web; do
|
||||
for component in api web worker; do
|
||||
echo " 拉取 $component:$VERSION ..."
|
||||
docker pull "${REGISTRY}-${component}:${VERSION}" 2>&1 | tail -1
|
||||
done
|
||||
echo " ✅ 镜像拉取完成"
|
||||
|
||||
# 2. 启动灰度版本容器(canary)
|
||||
# 3. 启动 API Canary
|
||||
echo ""
|
||||
echo ">>> 启动灰度版本容器..."
|
||||
|
||||
# API canary
|
||||
CANARY_API_NAME="saas-api-canary"
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_API_NAME}$"; then
|
||||
echo " 停止旧 canary 容器..."
|
||||
docker stop "$CANARY_API_NAME" 2>/dev/null || true
|
||||
docker rm "$CANARY_API_NAME" 2>/dev/null || true
|
||||
echo ">>> 启动 API Canary 容器..."
|
||||
CANARY_API="xiaoxia-api-canary"
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_API}$"; then
|
||||
echo " 停止旧 canary..."
|
||||
docker rm -f "$CANARY_API" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
echo " 启动 api canary..."
|
||||
docker run -d \
|
||||
--name "$CANARY_API_NAME" \
|
||||
--network saas-network \
|
||||
-e DATABASE_URL="${DATABASE_URL}" \
|
||||
-e REDIS_URL="${REDIS_URL}" \
|
||||
-e FEATURE_FLAG_PROVIDER=redis \
|
||||
--name "$CANARY_API" \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p "127.0.0.1:${CANARY_API_PORT}:8000" \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$VERSION-canary" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
-v "${GENERATED_DIR}:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
"${REGISTRY}-api:${VERSION}"
|
||||
--cpus 1 \
|
||||
--memory 1g \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=50m \
|
||||
--log-opt max-file=3 \
|
||||
"${REGISTRY}-api:${VERSION}" >/dev/null
|
||||
|
||||
# Worker canary
|
||||
CANARY_WORKER_NAME="saas-worker-canary"
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_WORKER_NAME}$"; then
|
||||
echo " 停止旧 worker canary..."
|
||||
docker stop "$CANARY_WORKER_NAME" 2>/dev/null || true
|
||||
docker rm "$CANARY_WORKER_NAME" 2>/dev/null || true
|
||||
echo " ✅ API Canary 已启动(端口 $CANARY_API_PORT)"
|
||||
|
||||
# 4. 启动 Web Canary
|
||||
echo ""
|
||||
echo ">>> 启动 Web Canary 容器..."
|
||||
CANARY_WEB="xiaoxia-web-canary"
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_WEB}$"; then
|
||||
echo " 停止旧 canary..."
|
||||
docker rm -f "$CANARY_WEB" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
echo " 启动 worker canary..."
|
||||
docker run -d \
|
||||
--name "$CANARY_WORKER_NAME" \
|
||||
--network saas-network \
|
||||
-e DATABASE_URL="${DATABASE_URL}" \
|
||||
-e REDIS_URL="${REDIS_URL}" \
|
||||
--restart unless-stopped \
|
||||
"${REGISTRY}-worker:${VERSION}"
|
||||
LEGACY_VOLUME=""
|
||||
if [[ -d "$LEGACY_ASSETS_DIR" ]] && [[ -n "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
|
||||
# 3. 等待容器健康
|
||||
docker run -d \
|
||||
--name "$CANARY_WEB" \
|
||||
--network xiaoxia-net-production \
|
||||
-p "127.0.0.1:${CANARY_WEB_PORT}:80" \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 256m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 10s \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=50m \
|
||||
--log-opt max-file=3 \
|
||||
"${REGISTRY}-web:${VERSION}" >/dev/null
|
||||
|
||||
echo " ✅ Web Canary 已启动(端口 $CANARY_WEB_PORT)"
|
||||
|
||||
# 5. 等待健康检查
|
||||
echo ""
|
||||
echo ">>> 等待容器健康..."
|
||||
sleep 10
|
||||
if ! docker ps --format '{{.Names}} {{.Status}}' | grep -q "$CANARY_API_NAME"; then
|
||||
echo "错误: API canary 容器未运行"
|
||||
docker logs "$CANARY_API_NAME" --tail 20
|
||||
echo ">>> 等待 Canary 容器健康..."
|
||||
for i in $(seq 1 40); do
|
||||
api_healthy=$(docker inspect --format='{{.State.Health.Status}}' "$CANARY_API" 2>/dev/null || echo "starting")
|
||||
web_healthy=$(docker inspect --format='{{.State.Health.Status}}' "$CANARY_WEB" 2>/dev/null || echo "starting")
|
||||
|
||||
if [[ "$api_healthy" == "healthy" && "$web_healthy" == "healthy" ]]; then
|
||||
echo " ✅ API + Web Canary 均健康(用时 ${i}s)"
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ "$api_healthy" == "unhealthy" ]]; then
|
||||
echo " ❌ API Canary 健康检查失败"
|
||||
docker logs --tail 30 "$CANARY_API"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$web_healthy" == "unhealthy" ]]; then
|
||||
echo " ❌ Web Canary 健康检查失败"
|
||||
docker logs --tail 20 "$CANARY_WEB"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 6. 更新 Nginx 配置 - 添加 upstream 权重
|
||||
echo ""
|
||||
echo ">>> 更新 Nginx 权重(稳定: $((100-GRAY_PCT))% / 灰度: ${GRAY_PCT}%)..."
|
||||
|
||||
# 备份
|
||||
BAK_FILE="${NGINX_CONF}.bak.gray.$(date +%Y%m%d%H%M%S)"
|
||||
cp "$NGINX_CONF" "$BAK_FILE"
|
||||
echo " 已备份: $BAK_FILE"
|
||||
|
||||
# 生成 upstream 块
|
||||
UPSTREAM_BLOCK="
|
||||
# Gray release upstreams(自动生成 - gray_deploy.sh)
|
||||
upstream saas_api_backend {
|
||||
server 127.0.0.1:8001 weight=$((100-GRAY_PCT));
|
||||
server 127.0.0.1:${CANARY_API_PORT} weight=${GRAY_PCT};
|
||||
}
|
||||
|
||||
upstream saas_web_backend {
|
||||
server 127.0.0.1:3002 weight=$((100-GRAY_PCT));
|
||||
server 127.0.0.1:${CANARY_WEB_PORT} weight=${GRAY_PCT};
|
||||
}
|
||||
"
|
||||
|
||||
# 在文件最前面插入 upstream 块
|
||||
TMP_CONF=$(mktemp)
|
||||
{
|
||||
echo "$UPSTREAM_BLOCK"
|
||||
cat "$NGINX_CONF"
|
||||
} > "$TMP_CONF"
|
||||
|
||||
# 替换 proxy_pass 指向 upstream
|
||||
# API: proxy_pass http://127.0.0.1:8001 -> proxy_pass http://saas_api_backend
|
||||
sed -i 's|proxy_pass http://127\.0\.0\.1:8001|proxy_pass http://saas_api_backend|g' "$TMP_CONF"
|
||||
# Web: proxy_pass http://127.0.0.1:3002/ -> proxy_pass http://saas_web_backend/
|
||||
sed -i 's|proxy_pass http://127\.0\.0\.1:3002/|proxy_pass http://saas_web_backend/|g' "$TMP_CONF"
|
||||
|
||||
# 测试配置
|
||||
mv "$TMP_CONF" "$NGINX_CONF"
|
||||
if ! nginx -t 2>&1; then
|
||||
echo " ❌ Nginx 配置测试失败,回滚..."
|
||||
cp "$BAK_FILE" "$NGINX_CONF"
|
||||
nginx -t
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ API canary 运行中"
|
||||
|
||||
# 4. 更新Nginx权重
|
||||
nginx -s reload
|
||||
echo " ✅ Nginx 已 reload,灰度生效"
|
||||
|
||||
# 7. 验证灰度流量
|
||||
echo ""
|
||||
echo ">>> 更新Nginx权重 (稳定: $((100-GRAY_PCT))% / 灰度: ${GRAY_PCT}%)..."
|
||||
|
||||
NGINX_CONF="${NGINX_CONF:-/etc/nginx/conf.d/saas-api.conf}"
|
||||
if [[ -f "$NGINX_CONF" ]]; then
|
||||
# 备份
|
||||
cp "$NGINX_CONF" "${NGINX_CONF}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
# 更新 upstream 权重(需要根据实际配置调整)
|
||||
echo " 请手动更新 Nginx upstream 配置中的权重"
|
||||
echo " 示例配置:"
|
||||
cat <<EOF
|
||||
upstream saas_api_backend {
|
||||
server saas-api:8000 weight=$((100-GRAY_PCT));
|
||||
server saas-api-canary:8000 weight=${GRAY_PCT};
|
||||
}
|
||||
EOF
|
||||
nginx -t && nginx -s reload
|
||||
echo " ✅ Nginx 已reload"
|
||||
else
|
||||
echo " 警告: Nginx 配置文件不存在 ($NGINX_CONF)"
|
||||
echo " 请手动配置灰度流量权重"
|
||||
fi
|
||||
echo ">>> 验证灰度流量..."
|
||||
gray_hits=0
|
||||
total_hits=20
|
||||
for i in $(seq 1 $total_hits); do
|
||||
resp=$(curl -s -o /dev/null -w "%{http_code}" -H "X-Gray-Test: 1" http://127.0.0.1:${CANARY_API_PORT}/health 2>/dev/null || echo "000")
|
||||
if [[ "$resp" == "200" ]]; then
|
||||
gray_hits=$((gray_hits + 1))
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo " Canary 健康验证: $gray_hits/$total_hits 请求成功"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "============================================"
|
||||
echo " ✅ 灰度发布完成"
|
||||
echo " 新版本: $VERSION (${GRAY_PCT}%流量)"
|
||||
echo " 监控: Grafana / 日志"
|
||||
echo "=========================================="
|
||||
echo " 版本: $VERSION (${GRAY_PCT}%流量)"
|
||||
echo " API: 127.0.0.1:$CANARY_API_PORT"
|
||||
echo " Web: 127.0.0.1:$CANARY_WEB_PORT"
|
||||
echo " Nginx 备份: $BAK_FILE"
|
||||
echo " 回滚: ./scripts/rollback.sh"
|
||||
echo " Worker: 暂不灰度(队列消费无法按比例切流)"
|
||||
echo "============================================"
|
||||
|
||||
+37
-50
@@ -1,6 +1,11 @@
|
||||
#!/bin/bash
|
||||
# 一键发布脚本:打tag → 触发生产镜像构建 → 部署到灰度
|
||||
# 用法: ./scripts/release.sh v0.1.129 [--gray 5]
|
||||
# 一键发布脚本:打 tag → 触发 CI 构建 → 可选灰度发布
|
||||
# 用法: ./scripts/release.sh v0.1.130 [--gray 5]
|
||||
#
|
||||
# 说明:
|
||||
# - 打 tag 后 CI 会自动构建镜像并全量部署到生产
|
||||
# - 加 --gray 参数则在构建完成后执行灰度切流(需 SSH 到生产服务器执行)
|
||||
# - 加 --no-deploy 只打 tag 不触发自动部署
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -11,13 +16,12 @@ usage() {
|
||||
echo "用法: $0 <版本号> [--gray 百分比] [--no-deploy]"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 v0.1.129 # 打tag + 全量发布"
|
||||
echo " $0 v0.1.129 --gray 5 # 打tag + 5%灰度发布"
|
||||
echo " $0 v0.1.129 --no-deploy # 只打tag,不部署"
|
||||
echo " $0 v0.1.130 # 打tag + 全量发布(CI自动部署)"
|
||||
echo " $0 v0.1.130 --gray 5 # 打tag + 5%灰度发布"
|
||||
echo " $0 v0.1.130 --no-deploy # 只打tag,不部署"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 参数解析
|
||||
VERSION=""
|
||||
GRAY_PCT=0
|
||||
DEPLOY=true
|
||||
@@ -47,80 +51,63 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "错误: 请指定版本号(如 v0.1.129)"
|
||||
echo "错误: 请指定版本号(如 v0.1.130)"
|
||||
usage
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "============================================"
|
||||
echo " 发布版本: $VERSION"
|
||||
echo " 灰度比例: ${GRAY_PCT}%"
|
||||
echo " 自动部署: $DEPLOY"
|
||||
echo "=========================================="
|
||||
echo "============================================"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# 1. 确认在 develop 分支
|
||||
# 1. 确认分支
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$CURRENT_BRANCH" != "develop" ]]; then
|
||||
echo "错误: 请切换到 develop 分支后再发布"
|
||||
echo "错误: 请在 develop 分支上打 tag"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 拉取最新代码
|
||||
# 2. 拉取最新
|
||||
echo ""
|
||||
echo ">>> 拉取最新代码..."
|
||||
git pull origin develop
|
||||
|
||||
# 3. 生成 CHANGELOG
|
||||
echo ""
|
||||
echo ">>> 生成 CHANGELOG..."
|
||||
if [[ -f "scripts/generate_changelog.py" ]]; then
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
if [[ -n "$PREV_TAG" ]]; then
|
||||
python3 scripts/generate_changelog.py \
|
||||
--from-tag "$PREV_TAG" \
|
||||
--to-tag HEAD \
|
||||
--gitea-token "${GITEA_TOKEN:-}" \
|
||||
--output /tmp/changelog_$$.md
|
||||
echo "CHANGELOG 已生成到 /tmp/changelog_$$.md"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. 打tag
|
||||
echo ""
|
||||
echo ">>> 打 tag $VERSION ..."
|
||||
# 3. 检查 tag 是否已存在
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "警告: tag $VERSION 已存在,跳过打tag"
|
||||
echo "警告: tag $VERSION 已存在,跳过打 tag"
|
||||
else
|
||||
echo ""
|
||||
echo ">>> 打 tag $VERSION ..."
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
git push origin "$VERSION"
|
||||
echo "Tag $VERSION 已推送,触发生产镜像构建..."
|
||||
echo " ✅ Tag 已推送,CI 将自动构建生产镜像"
|
||||
fi
|
||||
|
||||
# 5. 等待镜像构建
|
||||
# 4. 部署提示
|
||||
if [[ "$DEPLOY" == "true" ]]; then
|
||||
echo ""
|
||||
echo ">>> 等待镜像构建完成(约10-15分钟)..."
|
||||
echo " 镜像: git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas-{api,worker,web}:$VERSION"
|
||||
|
||||
# 这里可以加镜像存在性检查
|
||||
echo " (镜像构建由CI自动完成,请在Gitea Actions中确认)"
|
||||
fi
|
||||
|
||||
# 6. 灰度部署
|
||||
if [[ "$DEPLOY" == "true" && "$GRAY_PCT" -gt 0 ]]; then
|
||||
echo ">>> 构建 & 部署"
|
||||
echo " CI 会自动执行:"
|
||||
echo " 1. Build Production Runtime Images(约10-15分钟)"
|
||||
echo " 2. Deploy Production(SSH 到生产服务器部署)"
|
||||
echo ""
|
||||
echo ">>> 灰度部署: ${GRAY_PCT}% 流量到 $VERSION"
|
||||
if [[ -f "scripts/gray_deploy.sh" ]]; then
|
||||
./scripts/gray_deploy.sh "$VERSION" "$GRAY_PCT"
|
||||
else
|
||||
echo "警告: gray_deploy.sh 不存在,跳过灰度部署"
|
||||
echo " 查看进度: Gitea Actions → 对应 tag 的 run"
|
||||
|
||||
if [[ "$GRAY_PCT" -gt 0 ]]; then
|
||||
echo ""
|
||||
echo ">>> 灰度发布"
|
||||
echo " 构建部署完成后,在生产服务器上执行:"
|
||||
echo " cd /var/lib/xiaoxia-saas-production"
|
||||
echo " ./gray_deploy.sh $VERSION $GRAY_PCT"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " ✅ 发布流程完成"
|
||||
echo "============================================"
|
||||
echo " ✅ 发布流程触发完成"
|
||||
echo " 版本: $VERSION"
|
||||
echo " 灰度: ${GRAY_PCT}%"
|
||||
echo "=========================================="
|
||||
echo "============================================"
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# 灰度回滚脚本:切回全量稳定版本,停止 canary 容器
|
||||
# 用法: ./scripts/rollback_gray.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NGINX_CONF="${NGINX_CONF:-/etc/nginx/sites-enabled/00-xiaoxia-saas}"
|
||||
CANARY_API="${CANARY_API:-xiaoxia-api-canary}"
|
||||
CANARY_WEB="${CANARY_WEB:-xiaoxia-web-canary}"
|
||||
|
||||
echo "============================================"
|
||||
echo " 灰度回滚"
|
||||
echo " 目标: 全量切回稳定版本"
|
||||
echo "============================================"
|
||||
|
||||
# 1. 找最近的灰度备份
|
||||
echo ""
|
||||
echo ">>> 查找最近的灰度备份..."
|
||||
LATEST_BAK=$(ls -t "${NGINX_CONF}".bak.gray.* 2>/dev/null | head -1 || true)
|
||||
|
||||
if [[ -z "$LATEST_BAK" ]]; then
|
||||
echo " 未找到灰度备份,尝试手动移除 upstream 配置..."
|
||||
|
||||
# 手动回滚:移除 upstream 块,把 proxy_pass 改回 127.0.0.1
|
||||
TMP_CONF=$(mktemp)
|
||||
|
||||
# 移除 upstream 块(从 "# Gray release upstreams" 到空行结束)
|
||||
awk '
|
||||
/^# Gray release upstreams/ { skip=1; next }
|
||||
skip && /^$/ && !found_first_empty { found_first_empty=1; next }
|
||||
skip && found_first_empty && /^$/ { skip=0; found_first_empty=0; next }
|
||||
skip { next }
|
||||
{ print }
|
||||
' "$NGINX_CONF" > "$TMP_CONF"
|
||||
|
||||
# 把 upstream 名改回 IP
|
||||
sed -i 's|proxy_pass http://saas_api_backend|proxy_pass http://127.0.0.1:8001|g' "$TMP_CONF"
|
||||
sed -i 's|proxy_pass http://saas_web_backend/|proxy_pass http://127.0.0.1:3002/|g' "$TMP_CONF"
|
||||
|
||||
mv "$TMP_CONF" "$NGINX_CONF"
|
||||
else
|
||||
echo " 从备份恢复: $LATEST_BAK"
|
||||
cp "$LATEST_BAK" "$NGINX_CONF"
|
||||
fi
|
||||
|
||||
# 2. 测试并 reload nginx
|
||||
echo ""
|
||||
echo ">>> Nginx 测试 & reload..."
|
||||
if ! nginx -t 2>&1; then
|
||||
echo " ❌ Nginx 配置测试失败!请检查"
|
||||
exit 1
|
||||
fi
|
||||
nginx -s reload
|
||||
echo " ✅ Nginx 已回滚,全量切回稳定版本"
|
||||
|
||||
# 3. 停止 canary 容器(延迟停止,保留30分钟便于排查)
|
||||
echo ""
|
||||
echo ">>> Canary 容器将在30分钟后停止(便于排查)"
|
||||
echo " 立即停止请执行: docker rm -f $CANARY_API $CANARY_WEB"
|
||||
|
||||
# 30分钟后停止(后台执行,不阻塞脚本)
|
||||
(
|
||||
sleep 1800
|
||||
for c in "$CANARY_API" "$CANARY_WEB"; do
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${c}$"; then
|
||||
docker stop "$c" >/dev/null 2>&1 && docker rm "$c" >/dev/null 2>&1
|
||||
echo "[$(date)] 已停止 canary 容器: $c"
|
||||
fi
|
||||
done
|
||||
) &
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " ✅ 灰度回滚完成"
|
||||
echo " 流量已全部切回稳定版本"
|
||||
echo " Canary 容器: 30分钟后自动清理"
|
||||
echo "============================================"
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
"""AudioMerger 单元测试 — P1 裸subprocess下沉验证.
|
||||
|
||||
验证 AudioMerger 使用 shared.ffmpeg_utils.run_ffmpeg 统一入口,
|
||||
不再直接调用 subprocess.run。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from application.tts_job.audio_merger import AudioMergeError, AudioMerger
|
||||
|
||||
|
||||
class TestAudioMergerUsesRunFfmpeg:
|
||||
"""验证 AudioMerger 使用 run_ffmpeg 统一入口,而非裸 subprocess."""
|
||||
|
||||
def test_single_file_does_not_call_ffmpeg(self):
|
||||
"""单文件时直接读取,不调用 FFmpeg."""
|
||||
merger = AudioMerger()
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
f.write(b"fake audio data")
|
||||
path = f.name
|
||||
|
||||
try:
|
||||
with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run:
|
||||
result = merger.merge([path])
|
||||
mock_run.assert_not_called()
|
||||
assert result == b"fake audio data"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_multiple_files_calls_run_ffmpeg(self):
|
||||
"""多文件时调用 run_ffmpeg 合并。"""
|
||||
merger = AudioMerger()
|
||||
paths = []
|
||||
for i in range(2):
|
||||
f = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
||||
f.write(f"audio{i}".encode())
|
||||
f.close()
|
||||
paths.append(f.name)
|
||||
|
||||
try:
|
||||
with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run:
|
||||
# run_ffmpeg 成功返回,模拟合并完成
|
||||
# 需要让 output_path 文件存在,否则 read 会报错
|
||||
def fake_run_ffmpeg(cmd, **kwargs):
|
||||
# 找到 output_path(命令最后一个参数)
|
||||
output_path = cmd[-1]
|
||||
with open(output_path, "wb") as out:
|
||||
out.write(b"merged audio")
|
||||
return ("", "")
|
||||
|
||||
mock_run.side_effect = fake_run_ffmpeg
|
||||
result = merger.merge(paths)
|
||||
|
||||
mock_run.assert_called_once()
|
||||
call_args = mock_run.call_args[0][0]
|
||||
# 验证使用了 FFMPEG_BIN 而非硬编码 "ffmpeg"
|
||||
from shared.ffmpeg_utils import FFMPEG_BIN
|
||||
|
||||
assert call_args[0] == FFMPEG_BIN
|
||||
# 验证使用 concat demuxer
|
||||
assert "concat" in call_args
|
||||
assert result == b"merged audio"
|
||||
finally:
|
||||
for p in paths:
|
||||
os.unlink(p)
|
||||
|
||||
def test_ffmpeg_failure_raises_audio_merge_error(self):
|
||||
"""FFmpeg 失败时抛出 AudioMergeError."""
|
||||
merger = AudioMerger()
|
||||
paths = []
|
||||
for i in range(2):
|
||||
f = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
||||
f.write(f"audio{i}".encode())
|
||||
f.close()
|
||||
paths.append(f.name)
|
||||
|
||||
try:
|
||||
with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(
|
||||
returncode=1, cmd=["ffmpeg"], stderr="concat error"
|
||||
)
|
||||
|
||||
with pytest.raises(AudioMergeError, match="FFmpeg 合并失败"):
|
||||
merger.merge(paths)
|
||||
|
||||
mock_run.assert_called_once()
|
||||
finally:
|
||||
for p in paths:
|
||||
os.unlink(p)
|
||||
|
||||
def test_ffmpeg_timeout_raises_audio_merge_error(self):
|
||||
"""FFmpeg 超时时抛出 AudioMergeError."""
|
||||
merger = AudioMerger()
|
||||
paths = []
|
||||
for i in range(2):
|
||||
f = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
||||
f.write(f"audio{i}".encode())
|
||||
f.close()
|
||||
paths.append(f.name)
|
||||
|
||||
try:
|
||||
with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["ffmpeg"], timeout=120)
|
||||
|
||||
with pytest.raises(AudioMergeError, match="超时"):
|
||||
merger.merge(paths)
|
||||
|
||||
mock_run.assert_called_once()
|
||||
finally:
|
||||
for p in paths:
|
||||
os.unlink(p)
|
||||
|
||||
def test_empty_list_raises_error(self):
|
||||
"""空列表时直接抛错,不调用 ffmpeg."""
|
||||
merger = AudioMerger()
|
||||
with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run:
|
||||
with pytest.raises(AudioMergeError, match="没有可合并的音频文件"):
|
||||
merger.merge([])
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_no_direct_subprocess_import(self):
|
||||
"""验证 audio_merger 模块不直接 import subprocess(通过模块源码检查)。"""
|
||||
import inspect
|
||||
|
||||
import application.tts_job.audio_merger as am_module
|
||||
|
||||
source = inspect.getsource(am_module)
|
||||
# 不应该有 "import subprocess" 整行
|
||||
src_lines = [line.strip() for line in source.split("\n") if line.strip()]
|
||||
# 允许 from subprocess import CalledProcessError, TimeoutExpired(只导入异常类)
|
||||
# 不允许直接 import subprocess
|
||||
assert not any(
|
||||
line == "import subprocess" for line in src_lines
|
||||
), "audio_merger.py 不应直接 import subprocess,应通过 run_ffmpeg 统一入口"
|
||||
@@ -25,8 +25,8 @@ class TestVerifyUrlAccessibleRetry:
|
||||
"""_verify_url_accessible 重试逻辑."""
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_first_attempt_success(self, mock_urlopen, mock_sleep):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
def test_first_attempt_success(self, mock_open, mock_sleep):
|
||||
"""首次成功,不重试."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
@@ -34,15 +34,15 @@ class TestVerifyUrlAccessibleRetry:
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
mock_open.return_value = mock_resp
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4") is True
|
||||
assert mock_urlopen.call_count == 1
|
||||
assert mock_open.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_retry_then_success(self, mock_urlopen, mock_sleep):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
def test_retry_then_success(self, mock_open, mock_sleep):
|
||||
"""首次失败,重试后成功."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
@@ -52,31 +52,31 @@ class TestVerifyUrlAccessibleRetry:
|
||||
mock_resp_ok.__enter__ = MagicMock(return_value=mock_resp_ok)
|
||||
mock_resp_ok.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_urlopen.side_effect = [
|
||||
mock_open.side_effect = [
|
||||
OSError("connection reset"),
|
||||
mock_resp_ok,
|
||||
]
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4") is True
|
||||
assert mock_urlopen.call_count == 2
|
||||
assert mock_open.call_count == 2
|
||||
mock_sleep.assert_called_once_with(1)
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_all_retries_exhausted(self, mock_urlopen, mock_sleep):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
def test_all_retries_exhausted(self, mock_open, mock_sleep):
|
||||
"""全部重试耗尽,返回 False."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
mock_urlopen.side_effect = OSError("connection refused")
|
||||
mock_open.side_effect = OSError("connection refused")
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4") is False
|
||||
# 1 首次 + 2 重试 = 3 次
|
||||
assert mock_urlopen.call_count == 3
|
||||
assert mock_open.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_http_500_then_success(self, mock_urlopen, mock_sleep):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
def test_http_500_then_success(self, mock_open, mock_sleep):
|
||||
"""HTTP 500 后重试成功."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
@@ -90,21 +90,21 @@ class TestVerifyUrlAccessibleRetry:
|
||||
mock_resp_200.__enter__ = MagicMock(return_value=mock_resp_200)
|
||||
mock_resp_200.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_urlopen.side_effect = [mock_resp_500, mock_resp_200]
|
||||
mock_open.side_effect = [mock_resp_500, mock_resp_200]
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4") is True
|
||||
assert mock_urlopen.call_count == 2
|
||||
assert mock_open.call_count == 2
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_custom_retries_zero(self, mock_urlopen, mock_sleep):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
def test_custom_retries_zero(self, mock_open, mock_sleep):
|
||||
"""retries=0 时不重试."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
mock_urlopen.side_effect = OSError("timeout")
|
||||
mock_open.side_effect = OSError("timeout")
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4", retries=0) is False
|
||||
assert mock_urlopen.call_count == 1
|
||||
assert mock_open.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
|
||||
+1016
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user