Files
MilkyShots/Lactose/Jobs/ConvertAnimatedJob.cs
T

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}"
};
}