feat: v0.14.2 推理接入点EP + 参考图片上限9张 + reEdit标签修复
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m34s

- 推理接入点:model字段优先使用EP接入点ID(ARK_ENDPOINT_SEEDANCE环境变量),无EP降级到模型ID
- 参考图片上限:提交时校验image类型不超过9张,超限返回友好中文提示
- 上传图片标签编号:接着已有素材编号,不再从1重新计数
- 轮询同步assetMentions:polling完成时同时更新references和assetMentions
- reEdit标签修复:用纯文本prompt重建标签,避免blob:URL失效导致图片标签丢失

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-29 01:43:35 +08:00
parent 6853b08fc9
commit 973a4f049d
7 changed files with 134 additions and 80 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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<Record<string, string>>,
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<Record<string, string>>) {
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<string, string>).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<string, string>).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<string, string>).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<string, string>) => {
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<string, string>, 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<GenerationState>((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,

View File

@ -135,12 +135,16 @@ export const useInputBarStore = create<InputBarState>((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<string, string>) => 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]++;
}