support 4k image for mapfor feature

This commit is contained in:
iceBear67 committed 2026-08-24 18:40:58 +08:00
1 parent b1c86c63aa
commit f026c65915
4 files changed
+137 -11

No files matched your search

@@ -26,14 +26,16 @@ public class GameConfig {
} }
public static class MapArt { public static class MapArt {
// 单张图片的下载体积上限(字节) // 单张图片的下载体积上限(字节),边收边计、超限立刻断开连接并提示玩家。
public int maxDownloadBytes = 8388608; // 16 MiB:4K JPEG 通常 1~5 MB,照片类 4K PNG 约 12 MB;只有不可压缩的极端 PNG(~24 MB)会被拒
public int maxDownloadBytes = 16777216;
// 全服同时进行中的图片下载+解码数上限,超出时新请求直接失败提示稍后重试(不排队) // 全服同时进行中的图片下载+解码数上限,超出时新请求直接失败提示稍后重试(不排队)
public int maxConcurrentRenders = 2; public int maxConcurrentRenders = 2;
// HTTP 超时(秒) // HTTP 超时(秒)
public int httpTimeoutSeconds = 15; public int httpTimeoutSeconds = 15;
// 输入图片单边像素上限(解码像素前按头部尺寸拦截,防爆内存);成品按宽高比在 4x4 格内自动选幅画 // 输入图片单边像素上限(解码像素前按头部尺寸拦截,防解压炸弹);4096 覆盖 4K 素材。
public int maxImageDimension = 2048; // 成品按宽高比在 4x4 格内自动选幅画;解码时按目标画布降采样,故调高此值不会等比推高解码内存
public int maxImageDimension = 4096;
// 远端下载/解码失败的 URL 冷却秒数,期间同一地址不再发起请求 // 远端下载/解码失败的 URL 冷却秒数,期间同一地址不再发起请求
public int failureCooldownSeconds = 60; public int failureCooldownSeconds = 60;
// 铁砧生成地图画的经验等级花费 // 铁砧生成地图画的经验等级花费
@@ -38,6 +38,7 @@ import org.jspecify.annotations.Nullable;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import javax.imageio.ImageReader; import javax.imageio.ImageReader;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
@@ -45,8 +46,11 @@ import java.net.URISyntaxException;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -54,8 +58,10 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.WeakHashMap; import java.util.WeakHashMap;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.Flow;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@@ -70,7 +76,8 @@ import java.util.concurrent.TimeUnit;
* </ul> * </ul>
* 玩家拿到的是单张"种子"地图(128x128 整图预览);上墙后每格替换为 128x128 的局部清晰切片。 * 玩家拿到的是单张"种子"地图(128x128 整图预览);上墙后每格替换为 128x128 的局部清晰切片。
* 输入单边像素上限见 * 输入单边像素上限见
* {@code GameConfig.MapArt#maxImageDimension},在解码像素前先读头部尺寸拦截,防止解压炸弹。 * {@code GameConfig.MapArt#maxImageDimension}(默认 4096,覆盖 4K 素材),在解码像素前先读头部尺寸
* 拦截,防止解压炸弹;放行后按目标画布降采样解码({@link #readSubsampled}),峰值内存与该上限脱钩。
* 生成物是普通 {@code FILLED_MAP}+{@code MAP_ID}/{@code CUSTOM_DATA} 组件,纯原版物品,无需 Polymer。 * 生成物是普通 {@code FILLED_MAP}+{@code MAP_ID}/{@code CUSTOM_DATA} 组件,纯原版物品,无需 Polymer。
* 名称走 {@code ITEM_NAME}(玩家不再看到每格"地图画"名牌,也不能再被铁砧重命名); * 名称走 {@code ITEM_NAME}(玩家不再看到每格"地图画"名牌,也不能再被铁砧重命名);
* 上墙的切片用 {@code TOOLTIP_DISPLAY} 隐藏 {@code MAP_ID},工具提示只剩名称一行。 * 上墙的切片用 {@code TOOLTIP_DISPLAY} 隐藏 {@code MAP_ID},工具提示只剩名称一行。
@@ -575,19 +582,22 @@ public class MapArtModule extends ServerModule {
.build(); .build();
int maxBytes = Math.max(1024, settings.maxDownloadBytes); int maxBytes = Math.max(1024, settings.maxDownloadBytes);
try { try {
return httpClient.sendAsync(request, return httpClient.sendAsync(request, info -> new BoundedBodySubscriber(maxBytes))
HttpResponse.BodyHandlers.buffering(HttpResponse.BodyHandlers.ofByteArray(), maxBytes))
.thenApply(response -> { .thenApply(response -> {
if (response.statusCode() / 100 != 2) { if (response.statusCode() / 100 != 2) {
markFailed(url); markFailed(url);
return RenderOutcome.fail(RenderFailure.DOWNLOAD_FAILED, "HTTP " + response.statusCode()); return RenderOutcome.fail(RenderFailure.DOWNLOAD_FAILED, "HTTP " + response.statusCode());
} }
if (response.body().overflow()) {
markFailed(url);
return RenderOutcome.fail(RenderFailure.BODY_TOO_LARGE, formatBytes(maxBytes));
}
var contentType = response.headers().firstValue("content-type").orElse(""); var contentType = response.headers().firstValue("content-type").orElse("");
if (!contentType.isBlank() && !contentType.regionMatches(true, 0, "image/", 0, 6)) { if (!contentType.isBlank() && !contentType.regionMatches(true, 0, "image/", 0, 6)) {
markFailed(url); markFailed(url);
return RenderOutcome.fail(RenderFailure.NOT_IMAGE, null); return RenderOutcome.fail(RenderFailure.NOT_IMAGE, null);
} }
return decode(url, response.body()); return decode(url, response.body().bytes());
}) })
.whenComplete((outcome, error) -> renderSlots.release()); .whenComplete((outcome, error) -> renderSlots.release());
} catch (Exception e) { } catch (Exception e) {
@@ -599,7 +609,8 @@ public class MapArtModule extends ServerModule {
} }
/** /**
* 先读图片头部尺寸(不解码像素)拦截超大图,通过后再栅格化到整幅墙画画布。 * 先读图片头部尺寸(不解码像素)拦截超大图,通过后按目标画布降采样解码
* (见 {@link #readSubsampled}),再栅格化到整幅墙画画布。
*/ */
private RenderOutcome decode(String url, byte[] body) { private RenderOutcome decode(String url, byte[] body) {
int maxDimension = configService.get().mapArt.maxImageDimension; int maxDimension = configService.get().mapArt.maxImageDimension;
@@ -626,10 +637,10 @@ public class MapArtModule extends ServerModule {
markFailed(url); markFailed(url);
return RenderOutcome.fail(RenderFailure.TOO_LARGE, width + "x" + height); return RenderOutcome.fail(RenderFailure.TOO_LARGE, width + "x" + height);
} }
var image = reader.read(0);
var grid = gridFor(width, height); var grid = gridFor(width, height);
int canvasWidth = grid[0] * MapArtRenderer.MAP_SIZE; int canvasWidth = grid[0] * MapArtRenderer.MAP_SIZE;
int canvasHeight = grid[1] * MapArtRenderer.MAP_SIZE; int canvasHeight = grid[1] * MapArtRenderer.MAP_SIZE;
var image = readSubsampled(reader, width, height, canvasWidth, canvasHeight);
var canvas = grid[0] == 1 && grid[1] == 1 var canvas = grid[0] == 1 && grid[1] == 1
? MapArtRenderer.render(image) ? MapArtRenderer.render(image)
: MapArtRenderer.render(image, canvasWidth, canvasHeight); : MapArtRenderer.render(image, canvasWidth, canvasHeight);
@@ -647,6 +658,33 @@ public class MapArtModule extends ServerModule {
} }
} }
/**
* 按目标画布尺寸降采样解码。目标画布最大也只有 512x512,而 4K 输入全分辨率解码要 64 MiB 的
* ARGB 缓冲——解码期每 step 个像素取一个就够,峰值内存与输入边长上限脱钩。
* 刻意留 2 倍余量交给 {@link MapArtRenderer#render} 的双线性缩放:恰好 2 倍下采样的双线性
* 等价于 2x2 盒式平均,比一路点采样到目标尺寸的锯齿少得多。
*/
private static BufferedImage readSubsampled(ImageReader reader, int width, int height,
int canvasWidth, int canvasHeight) throws IOException {
int step = Math.max(1, Math.min(width / (canvasWidth * 2), height / (canvasHeight * 2)));
if (step == 1) {
return reader.read(0); // 本就接近画布尺寸,不必绕 ImageReadParam
}
var param = reader.getDefaultReadParam();
param.setSourceSubsampling(step, step, 0, 0);
return reader.read(0, param);
}
/**
* 人类可读的体积上限,拼进玩家提示。单位符号留在代码里(不是需要翻译的散文),
* lang 那边只留 "%s" 的位置。
*/
private static String formatBytes(int bytes) {
return bytes >= 1024 * 1024
? (bytes / (1024 * 1024)) + " MiB"
: Math.max(1, bytes / 1024) + " KiB";
}
/** /**
* 远端失败(网络/非图片/超大)后进入短时黑名单,避免玩家反复触发下载。 * 远端失败(网络/非图片/超大)后进入短时黑名单,避免玩家反复触发下载。
*/ */
@@ -665,6 +703,8 @@ public class MapArtModule extends ServerModule {
case COOLDOWN -> Component.translatable("message.sfcraft.mapart.error.cooldown", outcome.detail()); case COOLDOWN -> Component.translatable("message.sfcraft.mapart.error.cooldown", outcome.detail());
case TOO_LARGE -> Component.translatable("message.sfcraft.mapart.error.too_large", case TOO_LARGE -> Component.translatable("message.sfcraft.mapart.error.too_large",
outcome.detail(), configService.get().mapArt.maxImageDimension); outcome.detail(), configService.get().mapArt.maxImageDimension);
case BODY_TOO_LARGE ->
Component.translatable("message.sfcraft.mapart.error.body_too_large", outcome.detail());
case NOT_IMAGE -> Component.translatable("message.sfcraft.mapart.error.not_image"); case NOT_IMAGE -> Component.translatable("message.sfcraft.mapart.error.not_image");
case DOWNLOAD_FAILED -> Component.translatable("message.sfcraft.mapart.error.download_failed", case DOWNLOAD_FAILED -> Component.translatable("message.sfcraft.mapart.error.download_failed",
outcome.detail() == null ? "?" : outcome.detail()); outcome.detail() == null ? "?" : outcome.detail());
@@ -732,6 +772,87 @@ public class MapArtModule extends ServerModule {
private record Rendered(int cols, int rows, byte[] canvas) { private record Rendered(int cols, int rows, byte[] canvas) {
} }
/**
* 收完的响应体。{@code overflow} 为真表示超过了 {@code GameConfig.MapArt#maxDownloadBytes},
* 上游已取消、字节已丢弃,{@code bytes} 为空。
*/
private record Downloaded(byte[] bytes, boolean overflow) {
private static final Downloaded OVERFLOW = new Downloaded(new byte[0], true);
}
/**
* 真正封顶的响应体收集器。
* <p>
* 注意 {@code BodyHandlers.buffering(downstream, size)} 的 size <b>不是</b>体积上限,只是"攒够多少
* 字节再往下游递"的缓冲块大小;下游 {@code ofByteArray} 依旧会把整个响应体收进内存。铁砧那条路径
* 不需要 OP 权限(任何玩家把地图改名成 URL 就能触发下载),所以这里必须自己数字节:一超上限就
* {@code cancel} 上游、丢掉已收分片,让下游拿到 {@link Downloaded#OVERFLOW}。
* <p>
* 全程非阻塞(不在 HttpClient 的 executor 上做阻塞读),因此不需要额外线程池。
*/
private static final class BoundedBodySubscriber implements HttpResponse.BodySubscriber<Downloaded> {
private final int maxBytes;
private final CompletableFuture<Downloaded> body = new CompletableFuture<>();
private final List<ByteBuffer> chunks = new ArrayList<>();
private Flow.Subscription subscription;
/** long:配置若被填成接近 Integer.MAX_VALUE,int 累加会绕回负数而永远测不出超限。 */
private long received;
private BoundedBodySubscriber(int maxBytes) {
this.maxBytes = maxBytes;
}
@Override
public CompletionStage<Downloaded> getBody() {
return body;
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.subscription = subscription;
subscription.request(Long.MAX_VALUE);
}
@Override
public void onNext(List<ByteBuffer> buffers) {
if (body.isDone()) {
return; // cancel 之后仍可能收到在途分片
}
for (var buffer : buffers) {
received += buffer.remaining();
if (received > maxBytes) {
chunks.clear();
subscription.cancel();
body.complete(Downloaded.OVERFLOW);
return;
}
chunks.add(buffer);
}
}
@Override
public void onError(Throwable throwable) {
chunks.clear();
body.completeExceptionally(throwable); // 已 complete 时是空操作,保持首个结果
}
@Override
public void onComplete() {
if (body.isDone()) {
return;
}
var bytes = new byte[(int) received]; // 走到这里必有 received <= maxBytes
int offset = 0;
for (var chunk : chunks) {
int length = chunk.remaining();
chunk.get(bytes, offset, length);
offset += length;
}
chunks.clear();
body.complete(new Downloaded(bytes, false));
}
}
private record RenderOutcome(@Nullable Rendered rendered, @Nullable RenderFailure failure, private record RenderOutcome(@Nullable Rendered rendered, @Nullable RenderFailure failure,
@Nullable String detail) { @Nullable String detail) {
private static RenderOutcome ok(Rendered rendered) { private static RenderOutcome ok(Rendered rendered) {
@@ -744,7 +865,8 @@ public class MapArtModule extends ServerModule {
} }
private enum RenderFailure { private enum RenderFailure {
INVALID_URL, USERINFO, UNSUPPORTED_EXTENSION, COOLDOWN, DOWNLOAD_FAILED, NOT_IMAGE, TOO_LARGE, BUSY INVALID_URL, USERINFO, UNSUPPORTED_EXTENSION, COOLDOWN, DOWNLOAD_FAILED, NOT_IMAGE,
TOO_LARGE, BODY_TOO_LARGE, BUSY
} }
private static final class AnvilSession { private static final class AnvilSession {
@@ -99,6 +99,7 @@
"message.sfcraft.mapart.error.unsupported_extension": "The URL must end with a supported image extension (png/jpg/jpeg/gif/bmp)", "message.sfcraft.mapart.error.unsupported_extension": "The URL must end with a supported image extension (png/jpg/jpeg/gif/bmp)",
"message.sfcraft.mapart.error.cooldown": "This URL failed recently; retry in %s seconds", "message.sfcraft.mapart.error.cooldown": "This URL failed recently; retry in %s seconds",
"message.sfcraft.mapart.error.too_large": "Image too large (%s; max %s pixels per side)", "message.sfcraft.mapart.error.too_large": "Image too large (%s; max %s pixels per side)",
"message.sfcraft.mapart.error.body_too_large": "Image file too large; the limit is %s",
"message.sfcraft.mapart.error.not_image": "The downloaded content is not a recognizable image", "message.sfcraft.mapart.error.not_image": "The downloaded content is not a recognizable image",
"message.sfcraft.mapart.error.download_failed": "Download failed: %s", "message.sfcraft.mapart.error.download_failed": "Download failed: %s",
"message.sfcraft.mapart.error.busy": "The server is already rendering %s map art(s) concurrently; please try again later", "message.sfcraft.mapart.error.busy": "The server is already rendering %s map art(s) concurrently; please try again later",
@@ -99,6 +99,7 @@
"message.sfcraft.mapart.error.unsupported_extension": "URL 需以受支持的图片扩展名结尾(png/jpg/jpeg/gif/bmp)", "message.sfcraft.mapart.error.unsupported_extension": "URL 需以受支持的图片扩展名结尾(png/jpg/jpeg/gif/bmp)",
"message.sfcraft.mapart.error.cooldown": "该地址刚请求失败过,%s 秒后可重试", "message.sfcraft.mapart.error.cooldown": "该地址刚请求失败过,%s 秒后可重试",
"message.sfcraft.mapart.error.too_large": "图片尺寸过大(%s,单边上限 %s 像素)", "message.sfcraft.mapart.error.too_large": "图片尺寸过大(%s,单边上限 %s 像素)",
"message.sfcraft.mapart.error.body_too_large": "图片体积过大,上限 %s",
"message.sfcraft.mapart.error.not_image": "下载的内容不是可识别的图片", "message.sfcraft.mapart.error.not_image": "下载的内容不是可识别的图片",
"message.sfcraft.mapart.error.download_failed": "下载失败: %s", "message.sfcraft.mapart.error.download_failed": "下载失败: %s",
"message.sfcraft.mapart.error.busy": "当前全服并发渲染已达上限(%s),请稍后再试", "message.sfcraft.mapart.error.busy": "当前全服并发渲染已达上限(%s),请稍后再试",