diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 8c13ab1..30c2294 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -64,6 +64,8 @@ jobs: run: | kubectl create secret generic video-backend-secrets \ --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 - - name: Apply K8s Manifests diff --git a/CLAUDE.md b/CLAUDE.md index 4bf1968..0307852 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -314,9 +314,10 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频 | `SECRET_KEY` | Django secret key | Yes | | `TOS_ACCESS_KEY` | Volcano Engine TOS AccessKeyId | Yes (upload) | | `TOS_SECRET_KEY` | Volcano Engine TOS SecretAccessKey | Yes (upload) | -| `TOS_BUCKET` | Volcano TOS bucket name (default: `video-huoshan`) | Yes (upload) | -| `TOS_ENDPOINT` | TOS endpoint URL (default: `https://tos-cn-guangzhou.volces.com`) | Yes (upload) | -| `TOS_REGION` | TOS region (default: `cn-guangzhou`) | Yes (upload) | +| `TOS_BUCKET` | Volcano TOS bucket name (default: `airdrama-media`) | Yes (upload) | +| `TOS_ENDPOINT` | TOS endpoint URL (default: `https://tos-cn-beijing.volces.com`) | 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_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 | 前端轮询间隔从 5 秒改为 3 分钟 | Frontend | | 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) diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 5ef88d0..2743ebb 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -230,6 +230,11 @@ def video_generate_view(request): if role: item['role'] = role 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'] mode = serializer.validated_data['mode'] @@ -346,7 +351,13 @@ def video_task_detail_view(request, task_id): if new_status == 'completed': video_url = extract_video_url(ark_resp) 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': error = ark_resp.get('error', {}) record.error_message = ( diff --git a/backend/config/settings.py b/backend/config/settings.py index 2863a60..5fd59d0 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -152,18 +152,17 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # ────────────────────────────────────────────── # TOS (Volcano Engine Object Storage) # ────────────────────────────────────────────── -TOS_ACCESS_KEY = os.environ.get('TOS_ACCESS_KEY', 'AKLTYjVlOTQwOWFkZDY4NGNhMmFkZGRhODQzMTUwNmIxOTM') -TOS_SECRET_KEY = os.environ.get('TOS_SECRET_KEY', 'TlRFMU5EUmtNamxsWlRsaU5HSXdPV0l6TVRBeVpqWTNNR00xT1RZNE0yRQ==') -TOS_ENDPOINT = os.environ.get('TOS_ENDPOINT', 'https://tos-cn-guangzhou.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', 'video-huoshan') -TOS_REGION = os.environ.get('TOS_REGION', 'cn-guangzhou') -TOS_CDN_DOMAIN = os.environ.get('TOS_CDN_DOMAIN', 'https://video-huoshan.tos-cn-guangzhou.volces.com') +TOS_ACCESS_KEY = os.environ.get('TOS_ACCESS_KEY', '') +TOS_SECRET_KEY = os.environ.get('TOS_SECRET_KEY', '') +TOS_ENDPOINT = os.environ.get('TOS_ENDPOINT', 'https://tos-cn-beijing.volces.com') +TOS_BUCKET = os.environ.get('TOS_BUCKET', 'airdrama-media') +TOS_REGION = os.environ.get('TOS_REGION', 'cn-beijing') +TOS_CDN_DOMAIN = os.environ.get('TOS_CDN_DOMAIN', 'https://airdrama-media.tos-cn-beijing.volces.com') # ────────────────────────────────────────────── # 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') # Set to True when Seedance model is activated on ARK platform SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true' diff --git a/backend/utils/tos_client.py b/backend/utils/tos_client.py index 8bc2d85..a0727ec 100644 --- a/backend/utils/tos_client.py +++ b/backend/utils/tos_client.py @@ -46,3 +46,26 @@ def upload_file(file_obj, folder='uploads'): ) 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}' diff --git a/docs/changelog.md b/docs/changelog.md index 606f926..6324817 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 — 异常上报体系优化 + 轮询策略调整 **状态**: ✅ 已完成 | **验收**: ✅ 通过 diff --git a/k8s/backend-deployment.yaml b/k8s/backend-deployment.yaml index faf8f78..e3fa8cf 100644 --- a/k8s/backend-deployment.yaml +++ b/k8s/backend-deployment.yaml @@ -50,6 +50,25 @@ spec: value: "true" - name: ENVIRONMENT 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) - name: ARK_API_KEY valueFrom: diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index 40a272e..c947bea 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -83,49 +83,64 @@ function backendToFrontend(bt: BackendTask): GenerationTask { } // Active polling timers -const pollTimers = new Map>(); +const pollTimers = new Map>(); + +// 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) { if (pollTimers.has(frontendId)) return; - const timer = setInterval(async () => { - try { - const { data } = await videoApi.getTaskStatus(taskId); - const newStatus = mapStatus(data.status); + const startTime = Date.now(); - useGenerationStore.setState((s) => ({ - tasks: s.tasks.map((t) => - t.id === frontendId - ? { - ...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 - ), - })); + function schedulePoll() { + const timer = setTimeout(async () => { + try { + const { data } = await videoApi.getTaskStatus(taskId); + const newStatus = mapStatus(data.status); - if (newStatus === 'completed' || newStatus === 'failed') { - clearInterval(timer); - pollTimers.delete(frontendId); - if (newStatus === 'completed') { - useAuthStore.getState().fetchUserInfo(); + useGenerationStore.setState((s) => ({ + tasks: s.tasks.map((t) => + t.id === frontendId + ? { + ...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 { - // Silently continue polling on error - } - }, 3 * 60 * 1000); // 3 minutes + }, getPollingInterval(startTime)); - pollTimers.set(frontendId, timer); + pollTimers.set(frontendId, timer); + } + + schedulePoll(); } function stopPolling(frontendId: string) { const timer = pollTimers.get(frontendId); if (timer) { - clearInterval(timer); + clearTimeout(timer); pollTimers.delete(frontendId); } }