Refine seller store implementation
This commit is contained in:
@@ -11,8 +11,17 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from api.proxy import guess_referer
|
||||
from config import get_settings
|
||||
from models import ProductAsset
|
||||
from services.storage import download_bytes, get_storage, image_extension, local_path, read_local_bytes, storage_key_from_url
|
||||
from services.storage import (
|
||||
download_bytes,
|
||||
get_storage,
|
||||
image_extension,
|
||||
local_path,
|
||||
normalize_image_bytes,
|
||||
read_local_bytes,
|
||||
storage_key_from_url,
|
||||
)
|
||||
from services.watermark import apply_watermark
|
||||
|
||||
|
||||
@@ -46,8 +55,20 @@ class ImageAssetService:
|
||||
return read_local_bytes(path), mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
return await download_bytes(url, referer=guess_referer(url))
|
||||
|
||||
async def save_original(self, asset: ProductAsset, data: bytes, content_type: str = "") -> str:
|
||||
async def save_original(
|
||||
self,
|
||||
asset: ProductAsset,
|
||||
data: bytes,
|
||||
content_type: str = "",
|
||||
max_bytes: int | None = None,
|
||||
) -> str:
|
||||
"""写入该素材唯一的无水印原图,并将 current 同步到原图。"""
|
||||
if asset.type == "img":
|
||||
normalized = normalize_image_bytes(data, max_bytes=max_bytes)
|
||||
data = normalized.data
|
||||
content_type = normalized.content_type
|
||||
asset.width = normalized.width
|
||||
asset.height = normalized.height
|
||||
key = self._key(asset, "original", data, content_type)
|
||||
url = await self.storage.save_bytes(data, key, content_type)
|
||||
asset.original_url = url
|
||||
@@ -60,7 +81,12 @@ class ImageAssetService:
|
||||
|
||||
async def save_original_from_url(self, asset: ProductAsset, url: str) -> str:
|
||||
data, content_type = await self._read(url)
|
||||
return await self.save_original(asset, data, content_type)
|
||||
return await self.save_original(
|
||||
asset,
|
||||
data,
|
||||
content_type,
|
||||
max_bytes=get_settings().image_download_max_bytes,
|
||||
)
|
||||
|
||||
async def ensure_original(self, asset: ProductAsset) -> tuple[bytes, str]:
|
||||
"""为旧记录补齐原图字段;首次触碰旧素材时迁入新的商品/素材路径。"""
|
||||
|
||||
@@ -26,6 +26,7 @@ from services.storage import get_storage
|
||||
from services.suite_service import append_generated_asset
|
||||
from services.tasks import TaskImage
|
||||
from services.watermark import apply_watermark
|
||||
from services.video_renderer import VideoRenderCancelled, render_slideshow
|
||||
|
||||
log = logging.getLogger("background.worker")
|
||||
|
||||
@@ -113,11 +114,52 @@ async def _process_watermark_item(claim: ClaimedItem) -> None:
|
||||
await mark_item_failed(claim.item_id, str(exc))
|
||||
|
||||
|
||||
async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
|
||||
"""一个视频任务只有一个子项;输入 URL 在提交时冻结,避免后续选择状态影响成片。"""
|
||||
payload = claim.request_payload
|
||||
sources = list(payload.get("sources") or [])
|
||||
source_urls = [str(source.get("url") or "") for source in sources]
|
||||
if not 2 <= len(source_urls) <= 5 or any(not url for url in source_urls):
|
||||
await mark_item_failed(claim.item_id, "视频任务缺少 2–5 张可用图片")
|
||||
return
|
||||
try:
|
||||
async def should_cancel() -> bool:
|
||||
return not await job_can_write(claim.job_id)
|
||||
|
||||
rendered = await render_slideshow(source_urls, should_cancel=should_cancel)
|
||||
if not await job_can_write(claim.job_id):
|
||||
await mark_item_cancelled(claim.item_id)
|
||||
return
|
||||
key = f"products/{claim.product_id}/videos/{claim.job_id}/result.mp4"
|
||||
result_url = await get_storage().save_bytes(rendered.data, key, "video/mp4")
|
||||
async with get_session_factory()() as db:
|
||||
from models import BackgroundJobItem
|
||||
item = await db.get(BackgroundJobItem, uuid.UUID(claim.item_id))
|
||||
if item is not None:
|
||||
item.payload = {
|
||||
**dict(item.payload),
|
||||
"duration_seconds": rendered.duration_seconds,
|
||||
"width": rendered.width,
|
||||
"height": rendered.height,
|
||||
"file_size_bytes": len(rendered.data),
|
||||
}
|
||||
await db.commit()
|
||||
await mark_item_done(claim.item_id, result_url=result_url, source_url=None, watermark_config=None, generated_asset_id=None)
|
||||
except VideoRenderCancelled:
|
||||
await mark_item_cancelled(claim.item_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("后台轮播视频任务 %s/%s 执行失败", claim.job_id, claim.item_id)
|
||||
await mark_item_failed(claim.item_id, str(exc))
|
||||
|
||||
|
||||
async def process_item(claim: ClaimedItem) -> None:
|
||||
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
|
||||
if claim.job_kind == "watermark_batch":
|
||||
await _process_watermark_item(claim)
|
||||
return
|
||||
if claim.job_kind == "slideshow_video":
|
||||
await _process_slideshow_video_item(claim)
|
||||
return
|
||||
if claim.job_kind not in {"image_suite", "image_edit"}:
|
||||
await mark_item_failed(claim.item_id, f"Worker 暂不支持任务类型: {claim.job_kind}")
|
||||
return
|
||||
@@ -201,7 +243,7 @@ async def run_worker(*, once: bool = False, idle_seconds: float = 0.75) -> None:
|
||||
log.warning("恢复了 %d 个过期后台任务租约", recovered)
|
||||
log.info("后台 Worker 已启动:%s", owner)
|
||||
while True:
|
||||
claim = await claim_next_item(owner)
|
||||
claim = await claim_next_item(owner, lease_seconds=get_settings().background_job_lease_seconds)
|
||||
if claim is None:
|
||||
if once:
|
||||
return
|
||||
|
||||
@@ -310,6 +310,21 @@ async def cancel_job(job_id: str) -> BackgroundJob | None:
|
||||
return job
|
||||
|
||||
|
||||
async def delete_job(job_id: str) -> bool:
|
||||
"""硬删除一个后台任务及其子项;调用方须先处理对应的存储对象。"""
|
||||
try:
|
||||
parsed = uuid.UUID(job_id)
|
||||
except ValueError:
|
||||
return False
|
||||
async with get_session_factory()() as db:
|
||||
job = await db.get(BackgroundJob, parsed)
|
||||
if job is None:
|
||||
return False
|
||||
await db.delete(job)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def cancel_product_jobs(product_id: str) -> int:
|
||||
"""硬删除商品前取消其未完成任务,Worker 随后只会丢弃结果而不会回写素材。"""
|
||||
try:
|
||||
@@ -468,3 +483,25 @@ def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str,
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def video_job_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str, Any]:
|
||||
"""轮播视频的轻量任务视图;仍复用 BackgroundJob 的恢复/取消/重试状态机。"""
|
||||
payload = dict(job.request_payload or {})
|
||||
item = items[0] if items else None
|
||||
item_payload = dict(item.payload or {}) if item else {}
|
||||
return {
|
||||
"id": str(job.id), "kind": job.kind, "status": job.status,
|
||||
"total": job.total, "completed": job.completed, "failed": job.failed,
|
||||
"cancel_requested": job.cancel_requested, "preset": payload.get("preset", "wb_ozon"),
|
||||
"source_asset_ids": [str(source.get("asset_id")) for source in payload.get("sources", [])],
|
||||
"slide_seconds": payload.get("slide_seconds", 0),
|
||||
"duration_seconds": item_payload.get("duration_seconds"),
|
||||
"width": item_payload.get("width", 810), "height": item_payload.get("height", 1080),
|
||||
"file_size_bytes": item_payload.get("file_size_bytes"),
|
||||
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
|
||||
"result": None if item is None else {
|
||||
"item_id": str(item.id), "status": item.status, "url": item.result_url, "error": item.error,
|
||||
},
|
||||
"error": job.error,
|
||||
}
|
||||
|
||||
+220
-24
@@ -1,12 +1,20 @@
|
||||
"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import ipaddress
|
||||
import mimetypes
|
||||
import socket
|
||||
import shutil
|
||||
import uuid
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from config import get_settings
|
||||
|
||||
@@ -26,6 +34,157 @@ class ResourceSizeLimitError(ValueError):
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
class UnsafeRemoteUrlError(ValueError):
|
||||
"""远程资源 URL 指向回环、内网或其他保留网络,不能由服务端代请求。"""
|
||||
|
||||
|
||||
class ResourceDownloadTimeoutError(TimeoutError):
|
||||
"""远程资源下载超过连接、读取或总耗时限制。"""
|
||||
|
||||
|
||||
class ImageValidationError(ValueError):
|
||||
"""图片字节无法解码,或不符合格式与像素限制。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizedImage:
|
||||
"""经真实格式校验后的图片字节及可落库元数据。"""
|
||||
|
||||
data: bytes
|
||||
content_type: str
|
||||
width: int
|
||||
height: int
|
||||
image_format: str
|
||||
orientation_normalized: bool
|
||||
|
||||
|
||||
_IMAGE_CONTENT_TYPES = {
|
||||
"JPEG": "image/jpeg",
|
||||
"PNG": "image/png",
|
||||
"WEBP": "image/webp",
|
||||
"GIF": "image/gif",
|
||||
"BMP": "image/bmp",
|
||||
}
|
||||
|
||||
|
||||
def normalize_image_bytes(
|
||||
data: bytes,
|
||||
max_bytes: int | None = None,
|
||||
max_pixels: int | None = None,
|
||||
) -> NormalizedImage:
|
||||
"""按真实图片内容校验、纠正 EXIF 方向,并返回可信 MIME 与宽高。
|
||||
|
||||
不盲从 URL 后缀或客户端 Content-Type。为避免无意义的质量损耗,仅当 EXIF
|
||||
方向不是默认值时才重编码;动画图不重编码,以免丢失帧。
|
||||
"""
|
||||
settings = get_settings()
|
||||
byte_limit = max_bytes if max_bytes is not None else settings.image_upload_max_bytes
|
||||
pixel_limit = max_pixels if max_pixels is not None else settings.image_max_pixels
|
||||
if byte_limit <= 0 or pixel_limit <= 0:
|
||||
raise ValueError("图片大小和像素上限必须大于 0")
|
||||
if not data:
|
||||
raise ImageValidationError("图片内容为空")
|
||||
if len(data) > byte_limit:
|
||||
raise ResourceSizeLimitError(byte_limit, len(data))
|
||||
|
||||
try:
|
||||
# Pillow 的默认 bomb 保护不能替代项目自定义像素上限;把 warning 升级为错误。
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
with Image.open(io.BytesIO(data)) as image:
|
||||
image_format = (image.format or "").upper()
|
||||
if image_format not in _IMAGE_CONTENT_TYPES:
|
||||
raise ImageValidationError(f"不支持的图片格式:{image_format or '未知'}")
|
||||
|
||||
width, height = image.size
|
||||
if width <= 0 or height <= 0 or width * height > pixel_limit:
|
||||
raise ImageValidationError(
|
||||
f"图片像素超过上限(最多 {pixel_limit:,},当前 {width:,} × {height:,})"
|
||||
)
|
||||
|
||||
# 强制实际解码,拒绝截断/伪造图片而不是只相信文件头。
|
||||
image.load()
|
||||
frame_count = getattr(image, "n_frames", 1)
|
||||
orientation = image.getexif().get(274, 1) if frame_count == 1 else 1
|
||||
normalized = orientation not in (None, 1)
|
||||
output = data
|
||||
if normalized:
|
||||
corrected = ImageOps.exif_transpose(image)
|
||||
width, height = corrected.size
|
||||
if image_format == "JPEG" and corrected.mode not in {"RGB", "L"}:
|
||||
corrected = corrected.convert("RGB")
|
||||
buffer = io.BytesIO()
|
||||
corrected.save(buffer, format=image_format)
|
||||
output = buffer.getvalue()
|
||||
except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc:
|
||||
raise ImageValidationError("文件不是可用的图片,或图片数据已损坏") from exc
|
||||
except Image.DecompressionBombWarning as exc:
|
||||
raise ImageValidationError("图片像素过大,已拒绝解码") from exc
|
||||
|
||||
if len(output) > byte_limit:
|
||||
raise ResourceSizeLimitError(byte_limit, len(output))
|
||||
return NormalizedImage(
|
||||
data=output,
|
||||
content_type=_IMAGE_CONTENT_TYPES[image_format],
|
||||
width=width,
|
||||
height=height,
|
||||
image_format=image_format,
|
||||
orientation_normalized=normalized,
|
||||
)
|
||||
|
||||
|
||||
def _is_public_ip(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""仅允许可公开路由的地址;IPv4-mapped IPv6 需按其 IPv4 实际地址判断。"""
|
||||
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
||||
return _is_public_ip(address.ipv4_mapped)
|
||||
return address.is_global
|
||||
|
||||
|
||||
async def validate_remote_url(url: str) -> None:
|
||||
"""校验服务端允许代请求的公网 HTTP(S) URL。
|
||||
|
||||
该校验用于代理、采集转存、AI 参考图和导出。项目自己的 `/media/` 文件
|
||||
在调用方会先直读磁盘;其他回环或私网 URL 一律不能通过网络访问。
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise UnsafeRemoteUrlError("图片地址端口无效") from exc
|
||||
|
||||
if parsed.scheme.lower() not in {"http", "https"}:
|
||||
raise UnsafeRemoteUrlError("仅允许 http/https 图片地址")
|
||||
if not parsed.hostname:
|
||||
raise UnsafeRemoteUrlError("图片地址缺少主机名")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise UnsafeRemoteUrlError("图片地址不能包含用户名或密码")
|
||||
|
||||
host = parsed.hostname.rstrip(".")
|
||||
if host.lower() in {"localhost", "localhost.localdomain"}:
|
||||
raise UnsafeRemoteUrlError("不允许请求 localhost 或本机网络地址")
|
||||
|
||||
try:
|
||||
addresses = {ipaddress.ip_address(host)}
|
||||
except ValueError:
|
||||
try:
|
||||
infos = await asyncio.get_running_loop().getaddrinfo(
|
||||
host,
|
||||
port or (443 if parsed.scheme.lower() == "https" else 80),
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise UnsafeRemoteUrlError("图片地址域名无法解析") from exc
|
||||
addresses = {ipaddress.ip_address(info[4][0]) for info in infos}
|
||||
|
||||
if not addresses:
|
||||
raise UnsafeRemoteUrlError("图片地址域名未解析到有效地址")
|
||||
private_addresses = [str(address) for address in addresses if not _is_public_ip(address)]
|
||||
if private_addresses:
|
||||
raise UnsafeRemoteUrlError(
|
||||
f"不允许请求非公网地址({', '.join(sorted(private_addresses))})"
|
||||
)
|
||||
|
||||
|
||||
def _ext_from_content_type(content_type: str) -> str:
|
||||
ctype = (content_type or "").split(";")[0].strip().lower()
|
||||
mapping = {
|
||||
@@ -48,41 +207,78 @@ def image_extension(data: bytes, content_type: str = "") -> str:
|
||||
return ".webp"
|
||||
if data[:3] == b"GIF":
|
||||
return ".gif"
|
||||
if data[:2] == b"\xff\xd8":
|
||||
return ".jpg"
|
||||
if data[:2] == b"BM":
|
||||
return ".bmp"
|
||||
return _ext_from_content_type(content_type)
|
||||
|
||||
|
||||
async def download_bytes(
|
||||
url: str,
|
||||
referer: str | None = None,
|
||||
timeout: float = 60.0,
|
||||
timeout: float | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> tuple[bytes, str]:
|
||||
"""流式下载远程图片并限制体积。返回 (bytes, content_type)。"""
|
||||
limit = max_bytes or get_settings().image_download_max_bytes
|
||||
"""安全地流式下载公网资源,并限制地址、重定向、体积和耗时。"""
|
||||
settings = get_settings()
|
||||
limit = max_bytes or settings.image_download_max_bytes
|
||||
if limit <= 0:
|
||||
raise ValueError("图片下载大小上限必须大于 0")
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
length = resp.headers.get("content-length")
|
||||
if length:
|
||||
try:
|
||||
declared_size = int(length)
|
||||
except ValueError:
|
||||
declared_size = None
|
||||
if declared_size is not None and declared_size > limit:
|
||||
raise ResourceSizeLimitError(limit, declared_size)
|
||||
total_timeout = timeout if timeout is not None else settings.image_download_total_timeout
|
||||
if total_timeout <= 0:
|
||||
raise ValueError("图片下载总超时必须大于 0")
|
||||
if settings.image_download_connect_timeout <= 0 or settings.image_download_read_timeout <= 0:
|
||||
raise ValueError("图片下载连接和读取超时必须大于 0")
|
||||
if settings.image_download_max_redirects < 0:
|
||||
raise ValueError("图片下载重定向上限不能小于 0")
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.aiter_bytes():
|
||||
total += len(chunk)
|
||||
if total > limit:
|
||||
raise ResourceSizeLimitError(limit, total)
|
||||
chunks.append(chunk)
|
||||
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
|
||||
return b"".join(chunks), ctype
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
http_timeout = httpx.Timeout(
|
||||
connect=settings.image_download_connect_timeout,
|
||||
read=settings.image_download_read_timeout,
|
||||
write=settings.image_download_read_timeout,
|
||||
pool=settings.image_download_connect_timeout,
|
||||
)
|
||||
current_url = url
|
||||
try:
|
||||
async with asyncio.timeout(total_timeout):
|
||||
async with httpx.AsyncClient(timeout=http_timeout, follow_redirects=False) as client:
|
||||
for redirect_count in range(settings.image_download_max_redirects + 1):
|
||||
await validate_remote_url(current_url)
|
||||
async with client.stream("GET", current_url, headers=headers) as resp:
|
||||
if resp.is_redirect:
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
raise httpx.RemoteProtocolError("重定向响应缺少 Location")
|
||||
if redirect_count >= settings.image_download_max_redirects:
|
||||
raise httpx.TooManyRedirects("图片重定向次数超过上限")
|
||||
current_url = urljoin(str(resp.url), location)
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
length = resp.headers.get("content-length")
|
||||
if length:
|
||||
try:
|
||||
declared_size = int(length)
|
||||
except ValueError:
|
||||
declared_size = None
|
||||
if declared_size is not None and declared_size > limit:
|
||||
raise ResourceSizeLimitError(limit, declared_size)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.aiter_bytes():
|
||||
total += len(chunk)
|
||||
if total > limit:
|
||||
raise ResourceSizeLimitError(limit, total)
|
||||
chunks.append(chunk)
|
||||
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
|
||||
return b"".join(chunks), ctype
|
||||
except (TimeoutError, httpx.TimeoutException) as exc:
|
||||
raise ResourceDownloadTimeoutError(f"图片下载超时(最多 {total_timeout:g} 秒)") from exc
|
||||
|
||||
raise RuntimeError("图片下载未返回有效响应")
|
||||
|
||||
|
||||
def read_local_bytes(path: Path, max_bytes: int | None = None) -> bytes:
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""用 FFmpeg 合成平台兼容的无音轨商品轮播视频。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from config import get_settings
|
||||
from services.storage import (
|
||||
ResourceSizeLimitError,
|
||||
download_bytes,
|
||||
image_extension,
|
||||
local_path,
|
||||
normalize_image_bytes,
|
||||
read_local_bytes,
|
||||
)
|
||||
|
||||
|
||||
class VideoRenderError(RuntimeError):
|
||||
"""FFmpeg 无法生成符合预设的商品视频。"""
|
||||
|
||||
|
||||
class VideoRenderCancelled(VideoRenderError):
|
||||
"""任务在 FFmpeg 运行期间被用户取消。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderedVideo:
|
||||
data: bytes
|
||||
duration_seconds: float
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
def slide_seconds_for(count: int) -> float:
|
||||
"""按图片数量维持约 9–13 秒成片,并确保两图时也不会过短。"""
|
||||
by_count = {2: 4.5, 3: 3.5, 4: 3.0, 5: 2.8}
|
||||
try:
|
||||
return by_count[count]
|
||||
except KeyError as exc:
|
||||
raise VideoRenderError("轮播视频只支持 2–5 张图片") from exc
|
||||
|
||||
|
||||
async def _read_image(url: str) -> tuple[bytes, str]:
|
||||
path = local_path(url)
|
||||
if path is not None:
|
||||
return read_local_bytes(path), ""
|
||||
return await download_bytes(url, max_bytes=get_settings().image_download_max_bytes)
|
||||
|
||||
|
||||
async def _run_process(
|
||||
args: list[str],
|
||||
*,
|
||||
should_cancel: Callable[[], Awaitable[bool]],
|
||||
timeout_seconds: int,
|
||||
) -> None:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
waited = 0.0
|
||||
while process.returncode is None:
|
||||
if await should_cancel():
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise VideoRenderCancelled("视频任务已取消")
|
||||
await asyncio.sleep(0.25)
|
||||
waited += 0.25
|
||||
if waited > timeout_seconds:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise VideoRenderError(f"视频渲染超过 {timeout_seconds} 秒,请减少图片后重试")
|
||||
stderr = (await process.stderr.read()).decode("utf-8", errors="replace") if process.stderr else ""
|
||||
if process.returncode != 0:
|
||||
raise VideoRenderError(f"FFmpeg 渲染失败:{stderr[-800:] or '未知错误'}")
|
||||
finally:
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
|
||||
async def _probe(path: Path) -> dict:
|
||||
settings = get_settings()
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
settings.video_ffprobe_path,
|
||||
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
|
||||
"-of", "json", str(path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
|
||||
try:
|
||||
return json.loads(stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise VideoRenderError("FFprobe 未返回可解析的视频信息") from exc
|
||||
|
||||
|
||||
async def render_slideshow(
|
||||
source_urls: list[str],
|
||||
*,
|
||||
should_cancel: Callable[[], Awaitable[bool]],
|
||||
) -> RenderedVideo:
|
||||
"""将已入库图片缩放留白后串为 MP4,严禁接收调用方任意命令参数。"""
|
||||
settings = get_settings()
|
||||
if not settings.video_ffmpeg_path or not settings.video_ffprobe_path:
|
||||
raise VideoRenderError("未配置 FFmpeg 或 FFprobe 可执行路径")
|
||||
count = len(source_urls)
|
||||
seconds = slide_seconds_for(count)
|
||||
transition = settings.video_transition_seconds
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="osk-video-") as temp_dir:
|
||||
workdir = Path(temp_dir)
|
||||
inputs: list[Path] = []
|
||||
for index, url in enumerate(source_urls):
|
||||
if await should_cancel():
|
||||
raise VideoRenderCancelled("视频任务已取消")
|
||||
data, content_type = await _read_image(url)
|
||||
normalized = normalize_image_bytes(data, max_bytes=settings.image_download_max_bytes)
|
||||
suffix = image_extension(normalized.data, content_type or normalized.content_type)
|
||||
image_path = workdir / f"input-{index}{suffix}"
|
||||
image_path.write_bytes(normalized.data)
|
||||
inputs.append(image_path)
|
||||
|
||||
output_path = workdir / "result.mp4"
|
||||
args = [settings.video_ffmpeg_path, "-y", "-hide_banner", "-loglevel", "error"]
|
||||
for image_path in inputs:
|
||||
args.extend(["-loop", "1", "-framerate", str(settings.video_fps), "-t", str(seconds), "-i", str(image_path)])
|
||||
|
||||
filters: list[str] = []
|
||||
for index in range(count):
|
||||
filters.append(
|
||||
f"[{index}:v]scale={settings.video_width}:{settings.video_height}:"
|
||||
"force_original_aspect_ratio=decrease,"
|
||||
f"pad={settings.video_width}:{settings.video_height}:(ow-iw)/2:(oh-ih):color=white,"
|
||||
"setsar=1,format=yuv420p"
|
||||
f"[slide{index}]"
|
||||
)
|
||||
previous = "slide0"
|
||||
accumulated = seconds
|
||||
for index in range(1, count):
|
||||
output = f"fade{index}"
|
||||
filters.append(
|
||||
f"[{previous}][slide{index}]xfade=transition=fade:duration={transition}:"
|
||||
f"offset={accumulated - transition:.3f}[{output}]"
|
||||
)
|
||||
previous = output
|
||||
accumulated += seconds - transition
|
||||
filters.append(f"[{previous}]fps={settings.video_fps},format=yuv420p[video]")
|
||||
args.extend([
|
||||
"-filter_complex", ";".join(filters), "-map", "[video]",
|
||||
"-an", "-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
|
||||
"-preset", "medium", "-crf", str(settings.video_crf),
|
||||
"-maxrate", settings.video_max_rate, "-bufsize", settings.video_buffer_size,
|
||||
"-movflags", "+faststart", str(output_path),
|
||||
])
|
||||
await _run_process(args, should_cancel=should_cancel, timeout_seconds=settings.video_render_timeout_seconds)
|
||||
if not output_path.is_file():
|
||||
raise VideoRenderError("FFmpeg 未生成视频文件")
|
||||
file_size = output_path.stat().st_size
|
||||
if file_size > settings.video_max_bytes:
|
||||
raise ResourceSizeLimitError(settings.video_max_bytes, file_size)
|
||||
probe = await _probe(output_path)
|
||||
stream = next((item for item in probe.get("streams", []) if item.get("codec_name") == "h264"), None)
|
||||
if stream is None:
|
||||
raise VideoRenderError("输出视频不是 H.264 编码")
|
||||
width, height = int(stream.get("width", 0)), int(stream.get("height", 0))
|
||||
duration = float((probe.get("format") or {}).get("duration", 0))
|
||||
if (width, height) != (settings.video_width, settings.video_height):
|
||||
raise VideoRenderError(f"输出分辨率异常:{width} × {height}")
|
||||
if not 0 < duration <= settings.video_max_duration_seconds:
|
||||
raise VideoRenderError(f"输出视频时长异常:{duration:.1f} 秒")
|
||||
data = output_path.read_bytes()
|
||||
if len(data) != file_size:
|
||||
raise VideoRenderError("读取视频输出失败")
|
||||
return RenderedVideo(data=data, duration_seconds=duration, width=width, height=height)
|
||||
Reference in New Issue
Block a user