Merge PR #1530: fix 素材缩略图全黑
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m29s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m28s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m33s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m21s
AI Code Review / AI Code Review (pull_request) Failing after 6m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m58s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 19m18s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 19m49s
CI/CD Pipeline / Unit Tests (push) Successful in 20m30s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 22m59s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 32m50s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 33m53s
CI/CD Pipeline / CI Gate (pull_request) Successful in 25s

This commit is contained in:
CI Bot
2026-08-28 19:15:27 +08:00
5 changed files with 214 additions and 17 deletions
+10 -4
View File
@@ -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(浏览器 <img> 无法渲染 .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,
@@ -59,8 +59,23 @@ const AssetCard: React.FC<AssetCardProps> = ({
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
{asset.thumbnail_url && !asset.thumbnail_url.match(/\.(mp4|mov|avi|webm|mkv)(\?|$)/i) ? (
<img
src={asset.thumbnail_url}
alt={asset.name}
loading="lazy"
onError={(e) => {
// 图片加载失败时降级显示类型图标
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)
}
}}
/>
) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
+5
View File
@@ -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",
@@ -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()
+17 -11
View File
@@ -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",