Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable user-facing changes are documented here.

## [Unreleased]

## [0.1.6] - 2026-08-03

### Added

- Multi-track replays can now mix all enabled audio tracks into one playback-friendly track when saving or overwriting a trimmed clip. Video remains untouched; only audio is re-encoded.

### Fixed

- Settings selected in the window now remain visible when the recording pipeline rejects a change, making it possible to correct the failing option without re-entering resolution, audio, and other pending choices.
- NVIDIA HEVC encoding no longer requests B-frames, fixing encoder startup on hardware such as the GeForce GTX 1080 where HEVC B-frames are unsupported.

## [0.1.5] - 2026-08-02

### Fixed
Expand Down
82 changes: 81 additions & 1 deletion src/Captail/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

namespace Captail;

public partial class App : Application

Check warning on line 14 in src/Captail/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Windows x64

Because an application's API isn't typically referenced from outside the assembly, types can be made internal (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1515)
{
private Config? _config;
private ObsReplayEngine? _obs;
Expand Down Expand Up @@ -86,6 +86,12 @@
StringComparison.OrdinalIgnoreCase))
?["--qa-clip-editor=".Length..];
bool clipEditorTest = !string.IsNullOrWhiteSpace(clipEditorTestPath);
string? audioMixTestPath = e.Args
.FirstOrDefault(argument => argument.StartsWith(
"--qa-audio-mix=",
StringComparison.OrdinalIgnoreCase))
?["--qa-audio-mix=".Length..];
bool audioMixTest = !string.IsNullOrWhiteSpace(audioMixTestPath);
string? previewGeometryTestPath = e.Args
.FirstOrDefault(argument => argument.StartsWith(
"--qa-preview-geometry=",
Expand All @@ -104,6 +110,7 @@
const bool replaySegmentsTest = false;
const bool updateCheckTest = false;
const bool clipEditorTest = false;
const bool audioMixTest = false;
const bool previewGeometryTest = false;
#endif
bool backgroundLaunch = e.Args.Contains(
Expand All @@ -118,7 +125,7 @@
shutdownExisting,
_uiOnly || faultTest || codecTest || capabilityModelTest ||
gameCaptureTest || replaySegmentsTest || updateCheckTest ||
clipEditorTest || previewGeometryTest))
clipEditorTest || audioMixTest || previewGeometryTest))
{
Shutdown();
return;
Expand Down Expand Up @@ -185,6 +192,11 @@
await RunClipEditorTestAsync(clipEditorTestPath!);
return;
}
if (audioMixTest)
{
await RunAudioMixTestAsync(audioMixTestPath!);
return;
}
if (previewGeometryTest)
{
await RunPreviewGeometryTestAsync(previewGeometryTestPath!);
Expand Down Expand Up @@ -251,6 +263,71 @@
}

#if DEBUG
private async Task RunAudioMixTestAsync(string path)
{
string fullPath = Path.GetFullPath(path);
if (!File.Exists(fullPath))
throw new FileNotFoundException("QA replay does not exist.", fullPath);

string destination = Path.Combine(
Path.GetTempPath(),
$"captail_audio_mix_{Guid.NewGuid():N}{Path.GetExtension(fullPath)}");
try
{
var ffmpeg = new FfmpegAdapter();
TimeSpan duration = await ffmpeg.ReadDurationAsync(fullPath);
IReadOnlyList<AudioTrackInfo> sourceTracks =
await ffmpeg.ReadAudioTracksAsync(fullPath);
VideoStreamInfo? sourceVideo = await ffmpeg.ReadVideoInfoAsync(fullPath);
if (sourceTracks.Count < 2 || sourceVideo is null)
throw new InvalidOperationException(
"QA replay requires video and at least two audio tracks.");

await ffmpeg.TrimCopyAsync(
fullPath,
destination,
TimeSpan.Zero,
duration,
sourceTracks.Select(track => track.StreamIndex).ToArray(),
mergeAudioTracks: true);

IReadOnlyList<AudioTrackInfo> mixedTracks =
await ffmpeg.ReadAudioTracksAsync(destination);
VideoStreamInfo? mixedVideo = await ffmpeg.ReadVideoInfoAsync(destination);
bool passed = mixedTracks.Count == 1 &&
mixedVideo is not null &&
mixedVideo.Codec.Equals(
sourceVideo.Codec,
StringComparison.OrdinalIgnoreCase) &&
mixedVideo.Width == sourceVideo.Width &&
mixedVideo.Height == sourceVideo.Height;
Log.Write(
$"AUDIO_MIX_TEST {(passed ? "PASS" : "FAIL")}: " +
$"sourceTracks={sourceTracks.Count}, mixedTracks={mixedTracks.Count}, " +
$"video={sourceVideo.Codec}/{mixedVideo?.Codec} " +
$"{sourceVideo.Width}x{sourceVideo.Height}/" +
$"{mixedVideo?.Width}x{mixedVideo?.Height}");
Shutdown(passed ? 0 : 16);
}
catch (Exception exception)
{
Log.Write($"AUDIO_MIX_TEST FAIL: {exception}");
Shutdown(16);
}
finally
{
try
{
if (File.Exists(destination))
File.Delete(destination);
}
catch (Exception exception)
{
Log.Write($"Audio mix QA cleanup failed: {exception.Message}");
}
}
}

