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):
|
class Asset(models.Model):
|
||||||
"""虚拟人像素材 — 单张图片。"""
|
"""虚拟人像素材 — 图片/视频/音频。"""
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('processing', '处理中'),
|
('processing', '处理中'),
|
||||||
('active', '可用'),
|
('active', '可用'),
|
||||||
('failed', '失败'),
|
('failed', '失败'),
|
||||||
]
|
]
|
||||||
|
ASSET_TYPE_CHOICES = [
|
||||||
|
('Image', '图像'),
|
||||||
|
('Video', '视频'),
|
||||||
|
('Audio', '音频'),
|
||||||
|
]
|
||||||
|
|
||||||
group = models.ForeignKey(
|
group = models.ForeignKey(
|
||||||
AssetGroup, on_delete=models.CASCADE,
|
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')
|
remote_asset_id = models.CharField(max_length=100, default='', verbose_name='火山Asset ID')
|
||||||
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='素材类型')
|
||||||
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='创建时间')
|
||||||
|
|||||||
@ -32,7 +32,7 @@ User = get_user_model()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# File validation constants
|
# 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_VIDEO_EXTS = {'mp4', 'mov'}
|
||||||
ALLOWED_AUDIO_EXTS = {'mp3', 'wav'}
|
ALLOWED_AUDIO_EXTS = {'mp3', 'wav'}
|
||||||
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
|
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
|
||||||
@ -287,20 +287,21 @@ def video_generate_view(request):
|
|||||||
reference_snapshots = []
|
reference_snapshots = []
|
||||||
content_items = []
|
content_items = []
|
||||||
seen_urls = set() # 去重:同一个素材只引用一次
|
seen_urls = set() # 去重:同一个素材只引用一次
|
||||||
_asset_cache = {} # group_id → resolved_url,避免同一素材组重复查询
|
_asset_cache = {} # group_id → [(asset_url, asset_type), ...],避免同一素材组重复查询
|
||||||
|
|
||||||
from .models import Asset as AssetModel
|
from .models import Asset as AssetModel
|
||||||
|
|
||||||
def _resolve_asset_group(gid, lbl):
|
def _resolve_asset_group_all(gid, lbl):
|
||||||
"""查询本地 DB + 必要时刷新火山状态,返回 Asset://xxx 或原始 asset:// URL。"""
|
"""查询本地 DB 获取组内所有 active 素材,返回 [(asset_url, asset_type), ...] 列表。
|
||||||
asset = AssetModel.objects.filter(
|
processing 的素材会尝试实时刷新状态。"""
|
||||||
|
assets = list(AssetModel.objects.filter(
|
||||||
group_id=gid, status__in=['active', 'processing']
|
group_id=gid, status__in=['active', 'processing']
|
||||||
).order_by(
|
).exclude(remote_asset_id='').order_by('created_at'))
|
||||||
Case(When(status='active', then=0), default=1)
|
if not assets:
|
||||||
).first()
|
logger.warning('No assets found for group %s (label=%s)', gid, lbl)
|
||||||
if not asset or not asset.remote_asset_id:
|
return []
|
||||||
logger.warning('No asset found for group %s (label=%s)', gid, lbl)
|
resolved_list = []
|
||||||
return f'asset://group-{gid}'
|
for asset in assets:
|
||||||
# 本地 processing → 实时查火山刷新
|
# 本地 processing → 实时查火山刷新
|
||||||
if asset.status == 'processing':
|
if asset.status == 'processing':
|
||||||
result, _ = _assets_api_call(assets_client.get_asset, asset.remote_asset_id)
|
result, _ = _assets_api_call(assets_client.get_asset, asset.remote_asset_id)
|
||||||
@ -310,14 +311,15 @@ def video_generate_view(request):
|
|||||||
asset.save(update_fields=['status', 'url'])
|
asset.save(update_fields=['status', 'url'])
|
||||||
logger.info('Asset %s refreshed to active from Volcano', asset.remote_asset_id)
|
logger.info('Asset %s refreshed to active from Volcano', asset.remote_asset_id)
|
||||||
else:
|
else:
|
||||||
logger.warning('Asset %s still processing on Volcano', asset.remote_asset_id)
|
logger.warning('Asset %s still processing, skipped', asset.remote_asset_id)
|
||||||
return f'asset://group-{gid}'
|
continue # 跳过未就绪的素材
|
||||||
aid = asset.remote_asset_id
|
aid = asset.remote_asset_id
|
||||||
if aid.startswith('asset-'):
|
if aid.startswith('Asset-'):
|
||||||
aid = 'Asset-' + aid[6:]
|
aid = 'asset-' + aid[6:]
|
||||||
resolved = f'Asset://{aid}'
|
resolved_url = f'asset://{aid}'
|
||||||
logger.info('Asset resolved: group=%s -> %s', gid, resolved)
|
resolved_list.append((resolved_url, asset.asset_type))
|
||||||
return resolved
|
logger.info('Asset group %s resolved: %d assets', gid, len(resolved_list))
|
||||||
|
return resolved_list
|
||||||
|
|
||||||
from utils import assets_client
|
from utils import assets_client
|
||||||
|
|
||||||
@ -347,30 +349,37 @@ 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} 为火山 Asset://Asset-xxx 格式(仅用于 content_items)
|
# 转换 asset://group-{id} → 展开为组内所有 active 素材(全发)
|
||||||
resolved_url = url
|
|
||||||
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-', ''))
|
||||||
# 跨迭代缓存:同一 group_id 不重复查询/刷新
|
|
||||||
if group_id in _asset_cache:
|
if group_id in _asset_cache:
|
||||||
resolved_url = _asset_cache[group_id]
|
asset_list = _asset_cache[group_id]
|
||||||
else:
|
else:
|
||||||
resolved_url = _resolve_asset_group(group_id, label)
|
asset_list = _resolve_asset_group_all(group_id, label)
|
||||||
_asset_cache[group_id] = resolved_url
|
_asset_cache[group_id] = asset_list
|
||||||
except Exception as e:
|
if not asset_list:
|
||||||
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({
|
return Response({
|
||||||
'error': 'asset_not_ready',
|
'error': 'asset_not_ready',
|
||||||
'message': f'素材「{label}」尚未就绪,请在素材库中确认状态为"可用"后重试',
|
'message': f'素材「{label}」尚未就绪,请在素材库中确认状态为"可用"后重试',
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
}, 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)
|
||||||
|
return Response({
|
||||||
|
'error': 'asset_not_ready',
|
||||||
|
'message': f'素材「{label}」解析失败,请重试',
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
continue # 素材组已展开为多个 content_items,跳过下面的单项处理
|
||||||
|
|
||||||
if ref_type == 'image':
|
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
|
# API 文档要求:参考图模式下所有图片的 role 必须为 reference_image
|
||||||
if mode == 'universal':
|
if mode == 'universal':
|
||||||
item['role'] = 'reference_image'
|
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'])
|
@api_view(['GET', 'POST'])
|
||||||
@permission_classes([IsTeamMember])
|
@permission_classes([IsTeamMember])
|
||||||
@parser_classes([MultiPartParser, JSONParser])
|
@parser_classes([MultiPartParser, JSONParser])
|
||||||
def asset_groups_view(request):
|
def asset_groups_view(request):
|
||||||
"""GET /api/v1/assets/groups — list groups for current team.
|
"""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
|
team = request.user.team
|
||||||
|
|
||||||
@ -2975,32 +3008,39 @@ def asset_groups_view(request):
|
|||||||
|
|
||||||
file = request.FILES.get('file')
|
file = request.FILES.get('file')
|
||||||
if not 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)
|
# 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:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
img = Image.open(file)
|
img = Image.open(file)
|
||||||
w, h = img.size
|
w, h = img.size
|
||||||
if w < 300 or h < 300:
|
if w < 300 or h < 300:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': f'图片太小了,请上传更大的图片(当前 {w}x{h},最小要求 300x300)'},
|
{'error': f'图片太小了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
if w > 6000 or h > 6000:
|
if w > 6000 or h > 6000:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': f'图片太大了,请压缩后重试(当前 {w}x{h},最大支持 6000x6000)'},
|
{'error': f'图片太大了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
file.seek(0) # Reset after PIL read
|
file.seek(0)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass # Pillow not installed, skip validation
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Not an image or corrupted, let TOS handle it
|
pass
|
||||||
|
|
||||||
# Upload to TOS
|
# Upload to TOS
|
||||||
|
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
|
||||||
try:
|
try:
|
||||||
tos_url = tos_upload(file, folder='assets')
|
tos_url = tos_upload(file, folder=folder)
|
||||||
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(
|
||||||
@ -3020,7 +3060,7 @@ def asset_groups_view(request):
|
|||||||
# Create remote asset
|
# Create remote asset
|
||||||
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)
|
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name, asset_type=asset_type)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@ -3040,6 +3080,7 @@ def asset_groups_view(request):
|
|||||||
remote_asset_id=remote_asset_id,
|
remote_asset_id=remote_asset_id,
|
||||||
name=name,
|
name=name,
|
||||||
url=tos_url,
|
url=tos_url,
|
||||||
|
asset_type=asset_type,
|
||||||
status='processing' if remote_asset_id else 'active',
|
status='processing' if remote_asset_id else 'active',
|
||||||
error_message='',
|
error_message='',
|
||||||
)
|
)
|
||||||
@ -3087,6 +3128,7 @@ def asset_group_detail_view(request, group_id):
|
|||||||
'id': a.id,
|
'id': a.id,
|
||||||
'name': a.name,
|
'name': a.name,
|
||||||
'url': a.url,
|
'url': a.url,
|
||||||
|
'asset_type': a.asset_type,
|
||||||
'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,
|
||||||
@ -3141,7 +3183,7 @@ def asset_group_detail_view(request, group_id):
|
|||||||
@permission_classes([IsTeamMember])
|
@permission_classes([IsTeamMember])
|
||||||
@parser_classes([MultiPartParser])
|
@parser_classes([MultiPartParser])
|
||||||
def asset_group_add_asset_view(request, group_id):
|
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
|
team = request.user.team
|
||||||
try:
|
try:
|
||||||
group = AssetGroup.objects.get(pk=group_id, team=team)
|
group = AssetGroup.objects.get(pk=group_id, team=team)
|
||||||
@ -3152,19 +3194,25 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
if not 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)
|
# 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:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
img = Image.open(file)
|
img = Image.open(file)
|
||||||
w, h = img.size
|
w, h = img.size
|
||||||
if w < 300 or h < 300:
|
if w < 300 or h < 300:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': f'图片太小了,请上传更大的图片(当前 {w}x{h},最小要求 300x300)'},
|
{'error': f'图片太小了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
if w > 6000 or h > 6000:
|
if w > 6000 or h > 6000:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': f'图片太大了,请压缩后重试(当前 {w}x{h},最大支持 6000x6000)'},
|
{'error': f'图片太大了(当前 {w}x{h}),宽高需在 300~6000 像素之间'},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
file.seek(0)
|
file.seek(0)
|
||||||
@ -3176,8 +3224,9 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
name = request.data.get('name', '').strip() or file.name
|
name = request.data.get('name', '').strip() or file.name
|
||||||
|
|
||||||
# Upload to TOS
|
# Upload to TOS
|
||||||
|
folder = 'assets' if asset_type == 'Image' else asset_type.lower()
|
||||||
try:
|
try:
|
||||||
tos_url = tos_upload(file, folder='assets')
|
tos_url = tos_upload(file, folder=folder)
|
||||||
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(
|
||||||
@ -3190,7 +3239,7 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
remote_asset_id = ''
|
remote_asset_id = ''
|
||||||
if group.remote_group_id:
|
if group.remote_group_id:
|
||||||
result, err = _assets_api_call(
|
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:
|
if err:
|
||||||
return err
|
return err
|
||||||
@ -3202,11 +3251,12 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
remote_asset_id=remote_asset_id,
|
remote_asset_id=remote_asset_id,
|
||||||
name=name,
|
name=name,
|
||||||
url=tos_url,
|
url=tos_url,
|
||||||
|
asset_type=asset_type,
|
||||||
status='processing' if remote_asset_id else 'active',
|
status='processing' if remote_asset_id else 'active',
|
||||||
error_message='',
|
error_message='',
|
||||||
)
|
)
|
||||||
|
|
||||||
# If first asset, set thumbnail
|
# If first asset or no thumbnail, set thumbnail
|
||||||
if not group.thumbnail_url:
|
if not group.thumbnail_url:
|
||||||
group.thumbnail_url = tos_url
|
group.thumbnail_url = tos_url
|
||||||
group.save(update_fields=['thumbnail_url'])
|
group.save(update_fields=['thumbnail_url'])
|
||||||
@ -3215,23 +3265,49 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
'id': asset.id,
|
'id': asset.id,
|
||||||
'name': asset.name,
|
'name': asset.name,
|
||||||
'url': asset.url,
|
'url': asset.url,
|
||||||
|
'asset_type': asset.asset_type,
|
||||||
'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(),
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['PUT'])
|
@api_view(['PUT', 'DELETE'])
|
||||||
@permission_classes([IsTeamMember])
|
@permission_classes([IsTeamMember])
|
||||||
@parser_classes([JSONParser])
|
@parser_classes([JSONParser])
|
||||||
def asset_update_view(request, asset_id):
|
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
|
team = request.user.team
|
||||||
try:
|
try:
|
||||||
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
|
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
|
||||||
except Asset.DoesNotExist:
|
except Asset.DoesNotExist:
|
||||||
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
|
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')
|
new_name = request.data.get('name')
|
||||||
if not new_name:
|
if not new_name:
|
||||||
return Response({'error': '请提供素材名称'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': '请提供素材名称'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|||||||
@ -201,12 +201,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assetCard {
|
.assetCard {
|
||||||
|
position: relative;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--color-border-card);
|
border: 1px solid var(--color-border-card);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
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 {
|
.assetThumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
|
|||||||
@ -6,6 +6,90 @@ import { ImageLightbox } from './ImageLightbox';
|
|||||||
import type { AssetGroup, AssetItem } from '../types';
|
import type { AssetGroup, AssetItem } from '../types';
|
||||||
import styles from './AssetLibraryModal.module.css';
|
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 {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -23,7 +107,6 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
const [dragOver, setDragOver] = useState(false);
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const addFileInputRef = 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);
|
||||||
@ -111,10 +194,12 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
|
}, [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);
|
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
|
||||||
setUploadFile(file);
|
setUploadFile(file);
|
||||||
setUploadPreview(URL.createObjectURL(file));
|
setUploadPreview(file.type.startsWith('image/') ? URL.createObjectURL(file) : null);
|
||||||
}, [uploadPreview]);
|
}, [uploadPreview]);
|
||||||
|
|
||||||
const refreshGroupDetail = useCallback(async () => {
|
const refreshGroupDetail = useCallback(async () => {
|
||||||
@ -127,6 +212,8 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
|
|
||||||
const handleAddAsset = useCallback(async (file: File) => {
|
const handleAddAsset = useCallback(async (file: File) => {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
|
const error = await validateAssetFile(file);
|
||||||
|
if (error) { showToast(error); return; }
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
try {
|
try {
|
||||||
@ -158,7 +245,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const file = e.dataTransfer.files[0];
|
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(file);
|
||||||
}
|
}
|
||||||
}, [handleFileSelect]);
|
}, [handleFileSelect]);
|
||||||
@ -290,26 +377,12 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
{view === 'detail' && selectedGroup && (
|
{view === 'detail' && selectedGroup && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button className={styles.actionBtn} onClick={() => addFileInputRef.current?.click()}>
|
|
||||||
+ 追加图片
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className={styles.actionBtnOutline}
|
className={styles.actionBtnOutline}
|
||||||
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
|
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
|
||||||
>
|
>
|
||||||
✎ 改名
|
✎ 改名
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{editingName && editingName.id === selectedGroup.id && (
|
{editingName && editingName.id === selectedGroup.id && (
|
||||||
@ -342,12 +415,52 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupAssets.length === 0 ? (
|
{/* ── 按类型分区显示 ── */}
|
||||||
<div className={styles.empty}>暂无素材图片</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 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}>
|
<div className={styles.assetGrid}>
|
||||||
{groupAssets.map((asset) => (
|
{typeAssets.map((asset) => (
|
||||||
<div key={asset.id} className={styles.assetCard}>
|
<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
|
<img
|
||||||
src={tosThumb(asset.url, 300)}
|
src={tosThumb(asset.url, 300)}
|
||||||
alt={asset.name}
|
alt={asset.name}
|
||||||
@ -355,13 +468,35 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
style={{ cursor: 'zoom-in' }}
|
style={{ cursor: 'zoom-in' }}
|
||||||
onClick={() => setLightboxSrc(asset.url)}
|
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.assetInfo}>
|
||||||
<div className={styles.assetName}>{asset.name}</div>
|
<div className={styles.assetName}>{asset.name}</div>
|
||||||
<span className={`${styles.statusBadge} ${
|
<span
|
||||||
|
className={`${styles.statusBadge} ${
|
||||||
asset.status === 'active' ? styles.statusActive
|
asset.status === 'active' ? styles.statusActive
|
||||||
: asset.status === 'processing' ? styles.statusProcessing
|
: asset.status === 'processing' ? styles.statusProcessing
|
||||||
: styles.statusFailed
|
: styles.statusFailed
|
||||||
}`}>
|
}`}
|
||||||
|
title={asset.status === 'failed' ? (asset.error_message || '素材处理失败,请删除后重新上传') : undefined}
|
||||||
|
>
|
||||||
{asset.status === 'active' && '可用'}
|
{asset.status === 'active' && '可用'}
|
||||||
{asset.status === 'processing' && '处理中'}
|
{asset.status === 'processing' && '处理中'}
|
||||||
{asset.status === 'failed' && '失败'}
|
{asset.status === 'failed' && '失败'}
|
||||||
@ -371,6 +506,9 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -389,7 +527,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.inputLabel}>角色图片</div>
|
<div className={styles.inputLabel}>素材文件</div>
|
||||||
<div
|
<div
|
||||||
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
|
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
@ -397,25 +535,32 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
onDragLeave={() => setDragOver(false)}
|
onDragLeave={() => setDragOver(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
{uploadPreview ? (
|
{uploadFile ? (
|
||||||
<>
|
<>
|
||||||
|
{uploadPreview ? (
|
||||||
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
|
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
|
||||||
<div className={styles.dropZoneHint}>点击重新选择</div>
|
) : (
|
||||||
|
<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.dropZoneText}>上传素材文件</div>
|
||||||
<div className={styles.dropZoneHint}>将角色的正面图或三视图拖拽到这里,或点击选择文件</div>
|
<div className={styles.dropZoneHint}>将素材拖拽到这里,或点击选择文件</div>
|
||||||
<div className={styles.dropZoneHint}>支持 JPG、PNG 格式,单张不超过 30MB</div>
|
<div className={styles.dropZoneHint}>支持图片(JPG/PNG/WEBP/HEIC)、视频(MP4/MOV)、音频(MP3/WAV)</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={styles.dropZoneWarning}>⚠️ 素材上传后无法删除,请确认后再上传</div>
|
<div className={styles.dropZoneWarning}>⚠️ 图片:宽高 300~6000px,比例 0.4~2.5</div>
|
||||||
<div className={styles.dropZoneWarning}>⚠️ 图片尺寸要求:宽高均需在 300~6000 像素之间</div>
|
<div className={styles.dropZoneWarning}>⚠️ 视频:2~15秒,≤50MB | 音频:2~15秒,≤15MB</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*,video/mp4,video/quicktime,audio/mpeg,audio/wav"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
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' } }),
|
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 }) =>
|
||||||
api.put(`/assets/${id}`, data),
|
api.put(`/assets/${id}`, data),
|
||||||
|
deleteAsset: (id: number) =>
|
||||||
|
api.delete(`/assets/${id}`),
|
||||||
search: (q: string) =>
|
search: (q: string) =>
|
||||||
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
|
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
|
||||||
pollStatus: (id: number) =>
|
pollStatus: (id: number) =>
|
||||||
|
|||||||
@ -435,6 +435,7 @@ export interface AssetItem {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
asset_type: 'Image' | 'Video' | 'Audio';
|
||||||
status: 'processing' | 'active' | 'failed';
|
status: 'processing' | 'active' | 'failed';
|
||||||
remote_asset_id: string;
|
remote_asset_id: string;
|
||||||
error_message: string;
|
error_message: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user