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) {