Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1591259bb8 | |||
| a1f25a4426 | |||
| 65a77e3fb6 | |||
| 9a57b0d5b8 | |||
| 4d98e98b57 | |||
| 81e1eb47fb | |||
| d3e4d6a07d | |||
| 0d6ce433d0 | |||
| eb2b009b33 | |||
| fbd89b4089 | |||
| 9b50e0696e | |||
| 9af73dcd86 | |||
| 6002f7a5e4 |
+3
-2
@@ -217,6 +217,7 @@ APIZERO_API_KEY=
|
||||
# GPU Worker 长期鉴权 Token,Worker 端 .env 的 GPU_WORKER_TOKEN 必须与此一致
|
||||
# 留空时 development 环境允许匿名访问(仅本地调试),staging/production 必须配置
|
||||
GPU_WORKER_TOKEN=
|
||||
# 单任务超时(秒),超过则回退 pending 或标记 failed
|
||||
GPU_TASK_TIMEOUT_SECONDS=300
|
||||
# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed
|
||||
# #1970:RTX2060 6G 推理 720p 长视频需 5 分钟以上,默认 900
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
|
||||
|
||||
@@ -1186,6 +1186,7 @@ jobs:
|
||||
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
|
||||
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
|
||||
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
|
||||
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
|
||||
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
|
||||
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
|
||||
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
|
||||
@@ -1641,6 +1642,7 @@ jobs:
|
||||
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
|
||||
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
|
||||
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
|
||||
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
|
||||
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
|
||||
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
|
||||
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging
|
||||
|
||||
Revision ID: 081_atom_clip_ai_tags
|
||||
Revises: 080_edit_plan_clips_atom_clip_id
|
||||
Revision ID: 082_atom_clip_ai_tags
|
||||
Revises: 081_add_gpu_lipsync
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,8 @@ import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "081_atom_clip_ai_tags"
|
||||
down_revision = "080_edit_plan_clips_atom_clip_id"
|
||||
revision = "082_atom_clip_ai_tags"
|
||||
down_revision = "081_add_gpu_lipsync"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -99,6 +99,7 @@ def register_worker(
|
||||
gpu_name=body.gpu_name,
|
||||
free_vram_mb=body.free_vram_mb,
|
||||
capabilities=body.capabilities,
|
||||
task_id=body.task_id,
|
||||
)
|
||||
return GpuWorkerRegisterResponse(ok=True, server_time=datetime.now(UTC), message="ok")
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ class GpuWorkerRegisterRequest(BaseModel):
|
||||
gpu_name: str = Field("", max_length=200, description="GPU 型号,如 'NVIDIA GeForce RTX 2060'")
|
||||
free_vram_mb: int = Field(0, ge=0, description="当前空闲显存(MB)")
|
||||
capabilities: str = Field("musetalk", max_length=500, description="能力列表,逗号分隔,如 'musetalk'")
|
||||
task_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description=(
|
||||
"当前正在处理的任务 ID。Worker 推理期间定期心跳时携带,"
|
||||
"服务端同步刷新该任务 last_heartbeat_at,防止长推理被误判超时;空闲时不传"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class GpuWorkerRegisterResponse(BaseModel):
|
||||
|
||||
@@ -49,7 +49,15 @@ class GpuLipsyncService:
|
||||
gpu_name: str = "",
|
||||
free_vram_mb: int = 0,
|
||||
capabilities: str = "musetalk",
|
||||
task_id: Optional[str] = None,
|
||||
) -> GpuWorkerModel:
|
||||
"""Worker 注册/心跳。
|
||||
|
||||
task_id 非空时(Worker 推理期间的任务级心跳),同步把对应 processing
|
||||
任务的 last_heartbeat_at 续到当前时间,使长推理不会被
|
||||
``_recover_timed_out_tasks`` 误回退。任务已结束 / 不属于该 worker
|
||||
(如已被超时回收重新派发)时忽略,不报错。
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
|
||||
if worker is None:
|
||||
@@ -69,6 +77,8 @@ class GpuLipsyncService:
|
||||
worker.free_vram_mb = free_vram_mb
|
||||
worker.capabilities = capabilities or worker.capabilities
|
||||
worker.last_heartbeat_at = now
|
||||
if task_id:
|
||||
self._touch_task_heartbeat(task_id, worker_id, now)
|
||||
self.db.commit()
|
||||
return worker
|
||||
|
||||
@@ -78,8 +88,10 @@ class GpuLipsyncService:
|
||||
"""原子地认领一条最早的 pending 任务,返回给 worker;无任务返回 None.
|
||||
|
||||
同时会:
|
||||
- 把 processing 状态且超时(超过 gpu_task_timeout_seconds 无心跳)的任务
|
||||
回退为 pending(attempt++,超过 MAX_ATTEMPTS 置 failed),让其它 worker 认领。
|
||||
- 把 processing 状态且真正超时(任务心跳停滞超过
|
||||
gpu_task_timeout_seconds;Worker 推理期会通过 register(task_id=...)
|
||||
续心跳,长推理不会误判)的任务回退为 pending(attempt++,超过
|
||||
MAX_ATTEMPTS 置 failed),让其它 worker 认领。
|
||||
- 刷新 worker 心跳。
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
@@ -238,6 +250,28 @@ class GpuLipsyncService:
|
||||
def _result_key(self, task_id: str) -> str:
|
||||
return f"{self.RESULT_PREFIX}{task_id}.mp4"
|
||||
|
||||
def _touch_task_heartbeat(self, task_id: str, worker_id: str, now: datetime) -> None:
|
||||
"""Worker 推理期间的任务级心跳:只刷新属于该 worker 且仍在 processing 的任务。
|
||||
|
||||
任务不存在 / 已被超时回收重新派发 / 已完成 → 静默忽略(此时旧 worker 的
|
||||
结果上报会被结果接口按最终态处理)。
|
||||
"""
|
||||
task = self.db.get(GpuLipsyncTaskModel, task_id)
|
||||
if task is None:
|
||||
return
|
||||
if task.status != "processing" or task.worker_id != worker_id:
|
||||
logger.info(
|
||||
"忽略过期任务心跳 task=%s worker=%s(status=%s owner=%s)",
|
||||
task_id,
|
||||
worker_id,
|
||||
task.status,
|
||||
task.worker_id,
|
||||
)
|
||||
return
|
||||
task.last_heartbeat_at = now
|
||||
task.updated_at = now
|
||||
self.db.flush()
|
||||
|
||||
def _touch_worker(self, worker_id: str, now: datetime) -> None:
|
||||
if not worker_id:
|
||||
return
|
||||
@@ -260,7 +294,13 @@ class GpuLipsyncService:
|
||||
self.db.flush()
|
||||
|
||||
def _recover_timed_out_tasks(self, now: datetime) -> None:
|
||||
"""扫描 processing 状态且超时(无心跳)的任务,回退 pending 或失败."""
|
||||
"""扫描 processing 状态且真正超时的任务,回退 pending 或失败。
|
||||
|
||||
判定只看任务自身 last_heartbeat_at:claim 时写入,Worker 推理期间通过
|
||||
/gpu/register(task_id=...) 每 30s 续期。因此仅在 Worker 崩溃/断网
|
||||
(任务心跳停滞超过 gpu_task_timeout_seconds)时才回收,
|
||||
不会因 Worker 主循环忙于推理而误回退。
|
||||
"""
|
||||
timeout = self.settings.gpu_task_timeout_seconds
|
||||
cutoff = now - timedelta(seconds=timeout)
|
||||
stuck_tasks = (
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
|
||||
for (let i = 0; i <= 2; i++) {
|
||||
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
|
||||
if (r.status() !== 429) {
|
||||
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
|
||||
return (await r.json()).access_token as string
|
||||
}
|
||||
console.log(`[douyin] 429 retry ${i + 1}/2`)
|
||||
await new Promise((res) => setTimeout(res, 65000))
|
||||
}
|
||||
throw new Error("Login retries exhausted")
|
||||
}
|
||||
|
||||
/**
|
||||
* #1972 抖音文案提取冒烟
|
||||
*
|
||||
* 路径:文案库页面 → 点「🎬 从抖音提取」→ 粘贴分享文案 → 点「开始提取」
|
||||
* → mock /api/v1/scripts/extract-from-douyin 返回稳定文案 → 断言「新建文案」弹窗中预填了非空文案
|
||||
*/
|
||||
test.describe("Douyin Script Extraction (#1972)", () => {
|
||||
test("extract flow: open modal, paste link, text prefilled in create modal", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000)
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const email = `e2e-douyin-${suffix}@example.com`
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username: `e2e_dy_${suffix}` },
|
||||
})
|
||||
const token = await loginWithRetry(request, email, PASSWORD)
|
||||
const authHeader = { Authorization: `Bearer ${token}` }
|
||||
|
||||
const proj = await request.post(`${apiBase}/projects`, {
|
||||
headers: authHeader,
|
||||
data: { name: `Smoke Douyin ${suffix}` },
|
||||
})
|
||||
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers: authHeader,
|
||||
data: { project_id: projectId, name: "Smoke", kind: "video" },
|
||||
})
|
||||
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// Mock 抖音提取接口返回稳定文案
|
||||
const extractedText = "大家好,今天给大家推荐一款超好用的产品,性价比非常高,快来看看吧!"
|
||||
await page.route("**/api/v1/scripts/extract-from-douyin", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ text: extractedText, duration_seconds: 15 }),
|
||||
}),
|
||||
)
|
||||
// 文案列表空态
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/scripts") && !url.pathname.includes("extract-from-douyin"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], total: 0, page: 1, page_size: 20 }),
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto("/app/scripts")
|
||||
// 文案库页面加载
|
||||
await expect(page.getByText(/文案库|文案/).first()).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// 点「🎬 从抖音提取」按钮
|
||||
await page.getByRole("button", { name: /从抖音提取/ }).click()
|
||||
await expect(page.getByText("从抖音视频提取文案")).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// 在 TextArea 粘贴"抖音分享文案"
|
||||
const textarea = page.locator(".ant-modal textarea").first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await textarea.fill("8.88 复制打开抖音,看看【推荐视频】https://v.douyin.com/abcDEF/")
|
||||
|
||||
// 点「开始提取」
|
||||
await page.getByRole("button", { name: "开始提取" }).click()
|
||||
await expect(page.getByText(/提取中/)).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// 等待抖音弹窗关闭,「新建文案」弹窗打开并预填提取文案
|
||||
await expect(page.getByText("从抖音视频提取文案")).not.toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText("新建文案")).toBeVisible({ timeout: 5000 })
|
||||
const createTextarea = page.locator(".ant-modal textarea").first()
|
||||
await expect(createTextarea).toBeVisible()
|
||||
await expect(createTextarea).toHaveValue(new RegExp(extractedText.slice(0, 10)))
|
||||
console.log("[douyin] Extraction flow completed ✓, text length:", extractedText.length)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
@@ -8,7 +8,8 @@ const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
@@ -24,276 +25,358 @@ async function loginWithRetry(
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
): Promise<string> {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
const resp = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
|
||||
if (resp.status() !== 429) {
|
||||
expect(resp.ok(), `Login should succeed: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.access_token
|
||||
}
|
||||
console.log(`[login] 429 rate limited, retry ${i + 1}/${maxRetries} after 65s`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
throw new Error("Login failed after retries")
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新用户 + 建项目/视频库/上传 sample.mp4,等素材 ready。返回 { token, projectId, libraryId, assetId }。
|
||||
*/
|
||||
async function setupFreshUser(
|
||||
request: APIRequestContext,
|
||||
label: string,
|
||||
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const email = `e2e-${label}-${suffix}@example.com`
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username: `e2e_${label}_${suffix}` },
|
||||
})
|
||||
}
|
||||
const token = await loginWithRetry(request, email, PASSWORD)
|
||||
const auth = { Authorization: `Bearer ${token}` }
|
||||
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
const proj = await request.post(`${apiBase}/projects`, {
|
||||
headers: auth,
|
||||
data: { name: `Smoke ${label} ${suffix}` },
|
||||
})
|
||||
expect(proj.ok(), `create project: ${await proj.text()}`).toBeTruthy()
|
||||
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers: auth,
|
||||
data: { project_id: projectId, name: "Smoke", kind: "video" },
|
||||
})
|
||||
expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy()
|
||||
const libraryId = (await lib.json()).id
|
||||
|
||||
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-${suffix}@example.com`
|
||||
const username = `e2e_gen_${suffix}`
|
||||
const libraryName = `E2E Gen Lib ${suffix}`
|
||||
|
||||
// Register
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
const registerData = (await register.json()) as { user_id: string }
|
||||
|
||||
// Login
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
|
||||
// Create project
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E Gen Proj ${suffix}` },
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
const projectData = (await project.json()) as ProjectResponse
|
||||
|
||||
// Create asset library
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectData.id, name: libraryName, kind: "video" },
|
||||
})
|
||||
expect(library.status()).toBe(200)
|
||||
const libraryData = (await library.json()) as LibraryResponse
|
||||
|
||||
// Upload source video
|
||||
const sourceFileName = "e2e-gen-source.mp4"
|
||||
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
|
||||
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: sourceFileName,
|
||||
mimeType: "video/mp4",
|
||||
buffer: sampleVideoBuffer,
|
||||
},
|
||||
const samplePath = path.join(__dirname, "fixtures", "sample.mp4")
|
||||
const sampleBuf = fs.readFileSync(samplePath)
|
||||
const up = await request.post(`${apiBase}/upload`, {
|
||||
headers: auth,
|
||||
multipart: {
|
||||
project_id: projectId,
|
||||
library_id: libraryId,
|
||||
file: {
|
||||
name: "sample.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: sampleBuf,
|
||||
},
|
||||
})
|
||||
expect(upload.status()).toBe(200)
|
||||
},
|
||||
})
|
||||
expect(up.ok(), `upload sample: ${await up.text()}`).toBeTruthy()
|
||||
const assetId = (await up.json()).asset_id
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const r = await request.get(`${apiBase}/assets/${assetId}`, { headers: auth })
|
||||
return r.ok() ? (await r.json()).status : "pending"
|
||||
},
|
||||
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
|
||||
)
|
||||
.toBe("ready")
|
||||
return { token, libraryId, assetId, suffix }
|
||||
}
|
||||
|
||||
// Wait for asset to be ready
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
})
|
||||
if (!assets.ok()) return `http_${assets.status()}`
|
||||
const data = (await assets.json()) as AssetListResponse
|
||||
const asset = data.items.find((a) => a.name === sourceFileName)
|
||||
if (!asset) return "missing"
|
||||
return asset.status
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
/**
|
||||
* #1970 智能剪辑核心冒烟(新 5 步向导)
|
||||
*
|
||||
* 新流程:选择模式 → 选择素材 → 选择标题 → 确认生成 → 选择封面
|
||||
*
|
||||
* 两条路径:
|
||||
* 1) 随机混剪(默认)→ Step1 下一步 → 配音选择弹窗 → Step2 选素材 → 数量弹窗
|
||||
* → Step3 标题 → Step4 确认生成 → 断言任务创建
|
||||
* 2) 叙事剪辑 → Step1 切模式 → 下一步 → 文案选择弹窗 → TTS 弹窗选音色(mock 合成)
|
||||
* → Step2 AI 提示卡可见 + 选素材 → 数量弹窗 → Step3 标题 → Step4 确认生成
|
||||
* → 断言任务创建
|
||||
*/
|
||||
test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
|
||||
test.setTimeout(600_000)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
const { token, suffix } = await setupFreshUser(request, "random")
|
||||
const authHeader = { Authorization: `Bearer ${token}` }
|
||||
|
||||
// 确保默认模板存在(智能剪辑页依赖模板)
|
||||
const tmpls = await request.get(`${apiBase}/templates`, { headers: authHeader })
|
||||
const tmplsJson = await tmpls.json()
|
||||
const templates = Array.isArray(tmplsJson)
|
||||
? tmplsJson
|
||||
: Array.isArray(tmplsJson.items)
|
||||
? tmplsJson.items
|
||||
: []
|
||||
expect(templates.length).toBeGreaterThan(0)
|
||||
|
||||
// 注入登录态 + 路由 API
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
.toBe("ready")
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// GET /templates auto-creates a default template for new users
|
||||
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
|
||||
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
|
||||
const templatesData = (await templatesResp.json()) as {
|
||||
items: Array<{ id: string }>
|
||||
}
|
||||
expect(Array.isArray(templatesData.items)).toBe(true)
|
||||
expect(templatesData.items.length).toBeGreaterThan(0)
|
||||
const templateId = templatesData.items[0].id
|
||||
expect(templateId).toBeTruthy()
|
||||
|
||||
// Set auth in localStorage
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ──
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/assets") && url.searchParams.get("kind") === "voice",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: `asset-voice-${suffix}`,
|
||||
name: "测试配音.mp3",
|
||||
file_url: "data:audio/mpeg;base64,",
|
||||
duration: 10,
|
||||
file_size: 1024,
|
||||
kind: "voice",
|
||||
status: "ready",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
user: {
|
||||
id: registerData.user_id,
|
||||
user_id: registerData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Navigate to generate page
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
|
||||
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("随机混剪")).toBeVisible()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 1: 素材选择 ──
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
|
||||
await expect(materialCard).toBeVisible({ timeout: 10_000 })
|
||||
await materialCard.click({ position: { x: 15, y: 15 } })
|
||||
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// ── 配音选择弹窗:选第一个配音 → 确认 ─────────────────────────
|
||||
await expect(page.getByText("🎙️ 选择配音")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("测试配音.mp3").first().click()
|
||||
await page.getByRole("button", { name: "确认选择" }).click()
|
||||
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
|
||||
|
||||
// ── 数量弹窗(PreviewCountModal) ──
|
||||
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
// ── Step 2:选择素材 ──────────────────────────────────────────
|
||||
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
await page.getByTestId("material-card").first().click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── 数量弹窗:默认 1 个 → 确认 ───────────────────────────────
|
||||
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByRole("button", { name: "生成 1 个视频" }).click()
|
||||
|
||||
// ── Step 2: 配音(新注册用户无配音素材,跳过) ──
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// ── Step 3: 标题设置 ──
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
const titleInput = page.locator(".ant-select-auto-complete input")
|
||||
// ── Step 3:填写标题 ──────────────────────────────────────────
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
||||
await titleInput.fill(`E2E Test ${suffix}`)
|
||||
await titleInput.fill(`测试随机剪辑 ${suffix}`)
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// Step 3 底部是「下一步 →」,点击进入 Step 4(确认生成)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText("随机混剪")).toBeVisible()
|
||||
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
|
||||
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
// ── Step 4: 确认生成 ──
|
||||
// 等待实时预览就绪(占位消失)
|
||||
await page
|
||||
.getByText("准备预览素材")
|
||||
.waitFor({ state: "detached", timeout: 30_000 })
|
||||
.catch(() => {})
|
||||
const createTask = page.waitForResponse(
|
||||
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
await confirmBtn.click()
|
||||
const taskResp = await createTask
|
||||
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
|
||||
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
|
||||
console.log("[random] Generation task created:", taskId)
|
||||
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
|
||||
console.log("[random] Wizard flow completed ✓")
|
||||
})
|
||||
|
||||
// Step 4 底部是「✨ 确认生成视频」
|
||||
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
|
||||
test("narrative mode: select script + mock TTS, create generation task", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(600_000)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
const { token, suffix } = await setupFreshUser(request, "narrative")
|
||||
|
||||
// 先挂 API 监听再点击
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
|
||||
const mockScriptId = `script-mock-${suffix}`
|
||||
const mockVoiceId = `preset-voice-${suffix}`
|
||||
const mockJobId = `tts-job-${suffix}`
|
||||
|
||||
// 文案列表(ScriptSelectModal 查询 /scripts)
|
||||
await page.route("**/api/v1/scripts**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.includes("/extract-from-douyin")) {
|
||||
route.continue()
|
||||
return
|
||||
}
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: mockScriptId,
|
||||
title: "测试带货文案",
|
||||
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
|
||||
tags: ["带货"],
|
||||
title_category: "daihuo",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// 预设音色(TtsVoiceModal 查询 GET /voices/presets)
|
||||
await page.route("**/api/v1/voices/presets**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
voice_id: mockVoiceId,
|
||||
name: "晓晓(女声)",
|
||||
description: "温柔女声",
|
||||
gender: "female",
|
||||
language: "zh-CN",
|
||||
preview_url: null,
|
||||
tags: ["温柔"],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await confirmBtn.click()
|
||||
// 克隆音色:空列表
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/voice-clones"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
}),
|
||||
)
|
||||
|
||||
// 验证生成 API 被调用
|
||||
const genResp = await generatePromise.catch(() => null)
|
||||
if (!genResp) {
|
||||
// staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过
|
||||
console.log(
|
||||
"[E2E] Generation API not triggered (preview not ready) — wizard navigation verified",
|
||||
)
|
||||
} else if (genResp.ok()) {
|
||||
const genData = (await genResp.json()) as {
|
||||
items: Array<{ id: string; status: string }>
|
||||
total: number
|
||||
}
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
// TTS 合成:直接返回 completed 任务
|
||||
await page.route("**/api/v1/tts/synthesize", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
job_id: mockJobId,
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
audio_url: "data:audio/mpeg;base64,",
|
||||
duration: 5,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
|
||||
}),
|
||||
)
|
||||
|
||||
// race:渲染完成 vs 生成失败/超时
|
||||
const downloadReady = page
|
||||
.getByText("视频生成完成")
|
||||
.isVisible({ timeout: 180_000 })
|
||||
.then((v) => (v ? "completed" : null))
|
||||
const generationFailed = page
|
||||
.getByText(/生成失败|重新生成/)
|
||||
.isVisible({ timeout: 180_000 })
|
||||
.then((v) => (v ? "failed" : null))
|
||||
|
||||
const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout")
|
||||
|
||||
if (outcome === "completed") {
|
||||
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
} else {
|
||||
console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`)
|
||||
}
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
}
|
||||
|
||||
// 验证成品库页面加载
|
||||
await page.goto("/app/products")
|
||||
await expect(page).toHaveURL(/\/app\/products/)
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
})
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-api-${suffix}@example.com`
|
||||
const username = `e2e_gen_api_${suffix}`
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await page.getByText("叙事剪辑").click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E API Proj ${suffix}` },
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
|
||||
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("测试带货文案").first().click()
|
||||
await page.getByRole("button", { name: "确认选择" }).click()
|
||||
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
|
||||
|
||||
const tasks = await request.get(`${apiBase}/tasks`, { headers })
|
||||
expect(tasks.status()).toBe(200)
|
||||
const tasksData = await tasks.json()
|
||||
expect(Array.isArray(tasksData.items)).toBe(true)
|
||||
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
|
||||
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("晓晓(女声)").first().click()
|
||||
await page.getByRole("button", { name: "🎧 合成配音" }).click()
|
||||
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
|
||||
|
||||
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
|
||||
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
|
||||
await page.getByTestId("material-card").first().click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── 数量弹窗 ─────────────────────────────────────────────────
|
||||
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByRole("button", { name: "生成 1 个视频" }).click()
|
||||
|
||||
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput2).toBeVisible({ timeout: 5000 })
|
||||
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText("叙事剪辑")).toBeVisible()
|
||||
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
|
||||
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
const createTask2 = page.waitForResponse(
|
||||
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
await confirmBtn2.click()
|
||||
const taskResp2 = await createTask2
|
||||
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
|
||||
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
|
||||
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
|
||||
console.log("[narrative] Wizard flow completed ✓")
|
||||
})
|
||||
})
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
|
||||
for (let i = 0; i <= 2; i++) {
|
||||
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
|
||||
if (r.status() !== 429) {
|
||||
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
|
||||
return (await r.json()).access_token as string
|
||||
}
|
||||
console.log(`[nav] 429 retry ${i + 1}/2`)
|
||||
await new Promise((res) => setTimeout(res, 65000))
|
||||
}
|
||||
throw new Error("Login retries exhausted")
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心页面导航冒烟:侧边栏主要入口能访问、文案库/配音库页面能正常加载(不出白屏/无致命 js error)
|
||||
*/
|
||||
test.describe("Core Navigation", () => {
|
||||
let authToken: string
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const email = `e2e-nav-${suffix}@example.com`
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username: `e2e_nav_${suffix}` },
|
||||
})
|
||||
authToken = await loginWithRetry(request, email, PASSWORD)
|
||||
const authHeader = { Authorization: `Bearer ${authToken}` }
|
||||
const proj = await request.post(`${apiBase}/projects`, {
|
||||
headers: authHeader,
|
||||
data: { name: `Smoke Nav ${suffix}` },
|
||||
})
|
||||
if (proj.ok()) {
|
||||
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers: authHeader,
|
||||
data: { project_id: projectId, name: "Nav Lib", kind: "video" },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
}, authToken)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
})
|
||||
|
||||
const navCases = [
|
||||
{ path: "/app/dashboard", marker: /概览|工作台|最近/i, name: "概览" },
|
||||
{ path: "/app/generate", marker: /智能剪辑|剪辑/, name: "智能剪辑" },
|
||||
{ path: "/app/assets", marker: /视频库|素材/, name: "视频库" },
|
||||
{ path: "/app/scripts", marker: /文案/, name: "文案库" },
|
||||
{ path: "/app/voices", marker: /配音|我的音色|配音库/, name: "配音库" },
|
||||
{ path: "/app/products", marker: /成品|作品/, name: "成品库" },
|
||||
{ path: "/app/history", marker: /历史|任务/, name: "任务历史" },
|
||||
{ path: "/app/tasks", marker: /任务中心|任务列表/, name: "任务中心" },
|
||||
{ path: "/app/points", marker: /积分|我的积分/, name: "积分中心" },
|
||||
]
|
||||
|
||||
for (const c of navCases) {
|
||||
test(`visit ${c.name} (${c.path}) loads without fatal pageerror`, async ({ page }) => {
|
||||
const errors: Error[] = []
|
||||
page.on("pageerror", (e) => errors.push(e))
|
||||
await page.goto(c.path)
|
||||
await expect(page.locator("body")).not.toBeEmpty({ timeout: 20000 })
|
||||
// 过滤掉常见第三方/非致命错误
|
||||
const fatal = errors.filter(
|
||||
(e) =>
|
||||
!/ResizeObserver|Loading chunk|network error|Failed to fetch|chunkLoadError/i.test(
|
||||
e.message,
|
||||
),
|
||||
)
|
||||
expect(fatal, `${c.name} pageerrors: ${fatal.map((e) => e.message).join("; ")}`).toHaveLength(
|
||||
0,
|
||||
)
|
||||
await expect(
|
||||
page.getByText(c.marker).first(),
|
||||
`${c.name} should show relevant text`,
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
console.log(`[nav] ${c.name} loaded ✓`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -493,6 +493,41 @@ class RenderAdapter:
|
||||
logger.warning("ASR 服务初始化失败,自动字幕将不可用: %s", e)
|
||||
return None
|
||||
|
||||
def _resolve_clip_has_text(self, clips: list[Any]) -> list[bool] | None:
|
||||
"""#1970:按源视频片段顺序解析 atom_clip.ai_tags.has_text。
|
||||
|
||||
顺序与 UnifiedRenderService 的「非 audio 源片段」口径一致。
|
||||
仅当 atom_clip 存在 ai_tags 字典且 has_text 显式为 False 时标记为
|
||||
无文字(允许 hflip);atom_clip_id 缺失、ai_tags 未生成、has_text 为
|
||||
true/null/非布尔值时一律按有文字处理(保守不翻转)。
|
||||
查询失败时返回 None,渲染层回退到全保守路径。
|
||||
"""
|
||||
video_clips = [c for c in clips if getattr(c, "clip_type", "main") != "audio"]
|
||||
atom_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for c in video_clips:
|
||||
atom_id = getattr(c, "atom_clip_id", "") or ""
|
||||
if atom_id and atom_id not in seen:
|
||||
seen.add(atom_id)
|
||||
atom_ids.append(atom_id)
|
||||
if not atom_ids:
|
||||
return None
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
|
||||
atom_clips = SQLAlchemyAssetAtomClipRepository(self._db).find_by_ids(atom_ids)
|
||||
except Exception as exc:
|
||||
logger.warning("[render-adapter] atom_clip ai_tags 查询失败,hflip 全量保守处理: %s", exc)
|
||||
return None
|
||||
has_text_map: dict[str, bool] = {}
|
||||
for ac in atom_clips:
|
||||
ai_tags = getattr(ac, "ai_tags", None)
|
||||
no_text = isinstance(ai_tags, dict) and ai_tags.get("has_text") is False
|
||||
has_text_map[ac.id] = not no_text
|
||||
return [has_text_map.get((getattr(c, "atom_clip_id", "") or ""), True) for c in video_clips]
|
||||
|
||||
def _do_render(
|
||||
self,
|
||||
plan: Any,
|
||||
@@ -542,6 +577,7 @@ class RenderAdapter:
|
||||
)
|
||||
|
||||
# 4. 执行统一渲染
|
||||
clip_has_text = self._resolve_clip_has_text(clips)
|
||||
render_svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
@@ -552,6 +588,7 @@ class RenderAdapter:
|
||||
bgm_path=bgm_path,
|
||||
asr_service=asr_service,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
clip_has_text=clip_has_text,
|
||||
)
|
||||
result = render_svc.render()
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ class UnifiedRenderService:
|
||||
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
|
||||
bgm_path: str | None = None, # BGM 本地文件路径
|
||||
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
|
||||
clip_has_text: list[bool] | None = None, # 源视频片段是否有文字(来自 atom_clip.ai_tags.has_text)
|
||||
):
|
||||
self.plan = plan
|
||||
self.clips = clips
|
||||
@@ -167,6 +168,8 @@ class UnifiedRenderService:
|
||||
self.asr_service = asr_service
|
||||
self.bgm_path = bgm_path
|
||||
self.voiceover_audio_path = voiceover_audio_path
|
||||
# #1970:片段级文字检测(顺序与非 audio 的源视频片段一致);None 表示无可靠检测,保守不翻转
|
||||
self._clip_has_text = clip_has_text
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
self._speed_engine = SpeedEngine()
|
||||
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
|
||||
@@ -186,7 +189,9 @@ class UnifiedRenderService:
|
||||
|
||||
种子 hash(generation_task_id + video_index)%10000,同一任务重渲结果一致。
|
||||
dedup_enabled=False 时返回 None,调用方不注入任何微变换。
|
||||
P1 字幕检测:无可靠的片段文字轨道信息,hflip 一律关闭(宁可不翻转)。
|
||||
hflip 放开(#1970):clip_has_text 来自 atom_clip.ai_tags.has_text,
|
||||
仅 AI 明确判定无文字的片段可参与 50% 翻转;未打标签 / has_text 为
|
||||
true/null 或缺位时一律视为有文字,保持保守不翻转。
|
||||
"""
|
||||
if self._micro_plan_loaded:
|
||||
return self._micro_plan_cache
|
||||
@@ -200,11 +205,14 @@ class UnifiedRenderService:
|
||||
cfg = self.plan.config or {}
|
||||
task_id = str(cfg.get("generation_task_id", "") or "")
|
||||
video_index = int(cfg.get("video_index", 0) or 0)
|
||||
# self._clip_has_text 顺序与非 audio 源片段一致;
|
||||
# None(未提供检测,如内存直渲/旧任务)→ 纯函数层按全有文字保守处理;
|
||||
# 列表短于片段数时缺位片段同样按有文字处理
|
||||
self._micro_plan_cache = build_micro_transform_plan(
|
||||
task_id,
|
||||
video_index,
|
||||
clip_count,
|
||||
clip_has_text=None, # P1 保守策略:全部按有文字处理,不翻转
|
||||
clip_has_text=self._clip_has_text,
|
||||
enable_bgm_offset=bool(cfg.get("bgm")),
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -28,6 +28,10 @@ celery_app.conf.imports = (
|
||||
"worker_app.tasks.health",
|
||||
"worker_app.tasks.ingest",
|
||||
"worker_app.tasks.atom_clips",
|
||||
# #1970 片段级 AI 标签:必须显式 import 注册,否则 worker 报
|
||||
# "Received unregistered task of type 'worker.tag_atom_clip'"
|
||||
"worker_app.tasks.atom_clip_tagging",
|
||||
"worker_app.tasks.backfill_atom_clip_tags",
|
||||
"worker_app.tasks.classification",
|
||||
"worker_app.tasks.generation",
|
||||
"worker_app.tasks.voice_extraction",
|
||||
|
||||
@@ -25,11 +25,14 @@ logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tag_atom_clip", bind=True, max_retries=2, default_retry_delay=10)
|
||||
def tag_atom_clip_task(self, atom_clip_id: str) -> dict:
|
||||
def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
"""为单个原子片段生成 AI 标签.
|
||||
|
||||
Args:
|
||||
atom_clip_id: 原子片段 ID。
|
||||
force: True 时允许覆盖只有 inherited_tags 的降级记录
|
||||
(视觉 API 曾失败写入的占位标签,#1970)。
|
||||
已有完整标签(含 has_text)始终跳过,保证幂等。
|
||||
|
||||
Returns:
|
||||
任务结果 dict:status / clip_id / ai_tags(部分字段)。
|
||||
@@ -43,9 +46,11 @@ def tag_atom_clip_task(self, atom_clip_id: str) -> dict:
|
||||
if clip is None:
|
||||
return {"status": "skipped", "reason": "clip not found", "clip_id": atom_clip_id}
|
||||
|
||||
# 已有标签则跳过(幂等)
|
||||
# 已有完整标签则跳过(幂等);force 仅放行缺失 has_text 的降级记录
|
||||
if clip.ai_tags is not None:
|
||||
return {"status": "skipped", "reason": "already tagged", "clip_id": atom_clip_id}
|
||||
has_real_tags = isinstance(clip.ai_tags, dict) and "has_text" in clip.ai_tags
|
||||
if has_real_tags or not force:
|
||||
return {"status": "skipped", "reason": "already tagged", "clip_id": atom_clip_id}
|
||||
|
||||
# 获取素材信息
|
||||
asset = asset_repo.find_by_id(clip.asset_id)
|
||||
|
||||
@@ -30,6 +30,7 @@ def backfill_atom_clip_tags(
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
batch_interval: int = DEFAULT_BATCH_INTERVAL,
|
||||
max_clips: int = 0,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""批量回填未打标的 atom_clips.
|
||||
|
||||
@@ -37,6 +38,8 @@ def backfill_atom_clip_tags(
|
||||
batch_size: 每批处理数量,默认 10。
|
||||
batch_interval: 每批间隔秒数,默认 5。
|
||||
max_clips: 最大处理总数,0 表示不限。
|
||||
force: True 时连同只有 inherited_tags 的降级记录一起强制重打
|
||||
(视觉 API 曾失败、DOUBAO_VISION_MODEL 修复后重跑用,#1970)。
|
||||
|
||||
Returns:
|
||||
任务结果 dict:total_submitted / batches。
|
||||
@@ -52,7 +55,7 @@ def backfill_atom_clip_tags(
|
||||
remaining = max_clips - total_submitted if max_clips > 0 else batch_size
|
||||
fetch_limit = min(batch_size, remaining) if max_clips > 0 else batch_size
|
||||
|
||||
untagged = atom_repo.find_untagged(limit=fetch_limit)
|
||||
untagged = atom_repo.find_untagged(limit=fetch_limit, include_downgraded=force)
|
||||
if not untagged:
|
||||
break
|
||||
|
||||
@@ -62,6 +65,7 @@ def backfill_atom_clip_tags(
|
||||
celery_app.send_task(
|
||||
"worker.tag_atom_clip",
|
||||
args=[clip.id],
|
||||
kwargs={"force": force},
|
||||
)
|
||||
total_submitted += 1
|
||||
except Exception as e:
|
||||
|
||||
@@ -234,6 +234,9 @@ DOUBAO_TIMEOUT=60
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
@@ -255,4 +258,4 @@ APIZERO_API_KEY=${APIZERO_API_KEY}
|
||||
|
||||
# ==================== GPU MuseTalk Worker(反向轮询) ====================
|
||||
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
|
||||
GPU_TASK_TIMEOUT_SECONDS=300
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
|
||||
@@ -251,6 +251,9 @@ DOUBAO_TIMEOUT=60
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
@@ -272,4 +275,4 @@ APIZERO_API_KEY=${APIZERO_API_KEY}
|
||||
|
||||
# ==================== GPU MuseTalk Worker(反向轮询) ====================
|
||||
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
|
||||
GPU_TASK_TIMEOUT_SECONDS=300
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
|
||||
@@ -19,7 +19,12 @@ MUSE_TALK_URL=http://127.0.0.1:7861
|
||||
# 轮询/心跳/超时(秒)
|
||||
POLL_INTERVAL=5
|
||||
HEARTBEAT_INTERVAL=15
|
||||
REQUEST_TIMEOUT=300
|
||||
# 下载/推理/上传 HTTP 超时,需与服务端 GPU_TASK_TIMEOUT_SECONDS 对齐(默认 900)
|
||||
REQUEST_TIMEOUT=900
|
||||
|
||||
# 单个任务本地最大重试次数(首次失败后再重试 N 次,默认 2)
|
||||
TASK_MAX_RETRY=2
|
||||
# 单个任务本地最大重试次数(仅网络/MuseTalk 瞬时错误才重试,默认 1)
|
||||
TASK_MAX_RETRY=1
|
||||
# 推理期间任务心跳间隔(秒,独立线程,无需改动)
|
||||
TASK_HEARTBEAT_INTERVAL=30
|
||||
# 输入视频最短时长(秒),小于则直接上报失败,不调用 MuseTalk
|
||||
MIN_VIDEO_DURATION_SECONDS=3
|
||||
|
||||
@@ -89,8 +89,9 @@ SaaS 后端部署完成后需配置:
|
||||
| 日志 `MuseTalk 健康检查未通过` | 本地 MuseTalk 没启动,或端口不是 7861;`curl http://127.0.0.1:7861/health` 验证 |
|
||||
| 任务长时间不被拉取 | Worker 和服务端连不上;检查 API_BASE_URL 是否可达、Token 是否正确 |
|
||||
| 推理后上传 OSS 失败 | 本地出口网络被防火墙拦截 OSS 域名(oss-cn-hangzhou.aliyuncs.com) |
|
||||
| 服务端看到任务回退到 pending 重试 | Worker 心跳超时(默认 5 分钟);Worker 进程崩溃或推理卡死超过 5 分钟 |
|
||||
| 日志 `MuseTalk 推理超时` | 视频太长或显存不足;可临时调大 REQUEST_TIMEOUT,或限制输入视频时长 |
|
||||
| 服务端看到任务回退到 pending 重试 | 任务心跳真正超时(默认 900s):Worker 进程崩溃/断网,或推理彻底卡死;正常长推理期间心跳线程每 30s 续期,不会回退 |
|
||||
| 日志 `MuseTalk 推理超时或连接失败` | 视频太长或显存不足;可临时调大 REQUEST_TIMEOUT(服务端 GPU_TASK_TIMEOUT_SECONDS 需同步调大),或限制输入视频时长 |
|
||||
| 日志 `视频过短(x.xxs < 3s)` | 输入视频不足 3s,MuseTalk 对短视频会 division by zero,已在本地直接上报失败;可用 MIN_VIDEO_DURATION_SECONDS 调整阈值 |
|
||||
|
||||
## 七、安全注意事项
|
||||
|
||||
|
||||
+125
-49
@@ -9,9 +9,12 @@
|
||||
WORKER_ID 本机唯一 ID(默认 hostname+网卡MAC 后4位)
|
||||
MUSE_TALK_URL 本地 MuseTalk 地址,默认 http://127.0.0.1:7861
|
||||
POLL_INTERVAL 轮询间隔秒,默认 5
|
||||
HEARTBEAT_INTERVAL 心跳间隔秒,默认 15
|
||||
REQUEST_TIMEOUT HTTP 请求超时秒,默认 60
|
||||
TASK_MAX_RETRY 单个任务最大重试次数(在 Worker 本地的重试),默认 2
|
||||
HEARTBEAT_INTERVAL 空闲心跳间隔秒,默认 15
|
||||
REQUEST_TIMEOUT HTTP 请求超时秒(下载/推理/上传统一使用),默认 900
|
||||
需与服务端 GPU_TASK_TIMEOUT_SECONDS(默认 900)对齐
|
||||
TASK_MAX_RETRY 单任务本地最大重试次数(仅对瞬时错误重试),默认 1
|
||||
TASK_HEARTBEAT_INTERVAL 推理期间任务心跳间隔秒,默认 30
|
||||
MIN_VIDEO_DURATION_SECONDS 最短输入视频时长秒,小于则直接上报失败,默认 3
|
||||
|
||||
用法:
|
||||
python gpu_worker.py
|
||||
@@ -19,13 +22,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -54,8 +57,17 @@ class Config:
|
||||
muse_talk_url: str = _env("MUSE_TALK_URL", "http://127.0.0.1:7861").rstrip("/")
|
||||
poll_interval: float = float(_env("POLL_INTERVAL", "5"))
|
||||
heartbeat_interval: float = float(_env("HEARTBEAT_INTERVAL", "15"))
|
||||
request_timeout: float = float(_env("REQUEST_TIMEOUT", "300"))
|
||||
task_max_retry: int = int(_env("TASK_MAX_RETRY", "2"))
|
||||
# #1970:RTX2060 6G 处理 720p 长视频可能 >5min;与服务端
|
||||
# GPU_TASK_TIMEOUT_SECONDS 默认值对齐为 900,避免推理被本地/服务端先掐断。
|
||||
request_timeout: float = float(_env("REQUEST_TIMEOUT", "900"))
|
||||
# 本地只在网络/MuseTalk 瞬时错误时重试 1 次;服务端 MAX_ATTEMPTS=3
|
||||
# 负责跨 worker/真正超时后的重派发,总尝试次数不再相乘放大。
|
||||
task_max_retry: int = int(_env("TASK_MAX_RETRY", "1"))
|
||||
# 推理期间任务心跳间隔(独立线程 POST /gpu/register 带 task_id)
|
||||
task_heartbeat_interval: float = float(_env("TASK_HEARTBEAT_INTERVAL", "30"))
|
||||
# 输入视频最短时长(秒):过短(如 1s)MuseTalk 会 division by zero,
|
||||
# 本地前置拦截,直接上报 failed,不浪费 GPU 时间
|
||||
min_video_duration_seconds: float = float(_env("MIN_VIDEO_DURATION_SECONDS", "3"))
|
||||
worker_id: str = _env("WORKER_ID", "")
|
||||
|
||||
@classmethod
|
||||
@@ -96,8 +108,12 @@ def _check_musetalk_health() -> tuple[bool, dict]:
|
||||
return False, {"error": str(exc)}
|
||||
|
||||
|
||||
def _register() -> bool:
|
||||
"""向服务端注册 / 心跳,附带 GPU 信息."""
|
||||
def _register(task_id: Optional[str] = None) -> bool:
|
||||
"""向服务端注册 / 心跳,附带 GPU 信息。
|
||||
|
||||
推理期间的心跳线程传 task_id:服务端会同步刷新该 processing 任务的
|
||||
last_heartbeat_at,防止长推理被误判超时回收。
|
||||
"""
|
||||
ok, info = _check_musetalk_health()
|
||||
free_vram = int(info.get("free_vram_mb", 0) or 0) if isinstance(info, dict) else 0
|
||||
gpu_name = info.get("gpu_name", "") if isinstance(info, dict) else ""
|
||||
@@ -111,6 +127,8 @@ def _register() -> bool:
|
||||
"free_vram_mb": free_vram,
|
||||
"capabilities": "musetalk",
|
||||
}
|
||||
if task_id:
|
||||
payload["task_id"] = task_id
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{Config.api_base_url}/api/v1/gpu/register",
|
||||
@@ -181,11 +199,13 @@ def _download(url: str, path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _call_musetalk(video_path: Path, audio_path: Path, out_path: Path) -> tuple[bool, float, str]:
|
||||
def _call_musetalk(video_path: Path, audio_path: Path, out_path: Path) -> tuple[bool, float, str, bool]:
|
||||
"""调用本地 MuseTalk /inference.
|
||||
|
||||
返回 (success, duration_seconds, error_msg).
|
||||
返回 (success, duration_seconds, error_msg, retryable)。
|
||||
duration 用 ffprobe 读结果视频,失败填 0。
|
||||
retryable 仅对瞬时错误(连接失败/超时/5xx)为 True;HTTP 4xx、结果过小
|
||||
等确定性失败不重试,直接上报服务端(服务端 MAX_ATTEMPTS 再决定是否重派发)。
|
||||
"""
|
||||
try:
|
||||
with open(video_path, "rb") as vf, open(audio_path, "rb") as af:
|
||||
@@ -199,17 +219,20 @@ def _call_musetalk(video_path: Path, audio_path: Path, out_path: Path) -> tuple[
|
||||
timeout=Config.request_timeout,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return False, 0.0, f"MuseTalk HTTP {r.status_code}: {r.text[:500]}"
|
||||
retryable = r.status_code >= 500
|
||||
return False, 0.0, f"MuseTalk HTTP {r.status_code}: {r.text[:500]}", retryable
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(r.content)
|
||||
if out_path.stat().st_size < 1024:
|
||||
return False, 0.0, f"MuseTalk 返回结果过小 ({out_path.stat().st_size} bytes)"
|
||||
# 确定性失败(推理产物异常),本地重试大概率还是坏的,不重试
|
||||
return False, 0.0, f"MuseTalk 返回结果过小 ({out_path.stat().st_size} bytes)", False
|
||||
duration = _probe_duration(out_path)
|
||||
return True, duration, ""
|
||||
except requests.exceptions.Timeout:
|
||||
return False, 0.0, f"MuseTalk 推理超时(>{Config.request_timeout}s)"
|
||||
return True, duration, "", False
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
|
||||
# 瞬时网络/超时错误,允许本地重试 1 次
|
||||
return False, 0.0, f"MuseTalk 推理超时或连接失败(>{Config.request_timeout}s)", True
|
||||
except Exception as exc:
|
||||
return False, 0.0, f"MuseTalk 调用异常: {exc}"
|
||||
return False, 0.0, f"MuseTalk 调用异常: {exc}", False
|
||||
|
||||
|
||||
def _probe_duration(path: Path) -> float:
|
||||
@@ -219,9 +242,13 @@ def _probe_duration(path: Path) -> float:
|
||||
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(path),
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
@@ -276,42 +303,91 @@ def _report_result(task_id: str, success: bool, duration: float = 0.0, error_msg
|
||||
return False
|
||||
|
||||
|
||||
class TaskHeartbeat(threading.Thread):
|
||||
"""推理期间的任务心跳线程。
|
||||
|
||||
主循环的空闲心跳在 ``_handle_task`` 同步阻塞(下载/推理/上传最长 900s)
|
||||
期间无法发送,服务端会因任务 last_heartbeat_at 停滞而误判超时回退 pending。
|
||||
本线程每 task_heartbeat_interval 秒(默认 30s)POST /gpu/register 并
|
||||
携带当前 task_id,让服务端持续续期任务心跳;任务处理结束 stop()。
|
||||
"""
|
||||
|
||||
def __init__(self, task_id: str, interval: float):
|
||||
super().__init__(daemon=True, name=f"hb-{task_id[:8]}")
|
||||
self.task_id = task_id
|
||||
self.interval = max(5.0, interval)
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def run(self) -> None:
|
||||
# 先立即发一次,再按间隔循环(首次心跳失败不影响主流程)
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
if _register(self.task_id):
|
||||
logger.debug("任务 %s 心跳已发送", self.task_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("任务 %s 心跳异常(忽略): %s", self.task_id, exc)
|
||||
self._stop_event.wait(self.interval)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
|
||||
def _handle_task(task: dict) -> None:
|
||||
"""处理一条任务(整个串行流程:下载→推理→上传→上报)."""
|
||||
"""处理一条任务(整个串行流程:下载→时长校验→推理→上传→上报)。"""
|
||||
task_id = task["task_id"]
|
||||
logger.info("开始处理任务 %s", task_id)
|
||||
with tempfile.TemporaryDirectory(prefix="musetalk_") as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
video_path = tmp / "input.mp4"
|
||||
audio_path = tmp / "input_audio.bin"
|
||||
out_path = tmp / "output.mp4"
|
||||
# 领取任务后立即启动任务级心跳线程,覆盖下载/推理/上报全过程
|
||||
hb = TaskHeartbeat(task_id, Config.task_heartbeat_interval)
|
||||
hb.start()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="musetalk_") as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
video_path = tmp / "input.mp4"
|
||||
audio_path = tmp / "input_audio.bin"
|
||||
out_path = tmp / "output.mp4"
|
||||
|
||||
# 1. 下载
|
||||
if not _download(task["video_url"], video_path):
|
||||
_report_result(task_id, False, 0.0, "下载人物视频失败")
|
||||
return
|
||||
if not _download(task["audio_url"], audio_path):
|
||||
_report_result(task_id, False, 0.0, "下载驱动音频失败")
|
||||
return
|
||||
# 1. 下载
|
||||
if not _download(task["video_url"], video_path):
|
||||
_report_result(task_id, False, 0.0, "下载人物视频失败")
|
||||
return
|
||||
if not _download(task["audio_url"], audio_path):
|
||||
_report_result(task_id, False, 0.0, "下载驱动音频失败")
|
||||
return
|
||||
|
||||
# 2. 推理(本地重试)
|
||||
success = False
|
||||
duration = 0.0
|
||||
err = ""
|
||||
for attempt in range(Config.task_max_retry + 1):
|
||||
if attempt > 0:
|
||||
logger.info("任务 %s 第 %d 次重试...", task_id, attempt + 1)
|
||||
time.sleep(2)
|
||||
success, duration, err = _call_musetalk(video_path, audio_path, out_path)
|
||||
if success:
|
||||
break
|
||||
if not success:
|
||||
logger.error("任务 %s 推理失败: %s", task_id, err)
|
||||
_report_result(task_id, False, 0.0, err)
|
||||
return
|
||||
# 2. 输入时长前置校验:短视频 MuseTalk 会 division by zero,
|
||||
# 直接上报 failed,不浪费 GPU 时间。ffprobe 不可用/读失败(0.0)
|
||||
# 时不拦截,交给 MuseTalk 处理,避免误杀。
|
||||
video_duration = _probe_duration(video_path)
|
||||
if video_duration and video_duration < Config.min_video_duration_seconds:
|
||||
msg = (
|
||||
f"视频过短({video_duration:.2f}s < {Config.min_video_duration_seconds:.0f}s),"
|
||||
"MuseTalk 无法处理"
|
||||
)
|
||||
logger.error("任务 %s %s", task_id, msg)
|
||||
_report_result(task_id, False, 0.0, msg)
|
||||
return
|
||||
|
||||
# 3. 上报结果(multipart 同时上传文件 → API 代为 PUT 到 OSS,逻辑最稳)
|
||||
_report_success_with_file(task_id, duration, out_path)
|
||||
# 3. 推理(本地仅对瞬时错误重试)
|
||||
success = False
|
||||
duration = 0.0
|
||||
err = ""
|
||||
retryable = False
|
||||
for attempt in range(Config.task_max_retry + 1):
|
||||
if attempt > 0:
|
||||
logger.info("任务 %s 第 %d 次重试(瞬时错误)...", task_id, attempt + 1)
|
||||
time.sleep(2)
|
||||
success, duration, err, retryable = _call_musetalk(video_path, audio_path, out_path)
|
||||
if success or not retryable:
|
||||
break
|
||||
if not success:
|
||||
logger.error("任务 %s 推理失败: %s", task_id, err)
|
||||
_report_result(task_id, False, 0.0, err)
|
||||
return
|
||||
|
||||
# 4. 上报结果(multipart 同时上传文件 → API 代为 PUT 到 OSS,逻辑最稳)
|
||||
_report_success_with_file(task_id, duration, out_path)
|
||||
finally:
|
||||
hb.stop()
|
||||
|
||||
|
||||
def _report_success_with_file(task_id: str, duration: float, file_path: Path) -> None:
|
||||
|
||||
@@ -91,15 +91,21 @@ class SQLAlchemyAssetAtomClipRepository:
|
||||
self.session.commit()
|
||||
return count > 0
|
||||
|
||||
def find_untagged(self, limit: int = 100) -> list[AssetAtomClip]:
|
||||
"""查找 ai_tags IS NULL 的片段,用于回填."""
|
||||
models = (
|
||||
self.session.query(AssetAtomClipModel)
|
||||
.filter(AssetAtomClipModel.ai_tags.is_(None))
|
||||
.order_by(AssetAtomClipModel.created_at.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
def find_untagged(self, limit: int = 100, include_downgraded: bool = False) -> list[AssetAtomClip]:
|
||||
"""查找未完成 AI 打标的片段,用于回填.
|
||||
|
||||
默认仅匹配 ai_tags IS NULL;include_downgraded=True 时额外包含
|
||||
只有 inherited_tags 的降级记录(视觉 API 失败时写入,无 has_text 字段),
|
||||
供强制回填(#1970 force backfill)使用。
|
||||
"""
|
||||
query = self.session.query(AssetAtomClipModel)
|
||||
if include_downgraded:
|
||||
# as_string() → JSON/JSONB ->> 取值;NULL 记录或缺 has_text 键
|
||||
# (降级记录)均为 NULL,has_text 为 true/false 的完整记录被排除
|
||||
query = query.filter(AssetAtomClipModel.ai_tags["has_text"].as_string().is_(None))
|
||||
else:
|
||||
query = query.filter(AssetAtomClipModel.ai_tags.is_(None))
|
||||
models = query.order_by(AssetAtomClipModel.created_at.asc()).limit(limit).all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def _to_model(self, clip: AssetAtomClip) -> AssetAtomClipModel:
|
||||
|
||||
@@ -84,8 +84,11 @@ class SharedSettings(BaseSettings):
|
||||
# Worker 用这个长期 Token 鉴权(不是用户 JWT)。多 Worker 共用同一个 Token;
|
||||
# worker_id 用于区分具体机器。生产必须配置;development 留空会跳过校验。
|
||||
gpu_worker_token: str = ""
|
||||
# GPU 任务超时(秒):超过此时长仍未完成则标记为 failed,可重新 poll
|
||||
gpu_task_timeout_seconds: int = 300
|
||||
# GPU 任务超时(秒):processing 状态超过此时长(以任务心跳为准)才回退
|
||||
# pending / failed。#1970:RTX2060 6G 推理 720p 长视频需 5 分钟以上,300→900。
|
||||
# Worker 推理期间每 30s 通过 /gpu/register(task_id=...) 续心跳,
|
||||
# 只有真正超时或 Worker 明确上报 failed 才会回退。
|
||||
gpu_task_timeout_seconds: int = 900
|
||||
# 结果预签名 URL 有效期(秒)
|
||||
gpu_result_url_expires: int = 3600
|
||||
# 输入预签名 URL 有效期(秒,需留出 Worker 下载时间)
|
||||
|
||||
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
|
||||
fi
|
||||
|
||||
# 共用 secrets 直接导出(如果存在)
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""#1970 AI 标签 Celery 任务注册回归测试。
|
||||
|
||||
背景:staging 上 worker.generate_atom_clips 正常派发 tag_atom_clip,
|
||||
但消费端报 "Received unregistered task of type 'worker.tag_atom_clip'",
|
||||
根因是 celery_app.conf.imports 漏列任务模块,worker 进程从未 import 之。
|
||||
|
||||
注意:tests/unit 下大量旧测试在 import 期向 sys.modules 注入
|
||||
worker_app.celery_app 的 MagicMock 且不还原,全量收集时会污染本测试,
|
||||
因此这里用 AST 静态解析 + 隔离子进程验证,不依赖 sys.modules 状态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
CELERY_APP_PY = REPO_ROOT / "apps" / "worker" / "worker_app" / "celery_app.py"
|
||||
|
||||
REQUIRED_MODULES = (
|
||||
"worker_app.tasks.atom_clip_tagging",
|
||||
"worker_app.tasks.backfill_atom_clip_tags",
|
||||
)
|
||||
|
||||
|
||||
def _conf_imports_values() -> set[str]:
|
||||
"""从 celery_app.py AST 中提取 celery_app.conf.imports 元组的字符串项。"""
|
||||
tree = ast.parse(CELERY_APP_PY.read_text(encoding="utf-8"))
|
||||
values: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Assign) and len(node.targets) == 1):
|
||||
continue
|
||||
target = node.targets[0]
|
||||
# celery_app.conf.imports = (...) 或 conf.imports = (...)
|
||||
if not (isinstance(target, ast.Attribute) and target.attr == "imports"):
|
||||
continue
|
||||
if isinstance(node.value, (ast.Tuple, ast.List)):
|
||||
for elt in node.value.elts:
|
||||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
||||
values.add(elt.value)
|
||||
return values
|
||||
|
||||
|
||||
def test_ai_tag_modules_in_celery_imports():
|
||||
imports = _conf_imports_values()
|
||||
for module in REQUIRED_MODULES:
|
||||
assert module in imports, f"{module} 未加入 celery_app.conf.imports"
|
||||
|
||||
|
||||
def test_ai_tag_tasks_registered_in_isolated_process():
|
||||
"""隔离子进程(无 conftest / 无 sys.modules mock)真实加载 Celery app。"""
|
||||
# 模拟 worker 启动时按 conf.imports import 任务模块的行为;
|
||||
# 只导入 AI 标签两个模块(其他模块依赖 cv2 等本地未安装的重依赖)。
|
||||
code = (
|
||||
"import importlib, sys; "
|
||||
"from worker_app.celery_app import celery_app; "
|
||||
"mods = [m for m in celery_app.conf.imports or () "
|
||||
"if 'atom_clip_tagging' in m or 'backfill_atom_clip_tags' in m]; "
|
||||
"[importlib.import_module(m) for m in mods]; "
|
||||
"missing = [n for n in "
|
||||
"['worker.tag_atom_clip', 'worker.backfill_atom_clip_tags'] "
|
||||
"if n not in celery_app.tasks]; "
|
||||
"sys.exit(1 if missing or len(mods) < 2 else 0)"
|
||||
)
|
||||
env = os.environ.copy()
|
||||
paths = [
|
||||
str(REPO_ROOT),
|
||||
str(REPO_ROOT / "apps" / "worker"),
|
||||
str(REPO_ROOT / "packages"),
|
||||
]
|
||||
env["PYTHONPATH"] = os.pathsep.join(paths) + os.pathsep + env.get("PYTHONPATH", "")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, "隔离子进程中任务未注册成功:\n" f"stdout={result.stdout}\nstderr={result.stderr}"
|
||||
@@ -0,0 +1,347 @@
|
||||
"""#1970 force 回填降级 AI 标签记录的回归测试。
|
||||
|
||||
背景:DOUBAO_VISION_MODEL 未配置时,tagger 降级写入
|
||||
{"inherited_tags": [...]}(非 NULL),默认 backfill 只捞 ai_tags IS NULL,
|
||||
这批记录永远不会重打。force=True 时应纳入降级记录,并在打标成功后覆盖。
|
||||
|
||||
覆盖:
|
||||
- find_untagged(include_downgraded) 的 SQL 过滤(SQLite 验证跨库 JSON 取值)
|
||||
- tag_atom_clip_task 的 force 跳过/放行/覆盖逻辑
|
||||
- backfill_atom_clip_tags(force=True) 给 tag 任务传 kwargs={"force": True}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
|
||||
|
||||
# ── 仓储层:find_untagged 过滤 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
AssetAtomClipModel.__table__.create(engine)
|
||||
SessionTest = sessionmaker(bind=engine)
|
||||
session = SessionTest()
|
||||
now = datetime.now(UTC)
|
||||
session.add_all(
|
||||
[
|
||||
AssetAtomClipModel(
|
||||
id="c-null",
|
||||
asset_id="a1",
|
||||
start_time=0,
|
||||
end_time=1,
|
||||
duration=1,
|
||||
clip_index=0,
|
||||
tags=[],
|
||||
ai_tags=None,
|
||||
created_at=now,
|
||||
),
|
||||
AssetAtomClipModel(
|
||||
id="c-downgraded-empty",
|
||||
asset_id="a1",
|
||||
start_time=1,
|
||||
end_time=2,
|
||||
duration=1,
|
||||
clip_index=1,
|
||||
tags=[],
|
||||
ai_tags={"inherited_tags": []},
|
||||
created_at=now,
|
||||
),
|
||||
AssetAtomClipModel(
|
||||
id="c-downgraded-tags",
|
||||
asset_id="a1",
|
||||
start_time=2,
|
||||
end_time=3,
|
||||
duration=1,
|
||||
clip_index=2,
|
||||
tags=[],
|
||||
ai_tags={"inherited_tags": ["口播"]},
|
||||
created_at=now,
|
||||
),
|
||||
AssetAtomClipModel(
|
||||
id="c-tagged-true",
|
||||
asset_id="a1",
|
||||
start_time=3,
|
||||
end_time=4,
|
||||
duration=1,
|
||||
clip_index=3,
|
||||
tags=[],
|
||||
ai_tags={"has_text": True, "scene": ["室内"], "inherited_tags": []},
|
||||
created_at=now,
|
||||
),
|
||||
AssetAtomClipModel(
|
||||
id="c-tagged-false",
|
||||
asset_id="a1",
|
||||
start_time=4,
|
||||
end_time=5,
|
||||
duration=1,
|
||||
clip_index=4,
|
||||
tags=[],
|
||||
ai_tags={"has_text": False, "inherited_tags": ["风景"]},
|
||||
created_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
# SQLAlchemy JSON 在 SQLite 下把 None 序列化为 'null' 字符串,
|
||||
# 而生产 PostgreSQL 存的是真 SQL NULL;用原生 SQL 对齐生产语义。
|
||||
from sqlalchemy import text
|
||||
|
||||
session.execute(text("UPDATE asset_atom_clips SET ai_tags = NULL WHERE id = 'c-null'"))
|
||||
session.commit()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def test_find_untagged_default_only_null(repo_session):
|
||||
repo = SQLAlchemyAssetAtomClipRepository(repo_session)
|
||||
ids = {c.id for c in repo.find_untagged(limit=100)}
|
||||
assert ids == {"c-null"}
|
||||
|
||||
|
||||
def test_find_untagged_include_downgraded(repo_session):
|
||||
repo = SQLAlchemyAssetAtomClipRepository(repo_session)
|
||||
ids = {c.id for c in repo.find_untagged(limit=100, include_downgraded=True)}
|
||||
# NULL + 两条降级记录;含 has_text=true/false 的完整记录都排除
|
||||
assert ids == {"c-null", "c-downgraded-empty", "c-downgraded-tags"}
|
||||
|
||||
|
||||
# ── 任务层:tag_atom_clip_task 的 force 语义 ───────────────────────────────
|
||||
|
||||
|
||||
def _import_tag_task_module():
|
||||
from worker_app.tasks import atom_clip_tagging as mod
|
||||
|
||||
return mod
|
||||
|
||||
|
||||
def _call_tag_task(mod, clip_id, force):
|
||||
"""直接调用任务,兼容两种环境。
|
||||
|
||||
全量收集时旧测试向 sys.modules 注入 celery_app MagicMock(其 task
|
||||
装饰器原样返回裸函数),此时是普通函数需显式传 self=None;
|
||||
正常 Celery 环境下属性是 Task 代理对象(非普通 function),
|
||||
已绑定 self,按业务签名直接调用即可。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
obj = mod.tag_atom_clip_task
|
||||
if inspect.isfunction(obj):
|
||||
return obj(None, clip_id, force=force)
|
||||
return obj(clip_id, force=force)
|
||||
|
||||
|
||||
def test_tag_task_skips_downgraded_without_force(monkeypatch):
|
||||
mod = _import_tag_task_module()
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"SessionLocal",
|
||||
lambda: SimpleNamespace(
|
||||
rollback=lambda: None,
|
||||
close=lambda: None,
|
||||
),
|
||||
)
|
||||
|
||||
class _Repo:
|
||||
def __init__(self, db):
|
||||
pass
|
||||
|
||||
def find_by_id(self, clip_id):
|
||||
return SimpleNamespace(
|
||||
id=clip_id,
|
||||
ai_tags={"inherited_tags": []},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod, "SQLAlchemyAssetAtomClipRepository", _Repo)
|
||||
|
||||
result = _call_tag_task(mod, "clip-downgraded", force=False)
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "already tagged"
|
||||
|
||||
|
||||
def test_tag_task_force_retags_downgraded_and_overwrites(monkeypatch):
|
||||
mod = _import_tag_task_module()
|
||||
updated: dict[str, dict] = {}
|
||||
|
||||
class _FakeSession:
|
||||
def rollback(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mod, "SessionLocal", _FakeSession)
|
||||
|
||||
class _AtomRepo:
|
||||
def __init__(self, db):
|
||||
pass
|
||||
|
||||
def find_by_id(self, clip_id):
|
||||
return SimpleNamespace(
|
||||
id=clip_id,
|
||||
asset_id="asset-1",
|
||||
start_time=0.0,
|
||||
end_time=2.0,
|
||||
tags=["旧标签"],
|
||||
ai_tags={"inherited_tags": []},
|
||||
)
|
||||
|
||||
def update_ai_tags(self, clip_id, ai_tags):
|
||||
updated[clip_id] = ai_tags
|
||||
|
||||
class _AssetRepo:
|
||||
def __init__(self, db):
|
||||
pass
|
||||
|
||||
def find_by_id(self, asset_id):
|
||||
return SimpleNamespace(id=asset_id, storage_key="k/video.mp4")
|
||||
|
||||
monkeypatch.setattr(mod, "SQLAlchemyAssetAtomClipRepository", _AtomRepo)
|
||||
monkeypatch.setattr(mod, "SQLAlchemyAssetRepository", _AssetRepo)
|
||||
|
||||
class _Storage:
|
||||
def get_download_url(self, key, expires_seconds=3600):
|
||||
return "https://example.com/signed.mp4"
|
||||
|
||||
monkeypatch.setattr(mod, "get_shared_storage_service", lambda: _Storage())
|
||||
monkeypatch.setattr(mod, "get_doubao_client", lambda: object())
|
||||
monkeypatch.setattr(mod, "get_mediakit_client", lambda: None)
|
||||
|
||||
new_tags = {
|
||||
"scene": ["室内"],
|
||||
"objects": ["人物"],
|
||||
"action": ["说话"],
|
||||
"shot": "中景",
|
||||
"has_text": True,
|
||||
"inherited_tags": ["旧标签"],
|
||||
}
|
||||
monkeypatch.setattr(mod, "tag_atom_clip", lambda **kw: new_tags)
|
||||
|
||||
result = _call_tag_task(mod, "clip-downgraded", force=True)
|
||||
assert result["status"] == "completed"
|
||||
assert result["has_ai_tags"] is True
|
||||
assert updated["clip-downgraded"] == new_tags
|
||||
|
||||
|
||||
def test_tag_task_force_still_skips_complete_tags(monkeypatch):
|
||||
mod = _import_tag_task_module()
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"SessionLocal",
|
||||
lambda: SimpleNamespace(rollback=lambda: None, close=lambda: None),
|
||||
)
|
||||
|
||||
class _Repo:
|
||||
def __init__(self, db):
|
||||
pass
|
||||
|
||||
def find_by_id(self, clip_id):
|
||||
return SimpleNamespace(
|
||||
id=clip_id,
|
||||
ai_tags={"has_text": False, "inherited_tags": []},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod, "SQLAlchemyAssetAtomClipRepository", _Repo)
|
||||
|
||||
result = _call_tag_task(mod, "clip-complete", force=True)
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "already tagged"
|
||||
|
||||
|
||||
# ── backfill 任务:force 透传到 send_task ──────────────────────────────────
|
||||
|
||||
|
||||
def test_backfill_force_passes_kwarg(monkeypatch):
|
||||
from worker_app.tasks import backfill_atom_clip_tags as bmod
|
||||
|
||||
sent: list[tuple] = []
|
||||
|
||||
class _FakeSession:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(bmod, "SessionLocal", _FakeSession)
|
||||
|
||||
class _AtomRepo:
|
||||
def __init__(self, db):
|
||||
self.calls: list[bool] = []
|
||||
|
||||
def find_untagged(self, limit, include_downgraded=False):
|
||||
self.calls.append(include_downgraded)
|
||||
# 第一批返回一条降级记录,第二批返回空结束循环
|
||||
if len(self.calls) == 1:
|
||||
return [SimpleNamespace(id="clip-1")]
|
||||
return []
|
||||
|
||||
repo_holder = {}
|
||||
|
||||
def _repo_factory(db):
|
||||
repo = _AtomRepo(db)
|
||||
repo_holder["repo"] = repo
|
||||
return repo
|
||||
|
||||
monkeypatch.setattr(bmod, "SQLAlchemyAssetAtomClipRepository", _repo_factory)
|
||||
|
||||
def _send_task(name, args=None, kwargs=None):
|
||||
sent.append((name, args, kwargs))
|
||||
|
||||
monkeypatch.setattr(bmod.celery_app, "send_task", _send_task)
|
||||
|
||||
result = bmod.backfill_atom_clip_tags(batch_size=10, batch_interval=0, force=True)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["total_submitted"] == 1
|
||||
assert repo_holder["repo"].calls == [True, True]
|
||||
assert sent == [
|
||||
("worker.tag_atom_clip", ["clip-1"], {"force": True}),
|
||||
]
|
||||
|
||||
|
||||
def test_backfill_default_does_not_force(monkeypatch):
|
||||
from worker_app.tasks import backfill_atom_clip_tags as bmod
|
||||
|
||||
sent_kwargs: list[dict | None] = []
|
||||
|
||||
class _FakeSession:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(bmod, "SessionLocal", _FakeSession)
|
||||
|
||||
class _AtomRepo:
|
||||
def __init__(self, db):
|
||||
self.calls: list[bool] = []
|
||||
|
||||
def find_untagged(self, limit, include_downgraded=False):
|
||||
self.calls.append(include_downgraded)
|
||||
return [SimpleNamespace(id="clip-null")] if self.calls == [False] else []
|
||||
|
||||
holder = {}
|
||||
|
||||
def _repo_factory(db):
|
||||
holder["repo"] = _AtomRepo(db)
|
||||
return holder["repo"]
|
||||
|
||||
monkeypatch.setattr(bmod, "SQLAlchemyAssetAtomClipRepository", _repo_factory)
|
||||
monkeypatch.setattr(
|
||||
bmod.celery_app,
|
||||
"send_task",
|
||||
lambda name, args=None, kwargs=None: sent_kwargs.append(kwargs),
|
||||
)
|
||||
|
||||
result = bmod.backfill_atom_clip_tags(batch_size=10, batch_interval=0)
|
||||
|
||||
assert result["total_submitted"] == 1
|
||||
assert holder["repo"].calls == [False, False]
|
||||
assert sent_kwargs == [{"force": False}]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""#1970 GPU Worker 修复单测.
|
||||
|
||||
覆盖 deploy/gpu_worker/gpu_worker.py(独立部署脚本,不在 apps/packages 包内,
|
||||
按文件路径动态加载):
|
||||
1. 默认配置:REQUEST_TIMEOUT=900 / TASK_MAX_RETRY=1 / 心跳 30s / 最短 3s;
|
||||
2. 推理期心跳线程 POST /gpu/register 带 task_id,任务结束能停;
|
||||
3. <3s 短视频直接上报失败,不调用 MuseTalk;
|
||||
4. _call_musetalk 仅对 5xx/网络瞬时错误标记 retryable,4xx 不重试;
|
||||
5. _handle_task 只对 retryable 错误本地重试 1 次。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKER_PATH = ROOT / "deploy" / "gpu_worker" / "gpu_worker.py"
|
||||
|
||||
|
||||
def _load_worker_module():
|
||||
spec = importlib.util.spec_from_file_location("gpu_worker_standalone_1970", WORKER_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker():
|
||||
return _load_worker_module()
|
||||
|
||||
|
||||
# ── 默认配置 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_config_defaults_900_and_retry_one(monkeypatch):
|
||||
"""CI/本机若显式导出过这些 env,说明是运维覆盖,不应拿默认值断言;
|
||||
因此只在四个 env 全部缺失时校验脚本内置默认值(#1970:900/1/30/3)。"""
|
||||
keys = (
|
||||
"REQUEST_TIMEOUT",
|
||||
"TASK_MAX_RETRY",
|
||||
"TASK_HEARTBEAT_INTERVAL",
|
||||
"MIN_VIDEO_DURATION_SECONDS",
|
||||
)
|
||||
if any(k in os.environ for k in keys):
|
||||
pytest.skip("环境显式设置了 worker 超时/重试变量,跳过默认值断言")
|
||||
for key in keys:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
mod = _load_worker_module()
|
||||
assert mod.Config.request_timeout == 900.0
|
||||
assert mod.Config.task_max_retry == 1
|
||||
assert mod.Config.task_heartbeat_interval == 30.0
|
||||
assert mod.Config.min_video_duration_seconds == 3.0
|
||||
|
||||
|
||||
# ── register 携带 task_id ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_register_payload_includes_task_id_only_when_provided(worker, monkeypatch):
|
||||
captured = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def _fake_post(url, json=None, headers=None, timeout=None):
|
||||
captured.append(json)
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(worker.requests, "post", _fake_post)
|
||||
monkeypatch.setattr(worker, "_check_musetalk_health", lambda: (True, {}))
|
||||
|
||||
assert worker._register("task-abc") is True
|
||||
assert captured[-1]["task_id"] == "task-abc"
|
||||
assert captured[-1]["worker_id"]
|
||||
|
||||
worker._register() # 空闲心跳不带 task_id
|
||||
assert "task_id" not in captured[-1]
|
||||
|
||||
|
||||
# ── 推理期心跳线程 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_task_heartbeat_thread_sends_and_stops(worker, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_register(task_id=None):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(worker, "_register", _fake_register)
|
||||
hb = worker.TaskHeartbeat("task-hb1", interval=5)
|
||||
hb.start()
|
||||
time.sleep(0.3) # 启动后立即发一次
|
||||
hb.stop()
|
||||
hb.join(timeout=2)
|
||||
assert not hb.is_alive()
|
||||
assert calls and all(c == "task-hb1" for c in calls)
|
||||
|
||||
|
||||
# ── 短视频前置拦截 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_handle_task_short_video_reports_failed_without_inference(worker, monkeypatch, tmp_path):
|
||||
video = tmp_path / "input.mp4"
|
||||
video.write_bytes(b"fake-mp4-bytes")
|
||||
audio = tmp_path / "input_audio.bin"
|
||||
audio.write_bytes(b"fake-audio")
|
||||
reports = []
|
||||
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
# ffprobe 读出 1.2s → 低于 3s 阈值
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 1.2)
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("短视频不应调用 MuseTalk 推理")
|
||||
|
||||
monkeypatch.setattr(worker, "_call_musetalk", _boom)
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_result",
|
||||
lambda task_id, success, duration=0.0, error_msg="": reports.append((task_id, success, error_msg)) or True,
|
||||
)
|
||||
|
||||
task = {
|
||||
"task_id": "task-short",
|
||||
"video_url": "https://example.com/v.mp4",
|
||||
"audio_url": "https://example.com/a.bin",
|
||||
}
|
||||
worker._handle_task(task)
|
||||
|
||||
assert len(reports) == 1
|
||||
tid, ok, err = reports[0]
|
||||
assert tid == "task-short"
|
||||
assert ok is False
|
||||
assert "视频过短" in err
|
||||
assert "3" in err
|
||||
|
||||
|
||||
def test_handle_task_probe_failure_does_not_block(worker, monkeypatch):
|
||||
"""ffprobe 不可用(duration=0.0)时不能误杀,应继续推理."""
|
||||
reports = []
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 0.0)
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_call_musetalk",
|
||||
lambda v, a, o: (True, 8.0, "", False),
|
||||
)
|
||||
uploaded = []
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_success_with_file",
|
||||
lambda task_id, duration, path: uploaded.append((task_id, duration)),
|
||||
)
|
||||
monkeypatch.setattr(worker, "_report_result", lambda *a, **k: True)
|
||||
|
||||
worker._handle_task({"task_id": "task-probe0", "video_url": "u", "audio_url": "u"})
|
||||
assert uploaded == [("task-probe0", 8.0)]
|
||||
assert reports == []
|
||||
|
||||
|
||||
# ── 重试语义:仅瞬时错误重试 ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_call_musetalk_4xx_not_retryable_5xx_retryable(worker, monkeypatch, tmp_path):
|
||||
video = tmp_path / "v.mp4"
|
||||
audio = tmp_path / "a.bin"
|
||||
video.write_bytes(b"v")
|
||||
audio.write_bytes(b"a")
|
||||
out = tmp_path / "o.mp4"
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, code, body=b"x" * 2048):
|
||||
self.status_code = code
|
||||
self.content = body
|
||||
self.text = "err"
|
||||
|
||||
# 4xx:确定性失败,不重试
|
||||
monkeypatch.setattr(worker.requests, "post", lambda *a, **k: _Resp(400))
|
||||
ok, _, _, retryable = worker._call_musetalk(video, audio, out)
|
||||
assert ok is False and retryable is False
|
||||
|
||||
monkeypatch.setattr(worker.requests, "post", lambda *a, **k: _Resp(503))
|
||||
ok, _, _, retryable = worker._call_musetalk(video, audio, out)
|
||||
assert ok is False and retryable is True
|
||||
|
||||
# 连接异常:瞬时错误,可重试
|
||||
import requests as _requests
|
||||
|
||||
def _conn_err(*a, **k):
|
||||
raise _requests.exceptions.ConnectionError("reset")
|
||||
|
||||
monkeypatch.setattr(worker.requests, "post", _conn_err)
|
||||
ok, _, _, retryable = worker._call_musetalk(video, audio, out)
|
||||
assert ok is False and retryable is True
|
||||
|
||||
|
||||
def test_handle_task_retries_once_for_transient_then_succeeds(worker, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_call(v, a, o):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
return False, 0.0, "MuseTalk HTTP 503: busy", True
|
||||
return True, 6.5, "", False
|
||||
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 12.0)
|
||||
monkeypatch.setattr(worker, "_call_musetalk", _fake_call)
|
||||
monkeypatch.setattr(worker, "time", mock.MagicMock()) # 重试 sleep 立即返回
|
||||
uploaded = []
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_success_with_file",
|
||||
lambda task_id, duration, path: uploaded.append((task_id, duration)),
|
||||
)
|
||||
|
||||
worker._handle_task({"task_id": "t-retry", "video_url": "u", "audio_url": "u"})
|
||||
assert len(calls) == 2
|
||||
assert uploaded == [("t-retry", 6.5)]
|
||||
|
||||
|
||||
def test_handle_task_no_retry_for_deterministic_failure(worker, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_call(v, a, o):
|
||||
calls.append(1)
|
||||
return False, 0.0, "MuseTalk HTTP 400: bad input", False
|
||||
|
||||
reports = []
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 12.0)
|
||||
monkeypatch.setattr(worker, "_call_musetalk", _fake_call)
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_result",
|
||||
lambda task_id, success, duration=0.0, error_msg="": reports.append(error_msg) or True,
|
||||
)
|
||||
|
||||
worker._handle_task({"task_id": "t-4xx", "video_url": "u", "audio_url": "u"})
|
||||
assert len(calls) == 1 # 4xx 本地不重试,直接交服务端决定
|
||||
assert reports and "400" in reports[0]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""#1970 hflip 放开(has_text 来自 atom_clip.ai_tags)端到端参数链路测试。
|
||||
|
||||
覆盖:
|
||||
1. UnifiedRenderService 传入 clip_has_text 后微变换计划的翻转门控;
|
||||
2. RenderAdapter._resolve_clip_has_text 按 atom_clip.ai_tags.has_text
|
||||
解析布尔列表(显式 False 才可翻转,其余保守),失败回退 None;
|
||||
3. 纯函数层在「混合有/无文字」列表下的行为(顺序对齐)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.micro_transform_pure import build_micro_transform_plan
|
||||
|
||||
|
||||
def _make_service(plan_config: dict | None = None, clip_has_text=None):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
svc = object.__new__(UnifiedRenderService)
|
||||
svc.plan = MagicMock()
|
||||
svc.plan.config = plan_config or {}
|
||||
svc.plan.id = "plan-1"
|
||||
svc.plan.clips = []
|
||||
svc._micro_plan_cache = None
|
||||
svc._micro_plan_loaded = False
|
||||
svc._clip_has_text = clip_has_text
|
||||
return svc
|
||||
|
||||
|
||||
def _clip(clip_id: str, atom_clip_id: str = "", clip_type: str = "main"):
|
||||
return SimpleNamespace(id=clip_id, atom_clip_id=atom_clip_id, clip_type=clip_type)
|
||||
|
||||
|
||||
def _atom(clip_id: str, ai_tags):
|
||||
return SimpleNamespace(id=clip_id, ai_tags=ai_tags)
|
||||
|
||||
|
||||
class TestServiceClipHasText:
|
||||
def test_none_stays_conservative(self):
|
||||
# 未注入检测列表:所有片段一律不翻转
|
||||
svc = _make_service({"generation_task_id": "t1"}, clip_has_text=None)
|
||||
plan = svc._get_micro_transform_plan(30)
|
||||
assert plan is not None
|
||||
assert all(c.has_text for c in plan.clips)
|
||||
assert all(not c.hflip for c in plan.clips)
|
||||
|
||||
def test_explicit_no_text_allows_hflip(self):
|
||||
# AI 明确判定无文字:允许参与 50% 翻转(40 段应至少出现一些翻转)
|
||||
svc = _make_service({"generation_task_id": "t-allow"}, clip_has_text=[False] * 40)
|
||||
plan = svc._get_micro_transform_plan(40)
|
||||
assert plan is not None
|
||||
assert all(not c.has_text for c in plan.clips)
|
||||
assert any(c.hflip for c in plan.clips)
|
||||
assert all(not c.hflip or not c.has_text for c in plan.clips)
|
||||
|
||||
def test_all_text_never_flips(self):
|
||||
svc = _make_service({"generation_task_id": "t-text"}, clip_has_text=[True] * 40)
|
||||
plan = svc._get_micro_transform_plan(40)
|
||||
assert all(c.has_text for c in plan.clips)
|
||||
assert all(not c.hflip for c in plan.clips)
|
||||
|
||||
def test_mixed_order_alignment(self):
|
||||
# 仅第 0、2 个片段无文字;has_text 标记必须与片段序号严格对齐
|
||||
svc = _make_service({"generation_task_id": "t-mix"}, clip_has_text=[False, True, False, True])
|
||||
plan = svc._get_micro_transform_plan(4)
|
||||
assert [c.has_text for c in plan.clips] == [False, True, False, True]
|
||||
assert all(not plan.clips[i].hflip for i in (1, 3))
|
||||
for i in (0, 2):
|
||||
# 无文字片段的翻转由 50% 种子决定,但允许翻转(不强制一定翻)
|
||||
assert plan.clips[i].has_text is False
|
||||
|
||||
def test_list_shorter_than_clips_missing_are_conservative(self):
|
||||
# 列表短于片段数:缺位片段按有文字处理
|
||||
svc = _make_service({"generation_task_id": "t-short"}, clip_has_text=[False])
|
||||
plan = svc._get_micro_transform_plan(3)
|
||||
assert [c.has_text for c in plan.clips] == [False, True, True]
|
||||
assert not plan.clips[1].hflip and not plan.clips[2].hflip
|
||||
|
||||
def test_plan_reproducible_with_real_list(self):
|
||||
cfg = {"generation_task_id": "task-x", "video_index": 1}
|
||||
flags = [False, True, False, False, True]
|
||||
p1 = _make_service(cfg, clip_has_text=flags)._get_micro_transform_plan(5)
|
||||
p2 = _make_service(dict(cfg), clip_has_text=list(flags))._get_micro_transform_plan(5)
|
||||
assert [c.hflip for c in p1.clips] == [c.hflip for c in p2.clips]
|
||||
|
||||
|
||||
class TestPureMixedFlags:
|
||||
def test_pure_function_mixed_flags(self):
|
||||
plan = build_micro_transform_plan("seed-1", 0, 4, clip_has_text=[False, True, False, True])
|
||||
assert [c.has_text for c in plan.clips] == [False, True, False, True]
|
||||
# 有文字片段绝不翻转
|
||||
assert not plan.clips[1].hflip and not plan.clips[3].hflip
|
||||
|
||||
|
||||
class TestResolveClipHasText:
|
||||
def _adapter(self):
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
|
||||
return RenderAdapter(MagicMock())
|
||||
|
||||
def test_no_atom_ids_returns_none(self):
|
||||
adapter = self._adapter()
|
||||
clips = [_clip("c1", ""), _clip("c2", "")]
|
||||
assert adapter._resolve_clip_has_text(clips) is None
|
||||
|
||||
def test_explicit_false_only_maps_to_false(self):
|
||||
adapter = self._adapter()
|
||||
clips = [
|
||||
_clip("c1", "a1"),
|
||||
_clip("c2", "a2"),
|
||||
_clip("c3", "a3"),
|
||||
_clip("c4", "a4"),
|
||||
_clip("c5", "a5"),
|
||||
]
|
||||
atoms = [
|
||||
_atom("a1", {"has_text": False}), # 明确无文字 → False
|
||||
_atom("a2", {"has_text": True}), # 有文字
|
||||
_atom("a3", None), # 标签未生成
|
||||
_atom("a4", {"scene": ["工厂"]}), # has_text 缺失(null)
|
||||
_atom("a5", {"has_text": "false"}), # 非布尔 → 保守
|
||||
]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
return_value=atoms,
|
||||
):
|
||||
result = adapter._resolve_clip_has_text(clips)
|
||||
assert result == [False, True, True, True, True]
|
||||
|
||||
def test_audio_clips_excluded_and_order_kept(self):
|
||||
adapter = self._adapter()
|
||||
clips = [
|
||||
_clip("c1", "a1", clip_type="main"),
|
||||
_clip("bgm", "", clip_type="audio"),
|
||||
_clip("c2", "a2", clip_type="pip"),
|
||||
]
|
||||
atoms = [
|
||||
_atom("a1", {"has_text": False}),
|
||||
_atom("a2", {"has_text": False}),
|
||||
]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
return_value=atoms,
|
||||
) as mock_find:
|
||||
result = adapter._resolve_clip_has_text(clips)
|
||||
# 只查非 audio 片段的 atom id,且顺序为 main → pip
|
||||
assert mock_find.call_args.args[0] == ["a1", "a2"]
|
||||
assert result == [False, False]
|
||||
|
||||
def test_missing_atom_record_defaults_true(self):
|
||||
adapter = self._adapter()
|
||||
clips = [_clip("c1", "a1"), _clip("c2", "a2")]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
return_value=[_atom("a1", {"has_text": False})], # a2 查不到
|
||||
):
|
||||
result = adapter._resolve_clip_has_text(clips)
|
||||
assert result == [False, True]
|
||||
|
||||
def test_query_failure_returns_none(self):
|
||||
adapter = self._adapter()
|
||||
clips = [_clip("c1", "a1")]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
side_effect=RuntimeError("db down"),
|
||||
):
|
||||
assert adapter._resolve_clip_has_text(clips) is None
|
||||
|
||||
def test_duplicate_atom_ids_queried_once(self):
|
||||
adapter = self._adapter()
|
||||
clips = [_clip("c1", "a1"), _clip("c2", "a1")]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
return_value=[_atom("a1", {"has_text": False})],
|
||||
) as mock_find:
|
||||
result = adapter._resolve_clip_has_text(clips)
|
||||
assert mock_find.call_args.args[0] == ["a1"]
|
||||
assert result == [False, False]
|
||||
@@ -22,6 +22,7 @@ def _make_service(plan_config: dict | None = None, clips=None):
|
||||
svc.plan.clips = clips or []
|
||||
svc._micro_plan_cache = None
|
||||
svc._micro_plan_loaded = False
|
||||
svc._clip_has_text = None
|
||||
return svc
|
||||
|
||||
|
||||
@@ -159,6 +160,7 @@ class TestStreamCopyGate:
|
||||
svc.clips = [source]
|
||||
svc._micro_plan_cache = None
|
||||
svc._micro_plan_loaded = False
|
||||
svc._clip_has_text = None
|
||||
resolved = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
|
||||
@@ -181,6 +181,73 @@ def test_register_worker_creates_then_updates(svc):
|
||||
assert w2.created_at == w.created_at # 没新建
|
||||
|
||||
|
||||
def test_register_with_task_id_refreshes_task_heartbeat(svc):
|
||||
"""#1970 推理期心跳:register(task_id=...) 只刷新本 worker 的 processing 任务."""
|
||||
from packages.adapters.sqlalchemy_impl.models import GpuWorkerModel
|
||||
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
svc.db.refresh(t)
|
||||
old_hb = t.last_heartbeat_at
|
||||
assert t.status == "processing"
|
||||
# 模拟时间流逝后心跳到达
|
||||
svc.db.query(GpuWorkerModel).filter_by(worker_id="w-1").update(
|
||||
{"last_heartbeat_at": old_hb - timedelta(seconds=300)}
|
||||
)
|
||||
svc.db.commit()
|
||||
svc.register_worker("w-1", task_id=t.id)
|
||||
svc.db.refresh(t)
|
||||
assert t.last_heartbeat_at > old_hb
|
||||
assert t.status == "processing" # 心跳不改变状态
|
||||
# worker 表心跳也被刷新
|
||||
w = svc.db.query(GpuWorkerModel).filter_by(worker_id="w-1").one()
|
||||
assert w.last_heartbeat_at > old_hb
|
||||
|
||||
|
||||
def test_register_task_heartbeat_ignores_finished_or_foreign_task(svc):
|
||||
"""任务已 done,或已被超时回收重新派发给别的 worker 时,旧心跳必须忽略."""
|
||||
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel, GpuWorkerModel
|
||||
|
||||
# 场景 1:任务已完成 → register 带 task_id 不得改写任务心跳
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
done = svc.report_result(t.id, "w-1", success=True, duration_seconds=10.0)
|
||||
hb_when_done = done.last_heartbeat_at
|
||||
svc.register_worker("w-1", task_id=t.id)
|
||||
svc.db.refresh(t)
|
||||
assert t.status == "done"
|
||||
assert t.last_heartbeat_at == hb_when_done # 没被改写
|
||||
|
||||
# 场景 2:任务超时回收后被 w-2 重新认领,旧 worker w-1 的迟到心跳无效
|
||||
t2 = svc.create_task(video_url="v2", audio_url="a2")
|
||||
svc.poll_task("w-1")
|
||||
svc.db.refresh(t2)
|
||||
t2.last_heartbeat_at = datetime.now(UTC) - timedelta(days=1)
|
||||
svc.db.commit()
|
||||
claimed = svc.poll_task("w-2") # 触发回收并由 w-2 重新认领
|
||||
assert claimed is not None and claimed.id == t2.id
|
||||
owner_hb = claimed.last_heartbeat_at
|
||||
# 把 w-2 的 worker 心跳拨早,确认旧心跳不会影响任务归属
|
||||
svc.db.query(GpuWorkerModel).filter_by(worker_id="w-2").update(
|
||||
{"last_heartbeat_at": owner_hb - timedelta(seconds=600)}
|
||||
)
|
||||
svc.db.commit()
|
||||
svc.register_worker("w-1", task_id=t2.id) # 旧 worker 迟到心跳
|
||||
svc.db.refresh(t2)
|
||||
assert t2.worker_id == "w-2"
|
||||
assert t2.status == "processing"
|
||||
assert t2.last_heartbeat_at == owner_hb
|
||||
|
||||
# 场景 3:不存在的 task_id 不报错
|
||||
svc.register_worker("w-1", task_id="nonexistent-id")
|
||||
assert svc.db.get(GpuLipsyncTaskModel, "nonexistent-id") is None
|
||||
|
||||
|
||||
def test_default_gpu_task_timeout_is_900(svc):
|
||||
"""#1970 默认超时 300→900,覆盖 RTX2060 长视频推理."""
|
||||
assert svc.settings.gpu_task_timeout_seconds == 900
|
||||
|
||||
|
||||
# ── get_by_lipsync_job ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user