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()); }