mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-31 23:09:58 +08:00
add # 新增项目整体大纲
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""可选共享图片层;核心模块无需导入本包。"""
|
||||
|
||||
from .models import RenderOptions, RenderResult
|
||||
from .renderer import FarmRenderer
|
||||
from .service import RenderService
|
||||
|
||||
__all__ = ["FarmRenderer", "RenderOptions", "RenderResult", "RenderService"]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""包内素材与有界 LRU;不下载、不解压到磁盘、不暴露共享图片。"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from importlib import resources
|
||||
from io import BytesIO
|
||||
import json
|
||||
from threading import RLock
|
||||
from zipfile import ZipFile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class ByteLRU:
|
||||
def __init__(self, limit):
|
||||
if type(limit) is not int or limit < 0:
|
||||
raise ValueError("Invalid cache budget")
|
||||
self.limit = limit
|
||||
self.used_bytes = 0
|
||||
self._items = OrderedDict()
|
||||
self._lock = RLock()
|
||||
|
||||
def get(self, key):
|
||||
with self._lock:
|
||||
item = self._items.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
self._items.move_to_end(key)
|
||||
return item[0]
|
||||
|
||||
def put(self, key, value, size):
|
||||
with self._lock:
|
||||
old = self._items.pop(key, None)
|
||||
if old is not None:
|
||||
self.used_bytes -= old[1]
|
||||
if size > self.limit or self.limit == 0:
|
||||
return
|
||||
while self._items and self.used_bytes + size > self.limit:
|
||||
_, (_, removed) = self._items.popitem(last=False)
|
||||
self.used_bytes -= removed
|
||||
self._items[key] = (value, size)
|
||||
self.used_bytes += size
|
||||
|
||||
|
||||
class Assets:
|
||||
def __init__(self, cache_bytes=32 * 1024 * 1024):
|
||||
data = resources.files(__package__).joinpath("data")
|
||||
self.metadata = json.loads(data.joinpath("metadata.json").read_bytes())
|
||||
self.cache = ByteLRU(cache_bytes)
|
||||
self._bundle = data.joinpath("assets.zip")
|
||||
|
||||
def image(self, name, size=None):
|
||||
if name not in self.metadata["images"]:
|
||||
raise KeyError("Unknown packaged image")
|
||||
size = tuple(size or self.metadata["images"][name]["size"])
|
||||
key = (name, size)
|
||||
cached = self.cache.get(key)
|
||||
if cached is None:
|
||||
with self._bundle.open("rb") as stream, ZipFile(stream) as bundle:
|
||||
content = bundle.read(name)
|
||||
with Image.open(BytesIO(content)) as original:
|
||||
original.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
cached = original.convert("RGBA")
|
||||
if cached.size != size:
|
||||
cached = cached.resize(size, Image.Resampling.LANCZOS)
|
||||
self.cache.put(key, cached, size[0] * size[1] * 4)
|
||||
return cached.copy()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""同步 Pillow 图片原语;调用方负责整次绘制的异步调度。"""
|
||||
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def font(path, size):
|
||||
return ImageFont.truetype(str(path), size=size)
|
||||
|
||||
|
||||
class Canvas:
|
||||
def __init__(self, image):
|
||||
self.image = image.convert("RGBA")
|
||||
|
||||
@classmethod
|
||||
def new(cls, size, color=(0, 0, 0, 0)):
|
||||
return cls(Image.new("RGBA", size, color))
|
||||
|
||||
def copy(self):
|
||||
return Canvas(self.image.copy())
|
||||
|
||||
def resize(self, size):
|
||||
return Canvas(self.image.resize(size, Image.Resampling.LANCZOS))
|
||||
|
||||
def crop(self, box):
|
||||
return Canvas(self.image.crop(box))
|
||||
|
||||
def paste(self, image, position):
|
||||
source = image.image if isinstance(image, Canvas) else image
|
||||
self.image.alpha_composite(source.convert("RGBA"), position)
|
||||
|
||||
@staticmethod
|
||||
def measure_text(text, font_path, size):
|
||||
return font(font_path, size).getlength(text)
|
||||
|
||||
@classmethod
|
||||
def fit_text(cls, text, font_path, size, width):
|
||||
text = " ".join(str(text).split())[:256]
|
||||
if cls.measure_text(text, font_path, size) <= width:
|
||||
return text
|
||||
suffix = "…"
|
||||
while text and cls.measure_text(
|
||||
text + suffix, font_path, size,
|
||||
) > width:
|
||||
text = text[:-1]
|
||||
return text + suffix if cls.measure_text(
|
||||
suffix, font_path, size,
|
||||
) <= width else ""
|
||||
|
||||
def text(self, position, text, font_path, size, color):
|
||||
ImageDraw.Draw(self.image).text(
|
||||
position, text, font=font(font_path, size), fill=color,
|
||||
)
|
||||
|
||||
def rectangle(self, box, color, radius=0):
|
||||
ImageDraw.Draw(self.image).rounded_rectangle(
|
||||
box, radius=radius, fill=color,
|
||||
)
|
||||
|
||||
def encode(self, codec, quality=75, method=2):
|
||||
output = BytesIO()
|
||||
if codec == "JPEG":
|
||||
flattened = Image.new("RGB", self.image.size, "white")
|
||||
flattened.paste(self.image, mask=self.image.getchannel("A"))
|
||||
flattened.save(output, "JPEG", quality=quality)
|
||||
elif codec == "WEBP":
|
||||
self.image.save(
|
||||
output, "WEBP", quality=quality, method=method,
|
||||
)
|
||||
elif codec == "PNG":
|
||||
self.image.save(output, "PNG", compress_level=3)
|
||||
else:
|
||||
raise ValueError("Unsupported output codec")
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1,8 @@
|
||||
# 绘图素材来源
|
||||
|
||||
assets.zip 原样复制自 Shu-Ying/zhenxun_plugin_farm 的本地保留基线,
|
||||
对应源提交与完整校验见主仓库 docs/legacy-manifest.json。
|
||||
仅选取农场季节背景、草地/普通地、作物及两个状态图标;metadata.json
|
||||
来自旧布局和作物配置。沿用仓库 GPLv3 许可与原始素材归属,迁移不
|
||||
增加第三方素材权利。后续对外发布前仍须核实历史素材来源及分发权限。
|
||||
不包含字体;部署方配置可合法使用的中文字体路径。
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
"""从一次快照与统一时间构造可缓存场景;不访问存储。"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from farm_core.domain import Identity, Snapshot, integer
|
||||
|
||||
from .models import PlotView, Scene
|
||||
|
||||
|
||||
def scene_from_snapshot(identity, snapshot, now, metadata):
|
||||
if not isinstance(identity, Identity) or not isinstance(snapshot, Snapshot):
|
||||
raise ValueError("Identity and snapshot are required")
|
||||
integer(now)
|
||||
slots = [p.slot for p in snapshot.plots]
|
||||
if len(set(slots)) != len(slots) or any(
|
||||
type(slot) is not int or not 1 <= slot <= 30 for slot in slots
|
||||
):
|
||||
raise ValueError("Invalid plot slots")
|
||||
views = []
|
||||
for plot in sorted(snapshot.plots, key=lambda p: p.slot):
|
||||
if plot.plant is None:
|
||||
views.append(PlotView(plot.slot, "empty"))
|
||||
continue
|
||||
if plot.withered:
|
||||
views.append(PlotView(
|
||||
plot.slot, "withered", "plant/basic/9.png", size=(150, 212),
|
||||
))
|
||||
continue
|
||||
integer(plot.mature_at)
|
||||
name = plot.plant.name
|
||||
info = metadata["plants"].get(name)
|
||||
if info is None:
|
||||
views.append(PlotView(plot.slot, "growing", "missing"))
|
||||
continue
|
||||
phases = tuple(dict.fromkeys(int(p) for p in info["phase"].split(",")))
|
||||
duration = (plot.plant.growth_seconds if plot.harvest_count == 0
|
||||
else plot.plant.regrowth_seconds)
|
||||
elapsed = max(0, now - (plot.mature_at - duration))
|
||||
ripe = now >= plot.mature_at
|
||||
stage = len(phases) if ripe else sum(
|
||||
elapsed * phases[-1] >= threshold * duration
|
||||
for threshold in phases
|
||||
)
|
||||
sprite = f"plant/{name}/{stage}.png"
|
||||
size = None
|
||||
if stage == 0:
|
||||
if info["general"]:
|
||||
sprite = "plant/basic/0.png"
|
||||
size = (max(1, 35 + info["offsetW"]),
|
||||
max(1, 58 + info["offsetH"]))
|
||||
views.append(PlotView(
|
||||
plot.slot, "ripe" if ripe else "growing", sprite, stage,
|
||||
(info["offsetX"], info["offsetY"]), size,
|
||||
))
|
||||
return Scene(
|
||||
hashlib.sha256(identity.key.encode()).hexdigest(),
|
||||
snapshot.account, tuple(views),
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""框架无关、不可变的渲染输入与输出。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import features
|
||||
|
||||
from farm_core.domain import Account
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderOptions:
|
||||
width: int = 1024 # 目标宽度;从此尺寸开始合成。
|
||||
format: str = "AUTO" # AUTO/WEBP/JPEG/PNG。
|
||||
quality: int = 75 # 有损编码质量,不代表分辨率。
|
||||
method: int = 2 # WebP 编码力度,0最快,6最慢。
|
||||
theme: str = "default" # 显式主题,不读取隐含季节或全局配置。
|
||||
|
||||
def __post_init__(self):
|
||||
for value, low, high in (
|
||||
(self.width, 256, 3072), (self.quality, 1, 95),
|
||||
(self.method, 0, 6),
|
||||
):
|
||||
if type(value) is not int or not low <= value <= high:
|
||||
raise ValueError("Invalid render option")
|
||||
if self.format not in ("AUTO", "WEBP", "JPEG", "PNG"):
|
||||
raise ValueError("Unsupported output format")
|
||||
if self.theme not in ("default", "spring", "summer", "autumn",
|
||||
"winter"):
|
||||
raise ValueError("Unsupported theme")
|
||||
|
||||
@classmethod
|
||||
def high(cls, **kwargs):
|
||||
return cls(width=1536, quality=85, **kwargs)
|
||||
|
||||
def codec(self, supported=("JPEG",)):
|
||||
if self.format == "AUTO":
|
||||
if "WEBP" in supported and features.check("webp"):
|
||||
return "WEBP"
|
||||
if "JPEG" in supported:
|
||||
return "JPEG"
|
||||
if "PNG" in supported:
|
||||
return "PNG"
|
||||
raise ValueError("No supported image codec")
|
||||
if self.format == "WEBP" and not features.check("webp"):
|
||||
raise ValueError("Pillow WebP support is unavailable")
|
||||
return self.format
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlotView:
|
||||
slot: int # 旧布局的1到30地块编号。
|
||||
state: str # empty/growing/ripe/withered。
|
||||
sprite: str = "" # 包内受控素材名,不来自用户路径。
|
||||
stage: int = 0 # 本季展示阶段。
|
||||
offset: tuple[int, int] = (0, 0) # 旧素材中心偏移。
|
||||
size: tuple[int, int] | None = None # 萌芽/枯萎原始尺寸覆盖。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scene:
|
||||
scope: str # 完整身份的摘要,只用于缓存隔离。
|
||||
account: Account # 一致快照中的可见账户。
|
||||
plots: tuple[PlotView, ...] # 已解锁地块及统一时刻的阶段。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderResult:
|
||||
content: bytes # 已编码图片,不携带用户对象。
|
||||
mime: str # 与实际编码一致的媒体类型。
|
||||
width: int # 实际宽度。
|
||||
height: int # 实际高度。
|
||||
compose_ms: float # 本次合成毫秒。
|
||||
encode_ms: float # 本次编码毫秒。
|
||||
cache_hit: bool = False # 成图缓存命中。
|
||||
@@ -0,0 +1,204 @@
|
||||
"""纯快照农场合成:目标尺寸、独立素材、可见状态成图缓存。"""
|
||||
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
|
||||
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||
|
||||
from .assets import Assets, ByteLRU
|
||||
from .canvas import Canvas, font
|
||||
from .layout import scene_from_snapshot
|
||||
from .models import RenderOptions, RenderResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
MAX_AVATAR_PIXELS = 4_000_000
|
||||
|
||||
|
||||
class FarmRenderer:
|
||||
def __init__(
|
||||
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
|
||||
result_cache_bytes=8 * 1024 * 1024,
|
||||
):
|
||||
self.font_path = Path(font_path).resolve()
|
||||
if not self.font_path.is_file():
|
||||
raise ValueError("Configure an existing Chinese font path")
|
||||
font(self.font_path, 16)
|
||||
self.assets = Assets(asset_cache_bytes)
|
||||
self.results = ByteLRU(result_cache_bytes)
|
||||
|
||||
def scene(self, identity, snapshot, now):
|
||||
return scene_from_snapshot(
|
||||
identity, snapshot, now, self.assets.metadata,
|
||||
)
|
||||
|
||||
def request_key(
|
||||
self, identity, snapshot, now, options=None, avatar=None,
|
||||
supported=("JPEG",),
|
||||
):
|
||||
options = options or RenderOptions()
|
||||
if avatar is not None and not isinstance(avatar, bytes):
|
||||
raise ValueError("Avatar must be bytes or None")
|
||||
digest = hashlib.sha256(avatar).hexdigest() if (
|
||||
avatar and len(avatar) <= MAX_AVATAR_BYTES
|
||||
) else "default"
|
||||
scene = self.scene(identity, snapshot, now)
|
||||
return scene, options, options.codec(supported), digest
|
||||
|
||||
def render(
|
||||
self, identity, snapshot, now, options=None, avatar=None,
|
||||
supported=("JPEG",),
|
||||
):
|
||||
key = self.request_key(
|
||||
identity, snapshot, now, options, avatar, supported,
|
||||
)
|
||||
cached = self.results.get(key)
|
||||
if cached is not None:
|
||||
logger.info("farm render cache=hit bytes=%s", len(cached.content))
|
||||
return replace(cached, cache_hit=True, compose_ms=0, encode_ms=0)
|
||||
scene, options, codec, _ = key
|
||||
started = perf_counter()
|
||||
canvas = self._base(scene, options)
|
||||
self._plants(canvas, scene, options)
|
||||
self._panel(canvas, scene, options, avatar)
|
||||
composed = perf_counter()
|
||||
content = canvas.encode(codec, options.quality, options.method)
|
||||
encoded = perf_counter()
|
||||
result = RenderResult(
|
||||
content, {"JPEG":"image/jpeg", "PNG":"image/png",
|
||||
"WEBP":"image/webp"}[codec],
|
||||
*canvas.image.size, (composed - started) * 1000,
|
||||
(encoded - composed) * 1000,
|
||||
)
|
||||
self.results.put(key, result, len(content) + 4096)
|
||||
logger.info(
|
||||
"farm render cache=miss compose_ms=%.2f encode_ms=%.2f "
|
||||
"width=%s height=%s bytes=%s codec=%s",
|
||||
result.compose_ms, result.encode_ms, result.width,
|
||||
result.height, len(content), codec,
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _scale(value, options):
|
||||
return round(value * options.width / 3072)
|
||||
|
||||
def _base(self, scene, options):
|
||||
slots = frozenset(p.slot for p in scene.plots)
|
||||
key = ("base", options.theme, options.width, slots)
|
||||
cached = self.assets.cache.get(key)
|
||||
if cached is not None:
|
||||
return Canvas(cached.copy())
|
||||
s = lambda n: self._scale(n, options)
|
||||
size = (options.width, s(1872))
|
||||
background = self.assets.image(
|
||||
"background/decorateIcon/"
|
||||
f"decorate_{options.theme}_1.jpg", size,
|
||||
)
|
||||
canvas = Canvas(background)
|
||||
soil_size = tuple(max(1, s(n)) for n in (
|
||||
self.assets.metadata["soil_size"]
|
||||
))
|
||||
tiles = {
|
||||
True: self.assets.image("soil/普通土地.png", soil_size),
|
||||
False: self.assets.image("soil/草土地.png", soil_size),
|
||||
}
|
||||
expansion = False
|
||||
for slot, pos in enumerate(self.assets.metadata["positions"], 1):
|
||||
xy = (s(pos["x"]), s(pos["y"]))
|
||||
canvas.paste(tiles[slot in slots], xy)
|
||||
if slot not in slots and not expansion:
|
||||
icon = self.assets.image(
|
||||
"background/expansion.png", (max(1, s(90)),) * 2,
|
||||
)
|
||||
canvas.paste(icon, (xy[0] + s(70), xy[1] + s(10)))
|
||||
expansion = True
|
||||
self.assets.cache.put(key, canvas.image, size[0] * size[1] * 4)
|
||||
return canvas.copy()
|
||||
|
||||
def _plants(self, canvas, scene, options):
|
||||
s = lambda n: self._scale(n, options)
|
||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||
first_ripe = True
|
||||
for plot in scene.plots:
|
||||
if not plot.sprite:
|
||||
continue
|
||||
pos = self.assets.metadata["positions"][plot.slot - 1]
|
||||
metadata = self.assets.metadata["images"].get(plot.sprite)
|
||||
if metadata is None:
|
||||
logger.warning("farm render missing packaged crop sprite")
|
||||
plant = Image.new("RGBA", (max(1, s(60)),) * 2, "#597b45")
|
||||
ImageDraw.Draw(plant).text(
|
||||
(s(10), 0), "?", fill="white",
|
||||
font=font(self.font_path, max(10, s(48))),
|
||||
)
|
||||
else:
|
||||
size = plot.size or metadata["size"]
|
||||
plant = self.assets.image(
|
||||
plot.sprite, tuple(max(1, s(n)) for n in size),
|
||||
)
|
||||
offset_x, offset_y = plot.offset
|
||||
xy = (
|
||||
s(pos["x"] + soil_w / 2 + offset_x) - plant.width // 2,
|
||||
s(pos["y"] + soil_h / 2 + offset_y) - plant.height // 2,
|
||||
)
|
||||
canvas.paste(plant, xy)
|
||||
if plot.state == "ripe" and first_ripe:
|
||||
icon = self.assets.image(
|
||||
"background/ripe.png", (max(1, s(100)),) * 2,
|
||||
)
|
||||
canvas.paste(icon, (s(pos["x"] + 70), s(pos["y"] - 80)))
|
||||
first_ripe = False
|
||||
|
||||
def _avatar(self, content, size):
|
||||
if content and len(content) <= MAX_AVATAR_BYTES:
|
||||
try:
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
if image.width * image.height > MAX_AVATAR_PIXELS:
|
||||
raise ValueError("Avatar dimensions exceed limit")
|
||||
image.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
avatar = Image.new("RGBA", size, "#cce3c1")
|
||||
avatar.alpha_composite(image.convert("RGBA"), (
|
||||
(size[0] - image.width) // 2,
|
||||
(size[1] - image.height) // 2,
|
||||
))
|
||||
return avatar
|
||||
except (UnidentifiedImageError, OSError, ValueError,
|
||||
Image.DecompressionBombError):
|
||||
logger.warning("farm render avatar rejected; using default")
|
||||
avatar = Image.new("RGBA", size, "#dce8cf")
|
||||
draw = ImageDraw.Draw(avatar)
|
||||
w, h = size
|
||||
draw.ellipse((w*.3, h*.12, w*.7, h*.52), fill="#6f8a54")
|
||||
draw.ellipse((w*.15, h*.55, w*.85, h*1.2), fill="#6f8a54")
|
||||
return avatar
|
||||
|
||||
def _panel(self, canvas, scene, options, avatar):
|
||||
s = lambda n: self._scale(n, options)
|
||||
canvas.rectangle(
|
||||
tuple(s(n) for n in (90, 75, 1160, 425)),
|
||||
"#fff9e8", s(24),
|
||||
)
|
||||
canvas.paste(self._avatar(avatar, (s(170), s(170))), (s(125), s(125)))
|
||||
size = max(12, s(55))
|
||||
name = canvas.fit_text(
|
||||
scene.account.name, self.font_path, size, s(790),
|
||||
)
|
||||
canvas.text((s(330), s(105)), name, self.font_path, size, "#493525")
|
||||
account = scene.account
|
||||
lines = (
|
||||
f"等级 {account.level} 经验 {account.experience}",
|
||||
f"农场币 {account.point} 点券 {account.vip_point}",
|
||||
f"已解锁 {len(scene.plots)}/30 块地",
|
||||
)
|
||||
for index, line in enumerate(lines):
|
||||
size = max(10, s(43))
|
||||
text = canvas.fit_text(line, self.font_path, size, s(790))
|
||||
canvas.text(
|
||||
(s(330), s(195 + index * 65)), text,
|
||||
self.font_path, size, "#674d34",
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""异步入口:一次快照、有限任务、共享绘制与关闭排空。"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from time import perf_counter
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RenderService:
|
||||
def __init__(self, renderer, concurrency=1, max_pending=8):
|
||||
if (type(concurrency) is not int or type(max_pending) is not int
|
||||
or concurrency < 1 or max_pending < concurrency):
|
||||
raise ValueError("Invalid render capacity")
|
||||
self.renderer = renderer
|
||||
self._semaphore = asyncio.Semaphore(concurrency)
|
||||
self._max_pending = max_pending
|
||||
self._pending = {}
|
||||
self._closed = False
|
||||
|
||||
async def my_farm(
|
||||
self, source, identity, *, now=None, options=None, avatar=None,
|
||||
supported=("JPEG",),
|
||||
):
|
||||
if self._closed:
|
||||
raise RuntimeError("Render service closed")
|
||||
started = perf_counter()
|
||||
snapshot = await source.snapshot(identity)
|
||||
logger.info("farm render query_ms=%.2f", (perf_counter()-started)*1000)
|
||||
return await self.render(
|
||||
identity, snapshot, int(time.time()) if now is None else now,
|
||||
options, avatar, supported,
|
||||
)
|
||||
|
||||
async def render(
|
||||
self, identity, snapshot, now, options=None, avatar=None,
|
||||
supported=("JPEG",),
|
||||
):
|
||||
if self._closed:
|
||||
raise RuntimeError("Render service closed")
|
||||
key = self.renderer.request_key(
|
||||
identity, snapshot, now, options, avatar, supported,
|
||||
)
|
||||
task = self._pending.get(key)
|
||||
if task is None:
|
||||
if len(self._pending) >= self._max_pending:
|
||||
raise RuntimeError("Render queue full")
|
||||
task = asyncio.create_task(self._run(
|
||||
identity, snapshot, now, options, avatar, supported,
|
||||
))
|
||||
self._pending[key] = task
|
||||
|
||||
def finished(done):
|
||||
self._pending.pop(key, None)
|
||||
if not done.cancelled():
|
||||
done.exception()
|
||||
|
||||
task.add_done_callback(finished)
|
||||
return await asyncio.shield(task)
|
||||
|
||||
async def _run(self, *args):
|
||||
started = perf_counter()
|
||||
async with self._semaphore:
|
||||
logger.info(
|
||||
"farm render queue_ms=%.2f", (perf_counter()-started)*1000,
|
||||
)
|
||||
worker = asyncio.create_task(asyncio.to_thread(
|
||||
self.renderer.render, *args,
|
||||
))
|
||||
try:
|
||||
return await asyncio.shield(worker)
|
||||
except asyncio.CancelledError:
|
||||
await worker
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
self._closed = True
|
||||
pending = tuple(self._pending.values())
|
||||
if pending:
|
||||
await asyncio.shield(asyncio.gather(
|
||||
*pending, return_exceptions=True,
|
||||
))
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
await self.close()
|
||||
Reference in New Issue
Block a user