Compare commits

...
3 Commits
Author SHA1 Message Date
NotAlexNoyle 16cfe03908 Exclude CODEOWNERS from language statistics 2026-10-29 16:18:59 -04:00
NotAlexNoyle 87c9f7c4ec Add CODEOWNERS (one staff approval) 2026-10-29 16:18:59 -04:00
NotAlexNoyle 3df8a6714a Fix bundled maps 2026-10-08 17:30:34 -04:00
16 changed files with 858 additions and 178 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
.gitea/CODEOWNERS linguist-detectable=false
+3
View File
@@ -0,0 +1,3 @@
# Pull requests request review from the eligible staff team(s).
# One approval from either an Admin or Developer satisfies branch protection.
* @trueog/Admins @trueog/Developers
+26 -7
View File
@@ -14,33 +14,52 @@ More information (screenshots, commands, permissions) about the plugin can be fo
The main command is `/imageframe` (aliases: `/iframe`, `/if`, `/frame`). All creation commands are op-only by default.
Sizes are measured in **maps** — one map fills one item frame block, so a `4 x 3` image needs a 4-wide by 3-tall grid of item frames.
Image URLs are **not accepted** — `create`, `overlay` and `refresh` reject any link. Images come from:
- **The images folder** — drop image files (png, jpg, gif, webp, …) into `plugins/ImageFrame/images/` and pass the file name, e.g. `/imageframe create poster poster.png selection`. No restart needed; new files show up in tab completion straight away. File names can't contain spaces.
- **The bundled maps** (below).
- **The upload service**, when `UploadService.Enabled` is `true` in `config.yml` — pass `upload` instead of a file name, then open the link the plugin sends you to upload a file.
### Quick way — create straight onto a wall
1. Place a grid of item frames on the wall (e.g. 4 wide × 3 tall).
2. Run `/imageframe select`, then left-click one corner frame and right-click the opposite corner frame to select the whole grid.
3. Run `/imageframe create <name> <url> selection`.
3. Run `/imageframe create <name> <file> selection`, where `<file>` is an image in `plugins/ImageFrame/images/`.
The image is downloaded, sized to your selection, and placed into the frames automatically.
The image is sized to your selection and placed into the frames automatically.
### Manual way — create, then place yourself
1. `/imageframe create <name> <url> <width> <height>` — creates the image map (`width`/`height` in maps).
1. `/imageframe create <name> <file> <width> <height>` — creates the image map (`width`/`height` in maps).
2. Put it on frames one of two ways:
- `/imageframe get <name>` — gives you the map item(s); place each on the item frames in order (left→right, top→bottom).
- or select a frame grid (as above) and run `/imageframe get <name> selection` to auto-fill it.
### Using bundled maps
Bundled maps are canonical maps shipped inside the plugin jar: `maps.yml` defines each map (name, size, dithering, and pinned Minecraft map ids) and the images live under `maps/` in the jar. They are re-rendered from the jar on every startup and never touch the plugin data folder, so they survive data wipes — the pinned map ids keep existing item frames in the world pointing at the right maps, the same way LaunchPads-OG preserves pad locations in its config.
Bundled maps are canonical maps shipped inside the plugin jar, under `maps/`. They are re-rendered from the jar on every startup and never touch the plugin data folder, so they survive data wipes — their map ids are kept so existing item frames in the world keep pointing at the right maps, the same way LaunchPads-OG preserves pad locations in its config.
Bundled maps are owned by **Console**. Anyone with access can see them with `/imageframe list` and use them by name:
- `/imageframe get <name> selection`, or reference them explicitly as `Console:<name>`.
To add or change a bundled map, edit `common/src/main/resources/maps.yml` and the images in `common/src/main/resources/maps/`, then rebuild the jar. Never change the `MapIds` of an existing map — item frames in the world reference those ids.
#### Adding a bundled map
It works like the images folder: drop the file in and it becomes a map named after the file.
1. Optional, to try it out live first: put the image in `plugins/ImageFrame/images/` and run `imageframe create Console:<name> <file> <width> <height>` from the console, using the file name without its extension as `<name>`. Refresh or recreate it until it looks right, and place it on frames if you like.
2. Copy the image into `common/src/main/resources/maps/`, rebuild the jar, deploy it and restart. No `maps.yml` entry is needed.
On first load the server sets the map up:
- If you did step 1, the bundled map takes over that map's size, dithering and map ids, and the copy in the data folder is removed. Frames you already placed keep working.
- Otherwise the size comes from the image at 128 pixels per map (a 512×384 image becomes 4×3), and the server assigns new map ids. Images that would go over `MaxSize` are skipped with a console message.
The server saves what it decided to `imageframe-bundled-maps.yml` in the main world folder. It stays the same on every restart and survives plugin data folder wipes, so there is nothing to copy back. Keep that file with the world.
Replacing a bundled image with a new file of the same name keeps its size and map ids, and frames update after the restart. Animated GIFs are supported. File names can't contain spaces or colons.
To override the name, size or dithering, or to pin exact map ids, add an entry to `common/src/main/resources/maps.yml` (see the comments at the top of that file). Never change the `MapIds` of an existing map — item frames in the world reference those ids.
### Useful extras
- `/imageframe info <name>` — shows a map's size (handy to build a matching frame grid) and its URL.
- `/imageframe refresh <name>` — re-downloads and re-renders an existing map.
- `/imageframe info <name>` — shows a map's size (handy to build a matching frame grid).
- `/imageframe refresh <name> [file]` — re-renders an existing map. Maps made from the images folder re-read their file, so edit the image in place and refresh to update it live. Pass a different file name (or `upload`) to swap the image. Bundled maps re-render from the jar. Maps whose stored source is a web URL can no longer be refreshed.
- Optional flags on `create`/`get`: a dithering type, and `combined`/`separated`.
### Behaviour of this fork
- Image URLs are not accepted anywhere; the old `RestrictImageUrl` whitelist is gone.
- Images are **stretched to fill** the whole frame grid (edge to edge) instead of being letterboxed with a transparent border. Match your frame grid's width:height to the image's aspect ratio to avoid distortion.
- Item frames filled by the plugin are automatically made **invisible**, so neighbouring tiles join seamlessly with no thin border between them.
@@ -24,6 +24,7 @@ import com.loohp.imageframe.api.events.ImageMapUpdatedEvent;
import com.loohp.imageframe.migration.ExternalPluginMigration;
import com.loohp.imageframe.migration.PluginMigrationRegistry;
import com.loohp.imageframe.objectholders.BlockPosition;
import com.loohp.imageframe.objectholders.BundledImageMap;
import com.loohp.imageframe.objectholders.DitheringType;
import com.loohp.imageframe.objectholders.IFPlayer;
import com.loohp.imageframe.objectholders.IFPlayerPreference;
@@ -91,6 +92,7 @@ import java.util.PrimitiveIterator;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static com.loohp.imageframe.language.TranslationKey.*;
@@ -222,7 +224,8 @@ public class Commands implements CommandExecutor, TabCompleter {
sendMessage(sender, translatable(DUPLICATE_MAP_NAME).color(NamedTextColor.RED));
return true;
}
if (!ImageFrame.isURLAllowed(args[2])) {
String source = ImageFrame.localImageFolder.resolve(args[2]);
if (!ImageFrame.isURLAllowed(source)) {
sendMessage(sender, translatable(URL_RESTRICTED).color(NamedTextColor.RED));
return true;
}
@@ -239,7 +242,7 @@ public class Commands implements CommandExecutor, TabCompleter {
ImageMapCreationTask<ImageMap> creationTask = null;
String url = "Pending...";
try {
url = args[2];
url = source;
if (ImageFrame.imageUploadManager.isOperational() && url.equalsIgnoreCase("upload")) {
UUID user = isConsole ? ImageMap.CONSOLE_CREATOR : player.getUniqueId();
PendingUpload pendingUpload = ImageFrame.imageUploadManager.newPendingUpload(user);
@@ -394,7 +397,7 @@ public class Commands implements CommandExecutor, TabCompleter {
Scheduler.runTaskAsynchronously(ImageFrame.plugin, () -> {
ImageMapCreationTask<ImageMap> creationTask = null;
try {
String url = args[2];
String url = ImageFrame.localImageFolder.resolve(args[2]);
if (ImageFrame.imageUploadManager.isOperational() && url.equalsIgnoreCase("upload")) {
UUID user = player.getUniqueId();
PendingUpload pendingUpload = ImageFrame.imageUploadManager.newPendingUpload(user);
@@ -797,12 +800,12 @@ public class Commands implements CommandExecutor, TabCompleter {
if (imageMap == null) {
ditheringType = DitheringType.fromNameOrNull(args[1].toLowerCase());
if (ditheringType == null) {
url = args[1];
url = ImageFrame.localImageFolder.resolve(args[1]);
}
} else if (args.length > 2) {
ditheringType = DitheringType.fromNameOrNull(args[2].toLowerCase());
if (ditheringType == null) {
url = args[2];
url = ImageFrame.localImageFolder.resolve(args[2]);
}
}
}
@@ -823,21 +826,22 @@ public class Commands implements CommandExecutor, TabCompleter {
} else {
if (ImageFrame.hasImageMapPermission(imageMap, sender, ImageMapAccessPermissionType.EDIT)) {
try {
if (imageMap instanceof URLImageMap) {
if (imageMap instanceof URLImageMap && !(imageMap instanceof BundledImageMap)) {
URLImageMap urlImageMap = (URLImageMap) imageMap;
String imageType = HTTPRequestUtils.getContentType(url);
String sourceUrl = url == null ? urlImageMap.getUrl() : url;
if (!ImageFrame.isURLAllowed(sourceUrl)) {
sendMessage(sender, translatable(URL_RESTRICTED).color(NamedTextColor.RED));
return;
}
String imageType = HTTPRequestUtils.getContentType(sourceUrl);
if (imageType == null) {
imageType = URLConnection.guessContentTypeFromName(url);
imageType = URLConnection.guessContentTypeFromName(sourceUrl);
}
if (imageType == null) {
imageType = "";
} else {
imageType = imageType.trim();
}
if (!ImageFrame.isURLAllowed(url)) {
sendMessage(sender, translatable(URL_RESTRICTED).color(NamedTextColor.RED));
return;
}
if (imageType.equals(MapUtils.GIF_CONTENT_TYPE) == urlImageMap.requiresAnimationService()) {
String oldUrl = urlImageMap.getUrl();
if (url != null) {
@@ -1716,10 +1720,10 @@ public class Commands implements CommandExecutor, TabCompleter {
}
if (sender.hasPermission("imageframe.refresh")) {
if ("refresh".equalsIgnoreCase(args[0])) {
tab.add("[url]");
if (ImageFrame.imageUploadManager.isOperational() && "upload".startsWith(args[1].toLowerCase())) {
tab.add("upload");
}
tab.addAll(getImageFileSuggestions(args[1]));
tab.addAll(ImageMapUtils.getImageMapNameSuggestions(sender, args[1]));
for (String ditheringType : DitheringType.values().keySet()) {
if (ditheringType.startsWith(args[1].toLowerCase())) {
@@ -1845,18 +1849,18 @@ public class Commands implements CommandExecutor, TabCompleter {
case 3:
if (sender.hasPermission("imageframe.create")) {
if ("create".equalsIgnoreCase(args[0])) {
tab.add("<url>");
if (ImageFrame.imageUploadManager.isOperational() && "upload".startsWith(args[2].toLowerCase())) {
tab.add("upload");
}
tab.addAll(getImageFileSuggestions(args[2]));
}
}
if (sender.hasPermission("imageframe.overlay")) {
if ("overlay".equalsIgnoreCase(args[0])) {
tab.add("<url>");
if (ImageFrame.imageUploadManager.isOperational() && "upload".startsWith(args[2].toLowerCase())) {
tab.add("upload");
}
tab.addAll(getImageFileSuggestions(args[2]));
}
}
if (sender.hasPermission("imageframe.clone")) {
@@ -1878,10 +1882,10 @@ public class Commands implements CommandExecutor, TabCompleter {
if ("refresh".equalsIgnoreCase(args[0])) {
ImageMap imageMap = ImageMapUtils.getFromPlayerPrefixedName(sender, args[1]);
if (imageMap != null) {
tab.add("[url]");
if (ImageFrame.imageUploadManager.isOperational() && "upload".startsWith(args[2].toLowerCase())) {
tab.add("upload");
}
tab.addAll(getImageFileSuggestions(args[2]));
for (String ditheringType : DitheringType.values().keySet()) {
if (ditheringType.startsWith(args[2].toLowerCase())) {
tab.add(ditheringType);
@@ -2255,4 +2259,9 @@ public class Commands implements CommandExecutor, TabCompleter {
}
}
private List<String> getImageFileSuggestions(String prefix) {
String lowerPrefix = prefix.toLowerCase();
return ImageFrame.localImageFolder.getImageFileNames().stream().filter(name -> name.toLowerCase().startsWith(lowerPrefix)).collect(Collectors.toList());
}
}
@@ -28,11 +28,9 @@ import com.loohp.imageframe.listeners.Events;
import com.loohp.imageframe.metrics.Charts;
import com.loohp.imageframe.metrics.Metrics;
import com.loohp.imageframe.objectholders.AnimatedFakeMapManager;
import com.loohp.imageframe.objectholders.BundledMapCreateInfo;
import com.loohp.imageframe.objectholders.BundledStaticImageMap;
import com.loohp.imageframe.objectholders.BundledMaps;
import com.loohp.imageframe.objectholders.CombinedMapItemHandler;
import com.loohp.imageframe.objectholders.CustomClientNetworkManager;
import com.loohp.imageframe.objectholders.DitheringType;
import com.loohp.imageframe.objectholders.IFPlayerManager;
import com.loohp.imageframe.objectholders.IFPlayerPreference;
import com.loohp.imageframe.objectholders.ImageMap;
@@ -52,6 +50,7 @@ import com.loohp.imageframe.storage.ImageFrameStorage;
import com.loohp.imageframe.storage.ImageFrameStorageLoaders;
import com.loohp.imageframe.updater.Updater;
import com.loohp.imageframe.upload.ImageUploadManager;
import com.loohp.imageframe.upload.LocalImageFolder;
import com.loohp.imageframe.utils.ChatColorUtils;
import com.loohp.imageframe.utils.KeyUtils;
import com.loohp.imageframe.utils.MCVersion;
@@ -63,7 +62,6 @@ import net.kyori.adventure.key.Key;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
@@ -72,16 +70,8 @@ import javax.imageio.ImageIO;
import javax.imageio.spi.IIORegistry;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -111,8 +101,6 @@ public class ImageFrame extends JavaPlugin {
public static String mapItemFormat;
public static boolean requireEmptyMaps;
public static int mapMaxSize;
public static boolean restrictImageUrlEnabled;
public static List<URL> restrictImageUrls;
public static Map<String, Integer> playerCreationLimit;
public static int mapMarkerLimit;
public static long maxImageFileSize;
@@ -157,29 +145,21 @@ public class ImageFrame extends JavaPlugin {
public static InvisibleFrameManager invisibleFrameManager;
public static ImageMapCreationTaskManager imageMapCreationTaskManager;
public static ImageUploadManager imageUploadManager;
public static LocalImageFolder localImageFolder;
public static CustomClientNetworkManager customClientNetworkManager;
// Image URLs are not accepted, only images from the local images folder or files sent through the upload service
public static boolean isURLAllowed(String link) {
if (!restrictImageUrlEnabled) {
return true;
}
if ("upload".equals(link) || imageUploadManager.wasUploaded(link)) {
return true;
}
try {
URL url = new URL(link);
return restrictImageUrls.stream().anyMatch(whitelisted -> {
if (!url.getProtocol().equalsIgnoreCase(whitelisted.getProtocol())) {
return false;
}
if (!url.getHost().equalsIgnoreCase(whitelisted.getHost())) {
return false;
}
return url.getPath().toLowerCase().startsWith(whitelisted.getPath().toLowerCase());
});
} catch (MalformedURLException e) {
if (link == null) {
return false;
}
if (localImageFolder.isImageFileUrl(link)) {
return true;
}
if (!imageUploadManager.isOperational()) {
return false;
}
return "upload".equalsIgnoreCase(link) || imageUploadManager.wasUploaded(link);
}
public static <T extends UnsetState> T getPreferenceUnsetValue(Player player, IFPlayerPreference<T> preference) {
@@ -297,6 +277,7 @@ public class ImageFrame extends JavaPlugin {
invisibleFrameManager = new InvisibleFrameManager();
imageMapCreationTaskManager = new ImageMapCreationTaskManager(ImageFrame.parallelProcessingLimit);
imageUploadManager = new ImageUploadManager(uploadServiceEnabled, uploadServiceServerAddress, uploadServiceServerPort);
localImageFolder = new LocalImageFolder(new File(getDataFolder(), "images"));
customClientNetworkManager = new CustomClientNetworkManager(imageFrameClientEnabled);
if (isPluginEnabled("PlaceholderAPI")) {
@@ -305,87 +286,11 @@ public class ImageFrame extends JavaPlugin {
ImageMapLoaders.init();
Scheduler.runTaskAsynchronously(this, () -> imageMapManager.loadMaps(ifPlayerManager, this::loadBundledMaps));
Scheduler.runTaskAsynchronously(this, () -> imageMapManager.loadMaps(ifPlayerManager, () -> BundledMaps.load(imageMapManager, getFile())));
getServer().getConsoleSender().sendMessage(ChatColor.GREEN + "[ImageFrame] ImageFrame has been Enabled!");
}
/**
* Loads the maps whose canonical definition is bundled inside the plugin
* jar (maps.yml + images under maps/). Bundled maps pin their Minecraft
* map ids in maps.yml, so item frames in the world keep working even if
* the plugin data folder is wiped. Legacy copies of the same map in the
* data folder (matching name and map ids) are deleted on first load.
*/
private int loadBundledMaps() {
InputStream mapsYmlStream = getClass().getClassLoader().getResourceAsStream("maps.yml");
if (mapsYmlStream == null) {
return 0;
}
CommandSender console = getServer().getConsoleSender();
YamlConfiguration mapsYml;
try (Reader reader = new InputStreamReader(mapsYmlStream, StandardCharsets.UTF_8)) {
mapsYml = YamlConfiguration.loadConfiguration(reader);
} catch (IOException e) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to read bundled maps.yml from the plugin jar");
e.printStackTrace();
return 0;
}
int count = 0;
for (Map<?, ?> entry : mapsYml.getMapList("Maps")) {
String name = entry.get("Name") == null ? null : entry.get("Name").toString();
String image = entry.get("Image") == null ? null : entry.get("Image").toString();
try {
if (name == null || image == null) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Skipping invalid maps.yml entry (missing Name or Image): " + entry);
continue;
}
int width = entry.get("Width") instanceof Number ? ((Number) entry.get("Width")).intValue() : 1;
int height = entry.get("Height") instanceof Number ? ((Number) entry.get("Height")).intValue() : 1;
if (width <= 0 || height <= 0) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to load bundled ImageMap \"" + name + "\": Width and Height must be positive (got " + width + "x" + height + ")");
continue;
}
DitheringType ditheringType = DitheringType.fromName(entry.get("Dithering") == null ? null : entry.get("Dithering").toString());
long creationTime = entry.get("CreationTime") instanceof Number ? ((Number) entry.get("CreationTime")).longValue() : System.currentTimeMillis();
List<Integer> mapIds = new ArrayList<>(width * height);
if (entry.get("MapIds") instanceof List) {
for (Object id : (List<?>) entry.get("MapIds")) {
if (id instanceof Number) {
mapIds.add(((Number) id).intValue());
}
}
}
if (mapIds.size() != width * height) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to load bundled ImageMap \"" + name + "\": MapIds must contain exactly Width * Height (" + (width * height) + ") map ids");
continue;
}
// Validate the bundled image is present and decodable before touching
// any legacy copy of this map, so a bad jar cannot destroy working maps.
BundledStaticImageMap.readResourceImage(image);
ImageMap existing = imageMapManager.getFromCreator(ImageMap.CONSOLE_CREATOR, name);
if (existing != null) {
if (!existing.getMapIds().equals(mapIds)) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Bundled ImageMap \"" + name + "\" has different map ids than the copy in storage, keeping the stored copy. Update MapIds in maps.yml to " + existing.getMapIds() + " to migrate it.");
continue;
}
// Same name and same map ids: the jar is now the canonical
// definition, so drop the legacy copy from the data folder.
imageMapManager.deleteMap(existing.getImageIndex());
console.sendMessage(ChatColor.YELLOW + "[ImageFrame] Migrated ImageMap \"" + name + "\" to the bundled definition in the plugin jar, its data folder copy has been removed");
}
BundledStaticImageMap imageMap = ImageMapLoaders.BUNDLED_STATIC_IMAGE_MAP_LOADER.create(new BundledMapCreateInfo(imageMapManager, name, image, width, height, ditheringType, mapIds, creationTime)).get();
imageMapManager.addBundledMap(imageMap);
count++;
console.sendMessage(ChatColor.GREEN + "[ImageFrame] Loaded bundled ImageMap \"" + name + "\" (" + width + "x" + height + ", map ids " + mapIds.get(0) + "-" + mapIds.get(mapIds.size() - 1) + ") from the plugin jar");
} catch (Exception e) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to load bundled ImageMap \"" + name + "\" from the plugin jar");
new IOException("Unable to load bundled ImageMap \"" + name + "\" from the plugin jar", e).printStackTrace();
}
}
return count;
}
@Override
public void onDisable() {
if (mapMarkerEditManager != null) {
@@ -423,15 +328,6 @@ public class ImageFrame extends JavaPlugin {
mapItemFormat = ChatColorUtils.translateAlternateColorCodes('&', config.getConfiguration().getString("Settings.MapItemFormat"));
requireEmptyMaps = config.getConfiguration().getBoolean("Settings.RequireEmptyMaps");
mapMaxSize = config.getConfiguration().getInt("Settings.MaxSize");
restrictImageUrlEnabled = config.getConfiguration().getBoolean("Settings.RestrictImageUrl.Enabled");
restrictImageUrls = config.getConfiguration().getStringList("Settings.RestrictImageUrl.Whitelist").stream().map(s -> {
try {
return new URL(s);
} catch (MalformedURLException e) {
e.printStackTrace();
return null;
}
}).filter(u -> u != null).collect(Collectors.toList());
playerCreationLimit = new HashMap<>();
for (String group : config.getConfiguration().getConfigurationSection("Settings.PlayerCreationLimit").getKeys(false)) {
playerCreationLimit.put(group, config.getConfiguration().getInt("Settings.PlayerCreationLimit." + group));
@@ -0,0 +1,70 @@
/*
* This file is part of ImageFrame.
*
* Copyright (C) 2025. LoohpJames <jamesloohp@gmail.com>
* Copyright (C) 2025. Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.loohp.imageframe.objectholders;
import com.loohp.imageframe.media.MediaFrame;
import com.loohp.imageframe.storage.ImageFrameStorage;
import com.loohp.imageframe.utils.GifReader;
import org.bukkit.map.MapCursor;
import org.bukkit.map.MapView;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Animated (GIF) counterpart of {@link BundledStaticImageMap}: the GIF is a
* jar resource and the Minecraft map ids are pinned in maps.yml.
*/
public class BundledAnimatedImageMap extends URLAnimatedImageMap implements BundledImageMap {
protected BundledAnimatedImageMap(ImageMapManager manager, ImageMapLoader<?, ?> loader, String name, String resourcePath, LazyMappedBufferedImage[][] cachedImages, List<MapView> mapViews, List<Integer> mapIds, List<Map<String, MapCursor>> mapMarkers, int width, int height, DitheringType ditheringType, long creationTime) {
super(manager, loader, -1, name, resourcePath, cachedImages, mapViews, mapIds, mapMarkers, width, height, ditheringType, ImageMap.CONSOLE_CREATOR, Collections.emptyMap(), creationTime, -1, 0);
}
@Override
public String getResourcePath() {
return url;
}
@Override
public void setUrl(String url) {
//bundled maps always render from their jar resource, the path cannot change at runtime
}
@Override
protected Iterator<MediaFrame> loadMedia() throws Exception {
List<GifReader.ImageFrame> frames = GifReader.readGif(BundledStaticImageMap.openResource(getResourcePath()), Long.MAX_VALUE).get();
return frames.stream().map(f -> MediaFrame.animatedFrame(f.getImage(), f.getDelay())).iterator();
}
@Override
public void save(ImageFrameStorage storage, boolean saveAsCopy) {
//the jar is the canonical storage, nothing to save
}
@Override
public ImageMap deepClone(String name, UUID creator) {
throw new UnsupportedOperationException("bundled maps cannot be cloned");
}
}
@@ -0,0 +1,98 @@
/*
* This file is part of ImageFrame.
*
* Copyright (C) 2025. LoohpJames <jamesloohp@gmail.com>
* Copyright (C) 2025. Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.loohp.imageframe.objectholders;
import com.google.gson.JsonObject;
import com.loohp.imageframe.utils.FutureUtils;
import net.kyori.adventure.key.Key;
import org.bukkit.map.MapCursor;
import org.bukkit.map.MapRenderer;
import org.bukkit.map.MapView;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
public class BundledAnimatedImageMapLoader extends ImageMapLoader<BundledAnimatedImageMap, BundledMapCreateInfo> {
private static final Key IDENTIFIER = Key.key("imageframe", "bundled_animated");
@Override
public Key getIdentifier() {
return IDENTIFIER;
}
@Override
public Class<BundledAnimatedImageMap> getImageMapClass() {
return BundledAnimatedImageMap.class;
}
@Override
public Class<BundledMapCreateInfo> getImageMapCreateInfoClass() {
return BundledMapCreateInfo.class;
}
@Override
public List<String> getExtraPermissions() {
return Collections.emptyList();
}
@Override
public boolean isSupported(String imageType) {
//bundled maps are only created from maps.yml in the jar, never through commands
return false;
}
@Override
public Future<BundledAnimatedImageMap> create(BundledMapCreateInfo createInfo) throws Exception {
int mapsCount = createInfo.getWidth() * createInfo.getHeight();
List<MapView> mapViews = BundledStaticImageMapLoader.getPinnedMapViews(createInfo);
List<Integer> mapIds = new ArrayList<>(mapsCount);
List<Map<String, MapCursor>> markers = new ArrayList<>(mapsCount);
for (MapView mapView : mapViews) {
mapIds.add(mapView.getId());
markers.add(new ConcurrentHashMap<>());
}
BundledAnimatedImageMap map = new BundledAnimatedImageMap(createInfo.getManager(), this, createInfo.getName(), createInfo.getResourcePath(), new LazyMappedBufferedImage[mapsCount][], mapViews, mapIds, markers, createInfo.getWidth(), createInfo.getHeight(), createInfo.getDitheringType(), createInfo.getCreationTime());
return FutureUtils.callAsyncMethod(() -> {
FutureUtils.callSyncMethod(() -> {
for (int i = 0; i < mapViews.size(); i++) {
MapView mapView = mapViews.get(i);
for (MapRenderer renderer : mapView.getRenderers()) {
mapView.removeRenderer(renderer);
}
mapView.addRenderer(new URLAnimatedImageMap.URLAnimatedImageMapRenderer(map, i));
}
}).get();
map.update(false);
return map;
});
}
@Override
public Future<BundledAnimatedImageMap> load(ImageMapManager manager, JsonObject json) {
throw new UnsupportedOperationException("bundled maps are loaded from maps.yml in the plugin jar, not from storage");
}
}
@@ -0,0 +1,32 @@
/*
* This file is part of ImageFrame.
*
* Copyright (C) 2025. LoohpJames <jamesloohp@gmail.com>
* Copyright (C) 2025. Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.loohp.imageframe.objectholders;
/**
* An image map whose canonical definition lives inside the plugin jar
* (maps.yml + an image under maps/). Bundled maps are rebuilt from the jar
* on every startup and are never persisted to the plugin data folder.
*/
public interface BundledImageMap {
String getResourcePath();
}
@@ -0,0 +1,417 @@
/*
* This file is part of ImageFrame.
*
* Copyright (C) 2025. LoohpJames <jamesloohp@gmail.com>
* Copyright (C) 2025. Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.loohp.imageframe.objectholders;
import com.loohp.imageframe.ImageFrame;
import com.loohp.imageframe.utils.MapUtils;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.YamlConfiguration;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* Loads the maps whose canonical definition is bundled inside the plugin jar.
* <p>
* Every image directly under maps/ in the jar becomes a Console owned map
* named after the file (without its extension); a .gif becomes an animated
* map. maps.yml can optionally add an entry per map to override its name,
* size, dithering or map ids. Anything not set there is decided once, on the
* first load:
* <ul>
* <li>If a stored Console map with the same name exists (for example one
* previewed from plugins/ImageFrame/images/), the bundled map takes over
* its size, dithering and map ids, and the stored copy is deleted.</li>
* <li>Otherwise the size is the image size divided by 128 pixels per map,
* and the server assigns new map ids.</li>
* </ul>
* Those decisions are remembered in the main world folder, so the maps keep
* their map ids even if the plugin data folder is wiped. MapIds pinned in
* maps.yml always win.
*/
public class BundledMaps {
public static final String MAPS_YML = "maps.yml";
public static final String MAPS_FOLDER = "maps/";
public static final String ASSIGNED_MAPS_FILE_NAME = "imageframe-bundled-maps.yml";
public static final Set<String> IMAGE_EXTENSIONS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("png", "jpg", "jpeg", "gif", "bmp", "webp")));
public static int load(ImageMapManager manager, File pluginJar) {
return new BundledMaps(manager, pluginJar).load();
}
private final ImageMapManager manager;
private final File pluginJar;
private final CommandSender console;
private BundledMaps(ImageMapManager manager, File pluginJar) {
this.manager = manager;
this.pluginJar = pluginJar;
this.console = Bukkit.getConsoleSender();
}
private int load() {
List<Definition> definitions = new ArrayList<>(readMapsYml());
Set<String> definedImages = new HashSet<>();
for (Definition definition : definitions) {
definedImages.add(definition.image);
}
for (String image : listBundledImages()) {
if (!definedImages.contains(image)) {
definitions.add(new Definition(getFileNameWithoutExtension(image), image));
}
}
// Pinned maps load first, so map ids the server assigns to other maps never collide with pinned ones
definitions.sort(Comparator.comparing(definition -> definition.pinnedMapIds == null));
World world = MapUtils.getMainWorld();
File assignedMapsFile = new File(world.getWorldFolder(), ASSIGNED_MAPS_FILE_NAME);
Map<String, Assigned> assignedMaps = readAssignedMaps(assignedMapsFile);
boolean assignedMapsChanged = false;
int count = 0;
for (Definition definition : definitions) {
String name = definition.name;
try {
if (name.isEmpty() || name.indexOf(':') >= 0 || name.chars().anyMatch(Character::isWhitespace)) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Skipping bundled image \"" + definition.image + "\": map names cannot contain spaces or colons, rename the file or set a Name in maps.yml");
continue;
}
// Validate the bundled image is present and decodable before touching
// any stored copy of this map, so a bad jar cannot destroy working maps.
BufferedImage image = BundledStaticImageMap.readResourceImage(definition.image);
ImageMap existing = manager.getFromCreator(ImageMap.CONSOLE_CREATOR, name);
if (existing instanceof BundledImageMap) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to load bundled ImageMap \"" + name + "\" from " + definition.image + ": " + ((BundledImageMap) existing).getResourcePath() + " already uses this name");
continue;
}
Assigned assigned = definition.pinnedMapIds == null ? assignedMaps.get(name) : null;
int width;
int height;
if (definition.width != null && definition.height != null) {
width = definition.width;
height = definition.height;
} else if (existing != null) {
width = existing.getWidth();
height = existing.getHeight();
} else if (assigned != null) {
width = assigned.width;
height = assigned.height;
} else {
width = Math.max(1, Math.round(image.getWidth() / (float) MapUtils.MAP_WIDTH));
height = Math.max(1, Math.round(image.getHeight() / (float) MapUtils.MAP_WIDTH));
if (width * height > ImageFrame.mapMaxSize) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Skipping bundled image \"" + definition.image + "\": at " + MapUtils.MAP_WIDTH + " pixels per map it would be " + width + "x" + height + " maps, over the MaxSize of " + ImageFrame.mapMaxSize + ". Shrink the image or set Width and Height in maps.yml");
continue;
}
}
if (width <= 0 || height <= 0) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to load bundled ImageMap \"" + name + "\": Width and Height must be positive (got " + width + "x" + height + ")");
continue;
}
DitheringType ditheringType;
if (definition.ditheringType != null) {
ditheringType = definition.ditheringType;
} else if (existing != null) {
ditheringType = existing.getDitheringType();
} else if (assigned != null) {
ditheringType = DitheringType.fromName(assigned.ditheringType);
} else {
ditheringType = DitheringType.fromName(null);
}
long creationTime;
if (definition.creationTime != null) {
creationTime = definition.creationTime;
} else if (existing != null) {
creationTime = existing.getCreationTime();
} else if (assigned != null) {
creationTime = assigned.creationTime;
} else {
creationTime = System.currentTimeMillis();
}
List<Integer> mapIds;
if (definition.pinnedMapIds != null) {
mapIds = definition.pinnedMapIds;
if (mapIds.size() != width * height) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to load bundled ImageMap \"" + name + "\": MapIds must contain exactly Width * Height (" + (width * height) + ") map ids");
continue;
}
} else if (existing != null) {
if (existing.getMapIds().size() != width * height) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Bundled ImageMap \"" + name + "\" is set to " + width + "x" + height + " in maps.yml but the copy in storage is " + existing.getWidth() + "x" + existing.getHeight() + ", keeping the stored copy. Remove Width and Height from maps.yml to migrate it.");
continue;
}
mapIds = new ArrayList<>(existing.getMapIds());
} else if (assigned != null && assigned.mapIds.size() == width * height) {
mapIds = assigned.mapIds;
} else {
mapIds = new ArrayList<>(width * height);
for (int i = 0; i < width * height; i++) {
mapIds.add(MapUtils.createMap(world).get().getId());
}
}
if (existing != null) {
if (!existing.getMapIds().equals(mapIds)) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Bundled ImageMap \"" + name + "\" has different map ids than the copy in storage, keeping the stored copy. Remove MapIds from maps.yml, or set them to " + existing.getMapIds() + ", to migrate it.");
continue;
}
// Same name and same map ids: the jar is now the canonical
// definition, so drop the copy from the data folder.
manager.deleteMap(existing.getImageIndex());
console.sendMessage(ChatColor.YELLOW + "[ImageFrame] Migrated ImageMap \"" + name + "\" to the bundled definition in the plugin jar, its data folder copy has been removed");
}
if (definition.pinnedMapIds != null) {
if (assignedMaps.remove(name) != null) {
assignedMapsChanged = true;
}
} else {
Assigned decided = new Assigned(width, height, ditheringType.getName(), mapIds, creationTime);
if (!decided.equals(assigned)) {
assignedMaps.put(name, decided);
assignedMapsChanged = true;
console.sendMessage(ChatColor.YELLOW + "[ImageFrame] Bundled ImageMap \"" + name + "\" uses map ids " + mapIds + ", saved to " + assignedMapsFile.getPath());
}
}
BundledMapCreateInfo createInfo = new BundledMapCreateInfo(manager, name, definition.image, width, height, ditheringType, mapIds, creationTime);
boolean animated = definition.image.toLowerCase().endsWith(".gif");
ImageMap imageMap;
if (animated) {
imageMap = ImageMapLoaders.BUNDLED_ANIMATED_IMAGE_MAP_LOADER.create(createInfo).get();
} else {
imageMap = ImageMapLoaders.BUNDLED_STATIC_IMAGE_MAP_LOADER.create(createInfo).get();
}
manager.addBundledMap(imageMap);
count++;
console.sendMessage(ChatColor.GREEN + "[ImageFrame] Loaded bundled " + (animated ? "animated " : "") + "ImageMap \"" + name + "\" (" + width + "x" + height + ", map ids " + mapIds.get(0) + "-" + mapIds.get(mapIds.size() - 1) + ") from the plugin jar");
} catch (Exception e) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to load bundled ImageMap \"" + name + "\" from the plugin jar");
new IOException("Unable to load bundled ImageMap \"" + name + "\" from the plugin jar", e).printStackTrace();
}
}
if (assignedMapsChanged) {
saveAssignedMaps(assignedMapsFile, assignedMaps);
}
return count;
}
private List<Definition> readMapsYml() {
InputStream stream = ImageFrame.plugin.getClass().getClassLoader().getResourceAsStream(MAPS_YML);
if (stream == null) {
return Collections.emptyList();
}
YamlConfiguration mapsYml;
try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
mapsYml = YamlConfiguration.loadConfiguration(reader);
} catch (IOException e) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to read bundled maps.yml from the plugin jar");
e.printStackTrace();
return Collections.emptyList();
}
List<Definition> definitions = new ArrayList<>();
for (Map<?, ?> entry : mapsYml.getMapList("Maps")) {
Object image = entry.get("Image");
if (image == null) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Skipping invalid maps.yml entry (missing Image): " + entry);
continue;
}
String imagePath = image.toString();
Definition definition = new Definition(entry.get("Name") == null ? getFileNameWithoutExtension(imagePath) : entry.get("Name").toString(), imagePath);
definition.width = entry.get("Width") instanceof Number ? ((Number) entry.get("Width")).intValue() : null;
definition.height = entry.get("Height") instanceof Number ? ((Number) entry.get("Height")).intValue() : null;
if ((definition.width == null) != (definition.height == null)) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Skipping maps.yml entry for " + imagePath + ": set both Width and Height, or neither");
continue;
}
definition.ditheringType = entry.get("Dithering") == null ? null : DitheringType.fromName(entry.get("Dithering").toString());
definition.pinnedMapIds = entry.get("MapIds") instanceof List ? parseMapIds(entry.get("MapIds")) : null;
definition.creationTime = entry.get("CreationTime") instanceof Number ? ((Number) entry.get("CreationTime")).longValue() : null;
definitions.add(definition);
}
return definitions;
}
private List<String> listBundledImages() {
List<String> images = new ArrayList<>();
if (pluginJar == null || !pluginJar.isFile()) {
return images;
}
try (ZipFile zip = new ZipFile(pluginJar)) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String path = entry.getName();
if (entry.isDirectory() || !path.startsWith(MAPS_FOLDER) || path.indexOf('/', MAPS_FOLDER.length()) >= 0) {
continue;
}
int dot = path.lastIndexOf('.');
if (dot > MAPS_FOLDER.length() && IMAGE_EXTENSIONS.contains(path.substring(dot + 1).toLowerCase())) {
images.add(path);
}
}
} catch (IOException e) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to list bundled images in the plugin jar");
e.printStackTrace();
}
// Sorted so the server assigns map ids in a predictable order
Collections.sort(images);
return images;
}
private Map<String, Assigned> readAssignedMaps(File file) {
Map<String, Assigned> assignedMaps = new LinkedHashMap<>();
if (!file.exists()) {
return assignedMaps;
}
for (Map<?, ?> entry : YamlConfiguration.loadConfiguration(file).getMapList("Maps")) {
if (entry.get("Name") == null || !(entry.get("Width") instanceof Number) || !(entry.get("Height") instanceof Number)) {
continue;
}
int width = ((Number) entry.get("Width")).intValue();
int height = ((Number) entry.get("Height")).intValue();
String ditheringType = entry.get("Dithering") == null ? null : entry.get("Dithering").toString();
long creationTime = entry.get("CreationTime") instanceof Number ? ((Number) entry.get("CreationTime")).longValue() : System.currentTimeMillis();
assignedMaps.put(entry.get("Name").toString(), new Assigned(width, height, ditheringType, parseMapIds(entry.get("MapIds")), creationTime));
}
return assignedMaps;
}
private void saveAssignedMaps(File file, Map<String, Assigned> assignedMaps) {
YamlConfiguration yaml = new YamlConfiguration();
List<Map<String, Object>> entries = new ArrayList<>(assignedMaps.size());
for (Map.Entry<String, Assigned> assigned : assignedMaps.entrySet()) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("Name", assigned.getKey());
entry.put("Width", assigned.getValue().width);
entry.put("Height", assigned.getValue().height);
entry.put("Dithering", assigned.getValue().ditheringType);
entry.put("MapIds", assigned.getValue().mapIds);
entry.put("CreationTime", assigned.getValue().creationTime);
entries.add(entry);
}
yaml.set("Maps", entries);
String header = "# Written by ImageFrame. Size, dithering and map ids of the bundled maps that do not\n" +
"# pin MapIds in maps.yml, decided the first time each map loaded. Item frames in this\n" +
"# world show these map ids, so keep this file with the world and do not edit the ids.\n";
try {
Files.write(file.toPath(), (header + yaml.saveToString()).getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
console.sendMessage(ChatColor.RED + "[ImageFrame] Unable to save " + file.getPath());
e.printStackTrace();
}
}
private static List<Integer> parseMapIds(Object value) {
List<Integer> mapIds = new ArrayList<>();
if (value instanceof List) {
for (Object id : (List<?>) value) {
if (id instanceof Number) {
mapIds.add(((Number) id).intValue());
}
}
}
return mapIds;
}
private static String getFileNameWithoutExtension(String path) {
String fileName = path.substring(path.lastIndexOf('/') + 1);
int dot = fileName.lastIndexOf('.');
return dot > 0 ? fileName.substring(0, dot) : fileName;
}
private static class Definition {
private final String name;
private final String image;
private Integer width;
private Integer height;
private DitheringType ditheringType;
private List<Integer> pinnedMapIds;
private Long creationTime;
private Definition(String name, String image) {
this.name = name;
this.image = image;
}
}
private static class Assigned {
private final int width;
private final int height;
private final String ditheringType;
private final List<Integer> mapIds;
private final long creationTime;
private Assigned(int width, int height, String ditheringType, List<Integer> mapIds, long creationTime) {
this.width = width;
this.height = height;
this.ditheringType = ditheringType;
this.mapIds = mapIds;
this.creationTime = creationTime;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Assigned)) {
return false;
}
Assigned assigned = (Assigned) o;
return width == assigned.width && height == assigned.height && creationTime == assigned.creationTime && Objects.equals(ditheringType, assigned.ditheringType) && mapIds.equals(assigned.mapIds);
}
@Override
public int hashCode() {
return Objects.hash(width, height, ditheringType, mapIds, creationTime);
}
}
}
@@ -43,13 +43,18 @@ import java.util.UUID;
* maps.yml. Bundled maps are re-rendered from the jar on every startup
* and are never persisted to the plugin data folder.
*/
public class BundledStaticImageMap extends URLStaticImageMap {
public class BundledStaticImageMap extends URLStaticImageMap implements BundledImageMap {
public static InputStream openResource(String resourcePath) throws IOException {
InputStream stream = ImageFrame.plugin.getClass().getClassLoader().getResourceAsStream(resourcePath);
if (stream == null) {
throw new IOException("Missing bundled image resource in jar: " + resourcePath);
}
return stream;
}
public static BufferedImage readResourceImage(String resourcePath) throws IOException {
try (InputStream stream = ImageFrame.plugin.getClass().getClassLoader().getResourceAsStream(resourcePath)) {
if (stream == null) {
throw new IOException("Missing bundled image resource in jar: " + resourcePath);
}
try (InputStream stream = openResource(resourcePath)) {
BufferedImage image = ImageIO.read(stream);
if (image == null) {
throw new IOException("Unable to decode bundled image resource in jar: " + resourcePath);
@@ -62,6 +67,7 @@ public class BundledStaticImageMap extends URLStaticImageMap {
super(manager, loader, -1, name, resourcePath, cachedImages, mapViews, mapIds, mapMarkers, width, height, ditheringType, ImageMap.CONSOLE_CREATOR, Collections.emptyMap(), creationTime);
}
@Override
public String getResourcePath() {
return url;
}
@@ -66,8 +66,11 @@ public class BundledStaticImageMapLoader extends ImageMapLoader<BundledStaticIma
return false;
}
@Override
public Future<BundledStaticImageMap> create(BundledMapCreateInfo createInfo) throws Exception {
/**
* Resolves the pinned map ids of a bundled map to their map views,
* creating any map id that does not exist in the world yet.
*/
public static List<MapView> getPinnedMapViews(BundledMapCreateInfo createInfo) throws Exception {
int mapsCount = createInfo.getWidth() * createInfo.getHeight();
List<Integer> pinnedMapIds = createInfo.getMapIds();
if (pinnedMapIds.size() != mapsCount) {
@@ -75,17 +78,25 @@ public class BundledStaticImageMapLoader extends ImageMapLoader<BundledStaticIma
}
World world = MapUtils.getMainWorld();
List<Future<MapView>> mapViewsFuture = new ArrayList<>(mapsCount);
List<Map<String, MapCursor>> markers = new ArrayList<>(mapsCount);
for (int mapId : pinnedMapIds) {
mapViewsFuture.add(MapUtils.getMapOrCreateMissing(world, mapId));
markers.add(new ConcurrentHashMap<>());
}
List<MapView> mapViews = new ArrayList<>(mapsCount);
List<Integer> mapIds = new ArrayList<>(mapsCount);
for (Future<MapView> future : mapViewsFuture) {
MapView mapView = future.get();
mapViews.add(mapView);
mapViews.add(future.get());
}
return mapViews;
}
@Override
public Future<BundledStaticImageMap> create(BundledMapCreateInfo createInfo) throws Exception {
int mapsCount = createInfo.getWidth() * createInfo.getHeight();
List<MapView> mapViews = getPinnedMapViews(createInfo);
List<Integer> mapIds = new ArrayList<>(mapsCount);
List<Map<String, MapCursor>> markers = new ArrayList<>(mapsCount);
for (MapView mapView : mapViews) {
mapIds.add(mapView.getId());
markers.add(new ConcurrentHashMap<>());
}
BundledStaticImageMap map = new BundledStaticImageMap(createInfo.getManager(), this, createInfo.getName(), createInfo.getResourcePath(), new LazyMappedBufferedImage[mapsCount], mapViews, mapIds, markers, createInfo.getWidth(), createInfo.getHeight(), createInfo.getDitheringType(), createInfo.getCreationTime());
return FutureUtils.callAsyncMethod(() -> {
@@ -46,6 +46,7 @@ public class ImageMapLoaders {
public static final MinecraftURLOverlayImageMapLoader MINECRAFT_URL_OVERLAY_IMAGE_MAP_LOADER = registerLoader(new MinecraftURLOverlayImageMapLoader());
public static final NonUpdatableStaticImageMapLoader NON_UPDATABLE_IMAGE_MAP_LOADER = registerLoader(new NonUpdatableStaticImageMapLoader());
public static final BundledStaticImageMapLoader BUNDLED_STATIC_IMAGE_MAP_LOADER = registerLoader(new BundledStaticImageMapLoader());
public static final BundledAnimatedImageMapLoader BUNDLED_ANIMATED_IMAGE_MAP_LOADER = registerLoader(new BundledAnimatedImageMapLoader());
public static final GifReaderMediaLoader GIF_READER_MEDIA_LOADER = registerLoader(new GifReaderMediaLoader(), URL_ANIMATED_IMAGE_MAP_LOADER);
public static final ImageIOMediaLoader IMAGE_IO_MEDIA_LOADER = registerLoader(new ImageIOMediaLoader(), URL_STATIC_IMAGE_MAP_LOADER, MINECRAFT_URL_OVERLAY_IMAGE_MAP_LOADER);
@@ -23,6 +23,7 @@ package com.loohp.imageframe.objectholders;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.loohp.imageframe.api.events.ImageMapUpdatedEvent;
import com.loohp.imageframe.media.MediaFrame;
import com.loohp.imageframe.media.TimedMediaFrameIterator;
import com.loohp.imageframe.storage.ImageFrameStorage;
import com.loohp.imageframe.utils.CollectionUtils;
@@ -38,6 +39,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -139,9 +141,13 @@ public class URLAnimatedImageMap extends URLImageMap {
cachedColors = null;
}
protected Iterator<MediaFrame> loadMedia() throws Exception {
return loader.tryLoadMedia(url);
}
@Override
public void update(boolean save) throws Exception {
List<BufferedImage> images = CollectionUtils.toList(new TimedMediaFrameIterator(loader.tryLoadMedia(url), 50));
List<BufferedImage> images = CollectionUtils.toList(new TimedMediaFrameIterator(loadMedia(), 50));
for (int i = 0; i < cachedImages.length; i++) {
cachedImages[i] = new LazyMappedBufferedImage[images.size()];
}
@@ -0,0 +1,106 @@
/*
* This file is part of ImageFrame.
*
* Copyright (C) 2025. LoohpJames <jamesloohp@gmail.com>
* Copyright (C) 2025. Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.loohp.imageframe.upload;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Images placed in plugins/ImageFrame/images/ can be used by file name
* wherever a command used to take an image URL. Files are read when the
* command runs, so new or edited images are picked up without a restart.
*/
public class LocalImageFolder {
private final File folder;
public LocalImageFolder(File folder) {
this.folder = folder;
this.folder.mkdirs();
}
public File getFolder() {
return folder;
}
public File getImageFile(String fileName) {
if (fileName == null || fileName.isEmpty() || fileName.contains("/") || fileName.contains("\\")) {
return null;
}
File file = new File(folder, fileName);
return file.isFile() && isInFolder(file) ? file : null;
}
/**
* Returns the file URL of the image when source names a file in the folder,
* otherwise returns source unchanged.
*/
public String resolve(String source) {
File file = getImageFile(source);
if (file == null) {
return source;
}
try {
return file.toURI().toURL().toString();
} catch (MalformedURLException e) {
return source;
}
}
public boolean isImageFileUrl(String url) {
try {
File file = Paths.get(new URL(url).toURI()).toFile();
return file.isFile() && isInFolder(file);
} catch (Exception e) {
return false;
}
}
public List<String> getImageFileNames() {
File[] files = folder.listFiles();
if (files == null) {
return Collections.emptyList();
}
List<String> names = new ArrayList<>();
for (File file : files) {
if (file.isFile() && !file.isHidden() && isInFolder(file)) {
names.add(file.getName());
}
}
Collections.sort(names);
return names;
}
private boolean isInFolder(File file) {
try {
return folder.getCanonicalFile().equals(file.getCanonicalFile().getParentFile());
} catch (IOException e) {
return false;
}
}
}
-6
View File
@@ -11,12 +11,6 @@ Settings:
RequireEmptyMaps: true
# Max size of an image map
MaxSize: 100
# When enabled, Only websites from the list below is allowed to be shown through in-game maps
RestrictImageUrl:
Enabled: true
Whitelist:
- https://true-og.net
- http://trueog.net
# If the image is larger than the defined size, it won't be downloaded for preview
# (In Bytes)
MaxImageFileSize: 52428800
+29 -18
View File
@@ -1,25 +1,36 @@
# Canonical image maps bundled inside the plugin jar.
#
# Each entry pairs an image shipped in the jar (under maps/) with the exact
# Minecraft map ids it renders to, so the maps survive wipes of the plugin
# data folder and can be rebuilt from the jar alone - the same way
# LaunchPads-OG preserves pad locations in its config.
# You do not need an entry here to add a map: every image placed directly in
# maps/ (png, jpg, gif, bmp, webp) is bundled automatically, named after its
# file without the extension. A .gif becomes an animated map. The first time
# it loads, the server decides the rest:
# - if a Console map with the same name is stored (e.g. one previewed with
# "imageframe create Console:<name> <file> <width> <height>" from
# plugins/ImageFrame/images/), the bundled map takes over its size,
# dithering and map ids, and the stored copy is removed
# - otherwise the size is the image size at 128 pixels per map (512x384
# becomes 4x3) and the server assigns new map ids
# Those choices are saved to imageframe-bundled-maps.yml in the main world
# folder, so they stay the same on every restart and survive wipes of the
# plugin data folder.
#
# Fields:
# Name - unique map name (owned by the Console creator)
# Image - path of the image inside the jar
# Width/Height - size in maps (each map is 128x128 pixels)
# Dithering - "nearest-color" or "floyd-steinberg"
# MapIds - Minecraft map ids, row-major (left to right, top to bottom).
# Must contain exactly Width * Height ids. These are pinned:
# item frames in the world reference these ids, so never
# change them for an existing map.
# CreationTime - optional, epoch milliseconds (display only)
# Add an entry below only to override something. Every field except Image is
# optional:
# Image - path of the image inside the jar, e.g. maps/poster.png
# Name - map name, defaults to the file name without its extension
# (owned by the Console creator)
# Width/Height - size in maps (each map is 128x128 pixels), set both or
# neither. Changing the size of a loaded map gives it new
# map ids, so item frames already showing it go blank
# Dithering - "nearest-color" (default) or "floyd-steinberg"
# MapIds - pin exact Minecraft map ids, row-major (left to right, top
# to bottom), exactly Width * Height of them, with Width and
# Height set. Item frames in the world reference these ids,
# so never change them for an existing map
# CreationTime - epoch milliseconds (display only)
#
# Maps listed here are read-only at runtime: they are re-rendered from the
# bundled image on every startup and are never written to the data folder.
# On first load after migrating, any legacy copy of the same map (matching
# name and map ids) in the data folder is deleted automatically.
# Bundled maps are read-only at runtime: they are re-rendered from the jar on
# every startup and are never written to the plugin data folder.
Maps:
- Name: icetrack
Image: maps/icetrack.png