Rework after second review

This commit is contained in:
Thorsten Sommer 2026-07-14 14:41:13 +02:00
parent 5118a87bd2
commit 3fcca39b51
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
29 changed files with 1237 additions and 219 deletions

View File

@ -31,15 +31,17 @@
@if (this.Body is not null) @if (this.Body is not null)
{ {
<MediaTranscriptionStatus/> <MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
<CascadingValue Value="@this"> <CascadingValue Value="@this.CurrentMediaImportOwner">
<CascadingValue Value="@this.Component"> <CascadingValue Value="@this">
@this.Body <CascadingValue Value="@this.Component">
@this.Body
</CascadingValue>
</CascadingValue> </CascadingValue>
</CascadingValue> </CascadingValue>
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.Start" Class="mb-3"> <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 @this.SubmitText
</MudButton> </MudButton>
@if (this.IsProcessing) @if (this.IsProcessing)

View File

@ -135,6 +135,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected CancellationTokenSource? CancellationTokenSource; protected CancellationTokenSource? CancellationTokenSource;
private bool isDisposed; private bool isDisposed;
private AssistantSessionKey assistantSessionKey; private AssistantSessionKey assistantSessionKey;
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey);
private Guid? assistantSessionId; private Guid? assistantSessionId;
private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot; private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot;
@ -227,7 +228,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
private async Task Start() private async Task Start()
{ {
if (this.MediaTranscriptionService.IsBusy) if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
return; return;
var activeSession = this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey); 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> /// <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 #endregion

View File

@ -134,7 +134,7 @@ else
{ {
var fileState = this.assistantState.FileContent[fileContent.Name]; var fileState = this.assistantState.FileContent[fileContent.Name];
<div class="@fileContent.Class" style="@GetOptionalStyle(fileContent.Style)"> <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> </div>
} }
break; break;

View File

@ -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. -- 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." 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 -- Open Settings
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "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. -- Assistant is still running.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "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. -- 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." 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. -- 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." 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 -- Stop media transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1868377405"] = "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 -- Inspecting media
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2431421733"] = "Inspecting media" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2431421733"] = "Inspecting media"

View File

@ -29,6 +29,12 @@ public sealed record ChatThread
/// </summary> /// </summary>
public ulong LastMediaTranscriptNumber { get; set; } 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> /// <summary>
/// Specifies the provider selected for the chat thread. /// Specifies the provider selected for the chat thread.
/// </summary> /// </summary>

View File

@ -1,7 +1,6 @@
using System.Text; using System.Text;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Chat; namespace AIStudio.Chat;

View File

@ -1,6 +1,7 @@
using AIStudio.Dialogs.Settings; using AIStudio.Dialogs.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions; using DialogOptions = AIStudio.Dialogs.DialogOptions;
@ -60,6 +61,9 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
[Inject] [Inject]
private AssistantSessionService AssistantSessionService { get; init; } = null!; private AssistantSessionService AssistantSessionService { get; init; } = null!;
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
private async Task OpenSettingsDialog() private async Task OpenSettingsDialog()
{ {
@ -71,7 +75,7 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
await this.DialogService.ShowAsync<TSettings>(T("Open Settings"), dialogParameters, DialogOptions.FULLSCREEN); 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, true => this.ColorTheme.GetCurrentPalette(this.SettingsManager).GrayDefault,
false => 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.Component == this.Component)
: this.AssistantSessionService.GetSnapshots().FirstOrDefault(snapshot => snapshot.Key.InstanceId == this.AssistantSessionInstanceId); : 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> /// <summary>
/// Gets the assistant session indicator shown on top of the assistant icon. /// Gets the assistant session indicator shown on top of the assistant icon.
/// </summary> /// </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.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.")), 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, _ => 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> /// <summary>
/// Refreshes the block when assistant session activity changes. /// Refreshes the block when assistant session activity changes.
/// </summary> /// </summary>

View File

@ -13,6 +13,11 @@ using DialogOptions = Dialogs.DialogOptions;
public partial class AttachDocuments : MSGComponentBase 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)); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AttachDocuments).Namespace, nameof(AttachDocuments));
[Parameter] [Parameter]
@ -67,6 +72,10 @@ public partial class AttachDocuments : MSGComponentBase
[Parameter] [Parameter]
public ChatThread? OwnerChat { get; set; } 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] [Inject]
private ILogger<AttachDocuments> Logger { get; set; } = null!; private ILogger<AttachDocuments> Logger { get; set; } = null!;
@ -88,7 +97,11 @@ public partial class AttachDocuments : MSGComponentBase
private uint numDropAreasAboveThis; private uint numDropAreasAboveThis;
private bool isComponentHovered; private bool isComponentHovered;
private bool isDraggingOver; 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 #region Overrides of MSGComponentBase
@ -100,10 +113,36 @@ public partial class AttachDocuments : MSGComponentBase
// Register this drop area: // Register this drop area:
await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer); await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer);
await base.OnInitializedAsync(); await base.OnInitializedAsync();
await this.SyncCompletedMediaAttachmentsAsync();
} }
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary> /// <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> /// <summary>Unsubscribes from the singleton media service.</summary>
protected override void DisposeResources() protected override void DisposeResources()
@ -226,6 +265,8 @@ public partial class AttachDocuments : MSGComponentBase
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths); this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths)) foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment); ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
this.ReconcileOwnerPendingTranscripts();
} }
private async Task ClearAllFiles() private async Task ClearAllFiles()
@ -235,7 +276,9 @@ public partial class AttachDocuments : MSGComponentBase
foreach (var attachment in this.DocumentPaths) foreach (var attachment in this.DocumentPaths)
ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment); ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment);
this.DocumentPaths.Clear(); this.DocumentPaths.Clear();
this.ReconcileOwnerPendingTranscripts();
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths); await this.OnChange(this.DocumentPaths);
} }
@ -273,11 +316,22 @@ public partial class AttachDocuments : MSGComponentBase
this.DocumentPaths.Remove(fileAttachment); this.DocumentPaths.Remove(fileAttachment);
ManagedTranscriptAttachment.TryDeleteOwnedFile(fileAttachment); ManagedTranscriptAttachment.TryDeleteOwnedFile(fileAttachment);
this.ReconcileOwnerPendingTranscripts();
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(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) private async Task AddFileBatchAsync(IEnumerable<string> paths)
{ {
var existingPaths = paths.Where(File.Exists).ToList(); var existingPaths = paths.Where(File.Exists).ToList();
@ -336,29 +390,10 @@ public partial class AttachDocuments : MSGComponentBase
if (dialogResult is null || dialogResult.Canceled) if (dialogResult is null || dialogResult.Canceled)
return; return;
var failures = new List<string>(); if (this.OwnerChat is null)
foreach (var mediaPath in mediaPaths) this.OwnerChat = await this.EnsureOwnerChatAsync(mediaPaths[0]);
{
var result = await this.MediaTranscriptionService.TranscribeImportAsync(mediaPath);
if (result.Status is MediaTranscriptionResultStatus.CANCELLED)
break;
if (result.Status is MediaTranscriptionResultStatus.FAILED) this.MediaTranscriptionService.TryStartAttachmentBatch(mediaPaths, this.EffectiveImportOwner, this.OwnerChat);
{
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)));
} }
private static bool IsTranscribableMedia(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO) || FileTypes.IsAllowedPath(path, FileTypes.VIDEO); private static bool IsTranscribableMedia(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO) || FileTypes.IsAllowedPath(path, FileTypes.VIDEO);

