Merge branch 'dev' of https://gitea.airlabs.art/zyc/video-shuoshan into dev
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6s
This commit is contained in:
commit
55c26fb1f5
23
backend/apps/generation/migrations/0017_add_asset_type.py
Normal file
23
backend/apps/generation/migrations/0017_add_asset_type.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-04-04 05:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generation', '0016_add_is_deleted_to_generationrecord'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='asset',
|
||||
name='asset_type',
|
||||
field=models.CharField(choices=[('Image', '图像'), ('Video', '视频'), ('Audio', '音频')], default='Image', max_length=10, verbose_name='素材类型'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='url',
|
||||
field=models.CharField(blank=True, default='', max_length=1000, verbose_name='素材URL'),
|
||||
),
|
||||
]
|
||||
@ -136,12 +136,17 @@ class AssetGroup(models.Model):
|
||||
|
||||
|
||||
class Asset(models.Model):
|
||||
"""虚拟人像素材 — 单张图片。"""
|
||||
"""虚拟人像素材 — 图片/视频/音频。"""
|
||||
STATUS_CHOICES = [
|
||||
('processing', '处理中'),
|
||||
('active', '可用'),
|
||||
('failed', '失败'),
|
||||
]
|
||||
ASSET_TYPE_CHOICES = [
|
||||
('Image', '图像'),
|
||||
('Video', '视频'),
|
||||
('Audio', '音频'),
|
||||
]
|
||||
|
||||
group = models.ForeignKey(
|
||||
AssetGroup, on_delete=models.CASCADE,
|
||||
@ -149,7 +154,8 @@ class Asset(models.Model):
|
||||
)
|
||||
remote_asset_id = models.CharField(max_length=100, default='', verbose_name='火山Asset ID')
|
||||
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='素材类型')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
|
||||
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
@ -32,7 +32,7 @@ User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File validation constants
|
||||
ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif'}
|
||||
ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif', 'heic', 'heif'}
|
||||
ALLOWED_VIDEO_EXTS = {'mp4', 'mov'}
|
||||
ALLOWED_AUDIO_EXTS = {'mp3', 'wav'}
|
||||
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
|
||||
@ -287,37 +287,39 @@ def video_generate_view(request):
|
||||
reference_snapshots = []
|
||||
content_items = []
|
||||
seen_urls = set() # 去重:同一个素材只引用一次
|
||||
_asset_cache = {} # group_id → resolved_url,避免同一素材组重复查询
|
||||
_asset_cache = {} # group_id → [(asset_url, asset_type), ...],避免同一素材组重复查询
|
||||
|
||||
from .models import Asset as AssetModel
|
||||
|
||||
def _resolve_asset_group(gid, lbl):
|
||||
"""查询本地 DB + 必要时刷新火山状态,返回 Asset://xxx 或原始 asset:// URL。"""
|
||||
asset = AssetModel.objects.filter(
|
||||
def _resolve_asset_group_all(gid, lbl):
|
||||
"""查询本地 DB 获取组内所有 active 素材,返回 [(asset_url, asset_type), ...] 列表。
|
||||
processing 的素材会尝试实时刷新状态。"""
|
||||
assets = list(AssetModel.objects.filter(
|
||||
group_id=gid, status__in=['active', 'processing']
|
||||
).order_by(
|
||||
Case(When(status='active', then=0), default=1)
|
||||
).first()
|
||||
if not asset or not asset.remote_asset_id:
|
||||
logger.warning('No asset found for group %s (label=%s)', gid, lbl)
|
||||
return f'asset://group-{gid}'
|
||||
# 本地 processing → 实时查火山刷新
|
||||
if asset.status == 'processing':
|
||||
result, _ = _assets_api_call(assets_client.get_asset, asset.remote_asset_id)
|
||||
if result and result.get('Status') == 'Active':
|
||||
asset.status = 'active'
|
||||
asset.url = result.get('Url', asset.url)
|
||||
asset.save(update_fields=['status', 'url'])
|
||||
logger.info('Asset %s refreshed to active from Volcano', asset.remote_asset_id)
|
||||
else:
|
||||
logger.warning('Asset %s still processing on Volcano', asset.remote_asset_id)
|
||||
return f'asset://group-{gid}'
|
||||
aid = asset.remote_asset_id
|
||||
if aid.startswith('asset-'):
|
||||
aid = 'Asset-' + aid[6:]
|
||||
resolved = f'Asset://{aid}'
|
||||
logger.info('Asset resolved: group=%s -> %s', gid, resolved)
|
||||
return resolved
|
||||
).exclude(remote_asset_id='').order_by('created_at'))
|
||||
if not assets:
|
||||
logger.warning('No assets found for group %s (label=%s)', gid, lbl)
|
||||
return []
|
||||
resolved_list = []
|
||||
for asset in assets:
|
||||
# 本地 processing → 实时查火山刷新
|
||||
if asset.status == 'processing':
|
||||
result, _ = _assets_api_call(assets_client.get_asset, asset.remote_asset_id)
|
||||
if result and result.get('Status') == 'Active':
|
||||
asset.status = 'active'
|
||||
asset.url = result.get('Url', asset.url)
|
||||
asset.save(update_fields=['status', 'url'])
|
||||
logger.info('Asset %s refreshed to active from Volcano', asset.remote_asset_id)
|
||||
else:
|
||||
logger.warning('Asset %s still processing, skipped', asset.remote_asset_id)
|
||||
continue # 跳过未就绪的素材
|
||||
aid = asset.remote_asset_id
|
||||
if aid.startswith('Asset-'):
|
||||
aid = 'asset-' + aid[6:]
|
||||
resolved_url = f'asset://{aid}'
|
||||
resolved_list.append((resolved_url, asset.asset_type))
|
||||
logger.info('Asset group %s resolved: %d assets', gid, len(resolved_list))
|
||||
return resolved_list
|
||||
|
||||
from utils import assets_client
|
||||
|
||||
@ -347,30 +349,37 @@ def video_generate_view(request):
|
||||
snap['thumb_url'] = thumb_url
|
||||
reference_snapshots.append(snap)
|
||||
|
||||
# 转换 asset://group-{id} 为火山 Asset://Asset-xxx 格式(仅用于 content_items)
|
||||
resolved_url = url
|
||||
# 转换 asset://group-{id} → 展开为组内所有 active 素材(全发)
|
||||
if url.startswith('asset://group-'):
|
||||
try:
|
||||
group_id = int(url.replace('asset://group-', ''))
|
||||
# 跨迭代缓存:同一 group_id 不重复查询/刷新
|
||||
if group_id in _asset_cache:
|
||||
resolved_url = _asset_cache[group_id]
|
||||
asset_list = _asset_cache[group_id]
|
||||
else:
|
||||
resolved_url = _resolve_asset_group(group_id, label)
|
||||
_asset_cache[group_id] = resolved_url
|
||||
asset_list = _resolve_asset_group_all(group_id, label)
|
||||
_asset_cache[group_id] = asset_list
|
||||
if not asset_list:
|
||||
return Response({
|
||||
'error': 'asset_not_ready',
|
||||
'message': f'素材「{label}」尚未就绪,请在素材库中确认状态为"可用"后重试',
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
for asset_url, asset_type in asset_list:
|
||||
if asset_type == 'Video':
|
||||
content_items.append({'type': 'video_url', 'video_url': {'url': asset_url}, 'role': 'reference_video'})
|
||||
elif asset_type == 'Audio':
|
||||
content_items.append({'type': 'audio_url', 'audio_url': {'url': asset_url}, 'role': 'reference_audio'})
|
||||
else:
|
||||
content_items.append({'type': 'image_url', 'image_url': {'url': asset_url}, 'role': 'reference_image'})
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve asset group URL %s: %s', url, e)
|
||||
|
||||
# 未解析成功的 asset URL → 返回明确错误,不再静默跳过
|
||||
if resolved_url.startswith('asset://'):
|
||||
logger.error('Unresolved asset URL: %s (label=%s)', resolved_url, label)
|
||||
return Response({
|
||||
'error': 'asset_not_ready',
|
||||
'message': f'素材「{label}」尚未就绪,请在素材库中确认状态为"可用"后重试',
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({
|
||||
'error': 'asset_not_ready',
|
||||
'message': f'素材「{label}」解析失败,请重试',
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
continue # 素材组已展开为多个 content_items,跳过下面的单项处理
|
||||
|
||||
if ref_type == 'image':
|
||||
item = {'type': 'image_url', 'image_url': {'url': resolved_url}}
|
||||
item = {'type': 'image_url', 'image_url': {'url': url}}
|
||||
# API 文档要求:参考图模式下所有图片的 role 必须为 reference_image
|
||||
if mode == 'universal':
|
||||
item['role'] = 'reference_image'
|
||||
@ -2923,12 +2932,36 @@ def _assets_api_call(func, *args, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
def _detect_asset_type(file):
|
||||
"""Detect asset type from file content_type. Returns ('Image'|'Video'|'Audio', error_response|None)."""
|
||||
ct = (file.content_type or '').lower()
|
||||
if ct.startswith('video/'):
|
||||
if ct not in ('video/mp4', 'video/quicktime'):
|
||||
return None, Response({'error': '仅支持 MP4 和 MOV 格式的视频'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if file.size > MAX_VIDEO_SIZE:
|
||||
return None, Response({'error': '视频文件不能超过 50MB'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return 'Video', None
|
||||
elif ct.startswith('audio/'):
|
||||
if ct not in ('audio/mpeg', 'audio/wav'):
|
||||
return None, Response({'error': '仅支持 MP3 和 WAV 格式的音频'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if file.size > MAX_AUDIO_SIZE:
|
||||
return None, Response({'error': '音频文件不能超过 15MB'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return 'Audio', None
|
||||
else:
|
||||
ext = file.name.rsplit('.', 1)[-1].lower() if '.' in file.name else ''
|
||||
if ext and ext not in ALLOWED_IMAGE_EXTS:
|
||||
return None, Response({'error': f'不支持的图片格式: {ext}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if file.size > MAX_IMAGE_SIZE:
|
||||
return None, Response({'error': '图片文件不能超过 30MB'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return 'Image', None
|
||||
|
||||
|
||||
@api_view(['GET', 'POST'])
|
||||
@permission_classes([IsTeamMember])
|
||||
@parser_classes([MultiPartParser, JSONParser])
|
||||
def asset_groups_view(request):
|
||||
"""GET /api/v1/assets/groups — list groups for current team.
|
||||
POST /api/v1/assets/groups — create a group with an initial image.
|
||||
POST /api/v1/assets/groups — create a group with an initial asset (image/video/audio).
|
||||
"""
|
||||
team = request.user.team
|
||||
|
||||
@ -2975,32 +3008,39 @@ def asset_groups_view(request):
|
||||
|
||||
file = request.FILES.get('file')
|
||||
if not file:
|
||||
return Response({'error': '请上传一张素材图片'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({'error': '请上传素材文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate image dimensions (Volcano Assets API requires 300-6000px)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(file)
|
||||
w, h = img.size
|
||||
if w < 300 or h < 300:
|
||||
return Response(
|
||||
{'error': f'图片太小了,请上传更大的图片(当前 {w}x{h},最小要求 300x300)'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if w > 6000 or h > 6000:
|
||||
return Response(
|
||||
{'error': f'图片太大了,请压缩后重试(当前 {w}x{h},最大支持 6000x6000)'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
file.seek(0) # Reset after PIL read
|
||||
except ImportError:
|
||||
pass # Pillow not installed, skip validation
|
||||
except Exception:
|
||||
pass # Not an image or corrupted, let TOS handle it
|
||||
# Detect asset type and validate format/size
|
||||
asset_type, err = _detect_asset_type(file)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Validate image dimensions (only for images)
|
||||
if asset_type == 'Image':
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(file)
|
||||
w, h = img.size
|
||||
if w < 300 or h < 300:
|
||||
return Response(
|
||||
{'error': f'图片太小了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if w > 6000 or h > 6000:
|
||||
return Response(
|
||||
{'error': f'图片太大了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
file.seek(0)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Upload to TOS
|
||||
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
|
||||
try:
|
||||
tos_url = tos_upload(file, folder='assets')
|
||||
tos_url = tos_upload(file, folder=folder)
|
||||
except Exception as e:
|
||||
logger.exception('TOS upload failed for asset')
|
||||
return Response(
|
||||
@ -3020,7 +3060,7 @@ def asset_groups_view(request):
|
||||
# Create remote asset
|
||||
remote_asset_id = ''
|
||||
if remote_group_id:
|
||||
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name)
|
||||
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name, asset_type=asset_type)
|
||||
if err:
|
||||
return err
|
||||
if result is not None:
|
||||
@ -3040,6 +3080,7 @@ def asset_groups_view(request):
|
||||
remote_asset_id=remote_asset_id,
|
||||
name=name,
|
||||
url=tos_url,
|
||||
asset_type=asset_type,
|
||||
status='processing' if remote_asset_id else 'active',
|
||||
error_message='',
|
||||
)
|
||||
@ -3087,6 +3128,7 @@ def asset_group_detail_view(request, group_id):
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'url': a.url,
|
||||
'asset_type': a.asset_type,
|
||||
'status': a.status,
|
||||
'remote_asset_id': a.remote_asset_id,
|
||||
'error_message': a.error_message,
|
||||
@ -3141,7 +3183,7 @@ def asset_group_detail_view(request, group_id):
|
||||
@permission_classes([IsTeamMember])
|
||||
@parser_classes([MultiPartParser])
|
||||
def asset_group_add_asset_view(request, group_id):
|
||||
"""POST /api/v1/assets/groups/<id>/assets — add an image to a group."""
|
||||
"""POST /api/v1/assets/groups/<id>/assets — add an asset (image/video/audio) to a group."""
|
||||
team = request.user.team
|
||||
try:
|
||||
group = AssetGroup.objects.get(pk=group_id, team=team)
|
||||
@ -3152,32 +3194,39 @@ def asset_group_add_asset_view(request, group_id):
|
||||
if not file:
|
||||
return Response({'error': '请上传文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate image dimensions (Volcano Assets API requires 300-6000px)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(file)
|
||||
w, h = img.size
|
||||
if w < 300 or h < 300:
|
||||
return Response(
|
||||
{'error': f'图片太小了,请上传更大的图片(当前 {w}x{h},最小要求 300x300)'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if w > 6000 or h > 6000:
|
||||
return Response(
|
||||
{'error': f'图片太大了,请压缩后重试(当前 {w}x{h},最大支持 6000x6000)'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
file.seek(0)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Detect asset type and validate format/size
|
||||
asset_type, err = _detect_asset_type(file)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Validate image dimensions (only for images)
|
||||
if asset_type == 'Image':
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(file)
|
||||
w, h = img.size
|
||||
if w < 300 or h < 300:
|
||||
return Response(
|
||||
{'error': f'图片太小了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if w > 6000 or h > 6000:
|
||||
return Response(
|
||||
{'error': f'图片太大了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
file.seek(0)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
name = request.data.get('name', '').strip() or file.name
|
||||
|
||||
# Upload to TOS
|
||||
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
|
||||
try:
|
||||
tos_url = tos_upload(file, folder='assets')
|
||||
tos_url = tos_upload(file, folder=folder)
|
||||
except Exception as e:
|
||||
logger.exception('TOS upload failed for asset')
|
||||
return Response(
|
||||
@ -3190,7 +3239,7 @@ def asset_group_add_asset_view(request, group_id):
|
||||
remote_asset_id = ''
|
||||
if group.remote_group_id:
|
||||
result, err = _assets_api_call(
|
||||
assets_client.create_asset, group.remote_group_id, tos_url, name,
|
||||
assets_client.create_asset, group.remote_group_id, tos_url, name, asset_type=asset_type,
|
||||
)
|
||||
if err:
|
||||
return err
|
||||
@ -3202,11 +3251,12 @@ def asset_group_add_asset_view(request, group_id):
|
||||
remote_asset_id=remote_asset_id,
|
||||
name=name,
|
||||
url=tos_url,
|
||||
asset_type=asset_type,
|
||||
status='processing' if remote_asset_id else 'active',
|
||||
error_message='',
|
||||
)
|
||||
|
||||
# If first asset, set thumbnail
|
||||
# If first asset or no thumbnail, set thumbnail
|
||||
if not group.thumbnail_url:
|
||||
group.thumbnail_url = tos_url
|
||||
group.save(update_fields=['thumbnail_url'])
|
||||
@ -3215,23 +3265,49 @@ def asset_group_add_asset_view(request, group_id):
|
||||
'id': asset.id,
|
||||
'name': asset.name,
|
||||
'url': asset.url,
|
||||
'asset_type': asset.asset_type,
|
||||
'status': asset.status,
|
||||
'remote_asset_id': asset.remote_asset_id,
|
||||
'created_at': asset.created_at.isoformat(),
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(['PUT'])
|
||||
@api_view(['PUT', 'DELETE'])
|
||||
@permission_classes([IsTeamMember])
|
||||
@parser_classes([JSONParser])
|
||||
def asset_update_view(request, asset_id):
|
||||
"""PUT /api/v1/assets/<id> — rename an asset."""
|
||||
"""PUT /api/v1/assets/<id> — rename an asset. DELETE — delete an asset."""
|
||||
team = request.user.team
|
||||
try:
|
||||
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
|
||||
except Asset.DoesNotExist:
|
||||
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if request.method == 'DELETE':
|
||||
# Delete from Volcano first
|
||||
if asset.remote_asset_id:
|
||||
from utils import assets_client
|
||||
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)
|
||||
|
||||
group = asset.group
|
||||
asset.delete()
|
||||
|
||||
# Update group thumbnail if needed
|
||||
remaining = Asset.objects.filter(group=group).exclude(status='failed').order_by('-created_at').first()
|
||||
if remaining:
|
||||
if group.thumbnail_url != remaining.url:
|
||||
group.thumbnail_url = remaining.url
|
||||
group.save(update_fields=['thumbnail_url'])
|
||||
else:
|
||||
group.thumbnail_url = ''
|
||||
group.save(update_fields=['thumbnail_url'])
|
||||
|
||||
return Response({'message': '素材已删除'})
|
||||
|
||||
# PUT — rename
|
||||
new_name = request.data.get('name')
|
||||
if not new_name:
|
||||
return Response({'error': '请提供素材名称'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@ -201,12 +201,38 @@
|
||||
}
|
||||
|
||||
.assetCard {
|
||||
position: relative;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assetDeleteBtn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.assetCard:hover .assetDeleteBtn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.assetThumb {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
|
||||
@ -6,6 +6,90 @@ import { ImageLightbox } from './ImageLightbox';
|
||||
import type { AssetGroup, AssetItem } from '../types';
|
||||
import styles from './AssetLibraryModal.module.css';
|
||||
|
||||
/** Validate asset file before upload. Returns error message or null if valid. */
|
||||
async function validateAssetFile(file: File): Promise<string | null> {
|
||||
const ct = file.type || '';
|
||||
|
||||
if (ct.startsWith('image/')) {
|
||||
// Format: accept all image/* since backend checks ext
|
||||
if (file.size > 30 * 1024 * 1024) return '图片文件不能超过 30MB';
|
||||
// Dimension check
|
||||
try {
|
||||
const dims = await new Promise<{ w: number; h: number }>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => { resolve({ w: img.naturalWidth, h: img.naturalHeight }); URL.revokeObjectURL(url); };
|
||||
img.onerror = () => { reject(); URL.revokeObjectURL(url); };
|
||||
img.src = url;
|
||||
});
|
||||
if (dims.w <= 300 || dims.h <= 300) return `图片尺寸过小(${dims.w}×${dims.h}),宽高需在 300~6000 像素之间`;
|
||||
if (dims.w >= 6000 || dims.h >= 6000) return `图片尺寸过大(${dims.w}×${dims.h}),宽高需在 300~6000 像素之间`;
|
||||
const ratio = dims.w / dims.h;
|
||||
if (ratio <= 0.4 || ratio >= 2.5) return `图片比例不支持(${dims.w}×${dims.h}),宽高比需在 0.4~2.5 之间`;
|
||||
} catch {
|
||||
// Can't read dimensions (e.g. HEIC), skip — backend will validate
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ct.startsWith('video/')) {
|
||||
if (ct !== 'video/mp4' && ct !== 'video/quicktime') return '仅支持 MP4 和 MOV 格式的视频';
|
||||
if (file.size > 50 * 1024 * 1024) return '视频文件不能超过 50MB';
|
||||
// Duration + dimension check
|
||||
try {
|
||||
const info = await new Promise<{ dur: number; w: number; h: number }>((resolve, reject) => {
|
||||
const vid = document.createElement('video');
|
||||
const url = URL.createObjectURL(file);
|
||||
const timeout = setTimeout(() => { reject(); URL.revokeObjectURL(url); }, 10000);
|
||||
vid.addEventListener('loadedmetadata', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ dur: vid.duration, w: vid.videoWidth, h: vid.videoHeight });
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
vid.addEventListener('error', () => { clearTimeout(timeout); reject(); URL.revokeObjectURL(url); });
|
||||
vid.src = url;
|
||||
});
|
||||
if (info.dur < 2 || info.dur > 15.4) return `视频时长需在 2~15 秒之间(当前 ${info.dur.toFixed(1)} 秒)`;
|
||||
if (info.w < 300 || info.h < 300) return `视频尺寸过小(${info.w}×${info.h}),宽高需在 300~6000 像素之间`;
|
||||
if (info.w > 6000 || info.h > 6000) return `视频尺寸过大(${info.w}×${info.h}),宽高需在 300~6000 像素之间`;
|
||||
const ratio = info.w / info.h;
|
||||
if (ratio < 0.4 || ratio > 2.5) return `视频比例不支持(${info.w}×${info.h}),宽高比需在 0.4~2.5 之间`;
|
||||
const pixels = info.w * info.h;
|
||||
if (pixels < 409600) return `视频像素过低(${info.w}×${info.h}=${pixels.toLocaleString()}),需在 409,600~927,408 之间`;
|
||||
if (pixels > 927408) return `视频像素过高(${info.w}×${info.h}=${pixels.toLocaleString()}),需在 409,600~927,408 之间`;
|
||||
} catch {
|
||||
// Can't read metadata, skip — backend will validate
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ct.startsWith('audio/')) {
|
||||
if (ct !== 'audio/mpeg' && ct !== 'audio/wav') return '仅支持 MP3 和 WAV 格式的音频';
|
||||
if (file.size > 15 * 1024 * 1024) return '音频文件不能超过 15MB';
|
||||
// Duration check
|
||||
try {
|
||||
const dur = await new Promise<number>((resolve, reject) => {
|
||||
const audio = new Audio();
|
||||
const url = URL.createObjectURL(file);
|
||||
const timeout = setTimeout(() => { reject(); URL.revokeObjectURL(url); }, 10000);
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(audio.duration);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
audio.addEventListener('error', () => { clearTimeout(timeout); reject(); URL.revokeObjectURL(url); });
|
||||
audio.src = url;
|
||||
});
|
||||
if (dur < 2 || dur > 15.4) return `音频时长需在 2~15 秒之间(当前 ${dur.toFixed(1)} 秒)`;
|
||||
} catch {
|
||||
// Can't read metadata, skip
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return '不支持的文件类型';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@ -23,7 +107,6 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const addFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const groups = useAssetLibraryStore((s) => s.groups);
|
||||
const loading = useAssetLibraryStore((s) => s.loading);
|
||||
@ -111,10 +194,12 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
}
|
||||
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
const handleFileSelect = useCallback(async (file: File) => {
|
||||
const error = await validateAssetFile(file);
|
||||
if (error) { showToast(error); return; }
|
||||
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
|
||||
setUploadFile(file);
|
||||
setUploadPreview(URL.createObjectURL(file));
|
||||
setUploadPreview(file.type.startsWith('image/') ? URL.createObjectURL(file) : null);
|
||||
}, [uploadPreview]);
|
||||
|
||||
const refreshGroupDetail = useCallback(async () => {
|
||||
@ -127,6 +212,8 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
|
||||
const handleAddAsset = useCallback(async (file: File) => {
|
||||
if (!selectedGroup) return;
|
||||
const error = await validateAssetFile(file);
|
||||
if (error) { showToast(error); return; }
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
@ -158,7 +245,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
if (file && (file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'))) {
|
||||
handleFileSelect(file);
|
||||
}
|
||||
}, [handleFileSelect]);
|
||||
@ -290,26 +377,12 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
{view === 'detail' && selectedGroup && (
|
||||
<>
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.actionBtn} onClick={() => addFileInputRef.current?.click()}>
|
||||
+ 追加图片
|
||||
</button>
|
||||
<button
|
||||
className={styles.actionBtnOutline}
|
||||
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
|
||||
>
|
||||
✎ 改名
|
||||
</button>
|
||||
<input
|
||||
ref={addFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleAddAsset(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editingName && editingName.id === selectedGroup.id && (
|
||||
@ -342,35 +415,100 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupAssets.length === 0 ? (
|
||||
<div className={styles.empty}>暂无素材图片</div>
|
||||
) : (
|
||||
<div className={styles.assetGrid}>
|
||||
{groupAssets.map((asset) => (
|
||||
<div key={asset.id} className={styles.assetCard}>
|
||||
<img
|
||||
src={tosThumb(asset.url, 300)}
|
||||
alt={asset.name}
|
||||
className={styles.assetThumb}
|
||||
style={{ cursor: 'zoom-in' }}
|
||||
onClick={() => setLightboxSrc(asset.url)}
|
||||
/>
|
||||
<div className={styles.assetInfo}>
|
||||
<div className={styles.assetName}>{asset.name}</div>
|
||||
<span className={`${styles.statusBadge} ${
|
||||
asset.status === 'active' ? styles.statusActive
|
||||
: asset.status === 'processing' ? styles.statusProcessing
|
||||
: styles.statusFailed
|
||||
}`}>
|
||||
{asset.status === 'active' && '可用'}
|
||||
{asset.status === 'processing' && '处理中'}
|
||||
{asset.status === 'failed' && '失败'}
|
||||
</span>
|
||||
</div>
|
||||
{/* ── 按类型分区显示 ── */}
|
||||
{(['Image', 'Video', 'Audio'] as const).map((assetType) => {
|
||||
const typeAssets = groupAssets.filter((a) => (a.asset_type || 'Image') === assetType);
|
||||
const typeLabel = assetType === 'Image' ? '肖像(图片)' : assetType === 'Video' ? '视频' : '音频';
|
||||
const acceptMap = { Image: 'image/*', Video: 'video/mp4,video/quicktime', Audio: 'audio/mpeg,audio/wav' };
|
||||
const hintMap = {
|
||||
Image: '支持 JPG、PNG、WEBP、HEIC,单张不超过 30MB',
|
||||
Video: '支持 MP4、MOV,单个不超过 50MB',
|
||||
Audio: '支持 MP3、WAV,单个不超过 15MB',
|
||||
};
|
||||
const warningMap = {
|
||||
Image: '⚠️ 宽高 300~6000 像素,宽高比 0.4~2.5',
|
||||
Video: '⚠️ 时长 2~15 秒,宽高 300~6000 像素,帧率 24~60 FPS',
|
||||
Audio: '⚠️ 时长 2~15 秒',
|
||||
};
|
||||
return (
|
||||
<div key={assetType} style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{typeLabel}</span>
|
||||
<label className={styles.actionBtn} style={{ cursor: 'pointer', fontSize: 12, padding: '3px 10px' }}>
|
||||
+ 追加
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptMap[assetType]}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleAddAsset(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</div>
|
||||
<div style={{ fontSize: 11, color: '#e8952e', marginBottom: 8 }}>{warningMap[assetType]}</div>
|
||||
{typeAssets.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-disabled)', padding: '12px 0' }}>暂无,点击上方按钮上传</div>
|
||||
) : (
|
||||
<div className={styles.assetGrid}>
|
||||
{typeAssets.map((asset) => (
|
||||
<div key={asset.id} className={styles.assetCard}>
|
||||
{assetType === 'Video' ? (
|
||||
<video src={asset.url} className={styles.assetThumb} muted preload="metadata" />
|
||||
) : assetType === 'Audio' ? (
|
||||
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}>♫</div>
|
||||
) : (
|
||||
<img
|
||||
src={tosThumb(asset.url, 300)}
|
||||
alt={asset.name}
|
||||
className={styles.assetThumb}
|
||||
style={{ cursor: 'zoom-in' }}
|
||||
onClick={() => setLightboxSrc(asset.url)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={styles.assetDeleteBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('确认删除此素材?删除后无法恢复。')) {
|
||||
assetsApi.deleteAsset(asset.id).then(() => {
|
||||
showToast('素材已删除');
|
||||
if (selectedGroup) {
|
||||
assetsApi.getGroupDetail(selectedGroup.id).then(({ data }) => {
|
||||
setGroupAssets(data.assets || []);
|
||||
});
|
||||
}
|
||||
loadGroups(page);
|
||||
}).catch(() => showToast('删除失败,请重试'));
|
||||
}
|
||||
}}
|
||||
title="删除素材"
|
||||
>×</button>
|
||||
<div className={styles.assetInfo}>
|
||||
<div className={styles.assetName}>{asset.name}</div>
|
||||
<span
|
||||
className={`${styles.statusBadge} ${
|
||||
asset.status === 'active' ? styles.statusActive
|
||||
: asset.status === 'processing' ? styles.statusProcessing
|
||||
: styles.statusFailed
|
||||
}`}
|
||||
title={asset.status === 'failed' ? (asset.error_message || '素材处理失败,请删除后重新上传') : undefined}
|
||||
>
|
||||
{asset.status === 'active' && '可用'}
|
||||
{asset.status === 'processing' && '处理中'}
|
||||
{asset.status === 'failed' && '失败'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -389,7 +527,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.inputLabel}>角色图片</div>
|
||||
<div className={styles.inputLabel}>素材文件</div>
|
||||
<div
|
||||
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@ -397,25 +535,32 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{uploadPreview ? (
|
||||
{uploadFile ? (
|
||||
<>
|
||||
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
|
||||
<div className={styles.dropZoneHint}>点击重新选择</div>
|
||||
{uploadPreview ? (
|
||||
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
|
||||
) : (
|
||||
<div style={{ fontSize: 32, padding: '16px 0' }}>
|
||||
{uploadFile.type.startsWith('video/') ? '🎬' : '♫'}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.dropZoneHint}>{uploadFile.name}</div>
|
||||
<div className={styles.dropZoneHint} style={{ color: 'var(--color-text-disabled)' }}>点击重新选择</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.dropZoneText}>上传角色图片</div>
|
||||
<div className={styles.dropZoneHint}>将角色的正面图或三视图拖拽到这里,或点击选择文件</div>
|
||||
<div className={styles.dropZoneHint}>支持 JPG、PNG 格式,单张不超过 30MB</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}>⚠️ 素材上传后无法删除,请确认后再上传</div>
|
||||
<div className={styles.dropZoneWarning}>⚠️ 图片尺寸要求:宽高均需在 300~6000 像素之间</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/*"
|
||||
accept="image/*,video/mp4,video/quicktime,audio/mpeg,audio/wav"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@ -424,6 +424,8 @@ export const assetsApi = {
|
||||
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||||
updateAsset: (id: number, data: { name: string }) =>
|
||||
api.put(`/assets/${id}`, data),
|
||||
deleteAsset: (id: number) =>
|
||||
api.delete(`/assets/${id}`),
|
||||
search: (q: string) =>
|
||||
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
|
||||
pollStatus: (id: number) =>
|
||||
|
||||
@ -435,6 +435,7 @@ export interface AssetItem {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
asset_type: 'Image' | 'Video' | 'Audio';
|
||||
status: 'processing' | 'active' | 'failed';
|
||||
remote_asset_id: string;
|
||||
error_message: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user