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

This commit is contained in:
zyc 2026-04-04 14:12:09 +08:00
commit 55c26fb1f5
7 changed files with 430 additions and 151 deletions

View 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'),
),
]

View File

@ -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='创建时间')

View File

@ -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)

View File

@ -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;

View File

@ -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 })}
>
&#9998;
</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}> JPGPNG 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~1550MB | 2~1515MB</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];

View File

@ -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) =>

View File

@ -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;