取消背景图

This commit is contained in:
2026-06-30 17:10:51 +08:00
parent ef9d9e8a74
commit 0c63512a29
39 changed files with 5931 additions and 661 deletions
@@ -0,0 +1,3 @@
from app.services.home_material.service import HomeMaterialService, home_material_service
__all__ = ["HomeMaterialService", "home_material_service"]
@@ -0,0 +1,412 @@
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any, Iterable
from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
from app.enums.home_material import (
HomeMaterialAssetStatus,
HomeMaterialMediaType,
HomeMaterialPublicResponseMode,
)
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.schemas.home_material import (
HomeMaterialAssetOut,
HomeMaterialCategoryOut,
HomeMaterialPublicAssetOut,
HomeMaterialPublicCategoryGroupOut,
HomeMaterialPublicCategoryOut,
HomeMaterialPublicFlatItemOut,
HomeMaterialWatermarkOut,
)
def _unique(values: Iterable[str | None]) -> list[str]:
return list({v for v in values if v})
def _load_json(value: str | None) -> dict[str, Any] | None:
if not value:
return None
try:
data = json.loads(value)
return data if isinstance(data, dict) else None
except Exception:
return None
class HomeMaterialQueryService:
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
async def batch_categories_map(self, db: AsyncSession, category_ids: Iterable[str | None]) -> dict[str, HomeMaterialCategory]:
ids = _unique(category_ids)
if not ids:
return {}
result = await db.execute(
select(HomeMaterialCategory).where(HomeMaterialCategory.id.in_(ids), HomeMaterialCategory.deleted_at.is_(None))
)
return {row.id: row for row in result.scalars().all()}
async def batch_watermarks_map(self, db: AsyncSession, watermark_ids: Iterable[str | None]) -> dict[str, HomeMaterialWatermark]:
ids = _unique(watermark_ids)
if not ids:
return {}
result = await db.execute(
select(HomeMaterialWatermark).where(HomeMaterialWatermark.id.in_(ids), HomeMaterialWatermark.deleted_at.is_(None))
)
return {row.id: row for row in result.scalars().all()}
async def asset_counts_map(
self,
db: AsyncSession,
category_ids: Iterable[str] | None = None,
*,
media_type: HomeMaterialMediaType | str | None = None,
public_only: bool = False,
) -> dict[str, dict[str, int]]:
stmt = select(
HomeMaterialAsset.category_id,
func.count(HomeMaterialAsset.id).label("asset_count"),
func.coalesce(func.sum(case((HomeMaterialAsset.media_type == HomeMaterialMediaType.IMAGE.value, 1), else_=0)), 0).label("image_count"),
func.coalesce(func.sum(case((HomeMaterialAsset.media_type == HomeMaterialMediaType.VIDEO.value, 1), else_=0)), 0).label("video_count"),
).where(HomeMaterialAsset.deleted_at.is_(None))
ids = _unique(category_ids or [])
if ids:
stmt = stmt.where(HomeMaterialAsset.category_id.in_(ids))
if media_type:
stmt = stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
if public_only:
stmt = stmt.where(
HomeMaterialAsset.is_active.is_(True),
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
HomeMaterialAsset.watermarked_url.is_not(None),
)
stmt = stmt.group_by(HomeMaterialAsset.category_id)
result = await db.execute(stmt)
out: dict[str, dict[str, int]] = {}
for row in result.all():
out[row.category_id] = {
"asset_count": int(row.asset_count or 0),
"image_count": int(row.image_count or 0),
"video_count": int(row.video_count or 0),
}
return out
def category_to_out(self, category: HomeMaterialCategory, counts: dict[str, int] | None = None) -> HomeMaterialCategoryOut:
c = counts or {}
return HomeMaterialCategoryOut(
id=category.id,
name=category.name,
key=category.key,
description=category.description,
icon=category.icon,
is_active=category.is_active,
sort_order=category.sort_order,
asset_count=int(c.get("asset_count", 0)),
image_count=int(c.get("image_count", 0)),
video_count=int(c.get("video_count", 0)),
created_at=category.created_at,
updated_at=category.updated_at,
)
def watermark_to_out(self, watermark: HomeMaterialWatermark) -> HomeMaterialWatermarkOut:
return HomeMaterialWatermarkOut(
id=watermark.id,
name=watermark.name,
file_url=watermark.file_url,
file_name=watermark.file_name,
file_size_bytes=watermark.file_size_bytes,
width=watermark.width,
height=watermark.height,
is_default=watermark.is_default,
is_active=watermark.is_active,
created_at=watermark.created_at,
updated_at=watermark.updated_at,
)
def asset_to_out(
self,
asset: HomeMaterialAsset,
*,
category_map: dict[str, HomeMaterialCategory] | None = None,
watermark_map: dict[str, HomeMaterialWatermark] | None = None,
include_original: bool = True,
) -> HomeMaterialAssetOut:
category = (category_map or {}).get(asset.category_id)
watermark = (watermark_map or {}).get(asset.watermark_id or "")
return HomeMaterialAssetOut(
id=asset.id,
category_id=asset.category_id,
category_name=category.name if category else None,
category_key=category.key if category else None,
title=asset.title,
media_type=HomeMaterialMediaType(asset.media_type),
status=HomeMaterialAssetStatus(asset.status),
original_url=asset.original_url if include_original else None,
watermarked_url=asset.watermarked_url,
cover_url=asset.cover_url,
watermark_id=asset.watermark_id,
watermark_name=watermark.name if watermark else None,
watermark_config=_load_json(asset.watermark_config_json),
width=asset.width,
height=asset.height,
duration_seconds=asset.duration_seconds,
file_size_bytes=asset.file_size_bytes,
watermarked_file_size_bytes=asset.watermarked_file_size_bytes,
is_active=asset.is_active,
sort_order=asset.sort_order,
error_message=asset.error_message,
processed_at=asset.processed_at,
created_at=asset.created_at,
updated_at=asset.updated_at,
)
async def list_admin_assets(
self,
db: AsyncSession,
*,
page: int,
page_size: int,
category_id: str | None = None,
media_type: HomeMaterialMediaType | str | None = None,
status: HomeMaterialAssetStatus | str | None = None,
is_active: bool | None = None,
keyword: str | None = None,
include_original: bool = True,
) -> tuple[list[HomeMaterialAssetOut], int]:
stmt = select(HomeMaterialAsset).where(HomeMaterialAsset.deleted_at.is_(None))
count_stmt = select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.deleted_at.is_(None))
conditions = []
if category_id:
conditions.append(HomeMaterialAsset.category_id == category_id)
if media_type:
conditions.append(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
if status:
conditions.append(HomeMaterialAsset.status == HomeMaterialAssetStatus(status).value)
if is_active is not None:
conditions.append(HomeMaterialAsset.is_active.is_(is_active))
if keyword:
conditions.append(HomeMaterialAsset.title.ilike(f"%{keyword}%"))
for condition in conditions:
stmt = stmt.where(condition)
count_stmt = count_stmt.where(condition)
total = int((await db.execute(count_stmt)).scalar_one() or 0)
result = await db.execute(
stmt.order_by(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
assets = result.scalars().all()
category_map = await self.batch_categories_map(db, [a.category_id for a in assets])
watermark_map = await self.batch_watermarks_map(db, [a.watermark_id for a in assets])
return [self.asset_to_out(a, category_map=category_map, watermark_map=watermark_map, include_original=include_original) for a in assets], total
async def resolve_category_ids(
self,
db: AsyncSession,
*,
category_id: str | None = None,
category_key: str | None = None,
category_ids: str | None = None,
category_keys: str | None = None,
active_only: bool = True,
) -> list[str] | None:
"""解析前台行业筛选参数,优先级:category_id > category_key > category_ids > category_keys。None 表示不限制。"""
if category_id:
return [category_id]
stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.deleted_at.is_(None))
if active_only:
stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
if category_key:
result = await db.execute(stmt.where(HomeMaterialCategory.key == category_key).limit(1))
cid = result.scalar_one_or_none()
return [cid] if cid else []
if category_ids:
return [v.strip() for v in category_ids.split(",") if v.strip()]
if category_keys:
keys = [v.strip() for v in category_keys.split(",") if v.strip()]
if not keys:
return []
result = await db.execute(stmt.where(HomeMaterialCategory.key.in_(keys)))
return list(result.scalars().all())
return None
async def list_public_categories(
self,
db: AsyncSession,
*,
with_asset_count: bool = True,
media_type: HomeMaterialMediaType | str | None = None,
only_has_assets: bool = True,
) -> list[HomeMaterialPublicCategoryOut]:
result = await db.execute(
select(HomeMaterialCategory)
.where(HomeMaterialCategory.deleted_at.is_(None), HomeMaterialCategory.is_active.is_(True))
.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
)
categories = result.scalars().all()
counts = await self.asset_counts_map(db, [c.id for c in categories], media_type=media_type, public_only=True) if with_asset_count or only_has_assets else {}
items: list[HomeMaterialPublicCategoryOut] = []
for category in categories:
c = counts.get(category.id, {})
if only_has_assets and int(c.get("asset_count", 0)) <= 0:
continue
items.append(
HomeMaterialPublicCategoryOut(
id=category.id,
name=category.name,
key=category.key,
icon=category.icon,
description=category.description,
sort_order=category.sort_order,
asset_count=int(c.get("asset_count", 0)),
image_count=int(c.get("image_count", 0)),
video_count=int(c.get("video_count", 0)),
)
)
return items
async def list_public_grouped(
self,
db: AsyncSession,
*,
category_ids: list[str] | None,
media_type: HomeMaterialMediaType | str | None,
limit_per_category: int,
include_empty_categories: bool,
) -> list[HomeMaterialPublicCategoryGroupOut]:
cat_stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None), HomeMaterialCategory.is_active.is_(True))
if category_ids is not None:
if not category_ids:
return []
cat_stmt = cat_stmt.where(HomeMaterialCategory.id.in_(category_ids))
cat_result = await db.execute(cat_stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc()))
categories = cat_result.scalars().all()
ids = [c.id for c in categories]
if not ids:
return []
asset_base = select(
HomeMaterialAsset.id.label("id"),
func.row_number()
.over(
partition_by=HomeMaterialAsset.category_id,
order_by=(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc()),
)
.label("rn"),
).where(
HomeMaterialAsset.deleted_at.is_(None),
HomeMaterialAsset.is_active.is_(True),
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
HomeMaterialAsset.watermarked_url.is_not(None),
HomeMaterialAsset.category_id.in_(ids),
)
if media_type:
asset_base = asset_base.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
ranked = asset_base.subquery()
asset_result = await db.execute(
select(HomeMaterialAsset)
.where(HomeMaterialAsset.id.in_(select(ranked.c.id).where(ranked.c.rn <= limit_per_category)))
.order_by(HomeMaterialAsset.category_id.asc(), HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
)
grouped: dict[str, list[HomeMaterialAsset]] = defaultdict(list)
for asset in asset_result.scalars().all():
grouped[asset.category_id].append(asset)
out: list[HomeMaterialPublicCategoryGroupOut] = []
for category in categories:
assets = grouped.get(category.id, [])
if not include_empty_categories and not assets:
continue
out.append(
HomeMaterialPublicCategoryGroupOut(
id=category.id,
name=category.name,
key=category.key,
icon=category.icon,
description=category.description,
sort_order=category.sort_order,
assets=[
HomeMaterialPublicAssetOut(
id=a.id,
title=a.title,
media_type=HomeMaterialMediaType(a.media_type),
url=a.watermarked_url or "",
cover_url=a.cover_url,
width=a.width,
height=a.height,
duration_seconds=a.duration_seconds,
sort_order=a.sort_order,
)
for a in assets
],
)
)
return out
async def list_public_flat(
self,
db: AsyncSession,
*,
category_ids: list[str] | None,
media_type: HomeMaterialMediaType | str | None,
page: int,
page_size: int,
) -> tuple[list[HomeMaterialPublicFlatItemOut], int]:
stmt = select(HomeMaterialAsset).where(
HomeMaterialAsset.deleted_at.is_(None),
HomeMaterialAsset.is_active.is_(True),
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
HomeMaterialAsset.watermarked_url.is_not(None),
)
count_stmt = select(func.count(HomeMaterialAsset.id)).where(
HomeMaterialAsset.deleted_at.is_(None),
HomeMaterialAsset.is_active.is_(True),
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
HomeMaterialAsset.watermarked_url.is_not(None),
)
if category_ids is not None:
if not category_ids:
return [], 0
stmt = stmt.where(HomeMaterialAsset.category_id.in_(category_ids))
count_stmt = count_stmt.where(HomeMaterialAsset.category_id.in_(category_ids))
if media_type:
stmt = stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
count_stmt = count_stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
total = int((await db.execute(count_stmt)).scalar_one() or 0)
result = await db.execute(
stmt.order_by(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
assets = result.scalars().all()
category_map = await self.batch_categories_map(db, [a.category_id for a in assets])
items: list[HomeMaterialPublicFlatItemOut] = []
for asset in assets:
category = category_map.get(asset.category_id)
if not category:
continue
items.append(
HomeMaterialPublicFlatItemOut(
id=asset.id,
category_id=category.id,
category_name=category.name,
category_key=category.key,
title=asset.title,
media_type=HomeMaterialMediaType(asset.media_type),
url=asset.watermarked_url or "",
cover_url=asset.cover_url,
width=asset.width,
height=asset.height,
duration_seconds=asset.duration_seconds,
sort_order=asset.sort_order,
)
)
return items, total
query_service = HomeMaterialQueryService()
@@ -0,0 +1,693 @@
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException, UploadFile, status
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.home_material import (
HOME_MATERIAL_DEFAULT_CONFIG,
HomeMaterialAssetStatus,
HomeMaterialConfigKeyEnum,
HomeMaterialMediaType,
HomeMaterialPublicResponseMode,
HomeMaterialWatermarkPosition,
HomeMaterialWatermarkSizeMode,
HomeMaterialWatermarkType,
)
from app.models.base import async_session
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.system_config import SystemConfig
from app.schemas.home_material import (
HomeMaterialAssetListOut,
HomeMaterialAssetOut,
HomeMaterialAssetStatusOut,
HomeMaterialAssetUpdate,
HomeMaterialCategoryCreate,
HomeMaterialCategoryListOut,
HomeMaterialCategoryOut,
HomeMaterialCategoryUpdate,
HomeMaterialConfigOut,
HomeMaterialConfigUpdate,
HomeMaterialPublicCategoryListOut,
HomeMaterialPublicFlatOut,
HomeMaterialPublicGroupedOut,
HomeMaterialRegenerateWatermarkRequest,
HomeMaterialTextWatermarkPreviewRequest,
HomeMaterialTextWatermarkPreviewResponse,
HomeMaterialUploadResultOut,
HomeMaterialWatermarkConfig,
HomeMaterialWatermarkListOut,
HomeMaterialWatermarkOut,
HomeMaterialWatermarkUpdate,
)
from app.services.home_material.query import query_service
from app.services.home_material.storage import storage_service
from app.services.home_material.watermark_processor import watermark_processor
from app.utils.id_gen import generate_id
def _json_dumps(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
def _json_loads(value: str | None) -> dict[str, Any] | None:
if not value:
return None
try:
data = json.loads(value)
return data if isinstance(data, dict) else None
except Exception:
return None
def _clean_title(value: str | None) -> str | None:
value = (value or "").strip()
return value or None
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
data = dict(value or {})
watermark_type = data.get("watermark_type") or HomeMaterialWatermarkType.IMAGE.value
data["watermark_type"] = watermark_type
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
data["watermark_id"] = None
return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
if fallback_watermark_id and not data.get("watermark_id"):
data["watermark_id"] = fallback_watermark_id
return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
def _config_to_out(data: dict[str, Any] | None) -> HomeMaterialConfigOut:
raw = {**HOME_MATERIAL_DEFAULT_CONFIG, **(data or {})}
return HomeMaterialConfigOut(
enabled=bool(raw.get("enabled", False)),
title=str(raw.get("title") or HOME_MATERIAL_DEFAULT_CONFIG["title"]),
subtitle=str(raw.get("subtitle") or HOME_MATERIAL_DEFAULT_CONFIG["subtitle"]),
show_original_in_admin=bool(raw.get("show_original_in_admin", True)),
)
def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
if not asset:
return None
return {
"id": asset.id,
"category_id": asset.category_id,
"title": asset.title,
"media_type": asset.media_type,
"status": asset.status,
"original_url": asset.original_url,
"watermarked_url": asset.watermarked_url,
"cover_url": asset.cover_url,
"watermark_id": asset.watermark_id,
"watermark_config": _json_loads(asset.watermark_config_json),
"is_active": asset.is_active,
"sort_order": asset.sort_order,
"deleted_at": asset.deleted_at,
}
def _category_snapshot(category: HomeMaterialCategory | None) -> dict[str, Any] | None:
if not category:
return None
return {
"id": category.id,
"name": category.name,
"key": category.key,
"description": category.description,
"icon": category.icon,
"is_active": category.is_active,
"sort_order": category.sort_order,
"deleted_at": category.deleted_at,
}
def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, Any] | None:
if not watermark:
return None
return {
"id": watermark.id,
"name": watermark.name,
"file_url": watermark.file_url,
"is_default": watermark.is_default,
"is_active": watermark.is_active,
"deleted_at": watermark.deleted_at,
}
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
"""
写入后立即返回 ORM 对象前必须显式刷新。
本模块模型继承 TimestampMixinupdated_at 使用 onupdate=func.now()。
AsyncSession 下 flush 后直接访问 updated_at/created_at 可能触发隐式 IO
导致 MissingGreenlet。统一 flush + refresh,避免响应组装阶段懒加载。
"""
await db.flush()
await db.refresh(obj)
class HomeMaterialService:
"""首页素材装修主业务服务。API 层只调用本服务,内部再委托 query/storage/processor。"""
async def get_config(self, db: AsyncSession) -> HomeMaterialConfigOut:
result = await db.execute(
select(SystemConfig.value)
.where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
.limit(1)
)
value = result.scalar_one_or_none()
if not value:
return _config_to_out(None)
return _config_to_out(_json_loads(value))
async def save_config(self, db: AsyncSession, req: HomeMaterialConfigUpdate) -> tuple[HomeMaterialConfigOut, dict[str, Any], dict[str, Any]]:
before = await self.get_config(db)
after = HomeMaterialConfigOut(**req.model_dump())
result = await db.execute(
select(SystemConfig)
.where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
.limit(1)
)
config = result.scalar_one_or_none()
payload = after.model_dump()
if config:
config.value = _json_dumps(payload)
config.description = "首页素材行业装修展示配置"
db.add(config)
else:
db.add(
SystemConfig(
id=generate_id(),
key=HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value,
value=_json_dumps(payload),
description="首页素材行业装修展示配置",
)
)
return after, before.model_dump(), after.model_dump()
async def list_categories(
self,
db: AsyncSession,
*,
page: int,
page_size: int,
keyword: str | None = None,
is_active: bool | None = None,
) -> HomeMaterialCategoryListOut:
stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None))
count_stmt = select(func.count(HomeMaterialCategory.id)).where(HomeMaterialCategory.deleted_at.is_(None))
conditions = []
if keyword:
like = f"%{keyword}%"
conditions.append(or_(HomeMaterialCategory.name.ilike(like), HomeMaterialCategory.key.ilike(like)))
if is_active is not None:
conditions.append(HomeMaterialCategory.is_active.is_(is_active))
for cond in conditions:
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
total = int((await db.execute(count_stmt)).scalar_one() or 0)
result = await db.execute(
stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
categories = result.scalars().all()
counts = await query_service.asset_counts_map(db, [c.id for c in categories])
return HomeMaterialCategoryListOut(
items=[query_service.category_to_out(c, counts.get(c.id)) for c in categories],
total=total,
)
async def create_category(self, db: AsyncSession, req: HomeMaterialCategoryCreate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any]]:
await self._ensure_category_key_available(db, req.key)
category = HomeMaterialCategory(
id=generate_id(),
name=req.name,
key=req.key,
description=req.description,
icon=req.icon,
is_active=req.is_active,
sort_order=req.sort_order,
created_by=admin_id,
updated_by=admin_id,
)
db.add(category)
await _flush_refresh(db, category)
return query_service.category_to_out(category), _category_snapshot(category) or {}
async def update_category(self, db: AsyncSession, category_id: str, req: HomeMaterialCategoryUpdate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any], dict[str, Any]]:
category = await self._get_category(db, category_id)
before = _category_snapshot(category) or {}
if req.key != category.key:
await self._ensure_category_key_available(db, req.key, exclude_id=category_id)
category.name = req.name
category.key = req.key
category.description = req.description
category.icon = req.icon
category.is_active = req.is_active
category.sort_order = req.sort_order
category.updated_by = admin_id
db.add(category)
await _flush_refresh(db, category)
after = _category_snapshot(category) or {}
return query_service.category_to_out(category), before, after
async def delete_category(self, db: AsyncSession, category_id: str, admin_id: str | None) -> tuple[HomeMaterialCategory, dict[str, Any]]:
category = await self._get_category(db, category_id)
count = int((await db.execute(select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.category_id == category_id, HomeMaterialAsset.deleted_at.is_(None)))).scalar_one() or 0)
if count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该行业下仍有素材,请先删除素材或禁用行业")
before = _category_snapshot(category) or {}
category.deleted_at = datetime.now(timezone.utc)
category.updated_by = admin_id
db.add(category)
return category, before
async def _ensure_category_key_available(self, db: AsyncSession, key: str, exclude_id: str | None = None) -> None:
stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.key == key, HomeMaterialCategory.deleted_at.is_(None))
if exclude_id:
stmt = stmt.where(HomeMaterialCategory.id != exclude_id)
exists = (await db.execute(stmt.limit(1))).scalar_one_or_none()
if exists:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="行业 key 已存在")
async def _get_category(self, db: AsyncSession, category_id: str, *, active_only: bool = False) -> HomeMaterialCategory:
stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.id == category_id, HomeMaterialCategory.deleted_at.is_(None))
if active_only:
stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
result = await db.execute(stmt.limit(1))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="行业不存在或已删除")
return category
async def upload_watermark(self, db: AsyncSession, *, file: UploadFile, name: str | None, is_default: bool, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any]]:
stored = await storage_service.save_watermark_file(file)
info = await watermark_processor.probe(stored.storage_path)
if is_default:
await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None)).values(is_default=False))
watermark = HomeMaterialWatermark(
id=generate_id(),
name=name or stored.file_name,
file_url=stored.file_url,
storage_path=stored.storage_path,
file_name=stored.file_name,
file_size_bytes=stored.file_size_bytes,
width=info.width,
height=info.height,
is_default=is_default,
is_active=True,
created_by=admin_id,
updated_by=admin_id,
)
db.add(watermark)
await _flush_refresh(db, watermark)
return query_service.watermark_to_out(watermark), _watermark_snapshot(watermark) or {}
async def list_watermarks(self, db: AsyncSession, *, page: int, page_size: int, is_active: bool | None = None) -> HomeMaterialWatermarkListOut:
stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None))
count_stmt = select(func.count(HomeMaterialWatermark.id)).where(HomeMaterialWatermark.deleted_at.is_(None))
if is_active is not None:
stmt = stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
count_stmt = count_stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
total = int((await db.execute(count_stmt)).scalar_one() or 0)
result = await db.execute(
stmt.order_by(HomeMaterialWatermark.is_default.desc(), HomeMaterialWatermark.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return HomeMaterialWatermarkListOut(items=[query_service.watermark_to_out(w) for w in result.scalars().all()], total=total)
async def update_watermark(self, db: AsyncSession, watermark_id: str, req: HomeMaterialWatermarkUpdate, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any], dict[str, Any]]:
watermark = await self._get_watermark(db, watermark_id)
before = _watermark_snapshot(watermark) or {}
if req.is_default:
await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.id != watermark_id).values(is_default=False))
watermark.name = req.name
watermark.is_default = req.is_default
watermark.is_active = req.is_active
watermark.updated_by = admin_id
db.add(watermark)
await _flush_refresh(db, watermark)
return query_service.watermark_to_out(watermark), before, _watermark_snapshot(watermark) or {}
async def delete_watermark(self, db: AsyncSession, watermark_id: str, admin_id: str | None) -> tuple[HomeMaterialWatermark, dict[str, Any]]:
watermark = await self._get_watermark(db, watermark_id)
before = _watermark_snapshot(watermark) or {}
watermark.deleted_at = datetime.now(timezone.utc)
watermark.is_active = False
watermark.is_default = False
watermark.updated_by = admin_id
db.add(watermark)
return watermark, before
async def _get_watermark(self, db: AsyncSession, watermark_id: str, *, active_only: bool = False) -> HomeMaterialWatermark:
stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.id == watermark_id, HomeMaterialWatermark.deleted_at.is_(None))
if active_only:
stmt = stmt.where(HomeMaterialWatermark.is_active.is_(True))
result = await db.execute(stmt.limit(1))
watermark = result.scalar_one_or_none()
if not watermark:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="水印不存在或已删除")
return watermark
async def upload_asset(
self,
db: AsyncSession,
*,
category_id: str,
file: UploadFile,
media_type: HomeMaterialMediaType,
title: str | None,
watermark_id: str | None,
watermark_file: UploadFile | None,
watermark_config: HomeMaterialWatermarkConfig,
is_active: bool,
sort_order: int,
admin_id: str | None,
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
await self._get_category(db, category_id, active_only=False)
clean_title = _clean_title(title)
watermark_type = watermark_config.watermark_type
final_watermark_id: str | None = None
if watermark_type == HomeMaterialWatermarkType.IMAGE:
final_watermark_id = watermark_id
if watermark_file is not None:
watermark_out, _ = await self.upload_watermark(db, file=watermark_file, name=f"临时水印-{clean_title or '首页素材'}", is_default=False, admin_id=admin_id)
final_watermark_id = watermark_out.id
if not final_watermark_id:
default_wm = await db.execute(
select(HomeMaterialWatermark)
.where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.is_active.is_(True), HomeMaterialWatermark.is_default.is_(True))
.limit(1)
)
wm = default_wm.scalar_one_or_none()
final_watermark_id = wm.id if wm else None
if not final_watermark_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先选择或上传水印图片")
await self._get_watermark(db, final_watermark_id, active_only=True)
elif watermark_file is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印模式不允许上传图片水印文件")
stored = await storage_service.save_asset_file(file, media_type)
probe = await watermark_processor.probe(stored.storage_path)
if media_type == HomeMaterialMediaType.VIDEO:
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
if probe.duration_seconds is not None and probe.duration_seconds > max_duration:
storage_service.safe_remove(stored.storage_path)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration}")
cfg = watermark_config.model_copy(update={"watermark_id": final_watermark_id, "opacity": round(watermark_config.opacity_level / 10, 2)})
asset = HomeMaterialAsset(
id=generate_id(),
category_id=category_id,
title=clean_title,
media_type=media_type.value,
original_url=stored.file_url,
original_storage_path=stored.storage_path,
watermark_id=final_watermark_id,
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
status=HomeMaterialAssetStatus.PROCESSING.value,
width=probe.width,
height=probe.height,
duration_seconds=probe.duration_seconds,
file_size_bytes=stored.file_size_bytes,
is_active=is_active,
sort_order=sort_order,
created_by=admin_id,
updated_by=admin_id,
)
db.add(asset)
await _flush_refresh(db, asset)
return self._upload_result(asset, message="素材已上传,水印处理中"), _asset_snapshot(asset) or {}
async def list_assets(
self,
db: AsyncSession,
*,
page: int,
page_size: int,
category_id: str | None,
media_type: HomeMaterialMediaType | str | None,
status: HomeMaterialAssetStatus | str | None,
is_active: bool | None,
keyword: str | None,
) -> HomeMaterialAssetListOut:
config = await self.get_config(db)
items, total = await query_service.list_admin_assets(
db,
page=page,
page_size=page_size,
category_id=category_id,
media_type=media_type,
status=status,
is_active=is_active,
keyword=keyword,
include_original=config.show_original_in_admin,
)
return HomeMaterialAssetListOut(items=items, total=total)
async def get_asset_detail(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetOut:
asset = await self._get_asset(db, asset_id)
category_map = await query_service.batch_categories_map(db, [asset.category_id])
watermark_map = await query_service.batch_watermarks_map(db, [asset.watermark_id])
config = await self.get_config(db)
return query_service.asset_to_out(asset, category_map=category_map, watermark_map=watermark_map, include_original=config.show_original_in_admin)
async def update_asset(self, db: AsyncSession, asset_id: str, req: HomeMaterialAssetUpdate, admin_id: str | None) -> tuple[HomeMaterialAssetOut, dict[str, Any], dict[str, Any]]:
asset = await self._get_asset(db, asset_id)
await self._get_category(db, req.category_id)
before = _asset_snapshot(asset) or {}
asset.category_id = req.category_id
asset.title = req.title
asset.is_active = req.is_active
asset.sort_order = req.sort_order
asset.updated_by = admin_id
db.add(asset)
await _flush_refresh(db, asset)
after = _asset_snapshot(asset) or {}
return await self.get_asset_detail(db, asset_id), before, after
async def prepare_regenerate(
self,
db: AsyncSession,
asset_id: str,
req: HomeMaterialRegenerateWatermarkRequest,
admin_id: str | None,
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any], dict[str, Any]]:
asset = await self._get_asset(db, asset_id)
before = _asset_snapshot(asset) or {}
req_data = req.model_dump(exclude={"wait", "wait_timeout_seconds"})
if req.watermark_type == HomeMaterialWatermarkType.IMAGE:
final_watermark_id = req.watermark_id or asset.watermark_id
await self._get_watermark(db, final_watermark_id or "", active_only=True)
cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": final_watermark_id})
else:
cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": None})
asset.watermark_id = cfg.watermark_id
asset.watermark_config_json = _json_dumps(cfg.model_dump(mode="json"))
asset.status = HomeMaterialAssetStatus.PROCESSING.value
asset.error_message = None
asset.updated_by = admin_id
db.add(asset)
await _flush_refresh(db, asset)
return self._upload_result(asset, message="水印重新生成中"), before, _asset_snapshot(asset) or {}
async def delete_asset(self, db: AsyncSession, asset_id: str, admin_id: str | None) -> tuple[HomeMaterialAsset, dict[str, Any]]:
asset = await self._get_asset(db, asset_id)
before = _asset_snapshot(asset) or {}
asset.deleted_at = datetime.now(timezone.utc)
asset.updated_by = admin_id
db.add(asset)
return asset, before
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
asset = await self._get_asset(db, asset_id)
return HomeMaterialAssetStatusOut(
id=asset.id,
status=HomeMaterialAssetStatus(asset.status),
error_message=asset.error_message,
original_url=asset.original_url,
watermarked_url=asset.watermarked_url,
cover_url=asset.cover_url,
processed_at=asset.processed_at,
)
async def _get_asset(self, db: AsyncSession, asset_id: str) -> HomeMaterialAsset:
result = await db.execute(
select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id, HomeMaterialAsset.deleted_at.is_(None)).limit(1)
)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在或已删除")
return asset
def _upload_result(self, asset: HomeMaterialAsset, *, message: str) -> HomeMaterialUploadResultOut:
return HomeMaterialUploadResultOut(
id=asset.id,
category_id=asset.category_id,
title=asset.title,
media_type=HomeMaterialMediaType(asset.media_type),
status=HomeMaterialAssetStatus(asset.status),
original_url=asset.original_url,
watermarked_url=asset.watermarked_url,
cover_url=asset.cover_url,
watermark_config=_json_loads(asset.watermark_config_json),
message=message,
)
async def wait_for_asset_result(self, asset_id: str, timeout_seconds: int) -> HomeMaterialUploadResultOut:
deadline = asyncio.get_running_loop().time() + max(1, min(timeout_seconds, 60))
while True:
async with async_session() as db:
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在")
if asset.status in (HomeMaterialAssetStatus.SUCCESS.value, HomeMaterialAssetStatus.FAILED.value):
return self._upload_result(asset, message="水印处理完成" if asset.status == HomeMaterialAssetStatus.SUCCESS.value else "水印处理失败")
if asyncio.get_running_loop().time() >= deadline:
async with async_session() as db:
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
asset = result.scalar_one()
return self._upload_result(asset, message="水印仍在处理中,请继续轮询状态")
await asyncio.sleep(0.5)
def start_watermark_task(self, asset_id: str) -> None:
asyncio.create_task(self._run_watermark_task(asset_id))
async def _run_watermark_task(self, asset_id: str) -> None:
try:
async with async_session() as db:
asset = await self._get_asset(db, asset_id)
cfg = _normalize_watermark_config_dict(_json_loads(asset.watermark_config_json), asset.watermark_id)
watermark_path: str | None = None
if cfg.get("watermark_type") == HomeMaterialWatermarkType.IMAGE.value:
watermark = await self._get_watermark(db, cfg.get("watermark_id") or asset.watermark_id or "", active_only=True)
watermark_path = watermark.storage_path
output_path, output_url = storage_service.build_watermarked_target(asset.id, asset.media_type)
cover_path = cover_url = None
if asset.media_type == HomeMaterialMediaType.VIDEO.value:
cover_path, cover_url = storage_service.build_cover_target(asset.id)
result = await watermark_processor.apply_watermark(
media_type=HomeMaterialMediaType(asset.media_type),
source_path=asset.original_storage_path,
watermark_path=watermark_path,
output_path=output_path,
config=cfg,
cover_path=cover_path,
)
asset.watermarked_storage_path = result.output_path
asset.watermarked_url = output_url
asset.cover_storage_path = result.cover_path
asset.cover_url = cover_url if result.cover_path else None
asset.width = result.width
asset.height = result.height
asset.duration_seconds = result.duration_seconds
asset.watermarked_file_size_bytes = result.file_size_bytes
asset.status = HomeMaterialAssetStatus.SUCCESS.value
asset.error_message = None
asset.processed_at = datetime.now(timezone.utc)
db.add(asset)
await db.commit()
except Exception as exc:
async with async_session() as db:
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
asset = result.scalar_one_or_none()
if asset:
asset.status = HomeMaterialAssetStatus.FAILED.value
asset.error_message = str(exc)[:4000]
db.add(asset)
await db.commit()
async def preview_text_watermark_layer(self, req: HomeMaterialTextWatermarkPreviewRequest) -> HomeMaterialTextWatermarkPreviewResponse:
data_url = await asyncio.to_thread(
watermark_processor.generate_repeated_text_layer_data_url,
width=req.width,
height=req.height,
text_config=req.text_watermark,
)
return HomeMaterialTextWatermarkPreviewResponse(
width=req.width,
height=req.height,
preview_layer_data_url=data_url,
)
async def mark_stale_processing_failed(self, db: AsyncSession) -> int:
minutes = int(getattr(settings, "HOME_MATERIAL_PROCESSING_STALE_MINUTES", 30))
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
result = await db.execute(
update(HomeMaterialAsset)
.where(
HomeMaterialAsset.deleted_at.is_(None),
HomeMaterialAsset.status == HomeMaterialAssetStatus.PROCESSING.value,
HomeMaterialAsset.updated_at < cutoff,
)
.values(status=HomeMaterialAssetStatus.FAILED.value, error_message="处理任务超时或服务重启,请重新生成水印")
)
return int(result.rowcount or 0)
async def get_public_categories(
self,
db: AsyncSession,
*,
with_asset_count: bool,
media_type: HomeMaterialMediaType | str | None,
only_has_assets: bool,
) -> HomeMaterialPublicCategoryListOut:
config = await self.get_config(db)
if not config.enabled:
return HomeMaterialPublicCategoryListOut(enabled=False, title=config.title, subtitle=config.subtitle, items=[])
items = await query_service.list_public_categories(db, with_asset_count=with_asset_count, media_type=media_type, only_has_assets=only_has_assets)
return HomeMaterialPublicCategoryListOut(enabled=True, title=config.title, subtitle=config.subtitle, items=items)
async def get_public_home_materials(
self,
db: AsyncSession,
*,
category_id: str | None,
category_key: str | None,
category_ids: str | None,
category_keys: str | None,
media_type: HomeMaterialMediaType | str | None,
limit_per_category: int,
include_empty_categories: bool,
response_mode: HomeMaterialPublicResponseMode,
page: int,
page_size: int,
) -> HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut:
config = await self.get_config(db)
if not config.enabled:
if response_mode == HomeMaterialPublicResponseMode.FLAT:
return HomeMaterialPublicFlatOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=[], total=0)
return HomeMaterialPublicGroupedOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=[])
resolved_ids = await query_service.resolve_category_ids(
db,
category_id=category_id,
category_key=category_key,
category_ids=category_ids,
category_keys=category_keys,
active_only=True,
)
if response_mode == HomeMaterialPublicResponseMode.FLAT:
items, total = await query_service.list_public_flat(db, category_ids=resolved_ids, media_type=media_type, page=page, page_size=page_size)
return HomeMaterialPublicFlatOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=items, total=total)
categories = await query_service.list_public_grouped(
db,
category_ids=resolved_ids,
media_type=media_type,
limit_per_category=limit_per_category,
include_empty_categories=include_empty_categories,
)
return HomeMaterialPublicGroupedOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=categories)
home_material_service = HomeMaterialService()
@@ -0,0 +1,170 @@
from __future__ import annotations
import os
import shutil
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
from fastapi import HTTPException, UploadFile, status
from app.config import settings
from app.enums.home_material import (
HOME_MATERIAL_IMAGE_EXTENSIONS,
HOME_MATERIAL_VIDEO_EXTENSIONS,
HOME_MATERIAL_WATERMARK_EXTENSIONS,
HomeMaterialMediaType,
)
from app.utils.id_gen import generate_id
@dataclass(frozen=True)
class StoredFile:
storage_path: str
file_url: str
file_name: str
file_size_bytes: int
suffix: str
class HomeMaterialStorageService:
"""首页素材文件存储服务。只处理文件、目录、URL,不访问数据库。"""
def __init__(self) -> None:
self.upload_root = Path(settings.UPLOAD_LOCAL_PATH).resolve()
self.home_root = self.upload_root / "home_materials"
def _date_dir(self) -> str:
return datetime.now().strftime("%Y/%m/%d")
def _safe_suffix(self, filename: str | None) -> str:
return Path(filename or "").suffix.lower()
def _assert_extension(self, suffix: str, allowed: Iterable[str], label: str) -> None:
if suffix not in set(allowed):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{label}文件类型不支持:{suffix or '无扩展名'}",
)
def _limit_mb(self, media_type: HomeMaterialMediaType | str) -> int:
if media_type == HomeMaterialMediaType.VIDEO or str(media_type) == HomeMaterialMediaType.VIDEO.value:
return int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_MB", 300))
return int(getattr(settings, "HOME_MATERIAL_MAX_IMAGE_MB", 20))
def _build_target(self, relative_dir: str, suffix: str) -> tuple[Path, str]:
file_id = generate_id()
relative = Path(relative_dir) / self._date_dir() / f"{file_id}{suffix}"
path = self.home_root / relative
path.parent.mkdir(parents=True, exist_ok=True)
return path, f"/uploads/home_materials/{relative.as_posix()}"
async def save_upload_file(
self,
file: UploadFile,
*,
relative_dir: str,
allowed_extensions: Iterable[str],
label: str,
max_mb: int | None = None,
) -> StoredFile:
suffix = self._safe_suffix(file.filename)
self._assert_extension(suffix, allowed_extensions, label)
target_path, file_url = self._build_target(relative_dir, suffix)
max_bytes = int(max_mb or 0) * 1024 * 1024 if max_mb else None
tmp_path = target_path.with_name(target_path.name + ".part")
size = 0
try:
with tmp_path.open("wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if max_bytes and size > max_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{label}文件大小超限,最大 {max_mb}MB",
)
out.write(chunk)
os.replace(tmp_path, target_path)
except Exception:
try:
if tmp_path.exists():
tmp_path.unlink()
finally:
raise
finally:
await file.seek(0)
return StoredFile(
storage_path=str(target_path),
file_url=file_url,
file_name=file.filename or target_path.name,
file_size_bytes=size,
suffix=suffix,
)
async def save_asset_file(self, file: UploadFile, media_type: HomeMaterialMediaType | str) -> StoredFile:
media = HomeMaterialMediaType(media_type)
if media == HomeMaterialMediaType.IMAGE:
return await self.save_upload_file(
file,
relative_dir="original/images",
allowed_extensions=HOME_MATERIAL_IMAGE_EXTENSIONS,
label="图片素材",
max_mb=self._limit_mb(media),
)
return await self.save_upload_file(
file,
relative_dir="original/videos",
allowed_extensions=HOME_MATERIAL_VIDEO_EXTENSIONS,
label="视频素材",
max_mb=self._limit_mb(media),
)
async def save_watermark_file(self, file: UploadFile) -> StoredFile:
return await self.save_upload_file(
file,
relative_dir="watermarks",
allowed_extensions=HOME_MATERIAL_WATERMARK_EXTENSIONS,
label="水印图片",
max_mb=int(getattr(settings, "HOME_MATERIAL_MAX_WATERMARK_MB", 10)),
)
def build_watermarked_target(self, asset_id: str, media_type: HomeMaterialMediaType | str) -> tuple[str, str]:
media = HomeMaterialMediaType(media_type)
suffix = ".png" if media == HomeMaterialMediaType.IMAGE else ".mp4"
folder = "watermarked/images" if media == HomeMaterialMediaType.IMAGE else "watermarked/videos"
relative = Path(folder) / self._date_dir() / f"{asset_id}{suffix}"
path = self.home_root / relative
path.parent.mkdir(parents=True, exist_ok=True)
return str(path), f"/uploads/home_materials/{relative.as_posix()}"
def build_cover_target(self, asset_id: str) -> tuple[str, str]:
relative = Path("covers") / self._date_dir() / f"{asset_id}.jpg"
path = self.home_root / relative
path.parent.mkdir(parents=True, exist_ok=True)
return str(path), f"/uploads/home_materials/{relative.as_posix()}"
@staticmethod
def file_size(path: str | None) -> int | None:
if not path:
return None
try:
return Path(path).stat().st_size
except FileNotFoundError:
return None
@staticmethod
def safe_remove(path: str | None) -> None:
if not path:
return
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
storage_service = HomeMaterialStorageService()
@@ -0,0 +1,622 @@
from __future__ import annotations
import asyncio
import base64
import json
import math
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import Any
from fastapi import HTTPException, status
try:
from PIL import Image, ImageDraw, ImageFont
except Exception: # pragma: no cover - 运行时给出明确错误
Image = None # type: ignore[assignment]
ImageDraw = None # type: ignore[assignment]
ImageFont = None # type: ignore[assignment]
from app.config import settings
from app.enums.home_material import (
HomeMaterialMediaType,
HomeMaterialWatermarkPosition,
HomeMaterialWatermarkSizeMode,
HomeMaterialWatermarkType,
)
from app.schemas.home_material import HomeMaterialTextWatermarkConfig, HomeMaterialWatermarkConfig
@dataclass(frozen=True)
class MediaProbeInfo:
width: int | None = None
height: int | None = None
duration_seconds: Decimal | None = None
@dataclass(frozen=True)
class WatermarkProcessResult:
output_path: str
width: int | None
height: int | None
duration_seconds: Decimal | None
file_size_bytes: int
cover_path: str | None = None
class HomeMaterialWatermarkProcessor:
"""首页素材 FFmpeg 水印处理器。纯处理层,不访问数据库。"""
def __init__(self) -> None:
self._image_semaphore = asyncio.Semaphore(int(getattr(settings, "HOME_MATERIAL_IMAGE_WATERMARK_CONCURRENCY", 4)))
self._video_semaphore = asyncio.Semaphore(int(getattr(settings, "HOME_MATERIAL_VIDEO_WATERMARK_CONCURRENCY", 2)))
def _ffmpeg_bin(self) -> str:
configured = getattr(settings, "FFMPEG_BIN", "") or ""
if configured:
return configured
found = shutil.which("ffmpeg") or shutil.which("ffmpeg.exe")
if not found:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="未找到 ffmpeg,请配置 FFMPEG_BIN 或安装 ffmpeg")
return found
def _ffprobe_bin(self) -> str:
configured = getattr(settings, "FFMPEG_BIN", "") or ""
if configured:
p = Path(configured)
candidate = p.with_name("ffprobe.exe" if p.name.endswith(".exe") else "ffprobe")
if candidate.exists():
return str(candidate)
found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
if not found:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="未找到 ffprobe,请确认 ffmpeg 环境完整")
return found
@staticmethod
def _run_blocking(args: list[str], timeout: int) -> tuple[str, str]:
"""在线程里执行 FFmpeg/ffprobe。
Windows 下 uvicorn/watchfiles 有概率使用不支持子进程的 SelectorEventLoop
asyncio.create_subprocess_exec 会直接抛 NotImplementedError。这里统一改为
subprocess.run + asyncio.to_thread,仍然不会阻塞 Web Server 事件循环。
"""
creationflags = 0
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
try:
completed = subprocess.run(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
creationflags=creationflags,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError("FFmpeg 处理超时") from exc
out = completed.stdout.decode("utf-8", errors="ignore")
err = completed.stderr.decode("utf-8", errors="ignore")
if completed.returncode != 0:
raise RuntimeError(err[-2000:] or f"FFmpeg 退出码异常:{completed.returncode}")
return out, err
async def _run(self, args: list[str], timeout: int | None = None) -> tuple[str, str]:
effective_timeout = timeout or int(getattr(settings, "HOME_MATERIAL_FFMPEG_TIMEOUT_SECONDS", 600))
return await asyncio.to_thread(self._run_blocking, args, effective_timeout)
async def probe(self, path: str) -> MediaProbeInfo:
args = [
self._ffprobe_bin(),
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,duration",
"-of",
"json",
path,
]
stdout, _ = await self._run(args, timeout=30)
try:
data = json.loads(stdout or "{}")
stream = (data.get("streams") or [{}])[0]
duration_raw = stream.get("duration")
return MediaProbeInfo(
width=int(stream["width"]) if stream.get("width") is not None else None,
height=int(stream["height"]) if stream.get("height") is not None else None,
duration_seconds=Decimal(str(duration_raw)).quantize(Decimal("0.001")) if duration_raw not in (None, "N/A") else None,
)
except Exception:
return MediaProbeInfo()
@staticmethod
def _value(config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any], key: str, default: Any = None) -> Any:
if isinstance(config, dict):
return config.get(key, default)
return getattr(config, key, default)
def _watermark_type(self, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> str:
raw = self._value(config, "watermark_type", None) or HomeMaterialWatermarkType.IMAGE.value
return HomeMaterialWatermarkType(raw).value
def _watermark_width(self, source_width: int | None, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> int:
size_mode = self._value(config, "size_mode", HomeMaterialWatermarkSizeMode.RATIO.value)
if hasattr(size_mode, "value"):
size_mode = size_mode.value
width_px = self._value(config, "width_px", None)
width_ratio = self._value(config, "width_ratio", 0.18) or 0.18
if size_mode == HomeMaterialWatermarkSizeMode.PX.value and width_px:
return max(1, int(width_px))
base_width = source_width or 1080
return max(1, int(base_width * float(width_ratio)))
def _overlay_xy(self, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> tuple[str, str]:
position = self._value(config, "position", HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value)
if hasattr(position, "value"):
position = position.value
margin_x = int(self._value(config, "margin_x", 24) or 24)
margin_y = int(self._value(config, "margin_y", 24) or 24)
custom_x_ratio = self._value(config, "custom_x_ratio", None)
custom_y_ratio = self._value(config, "custom_y_ratio", None)
if position == HomeMaterialWatermarkPosition.CUSTOM.value:
x_ratio = float(custom_x_ratio if custom_x_ratio is not None else 0.5)
y_ratio = float(custom_y_ratio if custom_y_ratio is not None else 0.5)
return f"(main_w-overlay_w)*{x_ratio:.6f}", f"(main_h-overlay_h)*{y_ratio:.6f}"
positions: dict[str, tuple[str, str]] = {
HomeMaterialWatermarkPosition.TOP_LEFT.value: (str(margin_x), str(margin_y)),
HomeMaterialWatermarkPosition.TOP_CENTER.value: ("(main_w-overlay_w)/2", str(margin_y)),
HomeMaterialWatermarkPosition.TOP_RIGHT.value: (f"main_w-overlay_w-{margin_x}", str(margin_y)),
HomeMaterialWatermarkPosition.MIDDLE_LEFT.value: (str(margin_x), "(main_h-overlay_h)/2"),
HomeMaterialWatermarkPosition.CENTER.value: ("(main_w-overlay_w)/2", "(main_h-overlay_h)/2"),
HomeMaterialWatermarkPosition.MIDDLE_RIGHT.value: (f"main_w-overlay_w-{margin_x}", "(main_h-overlay_h)/2"),
HomeMaterialWatermarkPosition.BOTTOM_LEFT.value: (str(margin_x), f"main_h-overlay_h-{margin_y}"),
HomeMaterialWatermarkPosition.BOTTOM_CENTER.value: ("(main_w-overlay_w)/2", f"main_h-overlay_h-{margin_y}"),
HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value: (f"main_w-overlay_w-{margin_x}", f"main_h-overlay_h-{margin_y}"),
}
return positions.get(str(position), positions[HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value])
def _opacity(self, config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any]) -> float:
level = int(self._value(config, "opacity_level", 6) or 6)
return min(max(level, 1), 10) / 10
def _filter_complex_image_watermark(self, source_width: int | None, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> str:
wm_width = self._watermark_width(source_width, config)
opacity = self._opacity(config)
x, y = self._overlay_xy(config)
return f"[1:v]format=rgba,colorchannelmixer=aa={opacity:.2f},scale={wm_width}:-1[wm];[0:v][wm]overlay={x}:{y}[v]"
@staticmethod
def _hex_to_rgb(value: str) -> tuple[int, int, int]:
raw = (value or "#ffffff").strip()
if not raw.startswith("#") or len(raw) != 7:
raw = "#ffffff"
try:
return int(raw[1:3], 16), int(raw[3:5], 16), int(raw[5:7], 16)
except Exception:
return 255, 255, 255
def _font_path(self) -> str:
configured = str(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_FONT", "") or "").strip()
if not configured:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="重复文字水印字体未配置,请配置 HOME_MATERIAL_TEXT_WATERMARK_FONT 为开源可商用字体文件路径,例如 Noto Sans CJK SC / Source Han Sans SC。",
)
path = Path(configured)
if not path.exists() or not path.is_file():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"重复文字水印字体文件不存在或不可读:{configured}",
)
return str(path)
def _assert_pillow_available(self) -> None:
if Image is None or ImageDraw is None or ImageFont is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="重复文字水印依赖 Pillow,请先安装 pillow,并配置 HOME_MATERIAL_TEXT_WATERMARK_FONT。",
)
def _text_config(self, config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any]) -> HomeMaterialTextWatermarkConfig | dict[str, Any]:
if isinstance(config, HomeMaterialTextWatermarkConfig):
return config
if isinstance(config, HomeMaterialWatermarkConfig):
if config.text_watermark is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印配置不能为空")
return config.text_watermark
text_config = config.get("text_watermark")
if not isinstance(text_config, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印配置不能为空")
return text_config
def generate_repeated_text_layer_file(
self,
*,
width: int,
height: int,
text_config: HomeMaterialTextWatermarkConfig | dict[str, Any],
output_path: str,
) -> str:
"""用固定开源字体生成透明重复文字水印层。
该方法同时用于前端精准预览和最终 FFmpeg overlay,保证前端看到的透明层和实际叠加层来自同一套渲染逻辑。
"""
self._assert_pillow_available()
font_path = self._font_path()
safe_width = max(1, int(width))
safe_height = max(1, int(height))
max_text_length = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_TEXT_LENGTH", 64))
max_font_size = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_FONT_SIZE", 160))
max_gap = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_GAP", 2000))
text = str(self._value(text_config, "text", "") or "").strip()[:max_text_length]
if not text:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印内容不能为空")
font_size = max(8, min(max_font_size, int(self._value(text_config, "font_size_px", 28) or 28)))
rotate_deg = max(-90, min(90, int(self._value(text_config, "rotate_deg", -30) or 0)))
gap_x = max(20, min(max_gap, int(self._value(text_config, "gap_x", 220) or 220)))
gap_y = max(20, min(max_gap, int(self._value(text_config, "gap_y", 140) or 140)))
staggered = bool(self._value(text_config, "staggered", True))
opacity = self._opacity(text_config)
r, g, b = self._hex_to_rgb(str(self._value(text_config, "color", "#ffffff") or "#ffffff"))
alpha = int(round(opacity * 255))
font = ImageFont.truetype(font_path, font_size)
measure = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
measure_draw = ImageDraw.Draw(measure)
bbox = measure_draw.textbbox((0, 0), text, font=font)
text_w = max(1, bbox[2] - bbox[0])
text_h = max(1, bbox[3] - bbox[1])
padding = max(8, int(font_size * 0.8))
text_img = Image.new("RGBA", (text_w + padding * 2, text_h + padding * 2), (0, 0, 0, 0))
text_draw = ImageDraw.Draw(text_img)
text_draw.text((padding - bbox[0], padding - bbox[1]), text, font=font, fill=(r, g, b, alpha))
if rotate_deg:
text_img = text_img.rotate(rotate_deg, resample=Image.Resampling.BICUBIC, expand=True)
layer = Image.new("RGBA", (safe_width, safe_height), (0, 0, 0, 0))
tile_w, tile_h = text_img.size
step_x = max(gap_x, int(tile_w * 0.8))
step_y = max(gap_y, int(tile_h * 0.8))
start_x = -tile_w
start_y = -tile_h
end_x = safe_width + tile_w
end_y = safe_height + tile_h
row = 0
y = start_y
while y <= end_y:
offset = step_x // 2 if staggered and row % 2 == 1 else 0
x = start_x + offset
while x <= end_x:
layer.alpha_composite(text_img, (int(x), int(y)))
x += step_x
y += step_y
row += 1
target = Path(output_path)
target.parent.mkdir(parents=True, exist_ok=True)
layer.save(str(target), format="PNG")
return str(target)
def generate_repeated_text_layer_data_url(
self,
*,
width: int,
height: int,
text_config: HomeMaterialTextWatermarkConfig | dict[str, Any],
) -> str:
max_width = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_WIDTH", 8192))
max_height = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_HEIGHT", 8192))
safe_width = max(1, min(max_width, int(width)))
safe_height = max(1, min(max_height, int(height)))
with tempfile.NamedTemporaryFile(prefix="home_material_text_wm_", suffix=".png", delete=False) as tmp:
tmp_path = tmp.name
try:
self.generate_repeated_text_layer_file(width=safe_width, height=safe_height, text_config=text_config, output_path=tmp_path)
raw = Path(tmp_path).read_bytes()
return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
finally:
try:
Path(tmp_path).unlink(missing_ok=True)
except Exception:
pass
async def apply_watermark(
self,
*,
media_type: HomeMaterialMediaType | str,
source_path: str,
watermark_path: str | None,
output_path: str,
config: HomeMaterialWatermarkConfig | dict[str, Any],
cover_path: str | None = None,
) -> WatermarkProcessResult:
media = HomeMaterialMediaType(media_type)
watermark_type = self._watermark_type(config)
if media == HomeMaterialMediaType.IMAGE:
async with self._image_semaphore:
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
return await self._apply_image_repeated_text_watermark(source_path, output_path, config)
if not watermark_path:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片水印文件不能为空")
return await self._apply_image_watermark(source_path, watermark_path, output_path, config)
async with self._video_semaphore:
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
return await self._apply_video_repeated_text_watermark(source_path, output_path, config, cover_path=cover_path)
if not watermark_path:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片水印文件不能为空")
return await self._apply_video_watermark(source_path, watermark_path, output_path, config, cover_path=cover_path)
async def _apply_image_watermark(
self,
source_path: str,
watermark_path: str,
output_path: str,
config: HomeMaterialWatermarkConfig | dict[str, Any],
) -> WatermarkProcessResult:
source_info = await self.probe(source_path)
tmp_path = output_path + ".part"
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
filter_complex = self._filter_complex_image_watermark(source_info.width, config)
args = [
self._ffmpeg_bin(),
"-y",
"-i",
source_path,
"-i",
watermark_path,
"-filter_complex",
filter_complex,
"-map",
"[v]",
"-frames:v",
"1",
"-f",
"image2",
"-vcodec",
"png",
tmp_path,
]
try:
await self._run(args)
os.replace(tmp_path, output_path)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
output_info = await self.probe(output_path)
return WatermarkProcessResult(
output_path=output_path,
width=output_info.width or source_info.width,
height=output_info.height or source_info.height,
duration_seconds=None,
file_size_bytes=os.path.getsize(output_path),
)
async def _apply_video_watermark(
self,
source_path: str,
watermark_path: str,
output_path: str,
config: HomeMaterialWatermarkConfig | dict[str, Any],
cover_path: str | None = None,
) -> WatermarkProcessResult:
source_info = await self.probe(source_path)
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
if source_info.duration_seconds is not None and source_info.duration_seconds > Decimal(max_duration):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration}")
tmp_path = output_path + ".part"
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
filter_complex = self._filter_complex_image_watermark(source_info.width, config)
args = [
self._ffmpeg_bin(),
"-y",
"-i",
source_path,
"-i",
watermark_path,
"-filter_complex",
filter_complex,
"-map",
"[v]",
"-map",
"0:a?",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-c:a",
"copy",
"-movflags",
"+faststart",
"-f",
"mp4",
tmp_path,
]
try:
await self._run(args)
os.replace(tmp_path, output_path)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
output_info = await self.probe(output_path)
generated_cover = None
if cover_path:
generated_cover = await self.generate_cover(output_path, cover_path)
return WatermarkProcessResult(
output_path=output_path,
width=output_info.width or source_info.width,
height=output_info.height or source_info.height,
duration_seconds=output_info.duration_seconds or source_info.duration_seconds,
file_size_bytes=os.path.getsize(output_path),
cover_path=generated_cover,
)
async def _apply_image_repeated_text_watermark(
self,
source_path: str,
output_path: str,
config: HomeMaterialWatermarkConfig | dict[str, Any],
) -> WatermarkProcessResult:
source_info = await self.probe(source_path)
width = int(source_info.width or 1080)
height = int(source_info.height or 1920)
tmp_path = output_path + ".part"
layer_path = output_path + ".text-layer.png"
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
self.generate_repeated_text_layer_file(width=width, height=height, text_config=self._text_config(config), output_path=layer_path)
args = [
self._ffmpeg_bin(),
"-y",
"-i",
source_path,
"-i",
layer_path,
"-filter_complex",
"[1:v]format=rgba[wm];[0:v][wm]overlay=0:0[v]",
"-map",
"[v]",
"-frames:v",
"1",
"-f",
"image2",
"-vcodec",
"png",
tmp_path,
]
try:
await self._run(args)
os.replace(tmp_path, output_path)
finally:
for p in (tmp_path, layer_path):
if os.path.exists(p):
os.remove(p)
output_info = await self.probe(output_path)
return WatermarkProcessResult(
output_path=output_path,
width=output_info.width or source_info.width,
height=output_info.height or source_info.height,
duration_seconds=None,
file_size_bytes=os.path.getsize(output_path),
)
async def _apply_video_repeated_text_watermark(
self,
source_path: str,
output_path: str,
config: HomeMaterialWatermarkConfig | dict[str, Any],
cover_path: str | None = None,
) -> WatermarkProcessResult:
source_info = await self.probe(source_path)
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
if source_info.duration_seconds is not None and source_info.duration_seconds > Decimal(max_duration):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration}")
width = int(source_info.width or 1080)
height = int(source_info.height or 1920)
tmp_path = output_path + ".part"
layer_path = output_path + ".text-layer.png"
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
self.generate_repeated_text_layer_file(width=width, height=height, text_config=self._text_config(config), output_path=layer_path)
args = [
self._ffmpeg_bin(),
"-y",
"-i",
source_path,
"-loop",
"1",
"-i",
layer_path,
"-filter_complex",
"[1:v]format=rgba[wm];[0:v][wm]overlay=0:0[v]",
"-map",
"[v]",
"-map",
"0:a?",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-c:a",
"copy",
"-movflags",
"+faststart",
"-shortest",
"-f",
"mp4",
tmp_path,
]
try:
await self._run(args)
os.replace(tmp_path, output_path)
finally:
for p in (tmp_path, layer_path):
if os.path.exists(p):
os.remove(p)
output_info = await self.probe(output_path)
generated_cover = None
if cover_path:
generated_cover = await self.generate_cover(output_path, cover_path)
return WatermarkProcessResult(
output_path=output_path,
width=output_info.width or source_info.width,
height=output_info.height or source_info.height,
duration_seconds=output_info.duration_seconds or source_info.duration_seconds,
file_size_bytes=os.path.getsize(output_path),
cover_path=generated_cover,
)
async def generate_cover(self, video_path: str, cover_path: str) -> str:
tmp_path = cover_path + ".part"
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
seek = getattr(settings, "VIDEO_COVER_SEEK_TIME", "00:00:01") or "00:00:01"
width = int(getattr(settings, "VIDEO_COVER_WIDTH", 720))
args = [
self._ffmpeg_bin(),
"-y",
"-ss",
seek,
"-i",
video_path,
"-frames:v",
"1",
"-vf",
f"scale={width}:-2",
"-f",
"image2",
"-vcodec",
"mjpeg",
tmp_path,
]
try:
await self._run(args, timeout=int(getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15)))
os.replace(tmp_path, cover_path)
return cover_path
except Exception:
if os.path.exists(tmp_path):
os.remove(tmp_path)
fallback_seek = getattr(settings, "VIDEO_COVER_FALLBACK_SEEK_TIME", "00:00:00") or "00:00:00"
fallback_args = args.copy()
fallback_args[fallback_args.index(seek)] = fallback_seek
await self._run(fallback_args, timeout=int(getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15)))
os.replace(tmp_path, cover_path)
return cover_path
watermark_processor = HomeMaterialWatermarkProcessor()