View File

@ -33,7 +33,7 @@
} }
</ChildContent> </ChildContent>
<FooterContent> <FooterContent>
<MediaTranscriptionStatus/> <MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
<MudElement Style="flex: 0 0 auto;"> <MudElement Style="flex: 0 0 auto;">
<MudTextField <MudTextField
T="string" T="string"
@ -46,7 +46,7 @@
Label="@this.InputLabel" Label="@this.InputLabel"
Placeholder="@this.ProviderPlaceholder" Placeholder="@this.ProviderPlaceholder"
Adornment="Adornment.End" 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()" OnAdornmentClick="() => this.SendMessage()"
Disabled="@this.IsInputForbidden()" Disabled="@this.IsInputForbidden()"
Immediate="@true" Immediate="@true"
@ -101,7 +101,7 @@
</MudTooltip> </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;"/> <MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>

View File

@ -15,6 +15,7 @@ namespace AIStudio.Components;
public partial class ChatComponent : MSGComponentBase, IAsyncDisposable public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{ {
private readonly Guid draftMediaOwnerId = Guid.NewGuid();
private const string CHAT_INPUT_ID = "chat-user-input"; private const string CHAT_INPUT_ID = "chat-user-input";
private const string MARKDOWN_CODE = "code"; private const string MARKDOWN_CODE = "code";
private const string MARKDOWN_BOLD = "bold"; private const string MARKDOWN_BOLD = "bold";
@ -85,6 +86,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private Guid foregroundChatId = Guid.Empty; private Guid foregroundChatId = Guid.Empty;
private int workspaceHeaderSyncVersion; 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 // Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field. // this, we cannot clear the input field.
private MudTextField<string> inputField = null!; 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> /// <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) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
@ -689,10 +696,41 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.ComposerState.MarkUserDraft(); this.ComposerState.MarkUserDraft();
this.hasUnsavedChanges = true; 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) private async Task SendMessage(bool reuseLastUserPrompt = false)
{ {
if (this.MediaTranscriptionService.IsBusy) if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
return; return;
if (!this.IsProviderSelected) if (!this.IsProviderSelected)
@ -757,6 +795,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
Text = this.ComposerState.UserInput, Text = this.ComposerState.UserInput,
FileAttachments = normalizedAttachments, FileAttachments = normalizedAttachments,
}; };
this.ChatThread.PendingMediaTranscripts.Clear();
// //
// Add the user message to the thread: // Add the user message to the thread:

View File

@ -2,17 +2,17 @@
@inject MediaTranscriptionService MediaTranscriptionService @inject MediaTranscriptionService MediaTranscriptionService
@using AIStudio.Tools.Services @using AIStudio.Tools.Services
@if (this.MediaTranscriptionService.IsBusy) @if (this.Snapshot is { IsBusy: true } snapshot)
{ {
<MudPaper Outlined="true" Class="pa-2 mb-2"> <MudPaper Outlined="true" Class="pa-2 mb-2">
<MudStack Row="true" AlignItems="AlignItems.Center"> <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"> <MudText Typo="Typo.body2">
@this.StatusText @this.StatusText
</MudText> </MudText>
<MudSpacer/> <MudSpacer/>
<MudTooltip Text="@T("Stop media transcription")"> <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> </MudTooltip>
</MudStack> </MudStack>
</MudPaper> </MudPaper>

View File

@ -1,17 +1,26 @@
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components; namespace AIStudio.Components;
public partial class MediaTranscriptionStatus public partial class MediaTranscriptionStatus
{ {
/// <summary>Gets the localized visible status for the active import.</summary> /// <summary>The surface owner whose operation is rendered.</summary>
private string StatusText => this.MediaTranscriptionService.Phase switch [Parameter]
{ public MediaImportOwner Owner { get; set; }
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}",
_ => 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> /// <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> /// <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> /// <summary>Unsubscribes from singleton import state changes.</summary>
protected override void DisposeResources() protected override void DisposeResources()

View File

@ -1,13 +1,11 @@
@inherits MSGComponentBase @inherits MSGComponentBase
<MediaTranscriptionStatus/>
@if (this.EnableDragDrop) @if (this.EnableDragDrop)
{ {
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave"> <div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
<MudPaper Outlined="true" Class="@this.dragClass"> <MudPaper Outlined="true" Class="@this.dragClass">
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.None" Wrap="Wrap.Wrap"> <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 @this.ButtonText
</MudButton> </MudButton>
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">
@ -19,7 +17,7 @@
} }
else 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 @this.ButtonText
</MudButton> </MudButton>
} }

View File

@ -9,6 +9,21 @@ namespace AIStudio.Components;
public partial class ReadFileContent : MSGComponentBase 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] [Parameter]
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
@ -57,7 +72,7 @@ public partial class ReadFileContent : MSGComponentBase
private string dragClass = DEFAULT_DRAG_CLASS; private string dragClass = DEFAULT_DRAG_CLASS;
private uint numDropAreasAboveThis; private uint numDropAreasAboveThis;
private bool isComponentHovered; 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 #region Overrides of MSGComponentBase
@ -71,10 +86,31 @@ public partial class ReadFileContent : MSGComponentBase
} }
await base.OnInitializedAsync(); await base.OnInitializedAsync();
await this.SyncCompletedMediaTextAsync();
} }
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary> /// <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> /// <summary>Unsubscribes from the singleton media service.</summary>
protected override void DisposeResources() protected override void DisposeResources()
@ -242,17 +278,9 @@ public partial class ReadFileContent : MSGComponentBase
if (dialogResult is null || dialogResult.Canceled) if (dialogResult is null || dialogResult.Canceled)
return false; return false;
var result = await this.MediaTranscriptionService.TranscribeImportAsync(filePath); return this.MediaTranscriptionService.TryStartTextImport(
if (result.Status is not MediaTranscriptionResultStatus.SUCCEEDED) filePath,
{ new MediaImportTarget(this.EffectiveImportOwner, this.EffectiveMediaImportTargetId));
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;
} }
private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments); private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments);

