feat: v0.14.2 推理接入点EP + 参考图片上限9张 + reEdit标签修复
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m34s
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:
parent
6853b08fc9
commit
973a4f049d
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]++;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user