fix image preview and add option for automatically stop auto screenshoting when idle

This commit is contained in:
iceBear67
2026-08-07 19:00:20 +00:00
parent 014b18f1ba
commit 30d124fd8b
22 changed files with 641 additions and 32 deletions
@@ -0,0 +1,39 @@
package dev.photosync.core.capture;
/**
* Where a screenshot would be taken from, and which way it would be looking.
*
* <p>Five plain numbers rather than anything belonging to the game, so that the
* automatic-capture timer can tell whether the view has changed without this
* module knowing what a camera is. The platform fills it in from whatever entity
* the camera is attached to, which is the player except in spectator mode.
*/
public record CameraPose(double x, double y, double z, float yaw, float pitch) {
/**
* A player standing still is not perfectly still -- riding a boat, bobbing in
* water or resting on a slime block all jitter the last decimals -- so
* "moved" needs a floor. Five centimetres and half a degree sit well below
* anything a player does on purpose: one sneaking tick covers 0.065 blocks,
* and a single pixel of mouse movement turns the view by more than half a
* degree at default sensitivity.
*/
private static final double POSITION_EPSILON = 0.05;
private static final float ROTATION_EPSILON = 0.5f;
/**
* Whether this is a different enough view from {@code other} to count as the
* player having moved -- walked, or looked somewhere else.
*
* <p>Callers compare against the last pose that differed rather than against
* the previous tick, so movement slower than the epsilon per tick still
* registers once it has accumulated.
*/
public boolean movedFrom(CameraPose other) {
return Math.abs(x - other.x) > POSITION_EPSILON
|| Math.abs(y - other.y) > POSITION_EPSILON
|| Math.abs(z - other.z) > POSITION_EPSILON
|| Math.abs(yaw - other.yaw) > ROTATION_EPSILON
|| Math.abs(pitch - other.pitch) > ROTATION_EPSILON;
}
}
@@ -14,7 +14,8 @@ public record AutoCaptureSettings(
int intervalSeconds,
String fileNameSuffix,
boolean onlyInWorld,
boolean skipWhenScreenOpen) {
boolean skipWhenScreenOpen,
boolean skipWhenStill) {
public static final int MIN_INTERVAL_SECONDS = 5;
public static final int MAX_INTERVAL_SECONDS = 3600;
@@ -26,6 +27,9 @@ public record AutoCaptureSettings(
.fileNameSuffix("_auto")
.onlyInWorld(true)
.skipWhenScreenOpen(true)
// On, because the alternative is a hundred identical photographs
// of wherever the player was standing when they went for lunch.
.skipWhenStill(true)
.build();
}
@@ -33,12 +33,15 @@ import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Immich, expressed in PhotoSync's terms.
@@ -61,14 +64,24 @@ public final class ImmichProvider implements PhotoProvider {
private static final Type BULK_RESULT = new TypeToken<List<ImmichDtos.BulkIdResponse>>() {
}.getType();
/**
* What to ask for when a tile in the grid needs filling, and what to ask for
* when one is opened, most detailed first. Both end in {@code preview}, the
* only rendition Immich always has. See {@link #thumbnail}.
*/
private static final List<Rendition> GRID_LADDER = List.of(Rendition.THUMBNAIL, Rendition.PREVIEW);
private static final List<Rendition> DETAIL_LADDER = List.of(Rendition.FULLSIZE, Rendition.PREVIEW);
private final ProviderDescriptor descriptor;
private final ImmichApi api;
/**
* Whether this server's small thumbnails are usable, learned from the first
* one we fetch. See {@link #thumbnail}.
* Renditions this server turned out not to serve in a form the game can
* decode. Learned from the first attempt and skipped from then on, so a
* server missing one costs a single wasted request per session rather than
* one per image. See {@link #thumbnail}.
*/
private volatile boolean smallThumbnails = true;
private final Set<Rendition> unusable = Collections.synchronizedSet(EnumSet.noneOf(Rendition.class));
ImmichProvider(ProviderDescriptor descriptor, ProviderConnection connection) {
this.descriptor = descriptor;
@@ -198,25 +211,65 @@ public final class ImmichProvider implements PhotoProvider {
return new BucketPage(bucket, assets);
}
/**
* Walks the ladder for the requested size, taking the first rendition that
* comes back as something the game can decode.
*
* <p>The rungs above the last are optional on a real server, so a failure
* there is not the caller's problem: it retires that rendition and drops to
* the next one. The last rung is {@code preview}, which every Immich
* installation serves and defaults to JPEG, so its failures are the asset's
* or the server's and go back to the caller unchanged.
*/
@Override
public byte[] thumbnail(String assetId, ThumbnailSize size) throws ProviderException {
// Videos have no still of their own to serve, but Immich renders one for
// them at the same endpoint -- which is exactly what a video tile needs.
String path = "/assets/" + assetId + "/thumbnail";
if (size == ThumbnailSize.DETAIL || !smallThumbnails) {
return api.getBytes(path, Map.of("size", "preview"));
List<Rendition> ladder = size == ThumbnailSize.DETAIL ? DETAIL_LADDER : GRID_LADDER;
for (Rendition rendition : ladder.subList(0, ladder.size() - 1)) {
if (unusable.contains(rendition)) {
continue;
}
try {
byte[] bytes = api.getBytes(path, Map.of("size", rendition.query()));
if (!isWebP(bytes)) {
return bytes;
}
retire(rendition, "it is WebP, which the game's image decoder does not read");
} catch (ProviderException e) {
switch (e.kind()) {
// Not generated, or this API key may not ask for it. Neither
// changes while the game is running, so stop asking.
case NOT_FOUND, AUTHENTICATION, PROTOCOL -> retire(rendition, e.getMessage());
// Asking a second time is precisely what we were told not to do.
case RATE_LIMITED -> throw e;
// Might be this asset, might be this minute. Take the smaller
// image now and try the big one again next time.
default -> log.debug("Immich would not serve the {} of {}: {}",
rendition.query(), assetId, e.getMessage());
}
}
}
byte[] small = api.getBytes(path, Map.of("size", "thumbnail"));
if (!isWebP(small)) {
return small;
Rendition floor = ladder.get(ladder.size() - 1);
byte[] bytes = api.getBytes(path, Map.of("size", floor.query()));
if (isWebP(bytes)) {
// Only reachable on a server whose preview format has been changed
// from the default. Nothing below this rung would help, so say what
// is wrong instead of handing the screen bytes it cannot draw.
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"This Immich server returns WebP previews, and Minecraft decodes only PNG and JPEG. "
+ "Set Administration -> Settings -> Image -> Preview format to JPEG.");
}
return bytes;
}
private void retire(Rendition rendition, String reason) {
if (unusable.add(rendition)) {
log.info("Not using Immich's {} images this session, because {}", rendition.query(), reason);
}
// Immich's default thumbnail format is WebP, which the game's image
// decoder cannot read; its previews default to JPEG, which it can. One
// wasted request per session buys correct tiles on those servers, and
// servers already configured for JPEG never take this branch.
log.info("This Immich server serves WebP thumbnails; falling back to previews for the grid");
smallThumbnails = false;
return api.getBytes(path, Map.of("size", "preview"));
}
/** RIFF container with a WEBP fourcc, per the WebP specification. */
@@ -226,6 +279,31 @@ public final class ImmichProvider implements PhotoProvider {
&& bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P';
}
/**
* The three sizes Immich renders an asset at, named as the {@code size} query
* parameter spells them.
*
* <p>Only {@code preview} can be relied on. {@code thumbnail} is 250px and
* WebP by default, which is both too small to open and undecodable here;
* {@code fullsize} is off by default, and where it is on it can redirect to
* a download endpoint that a restricted API key is not allowed to follow.
*/
private enum Rendition {
FULLSIZE("fullsize"),
PREVIEW("preview"),
THUMBNAIL("thumbnail");
private final String query;
Rendition(String query) {
this.query = query;
}
String query() {
return query;
}
}
@Override
public void close() {
// Nothing to release: java.net.http.HttpClient has no close() before
@@ -0,0 +1,57 @@
package dev.photosync.core.capture;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The thresholds decide whether a player is "still", which decides whether their
* screenshots folder fills up while they are away from the keyboard. Both
* mistakes are visible: too tight and a boat's bobbing counts as sightseeing,
* too loose and a step to one side does not.
*/
class CameraPoseTest {
private static final CameraPose RESTING = new CameraPose(100.0, 64.0, -20.0, 45.0f, 10.0f);
@Test
@DisplayName("an identical pose has not moved")
void identical() {
assertFalse(RESTING.movedFrom(RESTING));
}
@Test
@DisplayName("jitter below the thresholds is not movement")
void jitter() {
CameraPose bobbing = new CameraPose(100.02, 63.98, -20.01, 45.2f, 10.3f);
assertFalse(bobbing.movedFrom(RESTING));
}
@Test
@DisplayName("one sneaking tick is movement")
void walked() {
// A sneaking player covers 0.065 blocks per tick -- the slowest way to
// travel, and the closest a deliberate move comes to the threshold.
CameraPose crept = new CameraPose(100.065, 64.0, -20.0, 45.0f, 10.0f);
assertTrue(crept.movedFrom(RESTING));
}
@Test
@DisplayName("looking somewhere else is movement, without leaving the spot")
void lookedAround() {
assertTrue(new CameraPose(100.0, 64.0, -20.0, 46.0f, 10.0f).movedFrom(RESTING), "yaw");
assertTrue(new CameraPose(100.0, 64.0, -20.0, 45.0f, 11.0f).movedFrom(RESTING), "pitch");
}
@Test
@DisplayName("each axis is judged on its own")
void everyAxis() {
assertTrue(new CameraPose(101.0, 64.0, -20.0, 45.0f, 10.0f).movedFrom(RESTING), "x");
assertTrue(new CameraPose(100.0, 65.0, -20.0, 45.0f, 10.0f).movedFrom(RESTING), "y");
assertTrue(new CameraPose(100.0, 64.0, -21.0, 45.0f, 10.0f).movedFrom(RESTING), "z");
}
}