View File

@ -4,6 +4,7 @@ using System.Text.Json;
using AIStudio.Chat; using AIStudio.Chat;
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Tools.AIJobs; using AIStudio.Tools.AIJobs;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@ -21,6 +22,9 @@ public partial class Workspaces : MSGComponentBase
[Inject] [Inject]
private AIJobService AIJobService { get; init; } = null!; private AIJobService AIJobService { get; init; } = null!;
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
[Parameter] [Parameter]
public ChatThread? CurrentChatThread { get; set; } public ChatThread? CurrentChatThread { get; set; }
@ -55,6 +59,7 @@ public partial class Workspaces : MSGComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
await base.OnInitializedAsync(); await base.OnInitializedAsync();
this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_CREATED ]); this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_CREATED ]);
_ = this.LoadTreeItemsAsync(startPrefetch: true); _ = this.LoadTreeItemsAsync(startPrefetch: true);
@ -376,12 +381,25 @@ public partial class Workspaces : MSGComponentBase
private bool IsChatTreeItemBusy(TreeItemData treeItem) 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) 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) private bool IsCurrentChatTreeItem(TreeItemData treeItem)
@ -394,6 +412,21 @@ public partial class Workspaces : MSGComponentBase
private string GetChatTreeIcon(Guid chatId, string defaultIcon) 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); var snapshot = this.AIJobService.TryGetChatSnapshot(chatId);
if (snapshot is null || !snapshot.IsActive) if (snapshot is null || !snapshot.IsActive)
return defaultIcon; 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() private async Task SafeStateHasChanged()
{ {
if (this.isDisposed) if (this.isDisposed)
@ -911,6 +950,7 @@ public partial class Workspaces : MSGComponentBase
protected override void DisposeResources() protected override void DisposeResources()
{ {
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
this.isDisposed = true; this.isDisposed = true;
this.prefetchCancellationTokenSource?.Cancel(); this.prefetchCancellationTokenSource?.Cancel();
this.prefetchCancellationTokenSource?.Dispose(); this.prefetchCancellationTokenSource?.Dispose();

View File

@ -2295,9 +2295,21 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "Das Bil
-- Open Settings -- Open Settings
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Einstellungen öffnen" 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. -- Assistant is still running.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistent läuft noch." 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. -- 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." 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. -- Click here to attach files.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1875575968"] = "Klicken Sie hier, um Dateien anzuhängen." 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: -- 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:" 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 -- Document Preview
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T285154968"] = "Dokumentenvorschau" 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 -- Clear file list
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3759696136"] = "Dateiliste löschen" 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. -- 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." 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. -- 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." 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. -- Drop one file here to load its content.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Datei hier ablegen, um ihren Inhalt zu laden." 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 -- Use file content as input
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Dokumenteninhalt als Eingabe verwenden" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Dokumenteninhalt als Eingabe verwenden"
-- Select file to read its content -- Select file to read its content
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T354817589"] = "Datei auswählen, um den Inhalt zu lesen" 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. -- 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." 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. -- 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." 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. -- Failed to start audio recording.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Audioaufnahme konnte nicht gestartet werden." 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. -- 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." 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. -- Failed to stop audio recording.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3462568264"] = "Audioaufnahme konnte nicht beendet werden." 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. -- An error occurred during transcription.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "Während der Transkription ist ein Fehler aufgetreten." 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. -- The transcription result is empty.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "Das Ergebnis der Transkription ist leer." 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. -- 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." 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. -- 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." 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. -- 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." 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. -- 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." 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 -- Copies the root certificate fingerprint to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2989678330"] = "Kopiert den Fingerabdruck des Stammzertifikats in die Zwischenablage" 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 -- Changelog
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Änderungsprotokoll" 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 -- User-language provided by the OS
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "Vom Betriebssystem bereitgestellte Sprache" 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: -- Status:
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3396815215"] = "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 -- Copies the allowed host configuration to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Kopiert die zulässige Host-Konfiguration in die Zwischenablage" 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 -- Installed Pandoc version
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installierte 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. -- 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." 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 -- Community & Code
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "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." 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 -- How to update
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "Update-Anleitung " UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "Update-Anleitung"
-- Install Pandoc -- Install Pandoc
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Pandoc installieren" UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Pandoc installieren"
@ -8487,6 +8535,39 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code
-- Document -- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Dokument" 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 -- Pandoc Installation
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc-Installation" UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc-Installation"

View File

@ -2295,9 +2295,21 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "The ima
-- Open Settings -- Open Settings
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "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. -- Assistant is still running.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "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. -- 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." 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. -- Click here to attach files.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1875575968"] = "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: -- 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:" 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 -- Document Preview
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T285154968"] = "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 -- Clear file list
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3759696136"] = "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. -- 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." 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. -- 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." 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. -- Drop one file here to load its content.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "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 -- Use file content as input
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Use file content as input" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Use file content as input"
-- Select file to read its content -- Select file to read its content
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T354817589"] = "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. -- 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." 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. -- 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." 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. -- Failed to start audio recording.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "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. -- 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." 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. -- Failed to stop audio recording.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3462568264"] = "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. -- An error occurred during transcription.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "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. -- The transcription result is empty.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "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. -- 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." 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. -- 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." 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. -- 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." 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. -- 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." 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 -- Copies the root certificate fingerprint to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2989678330"] = "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 -- Changelog
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "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 -- User-language provided by the OS
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "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: -- Status:
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3396815215"] = "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 -- Copies the allowed host configuration to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "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 -- Installed Pandoc version
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "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. -- 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." 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 -- Community & Code
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "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 -- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "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 -- Pandoc Installation
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc Installation" UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc Installation"

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

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.Services;
/// <summary>Supported persistent media-operation owners.</summary>
public enum MediaImportOwnerKind
{
CHAT,
ASSISTANT,
}

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

View 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,
}

