Skip to content

Commit

Permalink
Add rbx-thumbnails to prod. (#273)
Browse files Browse the repository at this point in the history
Remove Grid rendering when it is not needed anymore.
  • Loading branch information
jf-06 authored Jan 18, 2024
1 parent 0df17ba commit f35038a
Show file tree
Hide file tree
Showing 9 changed files with 571 additions and 29 deletions.
1 change: 1 addition & 0 deletions shared/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<PropertyGroup Label="PackageMetadata">
<Company>MFDLABS</Company>
<Copyright>Copyright © $(Company) $([System.DateTime]::Now.ToString(`yyyy`)). All rights reserved.</Copyright>
<Authors>$(Company);Nikita Petko</Authors>

<RepositoryUrl>https://github.com/mfdlabs/grid-bot</RepositoryUrl>
<RepositoryType>git</RepositoryType>
Expand Down
25 changes: 24 additions & 1 deletion shared/commands/Modules/Render.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Grid.Bot.Interactions.Public;
using Discord.Interactions;

using Logging;

using Thumbnails.Client;
using Utility;

/// <summary>
Expand Down Expand Up @@ -102,6 +102,8 @@ long id
_avatarSettings.RenderYDimension
);

try {

var (stream, fileName) = _avatarUtility.RenderUser(
id,
_avatarSettings.PlaceIdForRenders,
Expand All @@ -121,6 +123,27 @@ await FollowupWithFileAsync(
stream,
fileName
);

}
catch (Exception e)
{
_logger.Error("An error occurred while rendering the character for the user '{0}': {1}", id, e);

if (e is ThumbnailResponseException thumbnailResponseException)
{
if (thumbnailResponseException.State == ThumbnailResponseState.InReview)
{
// Bogus error here for the sake of the user. Like flood checker error.
await FollowupAsync("You are sending render commands too quickly, please wait a few moments and try again.");

return;
}

// Bogus error for anything else, we don't need this to be noted that we are using rbx-thumbnails.
}

await FollowupAsync("An error occurred while rendering the character.");
}
}

/// <summary>
Expand Down
36 changes: 34 additions & 2 deletions shared/settings/Providers/AvatarSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,46 @@ public class AvatarSettings : BaseSettingsProvider
/// </summary>
public int RenderXDimension => GetOrDefault(
nameof(RenderXDimension),
1068
720
);

/// <summary>
/// Gets the Y dimension for renders.
/// </summary>
public int RenderYDimension => GetOrDefault(
nameof(RenderYDimension),
1068
720
);

/// <summary>
/// Gets the rollout percentage for rbx-thumbnails.
/// </summary>
public int RbxThumbnailsRolloutPercent => GetOrDefault(
nameof(RbxThumbnailsRolloutPercent),
0
);

/// <summary>
/// Gets the url for rbx-thumbnails API.
/// </summary>
public string RbxThumbnailsUrl => GetOrDefault(
nameof(RbxThumbnailsUrl),
"https://thumbnails.roblox.com"
);

/// <summary>
/// Gets the render dimensions.
/// </summary>
public Thumbnails.Client.Size RenderDimensions => GetOrDefault(
nameof(RenderDimensions),
Thumbnails.Client.Size._720x720
);

/// <summary>
/// Gets the TTL for the local cache.
/// </summary>
public TimeSpan LocalCacheTtl => GetOrDefault(
nameof(LocalCacheTtl),
TimeSpan.FromMinutes(5)
);
}
2 changes: 2 additions & 0 deletions shared/settings/Shared.Settings.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
<PackageReference Include="Grid.ProcessManagement.Docker.Core" Version="2023.10.22" />
<PackageReference Include="Grid.ProcessManagement.Core" Version="2023.10.22" />
<PackageReference Include="Grid.ProcessManagement" Version="2023.10.22" />

<PackageReference Include="Thumbnails.Client" Version="2024.1.18" />
</ItemGroup>

<ItemGroup>
Expand Down
217 changes: 192 additions & 25 deletions shared/utility/Implementation/AvatarUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,103 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Collections.Generic;

using Logging;

using Random;
using Logging;
using FileSystem;
using Thumbnails.Client;
using Threading.Extensions;

