2.0.0 (#92)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
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
This commit was merged in pull request #92.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user