mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 19:32:10 +00:00
Rework after second review
This commit is contained in:
parent
5118a87bd2
commit
3fcca39b51
@ -31,15 +31,17 @@
|
||||
|
||||
@if (this.Body is not null)
|
||||
{
|
||||
<MediaTranscriptionStatus/>
|
||||
<CascadingValue Value="@this">
|
||||
<CascadingValue Value="@this.Component">
|
||||
@this.Body
|
||||
<MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
|
||||
<CascadingValue Value="@this.CurrentMediaImportOwner">
|
||||
<CascadingValue Value="@this">
|
||||
<CascadingValue Value="@this.Component">
|
||||
@this.Body
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.Start" Class="mb-3">
|
||||
<MudButton Disabled="@(this.SubmitDisabled || this.IsProcessing || this.MediaTranscriptionService.IsBusy)" Variant="Variant.Filled" OnClick="@(async () => await this.Start())" Style="@this.SubmitButtonStyle">
|
||||
<MudButton Disabled="@(this.SubmitDisabled || this.IsProcessing || this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))" Variant="Variant.Filled" OnClick="@(async () => await this.Start())" Style="@this.SubmitButtonStyle">
|
||||
@this.SubmitText
|
||||
</MudButton>
|
||||
@if (this.IsProcessing)
|
||||
|
||||
@ -135,6 +135,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
protected CancellationTokenSource? CancellationTokenSource;
|
||||
private bool isDisposed;
|
||||
private AssistantSessionKey assistantSessionKey;
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey);
|
||||
private Guid? assistantSessionId;
|
||||
private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot;
|
||||
|
||||
@ -227,7 +228,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
|
||||
private async Task Start()
|
||||
{
|
||||
if (this.MediaTranscriptionService.IsBusy)
|
||||
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
|
||||
return;
|
||||
|
||||
var activeSession = this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey);
|
||||
@ -695,7 +696,11 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
}
|
||||
|
||||
/// <summary>Refreshes assistant actions when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.CurrentMediaImportOwner)
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ else
|
||||
{
|
||||
var fileState = this.assistantState.FileContent[fileContent.Name];
|
||||
<div class="@fileContent.Class" style="@GetOptionalStyle(fileContent.Style)">
|
||||
<ReadFileContent @bind-FileContent="@fileState.Content" EnableDragDrop="true" Layer="@DropLayers.ASSISTANTS" CatchAllDocuments="true" />
|
||||
<ReadFileContent @bind-FileContent="@fileState.Content" MediaImportTargetId="@fileContent.Name" EnableDragDrop="true" Layer="@DropLayers.ASSISTANTS" CatchAllDocuments="true" />
|
||||
</div>
|
||||
}
|
||||
break;
|
||||
|
||||
@ -2290,15 +2290,24 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T3219823625"] = "The lo
|
||||
-- The image at the URL is too large (>10 MB). Skipping the image.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "The image at the URL is too large (>10 MB). Skipping the image."
|
||||
|
||||
-- Transcription: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::MANAGEDTRANSCRIPTATTACHMENT::T1004696017"] = "Transcription: {0}"
|
||||
|
||||
-- Open Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings"
|
||||
|
||||
-- Media transcription was canceled. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1233815302"] = "Media transcription was canceled. Open the assistant to review it."
|
||||
|
||||
-- Media transcription failed. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2177964639"] = "Media transcription failed. Open the assistant to review it."
|
||||
|
||||
-- Media is still being prepared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2600900617"] = "Media is still being prepared."
|
||||
|
||||
-- Assistant is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistant is still running."
|
||||
|
||||
-- The media transcript is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3248321953"] = "The media transcript is ready."
|
||||
|
||||
-- Assistant was canceled. Open it to review the result.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistant was canceled. Open it to review the result."
|
||||
|
||||
@ -2683,9 +2692,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T3511160492"] = "Ac
|
||||
-- Please review this text again. The content was changed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T941885055"] = "Please review this text again. The content was changed."
|
||||
|
||||
-- Waiting to prepare media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1167267986"] = "Waiting to prepare media"
|
||||
|
||||
-- Stop media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1868377405"] = "Stop media transcription"
|
||||
|
||||
-- Stopping media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1878101489"] = "Stopping media transcription"
|
||||
|
||||
-- Inspecting media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2431421733"] = "Inspecting media"
|
||||
|
||||
|
||||
@ -29,6 +29,12 @@ public sealed record ChatThread
|
||||
/// </summary>
|
||||
public ulong LastMediaTranscriptNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Managed transcript attachments prepared for the composer but not sent yet.
|
||||
/// Empty by default so older serialized threads require no migration.
|
||||
/// </summary>
|
||||
public List<ManagedTranscriptAttachment> PendingMediaTranscripts { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the provider selected for the chat thread.
|
||||
/// </summary>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using AIStudio.Dialogs.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
@ -61,6 +62,9 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
[Inject]
|
||||
private AssistantSessionService AssistantSessionService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
private async Task OpenSettingsDialog()
|
||||
{
|
||||
if (!this.HasSettingsPanel)
|
||||
@ -71,7 +75,7 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
await this.DialogService.ShowAsync<TSettings>(T("Open Settings"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
}
|
||||
|
||||
private string BorderColor => this.AssistantSessionSnapshot?.IsActive is true ? this.ColorTheme.GetActivityIndicatorColor(this.SettingsManager) : this.SettingsManager.IsDarkMode switch
|
||||
private string BorderColor => this.AssistantSessionSnapshot?.IsActive is true || this.MediaImportSnapshot?.IsBusy is true ? this.ColorTheme.GetActivityIndicatorColor(this.SettingsManager) : this.SettingsManager.IsDarkMode switch
|
||||
{
|
||||
true => this.ColorTheme.GetCurrentPalette(this.SettingsManager).GrayDefault,
|
||||
false => this.ColorTheme.GetCurrentPalette(this.SettingsManager).GrayDefault,
|
||||
@ -92,10 +96,28 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
? this.AssistantSessionService.GetSnapshots().FirstOrDefault(snapshot => snapshot.Key.Component == this.Component)
|
||||
: this.AssistantSessionService.GetSnapshots().FirstOrDefault(snapshot => snapshot.Key.InstanceId == this.AssistantSessionInstanceId);
|
||||
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(new AssistantSessionKey(this.Component, this.AssistantSessionInstanceId));
|
||||
|
||||
private MediaImportSnapshot? MediaImportSnapshot => string.IsNullOrWhiteSpace(this.AssistantSessionInstanceId)
|
||||
? this.MediaTranscriptionService.GetSnapshots().FirstOrDefault(snapshot =>
|
||||
snapshot.Owner.Kind is MediaImportOwnerKind.ASSISTANT
|
||||
&& snapshot.Owner.Id.StartsWith($"{this.Component}:", StringComparison.Ordinal))
|
||||
: this.MediaTranscriptionService.GetSnapshot(this.CurrentMediaImportOwner);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the assistant session indicator shown on top of the assistant icon.
|
||||
/// </summary>
|
||||
private AssistantSessionIndicatorData? AssistantSessionIndicator => this.AssistantSessionSnapshot?.Status switch
|
||||
private AssistantSessionIndicatorData? AssistantSessionIndicator => this.MediaImportSnapshot?.Status switch
|
||||
{
|
||||
MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING => new(Icons.Material.Filled.ChangeCircle, Color.Info, this.T("Media is still being prepared.")),
|
||||
MediaImportStatus.SUCCEEDED => new(Icons.Material.Filled.TaskAlt, Color.Success, this.T("The media transcript is ready.")),
|
||||
MediaImportStatus.FAILED => new(Icons.Material.Filled.Error, Color.Error, this.T("Media transcription failed. Open the assistant to review it.")),
|
||||
MediaImportStatus.CANCELLED => new(Icons.Material.Filled.Cancel, Color.Warning, this.T("Media transcription was canceled. Open the assistant to review it.")),
|
||||
|
||||
_ => this.AssistantSessionIndicatorWithoutMedia,
|
||||
};
|
||||
|
||||
private AssistantSessionIndicatorData? AssistantSessionIndicatorWithoutMedia => this.AssistantSessionSnapshot?.Status switch
|
||||
{
|
||||
AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING => new(Icons.Material.Filled.ChangeCircle, Color.Info, this.T("Assistant is still running.")),
|
||||
AssistantSessionStatus.COMPLETED => new(Icons.Material.Filled.TaskAlt, Color.Success, this.T("The result is ready.")),
|
||||
@ -104,6 +126,28 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
_ => null,
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
var matches = string.IsNullOrWhiteSpace(this.AssistantSessionInstanceId)
|
||||
? owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.StartsWith($"{this.Component}:", StringComparison.Ordinal)
|
||||
: owner == this.CurrentMediaImportOwner;
|
||||
|
||||
if (matches)
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the block when assistant session activity changes.
|
||||
/// </summary>
|
||||
|
||||
@ -13,6 +13,11 @@ using DialogOptions = Dialogs.DialogOptions;
|
||||
|
||||
public partial class AttachDocuments : MSGComponentBase
|
||||
{
|
||||
private readonly MediaImportOwner fallbackMediaImportOwner = new(MediaImportOwnerKind.CHAT, $"attachments:{Guid.NewGuid():N}");
|
||||
|
||||
[CascadingParameter]
|
||||
private MediaImportOwner? ImportOwner { get; set; }
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AttachDocuments).Namespace, nameof(AttachDocuments));
|
||||
|
||||
[Parameter]
|
||||
@ -67,6 +72,10 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
[Parameter]
|
||||
public ChatThread? OwnerChat { get; set; }
|
||||
|
||||
/// <summary>Creates and persists a draft owner after media import confirmation.</summary>
|
||||
[Parameter]
|
||||
public Func<string, Task<ChatThread?>> EnsureOwnerChatAsync { get; set; } = _ => Task.FromResult<ChatThread?>(null);
|
||||
|
||||
[Inject]
|
||||
private ILogger<AttachDocuments> Logger { get; set; } = null!;
|
||||
|
||||
@ -88,7 +97,11 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
private uint numDropAreasAboveThis;
|
||||
private bool isComponentHovered;
|
||||
private bool isDraggingOver;
|
||||
private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy;
|
||||
private MediaImportOwner EffectiveImportOwner => this.OwnerChat is not null
|
||||
? MediaImportOwner.ForChat(this.OwnerChat.ChatId)
|
||||
: this.ImportOwner ?? this.fallbackMediaImportOwner;
|
||||
|
||||
private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner);
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
@ -100,10 +113,36 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
// Register this drop area:
|
||||
await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer);
|
||||
await base.OnInitializedAsync();
|
||||
await this.SyncCompletedMediaAttachmentsAsync();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.EffectiveImportOwner)
|
||||
_ = this.InvokeAsync(async () =>
|
||||
{
|
||||
await this.SyncCompletedMediaAttachmentsAsync();
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Reattaches completed owner results after progress updates or navigation.</summary>
|
||||
private async Task SyncCompletedMediaAttachmentsAsync()
|
||||
{
|
||||
var completed = this.MediaTranscriptionService.GetSnapshot(this.EffectiveImportOwner)?.CompletedAttachments ?? [];
|
||||
var pending = this.OwnerChat?.PendingMediaTranscripts ?? [];
|
||||
var changed = false;
|
||||
|
||||
foreach (var attachment in completed.Concat<FileAttachment>(pending))
|
||||
changed |= this.DocumentPaths.Add(attachment);
|
||||
|
||||
if (changed)
|
||||
{
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from the singleton media service.</summary>
|
||||
protected override void DisposeResources()
|
||||
@ -226,6 +265,8 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
|
||||
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
|
||||
|
||||
this.ReconcileOwnerPendingTranscripts();
|
||||
}
|
||||
|
||||
private async Task ClearAllFiles()
|
||||
@ -235,7 +276,9 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
foreach (var attachment in this.DocumentPaths)
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment);
|
||||
|
||||
this.DocumentPaths.Clear();
|
||||
this.ReconcileOwnerPendingTranscripts();
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
}
|
||||
@ -273,11 +316,22 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
this.DocumentPaths.Remove(fileAttachment);
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(fileAttachment);
|
||||
this.ReconcileOwnerPendingTranscripts();
|
||||
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
}
|
||||
|
||||
/// <summary>Keeps persisted chat-draft transcript references aligned with the composer.</summary>
|
||||
private void ReconcileOwnerPendingTranscripts()
|
||||
{
|
||||
if (this.OwnerChat is null)
|
||||
return;
|
||||
|
||||
var retainedPaths = this.DocumentPaths.Select(attachment => attachment.FilePath).ToHashSet(StringComparer.Ordinal);
|
||||
this.OwnerChat.PendingMediaTranscripts.RemoveAll(attachment => !retainedPaths.Contains(attachment.FilePath));
|
||||
}
|
||||
|
||||
private async Task AddFileBatchAsync(IEnumerable<string> paths)
|
||||
{
|
||||
var existingPaths = paths.Where(File.Exists).ToList();
|
||||
@ -336,29 +390,10 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var failures = new List<string>();
|
||||
foreach (var mediaPath in mediaPaths)
|
||||
{
|
||||
var result = await this.MediaTranscriptionService.TranscribeImportAsync(mediaPath);
|
||||
if (result.Status is MediaTranscriptionResultStatus.CANCELLED)
|
||||
break;
|
||||
if (this.OwnerChat is null)
|
||||
this.OwnerChat = await this.EnsureOwnerChatAsync(mediaPaths[0]);
|
||||
|
||||
if (result.Status is MediaTranscriptionResultStatus.FAILED)
|
||||
{
|
||||
failures.Add($"{Path.GetFileName(mediaPath)}: {result.UserMessage}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var attachment = this.OwnerChat is not null
|
||||
&& WorkspaceBehaviour.IsChatExisting(new LoadChat(this.OwnerChat.WorkspaceId, this.OwnerChat.ChatId))
|
||||
? await WorkspaceBehaviour.CreateManagedTranscriptAsync(this.OwnerChat, mediaPath, result.Text)
|
||||
: await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text);
|
||||
|
||||
this.DocumentPaths.Add(attachment);
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, string.Join(Environment.NewLine, failures)));
|
||||
this.MediaTranscriptionService.TryStartAttachmentBatch(mediaPaths, this.EffectiveImportOwner, this.OwnerChat);
|
||||
}
|
||||
|
||||
private static bool IsTranscribableMedia(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO) || FileTypes.IsAllowedPath(path, FileTypes.VIDEO);
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
}
|
||||
</ChildContent>
|
||||
<FooterContent>
|
||||
<MediaTranscriptionStatus/>
|
||||
<MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
|
||||
<MudElement Style="flex: 0 0 auto;">
|
||||
<MudTextField
|
||||
T="string"
|
||||
@ -46,7 +46,7 @@
|
||||
Label="@this.InputLabel"
|
||||
Placeholder="@this.ProviderPlaceholder"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@(this.MediaTranscriptionService.IsBusy ? Icons.Material.Filled.HourglassTop : Icons.Material.Filled.Send)"
|
||||
AdornmentIcon="@(this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner) ? Icons.Material.Filled.HourglassTop : Icons.Material.Filled.Send)"
|
||||
OnAdornmentClick="() => this.SendMessage()"
|
||||
Disabled="@this.IsInputForbidden()"
|
||||
Immediate="@true"
|
||||
@ -101,7 +101,7 @@
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider" OwnerChat="@this.ChatThread" Disabled="@this.MediaTranscriptionService.IsBusy"/>
|
||||
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)"/>
|
||||
|
||||
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ namespace AIStudio.Components;
|
||||
|
||||
public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
{
|
||||
private readonly Guid draftMediaOwnerId = Guid.NewGuid();
|
||||
private const string CHAT_INPUT_ID = "chat-user-input";
|
||||
private const string MARKDOWN_CODE = "code";
|
||||
private const string MARKDOWN_BOLD = "bold";
|
||||
@ -85,6 +86,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
private Guid foregroundChatId = Guid.Empty;
|
||||
private int workspaceHeaderSyncVersion;
|
||||
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForChat(this.ChatThread?.ChatId ?? this.draftMediaOwnerId);
|
||||
|
||||
// Unfortunately, we need the input field reference to blur the focus away. Without
|
||||
// this, we cannot clear the input field.
|
||||
private MudTextField<string> inputField = null!;
|
||||
@ -253,7 +256,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Refreshes send and attachment controls when the media import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.CurrentMediaImportOwner)
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
@ -690,9 +697,40 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
this.hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
|
||||
private async Task<ChatThread?> EnsureMediaImportChatAsync(string firstMediaPath)
|
||||
{
|
||||
if (this.ChatThread is not null)
|
||||
return this.ChatThread;
|
||||
|
||||
this.RefreshCurrentProfileAndChatTemplate();
|
||||
var promptName = this.ExtractThreadName(this.ComposerState.UserInput);
|
||||
this.ChatThread = new()
|
||||
{
|
||||
IncludeDateTime = true,
|
||||
SelectedProvider = this.Provider.Id,
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
SelectedChatTemplate = this.currentChatTemplate.Id,
|
||||
SystemPrompt = SystemPrompts.DEFAULT,
|
||||
WorkspaceId = this.currentWorkspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
DataSourceOptions = this.earlyDataSourceOptions,
|
||||
Name = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
|
||||
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
|
||||
: promptName,
|
||||
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
|
||||
};
|
||||
|
||||
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
|
||||
this.MarkCurrentChatAsLoadedParameter();
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
await this.SyncForegroundChatAsync();
|
||||
return this.ChatThread;
|
||||
}
|
||||
|
||||
private async Task SendMessage(bool reuseLastUserPrompt = false)
|
||||
{
|
||||
if (this.MediaTranscriptionService.IsBusy)
|
||||
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
|
||||
return;
|
||||
|
||||
if (!this.IsProviderSelected)
|
||||
@ -757,6 +795,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
Text = this.ComposerState.UserInput,
|
||||
FileAttachments = normalizedAttachments,
|
||||
};
|
||||
this.ChatThread.PendingMediaTranscripts.Clear();
|
||||
|
||||
//
|
||||
// Add the user message to the thread:
|
||||
|
||||
@ -2,17 +2,17 @@
|
||||
@inject MediaTranscriptionService MediaTranscriptionService
|
||||
@using AIStudio.Tools.Services
|
||||
|
||||
@if (this.MediaTranscriptionService.IsBusy)
|
||||
@if (this.Snapshot is { IsBusy: true } snapshot)
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-2 mb-2">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center">
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="@(this.MediaTranscriptionService.Progress is null)" Value="@((this.MediaTranscriptionService.Progress ?? 0) * 100)"/>
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="@(snapshot.Progress is null)" Value="@((snapshot.Progress ?? 0) * 100)"/>
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.StatusText
|
||||
</MudText>
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@T("Stop media transcription")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@this.MediaTranscriptionService.StopAsync"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(async () => await this.MediaTranscriptionService.StopAsync(this.Owner))"/>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@ -1,17 +1,26 @@
|
||||
using AIStudio.Tools.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class MediaTranscriptionStatus
|
||||
{
|
||||
/// <summary>Gets the localized visible status for the active import.</summary>
|
||||
private string StatusText => this.MediaTranscriptionService.Phase switch
|
||||
{
|
||||
MediaTranscriptionPhase.PROBING => $"{this.T("Inspecting media")}: {this.MediaTranscriptionService.CurrentFileName}",
|
||||
MediaTranscriptionPhase.TRANSCODING => $"{this.T("Preparing audio")}: {this.MediaTranscriptionService.CurrentFileName}",
|
||||
MediaTranscriptionPhase.UPLOADING => $"{this.T("Transcribing")}: {this.MediaTranscriptionService.CurrentFileName}",
|
||||
/// <summary>The surface owner whose operation is rendered.</summary>
|
||||
[Parameter]
|
||||
public MediaImportOwner Owner { get; set; }
|
||||
|
||||
_ => this.MediaTranscriptionService.CurrentFileName,
|
||||
private MediaImportSnapshot? Snapshot => this.MediaTranscriptionService.GetSnapshot(this.Owner);
|
||||
|
||||
/// <summary>Gets the localized visible status for the active import.</summary>
|
||||
private string StatusText => this.Snapshot?.Phase switch
|
||||
{
|
||||
MediaTranscriptionPhase.QUEUED => $"{this.T("Waiting to prepare media")}: {this.Snapshot.CurrentFileName}",
|
||||
MediaTranscriptionPhase.PROBING => $"{this.T("Inspecting media")}: {this.Snapshot.CurrentFileName}",
|
||||
MediaTranscriptionPhase.TRANSCODING => $"{this.T("Preparing audio")}: {this.Snapshot.CurrentFileName}",
|
||||
MediaTranscriptionPhase.UPLOADING => $"{this.T("Transcribing")}: {this.Snapshot.CurrentFileName}",
|
||||
MediaTranscriptionPhase.CANCELING => $"{this.T("Stopping media transcription")}: {this.Snapshot.CurrentFileName}",
|
||||
|
||||
_ => this.Snapshot?.CurrentFileName ?? string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>Subscribes to singleton import state changes.</summary>
|
||||
@ -22,7 +31,11 @@ public partial class MediaTranscriptionStatus
|
||||
}
|
||||
|
||||
/// <summary>Schedules a render after an import state transition.</summary>
|
||||
private void OnStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
private void OnStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.Owner)
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from singleton import state changes.</summary>
|
||||
protected override void DisposeResources()
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MediaTranscriptionStatus/>
|
||||
|
||||
@if (this.EnableDragDrop)
|
||||
{
|
||||
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
|
||||
<MudPaper Outlined="true" Class="@this.dragClass">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.None" Wrap="Wrap.Wrap">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Disabled="@(this.Disabled || this.MediaTranscriptionService.IsBusy)">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Disabled="@this.IsUnavailable">
|
||||
@this.ButtonText
|
||||
</MudButton>
|
||||
<MudText Typo="Typo.body2">
|
||||
@ -19,7 +17,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Class="mb-3" Disabled="@(this.Disabled || this.MediaTranscriptionService.IsBusy)">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Class="mb-3" Disabled="@this.IsUnavailable">
|
||||
@this.ButtonText
|
||||
</MudButton>
|
||||
}
|
||||
@ -9,6 +9,21 @@ namespace AIStudio.Components;
|
||||
|
||||
public partial class ReadFileContent : MSGComponentBase
|
||||
{
|
||||
private readonly MediaImportOwner fallbackMediaImportOwner = new(MediaImportOwnerKind.ASSISTANT, $"read-file-content:{Guid.NewGuid():N}");
|
||||
|
||||
[CascadingParameter]
|
||||
private MediaImportOwner? ImportOwner { get; set; }
|
||||
|
||||
private MediaImportOwner EffectiveImportOwner => this.ImportOwner ?? this.fallbackMediaImportOwner;
|
||||
|
||||
[Parameter]
|
||||
public string MediaImportTargetId { get; set; } = string.Empty;
|
||||
|
||||
private string EffectiveMediaImportTargetId => string.IsNullOrWhiteSpace(this.MediaImportTargetId)
|
||||
? string.IsNullOrWhiteSpace(this.Text) ? "primary" : this.Text
|
||||
: this.MediaImportTargetId;
|
||||
|
||||
private string lastAppliedMediaTranscript = string.Empty;
|
||||
[Parameter]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
@ -57,7 +72,7 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
private string dragClass = DEFAULT_DRAG_CLASS;
|
||||
private uint numDropAreasAboveThis;
|
||||
private bool isComponentHovered;
|
||||
private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy;
|
||||
private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner);
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
@ -71,10 +86,31 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
}
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
await this.SyncCompletedMediaTextAsync();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.EffectiveImportOwner)
|
||||
_ = this.InvokeAsync(async () =>
|
||||
{
|
||||
await this.SyncCompletedMediaTextAsync();
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Applies a completed target transcript after progress or navigation.</summary>
|
||||
private async Task SyncCompletedMediaTextAsync()
|
||||
{
|
||||
var completed = this.MediaTranscriptionService.GetSnapshot(this.EffectiveImportOwner)?.CompletedTextTargets;
|
||||
if (completed is null || !completed.TryGetValue(this.EffectiveMediaImportTargetId, out var text)
|
||||
|| text == this.lastAppliedMediaTranscript)
|
||||
return;
|
||||
|
||||
this.lastAppliedMediaTranscript = text;
|
||||
await this.FileContentChanged.InvokeAsync(text);
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from the singleton media service.</summary>
|
||||
protected override void DisposeResources()
|
||||
@ -242,17 +278,9 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return false;
|
||||
|
||||
var result = await this.MediaTranscriptionService.TranscribeImportAsync(filePath);
|
||||
if (result.Status is not MediaTranscriptionResultStatus.SUCCEEDED)
|
||||
{
|
||||
if (result.Status is MediaTranscriptionResultStatus.FAILED)
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, result.UserMessage));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.FileContentChanged.InvokeAsync(result.Text);
|
||||
return true;
|
||||
return this.MediaTranscriptionService.TryStartTextImport(
|
||||
filePath,
|
||||
new MediaImportTarget(this.EffectiveImportOwner, this.EffectiveMediaImportTargetId));
|
||||
}
|
||||
|
||||
private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments);
|
||||
|
||||
@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.AIJobs;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@ -22,6 +23,9 @@ public partial class Workspaces : MSGComponentBase
|
||||
[Inject]
|
||||
private AIJobService AIJobService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public ChatThread? CurrentChatThread { get; set; }
|
||||
|
||||
@ -55,6 +59,7 @@ public partial class Workspaces : MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
await base.OnInitializedAsync();
|
||||
this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_CREATED ]);
|
||||
_ = this.LoadTreeItemsAsync(startPrefetch: true);
|
||||
@ -376,12 +381,25 @@ public partial class Workspaces : MSGComponentBase
|
||||
|
||||
private bool IsChatTreeItemBusy(TreeItemData treeItem)
|
||||
{
|
||||
return treeItem.Type is TreeItemType.CHAT && this.AIJobService.IsChatGenerationActive(treeItem.ChatId);
|
||||
return treeItem.Type is TreeItemType.CHAT
|
||||
&& (this.AIJobService.IsChatGenerationActive(treeItem.ChatId)
|
||||
|| this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(treeItem.ChatId)));
|
||||
}
|
||||
|
||||
private string GetChatTreeItemTextStyle(TreeItemData treeItem)
|
||||
{
|
||||
return this.IsCurrentChatTreeItem(treeItem) ? "justify-self: start; font-weight: 700;" : "justify-self: start;";
|
||||
var status = this.MediaTranscriptionService.GetSnapshot(MediaImportOwner.ForChat(treeItem.ChatId))?.Status;
|
||||
var color = status switch
|
||||
{
|
||||
MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING => " color: var(--mud-palette-info);",
|
||||
MediaImportStatus.SUCCEEDED => " color: var(--mud-palette-success);",
|
||||
MediaImportStatus.FAILED => " color: var(--mud-palette-error);",
|
||||
MediaImportStatus.CANCELLED => " color: var(--mud-palette-warning);",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
var weight = this.IsCurrentChatTreeItem(treeItem) ? " font-weight: 700;" : string.Empty;
|
||||
return $"justify-self: start;{weight}{color}";
|
||||
}
|
||||
|
||||
private bool IsCurrentChatTreeItem(TreeItemData treeItem)
|
||||
@ -394,6 +412,21 @@ public partial class Workspaces : MSGComponentBase
|
||||
|
||||
private string GetChatTreeIcon(Guid chatId, string defaultIcon)
|
||||
{
|
||||
var mediaStatus = this.MediaTranscriptionService.GetSnapshot(MediaImportOwner.ForChat(chatId))?.Status;
|
||||
if (mediaStatus is not null)
|
||||
{
|
||||
return mediaStatus switch
|
||||
{
|
||||
MediaImportStatus.QUEUED => Icons.Material.Filled.HourglassTop,
|
||||
MediaImportStatus.RUNNING or MediaImportStatus.CANCELING => Icons.Material.Filled.ChangeCircle,
|
||||
MediaImportStatus.SUCCEEDED => Icons.Material.Filled.TaskAlt,
|
||||
MediaImportStatus.FAILED => Icons.Material.Filled.Error,
|
||||
MediaImportStatus.CANCELLED => Icons.Material.Filled.Cancel,
|
||||
|
||||
_ => defaultIcon,
|
||||
};
|
||||
}
|
||||
|
||||
var snapshot = this.AIJobService.TryGetChatSnapshot(chatId);
|
||||
if (snapshot is null || !snapshot.IsActive)
|
||||
return defaultIcon;
|
||||
@ -406,6 +439,12 @@ public partial class Workspaces : MSGComponentBase
|
||||
};
|
||||
}
|
||||
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner.Kind is MediaImportOwnerKind.CHAT)
|
||||
_ = this.SafeStateHasChanged();
|
||||
}
|
||||
|
||||
private async Task SafeStateHasChanged()
|
||||
{
|
||||
if (this.isDisposed)
|
||||
@ -911,6 +950,7 @@ public partial class Workspaces : MSGComponentBase
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
this.isDisposed = true;
|
||||
this.prefetchCancellationTokenSource?.Cancel();
|
||||
this.prefetchCancellationTokenSource?.Dispose();
|
||||
|
||||
@ -2295,9 +2295,21 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "Das Bil
|
||||
-- Open Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Einstellungen öffnen"
|
||||
|
||||
-- Media transcription was canceled. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1233815302"] = "Die Medientranskription wurde abgebrochen. Öffnen Sie den Assistenten, um sie zu überprüfen."
|
||||
|
||||
-- Media transcription failed. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2177964639"] = "Die Transkription der Medieninhalte ist fehlgeschlagen. Öffnen Sie den Assistenten, um sie zu überprüfen."
|
||||
|
||||
-- Media is still being prepared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2600900617"] = "Die Medien werden noch vorbereitet."
|
||||
|
||||
-- Assistant is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistent läuft noch."
|
||||
|
||||
-- The media transcript is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3248321953"] = "Das Medientranskript ist fertig."
|
||||
|
||||
-- Assistant was canceled. Open it to review the result.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistent wurde abgebrochen. Öffnen Sie ihn, um das Ergebnis zu überprüfen."
|
||||
|
||||
@ -2403,6 +2415,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T143112277"] = "Dateien
|
||||
-- Click here to attach files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1875575968"] = "Klicken Sie hier, um Dateien anzuhängen."
|
||||
|
||||
-- Transcribe media files
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2178031033"] = "Mediendateien transkribieren"
|
||||
|
||||
-- Drag and drop files into the marked area or click here to attach documents:
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T230755331"] = "Ziehen Sie Dateien in den markierten Bereich oder klicken Sie hier, um Dokumente anzuhängen:"
|
||||
|
||||
@ -2412,6 +2427,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2495931372"] = "Dateien
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T285154968"] = "Dokumentenvorschau"
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3172443094"] = "Für Mediendateien muss ein Transkriptionsanbieter eingerichtet sein. Richten Sie in den Einstellungen der Transkriptionen einen Anbieter ein."
|
||||
|
||||
-- The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T322693339"] = "Die ausgewählten Audio- und Videodateien werden lokal vorbereitet. Anschließend werden die Audiodaten an den konfigurierten Transkriptionsanbieter hochgeladen."
|
||||
|
||||
-- Clear file list
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3759696136"] = "Dateiliste löschen"
|
||||
|
||||
@ -2673,6 +2694,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T3511160492"] = "Ak
|
||||
-- Please review this text again. The content was changed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T941885055"] = "Bitte lesen Sie diesen Text erneut durch. Der Inhalt wurde geändert."
|
||||
|
||||
-- Waiting to prepare media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1167267986"] = "Warten, bis die Medien vorbereitet sind"
|
||||
|
||||
-- Stop media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1868377405"] = "Medientranskription stoppen"
|
||||
|
||||
-- Stopping media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1878101489"] = "Transkription von Medien wird beendet"
|
||||
|
||||
-- Inspecting media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2431421733"] = "Medien werden geprüft"
|
||||
|
||||
-- Transcribing
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2938661425"] = "Transkribieren"
|
||||
|
||||
-- Preparing audio
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T3200155905"] = "Audio wird vorbereitet"
|
||||
|
||||
-- Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Da mein Arbeitgeber sowohl Windows als auch Linux am Arbeitsplatz nutzt, wollte ich eine plattformübergreifende Lösung, die nahtlos auf allen wichtigen Betriebssystemen, einschließlich macOS, funktioniert. Außerdem wollte ich zeigen, dass es möglich ist, moderne, effiziente und plattformübergreifende Anwendungen zu erstellen, ohne auf Software-Ballast, wie z.B. das Electron-Framework, zurückzugreifen. Die Kombination aus .NET und Rust mit Tauri hat sich dabei als hervorragender Technologie-Stack für den Bau solch robuster Anwendungen erwiesen."
|
||||
|
||||
@ -2796,12 +2835,21 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1989554334"] = "Laden d
|
||||
-- Drop one file here to load its content.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Datei hier ablegen, um ihren Inhalt zu laden."
|
||||
|
||||
-- The selected media file will be prepared locally. Its audio will then be uploaded to the configured transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2839709466"] = "Die ausgewählte Mediendatei wird lokal vorbereitet. Anschließend wird die Audiospur an den konfigurierten Transkriptionsanbieter hochgeladen."
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3172443094"] = "Für Mediendateien muss ein Transkriptionsanbieter eingerichtet sein. Richten Sie in den Transkriptionseinstellungen einen Anbieter ein."
|
||||
|
||||
-- Use file content as input
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Dokumenteninhalt als Eingabe verwenden"
|
||||
|
||||
-- Select file to read its content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T354817589"] = "Datei auswählen, um den Inhalt zu lesen"
|
||||
|
||||
-- Transcribe media file
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3554222960"] = "Mediendatei transkribieren"
|
||||
|
||||
-- The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READWEBCONTENT::T1164201762"] = "Der Inhalt wird mithilfe eines LLM-Agents bereinigt: Der Hauptinhalt wird extrahiert, Werbung und andere irrelevante Elemente werden nach Möglichkeit entfernt. Relative Links werden nach Möglichkeit in absolute Links umgewandelt, damit sie verwendet werden können."
|
||||
|
||||
@ -3540,9 +3588,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Nützliche Assist
|
||||
-- Voice recording has been disabled for this session because audio playback could not be initialized on the client.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1123032432"] = "Die Sprachaufnahme wurde für diese Sitzung deaktiviert, da die Audiowiedergabe auf dem Client nicht initialisiert werden konnte."
|
||||
|
||||
-- Failed to create the transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Der Anbieter für die Transkription konnte nicht erstellt werden."
|
||||
|
||||
-- Failed to start audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Audioaufnahme konnte nicht gestartet werden."
|
||||
|
||||
@ -3561,21 +3606,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2851219233"] = "Transkrip
|
||||
-- Unfortunately, there was an error communicating with the AI system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3236134591"] = "Leider ist bei der Kommunikation mit dem KI-System ein Fehler aufgetreten."
|
||||
|
||||
-- The configured transcription provider was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T331613105"] = "Der konfigurierte Anbieter für die Transkription wurde nicht gefunden."
|
||||
|
||||
-- Failed to stop audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3462568264"] = "Audioaufnahme konnte nicht beendet werden."
|
||||
|
||||
-- The configured transcription provider does not meet the minimum confidence level.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3834149033"] = "Der konfigurierte Anbieter für die Transkription erfüllt nicht das erforderliche Mindestmaß an Vertrauenswürdigkeit."
|
||||
|
||||
-- An error occurred during transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "Während der Transkription ist ein Fehler aufgetreten."
|
||||
|
||||
-- No transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T663630295"] = "Es ist kein Anbieter für die Transkription konfiguriert."
|
||||
|
||||
-- The transcription result is empty.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "Das Ergebnis der Transkription ist leer."
|
||||
|
||||
@ -6750,9 +6786,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Kopiert die Serv
|
||||
-- This library is used to create temporary folders in runtime tests and supporting filesystem operations.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "Diese Bibliothek wird verwendet, um temporäre Ordner bei Laufzeittests zu erstellen und Dateisystemoperationen zu unterstützen."
|
||||
|
||||
-- This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2173617769"] = "Diese Bibliothek wird verwendet, um den Dateityp einer Datei zu bestimmen. Das ist zum Beispiel notwendig, wenn wir eine Datei streamen möchten."
|
||||
|
||||
-- For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2174764529"] = "Für die sichere Kommunikation zwischen der Benutzeroberfläche und der Laufzeit müssen wir Zertifikate erstellen. Diese Rust-Bibliothek eignet sich hervorragend dafür."
|
||||
|
||||
@ -6771,6 +6804,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Konfigurations-P
|
||||
-- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "Die Programmiersprache C# wird für die Umsetzung der Benutzeroberfläche und des Backends verwendet. Für die Entwicklung der Benutzeroberfläche mit C# kommt die Blazor-Technologie aus ASP.NET Core zum Einsatz. Alle diese Technologien sind im .NET SDK integriert."
|
||||
|
||||
-- We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2345444286"] = "Wir verwenden Rubato, um das dekodierte Audiosignal vor der Opus-Kodierung auf 48 kHz neu abzutasten."
|
||||
|
||||
-- Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux-AppImages bündeln GStreamer-Komponenten, um den Mikrofonzugriff und WebM-Audioaufnahmen in der eingebetteten WebKitGTK-Webansicht zu unterstützen."
|
||||
|
||||
@ -6852,6 +6888,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2929232062"] = "Kopiert die Quel
|
||||
-- Copies the root certificate fingerprint to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2989678330"] = "Kopiert den Fingerabdruck des Stammzertifikats in die Zwischenablage"
|
||||
|
||||
-- This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3002755581"] = "Diese Bibliothek identifiziert Dateien anhand ihres Inhalts. Sie wird für das Streaming von Dokumenten sowie als erste Sicherheits- und Medienklassifizierungsstufe vor der lokalen Audioverarbeitung verwendet."
|
||||
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Änderungsprotokoll"
|
||||
|
||||
@ -6897,6 +6936,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3315279770"] = "Externe HTTPS-St
|
||||
-- User-language provided by the OS
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "Vom Betriebssystem bereitgestellte Sprache"
|
||||
|
||||
-- webm-iterable provides the EBML and WebM writing path for normalized audio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3385332793"] = "webm-iterable stellt den EBML- und WebM-Schreibpfad für normalisiertes Audio bereit."
|
||||
|
||||
-- Status:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3396815215"] = "Status:"
|
||||
|
||||
@ -6957,6 +6999,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T396609403"] = "nicht zutreffend"
|
||||
-- Copies the allowed host configuration to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Kopiert die zulässige Host-Konfiguration in die Zwischenablage"
|
||||
|
||||
-- Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3971563979"] = "Symphonia wird zum Demultiplexen von Mediencontainern und zur Audiodekodierung verwendet. Der genaue, unter der MPL lizenzierte Quellcode ist im verlinkten Repository verfügbar und in den mit AI Studio gebündelten Offline-Hinweisen angegeben."
|
||||
|
||||
-- Installed Pandoc version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installierte Pandoc-Version"
|
||||
|
||||
@ -6975,6 +7020,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "Diese Bibliothek
|
||||
-- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "Diese Bibliothek wird verwendet, um asynchrone Datenströme in Rust zu erstellen. Sie ermöglicht es uns, mit Datenströmen zu arbeiten, die asynchron bereitgestellt werden, wodurch sich Ereignisse oder Daten, die nach und nach eintreffen, leichter verarbeiten lassen. Wir nutzen dies zum Beispiel, um beliebige Daten aus dem Dateisystem an das Einbettungssystem zu übertragen."
|
||||
|
||||
-- Ropus provides the Opus encoder and decoder used by the media pipeline.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4113556626"] = "Ropus stellt den Opus-Encoder und -Decoder bereit, die von der Medienpipeline verwendet werden."
|
||||
|
||||
-- Community & Code
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code"
|
||||
|
||||
@ -7045,7 +7093,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T864851737"] = "Axum wird verwend
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T870640199"] = "Für einige Datenübertragungen müssen wir die Daten in Base64 kodieren. Diese Rust-Bibliothek eignet sich dafür hervorragend."
|
||||
|
||||
-- How to update
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "Update-Anleitung "
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "Update-Anleitung"
|
||||
|
||||
-- Install Pandoc
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Pandoc installieren"
|
||||
@ -8487,6 +8535,39 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code
|
||||
-- Document
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Dokument"
|
||||
|
||||
-- The configured transcription provider could not be created.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1235984176"] = "Der konfigurierte Transkriptionsanbieter konnte nicht erstellt werden."
|
||||
|
||||
-- The selected media file no longer exists.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T129859547"] = "Die ausgewählte Mediendatei ist nicht mehr vorhanden."
|
||||
|
||||
-- The selected media file does not contain an audio track.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T134825479"] = "Die ausgewählte Mediendatei enthält keine Audiospur."
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1543974632"] = "Die Mediendatei konnte nicht transkribiert werden."
|
||||
|
||||
-- The selected file cannot be processed as media.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1707342767"] = "Die ausgewählte Datei kann nicht als Medium verarbeitet werden."
|
||||
|
||||
-- The media file is damaged or its format could not be identified.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2004316549"] = "Die Mediendatei ist beschädigt oder ihr Format konnte nicht erkannt werden."
|
||||
|
||||
-- This media format or audio codec is not supported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2142564510"] = "Dieses Medienformat oder dieser Audiocodec wird nicht unterstützt."
|
||||
|
||||
-- No usable transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2282521655"] = "Es ist kein nutzbarer Transkriptionsanbieter konfiguriert."
|
||||
|
||||
-- The media file could not be prepared for transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2749117459"] = "Die Mediendatei konnte nicht für die Transkription vorbereitet werden."
|
||||
|
||||
-- The transcription provider could not transcribe the media file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T3091669215"] = "Der Transkriptionsanbieter konnte die Mediendatei nicht transkribieren."
|
||||
|
||||
-- The media pipeline ended without an output file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T632852430"] = "Die Medienpipeline wurde beendet, ohne eine Ausgabedatei zu erzeugen."
|
||||
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc-Installation"
|
||||
|
||||
|
||||
@ -2295,9 +2295,21 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "The ima
|
||||
-- Open Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings"
|
||||
|
||||
-- Media transcription was canceled. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1233815302"] = "Media transcription was canceled. Open the assistant to review it."
|
||||
|
||||
-- Media transcription failed. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2177964639"] = "Media transcription failed. Open the assistant to review it."
|
||||
|
||||
-- Media is still being prepared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2600900617"] = "Media is still being prepared."
|
||||
|
||||
-- Assistant is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistant is still running."
|
||||
|
||||
-- The media transcript is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3248321953"] = "The media transcript is ready."
|
||||
|
||||
-- Assistant was canceled. Open it to review the result.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistant was canceled. Open it to review the result."
|
||||
|
||||
@ -2403,6 +2415,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T143112277"] = "Drop fil
|
||||
-- Click here to attach files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1875575968"] = "Click here to attach files."
|
||||
|
||||
-- Transcribe media files
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2178031033"] = "Transcribe media files"
|
||||
|
||||
-- Drag and drop files into the marked area or click here to attach documents:
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T230755331"] = "Drag and drop files into the marked area or click here to attach documents:"
|
||||
|
||||
@ -2412,6 +2427,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2495931372"] = "Select
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T285154968"] = "Document Preview"
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3172443094"] = "Media files require a configured transcription provider. Configure one in the transcription settings."
|
||||
|
||||
-- The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T322693339"] = "The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider."
|
||||
|
||||
-- Clear file list
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3759696136"] = "Clear file list"
|
||||
|
||||
@ -2673,6 +2694,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T3511160492"] = "Ac
|
||||
-- Please review this text again. The content was changed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T941885055"] = "Please review this text again. The content was changed."
|
||||
|
||||
-- Waiting to prepare media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1167267986"] = "Waiting to prepare media"
|
||||
|
||||
-- Stop media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1868377405"] = "Stop media transcription"
|
||||
|
||||
-- Stopping media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1878101489"] = "Stopping media transcription"
|
||||
|
||||
-- Inspecting media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2431421733"] = "Inspecting media"
|
||||
|
||||
-- Transcribing
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2938661425"] = "Transcribing"
|
||||
|
||||
-- Preparing audio
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T3200155905"] = "Preparing audio"
|
||||
|
||||
-- Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications."
|
||||
|
||||
@ -2796,12 +2835,21 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1989554334"] = "Failed
|
||||
-- Drop one file here to load its content.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Drop one file here to load its content."
|
||||
|
||||
-- The selected media file will be prepared locally. Its audio will then be uploaded to the configured transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2839709466"] = "The selected media file will be prepared locally. Its audio will then be uploaded to the configured transcription provider."
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3172443094"] = "Media files require a configured transcription provider. Configure one in the transcription settings."
|
||||
|
||||
-- Use file content as input
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Use file content as input"
|
||||
|
||||
-- Select file to read its content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T354817589"] = "Select file to read its content"
|
||||
|
||||
-- Transcribe media file
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3554222960"] = "Transcribe media file"
|
||||
|
||||
-- The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READWEBCONTENT::T1164201762"] = "The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used."
|
||||
|
||||
@ -3540,9 +3588,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants
|
||||
-- Voice recording has been disabled for this session because audio playback could not be initialized on the client.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1123032432"] = "Voice recording has been disabled for this session because audio playback could not be initialized on the client."
|
||||
|
||||
-- Failed to create the transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Failed to create the transcription provider."
|
||||
|
||||
-- Failed to start audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Failed to start audio recording."
|
||||
|
||||
@ -3561,21 +3606,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2851219233"] = "Transcrip
|
||||
-- Unfortunately, there was an error communicating with the AI system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3236134591"] = "Unfortunately, there was an error communicating with the AI system."
|
||||
|
||||
-- The configured transcription provider was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T331613105"] = "The configured transcription provider was not found."
|
||||
|
||||
-- Failed to stop audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3462568264"] = "Failed to stop audio recording."
|
||||
|
||||
-- The configured transcription provider does not meet the minimum confidence level.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3834149033"] = "The configured transcription provider does not meet the minimum confidence level."
|
||||
|
||||
-- An error occurred during transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "An error occurred during transcription."
|
||||
|
||||
-- No transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T663630295"] = "No transcription provider is configured."
|
||||
|
||||
-- The transcription result is empty.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "The transcription result is empty."
|
||||
|
||||
@ -6750,9 +6786,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Copies the serve
|
||||
-- This library is used to create temporary folders in runtime tests and supporting filesystem operations.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "This library is used to create temporary folders in runtime tests and supporting filesystem operations."
|
||||
|
||||
-- This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2173617769"] = "This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file."
|
||||
|
||||
-- For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2174764529"] = "For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose."
|
||||
|
||||
@ -6771,6 +6804,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration pl
|
||||
-- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK."
|
||||
|
||||
-- We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2345444286"] = "We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding."
|
||||
|
||||
-- Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view."
|
||||
|
||||
@ -6852,6 +6888,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2929232062"] = "Copies the confi
|
||||
-- Copies the root certificate fingerprint to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2989678330"] = "Copies the root certificate fingerprint to the clipboard"
|
||||
|
||||
-- This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3002755581"] = "This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing."
|
||||
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog"
|
||||
|
||||
@ -6897,6 +6936,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3315279770"] = "External HTTPS c
|
||||
-- User-language provided by the OS
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "User-language provided by the OS"
|
||||
|
||||
-- webm-iterable provides the EBML and WebM writing path for normalized audio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3385332793"] = "webm-iterable provides the EBML and WebM writing path for normalized audio."
|
||||
|
||||
-- Status:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3396815215"] = "Status:"
|
||||
|
||||
@ -6957,6 +6999,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T396609403"] = "not applicable"
|
||||
-- Copies the allowed host configuration to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Copies the allowed host configuration to the clipboard"
|
||||
|
||||
-- Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3971563979"] = "Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio."
|
||||
|
||||
-- Installed Pandoc version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installed Pandoc version"
|
||||
|
||||
@ -6975,6 +7020,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "This library is
|
||||
-- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system."
|
||||
|
||||
-- Ropus provides the Opus encoder and decoder used by the media pipeline.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4113556626"] = "Ropus provides the Opus encoder and decoder used by the media pipeline."
|
||||
|
||||
-- Community & Code
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code"
|
||||
|
||||
@ -8487,6 +8535,39 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like p
|
||||
-- Document
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
|
||||
|
||||
-- The configured transcription provider could not be created.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1235984176"] = "The configured transcription provider could not be created."
|
||||
|
||||
-- The selected media file no longer exists.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T129859547"] = "The selected media file no longer exists."
|
||||
|
||||
-- The selected media file does not contain an audio track.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T134825479"] = "The selected media file does not contain an audio track."
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1543974632"] = "The media file could not be transcribed."
|
||||
|
||||
-- The selected file cannot be processed as media.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1707342767"] = "The selected file cannot be processed as media."
|
||||
|
||||
-- The media file is damaged or its format could not be identified.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2004316549"] = "The media file is damaged or its format could not be identified."
|
||||
|
||||
-- This media format or audio codec is not supported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2142564510"] = "This media format or audio codec is not supported."
|
||||
|
||||
-- No usable transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2282521655"] = "No usable transcription provider is configured."
|
||||
|
||||
-- The media file could not be prepared for transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2749117459"] = "The media file could not be prepared for transcription."
|
||||
|
||||
-- The transcription provider could not transcribe the media file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T3091669215"] = "The transcription provider could not transcribe the media file."
|
||||
|
||||
-- The media pipeline ended without an output file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T632852430"] = "The media pipeline ended without an output file."
|
||||
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc Installation"
|
||||
|
||||
|
||||
11
app/MindWork AI Studio/Tools/Services/MediaImportOwner.cs
Normal file
11
app/MindWork AI Studio/Tools/Services/MediaImportOwner.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>Identifies the chat or assistant that owns a media import.</summary>
|
||||
public readonly record struct MediaImportOwner(MediaImportOwnerKind Kind, string Id)
|
||||
{
|
||||
public static MediaImportOwner ForChat(Guid chatId) => new(MediaImportOwnerKind.CHAT, chatId.ToString("N"));
|
||||
|
||||
public static MediaImportOwner ForAssistant(AssistantSessionKey key) => new(MediaImportOwnerKind.ASSISTANT, key.ToString());
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>Supported persistent media-operation owners.</summary>
|
||||
public enum MediaImportOwnerKind
|
||||
{
|
||||
CHAT,
|
||||
ASSISTANT,
|
||||
}
|
||||
23
app/MindWork AI Studio/Tools/Services/MediaImportSnapshot.cs
Normal file
23
app/MindWork AI Studio/Tools/Services/MediaImportSnapshot.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using AIStudio.Chat;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>Copied owner-specific state suitable for rendering after navigation.</summary>
|
||||
public sealed record MediaImportSnapshot
|
||||
{
|
||||
public required MediaImportOwner Owner { get; init; }
|
||||
|
||||
public required MediaTranscriptionPhase Phase { get; init; }
|
||||
|
||||
public required MediaImportStatus Status { get; init; }
|
||||
|
||||
public string CurrentFileName { get; init; } = string.Empty;
|
||||
|
||||
public double? Progress { get; init; }
|
||||
|
||||
public IReadOnlyList<FileAttachment> CompletedAttachments { get; init; } = [];
|
||||
|
||||
public IReadOnlyDictionary<string, string> CompletedTextTargets { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
public bool IsBusy => this.Status is MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING;
|
||||
}
|
||||
12
app/MindWork AI Studio/Tools/Services/MediaImportStatus.cs
Normal file
12
app/MindWork AI Studio/Tools/Services/MediaImportStatus.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>Lifecycle status retained independently for each owner.</summary>
|
||||
public enum MediaImportStatus
|
||||
{
|
||||
QUEUED,
|
||||
RUNNING,
|
||||
CANCELING,
|
||||
SUCCEEDED,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>Identifies the concrete attachment or file-content field inside an owner.</summary>
|
||||
public readonly record struct MediaImportTarget(MediaImportOwner Owner, string TargetId);
|
||||
@ -6,6 +6,9 @@ public enum MediaTranscriptionPhase
|
||||
/// <summary>No import is active.</summary>
|
||||
IDLE,
|
||||
|
||||
/// <summary>The operation is waiting for the serialized runtime lane.</summary>
|
||||
QUEUED,
|
||||
|
||||
/// <summary>The runtime is inspecting the input.</summary>
|
||||
PROBING,
|
||||
|
||||
@ -14,4 +17,7 @@ public enum MediaTranscriptionPhase
|
||||
|
||||
/// <summary>The normalized audio is being transcribed by the provider.</summary>
|
||||
UPLOADING,
|
||||
|
||||
/// <summary>Cancellation was requested and runtime cleanup is in progress.</summary>
|
||||
CANCELING,
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
@ -13,63 +14,252 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
/// <summary>Serializes attachment and file-content imports.</summary>
|
||||
private readonly SemaphoreSlim importQueue = new(1, 1);
|
||||
|
||||
/// <summary>Protects operation ownership and visible import state.</summary>
|
||||
/// <summary>Protects operation ownership and owner-specific import state.</summary>
|
||||
private readonly Lock stateLock = new();
|
||||
|
||||
/// <summary>All operations retained so disposal can cancel voice and import work.</summary>
|
||||
private readonly HashSet<MediaOperation> operations = [];
|
||||
|
||||
/// <summary>The currently visible import operation, if any.</summary>
|
||||
private MediaOperation? currentImport;
|
||||
/// <summary>The active or queued import operation for each owner.</summary>
|
||||
private readonly Dictionary<MediaImportOwner, MediaOperation> currentImports = [];
|
||||
|
||||
/// <summary>The latest copied state retained for each owner across navigation.</summary>
|
||||
private readonly Dictionary<MediaImportOwner, MediaImportSnapshot> snapshots = [];
|
||||
|
||||
/// <summary>Owners whose complete file batches are managed by this service.</summary>
|
||||
private readonly HashSet<MediaImportOwner> activeBatches = [];
|
||||
|
||||
/// <summary>Batch-level cancellation keeps Stop effective between two files.</summary>
|
||||
private readonly Dictionary<MediaImportOwner, CancellationTokenSource> batchCancellations = [];
|
||||
|
||||
/// <summary>Prevents new work after disposal.</summary>
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Raised whenever visible import state changes.</summary>
|
||||
public event Action? StateChanged;
|
||||
/// <summary>Raised only with the owner whose copied state changed.</summary>
|
||||
public event Action<MediaImportOwner>? StateChanged;
|
||||
|
||||
/// <summary>Gets whether the serialized import lane is active.</summary>
|
||||
public bool IsBusy { get; private set; }
|
||||
/// <summary>Gets whether one owner has queued, running, or canceling media work.</summary>
|
||||
public bool IsBusy(MediaImportOwner owner) => this.GetSnapshot(owner)?.IsBusy ?? false;
|
||||
|
||||
/// <summary>Gets the file name shown for the active import.</summary>
|
||||
public string CurrentFileName { get; private set; } = string.Empty;
|
||||
/// <summary>Gets the last retained state for one owner.</summary>
|
||||
public MediaImportSnapshot? GetSnapshot(MediaImportOwner owner)
|
||||
{
|
||||
lock (this.stateLock)
|
||||
return this.snapshots.GetValueOrDefault(owner);
|
||||
}
|
||||
|
||||
/// <summary>Gets the active import phase.</summary>
|
||||
public MediaTranscriptionPhase Phase { get; private set; } = MediaTranscriptionPhase.IDLE;
|
||||
/// <summary>Gets copied retained snapshots for navigation indicators.</summary>
|
||||
public IReadOnlyCollection<MediaImportSnapshot> GetSnapshots()
|
||||
{
|
||||
lock (this.stateLock)
|
||||
return [.. this.snapshots.Values];
|
||||
}
|
||||
|
||||
/// <summary>Gets optional import progress between zero and one.</summary>
|
||||
public double? Progress { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Transcribes an attachment or file-content import on the serialized visible lane.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">Source media path.</param>
|
||||
/// <param name="token">Caller cancellation token.</param>
|
||||
/// <returns>A typed terminal result.</returns>
|
||||
public async Task<MediaTranscriptionResult> TranscribeImportAsync(string mediaPath, CancellationToken token = default)
|
||||
/// <summary>Starts an owner-managed attachment batch and returns without holding the UI event handler.</summary>
|
||||
public bool TryStartAttachmentBatch(IReadOnlyList<string> mediaPaths, MediaImportOwner owner, ChatThread? ownerChat = null)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
await this.importQueue.WaitAsync(token);
|
||||
var operation = this.CreateOperation(token);
|
||||
lock (this.stateLock)
|
||||
this.currentImport = operation;
|
||||
{
|
||||
if (!this.activeBatches.Add(owner))
|
||||
return false;
|
||||
|
||||
this.batchCancellations[owner] = new();
|
||||
}
|
||||
|
||||
_ = this.RunAttachmentBatchAsync(mediaPaths, owner, ownerChat);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Starts a reattachable file-content import for one stable assistant field.</summary>
|
||||
public bool TryStartTextImport(string mediaPath, MediaImportTarget target)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (!this.activeBatches.Add(target.Owner))
|
||||
return false;
|
||||
|
||||
this.batchCancellations[target.Owner] = new();
|
||||
}
|
||||
|
||||
_ = this.RunTextImportAsync(mediaPath, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Completes a field import independently of the originating Blazor component.</summary>
|
||||
private async Task RunTextImportAsync(string mediaPath, MediaImportTarget target)
|
||||
{
|
||||
CancellationTokenSource cancellation;
|
||||
lock (this.stateLock)
|
||||
cancellation = this.batchCancellations[target.Owner];
|
||||
|
||||
try
|
||||
{
|
||||
this.UpdateImportState(true, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0);
|
||||
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: true);
|
||||
var result = await this.TranscribeImportAsync(mediaPath, target.Owner, cancellation.Token);
|
||||
if (result.Status is MediaTranscriptionResultStatus.SUCCEEDED)
|
||||
this.AddCompletedText(target, result.Text);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
var current = this.GetSnapshot(target.Owner);
|
||||
this.UpdateImportState(target.Owner, current?.CurrentFileName ?? string.Empty, MediaTranscriptionPhase.IDLE, null, MediaImportStatus.CANCELLED);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Owner media text import failed for '{Owner}' and target '{TargetId}'.", target.Owner, target.TargetId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (ReferenceEquals(this.currentImport, operation))
|
||||
this.currentImport = null;
|
||||
this.activeBatches.Remove(target.Owner);
|
||||
if (this.batchCancellations.Remove(target.Owner, out var ownedCancellation))
|
||||
ownedCancellation.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores a completed field transcript for reattachment after navigation.</summary>
|
||||
private void AddCompletedText(MediaImportTarget target, string text)
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
var current = this.snapshots.GetValueOrDefault(target.Owner);
|
||||
if (current is null)
|
||||
return;
|
||||
|
||||
var completed = new Dictionary<string, string>(current.CompletedTextTargets, StringComparer.Ordinal)
|
||||
{
|
||||
[target.TargetId] = text,
|
||||
};
|
||||
this.snapshots[target.Owner] = current with { CompletedTextTargets = completed };
|
||||
}
|
||||
|
||||
this.StateChanged?.Invoke(target.Owner);
|
||||
}
|
||||
|
||||
/// <summary>Serially transcribes a complete owner batch while retaining every successful result.</summary>
|
||||
private async Task RunAttachmentBatchAsync(IReadOnlyList<string> mediaPaths, MediaImportOwner owner, ChatThread? ownerChat)
|
||||
{
|
||||
CancellationToken batchToken;
|
||||
lock (this.stateLock)
|
||||
batchToken = this.batchCancellations[owner].Token;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var mediaPath in mediaPaths)
|
||||
{
|
||||
batchToken.ThrowIfCancellationRequested();
|
||||
var result = await this.TranscribeImportAsync(mediaPath, owner, batchToken);
|
||||
if (result.Status is MediaTranscriptionResultStatus.CANCELLED)
|
||||
break;
|
||||
|
||||
if (result.Status is not MediaTranscriptionResultStatus.SUCCEEDED)
|
||||
continue;
|
||||
|
||||
var isPersistedChat = ownerChat is not null
|
||||
&& WorkspaceBehaviour.IsChatExisting(new LoadChat(ownerChat.WorkspaceId, ownerChat.ChatId));
|
||||
var attachment = isPersistedChat
|
||||
? await WorkspaceBehaviour.CreateManagedTranscriptAsync(ownerChat!, mediaPath, result.Text)
|
||||
: await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text);
|
||||
|
||||
if (ownerChat is not null && attachment is { } managed
|
||||
&& ownerChat.PendingMediaTranscripts.All(existing => existing.FilePath != managed.FilePath))
|
||||
ownerChat.PendingMediaTranscripts.Add(managed);
|
||||
|
||||
if (isPersistedChat)
|
||||
await WorkspaceBehaviour.StoreChatAsync(ownerChat!);
|
||||
|
||||
this.AddCompletedAttachment(owner, attachment);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
var current = this.GetSnapshot(owner);
|
||||
this.UpdateImportState(owner, current?.CurrentFileName ?? string.Empty, MediaTranscriptionPhase.IDLE, null, MediaImportStatus.CANCELLED);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Owner media batch failed for '{Owner}'.", owner);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
this.activeBatches.Remove(owner);
|
||||
if (this.batchCancellations.Remove(owner, out var cancellation))
|
||||
cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds a successful partial result to the retained owner snapshot.</summary>
|
||||
private void AddCompletedAttachment(MediaImportOwner owner, FileAttachment attachment)
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
var current = this.snapshots.GetValueOrDefault(owner);
|
||||
if (current is null)
|
||||
return;
|
||||
|
||||
this.snapshots[owner] = current with { CompletedAttachments = [..current.CompletedAttachments, attachment] };
|
||||
}
|
||||
|
||||
this.StateChanged?.Invoke(owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transcribes an attachment or file-content import on the serialized visible lane.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">Source media path.</param>
|
||||
/// <param name="owner">Media import owner.</param>
|
||||
/// <param name="token">Caller cancellation token.</param>
|
||||
/// <returns>A typed terminal result.</returns>
|
||||
private async Task<MediaTranscriptionResult> TranscribeImportAsync(string mediaPath, MediaImportOwner owner, CancellationToken token = default)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
var operation = this.CreateOperation(owner, token);
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (!this.currentImports.TryAdd(owner, operation))
|
||||
throw new InvalidOperationException($"Media owner '{owner}' already has an active operation.");
|
||||
}
|
||||
this.UpdateImportState(owner, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED);
|
||||
|
||||
try
|
||||
{
|
||||
await this.importQueue.WaitAsync(operation.Cancellation.Token);
|
||||
operation.HasQueueLease = true;
|
||||
this.UpdateImportState(owner, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0, MediaImportStatus.RUNNING);
|
||||
var result = await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: true);
|
||||
|
||||
var status = result.Status switch
|
||||
{
|
||||
MediaTranscriptionResultStatus.SUCCEEDED => MediaImportStatus.SUCCEEDED,
|
||||
MediaTranscriptionResultStatus.CANCELLED => MediaImportStatus.CANCELLED,
|
||||
_ => MediaImportStatus.FAILED,
|
||||
};
|
||||
|
||||
this.UpdateImportState(owner, Path.GetFileName(mediaPath), MediaTranscriptionPhase.IDLE, null, status);
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.UpdateImportState(owner, Path.GetFileName(mediaPath), MediaTranscriptionPhase.IDLE, null, MediaImportStatus.CANCELLED);
|
||||
return MediaTranscriptionResult.Cancelled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (this.currentImports.GetValueOrDefault(owner) == operation)
|
||||
this.currentImports.Remove(owner);
|
||||
}
|
||||
|
||||
this.ReleaseOperation(operation);
|
||||
this.UpdateImportState(false, string.Empty, MediaTranscriptionPhase.IDLE, null);
|
||||
this.importQueue.Release();
|
||||
if (operation.HasQueueLease)
|
||||
this.importQueue.Release();
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +272,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
public async Task<MediaTranscriptionResult> TranscribeVoiceAsync(string mediaPath, CancellationToken token = default)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
var operation = this.CreateOperation(token);
|
||||
var operation = this.CreateOperation(null, token);
|
||||
|
||||
try
|
||||
{
|
||||
@ -94,16 +284,20 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cancels only the active visible import operation.</summary>
|
||||
public async Task StopAsync()
|
||||
/// <summary>Cancels only the queued or active operation belonging to one owner.</summary>
|
||||
public async Task StopAsync(MediaImportOwner owner)
|
||||
{
|
||||
MediaOperation? operation;
|
||||
lock (this.stateLock)
|
||||
{
|
||||
operation = this.currentImport;
|
||||
operation = this.currentImports.GetValueOrDefault(owner);
|
||||
this.batchCancellations.GetValueOrDefault(owner)?.Cancel();
|
||||
operation?.Cancellation.Cancel();
|
||||
}
|
||||
|
||||
if (operation is not null)
|
||||
this.UpdateImportState(owner, this.GetSnapshot(owner)?.CurrentFileName ?? string.Empty, MediaTranscriptionPhase.CANCELING, null, MediaImportStatus.CANCELING);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(operation?.JobId))
|
||||
await rustService.CancelMediaJobAsync(operation.JobId);
|
||||
}
|
||||
@ -131,7 +325,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
return MediaTranscriptionResult.Failed(TB("No usable transcription provider is configured."));
|
||||
|
||||
if (updateImportState)
|
||||
this.UpdateImportState(true, this.CurrentFileName, MediaTranscriptionPhase.UPLOADING, null);
|
||||
this.UpdateImportState(operation.Owner!.Value, Path.GetFileName(mediaPath), MediaTranscriptionPhase.UPLOADING, null, MediaImportStatus.RUNNING);
|
||||
|
||||
var provider = providerSettings.CreateProvider();
|
||||
if (provider.Provider is LLMProviders.NONE)
|
||||
@ -164,7 +358,8 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
{
|
||||
// NormalizeAsync does not return from cancellation until Rust has reached a terminal
|
||||
// phase, so deleting both paths here cannot race a still-writing worker.
|
||||
this.DeleteTemporaryFile(normalizedPath);
|
||||
if (!this.RetainNormalizedMediaIfRequested(normalizedPath, operation.Id))
|
||||
this.DeleteTemporaryFile(normalizedPath);
|
||||
this.DeleteTemporaryFile(normalizedPath + ".partial");
|
||||
}
|
||||
}
|
||||
@ -196,7 +391,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
var phase = mediaEvent.Phase is MediaJobPhase.PROBING
|
||||
? MediaTranscriptionPhase.PROBING
|
||||
: MediaTranscriptionPhase.TRANSCODING;
|
||||
this.UpdateImportState(true, this.CurrentFileName, phase, mediaEvent.Progress);
|
||||
this.UpdateImportState(operation.Owner!.Value, Path.GetFileName(mediaPath), phase, mediaEvent.Progress, MediaImportStatus.RUNNING);
|
||||
}
|
||||
|
||||
switch (mediaEvent.Phase)
|
||||
@ -253,11 +448,12 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
}
|
||||
|
||||
/// <summary>Creates and registers operation-owned cancellation state.</summary>
|
||||
/// <param name="owner">Media import owner.</param>
|
||||
/// <param name="token">Caller token linked to the operation.</param>
|
||||
/// <returns>The registered operation.</returns>
|
||||
private MediaOperation CreateOperation(CancellationToken token)
|
||||
private MediaOperation CreateOperation(MediaImportOwner? owner, CancellationToken token)
|
||||
{
|
||||
var operation = new MediaOperation(token);
|
||||
var operation = new MediaOperation(owner, token);
|
||||
lock (this.stateLock)
|
||||
this.operations.Add(operation);
|
||||
|
||||
@ -274,14 +470,24 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
operation.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Updates state exposed only by the serialized import lane.</summary>
|
||||
private void UpdateImportState(bool isBusy, string fileName, MediaTranscriptionPhase phase, double? progress)
|
||||
/// <summary>Updates and publishes copied state for exactly one owner.</summary>
|
||||
private void UpdateImportState(MediaImportOwner owner, string fileName, MediaTranscriptionPhase phase, double? progress, MediaImportStatus status)
|
||||
{
|
||||
this.IsBusy = isBusy;
|
||||
this.CurrentFileName = fileName;
|
||||
this.Phase = phase;
|
||||
this.Progress = progress;
|
||||
this.StateChanged?.Invoke();
|
||||
var snapshot = new MediaImportSnapshot
|
||||
{
|
||||
Owner = owner,
|
||||
CurrentFileName = fileName,
|
||||
Phase = phase,
|
||||
Progress = progress,
|
||||
Status = status,
|
||||
CompletedAttachments = this.GetSnapshot(owner)?.CompletedAttachments ?? [],
|
||||
CompletedTextTargets = this.GetSnapshot(owner)?.CompletedTextTargets ?? new Dictionary<string, string>(),
|
||||
};
|
||||
|
||||
lock (this.stateLock)
|
||||
this.snapshots[owner] = snapshot;
|
||||
|
||||
this.StateChanged?.Invoke(owner);
|
||||
}
|
||||
|
||||
/// <summary>Maps runtime codes to localized user-facing fallback text.</summary>
|
||||
@ -310,6 +516,35 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retains the exact provider upload only for opt-in debug diagnostics.</summary>
|
||||
private bool RetainNormalizedMediaIfRequested(string normalizedPath, Guid operationId)
|
||||
{
|
||||
#if DEBUG
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MINDWORK_AI_RETAIN_NORMALIZED_MEDIA"), "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| !File.Exists(normalizedPath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var diagnosticDirectory = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-media", "diagnostics");
|
||||
Directory.CreateDirectory(diagnosticDirectory);
|
||||
var diagnosticPath = Path.Combine(diagnosticDirectory, $"{operationId:N}.webm");
|
||||
File.Move(normalizedPath, diagnosticPath, overwrite: true);
|
||||
|
||||
foreach (var oldPath in new DirectoryInfo(diagnosticDirectory).EnumerateFiles("*.webm").OrderByDescending(file => file.LastWriteTimeUtc).Skip(10))
|
||||
oldPath.Delete();
|
||||
|
||||
logger.LogInformation("Retained normalized media diagnostic '{DiagnosticPath}'.", diagnosticPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not retain normalized media diagnostic for operation '{OperationId}'.", operationId);
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Returns localized text while registering the US-English fallback with I18N.</summary>
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MediaTranscriptionService).Namespace, nameof(MediaTranscriptionService));
|
||||
|
||||
@ -320,6 +555,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
public void Dispose()
|
||||
{
|
||||
MediaOperation[] active;
|
||||
CancellationTokenSource[] batches;
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (this.disposed)
|
||||
@ -327,10 +563,14 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
|
||||
this.disposed = true;
|
||||
active = [.. this.operations];
|
||||
batches = [.. this.batchCancellations.Values];
|
||||
}
|
||||
foreach (var operation in active)
|
||||
operation.Cancellation.Cancel();
|
||||
|
||||
foreach (var batch in batches)
|
||||
batch.Cancel();
|
||||
|
||||
// The semaphore may still be released by an operation unwinding after cancellation.
|
||||
}
|
||||
|
||||
@ -338,12 +578,17 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
private sealed class MediaOperation : IDisposable
|
||||
{
|
||||
/// <summary>Creates operation state linked to a caller token.</summary>
|
||||
/// <param name="owner">Media import owner.</param>
|
||||
/// <param name="token">Caller cancellation token.</param>
|
||||
public MediaOperation(CancellationToken token)
|
||||
public MediaOperation(MediaImportOwner? owner, CancellationToken token)
|
||||
{
|
||||
this.Owner = owner;
|
||||
this.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
}
|
||||
|
||||
/// <summary>Gets the optional visible import owner; voice operations have none.</summary>
|
||||
public MediaImportOwner? Owner { get; }
|
||||
|
||||
/// <summary>Gets the unique temporary-path identifier.</summary>
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
|
||||
@ -353,6 +598,9 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
/// <summary>Gets or sets the Rust job after its POST response establishes ownership.</summary>
|
||||
public string? JobId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether this operation currently owns the serialized lane.</summary>
|
||||
public bool HasQueueLease { get; set; }
|
||||
|
||||
/// <summary>Disposes operation-owned cancellation state.</summary>
|
||||
public void Dispose() => this.Cancellation.Dispose();
|
||||
}
|
||||
|
||||
@ -810,6 +810,8 @@ public static class WorkspaceBehaviour
|
||||
var targetDirectory = GetChatDirectory(targetWorkspaceId, chat.ChatId);
|
||||
var sourceSemaphore = GetChatSemaphore(sourceWorkspaceId, chat.ChatId);
|
||||
var targetSemaphore = GetChatSemaphore(targetWorkspaceId, chat.ChatId);
|
||||
// Always acquire both workspace/chat locks in canonical workspace-ID order. This prevents
|
||||
// opposing moves of the same chat from waiting on one another with reversed lock order.
|
||||
var orderedSemaphores = string.CompareOrdinal(sourceWorkspaceId.ToString("N"), targetWorkspaceId.ToString("N")) <= 0
|
||||
? new[] { sourceSemaphore, targetSemaphore }
|
||||
: new[] { targetSemaphore, sourceSemaphore };
|
||||
@ -823,7 +825,10 @@ public static class WorkspaceBehaviour
|
||||
if (!Directory.Exists(sourceDirectory))
|
||||
throw new DirectoryNotFoundException($"The source chat directory does not exist: '{sourceDirectory}'.");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetDirectory)!);
|
||||
// Only the workspace parent is created here. Directory.Move requires the chat target
|
||||
// directory itself not to exist so an existing destination is never merged silently.
|
||||
var targetWorkspaceDirectory = Path.GetDirectoryName(targetDirectory)!;
|
||||
Directory.CreateDirectory(targetWorkspaceDirectory);
|
||||
if (Directory.Exists(targetDirectory))
|
||||
throw new IOException($"The target chat directory already exists: '{targetDirectory}'.");
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
@ -81,3 +81,20 @@ tauri-plugin-updater = "2.10.1"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
# Media normalization is CPU-heavy even when the application itself is built for development.
|
||||
# Keep release settings untouched while optimizing the hot decoder/resampler/container crates.
|
||||
[profile.dev.package.symphonia-core]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.symphonia-format-mkv]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.ropus]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.rubato]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.webm-iterable]
|
||||
opt-level = 3
|
||||
|
||||
@ -43,6 +43,7 @@ pub fn init_logging() {
|
||||
log_config.push_str("tower_http=info, ");
|
||||
log_config.push_str("rustls=info, ");
|
||||
log_config.push_str("tokio_rustls=info, ");
|
||||
log_config.push_str("symphonia_format_mkv=info, ");
|
||||
log_config.push_str("reqwest=info");
|
||||
|
||||
// Configure the initial filename. On Unix systems, the file should start
|
||||
|
||||
@ -3,10 +3,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path as FilePath, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration as StdDuration, Instant};
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
@ -25,10 +26,10 @@ use symphonia::core::codecs::audio::{well_known::CODEC_ID_OPUS, AudioDecoder, Au
|
||||
use symphonia::core::codecs::CodecParameters;
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::{FormatOptions, Track, TrackFlags, TrackType};
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::io::{MediaSource, MediaSourceStream};
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::formats::probe::Hint;
|
||||
use symphonia::core::units::Timestamp;
|
||||
use symphonia::core::units::{TimeBase, Timestamp};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
@ -61,6 +62,12 @@ const RESAMPLE_INPUT_BLOCK_SAMPLES: usize = 2_048;
|
||||
/// Bounded block used by the cancellation-aware pass-through copy.
|
||||
const COPY_BLOCK_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Minimum interval between non-terminal progress events in one phase.
|
||||
const PROGRESS_EVENT_INTERVAL: StdDuration = StdDuration::from_secs(6);
|
||||
|
||||
/// Timestamp differences above this threshold are recorded as discontinuities.
|
||||
const LARGE_DISCONTINUITY_MS: i64 = 1_000;
|
||||
|
||||
/// Time a terminal job remains available for late SSE subscribers.
|
||||
const TERMINAL_JOB_RETENTION: std::time::Duration = std::time::Duration::from_secs(10 * 60);
|
||||
|
||||
@ -242,13 +249,16 @@ impl MediaError {
|
||||
/// Mutable state shared by the request routes and blocking worker.
|
||||
struct MediaJob {
|
||||
/// Cooperative cancellation flag checked at bounded intervals.
|
||||
cancelled: AtomicBool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
|
||||
/// Latest snapshot replayed to a newly connected SSE subscriber.
|
||||
/// The latest snapshot replayed to a newly connected SSE subscriber.
|
||||
current: Mutex<MediaJobEvent>,
|
||||
|
||||
/// Fan-out channel for live state changes.
|
||||
events: broadcast::Sender<MediaJobEvent>,
|
||||
|
||||
/// Last running progress publication, used to protect Blazor from render storms.
|
||||
last_progress: Mutex<Option<(MediaJobPhase, Instant)>>,
|
||||
}
|
||||
|
||||
impl MediaJob {
|
||||
@ -262,7 +272,12 @@ impl MediaJob {
|
||||
};
|
||||
|
||||
let (events, _) = broadcast::channel(32);
|
||||
Self { cancelled: AtomicBool::new(false), current: Mutex::new(initial), events }
|
||||
Self {
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
current: Mutex::new(initial),
|
||||
events,
|
||||
last_progress: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the replay snapshot and notifies all live subscribers.
|
||||
@ -271,6 +286,19 @@ impl MediaJob {
|
||||
let _ = self.events.send(event);
|
||||
}
|
||||
|
||||
/// Publishes running progress no more than once per interval and always on a phase change.
|
||||
fn publish_progress(&self, phase: MediaJobPhase, progress: Option<f64>) {
|
||||
let now = Instant::now();
|
||||
let mut last = self.last_progress.lock().unwrap();
|
||||
if last.is_some_and(|(last_phase, last_at)| last_phase == phase && now.duration_since(last_at) < PROGRESS_EVENT_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
*last = Some((phase, now));
|
||||
drop(last);
|
||||
self.publish(MediaJobEvent { phase, progress, result: None, error: None });
|
||||
}
|
||||
|
||||
/// Returns whether cooperative cancellation was requested.
|
||||
fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Relaxed)
|
||||
@ -299,30 +327,41 @@ pub async fn create_job(
|
||||
let completed_job_id = job_id.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let started_at = Instant::now();
|
||||
log::info!("media job registered: job_id={completed_job_id}");
|
||||
let max_pass_through_bytes = request.max_pass_through_bytes.unwrap_or(DEFAULT_MAX_PASS_THROUGH_BYTES);
|
||||
let task_job = Arc::clone(&job);
|
||||
let result = tokio::task::spawn_blocking(move || normalize_media(&input_path, &output_path, max_pass_through_bytes, &task_job)).await;
|
||||
match result {
|
||||
Ok(Ok(result)) => job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Completed,
|
||||
progress: Some(1.0),
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}),
|
||||
Ok(Ok(result)) => {
|
||||
log::info!("media job completed: job_id={completed_job_id}, elapsed_ms={}", started_at.elapsed().as_millis());
|
||||
job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Completed,
|
||||
progress: Some(1.0),
|
||||
result: Some(result),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Err(error)) if error.code == MediaErrorCode::Cancelled => job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Cancelled,
|
||||
progress: None,
|
||||
result: None,
|
||||
error: None,
|
||||
}),
|
||||
Ok(Err(error)) if error.code == MediaErrorCode::Cancelled => {
|
||||
log::info!("media job cancelled: job_id={completed_job_id}, elapsed_ms={}", started_at.elapsed().as_millis());
|
||||
job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Cancelled,
|
||||
progress: None,
|
||||
result: None,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Err(error)) => job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Failed,
|
||||
progress: None,
|
||||
result: None,
|
||||
error: Some(error),
|
||||
}),
|
||||
Ok(Err(error)) => {
|
||||
log::error!("media job failed: job_id={completed_job_id}, code={:?}, diagnostic={}, elapsed_ms={}", error.code, error.message, started_at.elapsed().as_millis());
|
||||
job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Failed,
|
||||
progress: None,
|
||||
result: None,
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
|
||||
Err(error) => job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Failed,
|
||||
@ -387,6 +426,76 @@ fn phase_name(phase: &MediaJobPhase) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared byte position retained after the source is moved into Symphonia.
|
||||
#[derive(Clone)]
|
||||
struct SourceProgress {
|
||||
bytes_read: Arc<AtomicU64>,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
impl SourceProgress {
|
||||
/// Returns monotonically clamped sequential read progress.
|
||||
fn fraction(&self) -> Option<f64> {
|
||||
(self.length > 0).then(|| (self.bytes_read.load(Ordering::Relaxed) as f64 / self.length as f64).clamp(0.0, 0.99))
|
||||
}
|
||||
}
|
||||
|
||||
/// File source that checks cancellation inside every read and seek operation.
|
||||
struct CancellationMediaSource {
|
||||
file: File,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
bytes_read: Arc<AtomicU64>,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
impl CancellationMediaSource {
|
||||
/// Wraps a regular file and exposes a progress handle to the transcoder.
|
||||
fn new(file: File, cancelled: Arc<AtomicBool>) -> std::io::Result<(Self, SourceProgress)> {
|
||||
let length = file.metadata()?.len();
|
||||
let bytes_read = Arc::new(AtomicU64::new(0));
|
||||
let progress = SourceProgress { bytes_read: Arc::clone(&bytes_read), length };
|
||||
Ok((Self { file, cancelled, bytes_read, length }, progress))
|
||||
}
|
||||
|
||||
/// Converts cancellation into an interrupted I/O operation understood by the reader.
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
if self.cancelled.load(Ordering::Relaxed) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "media job cancelled"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for CancellationMediaSource {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.check_cancelled()?;
|
||||
let count = self.file.read(buffer)?;
|
||||
self.bytes_read.fetch_add(count as u64, Ordering::Relaxed);
|
||||
self.check_cancelled()?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for CancellationMediaSource {
|
||||
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
|
||||
self.check_cancelled()?;
|
||||
let position = self.file.seek(position)?;
|
||||
self.check_cancelled()?;
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for CancellationMediaSource {
|
||||
fn is_seekable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn byte_len(&self) -> Option<u64> {
|
||||
Some(self.length)
|
||||
}
|
||||
}
|
||||
|
||||
/// Probes, normalizes, and atomically commits one media file.
|
||||
fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_through_bytes: u64, job: &MediaJob) -> Result<MediaJobResult, MediaError> {
|
||||
check_cancelled(job)?;
|
||||
@ -412,21 +521,47 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
|
||||
return Err(MediaError::new(MediaErrorCode::NotMedia, format!("The selected file is not supported media (detected as {detected:?}).")));
|
||||
}
|
||||
|
||||
let file_size = input_path.metadata().map(|metadata| metadata.len()).unwrap_or(0);
|
||||
log::info!("media job started: input='{}', output='{}', size_bytes={}, detected_file_format={detected:?}", input_path.display(), output_path.display(), file_size);
|
||||
|
||||
let file = File::open(input_path).map_err(|error| MediaError::new(MediaErrorCode::FileOpenFailed, error.to_string()))?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
let (source, source_progress) = CancellationMediaSource::new(file, Arc::clone(&job.cancelled))
|
||||
.map_err(|error| MediaError::new(MediaErrorCode::FileOpenFailed, error.to_string()))?;
|
||||
let mss = MediaSourceStream::new(Box::new(source), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(extension) = input_path.extension().and_then(|value| value.to_str()) {
|
||||
hint.with_extension(extension);
|
||||
}
|
||||
|
||||
let mut format = symphonia::default::get_probe()
|
||||
let mut format = match symphonia::default::get_probe()
|
||||
.probe(&hint, mss, FormatOptions::default(), MetadataOptions::default())
|
||||
.map_err(map_probe_error)?;
|
||||
{
|
||||
Ok(format) => format,
|
||||
Err(_) if job.is_cancelled() => return Err(MediaError::new(MediaErrorCode::Cancelled, "The media job was cancelled.")),
|
||||
Err(error) => return Err(map_probe_error(error)),
|
||||
};
|
||||
|
||||
let detected_format = format!("{detected:?} / {}", format.format_info().long_name);
|
||||
|
||||
let tracks = format.tracks();
|
||||
for track in tracks {
|
||||
if let Some(params) = track.codec_params.as_ref().and_then(CodecParameters::audio) {
|
||||
log::info!(
|
||||
"media track: id={}, type={:?}, default={}, codec={}, sample_rate={:?}, channels={:?}, duration={:?}, time_base={:?}",
|
||||
track.id,
|
||||
track.track_type(),
|
||||
track.flags.contains(TrackFlags::DEFAULT),
|
||||
params.codec,
|
||||
params.sample_rate,
|
||||
params.channels.as_ref().map(|channels| channels.count()),
|
||||
track.duration,
|
||||
track.time_base,
|
||||
);
|
||||
} else {
|
||||
log::info!("media track: id={}, type={:?}, default={}, non_audio=true", track.id, track.track_type(), track.flags.contains(TrackFlags::DEFAULT));
|
||||
}
|
||||
}
|
||||
if !tracks.iter().any(is_audio_track) {
|
||||
return Err(MediaError::new(MediaErrorCode::NoAudioTrack, "The selected media file does not contain an audio track."));
|
||||
}
|
||||
@ -437,14 +572,27 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
|
||||
let track_id = selected.id;
|
||||
let params = selected.codec_params.as_ref().and_then(CodecParameters::audio).unwrap().clone();
|
||||
let detected_codec = if params.codec == CODEC_ID_OPUS { "opus".to_string() } else { format!("{}", params.codec) };
|
||||
let duration_ms = selected.num_frames.zip(params.sample_rate)
|
||||
let track_duration_ms = selected.num_frames.zip(params.sample_rate)
|
||||
.map(|(frames, rate)| frames.saturating_mul(1_000) / u64::from(rate))
|
||||
.or_else(|| selected.time_base.zip(selected.duration).and_then(|(time_base, duration)| {
|
||||
let timestamp = Timestamp::new(i64::try_from(duration.get()).ok()?);
|
||||
let time = time_base.calc_time(timestamp)?;
|
||||
Some((time.as_secs_f64() * 1000.0).max(0.0).round() as u64)
|
||||
}))
|
||||
.unwrap_or(0);
|
||||
}));
|
||||
let container_duration_ms = format.media_info().time_base.zip(format.media_info().duration).and_then(|(time_base, duration)| {
|
||||
let timestamp = Timestamp::new(i64::try_from(duration.get()).ok()?);
|
||||
let time = time_base.calc_time(timestamp)?;
|
||||
Some((time.as_secs_f64() * 1000.0).max(0.0).round() as u64)
|
||||
});
|
||||
let duration_ms = track_duration_ms.or(container_duration_ms).unwrap_or(0);
|
||||
log::info!(
|
||||
"media audio selection: track_id={}, default={}, track_duration_ms={:?}, container_duration_ms={:?}, progress_duration_ms={}",
|
||||
track_id,
|
||||
selected.flags.contains(TrackFlags::DEFAULT),
|
||||
track_duration_ms,
|
||||
container_duration_ms,
|
||||
duration_ms,
|
||||
);
|
||||
|
||||
let channels = params.channels.as_ref().map(|value| value.count()).unwrap_or(0);
|
||||
let pass_through = is_webm_container(input_path)
|
||||
@ -454,6 +602,7 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
|
||||
&& params.sample_rate == Some(OUTPUT_SAMPLE_RATE)
|
||||
&& channels == 1
|
||||
&& input_path.metadata().map(|metadata| metadata.len() <= max_pass_through_bytes).unwrap_or(false);
|
||||
log::info!("media normalization decision: track_id={track_id}, pass_through={pass_through}, codec={detected_codec}, channels={channels}");
|
||||
|
||||
let partial_path = partial_path(output_path);
|
||||
if let Some(parent) = partial_path.parent() {
|
||||
@ -470,12 +619,7 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
|
||||
pass_through: true,
|
||||
})
|
||||
} else {
|
||||
job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Transcoding,
|
||||
progress: Some(0.0),
|
||||
result: None,
|
||||
error: None,
|
||||
});
|
||||
job.publish_progress(MediaJobPhase::Transcoding, Some(0.0));
|
||||
|
||||
let context = TranscodeContext {
|
||||
track_id,
|
||||
@ -487,6 +631,8 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
|
||||
detected_format,
|
||||
detected_codec,
|
||||
expected_duration_ms: duration_ms,
|
||||
time_base: selected.time_base,
|
||||
source_progress,
|
||||
job,
|
||||
};
|
||||
transcode(&mut *format, context)
|
||||
@ -568,11 +714,17 @@ struct TranscodeContext<'a> {
|
||||
/// Container duration used for progress reporting.
|
||||
expected_duration_ms: u64,
|
||||
|
||||
/// Selected track timebase used to align packet presentation timestamps.
|
||||
time_base: Option<TimeBase>,
|
||||
|
||||
/// Sequential byte progress fallback when the selected track has no duration.
|
||||
source_progress: SourceProgress,
|
||||
|
||||
/// Cancellation and progress state for the job.
|
||||
job: &'a MediaJob,
|
||||
}
|
||||
|
||||
/// Decodes a selected track and writes bounded 20 ms mono Opus frames.
|
||||
/// Decodes a selected track and writes timestamp-aligned 20 ms mono Opus frames.
|
||||
fn transcode(
|
||||
format: &mut dyn symphonia::core::formats::FormatReader,
|
||||
context: TranscodeContext<'_>,
|
||||
@ -591,6 +743,13 @@ fn transcode(
|
||||
let mut decoded_tail = Vec::<f32>::new();
|
||||
let mut encoded = [0u8; 4_000];
|
||||
let mut produced_samples = 0u64;
|
||||
let mut first_packet_pts = None::<i64>;
|
||||
let mut last_packet_pts = None::<i64>;
|
||||
let mut decoded_packets = 0u64;
|
||||
let mut discarded_packets = 0u64;
|
||||
let mut decode_errors = 0u64;
|
||||
let mut discontinuities = 0u64;
|
||||
let mut last_progress = 0.0f64;
|
||||
|
||||
loop {
|
||||
check_cancelled(context.job)?;
|
||||
@ -599,22 +758,36 @@ fn transcode(
|
||||
Ok(None) => break,
|
||||
|
||||
Err(SymphoniaError::ResetRequired) => return Err(MediaError::new(MediaErrorCode::StreamReset, "The media stream changed unexpectedly.")),
|
||||
Err(SymphoniaError::IoError(error)) if error.kind() == std::io::ErrorKind::Interrupted && context.job.is_cancelled() => {
|
||||
return Err(MediaError::new(MediaErrorCode::Cancelled, "The media job was cancelled."));
|
||||
}
|
||||
|
||||
Err(SymphoniaError::IoError(error)) if error.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(error) => return Err(MediaError::new(MediaErrorCode::DamagedContainer, format!("The media container is damaged: {error}"))),
|
||||
};
|
||||
|
||||
if packet.track_id != context.track_id {
|
||||
discarded_packets += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let packet_pts = packet.pts.get();
|
||||
first_packet_pts.get_or_insert(packet_pts);
|
||||
last_packet_pts = Some(packet_pts);
|
||||
let Some((mono, sample_rate)) = decoder.decode(&packet)? else {
|
||||
decode_errors += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
decoded_packets += 1;
|
||||
let stream_resampler = match resampler.as_mut() {
|
||||
Some(existing) if existing.input_rate() == sample_rate => existing,
|
||||
Some(_) => return Err(MediaError::new(MediaErrorCode::InvalidAudioParameters, "The decoded audio sample rate changed during the stream.")),
|
||||
None => resampler.insert(StreamResampler::new(sample_rate)?),
|
||||
|
||||
None => {
|
||||
log::info!("media resampling: input_rate={sample_rate}, output_rate={OUTPUT_SAMPLE_RATE}, enabled={}", sample_rate != OUTPUT_SAMPLE_RATE);
|
||||
resampler.insert(StreamResampler::new(sample_rate)?)
|
||||
}
|
||||
};
|
||||
|
||||
// Retain only the possible end padding so it can never be emitted prematurely.
|
||||
@ -623,35 +796,45 @@ fn transcode(
|
||||
let emit_len = decoded_tail.len().saturating_sub(padding);
|
||||
if emit_len > 0 {
|
||||
let emit: Vec<_> = decoded_tail.drain(..emit_len).collect();
|
||||
append_duration_bounded(
|
||||
let resampled = stream_resampler.push(&emit)?;
|
||||
|
||||
// FFT resamplers buffer across packet boundaries, so their returned samples no longer
|
||||
// begin at the current packet PTS. Native 48-kHz streams retain exact packet alignment.
|
||||
let desired_start = (sample_rate == OUTPUT_SAMPLE_RATE)
|
||||
.then(|| packet_output_start(packet_pts, first_packet_pts, context.time_base))
|
||||
.flatten();
|
||||
|
||||
let current_start = produced_samples.saturating_add(pending.len() as u64);
|
||||
append_timestamp_aligned(
|
||||
&mut pending,
|
||||
&stream_resampler.push(&emit)?,
|
||||
produced_samples,
|
||||
context.expected_duration_ms,
|
||||
&resampled,
|
||||
current_start,
|
||||
desired_start,
|
||||
context.track_id,
|
||||
packet_pts,
|
||||
&mut discontinuities,
|
||||
);
|
||||
}
|
||||
|
||||
encode_complete_frames(&mut pending, &mut opus_encoder, &mut writer, &mut encoded, &mut produced_samples, context.job)?;
|
||||
|
||||
if context.expected_duration_ms > 0 {
|
||||
let current_ms = produced_samples.saturating_mul(1000) / u64::from(OUTPUT_SAMPLE_RATE);
|
||||
context.job.publish(MediaJobEvent {
|
||||
phase: MediaJobPhase::Transcoding,
|
||||
progress: Some((current_ms as f64 / context.expected_duration_ms as f64).clamp(0.0, 0.99)),
|
||||
result: None,
|
||||
error: None,
|
||||
});
|
||||
let timestamp_ms = packet_timestamp_ms(packet_pts, first_packet_pts, context.time_base);
|
||||
let progress = if context.expected_duration_ms > 0 {
|
||||
timestamp_ms.map(|current_ms| (current_ms as f64 / context.expected_duration_ms as f64).clamp(0.0, 0.99))
|
||||
} else {
|
||||
context.source_progress.fraction()
|
||||
};
|
||||
|
||||
if let Some(progress) = progress {
|
||||
last_progress = last_progress.max(progress);
|
||||
}
|
||||
|
||||
context.job.publish_progress(MediaJobPhase::Transcoding, progress.map(|_| last_progress));
|
||||
}
|
||||
|
||||
if let Some(stream_resampler) = resampler.as_mut() {
|
||||
// Discard the retained decoded tail (container padding), then flush the filter delay.
|
||||
append_duration_bounded(
|
||||
&mut pending,
|
||||
&stream_resampler.finish()?,
|
||||
produced_samples,
|
||||
context.expected_duration_ms,
|
||||
);
|
||||
pending.extend_from_slice(&stream_resampler.finish()?);
|
||||
} else {
|
||||
return Err(MediaError::new(MediaErrorCode::InvalidAudioParameters, "The selected audio track did not yield decoded audio parameters."));
|
||||
}
|
||||
@ -669,26 +852,86 @@ fn transcode(
|
||||
writer.finish()?;
|
||||
check_cancelled(context.job)?;
|
||||
|
||||
let output_size = fs::metadata(context.partial_path).map(|metadata| metadata.len()).unwrap_or(0);
|
||||
let output_duration_ms = produced_samples.saturating_mul(1000) / u64::from(OUTPUT_SAMPLE_RATE);
|
||||
if context.expected_duration_ms > 0 && output_duration_ms.abs_diff(context.expected_duration_ms) > 2_000 {
|
||||
log::warn!(
|
||||
"media duration mismatch: track_id={}, expected_duration_ms={}, decoded_duration_ms={}",
|
||||
context.track_id,
|
||||
context.expected_duration_ms,
|
||||
output_duration_ms,
|
||||
);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"media transcode completed: track_id={}, decoded_packets={}, discarded_packets={}, recoverable_decode_errors={}, first_pts={:?}, last_pts={:?}, discontinuities={}, output_bytes={}, duration_ms={}",
|
||||
context.track_id,
|
||||
decoded_packets,
|
||||
discarded_packets,
|
||||
decode_errors,
|
||||
first_packet_pts,
|
||||
last_packet_pts,
|
||||
discontinuities,
|
||||
output_size,
|
||||
output_duration_ms,
|
||||
);
|
||||
|
||||
Ok(MediaJobResult {
|
||||
output_path: context.output_path.to_string_lossy().into_owned(),
|
||||
detected_format: context.detected_format,
|
||||
detected_codec: context.detected_codec,
|
||||
duration_ms: produced_samples.saturating_mul(1000) / u64::from(OUTPUT_SAMPLE_RATE),
|
||||
duration_ms: output_duration_ms,
|
||||
pass_through: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends resampled PCM without exceeding a reliable container duration.
|
||||
fn append_duration_bounded(pending: &mut Vec<f32>, samples: &[f32], produced_samples: u64, expected_duration_ms: u64) {
|
||||
if expected_duration_ms == 0 {
|
||||
pending.extend_from_slice(samples);
|
||||
return;
|
||||
/// Converts a packet PTS to its output sample offset relative to the first audio packet.
|
||||
fn packet_output_start(packet_pts: i64, first_packet_pts: Option<i64>, time_base: Option<TimeBase>) -> Option<u64> {
|
||||
packet_timestamp_ms(packet_pts, first_packet_pts, time_base)
|
||||
.map(|milliseconds| milliseconds.saturating_mul(u64::from(OUTPUT_SAMPLE_RATE)) / 1_000)
|
||||
}
|
||||
|
||||
/// Converts a packet PTS to milliseconds relative to the first selected-track packet.
|
||||
fn packet_timestamp_ms(packet_pts: i64, first_packet_pts: Option<i64>, time_base: Option<TimeBase>) -> Option<u64> {
|
||||
let delta = packet_pts.checked_sub(first_packet_pts?)?;
|
||||
if delta < 0 {
|
||||
return Some(0);
|
||||
}
|
||||
|
||||
let target_samples = expected_duration_ms.saturating_mul(u64::from(OUTPUT_SAMPLE_RATE)).div_ceil(1_000);
|
||||
let accepted = produced_samples.saturating_add(pending.len() as u64);
|
||||
let remaining = target_samples.saturating_sub(accepted) as usize;
|
||||
pending.extend_from_slice(&samples[..samples.len().min(remaining)]);
|
||||
let time = time_base?.calc_time(Timestamp::new(delta))?;
|
||||
Some((time.as_secs_f64() * 1_000.0).max(0.0).round() as u64)
|
||||
}
|
||||
|
||||
/// Inserts silence for forward timestamp gaps and trims overlapping decoded samples.
|
||||
fn append_timestamp_aligned(
|
||||
pending: &mut Vec<f32>,
|
||||
samples: &[f32],
|
||||
current_start: u64,
|
||||
desired_start: Option<u64>,
|
||||
track_id: u32,
|
||||
packet_pts: i64,
|
||||
discontinuities: &mut u64,
|
||||
) {
|
||||
let Some(desired_start) = desired_start else {
|
||||
pending.extend_from_slice(samples);
|
||||
return;
|
||||
};
|
||||
|
||||
let delta = i128::from(desired_start) - i128::from(current_start);
|
||||
let delta_ms = delta.saturating_mul(1_000) / i128::from(OUTPUT_SAMPLE_RATE);
|
||||
if delta_ms.unsigned_abs() >= LARGE_DISCONTINUITY_MS as u128 {
|
||||
*discontinuities += 1;
|
||||
log::warn!("audio timestamp discontinuity: track_id={track_id}, pts={packet_pts}, delta_ms={delta_ms}");
|
||||
}
|
||||
|
||||
if delta > 0 {
|
||||
let silence = usize::try_from(delta).unwrap_or(usize::MAX);
|
||||
pending.resize(pending.len().saturating_add(silence), 0.0);
|
||||
pending.extend_from_slice(samples);
|
||||
} else {
|
||||
let overlap = usize::try_from(delta.unsigned_abs()).unwrap_or(usize::MAX).min(samples.len());
|
||||
pending.extend_from_slice(&samples[overlap..]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Downmixes interleaved PCM to mono using a deterministic arithmetic mean.
|
||||
@ -1208,6 +1451,8 @@ fn webm_error(error: impl std::fmt::Display) -> MediaError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
use std::num::NonZeroU32;
|
||||
use tokio::sync::broadcast::error::TryRecvError;
|
||||
use symphonia::core::audio::{Channels, Position};
|
||||
use symphonia::core::codecs::audio::well_known::{CODEC_ID_AC3, CODEC_ID_PCM_S16LE};
|
||||
use symphonia::core::codecs::audio::AudioCodecParameters;
|
||||
@ -1305,6 +1550,57 @@ mod tests {
|
||||
assert_eq!(downmix_to_mono(&[1.0, -1.0, 0.5, 0.5], 2), vec![0.0, 0.5]);
|
||||
}
|
||||
|
||||
/// Verifies timestamp gaps become silence and overlaps do not duplicate decoded samples.
|
||||
#[test]
|
||||
fn timestamp_alignment_inserts_gaps_and_trims_overlaps() {
|
||||
let mut discontinuities = 0;
|
||||
let mut gap = vec![1.0; 960];
|
||||
append_timestamp_aligned(&mut gap, &vec![2.0; 960], 960, Some(1_920), 7, 40, &mut discontinuities);
|
||||
assert_eq!(gap.len(), 2_880);
|
||||
assert!(gap[960..1_920].iter().all(|sample| *sample == 0.0));
|
||||
assert!(gap[1_920..].iter().all(|sample| *sample == 2.0));
|
||||
|
||||
let mut overlap = vec![1.0; 960];
|
||||
append_timestamp_aligned(&mut overlap, &vec![2.0; 960], 960, Some(480), 7, 10, &mut discontinuities);
|
||||
assert_eq!(overlap.len(), 1_440);
|
||||
assert!(overlap[960..].iter().all(|sample| *sample == 2.0));
|
||||
}
|
||||
|
||||
/// Verifies packet progress uses a selected-track-relative time axis.
|
||||
#[test]
|
||||
fn packet_timestamps_are_relative_to_the_first_audio_packet() {
|
||||
let time_base = TimeBase::new(NonZeroU32::new(1).unwrap(), NonZeroU32::new(1_000).unwrap());
|
||||
assert_eq!(packet_timestamp_ms(5_250, Some(5_000), Some(time_base)), Some(250));
|
||||
assert_eq!(packet_output_start(5_250, Some(5_000), Some(time_base)), Some(12_000));
|
||||
}
|
||||
|
||||
/// Verifies running updates are throttled while a phase transition remains immediate.
|
||||
#[test]
|
||||
fn running_progress_is_throttled_but_phase_changes_are_immediate() {
|
||||
let job = MediaJob::new();
|
||||
let mut events = job.events.subscribe();
|
||||
job.publish_progress(MediaJobPhase::Transcoding, Some(0.1));
|
||||
job.publish_progress(MediaJobPhase::Transcoding, Some(0.2));
|
||||
job.publish_progress(MediaJobPhase::Probing, Some(0.0));
|
||||
|
||||
assert_eq!(events.try_recv().unwrap().progress, Some(0.1));
|
||||
assert_eq!(events.try_recv().unwrap().phase, MediaJobPhase::Probing);
|
||||
assert!(matches!(events.try_recv(), Err(TryRecvError::Empty)));
|
||||
}
|
||||
|
||||
/// Verifies cancellation interrupts source reads rather than waiting for another packet.
|
||||
#[test]
|
||||
fn cancellation_aware_source_interrupts_reads() {
|
||||
let path = std::env::temp_dir().join(format!("ai-studio-source-cancel-{}", rand::random::<u64>()));
|
||||
fs::write(&path, vec![0u8; COPY_BLOCK_BYTES * 2]).unwrap();
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let (mut source, _) = CancellationMediaSource::new(File::open(&path).unwrap(), Arc::clone(&cancelled)).unwrap();
|
||||
cancelled.store(true, Ordering::Relaxed);
|
||||
let error = source.read(&mut [0u8; 16]).unwrap_err();
|
||||
let _ = fs::remove_file(path);
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::Interrupted);
|
||||
}
|
||||
|
||||
/// Verifies output shape and at-most-one-frame duration rounding.
|
||||
#[test]
|
||||
fn wav_is_normalized_to_one_mono_opus_track_with_frame_bounded_duration() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user