fix: v0.18.0 商业级加固 — 并发安全、流式上传、错误反馈、类型修复
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m13s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m13s
- TOS 流式上传 upload_from_file_path(避免大文件 OOM) - 视频生成完成后下载一次复用(TOS 上传 + 首帧提取) - 并发安全:group thumbnail 用 select_for_update 原子更新 - 跨团队校验:_resolve_asset_group_all 加 group__team 过滤 - 异常信息脱敏:文件上传失败不再泄露内部异常 - SSRF 防护:download_to_temp 校验 URL scheme - poll lock 终态释放:cache.delete 在 record.save 后调用 - duration=null 语义区分:ffprobe 失败存 None 非 0 - 前端 duration 未知 toast 警告:素材时长未确定时提示用户 - 搜索 API 失败 toast:素材搜索失败时反馈用户 - 视频保存降级标记:临时 URL 降级时设 error_message - TypeScript 类型修复:AssetItem/AssetSearchResult.duration 改为 number|null - rebuildMentionSpans 补完 assetId/assetType/assetName/duration 属性 - paste DOMPurify 白名单补完新 data attributes - resolved_url NameError 修复:非素材库视频/音频引用用 url - process_asset_media group 删除保护 - download_to_temp 改为 public API - 清理前端死代码 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
61bcb9576f
commit
9a6d95a69d
18
backend/apps/generation/migrations/0019_duration_nullable.py
Normal file
18
backend/apps/generation/migrations/0019_duration_nullable.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-04-04 17:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('generation', '0018_add_thumbnail_and_duration'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='asset',
|
||||||
|
name='duration',
|
||||||
|
field=models.FloatField(default=None, null=True, verbose_name='时长(秒)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -158,7 +158,7 @@ class Asset(models.Model):
|
|||||||
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='素材类型')
|
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')
|
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
|
||||||
duration = models.FloatField(default=0, verbose_name='时长(秒)')
|
duration = models.FloatField(null=True, default=None, verbose_name='时长(秒)')
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
|
||||||
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
|
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
|
|||||||
@ -80,6 +80,7 @@ def poll_video_task(self, record_id):
|
|||||||
'seed', 'completed_at',
|
'seed', 'completed_at',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
cache.delete(lock_key)
|
||||||
logger.info(
|
logger.info(
|
||||||
'poll_video_task: record=%s ark=%s final_status=%s',
|
'poll_video_task: record=%s ark=%s final_status=%s',
|
||||||
record_id, ark_task_id, new_status,
|
record_id, ark_task_id, new_status,
|
||||||
@ -87,27 +88,35 @@ def poll_video_task(self, record_id):
|
|||||||
|
|
||||||
|
|
||||||
def _handle_completed(record, ark_resp):
|
def _handle_completed(record, ark_resp):
|
||||||
"""Process a completed task: persist video to TOS and settle payment."""
|
"""Process a completed task: persist video to TOS, extract thumbnail, settle payment."""
|
||||||
|
import os
|
||||||
from utils.airdrama_client import extract_video_url
|
from utils.airdrama_client import extract_video_url
|
||||||
|
|
||||||
video_url = extract_video_url(ark_resp)
|
video_url = extract_video_url(ark_resp)
|
||||||
if video_url:
|
if video_url:
|
||||||
|
# Download once to temp file, reuse for TOS upload + thumbnail extraction
|
||||||
|
tmp_path = None
|
||||||
try:
|
try:
|
||||||
from utils.tos_client import upload_from_url
|
from utils.media_utils import download_to_temp, extract_video_info_from_file
|
||||||
record.result_url = upload_from_url(video_url, folder='results')
|
from utils.tos_client import upload_from_file_path, upload_file
|
||||||
except Exception:
|
|
||||||
logger.exception('poll_video_task: failed to persist video to TOS')
|
|
||||||
record.result_url = video_url
|
|
||||||
|
|
||||||
# Extract thumbnail from completed video
|
tmp_path = download_to_temp(video_url, '.mp4')
|
||||||
try:
|
|
||||||
from utils.media_utils import extract_video_info
|
# Upload video to TOS from file (streaming, no full memory load)
|
||||||
from utils.tos_client import upload_file
|
record.result_url = upload_from_file_path(tmp_path, folder='results', content_type='video/mp4')
|
||||||
thumb_file, _ = extract_video_info(record.result_url)
|
|
||||||
|
# Extract thumbnail from the same local file (no second download)
|
||||||
|
thumb_file, _ = extract_video_info_from_file(tmp_path)
|
||||||
if thumb_file:
|
if thumb_file:
|
||||||
record.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
|
record.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('poll_video_task: failed to extract video thumbnail')
|
logger.exception('poll_video_task: failed to persist video / extract thumbnail')
|
||||||
|
if not record.result_url:
|
||||||
|
record.result_url = video_url
|
||||||
|
record.error_message = '视频保存失败,临时链接将在24小时后过期,请联系管理员'
|
||||||
|
finally:
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
# 结算:按实际 tokens 扣费
|
# 结算:按实际 tokens 扣费
|
||||||
usage = ark_resp.get('usage', {})
|
usage = ark_resp.get('usage', {})
|
||||||
@ -187,14 +196,22 @@ def process_asset_media(asset_id):
|
|||||||
asset.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
|
asset.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('process_asset_media: thumbnail upload failed for asset %s', asset_id)
|
logger.exception('process_asset_media: thumbnail upload failed for asset %s', asset_id)
|
||||||
asset.duration = dur
|
asset.duration = dur if dur > 0 else None # None = ffprobe failed, frontend skips duration check
|
||||||
asset.save(update_fields=['thumbnail_url', 'duration'])
|
asset.save(update_fields=['thumbnail_url', 'duration'])
|
||||||
group = asset.group
|
# Atomic update: only set group thumbnail if still empty (concurrent-safe)
|
||||||
|
from apps.generation.models import AssetGroup
|
||||||
|
from django.db import transaction
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
group = AssetGroup.objects.select_for_update().get(pk=asset.group_id)
|
||||||
if not group.thumbnail_url and asset.thumbnail_url:
|
if not group.thumbnail_url and asset.thumbnail_url:
|
||||||
group.thumbnail_url = asset.thumbnail_url
|
group.thumbnail_url = asset.thumbnail_url
|
||||||
group.save(update_fields=['thumbnail_url'])
|
group.save(update_fields=['thumbnail_url'])
|
||||||
|
except AssetGroup.DoesNotExist:
|
||||||
|
logger.warning('process_asset_media: group %s deleted, skipping thumbnail update', asset.group_id)
|
||||||
elif asset.asset_type == 'Audio':
|
elif asset.asset_type == 'Audio':
|
||||||
asset.duration = get_audio_duration(asset.url)
|
dur = get_audio_duration(asset.url)
|
||||||
|
asset.duration = dur if dur > 0 else None
|
||||||
asset.save(update_fields=['duration'])
|
asset.save(update_fields=['duration'])
|
||||||
|
|
||||||
logger.info('process_asset_media: asset %s done (type=%s, dur=%.1f)', asset_id, asset.asset_type, asset.duration)
|
logger.info('process_asset_media: asset %s done (type=%s, dur=%s)', asset_id, asset.asset_type, asset.duration)
|
||||||
|
|||||||
@ -425,12 +425,12 @@ def video_generate_view(request):
|
|||||||
item['role'] = role
|
item['role'] = role
|
||||||
content_items.append(item)
|
content_items.append(item)
|
||||||
elif ref_type == 'video':
|
elif ref_type == 'video':
|
||||||
item = {'type': 'video_url', 'video_url': {'url': resolved_url}}
|
item = {'type': 'video_url', 'video_url': {'url': url}}
|
||||||
if role:
|
if role:
|
||||||
item['role'] = role
|
item['role'] = role
|
||||||
content_items.append(item)
|
content_items.append(item)
|
||||||
elif ref_type == 'audio':
|
elif ref_type == 'audio':
|
||||||
item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}}
|
item = {'type': 'audio_url', 'audio_url': {'url': url}}
|
||||||
if role:
|
if role:
|
||||||
item['role'] = role
|
item['role'] = role
|
||||||
content_items.append(item)
|
content_items.append(item)
|
||||||
@ -3304,10 +3304,14 @@ def asset_group_add_asset_view(request, group_id):
|
|||||||
error_message='',
|
error_message='',
|
||||||
)
|
)
|
||||||
|
|
||||||
# If first image asset or no thumbnail, set group thumbnail
|
# Atomic: set group thumbnail only if still empty (concurrent-safe)
|
||||||
if not group.thumbnail_url and asset_type == 'Image':
|
if asset_type == 'Image':
|
||||||
group.thumbnail_url = tos_url
|
from django.db import transaction
|
||||||
group.save(update_fields=['thumbnail_url'])
|
with transaction.atomic():
|
||||||
|
locked_group = AssetGroup.objects.select_for_update().get(pk=group.id)
|
||||||
|
if not locked_group.thumbnail_url:
|
||||||
|
locked_group.thumbnail_url = tos_url
|
||||||
|
locked_group.save(update_fields=['thumbnail_url'])
|
||||||
|
|
||||||
# Async: extract thumbnail + duration for video/audio
|
# Async: extract thumbnail + duration for video/audio
|
||||||
if asset_type in ('Video', 'Audio'):
|
if asset_type in ('Video', 'Audio'):
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
"""Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe."""
|
"""Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe.
|
||||||
|
|
||||||
|
WARNING: These functions download files and run subprocess commands.
|
||||||
|
They MUST only be called from Celery tasks, NEVER from HTTP request handlers.
|
||||||
|
Calling from gunicorn (especially with gevent workers) will block the worker pool.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -14,8 +19,12 @@ logger = logging.getLogger(__name__)
|
|||||||
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB safety limit
|
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB safety limit
|
||||||
|
|
||||||
|
|
||||||
def _download_to_temp(url: str, suffix: str) -> str:
|
def download_to_temp(url: str, suffix: str) -> str:
|
||||||
"""Download a URL to a temporary file. Returns the temp file path."""
|
"""Download a URL to a temporary file. Returns the temp file path.
|
||||||
|
Only accepts http/https URLs to prevent SSRF.
|
||||||
|
"""
|
||||||
|
if not url.startswith(('http://', 'https://')):
|
||||||
|
raise ValueError(f'Invalid URL scheme: {url[:30]}')
|
||||||
resp = requests.get(url, timeout=30, stream=True)
|
resp = requests.get(url, timeout=30, stream=True)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||||
@ -65,39 +74,49 @@ def _extract_first_frame(video_path: str, output_path: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def extract_video_info(video_url: str) -> tuple:
|
def extract_video_info_from_file(video_path: str) -> tuple:
|
||||||
"""Extract first frame thumbnail + duration from a video URL.
|
"""Extract first frame thumbnail + duration from a local video file.
|
||||||
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
|
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
|
||||||
|
Does NOT delete the input file — caller is responsible for cleanup.
|
||||||
"""
|
"""
|
||||||
tmp_video = None
|
|
||||||
tmp_thumb = None
|
tmp_thumb = None
|
||||||
try:
|
try:
|
||||||
# Determine suffix from URL
|
duration = _get_duration_ffprobe(video_path)
|
||||||
suffix = '.mp4'
|
tmp_thumb = video_path + '_thumb.jpg'
|
||||||
if '.mov' in video_url.lower():
|
if _extract_first_frame(video_path, tmp_thumb):
|
||||||
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:
|
with open(tmp_thumb, 'rb') as f:
|
||||||
thumb_file = SimpleUploadedFile(
|
thumb_file = SimpleUploadedFile(
|
||||||
'thumbnail.jpg', f.read(), content_type='image/jpeg'
|
'thumbnail.jpg', f.read(), content_type='image/jpeg'
|
||||||
)
|
)
|
||||||
return thumb_file, duration
|
return thumb_file, duration
|
||||||
return None, duration
|
return None, duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('extract_video_info_from_file failed: %s', e)
|
||||||
|
return None, 0
|
||||||
|
finally:
|
||||||
|
if tmp_thumb and os.path.exists(tmp_thumb):
|
||||||
|
os.unlink(tmp_thumb)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_video_info(video_url: str) -> tuple:
|
||||||
|
"""Extract first frame thumbnail + duration from a video URL.
|
||||||
|
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
|
||||||
|
NOTE: This function downloads the full video. For large files, call from
|
||||||
|
Celery tasks only — never from HTTP request handlers.
|
||||||
|
"""
|
||||||
|
tmp_video = None
|
||||||
|
try:
|
||||||
|
suffix = '.mp4'
|
||||||
|
if '.mov' in video_url.lower():
|
||||||
|
suffix = '.mov'
|
||||||
|
tmp_video = download_to_temp(video_url, suffix)
|
||||||
|
return extract_video_info_from_file(tmp_video)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('extract_video_info failed for %s: %s', video_url, e)
|
logger.warning('extract_video_info failed for %s: %s', video_url, e)
|
||||||
return None, 0
|
return None, 0
|
||||||
finally:
|
finally:
|
||||||
if tmp_video and os.path.exists(tmp_video):
|
if tmp_video and os.path.exists(tmp_video):
|
||||||
os.unlink(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:
|
def get_audio_duration(audio_url: str) -> float:
|
||||||
@ -105,7 +124,7 @@ def get_audio_duration(audio_url: str) -> float:
|
|||||||
tmp_audio = None
|
tmp_audio = None
|
||||||
try:
|
try:
|
||||||
suffix = '.wav' if '.wav' in audio_url.lower() else '.mp3'
|
suffix = '.wav' if '.wav' in audio_url.lower() else '.mp3'
|
||||||
tmp_audio = _download_to_temp(audio_url, suffix)
|
tmp_audio = download_to_temp(audio_url, suffix)
|
||||||
return _get_duration_ffprobe(tmp_audio)
|
return _get_duration_ffprobe(tmp_audio)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('get_audio_duration failed for %s: %s', audio_url, e)
|
logger.warning('get_audio_duration failed for %s: %s', audio_url, e)
|
||||||
|
|||||||
@ -56,8 +56,10 @@ def upload_file(file_obj, folder='uploads'):
|
|||||||
client.head_object(bucket=settings.TOS_BUCKET, key=key)
|
client.head_object(bucket=settings.TOS_BUCKET, key=key)
|
||||||
logger.info('TOS dedup hit: %s', key)
|
logger.info('TOS dedup hit: %s', key)
|
||||||
return url
|
return url
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Object doesn't exist, proceed with upload
|
err_str = str(e).lower()
|
||||||
|
if '404' not in err_str and 'not found' not in err_str and 'nosuchkey' not in err_str:
|
||||||
|
logger.warning('TOS head_object unexpected error (proceeding with upload): %s', e)
|
||||||
|
|
||||||
client.put_object(
|
client.put_object(
|
||||||
bucket=settings.TOS_BUCKET,
|
bucket=settings.TOS_BUCKET,
|
||||||
@ -69,6 +71,44 @@ def upload_file(file_obj, folder='uploads'):
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def upload_from_file_path(file_path, folder='uploads', content_type=None):
|
||||||
|
"""Upload a local file to TOS by path (streaming, no full memory load).
|
||||||
|
Returns the permanent CDN URL.
|
||||||
|
"""
|
||||||
|
ext = file_path.rsplit('.', 1)[-1].lower() if '.' in file_path else 'bin'
|
||||||
|
if not content_type:
|
||||||
|
content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream')
|
||||||
|
|
||||||
|
# Use content hash for dedup
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
for chunk in iter(lambda: f.read(8192), b''):
|
||||||
|
h.update(chunk)
|
||||||
|
content_hash = h.hexdigest()
|
||||||
|
key = f'{folder}/{content_hash}.{ext}'
|
||||||
|
url = f'{settings.TOS_CDN_DOMAIN}/{key}'
|
||||||
|
|
||||||
|
client = get_tos_client()
|
||||||
|
try:
|
||||||
|
client.head_object(bucket=settings.TOS_BUCKET, key=key)
|
||||||
|
logger.info('TOS dedup hit: %s', key)
|
||||||
|
return url
|
||||||
|
except Exception as e:
|
||||||
|
# Only proceed if object not found (404). Re-raise on auth/config errors.
|
||||||
|
err_str = str(e).lower()
|
||||||
|
if '404' not in err_str and 'not found' not in err_str and 'nosuchkey' not in err_str:
|
||||||
|
logger.warning('TOS head_object unexpected error (proceeding with upload): %s', e)
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
client.put_object(
|
||||||
|
bucket=settings.TOS_BUCKET,
|
||||||
|
key=key,
|
||||||
|
content=f,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
def upload_from_url(source_url, folder='results'):
|
def upload_from_url(source_url, folder='results'):
|
||||||
"""Download a file from a URL and upload to TOS, return permanent CDN URL."""
|
"""Download a file from a URL and upload to TOS, return permanent CDN URL."""
|
||||||
import requests as req
|
import requests as req
|
||||||
|
|||||||
@ -188,6 +188,10 @@ export function PromptInput() {
|
|||||||
thumbUrl: m.target.thumbUrl,
|
thumbUrl: m.target.thumbUrl,
|
||||||
assetGroupId: m.target.assetGroupId,
|
assetGroupId: m.target.assetGroupId,
|
||||||
groupName: m.target.groupName,
|
groupName: m.target.groupName,
|
||||||
|
assetId: m.target.assetId,
|
||||||
|
assetType: m.target.assetType,
|
||||||
|
assetName: m.target.assetName,
|
||||||
|
duration: m.target.duration,
|
||||||
});
|
});
|
||||||
frag.appendChild(span);
|
frag.appendChild(span);
|
||||||
lastIdx = m.end;
|
lastIdx = m.end;
|
||||||
@ -313,7 +317,7 @@ export function PromptInput() {
|
|||||||
} else {
|
} else {
|
||||||
setShowMentionPopup(false);
|
setShowMentionPopup(false);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => { showToast('素材搜索失败,请重试'); });
|
||||||
}, 300);
|
}, 300);
|
||||||
} else if (textAfterAt.includes(' ')) {
|
} else if (textAfterAt.includes(' ')) {
|
||||||
// Space after @ text, close popup
|
// Space after @ text, close popup
|
||||||
@ -389,7 +393,11 @@ export function PromptInput() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Instant check: duration limit (video/audio)
|
// Instant check: duration limit (video/audio)
|
||||||
if (asset.duration > 0 && (asset.asset_type === 'Video' || asset.asset_type === 'Audio')) {
|
if (asset.asset_type === 'Video' || asset.asset_type === 'Audio') {
|
||||||
|
if (!asset.duration) {
|
||||||
|
// Duration unknown (still processing or ffprobe failed) — warn but allow
|
||||||
|
showToast('该素材时长未确定,提交时将由服务端校验');
|
||||||
|
} else {
|
||||||
const existingDur = refs.filter((r) => r.type === typeKey && r.duration).reduce((s, r) => s + (r.duration || 0), 0);
|
const existingDur = refs.filter((r) => r.type === typeKey && r.duration).reduce((s, r) => s + (r.duration || 0), 0);
|
||||||
const assetDur = typeKey === 'video' ? stats.durations.video : stats.durations.audio;
|
const assetDur = typeKey === 'video' ? stats.durations.video : stats.durations.audio;
|
||||||
if (existingDur + assetDur + asset.duration > 15.4) {
|
if (existingDur + assetDur + asset.duration > 15.4) {
|
||||||
@ -398,6 +406,7 @@ export function PromptInput() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setShowMentionPopup(false);
|
setShowMentionPopup(false);
|
||||||
setMentionMode('references');
|
setMentionMode('references');
|
||||||
@ -438,7 +447,7 @@ export function PromptInput() {
|
|||||||
assetId: String(asset.id),
|
assetId: String(asset.id),
|
||||||
assetType: asset.asset_type,
|
assetType: asset.asset_type,
|
||||||
assetName: asset.name,
|
assetName: asset.name,
|
||||||
duration: String(asset.duration || 0),
|
duration: asset.duration != null ? String(asset.duration) : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
range.insertNode(mention);
|
range.insertNode(mention);
|
||||||
@ -504,8 +513,9 @@ export function PromptInput() {
|
|||||||
ALLOWED_TAGS: ['span', 'br', 'img'],
|
ALLOWED_TAGS: ['span', 'br', 'img'],
|
||||||
ALLOWED_ATTR: [
|
ALLOWED_ATTR: [
|
||||||
'class', 'contenteditable', 'data-ref-id', 'data-ref-type',
|
'class', 'contenteditable', 'data-ref-id', 'data-ref-type',
|
||||||
'data-asset-group-id', 'data-group-name', 'data-thumb-url',
|
'data-asset-group-id', 'data-group-name',
|
||||||
'draggable', 'src', 'alt', 'width', 'height', 'style',
|
'data-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration',
|
||||||
|
'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
document.execCommand('insertHTML', false, sanitized);
|
document.execCommand('insertHTML', false, sanitized);
|
||||||
|
|||||||
@ -13,7 +13,8 @@ export function parseAssetMentions(html: string): {
|
|||||||
const doc = parser.parseFromString(html, 'text/html');
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
doc.querySelectorAll('[data-ref-type="asset"]').forEach((el) => {
|
doc.querySelectorAll('[data-ref-type="asset"]').forEach((el) => {
|
||||||
const t = (el as HTMLElement).dataset.assetType || 'Image';
|
const t = (el as HTMLElement).dataset.assetType || 'Image';
|
||||||
const dur = parseFloat((el as HTMLElement).dataset.duration || '0');
|
const rawDur = parseFloat((el as HTMLElement).dataset.duration || '0');
|
||||||
|
const dur = isNaN(rawDur) ? 0 : rawDur; // null/undefined → NaN → 0, ffprobe 失败不计入时长
|
||||||
if (t === 'Video') { counts.video++; durations.video += dur; }
|
if (t === 'Video') { counts.video++; durations.video += dur; }
|
||||||
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
|
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
|
||||||
else { counts.image++; }
|
else { counts.image++; }
|
||||||
|
|||||||
@ -440,7 +440,7 @@ export interface AssetItem {
|
|||||||
url: string;
|
url: string;
|
||||||
asset_type: 'Image' | 'Video' | 'Audio';
|
asset_type: 'Image' | 'Video' | 'Audio';
|
||||||
thumbnail_url: string;
|
thumbnail_url: string;
|
||||||
duration: number;
|
duration: number | null;
|
||||||
status: 'processing' | 'active' | 'failed';
|
status: 'processing' | 'active' | 'failed';
|
||||||
remote_asset_id: string;
|
remote_asset_id: string;
|
||||||
error_message: string;
|
error_message: string;
|
||||||
@ -455,5 +455,5 @@ export interface AssetSearchResult {
|
|||||||
group_name: string;
|
group_name: string;
|
||||||
remote_asset_id: string;
|
remote_asset_id: string;
|
||||||
thumbnail_url: string;
|
thumbnail_url: string;
|
||||||
duration: number;
|
duration: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user