c74b2d4d8d
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 / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 4m11s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m18s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m29s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m5s
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 / Frontend Lint (pull_request) Successful in 4m8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m13s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m47s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m22s
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 / PR Build Web Image (pull_request) Successful in 4m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m17s
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 1m36s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m8s
166 lines
6.2 KiB
Python
166 lines
6.2 KiB
Python
"""批量修复素材缩略图 — 为历史视频素材生成缩略图。
|
|
|
|
使用方式:
|
|
从管理接口或 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()
|