diff --git a/deploy/portainer-stack.yml b/deploy/portainer-stack.yml
index 26b5188..4785f93 100644
--- a/deploy/portainer-stack.yml
+++ b/deploy/portainer-stack.yml
@@ -1,6 +1,6 @@
services:
beacon-server:
- image: beacon-server:0.1.12
+ image: beacon-server:0.1.13
restart: always
environment:
ASPNETCORE_ENVIRONMENT: Production
@@ -59,7 +59,7 @@ services:
/bin/sh -c '(while sleep 86400; do nginx -s reload; done) & exec nginx -g "daemon off;"'
beacon-backup:
- image: beacon-backup:0.1.12
+ image: beacon-backup:0.1.13
restart: always
environment:
BACKUP_INTERVAL_SECONDS: "86400"
diff --git a/src/Beacon.Plugin/Beacon.Plugin.csproj b/src/Beacon.Plugin/Beacon.Plugin.csproj
index 2023ee6..e15bcec 100644
--- a/src/Beacon.Plugin/Beacon.Plugin.csproj
+++ b/src/Beacon.Plugin/Beacon.Plugin.csproj
@@ -4,7 +4,7 @@
Beacon
Beacon
- 0.1.12.0
+ 0.1.13.0
enable
diff --git a/src/Beacon.Plugin/UI/ProfileCard.cs b/src/Beacon.Plugin/UI/ProfileCard.cs
index 9a8103a..a3dbd63 100644
--- a/src/Beacon.Plugin/UI/ProfileCard.cs
+++ b/src/Beacon.Plugin/UI/ProfileCard.cs
@@ -40,6 +40,11 @@ public sealed class ProfileCard(ImageCache images)
///
public IDalamudTextureWrap? PendingPortrait { get; set; }
+ /// The crop being applied to , so the preview matches the editor.
+ public Vector2 PendingPortraitUv0 { get; set; } = Vector2.Zero;
+
+ public Vector2 PendingPortraitUv1 { get; set; } = Vector2.One;
+
/// Draws the card face.
public void Draw(ProfileDto profile, float scale) => DrawCardContents(profile, scale);
@@ -250,14 +255,21 @@ private void DrawPortrait(ProfileDto profile, float size, float scale)
var origin = ImGui.GetCursorScreenPos();
var draw = ImGui.GetWindowDrawList();
- var box = new Vector2(origin.X + size, origin.Y + (size * 1.25f));
+ var height = size / ProfileLimits.PortraitAspect;
+ var box = new Vector2(origin.X + size, origin.Y + height);
var portrait = PendingPortrait
?? (profile.HasPortrait ? images.Get(profile.PortraitImageId!.Value, thumb: false) : null);
if (portrait is not null)
{
- draw.AddImage(portrait.Handle, origin, box);
+ // A stored portrait is already cropped to shape; a pending one is the raw file and carries
+ // the crop the editor is previewing.
+ var (uv0, uv1) = ReferenceEquals(portrait, PendingPortrait)
+ ? (PendingPortraitUv0, PendingPortraitUv1)
+ : (Vector2.Zero, Vector2.One);
+
+ draw.AddImage(portrait.Handle, origin, box, uv0, uv1);
}
else if (profile.HasPortrait)
{
@@ -266,7 +278,7 @@ private void DrawPortrait(ProfileDto profile, float size, float scale)
const string Fetching = "Loading...";
var fetchingSize = ImGui.CalcTextSize(Fetching);
draw.AddText(
- new Vector2(origin.X + ((size - fetchingSize.X) / 2f), origin.Y + ((size * 1.25f - fetchingSize.Y) / 2f)),
+ new Vector2(origin.X + ((size - fetchingSize.X) / 2f), origin.Y + ((height - fetchingSize.Y) / 2f)),
Theme.InkFaint.Packed(),
Fetching);
}
@@ -276,7 +288,7 @@ private void DrawPortrait(ProfileDto profile, float size, float scale)
const string Label = "No likeness";
var textSize = ImGui.CalcTextSize(Label);
draw.AddText(
- new Vector2(origin.X + ((size - textSize.X) / 2f), origin.Y + ((size * 1.25f - textSize.Y) / 2f)),
+ new Vector2(origin.X + ((size - textSize.X) / 2f), origin.Y + ((height - textSize.Y) / 2f)),
Theme.InkFaint.Packed(),
Label);
}
@@ -286,7 +298,7 @@ private void DrawPortrait(ProfileDto profile, float size, float scale)
// Clicking the likeness opens it at a size you can actually look at.
if (portrait is not null && profile.HasPortrait)
{
- ImGui.InvisibleButton($"##portrait{profile.Id}", new Vector2(size, size * 1.25f));
+ ImGui.InvisibleButton($"##portrait{profile.Id}", new Vector2(size, height));
if (ImGui.IsItemHovered())
{
@@ -299,7 +311,7 @@ private void DrawPortrait(ProfileDto profile, float size, float scale)
OpenImage?.Invoke(profile.PortraitImageId!.Value);
}
else
- ImGui.Dummy(new Vector2(size, size * 1.25f));
+ ImGui.Dummy(new Vector2(size, height));
if (profile.Gallery.Count > 1)
{
diff --git a/src/Beacon.Plugin/Windows/ChronicleWindow.cs b/src/Beacon.Plugin/Windows/ChronicleWindow.cs
index 333c12c..2cb1dd8 100644
--- a/src/Beacon.Plugin/Windows/ChronicleWindow.cs
+++ b/src/Beacon.Plugin/Windows/ChronicleWindow.cs
@@ -368,7 +368,17 @@ private void DrawThumbnail(ImDrawListPtr draw, ProfileDto profile, Vector2 at, f
if (profile.HasPortrait && images.Get(profile.PortraitImageId!.Value) is { } texture)
{
- draw.AddImage(texture.Handle, at, box);
+ // The row is square and a portrait is not, so take the middle of it rather than squashing
+ // a face to fit.
+ var visible = ProfileLimits.PortraitAspect;
+ var inset = (1f - visible) / 2f;
+
+ draw.AddImage(
+ texture.Handle,
+ at,
+ box,
+ new Vector2(0f, inset),
+ new Vector2(1f, 1f - inset));
}
else
{
diff --git a/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs b/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs
index 8d3518d..11612aa 100644
--- a/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs
+++ b/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs
@@ -19,9 +19,10 @@ namespace Beacon.Windows;
///
public sealed class ProfileEditorWindow : Window, IDisposable
{
- private const float PortraitAspect = 5f / 6f;
- private const int PortraitOutputWidth = 600;
- private const int PortraitOutputHeight = 720;
+ // One shape for the crop preview, the stored image and the card.
+ private const float PortraitAspect = ProfileLimits.PortraitAspect;
+ private const int PortraitOutputWidth = ProfileLimits.PortraitWidth;
+ private const int PortraitOutputHeight = ProfileLimits.PortraitHeight;
private const int PortraitSourceMaxBytes = 25 * 1024 * 1024;
private const long PortraitSourceMaxPixels = 50_000_000;
@@ -905,8 +906,16 @@ private void DrawPreview(float scale)
Theme.PushPage();
if (ImGui.BeginChild("##previewPage", new Vector2(0, height), true))
{
- // The picture being considered, not the one it is about to replace.
+ // The picture being considered, not the one it is about to replace -- carrying the crop
+ // the editor is showing, so the preview frames it exactly as the upload will.
preview.PendingPortrait = pendingPortraitTexture;
+
+ if (pendingPortraitTexture is not null)
+ {
+ var (uv0, uv1) = CropUvs(pendingPortraitTexture);
+ preview.PendingPortraitUv0 = uv0;
+ preview.PendingPortraitUv1 = uv1;
+ }
preview.Draw(BuildDraft(), scale);
}
diff --git a/src/Beacon.Shared/Profiles/ProfileLimits.cs b/src/Beacon.Shared/Profiles/ProfileLimits.cs
index 8be5c33..fc856aa 100644
--- a/src/Beacon.Shared/Profiles/ProfileLimits.cs
+++ b/src/Beacon.Shared/Profiles/ProfileLimits.cs
@@ -75,6 +75,20 @@ public static class ProfileLimits
/// All four mature themes may be set; the cap exists only to bound a malformed request.
public const int MaxMatureThemes = 4;
+ ///
+ /// The shape of a portrait, in one place.
+ ///
+ /// The crop preview, the stored image and the card all have to agree on this. When they did not,
+ /// a portrait cropped to 5:6 was drawn into a 4:5 frame and every face on every card was quietly
+ /// stretched by four per cent -- which is exactly the kind of wrongness nobody can name but
+ /// everybody can see.
+ ///
+ public const int PortraitWidth = 600;
+ public const int PortraitHeight = 720;
+
+ /// Width over height, so a frame is width / PortraitAspect tall.
+ public const float PortraitAspect = PortraitWidth / (float)PortraitHeight;
+
/// Gallery size. Enough for a proper reference sheet, bounded enough to host.
public const int MaxGalleryImages = 8;
public const int CaptionMaxLength = 80;
diff --git a/tools/verify-release.ps1 b/tools/verify-release.ps1
index de561d9..42f32a2 100644
--- a/tools/verify-release.ps1
+++ b/tools/verify-release.ps1
@@ -61,8 +61,8 @@ try {
foreach ($required in @(
'CreateFromImageAsync',
'CropToPngAsync',
- 'PortraitOutputWidth = 600',
- 'PortraitOutputHeight = 720',
+ 'PortraitOutputWidth = ProfileLimits.PortraitWidth',
+ 'PortraitOutputHeight = ProfileLimits.PortraitHeight',
'PortraitSourceMaxBytes = 25 * 1024 * 1024',
'draw.AddImage(texture.Handle, origin, box, uv0, uv1)'
)) {
@@ -71,6 +71,25 @@ try {
}
}
+ # The crop preview, the stored image and the card have to frame a portrait identically. When they
+ # did not, every face on every card was drawn 4% taller than it was cropped, which is small enough
+ # to go unnamed and large enough to be seen. Guarded here because three files have to agree.
+ $profileLimits = Get-Content -LiteralPath 'src/Beacon.Shared/Profiles/ProfileLimits.cs' -Raw
+ foreach ($required in @(
+ 'PortraitWidth = 600',
+ 'PortraitHeight = 720',
+ 'PortraitAspect = PortraitWidth / (float)PortraitHeight'
+ )) {
+ if ($profileLimits.IndexOf($required, [StringComparison]::Ordinal) -lt 0) {
+ throw "The shared portrait shape is incomplete: $required"
+ }
+ }
+
+ $profileCard = Get-Content -LiteralPath 'src/Beacon.Plugin/UI/ProfileCard.cs' -Raw
+ if ($profileCard.IndexOf('size / ProfileLimits.PortraitAspect', [StringComparison]::Ordinal) -lt 0) {
+ throw 'The card must frame a portrait at the shape it is cropped to, not a hardcoded ratio.'
+ }
+
$screenshotService = Get-Content -LiteralPath 'src/Beacon.Plugin/Services/ScreenshotService.cs' -Raw
foreach ($required in @('CreateFromExistingTextureAsync', 'Uv0 = uv0', 'Uv1 = uv1', 'NewWidth = width', 'NewHeight = height')) {
if ($screenshotService.IndexOf($required, [StringComparison]::Ordinal) -lt 0) {