Files
LightlessClient/LightlessSync/Services/TextureCompression/TextureCompressionService.cs
defnotken 835a0a637d
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
2.0.0 (#92)
2.0.0 Changes:

- Reworked shell finder UI with compact or list view with profile tags showing with the listing, allowing moderators to broadcast the syncshell as well to have it be used more.
- Reworked user list in syncshell admin screen to have filter visible and moved away from table to its own thing, allowing to copy uid/note/alias when clicking on the name.
- Reworked download bars and download box to make it look more modern, removed the jitter around, so it shouldn't vibrate around much.
- Chat has been added to the top menu, working in Zone or in Syncshells to be used there.
- Paired system has been revamped to make pausing and unpausing faster, and loading people should be faster as well.
- Moved to the internal object table to have faster load times for users; people should load in faster
- Compactor is running on a multi-threaded level instead of single-threaded; this should increase the speed of compacting files
- Nameplate Service has been reworked so it wouldn't use the nameplate handler anymore.
- Files can be resized when downloading to reduce load on users if they aren't compressed. (can be toggled to resize all).
- Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many syncshells in your list.
- Lightfinder plates have been moved away from using Nameplates, but will use an overlay.
- Main UI has been changed a bit with a gradient, and on hover will glow up now.
- Reworked Profile UI for Syncshell and Users to be more user-facing with more customizable items.
- Reworked Settings UI to look more modern.
- Performance should be better due to new systems that would dispose of the collections and better caching of items.

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Minmoose <KennethBohr@outlook.com>
Reviewed-on: #92
2025-12-21 17:19:34 +00:00

326 lines
9.6 KiB
C#

using LightlessSync.Interop.Ipc;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
namespace LightlessSync.Services.TextureCompression;
public sealed class TextureCompressionService
{
private readonly ILogger<TextureCompressionService> _logger;
private readonly IpcManager _ipcManager;
private readonly FileCacheManager _fileCacheManager;
public IReadOnlyList<TextureCompressionTarget> SelectableTargets => TextureCompressionCapabilities.SelectableTargets;
public TextureCompressionTarget DefaultTarget => TextureCompressionCapabilities.DefaultTarget;
public TextureCompressionService(
ILogger<TextureCompressionService> logger,
IpcManager ipcManager,
FileCacheManager fileCacheManager)
{
_logger = logger;
_ipcManager = ipcManager;
_fileCacheManager = fileCacheManager;
}
public async Task ConvertTexturesAsync(
IReadOnlyList<TextureCompressionRequest> requests,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
{
if (requests.Count == 0)
{
return;
}
var total = requests.Count;
var completed = 0;
foreach (var request in requests)
{
token.ThrowIfCancellationRequested();
if (!TextureCompressionCapabilities.TryGetPenumbraTarget(request.Target, request.PrimaryFilePath, out var textureType))
{
_logger.LogWarning("Unsupported compression target {Target} requested.", request.Target);
completed++;
continue;
}
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false);
completed++;
}
}
public bool IsTargetSelectable(TextureCompressionTarget target) => TextureCompressionCapabilities.IsSelectable(target);
public TextureCompressionTarget NormalizeTarget(TextureCompressionTarget? desired) =>
TextureCompressionCapabilities.Normalize(desired);
private async Task RunPenumbraConversionAsync(
TextureCompressionRequest request,
TextureType targetType,
int total,
int completedBefore,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
{
var primaryPath = request.PrimaryFilePath;
var displayJob = new TextureConversionJob(
primaryPath,
primaryPath,
targetType,
IncludeMipMaps: true,
request.DuplicateFilePaths);
var backupPath = CreateBackupCopy(primaryPath);
var conversionJob = displayJob with { InputFile = backupPath };
progress?.Report(new TextureConversionProgress(completedBefore, total, displayJob));
try
{
WaitForAccess(primaryPath);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false);
if (!IsValidConversionResult(displayJob.OutputFile))
{
throw new InvalidOperationException($"Penumbra conversion produced no output for {displayJob.OutputFile}.");
}
UpdateFileCache(displayJob);
progress?.Report(new TextureConversionProgress(completedBefore + 1, total, displayJob));
}
catch (Exception ex)
{
RestoreFromBackup(backupPath, displayJob.OutputFile, displayJob.DuplicateTargets, ex);
throw;
}
finally
{
CleanupBackup(backupPath);
}
}
private void UpdateFileCache(TextureConversionJob job)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
job.OutputFile
};
if (job.DuplicateTargets is { Count: > 0 })
{
foreach (var duplicate in job.DuplicateTargets)
{
paths.Add(duplicate);
}
}
if (paths.Count == 0)
{
return;
}
var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray());
foreach (var path in paths)
{
if (!cacheEntries.TryGetValue(path, out var entry) || entry is null)
{
entry = _fileCacheManager.CreateFileEntry(path);
if (entry is null)
{
_logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path);
continue;
}
}
try
{
_fileCacheManager.UpdateHashedFile(entry);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh file cache entry for {Path}", path);
}
}
}
private static readonly string WorkingDirectory =
Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression");
private static string CreateBackupCopy(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Cannot back up missing texture file {filePath}.", filePath);
}
Directory.CreateDirectory(WorkingDirectory);
var extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension))
{
extension = ".tmp";
}
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath);
var backupName = $"{fileNameWithoutExtension}.backup.{Guid.NewGuid():N}{extension}";
var backupPath = Path.Combine(WorkingDirectory, backupName);
WaitForAccess(filePath);
File.Copy(filePath, backupPath, overwrite: false);
return backupPath;
}
private const int MaxAccessRetries = 10;
private static readonly TimeSpan AccessRetryDelay = TimeSpan.FromMilliseconds(200);
private static void WaitForAccess(string filePath)
{
if (!File.Exists(filePath))
{
return;
}
try
{
File.SetAttributes(filePath, FileAttributes.Normal);
}
catch
{
// ignore attribute changes here
}
Exception? lastException = null;
for (var attempt = 0; attempt < MaxAccessRetries; attempt++)
{
try
{
using var stream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.None);
return;
}
catch (IOException ex) when (IsSharingViolation(ex))
{
lastException = ex;
}
Thread.Sleep(AccessRetryDelay);
}
if (lastException != null)
{
throw lastException;
}
}
private static bool IsSharingViolation(IOException ex) =>
ex.HResult == unchecked((int)0x80070020);
private void RestoreFromBackup(
string backupPath,
string destinationPath,
IReadOnlyList<string>? duplicateTargets,
Exception reason)
{
if (string.IsNullOrEmpty(backupPath))
{
_logger.LogWarning(reason, "Conversion failed for {File}, but no backup was available to restore.", destinationPath);
return;
}
if (!File.Exists(backupPath))
{
_logger.LogWarning(reason, "Conversion failed for {File}, but backup path {Backup} no longer exists.", destinationPath, backupPath);
return;
}
try
{
TryReplaceFile(backupPath, destinationPath);
}
catch (Exception restoreEx)
{
_logger.LogError(restoreEx, "Failed to restore texture {File} after conversion failure.", destinationPath);
return;
}
if (duplicateTargets is { Count: > 0 })
{
foreach (var duplicate in duplicateTargets)
{
if (string.Equals(destinationPath, duplicate, StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
File.Copy(destinationPath, duplicate, overwrite: true);
}
catch (Exception duplicateEx)
{
_logger.LogDebug(duplicateEx, "Failed to restore duplicate {Duplicate} after conversion failure.", duplicate);
}
}
}
_logger.LogWarning(reason, "Restored original texture {File} after conversion failure.", destinationPath);
}
private static void TryReplaceFile(string sourcePath, string destinationPath)
{
WaitForAccess(destinationPath);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
File.Copy(sourcePath, destinationPath, overwrite: true);
}
private static void CleanupBackup(string backupPath)
{
if (string.IsNullOrEmpty(backupPath))
{
return;
}
try
{
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
}
catch
{
// avoid killing successful conversions on cleanup failure
}
}
private static bool IsValidConversionResult(string path)
{
try
{
var fileInfo = new FileInfo(path);
return fileInfo.Exists && fileInfo.Length > 0;
}
catch
{
return false;
}
}
}