v0.18.0 素材库多类型支持 + @ 引用改为单素材

对齐火山 API 文档(Asset URI 小写、HEIC/HEIF、DeleteAsset)
素材库支持视频/音频上传(按类型分三区显示、前端校验、拖拽上传)
@ 引用从素材组改为单个素材(搜索返回具体素材、即时数量/时长检查)
ffmpeg 视频封面帧提取 + 音频时长读取(Celery 异步)
生产级安全修复(跨团队校验、异常信息脱敏、下载大小限制)
This commit is contained in:
seaislee1209 2026-04-04 17:36:35 +08:00
parent 9bca1bc20f
commit da9a1413c3
17 changed files with 662 additions and 280 deletions

View File

@ -11,6 +11,7 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debia
gcc \
default-libmysqlclient-dev \
pkg-config \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Python dependencies

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.29 on 2026-04-04 09:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0017_add_asset_type'),
]
operations = [
migrations.AddField(
model_name='asset',
name='duration',
field=models.FloatField(default=0, verbose_name='时长(秒)'),
),
migrations.AddField(
model_name='asset',
name='thumbnail_url',
field=models.CharField(blank=True, default='', max_length=1000, verbose_name='缩略图URL'),
),
migrations.AddField(
model_name='generationrecord',
name='thumbnail_url',
field=models.CharField(blank=True, default='', max_length=1000, verbose_name='视频缩略图URL'),
),
]

View File

@ -42,6 +42,7 @@ class GenerationRecord(models.Model):
resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='视频缩略图URL')
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息')
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
@ -156,6 +157,8 @@ class Asset(models.Model):
name = models.CharField(max_length=100, default='', verbose_name='素材名称')
url = models.CharField(max_length=1000, blank=True, default='', verbose_name='素材URL')
asset_type = models.CharField(max_length=10, choices=ASSET_TYPE_CHOICES, default='Image', verbose_name='素材类型')
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
duration = models.FloatField(default=0, verbose_name='时长(秒)')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')

View File

@ -64,7 +64,7 @@ def poll_video_task(self, record_id):
record.completed_at = timezone.now()
record.save(update_fields=[
'status', 'result_url', 'error_message', 'raw_error',
'status', 'result_url', 'thumbnail_url', 'error_message', 'raw_error',
'seed', 'completed_at',
])
@ -87,6 +87,16 @@ def _handle_completed(record, ark_resp):
logger.exception('poll_video_task: failed to persist video to TOS')
record.result_url = video_url
# Extract thumbnail from completed video
try:
from utils.media_utils import extract_video_info
from utils.tos_client import upload_file
thumb_file, _ = extract_video_info(record.result_url)
if thumb_file:
record.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
except Exception:
logger.exception('poll_video_task: failed to extract video thumbnail')
# 结算:按实际 tokens 扣费
usage = ark_resp.get('usage', {})
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
@ -143,3 +153,36 @@ def _handle_failed(record, ark_resp):
else:
from apps.generation.views import _release_freeze
_release_freeze(record)
@shared_task(ignore_result=True)
def process_asset_media(asset_id):
"""Extract thumbnail + duration for video/audio assets asynchronously."""
from apps.generation.models import Asset
try:
asset = Asset.objects.select_related('group').get(pk=asset_id)
except Asset.DoesNotExist:
logger.warning('process_asset_media: asset %s not found', asset_id)
return
from utils.media_utils import extract_video_info, get_audio_duration
from utils.tos_client import upload_file
if asset.asset_type == 'Video':
thumb_file, dur = extract_video_info(asset.url)
if thumb_file:
try:
asset.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
except Exception:
logger.exception('process_asset_media: thumbnail upload failed for asset %s', asset_id)
asset.duration = dur
asset.save(update_fields=['thumbnail_url', 'duration'])
group = asset.group
if not group.thumbnail_url and asset.thumbnail_url:
group.thumbnail_url = asset.thumbnail_url
group.save(update_fields=['thumbnail_url'])
elif asset.asset_type == 'Audio':
asset.duration = get_audio_duration(asset.url)
asset.save(update_fields=['duration'])
logger.info('process_asset_media: asset %s done (type=%s, dur=%.1f)', asset_id, asset.asset_type, asset.duration)

View File

