Compare commits

..

No commits in common. "5da67435b2ece27750ea77905267be77f244a140" and "95bdb0a6e86011b271e9651e76178b9655cf8cbc" have entirely different histories.

27 changed files with 355 additions and 1464 deletions

View File

@ -88,7 +88,7 @@ jobs:
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && break
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
done
chmod +x kubectl && mv kubectl /usr/bin/kubectl
chmod +x kubectl && mv kubectl /usr/local/bin/
fi
kubectl version --client

View File

@ -11,7 +11,6 @@ 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

@ -1,28 +0,0 @@
# 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

@ -1,18 +0,0 @@
# Generated by Django 4.2.29 on 2026-04-04 17:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0018_add_thumbnail_and_duration'),
]
operations = [
migrations.AlterField(
model_name='asset',
name='duration',
field=models.FloatField(default=None, null=True, verbose_name='时长(秒)'),
),
]

View File

@ -42,7 +42,6 @@ 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='参考素材信息')
@ -157,8 +156,6 @@ 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(null=True, default=None, 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

@ -6,48 +6,44 @@ from celery import shared_task
logger = logging.getLogger(__name__)
# 轮询间隔(秒):每次查完后重新入队,不占 worker 进程
POLL_INTERVAL = 5
@shared_task(ignore_result=True)
def poll_video_task(record_id):
"""Poll Volcano API once for a video generation task.
一次性任务查一次 API更新 DB结束
recover_stuck_tasksbeat 每10秒调度统一驱动不再自己 retry
Redis 锁防止 _handle_completed 期间被重复 dispatch
@shared_task(bind=True, max_retries=None, ignore_result=True)
def poll_video_task(self, record_id):
"""Poll Volcano API for a video generation task.
每次只执行一轮查询查完通过 self.retry 重新入队
这样 worker 不会被 sleep 占死重启也不丢任务
"""
from django.core.cache import cache
# Redis 锁:防止同一 record 被并发处理_handle_completed 耗时较长)
lock_key = f'poll_lock:{record_id}'
if not cache.add(lock_key, '1', timeout=120):
return
try:
_do_poll(record_id)
except Exception:
logger.exception('poll_video_task: unexpected error for record=%s', record_id)
finally:
cache.delete(lock_key)
def _do_poll(record_id):
"""实际轮询逻辑,由 poll_video_task 调用。"""
from django.utils import timezone
from apps.generation.models import GenerationRecord
from utils.airdrama_client import query_task, map_status
# 防重复:同一 record 同一时刻只允许一个 poll 在执行
from django.core.cache import cache
lock_key = f'poll_lock:{record_id}'
if not cache.add(lock_key, '1', timeout=POLL_INTERVAL * 3):
logger.info('poll_video_task: record %s already being polled, skipping', record_id)
return
try:
record = GenerationRecord.objects.get(pk=record_id)
except GenerationRecord.DoesNotExist:
logger.warning('poll_video_task: record %s not found', record_id)
return
if record.status not in ('queued', 'processing'):
cache.delete(lock_key)
return
ark_task_id = record.ark_task_id
if not ark_task_id:
logger.warning('poll_video_task: record %s has no ark_task_id', record_id)
cache.delete(lock_key)
return
if record.status not in ('queued', 'processing'):
logger.info('poll_video_task: record %s already in terminal state: %s', record_id, record.status)
cache.delete(lock_key)
return
# Poll Volcano API
@ -55,13 +51,16 @@ def _do_poll(record_id):
ark_resp = query_task(ark_task_id)
new_status = map_status(ark_resp.get('status', ''))
except Exception:
logger.exception('poll_video_task: API query failed for record=%s ark=%s', record_id, ark_task_id)
return
logger.exception('poll_video_task: API query failed for %s, will retry', ark_task_id)
cache.delete(lock_key)
raise self.retry(countdown=POLL_INTERVAL)
if new_status in ('queued', 'processing'):
# Still running — update status, then re-enqueue
record.status = new_status
record.save(update_fields=['status', 'updated_at'])
return
cache.delete(lock_key)
raise self.retry(countdown=POLL_INTERVAL)
# Terminal state reached — process result
record.status = new_status
@ -77,7 +76,7 @@ def _do_poll(record_id):
record.completed_at = timezone.now()
record.save(update_fields=[
'status', 'result_url', 'thumbnail_url', 'error_message', 'raw_error',
'status', 'result_url', 'error_message', 'raw_error',
'seed', 'completed_at',
])
@ -88,35 +87,17 @@ def _do_poll(record_id):
def _handle_completed(record, ark_resp):
"""Process a completed task: persist video to TOS, extract thumbnail, settle payment."""
import os
"""Process a completed task: persist video to TOS and settle payment."""
from utils.airdrama_client import extract_video_url
video_url = extract_video_url(ark_resp)
if video_url:
# Download once to temp file, reuse for TOS upload + thumbnail extraction
tmp_path = None
try:
from utils.media_utils import download_to_temp, extract_video_info_from_file
from utils.tos_client import upload_from_file_path, upload_file
tmp_path = download_to_temp(video_url, '.mp4')
# Upload video to TOS from file (streaming, no full memory load)
record.result_url = upload_from_file_path(tmp_path, folder='results', content_type='video/mp4')
# Extract thumbnail from the same local file (no second download)
thumb_file, _ = extract_video_info_from_file(tmp_path)
if thumb_file:
record.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
from utils.tos_client import upload_from_url
record.result_url = upload_from_url(video_url, folder='results')
except Exception:
logger.exception('poll_video_task: failed to persist video / extract thumbnail')
if not record.result_url:
logger.exception('poll_video_task: failed to persist video to TOS')
record.result_url = video_url
record.error_message = '视频保存失败临时链接将在24小时后过期请联系管理员'
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
# 结算:按实际 tokens 扣费
usage = ark_resp.get('usage', {})
@ -131,27 +112,29 @@ def _handle_completed(record, ark_resp):
@shared_task(ignore_result=True)
def recover_stuck_tasks():
"""每30秒扫一次所有进行中的任务统一派发轮询。
poll_video_task 是一次性任务不再自己 retry由这里统一驱动
"""
"""定时扫描卡在 processing/queued 超过 3 分钟的任务,重新派发轮询。"""
from datetime import timedelta
from django.utils import timezone
from apps.generation.models import GenerationRecord
active_records = GenerationRecord.objects.filter(
cutoff = timezone.now() - timedelta(minutes=3)
stuck_records = GenerationRecord.objects.filter(
status__in=('queued', 'processing'),
ark_task_id__isnull=False,
).exclude(ark_task_id='').values_list('id', flat=True)
updated_at__lt=cutoff,
).exclude(ark_task_id='')
count = 0
for record_id in active_records:
for record in stuck_records:
logger.warning('recover_stuck_tasks: re-dispatching record=%s ark=%s', record.id, record.ark_task_id)
try:
poll_video_task.delay(record_id)
poll_video_task.delay(record.id)
count += 1
except Exception:
logger.error('recover_stuck_tasks: failed to dispatch record=%s', record_id)
logger.error('recover_stuck_tasks: failed to dispatch record=%s', record.id)
if count:
logger.info('recover_stuck_tasks: dispatched %d active tasks', count)
logger.info('recover_stuck_tasks: re-dispatched %d stuck tasks', count)
def _handle_failed(record, ark_resp):
@ -172,44 +155,3 @@ 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 if dur > 0 else None # None = ffprobe failed, frontend skips duration check
asset.save(update_fields=['thumbnail_url', 'duration'])
# Atomic update: only set group thumbnail if still empty (concurrent-safe)
from apps.generation.models import AssetGroup
from django.db import transaction
try:
with transaction.atomic():
group = AssetGroup.objects.select_for_update().get(pk=asset.group_id)
if not group.thumbnail_url and asset.thumbnail_url:
group.thumbnail_url = asset.thumbnail_url
group.save(update_fields=['thumbnail_url'])
except AssetGroup.DoesNotExist:
logger.warning('process_asset_media: group %s deleted, skipping thumbnail update', asset.group_id)
elif asset.asset_type == 'Audio':
dur = get_audio_duration(asset.url)
asset.duration = dur if dur > 0 else None
asset.save(update_fields=['duration'])
logger.info('process_asset_media: asset %s done (type=%s, dur=%s)', 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, group__team=team, status__in=['active', 'processing']
group_id=gid, 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,45 +349,7 @@ def video_generate_view(request):
snap['thumb_url'] = thumb_url
reference_snapshots.append(snap)
# 单素材引用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 素材
# 转换 asset://group-{id} → 展开为组内所有 active 素材(全发)
if url.startswith('asset://group-'):
try:
group_id = int(url.replace('asset://group-', ''))
@ -425,12 +387,12 @@ def video_generate_view(request):
item['role'] = role
content_items.append(item)
elif ref_type == 'video':
item = {'type': 'video_url', 'video_url': {'url': url}}
item = {'type': 'video_url', 'video_url': {'url': resolved_url}}
if role:
item['role'] = role
content_items.append(item)
elif ref_type == 'audio':
item = {'type': 'audio_url', 'audio_url': {'url': url}}
item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}}
if role:
item['role'] = role
content_items.append(item)
@ -641,7 +603,6 @@ 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,
@ -3046,13 +3007,15 @@ 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)
# Validate file BEFORE creating group (prevent orphan records)
asset_type = None
if file:
# Detect asset type and validate format/size
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
@ -3074,6 +3037,17 @@ 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 = ''
@ -3083,28 +3057,7 @@ def asset_groups_view(request):
if result is not None:
remote_group_id = result
# 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,
)
# Create remote asset
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)
@ -3113,7 +3066,16 @@ def asset_groups_view(request):
if result is not None:
remote_asset_id = result
asset_obj = Asset.objects.create(
# 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(
group=group,
remote_asset_id=remote_asset_id,
name=name,
@ -3122,32 +3084,23 @@ 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': Asset.objects.filter(group=group).count(),
'asset_count': 1,
'created_at': group.created_at.isoformat(),
}, status=status.HTTP_201_CREATED)
@api_view(['GET', 'PUT', 'DELETE'])
@api_view(['GET', 'PUT'])
@permission_classes([IsTeamMember])
@parser_classes([JSONParser])
def asset_group_detail_view(request, group_id):
"""GET /api/v1/assets/groups/<id> — group info + assets.
PUT /api/v1/assets/groups/<id> update name/description.
DELETE /api/v1/assets/groups/<id> delete entire group + all assets.
"""
team = request.user.team
try:
@ -3155,20 +3108,6 @@ def asset_group_detail_view(request, group_id):
except AssetGroup.DoesNotExist:
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND)
if request.method == 'DELETE':
# Delete all remote assets in this group
from utils import assets_client
for asset in Asset.objects.filter(group=group):
if asset.remote_asset_id:
try:
assets_client.delete_asset(asset.remote_asset_id)
except Exception as e:
logger.warning('Failed to delete remote asset %s: %s', asset.remote_asset_id, e)
# Delete local records
Asset.objects.filter(group=group).delete()
group.delete()
return Response({'message': '素材组已删除'})
if request.method == 'GET':
# 同步火山端的素材组名字
if group.remote_group_id:
@ -3190,8 +3129,6 @@ 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,
@ -3293,7 +3230,7 @@ def asset_group_add_asset_view(request, group_id):
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': '文件上传失败,请稍后重试'},
{'error': f'文件上传失败: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@ -3319,27 +3256,16 @@ def asset_group_add_asset_view(request, group_id):
error_message='',
)
# Atomic: set group thumbnail only if still empty (concurrent-safe)
if asset_type == 'Image':
from django.db import transaction
with transaction.atomic():
locked_group = AssetGroup.objects.select_for_update().get(pk=group.id)
if not locked_group.thumbnail_url:
locked_group.thumbnail_url = tos_url
locked_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)
# If first asset or no thumbnail, set thumbnail
if not group.thumbnail_url:
group.thumbnail_url = tos_url
group.save(update_fields=['thumbnail_url'])
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(),
@ -3369,17 +3295,14 @@ def asset_update_view(request, asset_id):
group = asset.group
asset.delete()
# Update group thumbnail: prefer Image > Video (with thumbnail) > empty
remaining_img = Asset.objects.filter(group=group, asset_type='Image').exclude(status='failed').first()
remaining_vid = Asset.objects.filter(group=group, asset_type='Video').exclude(status='failed').exclude(thumbnail_url='').first()
if remaining_img:
new_thumb = remaining_img.url
elif remaining_vid:
new_thumb = remaining_vid.thumbnail_url
# 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
group.save(update_fields=['thumbnail_url'])
else:
new_thumb = ''
if group.thumbnail_url != new_thumb:
group.thumbnail_url = new_thumb
group.thumbnail_url = ''
group.save(update_fields=['thumbnail_url'])
return Response({'message': '素材已删除'})
@ -3409,29 +3332,26 @@ def asset_update_view(request, asset_id):
@api_view(['GET'])
@permission_classes([IsTeamMember])
def asset_search_view(request):
"""GET /api/v1/assets/search?q=... — search individual assets for @ popup."""
"""GET /api/v1/assets/search?q=... — fast search for @ popup."""
team = request.user.team
q = request.query_params.get('q', '').strip()[:100] # 限制搜索长度
q = request.query_params.get('q', '').strip()
if not q:
return Response({'results': []})
assets = (
Asset.objects
.filter(group__team=team, name__icontains=q, status='active')
.select_related('group')
groups = (
AssetGroup.objects
.filter(team=team, name__icontains=q)
.annotate(asset_count=Count('assets'))
.order_by('-created_at')[:20]
)
results = []
for a in assets:
for g in groups:
results.append({
'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,
'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,
})
return Response({'results': results})

View File

@ -182,7 +182,7 @@ CELERY_TIMEZONE = 'Asia/Shanghai'
CELERY_BEAT_SCHEDULE = {
'recover-stuck-tasks': {
'task': 'apps.generation.tasks.recover_stuck_tasks',
'schedule': 10, # 每 10 秒
'schedule': 180, # 每 3 分钟
},
}

View File

@ -1,134 +0,0 @@
"""Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe.
WARNING: These functions download files and run subprocess commands.
They MUST only be called from Celery tasks, NEVER from HTTP request handlers.
Calling from gunicorn (especially with gevent workers) will block the worker pool.
"""
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.
Only accepts http/https URLs to prevent SSRF.
"""
if not url.startswith(('http://', 'https://')):
raise ValueError(f'Invalid URL scheme: {url[:30]}')
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_from_file(video_path: str) -> tuple:
"""Extract first frame thumbnail + duration from a local video file.
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
Does NOT delete the input file caller is responsible for cleanup.
"""
tmp_thumb = None
try:
duration = _get_duration_ffprobe(video_path)
tmp_thumb = video_path + '_thumb.jpg'
if _extract_first_frame(video_path, 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_from_file failed: %s', e)
return None, 0
finally:
if tmp_thumb and os.path.exists(tmp_thumb):
os.unlink(tmp_thumb)
def extract_video_info(video_url: str) -> tuple:
"""Extract first frame thumbnail + duration from a video URL.
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
NOTE: This function downloads the full video. For large files, call from
Celery tasks only never from HTTP request handlers.
"""
tmp_video = None
try:
suffix = '.mp4'
if '.mov' in video_url.lower():
suffix = '.mov'
tmp_video = download_to_temp(video_url, suffix)
return extract_video_info_from_file(tmp_video)
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)
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

@ -56,10 +56,8 @@ def upload_file(file_obj, folder='uploads'):
client.head_object(bucket=settings.TOS_BUCKET, key=key)
logger.info('TOS dedup hit: %s', key)
return url
except Exception as e:
err_str = str(e).lower()
if '404' not in err_str and 'not found' not in err_str and 'nosuchkey' not in err_str:
logger.warning('TOS head_object unexpected error (proceeding with upload): %s', e)
except Exception:
pass # Object doesn't exist, proceed with upload
client.put_object(
bucket=settings.TOS_BUCKET,
@ -71,44 +69,6 @@ def upload_file(file_obj, folder='uploads'):
return url
def upload_from_file_path(file_path, folder='uploads', content_type=None):
"""Upload a local file to TOS by path (streaming, no full memory load).
Returns the permanent CDN URL.
"""
ext = file_path.rsplit('.', 1)[-1].lower() if '.' in file_path else 'bin'
if not content_type:
content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream')
# Use content hash for dedup
h = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
h.update(chunk)
content_hash = h.hexdigest()
key = f'{folder}/{content_hash}.{ext}'
url = f'{settings.TOS_CDN_DOMAIN}/{key}'
client = get_tos_client()
try:
client.head_object(bucket=settings.TOS_BUCKET, key=key)
logger.info('TOS dedup hit: %s', key)
return url
except Exception as e:
# Only proceed if object not found (404). Re-raise on auth/config errors.
err_str = str(e).lower()
if '404' not in err_str and 'not found' not in err_str and 'nosuchkey' not in err_str:
logger.warning('TOS head_object unexpected error (proceeding with upload): %s', e)
with open(file_path, 'rb') as f:
client.put_object(
bucket=settings.TOS_BUCKET,
key=key,
content=f,
content_type=content_type,
)
return url
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

View File

@ -1,118 +0,0 @@
# 部署操作手册
> 本文档说明如何将代码推送到测试环境和生产环境。
> 日常开发在 `dev` 分支,生产发布通过合并到 `master` 分支触发。
---
## 环境说明
| 环境 | 触发分支 | 镜像仓库 | K3s 集群 | 域名 |
|------|---------|---------|---------|------|
| 测试development | `dev` | `cr.volces.com/zyc/...` | `192.168.0.129:6443` | `airflow-studio.test.airlabs.art` |
| 生产production | `master` | `gitea-prod-cn-shanghai.cr.volces.com/prod/...` | `192.168.0.130:6443` | `airflow-studio.airlabs.art` |
---
## 推送到测试环境
只需要把代码推到 `dev` 分支CI/CD 自动触发。
```bash
# 确认当前在 dev 分支
git checkout dev
# 提交代码
git add .
git commit -m "feat: 你的改动描述"
# 推送触发构建
git push origin dev
```
构建完成后在 Gitea Actions 查看进度:
- Build and Push Backend ✅
- Build and Push Web ✅
- Setup Kubectl ✅
- Deploy to K3s ✅
---
## 推送到生产环境
> ⚠️ **注意**:操作完成后必须切回 `dev` 分支,不要在 `master` 上继续开发。
### 完整流程
```bash
# 1. 确保 dev 分支代码是最新的
git checkout dev
git pull origin dev
# 2. 切换到 master 分支
git checkout master
# 3. 合并 dev 的代码
git merge dev
# 4. 推送到远程,触发生产构建
git push origin master
# 5. ⚠️ 立刻切回 dev不要停留在 master
git checkout dev
```
### 如果有合并冲突
```bash
# 解决冲突后
git add .
git commit -m "merge: dev into master"
git push origin master
git checkout dev
```
---
## 构建失败排查
### Build and Push 失败docker pull 超时)
Docker 镜像拉取超时CI 会自动重试 3 次。如仍失败,检查构建机网络。
### Setup Kubectl 失败command not found
kubectl 未安装或下载失败CI 会自动从 daocloud 镜像安装。
### Deploy to K3s 失败i/o timeout
K3s API Server 连接超时CI 会自动重试 3 次(每次间隔 10 秒)。
- 若持续失败,检查 K3s 节点状态:`kubectl get nodes`
- 确认 kubeconfig secret`VOLCANO_TEST_KUBE_CONFIG` / `VOLCANO_PROD_KUBE_CONFIG`)有值
---
## 快速检查部署状态
```bash
# 测试环境
ssh root@14.103.63.199
kubectl get pods -n default
# 生产环境
ssh root@118.196.0.100
kubectl get pods -n default
```
---
## Celery Worker 监控
Celery worker 负责轮询火山 API 的视频生成状态。
```bash
# 查看 worker 日志(测试环境)
kubectl logs -f deployment/celery-worker -n default
# 查看队列积压(测试环境 Redis
redis-cli -h redis-shzlsczo52dft8mia.redis.ivolces.com -p 6379 -a Zyc188208 llen celery
```
`recover_stuck_tasks` 定时任务每 3 分钟自动扫描卡住的任务并重新入队,无需手动干预。

View File

@ -24,9 +24,8 @@ server {
client_max_body_size 50m;
}
# Cache static assets (JS/CSS/images built by Vite into dist/assets/)
# Use regex to only match actual files with extensions, not bare /assets path
location ~* ^/assets/.+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp4|webm)$ {
# Cache static assets (must be before SPA fallback)
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}

