feat: add seedance2 ai video compatibility
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -2,6 +2,19 @@
|
||||
|
||||
这个文件用于给 Gitea 仓库保留阶段性版本更新记录,方便直接查看每一轮里程碑,不用只依赖零散 commit。
|
||||
|
||||
## 2026-04-05
|
||||
|
||||
### AI 视频链兼容 Seedance 2.0
|
||||
|
||||
- `创建 AI 视频任务` 现在新增了 `视频引擎`、`引擎模型`、`镜头语言`、`运动节奏`、`风格约束` 和 `画幅`,可以直接用当前默认引擎或切到 `Seedance 2.0`。
|
||||
- 当前选择 `Seedance 2.0` 时,前端会把镜头语言、运动节奏和风格约束一起拼进视频 brief,不再只是把通用文案原样丢给视频链。
|
||||
- 后端新增了 `Seedance 2.0` 兼容归一化:
|
||||
- 对外仍记录真实 `video_provider = seedance2`
|
||||
- 对内渲染会按兼容映射转到当前可执行的视频引擎链
|
||||
- 同时保留 `video_dispatch_provider / video_dispatch_model / video_provider_label`
|
||||
- OneLiner 直接创建 AI 视频时,也会把 `video_provider / video_model` 一起透传,不再丢回默认视频引擎。
|
||||
- 生产侧回归新增了 `Seedance 2.0` 归一化断言,锁住 `/v2/pipelines/ai-video` 和主 Agent 创建链都必须正确带上 provider 信息。
|
||||
|
||||
## 2026-04-04
|
||||
|
||||
### 平台 Agent 变更后自动回到详情工作区
|
||||
|
||||
@@ -278,6 +278,32 @@ class AiVideoJobRequest(BaseModel):
|
||||
duration: int = 5
|
||||
|
||||
|
||||
def normalize_ai_video_provider(provider: str, model: str = "") -> tuple[str, str]:
|
||||
normalized_provider = (provider or "doubao").strip().lower().replace(" ", "").replace("_", "").replace("-", "")
|
||||
normalized_model = (model or "").strip()
|
||||
if normalized_provider in {"seedance", "seedance20", "seedance2", "s2", "jimeng2", "jimeng20"}:
|
||||
return "seedance2", normalized_model or "seedance-2.0-pro"
|
||||
if normalized_provider in {"doubao", "jimeng", "jimeng3", "jimeng30"}:
|
||||
return "doubao", normalized_model
|
||||
return (provider or "doubao").strip() or "doubao", normalized_model
|
||||
|
||||
|
||||
def resolve_ai_video_dispatch(provider: str, model: str = "") -> tuple[str, str]:
|
||||
normalized_provider, normalized_model = normalize_ai_video_provider(provider, model)
|
||||
if normalized_provider == "seedance2":
|
||||
return "doubao", normalized_model or "seedance-2.0-pro"
|
||||
return normalized_provider, normalized_model
|
||||
|
||||
|
||||
def ai_video_provider_label(provider: str, model: str = "") -> str:
|
||||
normalized_provider, normalized_model = normalize_ai_video_provider(provider, model)
|
||||
if normalized_provider == "seedance2":
|
||||
return f"Seedance 2.0{f' · {normalized_model}' if normalized_model else ''}"
|
||||
if normalized_provider == "doubao":
|
||||
return f"默认视频引擎{f' · {normalized_model}' if normalized_model else ''}"
|
||||
return normalized_provider if not normalized_model else f"{normalized_provider} · {normalized_model}"
|
||||
|
||||
|
||||
class ReviewCreateRequest(BaseModel):
|
||||
project_id: str = ""
|
||||
source_job_id: str = ""
|
||||
@@ -4488,6 +4514,8 @@ async def create_ai_video_job(request: AiVideoJobRequest, account: dict[str, Any
|
||||
project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"])
|
||||
kb = resolve_target_kb(account["id"], request.knowledge_base_id or source_kb_id or None, project["id"], username=account["username"])
|
||||
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
|
||||
video_provider, video_model = normalize_ai_video_provider(request.video_provider, request.video_model)
|
||||
dispatch_provider, dispatch_model = resolve_ai_video_dispatch(video_provider, video_model)
|
||||
_enforce_tenant_quota(account, project_id=project["id"], usage_category="ai_video")
|
||||
source = create_content_source(
|
||||
account_id=account["id"],
|
||||
@@ -4512,8 +4540,11 @@ async def create_ai_video_job(request: AiVideoJobRequest, account: dict[str, Any
|
||||
"shots": request.shots,
|
||||
"image_provider": request.image_provider,
|
||||
"image_model": request.image_model,
|
||||
"video_provider": request.video_provider,
|
||||
"video_model": request.video_model,
|
||||
"video_provider": video_provider,
|
||||
"video_model": video_model,
|
||||
"video_dispatch_provider": dispatch_provider,
|
||||
"video_dispatch_model": dispatch_model,
|
||||
"video_provider_label": ai_video_provider_label(video_provider, video_model),
|
||||
"aspect_ratio": request.aspect_ratio,
|
||||
"duration": request.duration,
|
||||
"source_job_id": request.source_job_id.strip(),
|
||||
@@ -4525,7 +4556,12 @@ async def create_ai_video_job(request: AiVideoJobRequest, account: dict[str, Any
|
||||
category="ai_video",
|
||||
reference_type="job",
|
||||
reference_id=job_row["id"],
|
||||
details={"workflow_key": "ai_video_pipeline", "source_job_id": request.source_job_id.strip()},
|
||||
details={
|
||||
"workflow_key": "ai_video_pipeline",
|
||||
"source_job_id": request.source_job_id.strip(),
|
||||
"video_provider": video_provider,
|
||||
"video_model": video_model,
|
||||
},
|
||||
)
|
||||
return job_payload(await trigger_orchestrated_job(job_row))
|
||||
|
||||
@@ -5159,8 +5195,11 @@ async def internal_ai_video_render(
|
||||
rendered_scenes: list[dict[str, Any]] = []
|
||||
image_provider = str(artifacts.get("image_provider") or "openai")
|
||||
image_model = str(artifacts.get("image_model") or "")
|
||||
video_provider = str(artifacts.get("video_provider") or "doubao")
|
||||
video_model = str(artifacts.get("video_model") or "")
|
||||
video_provider, video_model = normalize_ai_video_provider(
|
||||
str(artifacts.get("video_provider") or "doubao"),
|
||||
str(artifacts.get("video_model") or ""),
|
||||
)
|
||||
dispatch_provider, dispatch_model = resolve_ai_video_dispatch(video_provider, video_model)
|
||||
aspect_ratio = str(artifacts.get("aspect_ratio") or "9:16")
|
||||
image_size = huobao_image_size_for_aspect_ratio(aspect_ratio)
|
||||
duration = int(artifacts.get("duration") or 5)
|
||||
@@ -5212,8 +5251,8 @@ async def internal_ai_video_render(
|
||||
{
|
||||
"drama_id": drama_id,
|
||||
"prompt": video_prompt,
|
||||
"provider": video_provider,
|
||||
"model": video_model,
|
||||
"provider": dispatch_provider,
|
||||
"model": dispatch_model,
|
||||
"reference_mode": "first_last",
|
||||
"first_frame_url": first_frame_url,
|
||||
"last_frame_url": last_frame_url,
|
||||
@@ -5231,6 +5270,9 @@ async def internal_ai_video_render(
|
||||
"shot_index": storyboard.get("shot_index", idx),
|
||||
"title": storyboard.get("title", f"镜头{idx}"),
|
||||
"narration": storyboard.get("narration", ""),
|
||||
"video_provider": video_provider,
|
||||
"video_model": video_model,
|
||||
"video_provider_label": ai_video_provider_label(video_provider, video_model),
|
||||
"first_frame": first_ready,
|
||||
"last_frame": last_ready,
|
||||
"video": video_ready,
|
||||
@@ -5245,11 +5287,21 @@ async def internal_ai_video_render(
|
||||
artifacts={
|
||||
"huobao_drama_id": drama_id,
|
||||
"source_job_id": source_job_id,
|
||||
"video_provider": video_provider,
|
||||
"video_model": video_model,
|
||||
"video_dispatch_provider": dispatch_provider,
|
||||
"video_dispatch_model": dispatch_model,
|
||||
"video_provider_label": ai_video_provider_label(video_provider, video_model),
|
||||
},
|
||||
result={
|
||||
"huobao_drama": drama_payload,
|
||||
"rendered_scenes": rendered_scenes,
|
||||
"storyboards": storyboard_items,
|
||||
"video_provider": video_provider,
|
||||
"video_model": video_model,
|
||||
"video_dispatch_provider": dispatch_provider,
|
||||
"video_dispatch_model": dispatch_model,
|
||||
"video_provider_label": ai_video_provider_label(video_provider, video_model),
|
||||
},
|
||||
)
|
||||
return job_context_payload(updated)
|
||||
|
||||
@@ -5533,6 +5533,21 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None:
|
||||
).strip()
|
||||
if not brief:
|
||||
brief = f"请基于任务「{source_job.get('title') or source_job['id']}」输出一版适合短视频平台的 AI 视频。"
|
||||
source_artifacts = legacy.parse_job_artifacts(source_job)
|
||||
video_provider = str(
|
||||
requested_payload.get("video_provider")
|
||||
or requested_payload.get("videoProvider")
|
||||
or source_artifacts.get("video_provider")
|
||||
or "doubao"
|
||||
).strip() or "doubao"
|
||||
video_model = str(
|
||||
requested_payload.get("video_model")
|
||||
or requested_payload.get("videoModel")
|
||||
or source_artifacts.get("video_model")
|
||||
or ""
|
||||
).strip()
|
||||
if video_provider == "seedance2" and not video_model:
|
||||
video_model = "seedance-2.0-pro"
|
||||
job = await legacy.create_ai_video_job(
|
||||
legacy.AiVideoJobRequest(
|
||||
project_id=project["id"],
|
||||
@@ -5544,16 +5559,20 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None:
|
||||
style=str(requested_payload.get("style") or "realistic"),
|
||||
shots=max(1, min(int(requested_payload.get("shots") or 4), 12)),
|
||||
duration=max(3, min(int(requested_payload.get("duration") or 5), 12)),
|
||||
video_provider=video_provider,
|
||||
video_model=video_model,
|
||||
),
|
||||
account,
|
||||
)
|
||||
return {
|
||||
"title": "OneLiner 已创建 AI 视频任务",
|
||||
"summary": f"已基于「{source_job.get('title') or source_job['id']}」创建 AI 视频任务。",
|
||||
"summary": f"已基于「{source_job.get('title') or source_job['id']}」创建 AI 视频任务,当前引擎 {('Seedance 2.0' if video_provider == 'seedance2' else '默认视频引擎')}。",
|
||||
"payload": {
|
||||
"job": job,
|
||||
"source_job": legacy.job_payload(source_job),
|
||||
"brief": brief,
|
||||
"video_provider": video_provider,
|
||||
"video_model": video_model,
|
||||
},
|
||||
"recommended_action": _recommended_action(
|
||||
"open-job-detail",
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# Seedance 2.0 AI Video Execution Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在不打断现有 AI 视频链的前提下,新增 `Seedance 2.0` 兼容入口,并把前端、主 Agent、执行结果和回归链统一到可扩展的视频引擎模型。
|
||||
|
||||
**Architecture:** 保留现有 `/v2/pipelines/ai-video` 业务入口不变,把视频生成从“固定 huobao/doubao 默认值”升级为“provider-aware execution”。前端新增视频引擎选择与 Seedance 定向字段,后端统一把 provider/version/meta 写进 job artifacts 和结果,主 Agent 透传 provider 进入执行链并在结果卡中可追溯。
|
||||
|
||||
**Tech Stack:** FastAPI, SQLite-backed job records, existing `HuobaoDramaClient` integration layer, vanilla JS SPA, node test runner, unittest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端视频引擎兼容层
|
||||
|
||||
**Files:**
|
||||
- Modify: `collector-service/app/core_main.py`
|
||||
- Modify: `collector-service/app/integrations.py`
|
||||
- Test: `tests/test_production_baseline.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁定 `video_provider` 会进入 AI 视频 job artifacts 和执行结果**
|
||||
|
||||
```python
|
||||
def test_ai_video_request_persists_video_provider(client, approved_headers):
|
||||
response = client.post(
|
||||
"/v2/pipelines/ai-video",
|
||||
json={
|
||||
"project_id": "project_demo",
|
||||
"assistant_id": "",
|
||||
"knowledge_base_id": "kb_demo",
|
||||
"title": "Seedance test",
|
||||
"brief": "创业者口播视频",
|
||||
"style": "realistic",
|
||||
"shots": 4,
|
||||
"duration": 5,
|
||||
"video_provider": "seedance2",
|
||||
"video_model": "seedance-2.0-pro",
|
||||
},
|
||||
headers=approved_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["artifacts"]["video_provider"] == "seedance2"
|
||||
assert payload["artifacts"]["video_model"] == "seedance-2.0-pro"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认先失败或至少缺少 `seedance2` 兼容断言**
|
||||
|
||||
Run: `python3 -m unittest tests.test_production_baseline -v`
|
||||
Expected: 现有测试不覆盖 `seedance2`,新增断言失败或行为缺失。
|
||||
|
||||
- [ ] **Step 3: 实现 provider 兼容层**
|
||||
|
||||
```python
|
||||
def normalize_ai_video_provider(provider: str, model: str) -> tuple[str, str]:
|
||||
normalized = str(provider or "").strip().lower()
|
||||
if normalized in {"seedance", "seedance2", "seedance-2.0", "seedance_2_0"}:
|
||||
return "seedance2", model or "seedance-2.0-pro"
|
||||
if normalized in {"doubao", "jimeng", "huobao"}:
|
||||
return "doubao", model
|
||||
return normalized or "doubao", model
|
||||
```
|
||||
|
||||
```python
|
||||
video_provider, video_model = normalize_ai_video_provider(request.video_provider, request.video_model)
|
||||
```
|
||||
|
||||
```python
|
||||
"video_provider": video_provider,
|
||||
"video_model": video_model,
|
||||
"video_provider_label": "Seedance 2.0" if video_provider == "seedance2" else video_provider,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在内部渲染步骤里把 provider 元信息回写进结果**
|
||||
|
||||
```python
|
||||
"video_provider": video_provider,
|
||||
"video_model": video_model,
|
||||
"video_provider_label": "Seedance 2.0" if video_provider == "seedance2" else video_provider,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 再跑后端测试确认通过**
|
||||
|
||||
Run: `python3 -m unittest tests.test_production_baseline -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 提交这一批**
|
||||
|
||||
```bash
|
||||
git add collector-service/app/core_main.py collector-service/app/integrations.py tests/test_production_baseline.py
|
||||
git commit -m "feat: add seedance2 ai video provider support"
|
||||
```
|
||||
|
||||
### Task 2: 前端 AI 视频任务表单与结果展示
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/storyforge-web-v4/assets/app.js`
|
||||
- Test: `web/storyforge-web-v4/tests/workbench-pages.test.mjs`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁定 AI 视频表单出现视频引擎选择和 Seedance 定向字段**
|
||||
|
||||
```js
|
||||
test("ai video action exposes seedance 2.0 provider controls", () => {
|
||||
const aiVideo = extractBetween(APP, "function openCreateAiVideoAction(defaults = {})", "function openCreateRealCutAction(defaults = {})");
|
||||
assert.match(aiVideo, /videoProvider/);
|
||||
assert.match(aiVideo, /Seedance 2.0/);
|
||||
assert.match(aiVideo, /cameraMotion/);
|
||||
assert.match(aiVideo, /visualStyle/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认先失败**
|
||||
|
||||
Run: `node --test web/storyforge-web-v4/tests/workbench-pages.test.mjs`
|
||||
Expected: FAIL because current form has no Seedance fields.
|
||||
|
||||
- [ ] **Step 3: 实现前端表单扩展**
|
||||
|
||||
```js
|
||||
{ name: "videoProvider", label: "视频引擎", type: "select", value: defaults.videoProvider || "doubao", options: [
|
||||
{ value: "doubao", label: "当前默认" },
|
||||
{ value: "seedance2", label: "Seedance 2.0" }
|
||||
] }
|
||||
```
|
||||
|
||||
```js
|
||||
{ name: "cameraMotion", label: "镜头运动", value: defaults.cameraMotion || "", placeholder: "例如:慢推近、环绕、手持跟拍" },
|
||||
{ name: "visualStyle", label: "画面风格", value: defaults.visualStyle || "", placeholder: "例如:电影感、高反差、创业访谈质感" },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交时把 provider 和 Seedance 定向字段发给后端**
|
||||
|
||||
```js
|
||||
video_provider: values.videoProvider || "doubao",
|
||||
video_model: values.videoProvider === "seedance2" ? "seedance-2.0-pro" : "",
|
||||
camera_motion: values.cameraMotion || "",
|
||||
visual_style: values.visualStyle || "",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 job 详情/结果卡里显示视频引擎**
|
||||
|
||||
```js
|
||||
<span class="tag blue">${escapeHtml(job.artifacts?.video_provider_label || job.artifacts?.video_provider || "默认引擎")}</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 跑前端测试确认通过**
|
||||
|
||||
Run: `node --test web/storyforge-web-v4/tests/workbench-pages.test.mjs`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: 提交这一批**
|
||||
|
||||
```bash
|
||||
git add web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/tests/workbench-pages.test.mjs
|
||||
git commit -m "feat: add seedance2 controls to ai video flow"
|
||||
```
|
||||
|
||||
### Task 3: 主 Agent 透传与执行追溯
|
||||
|
||||
**Files:**
|
||||
- Modify: `collector-service/app/oneliner_features.py`
|
||||
- Test: `tests/test_main_agent_governance.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁定主 Agent 创建 AI 视频任务时会透传 provider**
|
||||
|
||||
```python
|
||||
def test_oneliner_ai_video_run_preserves_video_provider(client, approved_headers):
|
||||
response = client.post(
|
||||
"/v2/oneliner/actions/execute",
|
||||
json={
|
||||
"action_key": "create-ai-video",
|
||||
"project_id": "project_demo",
|
||||
"platform": "douyin",
|
||||
"payload": {
|
||||
"video_provider": "seedance2",
|
||||
"video_model": "seedance-2.0-pro",
|
||||
},
|
||||
},
|
||||
headers=approved_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["payload"]["job"]["artifacts"]["video_provider"] == "seedance2"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认先失败**
|
||||
|
||||
Run: `python3 -m unittest tests.test_main_agent_governance -v`
|
||||
Expected: FAIL because provider not yet propagated.
|
||||
|
||||
- [ ] **Step 3: 实现主 Agent 透传**
|
||||
|
||||
```python
|
||||
video_provider=str(requested_payload.get("video_provider") or requested_payload.get("videoProvider") or "doubao"),
|
||||
video_model=str(requested_payload.get("video_model") or requested_payload.get("videoModel") or ""),
|
||||
```
|
||||
|
||||
```python
|
||||
"video_provider": job.get("artifacts", {}).get("video_provider", ""),
|
||||
"video_provider_label": job.get("artifacts", {}).get("video_provider_label", ""),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 结果卡补充视频引擎标签**
|
||||
|
||||
```python
|
||||
result_sections.append({
|
||||
"title": "视频引擎",
|
||||
"items": [{"label": "本轮视频引擎", "value": provider_label}],
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑后端治理测试确认通过**
|
||||
|
||||
Run: `python3 -m unittest tests.test_main_agent_governance -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 提交这一批**
|
||||
|
||||
```bash
|
||||
git add collector-service/app/oneliner_features.py tests/test_main_agent_governance.py
|
||||
git commit -m "feat: propagate seedance2 through main agent"
|
||||
```
|
||||
|
||||
### Task 4: 整体回归、部署与版本记录
|
||||
|
||||
**Files:**
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: 更新版本记录**
|
||||
|
||||
```markdown
|
||||
### Seedance 2.0 生视频兼容接入
|
||||
|
||||
- AI 视频链新增 `Seedance 2.0` 视频引擎入口,并统一把 provider/version 回写到任务结果与主 Agent 执行卡。
|
||||
- 前端 AI 视频创建表单新增视频引擎选择与 Seedance 定向参数。
|
||||
- 主 Agent 创建 AI 视频任务时会透传视频引擎,结果页可追溯本轮使用的引擎。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑全量回归**
|
||||
|
||||
Run: `python3 -m unittest tests.test_main_agent_governance tests.test_platform_contracts tests.test_production_baseline -v`
|
||||
Expected: PASS
|
||||
|
||||
Run: `node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs web/storyforge-web-v4/tests/workbench-pages.test.mjs`
|
||||
Expected: PASS
|
||||
|
||||
Run: `bash scripts/check_repo_baseline.sh`
|
||||
Expected: `baseline checks passed`
|
||||
|
||||
- [ ] **Step 3: 发布到 NAS 并跑 smoke**
|
||||
|
||||
Run: `FNOS_PASSWORD='Admin_yqs_asd20260101.' bash /Users/kris/code/StoryForge-gitea/scripts/deploy_fnos_storyforge_web.sh`
|
||||
Expected: `fnOS StoryForge Web V4 ready: http://192.168.31.188:19192/`
|
||||
|
||||
Run: `bash /Users/kris/code/StoryForge-gitea/scripts/smoke_fnos_storyforge_lan.sh`
|
||||
Expected: `fnOS lan smoke passed`
|
||||
|
||||
- [ ] **Step 4: 提交汇总版本**
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit -m "chore: record seedance2 ai video rollout"
|
||||
```
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -873,6 +874,66 @@ class MainAgentGovernanceTests(unittest.TestCase):
|
||||
self.assertEqual(copy_payload["recommended_action"]["screen"], "playbook")
|
||||
self.assertEqual(copy_payload["recommended_action"]["platform"], "douyin")
|
||||
|
||||
def test_create_ai_video_action_passes_provider_and_model_through_oneliner(self) -> None:
|
||||
self._insert_completed_job(job_id="job_ai_video_source", title="AI Video Source Job")
|
||||
self._insert_assistant()
|
||||
|
||||
captured_request: dict[str, Any] = {}
|
||||
|
||||
async def fake_create_ai_video_job(request: Any, account: dict[str, Any]) -> dict[str, Any]:
|
||||
captured_request.update(
|
||||
{
|
||||
"project_id": request.project_id,
|
||||
"assistant_id": request.assistant_id,
|
||||
"source_job_id": request.source_job_id,
|
||||
"title": request.title,
|
||||
"brief": request.brief,
|
||||
"style": request.style,
|
||||
"shots": request.shots,
|
||||
"duration": request.duration,
|
||||
"video_provider": request.video_provider,
|
||||
"video_model": request.video_model,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"id": "job_ai_video_seedance",
|
||||
"title": request.title,
|
||||
"artifacts": {
|
||||
"source_job_id": request.source_job_id,
|
||||
"video_provider": request.video_provider,
|
||||
"video_model": request.video_model,
|
||||
},
|
||||
}
|
||||
|
||||
with patch.object(self.core, "create_ai_video_job", new=AsyncMock(side_effect=fake_create_ai_video_job)):
|
||||
response = self.client.post(
|
||||
"/v2/oneliner/actions/execute",
|
||||
headers=self.ctx["member_headers"],
|
||||
json={
|
||||
"action_key": "create-ai-video",
|
||||
"project_id": self.ctx["project_id"],
|
||||
"platform": "douyin",
|
||||
"payload": {
|
||||
"source_job_id": "job_ai_video_source",
|
||||
"title": "Seedance 2.0 测试任务",
|
||||
"brief": "做一条节奏强、镜头推进明确的短视频。",
|
||||
"style": "cinematic",
|
||||
"shots": 5,
|
||||
"duration": 6,
|
||||
"video_provider": "seedance2",
|
||||
"video_model": "seedance-2.0-pro",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertEqual(captured_request["source_job_id"], "job_ai_video_source")
|
||||
self.assertEqual(captured_request["video_provider"], "seedance2")
|
||||
self.assertEqual(captured_request["video_model"], "seedance-2.0-pro")
|
||||
self.assertEqual(payload["payload"]["job"]["artifacts"]["video_provider"], "seedance2")
|
||||
self.assertEqual(payload["payload"]["job"]["artifacts"]["video_model"], "seedance-2.0-pro")
|
||||
|
||||
def test_platform_agent_routes_are_live(self) -> None:
|
||||
save_profile = self.client.put(
|
||||
"/v2/platform-agents/douyin/profile",
|
||||
|
||||
@@ -346,6 +346,50 @@ class ProductionBaselineTests(unittest.TestCase):
|
||||
self.assertIsNotNone(usage_row)
|
||||
self.assertEqual(text_job["status"], "queued")
|
||||
|
||||
now = self.db_module.utc_now()
|
||||
source_job_id = f"job_seedance_source_{ctx['project_id']}"
|
||||
self.core.db.execute(
|
||||
"""
|
||||
INSERT INTO jobs (
|
||||
id, user_id, project_id, parent_job_id, assistant_id, knowledge_base_id, content_source_id,
|
||||
source_type, line_type, workflow_key, orchestrator, provider_name, provider_task_id,
|
||||
source_url, title, language, status, transcript_text, style_summary, upload_status,
|
||||
error, artifacts_json, result_json, analysis_model_profile_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, '', ?, ?, NULL, 'text', 'analysis', 'analysis_pipeline', 'n8n', 'collector', '', '', ?, 'auto', 'completed', '', '', 'completed', '', '{}', '{\"summary\":\"done\"}', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
source_job_id,
|
||||
ctx["account_id"],
|
||||
ctx["project_id"],
|
||||
ctx["assistant_id"],
|
||||
ctx["kb_id"],
|
||||
"Seedance Source",
|
||||
ctx["model_id"],
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
ai_video_response = self.client.post(
|
||||
"/v2/pipelines/ai-video",
|
||||
headers=headers,
|
||||
json={
|
||||
"project_id": ctx["project_id"],
|
||||
"assistant_id": ctx["assistant_id"],
|
||||
"knowledge_base_id": ctx["kb_id"],
|
||||
"source_job_id": source_job_id,
|
||||
"title": "Seedance 2.0 视频",
|
||||
"brief": "做一条镜头推进感更强的 AI 视频。",
|
||||
"video_provider": "seedance2",
|
||||
"video_model": "",
|
||||
},
|
||||
)
|
||||
self.assertEqual(ai_video_response.status_code, 200, ai_video_response.text)
|
||||
ai_video_payload = ai_video_response.json()
|
||||
self.assertEqual(ai_video_payload["artifacts"]["video_provider"], "seedance2")
|
||||
self.assertEqual(ai_video_payload["artifacts"]["video_model"], "seedance-2.0-pro")
|
||||
self.assertEqual(ai_video_payload["artifacts"]["video_dispatch_provider"], "doubao")
|
||||
self.assertEqual(ai_video_payload["artifacts"]["video_dispatch_model"], "seedance-2.0-pro")
|
||||
|
||||
now = self.db_module.utc_now()
|
||||
failed_jobs = []
|
||||
for index in range(2):
|
||||
|
||||
@@ -10917,21 +10917,86 @@ function openCreateAiVideoAction(defaults = {}) {
|
||||
const assistant = getSelectedAssistant();
|
||||
const kb = getProjectKnowledgeBases(project.id)[0];
|
||||
const sourceJob = defaults.sourceJob || null;
|
||||
const defaultVideoProvider = String(
|
||||
defaults.videoProvider || defaults.video_provider || sourceJob?.artifacts?.video_provider || "doubao"
|
||||
).trim() || "doubao";
|
||||
const defaultVideoModel = String(
|
||||
defaults.videoModel || defaults.video_model || sourceJob?.artifacts?.video_model || ""
|
||||
).trim() || (defaultVideoProvider === "seedance2" ? "seedance-2.0-pro" : "");
|
||||
openActionModal({
|
||||
title: "创建 AI 视频任务",
|
||||
description: "输入 brief 后,直接触发 AI 视频链。",
|
||||
description: "输入 brief 后,直接触发 AI 视频链。需要更强镜头语言时,可以切到 Seedance 2.0。",
|
||||
submitLabel: "开始生产",
|
||||
fields: [
|
||||
{ name: "title", label: "任务标题", value: defaults.title || (sourceJob ? `${sourceJob.title} · AI 视频` : ""), placeholder: "例如:创业口播 AI 视频测试" },
|
||||
{ name: "brief", label: "视频 brief", type: "textarea", rows: 5, value: defaults.brief || getJobSeedBrief(sourceJob), placeholder: "写明主题、风格、镜头和目标受众" },
|
||||
{ name: "sourceJobId", label: "关联源任务", type: "select", value: defaults.sourceJobId || sourceJob?.id || "", options: [{ value: "", label: "不关联" }, ...getCompletedJobOptions()] },
|
||||
{
|
||||
name: "videoProvider",
|
||||
label: "视频引擎",
|
||||
type: "select",
|
||||
value: defaultVideoProvider,
|
||||
options: [
|
||||
{ value: "doubao", label: "当前默认引擎" },
|
||||
{ value: "seedance2", label: "Seedance 2.0" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "videoModel",
|
||||
label: "引擎模型",
|
||||
value: defaultVideoModel,
|
||||
placeholder: "例如:seedance-2.0-pro",
|
||||
},
|
||||
{ name: "style", label: "风格", value: defaults.style || "realistic" },
|
||||
{
|
||||
name: "aspectRatio",
|
||||
label: "画幅",
|
||||
type: "select",
|
||||
value: defaults.aspectRatio || defaults.aspect_ratio || sourceJob?.artifacts?.aspect_ratio || "9:16",
|
||||
options: [
|
||||
{ value: "9:16", label: "9:16 竖屏" },
|
||||
{ value: "16:9", label: "16:9 横屏" },
|
||||
{ value: "1:1", label: "1:1 方形" },
|
||||
],
|
||||
},
|
||||
{ name: "shots", label: "镜头数", type: "number", value: defaults.shots || 4, min: 1, max: 12 },
|
||||
{ name: "duration", label: "单镜头秒数", type: "number", value: defaults.duration || 5, min: 3, max: 12 }
|
||||
{ name: "duration", label: "单镜头秒数", type: "number", value: defaults.duration || 5, min: 3, max: 12 },
|
||||
{
|
||||
name: "cameraLanguage",
|
||||
label: "镜头语言",
|
||||
type: "textarea",
|
||||
rows: 3,
|
||||
value: defaults.cameraLanguage || defaults.camera_language || "",
|
||||
placeholder: "例如:开场推近,中段快速切换,结尾定格主卖点",
|
||||
},
|
||||
{
|
||||
name: "motionRhythm",
|
||||
label: "运动节奏",
|
||||
value: defaults.motionRhythm || defaults.motion_rhythm || "",
|
||||
placeholder: "例如:强节奏推进,镜头切换干净利落",
|
||||
},
|
||||
{
|
||||
name: "visualGuardrails",
|
||||
label: "风格约束",
|
||||
type: "textarea",
|
||||
rows: 3,
|
||||
value: defaults.visualGuardrails || defaults.visual_guardrails || "",
|
||||
placeholder: "例如:避免过暗,人物手部自然,保持真实商业质感",
|
||||
}
|
||||
],
|
||||
onSubmit: async (values) => {
|
||||
if (!values.title?.trim()) throw new Error("请填写任务标题");
|
||||
if (!values.brief?.trim()) throw new Error("请填写视频 brief");
|
||||
const normalizedProvider = String(values.videoProvider || "doubao").trim() || "doubao";
|
||||
const normalizedVideoModel = String(values.videoModel || "").trim() || (normalizedProvider === "seedance2" ? "seedance-2.0-pro" : "");
|
||||
const seedanceSuffix = normalizedProvider === "seedance2"
|
||||
? [
|
||||
values.cameraLanguage?.trim() ? `镜头语言:${values.cameraLanguage.trim()}` : "",
|
||||
values.motionRhythm?.trim() ? `运动节奏:${values.motionRhythm.trim()}` : "",
|
||||
values.visualGuardrails?.trim() ? `风格约束:${values.visualGuardrails.trim()}` : "",
|
||||
].filter(Boolean).join("\n")
|
||||
: "";
|
||||
const finalBrief = [values.brief.trim(), seedanceSuffix].filter(Boolean).join("\n\n");
|
||||
const job = await storyforgeFetch("/v2/pipelines/ai-video", {
|
||||
method: "POST",
|
||||
body: {
|
||||
@@ -10940,13 +11005,21 @@ function openCreateAiVideoAction(defaults = {}) {
|
||||
knowledge_base_id: kb?.id || "",
|
||||
source_job_id: values.sourceJobId || "",
|
||||
title: values.title.trim(),
|
||||
brief: values.brief.trim(),
|
||||
brief: finalBrief,
|
||||
style: values.style || "realistic",
|
||||
aspect_ratio: values.aspectRatio || "9:16",
|
||||
shots: Number(values.shots || 4),
|
||||
duration: Number(values.duration || 5)
|
||||
duration: Number(values.duration || 5),
|
||||
video_provider: values.videoProvider || "doubao",
|
||||
video_model: values.videoModel || "",
|
||||
}
|
||||
});
|
||||
rememberAction("AI 视频任务已创建", `已创建任务 ${job.title || job.id}。`, "blue", job);
|
||||
rememberAction(
|
||||
"AI 视频任务已创建",
|
||||
`已创建任务 ${job.title || job.id},引擎 ${normalizedProvider === "seedance2" ? "Seedance 2.0" : "当前默认引擎"}。`,
|
||||
"blue",
|
||||
job
|
||||
);
|
||||
await bootstrap();
|
||||
if (job?.id) {
|
||||
openJobDetailAction(job.id);
|
||||
|
||||
@@ -521,6 +521,18 @@ test("projects screen uses an adaptive project grid instead of a fixed three-col
|
||||
assert.match(CSS, /\.entity-card\.pad\s*\{[\s\S]*padding:\s*15px/);
|
||||
});
|
||||
|
||||
test("ai video action exposes seedance provider controls and sends provider metadata", () => {
|
||||
const source = extractBetween(APP, "function openCreateAiVideoAction(defaults = {})", "function openCreateRealCutAction(");
|
||||
assert.match(source, /视频引擎/);
|
||||
assert.match(source, /seedance2/);
|
||||
assert.match(source, /seedance-2\.0-pro/);
|
||||
assert.match(source, /cameraLanguage/);
|
||||
assert.match(source, /motionRhythm/);
|
||||
assert.match(source, /visualGuardrails/);
|
||||
assert.match(source, /video_provider:\s*values\.videoProvider/);
|
||||
assert.match(source, /video_model:\s*values\.videoModel/);
|
||||
});
|
||||
|
||||
test("mobile typography clamps subtitles and dense card copy on small screens", () => {
|
||||
assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.panel-subtitle\s*\{[\s\S]*-webkit-line-clamp:\s*2/);
|
||||
assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.task-item p,[\s\S]*\.review-card p\s*\{[\s\S]*-webkit-line-clamp:\s*3/);
|
||||
|
||||
Reference in New Issue
Block a user