View File

@ -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);

View File

@ -5,6 +5,9 @@ public enum MediaTranscriptionPhase
{ {
/// <summary>No import is active.</summary> /// <summary>No import is active.</summary>
IDLE, IDLE,
/// <summary>The operation is waiting for the serialized runtime lane.</summary>
QUEUED,
/// <summary>The runtime is inspecting the input.</summary> /// <summary>The runtime is inspecting the input.</summary>
PROBING, PROBING,
@ -14,4 +17,7 @@ public enum MediaTranscriptionPhase
/// <summary>The normalized audio is being transcribed by the provider.</summary> /// <summary>The normalized audio is being transcribed by the provider.</summary>
UPLOADING, UPLOADING,
/// <summary>Cancellation was requested and runtime cleanup is in progress.</summary>
CANCELING,
} }

View File

@ -1,3 +1,4 @@
using AIStudio.Chat;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
@ -13,63 +14,252 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
/// <summary>Serializes attachment and file-content imports.</summary> /// <summary>Serializes attachment and file-content imports.</summary>
private readonly SemaphoreSlim importQueue = new(1, 1); 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(); private readonly Lock stateLock = new();
/// <summary>All operations retained so disposal can cancel voice and import work.</summary> /// <summary>All operations retained so disposal can cancel voice and import work.</summary>
private readonly HashSet<MediaOperation> operations = []; private readonly HashSet<MediaOperation> operations = [];
/// <summary>The currently visible import operation, if any.</summary> /// <summary>The active or queued import operation for each owner.</summary>
private MediaOperation? currentImport; 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> /// <summary>Prevents new work after disposal.</summary>
private bool disposed; private bool disposed;
/// <summary>Raised whenever visible import state changes.</summary> /// <summary>Raised only with the owner whose copied state changed.</summary>
public event Action? StateChanged; public event Action<MediaImportOwner>? StateChanged;
/// <summary>Gets whether the serialized import lane is active.</summary> /// <summary>Gets whether one owner has queued, running, or canceling media work.</summary>
public bool IsBusy { get; private set; } public bool IsBusy(MediaImportOwner owner) => this.GetSnapshot(owner)?.IsBusy ?? false;
/// <summary>Gets the file name shown for the active import.</summary> /// <summary>Gets the last retained state for one owner.</summary>
public string CurrentFileName { get; private set; } = string.Empty; public MediaImportSnapshot? GetSnapshot(MediaImportOwner owner)
{
lock (this.stateLock)
return this.snapshots.GetValueOrDefault(owner);
}
/// <summary>Gets the active import phase.</summary> /// <summary>Gets copied retained snapshots for navigation indicators.</summary>
public MediaTranscriptionPhase Phase { get; private set; } = MediaTranscriptionPhase.IDLE; public IReadOnlyCollection<MediaImportSnapshot> GetSnapshots()
{
lock (this.stateLock)
return [.. this.snapshots.Values];
}
/// <summary>Gets optional import progress between zero and one.</summary> /// <summary>Starts an owner-managed attachment batch and returns without holding the UI event handler.</summary>
public double? Progress { get; private set; } public bool TryStartAttachmentBatch(IReadOnlyList<string> mediaPaths, MediaImportOwner owner, ChatThread? ownerChat = null)
/// <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)
{ {
this.ThrowIfDisposed(); this.ThrowIfDisposed();
await this.importQueue.WaitAsync(token);
var operation = this.CreateOperation(token);
lock (this.stateLock) 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 try
{ {
this.UpdateImportState(true, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0); var result = await this.TranscribeImportAsync(mediaPath, target.Owner, cancellation.Token);
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: true); 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 finally
{ {
lock (this.stateLock) lock (this.stateLock)
{ {
if (ReferenceEquals(this.currentImport, operation)) this.activeBatches.Remove(target.Owner);
this.currentImport = null; 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.ReleaseOperation(operation);
this.UpdateImportState(false, string.Empty, MediaTranscriptionPhase.IDLE, null); if (operation.HasQueueLease)
this.importQueue.Release(); this.importQueue.Release();
} }
} }
@ -82,7 +272,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
public async Task<MediaTranscriptionResult> TranscribeVoiceAsync(string mediaPath, CancellationToken token = default) public async Task<MediaTranscriptionResult> TranscribeVoiceAsync(string mediaPath, CancellationToken token = default)
{ {
this.ThrowIfDisposed(); this.ThrowIfDisposed();
var operation = this.CreateOperation(token); var operation = this.CreateOperation(null, token);
try try
{ {
@ -94,16 +284,20 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
} }
} }
/// <summary>Cancels only the active visible import operation.</summary> /// <summary>Cancels only the queued or active operation belonging to one owner.</summary>
public async Task StopAsync() public async Task StopAsync(MediaImportOwner owner)
{ {
MediaOperation? operation; MediaOperation? operation;
lock (this.stateLock) lock (this.stateLock)
{ {
operation = this.currentImport; operation = this.currentImports.GetValueOrDefault(owner);
this.batchCancellations.GetValueOrDefault(owner)?.Cancel();
operation?.Cancellation.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)) if (!string.IsNullOrWhiteSpace(operation?.JobId))
await rustService.CancelMediaJobAsync(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.")); return MediaTranscriptionResult.Failed(TB("No usable transcription provider is configured."));
if (updateImportState) 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(); var provider = providerSettings.CreateProvider();
if (provider.Provider is LLMProviders.NONE) 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 // NormalizeAsync does not return from cancellation until Rust has reached a terminal
// phase, so deleting both paths here cannot race a still-writing worker. // 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"); this.DeleteTemporaryFile(normalizedPath + ".partial");
} }
} }
@ -196,7 +391,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
var phase = mediaEvent.Phase is MediaJobPhase.PROBING var phase = mediaEvent.Phase is MediaJobPhase.PROBING
? MediaTranscriptionPhase.PROBING ? MediaTranscriptionPhase.PROBING
: MediaTranscriptionPhase.TRANSCODING; : 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) switch (mediaEvent.Phase)
@ -253,11 +448,12 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
} }
/// <summary>Creates and registers operation-owned cancellation state.</summary> /// <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> /// <param name="token">Caller token linked to the operation.</param>
/// <returns>The registered operation.</returns> /// <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) lock (this.stateLock)
this.operations.Add(operation); this.operations.Add(operation);
@ -274,14 +470,24 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
operation.Dispose(); operation.Dispose();
} }
/// <summary>Updates state exposed only by the serialized import lane.</summary> /// <summary>Updates and publishes copied state for exactly one owner.</summary>
private void UpdateImportState(bool isBusy, string fileName, MediaTranscriptionPhase phase, double? progress) private void UpdateImportState(MediaImportOwner owner, string fileName, MediaTranscriptionPhase phase, double? progress, MediaImportStatus status)
{ {
this.IsBusy = isBusy; var snapshot = new MediaImportSnapshot
this.CurrentFileName = fileName; {
this.Phase = phase; Owner = owner,
this.Progress = progress; CurrentFileName = fileName,
this.StateChanged?.Invoke(); 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> /// <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> /// <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)); 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() public void Dispose()
{ {
MediaOperation[] active; MediaOperation[] active;
CancellationTokenSource[] batches;
lock (this.stateLock) lock (this.stateLock)
{ {
if (this.disposed) if (this.disposed)
@ -327,10 +563,14 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
this.disposed = true; this.disposed = true;
active = [.. this.operations]; active = [.. this.operations];
batches = [.. this.batchCancellations.Values];
} }
foreach (var operation in active) foreach (var operation in active)
operation.Cancellation.Cancel(); operation.Cancellation.Cancel();
foreach (var batch in batches)
batch.Cancel();
// The semaphore may still be released by an operation unwinding after cancellation. // 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 private sealed class MediaOperation : IDisposable
{ {
/// <summary>Creates operation state linked to a caller token.</summary> /// <summary>Creates operation state linked to a caller token.</summary>
/// <param name="owner">Media import owner.</param>
/// <param name="token">Caller cancellation token.</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); 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> /// <summary>Gets the unique temporary-path identifier.</summary>
public Guid Id { get; } = Guid.NewGuid(); 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> /// <summary>Gets or sets the Rust job after its POST response establishes ownership.</summary>
public string? JobId { get; set; } 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> /// <summary>Disposes operation-owned cancellation state.</summary>
public void Dispose() => this.Cancellation.Dispose(); public void Dispose() => this.Cancellation.Dispose();
} }

View File

@ -810,6 +810,8 @@ public static class WorkspaceBehaviour
var targetDirectory = GetChatDirectory(targetWorkspaceId, chat.ChatId); var targetDirectory = GetChatDirectory(targetWorkspaceId, chat.ChatId);
var sourceSemaphore = GetChatSemaphore(sourceWorkspaceId, chat.ChatId); var sourceSemaphore = GetChatSemaphore(sourceWorkspaceId, chat.ChatId);
var targetSemaphore = GetChatSemaphore(targetWorkspaceId, 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 var orderedSemaphores = string.CompareOrdinal(sourceWorkspaceId.ToString("N"), targetWorkspaceId.ToString("N")) <= 0
? new[] { sourceSemaphore, targetSemaphore } ? new[] { sourceSemaphore, targetSemaphore }
: new[] { targetSemaphore, sourceSemaphore }; : new[] { targetSemaphore, sourceSemaphore };
@ -823,7 +825,10 @@ public static class WorkspaceBehaviour
if (!Directory.Exists(sourceDirectory)) if (!Directory.Exists(sourceDirectory))
throw new DirectoryNotFoundException($"The source chat directory does not exist: '{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)) if (Directory.Exists(targetDirectory))
throw new IOException($"The target chat directory already exists: '{targetDirectory}'."); throw new IOException($"The target chat directory already exists: '{targetDirectory}'.");

View File

@ -3,6 +3,7 @@
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />

View File

@ -81,3 +81,20 @@ tauri-plugin-updater = "2.10.1"
[features] [features]
custom-protocol = ["tauri/custom-protocol"] 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

View File

@ -43,6 +43,7 @@ pub fn init_logging() {
log_config.push_str("tower_http=info, "); log_config.push_str("tower_http=info, ");
log_config.push_str("rustls=info, "); log_config.push_str("rustls=info, ");
log_config.push_str("tokio_rustls=info, "); log_config.push_str("tokio_rustls=info, ");
log_config.push_str("symphonia_format_mkv=info, ");
log_config.push_str("reqwest=info"); log_config.push_str("reqwest=info");
// Configure the initial filename. On Unix systems, the file should start // Configure the initial filename. On Unix systems, the file should start

View File

@ -3,10 +3,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::convert::Infallible; use std::convert::Infallible;
use std::fs::{self, File}; 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::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::sync::{Arc, Mutex, RwLock};
use std::time::{Duration as StdDuration, Instant};
use axum::extract::Path; use axum::extract::Path;
use axum::http::StatusCode; 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::codecs::CodecParameters;
use symphonia::core::errors::Error as SymphoniaError; use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, Track, TrackFlags, TrackType}; 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::meta::MetadataOptions;
use symphonia::core::formats::probe::Hint; use symphonia::core::formats::probe::Hint;
use symphonia::core::units::Timestamp; use symphonia::core::units::{TimeBase, Timestamp};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt; 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. /// Bounded block used by the cancellation-aware pass-through copy.
const COPY_BLOCK_BYTES: usize = 64 * 1024; 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. /// Time a terminal job remains available for late SSE subscribers.
const TERMINAL_JOB_RETENTION: std::time::Duration = std::time::Duration::from_secs(10 * 60); 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. /// Mutable state shared by the request routes and blocking worker.
struct MediaJob { struct MediaJob {
/// Cooperative cancellation flag checked at bounded intervals. /// 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>, current: Mutex<MediaJobEvent>,
/// Fan-out channel for live state changes. /// Fan-out channel for live state changes.
events: broadcast::Sender<MediaJobEvent>, events: broadcast::Sender<MediaJobEvent>,
/// Last running progress publication, used to protect Blazor from render storms.
last_progress: Mutex<Option<(MediaJobPhase, Instant)>>,
} }
impl MediaJob { impl MediaJob {
@ -262,7 +272,12 @@ impl MediaJob {
}; };
let (events, _) = broadcast::channel(32); 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. /// Replaces the replay snapshot and notifies all live subscribers.
@ -271,6 +286,19 @@ impl MediaJob {
let _ = self.events.send(event); 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. /// Returns whether cooperative cancellation was requested.
fn is_cancelled(&self) -> bool { fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed) self.cancelled.load(Ordering::Relaxed)
@ -299,30 +327,41 @@ pub async fn create_job(
let completed_job_id = job_id.clone(); let completed_job_id = job_id.clone();
tauri::async_runtime::spawn(async move { 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 max_pass_through_bytes = request.max_pass_through_bytes.unwrap_or(DEFAULT_MAX_PASS_THROUGH_BYTES);
let task_job = Arc::clone(&job); 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; let result = tokio::task::spawn_blocking(move || normalize_media(&input_path, &output_path, max_pass_through_bytes, &task_job)).await;
match result { match result {
Ok(Ok(result)) => job.publish(MediaJobEvent { Ok(Ok(result)) => {
phase: MediaJobPhase::Completed, log::info!("media job completed: job_id={completed_job_id}, elapsed_ms={}", started_at.elapsed().as_millis());
progress: Some(1.0), job.publish(MediaJobEvent {
result: Some(result), phase: MediaJobPhase::Completed,
error: None, progress: Some(1.0),
}), result: Some(result),
error: None,
});
}
Ok(Err(error)) if error.code == MediaErrorCode::Cancelled => job.publish(MediaJobEvent { Ok(Err(error)) if error.code == MediaErrorCode::Cancelled => {
phase: MediaJobPhase::Cancelled, log::info!("media job cancelled: job_id={completed_job_id}, elapsed_ms={}", started_at.elapsed().as_millis());
progress: None, job.publish(MediaJobEvent {
result: None, phase: MediaJobPhase::Cancelled,
error: None, progress: None,
}), result: None,
error: None,
});
}
Ok(Err(error)) => job.publish(MediaJobEvent { Ok(Err(error)) => {
phase: MediaJobPhase::Failed, log::error!("media job failed: job_id={completed_job_id}, code={:?}, diagnostic={}, elapsed_ms={}", error.code, error.message, started_at.elapsed().as_millis());
progress: None, job.publish(MediaJobEvent {
result: None, phase: MediaJobPhase::Failed,
error: Some(error), progress: None,
}), result: None,
error: Some(error),
});
}
Err(error) => job.publish(MediaJobEvent { Err(error) => job.publish(MediaJobEvent {
phase: MediaJobPhase::Failed, 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. /// 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> { fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_through_bytes: u64, job: &MediaJob) -> Result<MediaJobResult, MediaError> {
check_cancelled(job)?; 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:?})."))); 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 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(); let mut hint = Hint::new();
if let Some(extension) = input_path.extension().and_then(|value| value.to_str()) { if let Some(extension) = input_path.extension().and_then(|value| value.to_str()) {
hint.with_extension(extension); 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()) .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 detected_format = format!("{detected:?} / {}", format.format_info().long_name);
let tracks = format.tracks(); 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) { if !tracks.iter().any(is_audio_track) {
return Err(MediaError::new(MediaErrorCode::NoAudioTrack, "The selected media file does not contain an 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 track_id = selected.id;
let params = selected.codec_params.as_ref().and_then(CodecParameters::audio).unwrap().clone(); 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 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)) .map(|(frames, rate)| frames.saturating_mul(1_000) / u64::from(rate))
.or_else(|| selected.time_base.zip(selected.duration).and_then(|(time_base, duration)| { .or_else(|| selected.time_base.zip(selected.duration).and_then(|(time_base, duration)| {
let timestamp = Timestamp::new(i64::try_from(duration.get()).ok()?); let timestamp = Timestamp::new(i64::try_from(duration.get()).ok()?);
let time = time_base.calc_time(timestamp)?; let time = time_base.calc_time(timestamp)?;
Some((time.as_secs_f64() * 1000.0).max(0.0).round() as u64) 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 channels = params.channels.as_ref().map(|value| value.count()).unwrap_or(0);
let pass_through = is_webm_container(input_path) 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) && params.sample_rate == Some(OUTPUT_SAMPLE_RATE)
&& channels == 1 && channels == 1
&& input_path.metadata().map(|metadata| metadata.len() <= max_pass_through_bytes).unwrap_or(false); && 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); let partial_path = partial_path(output_path);
if let Some(parent) = partial_path.parent() { 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, pass_through: true,
}) })
} else { } else {
job.publish(MediaJobEvent { job.publish_progress(MediaJobPhase::Transcoding, Some(0.0));
phase: MediaJobPhase::Transcoding,
progress: Some(0.0),
result: None,
error: None,
});
let context = TranscodeContext { let context = TranscodeContext {
track_id, track_id,
@ -487,6 +631,8 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
detected_format, detected_format,
detected_codec, detected_codec,
expected_duration_ms: duration_ms, expected_duration_ms: duration_ms,
time_base: selected.time_base,
source_progress,
job, job,
}; };
transcode(&mut *format, context) transcode(&mut *format, context)
@ -568,11 +714,17 @@ struct TranscodeContext<'a> {
/// Container duration used for progress reporting. /// Container duration used for progress reporting.
expected_duration_ms: u64, 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. /// Cancellation and progress state for the job.
job: &'a MediaJob, 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( fn transcode(
format: &mut dyn symphonia::core::formats::FormatReader, format: &mut dyn symphonia::core::formats::FormatReader,
context: TranscodeContext<'_>, context: TranscodeContext<'_>,
@ -591,6 +743,13 @@ fn transcode(
let mut decoded_tail = Vec::<f32>::new(); let mut decoded_tail = Vec::<f32>::new();
let mut encoded = [0u8; 4_000]; let mut encoded = [0u8; 4_000];
let mut produced_samples = 0u64; 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 { loop {
check_cancelled(context.job)?; check_cancelled(context.job)?;
@ -599,22 +758,36 @@ fn transcode(
Ok(None) => break, Ok(None) => break,
Err(SymphoniaError::ResetRequired) => return Err(MediaError::new(MediaErrorCode::StreamReset, "The media stream changed unexpectedly.")), 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(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}"))), Err(error) => return Err(MediaError::new(MediaErrorCode::DamagedContainer, format!("The media container is damaged: {error}"))),
}; };
if packet.track_id != context.track_id { if packet.track_id != context.track_id {
discarded_packets += 1;
continue; 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 { let Some((mono, sample_rate)) = decoder.decode(&packet)? else {
decode_errors += 1;
continue; continue;
}; };
decoded_packets += 1;
let stream_resampler = match resampler.as_mut() { let stream_resampler = match resampler.as_mut() {
Some(existing) if existing.input_rate() == sample_rate => existing, 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.")), 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. // 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); let emit_len = decoded_tail.len().saturating_sub(padding);
if emit_len > 0 { if emit_len > 0 {
let emit: Vec<_> = decoded_tail.drain(..emit_len).collect(); 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, &mut pending,
&stream_resampler.push(&emit)?, &resampled,
produced_samples, current_start,
context.expected_duration_ms, 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)?; encode_complete_frames(&mut pending, &mut opus_encoder, &mut writer, &mut encoded, &mut produced_samples, context.job)?;
if context.expected_duration_ms > 0 { let timestamp_ms = packet_timestamp_ms(packet_pts, first_packet_pts, context.time_base);
let current_ms = produced_samples.saturating_mul(1000) / u64::from(OUTPUT_SAMPLE_RATE); let progress = if context.expected_duration_ms > 0 {
context.job.publish(MediaJobEvent { timestamp_ms.map(|current_ms| (current_ms as f64 / context.expected_duration_ms as f64).clamp(0.0, 0.99))
phase: MediaJobPhase::Transcoding, } else {
progress: Some((current_ms as f64 / context.expected_duration_ms as f64).clamp(0.0, 0.99)), context.source_progress.fraction()
result: None, };
error: None,
}); 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() { if let Some(stream_resampler) = resampler.as_mut() {
// Discard the retained decoded tail (container padding), then flush the filter delay. // Discard the retained decoded tail (container padding), then flush the filter delay.
append_duration_bounded( pending.extend_from_slice(&stream_resampler.finish()?);
&mut pending,
&stream_resampler.finish()?,
produced_samples,
context.expected_duration_ms,
);
} else { } else {
return Err(MediaError::new(MediaErrorCode::InvalidAudioParameters, "The selected audio track did not yield decoded audio parameters.")); return Err(MediaError::new(MediaErrorCode::InvalidAudioParameters, "The selected audio track did not yield decoded audio parameters."));
} }
@ -669,26 +852,86 @@ fn transcode(
writer.finish()?; writer.finish()?;
check_cancelled(context.job)?; 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 { Ok(MediaJobResult {
output_path: context.output_path.to_string_lossy().into_owned(), output_path: context.output_path.to_string_lossy().into_owned(),
detected_format: context.detected_format, detected_format: context.detected_format,
detected_codec: context.detected_codec, 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, pass_through: false,
}) })
} }
/// Appends resampled PCM without exceeding a reliable container duration. /// Converts a packet PTS to its output sample offset relative to the first audio packet.
fn append_duration_bounded(pending: &mut Vec<f32>, samples: &[f32], produced_samples: u64, expected_duration_ms: u64) { fn packet_output_start(packet_pts: i64, first_packet_pts: Option<i64>, time_base: Option<TimeBase>) -> Option<u64> {
if expected_duration_ms == 0 { packet_timestamp_ms(packet_pts, first_packet_pts, time_base)
pending.extend_from_slice(samples); .map(|milliseconds| milliseconds.saturating_mul(u64::from(OUTPUT_SAMPLE_RATE)) / 1_000)
return; }
/// 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 time = time_base?.calc_time(Timestamp::new(delta))?;
let accepted = produced_samples.saturating_add(pending.len() as u64); Some((time.as_secs_f64() * 1_000.0).max(0.0).round() as u64)
let remaining = target_samples.saturating_sub(accepted) as usize; }
pending.extend_from_slice(&samples[..samples.len().min(remaining)]);
/// 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. /// 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 { mod tests {
use super::*; use super::*;
use std::io::Cursor; use std::io::Cursor;
use std::num::NonZeroU32;
use tokio::sync::broadcast::error::TryRecvError;
use symphonia::core::audio::{Channels, Position}; 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::well_known::{CODEC_ID_AC3, CODEC_ID_PCM_S16LE};
use symphonia::core::codecs::audio::AudioCodecParameters; 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]); 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. /// Verifies output shape and at-most-one-frame duration rounding.
#[test] #[test]
fn wav_is_normalized_to_one_mono_opus_track_with_frame_bounded_duration() { fn wav_is_normalized_to_one_mono_opus_track_with_frame_bounded_duration() {