fix: 素材库引用缩略图烂图 + 火山跨项目素材同步脚本
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m56s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m56s
- MentionTag/createMentionSpan/VideoDetailModal: img onError fallback,缩略图加载失败显示占位图标 - buildReferenceSnapshots: 素材库引用用 thumb_url 做 previewUrl,不再过滤 - isAssetRef 标记防止视频缩略图被 <video> 标签渲染、重新编辑时防重复 - sync_volcano_assets management command: 从火山 default 项目同步素材到本地 DB Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db1bbfa1d4
commit
ae0e2d4365
@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
从火山 default 项目同步素材组+素材到本地数据库。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py sync_volcano_assets --team-id 1 [--dry-run]
|
||||||
|
python manage.py sync_volcano_assets --team-id 1 --group-ids group-xxx,group-yyy
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from apps.accounts.models import Team
|
||||||
|
from apps.generation.models import AssetGroup, Asset
|
||||||
|
from utils.assets_client import _do_request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 火山素材所在的项目名
|
||||||
|
SOURCE_PROJECT = 'default'
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_groups_from_volcano(group_ids=None, page_size=50):
|
||||||
|
"""从火山 default 项目拉取资源组列表。"""
|
||||||
|
body = {
|
||||||
|
'Filter': {'GroupType': 'AIGC'},
|
||||||
|
'PageNumber': 1,
|
||||||
|
'PageSize': page_size,
|
||||||
|
'ProjectName': SOURCE_PROJECT,
|
||||||
|
}
|
||||||
|
if group_ids:
|
||||||
|
body['Filter']['GroupIds'] = group_ids
|
||||||
|
result = _do_request('ListAssetGroups', body)
|
||||||
|
return result.get('Items', [])
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_assets_from_volcano(group_id):
|
||||||
|
"""从火山 default 项目拉取某组下的所有素材。"""
|
||||||
|
body = {
|
||||||
|
'Filter': {'GroupType': 'AIGC', 'GroupIds': [group_id]},
|
||||||
|
'PageNumber': 1,
|
||||||
|
'PageSize': 100,
|
||||||
|
'ProjectName': SOURCE_PROJECT,
|
||||||
|
}
|
||||||
|
result = _do_request('ListAssets', body)
|
||||||
|
return result.get('Items', [])
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '从火山 default 项目同步素材组和素材到本地数据库'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument('--team-id', type=int, help='绑定到的团队 ID')
|
||||||
|
group.add_argument('--team-name', type=str, help='绑定到的团队名称')
|
||||||
|
parser.add_argument('--group-ids', type=str, default='', help='指定要同步的组 ID(逗号分隔),留空同步全部')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='只打印不写入数据库')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
group_ids_str = options['group_ids'].strip()
|
||||||
|
|
||||||
|
if options['team_id']:
|
||||||
|
try:
|
||||||
|
team = Team.objects.get(pk=options['team_id'])
|
||||||
|
except Team.DoesNotExist:
|
||||||
|
self.stderr.write(f'团队 ID {options["team_id"]} 不存在')
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
team = Team.objects.get(name=options['team_name'])
|
||||||
|
except Team.DoesNotExist:
|
||||||
|
self.stderr.write(f'团队「{options["team_name"]}」不存在')
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(f'目标团队: {team.name} (ID={team_id})')
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write('** DRY RUN — 不会写入数据库 **')
|
||||||
|
|
||||||
|
# 拉取火山资源组
|
||||||
|
group_ids = [g.strip() for g in group_ids_str.split(',') if g.strip()] if group_ids_str else None
|
||||||
|
volcano_groups = fetch_groups_from_volcano(group_ids=group_ids)
|
||||||
|
self.stdout.write(f'从火山 {SOURCE_PROJECT} 项目拉取到 {len(volcano_groups)} 个资源组')
|
||||||
|
|
||||||
|
created_groups = 0
|
||||||
|
created_assets = 0
|
||||||
|
skipped_groups = 0
|
||||||
|
skipped_assets = 0
|
||||||
|
|
||||||
|
for vg in volcano_groups:
|
||||||
|
remote_gid = vg.get('Id', '')
|
||||||
|
group_name = vg.get('Name', '') or remote_gid
|
||||||
|
|
||||||
|
# 检查本地是否已存在
|
||||||
|
existing = AssetGroup.objects.filter(remote_group_id=remote_gid, team=team).first()
|
||||||
|
if existing:
|
||||||
|
self.stdout.write(f' [跳过] 组 {group_name} ({remote_gid}) — 本地已存在 (pk={existing.pk})')
|
||||||
|
skipped_groups += 1
|
||||||
|
local_group = existing
|
||||||
|
else:
|
||||||
|
self.stdout.write(f' [新增] 组 {group_name} ({remote_gid})')
|
||||||
|
if not dry_run:
|
||||||
|
local_group = AssetGroup.objects.create(
|
||||||
|
team=team,
|
||||||
|
remote_group_id=remote_gid,
|
||||||
|
name=group_name,
|
||||||
|
description=vg.get('Description', ''),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
local_group = None
|
||||||
|
created_groups += 1
|
||||||
|
|
||||||
|
# 拉取该组下的素材
|
||||||
|
volcano_assets = fetch_assets_from_volcano(remote_gid)
|
||||||
|
self.stdout.write(f' └ {len(volcano_assets)} 个素材')
|
||||||
|
|
||||||
|
for va in volcano_assets:
|
||||||
|
remote_aid = va.get('Id', '')
|
||||||
|
asset_name = va.get('Name', '') or remote_aid
|
||||||
|
asset_type = va.get('AssetType', 'Image')
|
||||||
|
status_map = {'Active': 'active', 'Processing': 'processing', 'Failed': 'failed'}
|
||||||
|
local_status = status_map.get(va.get('Status', ''), 'processing')
|
||||||
|
preview_url = va.get('PreviewUrl', '') or ''
|
||||||
|
|
||||||
|
# 检查本地是否已存在
|
||||||
|
if local_group and Asset.objects.filter(remote_asset_id=remote_aid, group=local_group).exists():
|
||||||
|
self.stdout.write(f' [跳过] 素材 {asset_name} ({remote_aid})')
|
||||||
|
skipped_assets += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(f' [新增] 素材 {asset_name} | {asset_type} | {local_status} | {remote_aid}')
|
||||||
|
if not dry_run and local_group:
|
||||||
|
Asset.objects.create(
|
||||||
|
group=local_group,
|
||||||
|
remote_asset_id=remote_aid,
|
||||||
|
name=asset_name,
|
||||||
|
url=preview_url,
|
||||||
|
asset_type=asset_type,
|
||||||
|
status=local_status,
|
||||||
|
)
|
||||||
|
created_assets += 1
|
||||||
|
|
||||||
|
# 更新组缩略图(取第一张图片素材的 URL)
|
||||||
|
if not dry_run:
|
||||||
|
for ag in AssetGroup.objects.filter(team=team, thumbnail_url=''):
|
||||||
|
first_img = ag.assets.filter(asset_type='Image', status='active').first()
|
||||||
|
if first_img and first_img.url:
|
||||||
|
ag.thumbnail_url = first_img.url
|
||||||
|
ag.save(update_fields=['thumbnail_url'])
|
||||||
|
self.stdout.write(f' [更新缩略图] {ag.name}')
|
||||||
|
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write(f'完成! 新增 {created_groups} 组 + {created_assets} 素材,跳过 {skipped_groups} 组 + {skipped_assets} 素材')
|
||||||
@ -39,9 +39,12 @@ const DownloadIcon = () => (
|
|||||||
// Mention tag with thumbnail + hover preview
|
// Mention tag with thumbnail + hover preview
|
||||||
function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) {
|
function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) {
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
|
const [thumbBroken, setThumbBroken] = useState(false);
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||||
const isAudio = assetType === 'Audio' || assetType === 'audio';
|
const isAudio = assetType === 'Audio' || assetType === 'audio';
|
||||||
|
const isVideo = assetType === 'Video' || assetType === 'video';
|
||||||
|
const showThumb = thumbUrl && !thumbBroken;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -49,7 +52,7 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={styles.mentionTag}
|
className={styles.mentionTag}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (!isAudio && thumbUrl && ref.current) {
|
if (!isAudio && showThumb && ref.current) {
|
||||||
const rect = ref.current.getBoundingClientRect();
|
const rect = ref.current.getBoundingClientRect();
|
||||||
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
|
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
|
||||||
setHover(true);
|
setHover(true);
|
||||||
@ -59,18 +62,30 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
|
|||||||
>
|
>
|
||||||
{isAudio ? (
|
{isAudio ? (
|
||||||
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}>♫</span>
|
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}>♫</span>
|
||||||
) : thumbUrl ? (
|
) : showThumb ? (
|
||||||
<img
|
<img
|
||||||
src={tosThumb(thumbUrl, 28)}
|
src={tosThumb(thumbUrl, 28)}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
|
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
|
||||||
|
onError={() => setThumbBroken(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : isVideo ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ verticalAlign: 'middle', marginRight: 3, opacity: 0.6 }}>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ verticalAlign: 'middle', marginRight: 3, opacity: 0.6 }}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{hover && thumbUrl && createPortal(
|
{hover && showThumb && createPortal(
|
||||||
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
|
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
|
||||||
<img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} />
|
<img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||||
<div className={styles.mentionPreviewLabel}>{label}</div>
|
<div className={styles.mentionPreviewLabel}>{label}</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
@ -149,7 +164,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
||||||
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
||||||
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number } | null>(null);
|
const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number; isAssetRef?: boolean } | null>(null);
|
||||||
|
|
||||||
const startDetailLeave = useCallback(() => {
|
const startDetailLeave = useCallback(() => {
|
||||||
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
|
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
|
||||||
@ -294,11 +309,11 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (ref.type === 'audio') return;
|
if (ref.type === 'audio') return;
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2 });
|
setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2, isAssetRef: ref.isAssetRef });
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => setRefPreview(null)}
|
onMouseLeave={() => setRefPreview(null)}
|
||||||
>
|
>
|
||||||
{ref.type === 'video' ? (
|
{ref.type === 'video' && !ref.isAssetRef ? (
|
||||||
<video src={ref.previewUrl} className={styles.refMedia} muted />
|
<video src={ref.previewUrl} className={styles.refMedia} muted />
|
||||||
) : ref.type === 'audio' ? (
|
) : ref.type === 'audio' ? (
|
||||||
<div className={styles.audioThumb}>
|
<div className={styles.audioThumb}>
|
||||||
@ -309,7 +324,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} />
|
<img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -421,10 +436,10 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
{/* Reference thumbnail hover preview */}
|
{/* Reference thumbnail hover preview */}
|
||||||
{refPreview && createPortal(
|
{refPreview && createPortal(
|
||||||
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
|
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
|
||||||
{refPreview.type === 'video' ? (
|
{refPreview.type === 'video' && !refPreview.isAssetRef ? (
|
||||||
<video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
|
<video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
|
||||||
) : (
|
) : (
|
||||||
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} />
|
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||||
)}
|
)}
|
||||||
<div className={styles.mentionPreviewLabel}>{refPreview.label}</div>
|
<div className={styles.mentionPreviewLabel}>{refPreview.label}</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@ -98,6 +98,7 @@ export function PromptInput() {
|
|||||||
img.setAttribute('width', '16');
|
img.setAttribute('width', '16');
|
||||||
img.setAttribute('height', '16');
|
img.setAttribute('height', '16');
|
||||||
img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
|
img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
|
||||||
|
img.onerror = () => { img.style.display = 'none'; };
|
||||||
span.appendChild(img);
|
span.appendChild(img);
|
||||||
}
|
}
|
||||||
// @ 前缀隐藏(textContent 保留用于模式匹配,视觉上不显示)
|
// @ 前缀隐藏(textContent 保留用于模式匹配,视觉上不显示)
|
||||||
|
|||||||
@ -220,9 +220,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
if (task.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast');
|
if (task.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast');
|
||||||
if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any);
|
if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any);
|
||||||
if (task.duration) store.setDuration(task.duration);
|
if (task.duration) store.setDuration(task.duration);
|
||||||
// Load references from task
|
// Load references from task (exclude asset library refs — they restore via @mentions in editorHtml)
|
||||||
if (task.references && task.references.length > 0) {
|
if (task.references && task.references.length > 0) {
|
||||||
const refs = task.references.filter(r => r.previewUrl).map(r => ({
|
const refs = task.references.filter(r => r.previewUrl && !r.isAssetRef).map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
file: null as unknown as File,
|
file: null as unknown as File,
|
||||||
previewUrl: r.previewUrl,
|
previewUrl: r.previewUrl,
|
||||||
@ -485,7 +485,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
{task.references.map((ref) => (
|
{task.references.map((ref) => (
|
||||||
<div key={ref.id} className={styles.refItem}>
|
<div key={ref.id} className={styles.refItem}>
|
||||||
<div style={{ position: 'relative', width: 56, height: 56 }}>
|
<div style={{ position: 'relative', width: 56, height: 56 }}>
|
||||||
{ref.type === 'video' ? (
|
{ref.type === 'video' && !ref.isAssetRef ? (
|
||||||
<video src={ref.previewUrl} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} />
|
<video src={ref.previewUrl} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} />
|
||||||
) : ref.type === 'audio' ? (
|
) : ref.type === 'audio' ? (
|
||||||
<div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'audio' })}>
|
<div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'audio' })}>
|
||||||
@ -496,7 +496,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
) : ref.previewUrl ? (
|
) : ref.previewUrl ? (
|
||||||
<img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
|
<img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}>无预览</div>
|
<div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}>无预览</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -59,7 +59,7 @@ function isAssetUrl(url: string): boolean {
|
|||||||
return url.startsWith('asset://') || url.startsWith('Asset://');
|
return url.startsWith('asset://') || url.startsWith('Asset://');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build ReferenceSnapshot[] from raw reference_urls, excluding asset refs. */
|
/** Build ReferenceSnapshot[] from raw reference_urls (including asset refs with thumb_url). */
|
||||||
function buildReferenceSnapshots(
|
function buildReferenceSnapshots(
|
||||||
refs: Array<Record<string, string>>,
|
refs: Array<Record<string, string>>,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
@ -67,15 +67,23 @@ function buildReferenceSnapshots(
|
|||||||
return refs
|
return refs
|
||||||
.filter((ref) => {
|
.filter((ref) => {
|
||||||
const url = ref.url || '';
|
const url = ref.url || '';
|
||||||
return !isAssetUrl(url) && url.trim() !== '';
|
// 素材库引用必须有 thumb_url 才能显示缩略图
|
||||||
|
if (isAssetUrl(url)) return !!(ref.thumb_url);
|
||||||
|
return url.trim() !== '';
|
||||||
})
|
})
|
||||||
.map((ref, i) => ({
|
.map((ref, i) => {
|
||||||
|
const url = ref.url || '';
|
||||||
|
const assetRef = isAssetUrl(url);
|
||||||
|
return {
|
||||||
id: `ref_${taskId}_${i}`,
|
id: `ref_${taskId}_${i}`,
|
||||||
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
|
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
|
||||||
previewUrl: ref.url || '',
|
// 素材库引用用 thumb_url,直接上传用原始 url
|
||||||
|
previewUrl: assetRef ? ref.thumb_url : url,
|
||||||
label: ref.label || `素材${i + 1}`,
|
label: ref.label || `素材${i + 1}`,
|
||||||
role: ref.role,
|
role: ref.role,
|
||||||
}));
|
isAssetRef: assetRef || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract asset mention metadata from raw reference_urls. */
|
/** Extract asset mention metadata from raw reference_urls. */
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export interface ReferenceSnapshot {
|
|||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
label: string;
|
label: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
isAssetRef?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationTask {
|
export interface GenerationTask {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user