using AIStudio.Chat;
using AIStudio.Tools.Services;
namespace AIStudio.Assistants.VisualBriefing;
///
/// Validates, fingerprints, and prepares source material for the content and assembly stages.
///
/// The persistent visual briefing store.
/// The native service used to process and optimize source files.
/// The source preparation logger.
internal sealed class VisualBriefingSourcePreparationService(VisualBriefingStore store, RustService rustService, ILogger logger)
{
///
/// Prepares all current sources without persisting embedded asset bytes.
///
/// The briefing manifest.
/// The operation identifier.
/// The build identifier.
/// The cancellation token.
/// The prepared sources.
public async Task PrepareAsync(VisualBriefingManifest manifest, Guid operationId, Guid buildId, CancellationToken token)
{
var temporaryDirectory = Path.Combine(Path.GetTempPath(), $"mwai-visual-briefing-{Guid.NewGuid():N}");
Directory.CreateDirectory(temporaryDirectory);
try
{
List attachments = [];
Dictionary transcripts = [];
Dictionary assets = new(StringComparer.Ordinal);
List fingerprints = [];
long totalBytes = 0;
foreach (var source in manifest.Sources.OrderBy(source => source.SourceId))
{
token.ThrowIfCancellationRequested();
if (!File.Exists(source.Path))
throw new VisualBriefingBuildException(VisualBriefingFailureCode.SOURCE_UNREACHABLE, VisualBriefingBuildStage.SOURCE_PREPARATION, "A briefing source is no longer reachable.", "A source failed the reachability check.");
var info = new FileInfo(source.Path);
totalBytes += info.Length;
var sourceHash = await VisualBriefingHashing.ComputeFileAsync(source.Path, token);
var transcriptHash = string.Empty;
if (source.IsMedia)
{
var transcript = await store.ReadTranscriptAsync(manifest.BriefingId, source.SourceId, token);
if (string.IsNullOrWhiteSpace(transcript) || source.TranscriptStatus is not VisualBriefingTranscriptStatus.CURRENT)
throw new VisualBriefingBuildException(VisualBriefingFailureCode.TRANSCRIPT_UNAVAILABLE, VisualBriefingBuildStage.SOURCE_PREPARATION, "A media transcript is missing or outdated.", $"Transcript status for source {source.SourceId:D} is {source.TranscriptStatus}.");
transcripts[source.SourceId] = transcript;
transcriptHash = VisualBriefingHashing.Compute(transcript);
}
else if (source.Kind is VisualBriefingSourceKind.VISUAL_ASSET)
{
var optimized = await rustService.PrepareImageAsync(source.Path, manifest.Settings.OptimizeImages, token);
var extension = optimized.MimeType switch
{
"image/jpeg" => ".jpg",
"image/png" => ".png",
"image/webp" => ".webp",
_ => throw new VisualBriefingBuildException(VisualBriefingFailureCode.SOURCE_PREPARATION_FAILED, VisualBriefingBuildStage.SOURCE_PREPARATION, "A visual asset has an unsupported image format.", "The image optimizer returned an unsupported MIME type."),
};
var preparedPath = Path.Combine(temporaryDirectory, $"{source.AssetId}{extension}");
await File.WriteAllBytesAsync(preparedPath, DecodeDataUrl(optimized.DataUrl), token);
attachments.Add(FileAttachment.FromPath(preparedPath));
assets[source.AssetId] = new(source.AssetId, optimized.DataUrl, optimized.Width, optimized.Height);
}
else
{
attachments.Add(FileAttachment.FromPath(source.Path));
}
fingerprints.Add(string.Join('\u001f', source.SourceId, source.Kind, source.AssetId, sourceHash, transcriptHash));
}
var fingerprint = VisualBriefingHashing.ComputeSections([manifest.Settings.OptimizeImages.ToString(), .. fingerprints]);
logger.LogInformation(Event(VisualBriefingLogEventId.SOURCE_PREPARATION_FINISHED), "Visual briefing source preparation finished. OperationId={OperationId} BuildId={BuildId} SourceCount={SourceCount} AssetCount={AssetCount} TotalBytes={TotalBytes} SourceFingerprint={SourceFingerprint}", operationId, buildId, manifest.Sources.Count, assets.Count, totalBytes, fingerprint);
return new()
{
TemporaryDirectory = temporaryDirectory,
Attachments = attachments,
Transcripts = transcripts,
Assets = assets,
SourceFingerprint = fingerprint,
};
}
catch (OperationCanceledException)
{
DeleteTemporaryDirectory(temporaryDirectory);
throw;
}
catch (VisualBriefingBuildException)
{
DeleteTemporaryDirectory(temporaryDirectory);
throw;
}
catch (Exception exception)
{
DeleteTemporaryDirectory(temporaryDirectory);
logger.LogWarning(Event(VisualBriefingLogEventId.SOURCE_PREPARATION_REJECTED), "Visual briefing source preparation failed. OperationId={OperationId} BuildId={BuildId} ExceptionType={ExceptionType}", operationId, buildId, exception.GetType().Name);
throw new VisualBriefingBuildException(VisualBriefingFailureCode.SOURCE_PREPARATION_FAILED, VisualBriefingBuildStage.SOURCE_PREPARATION, "The briefing sources could not be prepared.", $"ExceptionType={exception.GetType().Name}.");
}
}
///
/// Decodes the payload of one image Data URL.
///
/// The Data URL.
/// The decoded bytes.
private static byte[] DecodeDataUrl(string dataUrl)
{
var comma = dataUrl.IndexOf(',');
if (comma < 0)
throw new VisualBriefingBuildException(VisualBriefingFailureCode.SOURCE_PREPARATION_FAILED, VisualBriefingBuildStage.SOURCE_PREPARATION, "A visual asset could not be prepared.", "The image optimizer returned an invalid Data URL.");
return Convert.FromBase64String(dataUrl[(comma + 1)..]);
}
///
/// Deletes a temporary source-preparation directory on a best-effort basis.
///
/// The temporary directory.
private static void DeleteTemporaryDirectory(string temporaryDirectory)
{
try
{
if (Directory.Exists(temporaryDirectory))
Directory.Delete(temporaryDirectory, recursive: true);
}
catch
{
// Temporary optimized visual assets are cleaned up best effort.
}
}
///
/// Creates a logging event from a stable identifier.
///
/// The stable event identifier.
/// The logging event.
private static EventId Event(VisualBriefingLogEventId eventId) => new((int)eventId, eventId.ToString());
}