From 9bca1bc20f0c505f5f57b28e42a739113489c192 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 4 Apr 2026 14:07:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.17.0=20=E5=AF=B9=E9=BD=90=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=20API=20=E6=96=87=E6=A1=A3=20+=20=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=BA=93=E5=A4=9A=E7=B1=BB=E5=9E=8B=E6=94=AF=E6=8C=81=20+=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Asset URI 大小写修复(Asset:// → asset://) - HEIC/HEIF 图片格式支持 - 素材删除功能(DeleteAsset API + 前端 hover 删除按钮) - 素材库支持视频/音频上传(asset_type 字段 + 后端类型检测) - 素材组详情页按类型分三区(肖像/视频/音频)+ 红字提示 - @ 引用全发(组内所有 active 素材按类型发送) - 前端素材库上传校验(validateAssetFile 全套校验) - Failed 素材 hover 显示错误原因 - 正在生成的视频可点重新编辑 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/0017_add_asset_type.py | 23 ++ backend/apps/generation/models.py | 10 +- backend/apps/generation/views.py | 262 +++++++++++------- .../components/AssetLibraryModal.module.css | 26 ++ web/src/components/AssetLibraryModal.tsx | 257 +++++++++++++---- web/src/lib/api.ts | 2 + web/src/types/index.ts | 1 + 7 files changed, 430 insertions(+), 151 deletions(-) create mode 100644 backend/apps/generation/migrations/0017_add_asset_type.py diff --git a/backend/apps/generation/migrations/0017_add_asset_type.py b/backend/apps/generation/migrations/0017_add_asset_type.py new file mode 100644 index 0000000..cc6e82a --- /dev/null +++ b/backend/apps/generation/migrations/0017_add_asset_type.py @@ -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'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 92b4c84..cee5520 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -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='创建时间') diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index fb3a1c1..ef431bf 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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//assets — add an image to a group.""" + """POST /api/v1/assets/groups//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/ — rename an asset.""" + """PUT /api/v1/assets/ — 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) diff --git a/web/src/components/AssetLibraryModal.module.css b/web/src/components/AssetLibraryModal.module.css index c7e85b8..9a6e696 100644 --- a/web/src/components/AssetLibraryModal.module.css +++ b/web/src/components/AssetLibraryModal.module.css @@ -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; diff --git a/web/src/components/AssetLibraryModal.tsx b/web/src/components/AssetLibraryModal.tsx index e8780f1..7126c83 100644 --- a/web/src/components/AssetLibraryModal.tsx +++ b/web/src/components/AssetLibraryModal.tsx @@ -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 { + 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((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(null); const fileInputRef = useRef(null); - const addFileInputRef = useRef(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 && ( <>
- - { - const file = e.target.files?.[0]; - if (file) handleAddAsset(file); - e.target.value = ''; - }} - />
{editingName && editingName.id === selectedGroup.id && ( @@ -342,35 +415,100 @@ export function AssetLibraryModal({ open, onClose }: Props) { )} - {groupAssets.length === 0 ? ( -
暂无素材图片
- ) : ( -
- {groupAssets.map((asset) => ( -
- {asset.name} setLightboxSrc(asset.url)} - /> -
-
{asset.name}
- - {asset.status === 'active' && '可用'} - {asset.status === 'processing' && '处理中'} - {asset.status === 'failed' && '失败'} - -
+ {/* ── 按类型分区显示 ── */} + {(['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 ( +
+
+ {typeLabel} +
- ))} -
- )} +
{hintMap[assetType]}
+
{warningMap[assetType]}
+ {typeAssets.length === 0 ? ( +
暂无,点击上方按钮上传
+ ) : ( +
+ {typeAssets.map((asset) => ( +
+ {assetType === 'Video' ? ( +
+ ))} +
+ )} +
+ ); + })} )} @@ -389,7 +527,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
-
角色图片
+
素材文件
fileInputRef.current?.click()} @@ -397,25 +535,32 @@ export function AssetLibraryModal({ open, onClose }: Props) { onDragLeave={() => setDragOver(false)} onDrop={handleDrop} > - {uploadPreview ? ( + {uploadFile ? ( <> - 预览 -
点击重新选择
+ {uploadPreview ? ( + 预览 + ) : ( +
+ {uploadFile.type.startsWith('video/') ? '🎬' : '♫'} +
+ )} +
{uploadFile.name}
+
点击重新选择
) : ( <> -
上传角色图片
-
将角色的正面图或三视图拖拽到这里,或点击选择文件
-
支持 JPG、PNG 格式,单张不超过 30MB
+
上传素材文件
+
将素材拖拽到这里,或点击选择文件
+
支持图片(JPG/PNG/WEBP/HEIC)、视频(MP4/MOV)、音频(MP3/WAV)
)} -
⚠️ 素材上传后无法删除,请确认后再上传
-
⚠️ 图片尺寸要求:宽高均需在 300~6000 像素之间
+
⚠️ 图片:宽高 300~6000px,比例 0.4~2.5
+
⚠️ 视频:2~15秒,≤50MB | 音频:2~15秒,≤15MB
{ const file = e.target.files?.[0]; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 359e9cb..929d1e8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -424,6 +424,8 @@ export const assetsApi = { api.post(`/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) => diff --git a/web/src/types/index.ts b/web/src/types/index.ts index cc60b32..43b95a9 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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;