fix image preview and add option for automatically stop auto screenshoting when idle
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user