303 lines
13 KiB
C#
303 lines
13 KiB
C#
using Butter.Settings;
|
|
using Butter.Types;
|
|
using Lactose.Models;
|
|
using Lactose.Repositories;
|
|
|
|
namespace Lactose.Jobs;
|
|
|
|
/// <summary>
|
|
/// Converts animated assets (GIF) to video (mp4 via ffmpeg) and extracts a first-frame WebP thumbnail.
|
|
/// Original files are never modified; outputs are stored under the Converted Path setting.
|
|
/// Master creates one sub-job per batch of assets.
|
|
/// </summary>
|
|
public sealed class ConvertAnimatedJob : Job {
|
|
ConvertAnimatedJob? ParentJob;
|
|
Asset[]? Batch;
|
|
int ThumbnailSize;
|
|
string ThumbnailPath = "";
|
|
string ConvertedPath = "";
|
|
string FfmpegPath = "ffmpeg";
|
|
string ExpectedFormat = "mp4";
|
|
string ExpectedThumbnailFormat = "webp";
|
|
int processedAssets;
|
|
int failedAssets;
|
|
int childJobFailures;
|
|
int childJobsWithErrors;
|
|
int totalChildJobs;
|
|
int totalAssets = 1;
|
|
int batchSize;
|
|
List<Job> childJobs = [];
|
|
ILogger<ConvertAnimatedJob> Logger;
|
|
ISettingsRepository SettingsRepository;
|
|
IAssetRepository AssetRepository;
|
|
JobManager? JobManager;
|
|
|
|
/// <inheritdoc />
|
|
public override string Name { get; }
|
|
|
|
/// <summary>
|
|
/// Creates a master animated-asset conversion job.
|
|
/// </summary>
|
|
public ConvertAnimatedJob(
|
|
ILogger<ConvertAnimatedJob> logger,
|
|
ISettingsRepository settingsRepository,
|
|
IAssetRepository assetRepository,
|
|
JobManager jobManager
|
|
) {
|
|
Logger = logger;
|
|
SettingsRepository = settingsRepository;
|
|
AssetRepository = assetRepository;
|
|
JobManager = jobManager;
|
|
Name = "Animated Asset Conversion Job";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a sub-job to convert a batch of animated assets.
|
|
/// </summary>
|
|
public ConvertAnimatedJob(
|
|
ConvertAnimatedJob parentJob,
|
|
IEnumerable<Asset> batch,
|
|
int thumbnailSize,
|
|
string thumbnailPath,
|
|
string convertedPath,
|
|
string ffmpegPath,
|
|
string expectedFormat,
|
|
int batchSize,
|
|
ILogger<ConvertAnimatedJob> logger,
|
|
ISettingsRepository settingsRepository,
|
|
IAssetRepository assetRepository
|
|
) {
|
|
ParentJob = parentJob;
|
|
ParentJobId = parentJob.Id;
|
|
Batch = batch.ToArray();
|
|
ThumbnailSize = thumbnailSize;
|
|
ThumbnailPath = thumbnailPath;
|
|
ConvertedPath = convertedPath;
|
|
FfmpegPath = ffmpegPath;
|
|
ExpectedFormat = expectedFormat;
|
|
this.batchSize = batchSize;
|
|
Logger = logger;
|
|
SettingsRepository = settingsRepository;
|
|
AssetRepository = assetRepository;
|
|
Name = $"Animated Conversion Batch ({Batch.Length} assets)";
|
|
totalAssets = Batch.Length;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task TaskJob(CancellationToken token) {
|
|
if (ParentJob == null) {
|
|
await MasterJob(token);
|
|
} else {
|
|
await SlaveJob(token);
|
|
}
|
|
}
|
|
|
|
async Task SlaveJob(CancellationToken token) {
|
|
if (token.IsCancellationRequested) {
|
|
JobStatus.Cancel("Cancellation requested from user.");
|
|
return;
|
|
}
|
|
|
|
if (Batch == null || Batch.Length == 0) {
|
|
Logger.LogWarning("Sub-job started with an empty batch. Completing.");
|
|
JobStatus.Complete("Empty batch — nothing to process.");
|
|
return;
|
|
}
|
|
|
|
JobStatus.Start();
|
|
|
|
if (string.IsNullOrEmpty(ThumbnailPath) || string.IsNullOrEmpty(ConvertedPath)) {
|
|
Logger.LogError("Thumbnail or converted output path is not set. Cannot convert assets.");
|
|
JobStatus.Fail("Thumbnail or converted output path is not set.");
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < Batch.Length; i++) {
|
|
if (token.IsCancellationRequested) {
|
|
JobStatus.Cancel("Cancellation requested from user.");
|
|
return;
|
|
}
|
|
|
|
var asset = Batch[i];
|
|
JobStatus.UpdateProgress((float)i / Batch.Length, $"Processing {asset.OriginalPath}");
|
|
|
|
try {
|
|
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
|
|
var errorMsg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
|
Logger.LogWarning(errorMsg);
|
|
JobHelpers.MarkBroken(asset, errorMsg);
|
|
failedAssets++;
|
|
continue;
|
|
}
|
|
|
|
var thumbPath = PathUtils.PathFromGuid(asset.Id, ThumbnailPath, ExpectedThumbnailFormat);
|
|
var videoPath = PathUtils.PathFromGuid(asset.Id, ConvertedPath, ExpectedFormat);
|
|
|
|
var thumbOutDir = Path.GetDirectoryName(thumbPath);
|
|
if (thumbOutDir != null && !Directory.Exists(thumbOutDir))
|
|
Directory.CreateDirectory(thumbOutDir);
|
|
var videoOutDir = Path.GetDirectoryName(videoPath);
|
|
if (videoOutDir != null && !Directory.Exists(videoOutDir))
|
|
Directory.CreateDirectory(videoOutDir);
|
|
|
|
// First frame as a static WebP thumbnail (avoids decoding the whole animation).
|
|
if (asset.ThumbnailPath != thumbPath || !File.Exists(thumbPath)) {
|
|
var thumbArgs = $"-y -i \"{asset.OriginalPath}\" -vf \"scale={ThumbnailSize}:-1\" -frames:v 1 \"{thumbPath}\"";
|
|
await FfmpegRunner.RunAsync(FfmpegPath, thumbArgs, Logger, token);
|
|
}
|
|
|
|
// Full animation → video (mp4 / H.264, no audio, faststart for streaming).
|
|
if (asset.ConvertedPath != videoPath || !File.Exists(videoPath)) {
|
|
var videoArgs = $"-y -i \"{asset.OriginalPath}\" -c:v libx264 -crf 23 -pix_fmt yuv420p -movflags +faststart -an \"{videoPath}\"";
|
|
await FfmpegRunner.RunAsync(FfmpegPath, videoArgs, Logger, token);
|
|
}
|
|
|
|
asset.ThumbnailPath = thumbPath;
|
|
asset.ThumbnailSize = ThumbnailSize;
|
|
asset.ThumbnailFormat = ExpectedThumbnailFormat;
|
|
asset.ConvertedPath = videoPath;
|
|
asset.ConvertedMimeType = MimeTypeFor(ExpectedFormat);
|
|
asset.ConvertedFormat = ExpectedFormat;
|
|
processedAssets++;
|
|
} catch (OperationCanceledException) {
|
|
throw;
|
|
} catch (Exception ex) {
|
|
Logger.LogError(ex, "Failed to convert animated asset ID {Id} at {Path}.", asset.Id, asset.OriginalPath);
|
|
JobHelpers.MarkBroken(asset, $"Animated conversion failed: {ex.Message}");
|
|
failedAssets++;
|
|
}
|
|
}
|
|
|
|
AssetRepository.UpdateBulk(Batch);
|
|
AssetRepository.Save();
|
|
|
|
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
|
Logger.LogInformation(resultMsg);
|
|
|
|
if (failedAssets == Batch.Length)
|
|
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
|
|
else if (failedAssets > 0)
|
|
JobStatus.CompleteWithErrors(resultMsg);
|
|
else
|
|
JobStatus.Complete(resultMsg);
|
|
}
|
|
|
|
async Task MasterJob(CancellationToken token) {
|
|
if (JobManager == null) {
|
|
Logger.LogError("JobManager is not available. Cannot create sub-jobs.");
|
|
JobStatus.Fail("JobManager is not available. Cannot create sub-jobs.");
|
|
return;
|
|
}
|
|
|
|
JobStatus.Start();
|
|
Logger.LogInformation("Starting master animated conversion job.");
|
|
|
|
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
|
|
var convertedPathSetting = await SettingsRepository.GetAsync(Settings.ConvertedPath.AsString(), token);
|
|
var convertedFormatSetting = await SettingsRepository.GetAsync(Settings.ConvertedFormat.AsString(), token);
|
|
var ffmpegPathSetting = await SettingsRepository.GetAsync(Settings.FfmpegPath.AsString(), token);
|
|
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
|
|
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
|
|
|
if (thumbPathSetting == null || convertedPathSetting == null
|
|
|| string.IsNullOrWhiteSpace(thumbPathSetting.Value) || string.IsNullOrWhiteSpace(convertedPathSetting.Value)) {
|
|
Logger.LogError("Thumbnail path or converted path is not set. Cannot proceed.");
|
|
JobStatus.Fail("Thumbnail path or converted path is not set.");
|
|
return;
|
|
}
|
|
|
|
if (!int.TryParse(thumbSizeSetting?.Value, out var thumbnailSize)) {
|
|
thumbnailSize = 512;
|
|
Logger.LogWarning("Thumbnail Size setting not found or invalid. Defaulting to {Size}.", thumbnailSize);
|
|
}
|
|
|
|
var convertedFormat = string.IsNullOrWhiteSpace(convertedFormatSetting?.Value) ? "mp4" : convertedFormatSetting.Value;
|
|
var ffmpegPath = string.IsNullOrWhiteSpace(ffmpegPathSetting?.Value) ? "ffmpeg" : ffmpegPathSetting.Value;
|
|
|
|
batchSize = 200;
|
|
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
|
batchSize = Math.Max(1, parsed);
|
|
|
|
totalAssets = AssetRepository.CountAssetsNeedingConversion();
|
|
Logger.LogInformation("Found {Count} animated assets needing conversion.", totalAssets);
|
|
|
|
if (totalAssets == 0) {
|
|
JobStatus.Complete("No animated assets need conversion.");
|
|
return;
|
|
}
|
|
|
|
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
int pending = 0;
|
|
|
|
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
|
var batch = AssetRepository.GetAssetsNeedingConversion(batchSize, offset);
|
|
var batchList = batch.ToList();
|
|
|
|
if (batchList.Count == 0) continue;
|
|
|
|
var job = JobManager.CreateJob<ConvertAnimatedJob>(
|
|
this, batchList, thumbnailSize, thumbPathSetting.Value!, convertedPathSetting.Value!, ffmpegPath, convertedFormat, batchSize
|
|
);
|
|
job.LinkParentToken(token);
|
|
lock (childJobs) { childJobs.Add(job); }
|
|
|
|
job.Done += (o, _) => {
|
|
var sub = (ConvertAnimatedJob)o!;
|
|
lock (childJobs) { childJobs.Remove(sub); }
|
|
Interlocked.Add(ref processedAssets, sub.processedAssets);
|
|
Interlocked.Add(ref failedAssets, sub.failedAssets);
|
|
switch (sub.JobStatus.Status) {
|
|
case EJobStatus.Failed:
|
|
case EJobStatus.Canceled:
|
|
Interlocked.Increment(ref childJobFailures);
|
|
break;
|
|
case EJobStatus.CompletedWithErrors:
|
|
Interlocked.Increment(ref childJobsWithErrors);
|
|
break;
|
|
}
|
|
if (Interlocked.Decrement(ref pending) == 0)
|
|
childTcs.TrySetResult();
|
|
};
|
|
|
|
Interlocked.Increment(ref pending);
|
|
Interlocked.Increment(ref totalChildJobs);
|
|
JobManager.EnqueueJob(job);
|
|
|
|
JobStatus.UpdateProgress(
|
|
(float)offset / totalAssets,
|
|
$"Created batch at {offset:N0}/{totalAssets:N0}."
|
|
);
|
|
}
|
|
|
|
if (token.IsCancellationRequested) {
|
|
JobStatus.Cancel("Cancellation requested from user.");
|
|
return;
|
|
}
|
|
|
|
var remain = Interlocked.CompareExchange(ref pending, 0, 0);
|
|
if (remain == 0) {
|
|
childTcs.TrySetResult();
|
|
} else {
|
|
using var ctr = token.Register(() => childTcs.TrySetCanceled(token));
|
|
try {
|
|
await childTcs.Task;
|
|
} catch (TaskCanceledException) {
|
|
JobStatus.Cancel("Cancellation requested from user.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (childJobFailures > 0)
|
|
JobStatus.Fail($"{childJobFailures:N0} of {totalChildJobs:N0} conversion batch(es) failed.");
|
|
else if (childJobsWithErrors > 0 || failedAssets > 0)
|
|
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} animated conversions failed.");
|
|
else
|
|
JobStatus.Complete("All animated assets converted successfully.");
|
|
}
|
|
|
|
static string MimeTypeFor(string format) => format switch {
|
|
"webm" => "video/webm",
|
|
"mp4" => "video/mp4",
|
|
_ => $"video/{format}"
|
|
};
|
|
} |