diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index a6a214a81..814b4a6c8 100755 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -46,10 +46,16 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse: logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True) file_url = None - # 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL - thumbnail_url = item.thumbnail_url - if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url: - thumbnail_url = file_url + # 缩略图:存储的是 storage_key,需要生成签名 URL 供前端使用 + # 不再降级使用视频文件 URL(浏览器 无法渲染 .mp4,会显示黑屏) + thumbnail_url = None + if item.thumbnail_url: + try: + svc = storage_service or get_storage_service() + thumbnail_url = svc.get_download_url(item.thumbnail_url) + except Exception: + logger.warning("生成缩略图签名URL失败: key=%s", item.thumbnail_url, exc_info=True) + thumbnail_url = None return AssetResponse( id=item.id, diff --git a/apps/web/src/components/asset-selector/AssetCard.tsx b/apps/web/src/components/asset-selector/AssetCard.tsx index cd48de38a..94cbe5cec 100644 --- a/apps/web/src/components/asset-selector/AssetCard.tsx +++ b/apps/web/src/components/asset-selector/AssetCard.tsx @@ -59,8 +59,23 @@ const AssetCard: React.FC = ({ > {/* 缩略图 */}
- {asset.thumbnail_url ? ( - {asset.name} + {asset.thumbnail_url && !asset.thumbnail_url.match(/\.(mp4|mov|avi|webm|mkv)(\?|$)/i) ? ( + {asset.name} { + // 图片加载失败时降级显示类型图标 + const parent = (e.target as HTMLElement).parentElement + if (parent) { + ;(e.target as HTMLElement).style.display = "none" + const icon = document.createElement("span") + icon.className = "as-card-thumb-icon" + icon.textContent = MATERIAL_TYPE_ICONS[asset.type] || "🎬" + parent.appendChild(icon) + } + }} + /> ) : ( {MATERIAL_TYPE_ICONS[asset.type]} )} diff --git a/apps/worker/worker_app/tasks/__init__.py b/apps/worker/worker_app/tasks/__init__.py index 87b7b613f..74c785861 100755 --- a/apps/worker/worker_app/tasks/__init__.py +++ b/apps/worker/worker_app/tasks/__init__.py @@ -49,10 +49,15 @@ def __getattr__(name: str): from .ai_tasks import run_generate_cover return run_generate_cover + elif name == "batch_generate_thumbnails": + from .batch_thumbnail import batch_generate_thumbnails + + return batch_generate_thumbnails raise AttributeError(f"module {__name__!r} has no attribute {name!r}") __all__ = [ + "batch_generate_thumbnails", "classify_asset", "generate_video", "healthcheck", diff --git a/apps/worker/worker_app/tasks/batch_thumbnail.py b/apps/worker/worker_app/tasks/batch_thumbnail.py new file mode 100644 index 000000000..36e17e2d9 --- /dev/null +++ b/apps/worker/worker_app/tasks/batch_thumbnail.py @@ -0,0 +1,165 @@ +"""批量修复素材缩略图 — 为历史视频素材生成缩略图。 + +使用方式: + 从管理接口或 shell 触发: + celery_app.send_task("worker.batch_generate_thumbnails") + +逻辑: + 1. 查询所有 file_type=video 且 thumbnail_url 为空或为旧格式公开 URL 的素材 + 2. 逐个:下载视频 → 抽第一帧 → 上传 OSS → 更新 thumbnail_url 为 storage_key + 3. 每处理 50 条 commit 一次,失败单条跳过不阻塞 +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from celery.utils.log import get_task_logger +from worker_app.celery_app import celery_app +from worker_app.db import SessionLocal + +logger = get_task_logger(__name__) + +# 旧格式 URL 前缀(ingest 旧代码生成的公开 URL),需替换为 storage_key +_OLD_URL_PREFIX = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/" +# 从公开 URL 中提取 storage_key 时,去掉域名前缀即可 +_DOMAIN_PREFIXES = [ + "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/", + "http://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/", +] + + +def _url_to_storage_key(url: str) -> str | None: + """尝试将旧格式公开 URL 转回 storage_key。""" + for prefix in _DOMAIN_PREFIXES: + if url.startswith(prefix): + return url[len(prefix) :] + return None + + +@celery_app.task(name="worker.batch_generate_thumbnails") +def batch_generate_thumbnails() -> dict: + """为所有缺少缩略图的视频素材批量生成缩略图。 + + Returns: + dict: {total, success, skipped, failed, converted_legacy} + """ + from video_processing.oss_helpers import download_asset, upload_to_oss + from video_processing.thumbnail_generator import extract_first_frame + + from packages.adapters.sqlalchemy_impl.models import AssetModel + + db = SessionLocal() + stats = {"total": 0, "success": 0, "skipped": 0, "failed": 0, "converted_legacy": 0} + + try: + # 查询所有视频素材中缩略图缺失的 + assets = ( + db.query(AssetModel) + .filter( + AssetModel.file_type == "video", + AssetModel.status != "deleted", + ) + .all() + ) + + # 筛选需要处理的:thumbnail_url 为空 或 旧格式公开 URL + to_process = [] + for asset in assets: + thumb = asset.thumbnail_url or "" + if not thumb: + to_process.append((asset, None)) # (asset, None=需要新生成) + elif thumb.startswith("http"): + # 旧格式公开 URL → 尝试转为 storage_key + sk = _url_to_storage_key(thumb) + if sk: + to_process.append((asset, sk)) # 已有文件,只需改 DB + else: + # 非预期 URL 格式,跳过 + stats["skipped"] += 1 + # else: 已经是 storage_key 格式,跳过 + + stats["total"] = len(to_process) + logger.info( + "批量缩略图修复启动: total=%d (new=%d, legacy_convert=%d)", + stats["total"], + sum(1 for _, sk in to_process if sk is None), + sum(1 for _, sk in to_process if sk is not None), + ) + + batch_count = 0 + for asset, existing_key in to_process: + try: + if existing_key is not None: + # 旧 URL → storage_key,只需更新 DB + asset.thumbnail_url = existing_key + stats["converted_legacy"] += 1 + stats["success"] += 1 + else: + # 需要新生成缩略图 + if not asset.storage_key: + stats["skipped"] += 1 + continue + + suffix = Path(asset.storage_key).suffix or ".mp4" + local_file = None + frame_path = None + try: + # 下载视频 + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + local_file = Path(tmp.name) + + if not download_asset(asset.storage_key, local_file): + logger.warning("下载失败: asset_id=%s key=%s", asset.id, asset.storage_key[:60]) + stats["failed"] += 1 + continue + + # 抽帧 + frame_path = extract_first_frame(str(local_file), width=640) + + # 上传 + thumb_key = f"assets/{asset.project_id}/thumbnails/{asset.id}.jpg" + upload_ok = upload_to_oss(frame_path, thumb_key) + if upload_ok: + asset.thumbnail_url = thumb_key + stats["success"] += 1 + else: + logger.warning("上传失败: asset_id=%s", asset.id) + stats["failed"] += 1 + finally: + if local_file and local_file.exists(): + try: + local_file.unlink(missing_ok=True) + except OSError: + pass + if frame_path and Path(frame_path).exists(): + try: + Path(frame_path).unlink(missing_ok=True) + except OSError: + pass + + batch_count += 1 + if batch_count % 50 == 0: + db.commit() + logger.info("批量缩略图进度: %d/%d", batch_count, stats["total"]) + + except Exception as e: + logger.warning("单条处理失败: asset_id=%s error=%s", asset.id, e) + stats["failed"] += 1 + try: + db.rollback() + except Exception: + pass + + # 最后提交 + db.commit() + logger.info("批量缩略图修复完成: %s", stats) + return stats + + except Exception as e: + logger.error("批量缩略图修复异常: %s", e) + db.rollback() + return {**stats, "error": str(e)} + finally: + db.close() diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index dd88ca16a..46c7d3394 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -214,20 +214,26 @@ def ingest_asset(job_id: str) -> dict: frame_path = extract_first_frame(str(local_file), width=640) thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg" - try: - thumbnail_url = upload_to_oss(frame_path, thumb_storage_key) - finally: - if frame_path: - try: - Path(frame_path).unlink(missing_ok=True) - except Exception: - pass - if thumbnail_url: + upload_ok = upload_to_oss(frame_path, thumb_storage_key) + if upload_ok: + # 存储 storage_key(非公开 URL),API 层通过 get_download_url 生成签名 URL + thumbnail_url = thumb_storage_key logger.info( - "素材缩略图生成成功: job_id=%s url=%s", + "素材缩略图生成成功: job_id=%s key=%s", job_id, - thumbnail_url[:80], + thumb_storage_key[:80], ) + else: + logger.warning( + "素材缩略图上传 OSS 失败: job_id=%s", + job_id, + ) + # frame_path 是临时文件,及时清理 + if frame_path: + try: + Path(frame_path).unlink(missing_ok=True) + except Exception: + pass except Exception as thumb_err: logger.warning( "素材缩略图生成失败(不影响主流程): job_id=%s error=%s",