using Dalamud.Interface.Textures.TextureWraps; using LightlessSync.LightlessConfiguration; using LightlessSync.UI; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace LightlessSync.Services.Chat; public sealed class ChatEmoteService : IDisposable { private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global"; private const int DefaultFrameDelayMs = 100; private const int MinFrameDelayMs = 20; private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly UiSharedService _uiSharedService; private readonly ChatConfigService _chatConfigService; private readonly ConcurrentDictionary _emotes = new(StringComparer.Ordinal); private readonly SemaphoreSlim _downloadGate = new(3, 3); private readonly object _loadLock = new(); private Task? _loadTask; public ChatEmoteService(ILogger logger, HttpClient httpClient, UiSharedService uiSharedService, ChatConfigService chatConfigService) { _logger = logger; _httpClient = httpClient; _uiSharedService = uiSharedService; _chatConfigService = chatConfigService; } public void EnsureGlobalEmotesLoaded() { lock (_loadLock) { if (_loadTask is not null && !_loadTask.IsCompleted) { return; } if (_emotes.Count > 0) { return; } _loadTask = Task.Run(LoadGlobalEmotesAsync); } } public IReadOnlyList GetEmoteNames() { EnsureGlobalEmotesLoaded(); var names = _emotes.Keys.ToArray(); Array.Sort(names, StringComparer.OrdinalIgnoreCase); return names; } public bool TryGetEmote(string code, out IDalamudTextureWrap? texture) { texture = null; EnsureGlobalEmotesLoaded(); if (!_emotes.TryGetValue(code, out var entry)) { return false; } var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes; if (entry.TryGetTexture(allowAnimation, out texture)) { if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation) { entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true); } return true; } entry.EnsureLoading(allowAnimation, QueueEmoteDownload); return true; } public void Dispose() { foreach (var entry in _emotes.Values) { entry.Dispose(); } _downloadGate.Dispose(); } private async Task LoadGlobalEmotesAsync() { try { using var stream = await _httpClient.GetStreamAsync(GlobalEmoteSetUrl).ConfigureAwait(false); using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false); if (!document.RootElement.TryGetProperty("emotes", out var emotes)) { _logger.LogWarning("7TV emote set response missing emotes array"); return; } foreach (var emoteElement in emotes.EnumerateArray()) { if (!emoteElement.TryGetProperty("name", out var nameElement)) { continue; } var name = nameElement.GetString(); if (string.IsNullOrWhiteSpace(name)) { continue; } var source = TryBuildEmoteSource(emoteElement); if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation)) { continue; } _emotes.TryAdd(name, new EmoteEntry(name, source.Value)); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load 7TV emote set"); } } private static EmoteSource? TryBuildEmoteSource(JsonElement emoteElement) { if (!emoteElement.TryGetProperty("data", out var dataElement)) { return null; } if (!dataElement.TryGetProperty("host", out var hostElement)) { return null; } if (!hostElement.TryGetProperty("url", out var urlElement)) { return null; } var baseUrl = urlElement.GetString(); if (string.IsNullOrWhiteSpace(baseUrl)) { return null; } if (baseUrl.StartsWith("//", StringComparison.Ordinal)) { baseUrl = "https:" + baseUrl; } if (!hostElement.TryGetProperty("files", out var filesElement)) { return null; } var files = ReadEmoteFiles(filesElement); if (files.Count == 0) { return null; } var animatedFile = PickBestAnimatedFile(files); var animatedUrl = animatedFile is null ? null : BuildEmoteUrl(baseUrl, animatedFile.Value.Name); var staticName = animatedFile?.StaticName; if (string.IsNullOrWhiteSpace(staticName)) { staticName = PickBestStaticFileName(files); } var staticUrl = string.IsNullOrWhiteSpace(staticName) ? null : BuildEmoteUrl(baseUrl, staticName); if (string.IsNullOrWhiteSpace(animatedUrl) && string.IsNullOrWhiteSpace(staticUrl)) { return null; } return new EmoteSource(staticUrl, animatedUrl); } private static string BuildEmoteUrl(string baseUrl, string fileName) => baseUrl.TrimEnd('/') + "/" + fileName; private static List ReadEmoteFiles(JsonElement filesElement) { var files = new List(); foreach (var file in filesElement.EnumerateArray()) { if (!file.TryGetProperty("name", out var nameElement)) { continue; } var name = nameElement.GetString(); if (string.IsNullOrWhiteSpace(name)) { continue; } string? staticName = null; if (file.TryGetProperty("static_name", out var staticNameElement) && staticNameElement.ValueKind == JsonValueKind.String) { staticName = staticNameElement.GetString(); } var frameCount = 1; if (file.TryGetProperty("frame_count", out var frameCountElement) && frameCountElement.ValueKind == JsonValueKind.Number) { frameCountElement.TryGetInt32(out frameCount); frameCount = Math.Max(frameCount, 1); } string? format = null; if (file.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String) { format = formatElement.GetString(); } files.Add(new EmoteFile(name, staticName, frameCount, format)); } return files; } private static EmoteFile? PickBestAnimatedFile(IReadOnlyList files) { EmoteFile? webp1x = null; EmoteFile? gif1x = null; EmoteFile? webpFallback = null; EmoteFile? gifFallback = null; foreach (var file in files) { if (file.FrameCount <= 1 || !IsAnimatedFormatSupported(file)) { continue; } if (file.Name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase)) { webp1x = file; } else if (file.Name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase)) { gif1x = file; } else if (file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null) { webpFallback = file; } else if (file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null) { gifFallback = file; } } return webp1x ?? gif1x ?? webpFallback ?? gifFallback; } private static string? PickBestStaticFileName(IReadOnlyList files) { string? png1x = null; string? webp1x = null; string? gif1x = null; string? pngFallback = null; string? webpFallback = null; string? gifFallback = null; foreach (var file in files) { if (file.FrameCount > 1) { continue; } var name = file.StaticName ?? file.Name; if (string.IsNullOrWhiteSpace(name)) { continue; } if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase)) { png1x = name; } else if (name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase)) { webp1x = name; } else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase)) { gif1x = name; } else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null) { pngFallback = name; } else if (name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null) { webpFallback = name; } else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null) { gifFallback = name; } } return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback; } private static bool IsAnimatedFormatSupported(EmoteFile file) { if (!string.IsNullOrWhiteSpace(file.Format)) { return file.Format.Equals("WEBP", StringComparison.OrdinalIgnoreCase) || file.Format.Equals("GIF", StringComparison.OrdinalIgnoreCase); } return file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) || file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase); } private readonly record struct EmoteSource(string? StaticUrl, string? AnimatedUrl) { public bool HasStatic => !string.IsNullOrWhiteSpace(StaticUrl); public bool HasAnimation => !string.IsNullOrWhiteSpace(AnimatedUrl); } private readonly record struct EmoteFile(string Name, string? StaticName, int FrameCount, string? Format); private void QueueEmoteDownload(EmoteEntry entry, bool allowAnimation) { _ = Task.Run(async () => { await _downloadGate.WaitAsync().ConfigureAwait(false); try { if (allowAnimation) { if (entry.HasAnimatedSource) { entry.MarkAnimationAttempted(); if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false)) { return; } } if (entry.HasStaticSource && !entry.HasStaticTexture && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false)) { return; } } else { if (entry.HasStaticSource && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false)) { return; } if (entry.HasAnimatedSource) { entry.MarkAnimationAttempted(); if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false)) { return; } } } entry.MarkFailed(); } catch (Exception ex) { _logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code); entry.MarkFailed(); } finally { _downloadGate.Release(); } }); } private async Task TryLoadAnimatedEmoteAsync(EmoteEntry entry) { if (string.IsNullOrWhiteSpace(entry.AnimatedUrl)) { return false; } try { var data = await _httpClient.GetByteArrayAsync(entry.AnimatedUrl).ConfigureAwait(false); var isWebp = entry.AnimatedUrl.EndsWith(".webp", StringComparison.OrdinalIgnoreCase); if (!TryDecodeAnimation(data, isWebp, out var animation)) { return false; } entry.SetAnimation(animation); return true; } catch (Exception ex) { _logger.LogDebug(ex, "Failed to decode animated 7TV emote {Emote}", entry.Code); return false; } } private async Task TryLoadStaticEmoteAsync(EmoteEntry entry) { if (string.IsNullOrWhiteSpace(entry.StaticUrl)) { return false; } try { var data = await _httpClient.GetByteArrayAsync(entry.StaticUrl).ConfigureAwait(false); var texture = _uiSharedService.LoadImage(data); entry.SetStaticTexture(texture); return true; } catch (Exception ex) { _logger.LogDebug(ex, "Failed to decode static 7TV emote {Emote}", entry.Code); return false; } } private bool TryDecodeAnimation(byte[] data, bool isWebp, out EmoteAnimation? animation) { animation = null; List? frames = null; try { Image image; if (isWebp) { using var stream = new MemoryStream(data); image = WebpDecoder.Instance.Decode( new WebpDecoderOptions { BackgroundColorHandling = BackgroundColorHandling.Ignore }, stream); } else { image = Image.Load(data); } using (image) { if (image.Frames.Count <= 1) { return false; } using var composite = new Image(image.Width, image.Height, Color.Transparent); Image? restoreCanvas = null; GifDisposalMethod? pendingGifDisposal = null; WebpDisposalMethod? pendingWebpDisposal = null; frames = new List(image.Frames.Count); for (var i = 0; i < image.Frames.Count; i++) { var frameMetadata = image.Frames[i].Metadata; var delayMs = GetFrameDelayMs(frameMetadata); ApplyDisposal(composite, ref restoreCanvas, pendingGifDisposal, pendingWebpDisposal); GifDisposalMethod? currentGifDisposal = null; WebpDisposalMethod? currentWebpDisposal = null; var blendMethod = WebpBlendMethod.Over; if (isWebp) { if (frameMetadata.TryGetWebpFrameMetadata(out var webpMetadata)) { currentWebpDisposal = webpMetadata.DisposalMethod; blendMethod = webpMetadata.BlendMethod; } } else if (frameMetadata.TryGetGifMetadata(out var gifMetadata)) { currentGifDisposal = gifMetadata.DisposalMethod; } if (currentGifDisposal == GifDisposalMethod.RestoreToPrevious) { restoreCanvas?.Dispose(); restoreCanvas = composite.Clone(); } using var frameImage = image.Frames.CloneFrame(i); var alphaMode = blendMethod == WebpBlendMethod.Source ? PixelAlphaCompositionMode.Src : PixelAlphaCompositionMode.SrcOver; composite.Mutate(ctx => ctx.DrawImage(frameImage, PixelColorBlendingMode.Normal, alphaMode, 1f)); using var renderedFrame = composite.Clone(); using var ms = new MemoryStream(); renderedFrame.SaveAsPng(ms); var texture = _uiSharedService.LoadImage(ms.ToArray()); frames.Add(new EmoteFrame(texture, delayMs)); pendingGifDisposal = currentGifDisposal; pendingWebpDisposal = currentWebpDisposal; } restoreCanvas?.Dispose(); animation = new EmoteAnimation(frames); return true; } } catch { if (frames is not null) { foreach (var frame in frames) { frame.Texture.Dispose(); } } return false; } } private static int GetFrameDelayMs(ImageFrameMetadata metadata) { if (metadata.TryGetGifMetadata(out var gifMetadata)) { var delayMs = (long)gifMetadata.FrameDelay * 10L; return NormalizeFrameDelayMs(delayMs); } if (metadata.TryGetWebpFrameMetadata(out var webpMetadata)) { return NormalizeFrameDelayMs(webpMetadata.FrameDelay); } return DefaultFrameDelayMs; } private static int NormalizeFrameDelayMs(long delayMs) { if (delayMs <= 0) { return DefaultFrameDelayMs; } var clamped = delayMs > int.MaxValue ? int.MaxValue : (int)delayMs; return Math.Max(clamped, MinFrameDelayMs); } private static void ApplyDisposal( Image composite, ref Image? restoreCanvas, GifDisposalMethod? gifDisposal, WebpDisposalMethod? webpDisposal) { if (gifDisposal is not null) { switch (gifDisposal) { case GifDisposalMethod.RestoreToBackground: composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); break; case GifDisposalMethod.RestoreToPrevious: if (restoreCanvas is not null) { composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); var restoreSnapshot = restoreCanvas; composite.Mutate(ctx => ctx.DrawImage(restoreSnapshot, PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.Src, 1f)); restoreCanvas.Dispose(); restoreCanvas = null; } break; } } else if (webpDisposal == WebpDisposalMethod.RestoreToBackground) { composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); } } private sealed class EmoteAnimation : IDisposable { private readonly EmoteFrame[] _frames; private readonly int _durationMs; private readonly long _startTimestamp; public EmoteAnimation(IReadOnlyList frames) { _frames = frames.ToArray(); _durationMs = Math.Max(1, frames.Sum(frame => frame.DurationMs)); _startTimestamp = Stopwatch.GetTimestamp(); } public IDalamudTextureWrap? GetCurrentFrame() { if (_frames.Length == 0) { return null; } if (_frames.Length == 1) { return _frames[0].Texture; } var elapsedTicks = Stopwatch.GetTimestamp() - _startTimestamp; var elapsedMs = (elapsedTicks * 1000L) / Stopwatch.Frequency; var targetMs = (int)(elapsedMs % _durationMs); var accumulated = 0; foreach (var frame in _frames) { accumulated += frame.DurationMs; if (targetMs < accumulated) { return frame.Texture; } } return _frames[^1].Texture; } public IDalamudTextureWrap? GetStaticFrame() { if (_frames.Length == 0) { return null; } return _frames[0].Texture; } public void Dispose() { foreach (var frame in _frames) { frame.Texture.Dispose(); } } } private readonly record struct EmoteFrame(IDalamudTextureWrap Texture, int DurationMs); private sealed class EmoteEntry : IDisposable { private int _loadingState; private int _animationAttempted; private IDalamudTextureWrap? _staticTexture; private EmoteAnimation? _animation; public EmoteEntry(string code, EmoteSource source) { Code = code; StaticUrl = source.StaticUrl; AnimatedUrl = source.AnimatedUrl; } public string Code { get; } public string? StaticUrl { get; } public string? AnimatedUrl { get; } public bool HasStaticSource => !string.IsNullOrWhiteSpace(StaticUrl); public bool HasAnimatedSource => !string.IsNullOrWhiteSpace(AnimatedUrl); public bool HasStaticTexture => _staticTexture is not null; public bool HasAttemptedAnimation => Interlocked.CompareExchange(ref _animationAttempted, 0, 0) != 0; public bool NeedsAnimationLoad => _animation is null && HasAnimatedSource; public void MarkAnimationAttempted() { Interlocked.Exchange(ref _animationAttempted, 1); } public bool TryGetTexture(bool allowAnimation, out IDalamudTextureWrap? texture) { if (allowAnimation && _animation is not null) { texture = _animation.GetCurrentFrame(); return true; } if (_staticTexture is not null) { texture = _staticTexture; return true; } if (!allowAnimation && _animation is not null) { texture = _animation.GetStaticFrame(); return true; } texture = null; return false; } public void EnsureLoading(bool allowAnimation, Action queueDownload, bool allowWhenStaticLoaded = false) { if (_animation is not null) { return; } if (!allowWhenStaticLoaded && _staticTexture is not null) { return; } if (Interlocked.CompareExchange(ref _loadingState, 1, 0) != 0) { return; } queueDownload(this, allowAnimation); } public void SetAnimation(EmoteAnimation animation) { _staticTexture?.Dispose(); _staticTexture = null; _animation?.Dispose(); _animation = animation; Interlocked.Exchange(ref _loadingState, 0); } public void SetStaticTexture(IDalamudTextureWrap texture) { _staticTexture?.Dispose(); _staticTexture = texture; Interlocked.Exchange(ref _loadingState, 0); } public void MarkFailed() { Interlocked.Exchange(ref _loadingState, 0); } public void Dispose() { _animation?.Dispose(); _staticTexture?.Dispose(); } } }