View File

@ -1,12 +0,0 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test/e2e',
timeout: 30000,
retries: 0,
use: {
baseURL: 'https://airflow-studio.test.airlabs.art',
headless: true,
screenshot: 'only-on-failure',
},
});

View File

@ -50,7 +50,7 @@ export default function App() {
}
/>
<Route
path="/user-assets"
path="/assets"
element={
<ProtectedRoute requireTeamMember>
<AssetsPage />

View File

@ -233,29 +233,6 @@
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, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useAssetLibraryStore } from '../store/assetLibrary';
import { assetsApi, tosThumb } from '../lib/api';
import { showToast } from './Toast';
@ -102,7 +102,11 @@ 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);
@ -110,6 +114,7 @@ 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);
@ -173,22 +178,29 @@ export function AssetLibraryModal({ open, onClose }: Props) {
const handleUploadSubmit = useCallback(async () => {
const trimmed = newName.trim();
if (!trimmed) return;
if (!trimmed || !uploadFile) return;
if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; }
if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; }
setUploading(true);
const result = await createGroup(trimmed, null);
const result = await createGroup(newName.trim(), uploadFile);
setUploading(false);
if (result) {
pollAssetStatus(result.id);
setNewName('');
// 创建成功后直接进入详情页
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);
setUploadFile(null);
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
setUploadPreview(null);
handleBackToList();
}
}, [newName, createGroup, 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]);
const refreshGroupDetail = useCallback(async () => {
if (!selectedGroup) return;
@ -223,13 +235,21 @@ export function AssetLibraryModal({ open, onClose }: Props) {
clearInterval(pollInterval);
}
}, 3000);
const typeLabel = file.type.startsWith('video/') ? '视频' : file.type.startsWith('audio/') ? '音频' : '图片';
showToast(`${typeLabel}已上传,处理中...`);
showToast('图片已上传,处理中...');
} 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 (
@ -246,7 +266,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
</button>
)}
<span className={styles.title}>
{view === 'list' && '人物素材库'}
{view === 'list' && '素材库'}
{view === 'detail' && (selectedGroup?.name || '角色详情')}
{view === 'upload' && '上传新角色'}
</span>
@ -279,7 +299,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} />
)}
@ -363,20 +383,6 @@ export function AssetLibraryModal({ open, onClose }: Props) {
>
&#9998;
</button>
<button
className={styles.actionBtnOutline}
style={{ color: '#ef4444', borderColor: '#ef4444' }}
onClick={() => {
if (confirm('确认删除整个素材组?组内所有素材将被删除,此操作不可撤销。')) {
assetsApi.deleteGroup(selectedGroup.id).then(() => {
showToast('素材组已删除');
handleBackToList();
}).catch(() => showToast('删除失败,请重试'));
}
}}
>
</button>
</div>
{editingName && editingName.id === selectedGroup.id && (
@ -428,14 +434,30 @@ 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' ? (
<img src={tosThumb(asset.thumbnail_url || asset.url, 300)} alt={asset.name} className={styles.assetThumb} />
<video src={asset.url} className={styles.assetThumb} muted preload="metadata" />
) : assetType === 'Audio' ? (
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}></div>
) : (
@ -482,52 +504,15 @@ 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 — only name, no file */}
{/* Upload View */}
{view === 'upload' && (
<div className={styles.uploadForm}>
<div>
@ -538,19 +523,59 @@ 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 style={{ fontSize: 12, color: 'var(--color-text-disabled)', marginTop: 4 }}>
<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>
)}
<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() || uploading}
disabled={!newName.trim() || !uploadFile || uploading}
onClick={handleUploadSubmit}
>
{uploading ? '创建中...' : '创建角色'}
{uploading ? '上传中...' : '确认上传'}
</button>
</div>
)}