using Grid.Commands;

using GridJob = Grid.ComputeCloud.Job;

/// <summary>
/// Exception thrown when rbx-thumbnails returns a state that is not pending or completed.
/// </summary>
/// <remarks>
/// Construct a new instance of <see cref="ThumbnailResponseException"/>.
/// </remarks>
/// <param name="state">The <see cref="ThumbnailResponseState"/>.</param>
/// <param name="message">The message.</param>
/// <param name="innerException">The inner <see cref="Exception"/>.</param>
public class ThumbnailResponseException(ThumbnailResponseState state, string message, Exception innerException = null) : Exception(message, innerException)
{
/// <summary>
/// Gets the <see cref="ThumbnailResponseState"/>.
/// </summary>
public ThumbnailResponseState State { get; } = state;

/// <inheritdoc cref="Exception.Message"/>
public override string Message => $"The thumbnail response state was '{State}' and the message was '{base.Message}'";
}

// ReSharper disable UnusedMember.Global
// ReSharper disable MemberCanBeMadeStatic.Global

/// <summary>
/// Utility to be used when interacting with the rendering
/// layer of the grid servers.
/// </summary>
/// <remarks>
/// Construct a new instance of <see cref="AvatarUtility"/>.
/// </remarks>
/// <param name="logger">The <see cref="ILogger"/>.</param>
/// <param name="avatarSettings">The <see cref="AvatarSettings"/>.</param>
/// <param name="random">The <see cref="IRandom"/>.</param>
/// <param name="jobManager">The <see cref="IJobManager"/>.</param>
/// <exception cref="ArgumentNullException">
/// - <paramref name="logger"/> cannot be null.
/// - <paramref name="avatarSettings"/> cannot be null.
/// - <paramref name="random"/> cannot be null.
/// - <paramref name="jobManager"/> cannot be null.
/// </exception>
public class AvatarUtility(
ILogger logger,
AvatarSettings avatarSettings,
IRandom random,
IJobManager jobManager
) : IAvatarUtility
public class AvatarUtility : IAvatarUtility
{
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly AvatarSettings _avatarSettings = avatarSettings ?? throw new ArgumentNullException(nameof(avatarSettings));
private readonly IRandom _random = random ?? throw new ArgumentNullException(nameof(random));
private readonly IJobManager _jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
private readonly ILogger _logger;
private readonly AvatarSettings _avatarSettings;
private readonly IRandom _random;
private readonly IJobManager _jobManager;
private readonly IThumbnailsClient _thumbnailsClient;
private readonly IPercentageInvoker _percentageInvoker;

private readonly ExpirableDictionary<(long, ThumbnailCommandType), string> _localCachedPaths;

/// <summary>
/// Construct a new instance of <see cref="AvatarUtility"/>.
/// </summary>
/// <param name="logger">The <see cref="ILogger"/>.</param>
/// <param name="avatarSettings">The <see cref="AvatarSettings"/>.</param>
/// <param name="random">The <see cref="IRandom"/>.</param>
/// <param name="jobManager">The <see cref="IJobManager"/>.</param>
/// <param name="thumbnailsClient">The <see cref="IThumbnailsClient"/>.</param>
/// <param name="percentageInvoker">The <see cref="IPercentageInvoker"/>.</param>
public AvatarUtility(
ILogger logger,
AvatarSettings avatarSettings,
IRandom random,
IJobManager jobManager,
IThumbnailsClient thumbnailsClient,
IPercentageInvoker percentageInvoker
)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_avatarSettings = avatarSettings ?? throw new ArgumentNullException(nameof(avatarSettings));
_random = random ?? throw new ArgumentNullException(nameof(random));
_jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
_thumbnailsClient = thumbnailsClient ?? throw new ArgumentNullException(nameof(thumbnailsClient));
_percentageInvoker = percentageInvoker ?? throw new ArgumentNullException(nameof(percentageInvoker));

_localCachedPaths = new(avatarSettings.LocalCacheTtl);
_localCachedPaths.EntryRemoved += OnLocalCacheEntryRemoved;
}

