From 190b1a9b439a0bbedd5b90238688914e26c6c08c Mon Sep 17 00:00:00 2001 From: Shaala <156393271+ShaalaXIV@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:25:51 -0500 Subject: [PATCH 1/2] Frame portraits at the shape they are cropped to Every face on every card was stretched 4% taller than it was cropped. A portrait is cropped and stored at 600x720, which is 5:6, and the editor's crop preview framed it that way. The card framed it at width x 1.25, which is 4:5. The difference is small enough that nobody can name it and large enough that everybody can see it. The shape is now a single pair of numbers in ProfileLimits, and the crop preview, the stored image and the card all read it. Three places that had to agree and were never made to. Two related fixes fall out of it: - The Chronicle's list thumbnail drew a 5:6 portrait into a square box, which squashed it. It now takes the middle of the picture rather than crushing it. - A picture chosen but not yet uploaded was drawn in the preview without the crop the editor was showing, so the preview framed it differently from the upload. It now carries the crop. Co-Authored-By: Claude Opus 5 --- deploy/portainer-stack.yml | 4 ++-- src/Beacon.Plugin/Beacon.Plugin.csproj | 2 +- src/Beacon.Plugin/UI/ProfileCard.cs | 24 ++++++++++++++----- src/Beacon.Plugin/Windows/ChronicleWindow.cs | 12 +++++++++- .../Windows/ProfileEditorWindow.cs | 17 +++++++++---- src/Beacon.Shared/Profiles/ProfileLimits.cs | 14 +++++++++++ 6 files changed, 59 insertions(+), 14 deletions(-) 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; From 8e9e30cd622c9752aee8d88bba1b127c9b408b1e Mon Sep 17 00:00:00 2001 From: Shaala <156393271+ShaalaXIV@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:27:56 -0500 Subject: [PATCH 2/2] Guard the shared portrait shape in the release gate The gate asserted the crop workflow by matching the literals 600 and 720 in the editor, so moving them into ProfileLimits read as the workflow being torn out. It now asserts what actually has to hold: the shared constants exist with those values, the editor reads them, and the card frames a portrait at ProfileLimits.PortraitAspect rather than a ratio of its own. That is the invariant this commit exists to protect. The gate should fail if somebody hardcodes a portrait frame again, not if somebody stops. Co-Authored-By: Claude Opus 5 --- tools/verify-release.ps1 | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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) {