@ -295,7 +295,7 @@ def video_generate_view(request):
"""查询本地 DB 获取组内所有 active 素材,返回 [(asset_url, asset_type), ...] 列表。
processing 的素材会尝试实时刷新状态"""
assets = list(AssetModel.objects.filter(
group_id=gid, status__in=['active', 'processing']
group_id=gid, group__team=team, status__in=['active', 'processing']
).exclude(remote_asset_id='').order_by('created_at'))
if not assets:
logger.warning('No assets found for group %s (label=%s)', gid, lbl)
@ -349,7 +349,45 @@ def video_generate_view(request):
snap['thumb_url'] = thumb_url
reference_snapshots.append(snap)
# 转换 asset://group-{id} → 展开为组内所有 active 素材(全发)
# 单素材引用asset://local-{id} → 查 Asset 表 → 单个 content_item
if url.startswith('asset://local-'):
try:
asset_local_id = int(url.replace('asset://local-', ''))
asset_obj = AssetModel.objects.get(pk=asset_local_id, group__team=team)
if asset_obj.status != 'active':
return Response({
'error': 'asset_not_ready',
'message': f'素材「{label}」尚在处理中,请稍后重试',
}, status=status.HTTP_400_BAD_REQUEST)
if not asset_obj.remote_asset_id:
return Response({
'error': 'asset_not_ready',
'message': f'素材「{label}」尚未就绪,请稍后重试',
}, status=status.HTTP_400_BAD_REQUEST)
aid = asset_obj.remote_asset_id
if aid.startswith('Asset-'):
aid = 'asset-' + aid[6:]
resolved_asset_url = f'asset://{aid}'
if asset_obj.asset_type == 'Video':
content_items.append({'type': 'video_url', 'video_url': {'url': resolved_asset_url}, 'role': 'reference_video'})
elif asset_obj.asset_type == 'Audio':
content_items.append({'type': 'audio_url', 'audio_url': {'url': resolved_asset_url}, 'role': 'reference_audio'})
else:
content_items.append({'type': 'image_url', 'image_url': {'url': resolved_asset_url}, 'role': 'reference_image'})
except AssetModel.DoesNotExist:
return Response({
'error': 'asset_not_found',
'message': f'素材「{label}」不存在或已被删除',
}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.warning('Failed to resolve asset URL %s: %s', url, e)
return Response({
'error': 'asset_not_ready',
'message': f'素材「{label}」解析失败,请重试',
}, status=status.HTTP_400_BAD_REQUEST)
continue
# 向后兼容asset://group-{id} → 展开为组内所有 active 素材
if url.startswith('asset://group-'):
try:
group_id = int(url.replace('asset://group-', ''))
@ -603,6 +641,7 @@ def _serialize_task(record):
'base_cost_amount': float(record.base_cost_amount),
'status': record.status,
'result_url': d.get('result_url', ''),
'thumbnail_url': d.get('thumbnail_url', ''),
'error_message': d.get('error_message', ''),
'reference_urls': d.get('reference_urls') or [],
'is_favorited': record.is_favorited,
@ -3007,15 +3046,13 @@ def asset_groups_view(request):
return Response({'error': '请输入角色名称'}, status=status.HTTP_400_BAD_REQUEST)
file = request.FILES.get('file')
if not file:
return Response({'error': '请上传素材文件'}, status=status.HTTP_400_BAD_REQUEST)
# Detect asset type and validate format/size
# Validate file BEFORE creating group (prevent orphan records)
asset_type = None
if file:
asset_type, err = _detect_asset_type(file)
if err:
return err
# Validate image dimensions (only for images)
if asset_type == 'Image':
try:
from PIL import Image
@ -3037,17 +3074,6 @@ def asset_groups_view(request):
except Exception:
pass
# Upload to TOS
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
try:
tos_url = tos_upload(file, folder=folder)
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': f'文件上传失败: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Create remote group
from utils import assets_client
remote_group_id = ''
@ -3057,7 +3083,28 @@ def asset_groups_view(request):
if result is not None:
remote_group_id = result
# Create remote asset
# Local DB group
group = AssetGroup.objects.create(
team=team,
remote_group_id=remote_group_id,
name=name,
description='',
thumbnail_url='',
created_by=request.user,
)
# If file provided, create first asset (validation already done above)
if file and asset_type:
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
try:
tos_url = tos_upload(file, folder=folder)
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': '文件上传失败,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
remote_asset_id = ''
if remote_group_id:
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name, asset_type=asset_type)
@ -3066,16 +3113,7 @@ def asset_groups_view(request):
if result is not None:
remote_asset_id = result
# Local DB records
group = AssetGroup.objects.create(
team=team,
remote_group_id=remote_group_id,
name=name,
description='',
thumbnail_url=tos_url,
created_by=request.user,
)
Asset.objects.create(
asset_obj = Asset.objects.create(
group=group,
remote_asset_id=remote_asset_id,
name=name,
@ -3084,13 +3122,21 @@ def asset_groups_view(request):
status='processing' if remote_asset_id else 'active',
error_message='',
)
# Set group thumbnail for images; video/audio thumbnails extracted async
if asset_type == 'Image':
group.thumbnail_url = tos_url
group.save(update_fields=['thumbnail_url'])
# Async: extract thumbnail + duration for video/audio
if asset_type in ('Video', 'Audio'):
from apps.generation.tasks import process_asset_media
process_asset_media.delay(asset_obj.id)
return Response({
'id': group.id,
'name': group.name,
'thumbnail_url': group.thumbnail_url,
'remote_group_id': group.remote_group_id,
'asset_count': 1,
'asset_count': Asset.objects.filter(group=group).count(),
'created_at': group.created_at.isoformat(),
}, status=status.HTTP_201_CREATED)
@ -3129,6 +3175,8 @@ def asset_group_detail_view(request, group_id):
'name': a.name,
'url': a.url,
'asset_type': a.asset_type,
'thumbnail_url': a.thumbnail_url,
'duration': a.duration,
'status': a.status,
'remote_asset_id': a.remote_asset_id,
'error_message': a.error_message,
@ -3230,7 +3278,7 @@ def asset_group_add_asset_view(request, group_id):
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': f'文件上传失败: {e}'},
{'error': '文件上传失败,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@ -3256,16 +3304,23 @@ def asset_group_add_asset_view(request, group_id):
error_message='',
)
# If first asset or no thumbnail, set thumbnail
if not group.thumbnail_url:
# If first image asset or no thumbnail, set group thumbnail
if not group.thumbnail_url and asset_type == 'Image':
group.thumbnail_url = tos_url
group.save(update_fields=['thumbnail_url'])
# Async: extract thumbnail + duration for video/audio
if asset_type in ('Video', 'Audio'):
from apps.generation.tasks import process_asset_media
process_asset_media.delay(asset.id)
return Response({
'id': asset.id,
'name': asset.name,
'url': asset.url,
'asset_type': asset.asset_type,
'thumbnail_url': asset.thumbnail_url,
'duration': asset.duration,
'status': asset.status,
'remote_asset_id': asset.remote_asset_id,
'created_at': asset.created_at.isoformat(),
@ -3298,8 +3353,9 @@ def asset_update_view(request, asset_id):
# Update group thumbnail if needed
remaining = Asset.objects.filter(group=group).exclude(status='failed').order_by('-created_at').first()
if remaining:
if group.thumbnail_url != remaining.url:
group.thumbnail_url = remaining.url
new_thumb = remaining.thumbnail_url or remaining.url
if group.thumbnail_url != new_thumb:
group.thumbnail_url = new_thumb
group.save(update_fields=['thumbnail_url'])
else:
group.thumbnail_url = ''
@ -3332,26 +3388,29 @@ def asset_update_view(request, asset_id):
@api_view(['GET'])
@permission_classes([IsTeamMember])
def asset_search_view(request):
"""GET /api/v1/assets/search?q=... — fast search for @ popup."""
"""GET /api/v1/assets/search?q=... — search individual assets for @ popup."""
team = request.user.team
q = request.query_params.get('q', '').strip()
q = request.query_params.get('q', '').strip()[:100] # 限制搜索长度
if not q:
return Response({'results': []})
groups = (
AssetGroup.objects
.filter(team=team, name__icontains=q)
.annotate(asset_count=Count('assets'))
assets = (
Asset.objects
.filter(group__team=team, name__icontains=q, status='active')
.select_related('group')
.order_by('-created_at')[:20]
)
results = []
for g in groups:
for a in assets:
results.append({
'id': g.id,
'name': g.name,
'thumbnail_url': g.thumbnail_url if g.asset_count > 0 else '',
'asset_count': g.asset_count,
'remote_group_id': g.remote_group_id,
'id': a.id,
'name': a.name,
'url': a.url,
'asset_type': a.asset_type,
'group_name': a.group.name,
'remote_asset_id': a.remote_asset_id,
'thumbnail_url': a.thumbnail_url,
'duration': a.duration,
})
return Response({'results': results})

View File

@ -0,0 +1,115 @@
"""Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe."""
import logging
import subprocess
import tempfile
import os
import requests
from django.core.files.uploadedfile import SimpleUploadedFile
logger = logging.getLogger(__name__)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB safety limit
def _download_to_temp(url: str, suffix: str) -> str:
"""Download a URL to a temporary file. Returns the temp file path."""
resp = requests.get(url, timeout=30, stream=True)
resp.raise_for_status()
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
downloaded = 0
try:
for chunk in resp.iter_content(8192):
downloaded += len(chunk)
if downloaded > MAX_DOWNLOAD_SIZE:
tmp.close()
os.unlink(tmp.name)
raise ValueError(f'File too large: {downloaded} bytes')
tmp.write(chunk)
tmp.close()
except Exception:
tmp.close()
if os.path.exists(tmp.name):
os.unlink(tmp.name)
raise
return tmp.name
def _get_duration_ffprobe(file_path: str) -> float:
"""Get media duration in seconds using ffprobe."""
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', file_path],
capture_output=True, text=True, timeout=15,
)
return float(result.stdout.strip())
except Exception as e:
logger.warning('ffprobe duration failed: %s', e)
return 0
def _extract_first_frame(video_path: str, output_path: str) -> bool:
"""Extract the first frame of a video as JPEG using ffmpeg."""
try:
subprocess.run(
['ffmpeg', '-y', '-i', video_path, '-vframes', '1',
'-f', 'image2', '-q:v', '2', output_path],
capture_output=True, timeout=15,
)
return os.path.exists(output_path) and os.path.getsize(output_path) > 0
except Exception as e:
logger.warning('ffmpeg frame extraction failed: %s', e)
return False
def extract_video_info(video_url: str) -> tuple:
"""Extract first frame thumbnail + duration from a video URL.
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
"""
tmp_video = None
tmp_thumb = None
try:
# Determine suffix from URL
suffix = '.mp4'
if '.mov' in video_url.lower():
suffix = '.mov'
tmp_video = _download_to_temp(video_url, suffix)
# Get duration
duration = _get_duration_ffprobe(tmp_video)
# Extract first frame
tmp_thumb = tmp_video + '_thumb.jpg'
if _extract_first_frame(tmp_video, tmp_thumb):
with open(tmp_thumb, 'rb') as f:
thumb_file = SimpleUploadedFile(
'thumbnail.jpg', f.read(), content_type='image/jpeg'
)
return thumb_file, duration
return None, duration
except Exception as e:
logger.warning('extract_video_info failed for %s: %s', video_url, e)
return None, 0
finally:
if tmp_video and os.path.exists(tmp_video):
os.unlink(tmp_video)
if tmp_thumb and os.path.exists(tmp_thumb):
os.unlink(tmp_thumb)
def get_audio_duration(audio_url: str) -> float:
"""Get audio duration in seconds from a URL."""
tmp_audio = None
try:
suffix = '.wav' if '.wav' in audio_url.lower() else '.mp3'
tmp_audio = _download_to_temp(audio_url, suffix)
return _get_duration_ffprobe(tmp_audio)
except Exception as e:
logger.warning('get_audio_duration failed for %s: %s', audio_url, e)
return 0
finally:
if tmp_audio and os.path.exists(tmp_audio):
os.unlink(tmp_audio)

View File

@ -233,6 +233,29 @@
opacity: 1;
}
.addAssetCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 1.5px dashed #3a3a48;
border-radius: 12px;
cursor: pointer;
color: var(--color-text-disabled);
font-size: 12px;
transition: all 0.2s;
background: transparent;
/* match assetThumb height + assetInfo height */
min-height: 180px;
}
.addAssetCard:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: rgba(108, 99, 255, 0.04);
}
.assetThumb {
width: 100%;
height: 140px;

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useAssetLibraryStore } from '../store/assetLibrary';
import { assetsApi, tosThumb } from '../lib/api';
import { showToast } from './Toast';
@ -102,11 +102,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
const [newName, setNewName] = useState('');
const [uploading, setUploading] = useState(false);
const [editingName, setEditingName] = useState<{ id: number; value: string } | null>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadPreview, setUploadPreview] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const groups = useAssetLibraryStore((s) => s.groups);
const loading = useAssetLibraryStore((s) => s.loading);
@ -114,7 +110,6 @@ export function AssetLibraryModal({ open, onClose }: Props) {
const page = useAssetLibraryStore((s) => s.page);
const loadGroups = useAssetLibraryStore((s) => s.loadGroups);
const createGroup = useAssetLibraryStore((s) => s.createGroup);
const pollAssetStatus = useAssetLibraryStore((s) => s.pollAssetStatus);
const totalPages = Math.ceil(total / 20);
@ -178,29 +173,22 @@ export function AssetLibraryModal({ open, onClose }: Props) {
const handleUploadSubmit = useCallback(async () => {
const trimmed = newName.trim();
if (!trimmed || !uploadFile) return;
if (!trimmed) return;
if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; }
if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; }
setUploading(true);
const result = await createGroup(newName.trim(), uploadFile);
const result = await createGroup(trimmed, null);
setUploading(false);
if (result) {
pollAssetStatus(result.id);
setNewName('');
setUploadFile(null);
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
setUploadPreview(null);
handleBackToList();
// 创建成功后直接进入详情页
const group: AssetGroup = { id: result.id, name: trimmed, thumbnail_url: '', asset_count: 0, remote_group_id: result.remote_group_id || '', description: '', created_at: new Date().toISOString() };
setSelectedGroup(group);
setGroupAssets([]);
setView('detail');
loadGroups(page);
}
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
const handleFileSelect = useCallback(async (file: File) => {
const error = await validateAssetFile(file);
if (error) { showToast(error); return; }
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
setUploadFile(file);
setUploadPreview(file.type.startsWith('image/') ? URL.createObjectURL(file) : null);
}, [uploadPreview]);
}, [newName, createGroup, loadGroups, page]);
const refreshGroupDetail = useCallback(async () => {
if (!selectedGroup) return;
@ -235,21 +223,13 @@ export function AssetLibraryModal({ open, onClose }: Props) {
clearInterval(pollInterval);
}
}, 3000);
showToast('图片已上传,处理中...');
const typeLabel = file.type.startsWith('video/') ? '视频' : file.type.startsWith('audio/') ? '音频' : '图片';
showToast(`${typeLabel}已上传,处理中...`);
} catch {
showToast('上传失败,请重试');
}
}, [selectedGroup, refreshGroupDetail]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file && (file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'))) {
handleFileSelect(file);
}
}, [handleFileSelect]);
if (!open) return null;
return (
@ -266,7 +246,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
</button>
)}
<span className={styles.title}>
{view === 'list' && '素材库'}
{view === 'list' && '人物素材库'}
{view === 'detail' && (selectedGroup?.name || '角色详情')}
{view === 'upload' && '上传新角色'}
</span>
@ -299,7 +279,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
{groups.map((group) => (
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(group)}>
{group.asset_count === 0 ? (
<div className={styles.cardThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--color-text-disabled)', fontSize: 12 }}></div>
<div className={styles.cardThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--color-text-disabled)', fontSize: 12 }}></div>
) : (
<img src={tosThumb(group.thumbnail_url, 300)} alt={group.name} className={styles.cardThumb} />
)}
@ -434,30 +414,14 @@ export function AssetLibraryModal({ open, onClose }: Props) {
<div key={assetType} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{typeLabel}</span>
<label className={styles.actionBtn} style={{ cursor: 'pointer', fontSize: 12, padding: '3px 10px' }}>
+
<input
type="file"
accept={acceptMap[assetType]}
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleAddAsset(file);
e.target.value = '';
}}
/>
</label>
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</div>
<div style={{ fontSize: 11, color: '#e8952e', marginBottom: 8 }}>{warningMap[assetType]}</div>
{typeAssets.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-disabled)', padding: '12px 0' }}></div>
) : (
<div className={styles.assetGrid}>
{typeAssets.map((asset) => (
<div key={asset.id} className={styles.assetCard}>
{assetType === 'Video' ? (
<video src={asset.url} className={styles.assetThumb} muted preload="metadata" />
<img src={tosThumb(asset.thumbnail_url || asset.url, 300)} alt={asset.name} className={styles.assetThumb} />
) : assetType === 'Audio' ? (
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}></div>
) : (
@ -504,15 +468,52 @@ export function AssetLibraryModal({ open, onClose }: Props) {
</div>
</div>
))}
{/* 拖拽上传卡片 — 和素材卡片同大小,始终在最后 */}
<label
className={styles.addAssetCard}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
// 检查文件类型是否匹配当前分区
const ft = file.type || '';
const matchesSection =
(assetType === 'Image' && ft.startsWith('image/')) ||
(assetType === 'Video' && ft.startsWith('video/')) ||
(assetType === 'Audio' && ft.startsWith('audio/'));
if (!matchesSection) {
const expected = assetType === 'Image' ? '图片' : assetType === 'Video' ? '视频' : '音频';
showToast(`请将${expected}文件拖到此区域`);
return;
}
handleAddAsset(file);
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span></span>
<input
type="file"
accept={acceptMap[assetType]}
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleAddAsset(file);
e.target.value = '';
}}
/>
</label>
</div>
)}
</div>
);
})}
</>
)}
{/* Upload View */}
{/* Upload View — only name, no file */}
{view === 'upload' && (
<div className={styles.uploadForm}>
<div>
@ -523,59 +524,19 @@ export function AssetLibraryModal({ open, onClose }: Props) {
maxLength={64}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleUploadSubmit(); }}
autoFocus
/>
</div>
<div>
<div className={styles.inputLabel}></div>
<div
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{uploadFile ? (
<>
{uploadPreview ? (
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
) : (
<div style={{ fontSize: 32, padding: '16px 0' }}>
{uploadFile.type.startsWith('video/') ? '🎬' : '♫'}
<div style={{ fontSize: 12, color: 'var(--color-text-disabled)', marginTop: 4 }}>
</div>
)}
<div className={styles.dropZoneHint}>{uploadFile.name}</div>
<div className={styles.dropZoneHint} style={{ color: 'var(--color-text-disabled)' }}></div>
</>
) : (
<>
<div className={styles.dropZoneText}></div>
<div className={styles.dropZoneHint}></div>
<div className={styles.dropZoneHint}>(JPG/PNG/WEBP/HEIC)(MP4/MOV)(MP3/WAV)</div>
</>
)}
<div className={styles.dropZoneWarning}> 300~6000px 0.4~2.5</div>
<div className={styles.dropZoneWarning}> 2~1550MB | 2~1515MB</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/mp4,video/quicktime,audio/mpeg,audio/wav"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = '';
}}
/>
</div>
<button
className={styles.submitBtn}
disabled={!newName.trim() || !uploadFile || uploading}
disabled={!newName.trim() || uploading}
onClick={handleUploadSubmit}
>
{uploading ? '上传中...' : '确认上传'}
{uploading ? '创建中...' : '创建角色'}
</button>
</div>
)}