private void OnLocalCacheEntryRemoved(string path, RemovalReason reason)
{
if (reason == RemovalReason.Expired)
{
_logger.Warning("The local cache entry '{0}' expired.", path);

try
{
path.PollDeletionBlocking();
}
catch (Exception ex)
{
_logger.Error(ex);
}
}
}

private IEnumerable<object> GetThumbnailArgs(string url, int x, int y)
{
Expand All @@ -62,9 +117,121 @@ private IEnumerable<object> GetThumbnailArgs(string url, int x, int y)
yield return 0; // cameraOffsetY
}

private static string PollUntilCompleted(Func<ThumbnailResponse> func)
{
var response = func() ?? throw new ThumbnailResponseException(ThumbnailResponseState.Error, "The thumbnail response was null.");

if (response.State == ThumbnailResponseState.Completed)
return response.ImageUrl;

if (response.State != ThumbnailResponseState.Pending)
throw new ThumbnailResponseException(response.State.GetValueOrDefault(), "The thumbnail response was not pending.");

while (response.State == ThumbnailResponseState.Pending)
{
Task.Delay(1000).Wait();

response = func();
}

if (response.State != ThumbnailResponseState.Completed)
throw new ThumbnailResponseException(response.State.GetValueOrDefault(), "The thumbnail response was not completed.");

return response.ImageUrl;
}

private static string DownloadFile(string url)
{
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.png");
var file = File.OpenWrite(path);

using var client = new HttpClient();
using var stream = client.GetStreamAsync(url).SyncOrDefault();

stream.CopyTo(file);

file.Close();
file.Dispose();

return path;
}

private (Stream, string) GetThumbnail(long userId, ThumbnailCommandType thumbnailCommandType)
{
if (thumbnailCommandType != ThumbnailCommandType.Closeup && thumbnailCommandType != ThumbnailCommandType.Avatar_R15_Action)
throw new ArgumentException("The thumbnail command type must be either closeup or avatar_r15_action.", nameof(thumbnailCommandType));

var userIds = new[] { userId };

var path = _localCachedPaths.GetOrAdd(
(userId, thumbnailCommandType),
(key) =>
{
_logger.Warning(
"Entry for user '{0}' with the thumbnail command type of '{1}' was not found in the local cache, " +
"fetching from rbx-thumbnails and caching locally.",
userId,
thumbnailCommandType
);

string url = null;

switch (thumbnailCommandType)
{
case ThumbnailCommandType.Closeup:
url = PollUntilCompleted(
() => _thumbnailsClient.GetAvatarHeadshotThumbnailAsync(
userIds,
_avatarSettings.RenderDimensions,
Format.Png,
false
).SyncOrDefault().Data?.FirstOrDefault()
);

return DownloadFile(url);
case ThumbnailCommandType.Avatar_R15_Action:
url = PollUntilCompleted(
() => _thumbnailsClient.GetAvatarThumbnailAsync(
userIds,
_avatarSettings.RenderDimensions,
Format.Png,
false
).SyncOrDefault().Data?.FirstOrDefault()
);

return DownloadFile(url);
default:
throw new ArgumentOutOfRangeException(nameof(thumbnailCommandType), thumbnailCommandType, null);
}
}
);

using var file = File.OpenRead(path);
var memoryStream = new MemoryStream();

file.CopyTo(memoryStream);

return (memoryStream, Path.GetFileName(path));
}

/// <inheritdoc cref="IAvatarUtility.RenderUser(long,long,int,int)"/>
public (Stream, string) RenderUser(long userId, long placeId, int sizeX, int sizeY)
{
if (_percentageInvoker.CanInvoke(_avatarSettings.RbxThumbnailsRolloutPercent))
{
_logger.Warning(
"Trying to fetch the thumbnail for user '{0}' via rbx-thumbnails with the dimensions of {1}",
userId,
_avatarSettings.RenderDimensions
);

var commandType = _random.Next(0, 10) < 6
? ThumbnailCommandType.Avatar_R15_Action
: ThumbnailCommandType.Closeup;

return GetThumbnail(userId, commandType);
}

var url = GetAvatarFetchUrl(userId, placeId);

_logger.Warning(
Expand Down
Loading

0 comments on commit f35038a

Please sign in to comment.