diff --git a/backend/Dockerfile b/backend/Dockerfile index 21287ed..c271d13 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -11,6 +11,7 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debia gcc \ default-libmysqlclient-dev \ pkg-config \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* # Python dependencies diff --git a/backend/apps/generation/migrations/0018_add_thumbnail_and_duration.py b/backend/apps/generation/migrations/0018_add_thumbnail_and_duration.py new file mode 100644 index 0000000..8a26ee9 --- /dev/null +++ b/backend/apps/generation/migrations/0018_add_thumbnail_and_duration.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.29 on 2026-04-04 09:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generation', '0017_add_asset_type'), + ] + + operations = [ + migrations.AddField( + model_name='asset', + name='duration', + field=models.FloatField(default=0, verbose_name='时长(秒)'), + ), + migrations.AddField( + model_name='asset', + name='thumbnail_url', + field=models.CharField(blank=True, default='', max_length=1000, verbose_name='缩略图URL'), + ), + migrations.AddField( + model_name='generationrecord', + name='thumbnail_url', + field=models.CharField(blank=True, default='', max_length=1000, verbose_name='视频缩略图URL'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index cee5520..214610b 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -42,6 +42,7 @@ class GenerationRecord(models.Model): resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态') result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL') + thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='视频缩略图URL') error_message = models.TextField(blank=True, default='', verbose_name='错误信息') raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息') reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息') @@ -156,6 +157,8 @@ class Asset(models.Model): name = models.CharField(max_length=100, default='', verbose_name='素材名称') 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='素材类型') + thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL') + duration = models.FloatField(default=0, 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/tasks.py b/backend/apps/generation/tasks.py index 9e7384c..f42832f 100644 --- a/backend/apps/generation/tasks.py +++ b/backend/apps/generation/tasks.py @@ -64,7 +64,7 @@ def poll_video_task(self, record_id): record.completed_at = timezone.now() record.save(update_fields=[ - 'status', 'result_url', 'error_message', 'raw_error', + 'status', 'result_url', 'thumbnail_url', 'error_message', 'raw_error', 'seed', 'completed_at', ]) @@ -87,6 +87,16 @@ def _handle_completed(record, ark_resp): logger.exception('poll_video_task: failed to persist video to TOS') record.result_url = video_url + # Extract thumbnail from completed video + try: + from utils.media_utils import extract_video_info + from utils.tos_client import upload_file + thumb_file, _ = extract_video_info(record.result_url) + if thumb_file: + record.thumbnail_url = upload_file(thumb_file, folder='thumbnails') + except Exception: + logger.exception('poll_video_task: failed to extract video thumbnail') + # 结算:按实际 tokens 扣费 usage = ark_resp.get('usage', {}) total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0 @@ -143,3 +153,36 @@ def _handle_failed(record, ark_resp): else: from apps.generation.views import _release_freeze _release_freeze(record) + + +@shared_task(ignore_result=True) +def process_asset_media(asset_id): + """Extract thumbnail + duration for video/audio assets asynchronously.""" + from apps.generation.models import Asset + try: + asset = Asset.objects.select_related('group').get(pk=asset_id) + except Asset.DoesNotExist: + logger.warning('process_asset_media: asset %s not found', asset_id) + return + + from utils.media_utils import extract_video_info, get_audio_duration + from utils.tos_client import upload_file + + if asset.asset_type == 'Video': + thumb_file, dur = extract_video_info(asset.url) + if thumb_file: + try: + asset.thumbnail_url = upload_file(thumb_file, folder='thumbnails') + except Exception: + logger.exception('process_asset_media: thumbnail upload failed for asset %s', asset_id) + asset.duration = dur + asset.save(update_fields=['thumbnail_url', 'duration']) + group = asset.group + if not group.thumbnail_url and asset.thumbnail_url: + group.thumbnail_url = asset.thumbnail_url + group.save(update_fields=['thumbnail_url']) + elif asset.asset_type == 'Audio': + asset.duration = get_audio_duration(asset.url) + asset.save(update_fields=['duration']) + + logger.info('process_asset_media: asset %s done (type=%s, dur=%.1f)', asset_id, asset.asset_type, asset.duration) diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index ef431bf..b13cfd4 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -295,7 +295,7 @@ def video_generate_view(request): """查询本地 DB 获取组内所有 active 素材,返回 [(asset_url, asset_type), ...] 列表。 processing 的素材会尝试实时刷新状态。""" assets = list(AssetModel.objects.filter( - group_id=gid, status__in=['active', 'processing'] + group_id=gid, group__team=team, status__in=['active', 'processing'] ).exclude(remote_asset_id='').order_by('created_at')) if not assets: logger.warning('No assets found for group %s (label=%s)', gid, lbl) @@ -349,7 +349,45 @@ def video_generate_view(request): snap['thumb_url'] = thumb_url reference_snapshots.append(snap) - # 转换 asset://group-{id} → 展开为组内所有 active 素材(全发) + # 单素材引用:asset://local-{id} → 查 Asset 表 → 单个 content_item + if url.startswith('asset://local-'): + try: + asset_local_id = int(url.replace('asset://local-', '')) + asset_obj = AssetModel.objects.get(pk=asset_local_id, group__team=team) + if asset_obj.status != 'active': + return Response({ + 'error': 'asset_not_ready', + 'message': f'素材「{label}」尚在处理中,请稍后重试', + }, status=status.HTTP_400_BAD_REQUEST) + if not asset_obj.remote_asset_id: + return Response({ + 'error': 'asset_not_ready', + 'message': f'素材「{label}」尚未就绪,请稍后重试', + }, status=status.HTTP_400_BAD_REQUEST) + aid = asset_obj.remote_asset_id + if aid.startswith('Asset-'): + aid = 'asset-' + aid[6:] + resolved_asset_url = f'asset://{aid}' + if asset_obj.asset_type == 'Video': + content_items.append({'type': 'video_url', 'video_url': {'url': resolved_asset_url}, 'role': 'reference_video'}) + elif asset_obj.asset_type == 'Audio': + content_items.append({'type': 'audio_url', 'audio_url': {'url': resolved_asset_url}, 'role': 'reference_audio'}) + else: + content_items.append({'type': 'image_url', 'image_url': {'url': resolved_asset_url}, 'role': 'reference_image'}) + except AssetModel.DoesNotExist: + return Response({ + 'error': 'asset_not_found', + 'message': f'素材「{label}」不存在或已被删除', + }, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + logger.warning('Failed to resolve asset URL %s: %s', url, e) + return Response({ + 'error': 'asset_not_ready', + 'message': f'素材「{label}」解析失败,请重试', + }, status=status.HTTP_400_BAD_REQUEST) + continue + + # 向后兼容:asset://group-{id} → 展开为组内所有 active 素材 if url.startswith('asset://group-'): try: group_id = int(url.replace('asset://group-', '')) @@ -603,6 +641,7 @@ def _serialize_task(record): 'base_cost_amount': float(record.base_cost_amount), 'status': record.status, 'result_url': d.get('result_url', ''), + 'thumbnail_url': d.get('thumbnail_url', ''), 'error_message': d.get('error_message', ''), 'reference_urls': d.get('reference_urls') or [], 'is_favorited': record.is_favorited, @@ -3007,46 +3046,33 @@ def asset_groups_view(request): return Response({'error': '请输入角色名称'}, status=status.HTTP_400_BAD_REQUEST) file = request.FILES.get('file') - if not file: - return Response({'error': '请上传素材文件'}, status=status.HTTP_400_BAD_REQUEST) - # 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=folder) - except Exception as e: - logger.exception('TOS upload failed for asset') - return Response( - {'error': f'文件上传失败: {e}'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + # Validate file BEFORE creating group (prevent orphan records) + asset_type = None + if file: + asset_type, err = _detect_asset_type(file) + if err: + return err + 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 # Create remote group from utils import assets_client @@ -3057,40 +3083,60 @@ def asset_groups_view(request): if result is not None: remote_group_id = result - # 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, asset_type=asset_type) - if err: - return err - if result is not None: - remote_asset_id = result - - # Local DB records + # Local DB group group = AssetGroup.objects.create( team=team, remote_group_id=remote_group_id, name=name, description='', - thumbnail_url=tos_url, + thumbnail_url='', created_by=request.user, ) - Asset.objects.create( - group=group, - 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 file provided, create first asset (validation already done above) + if file and asset_type: + folder = 'assets' if asset_type == 'Image' else asset_type.lower() + try: + tos_url = tos_upload(file, folder=folder) + except Exception as e: + logger.exception('TOS upload failed for asset') + return Response( + {'error': '文件上传失败,请稍后重试'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + remote_asset_id = '' + if remote_group_id: + 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: + remote_asset_id = result + + asset_obj = Asset.objects.create( + group=group, + 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='', + ) + # Set group thumbnail for images; video/audio thumbnails extracted async + if asset_type == 'Image': + group.thumbnail_url = tos_url + group.save(update_fields=['thumbnail_url']) + # Async: extract thumbnail + duration for video/audio + if asset_type in ('Video', 'Audio'): + from apps.generation.tasks import process_asset_media + process_asset_media.delay(asset_obj.id) return Response({ 'id': group.id, 'name': group.name, 'thumbnail_url': group.thumbnail_url, 'remote_group_id': group.remote_group_id, - 'asset_count': 1, + 'asset_count': Asset.objects.filter(group=group).count(), 'created_at': group.created_at.isoformat(), }, status=status.HTTP_201_CREATED) @@ -3129,6 +3175,8 @@ def asset_group_detail_view(request, group_id): 'name': a.name, 'url': a.url, 'asset_type': a.asset_type, + 'thumbnail_url': a.thumbnail_url, + 'duration': a.duration, 'status': a.status, 'remote_asset_id': a.remote_asset_id, 'error_message': a.error_message, @@ -3230,7 +3278,7 @@ def asset_group_add_asset_view(request, group_id): except Exception as e: logger.exception('TOS upload failed for asset') return Response( - {'error': f'文件上传失败: {e}'}, + {'error': '文件上传失败,请稍后重试'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -3256,16 +3304,23 @@ def asset_group_add_asset_view(request, group_id): error_message='', ) - # If first asset or no thumbnail, set thumbnail - if not group.thumbnail_url: + # If first image asset or no thumbnail, set group thumbnail + if not group.thumbnail_url and asset_type == 'Image': group.thumbnail_url = tos_url group.save(update_fields=['thumbnail_url']) + # Async: extract thumbnail + duration for video/audio + if asset_type in ('Video', 'Audio'): + from apps.generation.tasks import process_asset_media + process_asset_media.delay(asset.id) + return Response({ 'id': asset.id, 'name': asset.name, 'url': asset.url, 'asset_type': asset.asset_type, + 'thumbnail_url': asset.thumbnail_url, + 'duration': asset.duration, 'status': asset.status, 'remote_asset_id': asset.remote_asset_id, 'created_at': asset.created_at.isoformat(), @@ -3298,8 +3353,9 @@ def asset_update_view(request, asset_id): # 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 + new_thumb = remaining.thumbnail_url or remaining.url + if group.thumbnail_url != new_thumb: + group.thumbnail_url = new_thumb group.save(update_fields=['thumbnail_url']) else: group.thumbnail_url = '' @@ -3332,26 +3388,29 @@ def asset_update_view(request, asset_id): @api_view(['GET']) @permission_classes([IsTeamMember]) def asset_search_view(request): - """GET /api/v1/assets/search?q=... — fast search for @ popup.""" + """GET /api/v1/assets/search?q=... — search individual assets for @ popup.""" team = request.user.team - q = request.query_params.get('q', '').strip() + q = request.query_params.get('q', '').strip()[:100] # 限制搜索长度 if not q: return Response({'results': []}) - groups = ( - AssetGroup.objects - .filter(team=team, name__icontains=q) - .annotate(asset_count=Count('assets')) + assets = ( + Asset.objects + .filter(group__team=team, name__icontains=q, status='active') + .select_related('group') .order_by('-created_at')[:20] ) results = [] - for g in groups: + for a in assets: results.append({ - 'id': g.id, - 'name': g.name, - 'thumbnail_url': g.thumbnail_url if g.asset_count > 0 else '', - 'asset_count': g.asset_count, - 'remote_group_id': g.remote_group_id, + 'id': a.id, + 'name': a.name, + 'url': a.url, + 'asset_type': a.asset_type, + 'group_name': a.group.name, + 'remote_asset_id': a.remote_asset_id, + 'thumbnail_url': a.thumbnail_url, + 'duration': a.duration, }) return Response({'results': results}) diff --git a/backend/utils/media_utils.py b/backend/utils/media_utils.py new file mode 100644 index 0000000..343ea48 --- /dev/null +++ b/backend/utils/media_utils.py @@ -0,0 +1,115 @@ +"""Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe.""" + +import logging +import subprocess +import tempfile +import os +import requests + +from django.core.files.uploadedfile import SimpleUploadedFile + +logger = logging.getLogger(__name__) + + +MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB safety limit + + +def _download_to_temp(url: str, suffix: str) -> str: + """Download a URL to a temporary file. Returns the temp file path.""" + resp = requests.get(url, timeout=30, stream=True) + resp.raise_for_status() + tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + downloaded = 0 + try: + for chunk in resp.iter_content(8192): + downloaded += len(chunk) + if downloaded > MAX_DOWNLOAD_SIZE: + tmp.close() + os.unlink(tmp.name) + raise ValueError(f'File too large: {downloaded} bytes') + tmp.write(chunk) + tmp.close() + except Exception: + tmp.close() + if os.path.exists(tmp.name): + os.unlink(tmp.name) + raise + return tmp.name + + +def _get_duration_ffprobe(file_path: str) -> float: + """Get media duration in seconds using ffprobe.""" + try: + result = subprocess.run( + ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', file_path], + capture_output=True, text=True, timeout=15, + ) + return float(result.stdout.strip()) + except Exception as e: + logger.warning('ffprobe duration failed: %s', e) + return 0 + + +def _extract_first_frame(video_path: str, output_path: str) -> bool: + """Extract the first frame of a video as JPEG using ffmpeg.""" + try: + subprocess.run( + ['ffmpeg', '-y', '-i', video_path, '-vframes', '1', + '-f', 'image2', '-q:v', '2', output_path], + capture_output=True, timeout=15, + ) + return os.path.exists(output_path) and os.path.getsize(output_path) > 0 + except Exception as e: + logger.warning('ffmpeg frame extraction failed: %s', e) + return False + + +def extract_video_info(video_url: str) -> tuple: + """Extract first frame thumbnail + duration from a video URL. + Returns (thumbnail_file: SimpleUploadedFile | None, duration: float). + """ + tmp_video = None + tmp_thumb = None + try: + # Determine suffix from URL + suffix = '.mp4' + if '.mov' in video_url.lower(): + suffix = '.mov' + tmp_video = _download_to_temp(video_url, suffix) + + # Get duration + duration = _get_duration_ffprobe(tmp_video) + + # Extract first frame + tmp_thumb = tmp_video + '_thumb.jpg' + if _extract_first_frame(tmp_video, tmp_thumb): + with open(tmp_thumb, 'rb') as f: + thumb_file = SimpleUploadedFile( + 'thumbnail.jpg', f.read(), content_type='image/jpeg' + ) + return thumb_file, duration + return None, duration + except Exception as e: + logger.warning('extract_video_info failed for %s: %s', video_url, e) + return None, 0 + finally: + if tmp_video and os.path.exists(tmp_video): + os.unlink(tmp_video) + if tmp_thumb and os.path.exists(tmp_thumb): + os.unlink(tmp_thumb) + + +def get_audio_duration(audio_url: str) -> float: + """Get audio duration in seconds from a URL.""" + tmp_audio = None + try: + suffix = '.wav' if '.wav' in audio_url.lower() else '.mp3' + tmp_audio = _download_to_temp(audio_url, suffix) + return _get_duration_ffprobe(tmp_audio) + except Exception as e: + logger.warning('get_audio_duration failed for %s: %s', audio_url, e) + return 0 + finally: + if tmp_audio and os.path.exists(tmp_audio): + os.unlink(tmp_audio) diff --git a/web/src/components/AssetLibraryModal.module.css b/web/src/components/AssetLibraryModal.module.css index 9a6e696..e4e38eb 100644 --- a/web/src/components/AssetLibraryModal.module.css +++ b/web/src/components/AssetLibraryModal.module.css @@ -233,6 +233,29 @@ opacity: 1; } +.addAssetCard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + border: 1.5px dashed #3a3a48; + border-radius: 12px; + cursor: pointer; + color: var(--color-text-disabled); + font-size: 12px; + transition: all 0.2s; + background: transparent; + /* match assetThumb height + assetInfo height */ + min-height: 180px; +} + +.addAssetCard:hover { + border-color: var(--color-primary); + color: var(--color-primary); + background: rgba(108, 99, 255, 0.04); +} + .assetThumb { width: 100%; height: 140px; diff --git a/web/src/components/AssetLibraryModal.tsx b/web/src/components/AssetLibraryModal.tsx index 7126c83..c42a62a 100644 --- a/web/src/components/AssetLibraryModal.tsx +++ b/web/src/components/AssetLibraryModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useAssetLibraryStore } from '../store/assetLibrary'; import { assetsApi, tosThumb } from '../lib/api'; import { showToast } from './Toast'; @@ -102,11 +102,7 @@ export function AssetLibraryModal({ open, onClose }: Props) { const [newName, setNewName] = useState(''); const [uploading, setUploading] = useState(false); const [editingName, setEditingName] = useState<{ id: number; value: string } | null>(null); - const [uploadFile, setUploadFile] = useState(null); - const [uploadPreview, setUploadPreview] = useState(null); - const [dragOver, setDragOver] = useState(false); const [lightboxSrc, setLightboxSrc] = useState(null); - const fileInputRef = useRef(null); const groups = useAssetLibraryStore((s) => s.groups); const loading = useAssetLibraryStore((s) => s.loading); @@ -114,7 +110,6 @@ export function AssetLibraryModal({ open, onClose }: Props) { const page = useAssetLibraryStore((s) => s.page); const loadGroups = useAssetLibraryStore((s) => s.loadGroups); const createGroup = useAssetLibraryStore((s) => s.createGroup); - const pollAssetStatus = useAssetLibraryStore((s) => s.pollAssetStatus); const totalPages = Math.ceil(total / 20); @@ -178,29 +173,22 @@ export function AssetLibraryModal({ open, onClose }: Props) { const handleUploadSubmit = useCallback(async () => { const trimmed = newName.trim(); - if (!trimmed || !uploadFile) return; + if (!trimmed) return; if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; } if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; } setUploading(true); - const result = await createGroup(newName.trim(), uploadFile); + const result = await createGroup(trimmed, null); setUploading(false); if (result) { - pollAssetStatus(result.id); setNewName(''); - setUploadFile(null); - if (uploadPreview) URL.revokeObjectURL(uploadPreview); - setUploadPreview(null); - handleBackToList(); + // 创建成功后直接进入详情页 + const group: AssetGroup = { id: result.id, name: trimmed, thumbnail_url: '', asset_count: 0, remote_group_id: result.remote_group_id || '', description: '', created_at: new Date().toISOString() }; + setSelectedGroup(group); + setGroupAssets([]); + setView('detail'); + loadGroups(page); } - }, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]); - - const handleFileSelect = useCallback(async (file: File) => { - const error = await validateAssetFile(file); - if (error) { showToast(error); return; } - if (uploadPreview) URL.revokeObjectURL(uploadPreview); - setUploadFile(file); - setUploadPreview(file.type.startsWith('image/') ? URL.createObjectURL(file) : null); - }, [uploadPreview]); + }, [newName, createGroup, loadGroups, page]); const refreshGroupDetail = useCallback(async () => { if (!selectedGroup) return; @@ -235,21 +223,13 @@ export function AssetLibraryModal({ open, onClose }: Props) { clearInterval(pollInterval); } }, 3000); - showToast('图片已上传,处理中...'); + const typeLabel = file.type.startsWith('video/') ? '视频' : file.type.startsWith('audio/') ? '音频' : '图片'; + showToast(`${typeLabel}已上传,处理中...`); } catch { showToast('上传失败,请重试'); } }, [selectedGroup, refreshGroupDetail]); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setDragOver(false); - const file = e.dataTransfer.files[0]; - if (file && (file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'))) { - handleFileSelect(file); - } - }, [handleFileSelect]); - if (!open) return null; return ( @@ -266,7 +246,7 @@ export function AssetLibraryModal({ open, onClose }: Props) { )} - {view === 'list' && '素材库'} + {view === 'list' && '人物素材库'} {view === 'detail' && (selectedGroup?.name || '角色详情')} {view === 'upload' && '上传新角色'} @@ -299,7 +279,7 @@ export function AssetLibraryModal({ open, onClose }: Props) { {groups.map((group) => (
handleGroupClick(group)}> {group.asset_count === 0 ? ( -
暂无图片
+
暂无素材
) : ( {group.name} )} @@ -434,8 +414,87 @@ export function AssetLibraryModal({ open, onClose }: Props) {
{typeLabel} -
+
{hintMap[assetType]}
+
{warningMap[assetType]}
+
+ {typeAssets.map((asset) => ( +
+ {assetType === 'Video' ? ( + {asset.name} + ) : assetType === 'Audio' ? ( +
+ ) : ( + {asset.name} setLightboxSrc(asset.url)} + /> + )} + +
+
{asset.name}
+ + {asset.status === 'active' && '可用'} + {asset.status === 'processing' && '处理中'} + {asset.status === 'failed' && '失败'} + +
+
+ ))} + {/* 拖拽上传卡片 — 和素材卡片同大小,始终在最后 */} +
-
{hintMap[assetType]}
-
{warningMap[assetType]}
- {typeAssets.length === 0 ? ( -
暂无,点击上方按钮上传
- ) : ( -
- {typeAssets.map((asset) => ( -
- {assetType === 'Video' ? ( -
- ))} -
- )}
); })} )} - {/* Upload View */} + {/* Upload View — only name, no file */} {view === 'upload' && (
@@ -523,59 +524,19 @@ export function AssetLibraryModal({ open, onClose }: Props) { maxLength={64} value={newName} onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleUploadSubmit(); }} + autoFocus />
- -
-
素材文件
-
fileInputRef.current?.click()} - onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} - onDragLeave={() => setDragOver(false)} - onDrop={handleDrop} - > - {uploadFile ? ( - <> - {uploadPreview ? ( - 预览 - ) : ( -
- {uploadFile.type.startsWith('video/') ? '🎬' : '♫'} -
- )} -
{uploadFile.name}
-
点击重新选择
- - ) : ( - <> -
上传素材文件
-
将素材拖拽到这里,或点击选择文件
-
支持图片(JPG/PNG/WEBP/HEIC)、视频(MP4/MOV)、音频(MP3/WAV)
- - )} -
⚠️ 图片:宽高 300~6000px,比例 0.4~2.5
-
⚠️ 视频:2~15秒,≤50MB | 音频:2~15秒,≤15MB
-
- { - const file = e.target.files?.[0]; - if (file) handleFileSelect(file); - e.target.value = ''; - }} - /> +
+ 创建后可在详情页上传图片、视频、音频素材
-
)} diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index 0539b0d..9d2afa0 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -79,13 +79,13 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) { // Render prompt text with @mentions as styled tags (thumbnail + hover preview) export function renderPromptWithMentions( text: string, - assetMentions: { label: string; thumbUrl?: string }[], + assetMentions: Record[], references: { label: string; previewUrl?: string }[] ) { // Build lookup: label → thumbUrl const thumbMap = new Map(); for (const am of assetMentions) { - if (am.label) thumbMap.set(am.label, am.thumbUrl || ''); + if (am.label) thumbMap.set(am.label as string, (am.thumbUrl as string) || ''); } for (const r of references) { if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, r.previewUrl || ''); diff --git a/web/src/components/InputBar.tsx b/web/src/components/InputBar.tsx index 26e772e..339e696 100644 --- a/web/src/components/InputBar.tsx +++ b/web/src/components/InputBar.tsx @@ -114,7 +114,7 @@ export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNod onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }} > - 素材库 + 人物素材库 ))} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 929d1e8..3636cb3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -4,7 +4,7 @@ import type { AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse, BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats, AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo, - LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem, + LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem, AssetSearchResult, } from '../types'; import { reportError } from './logCenter'; @@ -146,7 +146,7 @@ export const videoApi = { model: string; aspect_ratio: string; duration: number; - references: { url: string; type: string; role: string; label: string; thumb_url?: string }[]; + references: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[]; search_mode?: string; seed?: number; }) => @@ -427,7 +427,7 @@ export const assetsApi = { deleteAsset: (id: number) => api.delete(`/assets/${id}`), search: (q: string) => - api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }), + api.get<{ results: AssetSearchResult[] }>('/assets/search', { params: { q } }), pollStatus: (id: number) => api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`), }; diff --git a/web/src/lib/assetMentions.ts b/web/src/lib/assetMentions.ts new file mode 100644 index 0000000..c712796 --- /dev/null +++ b/web/src/lib/assetMentions.ts @@ -0,0 +1,22 @@ +/** + * Parse asset mention spans from editor HTML. + * Returns counts and durations by type, used for number/duration limit checks. + */ +export function parseAssetMentions(html: string): { + counts: { image: number; video: number; audio: number }; + durations: { video: number; audio: number }; +} { + const counts = { image: 0, video: 0, audio: 0 }; + const durations = { video: 0, audio: 0 }; + if (!html) return { counts, durations }; + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + doc.querySelectorAll('[data-ref-type="asset"]').forEach((el) => { + const t = (el as HTMLElement).dataset.assetType || 'Image'; + const dur = parseFloat((el as HTMLElement).dataset.duration || '0'); + if (t === 'Video') { counts.video++; durations.video += dur; } + else if (t === 'Audio') { counts.audio++; durations.audio += dur; } + else { counts.image++; } + }); + return { counts, durations }; +} diff --git a/web/src/store/assetLibrary.ts b/web/src/store/assetLibrary.ts index 0988a33..54bdceb 100644 --- a/web/src/store/assetLibrary.ts +++ b/web/src/store/assetLibrary.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { assetsApi } from '../lib/api'; -import type { AssetGroup } from '../types'; +import type { AssetGroup, AssetSearchResult } from '../types'; import { showToast } from '../components/Toast'; interface AssetLibraryState { @@ -8,12 +8,12 @@ interface AssetLibraryState { loading: boolean; total: number; page: number; - searchResults: AssetGroup[]; + searchResults: AssetSearchResult[]; searching: boolean; loadGroups: (page?: number) => Promise; searchAssets: (query: string) => Promise; - createGroup: (name: string, file: File) => Promise; + createGroup: (name: string, file: File | null) => Promise; pollAssetStatus: (assetId: number) => void; } @@ -45,10 +45,10 @@ export const useAssetLibraryStore = create((set) => ({ } }, - createGroup: async (name: string, file: File) => { + createGroup: async (name: string, file: File | null) => { const formData = new FormData(); formData.append('name', name); - formData.append('file', file); + if (file) formData.append('file', file); try { const { data } = await assetsApi.createGroup(formData); showToast('角色创建成功'); diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index f7503af..2d48416 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -84,8 +84,17 @@ function buildAssetMentions(refs: Array>) { .filter((ref) => isAssetUrl(ref.url || '')) .map((ref) => { const url = ref.url || ''; + // New format: asset://local-{id} + if (url.startsWith('asset://local-')) { + const assetId = url.replace('asset://local-', ''); + return { + assetId, label: ref.label || '', thumbUrl: ref.thumb_url || '', + assetType: ref.type || 'image', duration: parseFloat(ref.duration || '0'), + }; + } + // Legacy format: asset://group-{id} const groupId = url.startsWith('asset://group-') ? url.replace('asset://group-', '') : ''; - return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '' }; + return { groupId, label: ref.label || '', thumbUrl: ref.thumb_url || '', assetType: 'image', duration: 0 }; }); } @@ -109,6 +118,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask { status: mapStatus(bt.status), progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status), resultUrl: bt.result_url || undefined, + thumbnailUrl: bt.thumbnail_url || undefined, errorMessage: mapErrorMessage(bt.error_message), createdAt: new Date(bt.created_at).getTime(), tokensConsumed: bt.tokens_consumed || 0, @@ -349,7 +359,7 @@ export const useGenerationStore = create((set, get) => ({ ].filter(Boolean) as ReferenceSnapshot[]; // Extract asset mentions for placeholder display - const placeholderAssetMentions: { groupId: string; label: string; thumbUrl: string }[] = []; + const placeholderAssetMentions: Record[] = []; if (input.editorHtml) { const parser = new DOMParser(); const doc = parser.parseFromString(input.editorHtml, 'text/html'); @@ -410,7 +420,7 @@ export const useGenerationStore = create((set, get) => ({ try { // Use pre-uploaded TOS URLs (immediate upload), fallback to upload here if needed - const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = []; + const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[] = []; for (const item of filesToUpload) { if (item.tosUrl && !item.tosUrl.startsWith('blob:')) { @@ -422,38 +432,69 @@ export const useGenerationStore = create((set, get) => ({ } } - // Extract asset mentions from editor HTML — deduplicate by groupId - const seenGroupIds = new Set(); + // Extract asset mentions from editor HTML — deduplicate by assetId + const seenAssetIds = new Set(); if (input.editorHtml) { const parser = new DOMParser(); const doc = parser.parseFromString(input.editorHtml, 'text/html'); const assetSpans = doc.querySelectorAll('[data-ref-type="asset"]'); assetSpans.forEach((span) => { const el = span as HTMLElement; - const groupId = el.dataset.assetGroupId; - const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || ''; - if (groupId && !seenGroupIds.has(groupId)) { - seenGroupIds.add(groupId); + const assetId = el.dataset.assetId; + const assetType = (el.dataset.assetType || 'Image').toLowerCase(); + const assetName = el.dataset.assetName || el.textContent?.replace('@', '') || ''; + const duration = el.dataset.duration || '0'; + if (assetId && !seenAssetIds.has(assetId)) { + seenAssetIds.add(assetId); uploadedRefs.push({ - url: `asset://group-${groupId}`, - type: 'image', - role: 'reference_image', - label: groupName, + url: `asset://local-${assetId}`, + type: assetType, + role: `reference_${assetType}`, + label: assetName, thumb_url: el.dataset.thumbUrl || '', + duration, }); } + // Legacy: data-asset-group-id (old format) + if (!assetId && el.dataset.assetGroupId) { + const groupId = el.dataset.assetGroupId; + const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || ''; + if (!seenAssetIds.has(`group-${groupId}`)) { + seenAssetIds.add(`group-${groupId}`); + uploadedRefs.push({ + url: `asset://group-${groupId}`, + type: 'image', + role: 'reference_image', + label: groupName, + thumb_url: el.dataset.thumbUrl || '', + }); + } + } }); } // Fallback: only use inputBar assetMentions when editorHtml has NO asset spans // (regenerate scenario where editorHtml is plain text) - // If user edited the HTML and removed some asset tags, respect that — don't re-add from store const htmlHadAssetSpans = input.editorHtml?.includes('data-ref-type="asset"'); if (!htmlHadAssetSpans) { const inputAssetMentions = input.assetMentions || []; for (const am of inputAssetMentions) { - if (am.groupId && !seenGroupIds.has(am.groupId)) { - seenGroupIds.add(am.groupId); + // New format + if (am.assetId && !seenAssetIds.has(am.assetId)) { + seenAssetIds.add(am.assetId); + const t = (am.assetType || 'Image').toLowerCase(); + uploadedRefs.push({ + url: `asset://local-${am.assetId}`, + type: t, + role: `reference_${t}`, + label: am.label, + thumb_url: am.thumbUrl || '', + duration: String(am.duration || 0), + }); + } + // Legacy format + if (!am.assetId && am.groupId && !seenAssetIds.has(`group-${am.groupId}`)) { + seenAssetIds.add(`group-${am.groupId}`); uploadedRefs.push({ url: `asset://group-${am.groupId}`, type: 'image', diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts index 14e7a44..dc3900d 100644 --- a/web/src/store/inputBar.ts +++ b/web/src/store/inputBar.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types'; import { showToast } from '../components/Toast'; import { mediaApi } from '../lib/api'; +import { parseAssetMentions } from '../lib/assetMentions'; let fileCounter = 0; @@ -123,7 +124,8 @@ interface InputBarState { setSeedEnabled: (enabled: boolean) => void; // Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild) - assetMentions: { groupId: string; label: string; thumbUrl: string }[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assetMentions: Record[]; // @ trigger (for toolbar button to insert @ in contentEditable) insertAtTrigger: number; @@ -170,9 +172,13 @@ export const useInputBarStore = create((set, get) => ({ prevReferences: [], addReferences: (files) => { const state = get(); - // Count existing references by type + // Count existing references by type + merge @ asset mentions const counts = { image: 0, video: 0, audio: 0 }; for (const ref of state.references) counts[ref.type]++; + const { counts: assetCounts } = parseAssetMentions(state.editorHtml); + counts.image += assetCounts.image; + counts.video += assetCounts.video; + counts.audio += assetCounts.audio; // Separate images (sync) from audio/video (need async duration check) const imageFiles: File[] = []; @@ -496,11 +502,13 @@ async function _validateAndAddMedia(files: File[]) { } } - // Total duration check (same type) + // Total duration check (same type) — merge @ asset mention durations const state = useInputBarStore.getState(); - const existingDuration = state.references + const { durations: assetDurations } = parseAssetMentions(state.editorHtml); + const refDuration = state.references .filter((r) => r.type === type && r.duration) .reduce((sum, r) => sum + (r.duration || 0), 0); + const existingDuration = refDuration + (type === 'video' ? assetDurations.video : assetDurations.audio); if (existingDuration + dur > MAX_MEDIA_DURATION + 0.4) { showToast(`${typeLabel}总时长不能超过${MAX_MEDIA_DURATION}秒`); continue; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 43b95a9..f3f0730 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -44,10 +44,12 @@ export interface GenerationTask { aspectRatio: AspectRatio; duration: Duration; references: ReferenceSnapshot[]; - assetMentions: { groupId: string; label: string; thumbUrl: string }[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assetMentions: Record[]; status: TaskStatus; progress: number; resultUrl?: string; + thumbnailUrl?: string; errorMessage?: string; createdAt: number; tokensConsumed?: number; @@ -71,6 +73,7 @@ export interface BackendTask { base_cost_amount: number; status: 'queued' | 'processing' | 'completed' | 'failed'; result_url: string; + thumbnail_url: string; error_message: string; reference_urls: { url: string; type: string; role: string; label: string }[]; is_favorited: boolean; @@ -436,8 +439,21 @@ export interface AssetItem { name: string; url: string; asset_type: 'Image' | 'Video' | 'Audio'; + thumbnail_url: string; + duration: number; status: 'processing' | 'active' | 'failed'; remote_asset_id: string; error_message: string; created_at: string; } + +export interface AssetSearchResult { + id: number; + name: string; + url: string; + asset_type: 'Image' | 'Video' | 'Audio'; + group_name: string; + remote_asset_id: string; + thumbnail_url: string; + duration: number; +}