View File

@ -79,13 +79,13 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
// Render prompt text with @mentions as styled tags (thumbnail + hover preview)
export function renderPromptWithMentions(
text: string,
assetMentions: { label: string; thumbUrl?: string }[],
assetMentions: Record<string, unknown>[],
references: { label: string; previewUrl?: string }[]
) {
// Build lookup: label → thumbUrl
const thumbMap = new Map<string, string>();
for (const am of assetMentions) {
if (am.label) thumbMap.set(am.label, am.thumbUrl || '');
if (am.label) thumbMap.set(am.label as string, (am.thumbUrl as string) || '');
}
for (const r of references) {
if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, r.previewUrl || '');

View File

@ -114,7 +114,7 @@ export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNod
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
>
</button>
<button
onClick={() => { if (!searchDisabled) setSearchMode(searchMode === 'smart' ? 'off' : 'smart'); }}

View File

@ -2,7 +2,9 @@ import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar';
import { assetsApi, tosThumb } from '../lib/api';
import type { UploadedFile, AssetGroup } from '../types';
import type { UploadedFile, AssetSearchResult } from '../types';
import { parseAssetMentions } from '../lib/assetMentions';
import { showToast } from './Toast';
import styles from './PromptInput.module.css';
const placeholders: Record<string, string> = {
@ -27,7 +29,7 @@ export function PromptInput() {
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
const [mentionMode, setMentionMode] = useState<'references' | 'assets'>('references');
const [assetSearchResults, setAssetSearchResults] = useState<AssetGroup[]>([]);
const [assetSearchResults, setAssetSearchResults] = useState<AssetSearchResult[]>([]);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-focus
@ -40,7 +42,7 @@ export function PromptInput() {
const el = editorRef.current;
if (!el) return;
if (el.innerHTML !== editorHtml) {
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br', 'img'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'data-asset-group-id', 'data-group-name', 'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style'] });
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br', 'img'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'data-asset-group-id', 'data-group-name', 'data-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration', 'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style'] });
// If the HTML is plain text but we have references or asset mentions, rebuild mention spans
// This handles the case where editorHtml comes from backend (plain text only)
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
@ -64,6 +66,7 @@ export function PromptInput() {
const createMentionSpan = useCallback((opts: {
refId: string; refType: string; label: string; thumbUrl?: string;
assetGroupId?: string; groupName?: string;
assetId?: string; assetType?: string; assetName?: string; duration?: string;
}) => {
const span = document.createElement('span');
span.className = styles.mention;
@ -72,10 +75,18 @@ export function PromptInput() {
span.dataset.refType = opts.refType;
span.draggable = true;
if (opts.thumbUrl) span.dataset.thumbUrl = opts.thumbUrl;
// New asset attributes (individual asset reference)
if (opts.assetId) span.dataset.assetId = opts.assetId;
if (opts.assetType) span.dataset.assetType = opts.assetType;
if (opts.assetName) span.dataset.assetName = opts.assetName;
if (opts.duration) span.dataset.duration = opts.duration;
// Legacy group attributes (backward compat for old records)
if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
if (opts.groupName) span.dataset.groupName = opts.groupName;
if (opts.refType === 'audio') {
// Render icon/thumbnail based on type
const isAudio = opts.refType === 'audio' || opts.assetType === 'Audio';
if (isAudio) {
const icon = document.createElement('span');
icon.textContent = '\u266B';
icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none';
@ -102,15 +113,32 @@ export function PromptInput() {
const rebuildMentionSpans = useCallback((el: HTMLElement) => {
// Collect all targets to match: references + asset mentions
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
type MatchTarget = { label: string; refId: string; refType: string; thumbUrl: string; assetGroupId?: string; groupName?: string };
type MatchTarget = {
label: string; refId: string; refType: string; thumbUrl: string;
assetGroupId?: string; groupName?: string;
assetId?: string; assetType?: string; assetName?: string; duration?: string;
};
const targets: MatchTarget[] = [
...references.map((ref) => ({
label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl,
})),
...currentAssetMentions.map((am) => ({
label: am.label, refId: am.groupId, refType: 'asset', thumbUrl: am.thumbUrl || '',
assetGroupId: am.groupId, groupName: am.label,
})),
...currentAssetMentions.map((am: Record<string, unknown>) => {
// New format (individual asset)
if (am.assetId) {
return {
label: am.label as string, refId: am.assetId as string, refType: 'asset',
thumbUrl: (am.thumbUrl as string) || '',
assetId: am.assetId as string, assetType: am.assetType as string,
assetName: am.label as string, duration: String(am.duration || 0),
};
}
// Legacy format (group reference)
return {
label: am.label as string, refId: (am.groupId as string) || '', refType: 'asset',
thumbUrl: (am.thumbUrl as string) || '',
assetGroupId: am.groupId as string, groupName: am.label as string,
};
}),
];
if (targets.length === 0) return;
@ -347,7 +375,30 @@ export function PromptInput() {
extractText();
}, [extractText]);
const insertAssetMention = useCallback((group: AssetGroup) => {
const insertAssetMention = useCallback((asset: AssetSearchResult) => {
// Instant check: count limit
const stats = parseAssetMentions(editorHtml);
const refs = useInputBarStore.getState().references;
const refCounts = { image: 0, video: 0, audio: 0 };
refs.forEach((r) => refCounts[r.type]++);
const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image';
const maxMap = { image: 9, video: 3, audio: 3 };
if (refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片';
showToast(`${typeLabel}已达上限`);
return;
}
// Instant check: duration limit (video/audio)
if (asset.duration > 0 && (asset.asset_type === 'Video' || asset.asset_type === 'Audio')) {
const existingDur = refs.filter((r) => r.type === typeKey && r.duration).reduce((s, r) => s + (r.duration || 0), 0);
const assetDur = typeKey === 'video' ? stats.durations.video : stats.durations.audio;
if (existingDur + assetDur + asset.duration > 15.4) {
const typeLabel = asset.asset_type === 'Video' ? '视频' : '音频';
showToast(`${typeLabel}总时长超过15秒限制`);
return;
}
}
setShowMentionPopup(false);
setMentionMode('references');
setAssetSearchResults([]);
@ -378,14 +429,16 @@ export function PromptInput() {
range.deleteContents();
// Create mention span for asset with thumbnail
// Create mention span for individual asset
const mention = createMentionSpan({
refId: String(group.id),
refId: String(asset.id),
refType: 'asset',
label: group.name,
thumbUrl: group.thumbnail_url,
assetGroupId: String(group.id),
groupName: group.name,
label: asset.name,
thumbUrl: asset.thumbnail_url || asset.url,
assetId: String(asset.id),
assetType: asset.asset_type,
assetName: asset.name,
duration: String(asset.duration || 0),
});
range.insertNode(mention);
@ -400,7 +453,7 @@ export function PromptInput() {
sel.addRange(newRange);
extractText();
}, [extractText]);
}, [extractText, editorHtml, references]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup) {
@ -488,13 +541,15 @@ export function PromptInput() {
// 素材库标签:用 data-thumb-url 构造预览数据
if (!found && refType === 'asset') {
const assetType = target.dataset.assetType || 'Image';
if (assetType === 'Audio') return; // 音频素材不弹预览
const thumbUrl = target.dataset.thumbUrl;
if (thumbUrl) {
found = {
id: refId || '',
type: 'image',
type: assetType === 'Video' ? 'video' : 'image',
previewUrl: thumbUrl,
label: target.dataset.groupName || target.textContent || '',
label: target.dataset.assetName || target.textContent || '',
};
}
}
@ -632,25 +687,32 @@ export function PromptInput() {
)}
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
<>
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((group, idx) => (
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((asset, idx) => (
<button
key={group.id}
key={asset.id}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertAssetMention(group);
insertAssetMention(asset);
}}
>
<div className={styles.mentionThumb}>
{group.thumbnail_url ? (
<img src={tosThumb(group.thumbnail_url, 72)} alt="" className={styles.thumbMedia} />
{asset.asset_type === 'Audio' ? (
<span style={{ fontSize: 16 }}></span>
) : (asset.thumbnail_url || asset.url) ? (
<img src={tosThumb(asset.thumbnail_url || asset.url, 72)} alt="" className={styles.thumbMedia} />
) : (
<span style={{ fontSize: 9, color: 'var(--color-text-disabled)' }}></span>
)}
</div>
<span className={styles.mentionLabel}>{group.name}</span>
<span className={styles.mentionType}></span>
<div style={{ flex: 1, minWidth: 0 }}>
<span className={styles.mentionLabel}>{asset.name}</span>
<span style={{ fontSize: 10, color: '#5a5a6a', marginLeft: 4 }}>{asset.group_name}</span>
</div>
<span className={styles.mentionType}>
{asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片'}
</span>
</button>
))}
</>

View File

@ -4,7 +4,7 @@ import type {
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem,
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem, AssetSearchResult,
} from '../types';
import { reportError } from './logCenter';
@ -146,7 +146,7 @@ export const videoApi = {
model: string;
aspect_ratio: string;
duration: number;
references: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
references: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[];
search_mode?: string;
seed?: number;
}) =>
@ -427,7 +427,7 @@ export const assetsApi = {
deleteAsset: (id: number) =>
api.delete(`/assets/${id}`),
search: (q: string) =>
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
api.get<{ results: AssetSearchResult[] }>('/assets/search', { params: { q } }),
pollStatus: (id: number) =>
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
};

View File

@ -0,0 +1,22 @@
/**
* Parse asset mention spans from editor HTML.
* Returns counts and durations by type, used for number/duration limit checks.
*/
export function parseAssetMentions(html: string): {
counts: { image: number; video: number; audio: number };
durations: { video: number; audio: number };
} {
const counts = { image: 0, video: 0, audio: 0 };
const durations = { video: 0, audio: 0 };
if (!html) return { counts, durations };
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('[data-ref-type="asset"]').forEach((el) => {
const t = (el as HTMLElement).dataset.assetType || 'Image';
const dur = parseFloat((el as HTMLElement).dataset.duration || '0');
if (t === 'Video') { counts.video++; durations.video += dur; }
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
else { counts.image++; }
});
return { counts, durations };
}

View File

@ -1,6 +1,6 @@
import { create } from 'zustand';
import { assetsApi } from '../lib/api';
import type { AssetGroup } from '../types';
import type { AssetGroup, AssetSearchResult } from '../types';
import { showToast } from '../components/Toast';
interface AssetLibraryState {
@ -8,12 +8,12 @@ interface AssetLibraryState {
loading: boolean;
total: number;
page: number;
searchResults: AssetGroup[];
searchResults: AssetSearchResult[];
searching: boolean;
loadGroups: (page?: number) => Promise<void>;
searchAssets: (query: string) => Promise<void>;
createGroup: (name: string, file: File) => Promise<AssetGroup | null>;
createGroup: (name: string, file: File | null) => Promise<AssetGroup | null>;
pollAssetStatus: (assetId: number) => void;
}
@ -45,10 +45,10 @@ export const useAssetLibraryStore = create<AssetLibraryState>((set) => ({
}
},
createGroup: async (name: string, file: File) => {
createGroup: async (name: string, file: File | null) => {
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
if (file) formData.append('file', file);
try {
const { data } = await assetsApi.createGroup(formData);
showToast('角色创建成功');

View File

@ -84,8 +84,17 @@ function buildAssetMentions(refs: Array<Record<string, string>>) {
.filter((ref) => isAssetUrl(ref.url || ''))
.map((ref) => {
const url = ref.url || '';
// New format: asset://local-{id}
if (url.startsWith('asset://local-')) {
const assetId = url.replace('asset://local-', '');
return {
assetId, label: ref.label || '', thumbUrl: ref.thumb_url || '',
assetType: ref.type || 'image', duration: parseFloat(ref.duration || '0'),
};
}
// Legacy format: asset://group-{id}
const groupId = url.startsWith('asset://group-') ? url.replace('asset://group-', '') : '';
return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '' };
return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '', assetType: 'image', duration: 0 };
});
}
@ -109,6 +118,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
status: mapStatus(bt.status),
progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status),
resultUrl: bt.result_url || undefined,
thumbnailUrl: bt.thumbnail_url || undefined,
errorMessage: mapErrorMessage(bt.error_message),
createdAt: new Date(bt.created_at).getTime(),
tokensConsumed: bt.tokens_consumed || 0,
@ -349,7 +359,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
].filter(Boolean) as ReferenceSnapshot[];
// Extract asset mentions for placeholder display
const placeholderAssetMentions: { groupId: string; label: string; thumbUrl: string }[] = [];
const placeholderAssetMentions: Record<string, unknown>[] = [];
if (input.editorHtml) {
const parser = new DOMParser();
const doc = parser.parseFromString(input.editorHtml, 'text/html');
@ -410,7 +420,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
try {
// Use pre-uploaded TOS URLs (immediate upload), fallback to upload here if needed
const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = [];
const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[] = [];
for (const item of filesToUpload) {
if (item.tosUrl && !item.tosUrl.startsWith('blob:')) {
@ -422,18 +432,35 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
}
}
// Extract asset mentions from editor HTML — deduplicate by groupId
const seenGroupIds = new Set<string>();
// Extract asset mentions from editor HTML — deduplicate by assetId
const seenAssetIds = new Set<string>();
if (input.editorHtml) {
const parser = new DOMParser();
const doc = parser.parseFromString(input.editorHtml, 'text/html');
const assetSpans = doc.querySelectorAll('[data-ref-type="asset"]');
assetSpans.forEach((span) => {
const el = span as HTMLElement;
const assetId = el.dataset.assetId;
const assetType = (el.dataset.assetType || 'Image').toLowerCase();
const assetName = el.dataset.assetName || el.textContent?.replace('@', '') || '';
const duration = el.dataset.duration || '0';
if (assetId && !seenAssetIds.has(assetId)) {
seenAssetIds.add(assetId);
uploadedRefs.push({
url: `asset://local-${assetId}`,
type: assetType,
role: `reference_${assetType}`,
label: assetName,
thumb_url: el.dataset.thumbUrl || '',
duration,
});
}
// Legacy: data-asset-group-id (old format)
if (!assetId && el.dataset.assetGroupId) {
const groupId = el.dataset.assetGroupId;
const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || '';
if (groupId && !seenGroupIds.has(groupId)) {
seenGroupIds.add(groupId);
if (!seenAssetIds.has(`group-${groupId}`)) {
seenAssetIds.add(`group-${groupId}`);
uploadedRefs.push({
url: `asset://group-${groupId}`,
type: 'image',
@ -442,18 +469,32 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
thumb_url: el.dataset.thumbUrl || '',
});
}
}
});
}
// Fallback: only use inputBar assetMentions when editorHtml has NO asset spans
// (regenerate scenario where editorHtml is plain text)
// If user edited the HTML and removed some asset tags, respect that — don't re-add from store
const htmlHadAssetSpans = input.editorHtml?.includes('data-ref-type="asset"');
if (!htmlHadAssetSpans) {
const inputAssetMentions = input.assetMentions || [];
for (const am of inputAssetMentions) {
if (am.groupId && !seenGroupIds.has(am.groupId)) {
seenGroupIds.add(am.groupId);
// New format
if (am.assetId && !seenAssetIds.has(am.assetId)) {
seenAssetIds.add(am.assetId);
const t = (am.assetType || 'Image').toLowerCase();
uploadedRefs.push({
url: `asset://local-${am.assetId}`,
type: t,
role: `reference_${t}`,
label: am.label,
thumb_url: am.thumbUrl || '',
duration: String(am.duration || 0),
});
}
// Legacy format
if (!am.assetId && am.groupId && !seenAssetIds.has(`group-${am.groupId}`)) {
seenAssetIds.add(`group-${am.groupId}`);
uploadedRefs.push({
url: `asset://group-${am.groupId}`,
type: 'image',

View File

@ -2,6 +2,7 @@ import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
import { mediaApi } from '../lib/api';
import { parseAssetMentions } from '../lib/assetMentions';
let fileCounter = 0;
@ -123,7 +124,8 @@ interface InputBarState {
setSeedEnabled: (enabled: boolean) => void;
// Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild)
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assetMentions: Record<string, any>[];
// @ trigger (for toolbar button to insert @ in contentEditable)
insertAtTrigger: number;
@ -170,9 +172,13 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prevReferences: [],
addReferences: (files) => {
const state = get();
// Count existing references by type
// Count existing references by type + merge @ asset mentions
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++;
const { counts: assetCounts } = parseAssetMentions(state.editorHtml);
counts.image += assetCounts.image;
counts.video += assetCounts.video;
counts.audio += assetCounts.audio;
// Separate images (sync) from audio/video (need async duration check)
const imageFiles: File[] = [];
@ -496,11 +502,13 @@ async function _validateAndAddMedia(files: File[]) {
}
}
// Total duration check (same type)
// Total duration check (same type) — merge @ asset mention durations
const state = useInputBarStore.getState();
const existingDuration = state.references
const { durations: assetDurations } = parseAssetMentions(state.editorHtml);
const refDuration = state.references
.filter((r) => r.type === type && r.duration)
.reduce((sum, r) => sum + (r.duration || 0), 0);
const existingDuration = refDuration + (type === 'video' ? assetDurations.video : assetDurations.audio);
if (existingDuration + dur > MAX_MEDIA_DURATION + 0.4) {
showToast(`${typeLabel}总时长不能超过${MAX_MEDIA_DURATION}`);
continue;

View File

@ -44,10 +44,12 @@ export interface GenerationTask {
aspectRatio: AspectRatio;
duration: Duration;
references: ReferenceSnapshot[];
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assetMentions: Record<string, any>[];
status: TaskStatus;
progress: number;
resultUrl?: string;
thumbnailUrl?: string;
errorMessage?: string;
createdAt: number;
tokensConsumed?: number;
@ -71,6 +73,7 @@ export interface BackendTask {
base_cost_amount: number;
status: 'queued' | 'processing' | 'completed' | 'failed';
result_url: string;
thumbnail_url: string;
error_message: string;
reference_urls: { url: string; type: string; role: string; label: string }[];
is_favorited: boolean;
@ -436,8 +439,21 @@ export interface AssetItem {
name: string;
url: string;
asset_type: 'Image' | 'Video' | 'Audio';
thumbnail_url: string;
duration: number;
status: 'processing' | 'active' | 'failed';
remote_asset_id: string;
error_message: string;
created_at: string;
}
export interface AssetSearchResult {
id: number;
name: string;
url: string;
asset_type: 'Image' | 'Video' | 'Audio';
group_name: string;
remote_asset_id: string;
thumbnail_url: string;
duration: number;
}