private async Task RunClipEditorTestAsync(string path)
{
string fullPath = Path.GetFullPath(path);
Expand Down Expand Up @@ -373,6 +450,9 @@
invalidConfig.Codec == "h264" &&
invalidConfig.SystemAudioVolume == 100 &&
invalidConfig.Hotkey == "Ctrl+Shift+F10" &&
ObsReplayEngine.RecommendedNvencBFrames("hevc", true) == 0 &&
ObsReplayEngine.RecommendedNvencBFrames("h264", true) == 2 &&
ObsReplayEngine.RecommendedNvencBFrames("h264", false) == 0 &&
invalidConfig.PipelineEquals(hotkeyOnlyChange) &&
!invalidConfig.PipelineEquals(pipelineChange);
Log.Write(
Expand Down
2 changes: 1 addition & 1 deletion src/Captail/Captail.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<Product>Captail</Product>
<RootNamespace>Captail</RootNamespace>
<ApplicationIcon>Assets\Captail.ico</ApplicationIcon>
<Version>0.1.5</Version>
<Version>0.1.6</Version>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<DefineConstants Condition="'$(MicrosoftStoreBuild)' == 'true'">$(DefineConstants);MICROSOFT_STORE</DefineConstants>
</PropertyGroup>
Expand Down
73 changes: 72 additions & 1 deletion src/Captail/ClipEditorWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,63 @@
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="{StaticResource FontUi}"
PreviewKeyDown="Window_PreviewKeyDown">
<Window.Resources>
<Style x:Key="EditorMergeCheckBox" TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Background" Value="{StaticResource BgControlBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderControlBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="18"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border x:Name="Box" Width="18" Height="18" CornerRadius="5"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Path x:Name="Check" Visibility="Collapsed"
Data="M4 9l3 3 7-7" Stroke="#07110E"
StrokeThickness="2" StrokeStartLineCap="Round"
StrokeEndLineCap="Round" StrokeLineJoin="Round"/>
</Border>
<ContentPresenter Grid.Column="1" Margin="8,0,0,0"
VerticalAlignment="Center"/>
<Border x:Name="FocusBorder" Grid.ColumnSpan="2" Margin="-4"
BorderBrush="{StaticResource AccentBrush}"
BorderThickness="1" CornerRadius="7"
Visibility="Collapsed" IsHitTestVisible="False"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="BorderBrush"
Value="{StaticResource TextMutedBrush}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background"
Value="{StaticResource AccentBrush}"/>
<Setter TargetName="Box" Property="BorderBrush"
Value="{StaticResource AccentBrush}"/>
<Setter TargetName="Check" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="FocusBorder" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
<Setter Property="Cursor" Value="Arrow"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Border Background="{StaticResource BgWindowGradient}"
BorderBrush="{StaticResource WindowBorderBrush}" BorderThickness="1"
CornerRadius="{StaticResource RadiusWindow}">
Expand Down Expand Up @@ -234,6 +291,19 @@
Content="{DynamicResource L.Library.Overwrite}"
Style="{StaticResource SubtleButton}" Padding="16,9"
Click="RequestOverwrite_Click"/>
<CheckBox x:Name="MergeAudioCheckBox" Margin="12,0,3,0"
Style="{StaticResource EditorMergeCheckBox}"
Visibility="Collapsed"
ToolTip="{DynamicResource L.Library.MergeAudioTip}"
ToolTipService.ShowOnDisabled="True">
<StackPanel>
<TextBlock Text="{DynamicResource L.Library.MergeAudio}"
FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="{DynamicResource L.Library.MergeAudioSummary}"
FontSize="9.5"
Foreground="{StaticResource TextMutedBrush}"/>
</StackPanel>
</CheckBox>
<Button x:Name="SaveTrimButton" Margin="8,0,0,0"
Content="{DynamicResource L.Library.SaveTrim}"
Style="{StaticResource AccentButton}" Padding="18,9"
Expand All @@ -250,7 +320,8 @@
<StackPanel>
<TextBlock Text="{DynamicResource L.Library.OverwriteTitle}"
FontSize="15" FontWeight="Bold"/>
<TextBlock Text="{DynamicResource L.Library.OverwriteMessage}"
<TextBlock x:Name="OverwriteMessageText"
Text="{DynamicResource L.Library.OverwriteMessage}"
Margin="0,9,0,0" Style="{StaticResource SecondaryText}"
TextWrapping="Wrap"/>
<TextBlock x:Name="OverwriteFileText" Margin="0,9,0,18"
Expand Down
30 changes: 29 additions & 1 deletion src/Captail/ClipEditorWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

namespace Captail;

public partial class ClipEditorWindow : Window

Check warning on line 18 in src/Captail/ClipEditorWindow.xaml.cs

View workflow job for this annotation

GitHub Actions / Windows x64

Because an application's API isn't typically referenced from outside the assembly, types can be made internal (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1515)
{
private const double MinimumSelectionSeconds = 0.25;
private const int TimelineFrameCount = 12;
Expand Down Expand Up @@ -160,6 +160,7 @@
NoAudioText.Visibility = tracks.Count == 0
? Visibility.Visible
: Visibility.Collapsed;
UpdateMergeAudioState();

await Task.WhenAll(AudioTracks.Select(LoadWaveformAsync));
}
Expand All @@ -171,6 +172,7 @@
{
Log.Write($"Audio track inspection failed: {exception.Message}");
NoAudioText.Visibility = Visibility.Visible;
MergeAudioCheckBox.Visibility = Visibility.Collapsed;
}
}

Expand Down Expand Up @@ -451,6 +453,7 @@

private async void AudioTrackToggle_Click(object sender, RoutedEventArgs e)
{
UpdateMergeAudioState();
if (!_playing || _playerLoading)
return;
double position = CurrentPlaybackPosition();
Expand Down Expand Up @@ -588,11 +591,28 @@
.Select(track => track.Track.StreamIndex)
.ToArray();

private void UpdateMergeAudioState()
{
bool hasSeparateTracks = AudioTracks.Count > 1;
MergeAudioCheckBox.Visibility = hasSeparateTracks
? Visibility.Visible
: Visibility.Collapsed;
bool canMerge = hasSeparateTracks &&
AudioTracks.Count(track => track.IsSelected) > 1;
MergeAudioCheckBox.IsEnabled = canMerge;
if (!canMerge)
MergeAudioCheckBox.IsChecked = false;
}

private async void SaveTrim_Click(object sender, RoutedEventArgs e) =>
await SaveTrimAsync(overwrite: false);

private void RequestOverwrite_Click(object sender, RoutedEventArgs e)
{
OverwriteMessageText.Text = Localization.Text(
MergeAudioCheckBox.IsChecked == true
? "L.Library.OverwriteMergeMessage"
: "L.Library.OverwriteMessage");
OverwriteFileText.Text = _clip.Name;
OverwriteConfirmOverlay.Visibility = Visibility.Visible;
}
Expand All @@ -613,7 +633,12 @@
{
SaveTrimButton.IsEnabled = false;
OverwriteButton.IsEnabled = false;
EditorStatusText.Text = Localization.Text("L.Library.Trimming");
MergeAudioCheckBox.IsEnabled = false;
bool mergeAudioTracks = MergeAudioCheckBox.IsChecked == true;
EditorStatusText.Text = Localization.Text(
mergeAudioTracks
? "L.Library.TrimmingMerge"
: "L.Library.Trimming");
try
{
StopNativePlayback();
Expand All @@ -627,13 +652,15 @@
start,
end,
audioStreams,
mergeAudioTracks,
_lifetimeCts.Token)
: await _library.TrimAsync(
_rootDirectory,
_clip,
start,
end,
audioStreams,
mergeAudioTracks,
_lifetimeCts.Token);
_onSaved(path);
DialogResult = true;
Expand All @@ -649,6 +676,7 @@
EditorStatusText.Text = exception.Message;
SaveTrimButton.IsEnabled = true;
OverwriteButton.IsEnabled = true;
UpdateMergeAudioState();
}
}

Expand Down Expand Up @@ -760,7 +788,7 @@
int valueSize);
}

public sealed class AudioTrackRow : INotifyPropertyChanged

Check warning on line 791 in src/Captail/ClipEditorWindow.xaml.cs

View workflow job for this annotation

GitHub Actions / Windows x64

Because an application's API isn't typically referenced from outside the assembly, types can be made internal (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1515)
{
private ImageSource? _waveform;
private bool _isSelected = true;
Expand Down
Loading
Loading