View File

@ -37,11 +37,10 @@ const DownloadIcon = () => (
);
// Mention tag with thumbnail + hover preview
function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) {
function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
const [hover, setHover] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
const isAudio = assetType === 'Audio' || assetType === 'audio';
return (
<>
@ -49,7 +48,7 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
ref={ref}
className={styles.mentionTag}
onMouseEnter={() => {
if (!isAudio && thumbUrl && ref.current) {
if (thumbUrl && ref.current) {
const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
setHover(true);
@ -57,15 +56,13 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
}}
onMouseLeave={() => setHover(false)}
>
{isAudio ? (
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}></span>
) : thumbUrl ? (
{thumbUrl && (
<img
src={tosThumb(thumbUrl, 28)}
alt=""
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
/>
) : null}
)}
{label}
</span>
{hover && thumbUrl && createPortal(
@ -82,22 +79,16 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
// Render prompt text with @mentions as styled tags (thumbnail + hover preview)
export function renderPromptWithMentions(
text: string,
assetMentions: Record<string, unknown>[],
assetMentions: { label: string; thumbUrl?: string }[],
references: { label: string; previewUrl?: string }[]
) {
// Build lookup: label → { thumbUrl, assetType }
const thumbMap = new Map<string, { thumbUrl: string; assetType: string }>();
// Build lookup: label → thumbUrl
const thumbMap = new Map<string, string>();
for (const am of assetMentions) {
if (am.label) thumbMap.set(am.label as string, {
thumbUrl: (am.thumbUrl as string) || '',
assetType: (am.assetType as string) || 'image',
});
if (am.label) thumbMap.set(am.label, am.thumbUrl || '');
}
for (const r of references) {
if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, {
thumbUrl: r.previewUrl || '',
assetType: (r as Record<string, unknown>).type as string || 'image',
});
if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, r.previewUrl || '');
}
const labels = [...thumbMap.keys()];
@ -115,8 +106,7 @@ export function renderPromptWithMentions(
if (regex.test(part)) {
regex.lastIndex = 0;
const label = part.slice(1); // remove @
const info = thumbMap.get(label);
return <MentionTag key={i} label={label} thumbUrl={info?.thumbUrl} assetType={info?.assetType} />;
return <MentionTag key={i} label={label} thumbUrl={thumbMap.get(label)} />;
}
regex.lastIndex = 0;
return part;

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,9 +2,7 @@ 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, AssetSearchResult } from '../types';
import { parseAssetMentionsFromDOM } from '../lib/assetMentions';
import { showToast } from './Toast';
import type { UploadedFile, AssetGroup } from '../types';
import styles from './PromptInput.module.css';
const placeholders: Record<string, string> = {
@ -29,7 +27,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<AssetSearchResult[]>([]);
const [assetSearchResults, setAssetSearchResults] = useState<AssetGroup[]>([]);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-focus
@ -42,7 +40,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-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration', '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-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 || [];
@ -66,7 +64,6 @@ 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;
@ -75,18 +72,10 @@ 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;
// Render icon/thumbnail based on type
const isAudio = opts.refType === 'audio' || opts.assetType === 'Audio';
if (isAudio) {
if (opts.refType === 'audio') {
const icon = document.createElement('span');
icon.textContent = '\u266B';
icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none';
@ -113,40 +102,19 @@ 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;
assetId?: string; assetType?: string; assetName?: string; duration?: string;
};
type MatchTarget = { label: string; refId: string; refType: string; thumbUrl: string; assetGroupId?: string; groupName?: string };
const targets: MatchTarget[] = [
...references.map((ref) => ({
label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl,
})),
...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,
};
}),
...currentAssetMentions.map((am) => ({
label: am.label, refId: am.groupId, refType: 'asset', thumbUrl: am.thumbUrl || '',
assetGroupId: am.groupId, groupName: am.label,
})),
];
if (targets.length === 0) return;
// Sort targets by label length descending — longer labels match first
// Prevents "苏晓雨" from stealing the match before "苏晓雨音频"
targets.sort((a, b) => b.label.length - a.label.length);
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = [];
@ -192,10 +160,6 @@ export function PromptInput() {
thumbUrl: m.target.thumbUrl,
assetGroupId: m.target.assetGroupId,
groupName: m.target.groupName,
assetId: m.target.assetId,
assetType: m.target.assetType,
assetName: m.target.assetName,
duration: m.target.duration,
});
frag.appendChild(span);
lastIdx = m.end;
@ -276,16 +240,6 @@ export function PromptInput() {
}
}, [references, extractText]);
// Sync editorHtml immediately on ANY DOM change (backspace delete, etc.)
// Without this, deleting a mention span doesn't update editorHtml until next input event
useEffect(() => {
const el = editorRef.current;
if (!el) return;
const observer = new MutationObserver(() => extractText());
observer.observe(el, { childList: true, subtree: true, characterData: true });
return () => observer.disconnect();
}, [extractText]);
const handleInput = useCallback(() => {
extractText();
@ -331,7 +285,7 @@ export function PromptInput() {
} else {
setShowMentionPopup(false);
}
}).catch(() => { showToast('素材搜索失败,请重试'); });
}).catch(() => {});
}, 300);
} else if (textAfterAt.includes(' ')) {
// Space after @ text, close popup
@ -393,35 +347,7 @@ export function PromptInput() {
extractText();
}, [extractText]);
const insertAssetMention = useCallback((asset: AssetSearchResult) => {
// Instant check: count limit
const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } };
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.asset_type === 'Video' || asset.asset_type === 'Audio') {
if (!asset.duration) {
// Duration unknown (still processing or ffprobe failed) — warn but allow
showToast('该素材时长未确定,提交时将由服务端校验');
} else {
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;
}
}
}
const insertAssetMention = useCallback((group: AssetGroup) => {
setShowMentionPopup(false);
setMentionMode('references');
setAssetSearchResults([]);
@ -452,16 +378,14 @@ export function PromptInput() {
range.deleteContents();
// Create mention span for individual asset
// Create mention span for asset with thumbnail
const mention = createMentionSpan({
refId: String(asset.id),
refId: String(group.id),
refType: 'asset',
label: asset.name,
thumbUrl: asset.thumbnail_url || asset.url,
assetId: String(asset.id),
assetType: asset.asset_type,
assetName: asset.name,
duration: asset.duration != null ? String(asset.duration) : '',
label: group.name,
thumbUrl: group.thumbnail_url,
assetGroupId: String(group.id),
groupName: group.name,
});
range.insertNode(mention);
@ -476,7 +400,7 @@ export function PromptInput() {
sel.addRange(newRange);
extractText();
}, [extractText, editorHtml, references]);
}, [extractText]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup) {
@ -527,9 +451,8 @@ export function PromptInput() {
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',
'data-asset-group-id', 'data-group-name', 'data-thumb-url',
'draggable', 'src', 'alt', 'width', 'height', 'style',
],
});
document.execCommand('insertHTML', false, sanitized);
@ -565,15 +488,13 @@ 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: assetType === 'Video' ? 'video' : 'image',
type: 'image',
previewUrl: thumbUrl,
label: target.dataset.assetName || target.textContent || '',
label: target.dataset.groupName || target.textContent || '',
};
}
}
@ -711,32 +632,25 @@ export function PromptInput() {
)}
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
<>
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((asset, idx) => (
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((group, idx) => (
<button
key={asset.id}
key={group.id}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertAssetMention(asset);
insertAssetMention(group);
}}
>
<div className={styles.mentionThumb}>
{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} />
{group.thumbnail_url ? (
<img src={tosThumb(group.thumbnail_url, 72)} alt="" className={styles.thumbMedia} />
) : (
<span style={{ fontSize: 9, color: 'var(--color-text-disabled)' }}></span>
)}
</div>
<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>
<span className={styles.mentionLabel}>{group.name}</span>
<span className={styles.mentionType}></span>
</button>
))}
</>

