feat: v0.8.0 — Seedance API 全流程修复 + TOS 视频持久化
- 新增音频引用传递给 Seedance API - 视频生成完成后自动持久化到 TOS(永久 CDN URL) - 移除 ARK_API_KEY 硬编码默认值 - 前端渐进式轮询(10s/30s/60s)替代固定 3 分钟 - TOS 桶切换到 airdrama-media (cn-beijing) - K8s Secret 注入 TOS 密钥,CI/CD 同步更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
add3af7904
commit
32f0ee58f4
@ -64,6 +64,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
kubectl create secret generic video-backend-secrets \
|
kubectl create secret generic video-backend-secrets \
|
||||||
--from-literal=ARK_API_KEY=${{ secrets.ARK_API_KEY }} \
|
--from-literal=ARK_API_KEY=${{ secrets.ARK_API_KEY }} \
|
||||||
|
--from-literal=TOS_ACCESS_KEY=${{ secrets.TOS_ACCESS_KEY }} \
|
||||||
|
--from-literal=TOS_SECRET_KEY=${{ secrets.TOS_SECRET_KEY }} \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
- name: Apply K8s Manifests
|
- name: Apply K8s Manifests
|
||||||
|
|||||||
@ -314,9 +314,10 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
|
|||||||
| `SECRET_KEY` | Django secret key | Yes |
|
| `SECRET_KEY` | Django secret key | Yes |
|
||||||
| `TOS_ACCESS_KEY` | Volcano Engine TOS AccessKeyId | Yes (upload) |
|
| `TOS_ACCESS_KEY` | Volcano Engine TOS AccessKeyId | Yes (upload) |
|
||||||
| `TOS_SECRET_KEY` | Volcano Engine TOS SecretAccessKey | Yes (upload) |
|
| `TOS_SECRET_KEY` | Volcano Engine TOS SecretAccessKey | Yes (upload) |
|
||||||
| `TOS_BUCKET` | Volcano TOS bucket name (default: `video-huoshan`) | Yes (upload) |
|
| `TOS_BUCKET` | Volcano TOS bucket name (default: `airdrama-media`) | Yes (upload) |
|
||||||
| `TOS_ENDPOINT` | TOS endpoint URL (default: `https://tos-cn-guangzhou.volces.com`) | Yes (upload) |
|
| `TOS_ENDPOINT` | TOS endpoint URL (default: `https://tos-cn-beijing.volces.com`) | Yes (upload) |
|
||||||
| `TOS_REGION` | TOS region (default: `cn-guangzhou`) | Yes (upload) |
|
| `TOS_REGION` | TOS region (default: `cn-beijing`) | Yes (upload) |
|
||||||
|
| `TOS_CDN_DOMAIN` | TOS CDN domain for permanent URLs (default: `https://airdrama-media.tos-cn-beijing.volces.com`) | Yes (upload) |
|
||||||
| `ARK_API_KEY` | Volcano Engine ARK API key for Seedance | Yes (video gen) |
|
| `ARK_API_KEY` | Volcano Engine ARK API key for Seedance | Yes (video gen) |
|
||||||
| `ARK_BASE_URL` | ARK API base URL (default: `https://ark.cn-beijing.volces.com/api/v3`) | No |
|
| `ARK_BASE_URL` | ARK API base URL (default: `https://ark.cn-beijing.volces.com/api/v3`) | No |
|
||||||
|
|
||||||
@ -361,6 +362,8 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
|
|||||||
| 2026-03-13 | custom_exception_handler: 未处理异常返回 JSON 500 而非 HTML | Backend |
|
| 2026-03-13 | custom_exception_handler: 未处理异常返回 JSON 500 而非 HTML | Backend |
|
||||||
| 2026-03-13 | 前端轮询间隔从 5 秒改为 3 分钟 | Frontend |
|
| 2026-03-13 | 前端轮询间隔从 5 秒改为 3 分钟 | Frontend |
|
||||||
| 2026-03-13 | CLAUDE.md 增量开发指南更新为 agent-auto(替换 Autonomous Skill) | Documentation |
|
| 2026-03-13 | CLAUDE.md 增量开发指南更新为 agent-auto(替换 Autonomous Skill) | Documentation |
|
||||||
|
| 2026-03-15 | v0.8.0: 音频引用支持 + 视频 TOS 持久化 + 移除硬编码密钥 + 渐进式轮询 | Full stack |
|
||||||
|
| 2026-03-15 | TOS 桶切换到 airdrama-media (cn-beijing),K8s Secret 注入 TOS 密钥 | Infra |
|
||||||
|
|
||||||
### Phase 4 Details (2026-03-13)
|
### Phase 4 Details (2026-03-13)
|
||||||
|
|
||||||
|
|||||||
@ -230,6 +230,11 @@ def video_generate_view(request):
|
|||||||
if role:
|
if role:
|
||||||
item['role'] = role
|
item['role'] = role
|
||||||
content_items.append(item)
|
content_items.append(item)
|
||||||
|
elif ref_type == 'audio':
|
||||||
|
item = {'type': 'audio_url', 'audio_url': {'url': url}}
|
||||||
|
if role:
|
||||||
|
item['role'] = role
|
||||||
|
content_items.append(item)
|
||||||
|
|
||||||
prompt = serializer.validated_data['prompt']
|
prompt = serializer.validated_data['prompt']
|
||||||
mode = serializer.validated_data['mode']
|
mode = serializer.validated_data['mode']
|
||||||
@ -346,7 +351,13 @@ def video_task_detail_view(request, task_id):
|
|||||||
if new_status == 'completed':
|
if new_status == 'completed':
|
||||||
video_url = extract_video_url(ark_resp)
|
video_url = extract_video_url(ark_resp)
|
||||||
if video_url:
|
if video_url:
|
||||||
record.result_url = video_url
|
# Persist to TOS for permanent storage (Seedance URLs expire in 24h)
|
||||||
|
try:
|
||||||
|
from utils.tos_client import upload_from_url
|
||||||
|
record.result_url = upload_from_url(video_url, folder='results')
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Failed to persist video to TOS, using temporary URL')
|
||||||
|
record.result_url = video_url
|
||||||
elif new_status == 'failed':
|
elif new_status == 'failed':
|
||||||
error = ark_resp.get('error', {})
|
error = ark_resp.get('error', {})
|
||||||
record.error_message = (
|
record.error_message = (
|
||||||
|
|||||||
@ -152,18 +152,17 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# TOS (Volcano Engine Object Storage)
|
# TOS (Volcano Engine Object Storage)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
TOS_ACCESS_KEY = os.environ.get('TOS_ACCESS_KEY', 'AKLTYjVlOTQwOWFkZDY4NGNhMmFkZGRhODQzMTUwNmIxOTM')
|
TOS_ACCESS_KEY = os.environ.get('TOS_ACCESS_KEY', '')
|
||||||
TOS_SECRET_KEY = os.environ.get('TOS_SECRET_KEY', 'TlRFMU5EUmtNamxsWlRsaU5HSXdPV0l6TVRBeVpqWTNNR00xT1RZNE0yRQ==')
|
TOS_SECRET_KEY = os.environ.get('TOS_SECRET_KEY', '')
|
||||||
TOS_ENDPOINT = os.environ.get('TOS_ENDPOINT', 'https://tos-cn-guangzhou.volces.com')
|
TOS_ENDPOINT = os.environ.get('TOS_ENDPOINT', 'https://tos-cn-beijing.volces.com')
|
||||||
TOS_S3_ENDPOINT = os.environ.get('TOS_S3_ENDPOINT', 'https://tos-s3-cn-guangzhou.volces.com')
|
TOS_BUCKET = os.environ.get('TOS_BUCKET', 'airdrama-media')
|
||||||
TOS_BUCKET = os.environ.get('TOS_BUCKET', 'video-huoshan')
|
TOS_REGION = os.environ.get('TOS_REGION', 'cn-beijing')
|
||||||
TOS_REGION = os.environ.get('TOS_REGION', 'cn-guangzhou')
|
TOS_CDN_DOMAIN = os.environ.get('TOS_CDN_DOMAIN', 'https://airdrama-media.tos-cn-beijing.volces.com')
|
||||||
TOS_CDN_DOMAIN = os.environ.get('TOS_CDN_DOMAIN', 'https://video-huoshan.tos-cn-guangzhou.volces.com')
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Seedance API (Volcano Engine ARK)
|
# Seedance API (Volcano Engine ARK)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
ARK_API_KEY = os.environ.get('ARK_API_KEY', '846b6981-9954-4c58-bb39-63079393bdb8')
|
ARK_API_KEY = os.environ.get('ARK_API_KEY', '')
|
||||||
ARK_BASE_URL = os.environ.get('ARK_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3')
|
ARK_BASE_URL = os.environ.get('ARK_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3')
|
||||||
# Set to True when Seedance model is activated on ARK platform
|
# Set to True when Seedance model is activated on ARK platform
|
||||||
SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true'
|
SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true'
|
||||||
|
|||||||
@ -46,3 +46,26 @@ def upload_file(file_obj, folder='uploads'):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return f'{settings.TOS_CDN_DOMAIN}/{key}'
|
return f'{settings.TOS_CDN_DOMAIN}/{key}'
|
||||||
|
|
||||||
|
|
||||||
|
def upload_from_url(source_url, folder='results'):
|
||||||
|
"""Download a file from a URL and upload to TOS, return permanent CDN URL."""
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
resp = req.get(source_url, timeout=120, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
content = resp.content
|
||||||
|
|
||||||
|
content_type = resp.headers.get('Content-Type', 'video/mp4')
|
||||||
|
ext = 'mp4' # Seedance always returns mp4
|
||||||
|
|
||||||
|
key = f'{folder}/{uuid.uuid4().hex}.{ext}'
|
||||||
|
client = get_tos_client()
|
||||||
|
client.put_object(
|
||||||
|
bucket=settings.TOS_BUCKET,
|
||||||
|
key=key,
|
||||||
|
content=content,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return f'{settings.TOS_CDN_DOMAIN}/{key}'
|
||||||
|
|||||||
@ -4,6 +4,40 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-03-15 — v0.8.0: Seedance API 全流程修复 + TOS 视频持久化
|
||||||
|
|
||||||
|
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地端到端测试)
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
1. **音频引用支持** — 后端 content_items 构建新增 `audio` 类型分支,Seedance API 可接收音频参考素材
|
||||||
|
2. **视频 TOS 持久化** — Seedance 返回的临时 URL(24h 有效)自动下载并上传到 TOS,生成永久 CDN 链接;失败时降级使用临时 URL
|
||||||
|
3. **移除硬编码密钥** — ARK_API_KEY 默认值从测试 key 改为空字符串,避免生产环境误用
|
||||||
|
4. **渐进式轮询** — 前端轮询从固定 3 分钟改为渐进式:前 2 分钟每 10s → 2-5 分钟每 30s → 5 分钟后每 60s
|
||||||
|
5. **TOS 新桶配置** — 切换到独立桶 `airdrama-media`(cn-beijing),TOS 配置默认值更新
|
||||||
|
6. **K8s Secret 注入** — TOS_ACCESS_KEY / TOS_SECRET_KEY 通过 K8s Secret 注入,CI/CD 自动创建
|
||||||
|
7. **CI/CD 密钥同步** — deploy.yaml 新增 TOS 密钥到 kubectl secret 创建命令
|
||||||
|
|
||||||
|
### 变更文件
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/apps/generation/views.py` | 新增 audio content_item 分支 + 完成时 TOS 持久化逻辑 |
|
||||||
|
| `backend/utils/tos_client.py` | 新增 `upload_from_url()` — 从 URL 下载并上传到 TOS |
|
||||||
|
| `backend/config/settings.py` | TOS 桶/区域/端点改为 airdrama-media (cn-beijing);ARK_API_KEY 默认值清空 |
|
||||||
|
| `web/src/store/generation.ts` | setInterval → setTimeout 渐进式轮询 |
|
||||||
|
| `k8s/backend-deployment.yaml` | 新增 6 个 TOS 环境变量(AK/SK from Secret) |
|
||||||
|
| `.gitea/workflows/deploy.yaml` | kubectl secret 新增 TOS_ACCESS_KEY / TOS_SECRET_KEY |
|
||||||
|
|
||||||
|
### 触发原因
|
||||||
|
- Seedance API 文档审查发现:音频引用未传递、视频 URL 24h 过期、密钥硬编码
|
||||||
|
- 需要独立 TOS 桶存放生成视频(原桶为同事的阿里云 OSS)
|
||||||
|
|
||||||
|
### 备注
|
||||||
|
- 本地测试通过:文生视频 "一只猫在阳光下伸懒腰" → 87,300 tokens → 永久 TOS URL
|
||||||
|
- Seedance 2.0 定价:不含视频输入 46 元/百万 tokens,含视频输入 28 元/百万 tokens
|
||||||
|
- 资源包 5,000,000 tokens,约可生成 57 个 4 秒 720p 视频
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-03-13 — 异常上报体系优化 + 轮询策略调整
|
## 2026-03-13 — 异常上报体系优化 + 轮询策略调整
|
||||||
|
|
||||||
**状态**: ✅ 已完成 | **验收**: ✅ 通过
|
**状态**: ✅ 已完成 | **验收**: ✅ 通过
|
||||||
|
|||||||
@ -50,6 +50,25 @@ spec:
|
|||||||
value: "true"
|
value: "true"
|
||||||
- name: ENVIRONMENT
|
- name: ENVIRONMENT
|
||||||
value: "production"
|
value: "production"
|
||||||
|
# TOS (from Secret)
|
||||||
|
- name: TOS_ACCESS_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: video-backend-secrets
|
||||||
|
key: TOS_ACCESS_KEY
|
||||||
|
- name: TOS_SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: video-backend-secrets
|
||||||
|
key: TOS_SECRET_KEY
|
||||||
|
- name: TOS_BUCKET
|
||||||
|
value: "airdrama-media"
|
||||||
|
- name: TOS_ENDPOINT
|
||||||
|
value: "https://tos-cn-beijing.volces.com"
|
||||||
|
- name: TOS_REGION
|
||||||
|
value: "cn-beijing"
|
||||||
|
- name: TOS_CDN_DOMAIN
|
||||||
|
value: "https://airdrama-media.tos-cn-beijing.volces.com"
|
||||||
# Seedance API (from Secret)
|
# Seedance API (from Secret)
|
||||||
- name: ARK_API_KEY
|
- name: ARK_API_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
|
|||||||
@ -83,49 +83,64 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Active polling timers
|
// Active polling timers
|
||||||
const pollTimers = new Map<string, ReturnType<typeof setInterval>>();
|
const pollTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Progressive polling: 10s for first 2min, 30s for 2-5min, 60s after 5min
|
||||||
|
function getPollingInterval(startTime: number): number {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
if (elapsed < 2 * 60 * 1000) return 10 * 1000; // first 2 min: every 10s
|
||||||
|
if (elapsed < 5 * 60 * 1000) return 30 * 1000; // 2-5 min: every 30s
|
||||||
|
return 60 * 1000; // 5+ min: every 60s
|
||||||
|
}
|
||||||
|
|
||||||
function startPolling(taskId: string, frontendId: string) {
|
function startPolling(taskId: string, frontendId: string) {
|
||||||
if (pollTimers.has(frontendId)) return;
|
if (pollTimers.has(frontendId)) return;
|
||||||
|
|
||||||
const timer = setInterval(async () => {
|
const startTime = Date.now();
|
||||||
try {
|
|
||||||
const { data } = await videoApi.getTaskStatus(taskId);
|
|
||||||
const newStatus = mapStatus(data.status);
|
|
||||||
|
|
||||||
useGenerationStore.setState((s) => ({
|
function schedulePoll() {
|
||||||
tasks: s.tasks.map((t) =>
|
const timer = setTimeout(async () => {
|
||||||
t.id === frontendId
|
try {
|
||||||
? {
|
const { data } = await videoApi.getTaskStatus(taskId);
|
||||||
...t,
|
const newStatus = mapStatus(data.status);
|
||||||
status: newStatus,
|
|
||||||
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
|
|
||||||
resultUrl: data.result_url || t.resultUrl,
|
|
||||||
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
|
|
||||||
}
|
|
||||||
: t
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (newStatus === 'completed' || newStatus === 'failed') {
|
useGenerationStore.setState((s) => ({
|
||||||
clearInterval(timer);
|
tasks: s.tasks.map((t) =>
|
||||||
pollTimers.delete(frontendId);
|
t.id === frontendId
|
||||||
if (newStatus === 'completed') {
|
? {
|
||||||
useAuthStore.getState().fetchUserInfo();
|
...t,
|
||||||
|
status: newStatus,
|
||||||
|
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
|
||||||
|
resultUrl: data.result_url || t.resultUrl,
|
||||||
|
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (newStatus === 'completed' || newStatus === 'failed') {
|
||||||
|
pollTimers.delete(frontendId);
|
||||||
|
if (newStatus === 'completed') {
|
||||||
|
useAuthStore.getState().fetchUserInfo();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
schedulePoll(); // schedule next poll with progressive interval
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
schedulePoll(); // retry on error
|
||||||
}
|
}
|
||||||
} catch {
|
}, getPollingInterval(startTime));
|
||||||
// Silently continue polling on error
|
|
||||||
}
|
|
||||||
}, 3 * 60 * 1000); // 3 minutes
|
|
||||||
|
|
||||||
pollTimers.set(frontendId, timer);
|
pollTimers.set(frontendId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulePoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(frontendId: string) {
|
function stopPolling(frontendId: string) {
|
||||||
const timer = pollTimers.get(frontendId);
|
const timer = pollTimers.get(frontendId);
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearInterval(timer);
|
clearTimeout(timer);
|
||||||
pollTimers.delete(frontendId);
|
pollTimers.delete(frontendId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user