mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-31 15:00:03 +08:00
fixed # 适配AstrBot
This commit is contained in:
@@ -42,11 +42,12 @@ class ByteLRU:
|
||||
|
||||
|
||||
class Assets:
|
||||
def __init__(self, cache_bytes=32 * 1024 * 1024):
|
||||
def __init__(self, cache_bytes=32 * 1024 * 1024, bundle_path=None):
|
||||
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")
|
||||
self._bundle = (bundle_path if bundle_path is not None
|
||||
else data.joinpath('assets.zip'))
|
||||
|
||||
def image(self, name, size=None):
|
||||
if name not in self.metadata["images"]:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""固定摘要的外部素材缓存;下载后校验,原子替换,不解压到磁盘。"""
|
||||
|
||||
import hashlib
|
||||
from importlib import resources
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
from zipfile import ZipFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_LOCK = threading.Lock()
|
||||
SOURCES = ('Github', 'Gitea', 'farm_service')
|
||||
MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def expected_manifest():
|
||||
return json.loads(resources.files(__package__).joinpath(
|
||||
'data', 'asset-manifest.json',
|
||||
).read_bytes())
|
||||
|
||||
|
||||
def validate_options(source, service_url):
|
||||
if source not in SOURCES:
|
||||
raise ValueError('Unknown resource source')
|
||||
if service_url:
|
||||
checked_url(service_url)
|
||||
if source == 'farm_service' and not service_url:
|
||||
raise ValueError('farm_service address required')
|
||||
|
||||
|
||||
def checked_url(url):
|
||||
parsed = urlsplit(url)
|
||||
if (parsed.scheme != 'https' or not parsed.hostname or parsed.username
|
||||
or parsed.password or parsed.query or parsed.fragment):
|
||||
raise ValueError('Resource URL requires public HTTPS without credentials')
|
||||
return url
|
||||
|
||||
|
||||
def _copy_remote(url, destination, limit):
|
||||
request = Request(checked_url(url), headers={'User-Agent': 'Farm-assets/1'})
|
||||
started = time.monotonic()
|
||||
with urlopen(request, timeout=15) as response:
|
||||
checked_url(response.geturl())
|
||||
length = response.headers.get('Content-Length')
|
||||
if length is not None and int(length) > limit:
|
||||
raise ValueError('Resource download exceeds limit')
|
||||
total = 0
|
||||
while True:
|
||||
chunk = response.read(min(65536, limit-total+1))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > limit or time.monotonic()-started > 60:
|
||||
raise ValueError('Resource download limit or timeout')
|
||||
destination.write(chunk)
|
||||
|
||||
|
||||
def verify_bundle(path, manifest):
|
||||
path = Path(path)
|
||||
if (not path.is_file() or path.stat().st_size != manifest['size_bytes']
|
||||
or not 0 < manifest['size_bytes'] <= MAX_BYTES):
|
||||
raise ValueError('Resource size mismatch')
|
||||
digest = hashlib.sha256()
|
||||
with path.open('rb') as stream:
|
||||
for chunk in iter(lambda: stream.read(65536), b''):
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != manifest['sha256']:
|
||||
raise ValueError('Resource SHA256 mismatch')
|
||||
metadata = json.loads(resources.files(__package__).joinpath(
|
||||
'data', 'metadata.json',
|
||||
).read_bytes())
|
||||
with ZipFile(path) as archive:
|
||||
entries = archive.infolist()
|
||||
names = [item.filename for item in entries]
|
||||
if (len(names) != len(set(names))
|
||||
or set(names) != set(metadata['images'])
|
||||
or sum(item.file_size for item in entries) > 256*1024*1024
|
||||
or any(item.is_dir() or item.flag_bits & 1
|
||||
for item in entries)):
|
||||
raise ValueError('Resource contents mismatch')
|
||||
return path
|
||||
|
||||
|
||||
def ensure_assets(data_dir, source='Github', service_url=''):
|
||||
"""每次启动校验缓存;仅缺失或不匹配时访问所选源。"""
|
||||
validate_options(source, service_url)
|
||||
expected = expected_manifest()
|
||||
target = (Path(data_dir) / 'assets' / expected['sha256'] / 'assets.zip')
|
||||
with _LOCK:
|
||||
try:
|
||||
return verify_bundle(target, expected)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 清单绑定在插件内;远端不能通过更换清单放行不兼容的素材。
|
||||
if source == 'farm_service':
|
||||
url = (service_url.rstrip('/') + '/v1/assets/'
|
||||
+ expected['sha256'] + '/assets.zip')
|
||||
else:
|
||||
url = expected['sources'][source.lower()]
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
dir=target.parent, suffix='.download', delete=False,
|
||||
) as stream:
|
||||
temporary = Path(stream.name)
|
||||
_copy_remote(url, stream, expected['size_bytes'])
|
||||
verify_bundle(temporary, expected)
|
||||
temporary.replace(target)
|
||||
logger.info('farm assets ready source=%s sha256=%s',
|
||||
source, expected['sha256'])
|
||||
return target
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"version": "farm-assets-v1",
|
||||
"sha256": "78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9",
|
||||
"size_bytes": 21264561,
|
||||
"sources": {
|
||||
"github": "https://raw.githubusercontent.com/Shu-Ying/farm_service/main/assets/assets.zip",
|
||||
"gitea": "https://gitea.diuse.work/Diuse/farm_service/raw/branch/main/assets/assets.zip",
|
||||
"farm_service": "https://farm.example/v1/assets/78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9/assets.zip"
|
||||
}
|
||||
}
|
||||
@@ -6484,9 +6484,9 @@
|
||||
"contact_alpha_threshold": 32,
|
||||
"contact_band_fraction": 0.2,
|
||||
"grid_fit": {
|
||||
"maximum": 9.082568081176591,
|
||||
"maximum": 9.08256808,
|
||||
"points": 30,
|
||||
"rms": 4.178633329197882
|
||||
"rms": 4.17863333
|
||||
},
|
||||
"growth_scale": 0.75,
|
||||
"mode": "visible_contact_center",
|
||||
@@ -6494,12 +6494,12 @@
|
||||
"reference_soil_width": 153,
|
||||
"scene_transform": [
|
||||
[
|
||||
1.467423053281315,
|
||||
0.07509807118862355
|
||||
1.46742305,
|
||||
0.07509807
|
||||
],
|
||||
[
|
||||
0.02736505575131576,
|
||||
-1.4715307877291894
|
||||
0.02736506,
|
||||
-1.47153079
|
||||
]
|
||||
],
|
||||
"schema": 4,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""单张动态帮助长图:实测换行、固定字号及内容驱动高度。"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from farm_core.commands import HELP_NOTES, help_groups
|
||||
|
||||
from .canvas import Canvas, font
|
||||
from .models import RenderOptions
|
||||
|
||||
|
||||
def wrap_text(value, font_path, size, width):
|
||||
"""按实际字宽换行,不截断命令、别名或说明。"""
|
||||
lines = []
|
||||
for paragraph in value.split('\n'):
|
||||
line = ''
|
||||
for char in paragraph:
|
||||
if line and Canvas.measure_text(
|
||||
line + char, font_path, size,
|
||||
) > width:
|
||||
lines.append(line)
|
||||
line = ''
|
||||
line += char
|
||||
lines.append(line)
|
||||
return tuple(lines)
|
||||
|
||||
|
||||
def help_layout(specs, font_path):
|
||||
groups = []
|
||||
for title, rows in help_groups(specs):
|
||||
title_lines = wrap_text(title, font_path, 30, 452)
|
||||
header = 26 + len(title_lines) * 42
|
||||
prepared = []
|
||||
height = header + 18
|
||||
for usage, description in rows:
|
||||
commands = wrap_text(usage, font_path, 28, 484)
|
||||
notes = wrap_text(description, font_path, 23, 484)
|
||||
row_height = len(commands)*40 + len(notes)*34 + 30
|
||||
prepared.append((commands, notes, row_height))
|
||||
height += row_height
|
||||
groups.append((title_lines, header, tuple(prepared), height))
|
||||
# 先分配较高分类,再恢复各列原有分类顺序,避免长列旁大块留白。
|
||||
buckets = [[], []]
|
||||
heights = [0, 0]
|
||||
for index in sorted(range(len(groups)), key=lambda i: -groups[i][3]):
|
||||
column = min(range(2), key=lambda i: heights[i])
|
||||
buckets[column].append(index)
|
||||
heights[column] += groups[index][3]+24
|
||||
columns = tuple(tuple(groups[i] for i in sorted(bucket))
|
||||
for bucket in buckets)
|
||||
body_height = max(sum(g[3]+24 for g in col) for col in columns)
|
||||
tips = tuple(line for note in HELP_NOTES for line in wrap_text(
|
||||
note, font_path, 23, 1072,
|
||||
))
|
||||
return columns, body_height, tips
|
||||
|
||||
|
||||
def render_help(renderer, specs, platform, image_format='AUTO', quality=75):
|
||||
options = RenderOptions(width=1200, format=image_format, quality=quality)
|
||||
columns, body_height, tips = help_layout(specs, renderer.font_path)
|
||||
footer_y = 236 + body_height
|
||||
canvas = Canvas.new((1200, footer_y + 90 + len(tips)*35 + 40), '#f5f3e6')
|
||||
|
||||
# 使用包内现有农场背景,仅作小面积页眉,不在正文后叠加纹理。
|
||||
landscape = renderer.assets.image(
|
||||
'background/decorateIcon/decorate_default_1.jpg',
|
||||
)
|
||||
crop = landscape.crop((0, 0, landscape.width, landscape.height//3))
|
||||
crop = crop.resize((1200, 200), Image.Resampling.LANCZOS)
|
||||
canvas.paste(crop, (0, 0))
|
||||
canvas.rectangle((340, 32, 860, 160), '#915d32', radius=18)
|
||||
canvas.rectangle((348, 38, 852, 154), '#f2d69b', radius=14)
|
||||
|
||||
def text(value, x, y, size=23, color='#504a3d'):
|
||||
box = font(renderer.font_path, size).getbbox(value)
|
||||
canvas.text((x, y-box[1]), value, renderer.font_path, size, color)
|
||||
|
||||
text('农场帮助', 478, 53, 52, '#382b19')
|
||||
label = {'zhenxun': '真寻', 'nonebot': 'NoneBot', 'astrbot': 'AstrBot'}
|
||||
subtitle = f'{label[platform]} · 常用指令与玩法'
|
||||
subtitle_width = canvas.measure_text(subtitle, renderer.font_path, 23)
|
||||
text(subtitle, round((1200-subtitle_width)/2), 118)
|
||||
for column, x in zip(columns, (36, 612)):
|
||||
y = 224
|
||||
for title_lines, header, rows, height in column:
|
||||
canvas.rectangle((x, y, x+552, y+height), '#d1d7bc', radius=16)
|
||||
canvas.rectangle((x+1, y+1, x+551, y+height-1),
|
||||
'#fffdf5', radius=15)
|
||||
canvas.rectangle((x, y, x+552, y+header), '#4c7542', radius=15)
|
||||
canvas.rectangle((x, y+header-15, x+552, y+header), '#4c7542')
|
||||
# 小芽标记;不依赖 emoji 字体或生成图片内的文字。
|
||||
canvas.rectangle((x+22, y+21, x+29, y+44), '#d7e8a0', radius=3)
|
||||
canvas.rectangle((x+10, y+13, x+26, y+28), '#bad571', radius=7)
|
||||
canvas.rectangle((x+28, y+9, x+46, y+24), '#e2ecac', radius=8)
|
||||
for index, line in enumerate(title_lines):
|
||||
text(line, x+62, y+16+index*42, 30, '#ffffff')
|
||||
yy = y+header+18
|
||||
for index, (commands, notes, row_height) in enumerate(rows):
|
||||
for line in commands:
|
||||
text(line, x+28, yy, 28, '#342c20')
|
||||
text(line, x+29, yy, 28, '#342c20')
|
||||
yy += 40
|
||||
for line in notes:
|
||||
text(line, x+28, yy+2)
|
||||
yy += 34
|
||||
yy += 30
|
||||
if index+1 < len(rows):
|
||||
canvas.rectangle((x+28, yy-15, x+524, yy-14), '#e4e3d3')
|
||||
y += height+24
|
||||
canvas.rectangle((36, footer_y, 1164, canvas.image.height-36),
|
||||
'#e4ecd5', radius=16)
|
||||
text('参数与玩法提示', 64, footer_y+20, 29, '#3f6437')
|
||||
for index, line in enumerate(tips):
|
||||
text(line, 64, footer_y+70+index*35)
|
||||
return canvas.encode(options.codec(), options.quality, options.method)
|
||||
@@ -26,12 +26,13 @@ class FarmRenderer:
|
||||
def __init__(
|
||||
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
|
||||
result_cache_bytes=8 * 1024 * 1024,
|
||||
bundle_path=None,
|
||||
):
|
||||
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.assets = Assets(asset_cache_bytes, bundle_path=bundle_path)
|
||||
self.results = ByteLRU(result_cache_bytes)
|
||||
|
||||
def scene(self, identity, snapshot, now):
|
||||
|
||||
@@ -9,6 +9,7 @@ import hashlib
|
||||
from .shop import render_shop
|
||||
from .details import render_details
|
||||
from .warehouse import render_warehouse
|
||||
from .help import render_help
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -147,6 +148,33 @@ class RenderService:
|
||||
self.renderer.results.put(key, content, len(content))
|
||||
return content
|
||||
|
||||
async def help_menu(self, specs, platform, image_format='AUTO', quality=75):
|
||||
if self._closed:
|
||||
raise RuntimeError('Render service closed')
|
||||
key = ('help', specs, platform, image_format, quality)
|
||||
cached = self.renderer.results.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
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_function(
|
||||
render_help, self.renderer, specs, platform,
|
||||
image_format, quality,
|
||||
))
|
||||
self._pending[key] = task
|
||||
|
||||
def finished(done):
|
||||
self._pending.pop(key, None)
|
||||
if not done.cancelled():
|
||||
done.exception()
|
||||
|
||||
task.add_done_callback(finished)
|
||||
content = await asyncio.shield(task)
|
||||
self.renderer.results.put(key, content, len(content))
|
||||
return content
|
||||
|
||||
async def _run_function(self, function, *args):
|
||||
started = perf_counter()
|
||||
async with self._semaphore:
|
||||
|
||||
Reference in New Issue
Block a user