diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index fa3f40e..18b9734 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from django.contrib.auth import get_user_model from django.utils import timezone from django.db import transaction -from django.db.models import Sum, Q, F, Count, Exists, OuterRef +from django.db.models import Sum, Q, F, Count, Exists, OuterRef, Case, When, Value from django.db.models.functions import TruncDate from django.db.utils import OperationalError as DbOperationalError from datetime import timedelta @@ -269,12 +269,51 @@ def video_generate_view(request): # 构建参考素材 references = request.data.get('references', []) + # 火山限制最多 9 张参考图片 + image_count = sum(1 for r in references if r.get('type', 'image') == 'image') + if image_count > 9: + return Response({ + 'error': 'too_many_references', + 'message': f'参考图片最多 9 张,当前 {image_count} 张,请减少后重试', + }, status=status.HTTP_400_BAD_REQUEST) + reference_snapshots = [] content_items = [] seen_urls = set() # 去重:同一个素材只引用一次 + _asset_cache = {} # group_id → resolved_url,避免同一素材组重复查询 from .models import Asset as AssetModel + def _resolve_asset_group(gid, lbl): + """查询本地 DB + 必要时刷新火山状态,返回 Asset://xxx 或原始 asset:// URL。""" + asset = AssetModel.objects.filter( + group_id=gid, status__in=['active', 'processing'] + ).order_by( + Case(When(status='active', then=0), default=1) + ).first() + if not asset or not asset.remote_asset_id: + logger.warning('No asset found for group %s (label=%s)', gid, lbl) + return f'asset://group-{gid}' + # 本地 processing → 实时查火山刷新 + if asset.status == 'processing': + result, _ = _assets_api_call(assets_client.get_asset, asset.remote_asset_id) + if result and result.get('Status') == 'Active': + asset.status = 'active' + asset.url = result.get('Url', asset.url) + asset.save(update_fields=['status', 'url']) + logger.info('Asset %s refreshed to active from Volcano', asset.remote_asset_id) + else: + logger.warning('Asset %s still processing on Volcano', asset.remote_asset_id) + return f'asset://group-{gid}' + aid = asset.remote_asset_id + if aid.startswith('asset-'): + aid = 'Asset-' + aid[6:] + resolved = f'Asset://{aid}' + logger.info('Asset resolved: group=%s -> %s', gid, resolved) + return resolved + + from utils import assets_client + for ref in references: url = ref.get('url', '') original_url = url # 保留原始 URL 用于 reference_snapshots @@ -299,27 +338,29 @@ def video_generate_view(request): if url.startswith('asset://group-'): try: group_id = int(url.replace('asset://group-', '')) - first_asset = AssetModel.objects.filter( - group_id=group_id, status='active' - ).first() - if first_asset and first_asset.remote_asset_id: - aid = first_asset.remote_asset_id - if aid.startswith('asset-'): - aid = 'Asset-' + aid[6:] - resolved_url = f'Asset://{aid}' + # 跨迭代缓存:同一 group_id 不重复查询/刷新 + if group_id in _asset_cache: + resolved_url = _asset_cache[group_id] else: - logger.warning('No active asset found for group %s', group_id) - except (ValueError, Exception) as e: + resolved_url = _resolve_asset_group(group_id, label) + _asset_cache[group_id] = resolved_url + except Exception as e: logger.warning('Failed to resolve asset group URL %s: %s', url, e) - # 未解析成功的 asset URL 不发给火山 API(会导致 InvalidParameter) + # 未解析成功的 asset URL → 返回明确错误,不再静默跳过 if resolved_url.startswith('asset://'): - logger.warning('Skipping unresolved asset URL: %s', resolved_url) - continue + logger.error('Unresolved asset URL: %s (label=%s)', resolved_url, label) + return Response({ + 'error': 'asset_not_ready', + 'message': f'素材「{label}」尚未就绪,请在素材库中确认状态为"可用"后重试', + }, status=status.HTTP_400_BAD_REQUEST) if ref_type == 'image': item = {'type': 'image_url', 'image_url': {'url': resolved_url}} - if role: + # API 文档要求:参考图模式下所有图片的 role 必须为 reference_image + if mode == 'universal': + item['role'] = 'reference_image' + elif role: item['role'] = role content_items.append(item) elif ref_type == 'video': @@ -333,6 +374,8 @@ def video_generate_view(request): item['role'] = role content_items.append(item) + logger.info('Video generate: %d content_items built (prompt=%s...)', len(content_items), prompt[:60]) + # 冻结(不扣余额) record = GenerationRecord.objects.create( user=user, diff --git a/backend/config/settings.py b/backend/config/settings.py index 58456fc..1d73838 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -226,6 +226,9 @@ TOS_CDN_DOMAIN = os.environ.get('TOS_CDN_DOMAIN', 'https://airdrama-media.tos-cn # ────────────────────────────────────────────── 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') +# 推理接入点 ID(优先使用,为空时降级到模型 ID) +ARK_ENDPOINT_SEEDANCE = os.environ.get('ARK_ENDPOINT_SEEDANCE', '') +ARK_ENDPOINT_SEEDANCE_FAST = os.environ.get('ARK_ENDPOINT_SEEDANCE_FAST', '') # Set to True when Seedance model is activated on ARK platform SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true' # Set to True to enable the Assets API (virtual avatar library) diff --git a/backend/utils/airdrama_client.py b/backend/utils/airdrama_client.py index 60756c3..6eeaf6a 100644 --- a/backend/utils/airdrama_client.py +++ b/backend/utils/airdrama_client.py @@ -68,6 +68,17 @@ MODEL_MAP = { 'seedance_2.0_fast': 'doubao-seedance-2-0-fast-260128', } +# 推理接入点优先:有 EP 用 EP,没有降级到模型 ID +def _resolve_model(model): + ep_map = { + 'seedance_2.0': settings.ARK_ENDPOINT_SEEDANCE, + 'seedance_2.0_fast': settings.ARK_ENDPOINT_SEEDANCE_FAST, + } + ep = ep_map.get(model, '') + if ep: + return ep + return MODEL_MAP.get(model, model) + def _headers(): return { @@ -100,7 +111,7 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, content.extend(content_items) payload = { - 'model': MODEL_MAP.get(model, model), + 'model': _resolve_model(model), 'content': content, 'generate_audio': generate_audio, 'ratio': aspect_ratio, @@ -115,6 +126,10 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, import logging logger = logging.getLogger(__name__) logger.info('AirDrama API payload: %s', {k: v for k, v in payload.items() if k != 'content'}) + # 记录 content 中的非文本项,方便排查素材引用问题 + media_items = [ci for ci in content if ci.get('type') != 'text'] + if media_items: + logger.info('AirDrama content media items (%d): %s', len(media_items), media_items) resp = requests.post(url, json=payload, headers=_headers(), timeout=60) if resp.status_code != 200: diff --git a/k8s/backend-deployment.yaml b/k8s/backend-deployment.yaml index b5d4fcd..010a72c 100644 --- a/k8s/backend-deployment.yaml +++ b/k8s/backend-deployment.yaml @@ -92,6 +92,8 @@ spec: secretKeyRef: name: video-backend-secrets key: ARK_API_KEY + - name: ARK_ENDPOINT_SEEDANCE + value: "ep-m-20260315211214-z9dp6" - name: SEEDANCE_ENABLED value: "true" - name: ASSETS_API_ENABLED diff --git a/k8s/celery-deployment.yaml b/k8s/celery-deployment.yaml index f7de818..71bdd7b 100644 --- a/k8s/celery-deployment.yaml +++ b/k8s/celery-deployment.yaml @@ -81,6 +81,10 @@ spec: secretKeyRef: name: video-backend-secrets key: ARK_API_KEY + - name: ARK_ENDPOINT_SEEDANCE + value: "ep-m-20260315211214-z9dp6" + - name: SEEDANCE_ENABLED + value: "true" resources: requests: memory: "128Mi" diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index d22b06e..65b03ea 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -54,49 +54,46 @@ function mapProgress(backendStatus: string): number { return 50; } +/** Check if a URL is an asset library reference (case-insensitive protocol). */ +function isAssetUrl(url: string): boolean { + return url.startsWith('asset://') || url.startsWith('Asset://'); +} + +/** Build ReferenceSnapshot[] from raw reference_urls, excluding asset refs. */ +function buildReferenceSnapshots( + refs: Array>, + taskId: string, +): ReferenceSnapshot[] { + return refs + .filter((ref) => { + const url = ref.url || ''; + return !isAssetUrl(url) && url.trim() !== ''; + }) + .map((ref, i) => ({ + id: `ref_${taskId}_${i}`, + type: (ref.type || 'image') as 'image' | 'video' | 'audio', + previewUrl: ref.url || '', + label: ref.label || `素材${i + 1}`, + role: ref.role, + })); +} + +/** Extract asset mention metadata from raw reference_urls. */ +function buildAssetMentions(refs: Array>) { + return refs + .filter((ref) => isAssetUrl(ref.url || '')) + .map((ref) => { + const url = ref.url || ''; + const groupId = url.startsWith('asset://group-') ? url.replace('asset://group-', '') : ''; + return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '' }; + }); +} + // Convert a BackendTask to a frontend GenerationTask function backendToFrontend(bt: BackendTask): GenerationTask { const allRefs = bt.reference_urls || []; - - // 普通引用(参考图/视频/音频)— 可显示的缩略图 - const references: ReferenceSnapshot[] = allRefs - .filter((ref) => { - const url = ref.url || ''; - const thumbUrl = (ref as Record).thumb_url || ''; - // 保留有真实 URL 或有缩略图的引用 - if (thumbUrl) return true; - if (!url || url.trim() === '') return false; - if (url.startsWith('asset://') || url.startsWith('Asset://')) return false; - return true; - }) - .map((ref, i) => { - const url = ref.url || ''; - const thumbUrl = (ref as Record).thumb_url || ''; - // 素材库引用用 thumb_url,普通上传用 url - const displayUrl = (url.startsWith('asset://') || url.startsWith('Asset://')) ? thumbUrl : url; - return { - id: `ref_${bt.task_id}_${i}`, - type: (ref.type || 'image') as 'image' | 'video', - previewUrl: displayUrl, - label: ref.label || `素材${i + 1}`, - role: ref.role, - }; - }); - - // Asset 引用 — 仅用于 reEdit/regenerate 重建 mention span - const assetMentions = allRefs - .filter((ref) => { - const url = ref.url || ''; - return url.startsWith('asset://') || url.startsWith('Asset://'); - }) - .map((ref) => { - const url = ref.url || ''; - let groupId = ''; - if (url.startsWith('asset://group-')) { - groupId = url.replace('asset://group-', ''); - } - return { groupId, label: ref.label || '', thumbUrl: (ref as Record).thumb_url || '' }; - }); + const references = buildReferenceSnapshots(allRefs, bt.task_id); + const assetMentions = buildAssetMentions(allRefs); return { id: `backend_${bt.task_id}`, @@ -169,28 +166,10 @@ function startPolling(taskId: string, frontendId: string) { const { data } = await videoApi.getTaskStatus(taskId); const newStatus = mapStatus(data.status); - // Parse references from polling response (includes thumb_url for asset refs) - const pollRefs: ReferenceSnapshot[] = (data.reference_urls || []) - .filter((ref: Record) => { - const url = ref.url || ''; - const thumbUrl = ref.thumb_url || ''; - if (thumbUrl) return true; - if (!url || url.trim() === '') return false; - if (url.startsWith('asset://') || url.startsWith('Asset://')) return false; - return true; - }) - .map((ref: Record, i: number) => { - const url = ref.url || ''; - const thumbUrl = ref.thumb_url || ''; - const displayUrl = (url.startsWith('asset://') || url.startsWith('Asset://')) ? thumbUrl : url; - return { - id: `ref_${taskId}_${i}`, - type: (ref.type || 'image') as 'image' | 'video' | 'audio', - previewUrl: displayUrl, - label: ref.label || `素材${i + 1}`, - role: ref.role, - }; - }); + // Parse references from polling response + const pollAllRefs = data.reference_urls || []; + const pollRefs = buildReferenceSnapshots(pollAllRefs, taskId); + const pollAssetMentions = buildAssetMentions(pollAllRefs); useGenerationStore.setState((s) => ({ tasks: s.tasks.map((t) => @@ -205,6 +184,7 @@ function startPolling(taskId: string, frontendId: string) { costAmount: data.cost_amount ?? t.costAmount, seed: data.seed ?? t.seed, references: pollRefs.length > 0 ? pollRefs : t.references, + assetMentions: pollAssetMentions.length > 0 ? pollAssetMentions : t.assetMentions, } : t ), @@ -596,11 +576,14 @@ export const useGenerationStore = create((set, get) => ({ label: r.label, tosUrl: r.previewUrl, })); + + // Always use plain text prompt for reEdit — let PromptInput's rebuildMentionSpans + // reconstruct tags from references + assetMentions (avoids dead blob: URLs) const taskSeed = task.seed ?? -1; const currentSeedEnabled = useInputBarStore.getState().seedEnabled; useInputBarStore.setState({ prompt: task.prompt, - editorHtml: task.editorHtml || task.prompt, + editorHtml: task.prompt, aspectRatio: task.aspectRatio, duration: task.duration, references, diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts index 9f70700..05066a0 100644 --- a/web/src/store/inputBar.ts +++ b/web/src/store/inputBar.ts @@ -135,12 +135,16 @@ export const useInputBarStore = create((set, get) => ({ fileCounter++; const labelPrefix = type === 'video' ? '视频' : type === 'audio' ? '音频' : '图片'; + // 编号接着已有的同类型素材(包括 @ 引用的 assetMentions) + const existingSameType = state.references.filter(r => r.type === type).length + + newRefs.filter(r => r.type === type).length + + (state.assetMentions || []).filter((m: Record) => m.type === type).length; newRefs.push({ id: `ref_${fileCounter}`, file, type, previewUrl: type === 'audio' ? '' : URL.createObjectURL(file), - label: `${labelPrefix}${fileCounter}`, + label: `${labelPrefix}${existingSameType + 1}`, }); counts[type]++; }