View File

@ -38,8 +38,8 @@ export function Sidebar() {
<span></span>
</div>
<div
className={`${styles.navItem} ${isActive('/user-assets') ? styles.active : ''}`}
onClick={() => navigate('/user-assets')}
className={`${styles.navItem} ${isActive('/assets') ? styles.active : ''}`}
onClick={() => navigate('/assets')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />

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, AssetSearchResult,
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem,
} 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; duration?: string }[];
references: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
search_mode?: string;
seed?: number;
}) =>
@ -420,8 +420,6 @@ export const assetsApi = {
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
updateGroup: (id: number, data: { name?: string; description?: string }) =>
api.put(`/assets/groups/${id}`, data),
deleteGroup: (id: number) =>
api.delete(`/assets/groups/${id}`),
addAsset: (groupId: number, data: FormData) =>
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
updateAsset: (id: number, data: { name: string }) =>
@ -429,7 +427,7 @@ export const assetsApi = {
deleteAsset: (id: number) =>
api.delete(`/assets/${id}`),
search: (q: string) =>
api.get<{ results: AssetSearchResult[] }>('/assets/search', { params: { q } }),
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
pollStatus: (id: number) =>
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
};

View File

@ -1,44 +0,0 @@
/**
* Parse asset mention spans directly from a DOM element (real-time, no stale state).
* Use this when you have access to the editor DOM element.
*/
export function parseAssetMentionsFromDOM(el: HTMLElement): {
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 };
el.querySelectorAll('[data-ref-type="asset"]').forEach((span) => {
const t = (span as HTMLElement).dataset.assetType || 'Image';
const rawDur = parseFloat((span as HTMLElement).dataset.duration || '0');
const dur = isNaN(rawDur) ? 0 : rawDur;
if (t === 'Video') { counts.video++; durations.video += dur; }
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
else { counts.image++; }
});
return { counts, durations };
}
/**
* Parse asset mention spans from editor HTML string.
* Use this when you only have the HTML string (e.g., from store state).
*/
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 rawDur = parseFloat((el as HTMLElement).dataset.duration || '0');
const dur = isNaN(rawDur) ? 0 : rawDur; // null/undefined → NaN → 0, ffprobe 失败不计入时长
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, AssetSearchResult } from '../types';
import type { AssetGroup } from '../types';
import { showToast } from '../components/Toast';
interface AssetLibraryState {
@ -8,12 +8,12 @@ interface AssetLibraryState {
loading: boolean;
total: number;
page: number;
searchResults: AssetSearchResult[];
searchResults: AssetGroup[];
searching: boolean;
loadGroups: (page?: number) => Promise<void>;
searchAssets: (query: string) => Promise<void>;
createGroup: (name: string, file: File | null) => Promise<AssetGroup | null>;
createGroup: (name: string, file: File) => Promise<AssetGroup | null>;
pollAssetStatus: (assetId: number) => void;
}
@ -45,10 +45,10 @@ export const useAssetLibraryStore = create<AssetLibraryState>((set) => ({
}
},
createGroup: async (name: string, file: File | null) => {
createGroup: async (name: string, file: File) => {
const formData = new FormData();
formData.append('name', name);
if (file) formData.append('file', file);
formData.append('file', file);
try {
const { data } = await assetsApi.createGroup(formData);
showToast('角色创建成功');

View File

@ -84,17 +84,8 @@ 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 || '', assetType: 'image', duration: 0 };
return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '' };
});
}
@ -118,7 +109,6 @@ 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,
@ -359,7 +349,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
].filter(Boolean) as ReferenceSnapshot[];
// Extract asset mentions for placeholder display
const placeholderAssetMentions: Record<string, unknown>[] = [];
const placeholderAssetMentions: { groupId: string; label: string; thumbUrl: string }[] = [];
if (input.editorHtml) {
const parser = new DOMParser();
const doc = parser.parseFromString(input.editorHtml, 'text/html');
@ -420,7 +410,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; duration?: string }[] = [];
const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = [];
for (const item of filesToUpload) {
if (item.tosUrl && !item.tosUrl.startsWith('blob:')) {
@ -432,35 +422,18 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
}
}
// Extract asset mentions from editor HTML — deduplicate by assetId
const seenAssetIds = new Set<string>();
// Extract asset mentions from editor HTML — deduplicate by groupId
const seenGroupIds = 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 (!seenAssetIds.has(`group-${groupId}`)) {
seenAssetIds.add(`group-${groupId}`);
if (groupId && !seenGroupIds.has(groupId)) {
seenGroupIds.add(groupId);
uploadedRefs.push({
url: `asset://group-${groupId}`,
type: 'image',
@ -469,32 +442,18 @@ 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) {
// 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}`);
if (am.groupId && !seenGroupIds.has(am.groupId)) {
seenGroupIds.add(am.groupId);
uploadedRefs.push({
url: `asset://group-${am.groupId}`,
type: 'image',

View File

@ -2,7 +2,6 @@ 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;
@ -124,8 +123,7 @@ interface InputBarState {
setSeedEnabled: (enabled: boolean) => void;
// Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assetMentions: Record<string, any>[];
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
// @ trigger (for toolbar button to insert @ in contentEditable)
insertAtTrigger: number;
@ -172,13 +170,9 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prevReferences: [],
addReferences: (files) => {
const state = get();
// Count existing references by type + merge @ asset mentions
// Count existing references by type
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[] = [];
@ -502,13 +496,11 @@ async function _validateAndAddMedia(files: File[]) {
}
}
// Total duration check (same type) — merge @ asset mention durations
// Total duration check (same type)
const state = useInputBarStore.getState();
const { durations: assetDurations } = parseAssetMentions(state.editorHtml);
const refDuration = state.references
const existingDuration = 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,12 +44,10 @@ export interface GenerationTask {
aspectRatio: AspectRatio;
duration: Duration;
references: ReferenceSnapshot[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assetMentions: Record<string, any>[];
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
status: TaskStatus;
progress: number;
resultUrl?: string;
thumbnailUrl?: string;
errorMessage?: string;
createdAt: number;
tokensConsumed?: number;
@ -73,7 +71,6 @@ 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;
@ -439,21 +436,8 @@ export interface AssetItem {
name: string;
url: string;
asset_type: 'Image' | 'Video' | 'Audio';
thumbnail_url: string;
duration: number | null;
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 | null;
}

View File

@ -1,411 +0,0 @@
/**
* v0.18.0 E2E Tests run against test environment
* Tests: asset search, asset library, generation page interactions
*/
import { test, expect, Page } from '@playwright/test';
const BASE_URL = 'https://airflow-studio.test.airlabs.art';
const API_URL = 'https://airflow-studio-api.test.airlabs.art';
const USERNAME = 'tudou';
const PASSWORD = 'seaislee';
let accessToken = '';
// Login and get token
async function login(page: Page) {
const resp = await page.request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
expect(resp.ok()).toBeTruthy();
const body = await resp.json();
accessToken = body.tokens.access;
// Set tokens in localStorage and navigate
await page.goto(BASE_URL);
await page.evaluate(({ access, refresh }) => {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}, { access: body.tokens.access, refresh: body.tokens.refresh });
await page.goto(`${BASE_URL}/app`);
await page.waitForTimeout(2000);
}
// ─── API Tests ───
test.describe('Backend API Tests', () => {
test('asset search returns individual assets (not groups)', async ({ request }) => {
// Login
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Search for assets
const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=test`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
expect(searchResp.ok()).toBeTruthy();
const data = await searchResp.json();
// Should return results array
expect(data).toHaveProperty('results');
expect(Array.isArray(data.results)).toBeTruthy();
// Each result should have individual asset fields (not group fields)
if (data.results.length > 0) {
const asset = data.results[0];
expect(asset).toHaveProperty('id');
expect(asset).toHaveProperty('name');
expect(asset).toHaveProperty('url');
expect(asset).toHaveProperty('asset_type');
expect(asset).toHaveProperty('group_name');
expect(asset).toHaveProperty('thumbnail_url');
expect(asset).toHaveProperty('duration');
// Should NOT have group-level fields
expect(asset).not.toHaveProperty('asset_count');
expect(asset).not.toHaveProperty('remote_group_id');
}
});
test('asset search only returns active assets', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=a`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
const data = await searchResp.json();
// All returned assets should be active
for (const asset of data.results) {
// Search API doesn't return status, but only queries active ones
expect(asset).toHaveProperty('id');
}
});
test('search query is truncated at 100 chars', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const longQuery = 'a'.repeat(200);
const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=${longQuery}`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
// Should not crash
expect(searchResp.ok()).toBeTruthy();
});
test('create asset group without file', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const formData = new FormData();
formData.append('name', `test-e2e-${Date.now()}`);
// Note: multipart/form-data without file
const createResp = await request.post(`${API_URL}/api/v1/assets/groups`, {
headers: { Authorization: `Bearer ${tokens.access}` },
multipart: { name: `test-e2e-${Date.now()}` },
});
expect(createResp.ok()).toBeTruthy();
const group = await createResp.json();
expect(group).toHaveProperty('id');
expect(group.asset_count).toBe(0);
expect(group.thumbnail_url).toBe('');
});
test('delete asset endpoint works', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Get groups to find an asset to test with
const groupsResp = await request.get(`${API_URL}/api/v1/assets/groups`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
const groups = await groupsResp.json();
// Find a group with assets
for (const group of groups.results) {
if (group.asset_count > 0) {
const detailResp = await request.get(`${API_URL}/api/v1/assets/groups/${group.id}`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
const detail = await detailResp.json();
// Verify assets have asset_type and thumbnail_url fields
if (detail.assets && detail.assets.length > 0) {
const asset = detail.assets[0];
expect(asset).toHaveProperty('asset_type');
expect(asset).toHaveProperty('thumbnail_url');
expect(asset).toHaveProperty('duration');
}
break;
}
}
});
test('HEIC format accepted in upload', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Upload a fake HEIC file (just test the format check, not actual processing)
const uploadResp = await request.post(`${API_URL}/api/v1/media/upload`, {
headers: { Authorization: `Bearer ${tokens.access}` },
multipart: {
file: {
name: 'test.heic',
mimeType: 'image/heic',
buffer: Buffer.from('fake heic content'),
},
},
});
// Should not reject with "不支持的文件格式"
// It may fail for other reasons (invalid image), but format should be accepted
const status = uploadResp.status();
if (status === 400) {
const body = await uploadResp.json();
expect(body.error).not.toContain('不支持的文件格式');
}
});
test('asset://local- format accepted in generation', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Try to generate with a non-existent asset://local- reference
const genResp = await request.post(`${API_URL}/api/v1/video/generate`, {
headers: { Authorization: `Bearer ${tokens.access}` },
data: {
prompt: 'test',
mode: 'universal',
model: 'seedance_2.0',
aspect_ratio: '16:9',
duration: 5,
references: [
{ url: 'asset://local-99999', type: 'image', role: 'reference_image', label: 'test' },
],
},
});
// Should return 400 with friendly error (asset not found), not 500
expect(genResp.status()).toBe(400);
const body = await genResp.json();
expect(body.error).toBe('asset_not_found');
});
test('asset://group- format still works (backward compat)', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
// Try old format with non-existent group
const genResp = await request.post(`${API_URL}/api/v1/video/generate`, {
headers: { Authorization: `Bearer ${tokens.access}` },
data: {
prompt: 'test',
mode: 'universal',
model: 'seedance_2.0',
aspect_ratio: '16:9',
duration: 5,
references: [
{ url: 'asset://group-99999', type: 'image', role: 'reference_image', label: 'test' },
],
},
});
// Should return 400 (not ready), not 500
expect(genResp.status()).toBe(400);
const body = await genResp.json();
expect(body.error).toBe('asset_not_ready');
});
test('blob: URL rejected by backend', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const genResp = await request.post(`${API_URL}/api/v1/video/generate`, {
headers: { Authorization: `Bearer ${tokens.access}` },
data: {
prompt: 'test blob',
mode: 'universal',
model: 'seedance_2.0',
aspect_ratio: '16:9',
duration: 5,
references: [
{ url: 'blob:http://localhost/fake', type: 'image', role: 'reference_image', label: 'test' },
],
},
});
expect(genResp.status()).toBe(400);
const body = await genResp.json();
expect(body.error).toBe('upload_failed');
});
test('task detail returns thumbnail_url field', async ({ request }) => {
const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
const { tokens } = await loginResp.json();
const tasksResp = await request.get(`${API_URL}/api/v1/video/tasks?page_size=1`, {
headers: { Authorization: `Bearer ${tokens.access}` },
});
expect(tasksResp.ok()).toBeTruthy();
const data = await tasksResp.json();
if (data.results.length > 0) {
const task = data.results[0];
expect(task).toHaveProperty('thumbnail_url');
expect(task).toHaveProperty('result_url');
}
});
});
// ─── Frontend Page Tests ───
test.describe('Frontend Page Tests', () => {
test('login page loads', async ({ page }) => {
await page.goto(`${BASE_URL}/login`);
await expect(page).toHaveTitle(/AirDrama|Airflow/i);
});
test('generation page loads after login', async ({ page }) => {
await login(page);
// Should see the input bar
await expect(page.locator('text=人物素材库')).toBeVisible({ timeout: 10000 });
});
test('asset library modal opens', async ({ page }) => {
await login(page);
// Click the asset library button
await page.click('text=人物素材库');
await page.waitForTimeout(1000);
// Should see the modal
await expect(page.locator('text=上传新角色').first()).toBeVisible({ timeout: 5000 });
});
test('create group flow — name only', async ({ page }) => {
await login(page);
await page.click('text=人物素材库');
await page.waitForTimeout(1000);
// Click create
await page.click('text=上传新角色');
await page.waitForTimeout(500);
// Should see name input and "创建角色" button
await expect(page.locator('text=角色名称')).toBeVisible();
await expect(page.locator('text=创建角色')).toBeVisible();
// Should NOT see file upload area
await expect(page.locator('text=创建后可在详情页上传图片、视频、音频素材')).toBeVisible();
});
test('asset detail page shows three sections', async ({ page }) => {
await login(page);
await page.click('text=人物素材库');
await page.waitForTimeout(2000);
// Click on first group with assets (skip the empty test-e2e group)
const groupNames = page.locator('[class*="cardInfo"]');
if (await groupNames.count() > 1) {
await groupNames.nth(1).click();
await page.waitForTimeout(1000);
// Should see three sections
await expect(page.locator('text=肖像(图片)')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=视频').first()).toBeVisible();
await expect(page.locator('text=音频').first()).toBeVisible();
// Should see warning text
await expect(page.locator('text=宽高 300~6000 像素').first()).toBeVisible();
await expect(page.locator('text=时长 2~15 秒').first()).toBeVisible();
}
});
test('@ mention popup shows individual assets', async ({ page }) => {
await login(page);
// Type @ in the prompt input
const editor = page.locator('[contenteditable="true"]');
await editor.click();
await editor.type('@');
await page.waitForTimeout(500);
// If there are references, popup may show "可能@的内容"
// Type a search query
await editor.type('苏');
await page.waitForTimeout(1000);
// Check if popup appears with asset results
const popup = page.locator('text=人物素材库匹配');
if (await popup.isVisible()) {
// Should show individual asset names, not group names
// Should show type badges (图片/视频/音频)
const typeBadges = page.locator('text=图片, text=视频, text=音频');
// At least one badge should be visible
}
});
test('toast component has glass-card style', async ({ page }) => {
await login(page);
// Trigger a toast by uploading an invalid file format
// Check toast styling includes backdrop-filter
const toastEl = page.locator('[class*="toast"]');
// Toast may not be visible immediately, this is a structural check
});
test('scroll to bottom button appears', async ({ page }) => {
await login(page);
await page.waitForTimeout(2000);
// If there are enough tasks, scroll up
const contentArea = page.locator('[class*="contentArea"]');
if (await contentArea.isVisible()) {
await contentArea.evaluate((el) => el.scrollTop = 0);
await page.waitForTimeout(500);
// Check if "回到底部" button appears
const scrollBtn = page.locator('text=回到底部');
// May or may not appear depending on content height
}
});
test('assets page shows correct order (newest first)', async ({ page }) => {
await login(page);
await page.goto(`${BASE_URL}/assets`);
await page.waitForTimeout(2000);
// First date group should be "今天" or most recent date
const dateLabels = page.locator('h3');
if (await dateLabels.count() > 0) {
const firstLabel = await dateLabels.first().textContent();
// Should be "今天" or a recent date, not an old date
expect(firstLabel).toBeTruthy();
}
});
test('assets page has load more button', async ({ page }) => {
await login(page);
await page.goto(`${BASE_URL}/assets`);
await page.waitForTimeout(2000);
// If there are more than 20 videos, load more should appear
const loadMore = page.locator('text=加载更多');
// Just check it doesn't crash
});
});