Compare commits
13 Commits
95bdb0a6e8
...
5da67435b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da67435b2 | ||
| d73175b101 | |||
| f37c38d38b | |||
| 4cf9a0a4bb | |||
| 127ed9659d | |||
| ded5c4c44f | |||
|
|
ba33c35dd8 | ||
| 6353d2ec4f | |||
|
|
f1a7ad8a2f | ||
|
|
9a6d95a69d | ||
| 61bcb9576f | |||
|
|
2e72c82116 | ||
|
|
da9a1413c3 |
@ -88,7 +88,7 @@ jobs:
|
|||||||
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && break
|
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
|
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
|
||||||
done
|
done
|
||||||
chmod +x kubectl && mv kubectl /usr/local/bin/
|
chmod +x kubectl && mv kubectl /usr/bin/kubectl
|
||||||
fi
|
fi
|
||||||
kubectl version --client
|
kubectl version --client
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debia
|
|||||||
gcc \
|
gcc \
|
||||||
default-libmysqlclient-dev \
|
default-libmysqlclient-dev \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Python dependencies
|
# Python dependencies
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
backend/apps/generation/migrations/0019_duration_nullable.py
Normal file
18
backend/apps/generation/migrations/0019_duration_nullable.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# 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='时长(秒)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -42,6 +42,7 @@ class GenerationRecord(models.Model):
|
|||||||
resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率')
|
resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率')
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', 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')
|
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='错误信息')
|
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
||||||
raw_error = 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='参考素材信息')
|
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='素材名称')
|
name = models.CharField(max_length=100, default='', verbose_name='素材名称')
|
||||||
url = models.CharField(max_length=1000, blank=True, default='', verbose_name='素材URL')
|
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='素材类型')
|
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='状态')
|
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='错误信息')
|
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
|
|||||||
@ -6,44 +6,48 @@ from celery import shared_task
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=None, ignore_result=True)
|
一次性任务:查一次 API,更新 DB,结束。
|
||||||
def poll_video_task(self, record_id):
|
由 recover_stuck_tasks(beat 每10秒调度)统一驱动,不再自己 retry。
|
||||||
"""Poll Volcano API for a video generation task.
|
用 Redis 锁防止 _handle_completed 期间被重复 dispatch。
|
||||||
|
|
||||||
每次只执行一轮查询,查完通过 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 django.utils import timezone
|
||||||
from apps.generation.models import GenerationRecord
|
from apps.generation.models import GenerationRecord
|
||||||
from utils.airdrama_client import query_task, map_status
|
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:
|
try:
|
||||||
record = GenerationRecord.objects.get(pk=record_id)
|
record = GenerationRecord.objects.get(pk=record_id)
|
||||||
except GenerationRecord.DoesNotExist:
|
except GenerationRecord.DoesNotExist:
|
||||||
logger.warning('poll_video_task: record %s not found', record_id)
|
logger.warning('poll_video_task: record %s not found', record_id)
|
||||||
cache.delete(lock_key)
|
return
|
||||||
|
|
||||||
|
if record.status not in ('queued', 'processing'):
|
||||||
return
|
return
|
||||||
|
|
||||||
ark_task_id = record.ark_task_id
|
ark_task_id = record.ark_task_id
|
||||||
if not ark_task_id:
|
if not ark_task_id:
|
||||||
logger.warning('poll_video_task: record %s has no ark_task_id', record_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
|
return
|
||||||
|
|
||||||
# Poll Volcano API
|
# Poll Volcano API
|
||||||
@ -51,16 +55,13 @@ def poll_video_task(self, record_id):
|
|||||||
ark_resp = query_task(ark_task_id)
|
ark_resp = query_task(ark_task_id)
|
||||||
new_status = map_status(ark_resp.get('status', ''))
|
new_status = map_status(ark_resp.get('status', ''))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('poll_video_task: API query failed for %s, will retry', ark_task_id)
|
logger.exception('poll_video_task: API query failed for record=%s ark=%s', record_id, ark_task_id)
|
||||||
cache.delete(lock_key)
|
return
|
||||||
raise self.retry(countdown=POLL_INTERVAL)
|
|
||||||
|
|
||||||
if new_status in ('queued', 'processing'):
|
if new_status in ('queued', 'processing'):
|
||||||
# Still running — update status, then re-enqueue
|
|
||||||
record.status = new_status
|
record.status = new_status
|
||||||
record.save(update_fields=['status', 'updated_at'])
|
record.save(update_fields=['status', 'updated_at'])
|
||||||
cache.delete(lock_key)
|
return
|
||||||
raise self.retry(countdown=POLL_INTERVAL)
|
|
||||||
|
|
||||||
# Terminal state reached — process result
|
# Terminal state reached — process result
|
||||||
record.status = new_status
|
record.status = new_status
|
||||||
@ -76,7 +77,7 @@ def poll_video_task(self, record_id):
|
|||||||
|
|
||||||
record.completed_at = timezone.now()
|
record.completed_at = timezone.now()
|
||||||
record.save(update_fields=[
|
record.save(update_fields=[
|
||||||
'status', 'result_url', 'error_message', 'raw_error',
|
'status', 'result_url', 'thumbnail_url', 'error_message', 'raw_error',
|
||||||
'seed', 'completed_at',
|
'seed', 'completed_at',
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -87,17 +88,35 @@ def poll_video_task(self, record_id):
|
|||||||
|
|
||||||
|
|
||||||
def _handle_completed(record, ark_resp):
|
def _handle_completed(record, ark_resp):
|
||||||
"""Process a completed task: persist video to TOS and settle payment."""
|
"""Process a completed task: persist video to TOS, extract thumbnail, settle payment."""
|
||||||
|
import os
|
||||||
from utils.airdrama_client import extract_video_url
|
from utils.airdrama_client import extract_video_url
|
||||||
|
|
||||||
video_url = extract_video_url(ark_resp)
|
video_url = extract_video_url(ark_resp)
|
||||||
if video_url:
|
if video_url:
|
||||||
|
# Download once to temp file, reuse for TOS upload + thumbnail extraction
|
||||||
|
tmp_path = None
|
||||||
try:
|
try:
|
||||||
from utils.tos_client import upload_from_url
|
from utils.media_utils import download_to_temp, extract_video_info_from_file
|
||||||
record.result_url = upload_from_url(video_url, folder='results')
|
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')
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('poll_video_task: failed to persist video to TOS')
|
logger.exception('poll_video_task: failed to persist video / extract thumbnail')
|
||||||
|
if not record.result_url:
|
||||||
record.result_url = video_url
|
record.result_url = video_url
|
||||||
|
record.error_message = '视频保存失败,临时链接将在24小时后过期,请联系管理员'
|
||||||
|
finally:
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
# 结算:按实际 tokens 扣费
|
# 结算:按实际 tokens 扣费
|
||||||
usage = ark_resp.get('usage', {})
|
usage = ark_resp.get('usage', {})
|
||||||
@ -112,29 +131,27 @@ def _handle_completed(record, ark_resp):
|
|||||||
|
|
||||||
@shared_task(ignore_result=True)
|
@shared_task(ignore_result=True)
|
||||||
def recover_stuck_tasks():
|
def recover_stuck_tasks():
|
||||||
"""定时扫描卡在 processing/queued 超过 3 分钟的任务,重新派发轮询。"""
|
"""每30秒扫一次所有进行中的任务,统一派发轮询。
|
||||||
from datetime import timedelta
|
|
||||||
from django.utils import timezone
|
poll_video_task 是一次性任务,不再自己 retry,由这里统一驱动。
|
||||||
|
"""
|
||||||
from apps.generation.models import GenerationRecord
|
from apps.generation.models import GenerationRecord
|
||||||
|
|
||||||
cutoff = timezone.now() - timedelta(minutes=3)
|
active_records = GenerationRecord.objects.filter(
|
||||||
stuck_records = GenerationRecord.objects.filter(
|
|
||||||
status__in=('queued', 'processing'),
|
status__in=('queued', 'processing'),
|
||||||
ark_task_id__isnull=False,
|
ark_task_id__isnull=False,
|
||||||
updated_at__lt=cutoff,
|
).exclude(ark_task_id='').values_list('id', flat=True)
|
||||||
).exclude(ark_task_id='')
|
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for record in stuck_records:
|
for record_id in active_records:
|
||||||
logger.warning('recover_stuck_tasks: re-dispatching record=%s ark=%s', record.id, record.ark_task_id)
|
|
||||||
try:
|
try:
|
||||||
poll_video_task.delay(record.id)
|
poll_video_task.delay(record_id)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception:
|
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:
|
if count:
|
||||||
logger.info('recover_stuck_tasks: re-dispatched %d stuck tasks', count)
|
logger.info('recover_stuck_tasks: dispatched %d active tasks', count)
|
||||||
|
|
||||||
|
|
||||||
def _handle_failed(record, ark_resp):
|
def _handle_failed(record, ark_resp):
|
||||||
@ -155,3 +172,44 @@ def _handle_failed(record, ark_resp):
|
|||||||
else:
|
else:
|
||||||
from apps.generation.views import _release_freeze
|
from apps.generation.views import _release_freeze
|
||||||
_release_freeze(record)
|
_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)
|
||||||
|
|||||||
@ -295,7 +295,7 @@ def video_generate_view(request):
|
|||||||
"""查询本地 DB 获取组内所有 active 素材,返回 [(asset_url, asset_type), ...] 列表。
|
"""查询本地 DB 获取组内所有 active 素材,返回 [(asset_url, asset_type), ...] 列表。
|
||||||
processing 的素材会尝试实时刷新状态。"""
|
processing 的素材会尝试实时刷新状态。"""
|
||||||
assets = list(AssetModel.objects.filter(
|
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'))
|
).exclude(remote_asset_id='').order_by('created_at'))
|
||||||
if not assets:
|
if not assets:
|
||||||
logger.warning('No assets found for group %s (label=%s)', gid, lbl)
|
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
|
snap['thumb_url'] = thumb_url
|
||||||
reference_snapshots.append(snap)
|
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-'):
|
if url.startswith('asset://group-'):
|
||||||
try:
|
try:
|
||||||
group_id = int(url.replace('asset://group-', ''))
|
group_id = int(url.replace('asset://group-', ''))
|
||||||
@ -387,12 +425,12 @@ def video_generate_view(request):
|
|||||||
item['role'] = role
|
item['role'] = role
|
||||||
content_items.append(item)
|
content_items.append(item)
|
||||||
elif ref_type == 'video':
|
elif ref_type == 'video':
|
||||||
item = {'type': 'video_url', 'video_url': {'url': resolved_url}}
|
item = {'type': 'video_url', 'video_url': {'url': url}}
|
||||||
if role:
|
if role:
|
||||||
item['role'] = role
|
item['role'] = role
|
||||||
content_items.append(item)
|
content_items.append(item)
|
||||||
elif ref_type == 'audio':
|
elif ref_type == 'audio':
|
||||||
item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}}
|
item = {'type': 'audio_url', 'audio_url': {'url': url}}
|
||||||
if role:
|
if role:
|
||||||
item['role'] = role
|
item['role'] = role
|
||||||
content_items.append(item)
|
content_items.append(item)
|
||||||
@ -603,6 +641,7 @@ def _serialize_task(record):
|
|||||||
'base_cost_amount': float(record.base_cost_amount),
|
'base_cost_amount': float(record.base_cost_amount),
|
||||||
'status': record.status,
|
'status': record.status,
|
||||||
'result_url': d.get('result_url', ''),
|
'result_url': d.get('result_url', ''),
|
||||||
|
'thumbnail_url': d.get('thumbnail_url', ''),
|
||||||
'error_message': d.get('error_message', ''),
|
'error_message': d.get('error_message', ''),
|
||||||
'reference_urls': d.get('reference_urls') or [],
|
'reference_urls': d.get('reference_urls') or [],
|
||||||
'is_favorited': record.is_favorited,
|
'is_favorited': record.is_favorited,
|
||||||
@ -3007,15 +3046,13 @@ def asset_groups_view(request):
|
|||||||
return Response({'error': '请输入角色名称'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': '请输入角色名称'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
file = request.FILES.get('file')
|
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)
|
asset_type, err = _detect_asset_type(file)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
# Validate image dimensions (only for images)
|
|
||||||
if asset_type == 'Image':
|
if asset_type == 'Image':
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@ -3037,17 +3074,6 @@ def asset_groups_view(request):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Create remote group
|
||||||
from utils import assets_client
|
from utils import assets_client
|
||||||
remote_group_id = ''
|
remote_group_id = ''
|
||||||
@ -3057,7 +3083,28 @@ def asset_groups_view(request):
|
|||||||
if result is not None:
|
if result is not None:
|
||||||
remote_group_id = result
|
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 = ''
|
remote_asset_id = ''
|
||||||
if remote_group_id:
|
if remote_group_id:
|
||||||
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name, asset_type=asset_type)
|
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:
|
if result is not None:
|
||||||
remote_asset_id = result
|
remote_asset_id = result
|
||||||
|
|
||||||
# Local DB records
|
asset_obj = Asset.objects.create(
|
||||||
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,
|
group=group,
|
||||||
remote_asset_id=remote_asset_id,
|
remote_asset_id=remote_asset_id,
|
||||||
name=name,
|
name=name,
|
||||||
@ -3084,23 +3122,32 @@ def asset_groups_view(request):
|
|||||||
status='processing' if remote_asset_id else 'active',
|
status='processing' if remote_asset_id else 'active',
|
||||||
error_message='',
|
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({
|
return Response({
|
||||||
'id': group.id,
|
'id': group.id,
|
||||||
'name': group.name,
|
'name': group.name,
|
||||||
'thumbnail_url': group.thumbnail_url,
|
'thumbnail_url': group.thumbnail_url,
|
||||||
'remote_group_id': group.remote_group_id,
|
'remote_group_id': group.remote_group_id,
|
||||||
'asset_count': 1,
|
'asset_count': Asset.objects.filter(group=group).count(),
|
||||||
'created_at': group.created_at.isoformat(),
|
'created_at': group.created_at.isoformat(),
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET', 'PUT'])
|
@api_view(['GET', 'PUT', 'DELETE'])
|
||||||
@permission_classes([IsTeamMember])
|
@permission_classes([IsTeamMember])
|
||||||
@parser_classes([JSONParser])
|
@parser_classes([JSONParser])
|
||||||
def asset_group_detail_view(request, group_id):
|
def asset_group_detail_view(request, group_id):
|
||||||
"""GET /api/v1/assets/groups/<id> — group info + assets.
|
"""GET /api/v1/assets/groups/<id> — group info + assets.
|
||||||
PUT /api/v1/assets/groups/<id> — update name/description.
|
PUT /api/v1/assets/groups/<id> — update name/description.
|
||||||
|
DELETE /api/v1/assets/groups/<id> — delete entire group + all assets.
|
||||||
"""
|
"""
|
||||||
team = request.user.team
|
team = request.user.team
|
||||||
try:
|
try:
|
||||||
@ -3108,6 +3155,20 @@ def asset_group_detail_view(request, group_id):
|
|||||||
except AssetGroup.DoesNotExist:
|
except AssetGroup.DoesNotExist:
|
||||||
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND)
|
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 request.method == 'GET':
|
||||||
# 同步火山端的素材组名字
|
# 同步火山端的素材组名字
|
||||||
if group.remote_group_id:
|
if group.remote_group_id:
|
||||||
@ -3129,6 +3190,8 @@ def asset_group_detail_view(request, group_id):
|
|||||||
'name': a.name,
|
'name': a.name,
|
||||||
'url': a.url,
|
'url': a.url,
|
||||||
'asset_type': a.asset_type,
|
'asset_type': a.asset_type,
|
||||||
|
'thumbnail_url': a.thumbnail_url,
|
||||||
|
'duration': a.duration,
|
||||||
'status': a.status,
|
'status': a.status,
|
||||||
'remote_asset_id': a.remote_asset_id,
|
'remote_asset_id': a.remote_asset_id,
|
||||||
'error_message': a.error_message,
|
'error_message': a.error_message,
|
||||||
@ -3230,7 +3293,7 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception('TOS upload failed for asset')
|
logger.exception('TOS upload failed for asset')
|
||||||
return Response(
|
return Response(
|
||||||
{'error': f'文件上传失败: {e}'},
|
{'error': '文件上传失败,请稍后重试'},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -3256,16 +3319,27 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
error_message='',
|
error_message='',
|
||||||
)
|
)
|
||||||
|
|
||||||
# If first asset or no thumbnail, set thumbnail
|
# Atomic: set group thumbnail only if still empty (concurrent-safe)
|
||||||
if not group.thumbnail_url:
|
if asset_type == 'Image':
|
||||||
group.thumbnail_url = tos_url
|
from django.db import transaction
|
||||||
group.save(update_fields=['thumbnail_url'])
|
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)
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'id': asset.id,
|
'id': asset.id,
|
||||||
'name': asset.name,
|
'name': asset.name,
|
||||||
'url': asset.url,
|
'url': asset.url,
|
||||||
'asset_type': asset.asset_type,
|
'asset_type': asset.asset_type,
|
||||||
|
'thumbnail_url': asset.thumbnail_url,
|
||||||
|
'duration': asset.duration,
|
||||||
'status': asset.status,
|
'status': asset.status,
|
||||||
'remote_asset_id': asset.remote_asset_id,
|
'remote_asset_id': asset.remote_asset_id,
|
||||||
'created_at': asset.created_at.isoformat(),
|
'created_at': asset.created_at.isoformat(),
|
||||||
@ -3295,14 +3369,17 @@ def asset_update_view(request, asset_id):
|
|||||||
group = asset.group
|
group = asset.group
|
||||||
asset.delete()
|
asset.delete()
|
||||||
|
|
||||||
# Update group thumbnail if needed
|
# Update group thumbnail: prefer Image > Video (with thumbnail) > empty
|
||||||
remaining = Asset.objects.filter(group=group).exclude(status='failed').order_by('-created_at').first()
|
remaining_img = Asset.objects.filter(group=group, asset_type='Image').exclude(status='failed').first()
|
||||||
if remaining:
|
remaining_vid = Asset.objects.filter(group=group, asset_type='Video').exclude(status='failed').exclude(thumbnail_url='').first()
|
||||||
if group.thumbnail_url != remaining.url:
|
if remaining_img:
|
||||||
group.thumbnail_url = remaining.url
|
new_thumb = remaining_img.url
|
||||||
group.save(update_fields=['thumbnail_url'])
|
elif remaining_vid:
|
||||||
|
new_thumb = remaining_vid.thumbnail_url
|
||||||
else:
|
else:
|
||||||
group.thumbnail_url = ''
|
new_thumb = ''
|
||||||
|
if group.thumbnail_url != new_thumb:
|
||||||
|
group.thumbnail_url = new_thumb
|
||||||
group.save(update_fields=['thumbnail_url'])
|
group.save(update_fields=['thumbnail_url'])
|
||||||
|
|
||||||
return Response({'message': '素材已删除'})
|
return Response({'message': '素材已删除'})
|
||||||
@ -3332,26 +3409,29 @@ def asset_update_view(request, asset_id):
|
|||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsTeamMember])
|
@permission_classes([IsTeamMember])
|
||||||
def asset_search_view(request):
|
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
|
team = request.user.team
|
||||||
q = request.query_params.get('q', '').strip()
|
q = request.query_params.get('q', '').strip()[:100] # 限制搜索长度
|
||||||
if not q:
|
if not q:
|
||||||
return Response({'results': []})
|
return Response({'results': []})
|
||||||
|
|
||||||
groups = (
|
assets = (
|
||||||
AssetGroup.objects
|
Asset.objects
|
||||||
.filter(team=team, name__icontains=q)
|
.filter(group__team=team, name__icontains=q, status='active')
|
||||||
.annotate(asset_count=Count('assets'))
|
.select_related('group')
|
||||||
.order_by('-created_at')[:20]
|
.order_by('-created_at')[:20]
|
||||||
)
|
)
|
||||||
results = []
|
results = []
|
||||||
for g in groups:
|
for a in assets:
|
||||||
results.append({
|
results.append({
|
||||||
'id': g.id,
|
'id': a.id,
|
||||||
'name': g.name,
|
'name': a.name,
|
||||||
'thumbnail_url': g.thumbnail_url if g.asset_count > 0 else '',
|
'url': a.url,
|
||||||
'asset_count': g.asset_count,
|
'asset_type': a.asset_type,
|
||||||
'remote_group_id': g.remote_group_id,
|
'group_name': a.group.name,
|
||||||
|
'remote_asset_id': a.remote_asset_id,
|
||||||
|
'thumbnail_url': a.thumbnail_url,
|
||||||
|
'duration': a.duration,
|
||||||
})
|
})
|
||||||
return Response({'results': results})
|
return Response({'results': results})
|
||||||
|
|
||||||
|
|||||||
@ -182,7 +182,7 @@ CELERY_TIMEZONE = 'Asia/Shanghai'
|
|||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
'recover-stuck-tasks': {
|
'recover-stuck-tasks': {
|
||||||
'task': 'apps.generation.tasks.recover_stuck_tasks',
|
'task': 'apps.generation.tasks.recover_stuck_tasks',
|
||||||
'schedule': 180, # 每 3 分钟
|
'schedule': 10, # 每 10 秒
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
backend/utils/media_utils.py
Normal file
134
backend/utils/media_utils.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""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)
|
||||||
@ -56,8 +56,10 @@ def upload_file(file_obj, folder='uploads'):
|
|||||||
client.head_object(bucket=settings.TOS_BUCKET, key=key)
|
client.head_object(bucket=settings.TOS_BUCKET, key=key)
|
||||||
logger.info('TOS dedup hit: %s', key)
|
logger.info('TOS dedup hit: %s', key)
|
||||||
return url
|
return url
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Object doesn't exist, proceed with upload
|
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)
|
||||||
|
|
||||||
client.put_object(
|
client.put_object(
|
||||||
bucket=settings.TOS_BUCKET,
|
bucket=settings.TOS_BUCKET,
|
||||||
@ -69,6 +71,44 @@ def upload_file(file_obj, folder='uploads'):
|
|||||||
return url
|
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'):
|
def upload_from_url(source_url, folder='results'):
|
||||||
"""Download a file from a URL and upload to TOS, return permanent CDN URL."""
|
"""Download a file from a URL and upload to TOS, return permanent CDN URL."""
|
||||||
import requests as req
|
import requests as req
|
||||||
|
|||||||
118
docs/deployment-guide.md
Normal file
118
docs/deployment-guide.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# 部署操作手册
|
||||||
|
|
||||||
|
> 本文档说明如何将代码推送到测试环境和生产环境。
|
||||||
|
> 日常开发在 `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 分钟自动扫描卡住的任务并重新入队,无需手动干预。
|
||||||
@ -24,8 +24,9 @@ server {
|
|||||||
client_max_body_size 50m;
|
client_max_body_size 50m;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets (must be before SPA fallback)
|
# Cache static assets (JS/CSS/images built by Vite into dist/assets/)
|
||||||
location /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)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|||||||
12
web/playwright-test.config.ts
Normal file
12
web/playwright-test.config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -50,7 +50,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/assets"
|
path="/user-assets"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requireTeamMember>
|
<ProtectedRoute requireTeamMember>
|
||||||
<AssetsPage />
|
<AssetsPage />
|
||||||
|
|||||||
@ -233,6 +233,29 @@
|
|||||||
opacity: 1;
|
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 {
|
.assetThumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useAssetLibraryStore } from '../store/assetLibrary';
|
import { useAssetLibraryStore } from '../store/assetLibrary';
|
||||||
import { assetsApi, tosThumb } from '../lib/api';
|
import { assetsApi, tosThumb } from '../lib/api';
|
||||||
import { showToast } from './Toast';
|
import { showToast } from './Toast';
|
||||||
@ -102,11 +102,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [editingName, setEditingName] = useState<{ id: number; value: string } | null>(null);
|
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 [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const groups = useAssetLibraryStore((s) => s.groups);
|
const groups = useAssetLibraryStore((s) => s.groups);
|
||||||
const loading = useAssetLibraryStore((s) => s.loading);
|
const loading = useAssetLibraryStore((s) => s.loading);
|
||||||
@ -114,7 +110,6 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
const page = useAssetLibraryStore((s) => s.page);
|
const page = useAssetLibraryStore((s) => s.page);
|
||||||
const loadGroups = useAssetLibraryStore((s) => s.loadGroups);
|
const loadGroups = useAssetLibraryStore((s) => s.loadGroups);
|
||||||
const createGroup = useAssetLibraryStore((s) => s.createGroup);
|
const createGroup = useAssetLibraryStore((s) => s.createGroup);
|
||||||
const pollAssetStatus = useAssetLibraryStore((s) => s.pollAssetStatus);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / 20);
|
const totalPages = Math.ceil(total / 20);
|
||||||
|
|
||||||
@ -178,29 +173,22 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
|
|
||||||
const handleUploadSubmit = useCallback(async () => {
|
const handleUploadSubmit = useCallback(async () => {
|
||||||
const trimmed = newName.trim();
|
const trimmed = newName.trim();
|
||||||
if (!trimmed || !uploadFile) return;
|
if (!trimmed) return;
|
||||||
if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; }
|
if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; }
|
||||||
if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; }
|
if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; }
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
const result = await createGroup(newName.trim(), uploadFile);
|
const result = await createGroup(trimmed, null);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (result) {
|
if (result) {
|
||||||
pollAssetStatus(result.id);
|
|
||||||
setNewName('');
|
setNewName('');
|
||||||
setUploadFile(null);
|
// 创建成功后直接进入详情页
|
||||||
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
|
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() };
|
||||||
setUploadPreview(null);
|
setSelectedGroup(group);
|
||||||
handleBackToList();
|
setGroupAssets([]);
|
||||||
|
setView('detail');
|
||||||
|
loadGroups(page);
|
||||||
}
|
}
|
||||||
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
|
}, [newName, createGroup, loadGroups, page]);
|
||||||
|
|
||||||
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 () => {
|
const refreshGroupDetail = useCallback(async () => {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
@ -235,21 +223,13 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
showToast('图片已上传,处理中...');
|
const typeLabel = file.type.startsWith('video/') ? '视频' : file.type.startsWith('audio/') ? '音频' : '图片';
|
||||||
|
showToast(`${typeLabel}已上传,处理中...`);
|
||||||
} catch {
|
} catch {
|
||||||
showToast('上传失败,请重试');
|
showToast('上传失败,请重试');
|
||||||
}
|
}
|
||||||
}, [selectedGroup, refreshGroupDetail]);
|
}, [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;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -266,7 +246,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className={styles.title}>
|
<span className={styles.title}>
|
||||||
{view === 'list' && '素材库'}
|
{view === 'list' && '人物素材库'}
|
||||||
{view === 'detail' && (selectedGroup?.name || '角色详情')}
|
{view === 'detail' && (selectedGroup?.name || '角色详情')}
|
||||||
{view === 'upload' && '上传新角色'}
|
{view === 'upload' && '上传新角色'}
|
||||||
</span>
|
</span>
|
||||||
@ -299,7 +279,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(group)}>
|
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(group)}>
|
||||||
{group.asset_count === 0 ? (
|
{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} />
|
<img src={tosThumb(group.thumbnail_url, 300)} alt={group.name} className={styles.cardThumb} />
|
||||||
)}
|
)}
|
||||||
@ -383,6 +363,20 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
>
|
>
|
||||||
✎ 改名
|
✎ 改名
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtnOutline}
|
||||||
|
style={{ color: '#ef4444', borderColor: '#ef4444' }}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('确认删除整个素材组?组内所有素材将被删除,此操作不可撤销。')) {
|
||||||
|
assetsApi.deleteGroup(selectedGroup.id).then(() => {
|
||||||
|
showToast('素材组已删除');
|
||||||
|
handleBackToList();
|
||||||
|
}).catch(() => showToast('删除失败,请重试'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除素材组
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingName && editingName.id === selectedGroup.id && (
|
{editingName && editingName.id === selectedGroup.id && (
|
||||||
@ -434,30 +428,14 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
<div key={assetType} style={{ marginBottom: 20 }}>
|
<div key={assetType} style={{ marginBottom: 20 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{typeLabel}</span>
|
<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>
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</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>
|
<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}>
|
<div className={styles.assetGrid}>
|
||||||
{typeAssets.map((asset) => (
|
{typeAssets.map((asset) => (
|
||||||
<div key={asset.id} className={styles.assetCard}>
|
<div key={asset.id} className={styles.assetCard}>
|
||||||
{assetType === 'Video' ? (
|
{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' ? (
|
) : assetType === 'Audio' ? (
|
||||||
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}>♫</div>
|
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}>♫</div>
|
||||||
) : (
|
) : (
|
||||||
@ -504,15 +482,52 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upload View */}
|
{/* Upload View — only name, no file */}
|
||||||
{view === 'upload' && (
|
{view === 'upload' && (
|
||||||
<div className={styles.uploadForm}>
|
<div className={styles.uploadForm}>
|
||||||
<div>
|
<div>
|
||||||
@ -523,59 +538,19 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
maxLength={64}
|
maxLength={64}
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleUploadSubmit(); }}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
)}
|
|
||||||
<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~15秒,≤50MB | 音频:2~15秒,≤15MB</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
|
<button
|
||||||
className={styles.submitBtn}
|
className={styles.submitBtn}
|
||||||
disabled={!newName.trim() || !uploadFile || uploading}
|
disabled={!newName.trim() || uploading}
|
||||||
onClick={handleUploadSubmit}
|
onClick={handleUploadSubmit}
|
||||||
>
|
>
|
||||||
{uploading ? '上传中...' : '确认上传'}
|
{uploading ? '创建中...' : '创建角色'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -37,10 +37,11 @@ const DownloadIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Mention tag with thumbnail + hover preview
|
// Mention tag with thumbnail + hover preview
|
||||||
function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
|
function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) {
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||||
|
const isAudio = assetType === 'Audio' || assetType === 'audio';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -48,7 +49,7 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={styles.mentionTag}
|
className={styles.mentionTag}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (thumbUrl && ref.current) {
|
if (!isAudio && thumbUrl && ref.current) {
|
||||||
const rect = ref.current.getBoundingClientRect();
|
const rect = ref.current.getBoundingClientRect();
|
||||||
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
|
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
|
||||||
setHover(true);
|
setHover(true);
|
||||||
@ -56,13 +57,15 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
|
|||||||
}}
|
}}
|
||||||
onMouseLeave={() => setHover(false)}
|
onMouseLeave={() => setHover(false)}
|
||||||
>
|
>
|
||||||
{thumbUrl && (
|
{isAudio ? (
|
||||||
|
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}>♫</span>
|
||||||
|
) : thumbUrl ? (
|
||||||
<img
|
<img
|
||||||
src={tosThumb(thumbUrl, 28)}
|
src={tosThumb(thumbUrl, 28)}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
|
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{hover && thumbUrl && createPortal(
|
{hover && thumbUrl && createPortal(
|
||||||
@ -79,16 +82,22 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
|
|||||||
// Render prompt text with @mentions as styled tags (thumbnail + hover preview)
|
// Render prompt text with @mentions as styled tags (thumbnail + hover preview)
|
||||||
export function renderPromptWithMentions(
|
export function renderPromptWithMentions(
|
||||||
text: string,
|
text: string,
|
||||||
assetMentions: { label: string; thumbUrl?: string }[],
|
assetMentions: Record<string, unknown>[],
|
||||||
references: { label: string; previewUrl?: string }[]
|
references: { label: string; previewUrl?: string }[]
|
||||||
) {
|
) {
|
||||||
// Build lookup: label → thumbUrl
|
// Build lookup: label → { thumbUrl, assetType }
|
||||||
const thumbMap = new Map<string, string>();
|
const thumbMap = new Map<string, { thumbUrl: string; assetType: string }>();
|
||||||
for (const am of assetMentions) {
|
for (const am of assetMentions) {
|
||||||
if (am.label) thumbMap.set(am.label, am.thumbUrl || '');
|
if (am.label) thumbMap.set(am.label as string, {
|
||||||
|
thumbUrl: (am.thumbUrl as string) || '',
|
||||||
|
assetType: (am.assetType as string) || 'image',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const r of references) {
|
for (const r of references) {
|
||||||
if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, r.previewUrl || '');
|
if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, {
|
||||||
|
thumbUrl: r.previewUrl || '',
|
||||||
|
assetType: (r as Record<string, unknown>).type as string || 'image',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels = [...thumbMap.keys()];
|
const labels = [...thumbMap.keys()];
|
||||||
@ -106,7 +115,8 @@ export function renderPromptWithMentions(
|
|||||||
if (regex.test(part)) {
|
if (regex.test(part)) {
|
||||||
regex.lastIndex = 0;
|
regex.lastIndex = 0;
|
||||||
const label = part.slice(1); // remove @
|
const label = part.slice(1); // remove @
|
||||||
return <MentionTag key={i} label={label} thumbUrl={thumbMap.get(label)} />;
|
const info = thumbMap.get(label);
|
||||||
|
return <MentionTag key={i} label={label} thumbUrl={info?.thumbUrl} assetType={info?.assetType} />;
|
||||||
}
|
}
|
||||||
regex.lastIndex = 0;
|
regex.lastIndex = 0;
|
||||||
return part;
|
return part;
|
||||||
|
|||||||
@ -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)'; }}
|
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)'; }}
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
|
||||||
>
|
>
|
||||||
素材库
|
人物素材库
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (!searchDisabled) setSearchMode(searchMode === 'smart' ? 'off' : 'smart'); }}
|
onClick={() => { if (!searchDisabled) setSearchMode(searchMode === 'smart' ? 'off' : 'smart'); }}
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import { useRef, useEffect, useCallback, useState } from 'react';
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { useInputBarStore } from '../store/inputBar';
|
import { useInputBarStore } from '../store/inputBar';
|
||||||
import { assetsApi, tosThumb } from '../lib/api';
|
import { assetsApi, tosThumb } from '../lib/api';
|
||||||
import type { UploadedFile, AssetGroup } from '../types';
|
import type { UploadedFile, AssetSearchResult } from '../types';
|
||||||
|
import { parseAssetMentionsFromDOM } from '../lib/assetMentions';
|
||||||
|
import { showToast } from './Toast';
|
||||||
import styles from './PromptInput.module.css';
|
import styles from './PromptInput.module.css';
|
||||||
|
|
||||||
const placeholders: Record<string, string> = {
|
const placeholders: Record<string, string> = {
|
||||||
@ -27,7 +29,7 @@ export function PromptInput() {
|
|||||||
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
|
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
|
||||||
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
|
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
|
||||||
const [mentionMode, setMentionMode] = useState<'references' | 'assets'>('references');
|
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);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Auto-focus
|
// Auto-focus
|
||||||
@ -40,7 +42,7 @@ export function PromptInput() {
|
|||||||
const el = editorRef.current;
|
const el = editorRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (el.innerHTML !== editorHtml) {
|
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
|
// 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)
|
// This handles the case where editorHtml comes from backend (plain text only)
|
||||||
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
|
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
|
||||||
@ -64,6 +66,7 @@ export function PromptInput() {
|
|||||||
const createMentionSpan = useCallback((opts: {
|
const createMentionSpan = useCallback((opts: {
|
||||||
refId: string; refType: string; label: string; thumbUrl?: string;
|
refId: string; refType: string; label: string; thumbUrl?: string;
|
||||||
assetGroupId?: string; groupName?: string;
|
assetGroupId?: string; groupName?: string;
|
||||||
|
assetId?: string; assetType?: string; assetName?: string; duration?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = styles.mention;
|
span.className = styles.mention;
|
||||||
@ -72,10 +75,18 @@ export function PromptInput() {
|
|||||||
span.dataset.refType = opts.refType;
|
span.dataset.refType = opts.refType;
|
||||||
span.draggable = true;
|
span.draggable = true;
|
||||||
if (opts.thumbUrl) span.dataset.thumbUrl = opts.thumbUrl;
|
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.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
|
||||||
if (opts.groupName) span.dataset.groupName = opts.groupName;
|
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');
|
const icon = document.createElement('span');
|
||||||
icon.textContent = '\u266B';
|
icon.textContent = '\u266B';
|
||||||
icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none';
|
icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none';
|
||||||
@ -102,19 +113,40 @@ export function PromptInput() {
|
|||||||
const rebuildMentionSpans = useCallback((el: HTMLElement) => {
|
const rebuildMentionSpans = useCallback((el: HTMLElement) => {
|
||||||
// Collect all targets to match: references + asset mentions
|
// Collect all targets to match: references + asset mentions
|
||||||
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
|
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[] = [
|
const targets: MatchTarget[] = [
|
||||||
...references.map((ref) => ({
|
...references.map((ref) => ({
|
||||||
label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl,
|
label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl,
|
||||||
})),
|
})),
|
||||||
...currentAssetMentions.map((am) => ({
|
...currentAssetMentions.map((am: Record<string, unknown>) => {
|
||||||
label: am.label, refId: am.groupId, refType: 'asset', thumbUrl: am.thumbUrl || '',
|
// New format (individual asset)
|
||||||
assetGroupId: am.groupId, groupName: am.label,
|
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;
|
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 walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||||
const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = [];
|
const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = [];
|
||||||
|
|
||||||
@ -160,6 +192,10 @@ export function PromptInput() {
|
|||||||
thumbUrl: m.target.thumbUrl,
|
thumbUrl: m.target.thumbUrl,
|
||||||
assetGroupId: m.target.assetGroupId,
|
assetGroupId: m.target.assetGroupId,
|
||||||
groupName: m.target.groupName,
|
groupName: m.target.groupName,
|
||||||
|
assetId: m.target.assetId,
|
||||||
|
assetType: m.target.assetType,
|
||||||
|
assetName: m.target.assetName,
|
||||||
|
duration: m.target.duration,
|
||||||
});
|
});
|
||||||
frag.appendChild(span);
|
frag.appendChild(span);
|
||||||
lastIdx = m.end;
|
lastIdx = m.end;
|
||||||
@ -240,6 +276,16 @@ export function PromptInput() {
|
|||||||
}
|
}
|
||||||
}, [references, extractText]);
|
}, [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(() => {
|
const handleInput = useCallback(() => {
|
||||||
extractText();
|
extractText();
|
||||||
|
|
||||||
@ -285,7 +331,7 @@ export function PromptInput() {
|
|||||||
} else {
|
} else {
|
||||||
setShowMentionPopup(false);
|
setShowMentionPopup(false);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => { showToast('素材搜索失败,请重试'); });
|
||||||
}, 300);
|
}, 300);
|
||||||
} else if (textAfterAt.includes(' ')) {
|
} else if (textAfterAt.includes(' ')) {
|
||||||
// Space after @ text, close popup
|
// Space after @ text, close popup
|
||||||
@ -347,7 +393,35 @@ export function PromptInput() {
|
|||||||
extractText();
|
extractText();
|
||||||
}, [extractText]);
|
}, [extractText]);
|
||||||
|
|
||||||
const insertAssetMention = useCallback((group: AssetGroup) => {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setShowMentionPopup(false);
|
setShowMentionPopup(false);
|
||||||
setMentionMode('references');
|
setMentionMode('references');
|
||||||
setAssetSearchResults([]);
|
setAssetSearchResults([]);
|
||||||
@ -378,14 +452,16 @@ export function PromptInput() {
|
|||||||
|
|
||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
|
|
||||||
// Create mention span for asset with thumbnail
|
// Create mention span for individual asset
|
||||||
const mention = createMentionSpan({
|
const mention = createMentionSpan({
|
||||||
refId: String(group.id),
|
refId: String(asset.id),
|
||||||
refType: 'asset',
|
refType: 'asset',
|
||||||
label: group.name,
|
label: asset.name,
|
||||||
thumbUrl: group.thumbnail_url,
|
thumbUrl: asset.thumbnail_url || asset.url,
|
||||||
assetGroupId: String(group.id),
|
assetId: String(asset.id),
|
||||||
groupName: group.name,
|
assetType: asset.asset_type,
|
||||||
|
assetName: asset.name,
|
||||||
|
duration: asset.duration != null ? String(asset.duration) : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
range.insertNode(mention);
|
range.insertNode(mention);
|
||||||
@ -400,7 +476,7 @@ export function PromptInput() {
|
|||||||
sel.addRange(newRange);
|
sel.addRange(newRange);
|
||||||
|
|
||||||
extractText();
|
extractText();
|
||||||
}, [extractText]);
|
}, [extractText, editorHtml, references]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (showMentionPopup) {
|
if (showMentionPopup) {
|
||||||
@ -451,8 +527,9 @@ export function PromptInput() {
|
|||||||
ALLOWED_TAGS: ['span', 'br', 'img'],
|
ALLOWED_TAGS: ['span', 'br', 'img'],
|
||||||
ALLOWED_ATTR: [
|
ALLOWED_ATTR: [
|
||||||
'class', 'contenteditable', 'data-ref-id', 'data-ref-type',
|
'class', 'contenteditable', 'data-ref-id', 'data-ref-type',
|
||||||
'data-asset-group-id', 'data-group-name', 'data-thumb-url',
|
'data-asset-group-id', 'data-group-name',
|
||||||
'draggable', 'src', 'alt', 'width', 'height', 'style',
|
'data-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration',
|
||||||
|
'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
document.execCommand('insertHTML', false, sanitized);
|
document.execCommand('insertHTML', false, sanitized);
|
||||||
@ -488,13 +565,15 @@ export function PromptInput() {
|
|||||||
|
|
||||||
// 素材库标签:用 data-thumb-url 构造预览数据
|
// 素材库标签:用 data-thumb-url 构造预览数据
|
||||||
if (!found && refType === 'asset') {
|
if (!found && refType === 'asset') {
|
||||||
|
const assetType = target.dataset.assetType || 'Image';
|
||||||
|
if (assetType === 'Audio') return; // 音频素材不弹预览
|
||||||
const thumbUrl = target.dataset.thumbUrl;
|
const thumbUrl = target.dataset.thumbUrl;
|
||||||
if (thumbUrl) {
|
if (thumbUrl) {
|
||||||
found = {
|
found = {
|
||||||
id: refId || '',
|
id: refId || '',
|
||||||
type: 'image',
|
type: assetType === 'Video' ? 'video' : 'image',
|
||||||
previewUrl: thumbUrl,
|
previewUrl: thumbUrl,
|
||||||
label: target.dataset.groupName || target.textContent || '',
|
label: target.dataset.assetName || target.textContent || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -632,25 +711,32 @@ export function PromptInput() {
|
|||||||
)}
|
)}
|
||||||
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
|
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.mentionHeader}>素材库匹配</div>
|
<div className={styles.mentionHeader}>人物素材库匹配</div>
|
||||||
{assetSearchResults.map((group, idx) => (
|
{assetSearchResults.map((asset, idx) => (
|
||||||
<button
|
<button
|
||||||
key={group.id}
|
key={asset.id}
|
||||||
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
|
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
insertAssetMention(group);
|
insertAssetMention(asset);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles.mentionThumb}>
|
<div className={styles.mentionThumb}>
|
||||||
{group.thumbnail_url ? (
|
{asset.asset_type === 'Audio' ? (
|
||||||
<img src={tosThumb(group.thumbnail_url, 72)} alt="" className={styles.thumbMedia} />
|
<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>
|
<span style={{ fontSize: 9, color: 'var(--color-text-disabled)' }}>无图</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.mentionLabel}>{group.name}</span>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<span className={styles.mentionType}>人像</span>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -38,8 +38,8 @@ export function Sidebar() {
|
|||||||
<span>生成</span>
|
<span>生成</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${styles.navItem} ${isActive('/assets') ? styles.active : ''}`}
|
className={`${styles.navItem} ${isActive('/user-assets') ? styles.active : ''}`}
|
||||||
onClick={() => navigate('/assets')}
|
onClick={() => navigate('/user-assets')}
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<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" />
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type {
|
|||||||
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
||||||
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
|
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
|
||||||
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
|
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
|
||||||
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem,
|
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem, AssetSearchResult,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { reportError } from './logCenter';
|
import { reportError } from './logCenter';
|
||||||
|
|
||||||
@ -146,7 +146,7 @@ export const videoApi = {
|
|||||||
model: string;
|
model: string;
|
||||||
aspect_ratio: string;
|
aspect_ratio: string;
|
||||||
duration: number;
|
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;
|
search_mode?: string;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
}) =>
|
}) =>
|
||||||
@ -420,6 +420,8 @@ export const assetsApi = {
|
|||||||
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
|
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
|
||||||
updateGroup: (id: number, data: { name?: string; description?: string }) =>
|
updateGroup: (id: number, data: { name?: string; description?: string }) =>
|
||||||
api.put(`/assets/groups/${id}`, data),
|
api.put(`/assets/groups/${id}`, data),
|
||||||
|
deleteGroup: (id: number) =>
|
||||||
|
api.delete(`/assets/groups/${id}`),
|
||||||
addAsset: (groupId: number, data: FormData) =>
|
addAsset: (groupId: number, data: FormData) =>
|
||||||
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||||||
updateAsset: (id: number, data: { name: string }) =>
|
updateAsset: (id: number, data: { name: string }) =>
|
||||||
@ -427,7 +429,7 @@ export const assetsApi = {
|
|||||||
deleteAsset: (id: number) =>
|
deleteAsset: (id: number) =>
|
||||||
api.delete(`/assets/${id}`),
|
api.delete(`/assets/${id}`),
|
||||||
search: (q: string) =>
|
search: (q: string) =>
|
||||||
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
|
api.get<{ results: AssetSearchResult[] }>('/assets/search', { params: { q } }),
|
||||||
pollStatus: (id: number) =>
|
pollStatus: (id: number) =>
|
||||||
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
|
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
|
||||||
};
|
};
|
||||||
|
|||||||
44
web/src/lib/assetMentions.ts
Normal file
44
web/src/lib/assetMentions.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { assetsApi } from '../lib/api';
|
import { assetsApi } from '../lib/api';
|
||||||
import type { AssetGroup } from '../types';
|
import type { AssetGroup, AssetSearchResult } from '../types';
|
||||||
import { showToast } from '../components/Toast';
|
import { showToast } from '../components/Toast';
|
||||||
|
|
||||||
interface AssetLibraryState {
|
interface AssetLibraryState {
|
||||||
@ -8,12 +8,12 @@ interface AssetLibraryState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
searchResults: AssetGroup[];
|
searchResults: AssetSearchResult[];
|
||||||
searching: boolean;
|
searching: boolean;
|
||||||
|
|
||||||
loadGroups: (page?: number) => Promise<void>;
|
loadGroups: (page?: number) => Promise<void>;
|
||||||
searchAssets: (query: string) => 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;
|
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();
|
const formData = new FormData();
|
||||||
formData.append('name', name);
|
formData.append('name', name);
|
||||||
formData.append('file', file);
|
if (file) formData.append('file', file);
|
||||||
try {
|
try {
|
||||||
const { data } = await assetsApi.createGroup(formData);
|
const { data } = await assetsApi.createGroup(formData);
|
||||||
showToast('角色创建成功');
|
showToast('角色创建成功');
|
||||||
|
|||||||
@ -84,8 +84,17 @@ function buildAssetMentions(refs: Array<Record<string, string>>) {
|
|||||||
.filter((ref) => isAssetUrl(ref.url || ''))
|
.filter((ref) => isAssetUrl(ref.url || ''))
|
||||||
.map((ref) => {
|
.map((ref) => {
|
||||||
const url = ref.url || '';
|
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-', '') : '';
|
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),
|
status: mapStatus(bt.status),
|
||||||
progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status),
|
progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status),
|
||||||
resultUrl: bt.result_url || undefined,
|
resultUrl: bt.result_url || undefined,
|
||||||
|
thumbnailUrl: bt.thumbnail_url || undefined,
|
||||||
errorMessage: mapErrorMessage(bt.error_message),
|
errorMessage: mapErrorMessage(bt.error_message),
|
||||||
createdAt: new Date(bt.created_at).getTime(),
|
createdAt: new Date(bt.created_at).getTime(),
|
||||||
tokensConsumed: bt.tokens_consumed || 0,
|
tokensConsumed: bt.tokens_consumed || 0,
|
||||||
@ -349,7 +359,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
|||||||
].filter(Boolean) as ReferenceSnapshot[];
|
].filter(Boolean) as ReferenceSnapshot[];
|
||||||
|
|
||||||
// Extract asset mentions for placeholder display
|
// Extract asset mentions for placeholder display
|
||||||
const placeholderAssetMentions: { groupId: string; label: string; thumbUrl: string }[] = [];
|
const placeholderAssetMentions: Record<string, unknown>[] = [];
|
||||||
if (input.editorHtml) {
|
if (input.editorHtml) {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(input.editorHtml, 'text/html');
|
const doc = parser.parseFromString(input.editorHtml, 'text/html');
|
||||||
@ -410,7 +420,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use pre-uploaded TOS URLs (immediate upload), fallback to upload here if needed
|
// 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) {
|
for (const item of filesToUpload) {
|
||||||
if (item.tosUrl && !item.tosUrl.startsWith('blob:')) {
|
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
|
// Extract asset mentions from editor HTML — deduplicate by assetId
|
||||||
const seenGroupIds = new Set<string>();
|
const seenAssetIds = new Set<string>();
|
||||||
if (input.editorHtml) {
|
if (input.editorHtml) {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(input.editorHtml, 'text/html');
|
const doc = parser.parseFromString(input.editorHtml, 'text/html');
|
||||||
const assetSpans = doc.querySelectorAll('[data-ref-type="asset"]');
|
const assetSpans = doc.querySelectorAll('[data-ref-type="asset"]');
|
||||||
assetSpans.forEach((span) => {
|
assetSpans.forEach((span) => {
|
||||||
const el = span as HTMLElement;
|
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 groupId = el.dataset.assetGroupId;
|
||||||
const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || '';
|
const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || '';
|
||||||
if (groupId && !seenGroupIds.has(groupId)) {
|
if (!seenAssetIds.has(`group-${groupId}`)) {
|
||||||
seenGroupIds.add(groupId);
|
seenAssetIds.add(`group-${groupId}`);
|
||||||
uploadedRefs.push({
|
uploadedRefs.push({
|
||||||
url: `asset://group-${groupId}`,
|
url: `asset://group-${groupId}`,
|
||||||
type: 'image',
|
type: 'image',
|
||||||
@ -442,18 +469,32 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
|||||||
thumb_url: el.dataset.thumbUrl || '',
|
thumb_url: el.dataset.thumbUrl || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: only use inputBar assetMentions when editorHtml has NO asset spans
|
// Fallback: only use inputBar assetMentions when editorHtml has NO asset spans
|
||||||
// (regenerate scenario where editorHtml is plain text)
|
// (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"');
|
const htmlHadAssetSpans = input.editorHtml?.includes('data-ref-type="asset"');
|
||||||
if (!htmlHadAssetSpans) {
|
if (!htmlHadAssetSpans) {
|
||||||
const inputAssetMentions = input.assetMentions || [];
|
const inputAssetMentions = input.assetMentions || [];
|
||||||
for (const am of inputAssetMentions) {
|
for (const am of inputAssetMentions) {
|
||||||
if (am.groupId && !seenGroupIds.has(am.groupId)) {
|
// New format
|
||||||
seenGroupIds.add(am.groupId);
|
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({
|
uploadedRefs.push({
|
||||||
url: `asset://group-${am.groupId}`,
|
url: `asset://group-${am.groupId}`,
|
||||||
type: 'image',
|
type: 'image',
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
|
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
|
||||||
import { showToast } from '../components/Toast';
|
import { showToast } from '../components/Toast';
|
||||||
import { mediaApi } from '../lib/api';
|
import { mediaApi } from '../lib/api';
|
||||||
|
import { parseAssetMentions } from '../lib/assetMentions';
|
||||||
|
|
||||||
let fileCounter = 0;
|
let fileCounter = 0;
|
||||||
|
|
||||||
@ -123,7 +124,8 @@ interface InputBarState {
|
|||||||
setSeedEnabled: (enabled: boolean) => void;
|
setSeedEnabled: (enabled: boolean) => void;
|
||||||
|
|
||||||
// Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild)
|
// 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)
|
// @ trigger (for toolbar button to insert @ in contentEditable)
|
||||||
insertAtTrigger: number;
|
insertAtTrigger: number;
|
||||||
@ -170,9 +172,13 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
prevReferences: [],
|
prevReferences: [],
|
||||||
addReferences: (files) => {
|
addReferences: (files) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
// Count existing references by type
|
// Count existing references by type + merge @ asset mentions
|
||||||
const counts = { image: 0, video: 0, audio: 0 };
|
const counts = { image: 0, video: 0, audio: 0 };
|
||||||
for (const ref of state.references) counts[ref.type]++;
|
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)
|
// Separate images (sync) from audio/video (need async duration check)
|
||||||
const imageFiles: File[] = [];
|
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 state = useInputBarStore.getState();
|
||||||
const existingDuration = state.references
|
const { durations: assetDurations } = parseAssetMentions(state.editorHtml);
|
||||||
|
const refDuration = state.references
|
||||||
.filter((r) => r.type === type && r.duration)
|
.filter((r) => r.type === type && r.duration)
|
||||||
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
.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) {
|
if (existingDuration + dur > MAX_MEDIA_DURATION + 0.4) {
|
||||||
showToast(`${typeLabel}总时长不能超过${MAX_MEDIA_DURATION}秒`);
|
showToast(`${typeLabel}总时长不能超过${MAX_MEDIA_DURATION}秒`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -44,10 +44,12 @@ export interface GenerationTask {
|
|||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
duration: Duration;
|
duration: Duration;
|
||||||
references: ReferenceSnapshot[];
|
references: ReferenceSnapshot[];
|
||||||
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
assetMentions: Record<string, any>[];
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
tokensConsumed?: number;
|
tokensConsumed?: number;
|
||||||
@ -71,6 +73,7 @@ export interface BackendTask {
|
|||||||
base_cost_amount: number;
|
base_cost_amount: number;
|
||||||
status: 'queued' | 'processing' | 'completed' | 'failed';
|
status: 'queued' | 'processing' | 'completed' | 'failed';
|
||||||
result_url: string;
|
result_url: string;
|
||||||
|
thumbnail_url: string;
|
||||||
error_message: string;
|
error_message: string;
|
||||||
reference_urls: { url: string; type: string; role: string; label: string }[];
|
reference_urls: { url: string; type: string; role: string; label: string }[];
|
||||||
is_favorited: boolean;
|
is_favorited: boolean;
|
||||||
@ -436,8 +439,21 @@ export interface AssetItem {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
asset_type: 'Image' | 'Video' | 'Audio';
|
asset_type: 'Image' | 'Video' | 'Audio';
|
||||||
|
thumbnail_url: string;
|
||||||
|
duration: number | null;
|
||||||
status: 'processing' | 'active' | 'failed';
|
status: 'processing' | 'active' | 'failed';
|
||||||
remote_asset_id: string;
|
remote_asset_id: string;
|
||||||
error_message: string;
|
error_message: string;
|
||||||
created_at: 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;
|
||||||
|
}
|
||||||
|
|||||||
411
web/test/e2e/v018-test.spec.ts
Normal file
411
web/test/e2e/v018-test.spec.ts
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user