add # 新增项目整体大纲

This commit is contained in:
2026-10-15 17:21:05 +08:00
parent ff03600ae9
commit ff4df9ad51
52 changed files with 15104 additions and 47 deletions
+7
View File
@@ -0,0 +1,7 @@
"""可选共享图片层;核心模块无需导入本包。"""
from .models import RenderOptions, RenderResult
from .renderer import FarmRenderer
from .service import RenderService
__all__ = ["FarmRenderer", "RenderOptions", "RenderResult", "RenderService"]
+66
View File
@@ -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()
+77
View File
@@ -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()
+8
View File
@@ -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
+58
View File
@@ -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),
)
+74
View File
@@ -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 # 成图缓存命中。
+204
View File
@@ -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",
)
+89
View File
@@ -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()