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
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user